Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions .claude/hooks/require-linear-ticket.sh
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 RN 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/RN-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
16 changes: 16 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|mcp__.*(create_pull_request|create_pr|pull_request_create|open_pull_request).*",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/require-linear-ticket.sh",
"timeout": 30
}
]
}
]
}
}
30 changes: 30 additions & 0 deletions .github/workflows/cancel-pr-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Cancel PR Checks

on:
pull_request_target:
types: [closed]

permissions:
actions: write

jobs:
cancel:
name: Cancel
runs-on: ubuntu-latest
steps:
# Match on the head repository too: a fork's branch can share its name with another PR's branch.
# Not on the PR number: GitHub leaves `pull_requests` empty on runs from forks and once the PR is closed.
# A failed lookup fails the job. A failed cancel only warns: the run may have finished in the meantime.
- run: |
for status in queued in_progress waiting pending requested; do
ids=$(gh api --paginate "repos/$REPO/actions/runs?event=pull_request&status=$status&per_page=100" -f branch="$BRANCH" -X GET \
--jq '.workflow_runs[] | select(.head_repository.full_name == env.HEAD_REPO) | .id')
for id in $ids; do
gh run cancel "$id" --repo "$REPO" || echo "::warning::Could not cancel run $id"
done
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.event.pull_request.head.ref }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches:
- main
- develop
# Manual runs cut pre-release (RC) builds from the branch that defines them. GitHub reads this
# trigger from the default branch, so it has to live here even though develop never uses it.
workflow_dispatch:

permissions:
id-token: write # for OIDC / npm provenance if you use it
Expand All @@ -13,6 +16,8 @@ permissions:

jobs:
publish:
# main and develop release on push only; a manual run there would cut an out-of-band release.
if: github.event_name == 'push' || github.ref == 'refs/heads/V10'
runs-on: ubuntu-latest
strategy:
matrix:
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ Prettier: single quotes, trailing commas, 100-char width (120 for Markdown) —
### Git hooks

- `.husky/commit-msg` → `commitlint --edit` (Conventional Commits enforced)
- `.husky/pre-commit` → `dotgit/hooks/pre-commit-format.sh && dotgit/hooks/pre-commit-reject-binaries.py`
- `.husky/pre-commit` → `dotgit/hooks/pre-commit-format.sh && dotgit/hooks/pre-commit-reject-binaries.py` (the format hook checks only the staged files with Prettier and ESLint, plus the SDK validators when `package/` is staged; CI runs the full `yarn lint`)
- Root `postinstall` runs `husky`

### Commits
Expand All @@ -357,6 +357,8 @@ Closes #123

Follow `PULL_REQUEST_TEMPLATE.md`. Keep PRs small and focused.

