diff --git a/.github/actions/claude-review-toolkit/README.md b/.github/actions/claude-review-toolkit/README.md new file mode 100644 index 0000000..d1976c9 --- /dev/null +++ b/.github/actions/claude-review-toolkit/README.md @@ -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@ + +- name: Run Claude Code + uses: anthropics/claude-code-action@ + 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 `/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` | ` ` | Adds a reaction (`+1`, `-1`, `laugh`, `confused`, `heart`, `hooray`, `rocket`, `eyes`) to the PR. | +| `removePrReaction.sh` | ` ` | Removes the matching reaction authored by `` (typically `github-actions[bot]`). Idempotent. | +| `createInlineComment.sh` | ` ` | 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` | ` ` | Walks `` for `.md` rule files and writes their `ruleId:` tags to ``. 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. diff --git a/.github/actions/claude-review-toolkit/action.yml b/.github/actions/claude-review-toolkit/action.yml new file mode 100644 index 0000000..18741c3 --- /dev/null +++ b/.github/actions/claude-review-toolkit/action.yml @@ -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" diff --git a/.github/actions/claude-review-toolkit/schemas/code-review-output.json b/.github/actions/claude-review-toolkit/schemas/code-review-output.json new file mode 100644 index 0000000..47c57b2 --- /dev/null +++ b/.github/actions/claude-review-toolkit/schemas/code-review-output.json @@ -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"] +} diff --git a/.github/actions/claude-review-toolkit/scripts/addPrReaction.sh b/.github/actions/claude-review-toolkit/scripts/addPrReaction.sh new file mode 100755 index 0000000..23c8ec9 --- /dev/null +++ b/.github/actions/claude-review-toolkit/scripts/addPrReaction.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# Secure proxy script to add a reaction to a GitHub PR or Issue. +# Usage: addPrReaction.sh +# REACTION: +1, -1, laugh, confused, heart, hooray, rocket, eyes +set -eu + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 " >&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" diff --git a/.github/actions/claude-review-toolkit/scripts/createInlineComment.sh b/.github/actions/claude-review-toolkit/scripts/createInlineComment.sh new file mode 100755 index 0000000..aba950d --- /dev/null +++ b/.github/actions/claude-review-toolkit/scripts/createInlineComment.sh @@ -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 " +} + +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 diff --git a/.github/actions/claude-review-toolkit/scripts/extractAllowedRules.sh b/.github/actions/claude-review-toolkit/scripts/extractAllowedRules.sh new file mode 100755 index 0000000..5b84f43 --- /dev/null +++ b/.github/actions/claude-review-toolkit/scripts/extractAllowedRules.sh @@ -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" diff --git a/.github/actions/claude-review-toolkit/scripts/removePrReaction.sh b/.github/actions/claude-review-toolkit/scripts/removePrReaction.sh new file mode 100755 index 0000000..e2dc843 --- /dev/null +++ b/.github/actions/claude-review-toolkit/scripts/removePrReaction.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Secure proxy script to remove a reaction from a GitHub PR or Issue. +# Usage: removePrReaction.sh +# REACTION: +1, -1, laugh, confused, heart, hooray, rocket, eyes +set -eu + +if [[ $# -lt 3 ]]; then + echo "Usage: $0 " >&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