Merge pull request 'feat/sdlc-plan-requirement-issue/main' (#21) from feat/sdlc-plan-requirement-issue/main into master
Reviewed-on: #21 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #21.
This commit is contained in:
@@ -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 <owner/name>` 取得該 repo 的既有標籤,**只能從這份清單裡
|
||||
挑**。找不到合適的就不貼。**不得自行建立新標籤** —— 標籤體系由專案維護者決定,不該在多個
|
||||
repo 之間長出雜草。
|
||||
|
||||
### 5. 先試跑,再寫入
|
||||
|
||||
把組好的內容寫到一個暫存檔,然後:
|
||||
|
||||
```
|
||||
node scripts/issue-create.js --repo <owner/name> --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` 就是那道關卡。
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -418,6 +418,21 @@ function checkTimeTracker(info) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 標籤 ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 取得 repo 上的既有標籤。
|
||||
* 本專案不建立標籤,所以這是取得標籤的唯一途徑:要貼標籤的腳本先從這裡拿清單,
|
||||
* 挑不到合適的就不貼。
|
||||
* @param {{base: string, token: string}} login
|
||||
* @param {string} repo owner/name
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
export async function listLabels(login, repo) {
|
||||
const path = `/repos/${repo}/labels`;
|
||||
return expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
|
||||
}
|
||||
|
||||
// ── 冪等查重 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
## 總覽
|
||||
|
||||
{{總覽}}
|
||||
|
||||
圖解版總覽:{{總覽網頁}}
|
||||
|
||||
## 背景
|
||||
|
||||
{{背景}}
|
||||
|
||||
## 目標
|
||||
|
||||
{{目標}}
|
||||
|
||||
## 非目標
|
||||
|
||||
{{非目標}}
|
||||
|
||||
## 領域名詞表
|
||||
|
||||
| 名詞 | 定義 |
|
||||
| --- | --- |
|
||||
{{名詞表}}
|
||||
|
||||
## 流程圖
|
||||
|
||||
{{流程圖}}
|
||||
|
||||
## 驗收標準
|
||||
|
||||
{{驗收標準}}
|
||||
|
||||
## 影響範圍
|
||||
|
||||
{{影響範圍}}
|
||||
|
||||
## 未決事項
|
||||
|
||||
{{未決事項}}
|
||||
@@ -89,3 +89,17 @@ function safeParse(raw) {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 啟動假 Gitea 並登記在測試結束時關掉,省去每支測試各寫一次 close。
|
||||
* @param {object} t node:test 的 TestContext
|
||||
* @param {Record<string, object|Function>} 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' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
// ── 第一層:執行環境 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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, /不要加圍欄|不加圍欄/);
|
||||
});
|
||||
Reference in New Issue
Block a user