aboutsummaryrefslogtreecommitdiff
path: root/hooks/gh-pr-create-confirm.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-19 17:06:10 -0500
committerCraig Jennings <c@cjennings.net>2026-04-19 17:06:10 -0500
commit4957c60c9ee985628ad59344e593d20a18ca8fdb (patch)
treee8d6659dd2d7dd24126782fa83ccccffc6c6f836 /hooks/gh-pr-create-confirm.py
parentab4a07b3c081609a81ee049ec9bbe6ccded09b54 (diff)
downloadrulesets-4957c60c9ee985628ad59344e593d20a18ca8fdb.tar.gz
rulesets-4957c60c9ee985628ad59344e593d20a18ca8fdb.zip
feat(hooks): add global hooks — PreCompact priorities + git/gh confirm modals
Three new machine-wide hooks installed via `make install-hooks`: - `precompact-priorities.sh` (PreCompact) — injects a priority block into the compaction prompt so the generated summary retains information most expensive to reconstruct: unanswered questions, root causes with file:line, subagent findings as primary evidence, exact numbers/IDs, A-vs-B decisions, open TODOs, classified-data handling. - `git-commit-confirm.py` (PreToolUse/Bash) — gates `git commit` behind a confirmation modal showing parsed message, staged files, diff stats, author. Parses both HEREDOC and `-m`/`--message` forms. - `gh-pr-create-confirm.py` (PreToolUse/Bash) — gates `gh pr create` behind a modal showing title, base ← head, reviewers, labels, assignees, milestone, draft flag, body (HEREDOC or quoted). Makefile: adds `install-hooks` / `uninstall-hooks` targets and extends `list` with a Hooks section. Install prints the settings.json snippet (in `hooks/settings-snippet.json`) to merge into `~/.claude/settings.json`. Also: `languages/elisp/claude/hooks/validate-el.sh` now emits JSON with `hookSpecificOutput.additionalContext` on failure (via new `fail_json()` helper) so Claude sees a structured error in context, in addition to the existing stderr output and exit 2. Patterns synthesized clean-room from fcakyon/claude-codex-settings (Apache-2.0). Each hook is original content.
Diffstat (limited to 'hooks/gh-pr-create-confirm.py')
-rwxr-xr-xhooks/gh-pr-create-confirm.py173
1 files changed, 173 insertions, 0 deletions
diff --git a/hooks/gh-pr-create-confirm.py b/hooks/gh-pr-create-confirm.py
new file mode 100755
index 0000000..b983352
--- /dev/null
+++ b/hooks/gh-pr-create-confirm.py
@@ -0,0 +1,173 @@
+#!/usr/bin/env python3
+"""PreToolUse hook for Bash: gate `gh pr create` behind a confirmation modal.
+
+Parses title, body, base, head, reviewers, labels, draft flag from the
+`gh pr create` command and renders a modal so the user sees exactly what
+will be opened.
+
+Wire in ~/.claude/settings.json alongside git-commit-confirm.py:
+
+ {
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "~/.claude/hooks/gh-pr-create-confirm.py"
+ }
+ ]
+ }
+ ]
+ }
+ }
+"""
+
+import json
+import re
+import sys
+
+
+MAX_BODY_LINES = 20
+
+
+def main() -> int:
+ try:
+ payload = json.loads(sys.stdin.read())
+ except (json.JSONDecodeError, ValueError):
+ return 0
+
+ if payload.get("tool_name") != "Bash":
+ return 0
+
+ cmd = payload.get("tool_input", {}).get("command", "")
+ if not re.search(r"(?:^|[\s;&|()])gh\s+pr\s+create\b", cmd):
+ return 0
+
+ fields = parse_pr_create(cmd)
+ reason = format_pr_confirmation(fields)
+
+ output = {
+ "hookSpecificOutput": {
+ "hookEventName": "PreToolUse",
+ "permissionDecision": "ask",
+ "permissionDecisionReason": reason,
+ }
+ }
+ print(json.dumps(output))
+ return 0
+
+
+def parse_pr_create(cmd: str) -> dict:
+ fields: dict = {
+ "title": None,
+ "body": None,
+ "base": None,
+ "head": None,
+ "reviewers": [],
+ "labels": [],
+ "assignees": [],
+ "milestone": None,
+ "draft": False,
+ }
+
+ # Title — quoted string after --title / -t
+ t = re.search(r"--title\s+([\"'])(.*?)\1", cmd, re.DOTALL)
+ if not t:
+ t = re.search(r"\s-t\s+([\"'])(.*?)\1", cmd, re.DOTALL)
+ if t:
+ fields["title"] = t.group(2)
+
+ # Body — HEREDOC inside $() first, then plain quoted string, then --body-file
+ body_heredoc = re.search(
+ r"--body\s+\"\$\(cat\s*<<-?\s*['\"]?(\w+)['\"]?\s*\n(.*?)\n\s*\1\s*\)\"",
+ cmd,
+ re.DOTALL,
+ )
+ if body_heredoc:
+ fields["body"] = body_heredoc.group(2).strip()
+ else:
+ b = re.search(r"--body\s+([\"'])(.*?)\1", cmd, re.DOTALL)
+ if b:
+ fields["body"] = b.group(2).strip()
+ else:
+ bf = re.search(r"--body-file\s+(\S+)", cmd)
+ if bf:
+ fields["body"] = f"(body read from file: {bf.group(1)})"
+
+ # Base / head
+ base = re.search(r"--base\s+(\S+)", cmd)
+ if not base:
+ base = re.search(r"\s-B\s+(\S+)", cmd)
+ if base:
+ fields["base"] = base.group(1)
+
+ head = re.search(r"--head\s+(\S+)", cmd)
+ if not head:
+ head = re.search(r"\s-H\s+(\S+)", cmd)
+ if head:
+ fields["head"] = head.group(1)
+
+ # Multi-valued flags (comma-separated or repeated)
+ for name, key in (
+ ("reviewer", "reviewers"),
+ ("label", "labels"),
+ ("assignee", "assignees"),
+ ):
+ pattern = rf"--{name}[=\s]([\"']?)([^\s\"']+)\1"
+ for match in re.finditer(pattern, cmd):
+ fields[key].extend(match.group(2).split(","))
+
+ # Milestone
+ m = re.search(r"--milestone[=\s]([\"'])?([^\s\"']+)\1?", cmd)
+ if m:
+ fields["milestone"] = m.group(2)
+
+ # Draft flag
+ if re.search(r"--draft\b", cmd):
+ fields["draft"] = True
+
+ return fields
+
+
+def format_pr_confirmation(fields: dict) -> str:
+ lines = ["Create pull request?", ""]
+
+ if fields["draft"]:
+ lines.append("[DRAFT]")
+ lines.append("")
+
+ lines.append(f"Title: {fields['title'] or '(not parsed)'}")
+
+ base = fields["base"] or "(default — usually main)"
+ head = fields["head"] or "(current branch)"
+ lines.append(f"Base ← Head: {base} ← {head}")
+
+ if fields["reviewers"]:
+ lines.append(f"Reviewers: {', '.join(fields['reviewers'])}")
+ if fields["assignees"]:
+ lines.append(f"Assignees: {', '.join(fields['assignees'])}")
+ if fields["labels"]:
+ lines.append(f"Labels: {', '.join(fields['labels'])}")
+ if fields["milestone"]:
+ lines.append(f"Milestone: {fields['milestone']}")
+
+ lines.append("")
+ if fields["body"]:
+ lines.append("Body:")
+ body_lines = fields["body"].splitlines()
+ for line in body_lines[:MAX_BODY_LINES]:
+ lines.append(f" {line}")
+ if len(body_lines) > MAX_BODY_LINES:
+ lines.append(f" ... ({len(body_lines) - MAX_BODY_LINES} more lines)")
+ else:
+ lines.append("Body: (not parsed)")
+
+ lines.append("")
+ lines.append("Confirm target branch, title, body, and reviewers before proceeding.")
+ return "\n".join(lines)
+
+
+if __name__ == "__main__":
+ sys.exit(main())