From 09d880e6f272530c1e32d12c2511d49f0684b51f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 18:16:23 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(wp-list):=20=E5=88=97=E5=87=BA?= =?UTF-8?q?=E4=B8=80=E9=A1=86=E9=9C=80=E6=B1=82=E8=AD=B0=E9=A1=8C=E5=BA=95?= =?UTF-8?q?=E4=B8=8B=E7=9A=84=E5=B7=A5=E4=BD=9C=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 歸屬判準沿用工作包抽取那一套(關聯段落的「需求議題:#N」),不另發明一套; PR 的描述也有那一行,所以先擋掉 PR。清單逐頁讀完,讀不完寧可報錯—— 使用者會從缺了幾顆的清單裡挑,而且看不出缺的是哪幾顆。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/wp-list.js | 87 ++++++++++++++++++ test/wp-list.test.js | 206 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 scripts/wp-list.js create mode 100644 test/wp-list.test.js diff --git a/scripts/wp-list.js b/scripts/wp-list.js new file mode 100644 index 0000000..88cf842 --- /dev/null +++ b/scripts/wp-list.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * 列出一顆需求議題底下的工作包。 + * + * 使用者手上常常只有需求議題的編號——留言發在那裡、討論也在那裡——但真正要動手的 + * 單位是工作包。這一支把「哪幾顆工作包掛在這顆需求底下」答出來,讓他從清單裡挑一顆, + * 而不是自己去 Gitea 網頁上翻。 + * + * **判準沿用工作包抽取那一套**:關聯段落裡的 `需求議題:#<編號>`(見 wp-extract 的 + * `需求議題` 欄位,解析同樣走 issue-body 的 referencedIndex)。不另發明判準——標籤、 + * 標題前綴、相依關係都各有各的用途,拿它們當歸屬會與抽取契約分岔。 + * + * **PR 不算工作包。** 每個 PR 都是議題,而 pr-create 產出的 PR 描述本來就有 + * 「需求議題:#N」那一行,只看 body 會把 PR 混進清單裡。 + * + * 清單逐頁讀完,讀不完寧可報錯:半份清單會讓使用者從缺了幾顆的清單裡挑, + * 而且他看不出來缺的是哪幾顆。 + * + * 用法: + * node scripts/wp-list.js --repo owner/name --requirement 7 [--host <網址>] [--dry-run] + */ +import { + main, + pages, + parseFlags, + parseIndex, + parseRepo, + preflight, + resolveLogin, +} from './lib.js'; +import { parseSections, referencedIndex } from './issue-body.js'; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'requirement'], + optional: ['host'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const requirement = parseIndex(flags.requirement, '--requirement'); + const issuesPath = `/repos/${repo}/issues`; + + if (flags['dry-run']) { + return { + dryRun: true, + repo, + 需求議題: requirement, + requests: [{ method: 'GET', path: issuesPath }], + note: '議題清單逐頁讀完,頁數取決於 repo 的議題總數,事前無法列舉。', + }; + } + + const login = resolveLogin({ host: flags.host }); + await preflight(login, repo); + + const 工作包 = []; + for await (const issues of pages(login, issuesPath, { + query: { state: 'all' }, + limitCode: 'WORK_PACKAGE_LIMIT', + limitHint: `翻不完 ${repo} 的議題,列不出 #${requirement} 底下的完整工作包清單;請直接在 Gitea 上確認`, + })) { + for (const issue of issues) { + if (!belongsTo(issue, requirement)) continue; + 工作包.push({ + index: issue.number, + title: issue.title, + url: issue.html_url, + state: issue.state, + assignee: issue.assignee?.login ?? null, + }); + } + } + // 依編號排序:Gitea 的回傳順序會隨排序設定而變,清單的順序卻是使用者挑選的依據 + 工作包.sort((a, b) => a.index - b.index); + + return { repo, 需求議題: requirement, 工作包, 數量: 工作包.length }; +}); + + +/** + * 這顆議題是不是掛在指定需求底下的工作包。 + * PR 先擋掉——它的描述也有「需求議題:#N」那一行,但它不是工作包。 + */ +function belongsTo(issue, requirement) { + if (issue.pull_request != null) return false; + return referencedIndex(parseSections(issue.body), '關聯', '需求議題') === requirement; +} diff --git a/test/wp-list.test.js b/test/wp-list.test.js new file mode 100644 index 0000000..cce5c74 --- /dev/null +++ b/test/wp-list.test.js @@ -0,0 +1,206 @@ +/** + * 需求議題底下的工作包清單。 + * + * 這一支存在的理由只有一個:使用者手上有一顆需求議題編號,要挑出底下的某一顆工作包。 + * 所以測試集中在「挑得對不對」:認的是不是工作包抽取那一套關聯判準、PR 會不會混進來、 + * 清單讀不讀得完。半份清單最危險——使用者會從缺了幾顆的清單裡挑,而且看不出缺了誰。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runScript } from './helpers/run-script.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; +const REQUIREMENT = 7; + +/** 一顆工作包的 body:判準只在「關聯」段落那一行 */ +function wpBody(requirement) { + return `## 這個工作包在做什麼 + +一句話。 + +## 待辦 + +- [ ] 做一件事 + +## 關聯 + +需求議題:#${requirement} +估算人天:2 +`; +} + +function issue(index, { title = `工作包 ${index}`, body = wpBody(REQUIREMENT), ...rest } = {}) { + return { + number: index, + title, + body, + state: 'open', + html_url: `https://gitea.example/${REPO}/issues/${index}`, + assignee: null, + ...rest, + }; +} + +/** 假 Gitea:議題清單逐頁回,其餘走 healthy 預設 */ +function routes(issues, overrides = {}) { + return healthyRoutes(REPO, { + [`GET /api/v1/repos/${REPO}/issues`]: (req) => { + const page = Number(req.query.page ?? 1); + const limit = Number(req.query.limit ?? 50); + return { status: 200, body: issues.slice((page - 1) * limit, page * limit) }; + }, + ...overrides, + }); +} + +async function run(t, issues, args = []) { + const stub = await withStubGitea(t, routes(issues)); + const result = await runScript( + 'wp-list.js', + ['--repo', REPO, '--requirement', String(REQUIREMENT), ...args], + { env: envFor(stub) }, + ); + return { stub, ...result }; +} + +// ── 挑出哪幾顆 ───────────────────────────────────────────────────── + +test('只收關聯指回這顆需求議題的工作包', async (t) => { + const { code, json } = await run(t, [ + issue(9), + issue(10, { body: wpBody(99) }), + issue(11), + ]); + + assert.equal(code, 0); + assert.deepEqual( + json.data.工作包.map((wp) => wp.index), + [9, 11], + ); + assert.equal(json.data.數量, 2); + assert.equal(json.data.需求議題, REQUIREMENT); +}); + +test('沒有關聯段落的議題不算工作包', async (t) => { + const { json } = await run(t, [issue(9), issue(12, { body: '## 總覽\n\n這是一顆需求議題。\n' })]); + + assert.deepEqual(json.data.工作包.map((wp) => wp.index), [9]); +}); + +test('PR 不進清單,即使它的描述也寫了需求議題', async (t) => { + // pr-create 產出的 PR 描述本來就有「需求議題:#N」那一行,光看 body 會把 PR 當成工作包 + const { json } = await run(t, [ + issue(9), + issue(30, { title: 'feat: 某某', pull_request: { merged: false } }), + ]); + + assert.deepEqual(json.data.工作包.map((wp) => wp.index), [9]); +}); + +test('已關閉的工作包照樣列出,並帶上狀態與領取人', async (t) => { + const { json } = await run(t, [ + issue(9, { state: 'closed', assignee: { login: 'jeffery' } }), + ]); + + assert.deepEqual(json.data.工作包, [ + { + index: 9, + title: '工作包 9', + url: `https://gitea.example/${REPO}/issues/9`, + state: 'closed', + assignee: 'jeffery', + }, + ]); +}); + +test('依編號由小到大排,清單的順序不隨 Gitea 回傳順序飄動', async (t) => { + const { json } = await run(t, [issue(14), issue(9), issue(11)]); + + assert.deepEqual(json.data.工作包.map((wp) => wp.index), [9, 11, 14]); +}); + +test('一顆都沒有時回空清單而不是報錯', async (t) => { + const { code, json } = await run(t, [issue(10, { body: wpBody(99) })]); + + assert.equal(code, 0); + assert.deepEqual(json.data.工作包, []); + assert.equal(json.data.數量, 0); +}); + +test('逐頁讀完,不只讀第一頁', async (t) => { + const many = Array.from({ length: 60 }, (_, i) => issue(i + 1)); + + const { json, stub } = await run(t, many); + + assert.equal(json.data.數量, 60); + const listings = stub.requests.filter((r) => r.path === `/api/v1/repos/${REPO}/issues`); + assert.ok(listings.length >= 2, `要翻到第二頁,實際只發了 ${listings.length} 次`); +}); + +test('讀的是全部狀態的議題,不只 open', async (t) => { + const { stub } = await run(t, [issue(9)]); + + const listing = stub.requests.find((r) => r.path === `/api/v1/repos/${REPO}/issues`); + assert.equal(listing.query.state, 'all'); +}); + +// ── 契約 ─────────────────────────────────────────────────────────── + +test('--dry-run 列出將發出的請求,且完全不碰 Gitea', async (t) => { + const stub = await withStubGitea(t, routes([issue(9)])); + + const { code, json } = await runScript( + 'wp-list.js', + ['--repo', REPO, '--requirement', String(REQUIREMENT), '--dry-run'], + { env: envFor(stub) }, + ); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual(json.data.requests, [ + { method: 'GET', path: `/repos/${REPO}/issues` }, + ]); + assert.equal(stub.requests.length, 0); +}); + +test('缺 --requirement 時指名缺的是哪一個', async (t) => { + const stub = await withStubGitea(t, routes([])); + + const { json } = await runScript('wp-list.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.equal(json.error.code, 'MISSING_FLAG'); + assert.match(json.error.message, /--requirement/); +}); + +test('--requirement 不是正整數時擋下', async (t) => { + const stub = await withStubGitea(t, routes([])); + + const { json } = await runScript('wp-list.js', ['--repo', REPO, '--requirement', 'abc'], { + env: envFor(stub), + }); + + assert.equal(json.error.code, 'BAD_INDEX'); +}); + +test('議題多到翻不完時報錯,不回半份清單', async (t) => { + // 每一頁都回滿頁,永遠不會結束:翻到上限就要大聲說讀不完 + const stub = await withStubGitea( + t, + healthyRoutes(REPO, { + [`GET /api/v1/repos/${REPO}/issues`]: (req) => ({ + status: 200, + body: Array.from({ length: Number(req.query.limit ?? 50) }, (_, i) => issue(i + 1)), + }), + }), + ); + + const { code, json } = await runScript( + 'wp-list.js', + ['--repo', REPO, '--requirement', String(REQUIREMENT)], + { env: envFor(stub) }, + ); + + assert.equal(code, 1); + assert.equal(json.error.code, 'WORK_PACKAGE_LIMIT'); +}); -- 2.53.0 From a5f36ed14c4a559fd2442c71eeb495b20dfec292 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 18:16:23 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(sdlc-fix):=20=E8=BC=B8=E5=85=A5?= =?UTF-8?q?=E6=94=BE=E5=AF=AC=E7=82=BA=20PR=20=E7=B7=A8=E8=99=9F=E6=88=96?= =?UTF-8?q?=E8=AD=B0=E9=A1=8C=E7=B7=A8=E8=99=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 議題不自己重做一遍實作流程,而是交棒給 /sdlc-feat:工作包議題直接交棒, 需求議題先讓 /sdlc-sync 整併決策留言,剩下確實要改碼的才列出底下的工作包 讓使用者挑一顆。交棒複用 sdlc-sync 已經定好的接回機制,使用者不必重打指令。 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- prompts/sdlc-fix.md | 99 +++++++++++++++++++++++++++--- prompts/sdlc-sync.md | 4 +- test/sdlc-fix-assets.test.js | 114 ++++++++++++++++++++++++++++++++--- 4 files changed, 200 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b881154..abb2f1d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ | `/sdlc-plan` | 把一段口語需求變成結構化的需求議題 | | `/sdlc-analyze` | 逐題把可行性疑點問到共識,據以產出工作包 | | `/sdlc-feat` | 領取工作包、開分支、逐項實作並開 PR | -| `/sdlc-fix` | 處理 PR 上的留言 | +| `/sdlc-fix` | 處理 PR 上的留言;收到議題編號則交棒給 `/sdlc-feat` | | `/sdlc-sync` | 把散落在留言裡的決策整併回議題描述 | | `/sdlc-report` | 產出週/月/年工時報表 | diff --git a/prompts/sdlc-fix.md b/prompts/sdlc-fix.md index a4cf06c..bf8563a 100644 --- a/prompts/sdlc-fix.md +++ b/prompts/sdlc-fix.md @@ -1,17 +1,95 @@ name: sdlc-fix -description: 僅由 /sdlc-fix 指令叫用。定位工作包的工作樹,讀取 PR 上的三類留言,逐條處理並回覆,最後輸出修正摘要。 +description: 僅由 /sdlc-fix 指令叫用。收 PR 或議題編號:PR 就定位工作樹、逐條處理三類留言並回覆;議題則交棒給 /sdlc-feat。 # sdlc-fix reviewer 留完意見,跑這一段,意見被逐條處理並回覆,不漏掉任何一則。 +**意見不一定發在 PR 上。** 常常是發在工作包議題或需求議題的留言裡:使用者手上只有一個 +議題編號,看得到有人說「這裡要改」。所以輸入收得下三種東西,但只有 PR 在這裡處理完—— +議題交棒給 `/sdlc-feat`,不在這裡重做一遍它的流程。 + 這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。 ## 輸入 -一個 PR 編號。 +一個 PR 編號或議題編號。 -## 1. 看 PR 現況,並定位工作樹 +## 1. 判定輸入是哪一種 + +三種輸入走三條不同的路,先問清楚是哪一種再動手。**判定沿用既有契約,不另發明判準**—— +標籤、標題前綴、編號區間都猜得出來,但猜錯的代價是整條路走錯。 + +先讀一次留言。它議題與 PR 都收得下,`類型` 欄位就是 PR 與議題的分界: + +``` +node scripts/pr-comments.js --repo --index <編號> +``` + +**每個 PR 都是議題,反過來不成立**,所以這個問題只能從議題那一端問:先打 PR 的端點, +遇到純議題會 404,在讀到第一則留言之前就斷了。 + +`類型` 是 `議題` 時再問一次它是哪一種議題: + +``` +node scripts/wp-extract.js --repo --index <編號> +``` + +**`需求議題` 欄位(工作包的母議題)解析得出編號的就是工作包議題,解析不出來的就當需求 +議題。** 這是工作包抽取契約本來就有的欄位,`/sdlc-sync` 分這兩種議題用的也是同一份抽取。 + +| 判定 | 下一步 | +| --- | --- | +| `類型` 為 `PR` | 第 3 步起,這份正本後面每一步都是 PR 的路 | +| `類型` 為 `議題`,抽得出 `需求議題` | 第 2 步的「工作包議題」 | +| `類型` 為 `議題`,抽不出 `需求議題` | 第 2 步的「需求議題」 | + +## 2. 議題:交棒給 /sdlc-feat + +### 工作包議題 + +那一顆工作包該怎麼做,`/sdlc-feat`(`prompts/sdlc-feat.md`)已經從領取、備妥工作樹、 +逐項實作一路定到開出 PR。**照那份正本走,把這個編號當成它的輸入。** + +**不要把它的步驟搬過來重講一遍**:那會製造第二份正本,兩邊遲早分岔——而使用者不會知道 +自己讀到的是哪一份。 + +交棒沿用 `/sdlc-sync` 那一套接回機制,只是方向相反:**直接接下去做,不要求使用者重打 +指令**。他已經給過這個編號了。 + +留言的整併不必在這裡先做——`/sdlc-feat` 的第一步就會看 `未處理留言數`,該整併時它自己 +會轉去 `/sdlc-sync`。在這裡先做一次,等於把那一步也抄了過來。 + +### 需求議題 + +需求議題上的留言**絕大多數是決策討論**,不是「這一行要改」。所以先整併,再談改碼: + +1. 第 1 步的 `未處理數` 大於 0 就**走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`), + 做完**自動接回這裡**——重新讀一次留言,拿到的才是剛整併過的狀態。同樣不要求使用者重打指令。 +2. 整併(與使用者選擇略過)之後還剩下的留言裡,挑出**確實要求改程式碼**的那幾則。判斷 + 依據與第 5 步的「必改/建議」同一套:指出了錯誤、遺漏、會出事的寫法,或明確要求改動。 +3. **一則都沒有就到此為止。** 把整併了幾則、略過幾則講清楚,說明沒有要改碼的意見, + 然後停下來。先 sync 一次通常就清空了,選工作包那一步根本不會觸發。 + +確實有要改的,就列出這顆需求底下的工作包,讓使用者挑一顆: + +``` +node scripts/wp-list.js --repo --requirement <需求議題編號> +``` + +**不要因為「輸入不是工作包」就報錯。** 挑哪一顆他無論如何都要挑,報錯只是把這件事推回去 +讓他自己在 Gitea 網頁上翻。 + +一次問一題,選項是清單上的工作包(帶編號、標題、狀態、領取人),外加**手動輸入**—— +清單以「關聯」段落認歸屬,漏掉的那一顆他自己給得出編號。附上你的判斷:哪一顆的範圍 +涵蓋得到那幾則留言說的地方,以及為什麼。 + +清單是空的就照實說:這顆需求底下還沒有工作包,該跑的是 `/sdlc-analyze`,不是這一支。 + +挑定之後**交棒給 `/sdlc-feat`**,與上一小節同一條路。**把那幾則要求改碼的留言一起帶過去**, +它們是這一輪要做的事;留言本身留在需求議題上不動,交棒不搬走任何人說過的話。 + +## 3. 看 PR 現況,並定位工作樹 先問一次現況,再決定要不要動手: @@ -56,12 +134,14 @@ node scripts/worktree-ensure.js --repo --path <目標專案路徑> **後面每一步都在那棵工作樹裡做**,不要回到主工作區:它可能停在別的分支上,在那裡改 會把改動落到別顆工作包的分支去。 -## 2. 讀留言 +## 4. 讀留言 ``` node scripts/pr-comments.js --repo --index ``` +第 1 步判定型別時讀的就是這一份,**手上那一份還在就直接用,不必再讀一次**。 + 三類留言一次讀齊:**一般留言**、**review 總評**、**行內留言**。每一則都帶 `id`、`作者`、 `內容`、`已處理`,行內的還帶 `檔案`/`行`/`diff`——那段 diff 是判斷「他在說哪裡」的依據, 不要略過不看。 @@ -76,7 +156,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按 偶爾會遇到 `可標記` 是 `false` 的總評(在 timeline 上對不到它在 issue comment 表裡的 那一份)。那一則回覆照發,但沒有記號留得下來,**要在修正摘要裡單獨點出來**。 -## 3. 分類:必改還是建議 +## 5. 分類:必改還是建議 逐則判斷,**reviewer 不必逐則說明**。判斷依據是內容本身: @@ -87,7 +167,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按 分類錯的代價不對稱——把必改當成建議會漏掉真的問題,所以拿不準時歸到必改那一邊, 並在下一步問清楚。 -## 4. 不確定就問 +## 6. 不確定就問 **一次問一題。** 下列情況不要自作主張: @@ -102,7 +182,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按 **不確定卻硬改,比多問一題貴得多。** 改壞的地方 reviewer 下一輪才會看到。 -## 5. 逐則處理並回覆 +## 7. 逐則處理並回覆 一則一則來:先改,改完立刻回覆那一則,再處理下一則。**不要全部改完才一起回**—— 中途斷掉的話,沒有人知道哪幾則已經處理過。 @@ -126,7 +206,7 @@ node scripts/pr-reply.js --repo --index \ 不必自己去翻 diff。決定不改的也要回,並說明理由——建議類的留言常常合理地不採納, 但沉默會讓 reviewer 以為被忽略了。 -## 6. 修正摘要 +## 8. 修正摘要 全部處理完後印一則摘要,讓 reviewer 不必逐串點開: @@ -149,3 +229,6 @@ node scripts/pr-reply.js --repo --index \ - 不動 PR 的 review 狀態:回覆一則意見不該順手把整個 PR 標成通過或要求變更。 - **不在主工作區處理留言**,一律在 `worktree-ensure` 定位出來的那棵工作樹裡。 - PR 已經合併或關閉時不繼續處理留言,也不自己去刪推導路徑上的東西。 +- **不重做 `/sdlc-feat` 的步驟,也不把它的步驟抄進這份正本**:議題一律交棒過去。 +- 不因為輸入是議題就報錯要使用者改打別的指令,也不要求他把交棒過的指令重打一次。 +- 不替使用者決定要在哪一顆工作包上改:列出清單,讓他挑。 diff --git a/prompts/sdlc-sync.md b/prompts/sdlc-sync.md index 60242d8..291e388 100644 --- a/prompts/sdlc-sync.md +++ b/prompts/sdlc-sync.md @@ -89,8 +89,8 @@ node scripts/comments-merge.js --repo --index <編號> \ ## 接回原本的指令 -這個指令常常不是使用者自己叫的,而是 `/sdlc-analyze` 或 `/sdlc-feat` 發現有未整併留言後 -轉過來的。**整併完就直接接回去**,從原本那個指令被打斷的地方繼續,不要要求使用者重打一次。 +這個指令常常不是使用者自己叫的,而是 `/sdlc-analyze`、`/sdlc-feat` 或 `/sdlc-fix` 發現有 +未整併留言後轉過來的。**整併完就直接接回去**,從原本那個指令被打斷的地方繼續,不要要求使用者重打一次。 接回去之前先重跑一次抽取(`issue-extract`/`wp-extract`),拿到的才是剛更新過的描述—— 接著用舊的那一份做事,這一整段就白做了。 diff --git a/test/sdlc-fix-assets.test.js b/test/sdlc-fix-assets.test.js index 33fcb98..86f819a 100644 --- a/test/sdlc-fix-assets.test.js +++ b/test/sdlc-fix-assets.test.js @@ -1,9 +1,9 @@ /** * /sdlc-fix 的流程正本。 * - * 這一段有三件事只有正本做得到,腳本擋不住:分類必改/建議、不確定時停下來問、 - * 以及最後那則摘要。寫漏任何一件,reviewer 的意見就會被靜靜跳過——而那正是這個指令 - * 存在的理由。 + * 這一段有四件事只有正本做得到,腳本擋不住:把三種輸入判到對的路上、分類必改/建議、 + * 不確定時停下來問、以及最後那則摘要。寫漏任何一件,reviewer 的意見就會被靜靜跳過—— + * 而那正是這個指令存在的理由。 */ import test from 'node:test'; import assert from 'node:assert/strict'; @@ -11,10 +11,108 @@ import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js'; const prompt = readPrompt('sdlc-fix'); const steps = prompt.slice(prompt.indexOf('## 1.'), prompt.indexOf('## 邊界')); -/** 定位那一步,與後面處理留言的步驟分開看 */ -const locate = steps.slice(0, steps.indexOf('## 2.')); -test('第一步先看 PR 現況,終止狀態就停下來不白做工', () => { +/** 框出一個步驟的範圍,讓各步驟分開看 */ +const step = (from, to) => steps.slice(steps.indexOf(from), steps.indexOf(to)); + +/** 判定輸入是哪一種 */ +const 判定 = step('## 1.', '## 2.'); +/** 議題交棒那一步 */ +const 交棒 = step('## 2.', '## 3.'); +/** 工作包議題與需求議題各自的小節 */ +const 工作包路 = 交棒.slice(交棒.indexOf('### 工作包議題'), 交棒.indexOf('### 需求議題')); +const 需求路 = 交棒.slice(交棒.indexOf('### 需求議題')); +/** 定位工作樹那一步,與後面處理留言的步驟分開看 */ +const locate = step('## 3.', '## 4.'); + +// ── 輸入判定:三種輸入走三條路 ───────────────────────────────────── + +test('輸入接受 PR 編號或議題編號', () => { + const 輸入 = prompt.slice(prompt.indexOf('## 輸入'), prompt.indexOf('## 1.')); + assert.match(輸入, /PR 編號或議題編號/); +}); + +test('PR 與議題的分界用既有的留言讀取契約,不另發明判準', () => { + assert.match(判定, /pr-comments\.js/); + assert.match(判定, /類型/); + assert.match(判定, /每個 PR 都是議題/, '要說出為什麼只能從議題那一端問'); + assert.match(判定, /不另發明判準/); +}); + +test('兩種議題的分界是工作包抽取的母議題欄位', () => { + assert.match(判定, /wp-extract\.js/); + assert.match(判定, /需求議題.*母議題|母議題/); + assert.match(判定, /解析得出編號的就是工作包議題/); + assert.match(判定, /解析不出來的就當需求\s*議題/); +}); + +test('三種判定各自寫明下一步,不留一種讓 agent 自由發揮', () => { + const rows = 判定.slice(判定.indexOf('| 判定')); + assert.match(rows, /`類型` 為 `PR`/); + assert.match(rows, /抽得出 `需求議題`/); + assert.match(rows, /抽不出 `需求議題`/); +}); + +// ── 議題:交棒給 /sdlc-feat ──────────────────────────────────────── + +test('工作包議題交棒給 /sdlc-feat,指的是它的正本而不是複述它', () => { + assert.match(工作包路, /sdlc-feat/); + assert.match(工作包路, /prompts\/sdlc-feat\.md/); + assert.match(工作包路, /不要把它的步驟搬過來重講一遍/); + assert.match(工作包路, /第二份正本/, '要說出為什麼不抄'); +}); + +test('交棒不要求使用者重打指令,並指名沿用既有的接回機制', () => { + assert.match(交棒, /sdlc-sync/); + assert.match(交棒, /接回|接下去/); + assert.match(交棒, /不要求使用者重打\s*指令/); +}); + +test('工作包議題不在這裡先整併留言:那一步 /sdlc-feat 自己會做', () => { + assert.match(工作包路, /未處理留言數/); + assert.match(工作包路, /不必在這裡先做/); +}); + +test('正本裡不出現 /sdlc-feat 步驟的複製', () => { + for (const 腳本 of ['claim.js', 'branch-prep.js', 'timer.js', 'commit-split.js', 'pr-create.js']) { + assert.equal(prompt.includes(腳本), false, `這是 /sdlc-feat 的步驟,不該被抄進來:${腳本}`); + } +}); + +test('需求議題先讓 /sdlc-sync 整併,再談改不改碼', () => { + assert.match(需求路, /sdlc-sync/); + assert.match(需求路, /prompts\/sdlc-sync\.md/); + assert.match(需求路, /絕大多數是決策討論/, '要說出為什麼先 sync'); + assert.ok( + 需求路.indexOf('sdlc-sync') < 需求路.indexOf('wp-list.js'), + '整併要排在選工作包之前', + ); +}); + +test('整併完沒有改碼要求就停下來,不硬找一顆工作包來改', () => { + assert.match(需求路, /一則都沒有就到此為止/); + assert.match(需求路, /根本不會觸發/); +}); + +test('有改碼要求時列出底下的工作包讓使用者挑,不報錯把事推回去', () => { + assert.match(需求路, /wp-list\.js/); + assert.match(需求路, /--requirement/); + assert.match(需求路, /不要因為「輸入不是工作包」就報錯/); + assert.match(需求路, /一次問一題/); + assert.match(需求路, /手動輸入/); +}); + +test('清單為空時照實說,並指出該跑的是哪一支', () => { + assert.match(需求路, /清單是空的/); + assert.match(需求路, /sdlc-analyze/); +}); + +test('挑定之後一樣交棒給 /sdlc-feat,留言只帶脈絡不搬走', () => { + assert.match(需求路, /交棒給 `\/sdlc-feat`/); + assert.match(需求路, /不搬走/); +}); + +test('PR 的第一步先看現況,終止狀態就停下來不白做工', () => { assert.match(locate, /pr-watch\.js/); assert.match(locate, /terminal/); assert.match(locate, /不要繼續處理留言/); @@ -118,7 +216,7 @@ test('一次問一題,選項含手動輸入', () => { }); test('該問的情況有列舉,不是一句「不確定就問」', () => { - const section = steps.slice(steps.indexOf('## 4.'), steps.indexOf('## 5.')); + const section = steps.slice(steps.indexOf('## 6.'), steps.indexOf('## 7.')); const bullets = section.match(/^- /gm) ?? []; assert.ok(bullets.length >= 3, `該問的情況要列得出來,只找到 ${bullets.length} 條`); assert.match(section, /推了新 commit/, '位置對不上是最常見的一種,要點名'); @@ -159,7 +257,7 @@ test('寫入前要求先試跑', () => { // ── 摘要 ─────────────────────────────────────────────────────────── test('摘要要逐則列出,並單獨點出沒有記號的那幾則', () => { - const section = steps.slice(steps.indexOf('## 6.')); + const section = steps.slice(steps.indexOf('## 8.')); assert.match(section, /逐則一行/); assert.match(section, /沒有留下記號的那幾則/); assert.match(section, /以為它們被跳過/, '要說出為什麼得單獨列'); -- 2.53.0 From 03ce38222043febfcedf78c90ffeb9810529ff96 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 18:19:33 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor(issue-body):=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=85=E7=9A=84=E6=AD=B8=E5=B1=AC=E5=88=A4=E6=BA=96=E6=94=B6?= =?UTF-8?q?=E6=88=90=E4=B8=80=E5=80=8B=E5=87=BD=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wp-extract 與 wp-list 都在問「這顆工作包掛在哪顆需求底下」。規則寫兩份, 某天只會有一邊被改到,而分岔的樣子是「清單裡看得到、抽取卻說不是」。 順手把測試裡兩種取段落的寫法統一,並刪掉沒有人傳過的參數。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/issue-body.js | 13 +++++++++++++ scripts/wp-extract.js | 4 ++-- scripts/wp-list.js | 10 +++++----- test/sdlc-fix-assets.test.js | 4 ++-- test/wp-list.test.js | 4 ++-- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/scripts/issue-body.js b/scripts/issue-body.js index 280e745..15ef9a3 100644 --- a/scripts/issue-body.js +++ b/scripts/issue-body.js @@ -182,6 +182,19 @@ export function referencedIndex(sections, name, label) { return null; } +/** + * 工作包掛在哪一顆需求議題底下:關聯段落的 `需求議題:#7`。 + * + * 這是「這顆議題是不是工作包、屬於誰」的唯一判準,抽取(wp-extract)與清單(wp-list) + * 共用同一個函式。兩邊各寫一次也跑得起來,但歸屬規則一旦有兩份,某天只會有一邊被改到, + * 而分岔的樣子是「清單裡看得到、抽取卻說不是」——那種不一致沒有人看得懂。 + * @param {Map} sections + * @returns {number|null} 沒填或不是工作包時為 null + */ +export function requirementIndex(sections) { + return referencedIndex(sections, '關聯', '需求議題'); +} + /** * 在段落裡找出「標籤:數字」那一行的數字,例如關聯段落的 `估算人天:3`。 * 與 referencedIndex 同形狀,差別只在這裡要的是數量而非議題編號,所以認小數。 diff --git a/scripts/wp-extract.js b/scripts/wp-extract.js index d96a0fc..5486510 100644 --- a/scripts/wp-extract.js +++ b/scripts/wp-extract.js @@ -29,7 +29,7 @@ import { checklistInSection, listSection, parseSections, - referencedIndex, + requirementIndex, tableRows, textSection, } from './issue-body.js'; @@ -77,7 +77,7 @@ main(async () => { index: issue.number, url: issue.html_url, title: issue.title, - 需求議題: referencedIndex(sections, '關聯', '需求議題'), + 需求議題: requirementIndex(sections), 描述: textSection(sections, '描述'), 架構圖: textSection(sections, '架構圖'), 範圍邊界: listSection(sections, '範圍邊界'), diff --git a/scripts/wp-list.js b/scripts/wp-list.js index 88cf842..53b67e6 100644 --- a/scripts/wp-list.js +++ b/scripts/wp-list.js @@ -6,9 +6,9 @@ * 單位是工作包。這一支把「哪幾顆工作包掛在這顆需求底下」答出來,讓他從清單裡挑一顆, * 而不是自己去 Gitea 網頁上翻。 * - * **判準沿用工作包抽取那一套**:關聯段落裡的 `需求議題:#<編號>`(見 wp-extract 的 - * `需求議題` 欄位,解析同樣走 issue-body 的 referencedIndex)。不另發明判準——標籤、 - * 標題前綴、相依關係都各有各的用途,拿它們當歸屬會與抽取契約分岔。 + * **判準沿用工作包抽取那一套**:關聯段落裡的 `需求議題:#<編號>`。判準與 wp-extract 的 + * `需求議題` 欄位共用 issue-body 的 requirementIndex,不是各寫一份長得像的解析—— + * 標籤、標題前綴、相依關係都當得了歸屬判準,但各發明一套就會與抽取契約分岔。 * * **PR 不算工作包。** 每個 PR 都是議題,而 pr-create 產出的 PR 描述本來就有 * 「需求議題:#N」那一行,只看 body 會把 PR 混進清單裡。 @@ -28,7 +28,7 @@ import { preflight, resolveLogin, } from './lib.js'; -import { parseSections, referencedIndex } from './issue-body.js'; +import { parseSections, requirementIndex } from './issue-body.js'; main(async () => { const flags = parseFlags(process.argv.slice(2), { @@ -83,5 +83,5 @@ main(async () => { */ function belongsTo(issue, requirement) { if (issue.pull_request != null) return false; - return referencedIndex(parseSections(issue.body), '關聯', '需求議題') === requirement; + return requirementIndex(parseSections(issue.body)) === requirement; } diff --git a/test/sdlc-fix-assets.test.js b/test/sdlc-fix-assets.test.js index 86f819a..ec93c03 100644 --- a/test/sdlc-fix-assets.test.js +++ b/test/sdlc-fix-assets.test.js @@ -216,7 +216,7 @@ test('一次問一題,選項含手動輸入', () => { }); test('該問的情況有列舉,不是一句「不確定就問」', () => { - const section = steps.slice(steps.indexOf('## 6.'), steps.indexOf('## 7.')); + const section = step('## 6.', '## 7.'); const bullets = section.match(/^- /gm) ?? []; assert.ok(bullets.length >= 3, `該問的情況要列得出來,只找到 ${bullets.length} 條`); assert.match(section, /推了新 commit/, '位置對不上是最常見的一種,要點名'); @@ -257,7 +257,7 @@ test('寫入前要求先試跑', () => { // ── 摘要 ─────────────────────────────────────────────────────────── test('摘要要逐則列出,並單獨點出沒有記號的那幾則', () => { - const section = steps.slice(steps.indexOf('## 8.')); + const section = steps.slice(steps.indexOf('## 8.')); // 最後一步,到結尾為止 assert.match(section, /逐則一行/); assert.match(section, /沒有留下記號的那幾則/); assert.match(section, /以為它們被跳過/, '要說出為什麼得單獨列'); diff --git a/test/wp-list.test.js b/test/wp-list.test.js index cce5c74..aec4c7e 100644 --- a/test/wp-list.test.js +++ b/test/wp-list.test.js @@ -54,11 +54,11 @@ function routes(issues, overrides = {}) { }); } -async function run(t, issues, args = []) { +async function run(t, issues) { const stub = await withStubGitea(t, routes(issues)); const result = await runScript( 'wp-list.js', - ['--repo', REPO, '--requirement', String(REQUIREMENT), ...args], + ['--repo', REPO, '--requirement', String(REQUIREMENT)], { env: envFor(stub) }, ); return { stub, ...result }; -- 2.53.0 From a27959123270d6603154333f239bec8a3f1b46a5 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 18:21:36 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(sdlc-fix):=20=E9=9C=80=E6=B1=82?= =?UTF-8?q?=E8=AD=B0=E9=A1=8C=E6=9C=AC=E4=BE=86=E5=B0=B1=E6=B2=92=E6=9C=89?= =?UTF-8?q?=E6=9C=AA=E6=95=B4=E4=BD=B5=E7=95=99=E8=A8=80=E6=99=82=E7=85=A7?= =?UTF-8?q?=E6=A8=A3=E5=88=97=E5=87=BA=E5=B7=A5=E4=BD=9C=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原本只寫「未處理數大於 0 就先整併」,零則的入口從沒明寫,讀起來會變成 「沒有留言要處理就到此為止」,正好與「無未整併留言時要列出工作包」相反。 另把交棒的另一端補在 sdlc-feat 的輸入上——機制只有一邊寫得出來, 另一邊就只是散文承諾。 Co-Authored-By: Claude Opus 5 (1M context) --- prompts/sdlc-feat.md | 3 ++- prompts/sdlc-fix.md | 8 +++++--- test/sdlc-feat-assets.test.js | 8 ++++++++ test/sdlc-fix-assets.test.js | 10 ++++++++-- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/prompts/sdlc-feat.md b/prompts/sdlc-feat.md index 721b350..8083f5c 100644 --- a/prompts/sdlc-feat.md +++ b/prompts/sdlc-feat.md @@ -16,7 +16,8 @@ description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、備妥工 ## 輸入 -一個工作包議題編號。 +一個工作包議題編號。使用者直接給的,或 `/sdlc-fix` 收到議題後交棒過來的—— +兩者一樣處理,**不要因為是交棒來的就要求他再打一次指令**。 ## 第一段:領取與開工準備 diff --git a/prompts/sdlc-fix.md b/prompts/sdlc-fix.md index bf8563a..157a6b6 100644 --- a/prompts/sdlc-fix.md +++ b/prompts/sdlc-fix.md @@ -66,12 +66,14 @@ node scripts/wp-extract.js --repo --index <編號> 1. 第 1 步的 `未處理數` 大於 0 就**走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`), 做完**自動接回這裡**——重新讀一次留言,拿到的才是剛整併過的狀態。同樣不要求使用者重打指令。 + **`未處理數` 本來就是 0 的話這一步整個跳過**,直接往下選工作包:沒有留言要整併不表示 + 沒有事要做,使用者是帶著「要改什麼」來的。 2. 整併(與使用者選擇略過)之後還剩下的留言裡,挑出**確實要求改程式碼**的那幾則。判斷 依據與第 5 步的「必改/建議」同一套:指出了錯誤、遺漏、會出事的寫法,或明確要求改動。 -3. **一則都沒有就到此為止。** 把整併了幾則、略過幾則講清楚,說明沒有要改碼的意見, - 然後停下來。先 sync 一次通常就清空了,選工作包那一步根本不會觸發。 +3. **本來有留言,而整併完一則要改碼的都不剩,就到此為止。** 把整併了幾則、略過幾則講清楚, + 說明沒有要改碼的意見,然後停下來。先 sync 一次通常就清空了,選工作包那一步根本不會觸發。 -確實有要改的,就列出這顆需求底下的工作包,讓使用者挑一顆: +確實有要改的(或一開始就沒有留言要整併),就列出這顆需求底下的工作包,讓使用者挑一顆: ``` node scripts/wp-list.js --repo --requirement <需求議題編號> diff --git a/test/sdlc-feat-assets.test.js b/test/sdlc-feat-assets.test.js index 1908876..85ff915 100644 --- a/test/sdlc-feat-assets.test.js +++ b/test/sdlc-feat-assets.test.js @@ -14,6 +14,14 @@ const phase1 = prompt.slice(prompt.indexOf('## 第一段'), prompt.indexOf('## const phase2 = prompt.slice(prompt.indexOf('## 第二段'), prompt.indexOf('## 第三段')); const phase3 = prompt.slice(prompt.indexOf('## 第三段'), prompt.indexOf('## 邊界')); + +test('輸入收得下 /sdlc-fix 交棒過來的編號,且不要求使用者重打指令', () => { + const 輸入 = prompt.slice(prompt.indexOf('## 輸入'), prompt.indexOf('## 第一段')); + assert.match(輸入, /sdlc-fix/); + assert.match(輸入, /交棒/); + assert.match(輸入, /不要因為是交棒來的就要求他再打一次指令/); +}); + test('正本平台中立,description 前綴正確', () => { assertNeutralPrompt(prompt, 'sdlc-feat'); }); diff --git a/test/sdlc-fix-assets.test.js b/test/sdlc-fix-assets.test.js index ec93c03..0a57ef0 100644 --- a/test/sdlc-fix-assets.test.js +++ b/test/sdlc-fix-assets.test.js @@ -89,11 +89,17 @@ test('需求議題先讓 /sdlc-sync 整併,再談改不改碼', () => { ); }); -test('整併完沒有改碼要求就停下來,不硬找一顆工作包來改', () => { - assert.match(需求路, /一則都沒有就到此為止/); +test('本來有留言、整併完沒有改碼要求就停下來,不硬找一顆工作包來改', () => { + assert.match(需求路, /本來有留言,而整併完一則要改碼的都不剩,就到此為止/); assert.match(需求路, /根本不會觸發/); }); +test('本來就沒有未整併留言時照樣列出工作包,不當成沒事可做', () => { + assert.match(需求路, /`未處理數` 本來就是 0/); + assert.match(需求路, /這一步整個跳過/); + assert.match(需求路, /或一開始就沒有留言要整併/, '列清單的入口要同時收得下這一種'); +}); + test('有改碼要求時列出底下的工作包讓使用者挑,不報錯把事推回去', () => { assert.match(需求路, /wp-list\.js/); assert.match(需求路, /--requirement/); -- 2.53.0