Skip to content

Commit 9b7a7f3

Browse files
feat(w21-24kl): batch 21 — ticket gate hook + RED finalize tests
Implement pre-commit-ticket-gate.sh (dso-vl19): commit-msg hook that blocks commits without valid v3 ticket IDs (XXXX-XXXX hex), with allowlist skip, merge exemption, and graceful degradation. Add RED tests for _phase_finalize in cutover script (dso-rmn7): 11 tests covering git tag creation, file removal, compaction re-enable, and idempotent behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7e98b2e commit 9b7a7f3

File tree

7 files changed

+697
-5
lines changed

7 files changed

+697
-5
lines changed

.test-index

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,5 @@ plugins/dso/skills/validate-work/SKILL.md:plugins/dso/tests/test-sprint-skill-st
102102
plugins/dso/skills/validate-work/prompts/ci-status.md:tests/hooks/test-ci-status-no-jq.sh,tests/scripts/test-ci-status-auth-ratelimit.sh,tests/scripts/test-ci-status-timeout.sh,tests/scripts/test-ci-status.sh
103103
plugins/dso/skills/verification-before-completion/SKILL.md:plugins/dso/tests/test-sprint-skill-step10-no-merge-to-main.sh,tests/plugin/test-audit-skill-resolution.sh,tests/hooks/test-fix-bug-skill.sh,tests/hooks/test-generate-claude-md-skill.sh,tests/hooks/test-init-skill.sh,tests/scripts/test-qualify-skill-refs.sh,tests/scripts/test-skill-path-refs.sh,tests/scripts/test-check-skill-refs.sh,tests/skills/test_end_skill_final_verification_step.py,tests/skills/test_implementation_plan_skill_tdd_enforcement.py,tests/skills/test-quick-ref-skill.sh,tests/skills/test_project_setup_skill_conditional_prompts.py,tests/skills/test_fix_bug_skill.py,tests/skills/test_end_skill_summary_displays_stored_learnings.py,tests/skills/test_end_skill_learnings_step_before_commit.py,tests/skills/test-design-skills-cross-stack.sh,tests/skills/test_end_skill_dirty_worktree_resolution.py,tests/skills/test_fix_bug_skill_escalated_section.py,tests/skills/test_end_skill_bug_tickets_before_commit.py
104104
plugins/dso/scripts/cutover-tickets-migration.sh: tests/scripts/test-cutover-tickets-migration.sh [test_cutover_rollback_committed_uses_revert]
105+
plugins/dso/scripts/cutover-tickets-migration.sh: tests/scripts/test-cutover-tickets-migration-finalize.sh [test_finalize_creates_git_tag]
105106
plugins/dso/hooks/pre-commit-ticket-gate.sh: tests/hooks/test-pre-commit-ticket-gate.sh [test_blocks_missing_ticket_id]

.tickets/.sync-state.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,8 +449,8 @@
449449
"last_synced": "2026-03-19T18:38:35Z",
450450
"local_hash": "14c516947a151a3db8bdec4010e2fd6e"
451451
},
452-
"last_pull_timestamp": "2026-03-23T20:55:35Z",
453-
"last_sync_commit": "0ae161d9e098387e6596147a999ae6bcf2d32f9d",
452+
"last_pull_timestamp": "2026-03-23T21:20:47Z",
453+
"last_sync_commit": "7e98b2ec5ac33564d5215c742707fffffff5d706",
454454
"w21-5cqr": {
455455
"jira_hash": "bce29d76f01c58613ee99cb1dd03920d",
456456
"jira_key": "DIG-61",

.tickets/dso-qki2.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
id: dso-qki2
3-
status: in_progress
3+
status: closed
44
deps: []
55
links: []
66
created: 2026-03-23T20:26:38Z

.tickets/dso-rmn7.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
id: dso-rmn7
3-
status: open
3+
status: in_progress
44
deps: []
55
links: []
66
created: 2026-03-23T20:26:09Z
@@ -53,3 +53,29 @@ tests/scripts/test-cutover-tickets-migration-finalize.sh (new file)
5353
- [ ] .test-index entry maps cutover-tickets-migration.sh to test-cutover-tickets-migration-finalize.sh
5454
Verify: grep -q 'cutover-tickets-migration.sh.*test-cutover-tickets-migration-finalize.sh' $(git rev-parse --show-toplevel)/.test-index
5555

56+
57+
## Notes
58+
59+
**2026-03-23T21:13:06Z**
60+
61+
CHECKPOINT 1/6: Task context loaded ✓
62+
63+
**2026-03-23T21:13:45Z**
64+
65+
CHECKPOINT 2/6: Code patterns understood ✓
66+
67+
**2026-03-23T21:15:11Z**
68+
69+
CHECKPOINT 3/6: Tests written ✓
70+
71+
**2026-03-23T21:15:34Z**
72+
73+
CHECKPOINT 4/6: Implementation complete ✓
74+
75+
**2026-03-23T21:16:05Z**
76+
77+
CHECKPOINT 5/6: Validation passed ✓
78+
79+
**2026-03-23T21:16:20Z**
80+
81+
CHECKPOINT 6/6: Done ✓

.tickets/dso-vl19.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
id: dso-vl19
3-
status: open
3+
status: in_progress
44
deps: [dso-qki2]
55
links: []
66
created: 2026-03-23T20:27:05Z
@@ -66,3 +66,37 @@ DO NOT modify review-gate-allowlist.conf, pre-commit-review-gate.sh, review-gate
6666
- [ ] ruff format --check passes (exit 0)
6767
Verify: ruff format --check $(git rev-parse --show-toplevel)/plugins/dso/scripts/*.py $(git rev-parse --show-toplevel)/tests/**/*.py
6868

69+
## ACCEPTANCE CRITERIA
70+
71+
- [ ] plugins/dso/hooks/pre-commit-ticket-gate.sh exists and is executable
72+
Verify: test -x plugins/dso/hooks/pre-commit-ticket-gate.sh
73+
- [ ] Bash syntax validation passes
74+
Verify: bash -n plugins/dso/hooks/pre-commit-ticket-gate.sh
75+
- [ ] RED tests from dso-qki2 now pass (GREEN)
76+
Verify: bash tests/hooks/test-pre-commit-ticket-gate.sh 2>&1 | grep -qi passed
77+
78+
## Notes
79+
80+
**2026-03-23T21:12:52Z**
81+
82+
CHECKPOINT 1/6: Task context loaded ✓
83+
84+
**2026-03-23T21:13:08Z**
85+
86+
CHECKPOINT 2/6: Code patterns understood ✓
87+
88+
**2026-03-23T21:13:08Z**
89+
90+
CHECKPOINT 3/6: Tests written (none required) ✓
91+
92+
**2026-03-23T21:13:56Z**
93+
94+
CHECKPOINT 4/6: Implementation complete ✓
95+
96+
**2026-03-23T21:14:10Z**
97+
98+
CHECKPOINT 5/6: Validation passed ✓
99+
100+
**2026-03-23T21:15:07Z**
101+
102+
CHECKPOINT 6/6: Done ✓
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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

Comments
 (0)