From 16c03e46c1082bc4cfa97f89aea6d8e2300dcff4 Mon Sep 17 00:00:00 2001 From: claude-code Date: Fri, 28 Aug 2026 00:27:40 +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/tests/pr-verdict-guard.test.sh=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/tests/pr-verdict-guard.test.sh | 220 +++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 hooks/tests/pr-verdict-guard.test.sh diff --git a/hooks/tests/pr-verdict-guard.test.sh b/hooks/tests/pr-verdict-guard.test.sh new file mode 100644 index 0000000..12a2292 --- /dev/null +++ b/hooks/tests/pr-verdict-guard.test.sh @@ -0,0 +1,220 @@ +#!/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