pr-rocket 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ .venv/
7
+
8
+ # Build artifacts
9
+ node_modules/
10
+
11
+ # Frontend build output (vite emits into public/ with emptyOutDir=true;
12
+ # CI rebuilds on deploy via `just fe-build`). Anchored to repo root — an
13
+ # unanchored `public/` also matches frontend/src/apps/public/, which broke
14
+ # Cloud Build's source upload (gcloud reuses .gitignore for gcloudignore).
15
+ /public/
16
+
17
+ # Secrets
18
+ .env
19
+ .dev.vars
20
+ *.pem
21
+ admin-auth.json
22
+ deploy/k8s/secret.yaml
23
+
24
+ # Native runtime local state (git checkout cache)
25
+ data/
26
+
27
+ # uv (lock file is tracked — don't ignore it)
28
+
29
+ # Testing
30
+ .pytest_cache/
31
+ .hypothesis/
32
+ .coverage
33
+ htmlcov/
34
+ frontend/test-results/
35
+ frontend/playwright/.cache/
36
+ playwright-report/
37
+ *.trace
38
+ *.har
39
+ *.webm
40
+
41
+ # IDE / tools
42
+ .cache/
43
+ .ruff_cache/
44
+ .mypy_cache/
45
+ .claude/worktrees/
46
+ .playwright-cli/
47
+ .playwright/
48
+ .codex/
49
+ .serena/
50
+
51
+ # Misc
52
+ error.log
53
+ *.png
54
+ PR Rocket.zip
55
+ .vite/
@@ -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,97 @@
1
+ # pr-rocket CLI
2
+
3
+ Get PR Rocket's code review of your local changes before you open a pull request.
4
+
5
+ ```
6
+ uv tool install 'git+https://github.com/pydantic/pr-rocket#subdirectory=cli'
7
+ pr-rocket login # approve the sign-in in your browser
8
+ pr-rocket review
9
+ ```
10
+
11
+ `review` sends every file that differs from where your branch forked from
12
+ `origin`'s default branch (`git merge-base HEAD origin/<default>`): unpushed
13
+ commits, staged, unstaged and untracked files. That base is always a pushed
14
+ commit, so it works from any local branch. Use `--base REF` to review against
15
+ something else. The review takes a few minutes. Findings are printed worst first,
16
+ and the command exits 1 when there are any. Nothing is posted to GitHub.
17
+
18
+ Binary files, symlinks, submodules and files over 1 MB are not sent, and one
19
+ review uploads at most 4,000,000 characters in total.
20
+
21
+ `pr-rocket review [PATH]` reviews the checkout containing `PATH` (default `.`).
22
+ Exit codes: 0 no findings, 1 findings, 2 an error or nothing reviewable (every
23
+ changed file was skipped), 3 PR Rocket doesn't review this repo (it isn't
24
+ installed there, or the repo's code review is off). The CLI asks the server
25
+ about the repo first, so in that case nothing is uploaded. `pr-rocket logout`
26
+ forgets the saved key.
27
+
28
+ ## Coding agents
29
+
30
+ Agents can run the CLI in a shell. `--json` prints the result as JSON:
31
+
32
+ ```
33
+ pr-rocket review --json
34
+ ```
35
+
36
+ Run `pr-rocket login` once in a terminal, or give the agent's environment `PR_ROCKET_TOKEN`.
37
+ An agent can also run `pr-rocket login --no-browser` and hand you the link it prints to approve.
38
+ An agent that gets exit code 3 should skip the review: the repo isn't one PR Rocket reviews.
39
+
40
+ ## Access
41
+
42
+ `pr-rocket login` signs in through your browser. It prints a code such as
43
+ `BCDF-GHJK` and opens Ground Control's sign-in page, which shows the same code and
44
+ this machine's name. Approve it there (signing in with GitHub first if you need
45
+ to), and the CLI collects a new key, named after the machine, that lasts 90 days.
46
+ The code expires after 10 minutes. On a machine without a browser (over SSH, in a
47
+ container), open the printed link on any device instead; `--no-browser` only prints it.
48
+
49
+ The key is saved in `$XDG_CONFIG_HOME/pr-rocket/credentials.json` (default
50
+ `~/.config`, mode 0600). `PR_ROCKET_TOKEN` overrides the saved key, for CI and
51
+ agents. You can see and revoke keys in Ground Control under **CLI keys** at any time.
52
+
53
+ You can also make a key by hand in Ground Control under **CLI keys** (choosing
54
+ 30 days, 90 days or a year) and pipe it in (`echo prk_... | pr-rocket login`), or
55
+ paste it with `pr-rocket login --paste`.
56
+
57
+ A key acts as you. To review a repo you need three things:
58
+ - approved Ground Control access;
59
+ - GitHub read access to the repo;
60
+ - PR Rocket installed on the repo, with **Code review** on in its Ground Control settings.
61
+
62
+ Each person can have two reviews running at a time, and 50 per hour.
63
+
64
+ Set `PR_ROCKET_URL` (or pass `--server`, before the subcommand: `pr-rocket --server URL review`) to use another PR Rocket instance.
65
+
66
+ ## Your own Codex plan
67
+
68
+ ```
69
+ pr-rocket codex connect # sign in to ChatGPT in the browser, then upload the sign-in
70
+ pr-rocket codex status
71
+ pr-rocket codex disconnect # delete it from PR Rocket
72
+ ```
73
+
74
+ `connect` lets PR Rocket do its work on the pull requests you author (reviews,
75
+ summaries, titles), and the `pr-rocket review` runs you start, on your own
76
+ ChatGPT/Codex plan instead of its API account.
77
+ A request your plan can't serve (its usage limit, a model it doesn't offer, a
78
+ sign-in that stopped working) goes to the API account instead, and a usage
79
+ limit pauses your plan for 15 minutes. It's a separate ChatGPT sign-in from `codex
80
+ login` (same browser flow, callback on `localhost:1455`), so it never logs your
81
+ local Codex out. PR Rocket stores the tokens encrypted, and `disconnect`
82
+ deletes them. It asks for your consent first; `--yes` skips the prompt.
83
+
84
+ Run `pr-rocket login` first: the CLI key says whose PRs the plan is for. Approve
85
+ the ChatGPT sign-in in a browser on the same machine, since it calls back to that
86
+ machine's `localhost:1455`; `connect` gives up after 5 minutes without it.
87
+
88
+ ## Development
89
+
90
+ `just cli-check` from the repo root formats, lints, type-checks and tests this package.
91
+
92
+ ## Releasing
93
+
94
+ The CLI is published to PyPI as `pr-rocket` by `.github/workflows/publish-cli.yml`, with
95
+ PyPI trusted publishing (no stored token). Bump `version` in `cli/pyproject.toml`, merge,
96
+ then tag that commit on main: `git tag cli-v<version> && git push origin cli-v<version>`.
97
+ The workflow checks the tag matches the version, runs the tests, builds and publishes.
@@ -0,0 +1,45 @@
1
+ [project]
2
+ # The PyPI name, so `uvx pr-rocket ...` runs the `pr-rocket` script below. The server
3
+ # (repo root) is never published.
4
+ name = "pr-rocket"
5
+ version = "0.1.0"
6
+ description = "Review local changes with PR Rocket before opening a pull request"
7
+ readme = "README.md"
8
+ requires-python = ">=3.12"
9
+ authors = [{ name = "Pydantic Services Inc." }]
10
+ classifiers = [
11
+ "Environment :: Console",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Topic :: Software Development :: Quality Assurance",
16
+ ]
17
+ dependencies = [
18
+ "httpx>=0.28",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://pr-rocket.pydantic.dev"
23
+
24
+ [project.scripts]
25
+ pr-rocket = "pr_rocket_cli.__main__:main"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "pytest>=8",
30
+ "pytest-asyncio>=1.0",
31
+ ]
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/pr_rocket_cli"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ asyncio_mode = "auto"
43
+
44
+ [tool.ruff]
45
+ line-length = 120
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())
@@ -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 ""