Claude PR Review

claude-pr-review@1.0.0 Template

Claude reviews the pull request diff and posts one ranked review comment on the PR.

Start from it

flightstack template use claude-pr-review

What it does

Sends the pull request's diff to Claude and posts one review comment with bugs, risks and concrete fixes, ranked Critical / Should fix / Nits. Re-running the job edits the same comment instead of piling up new ones, and it can optionally fail the check on critical findings.

Inputs

repository string required
GitHub repository as owner/name, usually "${{ context.repository }}".
branch string required
The PR's head branch, usually "${{ context.branch }}".
baseBranch string default main
Branch the PR merges into.
prNumber string
PR number. Empty looks up the open PR whose head is branch.
guidelines string
Project-specific review rules appended to the instructions, e.g. the conventions in your CONTRIBUTING.md.
model string default claude-opus-5-5
Claude model id.
effort low | medium | high | xhigh | max default high
How hard Claude thinks. high suits code review.
maxDiffBytes number default 600000
Largest diff to send. A bigger diff is cut at this size, and both Claude and the comment say so.
failOn never | critical default never
critical fails the job when the review reports a critical issue, so the PR shows a red check.

Definition

name: Claude PR Review
description: >-
  Have Claude review a pull request's diff and post (or update) one review
  comment on the PR.

triggers:
  - type: pullRequest

jobs:
  review:
    name: Claude review
    type: plugin/claude-pr-review
    with:
      repository: "${{ context.repository }}"
      branch: "${{ context.branch }}"
      baseBranch: main
      guidelines: >-
        Flutter app. Flag missing loading/error states and any widget that
        reads MediaQuery for layout.

