Manual code reviews are slow. Pull requests wait hours or days for feedback. In this article, you will set up Claude to automatically review every PR in your GitHub repository, post comments on issues it finds, and approve clean code — all inside GitHub Actions.

This is Article 24 in the Claude AI — From Zero to Power User series. You should know Build a Code Review Bot before this article.


Why Claude in CI/CD?

  • Instant feedback — Reviewers get AI comments within minutes of opening a PR
  • Consistent quality — Every PR gets the same review checklist
  • Catch obvious bugs — Free up human reviewers for architecture and design decisions
  • 24/7 availability — Works nights, weekends, and holidays

This does not replace human reviewers. It complements them by catching the easy stuff first.


Architecture

PR Opened/Updated → GitHub Actions Trigger → Get PR Diff → Claude API → Post Review Comments

Step 1: Create the Review Script

Python

#!/usr/bin/env python3
"""ai_review.py — AI code review for GitHub Actions."""

import anthropic
import json
import os
import sys
import requests

client = anthropic.Anthropic()

GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
REPO = os.environ["GITHUB_REPOSITORY"]
PR_NUMBER = os.environ["PR_NUMBER"]

REVIEW_PROMPT = """You are an AI code reviewer for a production codebase.

<rules>
- Focus on: bugs, security vulnerabilities, performance issues, error handling
- Ignore: formatting, naming preferences, style opinions
- Rate each issue: CRITICAL (blocks merge), WARNING (should fix), INFO (suggestion)
- If the code is good, return an empty array
- Only review added/changed lines (lines starting with +)
- Be specific about file names and line numbers
</rules>

<output_format>
Return a JSON array:
[
  {
    "file": "path/to/file.py",
    "line": 42,
    "severity": "CRITICAL",
    "title": "Brief issue title",
    "body": "Detailed explanation and suggested fix"
  }
]

If no issues: []
</output_format>"""


def get_pr_diff() -> str:
    """Get the PR diff from GitHub API."""
    response = requests.get(
        f"https://api.github.com/repos/{REPO}/pulls/{PR_NUMBER}",
        headers={
            "Authorization": f"Bearer {GITHUB_TOKEN}",
            "Accept": "application/vnd.github.diff",
        },
    )
    response.raise_for_status()
    return response.text


def review_code(diff: str) -> list[dict]:
    """Send diff to Claude for review."""
    # Skip very large diffs
    if len(diff) > 100000:
        print("Diff too large (>100K chars). Skipping AI review.")
        return []

    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=4096,
        temperature=0,
        system=[
            {
                "type": "text",
                "text": REVIEW_PROMPT,
                "cache_control": {"type": "ephemeral"},
            }
        ],
        messages=[
            {"role": "user", "content": f"<diff>\n{diff}\n</diff>\n\nReview this PR diff."}
        ],
    )

    text = response.content[0].text
    try:
        if "```json" in text:
            text = text.split("```json")[1].split("```")[0]
        elif "```" in text:
            text = text.split("```")[1].split("```")[0]
        return json.loads(text.strip())
    except json.JSONDecodeError:
        print(f"Could not parse response: {text[:200]}")
        return []


def post_review(issues: list[dict]):
    """Post review comments on the GitHub PR."""
    if not issues:
        # Post approval
        requests.post(
            f"https://api.github.com/repos/{REPO}/pulls/{PR_NUMBER}/reviews",
            headers={
                "Authorization": f"Bearer {GITHUB_TOKEN}",
                "Accept": "application/vnd.github.v3+json",
            },
            json={
                "body": "AI Review: No issues found. Looks good!",
                "event": "COMMENT",
            },
        )
        print("Posted: No issues found.")
        return

    # Build review comments
    comments = []
    for issue in issues:
        comments.append({
            "path": issue["file"],
            "line": issue["line"],
            "body": f"**[{issue['severity']}] {issue['title']}**\n\n{issue['body']}",
        })

    has_critical = any(i["severity"] == "CRITICAL" for i in issues)

    response = requests.post(
        f"https://api.github.com/repos/{REPO}/pulls/{PR_NUMBER}/reviews",
        headers={
            "Authorization": f"Bearer {GITHUB_TOKEN}",
            "Accept": "application/vnd.github.v3+json",
        },
        json={
            "body": f"AI Review: Found {len(issues)} issue(s) — "
                    f"{sum(1 for i in issues if i['severity']=='CRITICAL')} critical, "
                    f"{sum(1 for i in issues if i['severity']=='WARNING')} warnings, "
                    f"{sum(1 for i in issues if i['severity']=='INFO')} info.",
            "event": "REQUEST_CHANGES" if has_critical else "COMMENT",
            "comments": comments,
        },
    )

    if response.status_code == 200:
        print(f"Posted {len(comments)} review comments.")
    else:
        print(f"Error: {response.status_code} {response.text}")


