Build a Claude-Powered GitHub Actions PR Review Bot
Wire the Anthropic API into a GitHub Actions workflow that posts inline code review comments on every pull request, automatically.
What You'll Build
A GitHub Actions workflow that triggers on every opened or updated pull request, sends per-file diffs to the Claude API, and posts inline review comments directly on the Files changed tab. The bot surfaces bugs, security issues, and non-obvious mistakes without waiting for a human to triage first.
Prerequisites
- A GitHub repository with Actions enabled
- An Anthropic API key from console.anthropic.com
- Basic familiarity with GitHub Actions YAML and Python
The workflow runs on ubuntu-latest and installs its own dependencies, so no local setup is required.
Step 1: Store Your API Key
In your repo, go to Settings > Secrets and variables > Actions > New repository secret. Create one secret:
- Name:
ANTHROPIC_API_KEY - Value: your key from Anthropic's console
GITHUB_TOKEN is injected automatically by the Actions runtime. You don't create it.
Step 2: Add the Workflow File
Create .github/workflows/pr-review.yml:
name: Claude PR Review
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install anthropic requests
- name: Run Claude review
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: python .github/scripts/pr_review.py
The permissions block is mandatory. Without pull-requests: write, the GITHUB_TOKEN can't post review comments and you'll get a 403. The synchronize event type re-runs the bot whenever new commits are pushed to the branch, not just on PR open.
Step 3: Write the Review Script
Create .github/scripts/pr_review.py. This fetches per-file diffs from the GitHub API, sends each to Claude, and posts a single batched review:
#!/usr/bin/env python3
import json
import os
import re
import requests
from anthropic import Anthropic
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
REPO = os.environ["GITHUB_REPOSITORY"]
PR_NUMBER = os.environ["PR_NUMBER"]
API_BASE = "https://api.github.com"
MAX_PATCH_CHARS = 4000
client = Anthropic()
gh_headers = {
"Authorization": f"Bearer {GITHUB_TOKEN}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
def valid_diff_lines(patch: str) -> set[int]:
"""New-file line numbers that are commentable (added + context lines)."""
lines: set[int] = set()
current = 0
for line in patch.splitlines():
m = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
if m:
current = int(m.group(1))
continue
if line.startswith("+"):
lines.add(current)
current += 1
elif line.startswith(" "):
lines.add(current)
current += 1
# "-" lines belong to the old file; skip without incrementing
return lines
def review_patch(filename: str, patch: str) -> list[dict]:
valid = valid_diff_lines(patch)
if not valid:
return []
prompt = (
f"Review this unified diff for `{filename}`.\n\n"
"Reply with ONLY a JSON array. Each element:\n"
' "line": integer from the list of valid lines below\n'
' "body": your comment (Markdown OK)\n\n'
"Flag bugs, security issues, and non-obvious mistakes. "
"Skip pure style nitpicks. Return [] if nothing is worth flagging.\n\n"
f"Valid line numbers: {sorted(valid)[:40]}\n\n"
f"```diff\n{patch[:MAX_PATCH_CHARS]}\n```"
)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
text = response.content[0].text.strip()
# Strip markdown fences Claude sometimes wraps around JSON
text = re.sub(r"^```(?:json)?\n?", "", text)
text = re.sub(r"\n?```$", "", text)
try:
comments = json.loads(text)
except json.JSONDecodeError:
match = re.search(r"\[.*\]", text, re.DOTALL)
if not match:
return []
try:
comments = json.loads(match.group())
except json.JSONDecodeError:
return []
return [
c for c in comments
if isinstance(c.get("line"), int) and c["line"] in valid
]
def main() -> None:
resp = requests.get(
f"{API_BASE}/repos/{REPO}/pulls/{PR_NUMBER}/files",
headers=gh_headers,
)
resp.raise_for_status()
review_comments = []
for f in resp.json():
patch = f.get("patch")
if not patch:
continue # binary files or renames without content changes
for c in review_patch(f["filename"], patch):
review_comments.append({
"path": f["filename"],
"line": c["line"],
"side": "RIGHT",
"body": c["body"],
})
if not review_comments:
print("Claude found no issues to flag.")
return
r = requests.post(
f"{API_BASE}/repos/{REPO}/pulls/{PR_NUMBER}/reviews",
headers=gh_headers,
json={
"body": "Automated review by Claude. Suggestions only.",
"event": "COMMENT",
"comments": review_comments,
},
)
r.raise_for_status()
print(f"Posted {len(review_comments)} inline comment(s).")
if __name__ == "__main__":
main()
Two design decisions worth understanding. MAX_PATCH_CHARS = 4000 caps what gets sent per file so prompts stay well under token limits on large refactors. The valid_diff_lines parser ensures every line number Claude returns actually exists in the diff. If a single comment references a non-existent line, GitHub returns a 422 and the entire review payload is rejected, so this filter is not optional.
Verify It Works
Open a PR with a meaningful change (intentionally introducing a bug works well for a first test). The Claude PR Review check appears in the Checks tab within about 30 seconds. Once it finishes, go to Files changed: Claude's comments appear as standard review threads pinned to specific lines.
Expected output in the Actions log:
Posted 3 inline comment(s).
If Claude found nothing actionable:
Claude found no issues to flag.
No review is posted in that case. That's correct behavior, not a failure.
Troubleshooting
HTTP 403 when posting the review. The permissions block is missing, or the repo's default token permission is read-only. Check Settings > Actions > General > Workflow permissions and ensure the token has read and write permissions, or add the explicit permissions block shown in Step 2.
HTTP 422 Unprocessable Entity. A comment landed on a line GitHub considers outside the diff. Add print(f"{filename}: valid={sorted(valid)[:10]}") before calling review_patch to inspect what the parser produced. Renames-only and binary-adjacent files are common offenders.
No comments posted even when issues exist. Claude may have returned prose instead of JSON. Add print(f"Raw response: {text}") inside review_patch to see what came back, then adjust the prompt or extraction logic accordingly.
Action burns API credits on every trivial push. Add a path filter:
on:
pull_request:
types: [opened, synchronize]
paths-ignore:
- "**.md"
- ".github/**"
Next Steps
- Language-specific prompts. Map file extensions to custom instructions and inject your
CONTRIBUTING.mdor relevant linting rules into the prompt before sending. - Swap to a faster model.
claude-3-5-haiku-20241022is cheaper and faster per token. Use it as a first-pass filter on all files, then re-run Sonnet only on files it flagged. - Escalate to
REQUEST_CHANGES. Parse Claude's output for severity signals and flip"event"from"COMMENT"to"REQUEST_CHANGES"only on confirmed blockers. Without this distinction, reviewers get alert fatigue quickly. - Cache review results. Hash each file's patch and store results in a PR comment or Actions cache key. Skip re-reviewing files whose patch didn't change between pushes to the same branch.
Rachel has been embedded in the developer tooling ecosystem for nearly eight years, covering everything from IDE wars and package-manager drama to the quiet rise of AI-assisted coding. She has a soft spot for open-source maintainers and an unhealthy number of terminal emulators installed on a single laptop.
Discussion 0
No comments yet
Be the first to weigh in.