**Linear ticket required.** Before opening a PR, find the Linear ticket for the work (or create one in the `RN` 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/RN-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 `REACT`, `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:unit` passed
- [ ] `cd package && yarn test:typecheck` passed
Expand Down
63 changes: 57 additions & 6 deletions dotgit/hooks/pre-commit-format.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,61 @@
#!/usr/bin/env bash

set -e
# Lints only the staged files, so a commit takes seconds instead of a full-repo `yarn lint`.
# CI (check-pr.yml) still runs the full `yarn lint`.

if ! yarn lint; then
yarn lint-fix
echo "some files were not formatted correctly (prettier/eslint) commit aborted!"
echo "your changes are still staged, you can accept formatting changes with git add or ignore them by adding --no-verify to git commit"
exit 1
set -euo pipefail

ROOT_DIR="$(git rev-parse --show-toplevel)"
cd "$ROOT_DIR"

PRETTIER="node_modules/.bin/prettier"
ESLINT="node_modules/.bin/eslint"

# Staged files that still exist (added, copied, modified, renamed). Bash 3.2 has no mapfile.
staged=()
while IFS= read -r -d '' file; do
staged+=("$file")
done < <(git diff --cached --name-only --diff-filter=ACMR -z)

if [ "${#staged[@]}" -eq 0 ]; then
exit 0
fi

lintable=()
touches_package=false
for file in "${staged[@]}"; do
case "$file" in
*.js | *.jsx | *.ts | *.tsx | *.mjs | *.cjs) lintable+=("$file") ;;
esac
case "$file" in
package/*) touches_package=true ;;
esac
done

run_checks() {
# --ignore-unknown: skip files Prettier has no parser for. .prettierignore still applies.
"$PRETTIER" --list-different --ignore-unknown "${staged[@]}" || return 1

if [ "${#lintable[@]}" -gt 0 ]; then
# --no-warn-ignored: files excluded by the ESLint config would otherwise count as warnings.
"$ESLINT" --max-warnings 0 --no-warn-ignored --cache --cache-location node_modules/.cache/eslint/ \
"${lintable[@]}" || return 1
fi

# Both validators only read the SDK package, and take about a second each.
if [ "$touches_package" = true ]; then
yarn workspace stream-chat-react-native-core validate-translations || return 1
yarn workspace stream-chat-react-native-core validate-entry-isolation || return 1
fi
}

if ! run_checks; then
"$PRETTIER" --write --ignore-unknown "${staged[@]}" >/dev/null || true
if [ "${#lintable[@]}" -gt 0 ]; then
"$ESLINT" --fix --no-warn-ignored --cache --cache-location node_modules/.cache/eslint/ \
"${lintable[@]}" >/dev/null || true
fi
echo "some files were not formatted correctly (prettier/eslint) commit aborted!"
echo "your changes are still staged, you can accept formatting changes with git add or ignore them by adding --no-verify to git commit"
exit 1
fi
2 changes: 1 addition & 1 deletion dotgit/hooks/pre-commit-reject-binaries.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
import os
import subprocess
import sys
Expand Down
4 changes: 2 additions & 2 deletions examples/ExpoChatGPTSample/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@
"expo-router": "~58.0.18",
"react": "19.3.0",
"react-native": "0.88.0",
"react-native-gesture-handler": "~3.3.0",
"react-native-gesture-handler": "~3.4.0",
"react-native-reanimated": "4.7.1",
"react-native-safe-area-context": "~5.10.1",
"react-native-screens": "~4.29.0",
"react-native-svg": "15.15.5",
"react-native-teleport": "^1.2.2",
"react-native-worklets": "0.13.0",
"stream-chat": "^9.54.0",
"stream-chat": "^9.54.1",
"stream-chat-expo": "workspace:^",
"stream-chat-react-native-core": "workspace:^",
"victory-native": "^42.0.1"
Expand Down
4 changes: 2 additions & 2 deletions examples/ExpoMessaging/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"react": "19.3.0",
"react-dom": "19.3.0",
"react-native": "0.88.0",
"react-native-gesture-handler": "~3.3.0",
"react-native-gesture-handler": "~3.4.0",
"react-native-keyboard-controller": "1.22.7",
"react-native-maps": "1.29.11",
"react-native-reanimated": "4.7.1",
Expand All @@ -51,7 +51,7 @@
"react-native-teleport": "^1.2.2",
"react-native-web": "~0.21.4",
"react-native-worklets": "0.13.0",
"stream-chat": "^9.54.0",
"stream-chat": "^9.54.1",
"stream-chat-expo": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
4 changes: 2 additions & 2 deletions examples/SampleApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"react": "19.3.0",
"react-native": "0.88.0",
"react-native-blob-util": "^0.24.12",
"react-native-gesture-handler": "^3.3.0",
"react-native-gesture-handler": "^3.4.0",
"react-native-haptic-feedback": "^3.0.0",
"react-native-image-picker": "^8.2.1",
"react-native-maps": "^1.29.11",
Expand All @@ -64,7 +64,7 @@
"react-native-teleport": "^1.2.2",
"react-native-video": "^6.19.3",
"react-native-worklets": "^0.13.0",
"stream-chat": "^9.54.0",
"stream-chat": "^9.54.1",
"stream-chat-react-native": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
4 changes: 2 additions & 2 deletions package/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
"react-native-markdown-package": "1.8.2",
"react-native-url-polyfill": "^2.0.0",
"react-syntax-highlighter": "^16.1.0",
"stream-chat": "^9.54.0",
"stream-chat": "^9.54.1",
"use-sync-external-store": "^1.5.0",
"zod": "^4.1.12"
},
Expand Down Expand Up @@ -158,7 +158,7 @@
"react": "19.3.0",
"react-native": "0.88.0",
"react-native-builder-bob": "0.40.11",
"react-native-gesture-handler": "^3.3.0",
"react-native-gesture-handler": "^3.4.0",
"react-native-reanimated": "^4.7.1",
"react-native-safe-area-context": "^5.10.1",
"react-native-svg": "15.15.5",
Expand Down
Loading
Loading