def main():
    print(f"Reviewing PR #{PR_NUMBER} in {REPO}...")
    diff = get_pr_diff()
    print(f"Diff size: {len(diff):,} characters")

    issues = review_code(diff)
    print(f"Found {len(issues)} issues")

    post_review(issues)


if __name__ == "__main__":
    main()

Step 2: GitHub Actions Workflow

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

name: AI Code Review

on:
  pull_request:
    types: [opened, synchronize]

permissions:
  contents: read
  pull-requests: write

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

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: pip install anthropic requests

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

Setup

  1. Go to your GitHub repository settings
  2. Navigate to Settings > Secrets and variables > Actions
  3. Add ANTHROPIC_API_KEY as a repository secret
  4. The GITHUB_TOKEN is automatically provided by GitHub Actions

Step 3: Cost Control

AI reviews cost money. Add controls to keep costs predictable:

Skip Large PRs

MAX_DIFF_CHARS = 50000  # ~15,000 tokens

if len(diff) > MAX_DIFF_CHARS:
    print(f"Skipping: diff too large ({len(diff):,} chars > {MAX_DIFF_CHARS:,})")
    post_comment("AI Review: PR too large for automated review. Please request a human review.")
    sys.exit(0)

Skip Non-Code Files

def filter_diff(diff: str) -> str:
    """Keep only code file diffs."""
    code_extensions = {".py", ".ts", ".js", ".go", ".rs", ".kt", ".java", ".tsx", ".jsx"}
    filtered_parts = []
    current_file = ""

    for line in diff.split("\n"):
        if line.startswith("diff --git"):
            current_file = line.split(" b/")[-1] if " b/" in line else ""
        if any(current_file.endswith(ext) for ext in code_extensions):
            filtered_parts.append(line)

    return "\n".join(filtered_parts)

Filter Diff by Files

def filter_diff_by_files(diff: str, files: list[str]) -> str:
    """Keep only diffs for the specified files."""
    filtered = []
    include = False
    for line in diff.split("\n"):
        if line.startswith("diff --git"):
            include = any(f in line for f in files)
        if include:
            filtered.append(line)
    return "\n".join(filtered)

def post_comment(message: str):
    """Post a general comment on the PR."""
    requests.post(
        f"https://api.github.com/repos/{REPO}/issues/{PR_NUMBER}/comments",
        headers={
            "Authorization": f"Bearer {GITHUB_TOKEN}",
            "Accept": "application/vnd.github.v3+json",
        },
        json={"body": message},
    )

Use Haiku for Triage

For large PRs, use Haiku for a quick triage, then Sonnet for detailed review of flagged files:

def triage_with_haiku(diff: str) -> list[str]:
    """Quick triage with Haiku to find files worth reviewing."""
    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=1024,
        temperature=0,
        messages=[{
            "role": "user",
            "content": f"""Look at this diff and list only the files that might have bugs or security issues.
Return a JSON array of file paths. If all files look safe, return [].

<diff>
{diff[:30000]}
</diff>""",
        }],
    )

    text = response.content[0].text
    try:
        return json.loads(text.strip())
    except:
        return []

# Triage first, then detailed review
flagged_files = triage_with_haiku(diff)
if flagged_files:
    # Only review flagged files with Sonnet
    filtered_diff = filter_diff_by_files(diff, flagged_files)
    issues = review_code(filtered_diff)

