PR 一定要有結論(inkstone/ISEP#81)(hooks/pr-verdict-guard.sh)
This commit is contained in:
@@ -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 - <<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
|
||||
Reference in New Issue
Block a user