Skip to content

Commit af55fa1

Browse files
committed
feat: Claude-powered code review agent
- Add CLAUDE.md (engineering standards, replaces GEMINI.md) - Add tools/code_review_agent.py: fetches PR diff, reviews against CLAUDE.md via Claude API, posts review to GitHub PR - Add .github/workflows/code-review.yml: triggers on PRs to main, requires ANTHROPIC_API_KEY repo secret Closes #3
1 parent 2ad4ca3 commit af55fa1

3 files changed

Lines changed: 238 additions & 2 deletions

File tree

.github/workflows/code-review.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Code Review
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
types: [opened, synchronize, reopened]
7+
8+
jobs:
9+
review:
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: read
13+
pull-requests: write
14+
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- name: Set up Python
19+
uses: actions/setup-python@v5
20+
with:
21+
python-version: "3.9"
22+
23+
- name: Install dependencies
24+
run: pip install anthropic
25+
26+
- name: Run code review agent
27+
env:
28+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
29+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30+
PR_NUMBER: ${{ github.event.pull_request.number }}
31+
REPO: ${{ github.repository }}
32+
run: python tools/code_review_agent.py

GEMINI.md renamed to CLAUDE.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ This document serves as the foundational mandate for all development work perfor
1818

1919
## 3. Data & Privacy (Mandatory)
2020

21-
* **Anonymity**: Never hardcode personal data (locations, usernames, credentials) into the codebase.
21+
* **Anonymity**: Never hardcode personal data (locations, usernames, credentials) into the codebase.
2222
* **Externalize Assumptions**: Any personal identifying data or location assumptions must reside in external JSON files (e.g., `default_assumptions.json.example`) or environment variables.
2323
* **Credential Protection**: Use the `AUTOBIO_` environment variable prefix for all configuration. Never log or print API keys or secrets.
2424

@@ -40,4 +40,3 @@ This document serves as the foundational mandate for all development work perfor
4040
2. **Strategy**: Formulate a plan that prioritizes the least disruptive, most maintainable change.
4141
3. **Act**: Apply surgical edits. Use `replace` for targeted updates to large files.
4242
4. **Validate**: Run tests, check linting, and verify manual use cases.
43-

