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>
This commit is contained in:
2026-09-17 09:16:34 +00:00
co-authored by Claude Opus 5
parent 767ca69c12
commit 42f5b86299
9 changed files with 1094 additions and 149 deletions
+147
View File
@@ -0,0 +1,147 @@
#!/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';
}