From ee474d04392d3ca7b72db762d1a0e01a3f6e5615 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 09:19:05 +0000 Subject: [PATCH 01/10] =?UTF-8?q?refactor(lib):=20=E8=AE=80=E6=AA=94?= =?UTF-8?q?=E8=88=87=E5=88=97=E7=95=99=E8=A8=80=E6=94=B6=E9=80=B2=20lib?= =?UTF-8?q?=EF=BC=8C=E4=B8=A6=E8=AE=93=E5=B7=B2=E6=95=B4=E4=BD=B5=E5=8F=AA?= =?UTF-8?q?=E8=AA=8D=E8=87=AA=E5=B7=B1=E6=89=93=E7=9A=84=20+1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「讀一個 --xxx-file 或直接失敗」原本在四支腳本各寫一份,錯誤碼還有三種拼法 (BODY_FILE_NOT_FOUND/BODY_FILE_MISSING/CONTENT_FILE_NOT_FOUND)。同一種情況要有同一個 碼,呼叫端才分辨得出是哪一步壞了。留言分頁的那段咒語也是第三份,一併收成 listIssueComments。 countUnmergedComments 原本接受任何人的 +1,而讀留言那邊只認自己的——兩端對「已整併」的 定義不一致。後果是隊友對決策留言按個讚,未處理留言數就掉到 0,analyze 與 feat 再也不提示, 那則決策永遠不會被收進描述。統一成只認自己打的:別人按讚是「我同意」,不是「已經收進去了」。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/lib.js | 75 +++++++++++++++++++++++++++++++++++--------- scripts/pr-create.js | 10 ++---- 2 files changed, 63 insertions(+), 22 deletions(-) diff --git a/scripts/lib.js b/scripts/lib.js index ca2fb8b..bc97400 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -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} */ -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} 一則一則交出去 + */ +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); +} + // ── 碼錶 ─────────────────────────────────────────────────────────── /** diff --git a/scripts/pr-create.js b/scripts/pr-create.js index c256f1b..a9dc353 100644 --- a/scripts/pr-create.js +++ b/scripts/pr-create.js @@ -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) { From b263915afc5b0467984f3351140bef09e00c4518 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 09:19:05 +0000 Subject: [PATCH 02/10] =?UTF-8?q?test(pr-create):=20=E8=AE=80=E6=AA=94?= =?UTF-8?q?=E8=88=87=E5=88=97=E7=95=99=E8=A8=80=E6=94=B6=E9=80=B2=20lib?= =?UTF-8?q?=EF=BC=8C=E4=B8=A6=E8=AE=93=E5=B7=B2=E6=95=B4=E4=BD=B5=E5=8F=AA?= =?UTF-8?q?=E8=AA=8D=E8=87=AA=E5=B7=B1=E6=89=93=E7=9A=84=20+1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- test/pr-create.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pr-create.test.js b/test/pr-create.test.js index a5dce61..a598c1d 100644 --- a/test/pr-create.test.js +++ b/test/pr-create.test.js @@ -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 ───────────────────────────────────────────────────── From 051fe0ded4c83be0ab46a968ac2e9ccd05996047 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 09:19:06 +0000 Subject: [PATCH 03/10] =?UTF-8?q?fix(pr-comments):=20=E8=AD=B0=E9=A1=8C?= =?UTF-8?q?=E4=B9=9F=E8=AE=80=E5=BE=97=E4=BA=86=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=85=88=E6=89=93=20PR=20=E7=AB=AF=E9=BB=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每個 PR 都是議題,議題不一定是 PR——原本的註解把這句話講反了,程式也照著反過來寫: 先打 /pulls/{index},對純議題回 404,於是 /sdlc-sync 在讀到第一則留言之前就斷了。 改成先讀 /issues/{index}(兩種都有),看它有沒有 pull_request 才決定要不要去翻 review。 輸出加上「類型」讓下游知道拿到的是議題還是 PR。 讀取的共用結構沿用 pr-threads(#48 為了讓 pr-watch 數同一件事而抽出來的):readGeneral 改名 readGeneralComments 並導出,純議題只叫它;pr-threads 自己那份 markedByMe 拿掉, 改用 lib 的 mergedByMe。#48 的檔頭擔心「規則寫兩份遲早會各自演化」,現在三處共用一份。 試跑改印 commonRequests:pr-comments 收得下兩種輸入,而試跑階段還沒讀過議題、不知道是 哪一種。與其假設是 PR 而列出五個(對純議題有三個根本不會發),不如只列一定會發的。 pr-watch 的輸入一定是 PR,繼續用 plannedRequests。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/pr-comments.js | 45 +++++++++++++++----------- scripts/pr-threads.js | 72 ++++++++++++++++++++++-------------------- 2 files changed, 65 insertions(+), 52 deletions(-) diff --git a/scripts/pr-comments.js b/scripts/pr-comments.js index f327a58..7b74475 100644 --- a/scripts/pr-comments.js +++ b/scripts/pr-comments.js @@ -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(留言), }; diff --git a/scripts/pr-threads.js b/scripts/pr-threads.js index b7b96a6..2120d80 100644 --- a/scripts/pr-threads.js +++ b/scripts/pr-threads.js @@ -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); -} From 3b7e3bdfc7db13e329eb0d05bbb13839189bbbd3 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 09:19:07 +0000 Subject: [PATCH 04/10] =?UTF-8?q?test(pr-comments):=20=E8=AD=B0=E9=A1=8C?= =?UTF-8?q?=E4=B9=9F=E8=AE=80=E5=BE=97=E4=BA=86=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=85=88=E6=89=93=20PR=20=E7=AB=AF=E9=BB=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- test/pr-comments.test.js | 47 +++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/test/pr-comments.test.js b/test/pr-comments.test.js index 7456048..8997312 100644 --- a/test/pr-comments.test.js +++ b/test/pr-comments.test.js @@ -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/); From c1ab712afd064a1555f7db9428e81e5684bb83a5 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 09:19:08 +0000 Subject: [PATCH 05/10] =?UTF-8?q?feat(=E8=AD=B0=E9=A1=8C=E8=A7=A3=E6=9E=90?= =?UTF-8?q?):=20=E6=8F=9B=E6=8E=89=E4=B8=80=E5=80=8B=E6=AE=B5=E8=90=BD?= =?UTF-8?q?=E7=9A=84=E5=85=A7=E5=AE=B9=EF=BC=8C=E6=A8=99=E9=A1=8C=E8=88=87?= =?UTF-8?q?=E5=85=B6=E9=A4=98=E6=AE=B5=E8=90=BD=E4=B8=80=E5=AD=97=E4=B8=8D?= =?UTF-8?q?=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 整併留言裡的決策時用它。不整份重寫的理由跟 upsertLineInSection 一樣,只是代價更大: 重寫會把別人在其他段落的編輯一起蓋掉,而議題的編輯紀錄沒有人會去比對。 三件事照著同檔既有的規矩做:圍欄裡的假標題不算段落;同名標題出現不只一次時交回 ambiguous 而不賭第一個(蓋掉的是一整段,猜錯的代價比 tickLine 更高);換行沿用 body 原本的那一種, CRLF 的 body 裡混進 LF 會讓抽取契約交出的 raw 對不上原文,之後就勾不動那幾行。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/issue-body.js | 59 ++++++++++++++++++++++++++++++++++++++++ scripts/issue-extract.js | 4 +-- scripts/wp-extract.js | 4 +-- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/scripts/issue-body.js b/scripts/issue-body.js index 0777eb7..280e745 100644 --- a/scripts/issue-body.js +++ b/scripts/issue-body.js @@ -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, + }; +} + /** * 在指定段落裡就地更新(或補上)一行「前綴+值」。 * diff --git a/scripts/issue-extract.js b/scripts/issue-extract.js index 8306eac..89e5bc5 100644 --- a/scripts/issue-extract.js +++ b/scripts/issue-extract.js @@ -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), }; }); diff --git a/scripts/wp-extract.js b/scripts/wp-extract.js index eb31ee1..d96a0fc 100644 --- a/scripts/wp-extract.js +++ b/scripts/wp-extract.js @@ -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), }; }); From 5ef25d3ee9226246a637722aa42a71ca90e26e2c Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 09:19:08 +0000 Subject: [PATCH 06/10] =?UTF-8?q?test(=E6=8A=BD=E5=8F=96=E5=A5=91=E7=B4=84?= =?UTF-8?q?):=20=E6=9C=AA=E6=95=B4=E4=BD=B5=E7=9A=84=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E6=94=B9=E6=88=90=E5=8F=AA=E8=AA=8D=E8=87=AA=E5=B7=B1=E6=89=93?= =?UTF-8?q?=E7=9A=84=20+1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- test/issue-extract.test.js | 2 +- test/wp-extract.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/issue-extract.test.js b/test/issue-extract.test.js index 0d65c74..2aa0d37 100644 --- a/test/issue-extract.test.js +++ b/test/issue-extract.test.js @@ -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 }; diff --git a/test/wp-extract.test.js b/test/wp-extract.test.js index b0634ab..faf4927 100644 --- a/test/wp-extract.test.js +++ b/test/wp-extract.test.js @@ -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 }; From 16f93f6e8b55942222660aafc52792c1faad73c0 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 09:19:09 +0000 Subject: [PATCH 07/10] =?UTF-8?q?feat(comments-merge):=20=E6=8A=8A?= =?UTF-8?q?=E7=95=99=E8=A8=80=E8=A3=A1=E7=9A=84=E6=B1=BA=E7=AD=96=E6=95=B4?= =?UTF-8?q?=E4=BD=B5=E5=9B=9E=E8=AD=B0=E9=A1=8C=E6=8F=8F=E8=BF=B0=E4=B8=A6?= =?UTF-8?q?=E6=A8=99=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 判斷「哪幾則有決策、該併進哪一段」是讀得懂內容的人的事;這一支只負責把結果安全地寫回去。 先寫描述再標記,描述寫失敗就不標記:反過來的話,那幾則已經被標成處理過,再也不會被提出來。 --merged 只收真的併進去的那幾則,略過的保持未標記。標記之前先核對那幾則確實在這顆議題上—— 打錯 id 的 reaction 會落在別顆議題的留言上,而那幾乎不會有人發現。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/comments-merge.js | 141 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 scripts/comments-merge.js diff --git a/scripts/comments-merge.js b/scripts/comments-merge.js new file mode 100644 index 0000000..795781a --- /dev/null +++ b/scripts/comments-merge.js @@ -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)', + ); + } +} From 3ad08be14fbd214de8d0d2a9b54115b4408649ac Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 09:19:10 +0000 Subject: [PATCH 08/10] =?UTF-8?q?test(comments-merge):=20=E6=8A=8A?= =?UTF-8?q?=E7=95=99=E8=A8=80=E8=A3=A1=E7=9A=84=E6=B1=BA=E7=AD=96=E6=95=B4?= =?UTF-8?q?=E4=BD=B5=E5=9B=9E=E8=AD=B0=E9=A1=8C=E6=8F=8F=E8=BF=B0=E4=B8=A6?= =?UTF-8?q?=E6=A8=99=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- test/comments-merge.test.js | 335 ++++++++++++++++++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 test/comments-merge.test.js diff --git a/test/comments-merge.test.js b/test/comments-merge.test.js new file mode 100644 index 0000000..9a14cd9 --- /dev/null +++ b/test/comments-merge.test.js @@ -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`], + ); +}); From f85fabb7582ceaa1e35b285c415323bb39661cf6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 09:19:10 +0000 Subject: [PATCH 09/10] =?UTF-8?q?feat(=E6=B5=81=E7=A8=8B=E6=AD=A3=E6=9C=AC?= =?UTF-8?q?):=20=E6=96=B0=E5=A2=9E=20sdlc-sync=EF=BC=8C=E4=B8=A6=E8=AE=93?= =?UTF-8?q?=E5=8F=A6=E5=A4=96=E5=85=A9=E4=BB=BD=E6=AD=A3=E6=9C=AC=E6=95=B4?= =?UTF-8?q?=E4=BD=B5=E5=AE=8C=E8=87=AA=E5=8B=95=E6=8E=A5=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 挑出哪些留言真的是決策、寫回去之前讓使用者點頭、略過的保持未標記——三件都只有正本做得到。 analyze 與 feat 原本寫「建議先執行 /sdlc-sync,再回來」,那等於要使用者重打指令。改成直接走 sync 的流程、做完自動接回,並在接回前重新抽取一次——接著用舊的那一份做事,這一整段就白做了。 略過的留言會讓未處理留言數停在大於 0,於是 analyze 與 feat 每次都會再停一次。這是驗收標準 本身的兩條放在一起的結果,正本把它講明白,讓使用者分得出「沒整併乾淨」與「我選了略過」。 Co-Authored-By: Claude Opus 5 (1M context) --- prompts/sdlc-analyze.md | 9 ++-- prompts/sdlc-feat.md | 9 ++-- prompts/sdlc-sync.md | 104 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 prompts/sdlc-sync.md diff --git a/prompts/sdlc-analyze.md b/prompts/sdlc-analyze.md index 7608888..8b97dd7 100644 --- a/prompts/sdlc-analyze.md +++ b/prompts/sdlc-analyze.md @@ -26,9 +26,12 @@ node scripts/issue-extract.js --repo --index <編號> 拿到的是結構化欄位,不必再讀整份議題全文。 **先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被 -整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,建議先執行 `/sdlc-sync` -把它們整併回描述,再回來做分析。使用者堅持要繼續就繼續,但要記下這件事, -並在共識摘要裡註明「分析基於未整併留言前的描述」。 +整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,問他要不要現在整併。 + +要整併的話**直接走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`),做完**自動接回這裡**: +重新抽取一次拿到更新後的描述,再往下走。**不要要求使用者重打指令**——他已經說要整併了。 + +使用者選擇不整併就繼續,但要記下這件事,並在共識摘要裡註明「分析基於未整併留言前的描述」。 ### 2. 對四份清單列出疑點 diff --git a/prompts/sdlc-feat.md b/prompts/sdlc-feat.md index c870dee..721b350 100644 --- a/prompts/sdlc-feat.md +++ b/prompts/sdlc-feat.md @@ -30,9 +30,12 @@ node scripts/wp-extract.js --repo --index <編號> 不必再讀整份議題全文。 **先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被 -整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,建議先執行 `/sdlc-sync` -把它們整併回描述,再回來實作。使用者堅持要繼續就繼續,但要記下這件事, -並在最後的 PR 描述裡註明「實作基於未整併留言前的描述」。 +整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,問他要不要現在整併。 + +要整併的話**直接走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`),做完**自動接回這裡**: +重新抽取一次拿到更新後的描述,再往下走。**不要要求使用者重打指令**——他已經說要整併了。 + +使用者選擇不整併就繼續,但要記下這件事,並在最後的 PR 描述裡註明「實作基於未整併留言前的描述」。 **再看 `相依.depends`。** 裡面還有沒關閉的議題,代表這顆的前置還沒做完。照樣先說出來, 讓使用者決定要不要現在做。 diff --git a/prompts/sdlc-sync.md b/prompts/sdlc-sync.md new file mode 100644 index 0000000..60242d8 --- /dev/null +++ b/prompts/sdlc-sync.md @@ -0,0 +1,104 @@ +name: sdlc-sync +description: 僅由 /sdlc-sync 指令叫用。把議題留言裡的決策整併回議題描述,並標記已整併的留言。 + +# sdlc-sync + +新加入的人不必爬完整串留言,就能從議題描述知道現況。 + +這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。 + +## 輸入 + +一個議題編號。可能是需求議題,也可能是工作包議題。 + +## 1. 讀議題與留言 + +需求議題用 `issue-extract`,工作包議題用 `wp-extract`: + +``` +node scripts/issue-extract.js --repo --index <編號> +``` + +兩支都會給 `未處理留言數`。**那是這一輪要看的量**;已經標記過的留言不再處理。 + +接著讀留言本身。抽取契約只給數字不給內容,所以留言要另外拿: + +``` +node scripts/pr-comments.js --repo --index <編號> +``` + +議題與 PR 都收得下:它先讀議題本身,是 PR 才會再去翻 review。純議題的 `類型` 是 `議題`, +留言的 `類型` 都是 `一般`。`已處理` 為 `true` 的跳過——那是前幾輪整併過的。 + +`已處理` 認的是**自己打的** `+1`。別人按讚是「我同意」,不是「這則已經收進描述了」。 + +## 2. 挑出真正的決策 + +**不是每一則留言都要整併。** 逐則判斷它有沒有改變「這顆議題現在說的事」: + +- **要整併** — 改變了目標、範圍、做法、驗收標準;補上了原本沒寫的限制;推翻了描述裡的假設。 +- **不整併** — 提問與答覆、進度回報、「收到」、與內容無關的討論、已經反映在描述裡的事。 + +判斷不了的**當成要整併**,然後在下一步問使用者——漏掉一個決策,描述就會繼續騙後面的人。 + +## 3. 提出整併方案,讓使用者點頭 + +**先列出來再動手。** 對每一則要整併的留言,說明: + +- 它說了什麼(一句話) +- 要併進**哪一段**(`總覽`/`目標`/`非目標`/`驗收標準`/`未決事項`…) +- 那一段**改完長什麼樣** + +然後一次問一題,兩個選項: + +- **整併** — 照你提的方案寫回去。 +- **略過** — 這一則不併。**略過的留言保持未標記**,下次執行還會被提出來。 + +使用者要改你的寫法時,照他說的改。這一步是議題描述的最後一道關卡——寫進去之後, +後面的人就是拿它當事實。 + +## 4. 逐段寫回 + +一段一段來。同一段有多則留言的決策就先合併成一份內容,一次寫回: + +``` +node scripts/comments-merge.js --repo --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`),拿到的才是剛更新過的描述—— +接著用舊的那一份做事,這一整段就白做了。 + +## 邊界 + +- 不自行決定要不要整併:方案一定先給使用者看過。 +- 不整份重寫描述,只換談好的那幾段。 +- 不標記略過的留言。 +- 不刪除、不編輯任何留言——留言是誰說過什麼的紀錄,整併是把結論抄進描述,不是把原文搬走。 +- 不因為整併而改變議題的狀態、標籤或指派。 From ceb723c63e5c6e383046f1018623b29e94a90b61 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 09:19:11 +0000 Subject: [PATCH 10/10] =?UTF-8?q?test(=E6=B5=81=E7=A8=8B=E6=AD=A3=E6=9C=AC?= =?UTF-8?q?):=20=E6=96=B0=E5=A2=9E=20sdlc-sync=EF=BC=8C=E4=B8=A6=E8=AE=93?= =?UTF-8?q?=E5=8F=A6=E5=A4=96=E5=85=A9=E4=BB=BD=E6=AD=A3=E6=9C=AC=E6=95=B4?= =?UTF-8?q?=E4=BD=B5=E5=AE=8C=E8=87=AA=E5=8B=95=E6=8E=A5=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- test/sdlc-sync-assets.test.js | 151 ++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 test/sdlc-sync-assets.test.js diff --git a/test/sdlc-sync-assets.test.js b/test/sdlc-sync-assets.test.js new file mode 100644 index 0000000..e71711b --- /dev/null +++ b/test/sdlc-sync-assets.test.js @@ -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), /重新抽取一次拿到更新後的描述/); + }); +}