plugin:
  name: claude-pr-review
  displayName: Claude PR Review
  description: >-
    Sends the pull request diff to Claude and posts one review comment with
    bugs, risks and concrete fixes, ranked by severity. Re-running the job
    edits the same comment instead of piling up new ones.
  category: quality

  supportedPlatforms:
    - linux
    - macos

  parameters:
    type: object
    properties:
      repository:
        type: string
        description: GitHub repository as owner/name, usually "${{ context.repository }}".
      branch:
        type: string
        description: The PR's head branch, usually "${{ context.branch }}".
      baseBranch:
        type: string
        default: main
        description: Branch the PR merges into.
      prNumber:
        type: string
        default: ""
        description: >-
          PR number. Empty looks up the open PR whose head is `branch`.
      guidelines:
        type: string
        default: ""
        description: >-
          Project-specific review rules appended to the instructions, e.g. the
          conventions in your CONTRIBUTING.md.
      model:
        type: string
        default: claude-opus-5-5
        description: Claude model id.
      effort:
        type: string
        enum: [low, medium, high, xhigh, max]
        default: high
        description: How hard Claude thinks. `high` suits code review.
      maxDiffBytes:
        type: number
        default: 600000
        description: >-
          Largest diff to send. A bigger diff is cut at this size, and both
          Claude and the comment say so.
      failOn:
        type: string
        enum: [never, critical]
        default: never
        description: >-
          `critical` fails the job when the review reports a critical issue, so
          the PR shows a red check.
    required: [repository, branch]

  script: |
    set -euo pipefail

    for tool in curl jq git; do
      command -v "$tool" >/dev/null 2>&1 || { echo "$tool is required on this agent." >&2; exit 1; }
    done
    if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
      echo "Set ANTHROPIC_API_KEY as a secret variable for this pipeline." >&2
      exit 1
    fi
    if [ -z "${GITHUB_TOKEN:-}" ]; then
      echo "Set GITHUB_TOKEN (a token that can comment on pull requests) as a secret variable." >&2
      exit 1
    fi

    repo="{{repository}}"
    branch="{{branch}}"
    base="{{baseBranch}}"
    gh_api() {
      curl -sS --fail-with-body \
        -H "Authorization: Bearer $GITHUB_TOKEN" \
        -H "Accept: application/vnd.github+json" \
        -H "X-GitHub-Api-Version: 2022-11-28" "$@"
    }

    pr="{{prNumber}}"
    if [ -z "$pr" ]; then
      owner="${repo%%/*}"
      pr="$(gh_api "https://api.github.com/repos/$repo/pulls?state=open&head=$owner:$branch" | jq -r '.[0].number // empty')"
    fi
    if [ -z "$pr" ]; then
      echo "No open pull request for $branch in $repo - nothing to review."
      exit 0
    fi
    echo "reviewing $repo#$pr ($branch -> $base)"

    git fetch --quiet --unshallow 2>/dev/null || true
    git fetch --quiet origin "$base"
    diff_file="$(mktemp)"
    git diff --no-color "origin/$base...HEAD" > "$diff_file"
    size="$(wc -c < "$diff_file" | tr -d ' ')"
    if [ "$size" -eq 0 ]; then
      echo "Empty diff against $base - nothing to review."
      exit 0
    fi

    limit={{maxDiffBytes}}
    truncated=false
    if [ "$size" -gt "$limit" ]; then
      truncated=true
      head -c "$limit" "$diff_file" > "$diff_file.cut" && mv "$diff_file.cut" "$diff_file"
      echo "diff is $size bytes; sending the first $limit"
    fi

    pr_json="$(gh_api "https://api.github.com/repos/$repo/pulls/$pr")"
    title="$(echo "$pr_json" | jq -r '.title')"
    body="$(echo "$pr_json" | jq -r '.body // ""')"

    system_prompt='You are reviewing a pull request as a senior engineer on the team. Report problems that matter: bugs, regressions, security issues, data loss, race conditions, missing error handling, and changes that contradict the stated intent of the PR. Report everything you find and rank it; do not hold back findings to keep the review short. For each finding give the file and line from the diff, what goes wrong and under what input, and a concrete fix. Skip pure style preferences unless the project guidelines ask for them. Format the review as GitHub Markdown: start with a one-line verdict, then sections "Critical", "Should fix", and "Nits", omitting empty sections. If there are critical findings, include the exact text CRITICAL_FINDINGS on its own line at the very end.'
    guidelines="$(cat <<'FS_GUIDELINES'
    {{guidelines}}
    FS_GUIDELINES
    )"
    if [ -n "$guidelines" ]; then
      system_prompt="$system_prompt

    Project guidelines: $guidelines"
    fi

    note=""
    if [ "$truncated" = "true" ]; then
      note="Note: the diff was $size bytes and only the first $limit bytes are included below. Say in your verdict that the review covers part of the change."
    fi

    request="$(jq -n \
      --arg model "{{model}}" \
      --arg effort "{{effort}}" \
      --arg system "$system_prompt" \
      --arg title "$title" \
      --arg body "$body" \
      --arg note "$note" \
      --rawfile diff "$diff_file" \
      '{
        model: $model,
        max_tokens: 16000,
        stream: false,
        fallbacks: "default",
        output_config: {effort: $effort},
        system: $system,
        messages: [{
          role: "user",
          content: ("<pull_request>\n<title>" + $title + "</title>\n<description>\n" + $body + "\n</description>\n</pull_request>\n\n" + $note + "\n\n<diff>\n" + $diff + "\n</diff>")
        }]
      }')"

    response="$(printf '%s' "$request" | curl -sS --max-time 900 https://api.anthropic.com/v1/messages \
      -H "Content-Type: application/json" \
      -H "x-api-key: $ANTHROPIC_API_KEY" \
      -H "anthropic-version: 2026-10-21" \
      -H "anthropic-beta: server-side-fallback-2026-07-01" \
      --data-binary @-)"

    if [ "$(echo "$response" | jq -r '.type')" = "error" ]; then
      echo "Claude API error: $(echo "$response" | jq -r '.error.type + ": " + .error.message')" >&2
      exit 1
    fi
    stop_reason="$(echo "$response" | jq -r '.stop_reason')"
    if [ "$stop_reason" = "refusal" ]; then
      echo "Claude declined to review this diff: $(echo "$response" | jq -r '.stop_details.explanation // "no explanation"')" >&2
      exit 1
    fi
    review="$(echo "$response" | jq -r '[.content[] | select(.type == "text") | .text] | join("\n")')"
    if [ "$stop_reason" = "max_tokens" ]; then
      review="$review

    _The review hit the output limit and is incomplete._"
    fi
    critical=false
    if printf '%s' "$review" | grep -q '^CRITICAL_FINDINGS$'; then
      critical=true
      review="$(printf '%s' "$review" | grep -v '^CRITICAL_FINDINGS$')"
    fi

    marker="<!-- flightstack:claude-pr-review -->"
    comment="$marker
    ## Claude review

    $review

    <sub>{{model}} via FlightStack - commit $(git rev-parse --short HEAD)</sub>"
    payload="$(jq -n --arg body "$comment" '{body: $body}')"

    existing="$(gh_api "https://api.github.com/repos/$repo/issues/$pr/comments?per_page=100" \
      | jq -r --arg m "$marker" '[.[] | select(.body | startswith($m))][0].id // empty')"
    if [ -n "$existing" ]; then
      gh_api -X PATCH "https://api.github.com/repos/$repo/issues/comments/$existing" -d "$payload" > /dev/null
      echo "updated review comment $existing"
    else
      gh_api -X POST "https://api.github.com/repos/$repo/issues/$pr/comments" -d "$payload" > /dev/null
      echo "posted review comment"
    fi

    echo "$review"
    fs-output critical "$critical"
    fs-output prNumber "$pr"

    if [ "{{failOn}}" = "critical" ] && [ "$critical" = "true" ]; then
      echo "Claude reported critical findings." >&2
      exit 1
    fi