diff --git a/scripts/wp-list.js b/scripts/wp-list.js new file mode 100644 index 0000000..88cf842 --- /dev/null +++ b/scripts/wp-list.js @@ -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; +} diff --git a/test/wp-list.test.js b/test/wp-list.test.js new file mode 100644 index 0000000..cce5c74 --- /dev/null +++ b/test/wp-list.test.js @@ -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'); +});