PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
PR Gates / Docs update reminder (pull_request) Successful in 23s
PR Gates / decisions lifecycle (pull_request) Successful in 31s
review-verdict/h10 Awaiting review verdict for d8bd1dc
Review verdict / Set review-verdict status (pull_request_target) Successful in 36s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m59s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round-2 cross-family review returned BLOCKED: 2 High + 3 Medium. HIGH — here-strings traded one fail-open for another. `grep -q… <<< "$data"` fixes the SIGPIPE inversion, but bash materialises a large here-string via temporary storage, so it fails when temp space is full or unwritable — and since these sit inside `if`/`!`, that failure flips the predicate exactly as SIGPIPE did. It did NOT reproduce on my bash 3.2, DID on the reviewer's Linux bash 5.x, and CI is Linux; the disagreement is itself the argument for a construct that cannot fail either way. Path predicates now COUNT with `grep -c`, which drains stdin (no early exit, no SIGPIPE) over an ordinary pipe (no temp file), and grep's status is read honestly: exit 1 means "zero matches", a legitimate answer, while >1 is a real error that FAILS THE JOB rather than silently reading as "no match". `set -e` does not catch these on its own — they sit in command substitution inside a conditional. Verified correct under 171KB input AND an unwritable TMPDIR. The description test became a `case` prefix match, removing another pipeline from a security predicate. New record `ci.grep-q-pipefail-inversion` covers the whole class. HIGH — a human verdict landing mid-run was still overwritten, and the code claimed otherwise. The job read statuses once, classified over several round-trips, then posted: a reviewer posting BLOCKED in between had it replaced by an exemption `success`, turning an explicit rejection into a merge. Added a re-read immediately before the POST which refuses to write over a human verdict found then. The heading no longer says "never overwrite" — it cannot promise that, since there is no compare-and-set on Gitea's status API. Remainder tracked as #706. MEDIUM — documentation was stale in three places, all mine. The record's frontmatter `rule:` still listed the npm manifests (I fixed the body and forgot the frontmatter, so the canonical rule AND the generated catalog were wrong); docs/ci-cd.md still said `edited` was absent from `types:`, contradicting a section I had just updated; and the workflow header still implied the `edited` re-run settles the ABA race. All corrected to say detection, not atomicity. TESTS. 373 pass. New: a mid-run human verdict via a status stub that returns nothing on the first read and BLOCKED on the re-read, and large-input regression tests for the ADVISORY hook, which had none — the copy with less authority is the one that quietly keeps a bug (#649's whole point). Mutation-verified: reverting the hook predicate, the workflow predicates, or the pre-POST re-read turns exactly the intended tests red while every positive control stays green. Refs: #698 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
377 lines
30 KiB
Bash
Executable File
377 lines
30 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# PreToolUse / mcp__gitea__pull_request_write — derive merge consent from STATE instead of
|
|
# trusting the agent's judgment (ersatztv#303 H6 + H10). A PR merge is the one irreversible op; allow it
|
|
# only when ALL are true:
|
|
# (a) the PR's CI combined status is green, AND
|
|
# (b) every checkbox in the linked issue's "## Done-when" section is ticked, AND
|
|
# (c) a review-verdict comment on the PR references the CURRENT head sha (H10) — proving the
|
|
# LATEST commit was reviewed, not a stale earlier diff (the ersatztv#242 failure mode:
|
|
# "re-review the fix commit, not just the initial PR diff").
|
|
#
|
|
# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. That is sound for an
|
|
# immediate merge and UNSOUND for a scheduled one: with merge_when_checks_succeed, Gitea merges
|
|
# later, against whatever head is green then (ersatztv#622). So the sha-bound half of H10 is
|
|
# enforced by the SERVER, not here — `review-verdict/h10` is a required status check on `main`,
|
|
# written per-sha by scripts/post-review-verdict.sh, and a new commit cannot inherit it. This hook
|
|
# additionally refuses to SCHEDULE an auto-merge unless that status is already green on head, so the
|
|
# two mechanisms agree at the only moment they can both observe the same commit.
|
|
# The "## Done-when" issue-body checklist is the convention (docs/decisions.md, CLAUDE.md Task
|
|
# Completion Protocol). One box is "adversarial review passed"; the others are per-issue.
|
|
# The H10 review-verdict convention: after reviewing a PR (or its latest fix commit), post a PR
|
|
# comment carrying a line `Review-verdict: <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> @ <head-sha>`.
|
|
#
|
|
# Decision policy — a CONSENT gate, so it does NOT fail silently open:
|
|
# - state derivable and satisfied -> grant (auto-approve: permissionDecision "allow",
|
|
# so NO redundant permission prompt fires —
|
|
# the derived state IS the consent, ersatztv#314)
|
|
# - state derivable and NOT satisfied -> deny (actionable reason)
|
|
# - state NOT derivable (no creds, Gitea down,
|
|
# no linked issue, no Done-when section) -> ask (surface to a human/session judgment)
|
|
# Only a real merge is gated; every other pull_request_write method is passed through UNTOUCHED
|
|
# (bare exit 0 → normal permissioning still applies), NOT auto-granted.
|
|
#
|
|
# WHY "grant" (not a bare exit 0) on the satisfied path (ersatztv#314 root cause): a PreToolUse hook
|
|
# that exits 0 with no JSON does NOT auto-approve — it only declines to block, so control falls through
|
|
# to the normal permission system and the raw MCP prompt still fires. The gate therefore only ever
|
|
# ADDED a deny/ask net; it never REMOVED the baseline prompt on the happy path, so a satisfied merge
|
|
# was confirmed twice (conversationally + a redundant mechanical prompt). Emitting permissionDecision
|
|
# "allow" is what actually suppresses the prompt — "derive consent from state" made real.
|
|
#
|
|
# Gitea auth from env (never committed): ETV_GITEA_TOKEN (a token) OR ETV_GITEA_BASICAUTH (user:pass).
|
|
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
|
|
set -euo pipefail
|
|
input=$(cat)
|
|
|
|
decide() { # $1=grant|allow|deny|ask $2=reason
|
|
case "$1" in
|
|
# grant = the gate is SATISFIED → auto-approve so no redundant permission prompt fires.
|
|
grant) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:$r}}'; exit 0 ;;
|
|
# allow = not our concern (non-merge method) → pass through untouched; normal permissioning applies.
|
|
allow) exit 0 ;;
|
|
deny) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'; exit 0 ;;
|
|
ask) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}'; exit 0 ;;
|
|
esac
|
|
}
|
|
|
|
method=$(printf '%s' "$input" | jq -r '.tool_input.method // ""' 2>/dev/null || true)
|
|
[ "$method" = "merge" ] || decide allow ""
|
|
|
|
owner=$(printf '%s' "$input" | jq -r '.tool_input.owner // ""' 2>/dev/null || true)
|
|
repo=$(printf '%s' "$input" | jq -r '.tool_input.repo // ""' 2>/dev/null || true)
|
|
pr=$(printf '%s' "$input" | jq -r '.tool_input.pull_number // ""' 2>/dev/null || true)
|
|
mwcs=$(printf '%s' "$input" | jq -r '.tool_input.merge_when_checks_succeed // false' 2>/dev/null || true)
|
|
[ -n "$owner" ] && [ -n "$repo" ] && [ -n "$pr" ] || decide ask "H6 merge gate: could not read owner/repo/pull_number from the merge call; confirm manually that CI is green and the issue's Done-when boxes are ticked."
|
|
|
|
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
|
|
# curl wrapper carrying whichever auth is configured; empty output on any failure.
|
|
gq() {
|
|
local path="$1"
|
|
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
|
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$path" 2>/dev/null || true
|
|
elif [ -n "${ETV_GITEA_BASICAUTH:-}" ]; then
|
|
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$path" 2>/dev/null || true
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
|
|
decide ask "H6 merge gate: no Gitea credentials in env (ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH), so CI/Done-when state can't be verified. Confirm manually that CI is green and the linked issue's Done-when boxes are all ticked, then approve."
|
|
fi
|
|
|
|
prjson=$(gq "repos/$owner/$repo/pulls/$pr")
|
|
[ -n "$prjson" ] || decide ask "H6 merge gate: could not fetch PR #$pr from Gitea (unreachable or auth rejected). Verify CI-green + Done-when manually before merging."
|
|
|
|
sha=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true)
|
|
body=$(printf '%s' "$prjson" | jq -r '.body // ""' 2>/dev/null || true)
|
|
|
|
# --- Docs-only exemption: if every changed file is docs/process, skip the gate. ---
|
|
# The file list must be enumerated EXHAUSTIVELY, validated row by row, and bound to ONE head, or the
|
|
# exemption is unsafe. ALL of that now lives in scripts/pr-changed-files.sh — the single shared
|
|
# implementation, also called by .gitea/workflows/review-verdict.yml (ersatztv#649).
|
|
#
|
|
# Why it moved: this logic was written twice. This copy is ADVISORY (a failure produces a human
|
|
# prompt); the workflow's copy is ENFORCED (it writes the branch-protection-required
|
|
# `review-verdict/h10` status). Four rounds of ersatztv#643 hardening landed here and never reached
|
|
# there, leaving the copy with real authority strictly weaker than the copy without — and its safe
|
|
# behaviour resting on a bash arithmetic error rather than an intentional guard. Two copies of a
|
|
# security predicate drift; one cannot.
|
|
#
|
|
# What is NOT shared, deliberately: the docs-only allow-list below. This one also lets .claude/,
|
|
# .gitea/ and .husky/ through, which is safe HERE only because a match falls through to a human
|
|
# prompt rather than auto-granting. The workflow's list is narrower for exactly that reason. Sharing
|
|
# the enumeration fixes the drift; sharing the classification would erase an intended difference.
|
|
#
|
|
# A non-zero exit means "could not tell" and MUST withhold the exemption — never read stdout without
|
|
# checking the status. An empty `$sha` (unparseable PR JSON) reaches the script as an empty argument
|
|
# and is rejected there, so that path also fails closed.
|
|
#
|
|
# The 5th argument binds the enumeration to a base branch (ersatztv#698 route 1), because
|
|
# `/pulls/{n}/files` diffs against the PR's LIVE base and retargeting moves that without moving the
|
|
# head. Be precise about what it buys HERE, which is less than what it buys in the workflow: the
|
|
# workflow passes the base from a `pull_request_target` event payload, fixed at event time and beyond
|
|
# a retarget's reach, so it detects a retarget outright. This hook has no such trusted snapshot — it
|
|
# passes the base it just read from the live PR, so what it asserts is that the base did not move
|
|
# between that read and the enumeration. Narrower, and still worth having: without it the hook cannot
|
|
# tell a mid-flight retarget from an honest read at all. An empty/unparseable `.base.ref` reaches the
|
|
# script as an empty argument and is rejected there, so that path fails closed too.
|
|
base_ref=$(printf '%s' "$prjson" | jq -r '.base.ref // ""' 2>/dev/null || true)
|
|
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
|
files=""; files_complete=no
|
|
if files=$("$repo_root/scripts/pr-changed-files.sh" "$owner" "$repo" "$pr" "$sha" "$base_ref" 2>/dev/null); then
|
|
files_complete=yes
|
|
fi
|
|
|
|
# HOW THIS PREDICATE IS EVALUATED, matching the enforced gate (ersatztv#698,
|
|
# `ci.grep-q-pipefail-inversion`). `printf … | grep -q` INVERTS under `set -o pipefail`: grep -q exits
|
|
# at its first match, printf then takes SIGPIPE (141), and a MATCH is reported as a failed pipeline —
|
|
# so this negated test would grant a spurious docs-only exemption for any PR whose path list exceeds
|
|
# the pipe buffer. A here-string fixes that but is materialised via temporary storage for large inputs,
|
|
# so it can fail when temp space is full or unwritable and flip the predicate the same way. Counting
|
|
# with `grep -c` drains stdin (no SIGPIPE) over an ordinary pipe (no temp file); `grep -c` exits 1 for
|
|
# a zero count, which is a legitimate answer, so only a status >1 is a real error and is treated as
|
|
# "cannot tell" -> no exemption.
|
|
# Advisory here, so the blast radius is a missing prompt rather than a green required check; the
|
|
# construct is identical on purpose, because the two copies drifting is what ersatztv#649 was about.
|
|
docs_nonmatching=$(printf '%s\n' "$files" | grep -cvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)') || docs_grep_status=$?
|
|
if [ "${docs_grep_status:-0}" -gt 1 ]; then
|
|
docs_nonmatching=1 # grep itself failed: cannot tell, so withhold the exemption
|
|
fi
|
|
if [ "$files_complete" = yes ] && [ -n "$files" ] && [ "${docs_nonmatching:-1}" -eq 0 ]; then
|
|
# Docs/process-only PR: the Done-when + review-verdict gate doesn't apply — but this exemption is a
|
|
# file-TYPE bypass, NOT the a+b+c "provably reviewed & ready" proof, so it does NOT auto-grant. It
|
|
# passes through to normal permissioning (one prompt). This deliberately keeps a human in the loop for
|
|
# process-control files (.claude/ / .gitea/ / .husky/ — the gate, CI, and git hooks themselves): a PR
|
|
# that weakens the gate must not silently self-merge (ersatztv#317 review nit). Only the satisfied
|
|
# merge path below auto-grants.
|
|
decide allow "" # passthrough (exit 0 → normal prompt), NOT grant
|
|
fi
|
|
|
|
# --- Base-change detection: a verdict is bound to a head AND to a base (ersatztv#632). ---
|
|
# `review-verdict/h10` is per-sha, which makes "the head moved under a fixed verdict" impossible by
|
|
# construction. Retargeting a PR's base is the mirror case and slips through: it changes neither the
|
|
# head sha nor the status, so a verdict formed while the PR targeted `main` still reads green after
|
|
# the PR is pointed at a branch with a very different merge-base. The diff moves while the verdict
|
|
# and the head both hold still.
|
|
#
|
|
# DETECTION, NOT PREVENTION, and only on this path. A commit status carries no base, so the
|
|
# server-side required check cannot see this; 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 is now recorded in a place that fails LOUD rather than only in a doc.
|
|
#
|
|
# GRACEFUL ADOPTION, mirroring (b) and (c): a description with no `(base: …)` field is a verdict
|
|
# posted before ersatztv#632 and gets NO opinion, rather than denying every in-flight PR the day
|
|
# this lands. The window closes on its own — verdicts are per-head and short-lived, so every verdict
|
|
# posted after this carries the field.
|
|
# "Could not check" is a THIRD outcome, distinct from both "matches" and "no base recorded". Cold
|
|
# review found the first draft collapsing it into the latter: an unreadable status response yielded
|
|
# an empty `recorded_base`, which took the graceful-adoption path and skipped validation silently —
|
|
# after which a later, successful status read could still auto-grant. A transient failure would then
|
|
# have produced a "merge gate: satisfied" message for a comparison that never happened. Every
|
|
# unreadable input here therefore falls through to a human (`ask`), never to silence.
|
|
live_base=$(printf '%s' "$prjson" | jq -r '.base.ref // ""' 2>/dev/null || true)
|
|
if [ -z "$live_base" ]; then
|
|
decide ask "H10 merge gate: PR #$pr reports no base branch (.base.ref), so the verdict cannot be checked against the branch it was formed for (ersatztv#632). Confirm the PR still targets the branch it was reviewed against before merging."
|
|
fi
|
|
if [ -n "$sha" ]; then
|
|
# This is the THIRD read of this endpoint in a worst-case hook run (the ordinary-CI branch and the
|
|
# scheduled-auto-merge branch each do their own). Sharing one snapshot would close a narrow
|
|
# same-run window where two reads disagree, but the later branches derive different decisions from
|
|
# a failed read than this one does, so threading a shared response through them is a change to
|
|
# pre-existing logic rather than to ersatztv#632's. Left deliberately, noted so it is not
|
|
# rediscovered as an oversight: every `decide` exits immediately, so the reads cannot produce a
|
|
# single self-contradictory message — only a later decision made on a fresher snapshot.
|
|
vjson_base=$(gq "repos/$owner/$repo/commits/$sha/status?limit=100")
|
|
# Same jq-1.6 rule as everywhere else in this file: check emptiness in SHELL first, never via
|
|
# `jq -e`'s exit status over empty input.
|
|
# VALIDATE EVERY FIELD THE EXTRACTION CONSUMES, on EVERY row — the same rule the file-enumeration
|
|
# guard learned the hard way. Checking only that `.statuses` is an array left a hole one level
|
|
# down: `{"statuses":[1]}` passes a top-level type check, then `.context` on a number errors, and
|
|
# a `|| true` on the extraction turned that error into an empty `vdesc` — i.e. straight back onto
|
|
# the graceful-adoption path this block exists to distinguish from. That is the identical
|
|
# swallow-the-error shape fixed a few lines up, surviving one level deeper.
|
|
if [ -z "${vjson_base//[[:space:]]/}" ] \
|
|
|| ! printf '%s' "$vjson_base" \
|
|
| jq -e '.statuses | type == "array"
|
|
and all(.[]; type == "object"
|
|
and (.context | type == "string")
|
|
and (.description == null or (.description | type == "string")))' \
|
|
>/dev/null 2>&1; then
|
|
decide ask "H10 merge gate: could not read the commit statuses for PR #$pr head ${sha:0:7}, so the verdict could not be checked against the PR's base branch (ersatztv#632). Confirm the review covered the branch this PR currently targets ('$live_base') before merging."
|
|
fi
|
|
# No `|| true` here. The validation above makes an error unreachable, but a swallowed error would
|
|
# be indistinguishable from "no base recorded" — the exact confusion this block removes — so the
|
|
# failure is handled explicitly rather than left to a fallback that reads as a benign result.
|
|
if ! vdesc=$(printf '%s' "$vjson_base" \
|
|
| jq -r '[.statuses[] | select(.context == "review-verdict/h10")] | first | .description // ""' \
|
|
2>/dev/null); then
|
|
decide ask "H10 merge gate: the commit statuses for PR #$pr head ${sha:0:7} could not be parsed to find the review verdict, so it could not be checked against the PR's base branch (ersatztv#632). Confirm the review covered the branch this PR currently targets ('$live_base') before merging."
|
|
fi
|
|
# The field is written by scripts/post-review-verdict.sh as a trailing `(base: <ref>)`. Its
|
|
# ABSENCE is the one benign case: a verdict posted before ersatztv#632 could not have carried it,
|
|
# and denying those would block every in-flight PR the day this lands. The window closes on its
|
|
# own, since verdicts are per-head and short-lived.
|
|
recorded_base=$(printf '%s' "$vdesc" | sed -n 's/.*(base: \(.*\))$/\1/p')
|
|
if [ -n "$recorded_base" ] && [ "$recorded_base" != "$live_base" ]; then
|
|
decide deny "H10 merge gate: BLOCKED — the review verdict on head ${sha:0:7} was formed while PR #$pr targeted '$recorded_base', but it now targets '$live_base'. Retargeting a base does not move the head sha, so the per-sha verdict status still reads green even though the effective diff has changed (ersatztv#632). Re-review against the new base and run: scripts/post-review-verdict.sh $pr MERGEABLE"
|
|
fi
|
|
fi
|
|
|
|
# --- Linked issue: Gitea auto-close keywords in the PR body. ---
|
|
issues=$(printf '%s' "$body" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
|
[ -n "$issues" ] || decide ask "H6 merge gate: PR #$pr has no linked issue (no 'fixes #N' / 'closes #N' in its body), so there is no Done-when checklist to derive consent from. Confirm the work is complete + reviewed, then approve."
|
|
|
|
# --- (b) Done-when checkboxes: every linked issue must have an all-ticked section. ---
|
|
for n in $issues; do
|
|
ibody=$(gq "repos/$owner/$repo/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
|
|
[ -n "$ibody" ] || decide ask "H6 merge gate: could not fetch linked issue #$n. Verify its Done-when checklist manually before merging."
|
|
# Slice the "## Done-when" section: from that header to the next "## " (or EOF).
|
|
section=$(printf '%s\n' "$ibody" | awk '
|
|
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
|
|
grab && /^##[[:space:]]/ {grab=0}
|
|
grab {print}')
|
|
if [ -z "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
|
|
decide ask "H6 merge gate: linked issue #$n has no '## Done-when' checklist section (the merge-consent convention — see CLAUDE.md Task Completion Protocol). Add one, or confirm completion manually and approve."
|
|
fi
|
|
unchecked=$(printf '%s\n' "$section" | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
|
|
if [ "${unchecked:-0}" -gt 0 ]; then
|
|
decide deny "H6 merge gate: BLOCKED — linked issue #$n has $unchecked unticked box(es) in its ## Done-when checklist. Finish (or explicitly tick) every completion criterion — including the adversarial-review box — before merging PR #$pr."
|
|
fi
|
|
done
|
|
|
|
# --- (a) CI combined status must be green (unless deferring to Gitea's own check-gate). ---
|
|
if [ "$mwcs" != "true" ]; then
|
|
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check CI. Verify CI is green before merging."
|
|
cistatus=$(gq "repos/$owner/$repo/commits/$sha/status?limit=100")
|
|
state=$(printf '%s' "$cistatus" | jq -r '.state // ""' 2>/dev/null || true)
|
|
case "$state" in
|
|
success) : ;;
|
|
"") decide ask "H6 merge gate: could not read CI status for PR #$pr ($sha). Verify CI is green before merging." ;;
|
|
*)
|
|
# `review-verdict/h10` is itself one of the contexts folded into the COMBINED state, so a PR
|
|
# awaiting its verdict reports combined 'pending' and would otherwise be reported as a CI
|
|
# problem — sending the reader to build logs when the missing thing is the review. Name the
|
|
# real blocker when the verdict is the only thing outstanding.
|
|
#
|
|
# "Not green" is anything that is not `success`, NOT just pending/failure: Gitea also has
|
|
# `error` (and `warning`), and omitting those would let an errored build hide behind the
|
|
# verdict and produce the flatly false claim "every CI check is green". `skipped` IS treated
|
|
# as green — the image-push job skips on every PR (ersatztv#593: a skipped context is not red).
|
|
nongreen=$(printf '%s' "$cistatus" \
|
|
| jq -r '[.statuses[]? | select(.status != "success" and .status != "skipped")]
|
|
| map("\(.context)=\(.status)") | join(", ")' 2>/dev/null || true)
|
|
# The verdict's OWN state decides the wording: absent/pending means nobody has reviewed this
|
|
# head, while failure/error means someone reviewed it and said no. Telling a reviewer to "post
|
|
# a verdict" when they already posted a BLOCKED one would be actively misleading.
|
|
vonly=$(printf '%s' "$cistatus" \
|
|
| jq -r '[.statuses[]? | select(.status != "success" and .status != "skipped")]
|
|
| if (length == 1 and .[0].context == "review-verdict/h10") then .[0].status else "" end' 2>/dev/null || true)
|
|
case "$vonly" in
|
|
pending)
|
|
decide deny "H6/H10 merge gate: BLOCKED — every CI check on PR #$pr is green; the only outstanding context is 'review-verdict/h10' on head ${sha:0:7}, i.e. this head has no review verdict yet. Review it and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
|
failure|error)
|
|
decide deny "H6/H10 merge gate: BLOCKED — every CI check on PR #$pr is green, but 'review-verdict/h10' is '$vonly' on head ${sha:0:7}: this head was reviewed and REJECTED. Resolve the findings, then run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
|
esac
|
|
decide deny "H6 merge gate: BLOCKED — PR #$pr CI status is '$state', not 'success' (not green: ${nongreen:-unknown}). Wait for a green build (or pass merge_when_checks_succeed to let Gitea gate it) before merging."
|
|
;;
|
|
esac
|
|
else
|
|
# --- SCHEDULED auto-merge: everything this hook proves is a SNAPSHOT (ersatztv#622). ----------
|
|
# With merge_when_checks_succeed, Gitea performs the merge later, against whatever head is green
|
|
# at THAT moment — but (b) and (c) below are evaluated against the head that exists right now.
|
|
# Any commit pushed in between would merge with no verdict covering it. Demonstrated as a
|
|
# controlled A/B (#622): with a slow CI check pending so Gitea waits, an unreviewed commit pushed
|
|
# after scheduling MERGED without the required verdict context and was REFUSED with it.
|
|
#
|
|
# The durable fix is server-side and lives outside this hook: `review-verdict/h10` is a REQUIRED
|
|
# status check on `main`, and a commit status belongs to exactly ONE sha, so a later commit cannot
|
|
# inherit it and Gitea's own gate refuses to merge until that head is re-reviewed.
|
|
#
|
|
# What we add HERE is the matching precondition at SCHEDULING time: refuse to arm an auto-merge
|
|
# unless the sha-bound status already exists on this head. Checking the comment alone (condition
|
|
# (c) below) is not enough for this path — the comment is what a human reads, the status is what
|
|
# the server enforces, and only the latter survives a new push. Deny rather than ask: the remedy
|
|
# is a single documented command, so there is nothing here for a human to adjudicate.
|
|
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check the review-verdict status. Verify the review covered the latest commit before scheduling an auto-merge."
|
|
# Read the COMBINED endpoint, not `/statuses/{sha}`: the latter returns one row per status POST
|
|
# rather than per context and pages at 50, so a head with a few CI reruns can push the verdict off
|
|
# the first page and read as absent — a confusing false deny. The combined endpoint returns
|
|
# latest-per-context, which is exactly the question being asked.
|
|
vjson=$(gq "repos/$owner/$repo/commits/$sha/status?limit=100")
|
|
# Same portability point as the file-pagination guard above: do not let jq's empty-input exit
|
|
# status decide this. Here the fallthrough happens to land on `vstate=""` -> deny (fail-CLOSED,
|
|
# so this was never a hole), but it would have surfaced the wrong message — a "BLOCKED, no
|
|
# verdict" deny instead of the "could not read the status" ask this branch exists to give.
|
|
if [ -z "${vjson//[[:space:]]/}" ] || ! printf '%s' "$vjson" | jq -e '.statuses | type == "array"' >/dev/null 2>&1; then
|
|
decide ask "H6/H10 merge gate: could not read the 'review-verdict/h10' status for PR #$pr head ${sha:0:7} (Gitea unreachable or an unexpected response). Confirm the current head is reviewed before scheduling an auto-merge."
|
|
fi
|
|
vstate=$(printf '%s' "$vjson" | jq -r '[.statuses[] | select(.context == "review-verdict/h10")] | first | .status // ""')
|
|
case "$vstate" in
|
|
success) : ;;
|
|
"") decide deny "H6/H10 merge gate: BLOCKED — PR #$pr has no 'review-verdict/h10' commit status on head ${sha:0:7}, so scheduling an auto-merge would freeze consent at a head Gitea may not be the one to merge (ersatztv#622). Review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
|
pending) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is still pending on PR #$pr head ${sha:0:7} (no verdict posted for this commit yet). Review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
|
*) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is '$vstate' on PR #$pr head ${sha:0:7}. Resolve the findings, then run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
|
esac
|
|
fi
|
|
|
|
# --- (c) Review-verdict freshness (ersatztv#303 H10): a review-verdict comment must reference the
|
|
# CURRENT head sha, so the latest commit is proven-reviewed (ersatztv#242: re-review the fix
|
|
# commit, not just the initial diff). Graceful adoption mirrors (b): a verdict comment that
|
|
# references head must be positive -> allow; one that exists only for an OLDER commit -> deny
|
|
# (the stale-review failure mode); NO verdict comment at all -> ask (convention not yet used).
|
|
[ -n "$sha" ] || decide ask "H10 merge gate: could not resolve PR #$pr head sha to verify a review verdict. Confirm the review covered the latest commit before merging."
|
|
short=${sha:0:7}
|
|
comments=$(gq "repos/$owner/$repo/issues/$pr/comments?limit=100")
|
|
if [ -z "$comments" ]; then
|
|
decide ask "H10 merge gate: could not fetch PR #$pr comments to verify a head-referencing review verdict ($short). Confirm the adversarial/Codex review covered the latest commit before merging."
|
|
fi
|
|
# Classification is delegated to `scripts/check-review-verdict.sh` — the single source of truth for
|
|
# the H10 grammar, extracted in #629 so it could be TESTED. While it lived here it had none, and three
|
|
# false-opens survived in it: a prefix-matched token (`MERGEABLE-LATER` graded positive), a verdict
|
|
# inside a fenced code block (documentation showing the convention counted as a real verdict), and a
|
|
# sha taken from the first `@<hex>` anywhere on the line (a markdown link could supply it). Every
|
|
# decision the classifier makes is documented there; this file only maps a class onto a hook decision.
|
|
verdict_script="${CLAUDE_PROJECT_DIR:-.}/scripts/check-review-verdict.sh"
|
|
if [ ! -x "$verdict_script" ]; then
|
|
decide ask "H10 merge gate: verdict classifier not found at $verdict_script, so the review state can't be derived. Confirm the review covered the latest commit before merging."
|
|
fi
|
|
# An input error (exit 2) is NOT a classification — fall through to a human rather than guessing.
|
|
if ! class=$(printf '%s' "$comments" | "$verdict_script" --head "$sha" 2>/dev/null); then
|
|
decide ask "H10 merge gate: could not classify the review verdicts on PR #$pr (malformed comments payload or unreadable head). Confirm the review covered the latest commit ($short) before merging."
|
|
fi
|
|
|
|
case "$class" in
|
|
negative)
|
|
# A negative verdict on head wins over a positive one (a later BLOCKED retracts an earlier
|
|
# MERGEABLE on the SAME head; if the head were fixed the sha would change, so this can't
|
|
# wrongly block).
|
|
decide deny "H10 merge gate: BLOCKED — a review verdict for the current head ($short) is negative (BLOCKED/NOT-MERGEABLE). Resolve the findings and post a fresh 'Review-verdict: MERGEABLE @ $short' before merging PR #$pr." ;;
|
|
stale)
|
|
decide deny "H10 merge gate: BLOCKED — a review-verdict comment references an older commit, not the current head ($short). The latest commit(s) are unreviewed (ersatztv#242: re-review the fix commit, not just the initial diff). Re-review the head and post 'Review-verdict: MERGEABLE @ $short'." ;;
|
|
unknown)
|
|
decide ask "H10 merge gate: a 'Review-verdict:' comment on PR #$pr uses an unrecognized verdict token (not MERGEABLE/APPROVED/LGTM/BLOCKED/NOT-MERGEABLE). It is deliberately NOT read as approval. Post a verdict using the documented vocabulary — e.g. 'Review-verdict: MERGEABLE @ $short'." ;;
|
|
no-sha)
|
|
# Marker(s) exist but reference no sha at all -> ask (don't mislabel as a stale older-commit review).
|
|
decide ask "H10 merge gate: a 'Review-verdict:' comment on PR #$pr references no commit sha in its own '@ <sha>' field. Post one referencing the current head ($short) — e.g. 'Review-verdict: MERGEABLE @ $short' — or confirm the review covered the latest commit and approve." ;;
|
|
absent)
|
|
decide ask "H10 merge gate: no 'Review-verdict:' comment found on PR #$pr referencing head $short. Post the adversarial/Codex verdict (e.g. 'Review-verdict: MERGEABLE @ $short'), or confirm the review covered the latest commit and approve." ;;
|
|
positive) : ;;
|
|
*)
|
|
decide ask "H10 merge gate: unrecognized verdict classification '$class' for PR #$pr. Confirm the review covered the latest commit ($short) before merging." ;;
|
|
esac
|
|
|
|
if [ "$class" = "positive" ]; then
|
|
# (a) CI + (b) all Done-when ticked + (c) positive verdict @ current head -> SATISFIED. Auto-grant.
|
|
# The reason string must not claim more than was actually checked: on the merge_when_checks_succeed
|
|
# path this hook never read the CI status at all (it is delegated to Gitea), so saying "CI green"
|
|
# there was a plain falsehood in the one message a human reads to decide whether to trust the gate.
|
|
if [ "$mwcs" = "true" ]; then
|
|
decide grant "H6/H10 merge gate: satisfied — all Done-when boxes ticked, and both a positive Review-verdict comment and the 'review-verdict/h10' status cover the current head ($short). CI is gated by Gitea (merge_when_checks_succeed), and because the verdict status is bound to this sha, a commit pushed before Gitea merges will clear it and block the merge (ersatztv#622). Auto-granted."
|
|
fi
|
|
decide grant "H6/H10 merge gate: satisfied — CI green, all Done-when boxes ticked, and a positive Review-verdict references the current head ($short). Auto-granted (no separate confirmation needed)."
|
|
fi
|
|
|
|
# Unreachable: the `case` above exits on every class, and `positive` exits in the block above. Kept as
|
|
# a fail-safe so a future class added to the classifier without a branch here cannot fall off the end
|
|
# of the script (which would exit 0 = silent passthrough, the one outcome a gate must never produce).
|
|
decide ask "H10 merge gate: verdict classification for PR #$pr produced no decision. Confirm the review covered the latest commit ($short) before merging."
|