#!/usr/bin/env bash # pr-verdict-guard.sh 的迴歸測試(inkstone/ISEP#81) # # 兩個方向都要有證據: # A 群「不該擋」——沒有 PR/有人指派/掛 Human/draft/不在 org/剛開出來/ # 這回合動過/這回合指名過/已有 REQUEST_CHANGES/已被別的閘擋過/ # merge 完分支已刪/fixture 壞掉 # B 群「該擋」 ——沒人管的 open PR、放著不管一回合、merge 了但分支還在、 # 以及**票上那三個躺了兩週的真跡** # C 群「訊息承諾的出路真的走得通」——擋完歸零門檻加倍、指派下去立刻閉嘴 # # 🔴 全程離線(PR_VERDICT_FIXTURE),不打 Gitea、不留任何測試票/測試 PR。 # 狀態檔走 PR_VERDICT_STATE_DIR,不碰 /tmp 的正式檔。 # # 用法:hooks/tests/pr-verdict-guard.test.sh [hooks/pr-verdict-guard.sh 的路徑] set -u HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/pr-verdict-guard.sh}" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT PASS=0; FAIL=0; N=0 NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) OLD="2026-08-13T04:34:01Z" # fixture <檔名> # PR 規格:ref|updated|created|assignee|labels|draft mkfx() { python3 - "$@" <<'PY' import json, sys, os path, specs = sys.argv[1], sys.argv[2:] extra = {} if os.path.exists(path): extra = json.load(open(path)) pulls = [] for s in specs: ref, upd, crt, asg, lbl, draft = (s.split("|") + [""] * 6)[:6] full, num = ref.split("#") pulls.append({ "number": int(num), "title": "測試 PR " + ref, "created_at": crt, "updated_at": upd, "repository": {"full_name": full, "id": 1}, "assignees": [{"login": a} for a in asg.split(",") if a], "labels": [{"name": n} for n in lbl.split(",") if n], "pull_request": {"merged": False, "draft": draft == "draft", "html_url": "https://git.uncle6.me/%s/pulls/%s" % (full, num)}, }) extra["pulls"] = pulls json.dump(extra, open(path, "w"), ensure_ascii=False) PY } # 加一筆「這個 PR 已經被 merge,head 分支叫 X」的細節,以及分支還在不在 mkdetail() { python3 - "$@" <<'PY' import json, sys, os path, ref, branch, alive = sys.argv[1:5] d = json.load(open(path)) if os.path.exists(path) else {} full = ref.split("#")[0] d.setdefault("detail", {})[ref] = { "merged": True, "head": {"ref": branch, "repo": {"full_name": full}}} d.setdefault("branches", {})["%s:%s" % (full, branch)] = (alive == "alive") json.dump(d, open(path, "w"), ensure_ascii=False) PY } mkreview() { python3 - "$@" <<'PY' import json, sys, os path, ref, state = sys.argv[1:4] d = json.load(open(path)) if os.path.exists(path) else {} d.setdefault("reviews", {})[ref] = [{"state": state}] json.dump(d, open(path, "w"), ensure_ascii=False) PY } # 造一份「這一回合叫過這些工具、輸入長這樣」的 transcript mktranscript() { python3 - "$@" <<'PY' import json, sys path = sys.argv[1] with open(path, "w") as f: f.write(json.dumps({"type": "user", "message": {"content": [{"type": "text", "text": "繼續"}]}}) + "\n") blocks = [{"type": "tool_use", "name": "Bash", "id": "t", "input": {"command": c}} for c in sys.argv[2:]] f.write(json.dumps({"type": "assistant", "message": {"content": blocks}}, ensure_ascii=False) + "\n") PY } # fire <期望 exit> <說明> [transcript] [extra-json] [必須出現的字串] fire() { sid="$1"; fx="$2"; want="$3"; desc="$4"; tr="${5:-/nonexistent}"; extra="${6:-}"; must="${7:-}" N=$((N+1)) payload=$(printf '{"session_id":"%s","transcript_path":"%s"%s}' "$sid" "$tr" "$extra") out=$(printf '%s' "$payload" | PR_VERDICT_FIXTURE="$fx" PR_VERDICT_STATE_DIR="$TMP" \ CLAUDE_PROJECT_DIR="$TMP" bash "$HOOK" 2>&1 >/dev/null); rc=$? ok=1 [ "$rc" -eq "$want" ] || ok=0 if [ -n "$must" ] && ! printf '%s' "$out" | grep -qF "$must"; then ok=0; fi if [ "$ok" -eq 1 ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1)) else printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \ "${must:+ 且訊息含「$must」}" "$rc" printf '%s\n' "$out" | sed -n '1,8p' | sed 's/^/ /' FAIL=$((FAIL+1)) fi } echo "── A 群:不該擋 ────────────────────────────────────────────" FX="$TMP/empty.json"; mkfx "$FX" fire s-empty "$FX" 0 "① 一個 open PR 都沒有 ⇒ 放行" FX="$TMP/assigned.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|Leo||" fire s-asg "$FX" 0 "② 有人被指派(棒子在他手上=在等,不是忘了)⇒ 不點名" FX="$TMP/human.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD||Human,p/high|" fire s-hum "$FX" 0 "③ 掛了 Human(等 leo 親手做)⇒ 不點名" FX="$TMP/draft.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||draft" fire s-drf "$FX" 0 "④ draft(作者自己說還沒好)⇒ 不點名" FX="$TMP/otherorg.json"; mkfx "$FX" "Leo/Arcrun#104|$OLD|$OLD|||" fire s-org "$FX" 0 "⑤ 不在 inkstone org(舊 org 的鏡像)⇒ 不點名" FX="$TMP/review.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||" mkreview "$FX" "inkstone/Arcrun#104" "REQUEST_CHANGES" fire s-rev "$FX" 0 "⑥ 已經留了 REQUEST_CHANGES(結論給過了,球在對方腳下)⇒ 不點名" FX="$TMP/active.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||" fire s-act "$FX" 0 "⑦ stop_hook_active(已被別的 Stop 閘擋過)⇒ 不疊擋" \ /nonexistent ',"stop_hook_active":true' fire s-bad "$TMP/does-not-exist.json" 0 "⑧ fixture 讀不到 ⇒ 放行(Stop 閘不准把 session 鎖死)" echo "── B 群:該擋 ──────────────────────────────────────────────" # 票上那三個真跡(真實 ref、真實開票日) FX="$TMP/real.json" mkfx "$FX" \ "inkstone/arcrun-rag#91|$OLD|$OLD|||" \ "inkstone/Arcrun#116|2026-08-13T05:24:51Z|2026-08-13T05:24:51Z|||" \ "inkstone/Arcrun#104|2026-08-12T08:51:06Z|2026-08-12T08:51:06Z|||" fire s-real "$FX" 2 "⑨ 真跡重演:三個躺了兩週的 PR ⇒ **擋下**" \ /nonexistent '' 'inkstone/arcrun-rag#91' FX2="$TMP/real2.json"; cp "$FX" "$FX2" fire s-real2 "$FX2" 2 "⑩ 同上,訊息要點名 inkstone/Arcrun#116" \ /nonexistent '' 'inkstone/Arcrun#116' FX3="$TMP/real3.json"; cp "$FX" "$FX3" fire s-real3 "$FX3" 2 "⑪ 同上,訊息要點名 inkstone/Arcrun#104" \ /nonexistent '' 'inkstone/Arcrun#104' echo "── C 群:驗收條件 1–3(開著不管 → 擋;merge → 放行;分支要不見)──" # 驗收 1:一個新 PR 出現(不在它誕生那回合開罵),下一回合放著不管 ⇒ 擋 FX="$TMP/ac.json"; mkfx "$FX" fire s-ac "$FX" 0 "⑫ 基準回合:目前沒有 PR" mkfx "$FX" "inkstone/ISEP#999|$NOW|$NOW|||" fire s-ac "$FX" 0 "⑬ PR 剛開出來(上一回合還不存在)⇒ 不在它誕生的回合開罵" fire s-ac "$FX" 2 "⑭ **驗收 1**:開著放著不管,收工 ⇒ 擋下,且點名它" \ /nonexistent '' 'inkstone/ISEP#999' # 驗收 2+3:merge 掉(PR 從 open 清單消失)且分支已刪 ⇒ 放行 mkfx "$FX" # #999 從 open 清單消失 mkdetail "$FX" "inkstone/ISEP#999" "feat/x" "gone" fire s-ac "$FX" 0 "⑮ **驗收 2+3**:merge 掉且分支已不存在 ⇒ 放行" # 驗收 3 的反面:merge 了但分支還在 = 結論只給了一半 ⇒ 擋 FX="$TMP/left.json"; mkfx "$FX" "inkstone/ISEP#998|$NOW|$NOW|||" fire s-left "$FX" 2 "⑯ 先讓 #998 進到快照(沒人管 ⇒ 擋一次)" \ /nonexistent '' 'inkstone/ISEP#998' mkfx "$FX" # 消失了 mkdetail "$FX" "inkstone/ISEP#998" "feat/y" "alive" fire s-left "$FX" 2 "⑰ **驗收 3**:merge 了但分支 feat/y 還在 ⇒ 擋下並點名分支" \ /nonexistent '' 'feat/y' mkfx "$FX"; mkdetail "$FX" "inkstone/ISEP#998" "feat/y" "gone" fire s-left "$FX" 0 "⑱ 分支刪掉之後 ⇒ 放行(清單會自己清乾淨)" echo "── D 群:這一回合碰過它就不點名(不讀任何一句話)──────────────" FX="$TMP/touch.json"; mkfx "$FX" "inkstone/ISEP#997|$OLD|$OLD|||" fire s-t1 "$FX" 2 "⑲ 沒人管 ⇒ 擋一次(建立基準)" # updated_at 動了 = 外部系統的事實(留言/推 commit/改標籤都會動它) mkfx "$FX" "inkstone/ISEP#997|$NOW|$OLD|||" for i in 1 2 3 4 5; do fire s-t1 "$FX" 0 "⑳ updated_at 變了(這回合真的動過它)第 $i 次 ⇒ 不點名" mkfx "$FX" "inkstone/ISEP#997|$NOW-$i|$OLD|||" done FX="$TMP/mention.json"; mkfx "$FX" "inkstone/ISEP#996|$OLD|$OLD|||" TR="$TMP/tr.jsonl" mktranscript "$TR" "python3 scripts/ticket say inkstone/ISEP#996 -F /tmp/x.md" fire s-t2 "$FX" 0 "㉑ 這回合的 tool call 指名了 inkstone/ISEP#996(結論寫在票上)⇒ 不點名" "$TR" for i in 1 2 3 4; do fire s-t2 "$FX" 0 "㉒ 同上第 $i 次——指名過就不該累積成擋下" "$TR" done mktranscript "$TR" "python3 scripts/ticket say inkstone/ISEP#111 -F /tmp/x.md" fire s-t2 "$FX" 2 "㉓ 換成指名**別的**票號 ⇒ 這個 PR 仍然沒被碰 ⇒ 擋" echo "── E 群:響過就退讓(訊息裡承諾的出路真的存在)────────────────" FX="$TMP/backoff.json"; mkfx "$FX" "inkstone/ISEP#995|$OLD|$OLD|||" fire s-bo "$FX" 2 "㉔ 第 1 次 ⇒ 擋(門檻 1)" for i in 1 2 3; do fire s-bo "$FX" 0 "㉕ 擋過之後第 $i 個回合——門檻已加倍(1→4),不該再響" done fire s-bo "$FX" 2 "㉖ 第 4 個回合 ⇒ 再擋一次(門檻 4)" for i in 1 2 3 4 5 6 7; do fire s-bo "$FX" 0 "㉗ 再擋過之後第 $i 個回合——門檻 4→8,不該響" done fire s-bo "$FX" 2 "㉘ 第 8 個回合 ⇒ 再擋(證明退讓是加倍不是無限)" # 訊息說「把它指派給那個人,本閘立刻不再點名它」——這條出路要真的走得通 mkfx "$FX" "inkstone/ISEP#995|$OLD|$OLD|claude-code||" for i in 1 2 3 4 5 6 7 8 9; do fire s-bo "$FX" 0 "㉙ 指派下去之後第 $i 個回合 ⇒ 永遠不再點名(訊息承諾的出路成立)" done echo printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N" [ "$FAIL" -eq 0 ] || exit 1