diff --git a/prompts/sdlc-plan.md b/prompts/sdlc-plan.md new file mode 100644 index 0000000..661e2f5 --- /dev/null +++ b/prompts/sdlc-plan.md @@ -0,0 +1,97 @@ +name: sdlc-plan +description: 僅由 /sdlc-plan 指令叫用。把一段口語需求轉成結構化的需求議題,寫入 Gitea。 + +# sdlc-plan + +把使用者給的一段需求,變成一顆結構完整、下游指令讀得動的需求議題。 + +這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。 + +## 輸入 + +使用者給的東西可能是下列任一種,也可能三種混用: + +- **自由文字** — 一段口語描述。 +- **規格檔** — 一個檔案路徑,內容是既有的規格或筆記。 +- **議題編號** — 既有議題的編號,用來補充脈絡或作為延伸的起點。 + +先把三種來源讀齊,再開始問問題。規格檔用檔案讀取工具讀;議題編號用 +`scripts/issue-extract.js` 取(若該腳本尚未可用,改用 `scripts/issue-create.js` 以外的 +既有讀取途徑,並在摘要中註明資料來源)。 + +## 步驟 + +### 1. 讀齊輸入,列出還缺什麼 + +把九個段落逐一對照使用者給的材料,列出哪些段落已經有依據、哪些沒有。 + +### 2. 逐項詢問 + +**一次問一題**,等使用者回答完再問下一題,讓他能看著前一題的答案回答下一題。 + +每一題都附上你的建議與理由,讓使用者多數時候只要點頭;同時保留讓他自己寫答案的餘地。 + +**未獲得答覆的欄位不得自行編造。** 使用者沒說過的目標、沒提過的驗收標準,一個字都不能自己 +填。問不到就放進「未決事項」,那一段本來就是給未決的東西用的。 + +### 3. 組出議題內容 + +套用 `templates/requirement-issue.md`,依序填滿九個段落: + +1. **總覽** — 一句話講完這件事在做什麼,讓非技術的利害關係人不必讀完技術細節。圖解版總覽的 + 連結此時先留空,由後續流程回填。 +2. **背景** — 不超過三行。為什麼現在要做這件事。 +3. **目標** — 可量測。寫得出「怎樣算達成」才算數。 +4. **非目標** — 明列這次不做什麼,用來抵抗範圍蔓延。 +5. **領域名詞表** — 這份需求裡會反覆出現的詞,各給一行定義,讓團隊對同一個詞的理解一致。 +6. **流程圖** — 見下方「流程圖的限制」。 +7. **驗收標準** — 逐條列出,每一條都要能被驗證。 +8. **影響範圍** — 會動到哪些 repo、哪些既有功能。 +9. **未決事項** — 問不到答案、或需要他人拍板的事。 + +### 4. 挑標籤 + +先用 `scripts/labels-list.js --repo ` 取得該 repo 的既有標籤,**只能從這份清單裡 +挑**。找不到合適的就不貼。**不得自行建立新標籤** —— 標籤體系由專案維護者決定,不該在多個 +repo 之間長出雜草。 + +### 5. 先試跑,再寫入 + +把組好的內容寫到一個暫存檔,然後: + +``` +node scripts/issue-create.js --repo --title "<標題>" --body-file <暫存檔> \ + --labels "<標籤1,標籤2>" --dry-run +``` + +`--dry-run` 會印出將要送出的請求而不真的寫入。確認無誤後拿掉該旗標再跑一次。 + +同一段需求重跑不會產生第二顆議題:`issue-create` 以標題查重,發現同名議題就回傳既有那一顆 +並把 `created` 設為 `false`。 + +### 6. 回報 + +把議題編號與網址告訴使用者。不要把整份議題內容再貼一次 —— 連結點進去就看得到。 + +## 流程圖的限制 + +用 Mermaid 的 `flowchart`。節點數上限 **12**,每個節點的文字上限 **8 字**。 + +超過就拆成多張圖,或者乾脆不畫 —— 一張塞了二十個節點的圖,比沒有圖更難懂。 + +節點文字寫該步驟在做什麼,不要寫成編號或代號。 + +模板的 `{{流程圖}}` 要填入**完整的內容**,兩種形式擇一: + +- 要畫:一個或多個完整的 ```mermaid 圍欄區塊。 +- 不畫:一行說明為什麼不畫(例如「流程為單一直線,畫圖無助理解」),**不要加圍欄**。 + +圍欄寫在填入的內容裡而不是模板裡,否則不畫圖時會留下一個空的 mermaid 區塊, +在議題頁上是一塊渲染失敗的紅字。 + +## 邊界 + +- 不修改使用者的專案檔案。這個流程只讀輸入、寫 Gitea 議題。 +- 不建立標籤、不建立 Milestone、不建立專案看板。 +- 不關閉或刪除任何既有議題。 +- 規劃階段本身已含問題釐清,因此寫入 Gitea 前不再設額外的確認點;`--dry-run` 就是那道關卡。 diff --git a/scripts/issue-create.js b/scripts/issue-create.js new file mode 100644 index 0000000..78f60e6 --- /dev/null +++ b/scripts/issue-create.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * 建立議題。 + * + * 兩個刻意的限制: + * - 標籤只能指定 repo 上已經存在的,指到不存在的就中止。本工具不建立標籤。 + * - 以標題查重,同名議題已存在就回傳既有那一顆,讓中斷後重跑不產生重複議題。 + * + * `--dry-run` 不寫入,但會讀:要讓預覽忠實反映將送出的請求,就得先把標籤名稱換成 id、 + * 也得先查過重——否則預覽看起來會建一顆議題,實跑卻是 no-op,或反過來實跑才爆標籤錯字。 + * + * 用法: + * node scripts/issue-create.js --repo owner/name --title <標題> --body-file <路徑> + * [--labels a,b] [--host <網址>] [--dry-run] + */ +import { existsSync, readFileSync } from 'node:fs'; +import { + ScriptError, + expectOk, + findIssueByTitle, + giteaRequest, + listLabels, + main, + parseFlags, + parseRepo, + preflight, + resolveLogin, +} from './lib.js'; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'title', 'body-file'], + optional: ['labels', 'host'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const title = flags.title.trim(); + const body = readBody(flags['body-file']); + const labelNames = splitLabels(flags.labels); + const path = `/repos/${repo}/issues`; + const dryRun = flags['dry-run'] === true; + + const login = resolveLogin({ host: flags.host }); + // 試跑不做前置檢查:那一層擋的是寫入能力與時間追蹤,而試跑本來就不寫。 + if (!dryRun) await preflight(login, repo); + + // 先驗標籤再查重:參數打錯要立刻講,不要等到重跑時才發現 + const labelIds = labelNames.length > 0 ? await resolveLabelIds(login, repo, labelNames) : null; + + const existing = await findIssueByTitle(login, repo, title); + + const payload = { title, body }; + if (labelIds) payload.labels = labelIds; + + if (dryRun) { + return { + dryRun: true, + repo, + labels: labelNames, + existing: existing ? { number: existing.number, url: existing.html_url } : null, + // 同名議題已存在時實跑是 no-op,預覽就不該顯示將建立議題 + requests: existing ? [] : [{ method: 'POST', path, body: payload }], + }; + } + + if (existing) { + return { + repo, + number: existing.number, + url: existing.html_url, + title: existing.title, + created: false, + }; + } + + const issue = expectOk(await giteaRequest(login, 'POST', path, { body: payload }), `POST ${path}`); + + return { + repo, + number: issue.number, + url: issue.html_url, + title: issue.title, + labels: (issue.labels ?? []).map((label) => label.name), + created: true, + }; +}); + +function readBody(bodyFile) { + if (!existsSync(bodyFile)) { + throw new ScriptError('BODY_FILE_MISSING', `找不到 --body-file 指定的檔案 ${bodyFile}`); + } + return readFileSync(bodyFile, 'utf8'); +} + +function splitLabels(value) { + if (value === undefined) return []; + return value + .split(',') + .map((name) => name.trim()) + .filter((name) => name !== ''); +} + +/** + * 把標籤名稱換成 repo 上既有標籤的 id。指到不存在的標籤一律中止 —— + * 本工具不建立標籤,錯字應該當場講清楚,而不是悄悄少貼一個。 + */ +async function resolveLabelIds(login, repo, names) { + const labels = await listLabels(login, repo); + const byName = new Map(labels.map((label) => [label.name, label.id])); + + const missing = names.filter((name) => !byName.has(name)); + if (missing.length > 0) { + throw new ScriptError( + 'UNKNOWN_LABEL', + `${repo} 沒有這些標籤:${missing.join('、')}。` + + `本工具不建立標籤,請改挑既有的:${[...byName.keys()].join('、') || '(這個 repo 目前沒有任何標籤)'}`, + ); + } + return names.map((name) => byName.get(name)); +} diff --git a/scripts/labels-list.js b/scripts/labels-list.js index faf1a1b..483ec60 100644 --- a/scripts/labels-list.js +++ b/scripts/labels-list.js @@ -8,8 +8,7 @@ * 用法:node scripts/labels-list.js --repo owner/name [--host <網址>] [--dry-run] */ import { - expectOk, - giteaRequest, + listLabels, main, parseFlags, parseRepo, @@ -40,7 +39,7 @@ main(async () => { const login = resolveLogin({ host: flags.host }); await preflight(login, repo); - const labels = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; + const labels = await listLabels(login, repo); return { repo, diff --git a/scripts/lib.js b/scripts/lib.js index df7b46e..d9cba81 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -418,6 +418,21 @@ function checkTimeTracker(info) { } } +// ── 標籤 ─────────────────────────────────────────────────────────── + +/** + * 取得 repo 上的既有標籤。 + * 本專案不建立標籤,所以這是取得標籤的唯一途徑:要貼標籤的腳本先從這裡拿清單, + * 挑不到合適的就不貼。 + * @param {{base: string, token: string}} login + * @param {string} repo owner/name + * @returns {Promise} + */ +export async function listLabels(login, repo) { + const path = `/repos/${repo}/labels`; + return expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; +} + // ── 冪等查重 ─────────────────────────────────────────────────────── /** diff --git a/templates/requirement-issue.md b/templates/requirement-issue.md new file mode 100644 index 0000000..94dc261 --- /dev/null +++ b/templates/requirement-issue.md @@ -0,0 +1,39 @@ +## 總覽 + +{{總覽}} + +圖解版總覽:{{總覽網頁}} + +## 背景 + +{{背景}} + +## 目標 + +{{目標}} + +## 非目標 + +{{非目標}} + +## 領域名詞表 + +| 名詞 | 定義 | +| --- | --- | +{{名詞表}} + +## 流程圖 + +{{流程圖}} + +## 驗收標準 + +{{驗收標準}} + +## 影響範圍 + +{{影響範圍}} + +## 未決事項 + +{{未決事項}} diff --git a/test/helpers/stub-gitea.js b/test/helpers/stub-gitea.js index 8b76f23..df1dd1e 100644 --- a/test/helpers/stub-gitea.js +++ b/test/helpers/stub-gitea.js @@ -89,3 +89,17 @@ function safeParse(raw) { return raw; } } + +/** + * 啟動假 Gitea 並登記在測試結束時關掉,省去每支測試各寫一次 close。 + * @param {object} t node:test 的 TestContext + * @param {Record} routes + */ +export async function withStubGitea(t, routes) { + const stub = await startStubGitea(routes); + t.after(() => stub.close()); + return stub; +} + +/** 把腳本指向這台假 Gitea 的環境變數 */ +export const stubEnv = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' }); diff --git a/test/issue-create.test.js b/test/issue-create.test.js new file mode 100644 index 0000000..2a868a5 --- /dev/null +++ b/test/issue-create.test.js @@ -0,0 +1,329 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runScript, tmpRoot } from './helpers/run-script.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; +const TITLE = '把口語需求轉成結構化需求議題'; + +/** 建議題要帶 body,寫成檔案傳進去,避免長 markdown 擠在命令列上 */ +function writeBody(text = '## 總覽\n\n一句話說明這件事在做什麼。\n') { + mkdirSync(tmpRoot, { recursive: true }); + const dir = mkdtempSync(join(tmpRoot, 'body-')); + const path = join(dir, 'requirement.md'); + writeFileSync(path, text); + return path; +} + +/** 預設情境:repo 裡已經有兩個標籤,且沒有任何同名議題 */ +function defaultRoutes(overrides = {}) { + return healthyRoutes(REPO, { + [`GET /api/v1/repos/${REPO}/issues`]: { status: 200, body: [] }, + [`POST /api/v1/repos/${REPO}/issues`]: (req) => ({ + status: 201, + body: { + number: 42, + title: req.body.title, + html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/42`, + labels: (req.body.labels ?? []).map((id) => ({ id, name: `label-${id}` })), + }, + }), + ...overrides, + }); +} + +async function withStub(t, overrides = {}) { + return withStubGitea(t, defaultRoutes(overrides)); +} + +// ── 建立議題 ─────────────────────────────────────────────────────── + +test('建立議題並回傳編號與網址', async (t) => { + const stub = await withStub(t); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody()], + { env: envFor(stub) }, + ); + + assert.equal(code, 0); + assert.equal(json.ok, true); + assert.equal(json.data.number, 42); + assert.equal(json.data.title, TITLE); + assert.equal(json.data.url, `https://gitea.jsc.idv.tw/${REPO}/issues/42`); + assert.equal(json.data.created, true); +}); + +test('body 由檔案讀入,原樣送出不做加工', async (t) => { + const stub = await withStub(t); + const body = '## 總覽\n\n多行的\n\n內容。\n'; + + await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody(body)], + { env: envFor(stub) }, + ); + + const post = stub.requests.find((r) => r.method === 'POST'); + assert.equal(post.body.body, body); + assert.equal(post.body.title, TITLE); +}); + +test('body 檔案不存在時,帶著路徑失敗且不碰 Gitea', async (t) => { + const stub = await withStub(t); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', '/nonexistent/body.md'], + { env: envFor(stub) }, + ); + + assert.equal(code, 1); + assert.equal(json.error.code, 'BODY_FILE_MISSING'); + assert.match(json.error.message, /\/nonexistent\/body\.md/); + assert.equal(stub.requests.length, 0); +}); + +test('缺少必填 flag 時失敗', async (t) => { + const stub = await withStub(t); + + const { json } = await runScript('issue-create.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.equal(json.error.code, 'MISSING_FLAG'); +}); + +// ── 標籤:只能挑既有的 ───────────────────────────────────────────── + +test('以名稱指定標籤,送出時換成既有標籤的 id', async (t) => { + const stub = await withStub(t); + + const { json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--labels', 'ready-for-agent'], + { env: envFor(stub) }, + ); + + assert.equal(json.ok, true, JSON.stringify(json)); + const post = stub.requests.find((r) => r.method === 'POST'); + assert.deepEqual(post.body.labels, [55]); +}); + +test('多個標籤以逗號分隔', async (t) => { + const stub = await withStub(t); + + await runScript( + 'issue-create.js', + [ + '--repo', REPO, '--title', TITLE, '--body-file', writeBody(), + '--labels', 'ready-for-agent,進行中', + ], + { env: envFor(stub) }, + ); + + const post = stub.requests.find((r) => r.method === 'POST'); + assert.deepEqual(post.body.labels, [55, 56]); +}); + +test('指定不存在的標籤時中止,並列出可選的標籤', async (t) => { + const stub = await withStub(t); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--labels', 'needs-triage'], + { env: envFor(stub) }, + ); + + assert.equal(code, 1); + assert.equal(json.error.code, 'UNKNOWN_LABEL'); + assert.match(json.error.message, /needs-triage/); + assert.match(json.error.message, /ready-for-agent/, '訊息要列出可選的標籤'); +}); + +test('不建立標籤:沒有任何請求寫到 labels 端點', async (t) => { + const stub = await withStub(t); + + await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--labels', 'ready-for-agent'], + { env: envFor(stub) }, + ); + + const labelWrites = stub.requests.filter( + (r) => r.path.endsWith('/labels') && r.method !== 'GET', + ); + assert.deepEqual(labelWrites, []); +}); + +test('未指定標籤時不查標籤,也不在請求裡帶 labels', async (t) => { + const stub = await withStub(t); + + await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody()], + { env: envFor(stub) }, + ); + + assert.equal(stub.requests.some((r) => r.path.endsWith('/labels')), false); + const post = stub.requests.find((r) => r.method === 'POST'); + assert.equal('labels' in post.body, false); +}); + +// ── 冪等查重 ─────────────────────────────────────────────────────── + +test('同標題議題已存在時不重建,回傳既有那一顆', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/issues`]: { + status: 200, + body: [{ number: 9, title: TITLE, html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/9` }], + }, + }); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody()], + { env: envFor(stub) }, + ); + + assert.equal(code, 0); + assert.equal(json.data.created, false); + assert.equal(json.data.number, 9); + assert.equal(stub.requests.some((r) => r.method === 'POST'), false, '不得重建議題'); +}); + +test('標題只差前後空白仍視為同一顆,不重建', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/issues`]: { + status: 200, + body: [{ number: 9, title: ` ${TITLE} `, html_url: 'https://example.com/9' }], + }, + }); + + const { json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody()], + { env: envFor(stub) }, + ); + + assert.equal(json.data.created, false); + assert.equal(json.data.number, 9); +}); + +test('查重看的是 open 與 closed 兩種狀態', async (t) => { + const stub = await withStub(t); + + await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody()], + { env: envFor(stub) }, + ); + + const lookup = stub.requests.find((r) => r.method === 'GET' && r.path.endsWith('/issues')); + assert.equal(lookup.query.state, 'all'); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出將發出的請求,但一個字都不寫進 Gitea', async (t) => { + const stub = await withStub(t); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--dry-run'], + { env: envFor(stub) }, + ); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual( + json.data.requests.map((r) => `${r.method} ${r.path}`), + [`POST /repos/${REPO}/issues`], + ); + assert.equal( + stub.requests.some((r) => r.method !== 'GET'), + false, + '試跑可以讀,但不得發出任何寫入請求', + ); +}); + +test('--dry-run 的預覽忠實反映將送出的 body,標籤已換成 id', async (t) => { + const stub = await withStub(t); + const body = '## 總覽\n\n要送出去的內容。\n'; + + const { json } = await runScript( + 'issue-create.js', + [ + '--repo', REPO, '--title', TITLE, '--body-file', writeBody(body), + '--labels', 'ready-for-agent', '--dry-run', + ], + { env: envFor(stub) }, + ); + + const planned = json.data.requests[0].body; + assert.equal(planned.title, TITLE); + assert.equal(planned.body, body); + assert.deepEqual(planned.labels, [55], '預覽要看得出標籤會被貼上,而不是整個消失'); + assert.deepEqual(json.data.labels, ['ready-for-agent']); +}); + +test('--dry-run 就抓得到標籤錯字,不必等到實跑', async (t) => { + const stub = await withStub(t); + + const { code, json } = await runScript( + 'issue-create.js', + [ + '--repo', REPO, '--title', TITLE, '--body-file', writeBody(), + '--labels', 'needs-triage', '--dry-run', + ], + { env: envFor(stub) }, + ); + + assert.equal(code, 1); + assert.equal(json.error.code, 'UNKNOWN_LABEL'); +}); + +test('--dry-run 遇到同名議題時,如實顯示實跑會是 no-op', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/issues`]: { + status: 200, + body: [{ number: 9, title: TITLE, html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/9` }], + }, + }); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--dry-run'], + { env: envFor(stub) }, + ); + + assert.equal(code, 0); + assert.deepEqual(json.data.requests, [], '已存在就不該預告要建立議題'); + assert.equal(json.data.existing.number, 9); +}); + +// ── 前置檢查仍然生效 ─────────────────────────────────────────────── + +test('寫入型腳本一樣跑前置檢查:時間追蹤沒開就中止', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}`]: { + status: 200, + body: { + has_issues: true, + permissions: { admin: true, push: true, pull: true }, + internal_tracker: { enable_time_tracker: false }, + }, + }, + }); + + const { code, json } = await runScript( + 'issue-create.js', + ['--repo', REPO, '--title', TITLE, '--body-file', writeBody()], + { env: envFor(stub) }, + ); + + assert.equal(code, 1); + assert.equal(json.error.code, 'TIME_TRACKER_OFF'); + assert.equal(stub.requests.some((r) => r.method === 'POST'), false); +}); diff --git a/test/labels-list.test.js b/test/labels-list.test.js index 5e54b67..c156828 100644 --- a/test/labels-list.test.js +++ b/test/labels-list.test.js @@ -1,20 +1,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { runScript } from './helpers/run-script.js'; -import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; const REPO = 'plugins/tea-sdlc'; /** 讓每個測試都拿到一台乾淨的假 Gitea,並在結束後關掉 */ async function withStub(t, overrides = {}) { - const stub = await startStubGitea(healthyRoutes(REPO, overrides)); - t.after(() => stub.close()); - return stub; + return withStubGitea(t, healthyRoutes(REPO, overrides)); } -function envFor(stub) { - return { TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' }; -} test('列出目標 repo 的既有標籤,輸出單行 JSON 且 exit 0', async (t) => { const stub = await withStub(t); diff --git a/test/preflight.test.js b/test/preflight.test.js index 6297b2e..1872f3e 100644 --- a/test/preflight.test.js +++ b/test/preflight.test.js @@ -1,17 +1,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { runScript, pathWithOnly } from './helpers/run-script.js'; -import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; const REPO = 'plugins/tea-sdlc'; async function withStub(t, overrides = {}) { - const stub = await startStubGitea(healthyRoutes(REPO, overrides)); - t.after(() => stub.close()); - return stub; + return withStubGitea(t, healthyRoutes(REPO, overrides)); } -const envFor = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' }); // ── 第一層:執行環境 ──────────────────────────────────────────────── diff --git a/test/script-contract.test.js b/test/script-contract.test.js index 413d91c..1c0037a 100644 --- a/test/script-contract.test.js +++ b/test/script-contract.test.js @@ -6,17 +6,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { runScript } from './helpers/run-script.js'; -import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; const REPO = 'plugins/tea-sdlc'; async function withStub(t, overrides = {}) { - const stub = await startStubGitea(healthyRoutes(REPO, overrides)); - t.after(() => stub.close()); - return stub; + return withStubGitea(t, healthyRoutes(REPO, overrides)); } -const envFor = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' }); // ── 輸入:具名 flag ──────────────────────────────────────────────── diff --git a/test/sdlc-plan-assets.test.js b/test/sdlc-plan-assets.test.js new file mode 100644 index 0000000..f281f55 --- /dev/null +++ b/test/sdlc-plan-assets.test.js @@ -0,0 +1,127 @@ +/** + * 流程正本與輸出模板的結構驗證。 + * + * 這兩份是檔案而非程式,但它們是 #4 實際交付的東西:模板段落順序決定了下游 + * issue-extract 解析得到什麼,正本的平台中立性決定了轉接檔能不能一份寫到底。 + * 用測試釘住,比靠人記得住可靠。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { repoRoot } from './helpers/run-script.js'; + +const template = readFileSync(join(repoRoot, 'templates', 'requirement-issue.md'), 'utf8'); +const prompt = readFileSync(join(repoRoot, 'prompts', 'sdlc-plan.md'), 'utf8'); + +/** 需求議題的九個段落,順序即議題裡的順序 */ +const SECTIONS = [ + '總覽', + '背景', + '目標', + '非目標', + '領域名詞表', + '流程圖', + '驗收標準', + '影響範圍', + '未決事項', +]; + +// ── 輸出模板 ─────────────────────────────────────────────────────── + +test('模板依序包含九個段落', () => { + const headings = [...template.matchAll(/^## (.+)$/gm)].map((m) => m[1].trim()); + assert.deepEqual(headings, SECTIONS); +}); + +test('模板以 {{變數}} 佔位,不留任何空白待填欄位', () => { + const placeholders = [...template.matchAll(/\{\{([^}]+)\}\}/g)].map((m) => m[1]); + assert.ok(placeholders.length >= SECTIONS.length, '每個段落至少要有一個佔位'); + for (const name of placeholders) { + assert.match(name, /^[a-z一-龥]+$/u, `佔位名稱 ${name} 應為單一詞,不含空白或符號`); + } +}); + +test('總覽段落預留了總覽網頁的連結佔位', () => { + const overview = template.slice(template.indexOf('## 總覽'), template.indexOf('## 背景')); + assert.match(overview, /\{\{總覽\}\}/); + assert.match(overview, /\{\{總覽網頁\}\}/); +}); + +// ── 流程正本 ─────────────────────────────────────────────────────── + +test('正本的 description 以「僅由 /sdlc-plan 指令叫用。」起頭', () => { + const description = prompt.match(/^description:\s*(.+)$/m)?.[1]; + assert.ok(description, '正本需要一行 description 供轉接檔取用'); + assert.ok( + description.startsWith('僅由 /sdlc-plan 指令叫用。'), + `description 前綴不符:${description}`, + ); +}); + +test('正本是平台中立的:不出現任何特定助理的語法或名稱', () => { + const platformSpecific = [ + 'AskUserQuestion', + 'Claude', + 'Codex', + 'Antigravity', + 'Copilot', + 'Kiro', + 'OpenCode', + 'oh-my-pi', + '.claude', + '$sdlc-plan', + ]; + for (const token of platformSpecific) { + assert.equal(prompt.includes(token), false, `正本不該出現平台專屬字樣:${token}`); + } +}); + +test('正本沒有 YAML frontmatter:那是各平台轉接檔的事', () => { + assert.equal(prompt.startsWith('---'), false); +}); + +test('正本交代了三種輸入都要能吃', () => { + for (const kind of ['自由文字', '規格檔', '議題編號']) { + assert.match(prompt, new RegExp(kind), `正本要說明輸入可為${kind}`); + } +}); + +test('正本明令缺漏資訊要逐項問,不得自行編造', () => { + assert.match(prompt, /一次問一題|逐項詢問/); + assert.match(prompt, /不得(自行|替使用者)?(編造|填入)/); +}); + +test('正本釘住 Mermaid flowchart 的節點上限與字數上限', () => { + assert.match(prompt, /flowchart/); + assert.match(prompt, /12/); + assert.match(prompt, /8\s*字/); + assert.match(prompt, /拆(成多)?圖|不畫/); +}); + +test('正本要求標籤只能從既有標籤挑,並指名用 labels-list 取得', () => { + assert.match(prompt, /labels-list/); + assert.match(prompt, /不(得|能)(自行)?建立(新)?標籤/); +}); + +test('正本指名由 issue-create 寫入,並提醒先以 --dry-run 檢查', () => { + assert.match(prompt, /issue-create/); + assert.match(prompt, /--dry-run/); +}); + +test('正本逐一交代九個段落,且順序與模板一致', () => { + // 只看「組出議題內容」那份編號清單,不看散落在行文裡的提及 + const listed = [...prompt.matchAll(/^\d+\.\s+\*\*(.+?)\*\*/gm)].map((m) => m[1].trim()); + assert.deepEqual(listed, SECTIONS); +}); + +test('模板不把 mermaid 圍欄寫死:不畫圖時才不會留下渲染失敗的空區塊', () => { + assert.equal(template.includes('```mermaid'), false); + const section = template.slice(template.indexOf('## 流程圖'), template.indexOf('## 驗收標準')); + assert.match(section.trim(), /^## 流程圖\s+\{\{流程圖\}\}$/); +}); + +test('正本交代了畫與不畫兩種情況各該填什麼', () => { + assert.match(prompt, /```mermaid/); + assert.match(prompt, /不要加圍欄|不加圍欄/); +});