aboutsummaryrefslogtreecommitdiff
path: root/hooks/gh-pr-create-confirm.py
diff options
context:
space:
mode:
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())