|
| 1 | +#!/usr/bin/env bash |
| 2 | +# hooks/pre-commit-ticket-gate.sh |
| 3 | +# git commit-msg hook: blocks commits lacking a valid v3 ticket ID in the message. |
| 4 | +# |
| 5 | +# DESIGN: |
| 6 | +# This hook runs at the commit-msg stage, receiving the commit message file |
| 7 | +# path as $1 (standard git commit-msg hook convention). It checks that the |
| 8 | +# commit message contains at least one valid v3 ticket ID (XXXX-XXXX hex |
| 9 | +# format) that exists in the event-sourced tracker. |
| 10 | +# |
| 11 | +# LOGIC (in order): |
| 12 | +# 1. Fail-open on timeout (SIGTERM/SIGURG). |
| 13 | +# 2. Read commit message from $1 (or COMMIT_MSG_FILE_OVERRIDE for tests). |
| 14 | +# 3. Merge commit exemption: if MERGE_HEAD exists → exit 0. |
| 15 | +# 4. Get staged files via git diff --cached --name-only. |
| 16 | +# 5. Load allowlist from review-gate-allowlist.conf (via deps.sh). |
| 17 | +# 6. If ALL staged files match allowlist → exit 0 (no ticket needed). |
| 18 | +# 7. Graceful degradation: if tracker not mounted → warn → exit 0. |
| 19 | +# 8. Extract ticket IDs matching [a-z0-9]{4}-[a-z0-9]{4} from message. |
| 20 | +# 9. For each ID: check dir exists + CREATE event file present in tracker. |
| 21 | +# 10. If any valid ID found → exit 0. |
| 22 | +# 11. Otherwise → exit 1 with format hint and ticket creation pointer. |
| 23 | +# |
| 24 | +# INSTALL: |
| 25 | +# Registered in .pre-commit-config.yaml as a commit-msg stage local hook. |
| 26 | +# |
| 27 | +# ENVIRONMENT: |
| 28 | +# COMMIT_MSG_FILE_OVERRIDE — path to commit message file (used in tests) |
| 29 | +# TICKET_TRACKER_OVERRIDE — path to tracker dir (used in tests) |
| 30 | +# CONF_OVERRIDE — path to allowlist conf (used in tests) |
| 31 | + |
| 32 | +set -uo pipefail |
| 33 | + |
| 34 | +# ── Fail-open on timeout ───────────────────────────────────────────────────── |
| 35 | +# pre-commit sends SIGTERM after timeout; Claude Code tool timeout sends SIGURG. |
| 36 | +# A gate timeout is infrastructure failure — fail open so commits aren't blocked. |
| 37 | +_fail_open_on_timeout() { |
| 38 | + echo "pre-commit-ticket-gate: WARNING: timed out — failing open (commit allowed)" >&2 |
| 39 | + exit 0 |
| 40 | +} |
| 41 | +trap _fail_open_on_timeout TERM URG |
| 42 | + |
| 43 | +# ── Locate hook and plugin directories ────────────────────────────────────── |
| 44 | +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 45 | + |
| 46 | +# Source shared dependency library (provides _load_allowlist_patterns, _allowlist_to_grep_regex, get_artifacts_dir) |
| 47 | +source "$HOOK_DIR/lib/deps.sh" |
| 48 | + |
| 49 | +# ── Read commit message ────────────────────────────────────────────────────── |
| 50 | +# Supports COMMIT_MSG_FILE_OVERRIDE for test injection; falls back to $1 (git standard). |
| 51 | +_COMMIT_MSG_FILE="${COMMIT_MSG_FILE_OVERRIDE:-${1:-}}" |
| 52 | +if [[ -z "$_COMMIT_MSG_FILE" || ! -f "$_COMMIT_MSG_FILE" ]]; then |
| 53 | + # No commit message file available — fail open (do not block) |
| 54 | + exit 0 |
| 55 | +fi |
| 56 | +COMMIT_MSG=$(cat "$_COMMIT_MSG_FILE" 2>/dev/null || echo "") |
| 57 | + |
| 58 | +# ── Merge commit exemption ──────────────────────────────────────────────────── |
| 59 | +# When MERGE_HEAD exists (in-progress merge), exit 0 unconditionally. |
| 60 | +if [[ -f "$(git rev-parse --git-dir 2>/dev/null)/MERGE_HEAD" ]]; then |
| 61 | + exit 0 |
| 62 | +fi |
| 63 | + |
| 64 | +# ── Get staged files ───────────────────────────────────────────────────────── |
| 65 | +STAGED_FILES=() |
| 66 | +_staged_output=$(git diff --cached --name-only 2>/dev/null || true) |
| 67 | +if [[ -n "$_staged_output" ]]; then |
| 68 | + while IFS= read -r f; do |
| 69 | + [[ -z "$f" ]] && continue |
| 70 | + STAGED_FILES+=("$f") |
| 71 | + done <<< "$_staged_output" |
| 72 | +fi |
| 73 | + |
| 74 | +# No staged files → nothing to check |
| 75 | +if [[ ${#STAGED_FILES[@]} -eq 0 ]]; then |
| 76 | + exit 0 |
| 77 | +fi |
| 78 | + |
| 79 | +# ── Load allowlist patterns ─────────────────────────────────────────────────── |
| 80 | +ALLOWLIST_PATH="${CONF_OVERRIDE:-$HOOK_DIR/lib/review-gate-allowlist.conf}" |
| 81 | +ALLOWLIST_PATTERNS="" |
| 82 | +if [[ -f "$ALLOWLIST_PATH" ]]; then |
| 83 | + ALLOWLIST_PATTERNS=$(_load_allowlist_patterns "$ALLOWLIST_PATH" 2>/dev/null || true) |
| 84 | +fi |
| 85 | + |
| 86 | +# Build a grep regex from the allowlist for fast file matching |
| 87 | +NON_REVIEWABLE_REGEX="" |
| 88 | +if [[ -n "$ALLOWLIST_PATTERNS" ]]; then |
| 89 | + while IFS= read -r _regex_line; do |
| 90 | + [[ -z "$_regex_line" ]] && continue |
| 91 | + if [[ -z "$NON_REVIEWABLE_REGEX" ]]; then |
| 92 | + NON_REVIEWABLE_REGEX="$_regex_line" |
| 93 | + else |
| 94 | + NON_REVIEWABLE_REGEX="${NON_REVIEWABLE_REGEX}|${_regex_line}" |
| 95 | + fi |
| 96 | + done <<< "$(_allowlist_to_grep_regex "$ALLOWLIST_PATTERNS")" |
| 97 | +fi |
| 98 | + |
| 99 | +# ── Check if all staged files are allowlisted ───────────────────────────────── |
| 100 | +NON_ALLOWLISTED_FILES=() |
| 101 | +if [[ -z "$NON_REVIEWABLE_REGEX" ]]; then |
| 102 | + # No allowlist loaded — everything requires a ticket (fail-safe) |
| 103 | + NON_ALLOWLISTED_FILES=("${STAGED_FILES[@]}") |
| 104 | +else |
| 105 | + _non_allowlisted=$(printf '%s\n' "${STAGED_FILES[@]}" | grep -vE "$NON_REVIEWABLE_REGEX" 2>/dev/null || true) |
| 106 | + if [[ -n "$_non_allowlisted" ]]; then |
| 107 | + while IFS= read -r _classified_file; do |
| 108 | + [[ -n "$_classified_file" ]] && NON_ALLOWLISTED_FILES+=("$_classified_file") |
| 109 | + done <<< "$_non_allowlisted" |
| 110 | + fi |
| 111 | +fi |
| 112 | + |
| 113 | +# All staged files are allowlisted → no ticket needed |
| 114 | +if [[ ${#NON_ALLOWLISTED_FILES[@]} -eq 0 ]]; then |
| 115 | + exit 0 |
| 116 | +fi |
| 117 | + |
| 118 | +# ── Resolve tracker directory ───────────────────────────────────────────────── |
| 119 | +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "") |
| 120 | +TRACKER_DIR="${TICKET_TRACKER_OVERRIDE:-${REPO_ROOT}/.tickets-tracker}" |
| 121 | + |
| 122 | +# Graceful degradation: if tracker is not mounted, warn and fail open |
| 123 | +if [[ ! -d "$TRACKER_DIR" ]]; then |
| 124 | + echo "pre-commit-ticket-gate: WARNING: ticket tracker not mounted at ${TRACKER_DIR} — skipping ticket check" >&2 |
| 125 | + exit 0 |
| 126 | +fi |
| 127 | + |
| 128 | +# ── Extract v3 ticket IDs from commit message ───────────────────────────────── |
| 129 | +# v3 format: four lowercase hex chars, dash, four lowercase hex chars: e.g. dso-78iq |
| 130 | +TICKET_IDS=() |
| 131 | +while IFS= read -r _matched_id; do |
| 132 | + [[ -n "$_matched_id" ]] && TICKET_IDS+=("$_matched_id") |
| 133 | +done < <(echo "$COMMIT_MSG" | grep -oE '[a-z0-9]{4}-[a-z0-9]{4}' 2>/dev/null || true) |
| 134 | + |
| 135 | +# ── Validate each extracted ticket ID ──────────────────────────────────────── |
| 136 | +for _id in "${TICKET_IDS[@]+"${TICKET_IDS[@]}"}"; do |
| 137 | + _ticket_dir="${TRACKER_DIR}/${_id}" |
| 138 | + if [[ -d "$_ticket_dir" ]]; then |
| 139 | + # Check for a CREATE event file in the ticket directory |
| 140 | + _create_file=$(ls "$_ticket_dir/"*-CREATE.json 2>/dev/null | head -1 || echo "") |
| 141 | + if [[ -n "$_create_file" ]]; then |
| 142 | + # Found a valid ticket — allow commit |
| 143 | + exit 0 |
| 144 | + fi |
| 145 | + fi |
| 146 | +done |
| 147 | + |
| 148 | +# ── No valid ticket ID found → block commit ─────────────────────────────────── |
| 149 | +echo "" >&2 |
| 150 | +echo "BLOCKED: commit-msg ticket gate" >&2 |
| 151 | +echo "" >&2 |
| 152 | +echo " Commit message must reference a valid v3 ticket ID." >&2 |
| 153 | +echo " Expected format: XXXX-XXXX (hex, e.g. dso-78iq)" >&2 |
| 154 | +echo "" >&2 |
| 155 | +echo " Your commit message:" >&2 |
| 156 | +echo " ${COMMIT_MSG}" >&2 |
| 157 | +echo "" >&2 |
| 158 | +echo " To create a ticket: ticket create task \"<description>\"" >&2 |
| 159 | +echo " Then add the ticket ID to your commit message." >&2 |
| 160 | +echo "" >&2 |
| 161 | +exit 1 |
0 commit comments