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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ prrev = prrev.cli:app