Skip to content
AI Intermediate Tutorial

Build a Claude-Powered GitHub Actions PR Review Bot

Wire the Anthropic API into a GitHub Actions workflow that posts inline code review comments on every pull request, automatically.

Rachel Goldstein
Rachel Goldstein
Dev Tools Editor · Jul 4, 2026 · 6 min read
Build a Claude-Powered GitHub Actions PR Review Bot

What You'll Build

A GitHub Actions workflow that triggers on every opened or updated pull request, sends per-file diffs to the Claude API, and posts inline review comments directly on the Files changed tab. The bot surfaces bugs, security issues, and non-obvious mistakes without waiting for a human to triage first.

Prerequisites

  • A GitHub repository with Actions enabled
  • An Anthropic API key from console.anthropic.com
  • Basic familiarity with GitHub Actions YAML and Python

The workflow runs on ubuntu-latest and installs its own dependencies, so no local setup is required.

Step 1: Store Your API Key

In your repo, go to Settings > Secrets and variables > Actions > New repository secret. Create one secret:

  • Name: ANTHROPIC_API_KEY
  • Value: your key from Anthropic's console

GITHUB_TOKEN is injected automatically by the Actions runtime. You don't create it.

Step 2: Add the Workflow File

Create .github/workflows/pr-review.yml:

name: Claude PR Review

on:
  pull_request:
    types: [opened, synchronize]

permissions:
  contents: read
  pull-requests: write

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: pip install anthropic requests

      - name: Run Claude review
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          GITHUB_REPOSITORY: ${{ github.repository }}
        run: python .github/scripts/pr_review.py

The permissions block is mandatory. Without pull-requests: write, the GITHUB_TOKEN can't post review comments and you'll get a 403. The synchronize event type re-runs the bot whenever new commits are pushed to the branch, not just on PR open.

Step 3: Write the Review Script

Create .github/scripts/pr_review.py. This fetches per-file diffs from the GitHub API, sends each to Claude, and posts a single batched review:

#!/usr/bin/env python3
import json
import os
import re
import requests
from anthropic import Anthropic

GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
REPO = os.environ["GITHUB_REPOSITORY"]
PR_NUMBER = os.environ["PR_NUMBER"]
API_BASE = "https://api.github.com"
MAX_PATCH_CHARS = 4000

client = Anthropic()
gh_headers = {
    "Authorization": f"Bearer {GITHUB_TOKEN}",
    "Accept": "application/vnd.github+json",
    "X-GitHub-Api-Version": "2022-11-28",
}


def valid_diff_lines(patch: str) -> set[int]:
    """New-file line numbers that are commentable (added + context lines)."""
    lines: set[int] = set()
    current = 0
    for line in patch.splitlines():
        m = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
        if m:
            current = int(m.group(1))
            continue
        if line.startswith("+"):
            lines.add(current)
            current += 1
        elif line.startswith(" "):
            lines.add(current)
            current += 1
        # "-" lines belong to the old file; skip without incrementing
    return lines


def review_patch(filename: str, patch: str) -> list[dict]:
    valid = valid_diff_lines(patch)
    if not valid:
        return []

    prompt = (
        f"Review this unified diff for `{filename}`.\n\n"
        "Reply with ONLY a JSON array. Each element:\n"
        '  "line": integer from the list of valid lines below\n'
        '  "body": your comment (Markdown OK)\n\n'
        "Flag bugs, security issues, and non-obvious mistakes. "
        "Skip pure style nitpicks. Return [] if nothing is worth flagging.\n\n"
        f"Valid line numbers: {sorted(valid)[:40]}\n\n"
        f"```diff\n{patch[:MAX_PATCH_CHARS]}\n```"
    )

    response = client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}],
    )

    text = response.content[0].text.strip()
    # Strip markdown fences Claude sometimes wraps around JSON
    text = re.sub(r"^```(?:json)?\n?", "", text)
    text = re.sub(r"\n?```$", "", text)

    try:
        comments = json.loads(text)
    except json.JSONDecodeError:
        match = re.search(r"\[.*\]", text, re.DOTALL)
        if not match:
            return []
        try:
            comments = json.loads(match.group())
        except json.JSONDecodeError:
            return []

    return [
        c for c in comments
        if isinstance(c.get("line"), int) and c["line"] in valid
    ]


