Repository navigation
chore: require a Linear ticket ID before opening PRs #3350
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| #!/usr/bin/env bash | ||
| # PreToolUse hook: block PR creation unless a Linear ticket ID is present in | ||
| # the PR title or PR body. The branch name does not count. | ||
| # | ||
| # Acts on: | ||
| # - Bash commands that run `gh pr create` (or its alias `gh pr new`) | ||
| # - MCP tools whose name looks like a PR-creation tool | ||
| # (e.g. mcp__github__create_pull_request) | ||
| # Every other tool call exits 0. | ||
| # | ||
| # Team keys default to REACT, RN, CHAT and VID; override with LINEAR_TEAM_KEYS="A B C". | ||
| # If LINEAR_API_KEY is set, the ticket is also verified against Linear's API. | ||
| # | ||
| # Requires: bash, jq, curl. | ||
|
|
||
| set -uo pipefail | ||
|
|
||
| TEAM_KEYS="${LINEAR_TEAM_KEYS:-REACT RN CHAT VID}" | ||
| KEYS_ALT="$(echo "$TEAM_KEYS" | tr -s ' ,' '|' | sed 's/^|//; s/|$//')" | ||
| MCP_PR_TOOL_RE='^mcp__.*(create_pull_request|create_pr|pull_request_create|open_pull_request)' | ||
| GH_PR_CREATE_RE='(^|[;&|(`]|\$\()[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*gh[[:space:]]+pr[[:space:]]+(create|new)([[:space:]]|$)' | ||
|
|
||
| input="$(cat)" | ||
|
|
||
| if ! command -v jq >/dev/null 2>&1; then | ||
| # Without jq we can't parse the payload; only block if this looks like PR creation. | ||
| if printf '%s' "$input" | grep -qE 'gh[[:space:]]+pr[[:space:]]+(create|new)|create_pull_request'; then | ||
| echo "require-linear-ticket hook: jq is not installed, so the Linear ticket check cannot run. Install jq (e.g. 'brew install jq') and retry." >&2 | ||
| exit 2 | ||
| fi | ||
| exit 0 | ||
| fi | ||
|
|
||
| tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" | ||
| cwd="$(printf '%s' "$input" | jq -r '.cwd // empty')" | ||
| [ -n "$cwd" ] && [ -d "$cwd" ] || cwd="${CLAUDE_PROJECT_DIR:-$PWD}" | ||
|
|
||
| haystack="" | ||
|
|
||
| if [ "$tool_name" = "Bash" ]; then | ||
| cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" | ||
| printf '%s\n' "$cmd" | grep -qE "$GH_PR_CREATE_RE" || exit 0 | ||
|
|
||
| # The inline title/body (including heredocs) are part of the command string. | ||
| # Scan from `gh pr create` onward, so earlier commands (e.g. `git switch -c | ||
| # react-123-...`) don't count. | ||
| pr_cmd="$(printf '%s\n' "$cmd" \ | ||
| | awk 'found { print; next } match($0, /gh[[:space:]]+pr[[:space:]]+(create|new)/) { print substr($0, RSTART); found = 1 }')" | ||
| # Drop the values of flags that aren't the title or body (branch, repo, | ||
| # labels, assignees, ...), so e.g. `--label REACT-123` doesn't count. | ||
| haystack="$(printf '%s\n' "$pr_cmd" \ | ||
| | sed -E 's/(^|[[:space:]])(--head|-H|--base|-B|--repo|-R|--label|-l|--assignee|-a|--reviewer|-r|--milestone|-m|--project|-p)([= ]+)("[^"]*"|'\''[^'\'']*'\''|[^[:space:]]+)/\1/g')" | ||
|
|
||
| # --body-file/-F <path> of this `gh pr create`, without a trailing `;`, `&&`, ... | ||
| body_file="$(printf '%s' "$pr_cmd" | grep -oE -- '(--body-file|-F)[= ]+("[^"]*"|'\''[^'\'']*'\''|[^[:space:];&|)]+)' | head -n1 | sed -E 's/^(--body-file|-F)[= ]+//; s/["'\'']//g')" | ||
| if [ -n "$body_file" ] && [ "$body_file" != "-" ]; then | ||
| case "$body_file" in /*) ;; *) body_file="$cwd/$body_file" ;; esac | ||
| [ -f "$body_file" ] && haystack="$haystack"$'\n'"$(cat "$body_file")" | ||
| fi | ||
| elif printf '%s' "$tool_name" | grep -qiE "$MCP_PR_TOOL_RE"; then | ||
| haystack="$(printf '%s' "$input" | jq -r '[.tool_input.title, .tool_input.body] | map(select(. != null) | tostring) | join("\n")')" | ||
| else | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Split into tokens of letters, digits and dashes, so an ID only counts when it | ||
| # isn't glued to other letters or digits (REACT-123abc, NOTREACT-1). | ||
| ids="$(printf '%s\n' "$haystack" \ | ||
| | tr -c '[:alnum:]-' '\n' \ | ||
| | grep -oiE "(^|-)($KEYS_ALT)-[0-9]+(-|$)" \ | ||
| | grep -oiE "($KEYS_ALT)-[0-9]+" \ | ||
| | tr '[:lower:]' '[:upper:]' | awk '!seen[$0]++')" | ||
|
|
||
| if [ -z "$ids" ]; then | ||
| cat >&2 <<EOF | ||
| Blocked: no Linear ticket ID found for this pull request. | ||
|
|
||
| Checked the PR title and PR body for an ID matching one of the team keys: | ||
| ${TEAM_KEYS} (e.g. REACT-123, RN-45, CHAT-678, VID-910). The branch name does not count. | ||
|
|
||
| Before opening the PR: | ||
| 1. Find the existing Linear ticket for this work, or create one in the REACT team. | ||
| 2. Put its ID in the PR body, not the title, to keep the commit history | ||
| clean. Pass it with --body or --body-file, since the check runs before | ||
| gh prompts for anything (e.g. 'Ticket: https://linear.app/stream/issue/REACT-123'). | ||
| 3. Retry creating the PR. | ||
| EOF | ||
| exit 2 | ||
| fi | ||
|
|
||
| # Optional verification against Linear. Skipped silently when no key is set. | ||
| if [ -n "${LINEAR_API_KEY:-}" ]; then | ||
| found="" | ||
| for id in $ids; do | ||
| payload="$(jq -nc --arg id "$id" '{query: "query($id: String!) { issue(id: $id) { id } }", variables: {id: $id}}')" | ||
| resp="$(curl -sS --max-time 10 -X POST https://api.linear.app/graphql \ | ||
| -H "Content-Type: application/json" \ | ||
| -H "Authorization: ${LINEAR_API_KEY}" \ | ||
| --data "$payload" 2>/dev/null)" || { | ||
| echo "require-linear-ticket hook: could not reach Linear to verify $id; allowing." >&2 | ||
| exit 0 | ||
| } | ||
| if printf '%s' "$resp" | jq -e '.data.issue.id // empty' >/dev/null 2>&1; then | ||
| found="$id" | ||
| break | ||
| fi | ||
| # Only a "not found" answer counts against the ticket; auth errors or | ||
| # unexpected responses mean we can't verify, so don't block on them. | ||
| if ! printf '%s' "$resp" | jq -e '[.errors[]?.message] | any(test("not found"; "i"))' >/dev/null 2>&1; then | ||
| msg="$(printf '%s' "$resp" | jq -r '.errors[0].message // "unexpected response"' 2>/dev/null)" | ||
| echo "require-linear-ticket hook: could not verify $id with Linear (${msg:-unexpected response}); allowing." >&2 | ||
| exit 0 | ||
| fi | ||
| done | ||
| if [ -z "$found" ]; then | ||
| cat >&2 <<EOF | ||
| Blocked: Linear ticket ID(s) $(echo $ids | tr ' ' ',') were found, but none exist in Linear. | ||
|
|
||
| Find or create the correct Linear ticket and put its real ID in the PR title or PR body, then retry. | ||
| EOF | ||
| exit 2 | ||
| fi | ||
| fi | ||
|
|
||
| exit 0 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exclude the body-file path from the ticket check.
If
gh pr create --body-file REACT-123.mdreads a file without a ticket ID,haystackstill containsREACT-123.md. The hook accepts the ID from the path, although GitHub CLI uses the file contents as the PR body. Remove--body-fileand-Fvalues fromhaystack, then append only the selected file contents. (cli.github.com)