tools/code_review_agent.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Claude-powered code review agent for GitHub pull requests.
2+
3+
Reads CLAUDE.md as engineering standards context, fetches the PR diff via
4+
GitHub API, sends it to Claude for review, and posts the result as a GitHub
5+
PR review with inline comments where possible.
6+
7+
Required environment variables:
8+
ANTHROPIC_API_KEY: Anthropic API key.
9+
GITHUB_TOKEN: GitHub token with pull-requests: write permission.
10+
PR_NUMBER: Pull request number to review.
11+
REPO: Repository in owner/repo format.
12+
"""
13+
14+
import json
15+
import os
16+
import sys
17+
import urllib.request
18+
import urllib.error
19+
20+
21+
def _github_request(
22+
path: str,
23+
method: str = "GET",
24+
body: dict | None = None,
25+
token: str = "",
26+
) -> dict:
27+
"""Make a GitHub API request and return parsed JSON.
28+
29+
Args:
30+
path: API path, e.g. '/repos/owner/repo/pulls/1/files'.
31+
method: HTTP method.
32+
body: Optional JSON-serializable request body.
33+
token: GitHub token.
34+
35+
Returns:
36+
Parsed JSON response as a dict or list.
37+
"""
38+
url = f"https://api.github.com{path}"
39+
data = json.dumps(body).encode() if body else None
40+
req = urllib.request.Request(url, data=data, method=method)
41+
req.add_header("Authorization", f"Bearer {token}")
42+
req.add_header("Accept", "application/vnd.github+json")
43+
req.add_header("X-GitHub-Api-Version", "2022-11-28")
44+
req.add_header("Content-Type", "application/json")
45+
with urllib.request.urlopen(req) as resp:
46+
return json.loads(resp.read())
47+
48+
49+
def _fetch_pr_diff(repo: str, pr_number: str, token: str) -> str:
50+
"""Fetch the unified diff for a pull request.
51+
52+
Args:
53+
repo: Repository in owner/repo format.
54+
pr_number: Pull request number.
55+
token: GitHub token.
56+
57+
Returns:
58+
Unified diff as a single string.
59+
"""
60+
files = _github_request(f"/repos/{repo}/pulls/{pr_number}/files", token=token)
61+
chunks: list[str] = []
62+
for f in files:
63+
filename = f.get("filename", "")
64+
patch = f.get("patch", "")
65+
if patch:
66+
chunks.append(f"--- {filename}\n{patch}")
67+
return "\n\n".join(chunks)
68+
69+
70+
def _fetch_pr_commits(repo: str, pr_number: str, token: str) -> list[str]:
71+
"""Return commit SHAs for the PR (used for the review commit_id).
72+
73+
Args:
74+
repo: Repository in owner/repo format.
75+
pr_number: Pull request number.
76+
token: GitHub token.
77+
78+
Returns:
79+
List of commit SHAs, most recent last.
80+
"""
81+
commits = _github_request(
82+
f"/repos/{repo}/pulls/{pr_number}/commits", token=token
83+
)
84+
return [c["sha"] for c in commits]
85+
86+
87+
def _call_claude(standards: str, diff: str, api_key: str) -> str:
88+
"""Send the diff to Claude with engineering standards as context.
89+
90+
Args:
91+
standards: Contents of CLAUDE.md.
92+
diff: Unified diff of the PR.
93+
api_key: Anthropic API key.
94+
95+
Returns:
96+
Claude's review as a markdown string.
97+
"""
98+
import urllib.request
99+
100+
payload = {
101+
"model": "claude-sonnet-4-6",
102+
"max_tokens": 2048,
103+
"system": (
104+
"You are an expert code reviewer enforcing the engineering standards "
105+
"in the document below. Review the provided diff strictly against these "
106+
"standards. Be direct and specific. Reference file names and line numbers "
107+
"from the diff where relevant.\n\n"
108+
"Format your response as:\n"
109+
"## Summary\n"
110+
"<1-2 sentence overall assessment>\n\n"
111+
"## Issues\n"
112+
"<Blocking issues — violations of the standards. If none, write 'None.'>\n\n"
113+
"## Suggestions\n"
114+
"<Non-blocking improvements. If none, write 'None.'>\n\n"
115+
f"---\n\n# Engineering Standards\n\n{standards}"
116+
),
117+
"messages": [
118+
{
119+
"role": "user",
120+
"content": f"Please review this pull request diff:\n\n```diff\n{diff}\n```",
121+
}
122+
],
123+
}
124+
125+
data = json.dumps(payload).encode()
126+
req = urllib.request.Request(
127+
"https://api.anthropic.com/v1/messages", data=data, method="POST"
128+
)
129+
req.add_header("x-api-key", api_key)
130+
req.add_header("anthropic-version", "2023-06-01")
131+
req.add_header("content-type", "application/json")
132+
133+
with urllib.request.urlopen(req) as resp:
134+
result = json.loads(resp.read())
135+
136+
return result["content"][0]["text"]
137+
138+
139+
def _post_review(
140+
repo: str,
141+
pr_number: str,
142+
commit_sha: str,
143+
body: str,
144+
token: str,
145+
) -> None:
146+
"""Post a review comment on the pull request.
147+
148+
Args:
149+
repo: Repository in owner/repo format.
150+
pr_number: Pull request number.
151+
commit_sha: Latest commit SHA on the PR.
152+
body: Review body markdown text.
153+
token: GitHub token.
154+
"""
155+
_github_request(
156+
f"/repos/{repo}/pulls/{pr_number}/reviews",
157+
method="POST",
158+
body={"commit_id": commit_sha, "body": body, "event": "COMMENT"},
159+
token=token,
160+
)
161+
162+
163+
def main() -> None:
164+
"""Entry point: orchestrate fetch → review → post."""
165+
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
166+
github_token = os.environ.get("GITHUB_TOKEN", "")
167+
pr_number = os.environ.get("PR_NUMBER", "")
168+
repo = os.environ.get("REPO", "")
169+
170+
missing = [k for k, v in {
171+
"ANTHROPIC_API_KEY": api_key,
172+
"GITHUB_TOKEN": github_token,
173+
"PR_NUMBER": pr_number,
174+
"REPO": repo,
175+
}.items() if not v]
176+
if missing:
177+
print(f"ERROR: missing environment variables: {', '.join(missing)}", file=sys.stderr)
178+
sys.exit(1)
179+
180+
standards_path = os.path.join(os.path.dirname(__file__), "..", "CLAUDE.md")
181+
with open(standards_path) as f:
182+
standards = f.read()
183+
184+
print(f"Fetching diff for {repo}#{pr_number}...")
185+
diff = _fetch_pr_diff(repo, pr_number, github_token)
186+
if not diff.strip():
187+
print("No diff found — skipping review.")
188+
return
189+
190+
commits = _fetch_pr_commits(repo, pr_number, github_token)
191+
if not commits:
192+
print("No commits found — skipping review.")
193+
return
194+
head_sha = commits[-1]
195+
196+
print("Calling Claude for review...")
197+
review_body = _call_claude(standards, diff, api_key)
198+
199+
print("Posting review...")
200+
_post_review(repo, pr_number, head_sha, review_body, github_token)
201+
print("Done.")
202+
203+
204+
if __name__ == "__main__":
205+
main()

0 commit comments

Comments
 (0)