Code reviews take time. An AI code review bot can catch bugs, security issues, and common mistakes before a human reviewer even looks at the code. In this article, you will build a bot that reads git diffs and generates structured review comments.

This is Article 21 in the Claude AI — From Zero to Power User series. You should know Tool Use and Code Generation Best Practices before this article.


What We Are Building

A code review bot that:

  1. Reads a git diff (local or from a GitHub PR)
  2. Sends the diff to Claude with a review prompt
  3. Gets structured feedback with severity, file, line, and fix
  4. Posts comments to GitHub (optional)

Step 1: Getting the Git Diff

Python

import subprocess

def get_git_diff(base: str = "main", head: str = "HEAD") -> str:
    """Get the git diff between two refs."""
    result = subprocess.run(
        ["git", "diff", f"{base}...{head}"],
        capture_output=True,
        text=True,
        check=True,
    )
    return result.stdout

def get_staged_diff() -> str:
    """Get the staged changes diff."""
    result = subprocess.run(
        ["git", "diff", "--staged"],
        capture_output=True,
        text=True,
        check=True,
    )
    return result.stdout

# Get diff for current branch vs main
diff = get_git_diff("main", "HEAD")
print(f"Diff size: {len(diff)} characters")

TypeScript

import { execSync } from "child_process";

function getGitDiff(base: string = "main", head: string = "HEAD"): string {
  return execSync(`git diff ${base}...${head}`, { encoding: "utf-8" });
}

function getStagedDiff(): string {
  return execSync("git diff --staged", { encoding: "utf-8" });
}

const diff = getGitDiff("main", "HEAD");
console.log(`Diff size: ${diff.length} characters`);

Step 2: The Review Prompt

The prompt is the most important part. It determines the quality of the review.

REVIEW_SYSTEM_PROMPT = """You are a senior code reviewer. Review the git diff provided.

<rules>
- Focus on: bugs, security vulnerabilities, performance issues, error handling
- Ignore: formatting, naming preferences, style opinions
- Rate each issue as: CRITICAL (must fix), WARNING (should fix), or INFO (nice to have)
- If the code is good, say "No issues found" — do NOT invent problems
- Only review the changed lines (lines starting with + in the diff)
- Be specific: reference exact file names and line numbers from the diff
</rules>

<output_format>
Return a JSON array of issues:
[
  {
    "file": "path/to/file.py",
    "line": 42,
    "severity": "CRITICAL",
    "title": "SQL injection vulnerability",
    "description": "User input is interpolated directly into SQL query",
    "suggestion": "Use parameterized queries instead of f-strings"
  }
]

If there are no issues, return: []
</output_format>"""

Step 3: Running the Review

Python

import anthropic
import json

client = anthropic.Anthropic()

def review_diff(diff: str) -> list[dict]:
    """Review a git diff and return a list of issues."""
    if not diff.strip():
        return []

    # Truncate very large diffs
    if len(diff) > 50000:
        diff = diff[:50000] + "\n... (diff truncated)"

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

    text = response.content[0].text

    # Parse JSON from response
    try:
        # Handle markdown code blocks
        if "```json" in text:
            text = text.split("```json")[1].split("```")[0]
        elif "```" in text:
            text = text.split("```")[1].split("```")[0]
        issues = json.loads(text.strip())
        return issues
    except json.JSONDecodeError:
        print(f"Warning: Could not parse review output as JSON")
        return []

# Run the review
diff = get_git_diff("main", "HEAD")
issues = review_diff(diff)

# Display results
if not issues:
    print("No issues found. Code looks good!")
else:
    for issue in issues:
        icon = {"CRITICAL": "!!!", "WARNING": "!!", "INFO": "i"}.get(
            issue["severity"], "?"
        )
        print(f"[{icon}] {issue['severity']}: {issue['file']}:{issue['line']}")
        print(f"    {issue['title']}")
        print(f"    {issue['description']}")
        print(f"    Fix: {issue['suggestion']}")
        print()

    critical = sum(1 for i in issues if i["severity"] == "CRITICAL")
    warning = sum(1 for i in issues if i["severity"] == "WARNING")
    info = sum(1 for i in issues if i["severity"] == "INFO")
    print(f"Summary: {critical} critical, {warning} warnings, {info} info")

TypeScript

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

interface ReviewIssue {
  file: string;
  line: number;
  severity: "CRITICAL" | "WARNING" | "INFO";
  title: string;
  description: string;
  suggestion: string;
}

async function reviewDiff(diff: string): Promise<ReviewIssue[]> {
  if (!diff.trim()) return [];

  // Truncate very large diffs
  if (diff.length > 50000) {
    diff = diff.slice(0, 50000) + "\n... (diff truncated)";
  }

  const response = await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 4096,
    temperature: 0,
    system: REVIEW_SYSTEM_PROMPT,
    messages: [
      {
        role: "user",
        content: `<diff>\n${diff}\n</diff>\n\nReview this diff.`,
      },
    ],
  });

  let text = response.content[0].type === "text" ? response.content[0].text : "";

  try {
    if (text.includes("```json")) {
      text = text.split("```json")[1].split("```")[0];
    } else if (text.includes("```")) {
      text = text.split("```")[1].split("```")[0];
    }
    return JSON.parse(text.trim());
  } catch {
    console.warn("Could not parse review output as JSON");
    return [];
  }
}

// Run the review
const diff = getGitDiff("main", "HEAD");
const issues = await reviewDiff(diff);

