#622 made `review-verdict/h10` a per-sha required status, so a new commit cannot inherit an old verdict — the required context is simply absent on the new head. Retargeting a PR's base reaches the same end from the opposite direction: the head sha and the status both hold still while the merge-base, and therefore the effective diff the verdict was formed against, changes underneath them. #622's record claimed the invariant holds "by construction"; this was the documented exception, and an unrecorded exception is how a guarantee degrades into a habit. `post-review-verdict.sh` now records the base branch in the status description as a trailing `(base: <ref>)`, and refuses to write a status at all if the base moved between reading the PR and posting — the same TOCTOU window the head check already covers, which the head check cannot see because retargeting does not move the head. `pretooluse-merge-consent.sh` reads the field back and denies when it no longer matches the PR's live `base.ref`. Two choices are load-bearing, and each is pinned by a test rather than left to a comment: - The comparator is `base.ref`, NOT `base.sha`. `base.sha` tracks the base branch's tip, which moves whenever anything merges to `main` — comparing it would invalidate every open verdict on every unrelated merge, converting a rare-event guard into a permanent merge deadlock. A base that merely advances is out of scope by design: rebasing onto it moves the head sha, which the per-sha binding already covers. - The field goes in the status DESCRIPTION, not the verdict comment. The comment body is parsed by `scripts/check-review-verdict.sh`, whose grammar had three false-opens in its history (#629); nothing parses the description, so this adds a field without reopening that surface. Scope is stated honestly rather than overclaimed: this is DETECTION on the hook path only. A commit status carries no base of its own, so the server-side required check cannot see a retarget, and a merge driven through the Gitea UI or API is unaffected. That is the accepted exposure — base changes are rare, manual, and this is a two-account repo — but it now fails loud in the one place that evaluates consent, instead of living only in a doc. Verdicts posted before this change carry no `(base: …)` and get NO opinion rather than a deny; denying would block every in-flight PR the day it lands, and the window closes on its own since verdicts are per-head and short-lived. Verified by mutation, six mutants, each killed by its intended test: remove the hook's deny; compare base.sha instead of base.ref; drop graceful adoption; stop recording the base; drop the TOCTOU guard; accept a PR with no resolvable base. The positive controls matter more than usual here — the test PR is deliberately non-docs (a docs-only PR short-circuits the whole gate and would never reach the base check) and the rest of the gate is unstubbed, so "the hook denied" alone proves nothing. Refs #632 Decisions-Edit: yes
151 lines
6.2 KiB
Python
151 lines
6.2 KiB
Python
"""Tests for the base-change detection in `.claude/hooks/pretooluse-merge-consent.sh` (#632).
|
|
|
|
`review-verdict/h10` is a per-sha commit status, which makes "a new commit inherits an old verdict"
|
|
impossible by construction (#622). Retargeting a PR's base reaches the same end by the opposite
|
|
route: the head sha does not move, so the status stays green, while the merge-base — and therefore
|
|
the effective diff the verdict was formed against — changes underneath it.
|
|
|
|
What is asserted here is DETECTION on the hook path only, and the tests are written to keep that
|
|
claim narrow:
|
|
|
|
* a status carries no base field of its own, so the server-side required check cannot see this at
|
|
all; a merge driven through the Gitea UI or API is unaffected. No test here implies otherwise.
|
|
* a verdict posted before #632 has no `(base: …)` in its description and must get NO opinion,
|
|
rather than denying every in-flight PR the day this lands.
|
|
|
|
Observable contract: the hook exits 0 with EMPTY stdout when it has no opinion (passthrough to
|
|
normal permissioning), and emits a JSON `permissionDecision` otherwise.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
HOOK = REPO_ROOT / ".claude" / "hooks" / "pretooluse-merge-consent.sh"
|
|
|
|
SHA = "a9e3e23abf337980ca4c05854f5b1e210099d08b"
|
|
|
|
# The PR is deliberately NOT docs-only: the docs-only exemption short-circuits the whole gate, so a
|
|
# docs PR would never reach the base check and the tests would pass without exercising it.
|
|
CURL_SHIM = r'''#!/usr/bin/env python3
|
|
import json, os, sys, pathlib, urllib.parse
|
|
|
|
state = pathlib.Path(os.environ["STUB_DIR"])
|
|
args = sys.argv[1:]
|
|
url = [a for a in args if a.startswith("http")][-1]
|
|
|
|
if "/pulls/" in url and "/files" in url:
|
|
q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
|
page = int(q.get("page", ["1"])[0])
|
|
if page == 1:
|
|
print(json.dumps([{"filename": "ErsatzTV/Program.cs", "status": "modified"}]))
|
|
else:
|
|
print("[]")
|
|
sys.exit(0)
|
|
|
|
if "/status" in url:
|
|
desc = (state / "verdict_desc").read_text()
|
|
rows = [] if desc == "NONE" else [
|
|
{"context": "review-verdict/h10", "status": "success", "description": desc}]
|
|
print(json.dumps({"state": "success", "statuses": rows}))
|
|
sys.exit(0)
|
|
|
|
if "/pulls/" in url:
|
|
print(json.dumps({
|
|
"head": {"sha": os.environ["STUB_SHA"]},
|
|
"base": {"ref": (state / "live_base").read_text().strip()},
|
|
"body": "fixes #1",
|
|
}))
|
|
sys.exit(0)
|
|
|
|
print("{}")
|
|
'''
|
|
|
|
|
|
@pytest.fixture
|
|
def hook(tmp_path):
|
|
bindir = tmp_path / "bin"; bindir.mkdir()
|
|
curl = bindir / "curl"; curl.write_text(CURL_SHIM); curl.chmod(0o755)
|
|
state = tmp_path / "state"; state.mkdir()
|
|
(state / "live_base").write_text("main")
|
|
(state / "verdict_desc").write_text("Review-verdict: MERGEABLE @ a9e3e23 (base: main)")
|
|
|
|
env = dict(os.environ)
|
|
env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}"
|
|
env["STUB_DIR"] = str(state)
|
|
env["STUB_SHA"] = SHA
|
|
env["ETV_GITEA_TOKEN"] = "stub"
|
|
env["ETV_GITEA_URL"] = "http://gitea.example"
|
|
env.pop("ETV_GITEA_BASICAUTH", None)
|
|
|
|
class Handle:
|
|
def set_live_base(self, ref):
|
|
(state / "live_base").write_text(ref)
|
|
|
|
def set_verdict_description(self, desc):
|
|
"""'NONE' serves a head with no review-verdict/h10 status at all."""
|
|
(state / "verdict_desc").write_text(desc)
|
|
|
|
def decision(self):
|
|
payload = {"tool_input": {"method": "merge", "owner": "timothy",
|
|
"repo": "ersatztv", "pull_number": 42}}
|
|
r = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload),
|
|
env=env, capture_output=True, text=True)
|
|
assert r.returncode == 0, r.stderr
|
|
if not r.stdout.strip():
|
|
return None
|
|
return json.loads(r.stdout)
|
|
|
|
def reason(self):
|
|
d = self.decision()
|
|
return "" if d is None else json.dumps(d)
|
|
|
|
return Handle()
|
|
|
|
|
|
def test_a_retargeted_base_denies_a_verdict_formed_against_the_old_one(hook):
|
|
hook.set_live_base("release/26.4")
|
|
reason = hook.reason()
|
|
assert "deny" in reason, "a verdict formed against a different base was allowed to stand"
|
|
assert "release/26.4" in reason and "main" in reason, (
|
|
"the deny must name both bases; a reader cannot act on 'the base changed'")
|
|
|
|
|
|
def test_positive_control_an_unchanged_base_does_not_trigger_the_base_deny(hook):
|
|
"""Without this, the test above could pass because the hook denies on every path — which it
|
|
very nearly does, since this PR is non-docs and the rest of the gate is unstubbed."""
|
|
reason = hook.reason()
|
|
assert "ersatztv#632" not in reason, (
|
|
"the base check fired on a PR whose base never moved")
|
|
|
|
|
|
@pytest.mark.parametrize("desc", [
|
|
"Review-verdict: MERGEABLE @ a9e3e23", # posted before #632
|
|
"NONE", # no verdict status on this head at all
|
|
])
|
|
def test_a_verdict_with_no_recorded_base_gets_no_opinion(hook, desc):
|
|
"""Graceful adoption. Denying here would block every in-flight PR the day this lands, and the
|
|
window closes on its own: verdicts are per-head and short-lived, so every verdict posted after
|
|
#632 carries the field."""
|
|
hook.set_live_base("release/26.4")
|
|
hook.set_verdict_description(desc)
|
|
assert "ersatztv#632" not in hook.reason(), (
|
|
"a pre-#632 verdict was denied for a field it could not have carried")
|
|
|
|
|
|
def test_the_comparator_is_the_base_REF_not_its_tip_sha():
|
|
"""The design decision this test exists to freeze. `base.sha` tracks the base branch's TIP,
|
|
which moves every time anything merges to `main` — comparing that would invalidate every open
|
|
verdict on every unrelated merge, turning a rare-event guard into a permanent merge deadlock.
|
|
A base branch that merely ADVANCES must be silent here; rebasing onto it moves the head sha,
|
|
which the per-sha binding already covers."""
|
|
assert ".base.ref" in HOOK.read_text(), "the hook must compare the base BRANCH, not its tip sha"
|
|
assert ".base.sha" not in HOOK.read_text(), (
|
|
"comparing base.sha deadlocks every open PR whenever main advances")
|