Files
ersatztv/scripts/tests/test_merge_consent_base_change.py
T
timothyandtimothy d4c72697f2
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m54s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 15s
feat(780): commit a ruff config and enforce it in CI (#813)
Python lint here was a property of the operator's laptop: the global instructions
say to run ruff, no workflow ran it, and with no committed config ruff fell back
to whichever ~/.config/ruff/ruff.toml the machine happened to have.

- ruff.toml at the root, pinned ruff==0.12.11 in the script-tests job.
- Both lint steps pass an EXPLICIT population from `git ls-files` with
  `--no-force-exclude`, never `ruff check .` — an `exclude` empties a
  discovery-based run into a GREEN one (top level empties both commands, [lint]
  empties check, [format] empties format --check), and `ruff check .` over zero
  files exits 0 with only a stderr warning. Guarded by an empty-population arm.
- Tree clean: 74 findings at 706674272, 57 fixed in code, 17 per-site noqa with
  reasons inline. S105 deliberately per-site, not a directory blanket. RUF100
  selected so a suppression that suppresses nothing is itself a finding.
- pyright stays ungated; reasoning in the record.

Both steps witnessed red on the runner against the shipped bodies: run 2173 job
9176 (ruff check) and run 2170 job 9163 (ruff format --check).

Docs: new record ci.python-lint-ruff-config-committed, ci.script-tests-job
cross-ref, docs/ci-cd.md (also correcting a stale ~190-tests/~10s figure to the
measured 773 tests / ~4.5 min), docs/defect-shapes-773.md §5.2 resolved.

fixes #780

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-22 00:33:18 +00:00

212 lines
9.0 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()
if desc == "TRANSPORT-ERROR":
sys.exit(22)
if desc == "GARBAGE":
print('{"message":"internal error"}'); sys.exit(0)
if desc == "SCALAR-ROW":
print('{"state":"success","statuses":[1]}'); sys.exit(0)
if desc == "NONSTRING-DESC":
print(json.dumps({"state": "success", "statuses": [
{"context": "review-verdict/h10", "status": "success", "description": {"x": 1}}]}))
sys.exit(0)
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:
body = {"head": {"sha": os.environ["STUB_SHA"]}, "body": "fixes #1"}
live = (state / "live_base").read_text().strip()
if live != "MISSING":
body["base"] = {"ref": live}
print(json.dumps(body))
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" # noqa: S105 - deliberately fake; the real credential comes from the environment
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.
Asserting on the word "base" rather than on the issue tag, per cold review: the tag-only check
would have passed for a base-specific ask or deny whose wording happened to omit it, which is
the failure mode most likely to appear when someone edits these messages.
"""
hook.set_live_base("release/26.4")
hook.set_verdict_description(desc)
assert "base" not in hook.reason(), (
"a pre-#632 verdict drew a base-related decision for a field it could not have carried"
)
@pytest.mark.parametrize("failure", ["SCALAR-ROW", "NONSTRING-DESC"])
def test_a_malformed_status_MEMBER_asks_too(hook, failure):
"""One level below the previous fix, and it survived it.
Validating only that `.statuses` is an array left `{"statuses":[1]}` passing the guard, after
which `.context` on a number errors and a `|| true` on the extraction turned that error into an
empty description — straight back onto the graceful-adoption path, which is precisely the
outcome the guard exists to distinguish from. Same swallow-the-error shape as the bug one level
up, which is why the validation domain must match the CONSUMPTION domain rather than stopping at
the top-level type.
"""
hook.set_live_base("release/26.4")
hook.set_verdict_description(failure)
reason = hook.reason()
assert "ask" in reason and "base" in reason
@pytest.mark.parametrize("failure", ["TRANSPORT-ERROR", "GARBAGE"])
def test_an_UNREADABLE_status_response_asks_rather_than_skipping_the_check(hook, failure):
""" "Could not check" is a third outcome, not a quiet synonym for "no base recorded".
The first draft collapsed the two: an unreadable status response produced an empty
`recorded_base`, took the graceful-adoption path, and skipped validation in silence — after
which a later successful status read could still auto-grant, emitting "merge gate: satisfied"
for a comparison that never happened. A transient Gitea hiccup is not evidence that the base is
unchanged.
"""
hook.set_live_base("release/26.4")
hook.set_verdict_description(failure)
reason = hook.reason()
assert "ask" in reason, "an unreadable status response silently skipped the base check"
assert "base" in reason, "the ask must name what could not be checked"
def test_a_pr_with_no_resolvable_base_asks(hook):
"""A null/absent `.base.ref` is also 'could not check', not 'nothing to check'."""
hook.set_live_base("MISSING")
reason = hook.reason()
assert "ask" in reason and "base" in reason
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"