From 8125d16c2fe9b7c97d045abd368b0db9f5ad49df Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 26 Oct 2026 11:56:57 +0100 Subject: [PATCH 1/3] chore: require a Linear ticket ID before opening PRs Add a Claude Code PreToolUse hook that blocks gh pr create and MCP PR-creation tools unless a Linear ticket ID appears in the PR title or PR body. The branch name does not count. When LINEAR_API_KEY is set, the hook also checks that the ticket exists. Ported from GetStream/stream-video-js#2464. --- .claude/hooks/require-linear-ticket.sh | 118 +++++++++++++++++++++++++ .claude/settings.json | 12 +++ AGENTS.md | 2 + 3 files changed, 132 insertions(+) create mode 100755 .claude/hooks/require-linear-ticket.sh diff --git a/.claude/hooks/require-linear-ticket.sh b/.claude/hooks/require-linear-ticket.sh new file mode 100755 index 0000000000..a5f2e4bd9e --- /dev/null +++ b/.claude/hooks/require-linear-ticket.sh @@ -0,0 +1,118 @@ +#!/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; 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}" +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, and drop the branch/repo flag values. + haystack="$(printf '%s\n' "$cmd" \ + | awk 'found { print; next } match($0, /gh[[:space:]]+pr[[:space:]]+(create|new)/) { print substr($0, RSTART); found = 1 }' \ + | sed -E 's/(^|[[:space:]])(--head|-H|--base|-B|--repo|-R)([= ]+)("[^"]*"|'\''[^'\'']*'\''|[^[:space:]]+)/\1/g')" + + # --body-file/-F + body_file="$(printf '%s' "$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 + +ids="$(printf '%s\n' "$haystack" \ + | grep -oiE "(^|[^[:alnum:]])($KEYS_ALT)-[0-9]+" \ + | grep -oiE "($KEYS_ALT)-[0-9]+" \ + | tr '[:lower:]' '[:upper:]' | awk '!seen[$0]++')" + +if [ -z "$ids" ]; then + cat >&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 < Date: Mon, 26 Oct 2026 15:06:53 +0100 Subject: [PATCH 2/3] chore: accept Linear tickets from REACT, RN, CHAT and VID The hook now accepts ticket IDs from any of these teams in every repo. AGENTS.md still says which team new tickets for this repo belong to. --- .claude/hooks/require-linear-ticket.sh | 6 +++--- AGENTS.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude/hooks/require-linear-ticket.sh b/.claude/hooks/require-linear-ticket.sh index a5f2e4bd9e..a9a16b66d9 100755 --- a/.claude/hooks/require-linear-ticket.sh +++ b/.claude/hooks/require-linear-ticket.sh @@ -8,14 +8,14 @@ # (e.g. mcp__github__create_pull_request) # Every other tool call exits 0. # -# Team keys default to REACT; override with LINEAR_TEAM_KEYS="A B C". +# 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}" +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:]]|$)' @@ -70,7 +70,7 @@ if [ -z "$ids" ]; then 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). The branch name does not count. +${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. diff --git a/AGENTS.md b/AGENTS.md index 63ce9f7a27..50e4a456cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -448,7 +448,7 @@ The **pre-commit hook** runs `lint-staged`: eslint (`--max-warnings 0`) on stage Follow `.github/pull_request_template.md` (Goal / Implementation details / UI Changes). Keep PRs small and focused; include tests. -**Linear ticket required.** Before opening a PR, find the Linear ticket for the work (or create one in the `REACT` team) and put its ID in the PR title or PR body, e.g. `Ticket: https://linear.app/stream/issue/REACT-123`. A Claude Code PreToolUse hook (`.claude/hooks/require-linear-ticket.sh`) blocks `gh pr create` and MCP PR-creation tools unless a `REACT-…` ID appears in the PR title or PR body. The branch name does not count. If `LINEAR_API_KEY` is set, it also checks that the ticket exists. +**Linear ticket required.** Before opening a PR, find the Linear ticket for the work (or create one in the `REACT` team) and put its ID in the PR title or PR body, e.g. `Ticket: https://linear.app/stream/issue/REACT-123`. Tickets from the `RN`, `CHAT` and `VID` teams are accepted too. A Claude Code PreToolUse hook (`.claude/hooks/require-linear-ticket.sh`) blocks `gh pr create` and MCP PR-creation tools unless a `REACT-…`, `RN-…`, `CHAT-…` or `VID-…` ID appears in the PR title or PR body. The branch name does not count. If `LINEAR_API_KEY` is set, it also checks that the ticket exists. - [ ] `yarn lint-fix` passed - [ ] `yarn test` passed From 3f800e4d1f19eab70cfee09f3ae564e51baebf56 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 26 Oct 2026 18:04:35 +0100 Subject: [PATCH 3/3] fix: tighten the Linear ticket check and ask for the ID in the PR body - Ignore the values of --label, --assignee, --reviewer, --milestone and --project, so they can no longer supply the ticket ID. - Read --body-file/-F from the gh pr create invocation only, and stop the path at ;, &, | or ), so the PR's own body file is the one checked. - Only count an ID that stands alone, so REACT-123abc or NOTCHAT-1 no longer pass. - Tell agents to put the ticket in the PR body (via --body or --body-file) rather than the title, to keep the commit history clean. --- .claude/hooks/require-linear-ticket.sh | 27 ++++++++++++++++---------- AGENTS.md | 2 +- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.claude/hooks/require-linear-ticket.sh b/.claude/hooks/require-linear-ticket.sh index a9a16b66d9..27083c609f 100755 --- a/.claude/hooks/require-linear-ticket.sh +++ b/.claude/hooks/require-linear-ticket.sh @@ -43,13 +43,16 @@ if [ "$tool_name" = "Bash" ]; then # 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, and drop the branch/repo flag values. - haystack="$(printf '%s\n' "$cmd" \ - | awk 'found { print; next } match($0, /gh[[:space:]]+pr[[:space:]]+(create|new)/) { print substr($0, RSTART); found = 1 }' \ - | sed -E 's/(^|[[:space:]])(--head|-H|--base|-B|--repo|-R)([= ]+)("[^"]*"|'\''[^'\'']*'\''|[^[:space:]]+)/\1/g')" - - # --body-file/-F - body_file="$(printf '%s' "$cmd" | grep -oE -- '(--body-file|-F)[= ]+[^[:space:]]+' | head -n1 | sed -E 's/^(--body-file|-F)[= ]+//; s/["'\'']//g')" + # 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 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")" @@ -60,8 +63,11 @@ 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" \ - | grep -oiE "(^|[^[:alnum:]])($KEYS_ALT)-[0-9]+" \ + | tr -c '[:alnum:]-' '\n' \ + | grep -oiE "(^|-)($KEYS_ALT)-[0-9]+(-|$)" \ | grep -oiE "($KEYS_ALT)-[0-9]+" \ | tr '[:lower:]' '[:upper:]' | awk '!seen[$0]++')" @@ -74,8 +80,9 @@ ${TEAM_KEYS} (e.g. REACT-123, RN-45, CHAT-678, VID-910). The branch name does no 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 title or PR body - (e.g. 'Ticket: https://linear.app/stream/issue/REACT-123'). + 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 diff --git a/AGENTS.md b/AGENTS.md index 50e4a456cb..9581d78c55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -448,7 +448,7 @@ The **pre-commit hook** runs `lint-staged`: eslint (`--max-warnings 0`) on stage Follow `.github/pull_request_template.md` (Goal / Implementation details / UI Changes). Keep PRs small and focused; include tests. -**Linear ticket required.** Before opening a PR, find the Linear ticket for the work (or create one in the `REACT` team) and put its ID in the PR title or PR body, e.g. `Ticket: https://linear.app/stream/issue/REACT-123`. Tickets from the `RN`, `CHAT` and `VID` teams are accepted too. A Claude Code PreToolUse hook (`.claude/hooks/require-linear-ticket.sh`) blocks `gh pr create` and MCP PR-creation tools unless a `REACT-…`, `RN-…`, `CHAT-…` or `VID-…` ID appears in the PR title or PR body. The branch name does not count. If `LINEAR_API_KEY` is set, it also checks that the ticket exists. +**Linear ticket required.** Before opening a PR, find the Linear ticket for the work (or create one in the `REACT` team) and put its ID in the PR body, not the title, to keep the commit history clean, e.g. `gh pr create --title "…" --body "Ticket: https://linear.app/stream/issue/REACT-123"` (or `--body-file`). Pass the body as a flag: the check runs before `gh` prompts for anything, so an interactive `gh pr create` is blocked. Tickets from the `RN`, `CHAT` and `VID` teams are accepted too. A Claude Code PreToolUse hook (`.claude/hooks/require-linear-ticket.sh`) blocks `gh pr create` and MCP PR-creation tools unless a `REACT-…`, `RN-…`, `CHAT-…` or `VID-…` ID appears in the PR title or PR body. The branch name does not count. If `LINEAR_API_KEY` is set, it also checks that the ticket exists. - [ ] `yarn lint-fix` passed - [ ] `yarn test` passed