Files
tea-sdlc/test/pr-watch.test.js
T
jiantw83andClaude Opus 5 d331bef1ed feat(pr-watch): 回報 PR 現況,並在它結束時清掉工作樹
開發者要能隨時問一句「這個 PR 現在怎麼樣、我還有什麼要做」。回報 PR 狀態、還有幾則
留言沒處理、工作樹在哪、裡面有沒有沒提交的東西,以及固定列舉值的 suggestedAction
——用列舉值而不是一段文字,呼叫端才能程式化判斷。

一次性、無狀態:不做變化偵測。已處理的判定基準是 Gitea 上的 +1 與 resolve,那個狀態
不在本機記憶裡,所以一份現況快照就足以回答「還有沒有事要做」,不必跟上次比較,也就
不必留任何游標或狀態檔。不做常駐程序也不做 daemon,排程交給呼叫端。

只通知,不動手:偵測到未處理留言只給建議,不自動執行 /sdlc-fix——流程不該被模型自動
觸發,而 /sdlc-fix 要求「不確定時詢問使用者」,非互動模式下那個詢問無處可去。

四種狀態的處置不一致,所以表格驅動測:merged 與 closed 終止並清理;open 繼續監看;
draft 繼續監看且**不清理**——被退回草稿代表還要繼續改,這時候那棵工作樹更需要留著。

議題 #41

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:59:30 +08:00

312 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 回報 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 = [] } = {}) {
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,
head: { ref: 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('回報內容含 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, 有未提交變更: 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);
});