pr-rocket 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.5
2
+ Name: pr-rocket
3
+ Version: 0.1.0
4
+ Summary: Review local changes with PR Rocket before opening a pull request
5
+ Project-URL: Homepage, https://pr-rocket.pydantic.dev
6
+ Author: Pydantic Services Inc.
7
+ Classifier: Environment :: Console
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Topic :: Software Development :: Quality Assurance
12
+ Requires-Python: >=3.12
13
+ Requires-Dist: httpx>=0.28
14
+ Description-Content-Type: text/markdown
15
+
16
+ # pr-rocket CLI
17
+
18
+ Get PR Rocket's code review of your local changes before you open a pull request.
19
+
20
+ ```
21
+ uv tool install 'git+https://github.com/pydantic/pr-rocket#subdirectory=cli'
22
+ pr-rocket login # approve the sign-in in your browser
23
+ pr-rocket review
24
+ ```
25
+
26
+ `review` sends every file that differs from where your branch forked from
27
+ `origin`'s default branch (`git merge-base HEAD origin/<default>`): unpushed
28
+ commits, staged, unstaged and untracked files. That base is always a pushed
29
+ commit, so it works from any local branch. Use `--base REF` to review against
30
+ something else. The review takes a few minutes. Findings are printed worst first,
31
+ and the command exits 1 when there are any. Nothing is posted to GitHub.
32
+
33
+ Binary files, symlinks, submodules and files over 1 MB are not sent, and one
34
+ review uploads at most 4,000,000 characters in total.
35
+
36
+ `pr-rocket review [PATH]` reviews the checkout containing `PATH` (default `.`).
37
+ Exit codes: 0 no findings, 1 findings, 2 an error or nothing reviewable (every
38
+ changed file was skipped), 3 PR Rocket doesn't review this repo (it isn't
39
+ installed there, or the repo's code review is off). The CLI asks the server
40
+ about the repo first, so in that case nothing is uploaded. `pr-rocket logout`
41
+ forgets the saved key.
42
+
43
+ ## Coding agents
44
+
45
+ Agents can run the CLI in a shell. `--json` prints the result as JSON:
46
+
47
+ ```
48
+ pr-rocket review --json
49
+ ```
50
+
51
+ Run `pr-rocket login` once in a terminal, or give the agent's environment `PR_ROCKET_TOKEN`.
52
+ An agent can also run `pr-rocket login --no-browser` and hand you the link it prints to approve.
53
+ An agent that gets exit code 3 should skip the review: the repo isn't one PR Rocket reviews.
54
+
55
+ ## Access
56
+
57
+ `pr-rocket login` signs in through your browser. It prints a code such as
58
+ `BCDF-GHJK` and opens Ground Control's sign-in page, which shows the same code and
59
+ this machine's name. Approve it there (signing in with GitHub first if you need
60
+ to), and the CLI collects a new key, named after the machine, that lasts 90 days.
61
+ The code expires after 10 minutes. On a machine without a browser (over SSH, in a
62
+ container), open the printed link on any device instead; `--no-browser` only prints it.
63
+
64
+ The key is saved in `$XDG_CONFIG_HOME/pr-rocket/credentials.json` (default
65
+ `~/.config`, mode 0600). `PR_ROCKET_TOKEN` overrides the saved key, for CI and
66
+ agents. You can see and revoke keys in Ground Control under **CLI keys** at any time.
67
+
68
+ You can also make a key by hand in Ground Control under **CLI keys** (choosing
69
+ 30 days, 90 days or a year) and pipe it in (`echo prk_... | pr-rocket login`), or
70
+ paste it with `pr-rocket login --paste`.
71
+
72
+ A key acts as you. To review a repo you need three things:
73
+ - approved Ground Control access;
74
+ - GitHub read access to the repo;
75
+ - PR Rocket installed on the repo, with **Code review** on in its Ground Control settings.
76
+
77
+ Each person can have two reviews running at a time, and 50 per hour.
78
+
79
+ Set `PR_ROCKET_URL` (or pass `--server`, before the subcommand: `pr-rocket --server URL review`) to use another PR Rocket instance.
80
+
81
+ ## Your own Codex plan
82
+
83
+ ```
84
+ pr-rocket codex connect # sign in to ChatGPT in the browser, then upload the sign-in
85
+ pr-rocket codex status
86
+ pr-rocket codex disconnect # delete it from PR Rocket
87
+ ```
88
+
89
+ `connect` lets PR Rocket do its work on the pull requests you author (reviews,
90
+ summaries, titles), and the `pr-rocket review` runs you start, on your own
91
+ ChatGPT/Codex plan instead of its API account.
92
+ A request your plan can't serve (its usage limit, a model it doesn't offer, a
93
+ sign-in that stopped working) goes to the API account instead, and a usage
94
+ limit pauses your plan for 15 minutes. It's a separate ChatGPT sign-in from `codex
95
+ login` (same browser flow, callback on `localhost:1455`), so it never logs your
96
+ local Codex out. PR Rocket stores the tokens encrypted, and `disconnect`
97
+ deletes them. It asks for your consent first; `--yes` skips the prompt.
98
+
99
+ Run `pr-rocket login` first: the CLI key says whose PRs the plan is for. Approve
100
+ the ChatGPT sign-in in a browser on the same machine, since it calls back to that
101
+ machine's `localhost:1455`; `connect` gives up after 5 minutes without it.
102
+
103
+ ## Development
104
+
105
+ `just cli-check` from the repo root formats, lints, type-checks and tests this package.
106
+
107
+ ## Releasing
108
+
109
+ The CLI is published to PyPI as `pr-rocket` by `.github/workflows/publish-cli.yml`, with
110
+ PyPI trusted publishing (no stored token). Bump `version` in `cli/pyproject.toml`, merge,
111
+ then tag that commit on main: `git tag cli-v<version> && git push origin cli-v<version>`.
112
+ The workflow checks the tag matches the version, runs the tests, builds and publishes.
@@ -0,0 +1,10 @@
1
+ pr_rocket_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pr_rocket_cli/__main__.py,sha256=DdmndlQafjLKFGnWsMIhFkOOGVXt_TvxuZ3cnVPTbhQ,7978
3
+ pr_rocket_cli/auth.py,sha256=M8LahyKFItErFQnfi3iKFF_bF74klB6vGKiRPsNZSkE,5841
4
+ pr_rocket_cli/codex.py,sha256=sCr_a3p0GPvkc-FjMx0IBcmdIv_1U4_xKLROjgyN96A,8502
5
+ pr_rocket_cli/git_changes.py,sha256=OxPed0QEzNXI-hDZlQp6V9_7QmXW4wrkxLvLe6VamFs,6433
6
+ pr_rocket_cli/review.py,sha256=6pPhK3M_nhS-MnJffogUUmr2QToEnoqPLQ_shZof46g,7087
7
+ pr_rocket-0.1.0.dist-info/METADATA,sha256=d8PzxuQ8TZhCfNkIMsvl0Wn3-HHirYqvRlGzxnRaUUc,5171
8
+ pr_rocket-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ pr_rocket-0.1.0.dist-info/entry_points.txt,sha256=6tx2m1snAoZPvkLHGapNEN6ja01l5uRKXtlXdt0CYdQ,58
10
+ pr_rocket-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pr-rocket = pr_rocket_cli.__main__:main
File without changes
@@ -0,0 +1,178 @@
1
+ """pr-rocket: review local changes with PR Rocket before opening a pull request.
2
+
3
+ pr-rocket login sign in through your browser (or pipe in a key from Ground Control)
4
+ pr-rocket review [PATH] review every local change against where the branch forked
5
+ pr-rocket logout
6
+ pr-rocket codex connect review your own PRs and changes on your ChatGPT/Codex plan
7
+ pr-rocket codex status | disconnect
8
+
9
+ `review` exits 0 with no findings, 1 with findings, 2 on an error, and 3 when PR Rocket
10
+ doesn't review the repo (not installed there, or code review is off); nothing is uploaded then.
11
+ """
12
+
13
+ import argparse
14
+ import asyncio
15
+ import getpass
16
+ import json
17
+ import os
18
+ import socket
19
+ import sys
20
+ import webbrowser
21
+ from importlib.metadata import PackageNotFoundError, version
22
+ from pathlib import Path
23
+
24
+ import httpx
25
+
26
+ from pr_rocket_cli import codex
27
+ from pr_rocket_cli.auth import KeyStore, NotSignedInError, key_for, login, start_browser_login, wait_for_approval
28
+ from pr_rocket_cli.git_changes import GitError, collect_change
29
+ from pr_rocket_cli.review import RepoNotEnabledError, ReviewError, check_access, format_result, run_review
30
+
31
+ DEFAULT_SERVER = "https://pr-rocket.pydantic.dev"
32
+ # `review` exits 0 (no findings), 1 (findings), 2 (an error) or this: PR Rocket doesn't review the repo.
33
+ EXIT_NOT_ENABLED = 3
34
+
35
+
36
+ def _version() -> str:
37
+ try:
38
+ return version("pr-rocket")
39
+ except PackageNotFoundError: # running from a source tree that was never installed
40
+ return "unknown"
41
+
42
+
43
+ def _parser() -> argparse.ArgumentParser:
44
+ parser = argparse.ArgumentParser(
45
+ prog="pr-rocket", description=__doc__, formatter_class=argparse.RawTextHelpFormatter
46
+ )
47
+ parser.add_argument("--version", action="version", version=f"%(prog)s {_version()}")
48
+ parser.add_argument("--server", default=os.environ.get("PR_ROCKET_URL", DEFAULT_SERVER), help="PR Rocket URL")
49
+ commands = parser.add_subparsers(dest="command", required=True)
50
+ login_cmd = commands.add_parser(
51
+ "login", help="sign in through your browser; a key piped on stdin is saved instead (for scripts)"
52
+ )
53
+ login_cmd.add_argument("--no-browser", action="store_true", help="print the sign-in link instead of opening it")
54
+ login_cmd.add_argument("--paste", action="store_true", help="paste a key made in Ground Control instead")
55
+ commands.add_parser("logout", help="forget the saved key")
56
+ review = commands.add_parser("review", help="review local changes")
57
+ review.add_argument("path", nargs="?", default=".", help="directory inside the checkout (default: .)")
58
+ review.add_argument("--base", default="", help="ref the work branched from (default: origin's default branch)")
59
+ review.add_argument("--json", action="store_true", help="print the raw result as JSON")
60
+ codex_cmd = commands.add_parser("codex", help="use your own ChatGPT/Codex plan for reviews of your PRs")
61
+ codex_cmd.add_argument("action", choices=("connect", "status", "disconnect"))
62
+ codex_cmd.add_argument("--yes", action="store_true", help="agree to the consent prompt without asking")
63
+ return parser
64
+
65
+
66
+ async def _review(args: argparse.Namespace, store: KeyStore) -> int:
67
+ key = key_for(args.server, store=store)
68
+ change = collect_change(Path(args.path).expanduser(), base_ref=args.base)
69
+ if not change.files:
70
+ if change.skipped:
71
+ skipped = ", ".join(f"{path} ({why})" for path, why in change.skipped.items())
72
+ print(f"Nothing reviewable against {change.base_ref}; every changed file was skipped: {skipped}")
73
+ return 2
74
+ print(f"No changes against {change.base_ref} ({change.base_sha[:12]}).")
75
+ return 0
76
+
77
+ async def on_stage(stage: str) -> None:
78
+ print(f" {stage}…", file=sys.stderr)
79
+
80
+ async with httpx.AsyncClient(timeout=60) as http:
81
+ await check_access(change.repo, server=args.server, token=key, http=http)
82
+ print(
83
+ f"Reviewing {len(change.files)} changed file(s) in {change.repo} against {change.base_ref}…",
84
+ file=sys.stderr,
85
+ )
86
+ result = await run_review(change, server=args.server, token=key, http=http, on_stage=on_stage)
87
+ print(json.dumps(result, indent=2) if args.json else format_result(result, change))
88
+ return 1 if result.get("findings") else 0
89
+
90
+
91
+ def _piped_key() -> str:
92
+ """A key piped on stdin (`echo prk_... | pr-rocket login`), as before browser sign-in; "" when stdin is a
93
+ terminal or empty (e.g. /dev/null, as agents' shells give), which means sign in through the browser."""
94
+ if sys.stdin is None or sys.stdin.isatty():
95
+ return ""
96
+ return sys.stdin.readline().strip()
97
+
98
+
99
+ def _can_open_browser() -> bool:
100
+ if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
101
+ return False # the browser would open on the wrong machine, or a text browser would take over the terminal
102
+ return sys.platform in ("darwin", "win32") or bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
103
+
104
+
105
+ async def _browser_login(args: argparse.Namespace, store: KeyStore, http: httpx.AsyncClient) -> str:
106
+ pending = await start_browser_login(args.server, name=socket.gethostname(), http=http)
107
+ print(
108
+ f"\nSign in to PR Rocket in your browser and check it shows this code:\n\n {pending.user_code}\n",
109
+ file=sys.stderr,
110
+ )
111
+ opened = False
112
+ if not args.no_browser and _can_open_browser():
113
+ try:
114
+ opened = webbrowser.open(pending.url)
115
+ except webbrowser.Error:
116
+ opened = False
117
+ print(f"{'If the browser did not open, go to' if opened else 'Open'}:\n {pending.url}\n", file=sys.stderr)
118
+ print("Waiting for approval… (Ctrl-C to cancel)", file=sys.stderr)
119
+ return await wait_for_approval(args.server, pending, http=http, store=store)
120
+
121
+
122
+ async def _login(args: argparse.Namespace, store: KeyStore) -> int:
123
+ key = _piped_key()
124
+ if args.paste and not key:
125
+ print(f"Create a key under CLI keys in Ground Control: {args.server}/ground-control", file=sys.stderr)
126
+ key = getpass.getpass("Paste the key: ")
127
+ async with httpx.AsyncClient(timeout=60) as http:
128
+ who = await login(args.server, key, http=http, store=store) if key else await _browser_login(args, store, http)
129
+ print(f"Signed in as {who}.")
130
+ return 0
131
+
132
+
133
+ async def _codex(args: argparse.Namespace, store: KeyStore) -> int:
134
+ key = key_for(args.server, store=store)
135
+ if args.action == "connect":
136
+ print(codex.CONSENT, file=sys.stderr)
137
+ if not args.yes and input("Continue? [y/N] ").strip().lower() not in ("y", "yes"):
138
+ return 1
139
+ async with httpx.AsyncClient(timeout=60) as http:
140
+ if args.action == "connect":
141
+ view = await codex.connect(args.server, key, http=http)
142
+ elif args.action == "disconnect":
143
+ view = await codex.disconnect(args.server, key, http=http)
144
+ else:
145
+ view = await codex.status(args.server, key, http=http)
146
+ print(codex.describe(view))
147
+ return 0
148
+
149
+
150
+ def _run(args: argparse.Namespace, store: KeyStore):
151
+ if args.command == "login":
152
+ return _login(args, store)
153
+ return _codex(args, store) if args.command == "codex" else _review(args, store)
154
+
155
+
156
+ def main(argv: list[str] | None = None) -> int:
157
+ args = _parser().parse_args(argv)
158
+ store = KeyStore()
159
+ if args.command == "logout":
160
+ store.save(args.server, None)
161
+ return 0
162
+ try:
163
+ return asyncio.run(_run(args, store))
164
+ except RepoNotEnabledError as exc:
165
+ print(f"pr-rocket: {exc}. Nothing was reviewed.", file=sys.stderr)
166
+ return EXIT_NOT_ENABLED
167
+ except (NotSignedInError, GitError, ReviewError, codex.CodexError) as exc:
168
+ print(f"pr-rocket: {exc}", file=sys.stderr)
169
+ return 2
170
+ except httpx.TransportError as exc:
171
+ print(f"pr-rocket: couldn't reach {args.server} ({exc})", file=sys.stderr)
172
+ return 2
173
+ except KeyboardInterrupt:
174
+ return 130
175
+
176
+
177
+ if __name__ == "__main__":
178
+ sys.exit(main())
pr_rocket_cli/auth.py ADDED
@@ -0,0 +1,148 @@
1
+ """Your PR Rocket CLI key, saved by ``pr-rocket login``.
2
+
3
+ ``pr-rocket login`` signs in through the browser: the server gives a short code, the person
4
+ approves it on Ground Control's sign-in page, and the CLI collects a fresh key (an OAuth 2.0
5
+ device authorization grant). A key made by hand in Ground Control can be pasted or piped instead.
6
+ ``PR_ROCKET_TOKEN`` takes precedence over a saved key (for CI and agents).
7
+ Saved keys live in ``~/.config/pr-rocket/credentials.json`` (mode 0600),
8
+ one per server URL.
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ import os
14
+ from collections.abc import Awaitable, Callable, Mapping
15
+ from pathlib import Path
16
+
17
+ import httpx
18
+
19
+ TOKEN_ENV = "PR_ROCKET_TOKEN"
20
+
21
+
22
+ class NotSignedInError(RuntimeError):
23
+ """No usable key; the message says what to do."""
24
+
25
+
26
+ def credentials_path() -> Path:
27
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
28
+ return Path(base) / "pr-rocket" / "credentials.json"
29
+
30
+
31
+ class KeyStore:
32
+ def __init__(self, path: Path | None = None) -> None:
33
+ self.path = path or credentials_path()
34
+
35
+ def _all(self) -> dict:
36
+ try:
37
+ return json.loads(self.path.read_text())
38
+ except (FileNotFoundError, ValueError):
39
+ return {}
40
+
41
+ def load(self, server: str) -> str | None:
42
+ entry = self._all().get(server)
43
+ return str(entry["key"]) if isinstance(entry, dict) and entry.get("key") else None
44
+
45
+ def save(self, server: str, key: str | None) -> None:
46
+ entries = self._all()
47
+ if key is None:
48
+ entries.pop(server, None)
49
+ else:
50
+ entries[server] = {"key": key}
51
+ self.path.parent.mkdir(parents=True, exist_ok=True)
52
+ fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
53
+ with os.fdopen(fd, "w") as handle:
54
+ json.dump(entries, handle, indent=2)
55
+
56
+
57
+ def key_for(server: str, *, store: KeyStore, environ: Mapping[str, str] = os.environ) -> str:
58
+ key = environ.get(TOKEN_ENV) or store.load(server)
59
+ if not key:
60
+ raise NotSignedInError("not signed in: run `pr-rocket login` (or set PR_ROCKET_TOKEN)")
61
+ return key
62
+
63
+
64
+ async def check_key(server: str, key: str, *, http: httpx.AsyncClient) -> str:
65
+ """The GitHub login `key` belongs to; raises NotSignedInError when the server refuses it."""
66
+ response = await http.get(f"{server}/api/local-review/me", headers={"Authorization": f"Bearer {key}"})
67
+ if response.status_code in (401, 403):
68
+ try:
69
+ reason = response.json().get("error", "")
70
+ except ValueError:
71
+ reason = ""
72
+ raise NotSignedInError(reason or "the server refused that key")
73
+ response.raise_for_status()
74
+ return str(response.json().get("login", ""))
75
+
76
+
77
+ async def login(server: str, key: str, *, http: httpx.AsyncClient, store: KeyStore) -> str:
78
+ """Check `key` with the server, save it, and return who it belongs to."""
79
+ key = key.strip()
80
+ if not key.startswith("prk_"):
81
+ raise NotSignedInError("that isn't a PR Rocket key (they start with prk_)")
82
+ who = await check_key(server, key, http=http)
83
+ store.save(server, key)
84
+ return who
85
+
86
+
87
+ class BrowserLogin:
88
+ """A sign-in started with the server, waiting for the person to approve it in a browser."""
89
+
90
+ def __init__(self, started: dict) -> None:
91
+ self.device_code = str(started["device_code"])
92
+ self.user_code = str(started["user_code"])
93
+ self.url = str(started.get("verification_uri_complete") or started["verification_uri"])
94
+ self.expires_in = int(started.get("expires_in", 600))
95
+ self.interval = max(1, int(started.get("interval", 3)))
96
+
97
+
98
+ async def start_browser_login(server: str, *, name: str, http: httpx.AsyncClient) -> BrowserLogin:
99
+ """Ask the server for a sign-in code for this machine."""
100
+ response = await http.post(f"{server}/api/cli/login", json={"name": name})
101
+ if response.status_code != 200:
102
+ raise NotSignedInError(_error_of(response) or f"the server couldn't start a sign-in ({response.status_code})")
103
+ return BrowserLogin(response.json())
104
+
105
+
106
+ async def wait_for_approval(
107
+ server: str,
108
+ pending: BrowserLogin,
109
+ *,
110
+ http: httpx.AsyncClient,
111
+ store: KeyStore,
112
+ sleep: Callable[[float], Awaitable[object]] = asyncio.sleep,
113
+ ) -> str:
114
+ """Poll until the sign-in is approved, then check and save the new key; returns who it belongs to."""
115
+ waited = 0.0
116
+ while waited < pending.expires_in:
117
+ await sleep(pending.interval)
118
+ waited += pending.interval
119
+ response = await http.post(f"{server}/api/cli/login/token", json={"device_code": pending.device_code})
120
+ if response.status_code != 200:
121
+ raise NotSignedInError(_error_of(response) or f"sign-in failed ({response.status_code})")
122
+ status = response.json().get("status")
123
+ if status == "approved":
124
+ return await _save_new_key(server, str(response.json()["key"]), http=http, store=store)
125
+ if status == "denied":
126
+ raise NotSignedInError("the sign-in was denied in the browser")
127
+ if status == "expired":
128
+ break
129
+ raise NotSignedInError("the sign-in expired before it was approved; run `pr-rocket login` again")
130
+
131
+
132
+ async def _save_new_key(server: str, key: str, *, http: httpx.AsyncClient, store: KeyStore) -> str:
133
+ """Save a key the server just minted before anything else can fail: it is handed out only once."""
134
+ store.save(server, key)
135
+ try:
136
+ return await check_key(server, key, http=http)
137
+ except NotSignedInError:
138
+ store.save(server, None)
139
+ raise
140
+ except httpx.HTTPError:
141
+ return "your account (the key is saved; the server didn't answer who it belongs to)"
142
+
143
+
144
+ def _error_of(response: httpx.Response) -> str:
145
+ try:
146
+ return str(response.json().get("error", ""))
147
+ except ValueError:
148
+ return ""
pr_rocket_cli/codex.py ADDED
@@ -0,0 +1,212 @@
1
+ """``pr-rocket codex``: let PR Rocket review your PRs on your own ChatGPT/Codex subscription.
2
+
3
+ ``connect`` signs you in to ChatGPT in the browser (the Codex CLI's own OAuth
4
+ client, PKCE, callback on ``localhost:1455``) and uploads the resulting tokens
5
+ to PR Rocket. It is a separate sign-in from ``codex login``, so PR Rocket's
6
+ token refreshes never log your local Codex out. ``disconnect`` deletes them
7
+ from PR Rocket; you can also sign the session out from your ChatGPT settings.
8
+ """
9
+
10
+ import asyncio
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import secrets
15
+ import webbrowser
16
+ from collections.abc import Awaitable, Callable
17
+ from dataclasses import dataclass
18
+ from http.server import BaseHTTPRequestHandler, HTTPServer
19
+ from urllib.parse import parse_qs, urlencode, urlparse
20
+
21
+ import httpx
22
+
23
+ from pr_rocket_cli.auth import NotSignedInError
24
+
25
+ CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" # the public Codex CLI client; its redirect is pinned to this URI
26
+ REDIRECT_URI = "http://localhost:1455/auth/callback"
27
+ AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
28
+ TOKEN_URL = "https://auth.openai.com/oauth/token"
29
+ _AUTH_CLAIM = "https://api.openai.com/auth"
30
+ LOGIN_TIMEOUT_S = 300
31
+
32
+ CONSENT = """\
33
+ PR Rocket will store a ChatGPT sign-in for your account (encrypted) and use it for its
34
+ work on pull requests YOU author (reviews, summaries, titles), on your own ChatGPT/Codex
35
+ plan and its Codex limits. When the plan can't serve a request, PR Rocket's own API
36
+ account does. Run `pr-rocket codex disconnect` at any time to delete it."""
37
+
38
+
39
+ class CodexError(RuntimeError):
40
+ """Connecting failed; the message says why."""
41
+
42
+
43
+ @dataclass(frozen=True, kw_only=True)
44
+ class Pkce:
45
+ verifier: str
46
+ challenge: str
47
+ state: str
48
+
49
+
50
+ def new_pkce() -> Pkce:
51
+ verifier = secrets.token_urlsafe(64)
52
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
53
+ return Pkce(verifier=verifier, challenge=challenge, state=secrets.token_urlsafe(24))
54
+
55
+
56
+ def authorization_url(pkce: Pkce) -> str:
57
+ params = {
58
+ "response_type": "code",
59
+ "client_id": CLIENT_ID,
60
+ "redirect_uri": REDIRECT_URI,
61
+ "scope": "openid profile email offline_access",
62
+ "state": pkce.state,
63
+ "code_challenge": pkce.challenge,
64
+ "code_challenge_method": "S256",
65
+ "id_token_add_organizations": "true",
66
+ "codex_cli_simplified_flow": "true",
67
+ }
68
+ return f"{AUTHORIZE_URL}?{urlencode(params)}"
69
+
70
+
71
+ def account_id(id_token: str) -> str:
72
+ """The ChatGPT account id in an id_token's claims (unverified; the server only reads it as a hint)."""
73
+ parts = id_token.split(".")
74
+ if len(parts) != 3:
75
+ return ""
76
+ try:
77
+ claims = json.loads(base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4)))
78
+ except ValueError:
79
+ return ""
80
+ auth = claims.get(_AUTH_CLAIM) if isinstance(claims, dict) else None
81
+ return str(auth.get("chatgpt_account_id") or "") if isinstance(auth, dict) else ""
82
+
83
+
84
+ class CallbackListener:
85
+ """The OAuth callback on localhost:1455, bound on creation so it is listening before the browser opens."""
86
+
87
+ def __init__(self, state: str) -> None:
88
+ self.result: dict[str, str] = {}
89
+ result = self.result
90
+
91
+ class Callback(BaseHTTPRequestHandler):
92
+ def do_GET(self) -> None:
93
+ url = urlparse(self.path)
94
+ query = {k: v[0] for k, v in parse_qs(url.query).items()}
95
+ # Only a callback carrying our state counts; anything else (a stale tab, a forged link) is ignored.
96
+ ours = url.path == "/auth/callback" and query.get("state") == state
97
+ ok = ours and "code" in query
98
+ if ok:
99
+ result["code"] = query["code"]
100
+ elif ours:
101
+ result["error"] = query.get("error_description") or query.get("error") or "the sign-in was refused"
102
+ self.send_response(200 if ok else 400)
103
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
104
+ self.end_headers()
105
+ self.wfile.write(b"Signed in. You can close this tab." if ok else b"Sign-in failed; see your terminal.")
106
+
107
+ def log_message(self, format: str, *args: object) -> None:
108
+ return None
109
+
110
+ try:
111
+ self.server = HTTPServer(("127.0.0.1", 1455), Callback)
112
+ except OSError as exc:
113
+ raise CodexError(f"port 1455 is busy ({exc}); finish or cancel any other `codex login` first") from exc
114
+ self.server.timeout = 1
115
+
116
+ def wait(self, timeout_s: float = LOGIN_TIMEOUT_S) -> str:
117
+ """Serve until the callback delivers a code (or an error, or the timeout), then close."""
118
+ try:
119
+ for _ in range(int(timeout_s)):
120
+ if self.result:
121
+ break
122
+ self.server.handle_request()
123
+ finally:
124
+ self.server.server_close()
125
+ if "code" not in self.result:
126
+ raise CodexError(self.result.get("error", "timed out waiting for the browser sign-in"))
127
+ return self.result["code"]
128
+
129
+
130
+ def wait_for_code(state: str, *, timeout_s: float = LOGIN_TIMEOUT_S) -> str:
131
+ """Listen on localhost:1455 until the callback delivers a code for `state`."""
132
+ return CallbackListener(state).wait(timeout_s)
133
+
134
+
135
+ async def exchange_code(code: str, pkce: Pkce, *, http: httpx.AsyncClient) -> dict:
136
+ """Trade the authorization code for {access_token, refresh_token, account_id}."""
137
+ form = {
138
+ "grant_type": "authorization_code",
139
+ "code": code,
140
+ "code_verifier": pkce.verifier,
141
+ "redirect_uri": REDIRECT_URI,
142
+ "client_id": CLIENT_ID,
143
+ }
144
+ response = await http.post(TOKEN_URL, data=form)
145
+ if response.status_code != 200:
146
+ raise CodexError(f"ChatGPT refused the sign-in ({response.status_code})")
147
+ data = response.json()
148
+ tokens = {
149
+ "access_token": str(data.get("access_token") or ""),
150
+ "refresh_token": str(data.get("refresh_token") or ""),
151
+ "account_id": account_id(str(data.get("id_token") or "")),
152
+ }
153
+ if not all(tokens.values()):
154
+ raise CodexError("ChatGPT's answer was missing a token or the account id")
155
+ return tokens
156
+
157
+
158
+ async def _call(http: httpx.AsyncClient, method: str, server: str, key: str, body: dict | None = None) -> dict:
159
+ response = await http.request(
160
+ method, f"{server}/api/cli/codex-token", headers={"Authorization": f"Bearer {key}"}, json=body
161
+ )
162
+ if response.status_code in (401, 403):
163
+ raise NotSignedInError(_error(response) or "the server refused your key")
164
+ if response.status_code != 200:
165
+ raise CodexError(_error(response) or f"the server answered {response.status_code}")
166
+ return response.json()
167
+
168
+
169
+ def _error(response: httpx.Response) -> str:
170
+ try:
171
+ return str(response.json().get("error", ""))
172
+ except ValueError:
173
+ return ""
174
+
175
+
176
+ async def connect(
177
+ server: str,
178
+ key: str,
179
+ *,
180
+ http: httpx.AsyncClient,
181
+ open_browser: Callable[[str], object] = webbrowser.open,
182
+ get_code: Callable[[Pkce], Awaitable[str]] | None = None,
183
+ ) -> dict:
184
+ """Sign in to ChatGPT, upload the tokens, and return the server's status for them."""
185
+ pkce = new_pkce()
186
+ url = authorization_url(pkce)
187
+ listener = CallbackListener(pkce.state) if get_code is None else None # listening before the browser redirects
188
+ print(f"Opening your browser to sign in to ChatGPT. If it doesn't open, visit:\n{url}")
189
+ open_browser(url)
190
+ if get_code is not None:
191
+ code = await get_code(pkce)
192
+ else:
193
+ assert listener is not None
194
+ code = await asyncio.to_thread(listener.wait)
195
+ tokens = await exchange_code(code, pkce, http=http)
196
+ return await _call(http, "PUT", server, key, {**tokens, "consent": True})
197
+
198
+
199
+ async def status(server: str, key: str, *, http: httpx.AsyncClient) -> dict:
200
+ return await _call(http, "GET", server, key)
201
+
202
+
203
+ async def disconnect(server: str, key: str, *, http: httpx.AsyncClient) -> dict:
204
+ return await _call(http, "DELETE", server, key)
205
+
206
+
207
+ def describe(view: dict) -> str:
208
+ if not view.get("connected"):
209
+ return "Not connected: PR Rocket reviews your PRs on its own API account."
210
+ plan = f" ({view['plan_type']} plan)" if view.get("plan_type") else ""
211
+ state = "" if view.get("status") == "active" else " Its sign-in stopped working; run `pr-rocket codex connect`."
212
+ return f"Connected to ChatGPT account {view.get('account_id', '')}{plan}.{state}"
@@ -0,0 +1,155 @@
1
+ """What changed locally: the GitHub repo, the commit the work forked from, and the changed files' contents.
2
+
3
+ The base is ``git merge-base HEAD origin/<default branch>``: always a pushed
4
+ commit, whatever the local branch looks like (unpushed commits, uncommitted
5
+ edits, behind main). Every file that differs between that commit and the
6
+ working tree is sent whole, including untracked files; the server computes
7
+ the diff itself.
8
+ """
9
+
10
+ import errno
11
+ import os
12
+ import re
13
+ import stat
14
+ import subprocess
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ MAX_FILE_BYTES = 1_000_000
19
+ MAX_UPLOAD_CHARS = 4_000_000 # the server's limit on all file content in one review
20
+ # github.com as the whole host: at the start, after "//" (https/ssh URLs) or after "user@" (scp-style).
21
+ _GITHUB_REMOTE = re.compile(r"(?:^|//|@)(?:[^/@\s]+@)?github\.com[:/](?P<repo>[^/\s]+/[^/\s]+?)(?:\.git)?/?$")
22
+
23
+
24
+ class GitError(RuntimeError):
25
+ """Not a reviewable checkout; the message says what to fix."""
26
+
27
+
28
+ @dataclass(frozen=True, kw_only=True)
29
+ class LocalChange:
30
+ repo: str
31
+ base_ref: str
32
+ base_sha: str
33
+ files: dict[str, str | None] # path -> content, None when deleted
34
+ skipped: dict[str, str] = field(default_factory=dict) # path -> why it wasn't sent
35
+
36
+ def upload(self) -> dict:
37
+ return {"repo": self.repo, "base_sha": self.base_sha, "files": self.files}
38
+
39
+
40
+ def _git(repo_dir: Path, *args: str) -> str:
41
+ # surrogateescape: -z output carries pathnames as raw bytes, which needn't be UTF-8.
42
+ result = subprocess.run(
43
+ ["git", *args], cwd=repo_dir, capture_output=True, text=True, errors="surrogateescape", check=False
44
+ )
45
+ if result.returncode != 0:
46
+ raise GitError(f"git {' '.join(args)}: {result.stderr.strip() or 'failed'}")
47
+ return result.stdout
48
+
49
+
50
+ def github_repo(remote_url: str) -> str:
51
+ """owner/name from a GitHub remote URL (https, ssh or scp-style)."""
52
+ match = _GITHUB_REMOTE.search(remote_url.strip())
53
+ if not match:
54
+ raise GitError(f"origin ({remote_url.strip()}) is not a GitHub repository")
55
+ return match.group("repo")
56
+
57
+
58
+ def _default_base_ref(repo_dir: Path) -> str:
59
+ try:
60
+ return _git(repo_dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD").strip()
61
+ except GitError:
62
+ for candidate in ("origin/main", "origin/master"):
63
+ try:
64
+ _git(repo_dir, "rev-parse", "--verify", "--quiet", candidate)
65
+ return candidate
66
+ except GitError:
67
+ continue
68
+ raise GitError("can't find origin's default branch; pass --base (e.g. origin/main)")
69
+
70
+
71
+ def _changed_paths(repo_dir: Path, base_sha: str) -> dict[str, bool]:
72
+ """path -> still exists, for every path that differs from `base_sha` in the working tree."""
73
+ raw = _git(repo_dir, "diff", "--name-status", "-z", "--no-renames", base_sha)
74
+ fields = [f for f in raw.split("\0") if f]
75
+ changed = {path: status != "D" for status, path in zip(fields[::2], fields[1::2], strict=True)}
76
+ untracked = _git(repo_dir, "ls-files", "--others", "--exclude-standard", "-z")
77
+ changed.update({path: True for path in untracked.split("\0") if path})
78
+ return changed
79
+
80
+
81
+ def _read_regular_file(path: Path) -> tuple[bytes | None, str]:
82
+ """The bytes of `path` if it's a regular file of at most MAX_FILE_BYTES, else (None, why).
83
+
84
+ Opened with O_NOFOLLOW and checked on the open descriptor, so a symlink swapped in
85
+ after a check can't make us read (and upload) a file outside the checkout; O_NONBLOCK
86
+ keeps a FIFO from blocking the open.
87
+ """
88
+ try:
89
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
90
+ except OSError as exc:
91
+ return None, "symlink" if exc.errno == errno.ELOOP else f"unreadable ({exc.strerror or exc})"
92
+ with os.fdopen(fd, "rb") as handle:
93
+ info = os.fstat(handle.fileno())
94
+ if not stat.S_ISREG(info.st_mode):
95
+ return None, "not a regular file"
96
+ if info.st_size > MAX_FILE_BYTES:
97
+ return None, f"over {MAX_FILE_BYTES // 1_000_000} MB"
98
+ try:
99
+ data = handle.read(MAX_FILE_BYTES + 1)
100
+ except OSError as exc:
101
+ return None, f"unreadable ({exc.strerror or exc})"
102
+ if len(data) > MAX_FILE_BYTES:
103
+ return None, f"over {MAX_FILE_BYTES // 1_000_000} MB"
104
+ return data, ""
105
+
106
+
107
+ def _read_text(path: Path) -> tuple[str | None, str]:
108
+ """(content, "") or (None, why it can't be sent)."""
109
+ if path.is_symlink():
110
+ return None, "symlink"
111
+ if path.is_dir():
112
+ return None, "submodule"
113
+ data, why = _read_regular_file(path)
114
+ if data is None:
115
+ return None, why
116
+ if b"\0" in data:
117
+ return None, "binary"
118
+ try:
119
+ return data.decode(), ""
120
+ except UnicodeDecodeError:
121
+ return None, "binary"
122
+
123
+
124
+ def _has_surrogates(text: str) -> bool:
125
+ """Whether `text` holds undecodable bytes smuggled in by surrogateescape (they can't be uploaded as JSON)."""
126
+ return any("\udc80" <= char <= "\udcff" for char in text)
127
+
128
+
129
+ def collect_change(repo_dir: Path, *, base_ref: str = "") -> LocalChange:
130
+ """Everything a review of `repo_dir`'s local changes needs, read from git and the working tree."""
131
+ root = Path(_git(repo_dir, "rev-parse", "--show-toplevel").strip())
132
+ repo = github_repo(_git(root, "remote", "get-url", "origin"))
133
+ base_ref = base_ref or _default_base_ref(root)
134
+ base_sha = _git(root, "merge-base", "HEAD", base_ref).strip()
135
+ files: dict[str, str | None] = {}
136
+ skipped: dict[str, str] = {}
137
+ for rel_path, exists in sorted(_changed_paths(root, base_sha).items()):
138
+ if _has_surrogates(rel_path):
139
+ skipped[rel_path.encode("utf-8", "surrogateescape").decode("utf-8", "replace")] = "non-UTF-8 path"
140
+ continue
141
+ if not exists:
142
+ files[rel_path] = None
143
+ continue
144
+ content, why = _read_text(root / rel_path)
145
+ if content is None:
146
+ skipped[rel_path] = why
147
+ else:
148
+ files[rel_path] = content
149
+ total = sum(len(content or "") for content in files.values())
150
+ if total > MAX_UPLOAD_CHARS:
151
+ raise GitError(
152
+ f"{len(files)} changed files hold {total:,} characters; the limit is {MAX_UPLOAD_CHARS:,}."
153
+ " Review a smaller change, or pass --base to compare against something closer."
154
+ )
155
+ return LocalChange(repo=repo, base_ref=base_ref, base_sha=base_sha, files=files, skipped=skipped)
@@ -0,0 +1,173 @@
1
+ """Upload a local change to PR Rocket and wait for the review's findings."""
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Awaitable, Callable
6
+
7
+ import httpx
8
+
9
+ from pr_rocket_cli.auth import NotSignedInError
10
+ from pr_rocket_cli.git_changes import LocalChange
11
+
12
+ POLL_INTERVAL_S = 3.0
13
+ TIMEOUT_S = 20 * 60
14
+ # How long polls may keep missing the review before it counts as lost. While PR Rocket deploys,
15
+ # the old and new server both answer for a minute or two and only one of them has the review,
16
+ # and the load balancer may briefly answer 502/503 itself.
17
+ LOST_AFTER_S = 60.0
18
+ _TRANSIENT_STATUSES = frozenset({404, 502, 503, 504})
19
+ # How long to wait for a review slot when the server says the caller is at their limit (429 + Retry-After):
20
+ # long enough for a running review to finish, short of the hourly limit, which says to come back much later.
21
+ MAX_SLOT_WAIT_S = 5 * 60
22
+
23
+ OnStage = Callable[[str], Awaitable[None]]
24
+
25
+
26
+ class ReviewError(RuntimeError):
27
+ """The server refused or failed the review; the message is the server's."""
28
+
29
+
30
+ class RepoNotEnabledError(ReviewError):
31
+ """PR Rocket doesn't review this repo (the App isn't installed there, or its code review is off)."""
32
+
33
+
34
+ def _body(response: httpx.Response) -> dict:
35
+ try:
36
+ body = response.json()
37
+ except ValueError:
38
+ return {}
39
+ return body if isinstance(body, dict) else {}
40
+
41
+
42
+ def _error(response: httpx.Response) -> str:
43
+ return str(_body(response).get("error") or response.text or f"HTTP {response.status_code}")
44
+
45
+
46
+ def _check(response: httpx.Response) -> dict:
47
+ if response.status_code == 401:
48
+ raise NotSignedInError(f"{_error(response)}")
49
+ if response.status_code == 403 and _body(response).get("reason") == "not_enabled":
50
+ raise RepoNotEnabledError(_error(response))
51
+ if response.status_code >= 400:
52
+ raise ReviewError(_error(response))
53
+ return response.json()
54
+
55
+
56
+ async def check_access(repo: str, *, server: str, token: str, http: httpx.AsyncClient) -> None:
57
+ """Raise before anything is uploaded when the caller may not review `repo`.
58
+
59
+ A server from before this check answers 404 (it takes "access" for a review id); the upload then decides.
60
+ """
61
+ try:
62
+ response = await http.get(
63
+ f"{server}/api/local-review/access", params={"repo": repo}, headers={"Authorization": f"Bearer {token}"}
64
+ )
65
+ except httpx.TransportError as exc:
66
+ raise ReviewError(f"can't reach PR Rocket at {server} ({exc.__class__.__name__}); try again later") from exc
67
+ if response.status_code != 404:
68
+ _check(response)
69
+
70
+
71
+ async def _poll(http: httpx.AsyncClient, url: str, *, headers: dict[str, str]) -> httpx.Response | None:
72
+ """The poll's response, or None when it never got one (connection refused or reset, timed out)."""
73
+ try:
74
+ return await http.get(url, headers=headers)
75
+ except httpx.TransportError:
76
+ return None
77
+
78
+
79
+ def _retry_after_s(response: httpx.Response) -> float | None:
80
+ try:
81
+ return float(response.headers.get("Retry-After", ""))
82
+ except ValueError:
83
+ return None
84
+
85
+
86
+ async def _start(
87
+ change: LocalChange, *, server: str, headers: dict, http: httpx.AsyncClient, on_stage: OnStage | None, sleep
88
+ ) -> dict:
89
+ """Upload the change; at the caller's limit (429), wait as long as the server says when that fits MAX_SLOT_WAIT_S."""
90
+ waited = 0.0
91
+ while True:
92
+ response = await http.post(f"{server}/api/local-review", json=change.upload(), headers=headers)
93
+ retry_after = _retry_after_s(response) if response.status_code == 429 else None
94
+ if retry_after is None or waited + retry_after > MAX_SLOT_WAIT_S:
95
+ return _check(response)
96
+ if on_stage:
97
+ await on_stage(f"waiting {retry_after:.0f}s for a review slot ({_error(response)})")
98
+ await sleep(retry_after)
99
+ waited += retry_after
100
+
101
+
102
+ async def run_review(
103
+ change: LocalChange,
104
+ *,
105
+ server: str,
106
+ token: str,
107
+ http: httpx.AsyncClient,
108
+ on_stage: OnStage | None = None,
109
+ sleep=asyncio.sleep,
110
+ clock=time.monotonic,
111
+ ) -> dict:
112
+ """The review result: {status, findings, summary[, reason]}.
113
+
114
+ Time spent waiting counts the sleeps between polls and how long each poll took
115
+ (one that times out can take a minute).
116
+ """
117
+ headers = {"Authorization": f"Bearer {token}"}
118
+ job = await _start(change, server=server, headers=headers, http=http, on_stage=on_stage, sleep=sleep)
119
+ stage = ""
120
+ waited = 0.0
121
+ missing_since: float | None = None
122
+ while job["status"] not in ("done", "error"):
123
+ if waited > TIMEOUT_S:
124
+ raise ReviewError("the review is taking too long; try again later")
125
+ await sleep(POLL_INTERVAL_S)
126
+ waited += POLL_INTERVAL_S
127
+ polled_at, started = waited, clock()
128
+ response = await _poll(http, f"{server}/api/local-review/{job['id']}", headers=headers)
129
+ waited += clock() - started
130
+ if response is None or response.status_code in _TRANSIENT_STATUSES:
131
+ missing_since = polled_at if missing_since is None else missing_since
132
+ if waited - missing_since < LOST_AFTER_S:
133
+ continue
134
+ if response is not None and response.status_code == 404:
135
+ raise ReviewError("the server lost the review (it probably restarted); run it again")
136
+ raise ReviewError("PR Rocket is not answering; run the review again later")
137
+ missing_since = None
138
+ job = _check(response)
139
+ if on_stage and job.get("stage") and job["stage"] != stage:
140
+ stage = job["stage"]
141
+ await on_stage(stage)
142
+ if job["status"] == "error":
143
+ raise ReviewError(job.get("error") or "the review failed")
144
+ return job["result"]
145
+
146
+
147
+ def format_result(result: dict, change: LocalChange) -> str:
148
+ """Findings for a terminal: one block per finding, worst first."""
149
+ lines: list[str] = []
150
+ if change.skipped:
151
+ skipped = ", ".join(f"{path} ({why})" for path, why in change.skipped.items())
152
+ lines += [f"Not reviewed: {skipped}", ""]
153
+ findings = result.get("findings") or []
154
+ if not findings:
155
+ lines.append("No findings.")
156
+ for finding in findings:
157
+ lines.append(f"{finding['file']}:{finding['line']} {finding['severity']} ({finding['category']})")
158
+ lines.append(f" {finding['description']}")
159
+ if finding.get("suggested_fix"):
160
+ lines.append(f" Fix: {finding['suggested_fix']}")
161
+ if finding.get("suggestion"):
162
+ lines += [" Suggested code:", *(f" {code}" for code in finding["suggestion"].splitlines())]
163
+ lines.append("")
164
+ if result.get("summary"):
165
+ lines.append(result["summary"])
166
+ if files := result.get("unverified_files"):
167
+ lines += ["", f"Not fully reviewed (the review ran out of budget): {', '.join(files)}"]
168
+ if unverified := result.get("unverified_findings"):
169
+ lines += [
170
+ "",
171
+ f"Not shown: {unverified} finding(s) the review ran out of budget to verify. Review a smaller change.",
172
+ ]
173
+ return "\n".join(lines).rstrip() + "\n"