395 lines
18 KiB
Bash
395 lines
18 KiB
Bash
#!/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 - <<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 -> 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 <<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
|