Files
tea-sdlc/scripts/pr-watch.js
T
jiantw83andClaude Opus 5 695694b3fd fix(pr-watch): 來源分支被刪掉之後,改從 head.label 取分支名
PR 合併而來源分支被刪掉之後,Gitea 把 head.ref 換成 refs/pull/{編號}/head,分支名
退到 head.label。pr-watch 用的是 head.ref,於是推導出一條根本不存在的工作樹路徑,
回報「工作樹不在、沒事要做」——而合併正是唯一該清理工作樹的時機,等於自動清理在真實
情況下從來不會成立,而且靜悄悄的,沒有人會發現。

實測 #48(合併後)推導出 bb4f5127f641,真正的那一棵在 bd457620184e。

fork 來的 PR 的 label 是 owner:branch,只取分支那一段。兩邊都取不到時明確中止
(PULL_HEAD_MISSING),並指出手動出口怎麼指名那一棵,不拿空字串去推導路徑。

測試先前沒抓到,是因為假的 PR 一律照「開著的 PR」寫,head.ref 永遠是分支名。
補上「已合併且來源分支已刪」與 fork 兩種 head 的樣子。

議題 #50

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

168 lines
7.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 自己的來源分支推導,不必另外給——同一顆工作包算出來的永遠是同一條路徑
const branch = headBranch(pull);
if (branch === '') {
throw new ScriptError(
'PULL_HEAD_MISSING',
`PR #${index} 讀不到來源分支名,推導不出工作樹在哪;` +
'請改用 worktree-remove --repo <owner/name> --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';
}