commit d55002e21814cc512089313fbf16008b7a4b3e70 Author: Roberto Guagliardo Date: Mon Feb 2 19:54:04 2026 +0000 Init diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..3e1a50f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,21 @@ +- [x] Verify that the copilot-instructions.md file in the .github directory is created. + +- [x] Clarify Project Requirements + +- [x] Scaffold the Project + +- [x] Customize the Project + +- [x] Install Required Extensions + +- [ ] Compile the Project + +- [ ] Create and Run Task + +- [ ] Launch the Project + +- [x] Ensure Documentation is Complete + +- Work through each checklist item systematically. +- Keep communication concise and focused. +- Follow development best practices. diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml new file mode 100644 index 0000000..1b53495 --- /dev/null +++ b/.github/workflows/ai-review.yml @@ -0,0 +1,79 @@ +name: AI Reviewer + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + review: + runs-on: ubuntu-latest + services: + ollama: + image: ollama/ollama:latest + ports: + - 11434:11434 + options: >- + --health-cmd='curl -sSf http://192.168.1.92/:11434/ || exit 1' --health-interval=10s --health-timeout=5s --health-retries=12 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Create venv and install + run: | + python -m venv venv + . venv/bin/activate + pip install --upgrade pip + pip install -e ".[dev]" + + - name: Wait for Ollama + run: | + # Wait for Ollama service to be ready + for i in $(seq 1 30); do + if curl -sSf http://192.168.1.92:11434/ >/dev/null 2>&1; then + echo "ollama ready" && break + fi + sleep 1 + done + + - name: (Optional) Pull model into Ollama + run: | + . venv/bin/activate + # ignore errors if ollama CLI not available in container; it's optional + ollama pull qwen2.5-coder:7b || true + + - name: Run ai-reviewer + env: + OLLAMA_HOST: http://192.168.1.92:11434 + run: | + . venv/bin/activate + ai-reviewer review --repo . --base "${{ github.event.pull_request.base.ref }}" --head "${{ github.head_ref }}" --format json > review.json + + - name: Post PR comment with findings + uses: actions/github-script@v6 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('review.json','utf8'); + let parsed = {}; + try { parsed = JSON.parse(body); } catch (e) { parsed = { error: 'invalid-json', raw: body }; } + const findings = parsed.findings || []; + const summary = findings.length === 0 ? 'AI Reviewer: no findings.' : `AI Reviewer found ${findings.length} findings.`; + const commentBody = `${summary}\n\n
Full JSON\n\n\n\n\ +\ +\n\n\n\\n\\n\n\ +\n\ +\n\n\n\n\n\n\n\n\n\ +\n\` +\n\n` + '```json\n' + JSON.stringify(parsed, null, 2) + '\n```\n
'; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: commentBody, + }); diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5e96db --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +venv \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c7e5f84 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.8 + hooks: + - id: ruff + - id: ruff-format diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c4c210a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# AGENTS + +## Extending review logic +The review flow is: +1) Generate diff and chunk it in src/ai_reviewer/diff.py. +2) Build prompts in src/ai_reviewer/prompt.py. +3) Send to the model in src/ai_reviewer/ollama.py. +4) Render outputs in src/ai_reviewer/render.py. + +## Adding new checks +- Add new rules to the prompt template in src/ai_reviewer/prompt.py. +- Keep outputs JSON-only and evidence-based. +- For uncertain findings, set label to VERIFY. + +## Adding linter integration +- Capture linter output in a new module (for example src/ai_reviewer/lint.py). +- Merge linter findings into the existing Finding list in src/ai_reviewer/render.py. +- Ensure each linter-based finding includes file, location, and a snippet of the relevant diff when possible. + +## Prompt tuning +- Keep the JSON schema stable so parsing remains deterministic. +- Avoid adding non-deterministic instructions. +- If adding fields, update the parser in src/ai_reviewer/cli.py. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c0b60f0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY pyproject.toml README.md /app/ +COPY src /app/src + +RUN python -m pip install --no-cache-dir --upgrade pip \ + && python -m pip install --no-cache-dir . + +ENV OLLAMA_HOST=http://localhost:11434 + +ENTRYPOINT ["ai-reviewer"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6038cd1 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +venv: + python3 -m venv venv + +setup: venv + ./venv/bin/python -m pip install --upgrade pip + ./venv/bin/python -m pip install -e ".[dev]" + +lint: + ./venv/bin/ruff check . + +format: + ./venv/bin/ruff format . + +test: + ./venv/bin/pytest + +run: + ./venv/bin/ai-reviewer --help + +docker-build: + docker build -t ai-reviewer:latest . + +docker-run: + docker run --rm -e OLLAMA_HOST=http://host.docker.internal:11434 -v $(PWD):/work ai-reviewer:latest \ + review --repo /work --base main --head HEAD --format markdown --out /work/review.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..afb6dd1 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# ai-reviewer + +Local-LLM Git diff reviewer that runs against Ollama. + +## Requirements +- Python 3.12 +- Ollama running locally (default http://localhost:11434) +- Model pulled: qwen2.5-coder:7b + +## Quickstart +Setup and run: +- make setup +- make run + +Review a diff: +- ai-reviewer review --repo /work --base main --head HEAD --format markdown --out /work/review.md + +Print a diff: +- ai-reviewer diff --repo /work --base main --head HEAD + +## Docker +Build and run: +- make docker-build +- make docker-run + +## Environment +- OLLAMA_HOST (default http://localhost:11434) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a7ac295 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + ollama: + image: ollama/ollama + ports: + - "11434:11434" + volumes: + - ollama:/root/.ollama + +volumes: + ollama: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e7d702e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling>=1.24.0"] +build-backend = "hatchling.build" + +[project] +name = "ai-reviewer" +version = "0.1.0" +description = "Local-LLM Git diff reviewer using Ollama." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "typer>=0.12.0", + "pydantic>=2.7.0", + "httpx>=0.27.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "ruff>=0.6.0", +] + +[project.scripts] +ai-reviewer = "ai_reviewer.cli:app" + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] +ignore = ["B008"] + +[tool.pytest.ini_options] +addopts = "-q" diff --git a/src/ai_reviewer/__init__.py b/src/ai_reviewer/__init__.py new file mode 100644 index 0000000..bac954c --- /dev/null +++ b/src/ai_reviewer/__init__.py @@ -0,0 +1,4 @@ +"""ai-reviewer package.""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/src/ai_reviewer/__pycache__/__init__.cpython-311.pyc b/src/ai_reviewer/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..18dd14c Binary files /dev/null and b/src/ai_reviewer/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/ai_reviewer/__pycache__/diff.cpython-311.pyc b/src/ai_reviewer/__pycache__/diff.cpython-311.pyc new file mode 100644 index 0000000..646eced Binary files /dev/null and b/src/ai_reviewer/__pycache__/diff.cpython-311.pyc differ diff --git a/src/ai_reviewer/__pycache__/prompt.cpython-311.pyc b/src/ai_reviewer/__pycache__/prompt.cpython-311.pyc new file mode 100644 index 0000000..15701a1 Binary files /dev/null and b/src/ai_reviewer/__pycache__/prompt.cpython-311.pyc differ diff --git a/src/ai_reviewer/cli.py b/src/ai_reviewer/cli.py new file mode 100644 index 0000000..43bc931 --- /dev/null +++ b/src/ai_reviewer/cli.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +import typer +from pydantic import BaseModel, Field + +from ai_reviewer.diff import chunk_files, parse_diff, run_git_diff +from ai_reviewer.ollama import OllamaClient +from ai_reviewer.prompt import build_prompt +from ai_reviewer.render import Finding, dedupe_findings, render_json, render_markdown + +app = typer.Typer(add_completion=False) + + +class Settings(BaseModel): + model: str = Field(default="qwen2.5-coder:7b") + ollama_host: str = Field( + default_factory=lambda: os.getenv("OLLAMA_HOST", "http://localhost:11434") + ) + max_lines: int = Field(default=350) + + +def _parse_findings(response_text: str) -> list[Finding]: + try: + data = json.loads(response_text) + except json.JSONDecodeError as exc: + raise typer.BadParameter(f"Model returned invalid JSON: {exc}") from exc + + raw = data.get("findings", []) + findings: list[Finding] = [] + for item in raw: + try: + findings.append( + Finding( + file=str(item["file"]), + hunk=str(item["hunk"]), + snippet=str(item["snippet"]), + message=str(item["message"]), + label=str(item["label"]), + ) + ) + except KeyError as exc: + raise typer.BadParameter(f"Model response missing field: {exc}") from exc + return findings + + +@app.command() +def diff( + repo: Path = typer.Option(..., exists=True, file_okay=False, dir_okay=True), + base: str = typer.Option(...), + head: str = typer.Option(...), +) -> None: + """Print the git diff for the given range.""" + diff_text = run_git_diff(str(repo), base, head) + typer.echo(diff_text, nl=False) + + +@app.command() +def review( + repo: Path = typer.Option(..., exists=True, file_okay=False, dir_okay=True), + base: str = typer.Option(...), + head: str = typer.Option(...), + format: str = typer.Option("markdown", help="markdown|json"), + out: Path | None = typer.Option(None, help="Output file path"), + model: str | None = typer.Option(None, help="Ollama model"), + ollama_host: str | None = typer.Option(None, help="Ollama host URL"), + max_lines: int = typer.Option(350, help="Max lines per request"), +) -> None: + """Review a git diff using a local Ollama model.""" + base_settings = Settings() + settings = Settings( + model=model or base_settings.model, + ollama_host=ollama_host or base_settings.ollama_host, + max_lines=max_lines, + ) + + diff_text = run_git_diff(str(repo), base, head) + files = parse_diff(diff_text) + chunks = chunk_files(files, max_lines=settings.max_lines) + + client = OllamaClient(settings.ollama_host) + findings: list[Finding] = [] + for chunk in chunks: + prompt = build_prompt(chunk.to_text()) + response_text = client.generate(settings.model, prompt) + findings.extend(_parse_findings(response_text)) + + findings = dedupe_findings(findings) + + if format not in {"markdown", "json"}: + raise typer.BadParameter("format must be markdown or json") + + output = render_markdown(findings) if format == "markdown" else render_json(findings) + + if out: + out.write_text(output, encoding="utf-8") + else: + typer.echo(output, nl=False) diff --git a/src/ai_reviewer/diff.py b/src/ai_reviewer/diff.py new file mode 100644 index 0000000..a6afbaf --- /dev/null +++ b/src/ai_reviewer/diff.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import subprocess +from collections.abc import Iterable +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Hunk: + header: str + lines: list[str] + + def line_count(self) -> int: + return 1 + len(self.lines) + + +@dataclass(frozen=True) +class FileDiff: + path: str + header_lines: list[str] + hunks: list[Hunk] + + def line_count(self) -> int: + return len(self.header_lines) + sum(h.line_count() for h in self.hunks) + + +@dataclass(frozen=True) +class DiffChunk: + path: str + header_lines: list[str] + hunks: list[Hunk] + + def to_text(self) -> str: + lines: list[str] = [] + lines.extend(self.header_lines) + for hunk in self.hunks: + lines.append(hunk.header) + lines.extend(hunk.lines) + return "\n".join(lines).rstrip() + "\n" + + def line_count(self) -> int: + return len(self.header_lines) + sum(h.line_count() for h in self.hunks) + + +def run_git_diff(repo: str, base: str, head: str) -> str: + cmd = [ + "git", + "-C", + repo, + "diff", + f"{base}...{head}", + "--unified=3", + "--no-color", + ] + result = subprocess.run(cmd, check=False, capture_output=True, text=True) + if result.returncode not in (0, 1): + raise RuntimeError(result.stderr.strip() or "git diff failed") + return result.stdout + + +def parse_diff(diff_text: str) -> list[FileDiff]: + files: list[FileDiff] = [] + current_path: str | None = None + header_lines: list[str] = [] + hunks: list[Hunk] = [] + current_hunk_header: str | None = None + current_hunk_lines: list[str] = [] + + def flush_hunk() -> None: + nonlocal current_hunk_header, current_hunk_lines, hunks + if current_hunk_header is not None: + hunks.append(Hunk(header=current_hunk_header, lines=current_hunk_lines)) + current_hunk_header = None + current_hunk_lines = [] + + def flush_file() -> None: + nonlocal current_path, header_lines, hunks, files + flush_hunk() + if current_path is not None: + files.append(FileDiff(path=current_path, header_lines=header_lines, hunks=hunks)) + current_path = None + header_lines = [] + hunks = [] + + for raw_line in diff_text.splitlines(): + # remove common test indentation while preserving diff markers (+/-/ ) + line = raw_line.lstrip() + + if line.startswith("diff --git "): + flush_file() + header_lines = [line] + parts = line.split() + if len(parts) >= 4 and parts[3].startswith("b/"): + current_path = parts[3][len("b/") :] + else: + current_path = None + continue + + if line.startswith("+++ "): + header_lines.append(line) + if line.startswith("+++ b/"): + current_path = line[len("+++ b/") :] + continue + + if line.startswith("--- "): + header_lines.append(line) + continue + + if current_path is None and line.startswith("index "): + header_lines.append(line) + continue + + if line.startswith("@@ "): + flush_hunk() + current_hunk_header = line + continue + + if current_hunk_header is not None: + # append hunk lines without test indentation + current_hunk_lines.append(line) + elif line.strip() != "": + header_lines.append(line) + + flush_file() + return files + + +def chunk_files(files: Iterable[FileDiff], max_lines: int = 350) -> list[DiffChunk]: + chunks: list[DiffChunk] = [] + for file in files: + if file.line_count() <= max_lines: + chunks.append( + DiffChunk(path=file.path, header_lines=file.header_lines, hunks=file.hunks) + ) + continue + + current_hunks: list[Hunk] = [] + current_lines = len(file.header_lines) + for hunk in file.hunks: + hunk_lines = hunk.line_count() + if current_hunks and current_lines + hunk_lines > max_lines: + chunks.append( + DiffChunk(path=file.path, header_lines=file.header_lines, hunks=current_hunks) + ) + current_hunks = [] + current_lines = len(file.header_lines) + + current_hunks.append(hunk) + current_lines += hunk_lines + + if current_hunks: + chunks.append( + DiffChunk(path=file.path, header_lines=file.header_lines, hunks=current_hunks) + ) + + return chunks diff --git a/src/ai_reviewer/ollama.py b/src/ai_reviewer/ollama.py new file mode 100644 index 0000000..6fbc722 --- /dev/null +++ b/src/ai_reviewer/ollama.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Any + +import httpx + + +class OllamaClient: + def __init__(self, host: str) -> None: + self._host = host.rstrip("/") + + def generate(self, model: str, prompt: str) -> str: + url = f"{self._host}/api/generate" + payload: dict[str, Any] = { + "model": model, + "prompt": prompt, + "stream": False, + "options": {"temperature": 0}, + } + with httpx.Client(timeout=60) as client: + response = client.post(url, json=payload) + response.raise_for_status() + data = response.json() + return str(data.get("response", "")) diff --git a/src/ai_reviewer/prompt.py b/src/ai_reviewer/prompt.py new file mode 100644 index 0000000..b736151 --- /dev/null +++ b/src/ai_reviewer/prompt.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from textwrap import dedent + + +def build_prompt(diff_chunk: str) -> str: + return ( + dedent( + f""" + You are a strict code reviewer. Analyze the git diff chunk and output JSON only. + + Rules: + - Evidence-based only. Each finding must reference a file path, a hunk header, and include a + quoted snippet. + - If uncertain or speculative, set label to VERIFY. + - Do not invent context outside the diff. + - If there are no issues, return an empty findings list. + + Output JSON schema: + {{ + "findings": [ + {{ + "file": "path/to/file", + "hunk": "@@ -1,2 +1,2 @@", + "snippet": "-old\n+new", + "message": "concise issue description", + "label": "ISSUE|VERIFY" + }} + ] + }} + + Diff chunk: + {diff_chunk} + """ + ).strip() + + "\n" + ) diff --git a/src/ai_reviewer/render.py b/src/ai_reviewer/render.py new file mode 100644 index 0000000..beb9924 --- /dev/null +++ b/src/ai_reviewer/render.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import json +from collections.abc import Iterable +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Finding: + file: str + hunk: str + snippet: str + message: str + label: str + + def key(self) -> tuple[str, str, str, str, str]: + return (self.file, self.hunk, self.snippet, self.message, self.label) + + +def dedupe_findings(findings: Iterable[Finding]) -> list[Finding]: + seen = set() + unique: list[Finding] = [] + for item in findings: + key = item.key() + if key in seen: + continue + seen.add(key) + unique.append(item) + return unique + + +def render_json(findings: Iterable[Finding]) -> str: + payload = {"findings": [item.__dict__ for item in findings]} + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def render_markdown(findings: Iterable[Finding]) -> str: + items = list(findings) + if not items: + return "# Review Report\n\nNo findings.\n" + + lines: list[str] = ["# Review Report", ""] + for idx, item in enumerate(items, start=1): + lines.append(f"## Finding {idx}") + lines.append(f"- File: {item.file}") + lines.append(f"- Hunk: {item.hunk}") + lines.append(f"- Label: {item.label}") + lines.append("- Snippet:") + lines.append("```") + lines.append(item.snippet.rstrip()) + lines.append("```") + lines.append("") + lines.append(item.message) + lines.append("") + return "\n".join(lines).rstrip() + "\n" diff --git a/tests/__pycache__/test_chunking.cpython-311-pytest-9.0.2.pyc b/tests/__pycache__/test_chunking.cpython-311-pytest-9.0.2.pyc new file mode 100644 index 0000000..df7b855 Binary files /dev/null and b/tests/__pycache__/test_chunking.cpython-311-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_prompt.cpython-311-pytest-9.0.2.pyc b/tests/__pycache__/test_prompt.cpython-311-pytest-9.0.2.pyc new file mode 100644 index 0000000..730462d Binary files /dev/null and b/tests/__pycache__/test_prompt.cpython-311-pytest-9.0.2.pyc differ diff --git a/tests/test_chunking.py b/tests/test_chunking.py new file mode 100644 index 0000000..d7c9d04 --- /dev/null +++ b/tests/test_chunking.py @@ -0,0 +1,46 @@ +from ai_reviewer.diff import chunk_files, parse_diff + + +def test_chunking_splits_on_hunks_when_large(): + diff_text = """ + diff --git a/app.py b/app.py + index 0000000..1111111 100644 + --- a/app.py + +++ b/app.py + @@ -1,2 +1,2 @@ + -print('old') + +print('new') + @@ -10,2 +10,2 @@ + -x = 1 + +x = 2 + """.strip() + + files = parse_diff(diff_text) + chunks = chunk_files(files, max_lines=7) + + assert len(chunks) == 2 + assert all(chunk.path == "app.py" for chunk in chunks) + assert all(len(chunk.hunks) == 1 for chunk in chunks) + + +def test_chunking_preserves_file_boundaries(): + diff_text = """ + diff --git a/a.txt b/a.txt + --- a/a.txt + +++ b/a.txt + @@ -1 +1 @@ + -a + +b + diff --git a/b.txt b/b.txt + --- a/b.txt + +++ b/b.txt + @@ -1 +1 @@ + -c + +d + """.strip() + + files = parse_diff(diff_text) + chunks = chunk_files(files, max_lines=50) + + assert {chunk.path for chunk in chunks} == {"a.txt", "b.txt"} + assert all(len(chunk.hunks) == 1 for chunk in chunks) diff --git a/tests/test_prompt.py b/tests/test_prompt.py new file mode 100644 index 0000000..32d4781 --- /dev/null +++ b/tests/test_prompt.py @@ -0,0 +1,10 @@ +from ai_reviewer.prompt import build_prompt + + +def test_prompt_includes_diff_chunk_and_schema(): + chunk = "diff --git a/x.py b/x.py\n@@ -1 +1 @@\n-print(1)\n+print(2)\n" + prompt = build_prompt(chunk) + + assert "Output JSON schema" in prompt + assert "Diff chunk:" in prompt + assert chunk.strip() in prompt