Files
tea-sdlc/scripts/pr-watch.js
T
jiantw83andClaude Opus 5 42f5b86299 merge(pr-留言): 把議題支援套到 pr-threads 的共用結構上
#48 把留言讀取抽成 pr-threads 給 pr-watch 與 pr-comments 共用,本分支則在修「純議題讀不了」
與「已處理的判定有兩份」。兩邊動到同一塊,但要的其實是同一件事——#48 的檔頭就寫著
「規則寫兩份遲早會各自演化,一邊認自己打的 +1、另一邊認任何人的」,而那正是本分支在
lib 與 pr-comments 之間發現的那個 bug。

合併的方向是保留 pr-threads 的結構,把修正套進去:

- readGeneral 改名 readGeneralComments 並導出,pr-comments 判斷出是純議題時只叫它。
  先讀 /issues/{index} 再決定要不要翻 review——每個 PR 都是議題,反過來不成立。
- pr-threads 自己那份 markedByMe 拿掉,改用 lib 的 mergedByMe。抽取契約數未整併則數
  用的是同一條規則,現在三處共用一份,#48 擔心的分歧不會再發生。
- pr-comments 的試跑改印 commonRequests:它收得下兩種輸入,而試跑階段還沒讀過議題、
  不知道是哪一種。與其假設是 PR 而列出五個(對純議題有三個根本不會發),不如只列一定
  會發的,其餘交給 note。pr-watch 的輸入一定是 PR,繼續用 plannedRequests。

lib.js 與 sdlc-feat.md 兩邊改的是不同區域,三方合併無衝突。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 09:16:34 +00: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';
}