Merge pull request 'feat/comments-merge/main' (#52) from feat/comments-merge/main into master
Reviewed-on: #52 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #52.
This commit is contained in:
@@ -26,9 +26,12 @@ node scripts/issue-extract.js --repo <owner/name> --index <編號>
|
||||
拿到的是結構化欄位,不必再讀整份議題全文。
|
||||
|
||||
**先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被
|
||||
整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,建議先執行 `/sdlc-sync`
|
||||
把它們整併回描述,再回來做分析。使用者堅持要繼續就繼續,但要記下這件事,
|
||||
並在共識摘要裡註明「分析基於未整併留言前的描述」。
|
||||
整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,問他要不要現在整併。
|
||||
|
||||
要整併的話**直接走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`),做完**自動接回這裡**:
|
||||
重新抽取一次拿到更新後的描述,再往下走。**不要要求使用者重打指令**——他已經說要整併了。
|
||||
|
||||
使用者選擇不整併就繼續,但要記下這件事,並在共識摘要裡註明「分析基於未整併留言前的描述」。
|
||||
|
||||
### 2. 對四份清單列出疑點
|
||||
|
||||
|
||||
@@ -30,9 +30,12 @@ node scripts/wp-extract.js --repo <owner/name> --index <編號>
|
||||
不必再讀整份議題全文。
|
||||
|
||||
**先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被
|
||||
整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,建議先執行 `/sdlc-sync`
|
||||
把它們整併回描述,再回來實作。使用者堅持要繼續就繼續,但要記下這件事,
|
||||
並在最後的 PR 描述裡註明「實作基於未整併留言前的描述」。
|
||||
整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,問他要不要現在整併。
|
||||
|
||||
要整併的話**直接走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`),做完**自動接回這裡**:
|
||||
重新抽取一次拿到更新後的描述,再往下走。**不要要求使用者重打指令**——他已經說要整併了。
|
||||
|
||||
使用者選擇不整併就繼續,但要記下這件事,並在最後的 PR 描述裡註明「實作基於未整併留言前的描述」。
|
||||
|
||||
**再看 `相依.depends`。** 裡面還有沒關閉的議題,代表這顆的前置還沒做完。照樣先說出來,
|
||||
讓使用者決定要不要現在做。
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
name: sdlc-sync
|
||||
description: 僅由 /sdlc-sync 指令叫用。把議題留言裡的決策整併回議題描述,並標記已整併的留言。
|
||||
|
||||
# sdlc-sync
|
||||
|
||||
新加入的人不必爬完整串留言,就能從議題描述知道現況。
|
||||
|
||||
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
|
||||
|
||||
## 輸入
|
||||
|
||||
一個議題編號。可能是需求議題,也可能是工作包議題。
|
||||
|
||||
## 1. 讀議題與留言
|
||||
|
||||
需求議題用 `issue-extract`,工作包議題用 `wp-extract`:
|
||||
|
||||
```
|
||||
node scripts/issue-extract.js --repo <owner/name> --index <編號>
|
||||
```
|
||||
|
||||
兩支都會給 `未處理留言數`。**那是這一輪要看的量**;已經標記過的留言不再處理。
|
||||
|
||||
接著讀留言本身。抽取契約只給數字不給內容,所以留言要另外拿:
|
||||
|
||||
```
|
||||
node scripts/pr-comments.js --repo <owner/name> --index <編號>
|
||||
```
|
||||
|
||||
議題與 PR 都收得下:它先讀議題本身,是 PR 才會再去翻 review。純議題的 `類型` 是 `議題`,
|
||||
留言的 `類型` 都是 `一般`。`已處理` 為 `true` 的跳過——那是前幾輪整併過的。
|
||||
|
||||
`已處理` 認的是**自己打的** `+1`。別人按讚是「我同意」,不是「這則已經收進描述了」。
|
||||
|
||||
## 2. 挑出真正的決策
|
||||
|
||||
**不是每一則留言都要整併。** 逐則判斷它有沒有改變「這顆議題現在說的事」:
|
||||
|
||||
- **要整併** — 改變了目標、範圍、做法、驗收標準;補上了原本沒寫的限制;推翻了描述裡的假設。
|
||||
- **不整併** — 提問與答覆、進度回報、「收到」、與內容無關的討論、已經反映在描述裡的事。
|
||||
|
||||
判斷不了的**當成要整併**,然後在下一步問使用者——漏掉一個決策,描述就會繼續騙後面的人。
|
||||
|
||||
## 3. 提出整併方案,讓使用者點頭
|
||||
|
||||
**先列出來再動手。** 對每一則要整併的留言,說明:
|
||||
|
||||
- 它說了什麼(一句話)
|
||||
- 要併進**哪一段**(`總覽`/`目標`/`非目標`/`驗收標準`/`未決事項`…)
|
||||
- 那一段**改完長什麼樣**
|
||||
|
||||
然後一次問一題,兩個選項:
|
||||
|
||||
- **整併** — 照你提的方案寫回去。
|
||||
- **略過** — 這一則不併。**略過的留言保持未標記**,下次執行還會被提出來。
|
||||
|
||||
使用者要改你的寫法時,照他說的改。這一步是議題描述的最後一道關卡——寫進去之後,
|
||||
後面的人就是拿它當事實。
|
||||
|
||||
## 4. 逐段寫回
|
||||
|
||||
一段一段來。同一段有多則留言的決策就先合併成一份內容,一次寫回:
|
||||
|
||||
```
|
||||
node scripts/comments-merge.js --repo <owner/name> --index <編號> \
|
||||
--section <段落名> --content-file <暫存檔> --merged <該段的留言 id> --dry-run
|
||||
```
|
||||
|
||||
`--content-file` 是**那一段改完的完整內容**(不含 `## 標題` 那一行)。腳本只換那一段,
|
||||
其餘一字不動。
|
||||
|
||||
`--merged` 只放**真的被併進這一段**的留言 id。略過的不要放進去——放了就等於這則再也
|
||||
不會被提出來。
|
||||
|
||||
試跑會印出改完的描述與將標記的留言。確認無誤後拿掉旗標再跑一次。
|
||||
|
||||
腳本先寫描述再標記,描述寫失敗就不標記。`SECTION_NOT_FOUND` 表示段落名與議題上的
|
||||
`## 標題` 對不上,**不要改用別的段落硬塞**,回頭確認名稱。
|
||||
|
||||
## 5. 回報
|
||||
|
||||
- 幾則留言、整併了幾則、略過幾則
|
||||
- 改了哪幾段,各自併進了什麼
|
||||
- 略過的那幾則是哪些(**要列出來**,讓使用者知道它們下次還會出現)
|
||||
|
||||
**略過的留言會讓 `未處理留言數` 停在大於 0。** 那是刻意的——下次還要被提出來。但它也表示
|
||||
`/sdlc-analyze` 與 `/sdlc-feat` 每次開始時都會再停一次。回報時要講明白這件事,讓使用者知道
|
||||
那不是沒整併乾淨,而是他選了略過。
|
||||
|
||||
## 接回原本的指令
|
||||
|
||||
這個指令常常不是使用者自己叫的,而是 `/sdlc-analyze` 或 `/sdlc-feat` 發現有未整併留言後
|
||||
轉過來的。**整併完就直接接回去**,從原本那個指令被打斷的地方繼續,不要要求使用者重打一次。
|
||||
|
||||
接回去之前先重跑一次抽取(`issue-extract`/`wp-extract`),拿到的才是剛更新過的描述——
|
||||
接著用舊的那一份做事,這一整段就白做了。
|
||||
|
||||
## 邊界
|
||||
|
||||
- 不自行決定要不要整併:方案一定先給使用者看過。
|
||||
- 不整份重寫描述,只換談好的那幾段。
|
||||
- 不標記略過的留言。
|
||||
- 不刪除、不編輯任何留言——留言是誰說過什麼的紀錄,整併是把結論抄進描述,不是把原文搬走。
|
||||
- 不因為整併而改變議題的狀態、標籤或指派。
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 把留言裡的決策整併回議題描述,並標記那幾則留言。
|
||||
*
|
||||
* 判斷「哪幾則留言有決策、該併進哪一段、併成什麼樣子」是讀得懂內容的人的事;
|
||||
* 這一支只負責把結果安全地寫回去。
|
||||
*
|
||||
* 兩件事錯了都很安靜,所以都做得很窄:
|
||||
*
|
||||
* - **局部更新。** 只換指定那一段,標題與其餘段落一字不動。整份重寫會把別人在其他
|
||||
* 段落的編輯一起蓋掉,而議題的編輯紀錄沒有人會去比對。
|
||||
* - **標記只給真的整併進去的那幾則。** 略過的要保持未標記,下次才會再被提出來;
|
||||
* 描述沒寫成功就不標記——標了就等於這則再也不會被看到。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/comments-merge.js --repo owner/name --index 7
|
||||
* --section <段落名> --content-file <檔案> --merged 101,102
|
||||
* [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
ScriptError,
|
||||
expectOk,
|
||||
fetchIssue,
|
||||
giteaRequest,
|
||||
listIssueComments,
|
||||
main,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
readTextFile,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
import { replaceSection } from './issue-body.js';
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['repo', 'index', 'section', 'content-file', 'merged'],
|
||||
optional: ['host'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const index = parseIndex(flags.index);
|
||||
const section = flags.section;
|
||||
const content = readTextFile(flags['content-file'], '--content-file');
|
||||
const merged = parseMerged(flags.merged);
|
||||
const dryRun = flags['dry-run'] === true;
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
if (!dryRun) await preflight(login, repo);
|
||||
|
||||
const issue = await fetchIssue(login, repo, index);
|
||||
const issuePath = `/repos/${repo}/issues/${index}`;
|
||||
|
||||
const result = replaceSection(issue.body ?? '', section, content);
|
||||
if (result.status === 'not-found') {
|
||||
throw new ScriptError(
|
||||
'SECTION_NOT_FOUND',
|
||||
`議題 #${index} 上沒有「${section}」這個段落;請確認段落名與議題上的 \`## 標題\` 完全一致。` +
|
||||
'本工具不會把內容補到議題末尾——段落名打錯時那樣做比什麼都不做更難收拾',
|
||||
);
|
||||
}
|
||||
if (result.status === 'ambiguous') {
|
||||
throw new ScriptError(
|
||||
'SECTION_AMBIGUOUS',
|
||||
`議題 #${index} 上有 ${result.count} 個「${section}」段落,分不出要換哪一個;` +
|
||||
'請先到議題上把重複的標題改成看得出差別的名稱',
|
||||
);
|
||||
}
|
||||
const body = result.body;
|
||||
|
||||
// 標記之前先確認這幾則留言真的在這顆議題上:標錯地方的 reaction 很難發現
|
||||
await checkComments(login, repo, index, merged);
|
||||
|
||||
// 描述沒變就不送:空的 PATCH 會把議題的 updated_at 推新,看起來像有人動過
|
||||
const 描述已更新 = body !== issue.body;
|
||||
const requests = [
|
||||
...(描述已更新 ? [{ method: 'PATCH', path: issuePath, body: { body } }] : []),
|
||||
...merged.map((id) => ({
|
||||
method: 'POST',
|
||||
path: `/repos/${repo}/issues/comments/${id}/reactions`,
|
||||
body: { content: '+1' },
|
||||
})),
|
||||
];
|
||||
|
||||
if (dryRun) {
|
||||
return { dryRun: true, repo, index, section, 描述已更新, 已標記: merged, requests };
|
||||
}
|
||||
|
||||
// 順序是先寫描述再標記:標記是「這則已經收進去了」的結論,
|
||||
// 反過來的話,描述寫失敗時那幾則已經被標成處理過,再也不會被提出來
|
||||
for (const { method, path, body: payload } of requests) {
|
||||
expectOk(await giteaRequest(login, method, path, { body: payload }), `${method} ${path}`);
|
||||
}
|
||||
|
||||
return {
|
||||
repo,
|
||||
index,
|
||||
url: issue.html_url,
|
||||
section,
|
||||
描述已更新,
|
||||
已標記: merged,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
|
||||
/** 逗號分隔的留言 id。整併卻不標記的話,下次會重複處理同一則,所以這個 flag 是必填。 */
|
||||
function parseMerged(value) {
|
||||
const ids = value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
.map((item) => parseIndex(item, '--merged'));
|
||||
|
||||
if (ids.length === 0) {
|
||||
throw new ScriptError('MISSING_FLAG', '--merged 至少要有一則留言 id');
|
||||
}
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 確認這幾則留言都在這顆議題上。
|
||||
* 打錯 id 的 reaction 會落在別顆議題的留言上,而那幾乎不會有人發現。
|
||||
*/
|
||||
async function checkComments(login, repo, index, merged) {
|
||||
const seen = new Set();
|
||||
|
||||
for await (const comment of listIssueComments(login, repo, index)) {
|
||||
seen.add(comment.id);
|
||||
}
|
||||
|
||||
const missing = merged.filter((id) => !seen.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new ScriptError(
|
||||
'COMMENT_NOT_FOUND',
|
||||
`議題 #${index} 上沒有這幾則留言:${missing.join('、')};` +
|
||||
'請確認 id 來自這顆議題(必要時重跑 issue-extract 或 wp-extract)',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -338,6 +338,65 @@ export function tickLine(body, raw, section) {
|
||||
return { status: 'ticked', body: lines.join('\n'), line: ticked, count: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 換掉一個段落的內容,標題與其餘段落一字不動。
|
||||
*
|
||||
* 整併留言裡的決策時用它。不整份重寫的理由跟 upsertLineInSection 一樣,只是代價更大:
|
||||
* 重寫會把別人在其他段落的編輯一起蓋掉,而議題的編輯紀錄沒有人會去比對。
|
||||
*
|
||||
* 同名標題出現不只一次時交回 `ambiguous`,不賭第一個——理由與 tickLine 相同,
|
||||
* 而這裡蓋掉的是一整段而不是一行,猜錯的代價更高。
|
||||
*
|
||||
* 段落不存在時交回 `not-found` 讓呼叫端報錯,不補在結尾:「找不到那一段」多半是段落名
|
||||
* 打錯,這時把內容塞到議題末尾,比什麼都不做更難收拾。
|
||||
*
|
||||
* @param {string} body 議題 body
|
||||
* @param {string} section 段落名稱,例如 '目標'
|
||||
* @param {string} content 新的段落內容(不含 `## 標題` 那一行)
|
||||
* @returns {{status: 'replaced'|'not-found'|'ambiguous', body?: string, count: number}}
|
||||
*/
|
||||
export function replaceSection(body, section, content) {
|
||||
const rows = [...eachLine(body)];
|
||||
const headings = [];
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
if (rows[i].inFence) continue;
|
||||
if (rows[i].line.match(/^##\s+(.+?)\s*$/)?.[1] === section) headings.push(i);
|
||||
}
|
||||
|
||||
if (headings.length === 0) return { status: 'not-found', count: 0 };
|
||||
if (headings.length > 1) return { status: 'ambiguous', count: headings.length };
|
||||
|
||||
const [start] = headings;
|
||||
const lines = rows.map((row) => row.line);
|
||||
// 下一個段落的標題;沒有就是到結尾
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i += 1) {
|
||||
if (!rows[i].inFence && /^##\s+/.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 段落與段落之間的空行屬於版面,不屬於內容:換內容時把它留著。
|
||||
// 原本就沒有空行(兩個標題緊貼)時補一個,免得新內容黏在下一個標題上。
|
||||
let tail = end;
|
||||
while (tail > start + 1 && lines[tail - 1].trim() === '') tail -= 1;
|
||||
const spacer = end === lines.length || end > tail ? lines.slice(tail, end) : [''];
|
||||
|
||||
// 換行沿用 body 原本的那一種:CRLF 的 body 裡混進 LF,會讓抽取契約交出的 raw
|
||||
// 對不上原文,之後就勾不動那幾行了
|
||||
const eol = body.includes('\r\n') ? '\r\n' : '\n';
|
||||
const normalized = content.trim().split(/\r?\n/);
|
||||
|
||||
return {
|
||||
status: 'replaced',
|
||||
body: [...lines.slice(0, start + 1), '', ...normalized, ...spacer, ...lines.slice(end)]
|
||||
.map((line) => line.replace(/\r$/, ''))
|
||||
.join(eol),
|
||||
count: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定段落裡就地更新(或補上)一行「前綴+值」。
|
||||
*
|
||||
|
||||
@@ -47,7 +47,7 @@ main(async () => {
|
||||
}
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
await preflight(login, repo);
|
||||
const { user } = await preflight(login, repo);
|
||||
|
||||
const issue = await fetchIssue(login, repo, index);
|
||||
const sections = parseSections(issue.body);
|
||||
@@ -66,7 +66,7 @@ main(async () => {
|
||||
驗收標準: listSection(sections, '驗收標準'),
|
||||
影響範圍: listSection(sections, '影響範圍'),
|
||||
未決事項: listSection(sections, '未決事項'),
|
||||
未處理留言數: await countUnmergedComments(login, repo, index),
|
||||
未處理留言數: await countUnmergedComments(login, repo, index, user.login),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+61
-14
@@ -663,6 +663,28 @@ function checkTimeTracker(info) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀一個由 flag 指定的文字檔。
|
||||
*
|
||||
* 「讀一個 --xxx-file 或直接失敗」原本在四支腳本裡各寫一份,錯誤碼還有三種拼法。
|
||||
* 同一種情況要有同一個碼,呼叫端才分辨得出到底是哪一步壞了。
|
||||
*
|
||||
* @param {string} path 檔案路徑
|
||||
* @param {string} flag 出現在錯誤訊息裡的 flag 名,例如 '--body-file'
|
||||
* @param {{allowEmpty?: boolean}} options 內容可不可以是空的;預設不可以
|
||||
* @returns {string}
|
||||
*/
|
||||
export function readTextFile(path, flag, { allowEmpty = false } = {}) {
|
||||
if (!existsSync(path)) {
|
||||
throw new ScriptError('FILE_NOT_FOUND', `找不到 ${flag} 指定的檔案 ${path}`);
|
||||
}
|
||||
const content = readFileSync(path, 'utf8');
|
||||
if (!allowEmpty && content.trim() === '') {
|
||||
throw new ScriptError('FILE_EMPTY', `${flag} 指定的檔案 ${path} 是空的`);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// ── 議題讀取:兩支抽取腳本共用 ────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -697,32 +719,57 @@ export const UNMERGED_COMMENT_NOTE =
|
||||
* 數出尚未被整併回描述的留言則數。
|
||||
*
|
||||
* 抽取契約只讀 body 不讀留言,這個數字是下游判斷「手上的描述是不是過期了」的唯一依據。
|
||||
* 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記),而 Gitea 的留言物件
|
||||
* 不含 reaction,所以只能逐則再查一次。留言多時請求數會跟著長,但這個數字要準
|
||||
* ——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完,讀不完寧可報錯。
|
||||
* 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記)。留言多時請求數會跟著長,
|
||||
* 但這個數字要準——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完,
|
||||
* 讀不完寧可報錯。
|
||||
*
|
||||
* @param {{base: string, token: string}} login
|
||||
* @param {string} repo owner/name
|
||||
* @param {number} index
|
||||
* @param {string} me 目前登入帳號:只有自己打的 `+1` 才算整併過
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function countUnmergedComments(login, repo, index) {
|
||||
const commentsPath = `/repos/${repo}/issues/${index}/comments`;
|
||||
export async function countUnmergedComments(login, repo, index, me) {
|
||||
let unmerged = 0;
|
||||
|
||||
for await (const comments of pages(login, commentsPath, {
|
||||
limitCode: 'COMMENT_LIMIT',
|
||||
limitHint: `${commentsPath} 的留言太多,數不完未整併的則數`,
|
||||
})) {
|
||||
for (const comment of comments) {
|
||||
const path = `/repos/${repo}/issues/comments/${comment.id}/reactions`;
|
||||
const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
|
||||
if (!reactions.some((reaction) => reaction.content === '+1')) unmerged += 1;
|
||||
}
|
||||
for await (const comment of listIssueComments(login, repo, index)) {
|
||||
if (!(await mergedByMe(login, repo, comment.id, me))) unmerged += 1;
|
||||
}
|
||||
return unmerged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐頁走過一顆議題(或 PR)的一般留言。
|
||||
* 三支腳本都要做這件事:數未整併的則數、列出留言內容、核對 --merged 的 id。
|
||||
* @returns {AsyncGenerator<object>} 一則一則交出去
|
||||
*/
|
||||
export async function* listIssueComments(login, repo, index) {
|
||||
const path = `/repos/${repo}/issues/${index}/comments`;
|
||||
|
||||
for await (const comments of pages(login, path, {
|
||||
limitCode: 'COMMENT_LIMIT',
|
||||
limitHint: `${path} 的留言太多,讀不完整份清單`,
|
||||
})) {
|
||||
for (const comment of comments) yield comment;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 這一則是不是「我」標記過已整併。
|
||||
*
|
||||
* Gitea 的留言物件不含 reaction,只能逐則再查一次。認的是自己打的 `+1`:
|
||||
* 別人按讚是「我同意」,當成已整併會讓那一則的決策永遠不被收進描述——
|
||||
* 而那正是 /sdlc-sync 要解決的事。
|
||||
*
|
||||
* @param {string} me 目前登入帳號;preflight 的回傳帶得出來
|
||||
*/
|
||||
export async function mergedByMe(login, repo, id, me) {
|
||||
const path = `/repos/${repo}/issues/comments/${id}/reactions`;
|
||||
const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
|
||||
|
||||
return reactions.some((reaction) => reaction.content === '+1' && reaction.user?.login === me);
|
||||
}
|
||||
|
||||
// ── 碼錶 ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
+27
-18
@@ -1,9 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 讀 PR 上的三類留言:一般留言、review 總評、行內留言。
|
||||
* 讀 PR 或議題上的留言。
|
||||
*
|
||||
* 三類分散在三個端點,漏掉任何一類就會有 reviewer 的意見沒被處理——而那正是
|
||||
* `/sdlc-fix` 存在的理由。這一支只讀不寫,分類(必改/建議)由讀到內容的人判斷。
|
||||
* PR 有三類:一般留言、review 總評、行內留言,分散在三個端點——漏掉任何一類就會有
|
||||
* reviewer 的意見沒被處理,而那正是 `/sdlc-fix` 存在的理由。
|
||||
* 純議題只有一般留言,`/sdlc-sync` 要的就是那一份。
|
||||
*
|
||||
* **每個 PR 都是議題,但議題不一定是 PR。** 所以先讀 `/issues/{index}`(兩種都有),
|
||||
* 看它有沒有 `pull_request` 才決定要不要去翻 review;反過來先打 `/pulls/{index}`,
|
||||
* 對純議題會 404,整個流程在讀到第一則留言之前就斷了。
|
||||
*
|
||||
* 讀取本身與「已處理」的判定在 `pr-threads.js`——`pr-watch` 要數同一件事,
|
||||
* 規則寫兩份遲早會各自演化。
|
||||
@@ -12,8 +17,7 @@
|
||||
* node scripts/pr-comments.js --repo owner/name --index 45 [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
expectOk,
|
||||
giteaRequest,
|
||||
fetchIssue,
|
||||
main,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
@@ -22,9 +26,9 @@ import {
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
import {
|
||||
COMMENT_REQUEST_NOTE,
|
||||
fetchPull,
|
||||
plannedRequests,
|
||||
ISSUE_OR_PULL_NOTE,
|
||||
commonRequests,
|
||||
readGeneralComments,
|
||||
readPullComments,
|
||||
unhandledCount,
|
||||
} from './pr-threads.js';
|
||||
@@ -43,24 +47,29 @@ main(async () => {
|
||||
dryRun: true,
|
||||
repo,
|
||||
index,
|
||||
requests: plannedRequests(repo, index),
|
||||
note: COMMENT_REQUEST_NOTE,
|
||||
requests: commonRequests(repo, index),
|
||||
note: ISSUE_OR_PULL_NOTE,
|
||||
};
|
||||
}
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
await preflight(login, repo);
|
||||
const { user } = await preflight(login, repo);
|
||||
const me = user.login;
|
||||
|
||||
const me = expectOk(await giteaRequest(login, 'GET', '/user'), 'GET /user').login;
|
||||
const pull = await fetchPull(login, repo, index);
|
||||
const 留言 = await readPullComments(login, repo, index, me);
|
||||
// 先讀議題:PR 也是議題,反過來不成立。這一步同時決定要不要去翻 review
|
||||
const issue = await fetchIssue(login, repo, index);
|
||||
const isPull = issue.pull_request != null;
|
||||
const 留言 = isPull
|
||||
? await readPullComments(login, repo, index, me)
|
||||
: await readGeneralComments(login, repo, index, me);
|
||||
|
||||
return {
|
||||
repo,
|
||||
index: pull.number,
|
||||
title: pull.title,
|
||||
url: pull.html_url,
|
||||
state: pull.state,
|
||||
index: issue.number,
|
||||
類型: isPull ? 'PR' : '議題',
|
||||
title: issue.title,
|
||||
url: issue.html_url,
|
||||
state: issue.state,
|
||||
留言,
|
||||
未處理數: unhandledCount(留言),
|
||||
};
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
* --body-file <描述檔> --index 13
|
||||
* [--issue-repo owner/name] [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import {
|
||||
ScriptError,
|
||||
expectOk,
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
readTextFile,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
|
||||
@@ -87,7 +87,7 @@ main(async () => {
|
||||
const head = flags.head;
|
||||
const base = flags.base;
|
||||
const index = parseIndex(flags.index);
|
||||
const body = readBody(flags['body-file']);
|
||||
const body = readTextFile(flags['body-file'], '--body-file');
|
||||
|
||||
// 描述先驗完再談寫入:不合格的描述不該等到實跑才發現
|
||||
checkSections(body);
|
||||
@@ -160,12 +160,6 @@ async function findOpenPull(login, repo, head) {
|
||||
}
|
||||
|
||||
|
||||
function readBody(path) {
|
||||
if (!existsSync(path)) {
|
||||
throw new ScriptError('BODY_FILE_NOT_FOUND', `找不到描述檔 ${path}`);
|
||||
}
|
||||
return readFileSync(path, 'utf8');
|
||||
}
|
||||
|
||||
/** 八個段落一個都不能少,而且順序要與 SECTIONS 一致 */
|
||||
function checkSections(body) {
|
||||
|
||||
+38
-34
@@ -7,7 +7,8 @@
|
||||
* 才會被發現。
|
||||
*
|
||||
* 「已處理」在三類上的機制不同:
|
||||
* - 一般留言、review 總評 → 自己打的 `+1` reaction
|
||||
* - 一般留言、review 總評 → 自己打的 `+1` reaction(判定在 `lib.js` 的 mergedByMe,
|
||||
* 抽取契約數未整併則數時用的是同一條規則)
|
||||
* - 行內留言 → 有沒有被 resolve(只有 review comment 有 resolve 端點)
|
||||
*
|
||||
* **總評的 reaction 掛在它的 issue comment id 上,不是 review id。** Gitea 的 review
|
||||
@@ -17,7 +18,7 @@
|
||||
* reaction 要是**自己**打的才算已處理:reviewer 對留言按讚是「我同意」,不是
|
||||
* 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。
|
||||
*/
|
||||
import { ScriptError, expectOk, giteaRequest, pages } from './lib.js';
|
||||
import { ScriptError, expectOk, giteaRequest, listIssueComments, mergedByMe, pages } from './lib.js';
|
||||
|
||||
/** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */
|
||||
const DRAFT = 'PENDING';
|
||||
@@ -33,7 +34,7 @@ const DRAFT = 'PENDING';
|
||||
export async function readPullComments(login, repo, index, me) {
|
||||
const pullPath = `/repos/${repo}/pulls/${index}`;
|
||||
return [
|
||||
...(await readGeneral(login, repo, index, me)),
|
||||
...(await readGeneralComments(login, repo, index, me)),
|
||||
...(await readReviews(login, repo, index, pullPath, me)),
|
||||
];
|
||||
}
|
||||
@@ -54,6 +55,25 @@ export function plannedRequests(repo, index) {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 不確定是議題還是 PR 時,一定會發的那兩個請求。
|
||||
*
|
||||
* `pr-comments` 收得下兩種輸入,而它在試跑階段還沒讀過議題、不知道是哪一種。
|
||||
* 與其假設是 PR 而列出五個(對純議題有三個根本不會發),不如只列一定會發的,
|
||||
* 其餘交給 note 說明。`pr-watch` 的輸入一定是 PR,繼續用 plannedRequests。
|
||||
*/
|
||||
export function commonRequests(repo, index) {
|
||||
return [
|
||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}` },
|
||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/comments` },
|
||||
];
|
||||
}
|
||||
|
||||
/** `commonRequests` 列不完的那部分:是 PR 的話還要再讀三處。 */
|
||||
export const ISSUE_OR_PULL_NOTE =
|
||||
'每則留言還會各查一次 reaction;是 PR 的話還會再讀 review 清單、每個 review 的行內留言' +
|
||||
'與 timeline。次數取決於留言數,事前無法列舉。';
|
||||
|
||||
/**
|
||||
* `plannedRequests` 列不完的那部分。與 readPullComments 同進退——說明的是它發出的請求。
|
||||
*/
|
||||
@@ -83,28 +103,24 @@ export async function fetchPull(login, repo, index) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 一般留言。PR 在 Gitea 裡也是 issue,所以走 issue 的留言端點。
|
||||
* 一般留言。PR 在 Gitea 裡也是 issue,所以走 issue 的留言端點——
|
||||
* 純議題也只有這一類,`/sdlc-sync` 要的就是它。
|
||||
* 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。
|
||||
*/
|
||||
async function readGeneral(login, repo, index, me) {
|
||||
const path = `/repos/${repo}/issues/${index}/comments`;
|
||||
export async function readGeneralComments(login, repo, index, me) {
|
||||
const 留言 = [];
|
||||
|
||||
for await (const comments of pages(login, path, {
|
||||
limitCode: 'COMMENT_LIMIT',
|
||||
limitHint: `${path} 的留言太多,讀不完整份清單`,
|
||||
})) {
|
||||
for (const comment of comments) {
|
||||
if ((comment.body ?? '').trim() === '') continue;
|
||||
留言.push({
|
||||
id: comment.id,
|
||||
類型: '一般',
|
||||
作者: comment.user?.login ?? '',
|
||||
內容: comment.body,
|
||||
已處理: await markedByMe(login, repo, comment.id, me),
|
||||
可標記: true,
|
||||
});
|
||||
}
|
||||
for await (const comment of listIssueComments(login, repo, index)) {
|
||||
if ((comment.body ?? '').trim() === '') continue;
|
||||
留言.push({
|
||||
id: comment.id,
|
||||
review: null,
|
||||
類型: '一般',
|
||||
作者: comment.user?.login ?? '',
|
||||
內容: comment.body,
|
||||
已處理: await mergedByMe(login, repo, comment.id, me),
|
||||
可標記: true,
|
||||
});
|
||||
}
|
||||
return 留言;
|
||||
}
|
||||
@@ -136,7 +152,7 @@ async function readReviews(login, repo, index, pullPath, me) {
|
||||
作者: review.user?.login ?? '',
|
||||
內容: review.body,
|
||||
// 找不到它在 issue comment 表裡的那一份就標不了——那時如實說,不要假裝可以
|
||||
已處理: commentId === undefined ? false : await markedByMe(login, repo, commentId, me),
|
||||
已處理: commentId === undefined ? false : await mergedByMe(login, repo, commentId, me),
|
||||
可標記: commentId !== undefined,
|
||||
});
|
||||
}
|
||||
@@ -199,15 +215,3 @@ async function reviewCommentIds(login, repo, index) {
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 這一則是不是「我」標記過已處理。
|
||||
*
|
||||
* Gitea 的留言物件不含 reaction,只能逐則再查一次。認的是自己打的 `+1`:
|
||||
* reviewer 對留言按讚是「我同意」,當成已處理會讓那一則被靜靜跳過。
|
||||
*/
|
||||
async function markedByMe(login, repo, id, me) {
|
||||
const path = `/repos/${repo}/issues/comments/${id}/reactions`;
|
||||
const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
|
||||
|
||||
return reactions.some((reaction) => reaction.content === '+1' && reaction.user?.login === me);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ main(async () => {
|
||||
}
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
await preflight(login, repo);
|
||||
const { user } = await preflight(login, repo);
|
||||
|
||||
const issue = await fetchIssue(login, repo, index);
|
||||
const sections = parseSections(issue.body);
|
||||
@@ -88,7 +88,7 @@ main(async () => {
|
||||
相依: { blocks, depends },
|
||||
assignee: issue.assignee?.login ?? null,
|
||||
碼錶中: await hasRunningStopwatch(login, repo, index),
|
||||
未處理留言數: await countUnmergedComments(login, repo, index),
|
||||
未處理留言數: await countUnmergedComments(login, repo, index, user.login),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 把留言裡的決策整併回議題描述。
|
||||
*
|
||||
* 兩件事錯了都很安靜,所以測試集中在這裡:
|
||||
*
|
||||
* 1. **局部更新。** 只換指定那一段,其餘一字不動。整份重寫會把別人在其他段落的
|
||||
* 編輯一起蓋掉,而議題的編輯紀錄沒有人會去比對。
|
||||
* 2. **標記只給真的整併進去的那幾則。** 略過的要保持未標記,下次才會再被提出來;
|
||||
* 而描述沒寫成功就不該標記——標了就等於這則再也不會被看到。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 7;
|
||||
|
||||
/** 一份有多個段落的需求議題 */
|
||||
const BODY = `## 總覽
|
||||
|
||||
把一段口語需求變成結構化議題。
|
||||
|
||||
## 背景
|
||||
|
||||
需求目前寫成散文。
|
||||
|
||||
## 目標
|
||||
|
||||
- 需求議題可被下游腳本機讀
|
||||
- 建立議題的時間從 30 分鐘降到 5 分鐘
|
||||
|
||||
## 非目標
|
||||
|
||||
- 不處理工作包的拆解
|
||||
`;
|
||||
|
||||
/** 把段落內容寫成檔案,回傳路徑 */
|
||||
function contentFile(name, content) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const path = join(tmpRoot, `merge-${name}-${process.hrtime.bigint()}.md`);
|
||||
writeFileSync(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
function routes(overrides = {}, { body = BODY, comments = [101, 102] } = {}) {
|
||||
const base = healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
|
||||
status: 200,
|
||||
body: { number: INDEX, title: '以 sdlc-plan 轉成結構化需求議題', body, html_url: `https://x/${INDEX}` },
|
||||
},
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: (req) => ({
|
||||
status: 200,
|
||||
body: { number: INDEX, ...req.body, html_url: `https://x/${INDEX}` },
|
||||
}),
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
||||
status: 200,
|
||||
body: comments.map((id) => ({ id, body: `留言 ${id}`, user: { login: 'someone' } })),
|
||||
},
|
||||
});
|
||||
for (const id of comments) {
|
||||
base[`POST /api/v1/repos/${REPO}/issues/comments/${id}/reactions`] = { status: 201, body: {} };
|
||||
}
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
|
||||
|
||||
const run = (args, stub) =>
|
||||
runScript('comments-merge.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
const reactions = (stub) =>
|
||||
stub.requests.filter((r) => r.method === 'POST' && r.path.includes('/reactions'));
|
||||
|
||||
// ── 局部更新 ───────────────────────────────────────────────────────
|
||||
|
||||
test('只換指定那一段,其餘一字不動', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('goals', '- 需求議題可被下游腳本機讀\n- 建立議題的時間降到 5 分鐘\n- 名詞表由需求提出者維護\n');
|
||||
|
||||
const { code, json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
const written = patchOf(stub).body.body;
|
||||
assert.match(written, /- 名詞表由需求提出者維護/, '新內容要寫進去');
|
||||
assert.match(written, /## 總覽\n\n把一段口語需求變成結構化議題。/, '總覽原封不動');
|
||||
assert.match(written, /## 背景\n\n需求目前寫成散文。/, '背景原封不動');
|
||||
assert.match(written, /## 非目標\n\n- 不處理工作包的拆解/, '非目標原封不動');
|
||||
});
|
||||
|
||||
test('段落標題本身不動,只換它底下的內容', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('keep-heading', '- 換掉的內容\n');
|
||||
|
||||
await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
const written = patchOf(stub).body.body;
|
||||
assert.equal((written.match(/^## 目標$/gm) ?? []).length, 1, '標題只有一個,沒有被複製或刪掉');
|
||||
assert.match(written, /## 目標\n\n- 換掉的內容\n\n## 非目標/);
|
||||
});
|
||||
|
||||
test('段落之間的空行維持原本的樣子', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('spacing', '- 甲\n');
|
||||
|
||||
await run(['--section', '背景', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
assert.match(patchOf(stub).body.body, /## 背景\n\n- 甲\n\n## 目標/);
|
||||
});
|
||||
|
||||
test('最後一段也換得掉', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('last', '- 也不處理權限\n');
|
||||
|
||||
await run(['--section', '非目標', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
const written = patchOf(stub).body.body;
|
||||
assert.match(written, /## 非目標\n\n- 也不處理權限/);
|
||||
assert.equal(written.includes('不處理工作包的拆解'), false, '舊內容要被換掉');
|
||||
});
|
||||
|
||||
test('段落不存在時擋下,不把內容補到別的地方去', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('missing', '- 內容\n');
|
||||
|
||||
const { code, json } = await run(
|
||||
['--section', '沒有這一段', '--content-file', file, '--merged', '101'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'SECTION_NOT_FOUND');
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
assert.deepEqual(reactions(stub), [], '沒寫進去就不該標記');
|
||||
});
|
||||
|
||||
test('圍欄裡的假標題不算段落', async (t) => {
|
||||
const body = '## 流程圖\n\n```\n## 目標\n這不是段落\n```\n\n## 目標\n\n- 真的目標\n';
|
||||
const stub = await withStub(t, {}, { body });
|
||||
const file = contentFile('fenced', '- 換掉的目標\n');
|
||||
|
||||
await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
const written = patchOf(stub).body.body;
|
||||
assert.match(written, /```\n## 目標\n這不是段落\n```/, '圍欄裡的內容原封不動');
|
||||
assert.match(written, /## 目標\n\n- 換掉的目標/);
|
||||
});
|
||||
|
||||
// ── 標記:只給真的整併進去的 ───────────────────────────────────────
|
||||
|
||||
test('只標記 --merged 列出的那幾則', async (t) => {
|
||||
const stub = await withStub(t, {}, { comments: [101, 102, 103] });
|
||||
const file = contentFile('partial', '- 內容\n');
|
||||
|
||||
const { json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101,103'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.deepEqual(json.data.已標記, [101, 103]);
|
||||
assert.deepEqual(
|
||||
reactions(stub).map((r) => r.path),
|
||||
[
|
||||
`/api/v1/repos/${REPO}/issues/comments/101/reactions`,
|
||||
`/api/v1/repos/${REPO}/issues/comments/103/reactions`,
|
||||
],
|
||||
'沒被整併的 102 要保持未標記,下次才會再被提出來',
|
||||
);
|
||||
});
|
||||
|
||||
test('標記送的是 +1', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('thumb', '- 內容\n');
|
||||
|
||||
await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
assert.deepEqual(reactions(stub)[0].body, { content: '+1' });
|
||||
});
|
||||
|
||||
test('先寫描述再標記:標記是「這則已經收進去了」的結論', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('order', '- 內容\n');
|
||||
|
||||
await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
const writes = stub.requests
|
||||
.filter((r) => r.method !== 'GET' && !r.path.endsWith('/issues/0'))
|
||||
.map((r) => r.path);
|
||||
assert.ok(
|
||||
writes.indexOf(`/api/v1/repos/${REPO}/issues/${INDEX}`)
|
||||
< writes.indexOf(`/api/v1/repos/${REPO}/issues/comments/101/reactions`),
|
||||
);
|
||||
});
|
||||
|
||||
test('描述寫入失敗時不標記:標了就等於這則再也不會被看到', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 403, body: { message: 'forbidden' } },
|
||||
});
|
||||
const file = contentFile('fail', '- 內容\n');
|
||||
|
||||
const { code } = await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.deepEqual(reactions(stub), []);
|
||||
});
|
||||
|
||||
test('--merged 指到議題上沒有的留言時擋下', async (t) => {
|
||||
const stub = await withStub(t, {}, { comments: [101] });
|
||||
const file = contentFile('badid', '- 內容\n');
|
||||
|
||||
const { json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101,999'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'COMMENT_NOT_FOUND');
|
||||
assert.match(json.error.message, /999/);
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
});
|
||||
|
||||
// ── 冪等 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('內容與現況相同時不重寫描述,但該標記的還是要標', async (t) => {
|
||||
// 重跑常常是因為上一輪標記那一步斷掉了
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('same', '- 需求議題可被下游腳本機讀\n- 建立議題的時間從 30 分鐘降到 5 分鐘\n');
|
||||
|
||||
const { code, json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(json.data.描述已更新, false);
|
||||
assert.equal(patchOf(stub), undefined, '沒變就不要在議題上留一筆空的編輯');
|
||||
assert.equal(reactions(stub).length, 1, '標記照舊');
|
||||
});
|
||||
|
||||
// ── 輸入 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('內容是空的時候擋下:整併不該把一段清空', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('empty', ' \n');
|
||||
|
||||
const { json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'FILE_EMPTY');
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
});
|
||||
|
||||
test('內容檔不存在時回可區分的錯誤碼', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(
|
||||
['--section', '目標', '--content-file', join(tmpRoot, '不存在.md'), '--merged', '101'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'FILE_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('沒有 --merged 時擋下:整併卻不標記,下次會重複處理同一則', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('nomerged', '- 內容\n');
|
||||
|
||||
const { json } = await runScript('comments-merge.js', [
|
||||
'--repo', REPO, '--index', String(INDEX), '--section', '目標', '--content-file', file,
|
||||
], { env: envFor(stub) });
|
||||
|
||||
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||
assert.match(json.error.message, /--merged/);
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出改完的描述與將標記的留言,但不寫入', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('dry', '- 換掉的目標\n');
|
||||
|
||||
const { code, json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101,102', '--dry-run'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.match(json.data.requests[0].body.body, /- 換掉的目標/);
|
||||
assert.deepEqual(
|
||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||
[
|
||||
`PATCH /repos/${REPO}/issues/${INDEX}`,
|
||||
`POST /repos/${REPO}/issues/comments/101/reactions`,
|
||||
`POST /repos/${REPO}/issues/comments/102/reactions`,
|
||||
],
|
||||
);
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
assert.deepEqual(reactions(stub), []);
|
||||
});
|
||||
|
||||
test('--dry-run 遇到段落不存在一樣報錯,不會等到實跑才發現', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('dry-bad', '- 內容\n');
|
||||
|
||||
const { json } = await run(
|
||||
['--section', '沒有這一段', '--content-file', file, '--merged', '101', '--dry-run'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'SECTION_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('--dry-run 在內容沒變時不預告 PATCH', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('dry-same', '- 需求議題可被下游腳本機讀\n- 建立議題的時間從 30 分鐘降到 5 分鐘\n');
|
||||
|
||||
const { json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101', '--dry-run'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||
[`POST /repos/${REPO}/issues/comments/101/reactions`],
|
||||
);
|
||||
});
|
||||
@@ -85,7 +85,7 @@ function routes(overrides = {}, { body = FULL_BODY, comments = [] } = {}) {
|
||||
comments.forEach((c, i) => {
|
||||
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
|
||||
status: 200,
|
||||
body: (c.reactions ?? []).map((content) => ({ content })),
|
||||
body: (c.reactions ?? []).map((content) => ({ content, user: { login: c.reactedBy ?? 'tester' } })),
|
||||
};
|
||||
});
|
||||
return { ...base, ...overrides };
|
||||
|
||||
@@ -25,10 +25,14 @@ function routes(overrides = {}, options = {}) {
|
||||
general = [],
|
||||
reviews = [],
|
||||
pull = { number: INDEX, title: 'feat/pr-comments/main', html_url: `https://x/${INDEX}`, state: 'open' },
|
||||
isPull = true,
|
||||
} = options;
|
||||
|
||||
const base = healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}`]: { status: 200, body: pull },
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
|
||||
status: 200,
|
||||
body: { ...pull, ...(isPull ? { pull_request: { merged: false } } : {}) },
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
||||
status: 200,
|
||||
body: general.map((c, i) => ({
|
||||
@@ -357,18 +361,48 @@ test('帶出 PR 的識別資訊,讓回報不必再查一次', async (t) => {
|
||||
assert.equal(json.data.state, 'open');
|
||||
});
|
||||
|
||||
test('PR 不存在時回可區分的錯誤碼', async (t) => {
|
||||
test('議題不存在時回可區分的錯誤碼', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}`]: { status: 404, body: { message: 'not found' } },
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
|
||||
}, FULL);
|
||||
|
||||
const { code, json } = await run([], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'PULL_NOT_FOUND');
|
||||
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
|
||||
assert.match(json.error.message, new RegExp(String(INDEX)));
|
||||
});
|
||||
|
||||
test('純議題也讀得到:先讀 issue 再決定要不要翻 review', async (t) => {
|
||||
// 每個 PR 都是議題,議題不一定是 PR。先打 /pulls 的話,純議題會 404,
|
||||
// 而 /sdlc-sync 的輸入正是純議題——整個流程在讀到第一則留言之前就斷了
|
||||
const stub = await withStub(t, {}, {
|
||||
isPull: false,
|
||||
general: [{ body: '這顆議題上的決策', reactions: [] }],
|
||||
reviews: [],
|
||||
});
|
||||
|
||||
const { code, json } = await run([], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(json.data.類型, '議題');
|
||||
assert.deepEqual(json.data.留言.map((c) => c.內容), ['這顆議題上的決策']);
|
||||
assert.equal(
|
||||
stub.requests.some((r) => r.path.includes('/pulls/')),
|
||||
false,
|
||||
'純議題不該去打 PR 的端點',
|
||||
);
|
||||
});
|
||||
|
||||
test('是 PR 時類型標成 PR,並照樣讀 review', async (t) => {
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.equal(json.data.類型, 'PR');
|
||||
assert.ok(json.data.留言.some((c) => c.類型 === '總評'));
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
||||
@@ -381,11 +415,8 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
||||
assert.deepEqual(
|
||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||
[
|
||||
'GET /user',
|
||||
`GET /repos/${REPO}/pulls/${INDEX}`,
|
||||
`GET /repos/${REPO}/issues/${INDEX}`,
|
||||
`GET /repos/${REPO}/issues/${INDEX}/comments`,
|
||||
`GET /repos/${REPO}/pulls/${INDEX}/reviews`,
|
||||
`GET /repos/${REPO}/issues/${INDEX}/timeline`,
|
||||
],
|
||||
);
|
||||
assert.match(json.data.note, /reaction|review/);
|
||||
|
||||
@@ -391,7 +391,7 @@ test('描述檔不存在時回可區分的錯誤碼', async (t) => {
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'BODY_FILE_NOT_FOUND');
|
||||
assert.equal(json.error.code, 'FILE_NOT_FOUND');
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* /sdlc-sync 的流程正本,以及另外兩份正本的「接回」那一段。
|
||||
*
|
||||
* 這個指令的價值在於「描述不再騙人」,而三件關鍵事只有正本做得到:挑出哪些留言真的是
|
||||
* 決策、寫回去之前讓使用者點頭、以及略過的那幾則要保持未標記。寫漏任何一件,
|
||||
* 描述就會繼續過期,或者有決策被靜靜吞掉。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
|
||||
|
||||
const prompt = readPrompt('sdlc-sync');
|
||||
const steps = prompt.slice(prompt.indexOf('## 1.'), prompt.indexOf('## 邊界'));
|
||||
|
||||
test('正本平台中立,description 前綴正確', () => {
|
||||
assertNeutralPrompt(prompt, 'sdlc-sync');
|
||||
});
|
||||
|
||||
test('兩種議題各自指名對應的抽取腳本', () => {
|
||||
assert.match(steps, /issue-extract/);
|
||||
assert.match(steps, /wp-extract/);
|
||||
assert.match(steps, /需求議題.*工作包議題|工作包議題.*需求議題/s);
|
||||
});
|
||||
|
||||
test('留言內容另外拿,並說明為什麼抽取契約不夠', () => {
|
||||
assert.match(steps, /pr-comments/);
|
||||
assert.match(steps, /只給數字不給內容/);
|
||||
});
|
||||
|
||||
test('說明那支腳本議題與 PR 都收得下,不再宣稱「PR 也是 issue」', () => {
|
||||
assert.match(steps, /議題與 PR 都收得下/);
|
||||
assert.equal(steps.includes('Gitea 的 PR 也是 issue'), false, '反過來說才對:每個 PR 都是議題');
|
||||
});
|
||||
|
||||
test('已處理認的是自己打的 +1', () => {
|
||||
assert.match(steps, /自己打的/);
|
||||
assert.match(steps, /我同意/);
|
||||
});
|
||||
|
||||
test('略過會讓計數停在大於 0,這件事要講明白', () => {
|
||||
const section = steps.slice(steps.indexOf('## 5.'));
|
||||
assert.match(section, /停在大於 0/);
|
||||
assert.match(section, /每次開始時都會再停一次/);
|
||||
assert.match(section, /他選了略過/, '要讓使用者分得出「沒整併乾淨」與「選了略過」');
|
||||
});
|
||||
|
||||
test('已標記過的留言跳過', () => {
|
||||
assert.match(steps, /已經標記過的留言不再處理|已處理.*跳過/s);
|
||||
});
|
||||
|
||||
// ── 挑決策 ─────────────────────────────────────────────────────────
|
||||
|
||||
test('要整併與不整併各有判斷依據,不是只給兩個詞', () => {
|
||||
assert.match(steps, /\*\*要整併\*\* — .{10,}/);
|
||||
assert.match(steps, /\*\*不整併\*\* — .{10,}/);
|
||||
});
|
||||
|
||||
test('判斷不了時當成要整併,並說出漏掉的代價', () => {
|
||||
assert.match(steps, /判斷不了的\*\*當成要整併\*\*/);
|
||||
assert.match(steps, /描述就會繼續騙後面的人/);
|
||||
});
|
||||
|
||||
// ── 先點頭再寫 ─────────────────────────────────────────────────────
|
||||
|
||||
test('寫回去之前要列出方案給使用者看', () => {
|
||||
assert.match(steps, /先列出來再動手/);
|
||||
assert.match(steps, /哪一段/);
|
||||
assert.match(steps, /改完長什麼樣/);
|
||||
});
|
||||
|
||||
test('一次問一題,且略過的會保持未標記', () => {
|
||||
assert.match(steps, /一次問一題/);
|
||||
assert.match(steps, /略過的留言保持未標記/);
|
||||
assert.match(steps, /下次執行還會被提出來/);
|
||||
});
|
||||
|
||||
test('說明了這一步是描述的最後一道關卡', () => {
|
||||
assert.match(steps, /最後一道關卡/);
|
||||
assert.match(steps, /拿它當事實/);
|
||||
});
|
||||
|
||||
// ── 寫回 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('指名 comments-merge,並要求先試跑', () => {
|
||||
assert.match(steps, /comments-merge\.js/);
|
||||
assert.match(steps, /--dry-run/);
|
||||
});
|
||||
|
||||
test('--content-file 的內容是整段,且說明標題那一行不含在內', () => {
|
||||
assert.match(steps, /那一段改完的完整內容/);
|
||||
assert.match(steps, /不含 `## 標題` 那一行/);
|
||||
});
|
||||
|
||||
test('--merged 只放真的併進去的,並說出放錯的後果', () => {
|
||||
assert.match(steps, /只放\*\*真的被併進這一段\*\*/);
|
||||
assert.match(steps, /再也\n?不會被提出來|再也不會被提出來/);
|
||||
});
|
||||
|
||||
test('SECTION_NOT_FOUND 的處置是回頭確認,不是硬塞別的段落', () => {
|
||||
assert.match(steps, /SECTION_NOT_FOUND/);
|
||||
assert.match(steps, /不要改用別的段落硬塞/);
|
||||
});
|
||||
|
||||
// ── 回報 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('回報要列出略過的那幾則', () => {
|
||||
const section = steps.slice(steps.indexOf('## 5.'));
|
||||
assert.match(section, /略過的那幾則是哪些/);
|
||||
assert.match(section, /下次還會出現/);
|
||||
});
|
||||
|
||||
// ── 接回原本的指令 ─────────────────────────────────────────────────
|
||||
|
||||
test('整併完自動接回,不要求使用者重打指令', () => {
|
||||
const section = prompt.slice(prompt.indexOf('## 接回原本的指令'));
|
||||
assert.match(section, /直接接回去/);
|
||||
assert.match(section, /不要要求使用者重打一次/);
|
||||
});
|
||||
|
||||
test('接回之前要重新抽取,並說明為什麼', () => {
|
||||
const section = prompt.slice(prompt.indexOf('## 接回原本的指令'));
|
||||
assert.match(section, /先重跑一次抽取/);
|
||||
assert.match(section, /這一整段就白做了/);
|
||||
});
|
||||
|
||||
// ── 邊界 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('邊界列出不做的事,含不刪改留言', () => {
|
||||
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
|
||||
assert.match(boundary, /不自行決定要不要整併/);
|
||||
assert.match(boundary, /不整份重寫描述/);
|
||||
assert.match(boundary, /不標記略過的留言/);
|
||||
assert.match(boundary, /不刪除、不編輯任何留言/);
|
||||
assert.match(boundary, /誰說過什麼的紀錄/, '要說出為什麼留言不能動');
|
||||
});
|
||||
|
||||
// ── 另外兩份正本的接回 ─────────────────────────────────────────────
|
||||
|
||||
for (const name of ['sdlc-analyze', 'sdlc-feat']) {
|
||||
test(`${name} 偵測到未整併留言時會轉去整併,並自動接回`, () => {
|
||||
const other = readPrompt(name);
|
||||
assert.match(other, /未處理留言數/);
|
||||
assert.match(other, /直接走 `\/sdlc-sync` 的流程/, `${name} 要真的轉過去,不是叫人自己跑`);
|
||||
assert.match(other, /自動接回這裡/);
|
||||
assert.match(other, /不要要求使用者重打指令/);
|
||||
});
|
||||
|
||||
test(`${name} 接回前要重新抽取一次`, () => {
|
||||
assert.match(readPrompt(name), /重新抽取一次拿到更新後的描述/);
|
||||
});
|
||||
}
|
||||
@@ -106,7 +106,7 @@ function routes(overrides = {}, options = {}) {
|
||||
comments.forEach((c, i) => {
|
||||
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
|
||||
status: 200,
|
||||
body: (c.reactions ?? []).map((content) => ({ content })),
|
||||
body: (c.reactions ?? []).map((content) => ({ content, user: { login: c.reactedBy ?? 'tester' } })),
|
||||
};
|
||||
});
|
||||
return { ...base, ...overrides };
|
||||
|
||||
Reference in New Issue
Block a user