Files
ISEP/hooks/pr-verdict-guard.sh
T

395 lines
18 KiB
Bash
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 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 收斂警察(Stopinkstone/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` 跟上一次收工比變了 ⇒ 碰過(留言/改標籤/推 commitsubmit 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 - <<PY` 的 stdin **就是腳本本身**
# 再從 sys.stdin 讀就會讀到自己的原始碼(第一版實撞,回 CRASH:bad-payload)。
VERDICT=$(GITEA_TOKEN_RESOLVED="$TOKEN" PR_VERDICT_PAYLOAD="$PAYLOAD" python3 - <<'PY' 2>"$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 -> branchmerge 完分支還沒刪的
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 <<MSG
🔀 PR 收斂警察:**還有 ${N} 個 PR 沒有結論,現在不是收工的時候。**
$BODY
【inkstone/ISEP#81】「我交出去的 PR 沒有人去看它⋯⋯票看起來是『已交付』,
但東西沒有進 main,也就沒有進版本,**leo 手上永遠不會出現它**。」
━━ 「有結論」只有三種,缺一不可 ━━━━━━━━━━━━━━━━━━━━━━━━
merge → **併完當場刪掉那條 branch**
scripts/pr-verdict merge <owner/repo#N>
退回 → 理由**寫在票上**(不是寫在訊息裡),然後關掉 PR
scripts/pr-verdict reject <owner/repo#N> --ticket <owner/repo#N> -F <檔>
要求修改 → 修改要求**寫在票上**,並在 PR 上留一則 REQUEST_CHANGES
scripts/pr-verdict changes <owner/repo#N> --ticket <owner/repo#N> -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