Skip to content

require-qa-ui.sh

.claude/hooks/require-qa-ui.sh

PreToolUse

Gate PR creation on a fresh qa-ui pass for UI diffs (#964). A PR whose diff touches resources/js or resources/css is required to have run qa-ui (or qa-everything, which includes it) against the CURRENT HEAD first. qa-ui/qa-everything write a marker file on completion (see the tail of both SKILL.md files); this hook checks that marker matches the branch + HEAD sha about to be opened as a PR. A docs/backend-only diff is never blocked — it just gets a reminder in the hook's context output. Registered in settings.json under PreToolUse for mcp__github__create_pull_request. jq parses the hook's stdin JSON.

Source

bash
#!/usr/bin/env bash
#
# PreToolUse hook — gate PR creation on a fresh qa-ui pass for UI diffs (#964).
#
# A PR whose diff touches resources/js or resources/css is required to have run
# qa-ui (or qa-everything, which includes it) against the CURRENT HEAD first.
# qa-ui/qa-everything write a marker file on completion (see the tail of both
# SKILL.md files); this hook checks that marker matches the branch + HEAD sha
# about to be opened as a PR. A docs/backend-only diff is never blocked — it just
# gets a reminder in the hook's context output.
#
# Registered in settings.json under PreToolUse for
# mcp__github__create_pull_request. jq parses the hook's stdin JSON.

set -euo pipefail

cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0

input="$(cat)"
tool="$(jq -r '.tool_name // empty' <<<"$input")"

[ "$tool" = "mcp__github__create_pull_request" ] || exit 0

marker=".claude/.qa-ui-pass"
skip_once=".claude/.qa-ui-skip-once"

diff_files="$(git diff origin/main...HEAD --name-only 2>/dev/null || true)"
touches_ui=0
grep -qE '^(resources/js|resources/css)/' <<<"$diff_files" && touches_ui=1

if [ "$touches_ui" = 0 ]; then
  jq -n --arg ctx "Reminder: this PR's diff is docs/backend-only, so the qa-ui gate does not apply. If it does touch resources/js or resources/css indirectly, run /qa-ui or /qa-everything first." \
    '{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $ctx}}'
  exit 0
fi

# Escape hatch — consume the one-shot skip.
if [ -f "$skip_once" ]; then
  rm -f "$skip_once"
  jq -n --arg ctx "qa-ui gate skipped via .claude/.qa-ui-skip-once (consumed)." \
    '{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $ctx}}'
  exit 0
fi

branch="$(git branch --show-current)"
head_sha="$(git rev-parse HEAD)"

deny() {
  jq -n --arg r "$1" \
    '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
  exit 0
}

if [ ! -f "$marker" ]; then
  deny "UI diff without a qa-ui pass — run /qa-ui (or /qa-everything) first. This PR touches resources/js or resources/css, and no ${marker} marker was found."
fi

read -r marker_branch marker_sha < "$marker" || true

if [ "$marker_branch" != "$branch" ] || [ "$marker_sha" != "$head_sha" ]; then
  deny "Stale qa-ui pass — ${marker} was recorded for '${marker_branch:-?}' @ ${marker_sha:-?}, but this PR is '${branch}' @ ${head_sha}. Re-run /qa-ui (or /qa-everything) against the current HEAD first."
fi

exit 0