Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m33s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m36s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m33s
Adversarial-review nit on the first commit: auto-granting the docs-only exemption silently self-merges process-control PRs (.claude/.gitea/.husky — including the gate hook itself) with no prompt and no review, bypassing human-in-the-loop for exactly the files that control the gate. Restrict auto-grant to the genuinely- satisfied (a+b+c) merge path; the docs/process exemption reverts to bare exit-0 passthrough (one normal prompt). Corrects this PR's own decisions.md entry accordingly ([decisions-edit]: a not-yet-merged draft entry, not history). 8 pipe tests green (adds docs-only -> passthrough). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
184 lines
13 KiB
Bash
Executable File
184 lines
13 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").
|
|
# 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. ---
|
|
files=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=100" | jq -r '.[].filename // empty' 2>/dev/null || true)
|
|
if [ -n "$files" ] && ! printf '%s\n' "$files" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; 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
|
|
|
|
# --- 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."
|
|
state=$(gq "repos/$owner/$repo/commits/$sha/status" | 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." ;;
|
|
*) decide deny "H6 merge gate: BLOCKED — PR #$pr CI status is '$state', not 'success'. Wait for a green build (or pass merge_when_checks_succeed to let Gitea gate it) before merging." ;;
|
|
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
|
|
# Verdict lines across all comment bodies: a real verdict line STARTS with the marker (after optional
|
|
# leading whitespace). Anchoring to line-start is deliberate — it rejects a comment that merely QUOTES
|
|
# the positive template mid-sentence (an instruction "please post: Review-verdict: MERGEABLE @ <sha>",
|
|
# or the gate's own suggestion text echoed back), which would otherwise self-approve the merge.
|
|
verdicts=$(printf '%s' "$comments" | jq -r '.[].body // empty' 2>/dev/null | grep -iE '^[[:space:]]*review-verdict:' || true)
|
|
if [ -z "$verdicts" ]; then
|
|
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."
|
|
fi
|
|
# Classify each verdict line by the sha it references (its "@ <sha>" field) and its verdict word.
|
|
# A line references the CURRENT head iff head BEGINS WITH that sha token AND the token is >=7 chars
|
|
# (git short-sha prefix semantics) — NOT a loose substring test: an older sha that merely contains
|
|
# the head prefix, or the head prefix appearing in an unrelated URL on the line, must NOT count
|
|
# (adversarial false-opens). The verdict token must sit right after the marker on the same line.
|
|
head_pos=0; head_neg=0; stale=0
|
|
while IFS= read -r line; do
|
|
[ -n "$line" ] || continue
|
|
# The sha the line references: the hex token in its "@ <sha>" field (>=7 chars), lowercased.
|
|
ref=$(printf '%s' "$line" | grep -ioE '@[[:space:]]*[0-9a-f]{7,40}' | head -1 \
|
|
| grep -oiE '[0-9a-f]{7,40}' | tr 'A-F' 'a-f' || true)
|
|
is_pos=0
|
|
# Positive iff the line's OWN leading verdict word (right after the line-start marker) is positive —
|
|
# anchored so a second, later `review-verdict: mergeable` substring on a BLOCKED line can't flip it.
|
|
if printf '%s' "$line" | grep -iqE '^[[:space:]]*review-verdict:[[:space:]]*(mergeable|approved|lgtm)'; then is_pos=1; fi
|
|
[ -z "$ref" ] && continue # marker present but no @<sha> -> falls through to the final ask
|
|
case "$sha" in
|
|
"$ref"*) if [ "$is_pos" = 1 ]; then head_pos=1; else head_neg=1; fi ;;
|
|
*) stale=1 ;;
|
|
esac
|
|
done <<VERDICTS
|
|
$verdicts
|
|
VERDICTS
|
|
|
|
# A negative verdict on head wins over a positive one (a later BLOCKED retracts an earlier MERGEABLE
|
|
# on the SAME head; and if the head were fixed the sha would change, so this can't wrongly block).
|
|
if [ "$head_neg" = 1 ]; then
|
|
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."
|
|
fi
|
|
if [ "$head_pos" = 1 ]; then
|
|
# (a) CI green + (b) all Done-when ticked + (c) positive verdict @ current head -> SATISFIED. Auto-grant.
|
|
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
|
|
if [ "$stale" = 1 ]; then
|
|
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'."
|
|
fi
|
|
# 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. Post one referencing the current head ($short) — e.g. 'Review-verdict: MERGEABLE @ $short' — or confirm the review covered the latest commit and approve."
|
|
|
|
# All derivable and satisfied -> auto-grant (defensive: the head_pos branch above already exits here).
|
|
decide grant "H6/H10 merge gate: satisfied — auto-granted."
|