221 lines
10 KiB
Bash
221 lines
10 KiB
Bash
#!/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'
|
||
|
||
# 驗收 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
|