A web app requires a server, a database, a job queue, and auth — none of which
add value to a code review tool. A CLI is stateless: you run it, it does the
work, you read the output. It is also easier to integrate into a local workflow
(pre-push hook, CI pipeline) than a hosted service.
The tradeoff: no persistent review history, no multi-user dashboard. For a portfolio project demonstrating LLM integration and clean Python design, the CLI is the right scope.
Tree-sitter or ast would give us precise symbol names, call graphs, and type
information. It would also add:
- Language-specific parsers for every file type in the PR
- ~300 lines of visitor code to extract the "interesting" nodes
- Fragility when the PR contains syntax errors (AST parse fails)
Claude already understands Python syntax from pre-training. Sending raw diff
text and letting the LLM reason about it is simpler and generalises to every
language without extra code. The only thing AST would meaningfully add is
precise line numbers — and the unified diff already gives us those via the
@@ hunk headers.
Decision: send raw unified diff per file; ask Claude to cite line numbers from the diff hunk context.
A GitHub PR can contain hundreds of files. Sending the entire diff in one prompt risks:
- Exceeding Claude's context window (200k tokens, but long prompts are slow and expensive)
- Diluting attention — Claude gives shallower comments on a 50-file diff than on 5 files
Strategy: one API call per file. reviewer.py iterates over files returned
by github_client.py and calls Claude once per file diff. Results are collected
and passed to formatter.py.
Tradeoff: N API calls instead of 1. For a typical PR (5–20 files) this is fast enough. A future optimisation is to pack small files into a single prompt until a token budget is hit.
The prompt has two parts:
System prompt — sets Claude's persona and output contract:
- Role: "You are an expert Python code reviewer."
- Output format: JSON with a fixed schema (issues list + summary)
- Constraints: cite file name and line number; severity must be HIGH/MEDIUM/LOW
User prompt — provides the diff for one file:
- File name and language
- The unified diff text
- PR title and description for context
The JSON contract is critical. Without it, Claude returns free-text prose that
is hard to parse reliably. Asking for JSON + validating with Pydantic
(validator.py) catches cases where Claude invents new fields or omits required
ones.
{
"summary": "One paragraph overall assessment",
"issues": [
{
"severity": "HIGH | MEDIUM | LOW",
"file": "path/to/file.py",
"line": 42,
"title": "Short description",
"explanation": "Why this is a problem",
"suggestion": "How to fix it"
}
]
}Claude occasionally:
- Returns valid JSON wrapped in a markdown code fence (
json ...) - Omits optional fields
- Uses a severity string not in the enum (
"CRITICAL"instead of"HIGH")
validator.py should:
- Strip markdown fences before parsing
- Use Pydantic
model_validate()withstrict=Falseto coerce types - Map unknown severity values to
"MEDIUM"as a safe fallback - Raise a typed
ValidationError(not a rawjson.JSONDecodeError) soreviewer.pycan log a warning and continue rather than crashing
- Network errors (GitHub API, Claude API): surface a clean message with the HTTP status code; do not print a stack trace to the user
- Validation errors: log a warning and continue (skip the file rather than abort the whole review)
- Auth errors (missing token): fail fast with an actionable message pointing to the env var name