#!/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 { ScriptError, expectOk, giteaRequest, inspectWorktree, main, parseFlags, parseIndex, parseRepo, preflight, removeWorktree, resolveLogin, worktreePath, } from './lib.js'; import { COMMENT_REQUEST_NOTE, fetchPull, plannedRequests, 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 自己的來源分支推導,不必另外給——同一顆工作包算出來的永遠是同一條路徑 const branch = headBranch(pull); if (branch === '') { throw new ScriptError( 'PULL_HEAD_MISSING', `PR #${index} 讀不到來源分支名,推導不出工作樹在哪;` + '請改用 worktree-remove --repo --branch <分支名> 指名要清哪一棵', ); } const worktree = worktreePath(repo, branch); const terminal = state === 'merged' || state === 'closed'; // 終止狀態才清理。試跑只說要跑哪一行,不真的跑。 const 清理 = terminal && !dryRun ? removeWorktree(worktree) : null; const 工作樹 = 清理 ?? inspectWorktree(worktree); const cleaned = 清理?.removed === true; // 試跑要預告的那一行,條件與實跑完全同一個:reason 由 lib 算,兩邊不各判一次 const 清得掉 = 工作樹.reason === 'removable'; const 報告 = { repo, index: pull.number, title: pull.title, url: pull.html_url, branch, state, 未處理留言數, 工作樹: { 路徑: worktree, // 清掉之後這幾個欄位講的是清理之前的狀況:cleaned 已經說了現在還在不在 存在: cleaned ? false : 工作樹.exists, // 路徑上有東西卻不是工作樹(多半是別的 clone 留下的)時,清理不會發生也不該 // 靜靜跳過——手動出口會給出 NOT_A_WORKTREE,這個欄位是它的前情提要 是工作樹: 工作樹.isWorktree, 有未提交變更: 工作樹.dirty, 檔案: 工作樹.files, }, terminal, cleaned, suggestedAction: suggest({ terminal, cleaned, 清得掉, 未處理留言數, 工作樹 }), }; if (!dryRun) return 報告; return { dryRun: true, ...報告, requests: plannedRequests(repo, index), note: COMMENT_REQUEST_NOTE, // 讀取是冪等的,試跑照樣發;會改變東西的只有這一行,所以只有它被留到這裡 commands: terminal && 清得掉 ? [`git worktree remove ${worktree}`] : [], }; }); /** * PR 的來源分支名。 * * **不能只看 `head.ref`。** PR 合併而來源分支被刪掉之後,Gitea 會把 `head.ref` 換成 * `refs/pull/{編號}/head`,分支名退到 `head.label`——而合併正是唯一該清理工作樹的時機。 * 拿那個 ref 去推導會算出一條根本不存在的路徑,然後回報「工作樹不在、沒事要做」: * 自動清理於是在真實情況下從來不會成立,而且靜悄悄的,沒有人會發現。 * * fork 來的 PR 的 label 是 `owner:branch`,只取分支那一段——工作樹是以分支名推導的。 */ function headBranch(pull) { const label = (pull.head?.label ?? '').trim(); const 分支 = label.includes(':') ? label.slice(label.indexOf(':') + 1) : label; if (分支 !== '') return 分支; // label 缺席時才退回 ref,而且 refs/pull/… 不是分支名,寧可說讀不到 const ref = (pull.head?.ref ?? '').trim(); return /^refs\/pull\//.test(ref) ? '' : ref; } /** * 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'; } /** * 下一步該做什麼,固定四個值。 * * 終止的 PR 只問清理這件事:有沒提交的東西卡著就 blocked-dirty(要人自己處理), * 清掉了或本來就不在就沒事了,其餘都還有一棵樹等著清——試跑不動手,路徑上是別的 * clone 留下的東西也一樣,兩種都落在 cleanup,由手動出口給出確切的原因。 * * 還沒終止的 PR 只問留言:有沒處理完的就建議去跑 /sdlc-fix,但只是建議。 */ function suggest({ terminal, cleaned, 清得掉, 未處理留言數, 工作樹 }) { if (!terminal) return 未處理留言數 > 0 ? 'run-sdlc-fix' : 'nothing-to-do'; if (工作樹.reason === 'dirty') return 'blocked-dirty'; if (cleaned || 工作樹.reason === 'missing') return 'nothing-to-do'; return 清得掉 || 工作樹.reason === 'foreign' ? 'cleanup' : 'nothing-to-do'; }