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:
2026-09-17 09:22:58 +00:00
17 changed files with 976 additions and 95 deletions
+6 -3
View File
@@ -26,9 +26,12 @@ node scripts/issue-extract.js --repo <owner/name> --index <編號>
拿到的是結構化欄位,不必再讀整份議題全文。 拿到的是結構化欄位,不必再讀整份議題全文。
**先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被 **先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被
整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,建議先執行 `/sdlc-sync` 整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,問他要不要現在整併。
把它們整併回描述,再回來做分析。使用者堅持要繼續就繼續,但要記下這件事,
並在共識摘要裡註明「分析基於未整併留言前的描述」。 要整併的話**直接走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`),做完**自動接回這裡**:
重新抽取一次拿到更新後的描述,再往下走。**不要要求使用者重打指令**——他已經說要整併了。
使用者選擇不整併就繼續,但要記下這件事,並在共識摘要裡註明「分析基於未整併留言前的描述」。
### 2. 對四份清單列出疑點 ### 2. 對四份清單列出疑點
+6 -3
View File
@@ -30,9 +30,12 @@ node scripts/wp-extract.js --repo <owner/name> --index <編號>
不必再讀整份議題全文。 不必再讀整份議題全文。
**先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被 **先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被
整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,建議先執行 `/sdlc-sync` 整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,問他要不要現在整併。
把它們整併回描述,再回來實作。使用者堅持要繼續就繼續,但要記下這件事,
並在最後的 PR 描述裡註明「實作基於未整併留言前的描述」。 要整併的話**直接走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`),做完**自動接回這裡**:
重新抽取一次拿到更新後的描述,再往下走。**不要要求使用者重打指令**——他已經說要整併了。
使用者選擇不整併就繼續,但要記下這件事,並在最後的 PR 描述裡註明「實作基於未整併留言前的描述」。
**再看 `相依.depends`。** 裡面還有沒關閉的議題,代表這顆的前置還沒做完。照樣先說出來, **再看 `相依.depends`。** 裡面還有沒關閉的議題,代表這顆的前置還沒做完。照樣先說出來,
讓使用者決定要不要現在做。 讓使用者決定要不要現在做。
+104
View File
@@ -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`),拿到的才是剛更新過的描述——
接著用舊的那一份做事,這一整段就白做了。
## 邊界
- 不自行決定要不要整併:方案一定先給使用者看過。
- 不整份重寫描述,只換談好的那幾段。
- 不標記略過的留言。
- 不刪除、不編輯任何留言——留言是誰說過什麼的紀錄,整併是把結論抄進描述,不是把原文搬走。
- 不因為整併而改變議題的狀態、標籤或指派。
+141
View File
@@ -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)',
);
}
}
+59
View File
@@ -338,6 +338,65 @@ export function tickLine(body, raw, section) {
return { status: 'ticked', body: lines.join('\n'), line: ticked, count: 1 }; 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,
};
}
/** /**
* 在指定段落裡就地更新(或補上)一行「前綴+值」。 * 在指定段落裡就地更新(或補上)一行「前綴+值」。
* *
+2 -2
View File
@@ -47,7 +47,7 @@ main(async () => {
} }
const login = resolveLogin({ host: flags.host }); const login = resolveLogin({ host: flags.host });
await preflight(login, repo); const { user } = await preflight(login, repo);
const issue = await fetchIssue(login, repo, index); const issue = await fetchIssue(login, repo, index);
const sections = parseSections(issue.body); const sections = parseSections(issue.body);
@@ -66,7 +66,7 @@ main(async () => {
驗收標準: listSection(sections, '驗收標準'), 驗收標準: listSection(sections, '驗收標準'),
影響範圍: listSection(sections, '影響範圍'), 影響範圍: listSection(sections, '影響範圍'),
未決事項: listSection(sections, '未決事項'), 未決事項: listSection(sections, '未決事項'),
未處理留言數: await countUnmergedComments(login, repo, index), 未處理留言數: await countUnmergedComments(login, repo, index, user.login),
}; };
}); });
+61 -14
View File
@@ -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 不讀留言,這個數字是下游判斷「手上的描述是不是過期了」的唯一依據。 * 抽取契約只讀 body 不讀留言,這個數字是下游判斷「手上的描述是不是過期了」的唯一依據。
* 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記),而 Gitea 的留言物件 * 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記)。留言多時請求數會跟著長,
* 不含 reaction,所以只能逐則再查一次。留言多時請求數會跟著長,但這個數字要準 * 但這個數字要準——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完,
* ——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完,讀不完寧可報錯。 * 讀不完寧可報錯。
* *
* @param {{base: string, token: string}} login * @param {{base: string, token: string}} login
* @param {string} repo owner/name * @param {string} repo owner/name
* @param {number} index * @param {number} index
* @param {string} me 目前登入帳號:只有自己打的 `+1` 才算整併過
* @returns {Promise<number>} * @returns {Promise<number>}
*/ */
export async function countUnmergedComments(login, repo, index) { export async function countUnmergedComments(login, repo, index, me) {
const commentsPath = `/repos/${repo}/issues/${index}/comments`;
let unmerged = 0; let unmerged = 0;
for await (const comments of pages(login, commentsPath, { for await (const comment of listIssueComments(login, repo, index)) {
limitCode: 'COMMENT_LIMIT', if (!(await mergedByMe(login, repo, comment.id, me))) unmerged += 1;
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;
}
} }
return unmerged; 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
View File
@@ -1,9 +1,14 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* 讀 PR 上的三類留言:一般留言、review 總評、行內留言。 * 讀 PR 或議題上的留言。
* *
* 三類分散在三個端點,漏掉任何一類就會有 reviewer 的意見沒被處理——而那正是 * PR 有三類:一般留言、review 總評、行內留言,分散在三個端點——漏掉任何一類就會有
* `/sdlc-fix` 存在的理由。這一支只讀不寫,分類(必改/建議)由讀到內容的人判斷。 * reviewer 的意見沒被處理,而那正是 `/sdlc-fix` 存在的理由。
* 純議題只有一般留言,`/sdlc-sync` 要的就是那一份。
*
* **每個 PR 都是議題,但議題不一定是 PR。** 所以先讀 `/issues/{index}`(兩種都有),
* 看它有沒有 `pull_request` 才決定要不要去翻 review;反過來先打 `/pulls/{index}`,
* 對純議題會 404,整個流程在讀到第一則留言之前就斷了。
* *
* 讀取本身與「已處理」的判定在 `pr-threads.js`——`pr-watch` 要數同一件事, * 讀取本身與「已處理」的判定在 `pr-threads.js`——`pr-watch` 要數同一件事,
* 規則寫兩份遲早會各自演化。 * 規則寫兩份遲早會各自演化。
@@ -12,8 +17,7 @@
* node scripts/pr-comments.js --repo owner/name --index 45 [--host <網址>] [--dry-run] * node scripts/pr-comments.js --repo owner/name --index 45 [--host <網址>] [--dry-run]
*/ */
import { import {
expectOk, fetchIssue,
giteaRequest,
main, main,
parseFlags, parseFlags,
parseIndex, parseIndex,
@@ -22,9 +26,9 @@ import {
resolveLogin, resolveLogin,
} from './lib.js'; } from './lib.js';
import { import {
COMMENT_REQUEST_NOTE, ISSUE_OR_PULL_NOTE,
fetchPull, commonRequests,
plannedRequests, readGeneralComments,
readPullComments, readPullComments,
unhandledCount, unhandledCount,
} from './pr-threads.js'; } from './pr-threads.js';
@@ -43,24 +47,29 @@ main(async () => {
dryRun: true, dryRun: true,
repo, repo,
index, index,
requests: plannedRequests(repo, index), requests: commonRequests(repo, index),
note: COMMENT_REQUEST_NOTE, note: ISSUE_OR_PULL_NOTE,
}; };
} }
const login = resolveLogin({ host: flags.host }); 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; // 先讀議題:PR 也是議題,反過來不成立。這一步同時決定要不要去翻 review
const pull = await fetchPull(login, repo, index); const issue = await fetchIssue(login, repo, index);
const 留言 = await readPullComments(login, repo, index, me); const isPull = issue.pull_request != null;
const 留言 = isPull
? await readPullComments(login, repo, index, me)
: await readGeneralComments(login, repo, index, me);
return { return {
repo, repo,
index: pull.number, index: issue.number,
title: pull.title, 類型: isPull ? 'PR' : '議題',
url: pull.html_url, title: issue.title,
state: pull.state, url: issue.html_url,
state: issue.state,
留言, 留言,
未處理數: unhandledCount(留言), 未處理數: unhandledCount(留言),
}; };
+2 -8
View File
@@ -25,7 +25,6 @@
* --body-file <描述檔> --index 13 * --body-file <描述檔> --index 13
* [--issue-repo owner/name] [--host <網址>] [--dry-run] * [--issue-repo owner/name] [--host <網址>] [--dry-run]
*/ */
import { existsSync, readFileSync } from 'node:fs';
import { import {
ScriptError, ScriptError,
expectOk, expectOk,
@@ -35,6 +34,7 @@ import {
parseIndex, parseIndex,
parseRepo, parseRepo,
preflight, preflight,
readTextFile,
resolveLogin, resolveLogin,
} from './lib.js'; } from './lib.js';
@@ -87,7 +87,7 @@ main(async () => {
const head = flags.head; const head = flags.head;
const base = flags.base; const base = flags.base;
const index = parseIndex(flags.index); const index = parseIndex(flags.index);
const body = readBody(flags['body-file']); const body = readTextFile(flags['body-file'], '--body-file');
// 描述先驗完再談寫入:不合格的描述不該等到實跑才發現 // 描述先驗完再談寫入:不合格的描述不該等到實跑才發現
checkSections(body); 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 一致 */ /** 八個段落一個都不能少,而且順序要與 SECTIONS 一致 */
function checkSections(body) { function checkSections(body) {
+30 -26
View File
@@ -7,7 +7,8 @@
* 才會被發現。 * 才會被發現。
* *
* 「已處理」在三類上的機制不同: * 「已處理」在三類上的機制不同:
* - 一般留言、review 總評 → 自己打的 `+1` reaction * - 一般留言、review 總評 → 自己打的 `+1` reaction(判定在 `lib.js` 的 mergedByMe,
* 抽取契約數未整併則數時用的是同一條規則)
* - 行內留言 → 有沒有被 resolve(只有 review comment 有 resolve 端點) * - 行內留言 → 有沒有被 resolve(只有 review comment 有 resolve 端點)
* *
* **總評的 reaction 掛在它的 issue comment id 上,不是 review id。** Gitea 的 review * **總評的 reaction 掛在它的 issue comment id 上,不是 review id。** Gitea 的 review
@@ -17,7 +18,7 @@
* reaction 要是**自己**打的才算已處理:reviewer 對留言按讚是「我同意」,不是 * reaction 要是**自己**打的才算已處理:reviewer 對留言按讚是「我同意」,不是
* 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。 * 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。
*/ */
import { ScriptError, expectOk, giteaRequest, pages } from './lib.js'; import { ScriptError, expectOk, giteaRequest, listIssueComments, mergedByMe, pages } from './lib.js';
/** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */ /** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */
const DRAFT = 'PENDING'; const DRAFT = 'PENDING';
@@ -33,7 +34,7 @@ const DRAFT = 'PENDING';
export async function readPullComments(login, repo, index, me) { export async function readPullComments(login, repo, index, me) {
const pullPath = `/repos/${repo}/pulls/${index}`; const pullPath = `/repos/${repo}/pulls/${index}`;
return [ return [
...(await readGeneral(login, repo, index, me)), ...(await readGeneralComments(login, repo, index, me)),
...(await readReviews(login, repo, index, pullPath, 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 同進退——說明的是它發出的請求。 * `plannedRequests` 列不完的那部分。與 readPullComments 同進退——說明的是它發出的請求。
*/ */
@@ -83,29 +103,25 @@ export async function fetchPull(login, repo, index) {
} }
/** /**
* 一般留言。PR 在 Gitea 裡也是 issue,所以走 issue 的留言端點。 * 一般留言。PR 在 Gitea 裡也是 issue,所以走 issue 的留言端點——
* 純議題也只有這一類,`/sdlc-sync` 要的就是它。
* 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。 * 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。
*/ */
async function readGeneral(login, repo, index, me) { export async function readGeneralComments(login, repo, index, me) {
const path = `/repos/${repo}/issues/${index}/comments`;
const 留言 = []; const 留言 = [];
for await (const comments of pages(login, path, { for await (const comment of listIssueComments(login, repo, index)) {
limitCode: 'COMMENT_LIMIT',
limitHint: `${path} 的留言太多,讀不完整份清單`,
})) {
for (const comment of comments) {
if ((comment.body ?? '').trim() === '') continue; if ((comment.body ?? '').trim() === '') continue;
留言.push({ 留言.push({
id: comment.id, id: comment.id,
review: null,
類型: '一般', 類型: '一般',
作者: comment.user?.login ?? '', 作者: comment.user?.login ?? '',
內容: comment.body, 內容: comment.body,
已處理: await markedByMe(login, repo, comment.id, me), 已處理: await mergedByMe(login, repo, comment.id, me),
可標記: true, 可標記: true,
}); });
} }
}
return 留言; return 留言;
} }
@@ -136,7 +152,7 @@ async function readReviews(login, repo, index, pullPath, me) {
作者: review.user?.login ?? '', 作者: review.user?.login ?? '',
內容: review.body, 內容: review.body,
// 找不到它在 issue comment 表裡的那一份就標不了——那時如實說,不要假裝可以 // 找不到它在 issue comment 表裡的那一份就標不了——那時如實說,不要假裝可以
已處理: commentId === undefined ? false : await markedByMe(login, repo, commentId, me), 已處理: commentId === undefined ? false : await mergedByMe(login, repo, commentId, me),
可標記: commentId !== undefined, 可標記: commentId !== undefined,
}); });
} }
@@ -199,15 +215,3 @@ async function reviewCommentIds(login, repo, index) {
return ids; 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);
}
+2 -2
View File
@@ -64,7 +64,7 @@ main(async () => {
} }
const login = resolveLogin({ host: flags.host }); const login = resolveLogin({ host: flags.host });
await preflight(login, repo); const { user } = await preflight(login, repo);
const issue = await fetchIssue(login, repo, index); const issue = await fetchIssue(login, repo, index);
const sections = parseSections(issue.body); const sections = parseSections(issue.body);
@@ -88,7 +88,7 @@ main(async () => {
相依: { blocks, depends }, 相依: { blocks, depends },
assignee: issue.assignee?.login ?? null, assignee: issue.assignee?.login ?? null,
碼錶中: await hasRunningStopwatch(login, repo, index), 碼錶中: await hasRunningStopwatch(login, repo, index),
未處理留言數: await countUnmergedComments(login, repo, index), 未處理留言數: await countUnmergedComments(login, repo, index, user.login),
}; };
}); });
+335
View File
@@ -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`],
);
});
+1 -1
View File
@@ -85,7 +85,7 @@ function routes(overrides = {}, { body = FULL_BODY, comments = [] } = {}) {
comments.forEach((c, i) => { comments.forEach((c, i) => {
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = { base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
status: 200, status: 200,
body: (c.reactions ?? []).map((content) => ({ content })), body: (c.reactions ?? []).map((content) => ({ content, user: { login: c.reactedBy ?? 'tester' } })),
}; };
}); });
return { ...base, ...overrides }; return { ...base, ...overrides };
+39 -8
View File
@@ -25,10 +25,14 @@ function routes(overrides = {}, options = {}) {
general = [], general = [],
reviews = [], reviews = [],
pull = { number: INDEX, title: 'feat/pr-comments/main', html_url: `https://x/${INDEX}`, state: 'open' }, pull = { number: INDEX, title: 'feat/pr-comments/main', html_url: `https://x/${INDEX}`, state: 'open' },
isPull = true,
} = options; } = options;
const base = healthyRoutes(REPO, { 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`]: { [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
status: 200, status: 200,
body: general.map((c, i) => ({ body: general.map((c, i) => ({
@@ -357,18 +361,48 @@ test('帶出 PR 的識別資訊,讓回報不必再查一次', async (t) => {
assert.equal(json.data.state, 'open'); assert.equal(json.data.state, 'open');
}); });
test('PR 不存在時回可區分的錯誤碼', async (t) => { test('議題不存在時回可區分的錯誤碼', async (t) => {
const stub = await withStub(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); }, FULL);
const { code, json } = await run([], stub); const { code, json } = await run([], stub);
assert.equal(code, 1); 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))); 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 ───────────────────────────────────────────────────── // ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => { test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
@@ -381,11 +415,8 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
assert.deepEqual( assert.deepEqual(
json.data.requests.map((r) => `${r.method} ${r.path}`), json.data.requests.map((r) => `${r.method} ${r.path}`),
[ [
'GET /user', `GET /repos/${REPO}/issues/${INDEX}`,
`GET /repos/${REPO}/pulls/${INDEX}`,
`GET /repos/${REPO}/issues/${INDEX}/comments`, `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/); assert.match(json.data.note, /reaction|review/);
+1 -1
View File
@@ -391,7 +391,7 @@ test('描述檔不存在時回可區分的錯誤碼', async (t) => {
stub, stub,
); );
assert.equal(json.error.code, 'BODY_FILE_NOT_FOUND'); assert.equal(json.error.code, 'FILE_NOT_FOUND');
}); });
// ── --dry-run ───────────────────────────────────────────────────── // ── --dry-run ─────────────────────────────────────────────────────
+151
View File
@@ -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), /重新抽取一次拿到更新後的描述/);
});
}
+1 -1
View File
@@ -106,7 +106,7 @@ function routes(overrides = {}, options = {}) {
comments.forEach((c, i) => { comments.forEach((c, i) => {
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = { base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
status: 200, status: 200,
body: (c.reactions ?? []).map((content) => ({ content })), body: (c.reactions ?? []).map((content) => ({ content, user: { login: c.reactedBy ?? 'tester' } })),
}; };
}); });
return { ...base, ...overrides }; return { ...base, ...overrides };