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
62 changes: 62 additions & 0 deletions .github/actions/claude-review-toolkit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Claude Review Toolkit

Composite action that ships the shared scaffolding for the Claude PR review pipeline used by `Expensify/App`, `Expensify/Auth`, and `Expensify/Web-Expensify`.

It places a set of helper scripts on `GITHUB_PATH`, exposes the canonical violations-only JSON schema as both a file and a compacted string, and enforces a per-comment rule-ID security gate on inline comments.

## Usage

```yaml
- name: Setup Claude review toolkit
id: toolkit
uses: Expensify/GitHub-Actions/.github/actions/claude-review-toolkit@<sha>

- name: Run Claude Code
uses: anthropics/claude-code-action@<sha>
with:
claude_args: |
--json-schema '${{ steps.toolkit.outputs.schema_json }}'

- name: Post inline comment
env:
GH_TOKEN: ${{ github.token }}
run: createInlineComment.sh "${{ github.event.pull_request.number }}" "src/foo.ts" "PERF-1: ..." 42
```

Caller repos must ship a `.claude/skills/coding-standards/rules/` directory with at least one `.md` rule file whose YAML frontmatter declares a `ruleId:` tag matching `[A-Z]+(-[A-Z]+)*-[0-9]+` (e.g. `PERF-1`, `GEN-01`, `CLEAN-REACT-PATTERNS-0`). The action's extract step builds an allowlist from those tags and fails the workflow if the directory is missing or yields no tags.

## Outputs

| Name | Description |
| --- | --- |
| `schema_path` | Absolute filesystem path to `schemas/code-review-output.json` (the canonical violations-only schema). |
| `schema_json` | Same schema compacted as a single-line JSON string, ready to pass to `claude-code-action --json-schema`. Callers that need a repo-specific extension (e.g. Auth's `missingQueryTimings` flag) can `jq`-merge on top of this value in a subsequent step rather than forking the schema. |

## Side effects

- Prepends `<action-path>/scripts` to `GITHUB_PATH`, so the helper scripts below are callable by bare name in later steps.
- Exports `ALLOWED_RULES_FILE` to `$GITHUB_ENV`, pointing at the deduplicated allowlist extracted from the caller's rules directory.

## Scripts on `PATH`

| Script | Signature | Notes |
| --- | --- | --- |
| `addPrReaction.sh` | `<PR_NUMBER> <REACTION>` | Adds a reaction (`+1`, `-1`, `laugh`, `confused`, `heart`, `hooray`, `rocket`, `eyes`) to the PR. |
| `removePrReaction.sh` | `<PR_NUMBER> <REACTION> <USER>` | Removes the matching reaction authored by `<USER>` (typically `github-actions[bot]`). Idempotent. |
| `createInlineComment.sh` | `<PR_NUMBER> <path> <body> <line>` | Posts an inline review comment. Requires `GITHUB_REPOSITORY`, `GH_TOKEN`, and `ALLOWED_RULES_FILE` in env. The body must reference a rule tag matching `[A-Z]+(-[A-Z]+)*-[0-9]+` (e.g. `PERF-1`) that is present in the allowlist; otherwise the comment is rejected. |
| `extractAllowedRules.sh` | `<rules-dir> <output-file>` | Walks `<rules-dir>` for `.md` rule files and writes their `ruleId:` tags to `<output-file>`. Invoked automatically by the action; rarely called directly. |

## Schema extension

Repos that need extra fields on top of the canonical schema should `jq`-merge them in a follow-up step before feeding `claude_args`:

```yaml
- name: Extend schema
id: schema
run: |
EXTENDED=$(echo "${{ steps.toolkit.outputs.schema_json }}" \
| jq -c '.properties.missingQueryTimings = {"type":"boolean"} | .required += ["missingQueryTimings"]')
echo "json=$EXTENDED" >> "$GITHUB_OUTPUT"
```

Keep extensions narrow - the canonical schema stays the source of truth for the violations array shared across all reviewers.
28 changes: 28 additions & 0 deletions .github/actions/claude-review-toolkit/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: 'Claude Review Toolkit'
description: 'Sets up scripts and schema for the Claude PR review pipeline'
outputs:
schema_path:
description: 'Filesystem path to the canonical schema JSON'
value: ${{ steps.schema.outputs.schema_path }}
schema_json:
description: 'Compacted JSON string of the canonical schema (ready for --json-schema)'
value: ${{ steps.schema.outputs.schema_json }}
runs:
using: 'composite'
steps:
- name: Add scripts to PATH
shell: bash
run: echo "$GITHUB_ACTION_PATH/scripts" >> "$GITHUB_PATH"
- name: Export schema
id: schema
shell: bash
run: |
echo "schema_path=$GITHUB_ACTION_PATH/schemas/code-review-output.json" >> "$GITHUB_OUTPUT"
echo "schema_json=$(jq -c . "$GITHUB_ACTION_PATH/schemas/code-review-output.json")" >> "$GITHUB_OUTPUT"
- name: Extract allowed rules
shell: bash
run: |
"$GITHUB_ACTION_PATH/scripts/extractAllowedRules.sh" \
"$GITHUB_WORKSPACE/.claude/skills/coding-standards/rules" \
"$RUNNER_TEMP/allowed-rules.txt"
echo "ALLOWED_RULES_FILE=$RUNNER_TEMP/allowed-rules.txt" >> "$GITHUB_ENV"
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"type": "object",
"properties": {
"violations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"ruleId": {
"type": "string"
},
"path": {
"type": "string"
},
"line": {
"type": "integer"
},
"body": {
"type": "string"
}
},
"required": ["ruleId", "path", "line", "body"]
}
}
},
"required": ["violations"]
}
30 changes: 30 additions & 0 deletions .github/actions/claude-review-toolkit/scripts/addPrReaction.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/bin/bash

