feat(wp-list): 列出一顆需求議題底下的工作包
歸屬判準沿用工作包抽取那一套(關聯段落的「需求議題:#N」),不另發明一套; PR 的描述也有那一行,所以先擋掉 PR。清單逐頁讀完,讀不完寧可報錯—— 使用者會從缺了幾顆的清單裡挑,而且看不出缺的是哪幾顆。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 列出一顆需求議題底下的工作包。
|
||||||
|
*
|
||||||
|
* 使用者手上常常只有需求議題的編號——留言發在那裡、討論也在那裡——但真正要動手的
|
||||||
|
* 單位是工作包。這一支把「哪幾顆工作包掛在這顆需求底下」答出來,讓他從清單裡挑一顆,
|
||||||
|
* 而不是自己去 Gitea 網頁上翻。
|
||||||
|
*
|
||||||
|
* **判準沿用工作包抽取那一套**:關聯段落裡的 `需求議題:#<編號>`(見 wp-extract 的
|
||||||
|
* `需求議題` 欄位,解析同樣走 issue-body 的 referencedIndex)。不另發明判準——標籤、
|
||||||
|
* 標題前綴、相依關係都各有各的用途,拿它們當歸屬會與抽取契約分岔。
|
||||||
|
*
|
||||||
|
* **PR 不算工作包。** 每個 PR 都是議題,而 pr-create 產出的 PR 描述本來就有
|
||||||
|
* 「需求議題:#N」那一行,只看 body 會把 PR 混進清單裡。
|
||||||
|
*
|
||||||
|
* 清單逐頁讀完,讀不完寧可報錯:半份清單會讓使用者從缺了幾顆的清單裡挑,
|
||||||
|
* 而且他看不出來缺的是哪幾顆。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node scripts/wp-list.js --repo owner/name --requirement 7 [--host <網址>] [--dry-run]
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
main,
|
||||||
|
pages,
|
||||||
|
parseFlags,
|
||||||
|
parseIndex,
|
||||||
|
parseRepo,
|
||||||
|
preflight,
|
||||||
|
resolveLogin,
|
||||||
|
} from './lib.js';
|
||||||
|
import { parseSections, referencedIndex } from './issue-body.js';
|
||||||
|
|
||||||
|
main(async () => {
|
||||||
|
const flags = parseFlags(process.argv.slice(2), {
|
||||||
|
required: ['repo', 'requirement'],
|
||||||
|
optional: ['host'],
|
||||||
|
booleans: ['dry-run'],
|
||||||
|
});
|
||||||
|
const repo = parseRepo(flags.repo);
|
||||||
|
const requirement = parseIndex(flags.requirement, '--requirement');
|
||||||
|
const issuesPath = `/repos/${repo}/issues`;
|
||||||
|
|
||||||
|
if (flags['dry-run']) {
|
||||||
|
return {
|
||||||
|
dryRun: true,
|
||||||
|
repo,
|
||||||
|
需求議題: requirement,
|
||||||
|
requests: [{ method: 'GET', path: issuesPath }],
|
||||||
|
note: '議題清單逐頁讀完,頁數取決於 repo 的議題總數,事前無法列舉。',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = resolveLogin({ host: flags.host });
|
||||||
|
await preflight(login, repo);
|
||||||
|
|
||||||
|
const 工作包 = [];
|
||||||
|
for await (const issues of pages(login, issuesPath, {
|
||||||
|
query: { state: 'all' },
|
||||||
|
limitCode: 'WORK_PACKAGE_LIMIT',
|
||||||
|
limitHint: `翻不完 ${repo} 的議題,列不出 #${requirement} 底下的完整工作包清單;請直接在 Gitea 上確認`,
|
||||||
|
})) {
|
||||||
|
for (const issue of issues) {
|
||||||
|
if (!belongsTo(issue, requirement)) continue;
|
||||||
|
工作包.push({
|
||||||
|
index: issue.number,
|
||||||
|
title: issue.title,
|
||||||
|
url: issue.html_url,
|
||||||
|
state: issue.state,
|
||||||
|
assignee: issue.assignee?.login ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 依編號排序:Gitea 的回傳順序會隨排序設定而變,清單的順序卻是使用者挑選的依據
|
||||||
|
工作包.sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
return { repo, 需求議題: requirement, 工作包, 數量: 工作包.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 這顆議題是不是掛在指定需求底下的工作包。
|
||||||
|
* PR 先擋掉——它的描述也有「需求議題:#N」那一行,但它不是工作包。
|
||||||
|
*/
|
||||||
|
function belongsTo(issue, requirement) {
|
||||||
|
if (issue.pull_request != null) return false;
|
||||||
|
return referencedIndex(parseSections(issue.body), '關聯', '需求議題') === requirement;
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* 需求議題底下的工作包清單。
|
||||||
|
*
|
||||||
|
* 這一支存在的理由只有一個:使用者手上有一顆需求議題編號,要挑出底下的某一顆工作包。
|
||||||
|
* 所以測試集中在「挑得對不對」:認的是不是工作包抽取那一套關聯判準、PR 會不會混進來、
|
||||||
|
* 清單讀不讀得完。半份清單最危險——使用者會從缺了幾顆的清單裡挑,而且看不出缺了誰。
|
||||||
|
*/
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { runScript } from './helpers/run-script.js';
|
||||||
|
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
|
||||||
|
|
||||||
|
const REPO = 'plugins/tea-sdlc';
|
||||||
|
const REQUIREMENT = 7;
|
||||||
|
|
||||||
|
/** 一顆工作包的 body:判準只在「關聯」段落那一行 */
|
||||||
|
function wpBody(requirement) {
|
||||||
|
return `## 這個工作包在做什麼
|
||||||
|
|
||||||
|
一句話。
|
||||||
|
|
||||||
|
## 待辦
|
||||||
|
|
||||||
|
- [ ] 做一件事
|
||||||
|
|
||||||
|
## 關聯
|
||||||
|
|
||||||
|
需求議題:#${requirement}
|
||||||
|
估算人天:2
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function issue(index, { title = `工作包 ${index}`, body = wpBody(REQUIREMENT), ...rest } = {}) {
|
||||||
|
return {
|
||||||
|
number: index,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
state: 'open',
|
||||||
|
html_url: `https://gitea.example/${REPO}/issues/${index}`,
|
||||||
|
assignee: null,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 假 Gitea:議題清單逐頁回,其餘走 healthy 預設 */
|
||||||
|
function routes(issues, overrides = {}) {
|
||||||
|
return healthyRoutes(REPO, {
|
||||||
|
[`GET /api/v1/repos/${REPO}/issues`]: (req) => {
|
||||||
|
const page = Number(req.query.page ?? 1);
|
||||||
|
const limit = Number(req.query.limit ?? 50);
|
||||||
|
return { status: 200, body: issues.slice((page - 1) * limit, page * limit) };
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(t, issues, args = []) {
|
||||||
|
const stub = await withStubGitea(t, routes(issues));
|
||||||
|
const result = await runScript(
|
||||||
|
'wp-list.js',
|
||||||
|
['--repo', REPO, '--requirement', String(REQUIREMENT), ...args],
|
||||||
|
{ env: envFor(stub) },
|
||||||
|
);
|
||||||
|
return { stub, ...result };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 挑出哪幾顆 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('只收關聯指回這顆需求議題的工作包', async (t) => {
|
||||||
|
const { code, json } = await run(t, [
|
||||||
|
issue(9),
|
||||||
|
issue(10, { body: wpBody(99) }),
|
||||||
|
issue(11),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.deepEqual(
|
||||||
|
json.data.工作包.map((wp) => wp.index),
|
||||||
|
[9, 11],
|
||||||
|
);
|
||||||
|
assert.equal(json.data.數量, 2);
|
||||||
|
assert.equal(json.data.需求議題, REQUIREMENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('沒有關聯段落的議題不算工作包', async (t) => {
|
||||||
|
const { json } = await run(t, [issue(9), issue(12, { body: '## 總覽\n\n這是一顆需求議題。\n' })]);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.工作包.map((wp) => wp.index), [9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PR 不進清單,即使它的描述也寫了需求議題', async (t) => {
|
||||||
|
// pr-create 產出的 PR 描述本來就有「需求議題:#N」那一行,光看 body 會把 PR 當成工作包
|
||||||
|
const { json } = await run(t, [
|
||||||
|
issue(9),
|
||||||
|
issue(30, { title: 'feat: 某某', pull_request: { merged: false } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.工作包.map((wp) => wp.index), [9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('已關閉的工作包照樣列出,並帶上狀態與領取人', async (t) => {
|
||||||
|
const { json } = await run(t, [
|
||||||
|
issue(9, { state: 'closed', assignee: { login: 'jeffery' } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.工作包, [
|
||||||
|
{
|
||||||
|
index: 9,
|
||||||
|
title: '工作包 9',
|
||||||
|
url: `https://gitea.example/${REPO}/issues/9`,
|
||||||
|
state: 'closed',
|
||||||
|
assignee: 'jeffery',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('依編號由小到大排,清單的順序不隨 Gitea 回傳順序飄動', async (t) => {
|
||||||
|
const { json } = await run(t, [issue(14), issue(9), issue(11)]);
|
||||||
|
|
||||||
|
assert.deepEqual(json.data.工作包.map((wp) => wp.index), [9, 11, 14]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('一顆都沒有時回空清單而不是報錯', async (t) => {
|
||||||
|
const { code, json } = await run(t, [issue(10, { body: wpBody(99) })]);
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.deepEqual(json.data.工作包, []);
|
||||||
|
assert.equal(json.data.數量, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('逐頁讀完,不只讀第一頁', async (t) => {
|
||||||
|
const many = Array.from({ length: 60 }, (_, i) => issue(i + 1));
|
||||||
|
|
||||||
|
const { json, stub } = await run(t, many);
|
||||||
|
|
||||||
|
assert.equal(json.data.數量, 60);
|
||||||
|
const listings = stub.requests.filter((r) => r.path === `/api/v1/repos/${REPO}/issues`);
|
||||||
|
assert.ok(listings.length >= 2, `要翻到第二頁,實際只發了 ${listings.length} 次`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('讀的是全部狀態的議題,不只 open', async (t) => {
|
||||||
|
const { stub } = await run(t, [issue(9)]);
|
||||||
|
|
||||||
|
const listing = stub.requests.find((r) => r.path === `/api/v1/repos/${REPO}/issues`);
|
||||||
|
assert.equal(listing.query.state, 'all');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 契約 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('--dry-run 列出將發出的請求,且完全不碰 Gitea', async (t) => {
|
||||||
|
const stub = await withStubGitea(t, routes([issue(9)]));
|
||||||
|
|
||||||
|
const { code, json } = await runScript(
|
||||||
|
'wp-list.js',
|
||||||
|
['--repo', REPO, '--requirement', String(REQUIREMENT), '--dry-run'],
|
||||||
|
{ env: envFor(stub) },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.equal(json.data.dryRun, true);
|
||||||
|
assert.deepEqual(json.data.requests, [
|
||||||
|
{ method: 'GET', path: `/repos/${REPO}/issues` },
|
||||||
|
]);
|
||||||
|
assert.equal(stub.requests.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('缺 --requirement 時指名缺的是哪一個', async (t) => {
|
||||||
|
const stub = await withStubGitea(t, routes([]));
|
||||||
|
|
||||||
|
const { json } = await runScript('wp-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||||
|
assert.match(json.error.message, /--requirement/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--requirement 不是正整數時擋下', async (t) => {
|
||||||
|
const stub = await withStubGitea(t, routes([]));
|
||||||
|
|
||||||
|
const { json } = await runScript('wp-list.js', ['--repo', REPO, '--requirement', 'abc'], {
|
||||||
|
env: envFor(stub),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'BAD_INDEX');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('議題多到翻不完時報錯,不回半份清單', async (t) => {
|
||||||
|
// 每一頁都回滿頁,永遠不會結束:翻到上限就要大聲說讀不完
|
||||||
|
const stub = await withStubGitea(
|
||||||
|
t,
|
||||||
|
healthyRoutes(REPO, {
|
||||||
|
[`GET /api/v1/repos/${REPO}/issues`]: (req) => ({
|
||||||
|
status: 200,
|
||||||
|
body: Array.from({ length: Number(req.query.limit ?? 50) }, (_, i) => issue(i + 1)),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { code, json } = await runScript(
|
||||||
|
'wp-list.js',
|
||||||
|
['--repo', REPO, '--requirement', String(REQUIREMENT)],
|
||||||
|
{ env: envFor(stub) },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.error.code, 'WORK_PACKAGE_LIMIT');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user