Files
tea-sdlc/test/pr-watch.test.js
T
jiantw83andClaude Opus 5 42f5b86299 merge(pr-留言): 把議題支援套到 pr-threads 的共用結構上
#48 把留言讀取抽成 pr-threads 給 pr-watch 與 pr-comments 共用,本分支則在修「純議題讀不了」
與「已處理的判定有兩份」。兩邊動到同一塊,但要的其實是同一件事——#48 的檔頭就寫著
「規則寫兩份遲早會各自演化,一邊認自己打的 +1、另一邊認任何人的」,而那正是本分支在
lib 與 pr-comments 之間發現的那個 bug。

合併的方向是保留 pr-threads 的結構,把修正套進去:

- readGeneral 改名 readGeneralComments 並導出,pr-comments 判斷出是純議題時只叫它。
  先讀 /issues/{index} 再決定要不要翻 review——每個 PR 都是議題,反過來不成立。
- pr-threads 自己那份 markedByMe 拿掉,改用 lib 的 mergedByMe。抽取契約數未整併則數
  用的是同一條規則,現在三處共用一份,#48 擔心的分歧不會再發生。
- pr-comments 的試跑改印 commonRequests:它收得下兩種輸入,而試跑階段還沒讀過議題、
  不知道是哪一種。與其假設是 PR 而列出五個(對純議題有三個根本不會發),不如只列一定
  會發的,其餘交給 note。pr-watch 的輸入一定是 PR,繼續用 plannedRequests。

lib.js 與 sdlc-feat.md 兩邊改的是不同區域,三方合併無衝突。

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

345 lines
14 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('路徑上是別的 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);
});
// ── 回報內容 ───────────────────────────────────────────────────────
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);
});