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 +11 -0
- prrev-0.1.0/CLAUDE.md +119 -0
- prrev-0.1.0/PKG-INFO +126 -0
- prrev-0.1.0/README.md +105 -0
- prrev-0.1.0/interview-prep-prrev.md +105 -0
- prrev-0.1.0/pyproject.toml +49 -0
- prrev-0.1.0/src/prrev/__init__.py +1 -0
- prrev-0.1.0/src/prrev/cli.py +130 -0
- prrev-0.1.0/src/prrev/config.py +89 -0
- prrev-0.1.0/src/prrev/formatter.py +72 -0
- prrev-0.1.0/src/prrev/git.py +56 -0
- prrev-0.1.0/src/prrev/github.py +95 -0
- prrev-0.1.0/src/prrev/llm/__init__.py +0 -0
- prrev-0.1.0/src/prrev/llm/anthropic.py +107 -0
- prrev-0.1.0/src/prrev/llm/base.py +30 -0
- prrev-0.1.0/src/prrev/llm/openai.py +107 -0
- prrev-0.1.0/src/prrev/reviewer.py +102 -0
- prrev-0.1.0/tests/__init__.py +0 -0
- prrev-0.1.0/tests/test_cli.py +114 -0
- prrev-0.1.0/tests/test_config.py +112 -0
- prrev-0.1.0/tests/test_formatter.py +93 -0
- prrev-0.1.0/tests/test_git.py +95 -0
- prrev-0.1.0/tests/test_parsing.py +92 -0
- prrev-0.1.0/tests/test_providers.py +145 -0
- prrev-0.1.0/tests/test_reviewer.py +91 -0
prrev-0.1.0/.gitignore
ADDED
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)
|