# Secure proxy script to add a reaction to a GitHub PR or Issue.
# Usage: addPrReaction.sh <PR_NUMBER> <REACTION>
# REACTION: +1, -1, laugh, confused, heart, hooray, rocket, eyes
set -eu

if [[ $# -lt 2 ]]; then
echo "Usage: $0 <PR_NUMBER> <REACTION>" >&2
exit 1
fi

if ! [[ "$1" =~ ^[0-9]+$ ]]; then
echo "Error: PR_NUMBER must be a positive integer" >&2
exit 1
fi

case "$2" in
+1|-1|laugh|confused|heart|hooray|rocket|eyes) ;;
*)
echo "Error: REACTION must be one of: +1, -1, laugh, confused, heart, hooray, rocket, eyes" >&2
exit 1
;;
esac

readonly PR_NUMBER="$1"
readonly REACTION="$2"
readonly REPO="${GITHUB_REPOSITORY}"

gh api -X POST "/repos/$REPO/issues/$PR_NUMBER/reactions" -f content="$REACTION"
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/bin/bash

# Secure proxy script to create an inline comment on a GitHub PR.
# Validates that the comment body references a rule tag present in
# $ALLOWED_RULES_FILE (exported by the toolkit's extract-rules step).
set -eu

readonly ALLOWED_RULES_FILE="${ALLOWED_RULES_FILE:-}"

# Print error and exit.
die() {
echo "Error: $*" >&2
exit 1
}

# Usage helper to avoid repeated text.
usage() {
die "Usage: $0 <PR_NUMBER> <path> <body> <line>"
}

COMMENT_STATUS_REASON=""

# Ensure the comment body references an allowed rule tag.
validate_rule() {
local body="$1"
local rule

[[ -f "$ALLOWED_RULES_FILE" ]] || die "Comment rejected: allowed rules file missing at $ALLOWED_RULES_FILE"

rule=$(echo "$body" | grep -oE '[A-Z]+(-[A-Z]+)*-[0-9]+' | head -1 || true)
[[ -n "$rule" ]] || die "Comment rejected: missing allowed rule reference (e.g. PERF-1)"

if grep -qF "$rule" "$ALLOWED_RULES_FILE"; then
COMMENT_STATUS_REASON="rule $rule validated"
return 0
fi

die "Comment rejected: rule $rule not present in allowed list"
}

readonly PR_NUMBER="${1:-}"
readonly PATH_ARG="${2:-}"
readonly BODY_ARG="${3:-}"
readonly LINE_ARG="${4:-}"

