Build an AI Code Review Bot in Python (Runs Locally, Free Forever)
Cursor is $20/month. Kilo Code, Amp, Copilot — they all charge for the privilege of running your code through someone else's API. The functionality isn't magic: it's an LLM looking at a diff and giving feedback.
You can build the same thing yourself. It runs locally via Ollama, hooks into git as a pre-commit check, costs nothing per review, and you own every bit of it.
Here's how.
What We're Building
A Python script that:
- Gets the current git diff (staged changes before commit)
- Sends it to a local LLM via Ollama for review
- Outputs structured feedback: bugs, style issues, security concerns
- Optionally blocks commits if critical issues are found (pre-commit hook)
Can also be run standalone against any file or diff.
Prerequisites
- Python 3.10+
- Git repo to test against
- Ollama running with
qwen2.5:14b(setup guide)
pip install requests
# That's it — uses stdlib for everything else
Step 1: Get the Git Diff
import subprocess
import sys
from pathlib import Path
def get_staged_diff(repo_path: str = ".") -> str:
"""Get the staged diff ready for commit."""
result = subprocess.run(
["git", "diff", "--cached", "--unified=5"],
cwd=repo_path,
capture_output=True,
text=True
)
if result.returncode != 0:
raise RuntimeError(f"git diff failed: {result.stderr}")
return result.stdout
def get_file_diff(filepath: str) -> str:
"""Get unstaged diff for a specific file."""
result = subprocess.run(
["git", "diff", "--unified=5", filepath],
capture_output=True,
text=True
)
return result.stdout or open(filepath).read()
def get_branch_diff(base_branch: str = "main") -> str:
"""Get full diff between current branch and base."""
result = subprocess.run(
["git", "diff", f"{base_branch}...HEAD", "--unified=5"],
capture_output=True,
text=True
)
return result.stdout
def truncate_diff(diff: str, max_chars: int = 8000) -> str:
"""Truncate large diffs to fit context window."""
if len(diff) <= max_chars:
return diff
lines = diff.split('\n')
result = []
char_count = 0
for line in lines:
if char_count + len(line) > max_chars:
result.append(f"\n... [diff truncated at {max_chars} chars] ...")
break
result.append(line)
char_count += len(line)
return '\n'.join(result)
Step 2: The Review Prompt
The quality of the review lives in the prompt. Be specific about what you want:
REVIEW_PROMPT = """You are a senior software engineer performing a code review.
Analyze the following git diff and provide structured feedback.
Focus on:
1. **Bugs**: Logic errors, off-by-one errors, null/None handling, exception handling gaps
2. **Security**: Injection risks, hardcoded secrets, insecure defaults, input validation
3. **Performance**: Obvious inefficiencies, N+1 queries, unnecessary loops
4. **Style**: Naming, readability, overly complex logic that should be simplified
5. **Missing tests**: Critical paths with no test coverage
Format your response as:
## Summary
[1-2 sentence overall assessment]
## Issues Found
[If none, say "No significant issues found."]
### 🔴 Critical (must fix before merge)
- [issue]: [explanation + suggested fix]
### 🟡 Warnings (should fix)
- [issue]: [explanation]
### 🔵 Suggestions (nice to have)
- [issue]: [explanation]
## Verdict
APPROVE | REQUEST_CHANGES | NEEDS_DISCUSSION
Be direct. If the code is fine, say so. Don't invent issues.
---
DIFF TO REVIEW:
{diff}
"""
Step 3: Send to Ollama
import requests
import json
def review_with_ollama(
diff: str,
model: str = "qwen2.5:14b",
ollama_url: str = "http://localhost:11434"
) -> dict:
"""
Send diff to local Ollama model for code review.
Returns parsed review result.
"""
if not diff.strip():
return {"error": "Empty diff — nothing to review"}
prompt = REVIEW_PROMPT.format(diff=truncate_diff(diff))
response = requests.post(
f"{ollama_url}/api/generate",
json={
"model": model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.1, # Low temp = consistent, factual review
"num_predict": 1024,
}
},
timeout=120
)
response.raise_for_status()
review_text = response.json()["response"].strip()
# Parse verdict
verdict = "UNKNOWN"
for line in review_text.split('\n'):
if line.strip().startswith("APPROVE"):
verdict = "APPROVE"
elif line.strip().startswith("REQUEST_CHANGES"):
verdict = "REQUEST_CHANGES"
elif line.strip().startswith("NEEDS_DISCUSSION"):
verdict = "NEEDS_DISCUSSION"
return {
"review": review_text,
"verdict": verdict,
"model": model,
"diff_chars": len(diff)
}
Step 4: The CLI
import argparse
import os
def main():
parser = argparse.ArgumentParser(
description="AI code reviewer powered by local Ollama"
)
parser.add_argument(
"target",
nargs="?",
default="staged",
help="What to review: 'staged' (default), a file path, or a branch name"
)
parser.add_argument(
"--model", "-m",
default="qwen2.5:14b",
help="Ollama model to use (default: qwen2.5:14b)"
)
parser.add_argument(
"--block-on-critical",
action="store_true",
help="Exit with code 1 if critical issues found (for pre-commit hook)"
)
parser.add_argument(
"--json",
action="store_true",
help="Output results as JSON"
)
args = parser.parse_args()
# Get diff
print(f"[review] Getting diff for: {args.target}", file=sys.stderr)
try:
if args.target == "staged":
diff = get_staged_diff()
elif os.path.exists(args.target):
diff = get_file_diff(args.target)
else:
diff = get_branch_diff(args.target)
except Exception as e:
print(f"[error] Could not get diff: {e}", file=sys.stderr)
sys.exit(1)
if not diff.strip():
print("[review] No changes to review.", file=sys.stderr)
sys.exit(0)
print(f"[review] Sending {len(diff)} chars to {args.model}...", file=sys.stderr)
# Run review
result = review_with_ollama(diff, model=args.model)
if args.json:
print(json.dumps(result, indent=2))
else:
print("\n" + "="*60)
print(result["review"])
print("="*60)
print(f"\nVerdict: {result['verdict']}")
# Block commit if requested and critical issues found
if args.block_on_critical and result["verdict"] == "REQUEST_CHANGES":
print("\n[review] 🔴 Critical issues found — commit blocked.", file=sys.stderr)
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
Step 5: Install as a Git Pre-Commit Hook
# Save the script
cp code_review.py /usr/local/bin/ai-review
chmod +x /usr/local/bin/ai-review
# Install as pre-commit hook in any repo
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash
echo "[pre-commit] Running AI code review..."
python3 /usr/local/bin/ai-review staged --block-on-critical
EOF
chmod +x .git/hooks/pre-commit
Now every git commit triggers a review. If the model finds critical issues, the commit is blocked until you fix them or bypass with git commit --no-verify.
Example Output
============================================================
## Summary
Minor style issues and one potential null-reference bug. Core logic looks correct.
## Issues Found
### 🔴 Critical (must fix before merge)
- **Unhandled None in line 47**: `user.profile.name` will throw AttributeError if
`user.profile` is None. Add: `if user.profile and user.profile.name:`
### 🟡 Warnings (should fix)
- **Magic number on line 23**: `timeout=30` should be a named constant
`DEFAULT_TIMEOUT = 30` for clarity
### 🔵 Suggestions (nice to have)
- **Function `process_batch` is 47 lines**: Consider splitting into
`validate_batch()` + `execute_batch()` for testability
## Verdict
REQUEST_CHANGES
============================================================
Verdict: REQUEST_CHANGES
Performance
On CPU-only hardware (Intel tower, 32 GB RAM):
| Diff size | Review time | Notes |
|---|---|---|
| Small (< 50 lines) | 8–12s | Single function change |
| Medium (50–200 lines) | 15–25s | Feature branch diff |
| Large (200–500 lines) | 30–50s | Full PR diff |
| XL (500+ lines) | Truncated to 8K chars | Fits context window |
For pre-commit hooks, small/medium diffs are the common case — fast enough to not disrupt flow.
Making It Faster: Use the 7B Model
If 15 seconds is too slow for interactive commits:
ollama pull qwen2.5:7b
ai-review staged --model qwen2.5:7b # ~2x faster, still good quality
Or run full reviews on the 14B only for PR-level checks (ai-review main), and use 7B for pre-commit.
Extending It
A few natural additions:
- Language-specific prompts: add Python/JS/Rust-specific rules to the prompt
-
Config file:
.ai-review.yamlto set model, rules, blocked patterns - GitHub Action: run on PRs against the base branch automatically
- Slack/Discord webhook: post review summaries to a channel
For the GitHub Action version, the diff retrieval changes to:
# In CI, compare against the base branch
diff = get_branch_diff(os.environ.get("GITHUB_BASE_REF", "main"))
Everything else stays identical — same local model call, just pointed at the PR diff.
This is part of the local AI stack series:
Top comments (0)