rpr 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.
rpr-0.1.0/.gitignore ADDED
@@ -0,0 +1,62 @@
1
+ # --- Python ---
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg
6
+ *.egg-info/
7
+ .eggs/
8
+ build/
9
+ dist/
10
+ wheels/
11
+ .installed.cfg
12
+ *.manifest
13
+ *.spec
14
+ pip-log.txt
15
+ pip-delete-this-directory.txt
16
+
17
+ # Virtual environments
18
+ .venv/
19
+ .venv-*/
20
+ venv/
21
+ env/
22
+ ENV/
23
+
24
+ # Test / lint caches
25
+ .pytest_cache/
26
+ .mypy_cache/
27
+ .ruff_cache/
28
+ .tox/
29
+ .coverage
30
+ .coverage.*
31
+ htmlcov/
32
+ .cache/
33
+
34
+ # --- Node / npm ---
35
+ node_modules/
36
+ *.tgz
37
+ .npm/
38
+
39
+ # --- Editors / IDEs ---
40
+ .idea/
41
+ .vscode/
42
+ *.swp
43
+ *.swo
44
+ *~
45
+
46
+ # --- macOS ---
47
+ .DS_Store
48
+ .AppleDouble
49
+ .LSOverride
50
+
51
+ # --- Project-local secrets ---
52
+ .env
53
+ .env.local
54
+ *.local.json
55
+
56
+ # --- Claude Code ---
57
+ .claude/settings.local.json
58
+ .claude/.credentials.json
59
+
60
+ # --- Logs ---
61
+ *.log
62
+ logs/
rpr-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dedev-llc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
rpr-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,223 @@
1
+ Metadata-Version: 2.4
2
+ Name: rpr
3
+ Version: 0.1.0
4
+ Summary: Stealth PR reviewer — looks like you wrote every word.
5
+ Project-URL: Homepage, https://github.com/dedev-llc/rpr
6
+ Project-URL: Repository, https://github.com/dedev-llc/rpr
7
+ Project-URL: Issues, https://github.com/dedev-llc/rpr/issues
8
+ Author: dedev-llc
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 dedev-llc
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: ai,claude,code-review,github,pr,review
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Environment :: Console
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3 :: Only
39
+ Classifier: Programming Language :: Python :: 3.9
40
+ Classifier: Programming Language :: Python :: 3.10
41
+ Classifier: Programming Language :: Python :: 3.11
42
+ Classifier: Programming Language :: Python :: 3.12
43
+ Classifier: Topic :: Software Development :: Quality Assurance
44
+ Classifier: Topic :: Software Development :: Version Control :: Git
45
+ Requires-Python: >=3.9
46
+ Description-Content-Type: text/markdown
47
+
48
+ # rpr — Stealth PR Reviewer
49
+
50
+ AI-powered PR reviews that look like you wrote them. No GitHub Apps, no bots, no traces.
51
+
52
+ ## What It Does
53
+
54
+ 1. Fetches the PR diff from GitHub
55
+ 2. Sends it to Claude with a prompt engineered to sound like a senior engineer (not AI)
56
+ 3. Posts the review under **your GitHub account** inline comments on specific lines
57
+
58
+ Nobody can tell the difference.
59
+
60
+ ## Install
61
+
62
+ | Channel | Command |
63
+ |---|---|
64
+ | **pipx** (recommended) | `pipx install rpr` |
65
+ | **pip** | `pip install --user rpr` |
66
+ | **Homebrew** | `brew install dedev-llc/rpr/rpr` |
67
+ | **npm** | `npm install -g rpr` |
68
+ | **npx** | `npx rpr <pr-number>` |
69
+ | **curl** | `curl -fsSL https://raw.githubusercontent.com/dedev-llc/rpr/main/install.sh \| bash` |
70
+
71
+ All channels install the same `rpr` command. You'll also need:
72
+
73
+ - **GitHub CLI (`gh`)** — [install](https://cli.github.com/), then `gh auth login`
74
+ - **Anthropic API key** — `export ANTHROPIC_API_KEY=sk-ant-...` ([get one](https://console.anthropic.com/))
75
+ - **Python 3.9+** (already required by all channels except npm/npx, which need it on PATH at runtime)
76
+
77
+ ## Usage
78
+
79
+ ```bash
80
+ # In any git repo:
81
+ rpr 42 # Review PR #42
82
+ rpr 42 --dry-run # Preview in terminal first
83
+ rpr 42 --approve # Review + approve
84
+ rpr 42 --request-changes # Review + request changes
85
+ rpr 42 --comment-only # Post as single comment (no inline)
86
+ rpr 42 --repo owner/repo # Specify repo explicitly
87
+ rpr 42 --model claude-sonnet-4-6 # Override model
88
+ rpr 42 -v # Verbose mode (debug)
89
+ ```
90
+
91
+ ### Recommended Workflow
92
+
93
+ 1. **Always dry-run first** until you trust the output:
94
+ ```bash
95
+ rpr 42 --dry-run
96
+ ```
97
+
98
+ 2. If it looks right, post it:
99
+ ```bash
100
+ rpr 42
101
+ ```
102
+
103
+ 3. Optionally tweak a word or two in the posted review for your personal touch.
104
+
105
+ ## Configuration
106
+
107
+ `rpr` ships with sensible defaults. To override them, drop a config file at one of these paths (first match wins):
108
+
109
+ 1. `./rpr.config.json` — project-local override (per-repo)
110
+ 2. `~/.config/rpr/config.json` — user-wide
111
+
112
+ Example (see `examples/config.json`):
113
+
114
+ ```json
115
+ {
116
+ "model": "claude-opus-4-6",
117
+ "max_tokens": 16000,
118
+ "max_diff_chars": 120000,
119
+ "skip_patterns": [
120
+ "*.lock",
121
+ "*.g.dart",
122
+ "*.freezed.dart"
123
+ ]
124
+ }
125
+ ```
126
+
127
+ | Key | Meaning |
128
+ |---|---|
129
+ | `model` | Claude model to use |
130
+ | `max_tokens` | Max response length |
131
+ | `max_diff_chars` | Truncate diffs larger than this |
132
+ | `skip_patterns` | Glob patterns for files to ignore (generated code, locks, etc.) |
133
+
134
+ ### Custom review guidelines
135
+
136
+ Inject your team's coding standards by dropping a `review-guidelines.md` at:
137
+
138
+ 1. `./review-guidelines.md` — project-local (per-repo)
139
+ 2. `~/.config/rpr/review-guidelines.md` — user-wide
140
+
141
+ The contents get appended to the system prompt and the AI enforces them as if they were your personal standards. See `examples/review-guidelines.md` for a starter template.
142
+
143
+ ## How It Stays Invisible
144
+
145
+ | Concern | Solution |
146
+ |---|---|
147
+ | GitHub Actions history | None — runs locally on your machine |
148
+ | Workflow files in repo | None — no `.github/workflows` needed |
149
+ | Bot label on comments | None — uses your PAT via `gh` CLI |
150
+ | AI-sounding language | Prompt is engineered to sound human |
151
+ | Review pattern detection | Varies tone, uses informal language |
152
+
153
+ ## Cost
154
+
155
+ Each review costs roughly $0.01–$0.08 depending on diff size and model:
156
+
157
+ - Small PR (< 200 lines): ~$0.01
158
+ - Medium PR (200–1000 lines): ~$0.03
159
+ - Large PR (1000+ lines): ~$0.05–0.08
160
+
161
+ ## Tips
162
+
163
+ - **Edit after posting**: Tweak a word or two in your posted review for authenticity.
164
+ - **Use `--dry-run` liberally**: Especially on important PRs.
165
+ - **Customize guidelines**: The more specific your `review-guidelines.md`, the better the reviews match your real style.
166
+ - **Skip generated files**: Add patterns for code generators your team uses (Freezed, json_serializable, etc.) to avoid noise.
167
+
168
+ ## Development
169
+
170
+ ```bash
171
+ git clone https://github.com/dedev-llc/rpr
172
+ cd rpr
173
+ python -m venv .venv && source .venv/bin/activate
174
+ pip install -e . # editable install — `rpr` command works from anywhere
175
+ rpr --help
176
+ ```
177
+
178
+ Run as a module: `python -m rpr 42 --dry-run`
179
+
180
+ > **macOS gotcha**: do **not** clone the repo into `~/Desktop` (or `~/Documents`). macOS sets the `UF_HIDDEN` file flag on everything inside those App Sandbox directories, which makes Python 3.13's `site.py` skip the editable install's `.pth` file (it treats hidden-flagged files as hidden, regardless of name). The published wheel from PyPI is unaffected — this only bites editable dev installs in sandboxed dirs. Symptom: `ModuleNotFoundError: No module named 'rpr'` after `pip install -e .`. Fix: clone to `~/code/rpr` or somewhere outside the sandbox.
181
+
182
+ ## Publishing (maintainer notes)
183
+
184
+ The PyPI package is the source of truth. Other channels wrap it.
185
+
186
+ ### PyPI
187
+
188
+ ```bash
189
+ pip install build twine
190
+ python -m build # produces dist/rpr-X.Y.Z.tar.gz + .whl
191
+ twine upload dist/* # requires PyPI account + token
192
+ ```
193
+
194
+ ### Homebrew formula
195
+
196
+ After publishing to PyPI, update `Formula/rpr.rb`:
197
+
198
+ ```bash
199
+ SHA=$(curl -sL https://files.pythonhosted.org/packages/source/r/rpr/rpr-0.1.0.tar.gz | shasum -a 256 | cut -d' ' -f1)
200
+ # Edit Formula/rpr.rb: replace the placeholder sha256 with $SHA, bump version in url
201
+ git commit -am "brew: bump rpr to 0.1.0" && git push
202
+ ```
203
+
204
+ Users install via: `brew install dedev-llc/rpr/rpr`
205
+
206
+ ### npm
207
+
208
+ ```bash
209
+ cd npm
210
+ npm version 0.1.0 --no-git-tag-version # match pyproject.toml version
211
+ npm pack --dry-run # sanity check: should list bin/ + lib/
212
+ npm publish # requires npm login
213
+ ```
214
+
215
+ The `prepack` hook copies `src/rpr/*.py` into `npm/lib/` automatically.
216
+
217
+ ### Release checklist
218
+
219
+ 1. Bump version in `pyproject.toml`, `src/rpr/__init__.py`, `npm/package.json`, `Formula/rpr.rb` (`url`)
220
+ 2. Tag: `git tag v0.1.0 && git push --tags`
221
+ 3. Publish to PyPI (above)
222
+ 4. Update Homebrew formula sha256 (above)
223
+ 5. Publish to npm (above)
rpr-0.1.0/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # rpr — Stealth PR Reviewer
2
+
3
+ AI-powered PR reviews that look like you wrote them. No GitHub Apps, no bots, no traces.
4
+
5
+ ## What It Does
6
+
7
+ 1. Fetches the PR diff from GitHub
8
+ 2. Sends it to Claude with a prompt engineered to sound like a senior engineer (not AI)
9
+ 3. Posts the review under **your GitHub account** inline comments on specific lines
10
+
11
+ Nobody can tell the difference.
12
+
13
+ ## Install
14
+
15
+ | Channel | Command |
16
+ |---|---|
17
+ | **pipx** (recommended) | `pipx install rpr` |
18
+ | **pip** | `pip install --user rpr` |
19
+ | **Homebrew** | `brew install dedev-llc/rpr/rpr` |
20
+ | **npm** | `npm install -g rpr` |
21
+ | **npx** | `npx rpr <pr-number>` |
22
+ | **curl** | `curl -fsSL https://raw.githubusercontent.com/dedev-llc/rpr/main/install.sh \| bash` |
23
+
24
+ All channels install the same `rpr` command. You'll also need:
25
+
26
+ - **GitHub CLI (`gh`)** — [install](https://cli.github.com/), then `gh auth login`
27
+ - **Anthropic API key** — `export ANTHROPIC_API_KEY=sk-ant-...` ([get one](https://console.anthropic.com/))
28
+ - **Python 3.9+** (already required by all channels except npm/npx, which need it on PATH at runtime)
29
+
30
+ ## Usage
31
+
32
+ ```bash
33
+ # In any git repo:
34
+ rpr 42 # Review PR #42
35
+ rpr 42 --dry-run # Preview in terminal first
36
+ rpr 42 --approve # Review + approve
37
+ rpr 42 --request-changes # Review + request changes
38
+ rpr 42 --comment-only # Post as single comment (no inline)
39
+ rpr 42 --repo owner/repo # Specify repo explicitly
40
+ rpr 42 --model claude-sonnet-4-6 # Override model
41
+ rpr 42 -v # Verbose mode (debug)
42
+ ```
43
+
44
+ ### Recommended Workflow
45
+
46
+ 1. **Always dry-run first** until you trust the output:
47
+ ```bash
48
+ rpr 42 --dry-run
49
+ ```
50
+
51
+ 2. If it looks right, post it:
52
+ ```bash
53
+ rpr 42
54
+ ```
55
+
56
+ 3. Optionally tweak a word or two in the posted review for your personal touch.
57
+
58
+ ## Configuration
59
+
60
+ `rpr` ships with sensible defaults. To override them, drop a config file at one of these paths (first match wins):
61
+
62
+ 1. `./rpr.config.json` — project-local override (per-repo)
63
+ 2. `~/.config/rpr/config.json` — user-wide
64
+
65
+ Example (see `examples/config.json`):
66
+
67
+ ```json
68
+ {
69
+ "model": "claude-opus-4-6",
70
+ "max_tokens": 16000,
71
+ "max_diff_chars": 120000,
72
+ "skip_patterns": [
73
+ "*.lock",
74
+ "*.g.dart",
75
+ "*.freezed.dart"
76
+ ]
77
+ }
78
+ ```
79
+
80
+ | Key | Meaning |
81
+ |---|---|
82
+ | `model` | Claude model to use |
83
+ | `max_tokens` | Max response length |
84
+ | `max_diff_chars` | Truncate diffs larger than this |
85
+ | `skip_patterns` | Glob patterns for files to ignore (generated code, locks, etc.) |
86
+
87
+ ### Custom review guidelines
88
+
89
+ Inject your team's coding standards by dropping a `review-guidelines.md` at:
90
+
91
+ 1. `./review-guidelines.md` — project-local (per-repo)
92
+ 2. `~/.config/rpr/review-guidelines.md` — user-wide
93
+
94
+ The contents get appended to the system prompt and the AI enforces them as if they were your personal standards. See `examples/review-guidelines.md` for a starter template.
95
+
96
+ ## How It Stays Invisible
97
+
98
+ | Concern | Solution |
99
+ |---|---|
100
+ | GitHub Actions history | None — runs locally on your machine |
101
+ | Workflow files in repo | None — no `.github/workflows` needed |
102
+ | Bot label on comments | None — uses your PAT via `gh` CLI |
103
+ | AI-sounding language | Prompt is engineered to sound human |
104
+ | Review pattern detection | Varies tone, uses informal language |
105
+
106
+ ## Cost
107
+
108
+ Each review costs roughly $0.01–$0.08 depending on diff size and model:
109
+
110
+ - Small PR (< 200 lines): ~$0.01
111
+ - Medium PR (200–1000 lines): ~$0.03
112
+ - Large PR (1000+ lines): ~$0.05–0.08
113
+
114
+ ## Tips
115
+
116
+ - **Edit after posting**: Tweak a word or two in your posted review for authenticity.
117
+ - **Use `--dry-run` liberally**: Especially on important PRs.
118
+ - **Customize guidelines**: The more specific your `review-guidelines.md`, the better the reviews match your real style.
119
+ - **Skip generated files**: Add patterns for code generators your team uses (Freezed, json_serializable, etc.) to avoid noise.
120
+
121
+ ## Development
122
+
123
+ ```bash
124
+ git clone https://github.com/dedev-llc/rpr
125
+ cd rpr
126
+ python -m venv .venv && source .venv/bin/activate
127
+ pip install -e . # editable install — `rpr` command works from anywhere
128
+ rpr --help
129
+ ```
130
+
131
+ Run as a module: `python -m rpr 42 --dry-run`
132
+
133
+ > **macOS gotcha**: do **not** clone the repo into `~/Desktop` (or `~/Documents`). macOS sets the `UF_HIDDEN` file flag on everything inside those App Sandbox directories, which makes Python 3.13's `site.py` skip the editable install's `.pth` file (it treats hidden-flagged files as hidden, regardless of name). The published wheel from PyPI is unaffected — this only bites editable dev installs in sandboxed dirs. Symptom: `ModuleNotFoundError: No module named 'rpr'` after `pip install -e .`. Fix: clone to `~/code/rpr` or somewhere outside the sandbox.
134
+
135
+ ## Publishing (maintainer notes)
136
+
137
+ The PyPI package is the source of truth. Other channels wrap it.
138
+
139
+ ### PyPI
140
+
141
+ ```bash
142
+ pip install build twine
143
+ python -m build # produces dist/rpr-X.Y.Z.tar.gz + .whl
144
+ twine upload dist/* # requires PyPI account + token
145
+ ```
146
+
147
+ ### Homebrew formula
148
+
149
+ After publishing to PyPI, update `Formula/rpr.rb`:
150
+
151
+ ```bash
152
+ SHA=$(curl -sL https://files.pythonhosted.org/packages/source/r/rpr/rpr-0.1.0.tar.gz | shasum -a 256 | cut -d' ' -f1)
153
+ # Edit Formula/rpr.rb: replace the placeholder sha256 with $SHA, bump version in url
154
+ git commit -am "brew: bump rpr to 0.1.0" && git push
155
+ ```
156
+
157
+ Users install via: `brew install dedev-llc/rpr/rpr`
158
+
159
+ ### npm
160
+
161
+ ```bash
162
+ cd npm
163
+ npm version 0.1.0 --no-git-tag-version # match pyproject.toml version
164
+ npm pack --dry-run # sanity check: should list bin/ + lib/
165
+ npm publish # requires npm login
166
+ ```
167
+
168
+ The `prepack` hook copies `src/rpr/*.py` into `npm/lib/` automatically.
169
+
170
+ ### Release checklist
171
+
172
+ 1. Bump version in `pyproject.toml`, `src/rpr/__init__.py`, `npm/package.json`, `Formula/rpr.rb` (`url`)
173
+ 2. Tag: `git tag v0.1.0 && git push --tags`
174
+ 3. Publish to PyPI (above)
175
+ 4. Update Homebrew formula sha256 (above)
176
+ 5. Publish to npm (above)
@@ -0,0 +1,24 @@
1
+ {
2
+ "model": "claude-opus-4-6",
3
+ "max_tokens": 16000,
4
+ "max_diff_chars": 120000,
5
+ "review_style": "senior_engineer",
6
+ "skip_patterns": [
7
+ "*.lock",
8
+ "*.g.dart",
9
+ "*.freezed.dart",
10
+ "*.gen.dart",
11
+ "*.mocks.dart",
12
+ "pubspec.lock",
13
+ "package-lock.json",
14
+ "yarn.lock",
15
+ "Podfile.lock",
16
+ "*.pbxproj",
17
+ "*.min.js",
18
+ "*.min.css",
19
+ "*.map",
20
+ "ios/Runner.xcodeproj/*",
21
+ "android/app/src/main/res/*",
22
+ ".dart_tool/*"
23
+ ]
24
+ }
@@ -0,0 +1,43 @@
1
+ # Review Guidelines
2
+
3
+ These are injected into every review to enforce team standards.
4
+ Edit this file to match your project's conventions.
5
+
6
+ ## Architecture
7
+ - We use Clean Architecture with BLoC pattern
8
+ - Feature-first folder structure: lib/features/<feature>/{data,domain,presentation}
9
+ - Repository pattern for data layer — no direct API calls from BLoC
10
+ - Use cases should be single-responsibility
11
+
12
+ ## Dart / Flutter
13
+ - Prefer `final` over `var` wherever possible
14
+ - Use `sealed` classes for state modeling
15
+ - Freezed unions for complex states, simple classes for straightforward ones
16
+ - No business logic in widgets — everything through BLoC
17
+ - Dispose streams and controllers properly
18
+ - Handle all error states — no silent failures
19
+ - Use `Either<Failure, Success>` pattern for repository returns
20
+
21
+ ## State Management
22
+ - BLoC for feature-level state
23
+ - No nested BlocBuilders unless absolutely necessary
24
+ - Use BlocSelector for granular rebuilds
25
+ - Events should be past-tense verbs (e.g., `LoginSubmitted`, `UserProfileLoaded`)
26
+
27
+ ## API & Networking
28
+ - All API calls must have proper error handling with timeout
29
+ - Use interceptors for auth token refresh
30
+ - DTOs for API responses, separate domain models for business logic
31
+ - Never expose raw API models to the presentation layer
32
+
33
+ ## Security
34
+ - No hardcoded API keys or secrets
35
+ - Validate all user input
36
+ - Sanitize data before rendering in WebViews
37
+ - Use secure storage for tokens
38
+
39
+ ## Performance
40
+ - Avoid unnecessary rebuilds (const constructors, proper keys)
41
+ - Lazy loading for lists and heavy content
42
+ - Image caching and proper sizing
43
+ - No synchronous heavy computation on main isolate
@@ -0,0 +1,33 @@
1
+ # rpr
2
+
3
+ Stealth PR reviewer — looks like you wrote every word.
4
+
5
+ This is the npm distribution of `rpr`. It bundles the Python source and runs it via your local `python3`. You'll need:
6
+
7
+ - **Python 3.9+** on your `PATH`
8
+ - **GitHub CLI (`gh`)**, authenticated
9
+ - **`ANTHROPIC_API_KEY`** environment variable
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install -g rpr
15
+ # or
16
+ npx rpr <pr-number>
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ rpr 42 # Review PR #42 in current repo
23
+ rpr 42 --dry-run # Print review, don't post
24
+ rpr 42 --approve # Review + approve
25
+ rpr 42 --request-changes # Review + request changes
26
+ rpr 42 --repo owner/repo # Specify a different repo
27
+ ```
28
+
29
+ See the [main repo](https://github.com/dedev-llc/rpr) for full docs and configuration.
30
+
31
+ ## Why a Node wrapper around Python?
32
+
33
+ `rpr` is written in Python (stdlib only). The npm package is a thin Node shim that finds your `python3` and execs the bundled CLI. If you're already a Python user, install via `pipx install rpr` instead — it's more idiomatic.
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ # hatchling >=1.27 emits editable .pth files without a leading underscore,
3
+ # which Python 3.13's site.py would otherwise treat as "hidden" and skip.
4
+ requires = ["hatchling>=1.27"]
5
+ build-backend = "hatchling.build"
6
+
7
+ [project]
8
+ name = "rpr"
9
+ version = "0.1.0"
10
+ description = "Stealth PR reviewer — looks like you wrote every word."
11
+ readme = "README.md"
12
+ license = { file = "LICENSE" }
13
+ requires-python = ">=3.9"
14
+ authors = [{ name = "dedev-llc" }]
15
+ keywords = ["pr", "review", "github", "claude", "ai", "code-review"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.9",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Topic :: Software Development :: Quality Assurance",
29
+ "Topic :: Software Development :: Version Control :: Git",
30
+ ]
31
+ dependencies = []
32
+
33
+ [project.scripts]
34
+ rpr = "rpr.cli:main"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/dedev-llc/rpr"
38
+ Repository = "https://github.com/dedev-llc/rpr"
39
+ Issues = "https://github.com/dedev-llc/rpr/issues"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/rpr"]
43
+
44
+ [tool.hatch.build.targets.sdist]
45
+ include = [
46
+ "src/rpr",
47
+ "README.md",
48
+ "LICENSE",
49
+ "examples",
50
+ ]
@@ -0,0 +1,3 @@
1
+ """rpr — stealth PR reviewer."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,727 @@
1
+ """
2
+ Stealth PR Reviewer — looks like you wrote every word.
3
+
4
+ Usage:
5
+ rpr 42 # Review PR #42 in current repo
6
+ rpr 42 --repo owner/repo # Review PR #42 in specific repo
7
+ rpr 42 --comment-only # Post as a single PR comment (not inline review)
8
+ rpr 42 --dry-run # Print review to terminal, don't post
9
+ rpr 42 --approve # Post review + approve the PR
10
+ rpr 42 --request-changes # Post review + request changes
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ import urllib.error
19
+ import urllib.request
20
+ from pathlib import Path
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Config
24
+ # ---------------------------------------------------------------------------
25
+
26
+ DEFAULT_CONFIG = {
27
+ "model": "claude-opus-4-6",
28
+ "max_tokens": 16000,
29
+ "max_diff_chars": 120000,
30
+ "review_style": "senior_engineer",
31
+ "skip_patterns": [
32
+ "*.lock",
33
+ "*.g.dart",
34
+ "*.freezed.dart",
35
+ "*.gen.dart",
36
+ "*.mocks.dart",
37
+ "pubspec.lock",
38
+ "package-lock.json",
39
+ "yarn.lock",
40
+ "Podfile.lock",
41
+ "*.pbxproj",
42
+ ],
43
+ }
44
+
45
+
46
+ def _xdg_config_home() -> Path:
47
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
48
+
49
+
50
+ def _config_search_paths() -> list[Path]:
51
+ """Search order for the user's config file. First match wins."""
52
+ return [
53
+ Path.cwd() / "rpr.config.json", # project-local override
54
+ _xdg_config_home() / "rpr" / "config.json", # user config
55
+ ]
56
+
57
+
58
+ def _guidelines_search_paths() -> list[Path]:
59
+ """Search order for the review-guidelines.md file. First match wins."""
60
+ return [
61
+ Path.cwd() / "review-guidelines.md",
62
+ _xdg_config_home() / "rpr" / "review-guidelines.md",
63
+ ]
64
+
65
+
66
+ def load_config() -> dict:
67
+ config = DEFAULT_CONFIG.copy()
68
+ for p in _config_search_paths():
69
+ if p.exists():
70
+ with open(p) as f:
71
+ config.update(json.load(f))
72
+ break
73
+ return config
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # GitHub helpers (uses `gh` CLI — no extra dependencies)
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ def run_gh(args: list, repo: str | None = None) -> str:
82
+ cmd = ["gh"]
83
+ if repo:
84
+ cmd += ["--repo", repo]
85
+ cmd += args
86
+ result = subprocess.run(cmd, capture_output=True, text=True)
87
+ if result.returncode != 0:
88
+ print(f"āŒ gh error: {result.stderr.strip()}", file=sys.stderr)
89
+ sys.exit(1)
90
+ return result.stdout
91
+
92
+
93
+ def get_pr_info(pr_number: int, repo: str | None) -> dict:
94
+ """Fetch PR metadata."""
95
+ raw = run_gh(
96
+ [
97
+ "pr",
98
+ "view",
99
+ str(pr_number),
100
+ "--json",
101
+ "title,body,baseRefName,headRefName,files,url,additions,deletions",
102
+ ],
103
+ repo,
104
+ )
105
+ return json.loads(raw)
106
+
107
+
108
+ def get_pr_diff(pr_number: int, repo: str | None) -> str:
109
+ """Fetch the unified diff."""
110
+ return run_gh(["pr", "diff", str(pr_number)], repo)
111
+
112
+
113
+ def get_pr_files(pr_number: int, repo: str | None) -> list:
114
+ """Fetch changed files with patches via API."""
115
+ raw = run_gh(
116
+ ["api", f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/files", "--paginate"],
117
+ repo,
118
+ )
119
+ return json.loads(raw)
120
+
121
+
122
+ def post_review_comment(pr_number: int, body: str, repo: str | None):
123
+ """Post a simple comment on the PR (not an inline review)."""
124
+ run_gh(["pr", "comment", str(pr_number), "--body", body], repo)
125
+
126
+
127
+ def post_review(
128
+ pr_number: int,
129
+ body: str,
130
+ comments: list,
131
+ event: str,
132
+ repo: str | None,
133
+ ):
134
+ """
135
+ Submit a pull request review with optional inline comments.
136
+ event: COMMENT | APPROVE | REQUEST_CHANGES
137
+ """
138
+ # Build the review payload
139
+ payload = {"event": event, "body": body}
140
+
141
+ if comments:
142
+ payload["comments"] = []
143
+ for c in comments:
144
+ entry = {
145
+ "path": c["path"],
146
+ "body": c["body"],
147
+ }
148
+ # Use line-based positioning
149
+ if c.get("line"):
150
+ entry["line"] = c["line"]
151
+ entry["side"] = "RIGHT"
152
+ elif c.get("position"):
153
+ entry["position"] = c["position"]
154
+
155
+ # Multi-line comment support
156
+ if c.get("start_line") and c.get("line") and c["start_line"] != c["line"]:
157
+ entry["start_line"] = c["start_line"]
158
+ entry["start_side"] = "RIGHT"
159
+
160
+ payload["comments"].append(entry)
161
+
162
+ payload_json = json.dumps(payload)
163
+
164
+ # Use gh api to submit the review
165
+ result = subprocess.run(
166
+ _gh_cmd(
167
+ [
168
+ "api",
169
+ f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/reviews",
170
+ "--method",
171
+ "POST",
172
+ "--input",
173
+ "-",
174
+ ],
175
+ repo,
176
+ ),
177
+ input=payload_json,
178
+ capture_output=True,
179
+ text=True,
180
+ )
181
+
182
+ if result.returncode != 0:
183
+ err = result.stderr.strip()
184
+ # If inline comments fail (common with line number mismatches),
185
+ # fall back to posting as a single review body
186
+ if "pull_request_review_thread" in err or "Validation" in err:
187
+ print("āš ļø Inline comments failed, falling back to body-only review...", file=sys.stderr)
188
+ fallback_body = body + "\n\n---\n\n"
189
+ for c in comments:
190
+ fallback_body += f"**`{c['path']}`**"
191
+ if c.get("line"):
192
+ fallback_body += f" (line {c['line']})"
193
+ fallback_body += f"\n{c['body']}\n\n"
194
+
195
+ fallback_payload = json.dumps({"event": event, "body": fallback_body})
196
+ subprocess.run(
197
+ _gh_cmd(
198
+ [
199
+ "api",
200
+ f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/reviews",
201
+ "--method",
202
+ "POST",
203
+ "--input",
204
+ "-",
205
+ ],
206
+ repo,
207
+ ),
208
+ input=fallback_payload,
209
+ capture_output=True,
210
+ text=True,
211
+ )
212
+ else:
213
+ print(f"āŒ Review submission error: {err}", file=sys.stderr)
214
+ sys.exit(1)
215
+
216
+
217
+ def _gh_cmd(args: list, repo: str | None) -> list:
218
+ cmd = ["gh"]
219
+ if repo:
220
+ cmd += ["--repo", repo]
221
+ cmd += args
222
+ return cmd
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Diff filtering
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ def should_skip_file(filename: str, skip_patterns: list) -> bool:
231
+ from fnmatch import fnmatch
232
+ return any(fnmatch(filename, pat) for pat in skip_patterns)
233
+
234
+
235
+ def filter_diff(diff: str, skip_patterns: list) -> str:
236
+ """Remove diff hunks for files matching skip patterns."""
237
+ lines = diff.split("\n")
238
+ filtered = []
239
+ skip_current = False
240
+ current_file = None
241
+
242
+ for line in lines:
243
+ if line.startswith("diff --git"):
244
+ # Extract filename: diff --git a/path b/path
245
+ parts = line.split(" b/")
246
+ current_file = parts[-1] if len(parts) > 1 else None
247
+ skip_current = current_file and should_skip_file(current_file, skip_patterns)
248
+
249
+ if not skip_current:
250
+ filtered.append(line)
251
+
252
+ return "\n".join(filtered)
253
+
254
+
255
+ def truncate_diff(diff: str, max_chars: int) -> tuple:
256
+ """Truncate diff if too large, return (diff, was_truncated)."""
257
+ if len(diff) <= max_chars:
258
+ return diff, False
259
+ return diff[:max_chars] + "\n\n... [diff truncated due to size]", True
260
+
261
+
262
+ def parse_diff_lines(diff: str) -> dict:
263
+ """
264
+ Parse a unified diff and extract the actual NEW-SIDE line numbers
265
+ that were added or modified per file. This is the source of truth
266
+ for which lines Claude is allowed to comment on.
267
+
268
+ Returns: {"path/to/file.dart": [12, 13, 14, 55, 56], ...}
269
+ """
270
+ import re
271
+ file_lines: dict = {}
272
+ current_file = None
273
+ current_line = 0
274
+
275
+ for raw_line in diff.split("\n"):
276
+ # New file in diff: diff --git a/path b/path
277
+ if raw_line.startswith("diff --git"):
278
+ parts = raw_line.split(" b/")
279
+ current_file = parts[-1] if len(parts) > 1 else None
280
+ if current_file and current_file not in file_lines:
281
+ file_lines[current_file] = []
282
+ continue
283
+
284
+ # Hunk header: @@ -old_start,old_count +new_start,new_count @@
285
+ hunk_match = re.match(r'^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@', raw_line)
286
+ if hunk_match:
287
+ current_line = int(hunk_match.group(1))
288
+ continue
289
+
290
+ if current_file is None:
291
+ continue
292
+
293
+ # Added or modified line (starts with +, but not +++ header)
294
+ if raw_line.startswith("+") and not raw_line.startswith("+++"):
295
+ file_lines[current_file].append(current_line)
296
+ current_line += 1
297
+ # Removed line (starts with -, but not --- header) — doesn't advance new-side counter
298
+ elif raw_line.startswith("-") and not raw_line.startswith("---"):
299
+ pass
300
+ # Context line (unchanged) — advances new-side counter
301
+ elif not raw_line.startswith("\\"):
302
+ current_line += 1
303
+
304
+ # Remove files with no changed lines
305
+ return {f: lines for f, lines in file_lines.items() if lines}
306
+
307
+
308
+ def build_valid_lines_summary(valid_lines: dict) -> str:
309
+ """
310
+ Build a compact summary of which files and line ranges were changed.
311
+ e.g. "lib/auth.dart: 12-18, 45-50, 88"
312
+ """
313
+ parts = []
314
+ for filepath, lines in sorted(valid_lines.items()):
315
+ if not lines:
316
+ continue
317
+ # Compress consecutive lines into ranges
318
+ ranges = []
319
+ start = lines[0]
320
+ end = lines[0]
321
+ for ln in lines[1:]:
322
+ if ln == end + 1:
323
+ end = ln
324
+ else:
325
+ ranges.append(f"{start}-{end}" if start != end else str(start))
326
+ start = end = ln
327
+ ranges.append(f"{start}-{end}" if start != end else str(start))
328
+ parts.append(f" {filepath}: lines {', '.join(ranges)}")
329
+ return "\n".join(parts)
330
+
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # Claude API
334
+ # ---------------------------------------------------------------------------
335
+
336
+
337
+ def call_claude(prompt: str, system: str, config: dict) -> str:
338
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
339
+ if not api_key:
340
+ # Try loading from config
341
+ api_key = config.get("anthropic_api_key")
342
+ if not api_key:
343
+ print(
344
+ "āŒ Set ANTHROPIC_API_KEY env var or add it to ~/.config/rpr/config.json",
345
+ file=sys.stderr,
346
+ )
347
+ sys.exit(1)
348
+
349
+ model = config["model"]
350
+ payload = {
351
+ "model": model,
352
+ "max_tokens": config["max_tokens"],
353
+ "system": system,
354
+ "messages": [{"role": "user", "content": prompt}],
355
+ }
356
+
357
+ # Enable adaptive thinking for 4.6 / opus models
358
+ if "opus" in model or "4-6" in model or "4_6" in model:
359
+ payload["temperature"] = 1 # required for thinking
360
+ payload["thinking"] = {"type": "adaptive"}
361
+
362
+ req = urllib.request.Request(
363
+ "https://api.anthropic.com/v1/messages",
364
+ data=json.dumps(payload).encode("utf-8"),
365
+ headers={
366
+ "content-type": "application/json",
367
+ "x-api-key": api_key,
368
+ "anthropic-version": "2023-06-01",
369
+ },
370
+ method="POST",
371
+ )
372
+
373
+ try:
374
+ with urllib.request.urlopen(req, timeout=300) as resp:
375
+ response = json.loads(resp.read().decode("utf-8"))
376
+ except urllib.error.HTTPError as e:
377
+ body = e.read().decode("utf-8", errors="replace")
378
+ print(f"āŒ Claude API error ({e.code}): {body}", file=sys.stderr)
379
+ sys.exit(1)
380
+ except urllib.error.URLError as e:
381
+ print(f"āŒ Network error contacting Claude API: {e.reason}", file=sys.stderr)
382
+ sys.exit(1)
383
+
384
+ if "error" in response:
385
+ print(f"āŒ Claude API error: {response['error']}", file=sys.stderr)
386
+ sys.exit(1)
387
+
388
+ # Extract text content, skipping thinking blocks
389
+ for block in response.get("content", []):
390
+ if block.get("type") == "text":
391
+ return block["text"]
392
+
393
+ # Fallback
394
+ return response["content"][0].get("text", "")
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # Review prompt engineering
399
+ # ---------------------------------------------------------------------------
400
+
401
+
402
+ def get_review_guidelines() -> str:
403
+ """Load custom review guidelines from cwd or XDG config dir."""
404
+ for p in _guidelines_search_paths():
405
+ if p.exists():
406
+ return p.read_text()
407
+ return ""
408
+
409
+
410
+ SYSTEM_PROMPT = """\
411
+ You are a senior software engineer doing a code review. You are NOT an AI — you are \
412
+ the developer reviewing this pull request. Write exactly how a sharp, experienced \
413
+ engineer writes PR reviews:
414
+
415
+ VOICE & TONE RULES:
416
+ - Be direct. No pleasantries, no "Great work overall!" fluff.
417
+ - Use first person naturally: "I think", "I'd suggest", "this concerns me"
418
+ - Be specific about what's wrong and why
419
+ - Occasionally use informal language — "this looks off", "hmm", "nit:", "not sure about this"
420
+ - Vary your sentence length. Mix short punchy observations with longer explanations.
421
+ - It's okay to say "looks good" for parts that are fine — but don't overdo it
422
+ - NEVER use phrases like "as an AI", "I notice", "I'd like to highlight"
423
+ - NEVER use bullet points with perfect parallel structure — real humans don't write that way
424
+ - Skip obvious stuff. Don't explain what the code does back to the author.
425
+
426
+ WHAT TO FOCUS ON:
427
+ - Bugs, logic errors, edge cases that will break
428
+ - Security issues (injection, auth gaps, data exposure)
429
+ - Performance problems that actually matter
430
+ - Missing error handling that will cause production issues
431
+ - Concurrency / race conditions
432
+ - Resource leaks
433
+
434
+ WHAT TO SKIP:
435
+ - Style preferences (formatting, naming conventions) unless truly confusing
436
+ - "Consider adding tests" — they know
437
+ - Obvious documentation suggestions
438
+ - Praise for basic competence
439
+
440
+ {custom_guidelines}"""
441
+
442
+ USER_PROMPT_TEMPLATE = """\
443
+ Review this pull request.
444
+
445
+ **{title}**
446
+ {description}
447
+
448
+ Base: `{base}` ← Head: `{head}`
449
+ {stats}
450
+
451
+ CHANGED FILES AND LINES (only these are reviewable):
452
+ {changed_lines_summary}
453
+
454
+ CRITICAL CONSTRAINT: You may ONLY comment on lines listed above. These are the \
455
+ lines that were actually added or modified in this PR. Do NOT comment on:
456
+ - Lines that appear in the diff as context (lines without + prefix)
457
+ - Lines from files not listed above
458
+ - Code that exists in the repo but was not changed in this PR
459
+ If an issue involves unchanged code, mention it in the summary instead.
460
+
461
+ Return your review as JSON with this exact structure — no markdown fences, no preamble:
462
+
463
+ {{
464
+ "action": "approve" | "request_changes" | "comment",
465
+ "summary": "Your overall assessment in 2-3 sentences. Be direct.",
466
+ "comments": [
467
+ {{
468
+ "path": "relative/file/path.ext",
469
+ "line": <line number from the CHANGED LINES list above>,
470
+ "body": "Your comment about this specific line/section"
471
+ }}
472
+ ]
473
+ }}
474
+
475
+ Rules for "action":
476
+ - "approve" — the code is solid, no blocking issues. Minor nits are fine alongside an approval.
477
+ - "request_changes" — there are bugs, security holes, or logic errors that MUST be fixed before merge.
478
+ - "comment" — you have feedback but nothing blocking. Use this when you have suggestions but the code would work fine as-is.
479
+ - Default to "approve" if the changes look correct and there are no real issues.
480
+ - Only use "request_changes" for actual problems, not style preferences.
481
+
482
+ Rules for the JSON:
483
+ - "path" must be one of the files listed in CHANGED FILES above
484
+ - "line" must be a number from that file's changed lines listed above
485
+ - If you can't pinpoint an exact changed line, put it in the summary instead
486
+ - Keep comments concise — 1-3 sentences each
487
+ - If the PR looks solid, return an empty comments array and say so in the summary
488
+ - Do NOT invent issues just to have something to say
489
+
490
+ Here's the diff:
491
+
492
+ {diff}"""
493
+
494
+
495
+ def build_prompt(pr_info: dict, diff: str, valid_lines: dict) -> tuple:
496
+ """Build the system and user prompts."""
497
+ custom_guidelines = get_review_guidelines()
498
+ if custom_guidelines:
499
+ custom_guidelines = f"\nADDITIONAL TEAM GUIDELINES:\n{custom_guidelines}"
500
+
501
+ system = SYSTEM_PROMPT.format(custom_guidelines=custom_guidelines)
502
+
503
+ description = pr_info.get("body") or "(no description)"
504
+ # Truncate long descriptions
505
+ if len(description) > 2000:
506
+ description = description[:2000] + "..."
507
+
508
+ stats = f"+{pr_info.get('additions', '?')} / -{pr_info.get('deletions', '?')}"
509
+ changed_lines_summary = build_valid_lines_summary(valid_lines)
510
+
511
+ user = USER_PROMPT_TEMPLATE.format(
512
+ title=pr_info.get("title", "Untitled"),
513
+ description=description,
514
+ base=pr_info.get("baseRefName", "?"),
515
+ head=pr_info.get("headRefName", "?"),
516
+ stats=stats,
517
+ changed_lines_summary=changed_lines_summary,
518
+ diff=diff,
519
+ )
520
+
521
+ return system, user
522
+
523
+
524
+ # ---------------------------------------------------------------------------
525
+ # Response parsing
526
+ # ---------------------------------------------------------------------------
527
+
528
+
529
+ def parse_review(raw: str) -> dict:
530
+ """Parse Claude's JSON response, handling common quirks."""
531
+ # Strip markdown code fences if present
532
+ text = raw.strip()
533
+ if text.startswith("```"):
534
+ # Remove first line and last line
535
+ lines = text.split("\n")
536
+ if lines[0].startswith("```"):
537
+ lines = lines[1:]
538
+ if lines and lines[-1].strip() == "```":
539
+ lines = lines[:-1]
540
+ text = "\n".join(lines)
541
+
542
+ try:
543
+ return json.loads(text)
544
+ except json.JSONDecodeError:
545
+ # Try to extract JSON from the response
546
+ start = text.find("{")
547
+ end = text.rfind("}") + 1
548
+ if start >= 0 and end > start:
549
+ try:
550
+ return json.loads(text[start:end])
551
+ except json.JSONDecodeError:
552
+ pass
553
+
554
+ # Last resort: return as plain comment
555
+ return {"summary": raw, "comments": []}
556
+
557
+
558
+ # ---------------------------------------------------------------------------
559
+ # Main
560
+ # ---------------------------------------------------------------------------
561
+
562
+
563
+ def main():
564
+ parser = argparse.ArgumentParser(
565
+ prog="rpr",
566
+ description="Stealth PR Reviewer — looks like you wrote every word.",
567
+ )
568
+ parser.add_argument("pr_number", type=int, help="PR number to review")
569
+ parser.add_argument("--repo", "-r", help="owner/repo (default: current repo)")
570
+ parser.add_argument("--dry-run", "-n", action="store_true", help="Print review, don't post")
571
+ parser.add_argument("--comment-only", action="store_true", help="Post as simple comment, not inline review")
572
+ parser.add_argument("--approve", action="store_true", help="Approve the PR with review")
573
+ parser.add_argument("--request-changes", action="store_true", help="Request changes")
574
+ parser.add_argument("--model", help="Override Claude model")
575
+ parser.add_argument("--verbose", "-v", action="store_true", help="Show debug info")
576
+
577
+ args = parser.parse_args()
578
+ config = load_config()
579
+
580
+ if args.model:
581
+ config["model"] = args.model
582
+
583
+ # 1. Fetch PR info
584
+ print(f"šŸ“‹ Fetching PR #{args.pr_number}...", file=sys.stderr)
585
+ pr_info = get_pr_info(args.pr_number, args.repo)
586
+
587
+ if args.verbose:
588
+ print(f" Title: {pr_info.get('title')}", file=sys.stderr)
589
+ files = pr_info.get("files", [])
590
+ print(f" Files: {len(files)} changed", file=sys.stderr)
591
+
592
+ # 2. Get diff
593
+ print("šŸ“ Fetching diff...", file=sys.stderr)
594
+ diff = get_pr_diff(args.pr_number, args.repo)
595
+
596
+ # 3. Filter out noise
597
+ diff = filter_diff(diff, config["skip_patterns"])
598
+ diff, was_truncated = truncate_diff(diff, config["max_diff_chars"])
599
+
600
+ if was_truncated:
601
+ print("āš ļø Diff was truncated (too large). Review may miss some files.", file=sys.stderr)
602
+
603
+ if not diff.strip():
604
+ print("āš ļø No reviewable changes found (all files matched skip patterns).", file=sys.stderr)
605
+ sys.exit(0)
606
+
607
+ # 3b. Parse diff to get exact changed lines per file
608
+ valid_lines = parse_diff_lines(diff)
609
+ valid_files = set(valid_lines.keys())
610
+
611
+ if args.verbose:
612
+ total_changed = sum(len(v) for v in valid_lines.values())
613
+ print(f" Changed lines: {total_changed} across {len(valid_files)} files", file=sys.stderr)
614
+
615
+ # 4. Build prompt and call Claude
616
+ system, user = build_prompt(pr_info, diff, valid_lines)
617
+
618
+ print(f"šŸ¤– Reviewing with {config['model']}...", file=sys.stderr)
619
+ raw_response = call_claude(user, system, config)
620
+
621
+ if args.verbose:
622
+ print(f"\n--- Raw response ---\n{raw_response}\n---\n", file=sys.stderr)
623
+
624
+ # 5. Parse response
625
+ review = parse_review(raw_response)
626
+ summary = review.get("summary", "Looks good.")
627
+ comments = review.get("comments", [])
628
+
629
+ # Filter out comments that target files/lines NOT in the diff
630
+ valid_comments = []
631
+ dropped = 0
632
+ for c in comments:
633
+ path = c.get("path")
634
+ line = c.get("line")
635
+ if not path or not line:
636
+ dropped += 1
637
+ continue
638
+ if path not in valid_files:
639
+ dropped += 1
640
+ if args.verbose:
641
+ print(f" ā›” Dropped comment on {path}:{line} — file not in diff", file=sys.stderr)
642
+ # Append to summary instead
643
+ summary += f"\n\n**Note on `{path}`** (line {line}): {c.get('body', '')}"
644
+ continue
645
+ if line not in valid_lines.get(path, []):
646
+ # Try to snap to the nearest valid line in that file
647
+ file_valid = valid_lines.get(path, [])
648
+ if file_valid:
649
+ nearest = min(file_valid, key=lambda x: abs(x - line))
650
+ # Only snap if within 5 lines (likely referring to same block)
651
+ if abs(nearest - line) <= 5:
652
+ if args.verbose:
653
+ print(f" šŸ“Œ Snapped {path}:{line} → {path}:{nearest}", file=sys.stderr)
654
+ c["line"] = nearest
655
+ valid_comments.append(c)
656
+ else:
657
+ dropped += 1
658
+ if args.verbose:
659
+ print(f" ā›” Dropped comment on {path}:{line} — not a changed line", file=sys.stderr)
660
+ summary += f"\n\n**Note on `{path}`** (line {line}): {c.get('body', '')}"
661
+ else:
662
+ dropped += 1
663
+ continue
664
+ valid_comments.append(c)
665
+
666
+ if dropped and args.verbose:
667
+ print(f" āš ļø {dropped} comments dropped (not on changed lines)", file=sys.stderr)
668
+
669
+ print(f"āœ… Review ready: {len(valid_comments)} inline comments", file=sys.stderr)
670
+
671
+ # 6. Determine review action
672
+ # CLI flags override, otherwise use Claude's decision
673
+ if args.approve:
674
+ event = "APPROVE"
675
+ elif args.request_changes:
676
+ event = "REQUEST_CHANGES"
677
+ else:
678
+ # Use Claude's recommendation
679
+ ai_action = review.get("action", "comment").lower().strip()
680
+ action_map = {
681
+ "approve": "APPROVE",
682
+ "request_changes": "REQUEST_CHANGES",
683
+ "comment": "COMMENT",
684
+ }
685
+ event = action_map.get(ai_action, "COMMENT")
686
+
687
+ action_label = {
688
+ "COMMENT": "šŸ’¬ commented on",
689
+ "APPROVE": "āœ… approved",
690
+ "REQUEST_CHANGES": "šŸ”„ requested changes on",
691
+ }
692
+
693
+ # 7. Output or post
694
+ if args.dry_run:
695
+ print("\n" + "=" * 60)
696
+ print(f"ACTION: {action_label.get(event, event)}")
697
+ print("=" * 60)
698
+ print("REVIEW SUMMARY:")
699
+ print(summary)
700
+ if valid_comments:
701
+ print("\n" + "-" * 60)
702
+ print("INLINE COMMENTS:")
703
+ print("-" * 60)
704
+ for c in valid_comments:
705
+ print(f"\nšŸ“„ {c['path']}:{c['line']}")
706
+ print(f" {c['body']}")
707
+ print()
708
+ return
709
+
710
+ if args.comment_only:
711
+ # Post as a single PR comment
712
+ body = summary
713
+ if valid_comments:
714
+ body += "\n\n---\n"
715
+ for c in valid_comments:
716
+ body += f"\n**`{c['path']}`** (L{c['line']})\n{c['body']}\n"
717
+ post_review_comment(args.pr_number, body, args.repo)
718
+ print(f"āœ… Posted as comment on PR #{args.pr_number}", file=sys.stderr)
719
+ else:
720
+ post_review(args.pr_number, summary, valid_comments, event, args.repo)
721
+ print(f"{action_label[event]} PR #{args.pr_number}", file=sys.stderr)
722
+
723
+ print(f"šŸ”— {pr_info.get('url', '')}", file=sys.stderr)
724
+
725
+
726
+ if __name__ == "__main__":
727
+ main()