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
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>
70 lines
3.2 KiB
Bash
Executable File
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
|