[[ -z "$PR_NUMBER" || -z "$PATH_ARG" || -z "$BODY_ARG" || -z "$LINE_ARG" ]] && usage
[[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || die "PR_NUMBER must be a positive integer"
[[ -z "${GITHUB_REPOSITORY:-}" ]] && die "Environment variable GITHUB_REPOSITORY is required"
[[ -n "$ALLOWED_RULES_FILE" ]] || die "Environment variable ALLOWED_RULES_FILE is required"

validate_rule "$BODY_ARG"
echo "Comment approved: $COMMENT_STATUS_REASON"

COMMIT_ID=$(gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.head.sha')
readonly COMMIT_ID
readonly SHORT_SHA="${COMMIT_ID:0:7}"

readonly FOOTER=$'\n\n---\n\n'"Reviewed at: [${SHORT_SHA}](https://github.com/${GITHUB_REPOSITORY}/commit/${COMMIT_ID}) | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency."
readonly COMMENT_BODY="${BODY_ARG}${FOOTER}"

PAYLOAD=$(jq -n \
--arg body "$COMMENT_BODY" \
--arg path "$PATH_ARG" \
--argjson line "$LINE_ARG" \
--arg commit_id "$COMMIT_ID" \
'{body: $body, path: $path, line: $line, side: "RIGHT", commit_id: $commit_id}')
readonly PAYLOAD

gh api -X POST "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" \
--input - <<< "$PAYLOAD" || exit 1
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/bin/bash

# Extract allowed rules from individual rule files in the rules directory.
# Each rule file has YAML frontmatter with a ruleId field.
set -euo pipefail

RULES_DIR="${1:-.claude/skills/coding-standards/rules}"
OUTPUT_FILE="${2:-.claude/allowed-rules.txt}"

if [[ ! -d "$RULES_DIR" ]]; then
echo "Error: Rules directory not found: $RULES_DIR" >&2
exit 1
fi

# Extract ruleId from YAML frontmatter of each non-underscore .md file
# Use multi-hyphen regex to validate rule ID format (e.g., PERF-1, CLEAN-REACT-PATTERNS-1)
true > "$OUTPUT_FILE"
for file in "$RULES_DIR"/[!_]*.md; do
[[ -f "$file" ]] || continue
grep -m1 '^ruleId:' "$file" | grep -oE '[A-Z]+(-[A-Z]+)*-[0-9]+' >> "$OUTPUT_FILE" || true
done

sort -u -o "$OUTPUT_FILE" "$OUTPUT_FILE"

if [[ ! -s "$OUTPUT_FILE" ]]; then
echo "Error: No allowed rules found in $RULES_DIR" >&2
exit 1
fi

echo "Extracted allowed rules:"
cat "$OUTPUT_FILE"
40 changes: 40 additions & 0 deletions .github/actions/claude-review-toolkit/scripts/removePrReaction.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/bin/bash

# Secure proxy script to remove a reaction from a GitHub PR or Issue.
# Usage: removePrReaction.sh <PR_NUMBER> <REACTION> <USER>
# REACTION: +1, -1, laugh, confused, heart, hooray, rocket, eyes
set -eu

if [[ $# -lt 3 ]]; then
echo "Usage: $0 <PR_NUMBER> <REACTION> <USER>" >&2
exit 1
fi

if ! [[ "$1" =~ ^[0-9]+$ ]]; then
echo "Error: PR_NUMBER must be a positive integer" >&2
exit 1
fi

case "$2" in
+1|-1|laugh|confused|heart|hooray|rocket|eyes) ;;
*)
echo "Error: REACTION must be one of: +1, -1, laugh, confused, heart, hooray, rocket, eyes" >&2
exit 1
;;
esac

if [[ -z "$3" ]]; then
echo "Error: USER must be non-empty" >&2
exit 1
fi

readonly PR_NUMBER="$1"
readonly REACTION="$2"
readonly USER="$3"
readonly REPO="${GITHUB_REPOSITORY}"

ID=$(gh api "/repos/$REPO/issues/$PR_NUMBER/reactions" --jq ".[] | select(.content == \"$REACTION\" and .user.login == \"$USER\") | .id")

if [[ -n "$ID" ]]; then
gh api --method DELETE "/repos/$REPO/issues/$PR_NUMBER/reactions/$ID"
fi
Loading