/** * 回報 PR 現況,並在它結束時清掉工作樹。 * * 這一支的價值在**四種 PR 狀態各自的處置**:merged 與 closed 是終止狀態,工作樹清掉; * open 繼續監看;draft 尤其要盯住——被退回草稿代表還要繼續改,這時候那棵工作樹更需要 * 留著,清掉它等於把人做到一半的環境收走。 * * 另外兩件事各有測試:建議動作是**列舉值**(呼叫端要程式化判斷,不是去讀一段文字), * 以及清理的守門(有未提交變更就擋下,絕不 `--force`)。 * * 一次性、無狀態:不與上次的結果比較,也不讀寫任何游標或狀態檔——「已處理」的判定 * 基準是 Gitea 上的 `+1` 與 resolve,現況快照本身就足以回答「還有沒有事要做」。 */ import test from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { runScript, tmpRoot } from './helpers/run-script.js'; import { makeTempRepoWithRemote } from './helpers/temp-repo.js'; import { healthyRoutes, stubEnv, withStubGitea } from './helpers/stub-gitea.js'; const REPO = 'plugins/tea-sdlc'; const INDEX = 46; const SLUG = 'pr-watch-and-cleanup'; const BRANCH = `feat/${SLUG}/main`; /** * 一顆 PR 的現場:狀態由 options 決定,留言預設沒有。 * 一般留言只有在沒有自己打的 `+1` 時才算未處理。 */ function routes({ state = 'open', merged = false, draft = false, general = [], head } = {}) { const base = healthyRoutes(REPO, { [`GET /api/v1/repos/${REPO}/pulls/${INDEX}`]: { status: 200, body: { number: INDEX, title: BRANCH, html_url: `https://gitea.jsc.idv.tw/${REPO}/pulls/${INDEX}`, state, merged, draft, // 預設是「PR 還開著」的樣子:head.ref 就是分支名。合併之後 Gitea 會換一種樣子, // 那一種由測試自己指定。 head: head ?? { ref: BRANCH, label: BRANCH }, }, }, [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { status: 200, body: general.map((c, i) => ({ id: 100 + i, body: c.body, user: { login: 'reviewer' } })), }, [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: [] }, [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, body: [] }, }); general.forEach((c, i) => { base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = { status: 200, body: (c.reactions ?? []).map((content) => ({ content, user: { login: c.reactedBy ?? 'tester' }, })), }; }); return base; } /** 備好一棵真的工作樹(由 branch-prep 建)與一台假 Gitea */ async function withScene(t, pr = {}) { const repo = makeTempRepoWithRemote(); t.after(() => repo.cleanup()); mkdirSync(tmpRoot, { recursive: true }); const home = mkdtempSync(join(tmpRoot, 'home-')); t.after(() => rmSync(home, { recursive: true, force: true })); const prep = await runScript( 'branch-prep.js', ['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', SLUG], { env: { TEA_SDLC_HOME: home } }, ); assert.equal(prep.json.ok, true, prep.json.error?.message); const stub = await withStubGitea(t, routes(pr)); const run = (args = []) => runScript('pr-watch.js', ['--repo', REPO, '--index', String(INDEX), ...args], { env: { ...stubEnv(stub), TEA_SDLC_HOME: home }, }); return { repo, home, stub, run, worktree: prep.json.data.worktree }; } // ── PR 狀態到處置的對照表 ───────────────────────────────────────── const DISPOSITION = [ { name: 'open:繼續監看,工作樹留著', pr: { state: 'open' }, expected: { state: 'open', terminal: false, cleaned: false, suggestedAction: 'nothing-to-do' }, 工作樹還在: true, }, { name: 'merged:終止,工作樹清掉', pr: { state: 'closed', merged: true }, expected: { state: 'merged', terminal: true, cleaned: true, suggestedAction: 'nothing-to-do' }, 工作樹還在: false, }, { name: 'closed:同樣終止,工作樹清掉', pr: { state: 'closed', merged: false }, expected: { state: 'closed', terminal: true, cleaned: true, suggestedAction: 'nothing-to-do' }, 工作樹還在: false, }, { name: 'draft:不終止也不清理——退回草稿代表還要繼續改', pr: { state: 'open', draft: true }, expected: { state: 'draft', terminal: false, cleaned: false, suggestedAction: 'nothing-to-do' }, 工作樹還在: true, }, ]; for (const { name, pr, expected, 工作樹還在 } of DISPOSITION) { test(`狀態:${name}`, async (t) => { const { run, worktree } = await withScene(t, pr); const { code, json } = await run(); assert.equal(code, 0, json.error?.message); assert.equal(json.data.state, expected.state); assert.equal(json.data.terminal, expected.terminal); assert.equal(json.data.cleaned, expected.cleaned); assert.equal(json.data.suggestedAction, expected.suggestedAction); assert.equal(existsSync(worktree), 工作樹還在); }); } test('清理只移除工作樹,本機分支留著', async (t) => { const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true }); await run(); assert.equal(existsSync(worktree), false); assert.equal( repo.git('branch', '--list', BRANCH).trim().replace(/^\*?\s*/, ''), BRANCH, '本機分支要留著,之後還能回頭看那段歷史', ); }); // ── 未處理留言 ───────────────────────────────────────────────────── test('有未處理留言時建議去跑 sdlc-fix,但不自己執行', async (t) => { const { run, stub } = await withScene(t, { general: [{ body: '這裡少了錯誤處理' }] }); const { json } = await run(); assert.equal(json.data.未處理留言數, 1); assert.equal(json.data.suggestedAction, 'run-sdlc-fix'); assert.deepEqual( stub.requests.filter((r) => r.method !== 'GET' && !r.path.endsWith('/issues/0')), [], '只通知不動手:監看不該替使用者回覆或標記任何東西', ); }); test('自己打過 +1 的留言算已處理,不再催', async (t) => { const { run } = await withScene(t, { general: [{ body: '這裡少了錯誤處理', reactions: ['+1'], reactedBy: 'tester' }], }); const { json } = await run(); assert.equal(json.data.未處理留言數, 0); assert.equal(json.data.suggestedAction, 'nothing-to-do'); }); test('別人打的 +1 不算已處理:那是「我同意」,不是「我處理過了」', async (t) => { const { run } = await withScene(t, { general: [{ body: '這裡少了錯誤處理', reactions: ['+1'], reactedBy: 'reviewer' }], }); const { json } = await run(); assert.equal(json.data.未處理留言數, 1); }); test('draft 上也照樣數留言,繼續監看', async (t) => { const { run, worktree } = await withScene(t, { state: 'open', draft: true, general: [{ body: '這段先別急著合併' }], }); const { json } = await run(); assert.equal(json.data.state, 'draft'); assert.equal(json.data.suggestedAction, 'run-sdlc-fix'); assert.equal(json.data.terminal, false); assert.equal(existsSync(worktree), true, '被退回草稿時更需要那棵工作樹'); }); // ── 清理的守門 ───────────────────────────────────────────────────── test('工作樹裡有未提交變更時擋下清理,並報出路徑', async (t) => { const { run, worktree } = await withScene(t, { state: 'closed', merged: true }); writeFileSync(join(worktree, 'wip.txt'), '做到一半\n'); const { code, json } = await run(); assert.equal(code, 0, '這是一份現況回報,擋下清理不等於整件事失敗'); assert.equal(json.data.terminal, true); assert.equal(json.data.cleaned, false); assert.equal(json.data.suggestedAction, 'blocked-dirty'); assert.equal(json.data.工作樹.有未提交變更, true); assert.deepEqual(json.data.工作樹.檔案, ['wip.txt']); assert.equal(existsSync(join(worktree, 'wip.txt')), true, '絕不 --force:沒提交的東西救不回來'); }); test('工作樹早就不在時不當成失敗,也不說自己清了', async (t) => { const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true }); repo.git('worktree', 'remove', worktree); const { code, json } = await run(); assert.equal(code, 0); assert.equal(json.data.工作樹.存在, false); assert.equal(json.data.cleaned, false); assert.equal(json.data.suggestedAction, 'nothing-to-do'); }); test('路徑上是別的 clone 留下的東西時,說出來而不是靜靜跳過', async (t) => { const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true }); repo.git('worktree', 'remove', worktree); mkdirSync(join(worktree, '.git'), { recursive: true }); const { code, json } = await run(); assert.equal(code, 0); assert.equal(json.data.工作樹.是工作樹, false, '.git 是目錄的是獨立 clone,不是工作樹'); assert.equal(json.data.cleaned, false); assert.equal(json.data.suggestedAction, 'cleanup', '要人動手,而手動出口會說出確切的原因'); assert.equal(existsSync(join(worktree, '.git')), true, '不是我們建的東西就不碰'); }); test('清不掉的路徑,--dry-run 不會預告一行實跑會拒絕的指令', async (t) => { // 試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差 const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true }); repo.git('worktree', 'remove', worktree); mkdirSync(worktree, { recursive: true }); writeFileSync(join(worktree, '別人的東西.txt'), 'x\n'); const { json } = await run(['--dry-run']); assert.deepEqual(json.data.commands, []); assert.equal(json.data.工作樹.是工作樹, false); }); // ── 合併之後的 head ─────────────────────────────────────────────── test('來源分支在合併時被刪掉,仍要推導出正確的工作樹並清掉它', async (t) => { // Gitea 在這種情況下把 head.ref 換成 refs/pull/{編號}/head,分支名退到 head.label。 // 拿 ref 去推導會算出一條不存在的路徑,然後靜靜回報「沒事要做」——而合併正是唯一 // 該動手清理的時機,等於自動清理在真實情況下從來不會成立。 const { run, worktree } = await withScene(t, { state: 'closed', merged: true, head: { ref: `refs/pull/${INDEX}/head`, label: BRANCH }, }); const { code, json } = await run(); assert.equal(code, 0, json.error?.message); assert.equal(json.data.branch, BRANCH, '分支名要取自 head.label'); assert.equal(json.data.工作樹.路徑, worktree); assert.equal(json.data.cleaned, true); assert.equal(existsSync(worktree), false); }); test('fork 來的 PR:head.label 是 owner:branch,只取分支那一段', async (t) => { const { run, worktree } = await withScene(t, { state: 'closed', merged: true, head: { ref: `refs/pull/${INDEX}/head`, label: `someone:${BRANCH}` }, }); const { json } = await run(); assert.equal(json.data.branch, BRANCH); assert.equal(existsSync(worktree), false, '推導出來的仍是同一條路徑'); }); test('分支名兩邊都取不到時明確中止,不拿空字串去推導路徑', async (t) => { const { run } = await withScene(t, { state: 'closed', merged: true, head: { ref: `refs/pull/${INDEX}/head` }, }); const { code, json } = await run(); assert.equal(code, 1); assert.equal(json.error.code, 'PULL_HEAD_MISSING'); assert.match(json.error.message, /worktree-remove/, '要指出手動出口怎麼指名那一棵'); }); // ── 回報內容 ─────────────────────────────────────────────────────── test('回報內容含 PR 狀態、未處理留言數與工作樹現況', async (t) => { const { run, worktree } = await withScene(t, { general: [{ body: '一則意見' }] }); const { json } = await run(); assert.equal(json.data.index, INDEX); assert.equal(json.data.url, `https://gitea.jsc.idv.tw/${REPO}/pulls/${INDEX}`); assert.equal(json.data.branch, BRANCH, '工作樹是從 PR 的 head 分支推導的,要說出用的是哪一支'); assert.equal(json.data.未處理留言數, 1); assert.deepEqual(json.data.工作樹, { 路徑: worktree, 存在: true, 是工作樹: true, 有未提交變更: false, 檔案: [], }); }); test('建議動作是固定的列舉值,呼叫端才能程式化判斷', async (t) => { const 列舉 = ['run-sdlc-fix', 'cleanup', 'nothing-to-do', 'blocked-dirty']; const { run } = await withScene(t); const { json } = await run(); assert.ok(列舉.includes(json.data.suggestedAction), `不在列舉裡:${json.data.suggestedAction}`); }); // ── 無狀態 ───────────────────────────────────────────────────────── test('跑兩次結果一樣,而且不留下任何游標或狀態檔', async (t) => { const { repo, home, run } = await withScene(t, { general: [{ body: '一則意見' }] }); const first = await run(); const second = await run(); assert.deepEqual(second.json, first.json, '不與上次比較,同樣的現況就該得到同樣的答案'); assert.deepEqual(readdirSync(home), ['worktrees'], '家目錄底下只該有工作樹本身'); assert.equal(repo.git('status', '--porcelain'), '', '目標專案裡不留任何東西'); }); // ── --dry-run ───────────────────────────────────────────────────── test('--dry-run 印出將執行的 git 指令與將發出的請求,且不清理', async (t) => { const { run, worktree } = await withScene(t, { state: 'closed', merged: true }); const { code, json } = await run(['--dry-run']); assert.equal(code, 0); assert.equal(json.data.dryRun, true); assert.deepEqual(json.data.commands, [`git worktree remove ${worktree}`]); assert.ok(json.data.requests.some((r) => r.path.endsWith(`/pulls/${INDEX}`))); assert.equal(json.data.cleaned, false); assert.equal(json.data.suggestedAction, 'cleanup', '試跑不動手,該做的事要說出來'); assert.equal(existsSync(worktree), true); }); test('--dry-run 在還不該清理的狀態下不印 git 指令', async (t) => { const { run } = await withScene(t, { state: 'open' }); const { json } = await run(['--dry-run']); assert.deepEqual(json.data.commands, []); }); // ── 錯誤 ─────────────────────────────────────────────────────────── test('PR 不存在時回可區分的錯誤碼', async (t) => { const { stub, run } = await withScene(t); stub.requests.length = 0; const { json } = await runScript( 'pr-watch.js', ['--repo', REPO, '--index', '999'], { env: stubEnv(stub) }, ); assert.equal(json.error.code, 'PULL_NOT_FOUND'); }); test('--index 不是正整數時擋在打 Gitea 之前', async (t) => { const { stub } = await withScene(t); const before = stub.requests.length; const { json } = await runScript('pr-watch.js', ['--repo', REPO, '--index', '0'], { env: stubEnv(stub), }); assert.equal(json.error.code, 'BAD_INDEX'); assert.equal(stub.requests.length, before); });