prrev 0.1.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.
- prrev/__init__.py +1 -0
- prrev/cli.py +130 -0
- prrev/config.py +89 -0
- prrev/formatter.py +72 -0
- prrev/git.py +56 -0
- prrev/github.py +95 -0
- prrev/llm/__init__.py +0 -0
- prrev/llm/anthropic.py +107 -0
- prrev/llm/base.py +30 -0
- prrev/llm/openai.py +107 -0
- prrev/reviewer.py +102 -0
- prrev-0.1.0.dist-info/METADATA +126 -0
- prrev-0.1.0.dist-info/RECORD +15 -0
- prrev-0.1.0.dist-info/WHEEL +4 -0
- prrev-0.1.0.dist-info/entry_points.txt +2 -0
prrev/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
prrev/cli.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# single command cli, uses callback bc theres no subcommands
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from prrev.config import load_config
|
|
10
|
+
from prrev.formatter import print_review, to_markdown
|
|
11
|
+
from prrev.git import get_diff
|
|
12
|
+
from prrev.github import fetch_pr, parse_pr_url, post_review
|
|
13
|
+
from prrev.llm.anthropic import AnthropicProvider
|
|
14
|
+
from prrev.llm.openai import OpenAIProvider
|
|
15
|
+
from prrev.reviewer import review_diff
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(add_completion=False)
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_github_url(target: str) -> bool:
|
|
22
|
+
return target.startswith("https://github.com/") and "/pull/" in target
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.callback(invoke_without_command=True)
|
|
26
|
+
def main(
|
|
27
|
+
target: str = typer.Argument(..., help="Local repo path or GitHub PR URL"),
|
|
28
|
+
commit: str | None = typer.Option(None, help="Review a specific commit"),
|
|
29
|
+
range: str | None = typer.Option(None, help="Review a commit range (abc..def)"),
|
|
30
|
+
staged: bool = typer.Option(False, help="Review only staged changes"),
|
|
31
|
+
provider: str | None = typer.Option(None, help="LLM provider: anthropic or openai"),
|
|
32
|
+
model: str | None = typer.Option(None, help="Model override"),
|
|
33
|
+
post: bool = typer.Option(False, help="Post review as GitHub PR comment"),
|
|
34
|
+
output: str | None = typer.Option(None, help="Write review to markdown file"),
|
|
35
|
+
fail_on: str | None = typer.Option(
|
|
36
|
+
None, help="Exit 1 if issues at this severity or above (critical, warning, suggestion)"
|
|
37
|
+
),
|
|
38
|
+
) -> None:
|
|
39
|
+
# validate --fail-on early
|
|
40
|
+
valid_severities = {"critical", "warning", "suggestion"}
|
|
41
|
+
if fail_on and fail_on not in valid_severities:
|
|
42
|
+
console.print(f"error: --fail-on must be one of: {', '.join(valid_severities)}", style="red")
|
|
43
|
+
raise typer.Exit(2)
|
|
44
|
+
|
|
45
|
+
# cli flags override config, config fills in defaults
|
|
46
|
+
repo_path = target if not _is_github_url(target) else None
|
|
47
|
+
cfg = load_config(repo_path=repo_path)
|
|
48
|
+
prov = provider or cfg.provider
|
|
49
|
+
mdl = model or cfg.model
|
|
50
|
+
|
|
51
|
+
# route based on target type
|
|
52
|
+
if _is_github_url(target):
|
|
53
|
+
if not cfg.github_token:
|
|
54
|
+
console.print("error: GITHUB_TOKEN not set", style="red")
|
|
55
|
+
raise typer.Exit(2)
|
|
56
|
+
try:
|
|
57
|
+
owner, repo, number = parse_pr_url(target)
|
|
58
|
+
pr = asyncio.run(fetch_pr(owner, repo, number, cfg.github_token))
|
|
59
|
+
diff = pr.diff
|
|
60
|
+
console.print(f"reviewing PR #{pr.number}: {pr.title}", style="bold")
|
|
61
|
+
except ValueError as e:
|
|
62
|
+
console.print(f"error: {e}", style="red")
|
|
63
|
+
raise typer.Exit(2)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
console.print(f"failed to fetch PR: {e}", style="red")
|
|
66
|
+
raise typer.Exit(2)
|
|
67
|
+
else:
|
|
68
|
+
try:
|
|
69
|
+
diff = get_diff(target, commit=commit, range=range, staged=staged)
|
|
70
|
+
except ValueError as e:
|
|
71
|
+
console.print(f"error: {e}", style="red")
|
|
72
|
+
raise typer.Exit(2)
|
|
73
|
+
|
|
74
|
+
# pick provider
|
|
75
|
+
try:
|
|
76
|
+
if prov == "openai":
|
|
77
|
+
llm = OpenAIProvider(model=mdl, api_key=cfg.openai_api_key) if mdl else OpenAIProvider(api_key=cfg.openai_api_key)
|
|
78
|
+
elif prov == "anthropic":
|
|
79
|
+
llm = AnthropicProvider(model=mdl, api_key=cfg.anthropic_api_key) if mdl else AnthropicProvider(api_key=cfg.anthropic_api_key)
|
|
80
|
+
else:
|
|
81
|
+
console.print(f"unknown provider: {prov}", style="red")
|
|
82
|
+
raise typer.Exit(2)
|
|
83
|
+
except ValueError as e:
|
|
84
|
+
console.print(f"error: {e}", style="red")
|
|
85
|
+
raise typer.Exit(2)
|
|
86
|
+
|
|
87
|
+
# run the review
|
|
88
|
+
try:
|
|
89
|
+
result = asyncio.run(review_diff(llm, diff, max_items=cfg.max_items))
|
|
90
|
+
except Exception as e:
|
|
91
|
+
console.print(f"review failed: {e}", style="red")
|
|
92
|
+
raise typer.Exit(2)
|
|
93
|
+
|
|
94
|
+
# count files in the diff for the header
|
|
95
|
+
file_count = diff.count("diff --git ")
|
|
96
|
+
print_review(result, file_count=file_count)
|
|
97
|
+
|
|
98
|
+
# markdown output
|
|
99
|
+
if output:
|
|
100
|
+
Path(output).write_text(to_markdown(result))
|
|
101
|
+
console.print(f"\nreview written to {output}", style="dim")
|
|
102
|
+
|
|
103
|
+
# post review as github pr comment
|
|
104
|
+
if post:
|
|
105
|
+
if not _is_github_url(target):
|
|
106
|
+
console.print("error: --post only works with github PR urls", style="red")
|
|
107
|
+
raise typer.Exit(2)
|
|
108
|
+
if not cfg.github_token:
|
|
109
|
+
console.print("error: GITHUB_TOKEN not set", style="red")
|
|
110
|
+
raise typer.Exit(2)
|
|
111
|
+
try:
|
|
112
|
+
items_for_api = [
|
|
113
|
+
{"file": i.file, "line": i.line, "severity": i.severity,
|
|
114
|
+
"summary": i.summary, "explanation": i.explanation}
|
|
115
|
+
for i in result.items
|
|
116
|
+
]
|
|
117
|
+
body = to_markdown(result)
|
|
118
|
+
asyncio.run(post_review(owner, repo, number, body, cfg.github_token, items=items_for_api))
|
|
119
|
+
console.print("\nreview posted to PR", style="bold green")
|
|
120
|
+
except Exception as e:
|
|
121
|
+
console.print(f"failed to post review: {e}", style="red")
|
|
122
|
+
raise typer.Exit(2)
|
|
123
|
+
|
|
124
|
+
# exit code based on --fail-on threshold
|
|
125
|
+
if fail_on and result.items:
|
|
126
|
+
# severity levels, lower number = more severe
|
|
127
|
+
severity_rank = {"critical": 0, "warning": 1, "suggestion": 2}
|
|
128
|
+
threshold = severity_rank[fail_on]
|
|
129
|
+
if any(severity_rank.get(i.severity, 2) <= threshold for i in result.items):
|
|
130
|
+
raise typer.Exit(1)
|
prrev/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# config file loading and env var handling
|
|
2
|
+
# precedence: cli flags > env vars > repo .prrev.toml > global config > defaults
|
|
3
|
+
# tokens only from env vars or global config, never repo config (security)
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
if sys.version_info >= (3, 11):
|
|
11
|
+
import tomllib
|
|
12
|
+
else:
|
|
13
|
+
try:
|
|
14
|
+
import tomllib
|
|
15
|
+
except ImportError:
|
|
16
|
+
import tomli as tomllib
|
|
17
|
+
|
|
18
|
+
GLOBAL_CONFIG_PATH = Path.home() / ".config" / "prrev" / "config.toml"
|
|
19
|
+
REPO_CONFIG_NAME = ".prrev.toml"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Config:
|
|
24
|
+
provider: str = "anthropic"
|
|
25
|
+
model: str | None = None
|
|
26
|
+
github_token: str | None = None
|
|
27
|
+
anthropic_api_key: str | None = None
|
|
28
|
+
openai_api_key: str | None = None
|
|
29
|
+
max_items: int = 20
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_toml(path: Path) -> dict:
|
|
33
|
+
if not path.is_file():
|
|
34
|
+
return {}
|
|
35
|
+
with open(path, "rb") as f:
|
|
36
|
+
return tomllib.load(f)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_config(repo_path: str | None = None) -> Config:
|
|
40
|
+
# start with defaults
|
|
41
|
+
cfg = Config()
|
|
42
|
+
|
|
43
|
+
# global config
|
|
44
|
+
global_data = _load_toml(GLOBAL_CONFIG_PATH)
|
|
45
|
+
_apply_toml(cfg, global_data, allow_tokens=True)
|
|
46
|
+
|
|
47
|
+
# repo config, tokens not allowed here
|
|
48
|
+
if repo_path:
|
|
49
|
+
repo_data = _load_toml(Path(repo_path) / REPO_CONFIG_NAME)
|
|
50
|
+
_apply_toml(cfg, repo_data, allow_tokens=False)
|
|
51
|
+
|
|
52
|
+
# env vars override everything
|
|
53
|
+
if v := os.environ.get("PRREV_PROVIDER"):
|
|
54
|
+
cfg.provider = v
|
|
55
|
+
if v := os.environ.get("PRREV_MODEL"):
|
|
56
|
+
cfg.model = v
|
|
57
|
+
if v := os.environ.get("PRREV_MAX_ITEMS"):
|
|
58
|
+
cfg.max_items = int(v)
|
|
59
|
+
if v := os.environ.get("GITHUB_TOKEN"):
|
|
60
|
+
cfg.github_token = v
|
|
61
|
+
if v := os.environ.get("ANTHROPIC_API_KEY"):
|
|
62
|
+
cfg.anthropic_api_key = v
|
|
63
|
+
if v := os.environ.get("OPENAI_API_KEY"):
|
|
64
|
+
cfg.openai_api_key = v
|
|
65
|
+
|
|
66
|
+
return cfg
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _apply_toml(cfg: Config, data: dict, *, allow_tokens: bool) -> None:
|
|
70
|
+
llm = data.get("llm", {})
|
|
71
|
+
if v := llm.get("provider"):
|
|
72
|
+
cfg.provider = v
|
|
73
|
+
if v := llm.get("model"):
|
|
74
|
+
cfg.model = v
|
|
75
|
+
|
|
76
|
+
review = data.get("review", {})
|
|
77
|
+
if v := review.get("max_items"):
|
|
78
|
+
cfg.max_items = int(v)
|
|
79
|
+
|
|
80
|
+
# tokens only from global config, not repo config
|
|
81
|
+
if allow_tokens:
|
|
82
|
+
github = data.get("github", {})
|
|
83
|
+
if v := github.get("token"):
|
|
84
|
+
cfg.github_token = v
|
|
85
|
+
llm_keys = data.get("llm", {})
|
|
86
|
+
if v := llm_keys.get("anthropic_api_key"):
|
|
87
|
+
cfg.anthropic_api_key = v
|
|
88
|
+
if v := llm_keys.get("openai_api_key"):
|
|
89
|
+
cfg.openai_api_key = v
|
prrev/formatter.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# rich terminal output and markdown file export
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
from prrev.llm.base import ReviewItem, ReviewResult
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
SEVERITY_STYLES = {
|
|
12
|
+
"critical": ("red", "CRITICAL"),
|
|
13
|
+
"warning": ("yellow", "WARNING"),
|
|
14
|
+
"suggestion": ("green", "SUGGESTION"),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _format_item(item: ReviewItem) -> Text:
|
|
19
|
+
color, label = SEVERITY_STYLES.get(item.severity, ("white", item.severity.upper()))
|
|
20
|
+
|
|
21
|
+
text = Text()
|
|
22
|
+
text.append(f"{label:10s}", style=f"bold {color}")
|
|
23
|
+
text.append(f" {item.file}", style="bold")
|
|
24
|
+
if item.line is not None:
|
|
25
|
+
text.append(f":{item.line}", style="bold")
|
|
26
|
+
text.append("\n")
|
|
27
|
+
text.append(f" {item.summary}\n", style="bold")
|
|
28
|
+
text.append(f" {item.explanation}\n")
|
|
29
|
+
|
|
30
|
+
return text
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def print_review(result: ReviewResult, file_count: int = 0) -> None:
|
|
34
|
+
# header panel
|
|
35
|
+
subtitle = f"Reviewed {file_count} files" if file_count else "Review"
|
|
36
|
+
console.print(Panel(
|
|
37
|
+
Text("PRRev", style="bold white"),
|
|
38
|
+
subtitle=subtitle,
|
|
39
|
+
border_style="blue",
|
|
40
|
+
))
|
|
41
|
+
console.print()
|
|
42
|
+
|
|
43
|
+
if not result.items:
|
|
44
|
+
console.print(" No issues found.", style="bold green")
|
|
45
|
+
console.print()
|
|
46
|
+
else:
|
|
47
|
+
for item in result.items:
|
|
48
|
+
console.print(_format_item(item))
|
|
49
|
+
|
|
50
|
+
# summary at the bottom
|
|
51
|
+
console.print(Panel(result.summary, title="Summary", border_style="dim"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def to_markdown(result: ReviewResult) -> str:
|
|
55
|
+
lines = ["# PRRev Code Review\n"]
|
|
56
|
+
|
|
57
|
+
if not result.items:
|
|
58
|
+
lines.append("No issues found.\n")
|
|
59
|
+
else:
|
|
60
|
+
for item in result.items:
|
|
61
|
+
color, label = SEVERITY_STYLES.get(item.severity, ("white", item.severity.upper()))
|
|
62
|
+
location = item.file
|
|
63
|
+
if item.line is not None:
|
|
64
|
+
location += f":{item.line}"
|
|
65
|
+
lines.append(f"### {label} — {location}\n")
|
|
66
|
+
lines.append(f"**{item.summary}**\n")
|
|
67
|
+
lines.append(f"{item.explanation}\n")
|
|
68
|
+
|
|
69
|
+
lines.append(f"## Summary\n")
|
|
70
|
+
lines.append(f"{result.summary}\n")
|
|
71
|
+
|
|
72
|
+
return "\n".join(lines)
|
prrev/git.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# local diff extraction via gitpython
|
|
2
|
+
|
|
3
|
+
from git import Repo, InvalidGitRepositoryError, NoSuchPathError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_diff(
|
|
7
|
+
repo_path: str,
|
|
8
|
+
*,
|
|
9
|
+
commit: str | None = None,
|
|
10
|
+
range: str | None = None,
|
|
11
|
+
staged: bool = False,
|
|
12
|
+
) -> str:
|
|
13
|
+
try:
|
|
14
|
+
repo = Repo(repo_path)
|
|
15
|
+
except InvalidGitRepositoryError:
|
|
16
|
+
raise ValueError(f"not a git repository: {repo_path}")
|
|
17
|
+
except NoSuchPathError:
|
|
18
|
+
raise ValueError(f"path does not exist: {repo_path}")
|
|
19
|
+
|
|
20
|
+
if repo.bare:
|
|
21
|
+
raise ValueError(f"cannot diff a bare repository: {repo_path}")
|
|
22
|
+
|
|
23
|
+
# specific commit, show its diff against parent
|
|
24
|
+
if commit:
|
|
25
|
+
commit_obj = repo.commit(commit)
|
|
26
|
+
if commit_obj.parents:
|
|
27
|
+
return repo.git.diff(commit_obj.parents[0].hexsha, commit_obj.hexsha)
|
|
28
|
+
# root commit, diff against empty tree
|
|
29
|
+
empty_tree = repo.git.hash_object("-t", "tree", "/dev/null")
|
|
30
|
+
return repo.git.diff(empty_tree, commit_obj.hexsha)
|
|
31
|
+
|
|
32
|
+
# commit range like abc123..def456
|
|
33
|
+
if range:
|
|
34
|
+
if ".." not in range:
|
|
35
|
+
raise ValueError(f"invalid range format, expected 'a..b': {range}")
|
|
36
|
+
return repo.git.diff(range)
|
|
37
|
+
|
|
38
|
+
# staged only
|
|
39
|
+
if staged:
|
|
40
|
+
diff = repo.git.diff("--cached")
|
|
41
|
+
if not diff:
|
|
42
|
+
raise ValueError("no staged changes found")
|
|
43
|
+
return diff
|
|
44
|
+
|
|
45
|
+
# default: all uncommitted changes (staged + unstaged)
|
|
46
|
+
# diff HEAD to catch both, but if theres no commits yet diff the index
|
|
47
|
+
if repo.head.is_valid():
|
|
48
|
+
diff = repo.git.diff("HEAD")
|
|
49
|
+
else:
|
|
50
|
+
# no commits yet, show whats staged
|
|
51
|
+
diff = repo.git.diff("--cached")
|
|
52
|
+
|
|
53
|
+
if not diff:
|
|
54
|
+
raise ValueError("no changes found")
|
|
55
|
+
|
|
56
|
+
return diff
|
prrev/github.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# github api, fetch pr diffs and post review comments
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
API_BASE = "https://api.github.com"
|
|
9
|
+
|
|
10
|
+
# matches urls like https://github.com/owner/repo/pull/42
|
|
11
|
+
PR_URL_PATTERN = re.compile(r"https://github\.com/([^/]+)/([^/]+)/pull/(\d+)")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class PRInfo:
|
|
16
|
+
owner: str
|
|
17
|
+
repo: str
|
|
18
|
+
number: int
|
|
19
|
+
title: str
|
|
20
|
+
diff: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_pr_url(url: str) -> tuple[str, str, int]:
|
|
24
|
+
match = PR_URL_PATTERN.match(url)
|
|
25
|
+
if not match:
|
|
26
|
+
raise ValueError(f"invalid github PR url: {url}")
|
|
27
|
+
return match.group(1), match.group(2), int(match.group(3))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def fetch_pr(owner: str, repo: str, number: int, token: str) -> PRInfo:
|
|
31
|
+
headers = {
|
|
32
|
+
"Authorization": f"Bearer {token}",
|
|
33
|
+
"Accept": "application/json",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async with httpx.AsyncClient(base_url=API_BASE, headers=headers) as client:
|
|
37
|
+
# get pr metadata
|
|
38
|
+
resp = await client.get(f"/repos/{owner}/{repo}/pulls/{number}")
|
|
39
|
+
resp.raise_for_status()
|
|
40
|
+
pr_data = resp.json()
|
|
41
|
+
|
|
42
|
+
# get the full diff using the diff accept header
|
|
43
|
+
diff_resp = await client.get(
|
|
44
|
+
f"/repos/{owner}/{repo}/pulls/{number}",
|
|
45
|
+
headers={"Accept": "application/vnd.github.v3.diff"},
|
|
46
|
+
)
|
|
47
|
+
diff_resp.raise_for_status()
|
|
48
|
+
|
|
49
|
+
return PRInfo(
|
|
50
|
+
owner=owner,
|
|
51
|
+
repo=repo,
|
|
52
|
+
number=number,
|
|
53
|
+
title=pr_data.get("title", ""),
|
|
54
|
+
diff=diff_resp.text,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def post_review(
|
|
59
|
+
owner: str,
|
|
60
|
+
repo: str,
|
|
61
|
+
number: int,
|
|
62
|
+
body: str,
|
|
63
|
+
token: str,
|
|
64
|
+
items: list[dict] | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
headers = {
|
|
67
|
+
"Authorization": f"Bearer {token}",
|
|
68
|
+
"Accept": "application/json",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# build inline comments from items that have file + line
|
|
72
|
+
comments = []
|
|
73
|
+
if items:
|
|
74
|
+
for item in items:
|
|
75
|
+
if item.get("file") and item.get("line"):
|
|
76
|
+
comments.append({
|
|
77
|
+
"path": item["file"],
|
|
78
|
+
"line": item["line"],
|
|
79
|
+
"side": "RIGHT",
|
|
80
|
+
"body": f"**{item['severity'].upper()}**: {item['summary']}\n\n{item['explanation']}",
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
payload: dict = {
|
|
84
|
+
"body": body,
|
|
85
|
+
"event": "COMMENT",
|
|
86
|
+
}
|
|
87
|
+
if comments:
|
|
88
|
+
payload["comments"] = comments
|
|
89
|
+
|
|
90
|
+
async with httpx.AsyncClient(base_url=API_BASE, headers=headers) as client:
|
|
91
|
+
resp = await client.post(
|
|
92
|
+
f"/repos/{owner}/{repo}/pulls/{number}/reviews",
|
|
93
|
+
json=payload,
|
|
94
|
+
)
|
|
95
|
+
resp.raise_for_status()
|
prrev/llm/__init__.py
ADDED
|
File without changes
|
prrev/llm/anthropic.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# anthropic provider, uses tool use for structured output so we
|
|
2
|
+
# dont have to parse json from raw text
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import anthropic
|
|
7
|
+
|
|
8
|
+
from prrev.llm.base import LLMProvider, ReviewItem, ReviewResult
|
|
9
|
+
|
|
10
|
+
# tool schema that forces the model to call submit_review
|
|
11
|
+
# with the exact shape we need
|
|
12
|
+
REVIEW_TOOL = {
|
|
13
|
+
"name": "submit_review",
|
|
14
|
+
"description": "Submit a structured code review.",
|
|
15
|
+
"input_schema": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"summary": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "1-2 sentence overall assessment of the diff.",
|
|
21
|
+
},
|
|
22
|
+
"items": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"items": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"severity": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"enum": ["critical", "warning", "suggestion"],
|
|
30
|
+
},
|
|
31
|
+
"file": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "filepath from the diff header",
|
|
34
|
+
},
|
|
35
|
+
"line": {
|
|
36
|
+
"type": ["integer", "null"],
|
|
37
|
+
"description": "new-file line number, or null if not identifiable",
|
|
38
|
+
},
|
|
39
|
+
"summary": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "one line description of the issue",
|
|
42
|
+
},
|
|
43
|
+
"explanation": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "1-3 sentence explanation",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
"required": ["severity", "file", "line", "summary", "explanation"],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
"required": ["summary", "items"],
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
SYSTEM_PROMPT = (
|
|
57
|
+
"You are a senior code reviewer. You will receive a unified diff. "
|
|
58
|
+
"Review it for bugs, security issues, logic errors, performance problems, "
|
|
59
|
+
"and style issues. Be concise, no filler. "
|
|
60
|
+
"If the diff is clean, submit an empty items array with a positive summary. "
|
|
61
|
+
"Use the submit_review tool to return your review."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AnthropicProvider(LLMProvider):
|
|
66
|
+
max_input_tokens = 180_000 # claude sonnet/opus context is 200k, leave room for output
|
|
67
|
+
|
|
68
|
+
def __init__(self, model: str = "claude-sonnet-4-6", api_key: str | None = None):
|
|
69
|
+
self.model = model
|
|
70
|
+
key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
71
|
+
if not key:
|
|
72
|
+
raise ValueError("ANTHROPIC_API_KEY not set")
|
|
73
|
+
self.client = anthropic.AsyncAnthropic(api_key=key)
|
|
74
|
+
|
|
75
|
+
def count_tokens(self, text: str) -> int:
|
|
76
|
+
# synchronous token counting using the anthropic sdk
|
|
77
|
+
sync_client = anthropic.Anthropic(api_key=self.client.api_key)
|
|
78
|
+
return sync_client.count_tokens(text)
|
|
79
|
+
|
|
80
|
+
async def review(self, diff: str) -> ReviewResult:
|
|
81
|
+
response = await self.client.messages.create(
|
|
82
|
+
model=self.model,
|
|
83
|
+
max_tokens=4096,
|
|
84
|
+
system=SYSTEM_PROMPT,
|
|
85
|
+
tools=[REVIEW_TOOL],
|
|
86
|
+
# force the model to use our tool
|
|
87
|
+
tool_choice={"type": "tool", "name": "submit_review"},
|
|
88
|
+
messages=[{"role": "user", "content": diff}],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# find the tool use block in the response
|
|
92
|
+
for block in response.content:
|
|
93
|
+
if block.type == "tool_use" and block.name == "submit_review":
|
|
94
|
+
data = block.input
|
|
95
|
+
items = [
|
|
96
|
+
ReviewItem(
|
|
97
|
+
severity=item["severity"],
|
|
98
|
+
file=item["file"],
|
|
99
|
+
line=item.get("line"),
|
|
100
|
+
summary=item["summary"],
|
|
101
|
+
explanation=item["explanation"],
|
|
102
|
+
)
|
|
103
|
+
for item in data.get("items", [])
|
|
104
|
+
]
|
|
105
|
+
return ReviewResult(items=items, summary=data.get("summary", ""))
|
|
106
|
+
|
|
107
|
+
raise RuntimeError("model did not call submit_review tool")
|
prrev/llm/base.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class ReviewItem:
|
|
7
|
+
severity: str # "critical" | "warning" | "suggestion"
|
|
8
|
+
file: str
|
|
9
|
+
line: int | None # new-file line number (right side of diff)
|
|
10
|
+
summary: str
|
|
11
|
+
explanation: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ReviewResult:
|
|
16
|
+
items: list[ReviewItem]
|
|
17
|
+
summary: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LLMProvider(ABC):
|
|
21
|
+
# max input tokens the model can handle, subclasses override
|
|
22
|
+
max_input_tokens: int = 100_000
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def review(self, diff: str) -> ReviewResult:
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def count_tokens(self, text: str) -> int:
|
|
30
|
+
...
|
prrev/llm/openai.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# openai provider, uses response_format for structured output
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import openai
|
|
7
|
+
import tiktoken
|
|
8
|
+
|
|
9
|
+
from prrev.llm.base import LLMProvider, ReviewItem, ReviewResult
|
|
10
|
+
|
|
11
|
+
# json schema for structured output, same shape as the anthropic tool
|
|
12
|
+
REVIEW_SCHEMA = {
|
|
13
|
+
"name": "review_response",
|
|
14
|
+
"strict": True,
|
|
15
|
+
"schema": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"summary": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "1-2 sentence overall assessment of the diff.",
|
|
21
|
+
},
|
|
22
|
+
"items": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"items": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"severity": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"enum": ["critical", "warning", "suggestion"],
|
|
30
|
+
},
|
|
31
|
+
"file": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "filepath from the diff header",
|
|
34
|
+
},
|
|
35
|
+
"line": {
|
|
36
|
+
"type": ["integer", "null"],
|
|
37
|
+
"description": "new-file line number, or null if not identifiable",
|
|
38
|
+
},
|
|
39
|
+
"summary": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "one line description of the issue",
|
|
42
|
+
},
|
|
43
|
+
"explanation": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "1-3 sentence explanation",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
"required": ["severity", "file", "line", "summary", "explanation"],
|
|
49
|
+
"additionalProperties": False,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
"required": ["summary", "items"],
|
|
54
|
+
"additionalProperties": False,
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
SYSTEM_PROMPT = (
|
|
59
|
+
"You are a senior code reviewer. You will receive a unified diff. "
|
|
60
|
+
"Review it for bugs, security issues, logic errors, performance problems, "
|
|
61
|
+
"and style issues. Be concise, no filler. "
|
|
62
|
+
"If the diff is clean, return an empty items array with a positive summary."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class OpenAIProvider(LLMProvider):
|
|
67
|
+
max_input_tokens = 110_000 # gpt-4o is 128k, leave room for output
|
|
68
|
+
|
|
69
|
+
def __init__(self, model: str = "gpt-4o", api_key: str | None = None):
|
|
70
|
+
self.model = model
|
|
71
|
+
key = api_key or os.environ.get("OPENAI_API_KEY")
|
|
72
|
+
if not key:
|
|
73
|
+
raise ValueError("OPENAI_API_KEY not set")
|
|
74
|
+
self.client = openai.AsyncOpenAI(api_key=key)
|
|
75
|
+
|
|
76
|
+
def count_tokens(self, text: str) -> int:
|
|
77
|
+
try:
|
|
78
|
+
enc = tiktoken.encoding_for_model(self.model)
|
|
79
|
+
except KeyError:
|
|
80
|
+
enc = tiktoken.get_encoding("cl100k_base")
|
|
81
|
+
return len(enc.encode(text))
|
|
82
|
+
|
|
83
|
+
async def review(self, diff: str) -> ReviewResult:
|
|
84
|
+
response = await self.client.chat.completions.create(
|
|
85
|
+
model=self.model,
|
|
86
|
+
messages=[
|
|
87
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
88
|
+
{"role": "user", "content": diff},
|
|
89
|
+
],
|
|
90
|
+
response_format={
|
|
91
|
+
"type": "json_schema",
|
|
92
|
+
"json_schema": REVIEW_SCHEMA,
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
data = json.loads(response.choices[0].message.content)
|
|
97
|
+
items = [
|
|
98
|
+
ReviewItem(
|
|
99
|
+
severity=item["severity"],
|
|
100
|
+
file=item["file"],
|
|
101
|
+
line=item.get("line"),
|
|
102
|
+
summary=item["summary"],
|
|
103
|
+
explanation=item["explanation"],
|
|
104
|
+
)
|
|
105
|
+
for item in data.get("items", [])
|
|
106
|
+
]
|
|
107
|
+
return ReviewResult(items=items, summary=data.get("summary", ""))
|
prrev/reviewer.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# orchestrator, takes diff + provider, returns structured review
|
|
2
|
+
# auto-chunks when diff exceeds 80% of the providers context window
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
|
|
6
|
+
from prrev.llm.base import LLMProvider, ReviewItem, ReviewResult
|
|
7
|
+
|
|
8
|
+
DIFF_HEADER = "diff --git "
|
|
9
|
+
|
|
10
|
+
# chunk when diff uses more than 80% of the providers max input tokens
|
|
11
|
+
CHUNK_THRESHOLD = 0.8
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _split_by_file(diff: str) -> list[str]:
|
|
15
|
+
chunks = []
|
|
16
|
+
current: list[str] = []
|
|
17
|
+
|
|
18
|
+
for line in diff.splitlines(keepends=True):
|
|
19
|
+
if line.startswith(DIFF_HEADER) and current:
|
|
20
|
+
chunks.append("".join(current))
|
|
21
|
+
current = []
|
|
22
|
+
current.append(line)
|
|
23
|
+
|
|
24
|
+
if current:
|
|
25
|
+
chunks.append("".join(current))
|
|
26
|
+
|
|
27
|
+
return chunks
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def review_diff(
|
|
31
|
+
provider: LLMProvider,
|
|
32
|
+
diff: str,
|
|
33
|
+
*,
|
|
34
|
+
max_items: int = 20,
|
|
35
|
+
) -> ReviewResult:
|
|
36
|
+
if not diff.strip():
|
|
37
|
+
raise ValueError("empty diff")
|
|
38
|
+
|
|
39
|
+
# check if we need to chunk based on token count
|
|
40
|
+
token_count = provider.count_tokens(diff)
|
|
41
|
+
threshold = int(provider.max_input_tokens * CHUNK_THRESHOLD)
|
|
42
|
+
|
|
43
|
+
if token_count <= threshold:
|
|
44
|
+
result = await provider.review(diff)
|
|
45
|
+
return _truncate(result, max_items)
|
|
46
|
+
|
|
47
|
+
# diff is too big, split by file and review in parallel
|
|
48
|
+
file_diffs = _split_by_file(diff)
|
|
49
|
+
if len(file_diffs) <= 1:
|
|
50
|
+
# single file thats too big, just send it and hope for the best.
|
|
51
|
+
# TODO: split within file by hunk
|
|
52
|
+
result = await provider.review(diff)
|
|
53
|
+
return _truncate(result, max_items)
|
|
54
|
+
|
|
55
|
+
# skip files that individually exceed the threshold
|
|
56
|
+
reviewable = []
|
|
57
|
+
skipped_files = []
|
|
58
|
+
for chunk in file_diffs:
|
|
59
|
+
if provider.count_tokens(chunk) > threshold:
|
|
60
|
+
# grab filename from the diff header for the warning
|
|
61
|
+
first_line = chunk.split("\n", 1)[0]
|
|
62
|
+
skipped_files.append(first_line)
|
|
63
|
+
else:
|
|
64
|
+
reviewable.append(chunk)
|
|
65
|
+
|
|
66
|
+
results = await asyncio.gather(*[provider.review(d) for d in reviewable])
|
|
67
|
+
merged = _merge_results(list(results))
|
|
68
|
+
|
|
69
|
+
# add warnings for skipped files
|
|
70
|
+
for skipped in skipped_files:
|
|
71
|
+
merged.items.append(ReviewItem(
|
|
72
|
+
severity="warning",
|
|
73
|
+
file=skipped,
|
|
74
|
+
line=None,
|
|
75
|
+
summary="file skipped, too large for context window",
|
|
76
|
+
explanation="this files diff exceeded the models token limit and was not reviewed.",
|
|
77
|
+
))
|
|
78
|
+
|
|
79
|
+
return _truncate(merged, max_items)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _merge_results(results: list[ReviewResult]) -> ReviewResult:
|
|
83
|
+
all_items: list[ReviewItem] = []
|
|
84
|
+
summaries: list[str] = []
|
|
85
|
+
|
|
86
|
+
for r in results:
|
|
87
|
+
all_items.extend(r.items)
|
|
88
|
+
if r.summary:
|
|
89
|
+
summaries.append(r.summary)
|
|
90
|
+
|
|
91
|
+
summary = " ".join(summaries) if summaries else "No issues found."
|
|
92
|
+
return ReviewResult(items=all_items, summary=summary)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _truncate(result: ReviewResult, max_items: int) -> ReviewResult:
|
|
96
|
+
if len(result.items) <= max_items:
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
# keep criticals first, then warnings, drop suggestions
|
|
100
|
+
severity_order = {"critical": 0, "warning": 1, "suggestion": 2}
|
|
101
|
+
sorted_items = sorted(result.items, key=lambda i: severity_order.get(i.severity, 2))
|
|
102
|
+
return ReviewResult(items=sorted_items[:max_items], summary=result.summary)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: prrev
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool that reviews code diffs using LLMs
|
|
5
|
+
Author-email: Timur Mamedov <tm412421@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: anthropic>=0.40
|
|
9
|
+
Requires-Dist: gitpython>=3.1
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: openai>=1.50
|
|
12
|
+
Requires-Dist: rich>=13.0
|
|
13
|
+
Requires-Dist: tiktoken>=0.7
|
|
14
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
15
|
+
Requires-Dist: typer>=0.12
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# PRRev
|
|
23
|
+
|
|
24
|
+
A CLI tool that reviews code diffs using LLMs. Point it at a local repo or a GitHub PR URL. It sends the diff to Claude or GPT-4o and outputs a structured review with severity ratings, file references, and line numbers.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/timurmamedov1/PRRev.git
|
|
30
|
+
cd PRRev
|
|
31
|
+
pip install -e .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires Python 3.10+.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# review uncommitted changes
|
|
40
|
+
prrev .
|
|
41
|
+
|
|
42
|
+
# review only staged changes
|
|
43
|
+
prrev . --staged
|
|
44
|
+
|
|
45
|
+
# review a specific commit
|
|
46
|
+
prrev . --commit abc123
|
|
47
|
+
|
|
48
|
+
# review a commit range
|
|
49
|
+
prrev . --range abc123..def456
|
|
50
|
+
|
|
51
|
+
# review a GitHub PR
|
|
52
|
+
prrev https://github.com/user/repo/pull/42
|
|
53
|
+
|
|
54
|
+
# post review as inline PR comments
|
|
55
|
+
prrev https://github.com/user/repo/pull/42 --post
|
|
56
|
+
|
|
57
|
+
# write review to a markdown file
|
|
58
|
+
prrev . --output review.md
|
|
59
|
+
|
|
60
|
+
# fail in CI if there are warnings or worse
|
|
61
|
+
prrev . --fail-on warning
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
Set API keys as environment variables:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
export ANTHROPIC_API_KEY=sk-...
|
|
70
|
+
export OPENAI_API_KEY=sk-...
|
|
71
|
+
export GITHUB_TOKEN=ghp_...
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or use a config file at `~/.config/prrev/config.toml`:
|
|
75
|
+
|
|
76
|
+
```toml
|
|
77
|
+
[llm]
|
|
78
|
+
provider = "anthropic"
|
|
79
|
+
model = "claude-sonnet-4-20250514"
|
|
80
|
+
anthropic_api_key = "sk-..."
|
|
81
|
+
|
|
82
|
+
[github]
|
|
83
|
+
token = "ghp_..."
|
|
84
|
+
|
|
85
|
+
[review]
|
|
86
|
+
max_items = 20
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
You can also put a `.prrev.toml` in your repo root for per-project settings. API keys are ignored in repo config for security. They only load from env vars or the global config.
|
|
90
|
+
|
|
91
|
+
Precedence: CLI flags > env vars > repo config > global config > defaults.
|
|
92
|
+
|
|
93
|
+
## Providers
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# use claude (default)
|
|
97
|
+
prrev . --provider anthropic
|
|
98
|
+
|
|
99
|
+
# use gpt-4o
|
|
100
|
+
prrev . --provider openai
|
|
101
|
+
|
|
102
|
+
# override the model
|
|
103
|
+
prrev . --provider anthropic --model claude-sonnet-4-20250514
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## How It Works
|
|
107
|
+
|
|
108
|
+
The diff is sent to the LLM using each provider's structured output mechanism: Anthropic's tool use and OpenAI's `response_format` with a JSON schema. This guarantees the response matches the expected structure at the API level without fragile JSON text parsing.
|
|
109
|
+
|
|
110
|
+
For large diffs, PRRev automatically chunks by file based on actual token counts (not character estimates) and reviews each chunk in parallel. Results are merged and truncated by severity: suggestions are dropped first, then warnings. Critical issues are never dropped.
|
|
111
|
+
|
|
112
|
+
## Exit Codes
|
|
113
|
+
|
|
114
|
+
- `0:` review completed, no issues at or above `--fail-on` threshold
|
|
115
|
+
- `1:` issues found at or above `--fail-on` threshold
|
|
116
|
+
- `2:` tool error (missing API key, invalid args, network failure)
|
|
117
|
+
|
|
118
|
+
## Tech Stack
|
|
119
|
+
|
|
120
|
+
- **Typer:** CLI framework
|
|
121
|
+
- **Rich:** terminal output with colored severity panels
|
|
122
|
+
- **GitPython:** local diff extraction
|
|
123
|
+
- **httpx:** GitHub API (fetch PR diffs, post inline comments)
|
|
124
|
+
- **Anthropic SDK:** Claude API with tool use for structured output
|
|
125
|
+
- **OpenAI SDK:** GPT-4o API with structured output
|
|
126
|
+
- **tiktoken:** token counting for OpenAI
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
prrev/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
prrev/cli.py,sha256=7mQrMIL8un-ndcqumvt3aJUOkOtmm0JxdKJLzoyX0xs,5307
|
|
3
|
+
prrev/config.py,sha256=rXoC8bAM7zPtYvn1bzpXGP9sQxq-TTvxL5RLU36AqyM,2532
|
|
4
|
+
prrev/formatter.py,sha256=FY49jCcbolcwABS3ZYmn3cfT9lYAbEZ503BDHE9PluQ,2125
|
|
5
|
+
prrev/git.py,sha256=GLPlLQZib85N8hCpoCRtB3YxT2SW-fmKp29A-r53Oto,1712
|
|
6
|
+
prrev/github.py,sha256=1vANHkdzgagYHbDm2EEN6tmY9aj3vQYJSiTFkJpJ3Mc,2557
|
|
7
|
+
prrev/reviewer.py,sha256=J5ODnnGXSaKk7Kx9jRqlEFJWM1GCMFaf5VCshVJCRyU,3241
|
|
8
|
+
prrev/llm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
prrev/llm/anthropic.py,sha256=pXjknqPHDDsSdAEqWwW7xeQn2n3aW8LefVoVv8bUqjk,4132
|
|
10
|
+
prrev/llm/base.py,sha256=gdCCuLBol-f7fKIQ7xgKxzvXyDGaB2iInMnpTX_7iz8,650
|
|
11
|
+
prrev/llm/openai.py,sha256=cBny6EbXD_4l2tbW4rfhcIgRx5rvwUgPAyo2NUD6R6M,3785
|
|
12
|
+
prrev-0.1.0.dist-info/METADATA,sha256=cTdfvXZNLHzTlAZ1QE94O3PAsW-YVqph4fMxhToZh40,3457
|
|
13
|
+
prrev-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
14
|
+
prrev-0.1.0.dist-info/entry_points.txt,sha256=tmn8g4SfTYeuGy3eWUaBHY9gM8GCVutgLXFTvPtSfHg,40
|
|
15
|
+
prrev-0.1.0.dist-info/RECORD,,
|