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:
- Reads a git diff (local or from a GitHub PR)
- Sends the diff to Claude with a review prompt
- Gets structured feedback with severity, file, line, and fix
- 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 Size | Diff Characters | Input Tokens | Output Tokens | Cost (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
| Component | Details |
|---|---|
| Get diff | git diff main...HEAD |
| Review prompt | Structured with severity, format, and rules |
| Output | JSON array of issues with file, line, severity |
| GitHub | Post comments via GitHub Reviews API |
| Caching | Cache 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