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

221 lines
10 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-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 規格...>
# 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 <session> <fixture> <期望 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'
# 驗收 23merge 掉(PR 從 open 清單消失)且分支已刪 ⇒ 放行
mkfx "$FX" # #999 從 open 清單消失
mkdetail "$FX" "inkstone/ISEP#999" "feat/x" "gone"
fire s-ac "$FX" 0 "⑮ **驗收 23**: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