Files
ersatztv/.claude/hooks/prepush-donewhen.sh
T
timothyandClaude Opus 4.8 0badff811d
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m32s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m48s
feat(process): #303 H6 merge-consent derived from ## Done-when checklist
Wave 2 hook H6: derive merge-consent from state instead of memory. An
issue's ## Done-when checklist (issue body) becomes the machine-readable
source of truth for whether its PR may merge — the structural fix for the
queue-drift #303 tracks (status was living in append-only prose).

- pretooluse-merge-consent.sh (Claude PreToolUse on mcp__gitea__
  pull_request_write): a merge is ALLOWED only when the PR's CI is green
  AND every ## Done-when box on the linked issue (fixes #N) is ticked;
  DENY on an unticked box / red CI; ASK (human prompt) when state isn't
  derivable (no linked issue, no section, no creds, Gitea down). Docs/
  process-only PRs exempt.
- .husky/pre-push -> prepush-donewhen.sh: fail-open backstop for a direct
  `git push origin main`; blocks only on a positively-proven unticked box.

Gitea auth from env only (ETV_GITEA_BASICAUTH / ETV_GITEA_TOKEN,
ETV_GITEA_URL) — nothing committed; without creds the gate degrades to
today's manual confirmation, never a silent pass. Non-breaking rollout:
until issues adopt ## Done-when the merge hook simply asks.

Pipe-tested: non-merge->allow, no-creds->ask, docs-only->allow, checklist
parser (unit), linked-issue extraction, and a live end-to-end block path
(temp Done-when on #303 -> exit 1 -> restored). Docs: CLAUDE.md Task
Completion Protocol + decisions.md entry. Refs #303.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:29:34 +02:00

70 lines
3.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# Husky pre-push backstop for ersatztv#303 H6 — the fast-forward-to-main path the Claude merge
# hook (pretooluse-merge-consent.sh) can't see. Reads git's pre-push ref lines on stdin; for a push
# to main it scans the pushed commits for a Gitea close-keyword (`fixes #N`), and if the linked
# issue's "## Done-when" checklist still has unticked boxes it BLOCKS the push.
#
# A git hook has no interactive "ask", so this is deliberately fail-OPEN: it only blocks when it can
# positively prove an unticked box (creds present, issue fetched, non-docs change). No creds, Gitea
# unreachable, docs-only diff, or no linked issue -> allow (a loud warning at most). The authoritative
# gate is the merge hook; this just catches a direct `git push origin main`.
#
# Auth (never committed): ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH; ETV_GITEA_URL overrides the base.
set -euo pipefail
# git passes "<localref> <localsha> <remoteref> <remotesha>" lines on stdin.
refs=$(cat || true)
printf '%s\n' "$refs" | grep -q 'refs/heads/main' || exit 0 # only gate pushes to main
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
exit 0 # can't verify -> fail-open (the merge hook is the real gate)
fi
gq() {
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$1" 2>/dev/null || true
else
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$1" 2>/dev/null || true
fi
}
zero=0000000000000000000000000000000000000000
blocked=""
while read -r localref localsha remoteref remotesha; do
[ "$remoteref" = "refs/heads/main" ] || continue
[ "$localsha" = "$zero" ] && continue # branch deletion
# Commit range being pushed. New branch (remotesha all-zero) -> just the tip, don't rescan history.
if [ "$remotesha" = "$zero" ]; then range="$localsha -1"; else range="$remotesha..$localsha"; fi
msgs=$(git log --format='%B' $range 2>/dev/null || true)
issues=$(printf '%s' "$msgs" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
[ -n "$issues" ] || continue
# Docs-only exemption over the pushed range.
changed=$(git diff --name-only $range 2>/dev/null || true)
if [ -n "$changed" ] && ! printf '%s\n' "$changed" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
continue
fi
for n in $issues; do
ibody=$(gq "repos/timothy/ersatztv/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
[ -n "$ibody" ] || continue # can't fetch -> fail-open
unchecked=$(printf '%s\n' "$ibody" | awk '
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
grab && /^##[[:space:]]/ {grab=0}
grab {print}' | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
if [ "${unchecked:-0}" -gt 0 ]; then
blocked="${blocked} - issue #$n has $unchecked unticked ## Done-when box(es)\n"
fi
done
done <<EOF
$refs
EOF
if [ -n "$blocked" ]; then
printf 'husky - H6 merge-consent (ersatztv#303): push to main BLOCKED\n' >&2
printf '%b' "$blocked" >&2
printf 'Finish/tick every Done-when criterion (incl. adversarial review) first, or push a docs-only change.\n' >&2
exit 1
fi
exit 0