/** * 掛 Milestone、寫截止日、記人天估算。 * 三件事都經同一個 PATCH 送出,因為它們常常要一起改,分開發等於多兩次往返。 */ import test from 'node:test'; import assert from 'node:assert/strict'; import { runScript } from './helpers/run-script.js'; import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js'; const REPO = 'plugins/tea-sdlc'; const INDEX = 12; const BODY = '## 這個工作包在做什麼\n\n一句話。\n\n## 關聯\n\n需求議題:#1\n'; function routes(overrides = {}, { body = BODY } = {}) { return healthyRoutes(REPO, { [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 200, body: { number: INDEX, title: '領取工作包並備妥分支', body, html_url: 'https://example.com/12' }, }, [`GET /api/v1/repos/${REPO}/milestones`]: { status: 200, body: [ { id: 3, title: '第一階段' }, { id: 4, title: '第二階段' }, ], }, [`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: (req) => ({ status: 200, body: { number: INDEX, ...req.body }, }), ...overrides, }); } const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options)); const run = (args, stub) => runScript('issue-update.js', ['--repo', REPO, '--index', String(INDEX), ...args], { env: envFor(stub), }); // ── Milestone ───────────────────────────────────────────────────── test('以名稱指定 Milestone,送出時換成 id', async (t) => { const stub = await withStub(t); const { code, json } = await run(['--milestone', '第二階段'], stub); assert.equal(code, 0, JSON.stringify(json)); assert.equal(patchOf(stub).body.milestone, 4); }); test('Milestone 不存在時中止,並列出可選的', async (t) => { const stub = await withStub(t); const { code, json } = await run(['--milestone', '第三階段'], stub); assert.equal(code, 1); assert.equal(json.error.code, 'UNKNOWN_MILESTONE'); assert.match(json.error.message, /第三階段/); assert.match(json.error.message, /第一階段/, '要列出可選的'); assert.equal(stub.requests.some((r) => r.method === 'PATCH' && !r.path.endsWith('/0')), false); }); test('不建立 Milestone:沒有任何請求寫到 milestones 端點', async (t) => { const stub = await withStub(t); await run(['--milestone', '第一階段'], stub); const writes = stub.requests.filter((r) => r.path.endsWith('/milestones') && r.method !== 'GET'); assert.deepEqual(writes, []); }); // ── 截止日 ───────────────────────────────────────────────────────── test('截止日以 RFC3339 送出', async (t) => { const stub = await withStub(t); await run(['--due-date', '2026-09-24'], stub); assert.equal(patchOf(stub).body.due_date, '2026-09-24T00:00:00Z'); }); test('截止日格式不對時擋在打 Gitea 之前', async (t) => { const stub = await withStub(t); const { json } = await run(['--due-date', '2026/09/24'], stub); assert.equal(json.error.code, 'BAD_DUE_DATE'); assert.equal(stub.requests.length, 0); }); // ── 人天估算 ─────────────────────────────────────────────────────── test('人天估算寫成議題 body 裡人類可讀的一行', async (t) => { const stub = await withStub(t); await run(['--estimate-days', '3'], stub); assert.match(patchOf(stub).body.body, /^估算人天:3$/m); }); test('估算寫進關聯段落,不是隨便黏在檔尾', async (t) => { const stub = await withStub(t); await run(['--estimate-days', '3'], stub); const updated = patchOf(stub).body.body; const relation = updated.slice(updated.indexOf('## 關聯')); assert.match(relation, /需求議題:#1/); assert.match(relation, /估算人天:3/); }); test('重跑改估算時就地更新那一行,不會累積成兩行', async (t) => { const stub = await withStub(t, {}, { body: '## 關聯\n\n需求議題:#1\n估算人天:3\n', }); await run(['--estimate-days', '5'], stub); const updated = patchOf(stub).body.body; assert.equal((updated.match(/估算人天:/g) ?? []).length, 1); assert.match(updated, /估算人天:5/); }); test('估算沒有變時不重寫 body', async (t) => { const stub = await withStub(t, {}, { body: '## 關聯\n\n需求議題:#1\n估算人天:3\n', }); await run(['--estimate-days', '3'], stub); assert.equal( patchOf(stub), undefined, '沒有任何欄位要改就整個 PATCH 都不發:空的 PATCH 會把議題的 updated_at 推新,' + '在列表上浮起來像是有人動過', ); }); test('有別的欄位要改時照樣發 PATCH,但沒變的 body 不跟著被重寫', async (t) => { const stub = await withStub(t, {}, { body: '## 關聯\n\n需求議題:#1\n估算人天:3\n', }); await run(['--estimate-days', '3', '--milestone', '第一階段'], stub); const patch = patchOf(stub); assert.equal(patch.body.milestone, 3); assert.equal('body' in patch.body, false, '估算沒變,body 就不該被塞進去'); }); test('人天必須是正數', async (t) => { const stub = await withStub(t); const { json } = await run(['--estimate-days', '0'], stub); assert.equal(json.error.code, 'BAD_ESTIMATE'); assert.equal(stub.requests.length, 0); }); test('議題沒有關聯段落時,估算補在 body 結尾而不是靜靜消失', async (t) => { const stub = await withStub(t, {}, { body: '## 描述\n\n沒有關聯段落的議題。\n' }); await run(['--estimate-days', '2'], stub); assert.match(patchOf(stub).body.body, /估算人天:2/); }); // ── 一起送 ───────────────────────────────────────────────────────── test('三件事一次 PATCH 送出', async (t) => { const stub = await withStub(t); await run(['--milestone', '第一階段', '--due-date', '2026-09-24', '--estimate-days', '2'], stub); const patches = stub.requests.filter((r) => r.method === 'PATCH' && !r.path.endsWith('/0')); assert.equal(patches.length, 1); assert.equal(patches[0].body.milestone, 3); assert.equal(patches[0].body.due_date, '2026-09-24T00:00:00Z'); assert.match(patches[0].body.body, /估算人天:2/); }); test('什麼都沒指定時中止,不發空的 PATCH', async (t) => { const stub = await withStub(t); const { code, json } = await run([], stub); assert.equal(code, 1); assert.equal(json.error.code, 'NOTHING_TO_UPDATE'); assert.equal(stub.requests.length, 0); }); // ── --dry-run ───────────────────────────────────────────────────── test('--dry-run 列出將送出的 PATCH,且不寫入', async (t) => { const stub = await withStub(t); const { code, json } = await run(['--milestone', '第一階段', '--due-date', '2026-09-24', '--dry-run'], stub); assert.equal(code, 0); assert.equal(json.data.dryRun, true); assert.equal(json.data.requests.length, 1); assert.equal(json.data.requests[0].body.milestone, 3, '預覽要看得出 Milestone 已解析成 id'); assert.equal(stub.requests.some((r) => r.method !== 'GET'), false); }); // ── 估算那一行只能動目標段落(以下三條皆為實際重現過的污染情境)────── test('圍欄裡的假標題不算段落,估算不會被塞進圍欄之後的位置', async (t) => { // 後面放一個真的段落,才分得出「當成段落處理」與「找不到段落」兩種結果 const stub = await withStub(t, {}, { body: '## 描述\n\n說明\n\n```\n## 關聯\n```\n\n## 整體驗收\n\n- 一條\n', }); await run(['--estimate-days', '3'], stub); const updated = patchOf(stub).body.body; assert.match(updated, /```\n## 關聯\n```/, '圍欄內容一字不動'); assert.ok( updated.indexOf('估算人天:3') > updated.indexOf('## 整體驗收'), '沒有真正的關聯段落,估算該補在 body 結尾,而不是插在假標題那一段裡', ); }); test('段落名稱是子字串時不誤認:## 關聯度說明 不是 ## 關聯', async (t) => { const stub = await withStub(t, {}, { body: '## 關聯度說明\n\n這不是關聯段落\n\n## 整體驗收\n\n- 一條\n', }); await run(['--estimate-days', '3'], stub); const updated = patchOf(stub).body.body; assert.match(updated, /## 關聯度說明\n\n這不是關聯段落/, '別人的段落不得被動到'); assert.ok( updated.indexOf('估算人天:3') > updated.indexOf('## 整體驗收'), '名稱只是相像不算命中,該落到 body 結尾', ); }); test('別的段落剛好有同前綴的一行時,改的仍是關聯段落那一行', async (t) => { const stub = await withStub(t, {}, { body: '## 總覽\n\n估算人天:99\n\n## 關聯\n\n需求議題:#1\n', }); await run(['--estimate-days', '3'], stub); const updated = patchOf(stub).body.body; assert.match(updated, /## 總覽\n\n估算人天:99/, '總覽那一行不得被改掉'); const relation = updated.slice(updated.indexOf('## 關聯')); assert.match(relation, /估算人天:3/); }); test('估算插在段落內容結尾,不會掉到下一個段落裡', async (t) => { const stub = await withStub(t, {}, { body: '## 關聯\n\n需求議題:#1\n\n## 整體驗收\n\n- 一條\n', }); await run(['--estimate-days', '2'], stub); const updated = patchOf(stub).body.body; assert.ok( updated.indexOf('估算人天:2') < updated.indexOf('## 整體驗收'), '估算要留在關聯段落內', ); }); // ── 總覽網頁的連結 ───────────────────────────────────────────────── test('把總覽網頁的連結寫進總覽段落', async (t) => { const stub = await withStub(t, {}, { body: '## 總覽\n\n一句話總覽。\n\n## 關聯\n\n需求議題:#1\n', }); await run(['--overview-url', 'https://example.com/artifact/abc'], stub); const updated = patchOf(stub).body.body; const overview = updated.slice(0, updated.indexOf('## 關聯')); assert.match(overview, /圖解版總覽:https:\/\/example\.com\/artifact\/abc/); }); test('連結旁註明 artifact 預設私有', async (t) => { const stub = await withStub(t, {}, { body: '## 總覽\n\n一句話總覽。\n' }); await run(['--overview-url', 'https://example.com/a'], stub); assert.match(patchOf(stub).body.body, /私有/); assert.match(patchOf(stub).body.body, /組織外/); }); test('議題原本的 markdown 白話總覽保留不動', async (t) => { const stub = await withStub(t, {}, { body: '## 總覽\n\n這段白話總覽不該被網頁取代。\n\n## 背景\n\n背景說明。\n', }); await run(['--overview-url', 'https://example.com/a'], stub); const updated = patchOf(stub).body.body; assert.match(updated, /這段白話總覽不該被網頁取代。/); assert.match(updated, /## 背景\n\n背景說明。/); }); test('重跑時原地更新同一行,不會長出第二個連結', async (t) => { const stub = await withStub(t, {}, { body: '## 總覽\n\n一句話。\n圖解版總覽:https://example.com/old(此連結預設為私有,組織外無法開啟)\n', }); await run(['--overview-url', 'https://example.com/new'], stub); const updated = patchOf(stub).body.body; assert.equal((updated.match(/圖解版總覽:/g) ?? []).length, 1); assert.match(updated, /example\.com\/new/); assert.equal(updated.includes('example.com/old'), false); }); test('連結沒變時不重寫 body', async (t) => { const stub = await withStub(t, {}, { body: '## 總覽\n\n一句話。\n圖解版總覽:https://example.com/a(此連結預設為私有,組織外無法開啟)\n', }); await run(['--overview-url', 'https://example.com/a'], stub); assert.equal(patchOf(stub), undefined, '連結沒變、也沒有別的欄位要改,就不發 PATCH'); }); test('不是網址時擋在打 Gitea 之前', async (t) => { const stub = await withStub(t); const { json } = await run(['--overview-url', '不是網址'], stub); assert.equal(json.error.code, 'BAD_OVERVIEW_URL'); assert.equal(stub.requests.length, 0); }); test('總覽網頁與其他欄位可以一次送出', async (t) => { const stub = await withStub(t); await run(['--overview-url', 'https://example.com/a', '--due-date', '2026-09-24'], stub); const patches = stub.requests.filter((r) => r.method === 'PATCH' && !r.path.endsWith('/0')); assert.equal(patches.length, 1); assert.match(patches[0].body.body, /圖解版總覽/); assert.equal(patches[0].body.due_date, '2026-09-24T00:00:00Z'); }); test('網址夾帶上一次寫回的提醒文字時擋下,免得那句話重複兩次', async (t) => { const stub = await withStub(t); const { json } = await run( ['--overview-url', 'https://example.com/a(此連結預設為私有,組織外無法開啟)'], stub, ); assert.equal(json.error.code, 'BAD_OVERVIEW_URL'); assert.equal(stub.requests.length, 0); });