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>
This commit is contained in:
2026-09-17 09:16:34 +00:00
co-authored by Claude Opus 5
parent 767ca69c12
commit 42f5b86299
9 changed files with 1094 additions and 149 deletions
+344
View File
@@ -0,0 +1,344 @@
/**
* 回報 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);
});
+13
View File
@@ -133,6 +133,19 @@ test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔',
assert.match(boundary, /換一台機器或換一個 agent/, '要說明為什麼不留狀態檔');
});
test('第三段收尾時告訴使用者之後怎麼查 PR,但不自己反覆跑', () => {
assert.match(phase3, /pr-watch\.js/);
assert.match(phase3, /worktree-remove\.js/, '手動清理的出口也要講,否則沒人知道它在');
assert.match(phase3, /不要自己反覆跑/);
assert.match(phase3, /suggestedAction/, '建議動作是列舉值,要讓使用者知道有這個東西可以判斷');
});
test('邊界擋住「監看報了就自己去跑 sdlc-fix」', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不自動反覆執行 `pr-watch`/);
assert.match(boundary, /只由使用者明確叫用/);
});
// ── 第二段:逐項實作 ───────────────────────────────────────────────
test('第二段明講改的是工作樹裡的檔案,不是主工作區', () => {
+178
View File
@@ -0,0 +1,178 @@
/**
* 手動清理工作樹。
*
* 這一支的重點全在**守門**:清理會被 pr-watch 自動執行,而自動執行的東西只能做
* 可逆的事。工作樹裡還有沒提交的東西就中止,絕不 `--force`——刪掉的檔案救不回來。
* 移除只動工作樹,本機分支留著,使用者還能回頭看那段歷史。
*
* 路徑由 `owner/repo/分支名` 推導,不查表也不讀狀態檔,所以這裡連帶驗它與
* branch-prep 算出來的是同一條。
*
* git 不做替身:在臨時 git repo 上跑真的 git,以本機裸 repo 充當遠端,不需網路。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, 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';
const REPO = 'plugins/tea-sdlc';
/**
* 備好一棵真的工作樹:路徑由 branch-prep 建,測試不自己算,
* 兩支腳本推導出同一條路徑這件事本身就是要驗的東西。
*/
async function withWorktree(t, slug = 'mine') {
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 { json } = await runScript(
'branch-prep.js',
['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', slug],
{ env: { TEA_SDLC_HOME: home } },
);
assert.equal(json.ok, true, json.error?.message);
return { repo, home, branch: json.data.branch, worktree: json.data.worktree };
}
const run = (home, args) => runScript('worktree-remove.js', args, { env: { TEA_SDLC_HOME: home } });
// ── 乾淨時移除 ─────────────────────────────────────────────────────
test('工作樹乾淨時移除它,本機分支留著', async (t) => {
const { repo, home, branch, worktree } = await withWorktree(t);
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.removed, true);
assert.equal(existsSync(worktree), false, '工作樹要真的不見了');
assert.equal(
repo.git('branch', '--list', branch).trim().replace(/^\*?\s*/, ''),
branch,
'本機分支要留著:不佔什麼空間,而使用者還會回頭看那段歷史',
);
});
test('回報的路徑就是 branch-prep 建的那一條', async (t) => {
const { home, branch, worktree } = await withWorktree(t);
const { json } = await run(home, ['--repo', REPO, '--branch', branch]);
assert.equal(json.data.worktree, worktree, '兩支腳本要從同一個輸入推導出同一條路徑');
});
// ── 守門:有未提交變更就中止 ───────────────────────────────────────
test('工作樹裡有未提交的變更時中止,並報出路徑', async (t) => {
const { home, branch, worktree } = await withWorktree(t);
writeFileSync(join(worktree, 'README.md'), '改到一半的東西\n');
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
assert.equal(code, 1);
assert.equal(json.error.code, 'WORKTREE_DIRTY');
assert.match(json.error.message, new RegExp(worktree), '要指名是哪一條路徑,人才找得到它');
assert.match(json.error.message, /README\.md/, '也要說出是哪些檔案擋住了');
assert.equal(existsSync(worktree), true, '擋下來就不該已經刪掉');
assert.equal(existsSync(join(worktree, 'README.md')), true);
});
test('未追蹤的檔案同樣算未提交:它一樣會被刪掉', async (t) => {
const { home, branch, worktree } = await withWorktree(t);
writeFileSync(join(worktree, 'notes.txt'), '還沒加進版控的筆記\n');
const { json } = await run(home, ['--repo', REPO, '--branch', branch]);
assert.equal(json.error.code, 'WORKTREE_DIRTY');
assert.match(json.error.message, /notes\.txt/);
});
// ── 冪等 ───────────────────────────────────────────────────────────
test('工作樹本來就不在時安靜地成功,重跑不會失敗', async (t) => {
const { home, branch } = await withWorktree(t);
await run(home, ['--repo', REPO, '--branch', branch]);
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.removed, false);
assert.equal(json.data.已經不在, true, '要說清楚是本來就不在,不是這次刪的');
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出將執行的 git 指令,且不移除任何東西', async (t) => {
const { home, branch, worktree } = await withWorktree(t);
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch, '--dry-run']);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.deepEqual(json.data.commands, [`git worktree remove ${worktree}`]);
assert.equal(
json.data.commands.some((c) => c.includes('--force')),
false,
'絕不 --force:自動執行的東西只能做可逆的事',
);
assert.equal(existsSync(worktree), true, '試跑不該真的刪掉');
});
test('--dry-run 也照樣把守門的結果說出來', async (t) => {
// 試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差
const { home, branch, worktree } = await withWorktree(t);
writeFileSync(join(worktree, 'wip.txt'), '做到一半\n');
const { json } = await run(home, ['--repo', REPO, '--branch', branch, '--dry-run']);
assert.equal(json.error.code, 'WORKTREE_DIRTY');
});
// ── flag ──────────────────────────────────────────────────────────
test('缺 --branch 時指名缺的是哪一個', async (t) => {
const { home } = await withWorktree(t);
const { json } = await run(home, ['--repo', REPO]);
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--branch/);
});
test('--repo 格式不是 owner/name 時失敗', async (t) => {
const { home } = await withWorktree(t);
const { json } = await run(home, ['--repo', 'tea-sdlc', '--branch', 'feat/mine/main']);
assert.equal(json.error.code, 'BAD_REPO');
});
test('路徑上是一個獨立的 clone 時也擋下:對它下 worktree remove 只會得到一句 git 的原始錯誤', async (t) => {
const { repo, home, branch, worktree } = await withWorktree(t);
repo.git('worktree', 'remove', worktree);
mkdirSync(join(worktree, '.git'), { recursive: true });
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
assert.equal(code, 1);
assert.equal(json.error.code, 'NOT_A_WORKTREE');
assert.equal(existsSync(join(worktree, '.git')), true);
});
test('推導出來的路徑上是別人的東西時不碰它', async (t) => {
const { repo, home, branch, worktree } = await withWorktree(t);
repo.git('worktree', 'remove', worktree);
mkdirSync(worktree, { recursive: true });
writeFileSync(join(worktree, '別人的東西.txt'), 'x\n');
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
assert.equal(code, 1);
assert.equal(json.error.code, 'NOT_A_WORKTREE');
assert.equal(existsSync(join(worktree, '別人的東西.txt')), true);
});