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.
- pr_rocket-0.1.0/.gitignore +55 -0
- pr_rocket-0.1.0/PKG-INFO +112 -0
- pr_rocket-0.1.0/README.md +97 -0
- pr_rocket-0.1.0/pyproject.toml +45 -0
- pr_rocket-0.1.0/src/pr_rocket_cli/__init__.py +0 -0
- pr_rocket-0.1.0/src/pr_rocket_cli/__main__.py +178 -0
- pr_rocket-0.1.0/src/pr_rocket_cli/auth.py +148 -0
- pr_rocket-0.1.0/src/pr_rocket_cli/codex.py +212 -0
- pr_rocket-0.1.0/src/pr_rocket_cli/git_changes.py +155 -0
- pr_rocket-0.1.0/src/pr_rocket_cli/review.py +173 -0
- pr_rocket-0.1.0/tests/conftest.py +36 -0
- pr_rocket-0.1.0/tests/test_auth_and_review.py +405 -0
- pr_rocket-0.1.0/tests/test_codex.py +86 -0
- pr_rocket-0.1.0/tests/test_git_changes.py +216 -0
- pr_rocket-0.1.0/tests/test_version.py +14 -0
- pr_rocket-0.1.0/uv.lock +181 -0
|
@@ -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/
|
pr_rocket-0.1.0/PKG-INFO
ADDED
|
@@ -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 ""
|