diff --git a/scripts/pr-watch.js b/scripts/pr-watch.js new file mode 100644 index 0000000..07e0619 --- /dev/null +++ b/scripts/pr-watch.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * 回報一顆 PR 的現況,並在它結束時清掉工作樹。 + * + * 一次性、無狀態、冪等:問一次答一次,**不做變化偵測**。「已處理」的判定基準是留言上 + * 自己打的 `+1` 與行內留言的 resolve,那個狀態已經存在 Gitea 上,所以一份現況快照就 + * 足以回答「還有沒有事要做」——不必跟上次的結果比較,也就不必在本機留任何游標或狀態檔。 + * + * **不是常駐程序也不是 daemon。** 「每隔多久跑一次」由呼叫端決定(cron、或 agent 工具 + * 自己的循環),本工具不長出排程器:daemon 要 pidfile,而本專案明定不在本機留狀態檔; + * 常駐前景程序雖然不留檔,卻把「監看中」綁在一個終端機 session 上。 + * + * **只通知,不動手。** 偵測到有未處理留言時只把建議動作放進輸出,不自動執行 `/sdlc-fix` + * ——流程不該被模型自動觸發,而 `/sdlc-fix` 要求「不確定時詢問使用者」,非互動模式下 + * 那個詢問無處可去,agent 只能自行決定,等於把一條驗收標準做成謊言。 + * + * 唯一會自動執行的副作用是清理工作樹,而且完全可逆(隨時能重建)。它只在 PR 已合併或 + * 已關閉時才發生;**被退回草稿時不清理**——那代表還要繼續改,這時候那棵工作樹更需要留著。 + * 有未提交變更就擋下並如實回報,絕不 `--force`。 + * + * 建議動作是**列舉值**而不是一段文字:呼叫端要能程式化判斷,而不是去解讀句子。 + * + * 用法: + * node scripts/pr-watch.js --repo owner/name --index 46 [--host <網址>] [--dry-run] + */ +import { + expectOk, + giteaRequest, + inspectWorktree, + main, + parseFlags, + parseIndex, + parseRepo, + preflight, + removeWorktree, + resolveLogin, + worktreePath, +} from './lib.js'; +import { fetchPull, readPullComments, unhandledCount } from './pr-threads.js'; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'index'], + optional: ['host'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const index = parseIndex(flags.index); + const dryRun = flags['dry-run'] === true; + + const login = resolveLogin({ host: flags.host }); + // 試跑照樣讀現況:這一支的輸出本來就是一份現況,手寫一份固定的清單等於什麼都沒回報 + if (!dryRun) await preflight(login, repo); + + const me = expectOk(await giteaRequest(login, 'GET', '/user'), 'GET /user').login; + const pull = await fetchPull(login, repo, index); + const state = stateOf(pull); + const 未處理留言數 = unhandledCount(await readPullComments(login, repo, index, me)); + + // 工作樹由 PR 自己的 head 分支推導,不必另外給——同一顆工作包算出來的永遠是同一條路徑 + const branch = pull.head?.ref ?? ''; + const worktree = worktreePath(repo, branch); + const terminal = state === 'merged' || state === 'closed'; + + // 終止狀態才清理。試跑只說要跑哪一行,不真的跑。 + const 清理 = terminal && !dryRun ? removeWorktree(worktree) : null; + const 工作樹 = 清理 ?? inspectWorktree(worktree); + const cleaned = 清理?.removed === true; + + const 報告 = { + repo, + index: pull.number, + title: pull.title, + url: pull.html_url, + branch, + state, + 未處理留言數, + 工作樹: { + 路徑: worktree, + // 清掉之後這兩個欄位講的是清理之前的狀況:cleaned 已經說了現在還在不在 + 存在: cleaned ? false : 工作樹.exists, + 有未提交變更: 工作樹.dirty, + 檔案: 工作樹.files, + }, + terminal, + cleaned, + suggestedAction: suggest({ terminal, cleaned, dryRun, 未處理留言數, 工作樹 }), + }; + + if (!dryRun) return 報告; + + return { + dryRun: true, + ...報告, + requests: [ + { method: 'GET', path: '/user' }, + { method: 'GET', path: `/repos/${repo}/pulls/${index}` }, + { method: 'GET', path: `/repos/${repo}/issues/${index}/comments` }, + { method: 'GET', path: `/repos/${repo}/pulls/${index}/reviews` }, + { method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` }, + ], + // 讀取是冪等的,試跑照樣發;會改變東西的只有這一行,所以只有它被留到這裡 + commands: terminal && 工作樹.exists && !工作樹.dirty ? [`git worktree remove ${worktree}`] : [], + }; +}); + + +/** + * PR 的四種狀態。 + * Gitea 的 `state` 只有 open/closed,合併與草稿各是另一個布林值——三個欄位湊成一種 + * 狀態,而處置是看那一種,不是看 `state`:merged 與 closed 都終止,draft 則要繼續監看。 + */ +function stateOf(pull) { + if (pull.merged === true) return 'merged'; + if (pull.state === 'closed') return 'closed'; + return pull.draft === true ? 'draft' : 'open'; +} + +/** + * 下一步該做什麼,固定四個值。 + * + * 順序有意義:有東西擋住清理時要先講那件事,因為它需要人動手;未處理的留言其次; + * 都沒有就是真的沒事。試跑時「該清而還沒清」講的是 cleanup——它是這次不做的那件事。 + */ +function suggest({ terminal, cleaned, dryRun, 未處理留言數, 工作樹 }) { + if (terminal) { + if (工作樹.exists && 工作樹.dirty) return 'blocked-dirty'; + if (dryRun && 工作樹.exists && 工作樹.worktree) return 'cleanup'; + return cleaned || !工作樹.exists ? 'nothing-to-do' : 'cleanup'; + } + return 未處理留言數 > 0 ? 'run-sdlc-fix' : 'nothing-to-do'; +} diff --git a/test/pr-watch.test.js b/test/pr-watch.test.js new file mode 100644 index 0000000..0c78f72 --- /dev/null +++ b/test/pr-watch.test.js @@ -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); +});