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>
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 回報 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);
|
||||
});
|
||||
Reference in New Issue
Block a user