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:
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user