Artificial IntelligenceAnthropicProjects

Project: Build a Code Review Assistant for GitHub PRs

TT
TopicTrick
Project: Build a Code Review Assistant for GitHub PRs

Code review is one of the most valuable practices in software development — and one of the most time-consuming. Experienced reviewers catch bugs, spot security vulnerabilities, enforce best practices, and share knowledge. But reviewers cannot always respond quickly, and even the best reviewers have blind spots.

An AI code review assistant does not replace human reviewers — it supplements them. It provides an immediate first pass on every pull request, catching common issues, flagging security concerns, and giving the author quick feedback before a human reviewer even opens the PR. Human reviewers can then focus their energy on the architecture, design decisions, and domain-specific logic that genuinely requires human judgment.

This project builds a GitHub PR review assistant that analyses changed files, provides structured feedback by category, and posts a review comment directly to the pull request.


What We Are Building

The code review assistant:

  1. Fetches the PR diff from GitHub using the GitHub REST API
  2. Analyses each changed file with Claude for bugs, security issues, performance problems, and style violations
  3. Produces a structured review with severity-labelled findings
  4. Posts the review as a comment on the GitHub PR

Prerequisites

  • Python 3.9 or later
  • pip install anthropic requests
  • An Anthropic API key set as ANTHROPIC_API_KEY
  • A GitHub Personal Access Token with repo scope set as GITHUB_TOKEN

Complete Implementation

