/** * 開立 PR 並停錶。 * * 三件事要驗: * 1. **標題等同分支名**——reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。 * 2. **描述的七段都在**,而且「測試結果」不是空話。這一段是 reviewer 唯一能判斷 * 「這東西真的跑過嗎」的依據,寫「已測試通過」等於沒寫。 * 3. **PR 開完才停錶**,而且開失敗時錶不能停——工時要記在真的有做事的那段時間上。 */ import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { runScript, tmpRoot } from './helpers/run-script.js'; import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; /** PR 開在目標專案上 */ const REPO = 'myorg/myapp'; /** 工作包議題在另一個 repo 上——這是常態,不是特例 */ const ISSUE_REPO = 'plugins/tea-sdlc'; const HEAD = 'feat/commit-split-and-pr/main'; const INDEX = 13; /** 一份七段俱全的描述 */ const BODY = `## 摘要 工作包做完之後,變更被整理成可讀的歷史,PR 開出來,碼錶停下。 ## 需求議題 #1 ## 工作包議題 #13 ## 變更內容 新增 commit-split 與 pr-create 兩支腳本。 ## 設計重點 分批的界線是類型,一個 commit 只裝一種。 ## 解決的問題 巨大的單一 commit 等於沒有歷史。 ## 影響的功能 sdlc-feat 的第三段。 ## 測試結果 \`\`\` ℹ tests 527 ℹ pass 527 ℹ fail 0 \`\`\` `; /** 把描述寫成檔案,回傳路徑 */ function bodyFile(name, content) { mkdirSync(tmpRoot, { recursive: true }); const path = join(tmpRoot, `pr-body-${name}-${process.hrtime.bigint()}.md`); writeFileSync(path, content); return path; } function routes(overrides = {}) { return healthyRoutes(REPO, { [`GET /api/v1/repos/${REPO}/pulls`]: { status: 200, body: [] }, [`POST /api/v1/repos/${REPO}/pulls`]: (req) => ({ status: 201, body: { number: 99, title: req.body.title, html_url: `https://gitea.jsc.idv.tw/${REPO}/pulls/99` }, }), [`POST /api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 201, body: {} }, ...overrides, }); } const withStub = (t, overrides = {}) => withStubGitea(t, routes(overrides)); const BASE_ARGS = ['--repo', REPO, '--head', HEAD, '--base', 'master']; const run = (args, stub) => runScript('pr-create.js', [...BASE_ARGS, ...args], { env: envFor(stub) }); /** 完整的一次呼叫:議題在另一個 repo 上 */ const runFull = (file, stub, extra = []) => run(['--body-file', file, '--issue-repo', ISSUE_REPO, '--index', String(INDEX), ...extra], stub); const posts = (stub) => stub.requests.filter((r) => r.method === 'POST').map((r) => r.path); // ── 標題與描述 ───────────────────────────────────────────────────── test('PR 標題等同分支名', async (t) => { const stub = await withStub(t); const file = bodyFile('full', BODY); const { code, json } = await runFull(file, stub); assert.equal(code, 0, JSON.stringify(json)); const pull = stub.requests.find((r) => r.method === 'POST' && r.path.endsWith('/pulls')); assert.equal(pull.body.title, HEAD); assert.equal(json.data.title, HEAD); }); test('描述原樣送出,一個字都不改寫', async (t) => { const stub = await withStub(t); const file = bodyFile('verbatim', BODY); await runFull(file, stub); const pull = stub.requests.find((r) => r.method === 'POST' && r.path.endsWith('/pulls')); assert.equal(pull.body.body, BODY); }); test('--base 照給的值送出,不預設猜一個', async (t) => { // 目標專案的開發分支可能叫 master、main 或 develop,猜錯會開到不存在的 base const stub = await withStub(t); const file = bodyFile('base', BODY); await runFull(file, stub); assert.equal(stub.requests.find((r) => r.method === 'POST').body.base, 'master'); }); test('沒給 --base 時擋下,並說明為什麼不替你猜', async (t) => { const stub = await withStub(t); const file = bodyFile('nobase', BODY); const { json } = await runScript('pr-create.js', [ '--repo', REPO, '--head', HEAD, '--body-file', file, '--issue-repo', ISSUE_REPO, '--index', String(INDEX), ], { env: envFor(stub) }); assert.equal(json.error.code, 'MISSING_FLAG'); assert.match(json.error.message, /--base/); }); // ── 七段:少一段就擋 ─────────────────────────────────────────────── const SECTIONS = [ '摘要', '需求議題', '工作包議題', '變更內容', '設計重點', '解決的問題', '影響的功能', '測試結果', ]; for (const missing of SECTIONS) { test(`描述缺少「${missing}」時擋下,並指名缺的是哪一段`, async (t) => { const stub = await withStub(t); const body = BODY.split(/^## /m) .filter((part) => !part.startsWith(missing)) .join('## '); const file = bodyFile(`missing-${missing}`, body); const { code, json } = await runFull(file, stub); assert.equal(code, 1); assert.equal(json.error.code, 'MISSING_SECTION'); assert.match(json.error.message, new RegExp(missing)); assert.deepEqual(posts(stub), [], '描述不合格就不該開 PR'); }); } test('段落順序不對時也擋下:reviewer 每次要在同一個位置找到同一件事', async (t) => { const stub = await withStub(t); const swapped = BODY.replace( /## 設計重點([\s\S]*?)## 解決的問題([\s\S]*?)## 影響的功能/, '## 解決的問題$2## 設計重點$1## 影響的功能', ); const file = bodyFile('order', swapped); const { json } = await runFull(file, stub); assert.equal(json.error.code, 'SECTION_ORDER'); }); test('測試結果整段都是空話時擋下,不只看單行', async (t) => { const stub = await withStub(t); const body = BODY.replace(/## 測試結果[\s\S]*$/, '## 測試結果\n\n已測試通過\n無異常\n'); const file = bodyFile('multi-talk', body); const { json } = await runFull(file, stub); assert.equal(json.error.code, 'EMPTY_TEST_RESULT'); }); test('描述裡引用到「## 測試結果」這幾個字時,檢查的仍是真正那一段', async (t) => { const stub = await withStub(t); const body = BODY.replace( '新增 commit-split 與 pr-create 兩支腳本。', '新增兩支腳本,並要求 `## 測試結果` 這一段放實際輸出。', ); const file = bodyFile('quoted-heading', body); const { code, json } = await runFull(file, stub); assert.equal(code, 0, JSON.stringify(json)); }); // ── 測試結果不能是空話 ───────────────────────────────────────────── const EMPTY_TALK = ['已測試通過', '測試通過', '全部通過', '測試皆已通過', '無']; for (const talk of EMPTY_TALK) { test(`測試結果只寫「${talk}」時擋下`, async (t) => { const stub = await withStub(t); const body = BODY.replace(/## 測試結果[\s\S]*$/, `## 測試結果\n\n${talk}\n`); const file = bodyFile(`talk-${talk}`, body); const { code, json } = await runFull(file, stub); assert.equal(code, 1); assert.equal(json.error.code, 'EMPTY_TEST_RESULT'); assert.match(json.error.message, /實際跑過|手動驗證/); assert.deepEqual(posts(stub), []); }); } test('測試結果是空的時候擋下', async (t) => { const stub = await withStub(t); const file = bodyFile('empty', BODY.replace(/## 測試結果[\s\S]*$/, '## 測試結果\n\n')); const { json } = await runFull(file, stub); assert.equal(json.error.code, 'EMPTY_TEST_RESULT'); }); test('沒有自動化測試時,寫得出可重現的手動驗證步驟就放行', async (t) => { const stub = await withStub(t); const manual = BODY.replace( /## 測試結果[\s\S]*$/, '## 測試結果\n\n本工作包無自動化測試,手動驗證步驟:\n\n' + '1. 執行 `node scripts/pr-create.js --dry-run`\n' + '2. 確認印出的標題等於分支名\n', ); const file = bodyFile('manual', manual); const { code, json } = await runFull(file, stub); assert.equal(code, 0, JSON.stringify(json)); }); // ── 停錶 ─────────────────────────────────────────────────────────── test('PR 開完之後才停錶,順序不能反', async (t) => { const stub = await withStub(t); const file = bodyFile('stop', BODY); await runFull(file, stub); assert.deepEqual(posts(stub), [ `/api/v1/repos/${REPO}/pulls`, `/api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`, ]); }); test('錶停在議題所在的 repo,不是 PR 所在的 repo', async (t) => { // claim 在工作包議題上起錶,而 PR 開在目標專案上——兩者常常不是同一個 repo。 // 拿 PR 的 repo 去停錶,停到的是別人的議題,而自己的錶還在跑。 const stub = await withStub(t); const file = bodyFile('two-repos', BODY); const { code, json } = await runFull(file, stub); assert.equal(code, 0, JSON.stringify(json)); assert.equal(json.data.碼錶已停, true); assert.equal( posts(stub).some((path) => path.startsWith(`/api/v1/repos/${REPO}/issues/`)), false, '不該對 PR 的那個 repo 發停錶請求', ); }); test('沒給 --issue-repo 時,議題就在 PR 的同一個 repo 上', async (t) => { const stub = await withStubGitea(t, routes({ [`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 201, body: {} }, })); const file = bodyFile('same-repo', BODY); const { code } = await run(['--body-file', file, '--index', String(INDEX)], stub); assert.equal(code, 0); assert.ok(posts(stub).includes(`/api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`)); }); test('PR 開失敗時不停錶:工時要記在真的有做事的那段時間上', async (t) => { const stub = await withStub(t, { [`POST /api/v1/repos/${REPO}/pulls`]: { status: 422, body: { message: 'pull request already exists' } }, }); const file = bodyFile('fail', BODY); const { code, json } = await runFull(file, stub); assert.equal(code, 1); assert.equal( posts(stub).some((path) => path.endsWith('/stopwatch/stop')), false, 'PR 沒開成就不該停錶', ); assert.match(json.error.message, /422|already exists/); }); test('錶本來就沒在跑時不算失敗:PR 已經開出去了', async (t) => { // Gitea 對「沒有碼錶在跑」回 500;這時 PR 已經建立,不該把整件事報成失敗 const stub = await withStub(t, { [`POST /api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 500, body: { message: 'cannot stop a non existent stopwatch' }, }, }); const file = bodyFile('nowatch', BODY); const { code, json } = await runFull(file, stub); assert.equal(code, 0, JSON.stringify(json)); assert.equal(json.data.碼錶已停, false); assert.match(json.data.note ?? '', /碼錶/); }); test('沒給 --index 時擋下:停錶是這一步的一部分,忘了給會讓工時算不準', async (t) => { const stub = await withStub(t); const file = bodyFile('noindex', BODY); const { json } = await run(['--body-file', file], stub); assert.equal(json.error.code, 'MISSING_FLAG'); assert.match(json.error.message, /--index/); }); // ── 冪等:重跑不會開出第二顆 PR ─────────────────────────────────── test('同一個 head 已經有開著的 PR 時回傳既有那一顆,不再開一顆', async (t) => { const stub = await withStub(t, { [`GET /api/v1/repos/${REPO}/pulls`]: { status: 200, body: [{ number: 7, title: HEAD, html_url: 'https://example.com/7', head: { ref: HEAD } }], }, }); const file = bodyFile('dup', BODY); const { code, json } = await runFull(file, stub); assert.equal(code, 0, JSON.stringify(json)); assert.equal(json.data.created, false); assert.equal(json.data.number, 7); assert.equal( posts(stub).some((path) => path.endsWith('/pulls')), false, '既有的那一顆就是答案,不要再開一顆', ); }); test('已經有 PR 時照樣停錶:那一步可能是上次中斷的地方', async (t) => { const stub = await withStub(t, { [`GET /api/v1/repos/${REPO}/pulls`]: { status: 200, body: [{ number: 7, title: HEAD, html_url: 'https://example.com/7', head: { ref: HEAD } }], }, }); const file = bodyFile('dup-stop', BODY); const { json } = await runFull(file, stub); assert.equal(json.data.碼錶已停, true); }); test('別的分支的 PR 不算數', async (t) => { const stub = await withStub(t, { [`GET /api/v1/repos/${REPO}/pulls`]: { status: 200, body: [{ number: 7, title: '別的', html_url: 'https://example.com/7', head: { ref: 'feat/別的/main' } }], }, }); const file = bodyFile('other-branch', BODY); const { json } = await runFull(file, stub); assert.equal(json.data.created, true); }); // ── 輸入 ─────────────────────────────────────────────────────────── test('描述檔不存在時回可區分的錯誤碼', async (t) => { const stub = await withStub(t); const { json } = await run( ['--body-file', join(tmpRoot, '不存在的檔案.md'), '--index', String(INDEX)], stub, ); assert.equal(json.error.code, 'FILE_NOT_FOUND'); }); // ── --dry-run ───────────────────────────────────────────────────── test('--dry-run 印出將建立的 PR 與將停的錶,但不碰 Gitea', async (t) => { const stub = await withStub(t); const file = bodyFile('dry', BODY); const { code, json } = await runFull(file, stub, ['--dry-run']); assert.equal(code, 0); assert.equal(json.data.dryRun, true); assert.deepEqual( json.data.requests.map((r) => `${r.method} ${r.path}`), [ `POST /repos/${REPO}/pulls`, `POST /repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`, ], ); assert.equal(json.data.requests[0].body.title, HEAD, '試跑要看得到標題長什麼樣'); assert.equal(stub.requests.length, 0); }); test('--dry-run 照樣驗描述:不合格的描述不該等到實跑才發現', async (t) => { const stub = await withStub(t); const file = bodyFile('dry-bad', BODY.replace('## 測試結果', '## 測試結論')); const { json } = await runFull(file, stub, ['--dry-run']); assert.equal(json.error.code, 'MISSING_SECTION'); });