Init
This commit is contained in:
21
.github/copilot-instructions.md
vendored
Normal file
21
.github/copilot-instructions.md
vendored
Normal file
@@ -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.
|
||||||
79
.github/workflows/ai-review.yml
vendored
Normal file
79
.github/workflows/ai-review.yml
vendored
Normal file
@@ -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<details><summary>Full JSON</summary>\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</details>';
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: commentBody,
|
||||||
|
});
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
venv
|
||||||
6
.pre-commit-config.yaml
Normal file
6
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.6.8
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
- id: ruff-format
|
||||||
23
AGENTS.md
Normal file
23
AGENTS.md
Normal file
@@ -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.
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -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"]
|
||||||
25
Makefile
Normal file
25
Makefile
Normal file
@@ -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
|
||||||
27
README.md
Normal file
27
README.md
Normal file
@@ -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)
|
||||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama
|
||||||
|
ports:
|
||||||
|
- "11434:11434"
|
||||||
|
volumes:
|
||||||
|
- ollama:/root/.ollama
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ollama:
|
||||||
34
pyproject.toml
Normal file
34
pyproject.toml
Normal file
@@ -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"
|
||||||
4
src/ai_reviewer/__init__.py
Normal file
4
src/ai_reviewer/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""ai-reviewer package."""
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
|
__version__ = "0.1.0"
|
||||||
BIN
src/ai_reviewer/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
src/ai_reviewer/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
src/ai_reviewer/__pycache__/diff.cpython-311.pyc
Normal file
BIN
src/ai_reviewer/__pycache__/diff.cpython-311.pyc
Normal file
Binary file not shown.
BIN
src/ai_reviewer/__pycache__/prompt.cpython-311.pyc
Normal file
BIN
src/ai_reviewer/__pycache__/prompt.cpython-311.pyc
Normal file
Binary file not shown.
101
src/ai_reviewer/cli.py
Normal file
101
src/ai_reviewer/cli.py
Normal file
@@ -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)
|
||||||
156
src/ai_reviewer/diff.py
Normal file
156
src/ai_reviewer/diff.py
Normal file
@@ -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
|
||||||
24
src/ai_reviewer/ollama.py
Normal file
24
src/ai_reviewer/ollama.py
Normal file
@@ -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", ""))
|
||||||
37
src/ai_reviewer/prompt.py
Normal file
37
src/ai_reviewer/prompt.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
55
src/ai_reviewer/render.py
Normal file
55
src/ai_reviewer/render.py
Normal file
@@ -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"
|
||||||
BIN
tests/__pycache__/test_chunking.cpython-311-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_chunking.cpython-311-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_prompt.cpython-311-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_prompt.cpython-311-pytest-9.0.2.pyc
Normal file
Binary file not shown.
46
tests/test_chunking.py
Normal file
46
tests/test_chunking.py
Normal file
@@ -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)
|
||||||
10
tests/test_prompt.py
Normal file
10
tests/test_prompt.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user