From 99abdf725bb9d4f77829061d62e4e3d7b645d5ad Mon Sep 17 00:00:00 2001 From: claude-code Date: Fri, 28 Aug 2026 00:27:39 +0000 Subject: [PATCH] =?UTF-8?q?PR=20=E4=B8=80=E5=AE=9A=E8=A6=81=E6=9C=89?= =?UTF-8?q?=E7=B5=90=E8=AB=96=EF=BC=88inkstone/ISEP#81=EF=BC=89=EF=BC=88ho?= =?UTF-8?q?oks/pr-verdict-guard.sh=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/pr-verdict-guard.sh | 394 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 hooks/pr-verdict-guard.sh diff --git a/hooks/pr-verdict-guard.sh b/hooks/pr-verdict-guard.sh new file mode 100644 index 0000000..574fba8 --- /dev/null +++ b/hooks/pr-verdict-guard.sh @@ -0,0 +1,394 @@ +#!/usr/bin/env bash +# 管什麼: 收工那一刻清點「我這邊還有沒有 PR 沒給結論」——open 且沒人在管的 PR、 +# 以及已經 merge 但**分支還留著**的 PR。有就擋一次並點名是哪幾個。 +# 為什麼: 2026-08-27 實查,最久的三個 open PR 躺了兩星期(inkstone/arcrun-rag#91、 +# inkstone/Arcrun#116、inkstone/Arcrun#104),而 v0.9.0 的 46 支閘沒有一支在管 PR。 +# 票看起來「已交付」,但東西沒進 main ⇒ 沒進版本 ⇒ leo 手上永遠不會出現它。 +# 誤觸時怎麼關: 直接再送一次即可(擋完就歸零,而且那幾個 PR 的門檻已加倍)。 +# 真的在等別人 ⇒ 把 PR 指派給他(或掛 Human),本閘立刻不再點名它。 +# +# pr-verdict-guard.sh — PR 收斂警察(Stop;inkstone/ISEP#81) +# +# ── 為什麼是 Stop 而不是 PreToolUse ────────────────────────────────── +# PR 堆積不是「某個動作做錯了」,是「某個動作從來沒發生」。 +# 沒有發生的事情攔不到,只能在收工那一刻清點。同 worklist-guard.sh 的形狀。 +# **刻意只掛 Stop,不掛 SubagentStop**:給 PR 結論是總管的事, +# subagent 手上沒有那個權(它連 main 都不准推)⇒ 掛上去只會變成對它的誤攔。 +# +# ── 這支跟 baton-handback-guard.sh 是接力,不是重複 ──────────────── +# baton 管「棒子有沒有交回來」(票的指派+tag+下一步三格)。 +# 本支管「交回來之後,總管有沒有動它」——PR 是那根棒子的實體。 +# +# ── 🔴 已知的坑:這支很容易長成「讓總管永遠停不下來」的那一支 ────── +# ISEP v0.6.0 的 divergence 文件 §B4 記過同款: +# 「`s/review` 佇列非空即 block 總管 stop」⇒ 佇列本來就不會空 ⇒ 永遠擋。 +# ⇒ **要擋的是遺忘,不是等待。** 本閘用三層把「等待」全部放掉: +# +# ① 有人被指派(或掛 Human)⇒ 不點名。 +# 指派是 Gitea 原生欄位、也是本 repo 既有的「現在誰該動」真相 +# (見 baton-handback-guard 檔頭那張三格表)。棒子在誰手上是**事實**, +# 不是措辭 ⇒ 有人拿著 = 在等,不是忘了。 +# ② 有一則 REQUEST_CHANGES 的 review ⇒ 不點名。 +# 「要求修改」這個結論已經給了,球在對方腳下。 +# ③ 這一回合碰過它 ⇒ 不點名(見下面「怎麼算碰過」)。 +# ④ 點名過就退讓:那個 PR 的門檻 1 → 4 → 8 → 16…(每 PR 各自算)。 +# ⇒ 不可能鎖死:擋完歸零,再送一次就過;真的長期不理才會再響。 +# +# ── 怎麼算「碰過」:不讀任何一句話 ────────────────────────────── +# ① `updated_at` 跟上一次收工比變了 ⇒ 碰過(留言/改標籤/推 commit/submit review +# 全部會動它)。這是**外部系統的事實**,跟我在對話裡怎麼寫無關。 +# ② 上一次收工時還不存在的 PR ⇒ 剛開出來 ⇒ 碰過(不在它誕生的那一回合就開罵)。 +# ③ 這一回合的 tool call 裡出現它的**識別碼**(`owner/repo#N`/`/pulls/N`/它的網址) +# ⇒ 碰過。這一條是為了接住「結論寫在票上」——票被改了、PR 沒被改, +# ①②都看不到,但那正是本票規定的其中一種合法結論。 +# +# 🔴 ③ 不是關鍵字黑名單(leo 2026-08-17 已證偽那條路:當日 8 次誤攔、0 次正確攔截)。 +# 差別在方向與集合:黑名單拿**措辭**來**擋**(措辭無限,永遠追不完); +# 這裡拿**識別碼**來**放行**(識別碼是有限且唯一的,而且比對錯了只會少擋一次)。 +# 本 repo 心法:只准往放行的方向做字面比對。 +# +# ── merge 了但分支還在 = 結論只給了一半 ──────────────────────── +# 票上寫死:「merge → 併完當場刪掉那條 branch」。所以某個 PR 從 open 清單消失時, +# 本閘去看它是不是 merged、head 分支還在不在;還在就一起點名。 +# (只在「上次還 open、這次不見了」時查,平常一通 API 都不多打。) +# +# ── 內部錯誤不准靜默放行 ──────────────────────────────────────── +# Stop 閘 fail-closed 會鎖死 session,而誤攔是本 repo 的第一級缺陷 +# ⇒ 內部錯誤走 exit 0,但留痕(log + stderr 各一行)。同 mainline-idle-guard.sh。 +# +# 迴歸測試:hooks/tests/pr-verdict-guard.test.sh(離線,走 PR_VERDICT_FIXTURE) +set -u + +PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" +PAYLOAD=$(cat 2>/dev/null || echo '{}') + +# ── token:同 scripts/ticket 的做法(掃 remote 找帶憑證的,再退到環境變數)── +TOKEN="" +if [ -z "${PR_VERDICT_FIXTURE:-}" ]; then + for d in "$PROJ" "$PROJ/products"/* "$PROJ/matrix"/* "$PROJ/polaris"/*; do + [ -e "$d/.git" ] || continue + U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true + if [ -n "${U:-}" ]; then + T=$(printf '%s' "$U" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') + [ "$T" != "$U" ] && { TOKEN="$T"; break; } + fi + done + [ -n "$TOKEN" ] || TOKEN="${GITEA_TOKEN_CLAUDE_CODE:-${GITEA_TOKEN:-}}" +fi + +# PR_VERDICT_DEBUG=1 時把內部錯誤原文放出來(平常吞掉,避免雜訊蓋掉閘的訊息) +ERRSINK=/dev/null +[ -n "${PR_VERDICT_DEBUG:-}" ] && ERRSINK=/dev/stderr + +# 🔴 payload 走環境變數,不走 stdin——`python3 - <"$ERRSINK" +import json, os, re, sys, time +import urllib.error, urllib.parse, urllib.request + +HOST = os.environ.get("PR_VERDICT_HOST", "https://git.uncle6.me") +ORGS = [o.strip().lower() for o in + os.environ.get("PR_VERDICT_ORGS", "inkstone").split(",") if o.strip()] +FIXTURE = os.environ.get("PR_VERDICT_FIXTURE", "") +TOKEN = os.environ.get("GITEA_TOKEN_RESOLVED", "") + +def out(s): + sys.stdout.write(s) + raise SystemExit + +try: + d = json.loads(os.environ.get("PR_VERDICT_PAYLOAD") or "{}") +except Exception: + out("CRASH:bad-payload") + +# 已被別的 Stop 閘擋過這一輪 ⇒ 不計數、不擋(避免疊擋,同 mainline-idle-guard) +if d.get("stop_hook_active"): + out("SKIP:already-nudged") + +sid = re.sub(r"[^A-Za-z0-9_.-]", "_", str(d.get("session_id") or "nosid"))[:64] +state_path = os.path.join(os.environ.get("PR_VERDICT_STATE_DIR", "/tmp"), + ".pr-verdict-%s.json" % sid) + +# ── 資料來源:正式打 Gitea,測試走 fixture(單一 JSON 檔或目錄下的 state.json)── +FX = None +if FIXTURE: + p = FIXTURE if os.path.isfile(FIXTURE) else os.path.join(FIXTURE, "state.json") + try: + with open(p) as f: + FX = json.load(f) + except Exception: + out("CRASH:bad-fixture") + +def api(path, soft=True): + """GET 一筆。fixture 模式完全不碰網路。失敗回 None(不影響該不該擋的核心判斷)。""" + if FX is not None: + return None + if not TOKEN: + return None + try: + req = urllib.request.Request(HOST + "/api/v1" + path, + headers={"Authorization": "token " + TOKEN}) + return json.load(urllib.request.urlopen(req, timeout=12)) + except urllib.error.HTTPError as e: + return {"__status__": e.code} + except Exception: + return None + +# ── ① 撈 open PR(一通跨 repo 的呼叫)───────────────────────────── +if FX is not None: + pulls = FX.get("pulls", []) +else: + if not TOKEN: + out("SKIP:no-token") + pulls = api("/repos/issues/search?type=pulls&state=open&limit=50") + if pulls is None or isinstance(pulls, dict): + out("SKIP:api-unreachable") + +def ref_of(pr): + return "%s#%s" % (pr.get("repository", {}).get("full_name", "?"), pr.get("number")) + +live = {} +for pr in pulls: + full = (pr.get("repository") or {}).get("full_name") or "" + if "/" not in full: + continue + if ORGS and full.split("/", 1)[0].lower() not in ORGS: + continue # 只管我這邊的 org;舊 org 的鏡像不是我的責任 + if (pr.get("pull_request") or {}).get("draft"): + continue # draft = 作者自己說「還沒好」,不是等我給結論 + live[ref_of(pr)] = pr + +# ── ② 讀上一次收工的快照 ───────────────────────────────────────── +st = {} +try: + with open(state_path) as f: + st = json.load(f) +except Exception: + st = {} +seen = st.get("seen") or {} +leftover = st.get("leftover") or {} # ref -> branch,merge 完分支還沒刪的 +first_run = not seen and "seen" not in st + +# ── ③ 這一回合的 tool call 裡提到了哪些識別碼(只用來放行)────────── +turn_blob = "" +tp = d.get("transcript_path") or "" +if tp and os.path.exists(tp): + try: + rows = [] + with open(tp) as f: + for line in f: + line = line.strip() + if line: + try: + rows.append(json.loads(line)) + except Exception: + pass + start = 0 + for i, r in enumerate(rows): + if r.get("type") == "user": + c = (r.get("message") or {}).get("content") + blocks = c if isinstance(c, list) else [{"type": "text"}] + if not any(isinstance(b, dict) and b.get("type") == "tool_result" + for b in blocks): + start = i + chunks = [] + for r in rows[start:]: + if r.get("type") != "assistant": + continue + for b in (r.get("message") or {}).get("content") or []: + if isinstance(b, dict) and b.get("type") == "tool_use": + chunks.append(json.dumps(b.get("input") or {}, ensure_ascii=False)) + turn_blob = "\n".join(chunks) + except Exception: + turn_blob = "" + +def mentioned(ref): + """這一回合的 tool call 有沒有指名這個 PR。識別碼比對,只用來放行。""" + if not turn_blob: + return False + full, num = ref.split("#") + owner, repo = full.split("/", 1) + pats = ["%s#%s" % (full, num), "%s#%s" % (repo, num), + "/pulls/%s" % num, "/pull/%s" % num] + return any(p in turn_blob for p in pats) + +# ── ④ merge 完分支還在不在(只在「上次還 open、這次不見了」時查)──── +def branch_alive(owner, repo, br): + if FX is not None: + return bool((FX.get("branches") or {}).get("%s/%s:%s" % (owner, repo, br))) + r = api("/repos/%s/%s/branches/%s" % (owner, repo, urllib.parse.quote(br, safe=""))) + if isinstance(r, dict) and r.get("__status__") == 404: + return False + return isinstance(r, dict) and "name" in r + +def pr_detail(ref): + if FX is not None: + return (FX.get("detail") or {}).get(ref) + full, num = ref.split("#") + owner, repo = full.split("/", 1) + r = api("/repos/%s/%s/pulls/%s" % (owner, repo, num)) + return r if isinstance(r, dict) and "__status__" not in r else None + +for ref in list(seen.keys()): + if ref in live: + continue + det = pr_detail(ref) # 不見了:是被 merge 還是被 close? + if not det or not det.get("merged"): + continue + br = ((det.get("head") or {}).get("ref")) or "" + if not br: + continue + full = ref.split("#")[0] + owner, repo = full.split("/", 1) + if branch_alive(owner, repo, br): + leftover[ref] = br + +for ref in list(leftover.keys()): # 已經刪掉的就從清單移除 + full = ref.split("#")[0] + owner, repo = full.split("/", 1) + if not branch_alive(owner, repo, leftover[ref]): + leftover.pop(ref, None) + +# ── ⑤ 逐個 open PR 判「有沒有人在管」────────────────────────────── +def reviews_of(ref): + if FX is not None: + return (FX.get("reviews") or {}).get(ref) or [] + full, num = ref.split("#") + owner, repo = full.split("/", 1) + r = api("/repos/%s/%s/pulls/%s/reviews" % (owner, repo, num)) + return r if isinstance(r, list) else [] + +try: + BASE = max(1, int(os.environ.get("PR_VERDICT_THRESHOLD", "1"))) +except Exception: + BASE = 1 + +overdue, newseen = [], {} +for ref, pr in live.items(): + prev = seen.get(ref) or {} + rec = {"updated_at": pr.get("updated_at"), + "quiet": int(prev.get("quiet", 0) or 0), + "threshold": int(prev.get("threshold", BASE) or BASE), + "fires": int(prev.get("fires", 0) or 0)} + newseen[ref] = rec + + # 有人被指派/掛了 Human = 棒子在某個人手上 ⇒ 在等,不是忘了 + if pr.get("assignees"): + rec["quiet"] = 0 + continue + if any((l.get("name") or "").lower() == "human" for l in (pr.get("labels") or [])): + rec["quiet"] = 0 + continue + # 上一次收工時還不存在 ⇒ 剛開出來,別在它誕生的那一回合就開罵 + if not first_run and ref not in seen: + rec["quiet"] = 0 + continue + # 這一回合真的動過它(外部事實)/指名過它(識別碼) + if prev.get("updated_at") and pr.get("updated_at") != prev.get("updated_at"): + rec["quiet"] = 0 + continue + if mentioned(ref): + rec["quiet"] = 0 + continue + + rec["quiet"] += 1 + if rec["quiet"] < rec["threshold"]: + continue + # 「要求修改」這個結論已經給了 ⇒ 球在對方腳下(只在要開罵時才多打這通) + if any((rv.get("state") or "").upper() == "REQUEST_CHANGES" for rv in reviews_of(ref)): + rec["quiet"] = 0 + continue + + rec["quiet"] = 0 + rec["threshold"] = max(4, rec["threshold"] * 4 if rec["threshold"] == 1 + else rec["threshold"] * 2) + rec["fires"] += 1 + overdue.append((ref, pr)) + +try: + with open(state_path, "w") as f: + json.dump({"seen": newseen, "leftover": leftover, "ts": time.time()}, f) +except Exception: + pass + +if not overdue and not leftover: + out("OK:clean:%d-open" % len(live)) + +def age_days(pr): + try: + t = time.mktime(time.strptime(pr.get("created_at", "")[:19], "%Y-%m-%dT%H:%M:%S")) + return max(0, int((time.time() - t) / 86400)) + except Exception: + return -1 + +lines = [] +if overdue: + lines.append("【沒有結論的 PR】(open、沒有人被指派、也沒有「要求修改」的 review)") + for ref, pr in sorted(overdue, key=lambda x: -age_days(x[1])): + dd = age_days(pr) + lines.append(" ❌ %-28s 開了 %s天 %s" + % (ref, dd if dd >= 0 else "?", (pr.get("title") or "")[:36])) + lines.append(" %s" % ((pr.get("pull_request") or {}).get("html_url") or "")) +if leftover: + lines.append("【merge 了,但分支還留著】(結論只給了一半)") + for ref, br in sorted(leftover.items()): + lines.append(" ❌ %-28s 分支 %s 還在" % (ref, br)) + +out("FIRE::" + json.dumps({"body": "\n".join(lines), + "n": len(overdue) + len(leftover)}, ensure_ascii=False)) +PY +) || VERDICT="CRASH:hook-error" +[ -n "${VERDICT:-}" ] || VERDICT="CRASH:empty" + +STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?") +LOGDIR="$PROJ/.claude/hooks" +note() { + if [ -d "$LOGDIR" ]; then + printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOGDIR/pr-verdict-guard.log" 2>/dev/null || true + fi +} + +case "$VERDICT" in + FIRE::*) + BODY=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["body"])') + N=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["n"])') + note "⛔ 擋下" "$N 個 PR 沒有結論" + cat >&2 < + 退回 → 理由**寫在票上**(不是寫在訊息裡),然後關掉 PR + scripts/pr-verdict reject --ticket -F <檔> + 要求修改 → 修改要求**寫在票上**,並在 PR 上留一則 REQUEST_CHANGES + scripts/pr-verdict changes --ticket -F <檔> + + 撈一次現況:scripts/pr-verdict list + +━━ 如果它本來就在等別人,不是被我忘了 ━━━━━━━━━━━━━━━━━━━━ + **把它指派給那個人**(或掛 Human)——本閘立刻不再點名它。 + 指派是 Gitea 原生欄位,撈一次就看得到「現在誰該動」; + 寫在對話裡的「我在等 X」會捲走,欄位不會。 + + 這一條就是本閘跟「s/review 佇列非空即 block」的分界: + **擋的是遺忘,不是等待。**(ISEP v0.6.0 divergence §B4 記過那個坑) + +━━ 這幾個現在真的不該處理 ━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 直接再送一次即可,本閘已經歸零,而且這幾個 PR 的門檻已加倍 + (1 → 4 → 8 → 16…,每個 PR 各自算)。**不必為了過這道閘去做一件假的動作。** +MSG + exit 2 ;; + CRASH*) + note "⚠️ 內部錯誤" "$VERDICT" + printf '⚠️ pr-verdict-guard 內部錯誤(%s)——本回合未清點 PR,已記入 log。\n' "$VERDICT" >&2 + exit 0 ;; + *) + note "✅ 放行" "$VERDICT" + exit 0 ;; +esac