prrev 0.1.0__tar.gz

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-0.1.0/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .env
8
+ .prrev.toml
9
+ *.egg
10
+ .ruff_cache/
11
+ .pytest_cache/
prrev-0.1.0/CLAUDE.md ADDED
@@ -0,0 +1,119 @@
1
+ # CLAUDE.md
2
+
3
+ ## Commands
4
+
5
+ ```bash
6
+ # Install in editable mode with dev dependencies
7
+ pip install -e ".[dev]"
8
+
9
+ # Run the CLI
10
+ prrev . # review uncommitted changes
11
+ prrev . --staged # review only staged changes
12
+ prrev . --commit abc123 # review a specific commit
13
+ prrev . --range abc123..def456 # review a commit range
14
+ prrev https://github.com/user/repo/pull/42 # review a GitHub PR
15
+ prrev https://github.com/user/repo/pull/42 --post # post review as PR comment
16
+
17
+ # Tests
18
+ pytest # run all tests
19
+ pytest tests/test_git.py # run a single test file
20
+ pytest -k "test_parse_pr_url" # run a single test by name
21
+
22
+ # Lint
23
+ ruff check src/ tests/ # lint
24
+ ruff check --fix src/ tests/ # lint and auto-fix
25
+ ruff format src/ tests/ # format
26
+ ```
27
+
28
+ ## Architecture
29
+
30
+ PRRev is a single-command CLI that pipes code diffs through an LLM and renders structured reviews.
31
+
32
+ **Core flow:** CLI (`cli.py`) → diff extraction (`git.py` or `github.py`) → orchestrator (`reviewer.py`) → LLM provider (`llm/`) → formatter (`formatter.py`) → terminal or file output.
33
+
34
+ ### Key modules
35
+
36
+ - **`cli.py`** — Single Typer command (uses `@app.callback(invoke_without_command=True)`). Routes to local git or GitHub path based on whether `target` is a URL. Wires config, provider, and formatter together. Runs the async review loop via `asyncio.run()`.
37
+ - **`git.py`** — Extracts diffs from local repos via GitPython. Supports uncommitted (staged + unstaged), `--staged` only, specific commit, and commit range. Returns raw unified diff as a string.
38
+ - **`github.py`** — Fetches PR diffs and posts reviews via GitHub API using httpx. The only module that uses httpx directly. Parses PR URLs, fetches full diff via `Accept: application/vnd.github.v3.diff` header, and posts reviews with inline comments.
39
+ - **`reviewer.py`** — Orchestrator. Takes an `LLMProvider` and a diff string. Handles diff chunking: splits by file using `diff --git` headers, reviews chunks in parallel via `asyncio.gather()`, merges results. Applies `max_items` truncation (drop suggestions first, then warnings).
40
+ - **`formatter.py`** — Renders `ReviewResult` to terminal using Rich (panels, colored severity) or to a markdown string for file/PR output.
41
+ - **`config.py`** — Loads config from `~/.config/prrev/config.toml` (global) or `.prrev.toml` (repo-root, gitignored). Env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GITHUB_TOKEN`) override config file values. CLI flags override everything.
42
+ - **`llm/base.py`** — Abstract base class `LLMProvider` with async `review(diff) -> ReviewResult`. Data classes `ReviewItem` and `ReviewResult`.
43
+ - **`llm/anthropic.py`** — Claude implementation. Uses Anthropic SDK's tool use for structured output (defines a `submit_review` tool with the ReviewResult JSON schema). The model calls the tool, we parse the tool call arguments — no fragile JSON-from-text parsing.
44
+ - **`llm/openai.py`** — OpenAI implementation. Uses `response_format` with a JSON schema for structured output.
45
+
46
+ ## Design Decisions
47
+
48
+ ### Structured output over JSON prompting
49
+ Never ask the LLM to return raw JSON text. Anthropic provider uses tool use (`tools=[...]` parameter); OpenAI provider uses structured outputs (`response_format={"type": "json_schema", ...}`). Both guarantee valid JSON matching the schema.
50
+
51
+ ### Async provider interface
52
+ `LLMProvider.review()` is async. This enables parallel chunk reviews via `asyncio.gather()` when a diff is split across files. The CLI entry point uses `asyncio.run()`.
53
+
54
+ ### Line numbers are new-file lines
55
+ `ReviewItem.line` always refers to the new-file line number (right side of the diff, `+` lines). This matches what GitHub's review API expects for inline comments on additions. For deleted lines, `line` refers to the original file's line number and comments use `side="LEFT"`.
56
+
57
+ ### Diff chunking
58
+ Split threshold: use actual token counts, not character estimates. Anthropic SDK provides `client.count_tokens()`; use `tiktoken` for OpenAI. Split at `diff --git a/... b/...` boundaries. If a single file exceeds the context window, skip it with a warning in the review output.
59
+
60
+ ### GitHub inline comments
61
+ When `--post` is used, map each `ReviewItem` to an inline comment using `POST /repos/{owner}/{repo}/pulls/{number}/reviews` with a `comments` array. Each comment needs `path` (from `ReviewItem.file`), `line` (from `ReviewItem.line`), `side` ("RIGHT" for additions, "LEFT" for deletions), and `body`. Items without a line number go into the top-level review body.
62
+
63
+ ### Exit codes
64
+ Exit 0 if review completes successfully with no issues at or above the `--fail-on` threshold. Exit 1 if issues meet/exceed the threshold. Exit 2 for tool errors (missing API key, invalid args, network failure). This enables CI integration.
65
+
66
+ ### Config precedence
67
+ CLI flags > environment variables > repo-root `.prrev.toml` > global `~/.config/prrev/config.toml` > defaults. Tokens are never read from repo-root config (security) — only from env vars or global config.
68
+
69
+ ### max_items truncation order
70
+ When `max_items` is reached, drop suggestions first, then warnings. Critical items are never dropped.
71
+
72
+ ## Code Style
73
+
74
+ ### Comments
75
+ - Casual dev-note tone: lowercase, abbreviations ("bc", "thru", "doesnt"). No em-dashes, use commas and periods.
76
+ - Don't bloat but don't leave files bare. Comment: why a design choice was made, non-obvious blocks, gotchas, TODOs.
77
+ - Quick dev notes, not documentation.
78
+
79
+ ### Commits
80
+ - Small, incremental. One logical change per commit.
81
+ - Lowercase, start with action verb: `added`, `fixed`, `tweaked`, `wired up`, `moved`, `prevent`, `validate`.
82
+ - No conventional commit prefixes (`feat:`, `fix:`, `docs:`).
83
+ - Include the why when its not obvious from context.
84
+
85
+ ### Workflow
86
+ - Build step by step, commit each piece.
87
+ - Never dump an entire project in one shot.
88
+
89
+ ## Build Phases
90
+
91
+ ### Phase 1 — MVP
92
+ 1. `git.py`: diff extraction (uncommitted, staged, commit, range)
93
+ 2. `llm/anthropic.py`: send diff via tool use, parse structured response
94
+ 3. `reviewer.py`: single-chunk review (no splitting yet)
95
+ 4. `formatter.py`: Rich terminal output
96
+ 5. `cli.py`: wire together, local path only
97
+ 6. `config.py`: env var loading (no config file yet)
98
+ 7. Basic error handling: missing API key, empty diff, invalid repo path
99
+ 8. End-to-end test: `prrev .` on a real repo
100
+
101
+ ### Phase 2 — GitHub
102
+ 1. `github.py`: parse PR URL, fetch diff via httpx
103
+ 2. `cli.py`: detect URL target, route to github.py
104
+ 3. `github.py`: post review with inline comments via `--post`
105
+ 4. `GITHUB_TOKEN` support via env var
106
+
107
+ ### Phase 3 — Polish
108
+ 1. `llm/openai.py` and `--provider` / `--model` flags
109
+ 2. `config.py`: TOML config file loading
110
+ 3. `--output` flag for markdown export
111
+ 4. Diff chunking with actual token counting and async parallel review
112
+ 5. `--fail-on` flag and exit codes
113
+ 6. `--staged` flag
114
+ 7. Error handling: API rate limits, network failures, oversized diffs
115
+
116
+ ### Phase 4 — Ship
117
+ 1. README with usage, install, config reference
118
+ 2. LICENSE (MIT)
119
+ 3. Tag v0.1.0, publish to PyPI
prrev-0.1.0/PKG-INFO ADDED
@@ -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
prrev-0.1.0/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # PRRev
2
+
3
+ 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.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ git clone https://github.com/timurmamedov1/PRRev.git
9
+ cd PRRev
10
+ pip install -e .
11
+ ```
12
+
13
+ Requires Python 3.10+.
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ # review uncommitted changes
19
+ prrev .
20
+
21
+ # review only staged changes
22
+ prrev . --staged
23
+
24
+ # review a specific commit
25
+ prrev . --commit abc123
26
+
27
+ # review a commit range
28
+ prrev . --range abc123..def456
29
+
30
+ # review a GitHub PR
31
+ prrev https://github.com/user/repo/pull/42
32
+
33
+ # post review as inline PR comments
34
+ prrev https://github.com/user/repo/pull/42 --post
35
+
36
+ # write review to a markdown file
37
+ prrev . --output review.md
38
+
39
+ # fail in CI if there are warnings or worse
40
+ prrev . --fail-on warning
41
+ ```
42
+
43
+ ## Configuration
44
+
45
+ Set API keys as environment variables:
46
+
47
+ ```bash
48
+ export ANTHROPIC_API_KEY=sk-...
49
+ export OPENAI_API_KEY=sk-...
50
+ export GITHUB_TOKEN=ghp_...
51
+ ```
52
+
53
+ Or use a config file at `~/.config/prrev/config.toml`:
54
+
55
+ ```toml
56
+ [llm]
57
+ provider = "anthropic"
58
+ model = "claude-sonnet-4-20250514"
59
+ anthropic_api_key = "sk-..."
60
+
61
+ [github]
62
+ token = "ghp_..."
63
+
64
+ [review]
65
+ max_items = 20
66
+ ```
67
+
68
+ 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.
69
+
70
+ Precedence: CLI flags > env vars > repo config > global config > defaults.
71
+
72
+ ## Providers
73
+
74
+ ```bash
75
+ # use claude (default)
76
+ prrev . --provider anthropic
77
+
78
+ # use gpt-4o
79
+ prrev . --provider openai
80
+
81
+ # override the model
82
+ prrev . --provider anthropic --model claude-sonnet-4-20250514
83
+ ```
84
+
85
+ ## How It Works
86
+
87
+ 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.
88
+
89
+ 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.
90
+
91
+ ## Exit Codes
92
+
93
+ - `0:` review completed, no issues at or above `--fail-on` threshold
94
+ - `1:` issues found at or above `--fail-on` threshold
95
+ - `2:` tool error (missing API key, invalid args, network failure)
96
+
97
+ ## Tech Stack
98
+
99
+ - **Typer:** CLI framework
100
+ - **Rich:** terminal output with colored severity panels
101
+ - **GitPython:** local diff extraction
102
+ - **httpx:** GitHub API (fetch PR diffs, post inline comments)
103
+ - **Anthropic SDK:** Claude API with tool use for structured output
104
+ - **OpenAI SDK:** GPT-4o API with structured output
105
+ - **tiktoken:** token counting for OpenAI
@@ -0,0 +1,105 @@
1
+ ## PRRev
2
+
3
+ ### What it is
4
+
5
+ PRRev is a Python CLI tool that reviews code diffs using LLMs. You point it at a local git repo or a GitHub PR URL, it extracts the diff, sends it to Claude or GPT-4o, and outputs a structured review with severity ratings, file references, and line numbers.
6
+
7
+ ### Architecture
8
+
9
+ **End-to-end CLI flow:**
10
+ The CLI is a single Typer command using `@app.callback(invoke_without_command=True)` since there are no subcommands. The entry point checks if the target is a GitHub URL or a local path, extracts the diff accordingly, instantiates the right LLM provider, passes the diff through the reviewer orchestrator, and renders the result with Rich. The async review is run via `asyncio.run()` from the synchronous Typer callback.
11
+
12
+ **Diff extraction — local git:**
13
+ `git.py` uses GitPython's `Repo` class. It handles four modes: uncommitted changes (diffs against HEAD), staged only (`--cached`), specific commit (diffs commit against its parent, or against git's empty tree SHA for root commits), and commit range (passes `a..b` straight to `git diff`). It also handles the edge case where the repo has no commits yet by falling back to diffing the index.
14
+
15
+ **Diff extraction — GitHub API:**
16
+ `github.py` uses httpx to make two API calls: one `GET` to `/repos/{owner}/{repo}/pulls/{number}` for PR metadata (title), and a second `GET` to the same endpoint with the `Accept: application/vnd.github.v3.diff` header to get the full unified diff. Auth is via Bearer token from `GITHUB_TOKEN` env var.
17
+
18
+ **LLM abstraction layer:**
19
+ `llm/base.py` defines an abstract base class `LLMProvider` with three things: an async `review(diff) -> ReviewResult` method, a `count_tokens(text) -> int` method, and a `max_input_tokens` class variable. Both `AnthropicProvider` and `OpenAIProvider` subclass it. The CLI picks which to instantiate based on the `--provider` flag. The reviewer orchestrator only depends on the `LLMProvider` interface, it never knows which provider it's talking to.
20
+
21
+ **Structured output — Anthropic:**
22
+ Defines a `submit_review` tool with a JSON schema matching the `ReviewResult` structure. Passes it via the `tools` parameter and forces the model to use it with `tool_choice={"type": "tool", "name": "submit_review"}`. The response comes back as a `tool_use` content block, and we read `block.input` directly. No JSON text parsing needed.
23
+
24
+ **Structured output — OpenAI:**
25
+ Uses `response_format={"type": "json_schema", "json_schema": REVIEW_SCHEMA}` with `strict: True`. The model returns JSON in `response.choices[0].message.content` which is guaranteed to match the schema. We `json.loads()` that string.
26
+
27
+ **GitHub inline comments:**
28
+ When `--post` is used, each `ReviewItem` with a file and line number becomes an inline comment in the `comments` array of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`. Each comment has `path`, `line`, `side` ("RIGHT" for additions), and `body` (formatted with severity and explanation). Items without line numbers go into the top-level review body.
29
+
30
+ **Libraries and why:**
31
+ - Typer: CLI framework. Handles argument parsing, help text, and exit codes. Used instead of argparse because it's cleaner for type-annotated single-command CLIs.
32
+ - Rich: Terminal output. Panels, colored text, styled severity labels. Used for the review display.
33
+ - GitPython: Wraps git operations. Used to extract diffs from local repos without shelling out to git directly.
34
+ - httpx: Async HTTP client. Used only in `github.py` for GitHub API calls. Chosen over requests because it supports async and is the modern standard.
35
+ - Anthropic SDK: Official Python SDK. Used for the Claude API calls and token counting (`client.count_tokens()`).
36
+ - OpenAI SDK: Official Python SDK. Used for GPT-4o API calls with structured output.
37
+ - tiktoken: OpenAI's tokenizer library. Used for token counting on the OpenAI side since the OpenAI SDK doesn't have built-in token counting.
38
+
39
+ ### Hardest technical problem I solved
40
+
41
+ Getting structured output reliably from both LLM providers. The naive approach is prompting "return only valid JSON" and parsing the response text. This is fragile — models wrap JSON in markdown fences, add preamble text, or occasionally produce invalid JSON.
42
+
43
+ I tried raw JSON prompting first and it broke on multi-file diffs where the model would sometimes add commentary between JSON blocks. The fix was using each SDK's native structured output mechanism: Anthropic's tool use (define a tool with the schema, force the model to call it) and OpenAI's `response_format` with `strict: True`. Both guarantee the output matches the schema at the API level, not the prompt level. This means the parsing code never has to handle malformed JSON — it either gets a valid tool call or a valid JSON string.
44
+
45
+ ### Specific details I need to be able to say out loud
46
+
47
+ **Diff chunking and parallel review:**
48
+ The reviewer checks the diff's token count against 80% of the provider's max input tokens (180k for Claude, 110k for GPT-4o). If it exceeds the threshold, it splits the diff at `diff --git` boundaries into per-file chunks. Each chunk is reviewed in parallel using `asyncio.gather()`. Results are merged by combining all items and concatenating summaries. Files that individually exceed the token limit are skipped with a warning. Token counting uses the Anthropic SDK's `count_tokens()` for Claude and tiktoken for OpenAI.
49
+
50
+ **CLI interface:**
51
+ Single command `prrev` with one positional argument (local path or GitHub PR URL). Flags: `--commit` (specific commit), `--range` (commit range), `--staged` (staged changes only), `--provider` (anthropic or openai), `--model` (model override), `--post` (post as GitHub PR comment), `--output` (write markdown to file), `--fail-on` (exit 1 if issues at severity threshold). Exit codes: 0 for success, 1 for issues meeting `--fail-on` threshold, 2 for tool errors.
52
+
53
+ **Rich output:**
54
+ Header panel with "PRRev" title and file count subtitle. Each review item shows a colored severity label (red CRITICAL, yellow WARNING, green SUGGESTION), file:line reference in bold, one-line summary, and explanation. Summary panel at the bottom. Uses `rich.text.Text` for styled inline text and `rich.panel.Panel` for bordered sections.
55
+
56
+ **Provider-agnostic design at the code level:**
57
+ `LLMProvider` is an ABC with `review()`, `count_tokens()`, and `max_input_tokens`. Both providers subclass it. The CLI instantiates the right one based on `--provider` flag. The reviewer orchestrator's signature is `review_diff(provider: LLMProvider, diff: str)` — it calls `provider.count_tokens()` to decide chunking and `provider.review()` to do the actual review. It never imports or references either concrete provider.
58
+
59
+ ### What's weak or unfinished
60
+
61
+ **Done:**
62
+ - Full local diff extraction (uncommitted, staged, commit, range)
63
+ - Both LLM providers with structured output
64
+ - Reviewer with auto-chunking and parallel review
65
+ - Rich terminal formatter and markdown export
66
+ - GitHub PR fetching and inline comment posting
67
+ - Config loading from TOML files and env vars with precedence
68
+ - `--fail-on` flag with exit codes
69
+ - All CLI flags wired up
70
+
71
+ **Still in progress:**
72
+ - Test suite is partially written (URL parsing and diff splitting tests done, still need tests for reviewer, formatter, config, git, providers, and CLI integration)
73
+ - README is a placeholder (just the project name and one-line description)
74
+ - No LICENSE file yet
75
+ - Not published to PyPI yet
76
+
77
+ **What I'd improve:**
78
+ - The Anthropic `count_tokens()` creates a new synchronous client on every call. Should cache the client instance.
79
+ - Single-file diffs that exceed the context window are sent anyway with no splitting. Should split within a file by hunk boundaries.
80
+ - No retry logic for API rate limits or transient network failures.
81
+ - The `--post` flag hardcodes `side: "RIGHT"` for all inline comments. Should use `"LEFT"` for deleted lines.
82
+
83
+ ### Python relevance
84
+
85
+ **Async:** `asyncio.run()` as the entry point, `async/await` throughout providers and GitHub module, `asyncio.gather()` for parallel chunk reviews, `httpx.AsyncClient` and async SDK clients (`AsyncAnthropic`, `AsyncOpenAI`).
86
+
87
+ **Type hints:** Union types with `X | None` syntax (Python 3.10+), `list[str]`, `tuple[str, str, int]`, `dict`, keyword-only arguments with `*`, return type annotations on all functions.
88
+
89
+ **Abstract base classes:** `LLMProvider(ABC)` with `@abstractmethod` for `review()` and `count_tokens()`, class variable `max_input_tokens` overridden by subclasses.
90
+
91
+ **Dataclasses:** `ReviewItem`, `ReviewResult`, `Config`, `PRInfo` — all use `@dataclass` for structured data without boilerplate.
92
+
93
+ **CLI framework:** Typer with `@app.callback(invoke_without_command=True)`, `typer.Argument`, `typer.Option`, `typer.Exit` with exit codes.
94
+
95
+ **HTTP clients:** httpx `AsyncClient` with `base_url`, custom headers, `raise_for_status()`. Context manager usage with `async with`.
96
+
97
+ **SDK usage:** Anthropic SDK (tool use, tool_choice, async client, token counting). OpenAI SDK (structured output with response_format, json_schema, async client). tiktoken for tokenization.
98
+
99
+ **Project structure:** `src/` layout with `pyproject.toml`, hatchling build backend, dynamic versioning from `__init__.py`, optional dependencies for dev tooling, editable install with `pip install -e ".[dev]"`.
100
+
101
+ **Config management:** TOML parsing with `tomllib` (stdlib 3.11+) and `tomli` fallback for 3.10. Layered config precedence (CLI > env > repo config > global config > defaults). Walrus operator (`:=`) for concise env var loading. Security constraint: tokens never loaded from repo-root config.
102
+
103
+ **Testing:** pytest with `pytest-asyncio`, test classes, `pytest.raises` for error cases, parametric assertions.
104
+
105
+ **Other patterns:** Regex compilation with `re.compile()` for URL parsing. `pathlib.Path` throughout. f-strings. List comprehensions. Generator expressions with `any()`. Keyword-only function arguments. Context managers.
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "prrev"
3
+ dynamic = ["version"]
4
+ description = "CLI tool that reviews code diffs using LLMs"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [{ name = "Timur Mamedov", email = "tm412421@gmail.com" }]
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "typer>=0.12",
11
+ "rich>=13.0",
12
+ "gitpython>=3.1",
13
+ "httpx>=0.27",
14
+ "anthropic>=0.40",
15
+ "openai>=1.50",
16
+ "tiktoken>=0.7",
17
+ "tomli>=2.0; python_version < '3.11'",
18
+ ]
19
+
20
+ [project.scripts]
21
+ prrev = "prrev.cli:app"
22
+
23
+ [build-system]
24
+ requires = ["hatchling"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [tool.hatch.version]
28
+ path = "src/prrev/__init__.py"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/prrev"]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=8.0",
36
+ "pytest-asyncio>=0.24",
37
+ "ruff>=0.6",
38
+ ]
39
+
40
+ [tool.ruff]
41
+ target-version = "py310"
42
+ line-length = 100
43
+
44
+ [tool.ruff.lint]
45
+ select = ["E", "F", "I", "N", "UP", "B", "SIM"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ asyncio_mode = "auto"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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)