From 6254a6b53d3a87fb7eacf43fcf38bc03cfad3372 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 05:00:27 +0000 Subject: [PATCH] =?UTF-8?q?feat(issue-extract):=20=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E9=9C=80=E6=B1=82=E8=AD=B0=E9=A1=8C=E7=9A=84=E6=8A=BD=E5=8F=96?= =?UTF-8?q?=E5=A5=91=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下游(分析、實作)唯一的議題讀取管道,存在的理由是「不必吞下整份議題全文」。 輸出契約上的十四個欄位,缺少的段落回傳空值。 只讀 body,不把留言內容納入輸出,但回報未整併的留言則數,好讓下游知道自己是 不是在拿過期的描述做事。已整併的留言由 sdlc-sync 打上 +1 reaction,而 Gitea 的留言物件不含 reaction,只能逐則再查一次——請求數會隨留言數增長,但這個數字 要準。 議題不存在與沒有讀取權分成兩個錯誤碼:前者是輸入錯,後者要去改權限,處置不同 就不該共用一個碼。 Closes #5 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/issue-extract.js | 107 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 scripts/issue-extract.js diff --git a/scripts/issue-extract.js b/scripts/issue-extract.js new file mode 100644 index 0000000..874037a --- /dev/null +++ b/scripts/issue-extract.js @@ -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; +}