#!/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; }