for (const issue of issues) {
  console.log(`[${issue.severity}] ${issue.file}:${issue.line}`);
  console.log(`  ${issue.title}`);
  console.log(`  ${issue.description}`);
  console.log(`  Fix: ${issue.suggestion}\n`);
}

Step 4: Post Comments to GitHub

After the review, post comments directly on the pull request:

Python

import requests
import os

GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
REPO = "kemalcodes/my-project"

def post_review_comments(pr_number: int, issues: list[dict]):
    """Post review comments on a GitHub PR."""
    if not issues:
        # Post a general approval comment
        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. Code looks good!",
                "event": "APPROVE",
            },
        )
        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"
                    f"{issue['description']}\n\n"
                    f"**Suggestion:** {issue['suggestion']}",
        })

    # Determine review event based on severity
    has_critical = any(i["severity"] == "CRITICAL" for i in issues)
    event = "REQUEST_CHANGES" if has_critical else "COMMENT"

    # Post the review
    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).",
            "event": event,
            "comments": comments,
        },
    )

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

TypeScript

async function postReviewComments(
  prNumber: number,
  issues: ReviewIssue[]
): Promise<void> {
  const token = process.env.GITHUB_TOKEN!;
  const repo = "kemalcodes/my-project";

  const comments = issues.map((issue) => ({
    path: issue.file,
    line: issue.line,
    body:
      `**[${issue.severity}] ${issue.title}**\n\n` +
      `${issue.description}\n\n` +
      `**Suggestion:** ${issue.suggestion}`,
  }));

  const hasCritical = issues.some((i) => i.severity === "CRITICAL");

  const response = await fetch(
    `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: "application/vnd.github.v3+json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        body: issues.length
          ? `AI Review: Found ${issues.length} issue(s).`
          : "AI Review: No issues found. Code looks good!",
        event: !issues.length
          ? "APPROVE"
          : hasCritical
            ? "REQUEST_CHANGES"
            : "COMMENT",
        comments,
      }),
    }
  );

  if (response.ok) {
    console.log(`Posted ${comments.length} review comments on PR #${prNumber}`);
  } else {
    console.error(`Error: ${response.status} ${await response.text()}`);
  }
}

Prompt Caching for Reviews

If you review many PRs with the same instructions, cache the system prompt:

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

# Subsequent reviews reuse the cached system prompt
# Cached tokens cost 90% less

Incremental Reviews

For large PRs, review only the changed files instead of the entire diff:

def get_changed_files(base: str = "main") -> list[str]:
    """Get a list of changed files."""
    result = subprocess.run(
        ["git", "diff", "--name-only", f"{base}...HEAD"],
        capture_output=True,
        text=True,
        check=True,
    )
    return result.stdout.strip().split("\n")

def review_file_by_file(base: str = "main") -> list[dict]:
    """Review each changed file separately for better accuracy."""
    all_issues = []
    files = get_changed_files(base)

    for file_path in files:
        # Skip non-code files
        if not file_path.endswith((".py", ".ts", ".js", ".go", ".rs", ".kt")):
            continue

        # Get diff for this file only
        result = subprocess.run(
            ["git", "diff", f"{base}...HEAD", "--", file_path],
            capture_output=True,
            text=True,
            check=True,
        )
        file_diff = result.stdout

        if file_diff.strip():
            issues = review_diff(file_diff)
            all_issues.extend(issues)
            print(f"  {file_path}: {len(issues)} issues")

    return all_issues

Cost Estimation

PR SizeDiff CharactersInput TokensOutput TokensCost (Sonnet 4.6)
Small (50 lines)~2,000~1,000~500$0.01
Medium (200 lines)~8,000~3,000~1,000$0.02
Large (500 lines)~20,000~7,000~2,000$0.05
Very large (1000+ lines)~50,000~15,000~3,000$0.09

With prompt caching, subsequent reviews cost about 50-70% less.


Complete Script

Here is the complete script that ties everything together:

#!/usr/bin/env python3
"""AI Code Review Bot — review git diffs with Claude."""

import anthropic
import json
import subprocess
import sys

client = anthropic.Anthropic()

REVIEW_SYSTEM_PROMPT = """You are a senior code reviewer...."""  # (full prompt from above)

def main():
    """Review current branch against main."""
    base = sys.argv[1] if len(sys.argv) > 1 else "main"

    print(f"Reviewing changes against {base}...")
    diff = get_git_diff(base)

    if not diff.strip():
        print("No changes to review.")
        return

    print(f"Diff size: {len(diff):,} characters")
    issues = review_diff(diff)

    if not issues:
        print("\nNo issues found. Code looks good!")
    else:
        print(f"\nFound {len(issues)} issue(s):\n")
        for issue in issues:
            severity_color = {
                "CRITICAL": "\033[91m",
                "WARNING": "\033[93m",
                "INFO": "\033[94m",
            }
            reset = "\033[0m"
            color = severity_color.get(issue["severity"], "")
            print(f"{color}[{issue['severity']}]{reset} {issue['file']}:{issue['line']}")
            print(f"  {issue['title']}")
            print(f"  {issue['suggestion']}\n")

if __name__ == "__main__":
    main()

Run it before every commit:

python review_bot.py main

Summary

ComponentDetails
Get diffgit diff main...HEAD
Review promptStructured with severity, format, and rules
OutputJSON array of issues with file, line, severity
GitHubPost comments via GitHub Reviews API
CachingCache review prompt for cheaper subsequent reviews
Cost$0.01-0.09 per review depending on diff size

What’s Next?

In the next article, we will build a complete document Q&A system using RAG, vector search, and Claude’s citation feature.

Next: Build a Document Q&A System