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,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';
|
||||
}
|
||||
Reference in New Issue
Block a user