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