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:
- Fetches the PR diff from GitHub using the GitHub REST API
- Analyses each changed file with Claude for bugs, security issues, performance problems, and style violations
- Produces a structured review with severity-labelled findings
- 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
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:
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 --postSummary
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.
