From e8f31917b52b0c27708cf3ca201122b2e7b43ba5 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 14:25:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(wp-extract):=20=E5=BB=BA=E7=AB=8B=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=85=E7=9A=84=E6=8A=BD=E5=8F=96=E5=A5=91=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實作階段的指令給一個議題編號就拿得到它需要的一切,不必吞下整份議題全文。 輸出與議題 #1 的契約一致:待辦與它自己的驗收是巢狀的,每一項都帶未經修改的 `raw`, 下游靠它只改那一行、不重寫整份 body。 body 說不出的三個活狀態另外現查:相依走 dependencies/blocks 兩個端點並逐頁讀完 (半份清單會讓下游把實作順序排錯,那比直接報錯更難發現)、領取人看 assignee、碼錶 走 /user/stopwatches。碼錶那一項受限於 Gitea 只讓人讀自己的錶,真正的語意是「我的錶 正跑在這顆議題上」,這是議題 #1 已接受的取捨;領取鎖看的仍是 assignee。 同樣只讀 body 不讀留言,但回報未處理留言數。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/wp-extract.js | 127 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 scripts/wp-extract.js diff --git a/scripts/wp-extract.js b/scripts/wp-extract.js new file mode 100644 index 0000000..eb081a8 --- /dev/null +++ b/scripts/wp-extract.js @@ -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, + ); +}