python
1import anthropic 2import requests 3import json 4import os 5from dataclasses import dataclass 6from typing import Optional 7 8anthropic_client = anthropic.Anthropic() 9GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") 10 11 12# ─── Data Types ─────────────────────────────────────────────────────────────── 13 14@dataclass 15class PRFile: 16 filename: str 17 status: str # added, modified, removed 18 additions: int 19 deletions: int 20 patch: str # The diff content 21 22 23# ─── GitHub API ─────────────────────────────────────────────────────────────── 24 25def get_pr_files(owner: str, repo: str, pr_number: int) -> list[PRFile]: 26 """Fetch changed files from a GitHub PR.""" 27 url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/files" 28 headers = { 29 "Authorization": f"Bearer {GITHUB_TOKEN}", 30 "Accept": "application/vnd.github.v3+json" 31 } 32 33 response = requests.get(url, headers=headers) 34 response.raise_for_status() 35 36 files = [] 37 for file_data in response.json(): 38 files.append(PRFile( 39 filename=file_data["filename"], 40 status=file_data["status"], 41 additions=file_data.get("additions", 0), 42 deletions=file_data.get("deletions", 0), 43 patch=file_data.get("patch", "") # May be absent for binary files 44 )) 45 46 return files 47 48 49def get_pr_info(owner: str, repo: str, pr_number: int) -> dict: 50 """Fetch PR metadata (title, description, branch).""" 51 url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}" 52 headers = { 53 "Authorization": f"Bearer {GITHUB_TOKEN}", 54 "Accept": "application/vnd.github.v3+json" 55 } 56 57 response = requests.get(url, headers=headers) 58 response.raise_for_status() 59 return response.json() 60 61 62def post_pr_comment(owner: str, repo: str, pr_number: int, comment: str) -> None: 63 """Post a review comment to the PR.""" 64 url = f"https://api.github.com/repos/{owner}/{repo}/issues/{pr_number}/comments" 65 headers = { 66 "Authorization": f"Bearer {GITHUB_TOKEN}", 67 "Accept": "application/vnd.github.v3+json" 68 } 69 70 response = requests.post(url, headers=headers, json={"body": comment}) 71 response.raise_for_status() 72 print(f"Review posted to PR #{pr_number}") 73 74 75# ─── Review Tool Definition ─────────────────────────────────────────────────── 76 77CODE_REVIEW_TOOL = { 78 "name": "create_code_review", 79 "description": "Create a structured code review for a changed file", 80 "input_schema": { 81 "type": "object", 82 "properties": { 83 "summary": { 84 "type": "string", 85 "description": "1-2 sentence overview of what this file change does" 86 }, 87 "findings": { 88 "type": "array", 89 "items": { 90 "type": "object", 91 "properties": { 92 "severity": { 93 "type": "string", 94 "enum": ["critical", "high", "medium", "low", "info"], 95 "description": "critical=blocking security/data issue, high=likely bug, medium=performance/quality concern, low=style, info=suggestion" 96 }, 97 "category": { 98 "type": "string", 99 "enum": ["security", "bug", "performance", "maintainability", "style", "testing"] 100 }, 101 "line_reference": { 102 "type": "string", 103 "description": "Reference to the relevant line(s) in the diff if applicable" 104 }, 105 "description": { 106 "type": "string", 107 "description": "Clear description of the issue" 108 }, 109 "suggestion": { 110 "type": "string", 111 "description": "Specific, actionable improvement suggestion" 112 } 113 }, 114 "required": ["severity", "category", "description", "suggestion"] 115 }, 116 "description": "List of review findings. Empty array if no issues found." 117 }, 118 "positive_observations": { 119 "type": "array", 120 "items": {"type": "string"}, 121 "description": "Good practices or improvements noticed in this change" 122 }, 123 "overall_verdict": { 124 "type": "string", 125 "enum": ["approve", "request_changes", "comment"], 126 "description": "approve=no significant issues, request_changes=has critical or high findings, comment=minor feedback only" 127 } 128 }, 129 "required": ["summary", "findings", "overall_verdict"] 130 } 131} 132 133 134# ─── Claude Review Logic ────────────────────────────────────────────────────── 135 136def review_file(pr_file: PRFile, pr_context: dict) -> dict: 137 """Use Claude to review a single changed file.""" 138 139 if not pr_file.patch: 140 return None # Skip binary files or files with no diff 141 142 response = anthropic_client.messages.create( 143 model="claude-sonnet-4-6", 144 max_tokens=4096, 145 tools=[CODE_REVIEW_TOOL], 146 tool_choice={"type": "tool", "name": "create_code_review"}, 147 system="""You are a thorough and constructive code reviewer. Your goal is to help developers write better code. 148 149Focus on: 1501. SECURITY: SQL injection, XSS, authentication bypass, sensitive data exposure, insecure dependencies 1512. BUGS: Logic errors, off-by-one errors, null pointer dereferences, race conditions, incorrect error handling 1523. PERFORMANCE: N+1 queries, unnecessary loops, missing indexes, memory leaks, blocking operations in async code 1534. MAINTAINABILITY: Magic numbers, unclear naming, overly complex functions, missing error handling 1545. TESTING: Missing test coverage for new code paths, edge cases not covered 155 156Be specific and constructive. Every finding must have an actionable suggestion. 157Do not flag stylistic preferences as high severity. Reserve critical only for genuine security vulnerabilities. 158""", 159 messages=[ 160 { 161 "role": "user", 162 "content": f"""Review this code change in the pull request context. 163 164PR: {pr_context.get('title', 'Unknown')} 165PR Description: {pr_context.get('body', 'No description')[:500]} 166 167File: {pr_file.filename} 168Status: {pr_file.status} 169Changes: +{pr_file.additions} lines, -{pr_file.deletions} lines 170 171Diff: 172{pr_file.patch}""" 173 } 174 ] 175 ) 176 177 for block in response.content: 178 if block.type == "tool_use": 179 return {"filename": pr_file.filename, "review": block.input} 180 181 return None 182 183 184# ─── Report Formatting ──────────────────────────────────────────────────────── 185 186def format_pr_review(file_reviews: list[dict], pr_info: dict) -> str: 187 """Format all file reviews into a single PR comment.""" 188 189 all_findings = [] 190 for fr in file_reviews: 191 if fr and fr.get("review"): 192 for finding in fr["review"].get("findings", []): 193 finding["filename"] = fr["filename"] 194 all_findings.append(finding) 195 196 critical_count = sum(1 for f in all_findings if f["severity"] == "critical") 197 high_count = sum(1 for f in all_findings if f["severity"] == "high") 198 199 # Overall verdict 200 if critical_count > 0: 201 overall = "🔴 **REQUEST CHANGES** — Critical issues found" 202 elif high_count > 0: 203 overall = "🟡 **REQUEST CHANGES** — High severity issues found" 204 elif all_findings: 205 overall = "🟢 **APPROVE WITH COMMENTS** — Minor feedback only" 206 else: 207 overall = "✅ **APPROVED** — No significant issues found" 208 209 lines = [ 210 "## 🤖 AI Code Review", 211 "", 212 overall, 213 "", 214 f"**Files reviewed:** {len(file_reviews)} | **Total findings:** {len(all_findings)} | **Critical:** {critical_count} | **High:** {high_count}", 215 "", 216 "---", 217 "" 218 ] 219 220 # Per-file summaries 221 for fr in file_reviews: 222 if not fr or not fr.get("review"): 223 continue 224 225 review = fr["review"] 226 file_findings = review.get("findings", []) 227 228 lines.append(f"### `{fr['filename']}`") 229 lines.append(f"*{review['summary']}*") 230 231 if file_findings: 232 lines.append("") 233 for finding in file_findings: 234 emoji = {"critical": "🔴", "high": "🟡", "medium": "🟠", "low": "🔵", "info": "ℹ️"}.get(finding["severity"], "•") 235 lines.append(f"{emoji} **[{finding['severity'].upper()}][{finding['category']}]** {finding['description']}") 236 lines.append(f" > 💡 {finding['suggestion']}") 237 238 if review.get("positive_observations"): 239 for obs in review["positive_observations"]: 240 lines.append(f"✅ {obs}") 241 242 lines.append("") 243 244 lines.append("---") 245 lines.append("*This review was generated automatically. Human review is still recommended for architectural and business logic decisions.*") 246 247 return "\n".join(lines) 248 249 250# ─── Main Review Function ───────────────────────────────────────────────────── 251 252def review_pull_request( 253 owner: str, 254 repo: str, 255 pr_number: int, 256 post_comment: bool = False, 257 skip_extensions: list = None 258) -> str: 259 """ 260 Review a GitHub PR and optionally post the review as a comment. 261 262 Returns the formatted review as a string. 263 """ 264 skip_ext = skip_extensions or [".md", ".txt", ".json", ".lock", ".png", ".jpg", ".svg"] 265 266 print(f"Fetching PR #{pr_number} from {owner}/{repo}...") 267 pr_info = get_pr_info(owner, repo, pr_number) 268 pr_files = get_pr_files(owner, repo, pr_number) 269 270 # Filter to reviewable files 271 reviewable = [f for f in pr_files if not any(f.filename.endswith(ext) for ext in skip_ext)] 272 print(f"Reviewing {len(reviewable)} of {len(pr_files)} changed files...") 273 274 file_reviews = [] 275 for pr_file in reviewable: 276 print(f" Reviewing: {pr_file.filename}") 277 review = review_file(pr_file, pr_info) 278 if review: 279 file_reviews.append(review) 280 281 formatted_review = format_pr_review(file_reviews, pr_info) 282 283 if post_comment: 284 post_pr_comment(owner, repo, pr_number, formatted_review) 285 286 return formatted_review 287 288 289# ─── Example Usage ──────────────────────────────────────────────────────────── 290 291if __name__ == "__main__": 292 # Review a PR without posting (dry run) 293 review = review_pull_request( 294 owner="your-org", 295 repo="your-repo", 296 pr_number=42, 297 post_comment=False # Set to True to post to GitHub 298 ) 299 300 print(review)

Set post_comment=False During Testing

Always test your review with post_comment=False first. This prints the formatted review to your terminal without posting to GitHub, letting you verify the output quality before it becomes visible to your team. Only set post_comment=True in your production GitHub Actions workflow once you are confident in the review quality.


    Deploying as a GitHub Action

    Create .github/workflows/ai-review.yml to run automatically on every PR:

    yaml
    1name: AI Code Review 2on: 3 pull_request: 4 types: [opened, synchronize] 5 6jobs: 7 review: 8 runs-on: ubuntu-latest 9 steps: 10 - uses: actions/checkout@v3 11 - name: Set up Python 12 uses: actions/setup-python@v4 13 with: 14 python-version: "3.11" 15 - name: Install dependencies 16 run: pip install anthropic requests 17 - name: Run AI review 18 env: 19 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} 20 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 run: | 22 python review.py \ 23 --owner ${{ github.repository_owner }} \ 24 --repo ${{ github.event.repository.name }} \ 25 --pr ${{ github.event.pull_request.number }} \ 26 --post

    Summary

    This project demonstrates Claude's ability to reason about code structure and quality across multiple files simultaneously. The critical design elements:

    • Structured tool output with severity levels — makes it easy to filter critical vs cosmetic findings
    • Context from the PR title and description — Claude understands the intent of the change, not just the code
    • Skipping non-reviewable files (documentation, images, lock files) — keeps focus on actual code
    • Posting to GitHub — makes the review visible in the existing developer workflow without requiring a separate tool

    Next project: Project: Build a Multi-Language Translator App with Claude.


    This post is part of the Anthropic AI Tutorial Series. Previous post: Project: Build an Automated Meeting Notes Summariser.