feat(issue-extract): 建立需求議題的抽取契約

下游(分析、實作)唯一的議題讀取管道,存在的理由是「不必吞下整份議題全文」。
輸出契約上的十四個欄位,缺少的段落回傳空值。

只讀 body,不把留言內容納入輸出,但回報未整併的留言則數,好讓下游知道自己是
不是在拿過期的描述做事。已整併的留言由 sdlc-sync 打上 +1 reaction,而 Gitea
的留言物件不含 reaction,只能逐則再查一次——請求數會隨留言數增長,但這個數字
要準。

議題不存在與沒有讀取權分成兩個錯誤碼:前者是輸入錯,後者要去改權限,處置不同
就不該共用一個碼。

Closes #5

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 12:55:01 +08:00
co-authored by Claude Opus 5
parent 849d90f208
commit e13f0366aa
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
/**
* 把一顆需求議題抽成下游要用的精簡 JSON。
*
* 這是下游(分析、實作)唯一的議題讀取管道,存在的理由是「不必吞下整份議題全文」。
* 只讀 body,不把留言內容納入輸出;但會回報有幾則留言尚未被整併回描述,
* 好讓下游知道自己是不是在拿過期的描述做事。
*
* 用法:
* node scripts/issue-extract.js --repo owner/name --index 7 [--host <網址>] [--dry-run]
*/
import {
ScriptError,
expectOk,
giteaRequest,
main,
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) {
const comments = expectOk(await giteaRequest(login, 'GET', commentsPath), `GET ${commentsPath}`) ?? [];
let unmerged = 0;
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;
}