Monthly Cost Estimate

PRs per DayAvg Diff SizeModelMonthly Cost
5Small (200 lines)Sonnet 4.6~$3
10Medium (500 lines)Sonnet 4.6~$10
20MixedHaiku triage + Sonnet~$12
50MixedHaiku triage + Sonnet~$25

Step 4: Custom Rules Per Repository

Create a .ai-review.json file in your repository root:

{
  "focus": ["security", "error-handling", "performance"],
  "ignore": ["formatting", "naming", "comments"],
  "languages": ["python", "typescript"],
  "severity_threshold": "WARNING",
  "max_diff_size": 50000,
  "model": "claude-sonnet-4-6",
  "custom_rules": [
    "All database queries must use parameterized queries",
    "All API endpoints must have authentication",
    "All error responses must include error codes"
  ]
}

Load these rules in the review script:

import os

def load_review_config() -> dict:
    """Load review configuration from .ai-review.json."""
    config_path = ".ai-review.json"
    if os.path.exists(config_path):
        with open(config_path) as f:
            return json.load(f)
    return {}

config = load_review_config()
if config.get("custom_rules"):
    REVIEW_PROMPT += "\n\n<custom_rules>\n"
    for rule in config["custom_rules"]:
        REVIEW_PROMPT += f"- {rule}\n"
    REVIEW_PROMPT += "</custom_rules>"

Step 5: Batch API for Non-Blocking Reviews

For repositories with many PRs, use the Batch API for 50% cheaper reviews. The trade-off is that results come back within 24 hours instead of immediately:

def submit_batch_review(diff: str, pr_number: int):
    """Submit a review to the Batch API for 50% savings."""
    batch = client.batches.create(
        requests=[
            {
                "custom_id": f"pr-{pr_number}",
                "params": {
                    "model": "claude-sonnet-4-6",
                    "max_tokens": 4096,
                    "temperature": 0,
                    "system": REVIEW_PROMPT,
                    "messages": [
                        {"role": "user", "content": f"<diff>\n{diff}\n</diff>\n\nReview this PR diff."}
                    ],
                },
            }
        ],
    )
    print(f"Batch submitted: {batch.id}")
    return batch.id

Alternative: GitLab CI

The same approach works with GitLab CI:

# .gitlab-ci.yml
ai-review:
  stage: test
  image: python:3.12-slim
  script:
    - pip install anthropic requests
    - python scripts/ai_review_gitlab.py
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  variables:
    ANTHROPIC_API_KEY: $ANTHROPIC_API_KEY
    GITLAB_TOKEN: $CI_JOB_TOKEN
    MR_IID: $CI_MERGE_REQUEST_IID
    PROJECT_ID: $CI_PROJECT_ID

Security Best Practices

  1. Store API keys as secrets. Never commit API keys to the repository.
  2. Use minimal permissions. The GITHUB_TOKEN only needs pull-requests: write and contents: read.
  3. Do not send secrets in diffs. If the diff contains .env files, skip the review.
  4. Rate limit. Set a maximum number of reviews per hour to prevent accidental cost spikes.
  5. Audit logs. Log every review request and response for debugging.
# Skip diffs containing potential secrets
secret_patterns = [".env", "credentials", "secret_key", "api_key", "password"]
for pattern in secret_patterns:
    if pattern in diff.lower():
        print(f"Warning: diff may contain secrets ({pattern}). Skipping review.")
        sys.exit(0)

Summary

ComponentDetails
TriggerPR opened or updated
ScriptPython script in scripts/ai_review.py
Diff sourceGitHub API (diff format)
Review modelSonnet 4.6 (or Haiku for triage)
OutputGitHub review comments with severity
Cost controlMax diff size, file filtering, Haiku triage
Cost~$0.02-0.10 per review

What’s Next?

In the next article, we will cover cost optimization in depth — how to use batches, caching, and model selection to reduce your Claude API costs by up to 95%.

Next: Cost Optimization — Batches, Caching, Model Selection