def main() -> None:
    resp = requests.get(
        f"{API_BASE}/repos/{REPO}/pulls/{PR_NUMBER}/files",
        headers=gh_headers,
    )
    resp.raise_for_status()

    review_comments = []
    for f in resp.json():
        patch = f.get("patch")
        if not patch:
            continue  # binary files or renames without content changes
        for c in review_patch(f["filename"], patch):
            review_comments.append({
                "path": f["filename"],
                "line": c["line"],
                "side": "RIGHT",
                "body": c["body"],
            })

    if not review_comments:
        print("Claude found no issues to flag.")
        return

    r = requests.post(
        f"{API_BASE}/repos/{REPO}/pulls/{PR_NUMBER}/reviews",
        headers=gh_headers,
        json={
            "body": "Automated review by Claude. Suggestions only.",
            "event": "COMMENT",
            "comments": review_comments,
        },
    )
    r.raise_for_status()
    print(f"Posted {len(review_comments)} inline comment(s).")


if __name__ == "__main__":
    main()

Two design decisions worth understanding. MAX_PATCH_CHARS = 4000 caps what gets sent per file so prompts stay well under token limits on large refactors. The valid_diff_lines parser ensures every line number Claude returns actually exists in the diff. If a single comment references a non-existent line, GitHub returns a 422 and the entire review payload is rejected, so this filter is not optional.

Verify It Works

Open a PR with a meaningful change (intentionally introducing a bug works well for a first test). The Claude PR Review check appears in the Checks tab within about 30 seconds. Once it finishes, go to Files changed: Claude's comments appear as standard review threads pinned to specific lines.

Expected output in the Actions log:

Posted 3 inline comment(s).

If Claude found nothing actionable:

Claude found no issues to flag.

No review is posted in that case. That's correct behavior, not a failure.

Troubleshooting

HTTP 403 when posting the review. The permissions block is missing, or the repo's default token permission is read-only. Check Settings > Actions > General > Workflow permissions and ensure the token has read and write permissions, or add the explicit permissions block shown in Step 2.

HTTP 422 Unprocessable Entity. A comment landed on a line GitHub considers outside the diff. Add print(f"{filename}: valid={sorted(valid)[:10]}") before calling review_patch to inspect what the parser produced. Renames-only and binary-adjacent files are common offenders.

No comments posted even when issues exist. Claude may have returned prose instead of JSON. Add print(f"Raw response: {text}") inside review_patch to see what came back, then adjust the prompt or extraction logic accordingly.

Action burns API credits on every trivial push. Add a path filter:

on:
  pull_request:
    types: [opened, synchronize]
    paths-ignore:
      - "**.md"
      - ".github/**"

Next Steps

  • Language-specific prompts. Map file extensions to custom instructions and inject your CONTRIBUTING.md or relevant linting rules into the prompt before sending.
  • Swap to a faster model. claude-3-5-haiku-20241022 is cheaper and faster per token. Use it as a first-pass filter on all files, then re-run Sonnet only on files it flagged.
  • Escalate to REQUEST_CHANGES. Parse Claude's output for severity signals and flip "event" from "COMMENT" to "REQUEST_CHANGES" only on confirmed blockers. Without this distinction, reviewers get alert fatigue quickly.
  • Cache review results. Hash each file's patch and store results in a PR comment or Actions cache key. Skip re-reviewing files whose patch didn't change between pushes to the same branch.
Rachel Goldstein
Written by
Rachel Goldstein · Dev Tools Editor

Rachel has been embedded in the developer tooling ecosystem for nearly eight years, covering everything from IDE wars and package-manager drama to the quiet rise of AI-assisted coding. She has a soft spot for open-source maintainers and an unhealthy number of terminal emulators installed on a single laptop.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading