devtrust-apr 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apr/__init__.py +14 -0
- apr/__main__.py +6 -0
- apr/cli.py +150 -0
- apr/engine.py +114 -0
- apr/llm.py +188 -0
- apr/models.py +111 -0
- apr/output.py +93 -0
- apr/prompts.py +193 -0
- apr/repox_integration.py +176 -0
- apr/rules.py +401 -0
- apr/rules_ai.py +640 -0
- apr/rules_js.py +201 -0
- devtrust_apr-0.2.0.dist-info/METADATA +111 -0
- devtrust_apr-0.2.0.dist-info/RECORD +16 -0
- devtrust_apr-0.2.0.dist-info/WHEEL +4 -0
- devtrust_apr-0.2.0.dist-info/entry_points.txt +2 -0
apr/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Agent-PR Reviewer (`apr`) - the Wave 2 lead bet of the DevTrust platform.
|
|
2
|
+
|
|
3
|
+
Consolidates the patterns from three existing GitHub Apps into one
|
|
4
|
+
deterministic, fast, AI-pattern-aware PR reviewer:
|
|
5
|
+
|
|
6
|
+
- ai-quality-gate -> AI-likelihood + verbose-pattern detection
|
|
7
|
+
- pr-coach -> coaching feedback (description quality, TODOs)
|
|
8
|
+
- commit-craft -> commit-message review and normalization
|
|
9
|
+
|
|
10
|
+
v0.0.1 ships the deterministic rule layer; LLM-backed review is layered
|
|
11
|
+
on top in v0.1+ once the rule output is stable enough to grade against.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.0"
|
apr/__main__.py
ADDED
apr/cli.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Agent-PR Reviewer command-line interface.
|
|
2
|
+
|
|
3
|
+
apr version
|
|
4
|
+
apr review [--repo PATH] [--changed FILE ...] [--title S] [--description S]
|
|
5
|
+
[--diff PATH] [--enable-ai] [--ai-provider null|anthropic]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from apr import __version__
|
|
19
|
+
from apr.engine import review as review_engine
|
|
20
|
+
from apr.llm import LLMProvider, build_provider
|
|
21
|
+
from apr.output import write_json, write_markdown
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
name="apr",
|
|
25
|
+
help="Agent-PR Reviewer - deterministic + AI-pattern review for PRs.",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
add_completion=False,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command()
|
|
34
|
+
def version() -> None:
|
|
35
|
+
"""Print the installed Agent-PR Reviewer version."""
|
|
36
|
+
console.print(f"apr [bold]v{__version__}[/bold]")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command()
|
|
40
|
+
def review(
|
|
41
|
+
repo: Annotated[
|
|
42
|
+
Path,
|
|
43
|
+
typer.Option("--repo", "-r", help="Repo to review. Defaults to current directory."),
|
|
44
|
+
] = Path("."),
|
|
45
|
+
changed: Annotated[
|
|
46
|
+
list[str] | None,
|
|
47
|
+
typer.Option(
|
|
48
|
+
"--changed",
|
|
49
|
+
"-c",
|
|
50
|
+
help="Path(s) that changed. Repeat for multiple files.",
|
|
51
|
+
),
|
|
52
|
+
] = None,
|
|
53
|
+
title: Annotated[
|
|
54
|
+
str | None,
|
|
55
|
+
typer.Option("--title", "-t", help="The PR title (for metadata checks)."),
|
|
56
|
+
] = None,
|
|
57
|
+
description: Annotated[
|
|
58
|
+
str | None,
|
|
59
|
+
typer.Option("--description", "-d", help="The PR description / body."),
|
|
60
|
+
] = None,
|
|
61
|
+
diff_path: Annotated[
|
|
62
|
+
Path | None,
|
|
63
|
+
typer.Option(
|
|
64
|
+
"--diff",
|
|
65
|
+
help=(
|
|
66
|
+
"Path to a unified-diff file. Required for the "
|
|
67
|
+
"ai-review:diff-comprehension rule when --enable-ai."
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
] = None,
|
|
71
|
+
enable_ai: Annotated[
|
|
72
|
+
bool,
|
|
73
|
+
typer.Option(
|
|
74
|
+
"--enable-ai/--no-enable-ai",
|
|
75
|
+
help=(
|
|
76
|
+
"Run the ai-review:* rule pack. Off by default. "
|
|
77
|
+
"ai-review:hallucinated-symbol needs a "
|
|
78
|
+
".repox/architecture.json (run `repox build .` first)."
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
] = False,
|
|
82
|
+
ai_provider: Annotated[
|
|
83
|
+
str,
|
|
84
|
+
typer.Option(
|
|
85
|
+
"--ai-provider",
|
|
86
|
+
help="LLM backend: 'null' (default, no calls) or 'anthropic'.",
|
|
87
|
+
),
|
|
88
|
+
] = "null",
|
|
89
|
+
quiet: Annotated[
|
|
90
|
+
bool,
|
|
91
|
+
typer.Option("--quiet", "-q", help="Suppress non-essential output."),
|
|
92
|
+
] = False,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Run the review and emit `.apr/review.{json,md}`."""
|
|
95
|
+
if not repo.exists() or not repo.is_dir():
|
|
96
|
+
console.print(f"[red]Error:[/red] not a directory: {repo}")
|
|
97
|
+
raise typer.Exit(code=2)
|
|
98
|
+
|
|
99
|
+
repo = repo.resolve()
|
|
100
|
+
files = list(changed or [])
|
|
101
|
+
|
|
102
|
+
diff_text: str | None = None
|
|
103
|
+
if diff_path is not None:
|
|
104
|
+
try:
|
|
105
|
+
diff_text = diff_path.read_text(encoding="utf-8", errors="replace")
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
console.print(f"[red]Error:[/red] cannot read diff: {exc}")
|
|
108
|
+
raise typer.Exit(code=2) from exc
|
|
109
|
+
|
|
110
|
+
provider: LLMProvider | None = None
|
|
111
|
+
if enable_ai:
|
|
112
|
+
# Anthropic API key from the conventional env var.
|
|
113
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
114
|
+
provider = build_provider(ai_provider, api_key)
|
|
115
|
+
|
|
116
|
+
if not quiet:
|
|
117
|
+
console.print(f"[bold]Reviewing[/bold] {repo}")
|
|
118
|
+
console.print(f"[dim]Changed files:[/dim] {len(files)}")
|
|
119
|
+
if enable_ai:
|
|
120
|
+
console.print(f"[dim]AI rules:[/dim] enabled (provider: {ai_provider})")
|
|
121
|
+
|
|
122
|
+
report = review_engine(
|
|
123
|
+
repo,
|
|
124
|
+
files,
|
|
125
|
+
pr_title=title,
|
|
126
|
+
pr_description=description,
|
|
127
|
+
enable_ai=enable_ai,
|
|
128
|
+
llm_provider=provider,
|
|
129
|
+
diff=diff_text,
|
|
130
|
+
)
|
|
131
|
+
json_path = write_json(report, repo)
|
|
132
|
+
md_path = write_markdown(report, repo)
|
|
133
|
+
|
|
134
|
+
if quiet:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
s = report.stats
|
|
138
|
+
table = Table(title="\nReview summary", show_header=True, header_style="bold")
|
|
139
|
+
table.add_column("Severity", style="cyan")
|
|
140
|
+
table.add_column("Count", justify="right")
|
|
141
|
+
table.add_row("info", str(s.info))
|
|
142
|
+
table.add_row("warning", str(s.warning))
|
|
143
|
+
table.add_row("error", str(s.error))
|
|
144
|
+
table.add_row("critical", str(s.critical))
|
|
145
|
+
table.add_row("[bold]total[/bold]", f"[bold]{s.total}[/bold]")
|
|
146
|
+
console.print(table)
|
|
147
|
+
if s.blocking > 0:
|
|
148
|
+
console.print(f"[red]Blocking findings:[/red] {s.blocking} (error + critical)")
|
|
149
|
+
console.print(f"\n[green]✓[/green] wrote [bold]{json_path}[/bold]")
|
|
150
|
+
console.print(f"[green]✓[/green] wrote [bold]{md_path}[/bold]")
|
apr/engine.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Top-level review orchestration.
|
|
2
|
+
|
|
3
|
+
The engine takes a repo root, a list of changed files, optional PR
|
|
4
|
+
metadata (title + description), and optional AI configuration (provider
|
|
5
|
+
+ diff). It runs:
|
|
6
|
+
|
|
7
|
+
1. PR-level checks (title, description) -- once per review.
|
|
8
|
+
2. File-level checks for each changed file in a known language.
|
|
9
|
+
3. AI rule pack (apr.rules_ai), gated behind `enable_ai=True`.
|
|
10
|
+
|
|
11
|
+
Findings are deduplicated and stable-sorted (file, line, severity).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from apr import __version__
|
|
21
|
+
from apr.llm import LLMProvider, NullProvider
|
|
22
|
+
from apr.models import (
|
|
23
|
+
Finding,
|
|
24
|
+
ReviewInputs,
|
|
25
|
+
ReviewReport,
|
|
26
|
+
ReviewStats,
|
|
27
|
+
Severity,
|
|
28
|
+
)
|
|
29
|
+
from apr.repox_integration import load as load_repox_artifact
|
|
30
|
+
from apr.rules import check_file, check_pr_metadata
|
|
31
|
+
from apr.rules_ai import run_ai_rules
|
|
32
|
+
|
|
33
|
+
_SEVERITY_ORDER: dict[Severity, int] = {
|
|
34
|
+
"info": 0,
|
|
35
|
+
"warning": 1,
|
|
36
|
+
"error": 2,
|
|
37
|
+
"critical": 3,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _stable_key(f: Finding) -> tuple[str, int, int, str]:
|
|
42
|
+
"""Stable sort key: file, line, severity rank (asc), rule_id."""
|
|
43
|
+
return (
|
|
44
|
+
f.file or "",
|
|
45
|
+
f.line or 0,
|
|
46
|
+
_SEVERITY_ORDER[f.severity],
|
|
47
|
+
f.rule_id,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def review(
|
|
52
|
+
repo_root: Path,
|
|
53
|
+
changed_files: list[str],
|
|
54
|
+
pr_title: str | None = None,
|
|
55
|
+
pr_description: str | None = None,
|
|
56
|
+
*,
|
|
57
|
+
enable_ai: bool = False,
|
|
58
|
+
llm_provider: LLMProvider | None = None,
|
|
59
|
+
diff: str | None = None,
|
|
60
|
+
) -> ReviewReport:
|
|
61
|
+
"""Run all checks and produce a ReviewReport.
|
|
62
|
+
|
|
63
|
+
AI rules are off by default. Pass `enable_ai=True` AND optionally
|
|
64
|
+
a `llm_provider` (defaults to NullProvider) to enable
|
|
65
|
+
`ai-review:hallucinated-symbol` (deterministic, uses repox call
|
|
66
|
+
graph) and `ai-review:diff-comprehension` (delegates to the
|
|
67
|
+
provider).
|
|
68
|
+
"""
|
|
69
|
+
findings: list[Finding] = []
|
|
70
|
+
|
|
71
|
+
findings.extend(check_pr_metadata(pr_title, pr_description))
|
|
72
|
+
for rel in changed_files:
|
|
73
|
+
findings.extend(check_file(repo_root, rel))
|
|
74
|
+
|
|
75
|
+
if enable_ai:
|
|
76
|
+
artifact = load_repox_artifact(repo_root)
|
|
77
|
+
provider: LLMProvider = llm_provider or NullProvider()
|
|
78
|
+
findings.extend(
|
|
79
|
+
run_ai_rules(
|
|
80
|
+
repo_root,
|
|
81
|
+
changed_files,
|
|
82
|
+
artifact=artifact,
|
|
83
|
+
provider=provider,
|
|
84
|
+
diff=diff,
|
|
85
|
+
pr_title=pr_title,
|
|
86
|
+
pr_description=pr_description,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
findings.sort(key=_stable_key)
|
|
91
|
+
|
|
92
|
+
counts: Counter[Severity] = Counter()
|
|
93
|
+
for f in findings:
|
|
94
|
+
counts[f.severity] += 1
|
|
95
|
+
|
|
96
|
+
stats = ReviewStats(
|
|
97
|
+
info=counts["info"],
|
|
98
|
+
warning=counts["warning"],
|
|
99
|
+
error=counts["error"],
|
|
100
|
+
critical=counts["critical"],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return ReviewReport(
|
|
104
|
+
generated_at=datetime.now(UTC),
|
|
105
|
+
tool_version=__version__,
|
|
106
|
+
inputs=ReviewInputs(
|
|
107
|
+
repo_root=str(repo_root),
|
|
108
|
+
changed_files=sorted(changed_files),
|
|
109
|
+
pr_title=pr_title,
|
|
110
|
+
pr_description=pr_description,
|
|
111
|
+
),
|
|
112
|
+
stats=stats,
|
|
113
|
+
findings=findings,
|
|
114
|
+
)
|
apr/llm.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""LLM provider interface for AI-backed apr rules.
|
|
2
|
+
|
|
3
|
+
- `LLMProvider` Protocol every backend implements
|
|
4
|
+
- `NullProvider` returns no findings; used when AI is disabled
|
|
5
|
+
- `AnthropicProvider` Claude-backed provider (real, v0.1.1+)
|
|
6
|
+
|
|
7
|
+
The rule modules talk to this interface, never to a vendor SDK directly.
|
|
8
|
+
Swapping providers (Anthropic / OpenAI / Bedrock / a local model) is a
|
|
9
|
+
matter of writing a new `LLMProvider` implementation -- not touching the
|
|
10
|
+
rule code or the engine.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any, Protocol
|
|
18
|
+
|
|
19
|
+
from apr.models import Finding
|
|
20
|
+
from apr.prompts import (
|
|
21
|
+
DEFAULT_MAX_DIFF_CHARS,
|
|
22
|
+
DEFAULT_MAX_TOKENS,
|
|
23
|
+
SYSTEM_PROMPT,
|
|
24
|
+
build_prompt,
|
|
25
|
+
parse_response,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LLMProvider(Protocol):
|
|
32
|
+
"""Anything that can answer 'does this PR description match the diff'."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
|
|
36
|
+
def analyze_diff(
|
|
37
|
+
self,
|
|
38
|
+
diff: str,
|
|
39
|
+
pr_title: str | None,
|
|
40
|
+
pr_description: str | None,
|
|
41
|
+
) -> list[Finding]:
|
|
42
|
+
"""Return findings (possibly empty) about diff/description coherence.
|
|
43
|
+
|
|
44
|
+
Implementations MUST be idempotent and side-effect-free other than
|
|
45
|
+
outbound HTTP. They MUST handle their own timeouts and rate limits;
|
|
46
|
+
the engine treats any exception as 'no findings' and continues.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NullProvider:
|
|
52
|
+
"""No-op provider. Returned when AI is disabled or no key configured."""
|
|
53
|
+
|
|
54
|
+
name = "null"
|
|
55
|
+
|
|
56
|
+
def analyze_diff(
|
|
57
|
+
self,
|
|
58
|
+
diff: str,
|
|
59
|
+
pr_title: str | None,
|
|
60
|
+
pr_description: str | None,
|
|
61
|
+
) -> list[Finding]:
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AnthropicProvider:
|
|
66
|
+
"""Anthropic Claude as the LLM backend.
|
|
67
|
+
|
|
68
|
+
Uses the official `anthropic` Python SDK (Messages API, non-streaming).
|
|
69
|
+
The provider builds a JSON-shaped prompt via `apr.prompts.build_prompt`,
|
|
70
|
+
parses the reply via `apr.prompts.parse_response`, and returns
|
|
71
|
+
`Finding` rows that the engine re-namespaces under
|
|
72
|
+
`ai-review:diff-comprehension`.
|
|
73
|
+
|
|
74
|
+
Cost / safety:
|
|
75
|
+
- Diff is truncated to `max_diff_chars` (default 60,000) before
|
|
76
|
+
sending. A typical PR fits comfortably; runaway lockfile diffs
|
|
77
|
+
are bounded.
|
|
78
|
+
- `max_tokens` on the reply is capped (default 1024). Plenty for
|
|
79
|
+
a JSON list of findings; not enough for the model to spiral.
|
|
80
|
+
- On any SDK exception (auth, rate limit, network), we log and
|
|
81
|
+
return [] rather than letting the engine die.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
name = "anthropic"
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
api_key: str,
|
|
89
|
+
model: str = "claude-sonnet-4-6",
|
|
90
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
91
|
+
max_diff_chars: int = DEFAULT_MAX_DIFF_CHARS,
|
|
92
|
+
client: Any | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
try:
|
|
95
|
+
import anthropic # noqa: F401 -- intentional probe
|
|
96
|
+
except ImportError as exc:
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
"AnthropicProvider requires the `anthropic` package. "
|
|
99
|
+
"Install with `pip install apr[ai]` or "
|
|
100
|
+
"`uv add anthropic` in this workspace."
|
|
101
|
+
) from exc
|
|
102
|
+
self.api_key = api_key
|
|
103
|
+
self.model = model
|
|
104
|
+
self.max_tokens = max_tokens
|
|
105
|
+
self.max_diff_chars = max_diff_chars
|
|
106
|
+
# Tests inject `client`. Production builds a fresh client per provider.
|
|
107
|
+
self._client = client
|
|
108
|
+
|
|
109
|
+
def _ensure_client(self) -> Any:
|
|
110
|
+
if self._client is not None:
|
|
111
|
+
return self._client
|
|
112
|
+
from anthropic import Anthropic
|
|
113
|
+
|
|
114
|
+
self._client = Anthropic(api_key=self.api_key)
|
|
115
|
+
return self._client
|
|
116
|
+
|
|
117
|
+
def analyze_diff(
|
|
118
|
+
self,
|
|
119
|
+
diff: str,
|
|
120
|
+
pr_title: str | None,
|
|
121
|
+
pr_description: str | None,
|
|
122
|
+
) -> list[Finding]:
|
|
123
|
+
prompt = build_prompt(
|
|
124
|
+
diff=diff,
|
|
125
|
+
pr_title=pr_title,
|
|
126
|
+
pr_description=pr_description,
|
|
127
|
+
max_diff_chars=self.max_diff_chars,
|
|
128
|
+
)
|
|
129
|
+
try:
|
|
130
|
+
client = self._ensure_client()
|
|
131
|
+
resp = client.messages.create(
|
|
132
|
+
model=self.model,
|
|
133
|
+
max_tokens=self.max_tokens,
|
|
134
|
+
system=SYSTEM_PROMPT,
|
|
135
|
+
messages=[{"role": "user", "content": prompt}],
|
|
136
|
+
)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
logger.warning("AnthropicProvider request failed: %s", exc)
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
text = _extract_text(resp)
|
|
142
|
+
if text is None:
|
|
143
|
+
return []
|
|
144
|
+
return parse_response(text)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _extract_text(resp: Any) -> str | None:
|
|
148
|
+
"""Pull the text body out of an `anthropic.Message` response.
|
|
149
|
+
|
|
150
|
+
The SDK exposes `.content` as a list of content blocks; the simplest
|
|
151
|
+
case is one `text` block. We concatenate all text-shaped blocks
|
|
152
|
+
together for robustness against future models that prepend a
|
|
153
|
+
'thinking' segment or similar.
|
|
154
|
+
"""
|
|
155
|
+
content = getattr(resp, "content", None)
|
|
156
|
+
if content is None:
|
|
157
|
+
return None
|
|
158
|
+
pieces: list[str] = []
|
|
159
|
+
for block in content:
|
|
160
|
+
block_type = getattr(block, "type", None)
|
|
161
|
+
if block_type == "text":
|
|
162
|
+
text = getattr(block, "text", None)
|
|
163
|
+
if isinstance(text, str):
|
|
164
|
+
pieces.append(text)
|
|
165
|
+
if not pieces:
|
|
166
|
+
return None
|
|
167
|
+
return "\n".join(pieces)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def build_provider(name: str | None, api_key: str | None) -> LLMProvider:
|
|
171
|
+
"""Map a provider name to a concrete provider instance.
|
|
172
|
+
|
|
173
|
+
Returns NullProvider when the input doesn't name a real provider or
|
|
174
|
+
when credentials are missing -- the engine never raises because the
|
|
175
|
+
operator forgot to set an env var.
|
|
176
|
+
"""
|
|
177
|
+
if not name or name == "null":
|
|
178
|
+
return NullProvider()
|
|
179
|
+
if name == "anthropic":
|
|
180
|
+
# Allow a per-process model override via env var.
|
|
181
|
+
model = os.environ.get("APR_ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
|
182
|
+
if not api_key:
|
|
183
|
+
return NullProvider()
|
|
184
|
+
try:
|
|
185
|
+
return AnthropicProvider(api_key=api_key, model=model)
|
|
186
|
+
except RuntimeError:
|
|
187
|
+
return NullProvider()
|
|
188
|
+
return NullProvider()
|
apr/models.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Pydantic models for the Agent-PR Reviewer.
|
|
2
|
+
|
|
3
|
+
The schema in this file is the public API of `apr`. Downstream consumers
|
|
4
|
+
(the GitHub App that posts comments, dashboards, anyone reading
|
|
5
|
+
`.apr/review.json`) read this shape. Treat changes as breaking.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION = "0.0.1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Findings
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
Severity = Literal["info", "warning", "error", "critical"]
|
|
23
|
+
|
|
24
|
+
Category = Literal[
|
|
25
|
+
"quality", # general code-quality smells
|
|
26
|
+
"security", # potentially unsafe patterns
|
|
27
|
+
"style", # convention / readability nits
|
|
28
|
+
"ai-pattern", # signs of AI-generated boilerplate or hallucinations
|
|
29
|
+
"todo", # TODO / FIXME / XXX bookkeeping
|
|
30
|
+
"commit", # commit-message issues (when `apr` is fed commit data)
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Finding(BaseModel):
|
|
35
|
+
"""One reviewer finding tied to a file and (optionally) a line."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(frozen=True)
|
|
38
|
+
|
|
39
|
+
rule_id: str = Field(
|
|
40
|
+
...,
|
|
41
|
+
description=(
|
|
42
|
+
"Stable identifier for the rule that produced this finding "
|
|
43
|
+
"(e.g. 'bare-except', 'todo-no-ticket', 'pr-description-too-short')."
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
severity: Severity
|
|
47
|
+
category: Category
|
|
48
|
+
message: str
|
|
49
|
+
file: str | None = Field(
|
|
50
|
+
default=None,
|
|
51
|
+
description="Repo-relative POSIX path. None for repo-level findings.",
|
|
52
|
+
)
|
|
53
|
+
line: int | None = Field(default=None, ge=1)
|
|
54
|
+
suggestion: str | None = Field(
|
|
55
|
+
default=None,
|
|
56
|
+
description="Optional human-readable suggested fix.",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Review report
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ReviewInputs(BaseModel):
|
|
66
|
+
"""The inputs the reviewer saw -- captured for reproducibility."""
|
|
67
|
+
|
|
68
|
+
model_config = ConfigDict(frozen=True)
|
|
69
|
+
|
|
70
|
+
repo_root: str
|
|
71
|
+
changed_files: list[str] = Field(default_factory=list)
|
|
72
|
+
pr_title: str | None = None
|
|
73
|
+
pr_description: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ReviewStats(BaseModel):
|
|
77
|
+
"""Aggregate counts useful for terminal display + CI gates."""
|
|
78
|
+
|
|
79
|
+
model_config = ConfigDict(frozen=True)
|
|
80
|
+
|
|
81
|
+
info: int = Field(..., ge=0)
|
|
82
|
+
warning: int = Field(..., ge=0)
|
|
83
|
+
error: int = Field(..., ge=0)
|
|
84
|
+
critical: int = Field(..., ge=0)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def total(self) -> int:
|
|
88
|
+
return self.info + self.warning + self.error + self.critical
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def blocking(self) -> int:
|
|
92
|
+
"""Findings severe enough that a CI gate should stop the merge."""
|
|
93
|
+
return self.error + self.critical
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ReviewReport(BaseModel):
|
|
97
|
+
"""The full report emitted by `apr review`.
|
|
98
|
+
|
|
99
|
+
JSON layout: `.apr/review.json`
|
|
100
|
+
Human companion: `.apr/review.md`
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
model_config = ConfigDict(frozen=False)
|
|
104
|
+
|
|
105
|
+
schema_version: str = SCHEMA_VERSION
|
|
106
|
+
generated_at: datetime
|
|
107
|
+
tool_version: str
|
|
108
|
+
|
|
109
|
+
inputs: ReviewInputs
|
|
110
|
+
stats: ReviewStats
|
|
111
|
+
findings: list[Finding] = Field(default_factory=list)
|
apr/output.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Writers for the review report.
|
|
2
|
+
|
|
3
|
+
Two outputs:
|
|
4
|
+
- review.json - canonical, machine-readable artifact (versioned)
|
|
5
|
+
- review.md - human-readable companion derived from the JSON
|
|
6
|
+
|
|
7
|
+
Both land in `.apr/` at the analyzed repo root.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from apr.models import ReviewReport
|
|
15
|
+
|
|
16
|
+
OUTPUT_DIR_NAME = ".apr"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write_json(report: ReviewReport, root: Path) -> Path:
|
|
20
|
+
out_dir = root / OUTPUT_DIR_NAME
|
|
21
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
out_path = out_dir / "review.json"
|
|
23
|
+
out_path.write_text(
|
|
24
|
+
report.model_dump_json(indent=2, exclude_none=False),
|
|
25
|
+
encoding="utf-8",
|
|
26
|
+
)
|
|
27
|
+
return out_path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_markdown(report: ReviewReport, root: Path) -> Path:
|
|
31
|
+
out_dir = root / OUTPUT_DIR_NAME
|
|
32
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
out_path = out_dir / "review.md"
|
|
34
|
+
|
|
35
|
+
lines: list[str] = []
|
|
36
|
+
lines.append("# Code review")
|
|
37
|
+
lines.append("")
|
|
38
|
+
lines.append(
|
|
39
|
+
f"> Generated by **apr v{report.tool_version}** at "
|
|
40
|
+
f"{report.generated_at.isoformat(timespec='seconds')}"
|
|
41
|
+
)
|
|
42
|
+
lines.append(f"> Schema: `{report.schema_version}`")
|
|
43
|
+
lines.append("")
|
|
44
|
+
lines.append("---")
|
|
45
|
+
lines.append("")
|
|
46
|
+
|
|
47
|
+
s = report.stats
|
|
48
|
+
lines.append("## Summary")
|
|
49
|
+
lines.append("")
|
|
50
|
+
lines.append(
|
|
51
|
+
f"- **Total findings:** {s.total} "
|
|
52
|
+
f"(info: {s.info}, warning: {s.warning}, "
|
|
53
|
+
f"error: {s.error}, critical: {s.critical})"
|
|
54
|
+
)
|
|
55
|
+
lines.append(f"- **Blocking (error + critical):** {s.blocking}")
|
|
56
|
+
lines.append(f"- **Changed files reviewed:** {len(report.inputs.changed_files)}")
|
|
57
|
+
lines.append("")
|
|
58
|
+
|
|
59
|
+
if not report.findings:
|
|
60
|
+
lines.append("_No findings. Nice work._")
|
|
61
|
+
lines.append("")
|
|
62
|
+
else:
|
|
63
|
+
lines.append("## Findings")
|
|
64
|
+
lines.append("")
|
|
65
|
+
lines.append("| Severity | File | Line | Rule | Message |")
|
|
66
|
+
lines.append("|---|---|---:|---|---|")
|
|
67
|
+
for f in report.findings:
|
|
68
|
+
file_disp = f"`{f.file}`" if f.file else "_(repo-level)_"
|
|
69
|
+
line_disp = str(f.line) if f.line else ""
|
|
70
|
+
# Pipes inside messages would break the table - escape.
|
|
71
|
+
msg = f.message.replace("|", "\\|")
|
|
72
|
+
lines.append(f"| `{f.severity}` | {file_disp} | {line_disp} | `{f.rule_id}` | {msg} |")
|
|
73
|
+
lines.append("")
|
|
74
|
+
|
|
75
|
+
# Surface up to 10 suggestions in a separate block, since they're
|
|
76
|
+
# the most actionable thing for the author.
|
|
77
|
+
with_suggestions = [f for f in report.findings if f.suggestion]
|
|
78
|
+
if with_suggestions:
|
|
79
|
+
lines.append("### Suggested fixes")
|
|
80
|
+
lines.append("")
|
|
81
|
+
for f in with_suggestions[:10]:
|
|
82
|
+
where = (
|
|
83
|
+
f"`{f.file}:{f.line}`"
|
|
84
|
+
if f.file and f.line
|
|
85
|
+
else f"`{f.file}`"
|
|
86
|
+
if f.file
|
|
87
|
+
else "_(repo-level)_"
|
|
88
|
+
)
|
|
89
|
+
lines.append(f"- {where} ({f.rule_id}) — {f.suggestion}")
|
|
90
|
+
lines.append("")
|
|
91
|
+
|
|
92
|
+
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
93
|
+
return out_path
|