feat/wp-extract-contract/main #33

Merged
admin merged 5 commits from feat/wp-extract-contract/main into master 2026-09-17 06:33:37 +00:00
5 changed files with 954 additions and 51 deletions
+105 -9
View File
@@ -90,7 +90,9 @@ export function listSection(sections, name) {
for (const { line, inFence } of eachLine(sections.get(name))) { for (const { line, inFence } of eachLine(sections.get(name))) {
if (inFence) continue; if (inFence) continue;
const item = line.match(/^\s*(?:[-*+]|\d+\.)\s+(.*)$/); // [\s\S] 而非 . :CRLF 的 body 逐行切開後行尾有 \r,而 . 不吃 \r,
// 用 . 會讓整行比不中,清單靜靜變成空的。後面的 trim 再把 \r 修掉。
const item = line.match(/^\s*(?:[-*+]|\d+\.)\s+([\s\S]*)$/);
if (!item) continue; if (!item) continue;
const text = item[1].replace(/^\[[ xX]\]\s*/, '').trim(); const text = item[1].replace(/^\[[ xX]\]\s*/, '').trim();
@@ -100,17 +102,111 @@ export function listSection(sections, name) {
} }
/** /**
* 取出兩欄表格型段落。以分隔列(|---|---|)為界,之後才是資料列; * 取出巢狀的待辦清單:上層是待辦,縮排一層是該項自己的驗收。
* 沒有分隔列就當成沒有資料,避免把表頭當成一筆名詞。
* *
* 只處理兩欄:多出來的欄會被丟掉。目前唯一的使用者是需求議題的領域名詞表。 * 只有這一支收 body 而不收切好的段落,因為它要交出 `raw`——下游靠 `raw` 在整份 body 上
* 工作包的介面契約是四欄(介面/產出者/消費者/形狀),wp-extract 需要另一個 * 做精確字串替換來勾選 checkbox,那一行必須逐字等於 body 裡的原樣,
* 保留全部欄位的版本,不能直接沿用這一支。 * 連縮排、行尾空白與 \r 都不能動。段落切分會修掉前後空白,拿不到這種保證。
*
* 兩種畸形寫法都不丟內容,寧可放在稍微不對的位置也不要靜靜消失:
* - 巢狀超過一層 → 攤進所在待辦的驗收
* - 還沒有上層待辦就先出現縮排項目 → 升格成待辦
*
* @param {string} body 議題 body
* @param {string} section 段落名稱,例如 '待辦'
* @returns {{text: string, done: boolean, raw: string, 驗收: {text: string, done: boolean, raw: string}[]}[]}
*/
export function checklistInSection(body, section) {
const rows = [...eachLine(body)];
const { start, end } = sectionBounds(rows, section);
if (start === -1) return [];
const todos = [];
let topIndent = null;
for (let i = start + 1; i < end; i += 1) {
if (rows[i].inFence) continue;
const item = parseChecklistItem(rows[i].line);
if (!item) continue;
const nested = topIndent !== null && todos.length > 0 && item.indent > topIndent;
if (nested) {
todos.at(-1).驗收.push(item.value);
continue;
}
// 比目前認定的上層還淺時,把上層改認成更淺的那一層:第一項剛好縮排時,
// 後面出現的真正上層才不會被當成它的驗收。
topIndent = topIndent === null ? item.indent : Math.min(topIndent, item.indent);
todos.push({ ...item.value, 驗收: [] });
}
return todos;
}
/**
* 拆一行清單項。符號清單與編號清單一視同仁,checkbox 可有可無——
* 忘了寫 checkbox 的項目仍是一項待辦,只是 done 為 false。
* @returns {{indent: number, value: {text: string, done: boolean, raw: string}}|null}
*/
function parseChecklistItem(line) {
// [\s\S] 而非 . 的理由同 listSection:CRLF 的 body 行尾有 \r,. 不吃它。
// text 靠 trim 修掉 \r,raw 則原樣留著——它要逐字等於 body 裡的那一行。
const item = line.match(/^(\s*)(?:[-*+]|\d+\.)\s+([\s\S]*)$/);
if (!item) return null;
const box = item[2].match(/^\[([ xX])\]\s*([\s\S]*)$/);
const text = (box ? box[2] : item[2]).trim();
if (text === '') return null;
return {
indent: item[1].length,
value: { text, done: box ? box[1].toLowerCase() === 'x' : false, raw: line },
};
}
/**
* 在段落裡找出「標籤:#編號」那一行的編號,例如關聯段落的 `需求議題:#7`。
* 全形與半形冒號都認;找不到回 null——沒填不是解析失敗。
* @param {Map<string, string>} sections
* @param {string} name 段落名稱
* @param {string} label 標籤,例如 '需求議題'
* @returns {number|null}
*/
export function referencedIndex(sections, name, label) {
for (const { line, inFence } of eachLine(sections.get(name))) {
if (inFence) continue;
const at = line.indexOf(label);
if (at === -1) continue;
const value = line.slice(at + label.length).match(/^\s*[::]\s*#?(\d+)/);
if (value) return Number(value[1]);
}
return null;
}
/**
* 取出兩欄表格型段落,欄位固定命名為 term 與 def。
* 需求議題的領域名詞表用它;四欄的介面契約請用 tableRows。
* @param {Map<string, string>} sections * @param {Map<string, string>} sections
* @param {string} name * @param {string} name
* @returns {{term: string, def: string}[]} * @returns {{term: string, def: string}[]}
*/ */
export function tableSection(sections, name) { export function tableSection(sections, name) {
return tableRows(sections, name, ['term', 'def']);
}
/**
* 取出表格型段落的資料列,欄位依 columns 命名。以分隔列(|---|---|)為界,
* 之後才是資料列;沒有分隔列就當成沒有資料,避免把表頭當成一筆資料。
*
* 資料列比 columns 短時補空字串而不是讓欄位消失——下游拿到的形狀要固定,
* 少一欄是內容的問題,不該變成「欄位不存在」讓下游多寫一種分支。
*
* @param {Map<string, string>} sections
* @param {string} name
* @param {string[]} columns 由左到右的欄位名稱;多出來的欄會被丟掉
* @returns {Record<string, string>[]}
*/
export function tableRows(sections, name, columns) {
const rows = []; const rows = [];
for (const { line, inFence } of eachLine(sections.get(name))) { for (const { line, inFence } of eachLine(sections.get(name))) {
@@ -123,10 +219,10 @@ export function tableSection(sections, name) {
return rows return rows
.slice(separator + 1) .slice(separator + 1)
// 同一段落裡若不慎貼了第二張表,它的分隔列不該變成一筆 {term:'---'} // 同一段落裡若不慎貼了第二張表,它的分隔列不該變成一筆資料
.filter((cells) => !isSeparator(cells)) .filter((cells) => !isSeparator(cells))
.filter((cells) => cells.length >= 2 && cells.some((cell) => cell !== '')) .filter((cells) => cells.some((cell) => cell !== ''))
.map(([term, def]) => ({ term, def })); .map((cells) => Object.fromEntries(columns.map((column, i) => [column, cells[i] ?? ''])));
} }
/** 以未被逸脫的直線切欄,再把 `\|` 還原成內容裡的直線 */ /** 以未被逸脫的直線切欄,再把 `\|` 還原成內容裡的直線 */
+6 -41
View File
@@ -10,11 +10,10 @@
* node scripts/issue-extract.js --repo owner/name --index 7 [--host <網址>] [--dry-run] * node scripts/issue-extract.js --repo owner/name --index 7 [--host <網址>] [--dry-run]
*/ */
import { import {
ScriptError, UNMERGED_COMMENT_NOTE,
expectOk, countUnmergedComments,
giteaRequest, fetchIssue,
main, main,
pages,
parseFlags, parseFlags,
parseIndex, parseIndex,
parseRepo, parseRepo,
@@ -43,14 +42,14 @@ main(async () => {
{ method: 'GET', path: issuePath }, { method: 'GET', path: issuePath },
{ method: 'GET', path: commentsPath }, { method: 'GET', path: commentsPath },
], ],
note: '每則留言還會各查一次 reaction,用來數出未整併的則數;則數取決於留言數,事前無法列舉。', note: UNMERGED_COMMENT_NOTE,
}; };
} }
const login = resolveLogin({ host: flags.host }); const login = resolveLogin({ host: flags.host });
await preflight(login, repo); await preflight(login, repo);
const issue = await fetchIssue(login, repo, index, issuePath); const issue = await fetchIssue(login, repo, index);
const sections = parseSections(issue.body); const sections = parseSections(issue.body);
return { return {
@@ -67,41 +66,7 @@ main(async () => {
驗收標準: listSection(sections, '驗收標準'), 驗收標準: listSection(sections, '驗收標準'),
影響範圍: listSection(sections, '影響範圍'), 影響範圍: listSection(sections, '影響範圍'),
未決事項: listSection(sections, '未決事項'), 未決事項: listSection(sections, '未決事項'),
未處理留言數: await countUnmergedComments(login, repo, commentsPath), 未處理留言數: await countUnmergedComments(login, repo, index),
}; };
}); });
async function fetchIssue(login, repo, index, path) {
const response = await giteaRequest(login, 'GET', path);
if (response.status === 404) {
throw new ScriptError('ISSUE_NOT_FOUND', `${repo} 沒有編號 ${index} 的議題`);
}
if (response.status === 403) {
throw new ScriptError('NO_READ_ACCESS', `目前的帳號沒有 ${repo} 議題 ${index} 的讀取權`);
}
return expectOk(response, `GET ${path}`);
}
/**
* 數出尚未被整併回描述的留言則數。
*
* 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記),而 Gitea 的留言物件
* 不含 reaction,所以只能逐則再查一次。留言多時請求數會跟著長,但這個數字要準
* ——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完,讀不完寧可報錯。
*/
async function countUnmergedComments(login, repo, commentsPath) {
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;
}
}
return unmerged;
}
+62 -1
View File
@@ -1,12 +1,13 @@
/** /**
* tea-sdlc 所有腳本的共用地基。 * tea-sdlc 所有腳本的共用地基。
* *
* 這一層負責五件事,其餘腳本只寫自己的業務: * 這一層負責六件事,其餘腳本只寫自己的業務:
* 1. 具名 flag 解析與單行 JSON 輸出({ok, data, error:{code, message}}) * 1. 具名 flag 解析與單行 JSON 輸出({ok, data, error:{code, message}})
* 2. Gitea API 呼叫 —— 全專案唯一的 HTTP 出口 * 2. Gitea API 呼叫 —— 全專案唯一的 HTTP 出口
* 3. git 執行 —— 全專案唯一的子行程出口 * 3. git 執行 —— 全專案唯一的子行程出口
* 4. 四層前置檢查 * 4. 四層前置檢查
* 5. 冪等查重 * 5. 冪等查重
* 6. 兩支抽取腳本共用的議題讀取
* *
* 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。 * 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。
*/ */
@@ -431,6 +432,66 @@ function checkTimeTracker(info) {
} }
} }
// ── 議題讀取:兩支抽取腳本共用 ────────────────────────────────────
/**
* 讀一顆議題。「不存在」與「沒有讀取權」要分得開——前者是編號打錯,
* 後者是權限沒開,兩種的下一步完全不同。
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @param {number} index
* @returns {Promise<object>}
*/
export async function fetchIssue(login, repo, index) {
const path = `/repos/${repo}/issues/${index}`;
const response = await giteaRequest(login, 'GET', path);
if (response.status === 404) {
throw new ScriptError('ISSUE_NOT_FOUND', `${repo} 沒有編號 ${index} 的議題`);
}
if (response.status === 403) {
throw new ScriptError('NO_READ_ACCESS', `目前的帳號沒有 ${repo} 議題 ${index} 的讀取權`);
}
return expectOk(response, `GET ${path}`);
}
/**
* 抽取腳本 `--dry-run` 的共同附註:留言的 reaction 要逐則查,事前列不出來。
* 與 countUnmergedComments 同進退——說明的是它發出的那些請求。
*/
export const UNMERGED_COMMENT_NOTE =
'每則留言還會各查一次 reaction,用來數出未整併的則數;則數取決於留言數,事前無法列舉。';
/**
* 數出尚未被整併回描述的留言則數。
*
* 抽取契約只讀 body 不讀留言,這個數字是下游判斷「手上的描述是不是過期了」的唯一依據。
* 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記),而 Gitea 的留言物件
* 不含 reaction,所以只能逐則再查一次。留言多時請求數會跟著長,但這個數字要準
* ——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完,讀不完寧可報錯。
*
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @param {number} index
* @returns {Promise<number>}
*/
export async function countUnmergedComments(login, repo, index) {
const commentsPath = `/repos/${repo}/issues/${index}/comments`;
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;
}
}
return unmerged;
}
// ── 標籤 ─────────────────────────────────────────────────────────── // ── 標籤 ───────────────────────────────────────────────────────────
/** /**
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env node
/**
* 把一顆工作包議題抽成實作階段要用的精簡 JSON。
*
* 與需求議題的抽取(issue-extract)同樣「只讀 body 不讀留言」,但多做三件事:
* 1. 待辦是巢狀的——每一項待辦底下掛它自己的驗收,並各自帶回未經修改的 `raw`,
* 下游靠 `raw` 做精確字串替換來勾選 checkbox,只改那一行,不重寫整份 body。
* 2. 介面契約是四欄表格,四欄都要留著。
* 3. body 說不出的三個活狀態要現查:相依、領取人、碼錶。
*
* 用法:
* node scripts/wp-extract.js --repo owner/name --index 9 [--host <網址>] [--dry-run]
*/
import {
UNMERGED_COMMENT_NOTE,
countUnmergedComments,
expectOk,
fetchIssue,
giteaRequest,
main,
pages,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
} from './lib.js';
import {
checklistInSection,
listSection,
parseSections,
referencedIndex,
tableRows,
textSection,
} from './issue-body.js';
/** 介面契約表格由左到右的四欄 */
const CONTRACT_COLUMNS = ['介面', '產出者', '消費者', '形狀'];
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'index'],
optional: ['host'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const index = parseIndex(flags.index);
const issuePath = `/repos/${repo}/issues/${index}`;
if (flags['dry-run']) {
return {
dryRun: true,
repo,
index,
requests: [
{ method: 'GET', path: issuePath },
{ method: 'GET', path: `${issuePath}/dependencies` },
{ method: 'GET', path: `${issuePath}/blocks` },
{ method: 'GET', path: '/user/stopwatches' },
{ method: 'GET', path: `${issuePath}/comments` },
],
note: UNMERGED_COMMENT_NOTE,
};
}
const login = resolveLogin({ host: flags.host });
await preflight(login, repo);
const issue = await fetchIssue(login, repo, index);
const sections = parseSections(issue.body);
// 先讀完兩種相依再組輸出:欄位順序照契約寫成 {blocks, depends},
// 但請求順序維持「先問誰擋著我」,與 --dry-run 預告的一致。
const depends = await fetchLinked(login, `${issuePath}/dependencies`, '先決');
const blocks = await fetchLinked(login, `${issuePath}/blocks`, '阻擋');
return {
index: issue.number,
url: issue.html_url,
title: issue.title,
需求議題: referencedIndex(sections, '關聯', '需求議題'),
描述: textSection(sections, '描述'),
架構圖: textSection(sections, '架構圖'),
範圍邊界: listSection(sections, '範圍邊界'),
介面契約: tableRows(sections, '介面契約', CONTRACT_COLUMNS),
待辦: checklistInSection(issue.body, '待辦'),
整體驗收: listSection(sections, '整體驗收'),
repos: listSection(sections, 'repo 列表'),
相依: { blocks, depends },
assignee: issue.assignee?.login ?? null,
碼錶中: await hasRunningStopwatch(login, repo, index),
未處理留言數: await countUnmergedComments(login, repo, index),
};
});
/**
* 讀一種相依關係上的議題編號。
* 逐頁讀完:半份清單會讓下游把實作順序排錯,那比直接報錯更難發現。
* @param {string} kind 出現在錯誤訊息裡的關係名稱
*/
async function fetchLinked(login, path, kind) {
const indexes = [];
for await (const issues of pages(login, path, {
limitCode: 'DEPENDENCY_LIMIT',
limitHint: `${path} 的${kind}關係太多,讀不完整份清單`,
})) {
for (const issue of issues) indexes.push(issue.number);
}
return indexes;
}
/**
* 這顆議題上是不是有碼錶在跑。
*
* Gitea 只讓人讀自己的碼錶(`/user/stopwatches`),所以這個欄位的真正語意是
* 「**我**的碼錶正跑在這顆議題上」。它用來提醒自己忘了停錶,不是用來判斷別人有沒有在做
* ——領取鎖看的是 assignee。
*/
async function hasRunningStopwatch(login, repo, index) {
const path = '/user/stopwatches';
const watches = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
return watches.some(
(watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index,
);
}
+654
View File
@@ -0,0 +1,654 @@
/**
* 工作包議題的抽取契約。
*
* 與需求議題那一支(issue-extract)的差別在三件事,測試也集中在這三件事上:
* 1. 待辦是巢狀的,而且每一項都要帶回未經修改的 `raw` —— 下游靠它精確勾選 checkbox。
* 2. 介面契約是四欄表格,四欄都要留著。
* 3. 議題 body 以外還要回報三個活狀態:相依、領取人、碼錶。
*
* 模板變體照樣要餵得夠雜:缺段落、巢狀驗收為空、checkbox 已勾、中英混排。
*/
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 INDEX = 9;
/** 一顆套好模板、九段俱全的工作包議題 */
const FULL_BODY = `## 這個工作包在做什麼
讓實作階段的指令能從工作包議題取得它需要的一切,而不必吞下整份議題全文。
## 描述
做完之後,實作指令給一個編號就拿得到待辦與驗收,不必人再讀一遍議題。
## 架構圖
\`\`\`mermaid
sequenceDiagram
實作指令->>wp-extract: 議題編號
wp-extract->>實作指令: 精簡 JSON
\`\`\`
## 範圍邊界
- 不讀留言內容,只回報未處理則數
- 不負責勾選 checkbox,那是 issue-update 的事
## 介面契約
| 介面 | 產出者 | 消費者 | 形狀 |
| --- | --- | --- | --- |
| wp-extract | 本工作包 | sdlc-feat | 單行 JSON |
| raw 欄位 | 本工作包 | issue-update | 原始 markdown 行 |
## 待辦
- [x] 解析九個段落
- [x] 缺段落回空值而不是報錯
- [ ] 圍欄裡的井字號不算標題
- [ ] 待辦解析成巢狀結構
- [ ] 每一項都帶未經修改的 raw
## 整體驗收
- [ ] 輸出欄位與契約完全一致
- [x] 模板變體各有測試案例並通過
## repo 列表
- plugins/tea-sdlc
## 關聯
需求議題:#1
估算人天:3
`;
function routes(overrides = {}, options = {}) {
const {
body = FULL_BODY,
comments = [],
assignee = null,
depends = [],
blocks = [],
stopwatches = [],
} = options;
const base = healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: {
number: INDEX,
title: '建立工作包的抽取契約',
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
body,
assignee: assignee === null ? null : { login: assignee },
},
},
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/dependencies`]: {
status: 200,
body: depends.map((number) => ({ number })),
},
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/blocks`]: {
status: 200,
body: blocks.map((number) => ({ number })),
},
'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches },
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
status: 200,
body: comments.map((c, i) => ({ id: 100 + i, body: c.body })),
},
});
comments.forEach((c, i) => {
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
status: 200,
body: (c.reactions ?? []).map((content) => ({ content })),
};
});
return { ...base, ...overrides };
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
const run = (args, stub) =>
runScript('wp-extract.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
env: envFor(stub),
});
/** 這顆議題上跑著的碼錶長什麼樣 */
const stopwatchHere = { issue_index: INDEX, repo_owner_name: 'plugins', repo_name: 'tea-sdlc' };
// ── 契約 ───────────────────────────────────────────────────────────
test('抽出契約上的每一個欄位,不多也不少', async (t) => {
const stub = await withStub(t);
const { code, json } = await run([], stub);
assert.equal(code, 0);
assert.deepEqual(Object.keys(json.data).sort(), [
'index', 'url', 'title', 'assignee', 'repos', '相依',
'需求議題', '描述', '架構圖', '範圍邊界', '介面契約',
'待辦', '整體驗收', '碼錶中', '未處理留言數',
].sort());
});
test('議題本身的識別資訊原樣帶出', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.equal(json.data.index, INDEX);
assert.equal(json.data.url, `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`);
assert.equal(json.data.title, '建立工作包的抽取契約');
});
test('需求議題從關聯段落解析成編號', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.equal(json.data.需求議題, 1);
});
test('文字型段落回傳整段內容,架構圖連圍欄一起', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.match(json.data.描述, /實作指令給一個編號就拿得到待辦與驗收/);
assert.match(json.data.架構圖, /^```mermaid/);
assert.match(json.data.架構圖, /sequenceDiagram/);
assert.match(json.data.架構圖, /```$/);
});
test('範圍邊界與 repo 列表回傳字串陣列', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.deepEqual(json.data.範圍邊界, [
'不讀留言內容,只回報未處理則數',
'不負責勾選 checkbox,那是 issue-update 的事',
]);
assert.deepEqual(json.data.repos, ['plugins/tea-sdlc']);
});
test('整體驗收回傳字串陣列,勾選與否都只留文字', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.deepEqual(json.data.整體驗收, [
'輸出欄位與契約完全一致',
'模板變體各有測試案例並通過',
]);
});
// ── 介面契約:四欄都要留著 ─────────────────────────────────────────
test('介面契約保留四欄,表頭與分隔列不算一筆', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.deepEqual(json.data.介面契約, [
{ 介面: 'wp-extract', 產出者: '本工作包', 消費者: 'sdlc-feat', 形狀: '單行 JSON' },
{ 介面: 'raw 欄位', 產出者: '本工作包', 消費者: 'issue-update', 形狀: '原始 markdown 行' },
]);
});
test('介面契約只有表頭時回傳空陣列', async (t) => {
const body = '## 介面契約\n\n| 介面 | 產出者 | 消費者 | 形狀 |\n| --- | --- | --- | --- |\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.介面契約, []);
});
test('介面契約缺欄時補空字串,不讓欄位整個消失', async (t) => {
const body = '## 介面契約\n\n| 介面 | 產出者 | 消費者 | 形狀 |\n| --- | --- | --- | --- |\n| 無 | 本工作包 |\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.介面契約, [
{ 介面: '無', 產出者: '本工作包', 消費者: '', 形狀: '' },
]);
});
test('只寫一格的「無」也是一列,不會整張表變空', async (t) => {
// 正本明講「這顆不產出對外介面就寫一列『無』」,那一列不該與「沒有這一段」混為一談
const body = '## 介面契約\n\n| 介面 | 產出者 | 消費者 | 形狀 |\n| --- | --- | --- | --- |\n| 無 |\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.介面契約, [
{ 介面: '無', 產出者: '', 消費者: '', 形狀: '' },
]);
});
// ── 待辦:巢狀與 raw ───────────────────────────────────────────────
test('待辦解析成巢狀結構,驗收掛在它自己的待辦底下', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.deepEqual(json.data.待辦.map((todo) => todo.text), [
'解析九個段落',
'待辦解析成巢狀結構',
]);
assert.deepEqual(json.data.待辦[0].驗收.map((item) => item.text), [
'缺段落回空值而不是報錯',
'圍欄裡的井字號不算標題',
]);
assert.deepEqual(json.data.待辦[1].驗收.map((item) => item.text), [
'每一項都帶未經修改的 raw',
]);
});
test('勾選狀態如實反映在 done 上,待辦與驗收各自獨立', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.deepEqual(json.data.待辦.map((todo) => todo.done), [true, false]);
assert.deepEqual(json.data.待辦[0].驗收.map((item) => item.done), [true, false]);
});
test('每一項待辦與驗收都帶 raw,內容為未經修改的原始 markdown 行', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.equal(json.data.待辦[0].raw, '- [x] 解析九個段落');
assert.equal(json.data.待辦[0].驗收[0].raw, ' - [x] 缺段落回空值而不是報錯');
assert.equal(json.data.待辦[1].raw, '- [ ] 待辦解析成巢狀結構');
assert.equal(json.data.待辦[1].驗收[0].raw, ' - [ ] 每一項都帶未經修改的 raw');
});
test('raw 逐行出現在原始 body 裡,下游才替換得到', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
const lines = FULL_BODY.split('\n');
for (const todo of json.data.待辦) {
assert.ok(lines.includes(todo.raw), `raw 不在 body 裡:${todo.raw}`);
for (const item of todo.驗收) {
assert.ok(lines.includes(item.raw), `raw 不在 body 裡:${item.raw}`);
}
}
});
test('沒有驗收的待辦回傳空陣列,不是缺欄位', async (t) => {
const body = '## 待辦\n\n- [ ] 一項沒有驗收的待辦\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.待辦.length, 1);
assert.deepEqual(json.data.待辦[0].驗收, []);
});
test('沒有 checkbox 的項目也收得到,done 為 false', async (t) => {
const body = '## 待辦\n\n- 忘了寫 checkbox 的待辦\n - 它的驗收\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.待辦[0].text, '忘了寫 checkbox 的待辦');
assert.equal(json.data.待辦[0].done, false);
assert.equal(json.data.待辦[0].raw, '- 忘了寫 checkbox 的待辦');
assert.deepEqual(json.data.待辦[0].驗收.map((i) => i.text), ['它的驗收']);
});
test('大寫的 [X] 也算勾選', async (t) => {
const body = '## 待辦\n\n- [X] 大寫也是勾選\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.待辦[0].done, true);
});
test('巢狀超過一層時攤進同一項的驗收,不無聲吃掉內容', async (t) => {
const body = '## 待辦\n\n- [ ] 上層待辦\n - [ ] 它的驗收\n - [ ] 不該存在的第三層\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.待辦.length, 1);
assert.deepEqual(json.data.待辦[0].驗收.map((i) => i.text), [
'它的驗收',
'不該存在的第三層',
]);
});
test('沒有上層待辦的縮排項目升格成待辦,不被丟掉', async (t) => {
const body = '## 待辦\n\n - [ ] 開頭就縮排的項目\n- [ ] 後面才出現的上層\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.待辦.map((todo) => todo.text), [
'開頭就縮排的項目',
'後面才出現的上層',
]);
});
test('編號清單與符號清單一視同仁', async (t) => {
const body = '## 待辦\n\n1. [ ] 第一項\n2. [ ] 第二項\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.待辦.map((todo) => todo.text), ['第一項', '第二項']);
});
test('圍欄裡的待辦不是待辦', async (t) => {
const body = '## 待辦\n\n```\n- [ ] 範例裡的假待辦\n```\n\n- [ ] 真正的待辦\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.待辦.map((todo) => todo.text), ['真正的待辦']);
});
test('中英混排與行內標記都照原樣留著', async (t) => {
const body = '## 待辦\n\n- [ ] 讓 `wp-extract` 的 output 可被 downstream 直接使用\n - [ ] 支援 CJK 與 ASCII 混排\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.待辦[0].text, '讓 `wp-extract` 的 output 可被 downstream 直接使用');
assert.equal(json.data.待辦[0].驗收[0].text, '支援 CJK 與 ASCII 混排');
});
// ── 模板變體 ───────────────────────────────────────────────────────
test('缺段落回傳空值而不是報錯', async (t) => {
const body = '## 描述\n\n只有描述的工作包。\n';
const stub = await withStub(t, {}, { body });
const { code, json } = await run([], stub);
assert.equal(code, 0);
assert.equal(json.data.描述, '只有描述的工作包。');
assert.equal(json.data.架構圖, '');
assert.equal(json.data.需求議題, null);
assert.deepEqual(json.data.範圍邊界, []);
assert.deepEqual(json.data.介面契約, []);
assert.deepEqual(json.data.待辦, []);
assert.deepEqual(json.data.整體驗收, []);
assert.deepEqual(json.data.repos, []);
});
test('議題完全沒有內容時不炸,所有欄位為空', async (t) => {
const stub = await withStub(t, {}, { body: '' });
const { code, json } = await run([], stub);
assert.equal(code, 0);
assert.equal(json.data.描述, '');
assert.deepEqual(json.data.待辦, []);
});
test('關聯段落沒寫需求議題時為 null,估算那一行不會被誤讀成編號', async (t) => {
const body = '## 關聯\n\n估算人天:3\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.需求議題, null);
});
test('不認得的段落不影響其他段落', async (t) => {
const body = '## 描述\n\n有效內容。\n\n## 附錄\n\n- 不在契約裡的段落\n\n## 待辦\n\n- [ ] 仍然抓得到\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.描述, '有效內容。');
assert.deepEqual(json.data.待辦.map((todo) => todo.text), ['仍然抓得到']);
});
// ── CRLF:在 Gitea 網頁上編輯過的 body 就長這樣 ───────────────────
test('CRLF 的 body 照樣解析得出待辦、清單與表格', async (t) => {
// 瀏覽器送出 textarea 一律用 CRLF,議題只要被網頁編輯過就會變成這樣。
// 逐行切開後每一行都掛著 \r,正則若用 . 會整行比不中,清單靜靜變成空的。
const body = FULL_BODY.replace(/\n/g, '\r\n');
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.待辦.map((todo) => todo.text), [
'解析九個段落',
'待辦解析成巢狀結構',
]);
assert.deepEqual(json.data.待辦[0].驗收.map((item) => item.text), [
'缺段落回空值而不是報錯',
'圍欄裡的井字號不算標題',
]);
assert.deepEqual(json.data.範圍邊界, [
'不讀留言內容,只回報未處理則數',
'不負責勾選 checkbox,那是 issue-update 的事',
]);
assert.deepEqual(json.data.repos, ['plugins/tea-sdlc']);
assert.equal(json.data.介面契約.length, 2);
assert.equal(json.data.需求議題, 1);
});
test('CRLF 的 raw 連行尾的 \\r 都留著,替換才對得上原文', async (t) => {
const body = FULL_BODY.replace(/\n/g, '\r\n');
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
const lines = body.split('\n');
assert.equal(json.data.待辦[0].raw, '- [x] 解析九個段落\r');
assert.ok(lines.includes(json.data.待辦[0].raw));
assert.ok(lines.includes(json.data.待辦[0].驗收[0].raw));
});
// ── body 以外的活狀態 ─────────────────────────────────────────────
test('相依反映 Gitea 上實際的 blocks 與 depends', async (t) => {
const stub = await withStub(t, {}, { depends: [7], blocks: [11, 12] });
const { json } = await run([], stub);
assert.deepEqual(json.data.相依, { depends: [7], blocks: [11, 12] });
});
test('沒有相依時兩邊都是空陣列', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.deepEqual(json.data.相依, { depends: [], blocks: [] });
});
test('assignee 帶出領取人的帳號', async (t) => {
const stub = await withStub(t, {}, { assignee: 'jiantw83' });
const { json } = await run([], stub);
assert.equal(json.data.assignee, 'jiantw83');
});
test('沒人領取時 assignee 為 null', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.equal(json.data.assignee, null);
});
test('碼錶跑在這顆議題上時為 true', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchHere] });
const { json } = await run([], stub);
assert.equal(json.data.碼錶中, true);
});
test('碼錶跑在別顆議題上時為 false', async (t) => {
const stub = await withStub(t, {}, {
stopwatches: [{ ...stopwatchHere, issue_index: INDEX + 1 }],
});
const { json } = await run([], stub);
assert.equal(json.data.碼錶中, false);
});
test('同編號但不同 repo 的碼錶不算數', async (t) => {
const stub = await withStub(t, {}, {
stopwatches: [{ ...stopwatchHere, repo_name: '別的專案' }],
});
const { json } = await run([], stub);
assert.equal(json.data.碼錶中, false);
});
test('沒有任何碼錶時為 false', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.equal(json.data.碼錶中, false);
});
// ── 留言:只數不讀 ─────────────────────────────────────────────────
test('未處理留言數只算沒有 +1 標記的留言', async (t) => {
const stub = await withStub(t, {}, {
comments: [
{ body: '這則已經整併過了', reactions: ['+1'] },
{ body: '這則還沒', reactions: [] },
{ body: '這則有別的 reaction 但不是 +1', reactions: ['heart'] },
],
});
const { json } = await run([], stub);
assert.equal(json.data.未處理留言數, 2);
});
test('只讀 body:留言內容一個字都不出現在輸出裡', async (t) => {
const stub = await withStub(t, {}, {
comments: [{ body: '留言裡提到的決策不該被抽出來', reactions: [] }],
});
const { stdout } = await run([], stub);
assert.equal(stdout.includes('留言裡提到的決策'), false);
});
// ── 錯誤 ───────────────────────────────────────────────────────────
test('議題不存在時回傳可區分的錯誤碼', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
});
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
assert.match(json.error.message, new RegExp(String(INDEX)));
});
test('沒有讀取權時的錯誤碼與「議題不存在」分得開', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 403, body: { message: 'forbidden' } },
});
const { json } = await run([], stub);
assert.equal(json.error.code, 'NO_READ_ACCESS');
});
test('--index 不是正整數時擋在打 Gitea 之前', async (t) => {
const stub = await withStub(t);
const { json } = await runScript('wp-extract.js', ['--repo', REPO, '--index', 'abc'], {
env: envFor(stub),
});
assert.equal(json.error.code, 'BAD_INDEX');
assert.equal(stub.requests.length, 0);
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(['--dry-run'], stub);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.deepEqual(
json.data.requests.map((r) => `${r.method} ${r.path}`),
[
`GET /repos/${REPO}/issues/${INDEX}`,
`GET /repos/${REPO}/issues/${INDEX}/dependencies`,
`GET /repos/${REPO}/issues/${INDEX}/blocks`,
'GET /user/stopwatches',
`GET /repos/${REPO}/issues/${INDEX}/comments`,
],
);
assert.match(json.data.note, /reaction/);
assert.equal(stub.requests.length, 0);
});
// ── 分頁 ───────────────────────────────────────────────────────────
test('留言逐頁讀完,不是只讀第一頁', async (t) => {
const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 200 + i }));
const page2 = Array.from({ length: 20 }, (_, i) => ({ id: 300 + i }));
const reactions = {};
for (const { id } of [...page1, ...page2]) {
reactions[`GET /api/v1/repos/${REPO}/issues/comments/${id}/reactions`] = { status: 200, body: [] };
}
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: (req) => ({
status: 200,
body: req.query.page === '1' ? page1 : page2,
}),
...reactions,
});
const { json } = await run([], stub);
assert.equal(json.data.未處理留言數, 70);
});
test('相依逐頁讀完:半份清單會讓下游把順序排錯', async (t) => {
const page1 = Array.from({ length: 50 }, (_, i) => ({ number: 1000 + i }));
const page2 = [{ number: 2000 }];
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/dependencies`]: (req) => ({
status: 200,
body: req.query.page === '1' ? page1 : page2,
}),
});
const { json } = await run([], stub);
assert.equal(json.data.相依.depends.length, 51);
assert.equal(json.data.相依.depends.at(-1), 2000);
});