Files
tea-sdlc/scripts/pr-watch.js
T
jiantw83andClaude Opus 5 617a3eef72 fix(pr-watch): 試跑的預告與實跑的守門對齊,並說出路徑被佔住這件事
review 抓到一處落差:試跑判「終止且工作樹乾淨」就預告 git worktree remove,
但實跑多判一件事——那條路徑上的東西是不是真的一棵工作樹。別的 clone 在同一條路徑上
留下目錄時(路徑由 owner/repo/分支名 推導,不含本機 clone 的位置),試跑會預告一行
實跑必然拒絕的指令,而回報裡完全看不出原因。

判斷收進 lib 的 inspectWorktree,由它直接給出 reason(missing/foreign/dirty/
removable):試跑與實跑、自動與手動四條路徑從此擋在同一個判斷上,worktree-remove 裡
那份重算的副本也跟著刪掉。回報多一個「是工作樹」欄位,手動出口的 NOT_A_WORKTREE
不再是使用者第一次聽到這件事。

順帶兩件同源的修正:
- 「是不是工作樹」改認 .git 為**檔案**。獨立 clone 的 .git 是目錄,先前會被當成工作樹,
  然後在 git worktree remove 那一步炸出一句原始錯誤。
- 兩支腳本的 --dry-run 請求預告收進 pr-threads 的 plannedRequests,並補上 pr-watch
  先前漏掉的那句說明(逐則 reaction 與逐個 review 的行內留言事前列不完)。預告與實際
  發出的請求分開寫,加一個端點就會有一邊忘了改。
- PR 讀不到 head 分支時給出 PULL_HEAD_MISSING,而不是拿空字串去推導一條路徑。

議題 #41

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

148 lines
6.1 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.
#!/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 自己的 head 分支推導,不必另外給——同一顆工作包算出來的永遠是同一條路徑
const branch = pull.head?.ref;
if (!branch) {
throw new ScriptError(
'PULL_HEAD_MISSING',
`PR #${index} 讀不到 head 分支(來源分支可能已經被刪掉),推導不出工作樹在哪;` +
'請改用 worktree-remove --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 的四種狀態。
* 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';
}