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.
- pr_rocket-0.1.0.dist-info/METADATA +112 -0
- pr_rocket-0.1.0.dist-info/RECORD +10 -0
- pr_rocket-0.1.0.dist-info/WHEEL +4 -0
- pr_rocket-0.1.0.dist-info/entry_points.txt +2 -0
- pr_rocket_cli/__init__.py +0 -0
- pr_rocket_cli/__main__.py +178 -0
- pr_rocket_cli/auth.py +148 -0
- pr_rocket_cli/codex.py +212 -0
- pr_rocket_cli/git_changes.py +155 -0
- pr_rocket_cli/review.py +173 -0
|
@@ -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,,
|
|
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)
|
pr_rocket_cli/review.py
ADDED
|
@@ -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"
|