#!/usr/bin/env bash # post-review-verdict.sh — post an H10 review verdict as BOTH a PR comment and a per-sha commit # status, so merge consent is bound to the exact commit that was reviewed (ersatztv#622). # # WHY THIS EXISTS. `pretooluse-merge-consent.sh` evaluates all three consent conditions at the # moment the merge tool is called. When that call passes `merge_when_checks_succeed=true`, condition # (a) is deliberately delegated to Gitea — but Gitea then merges whatever head is green at ITS merge # time, which may be several commits past the head that conditions (b) and (c) were proven against. # The gate reports satisfied and the newest code merges with no review verdict covering it. That is # not a bypass: the gate is *satisfied against a snapshot that stops being true* (ersatztv#622). # Shown by a controlled A/B: with a slow CI check still pending so Gitea waits, a commit pushed after # scheduling MERGED unreviewed without the required verdict context, and was REFUSED with it. # # THE FIX IS THE SHA, NOT THE SCRIPT. A Gitea commit status is attached to ONE sha. Making # `review-verdict/h10` a REQUIRED status check on `main` therefore makes the H10 invariant # self-invalidating by construction: push a new commit and the new head simply has no such status, # so Gitea's own auto-merge refuses to fire until someone re-reviews THAT head and posts a fresh # verdict. No re-evaluation hook, no polling, no snapshot to go stale — a status cannot be inherited # by a commit that did not exist when it was written. This closes the hole for every merge path # (Gitea UI, API, auto-merge, another agent's session), not just the one that goes through the # Claude PreToolUse hook. # # The comment is still posted because it is the human-readable artifact and the hook's condition (c) # parser reads it; the status is what the SERVER enforces. Both name the same sha on purpose — if # they ever disagree, the status wins, because it is the one a merge is actually gated on. # # Usage: # scripts/post-review-verdict.sh [note ...] # # MERGEABLE | APPROVED | LGTM -> commit status `success` (merge may proceed) # BLOCKED | NOT-MERGEABLE -> commit status `failure` (merge stays blocked) # [note] optional free text appended to the comment under the verdict line. # # Env: ETV_GITEA_TOKEN (token) or ETV_GITEA_BASICAUTH (user:pass) — required. # ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret). # ETV_GITEA_REPO overrides owner/repo (default: timothy/ersatztv). set -euo pipefail STATUS_CONTEXT="review-verdict/h10" die() { printf 'post-review-verdict: %s\n' "$*" >&2; exit 1; } usage() { sed -n '/^# Usage:/,/^# ETV_GITEA_REPO/p' "$0" | sed 's/^# \{0,1\}//' exit 2 } [ $# -ge 2 ] || usage pr="$1"; verdict_raw="$2"; shift 2 note="$*" printf '%s' "$pr" | grep -qE '^[0-9]+$' || die "pull-request number must be numeric, got '$pr'" # Normalize to upper-case and classify. The positive set MUST stay in sync with the READ side's # POS_RE/NEG_RE in scripts/check-review-verdict.sh (mergeable|approved|lgtm) — a word this script # treats as positive but the classifier does not would let the server-side status go green while # the merge-consent hook still denies, which reads as an unexplained deny. # # That sync is NOT asserted anywhere, and you should not assume otherwise. ersatztv#774 tried: a # test extracted both vocabularies from their own shell source and compared them. Six cold-review # rounds each found another shell construction that either escaped the extractor or made it fail on # a correct tree, so it was withdrawn rather than patched a seventh time. Writing a shell parser as # a regex does not converge. # # The fix is to stop having two copies — one declarative vocabulary both scripts read — tracked in # ersatztv#788. Until that lands, THIS COMMENT IS THE ONLY THING holding the two lists together, and # comments drift: this one already had, naming .claude/hooks/pretooluse-merge-consent.sh as the home # of the regex. The hook carries no copy; it delegates to check-review-verdict.sh. verdict=$(printf '%s' "$verdict_raw" | tr '[:lower:]' '[:upper:]') case "$verdict" in MERGEABLE|APPROVED|LGTM) state="success" ;; BLOCKED|NOT-MERGEABLE) state="failure" ;; *) die "unknown verdict '$verdict_raw' — use MERGEABLE, APPROVED, LGTM, BLOCKED or NOT-MERGEABLE" ;; esac base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1" repo_slug="${ETV_GITEA_REPO:-timothy/ersatztv}" owner="${repo_slug%%/*}" repo="${repo_slug##*/}" [ -n "$owner" ] && [ -n "$repo" ] && [ "$owner" != "$repo_slug" ] \ || die "ETV_GITEA_REPO must be owner/repo, got '$repo_slug'" if [ -n "${ETV_GITEA_TOKEN:-}" ]; then auth=(-H "Authorization: token $ETV_GITEA_TOKEN") elif [ -n "${ETV_GITEA_BASICAUTH:-}" ]; then auth=(-u "$ETV_GITEA_BASICAUTH") else die "no Gitea credentials in env — set ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH" fi api_get() { curl -sf "${auth[@]}" "$base_url/$1"; } api_post() { curl -sf "${auth[@]}" -X POST -H 'Content-Type: application/json' -d "$2" "$base_url/$1"; } # --- Resolve the head sha from the SERVER, never from a local checkout. ------------------------- # A local `git rev-parse HEAD` can be ahead of (unpushed work) or behind (stale fetch) what the PR # actually points at, and the status must land on the sha Gitea will merge. prjson=$(api_get "repos/$owner/$repo/pulls/$pr") \ || die "could not fetch PR #$pr from $base_url (unreachable, missing, or auth rejected)" sha=$(printf '%s' "$prjson" | jq -r '.head.sha // ""') pr_state=$(printf '%s' "$prjson" | jq -r '.state // ""') pr_url=$(printf '%s' "$prjson" | jq -r '.html_url // ""') [ -n "$sha" ] || die "PR #$pr has no resolvable head sha" [ "$pr_state" = "open" ] || die "PR #$pr is '$pr_state', not open — refusing to post a verdict" short=${sha:0:7} # --- Record the BASE BRANCH the verdict was formed against (ersatztv#632). ---------------------- # The sha binding closes "the head moved under a fixed verdict". It does not close the mirror case: # RETARGETING a PR's base changes neither the head sha nor the status, yet changes the effective # diff — so a verdict written while the PR targeted `main` still reads green after it is pointed at # a branch with a very different merge-base. Consent outliving what it was granted for, reached from # the other direction. # # The comparator is `base.ref` (the BRANCH NAME), deliberately NOT `base.sha`. `base.sha` tracks the # base branch's tip, which moves every time anything merges to `main` — comparing it would invalidate # every open verdict on every unrelated merge, i.e. a self-inflicted merge deadlock. `base.ref` # changes exactly when someone retargets the PR, which is the event being guarded. A base branch that # merely ADVANCES is out of scope by design: that is ordinary churn, and rebasing onto it changes the # head sha, which the existing per-sha binding already catches. base_ref=$(printf '%s' "$prjson" | jq -r '.base.ref // ""') [ -n "$base_ref" ] || die "PR #$pr has no resolvable base branch (.base.ref) — refusing to post a verdict that cannot record what it was formed against" # --- TOCTOU guard: refuse to green a head that stopped being head since we read it. ------------- # Without this, a commit pushed between the head read above and the status write below would inherit # a verdict written for its parent — reintroducing ersatztv#622 at a smaller time scale. We do NOT # retry against the new head: the new commit is genuinely unreviewed, and silently re-targeting the # verdict at it is exactly the failure this script exists to prevent. # # THE WINDOW THIS FENCES USED TO BE MUCH WIDER, and that is why the comment is now written AFTER the # status rather than before it (ersatztv#792). Posting the comment first meant every refusal below # left a PR carrying `Review-verdict: MERGEABLE @ ` with NO `review-verdict/h10` status — and # the comment is not the gate. The half-state was read by an operator as consent that had not been # granted. Ordering the two writes status-first makes the surviving half the SAFE half: a status # with no comment leaves the hook at condition (c) with nothing to classify, which is an `ask`, not # a grant. The refusals themselves are unchanged and must stay — see # `release.verdict-writes-status-before-comment`. # Fail CLOSED if the re-read itself fails. This used to be `sha_now=$(api_get ... | jq ...)`, where # `set -e` + `pipefail` aborted the script on a failed GET — implicitly, but before any status was # written. Folding the two reads into one variable with `|| true` would have swallowed that: both # `sha_now` and `base_now` come back empty, both `[ -n … ]` guards become no-ops, and the status is # written having confirmed NOTHING about the head or the base. That is a fail-open regression # introduced by the refactor, so the refusal is now explicit rather than a side effect of `set -e`. prjson_now=$(api_get "repos/$owner/$repo/pulls/$pr") \ || die "could not re-read PR #$pr to confirm the head and base had not moved while posting — no status was written. Re-run once Gitea is reachable." # `[ -n "$x" ] && [ "$x" != "$want" ]` was a fail-OPEN on BOTH of the checks below (ersatztv#778). # A 2xx body that merely LOST the field — `{"head":{},"base":{}}` — yields an empty value, so the # `-n` conjunct is false, the comparison never runs, and the status is posted having confirmed # NOTHING about either the head or the base. The re-read exists precisely to refuse when it cannot # confirm, so a field it cannot read must die exactly like a field that moved. The transport failure # one line up is already fatal; this closes the same hole one level down, which is where it keeps # reappearing in this repo. # # WHICH LINE CARRIES THE SAFETY, stated because it is not the one it looks like: dropping the `-n` # conjunct is the fix. The unconditional `!=` below already rejects an empty value, so the explicit # `-z` arms are REDUNDANT for the safety property and exist only to give the operator an accurate # message ("carried no head sha" rather than "moved to ''"). Disarming a `-z` arm alone therefore # leaves the suite green — the two overlap, and a mutation proof aimed at it would be vacuous. The # proof in `test_a_reread_that_LOSES_a_field_refuses_instead_of_posting` is taken against the real # predecessor (the `-n` conjunct restored), which is what actually goes red. sha_now=$(printf '%s' "$prjson_now" | jq -r '.head.sha // ""') if [ -z "$sha_now" ]; then die "re-read PR #$pr but its response carried no head sha, so it is UNPROVEN that the head is still $short — no status was written. Re-run once Gitea returns a well-formed PR body." fi if [ "$sha_now" != "$sha" ]; then die "head moved from $short to ${sha_now:0:7} while posting — that commit is UNREVIEWED, so no status was written. Re-review the new head and run this again." fi # The same TOCTOU window applies to the base (ersatztv#632): a retarget between the read above and # the status write below would bind the verdict to a base that is no longer the PR's, and the head # sha check would not notice because retargeting does not move the head. base_now=$(printf '%s' "$prjson_now" | jq -r '.base.ref // ""') if [ -z "$base_now" ]; then die "re-read PR #$pr but its response carried no base ref, so it is UNPROVEN that the base is still '$base_ref' — no status was written. Re-run once Gitea returns a well-formed PR body." fi if [ "$base_now" != "$base_ref" ]; then die "base branch changed from '$base_ref' to '$base_now' while posting — the diff you reviewed is not the diff this PR now merges, so no status was written. Re-review against the new base and run this again." fi # The base branch goes in the status DESCRIPTION, not in the comment. The comment body is parsed by # `scripts/check-review-verdict.sh`, whose grammar had three false-opens in its history; nothing # parses the description today, so this adds a field without reopening that surface. The hook reads # it back and compares (ersatztv#632). status_payload=$(jq -n \ --arg s "$state" --arg c "$STATUS_CONTEXT" --arg u "$pr_url" \ --arg d "Review-verdict: $verdict @ $short (base: $base_ref)" \ '{state:$s, context:$c, description:$d, target_url:$u}') api_post "repos/$owner/$repo/statuses/$sha" "$status_payload" >/dev/null \ || die "failed to post the '$STATUS_CONTEXT' commit status on $short" printf 'posted status: %s = %s on %s\n' "$STATUS_CONTEXT" "$state" "$short" # --- The comment (human-readable artifact + the hook's condition-(c) input). -------------------- # Written LAST, after the gating status exists (ersatztv#792). The verdict line MUST start the line: # the hook anchors its parser to line-start precisely so a comment that merely QUOTES the template # mid-sentence cannot self-approve a merge. body="Review-verdict: $verdict @ $short" [ -n "$note" ] && body="$body"$'\n\n'"$note" comment_payload=$(jq -n --arg b "$body" '{body:$b}') api_post "repos/$owner/$repo/issues/$pr/comments" "$comment_payload" >/dev/null \ || die "the '$STATUS_CONTEXT' status was written on $short, but the verdict COMMENT could not be posted. The merge gate needs both: it reads the comment for condition (c) and will ASK rather than auto-grant until one exists. Re-run this command once Gitea is reachable." printf 'posted comment: Review-verdict: %s @ %s\n' "$verdict" "$short" if [ "$state" = "failure" ]; then printf '\nPR #%s stays BLOCKED: %s is failing on head %s.\n' "$pr" "$STATUS_CONTEXT" "$short" else printf '\nPR #%s may merge on this head. Any NEW commit clears this status by construction —\n' "$pr" printf 'a fresh review + verdict is required for each head (ersatztv#622).\n' fi