Merge pull request 'feat/issue-extract-contract/main' (#22) from feat/issue-extract-contract/main into master
Reviewed-on: #22 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #22.
This commit is contained in:
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* 議題 body 的 markdown 解析。
|
||||||
|
*
|
||||||
|
* 純函式,不碰網路也不碰檔案系統:抽取類腳本的解析全部走這裡,
|
||||||
|
* 解析規則只寫一次,需求議題與工作包議題共用同一套。
|
||||||
|
*
|
||||||
|
* 全檔的共同前提是「圍欄裡的東西不是內容」:議題裡放 mermaid 或程式碼是常態,
|
||||||
|
* 那裡面的井字號不是標題、減號不是清單項、直線不是表格。這件事只在 eachLine
|
||||||
|
* 處理一次,其餘函式都靠它。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐行走過內容並標註圍欄狀態。
|
||||||
|
* 圍欄以 ``` 或 ~~~ 開啟,且要同一種標記才算關閉——混用時後者只是普通文字。
|
||||||
|
* 圍欄沒關就到結尾時,其後的內容一律算在圍欄內,這與 markdown 的實際渲染一致。
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {Generator<{line: string, inFence: boolean, isFence: boolean}>}
|
||||||
|
*/
|
||||||
|
function* eachLine(text) {
|
||||||
|
let fence = null;
|
||||||
|
|
||||||
|
for (const line of (text ?? '').split('\n')) {
|
||||||
|
const marker = line.match(/^\s*(`{3,}|~{3,})/)?.[1]?.[0];
|
||||||
|
|
||||||
|
if (marker && fence === null) {
|
||||||
|
fence = marker;
|
||||||
|
yield { line, inFence: true, isFence: true };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (marker && marker === fence) {
|
||||||
|
fence = null;
|
||||||
|
yield { line, inFence: true, isFence: true };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
yield { line, inFence: fence !== null, isFence: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依 `## 標題` 切出各段落。圍欄內的行原樣保留在段落內容裡
|
||||||
|
* ——流程圖那一段要的就是整個 mermaid 區塊。
|
||||||
|
* @param {string} body 議題 body
|
||||||
|
* @returns {Map<string, string>} 段落名稱 → 該段內容(前後空白已修掉)
|
||||||
|
*/
|
||||||
|
export function parseSections(body) {
|
||||||
|
const sections = new Map();
|
||||||
|
const buffer = [];
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (current !== null) sections.set(current, buffer.join('\n').trim());
|
||||||
|
buffer.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const { line, inFence } of eachLine(body)) {
|
||||||
|
const heading = inFence ? null : line.match(/^##\s+(.+?)\s*$/);
|
||||||
|
if (heading) {
|
||||||
|
flush();
|
||||||
|
current = heading[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current !== null) buffer.push(line);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出文字型段落的整段內容。段落不存在時回空字串,不報錯 ——
|
||||||
|
* 缺段落是模板的正常變體,不是解析失敗。
|
||||||
|
* @param {Map<string, string>} sections
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function textSection(sections, name) {
|
||||||
|
return sections.get(name) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出列表型段落的每一項。符號清單與編號清單一視同仁,checkbox 只留文字不留標記
|
||||||
|
* ——需求議題的驗收標準不追蹤勾選狀態。巢狀項目一律攤平:需求議題的模板沒有巢狀,
|
||||||
|
* 真的出現時寧可多帶一項,也不要無聲吃掉內容。
|
||||||
|
* @param {Map<string, string>} sections
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function listSection(sections, name) {
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
for (const { line, inFence } of eachLine(sections.get(name))) {
|
||||||
|
if (inFence) continue;
|
||||||
|
const item = line.match(/^\s*(?:[-*+]|\d+\.)\s+(.*)$/);
|
||||||
|
if (!item) continue;
|
||||||
|
|
||||||
|
const text = item[1].replace(/^\[[ xX]\]\s*/, '').trim();
|
||||||
|
if (text !== '') items.push(text);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出兩欄表格型段落。以分隔列(|---|---|)為界,之後才是資料列;
|
||||||
|
* 沒有分隔列就當成沒有資料,避免把表頭當成一筆名詞。
|
||||||
|
* @param {Map<string, string>} sections
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {{term: string, def: string}[]}
|
||||||
|
*/
|
||||||
|
export function tableSection(sections, name) {
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (const { line, inFence } of eachLine(sections.get(name))) {
|
||||||
|
if (inFence || !line.trim().startsWith('|')) continue;
|
||||||
|
rows.push(splitRow(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = rows.findIndex(isSeparator);
|
||||||
|
if (separator === -1) return [];
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.slice(separator + 1)
|
||||||
|
// 同一段落裡若不慎貼了第二張表,它的分隔列不該變成一筆 {term:'---'}
|
||||||
|
.filter((cells) => !isSeparator(cells))
|
||||||
|
.filter((cells) => cells.length >= 2 && cells.some((cell) => cell !== ''))
|
||||||
|
.map(([term, def]) => ({ term, def }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 以未被逸脫的直線切欄,再把 `\|` 還原成內容裡的直線 */
|
||||||
|
function splitRow(line) {
|
||||||
|
return line
|
||||||
|
.trim()
|
||||||
|
.replace(/^\||\|$/g, '')
|
||||||
|
.split(/(?<!\\)\|/)
|
||||||
|
.map((cell) => cell.replace(/\\\|/g, '|').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSeparator(cells) {
|
||||||
|
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 把一顆需求議題抽成下游要用的精簡 JSON。
|
||||||
|
*
|
||||||
|
* 這是下游(分析、實作)唯一的議題讀取管道,存在的理由是「不必吞下整份議題全文」。
|
||||||
|
* 只讀 body,不把留言內容納入輸出;但會回報有幾則留言尚未被整併回描述,
|
||||||
|
* 好讓下游知道自己是不是在拿過期的描述做事。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node scripts/issue-extract.js --repo owner/name --index 7 [--host <網址>] [--dry-run]
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
ScriptError,
|
||||||
|
expectOk,
|
||||||
|
giteaRequest,
|
||||||
|
main,
|
||||||
|
pages,
|
||||||
|
parseFlags,
|
||||||
|
parseRepo,
|
||||||
|
preflight,
|
||||||
|
resolveLogin,
|
||||||
|
} from './lib.js';
|
||||||
|
import { listSection, parseSections, tableSection, textSection } from './issue-body.js';
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
const commentsPath = `${issuePath}/comments`;
|
||||||
|
|
||||||
|
if (flags['dry-run']) {
|
||||||
|
return {
|
||||||
|
dryRun: true,
|
||||||
|
repo,
|
||||||
|
index,
|
||||||
|
requests: [
|
||||||
|
{ method: 'GET', path: issuePath },
|
||||||
|
{ method: 'GET', path: commentsPath },
|
||||||
|
],
|
||||||
|
note: '每則留言還會各查一次 reaction,用來數出未整併的則數;則數取決於留言數,事前無法列舉。',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = resolveLogin({ host: flags.host });
|
||||||
|
await preflight(login, repo);
|
||||||
|
|
||||||
|
const issue = await fetchIssue(login, repo, index, issuePath);
|
||||||
|
const sections = parseSections(issue.body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: issue.number,
|
||||||
|
url: issue.html_url,
|
||||||
|
title: issue.title,
|
||||||
|
labels: (issue.labels ?? []).map((label) => label.name),
|
||||||
|
總覽: textSection(sections, '總覽'),
|
||||||
|
背景: textSection(sections, '背景'),
|
||||||
|
目標: listSection(sections, '目標'),
|
||||||
|
非目標: listSection(sections, '非目標'),
|
||||||
|
名詞表: tableSection(sections, '領域名詞表'),
|
||||||
|
流程圖: textSection(sections, '流程圖'),
|
||||||
|
驗收標準: listSection(sections, '驗收標準'),
|
||||||
|
影響範圍: listSection(sections, '影響範圍'),
|
||||||
|
未決事項: listSection(sections, '未決事項'),
|
||||||
|
未處理留言數: await countUnmergedComments(login, repo, commentsPath),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseIndex(value) {
|
||||||
|
if (!/^[1-9]\d*$/.test(value)) {
|
||||||
|
throw new ScriptError('BAD_INDEX', `--index 需為正整數,收到的是 ${value}`);
|
||||||
|
}
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
+47
-16
@@ -443,25 +443,56 @@ export async function listLabels(login, repo) {
|
|||||||
* @param {string} title 要找的標題
|
* @param {string} title 要找的標題
|
||||||
* @returns {Promise<object|null>} 找到的議題,或 null
|
* @returns {Promise<object|null>} 找到的議題,或 null
|
||||||
*/
|
*/
|
||||||
export async function findIssueByTitle(login, repo, title) {
|
/**
|
||||||
const wanted = title.trim();
|
* 逐頁走訪一個回傳陣列的 Gitea 端點。
|
||||||
const pageSize = 50;
|
*
|
||||||
// 翻頁上限。查不完就停手並報錯,不能無聲回 null——那會讓呼叫端把既有議題再建一次,
|
* 所有「必須看完整份清單」的走訪都走這裡:讀不完就要大聲報錯,不能無聲回傳半份。
|
||||||
// 正好是冪等查重要防的事。
|
* 半份清單比報錯更危險——查重會漏掉既有議題而重建一顆,數留言會少算而讓下游
|
||||||
const maxPages = 200;
|
* 拿著過期描述做事。
|
||||||
|
* @param {{base: string, token: string}} login
|
||||||
|
* @param {string} path
|
||||||
|
* @param {{query?: object, pageSize?: number, maxPages?: number, limitCode?: string, limitHint?: string}} options
|
||||||
|
* @returns {AsyncGenerator<object[]>} 每次 yield 一頁
|
||||||
|
*/
|
||||||
|
export async function* pages(login, path, options = {}) {
|
||||||
|
const {
|
||||||
|
query = {},
|
||||||
|
pageSize = 50,
|
||||||
|
maxPages = 200,
|
||||||
|
limitCode = 'PAGE_LIMIT',
|
||||||
|
limitHint = `${path} 的資料量超出可走訪範圍`,
|
||||||
|
} = options;
|
||||||
|
|
||||||
for (let page = 1; page <= maxPages; page += 1) {
|
for (let page = 1; page <= maxPages; page += 1) {
|
||||||
const response = await giteaRequest(login, 'GET', `/repos/${repo}/issues`, {
|
const response = await giteaRequest(login, 'GET', path, {
|
||||||
query: { state: 'all', limit: pageSize, page },
|
query: { ...query, limit: pageSize, page },
|
||||||
});
|
});
|
||||||
const issues = expectOk(response, `GET /repos/${repo}/issues`) ?? [];
|
const items = expectOk(response, `GET ${path}`) ?? [];
|
||||||
const hit = issues.find((issue) => (issue.title ?? '').trim() === wanted);
|
yield items;
|
||||||
if (hit) return hit;
|
if (items.length < pageSize) return;
|
||||||
if (issues.length < pageSize) return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ScriptError(
|
throw new ScriptError(limitCode, `${limitHint}(已讀 ${maxPages * pageSize} 筆仍未讀完)`);
|
||||||
'DEDUPE_LIMIT',
|
}
|
||||||
`翻完 ${maxPages * pageSize} 筆議題仍未查完 ${repo},無法確認「${wanted}」是否已存在;請縮小範圍或手動確認`,
|
|
||||||
);
|
/**
|
||||||
|
* 依標題找出既有議題,讓寫入型腳本中斷重跑時不會產生重複議題。
|
||||||
|
* 比對前後空白修掉:同一顆議題不該因為標題多了一個空格就被當成新的。
|
||||||
|
* @param {{base: string, token: string}} login
|
||||||
|
* @param {string} repo owner/name
|
||||||
|
* @param {string} title 要找的標題
|
||||||
|
* @returns {Promise<object|null>} 找到的議題,或 null
|
||||||
|
*/
|
||||||
|
export async function findIssueByTitle(login, repo, title) {
|
||||||
|
const wanted = title.trim();
|
||||||
|
|
||||||
|
for await (const issues of pages(login, `/repos/${repo}/issues`, {
|
||||||
|
query: { state: 'all' },
|
||||||
|
limitCode: 'DEDUPE_LIMIT',
|
||||||
|
limitHint: `翻不完 ${repo} 的議題,無法確認「${wanted}」是否已存在;請縮小範圍或手動確認`,
|
||||||
|
})) {
|
||||||
|
const hit = issues.find((issue) => (issue.title ?? '').trim() === wanted);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
/**
|
||||||
|
* 需求議題的抽取契約。
|
||||||
|
*
|
||||||
|
* 這是整條鏈的上游:解析錯則下游全錯,所以模板變體要餵得夠雜——缺段落、
|
||||||
|
* 列表空的、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 = 7;
|
||||||
|
|
||||||
|
/** 一顆套好模板、九段俱全的需求議題 */
|
||||||
|
const FULL_BODY = `## 總覽
|
||||||
|
|
||||||
|
把一段口語需求變成結構化議題,讓開發者不必再來問細節。
|
||||||
|
|
||||||
|
圖解版總覽:https://claude.ai/artifact/abc
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
需求目前寫成散文,下游每次都要人重讀一遍。
|
||||||
|
|
||||||
|
## 目標
|
||||||
|
|
||||||
|
- 需求議題可被下游腳本機讀
|
||||||
|
- 建立議題的時間從 30 分鐘降到 5 分鐘
|
||||||
|
|
||||||
|
## 非目標
|
||||||
|
|
||||||
|
- 不處理工作包的拆解
|
||||||
|
- 不支援 Gitea 以外的議題系統
|
||||||
|
|
||||||
|
## 領域名詞表
|
||||||
|
|
||||||
|
| 名詞 | 定義 |
|
||||||
|
| --- | --- |
|
||||||
|
| 需求議題 | 描述一項需求的 Gitea issue |
|
||||||
|
| 工作包 | 從需求拆出的可獨立完成的單位 |
|
||||||
|
|
||||||
|
## 流程圖
|
||||||
|
|
||||||
|
\`\`\`mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[收集輸入] --> B[逐項詢問]
|
||||||
|
B --> C[產生議題]
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 驗收標準
|
||||||
|
|
||||||
|
- [ ] 輸入可為自由文字或議題編號
|
||||||
|
- [x] 缺漏時逐項詢問
|
||||||
|
- 產出議題套用固定模板
|
||||||
|
|
||||||
|
## 影響範圍
|
||||||
|
|
||||||
|
- plugins/tea-sdlc
|
||||||
|
|
||||||
|
## 未決事項
|
||||||
|
|
||||||
|
- 標籤體系由誰維護尚未決定
|
||||||
|
`;
|
||||||
|
|
||||||
|
function routes(overrides = {}, { body = FULL_BODY, comments = [] } = {}) {
|
||||||
|
const base = healthyRoutes(REPO, {
|
||||||
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
number: INDEX,
|
||||||
|
title: '以 sdlc-plan 把口語需求轉成結構化需求議題',
|
||||||
|
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
|
||||||
|
body,
|
||||||
|
labels: [{ id: 55, name: 'ready-for-agent' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
||||||
|
status: 200,
|
||||||
|
body: comments.map((c, i) => ({ id: 100 + i, body: c.body })),
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
// 每則留言各自的 reaction 端點
|
||||||
|
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('issue-extract.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||||
|
env: envFor(stub),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 完整議題 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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', 'labels', 'title', 'url',
|
||||||
|
'影響範圍', '未決事項', '未處理留言數', '流程圖',
|
||||||
|
'目標', '總覽', '背景', '名詞表', '非目標', '驗收標準',
|
||||||
|
].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, '以 sdlc-plan 把口語需求轉成結構化需求議題');
|
||||||
|
assert.deepEqual(json.data.labels, ['ready-for-agent']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('文字型段落回傳整段內容', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.match(json.data.總覽, /把一段口語需求變成結構化議題/);
|
||||||
|
assert.match(json.data.總覽, /圖解版總覽:https/, '總覽整段帶出,不丟掉網頁連結');
|
||||||
|
assert.equal(json.data.背景, '需求目前寫成散文,下游每次都要人重讀一遍。');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('列表型段落回傳去掉項目符號的字串陣列', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.目標, [
|
||||||
|
'需求議題可被下游腳本機讀',
|
||||||
|
'建立議題的時間從 30 分鐘降到 5 分鐘',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(json.data.非目標, ['不處理工作包的拆解', '不支援 Gitea 以外的議題系統']);
|
||||||
|
assert.deepEqual(json.data.影響範圍, ['plugins/tea-sdlc']);
|
||||||
|
assert.deepEqual(json.data.未決事項, ['標籤體系由誰維護尚未決定']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('名詞表解析成 term 與 def,表頭與分隔列不算一筆', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.名詞表, [
|
||||||
|
{ term: '需求議題', def: '描述一項需求的 Gitea issue' },
|
||||||
|
{ term: '工作包', def: '從需求拆出的可獨立完成的單位' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('流程圖原樣帶出,連圍欄一起', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.match(json.data.流程圖, /^```mermaid/);
|
||||||
|
assert.match(json.data.流程圖, /flowchart TD/);
|
||||||
|
assert.match(json.data.流程圖, /```$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 模板變體 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('checkbox 已勾或未勾都只留文字,不留標記', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.驗收標準, [
|
||||||
|
'輸入可為自由文字或議題編號',
|
||||||
|
'缺漏時逐項詢問',
|
||||||
|
'產出議題套用固定模板',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.流程圖, '');
|
||||||
|
assert.deepEqual(json.data.目標, []);
|
||||||
|
assert.deepEqual(json.data.名詞表, []);
|
||||||
|
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.目標, []);
|
||||||
|
assert.deepEqual(json.data.非目標, ['只有這一項']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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- 讓 `issue-extract` 的 output 可被 downstream 直接使用\n- 支援 CJK 與 ASCII 混排\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.目標, [
|
||||||
|
'讓 `issue-extract` 的 output 可被 downstream 直接使用',
|
||||||
|
'支援 CJK 與 ASCII 混排',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('編號清單與符號清單一視同仁', async (t) => {
|
||||||
|
const body = '## 驗收標準\n\n1. 第一條\n2. 第二條\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.驗收標準, ['第一條', '第二條']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('不認得的段落不影響其他段落', 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.目標, ['仍然抓得到']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 留言:只數不讀 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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('沒有留言時為 0', async (t) => {
|
||||||
|
const stub = await withStub(t);
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.未處理留言數, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('issue-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}/comments`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
json.data.note,
|
||||||
|
/reaction/,
|
||||||
|
'每則留言的 reaction 查詢無法事前列舉,預覽要說清楚而不是假裝沒有',
|
||||||
|
);
|
||||||
|
assert.equal(stub.requests.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 圍欄與表格的邊界(皆為 code review 抓出的實際缺陷,這裡封住回頭路)────
|
||||||
|
|
||||||
|
test('~~~ 圍欄裡的井字號不是標題', async (t) => {
|
||||||
|
const body = '## 流程圖\n\n~~~\n## 這不是標題\n~~~\n\n## 目標\n\n- 真的目標\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.目標, ['真的目標']);
|
||||||
|
assert.match(json.data.流程圖, /這不是標題/, '圍欄內容原樣留在流程圖段落裡');
|
||||||
|
});
|
||||||
|
|
||||||
|
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.驗收標準, ['真正的驗收標準']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('圍欄要同種標記才算關閉,混用時不會提早收尾', async (t) => {
|
||||||
|
const body = '## 驗收標準\n\n```\n~~~\n- 仍在圍欄內\n```\n\n- 圍欄外的項目\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.驗收標準, ['圍欄外的項目']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('圍欄沒關就到結尾時,其後內容算在圍欄內(與 markdown 渲染一致)', async (t) => {
|
||||||
|
const body = '## 流程圖\n\n```mermaid\nflowchart TD\n\n## 驗收標準\n\n- 被圍欄吃掉\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { code, json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(code, 0, '不該炸掉,只是內容歸屬不同');
|
||||||
|
assert.deepEqual(json.data.驗收標準, []);
|
||||||
|
assert.match(json.data.流程圖, /驗收標準/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('名詞表的欄位內容可以有逸脫的直線', async (t) => {
|
||||||
|
const body = '## 領域名詞表\n\n| 名詞 | 定義 |\n| --- | --- |\n| a\\|b | 帶直線的名詞 |\n';
|
||||||
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.名詞表, [{ term: 'a|b', def: '帶直線的名詞' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.名詞表, [
|
||||||
|
{ term: '甲', def: '一' },
|
||||||
|
{ term: '乙', def: '二' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// 第一頁滿 50 筆就得再翻一頁;第二頁不滿才收手
|
||||||
|
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);
|
||||||
|
|
||||||
|
const commentPages = stub.requests.filter((r) => r.path.endsWith(`/issues/${INDEX}/comments`));
|
||||||
|
assert.deepEqual(commentPages.map((r) => r.query.page), ['1', '2']);
|
||||||
|
assert.equal(json.data.未處理留言數, 70, '第二頁的留言也要算進來');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user