feat(wp-extract): 建立工作包的抽取契約

實作階段的指令給一個議題編號就拿得到它需要的一切,不必吞下整份議題全文。

輸出與議題 #1 的契約一致:待辦與它自己的驗收是巢狀的,每一項都帶未經修改的 `raw`,
下游靠它只改那一行、不重寫整份 body。

body 說不出的三個活狀態另外現查:相依走 dependencies/blocks 兩個端點並逐頁讀完
(半份清單會讓下游把實作順序排錯,那比直接報錯更難發現)、領取人看 assignee、碼錶
走 /user/stopwatches。碼錶那一項受限於 Gitea 只讓人讀自己的錶,真正的語意是「我的錶
正跑在這顆議題上」,這是議題 #1 已接受的取捨;領取鎖看的仍是 assignee。

同樣只讀 body 不讀留言,但回報未處理留言數。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 06:30:23 +00:00
co-authored by Claude Opus 5
parent 98861cecbd
commit 4655a30f47
+127
View File
@@ -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,
);
}