DEV Community

SatStack
SatStack

Posted on

Build an AI Code Review Bot in Python (Runs Locally, Free Forever)

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:

  1. Gets the current git diff (staged changes before commit)
  2. Sends it to a local LLM via Ollama for review
  3. Outputs structured feedback: bugs, style issues, security concerns
  4. 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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}
"""
Enter fullscreen mode Exit fullscreen mode

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)
    }
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.yaml to 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"))
Enter fullscreen mode Exit fullscreen mode

Everything else stays identical — same local model call, just pointed at the PR diff.


This is part of the local AI stack series:

  1. Bitcoin CLI Tools + Lightning
  2. Local AI Coding Agent (Ollama)
  3. Lightning Network 2026: Data Analysis
  4. RAG System with Local LLM
  5. AI Agent Long-Term Memory (SQLite)

Top comments (0)