pr-review-agent 0.16.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_review_agent-0.16.0/LICENSE +21 -0
- pr_review_agent-0.16.0/PKG-INFO +84 -0
- pr_review_agent-0.16.0/README.md +64 -0
- pr_review_agent-0.16.0/pyproject.toml +88 -0
- pr_review_agent-0.16.0/src/pr_review_agent/__init__.py +0 -0
- pr_review_agent-0.16.0/src/pr_review_agent/_compat.py +32 -0
- pr_review_agent-0.16.0/src/pr_review_agent/_startup.py +66 -0
- pr_review_agent-0.16.0/src/pr_review_agent/bootstrap.py +225 -0
- pr_review_agent-0.16.0/src/pr_review_agent/budget.py +722 -0
- pr_review_agent-0.16.0/src/pr_review_agent/cli/__init__.py +85 -0
- pr_review_agent-0.16.0/src/pr_review_agent/cli/_common.py +59 -0
- pr_review_agent-0.16.0/src/pr_review_agent/cli/cmd_config.py +91 -0
- pr_review_agent-0.16.0/src/pr_review_agent/cli/cmd_daemon.py +36 -0
- pr_review_agent-0.16.0/src/pr_review_agent/cli/cmd_host.py +37 -0
- pr_review_agent-0.16.0/src/pr_review_agent/config.py +656 -0
- pr_review_agent-0.16.0/src/pr_review_agent/daemon.py +445 -0
- pr_review_agent-0.16.0/src/pr_review_agent/engine/__init__.py +46 -0
- pr_review_agent-0.16.0/src/pr_review_agent/engine/claude.py +265 -0
- pr_review_agent-0.16.0/src/pr_review_agent/engine/cli.py +250 -0
- pr_review_agent-0.16.0/src/pr_review_agent/engine/fake.py +56 -0
- pr_review_agent-0.16.0/src/pr_review_agent/engine/models.py +183 -0
- pr_review_agent-0.16.0/src/pr_review_agent/engine/prompt.py +265 -0
- pr_review_agent-0.16.0/src/pr_review_agent/engine/standards.py +73 -0
- pr_review_agent-0.16.0/src/pr_review_agent/numbering.py +52 -0
- pr_review_agent-0.16.0/src/pr_review_agent/poller/__init__.py +25 -0
- pr_review_agent-0.16.0/src/pr_review_agent/poller/client.py +252 -0
- pr_review_agent-0.16.0/src/pr_review_agent/poller/endpoints.py +115 -0
- pr_review_agent-0.16.0/src/pr_review_agent/poller/etag_store.py +40 -0
- pr_review_agent-0.16.0/src/pr_review_agent/poller/interval.py +51 -0
- pr_review_agent-0.16.0/src/pr_review_agent/poller/payloads.py +134 -0
- pr_review_agent-0.16.0/src/pr_review_agent/poller/poller.py +91 -0
- pr_review_agent-0.16.0/src/pr_review_agent/poller/pulls.py +49 -0
- pr_review_agent-0.16.0/src/pr_review_agent/publisher.py +323 -0
- pr_review_agent-0.16.0/src/pr_review_agent/queue.py +367 -0
- pr_review_agent-0.16.0/src/pr_review_agent/runs.py +337 -0
- pr_review_agent-0.16.0/src/pr_review_agent/store.py +291 -0
- pr_review_agent-0.16.0/src/pr_review_agent/templates/config.example.yaml +269 -0
- pr_review_agent-0.16.0/src/pr_review_agent/templates/config.minimal.example.yaml +18 -0
- pr_review_agent-0.16.0/src/pr_review_agent/triggers/__init__.py +31 -0
- pr_review_agent-0.16.0/src/pr_review_agent/triggers/allowlist.py +62 -0
- pr_review_agent-0.16.0/src/pr_review_agent/triggers/classifier.py +168 -0
- pr_review_agent-0.16.0/src/pr_review_agent/triggers/mention.py +78 -0
- pr_review_agent-0.16.0/src/pr_review_agent/triggers/models.py +147 -0
- pr_review_agent-0.16.0/src/pr_review_agent/worker.py +503 -0
- pr_review_agent-0.16.0/src/pr_review_agent/workspace/__init__.py +25 -0
- pr_review_agent-0.16.0/src/pr_review_agent/workspace/exclusions.py +39 -0
- pr_review_agent-0.16.0/src/pr_review_agent/workspace/gitcmd.py +176 -0
- pr_review_agent-0.16.0/src/pr_review_agent/workspace/repo.py +396 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Prasad Talasila
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pr-review-agent
|
|
3
|
+
Version: 0.16.0
|
|
4
|
+
Summary: Locally-hosted LLM-based PR review agent with enforced usage budgets
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: The INTO-CPS Association
|
|
8
|
+
Requires-Python: >=3.10,<3.15
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Dist: PyYAML (>=6.0)
|
|
15
|
+
Requires-Dist: click (>=8.1,<9)
|
|
16
|
+
Requires-Dist: httpx (>=0.27)
|
|
17
|
+
Project-URL: Repository, https://github.com/prasadtalasila/pr-review-agent
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# pr-review-agent
|
|
21
|
+
|
|
22
|
+
<p align="center">
|
|
23
|
+
<b>A locally-hosted LLM pull request reviewer that cannot overspend and cannot
|
|
24
|
+
be summoned by a stranger.</b>
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
A single Python asyncio daemon with a SQLite store, running on a private host.
|
|
28
|
+
It polls on pull requests of a GitHub repository for exactly two events:
|
|
29
|
+
|
|
30
|
+
1. a freshly opened pull request whose author is pre-approved;
|
|
31
|
+
2. a comment containing `@claude` whose commenter is pre-approved.
|
|
32
|
+
|
|
33
|
+
and performs code review using Claude CLI and posts review comments on the pull request.
|
|
34
|
+
|
|
35
|
+
## 🚀 Quickstart
|
|
36
|
+
|
|
37
|
+
_Requires_: Python **3.10 – 3.14** and python virtualenv.
|
|
38
|
+
Download [latest release](https://github.com/prasadtalasila/pr-review-agent/releases).
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m venv .venv
|
|
42
|
+
source .venv/bin/activate
|
|
43
|
+
|
|
44
|
+
# download the latest release
|
|
45
|
+
pip install pr_review_agent-<version>-py3-none-any.whl
|
|
46
|
+
|
|
47
|
+
pr-review-agent config generate # writes ./config.yaml
|
|
48
|
+
# update config; `config generate --full` writes the commented template
|
|
49
|
+
pr-review-agent config validate
|
|
50
|
+
|
|
51
|
+
# get GitHub PAT with read and write permissions on pull requests
|
|
52
|
+
GITHUB_TOKEN=xxxx pr-review-agent host check # can this host reach it all?
|
|
53
|
+
GITHUB_TOKEN=xxxx pr-review-agent daemon start # reads ./config.yaml
|
|
54
|
+
GITHUB_TOKEN=xxxx pr-review-agent daemon start --config /etc/pr-review-agent/config.yaml
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Installing the package puts `pr-review-agent` on the path. Commands follow a
|
|
58
|
+
`pr-review-agent <noun> <verb>` grammar, grouped by the setup workflow:
|
|
59
|
+
`config` → `host` → `daemon`. `--config` is optional: without it a command
|
|
60
|
+
reads `config.yaml` from the directory it is started in, which is also where
|
|
61
|
+
`state.db` is written.
|
|
62
|
+
|
|
63
|
+
## 🗂 Documentation
|
|
64
|
+
|
|
65
|
+
| Document | Answers |
|
|
66
|
+
| :-- | :-- |
|
|
67
|
+
| [docs/CONFIG.md](docs/CONFIG.md) | What settings exist, what does each accept, and why are unknown keys an error? |
|
|
68
|
+
| [docs/DESIGN.md](docs/DESIGN.md) | Why does this exist and why is it shaped like this? The four constraints, every alternative considered and rejected, the billing-mode question that is still open, and how prompt injection is handled |
|
|
69
|
+
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | What actually runs? The components, the path an event takes, the package layout, and which layer may import which |
|
|
70
|
+
| [docs/TRIGGERS.md](docs/TRIGGERS.md) | What starts a review and what does not? Every reason code and its log level, what counts as a mention, the dedupe keys, and why identity is a number |
|
|
71
|
+
| [docs/POLLER.md](docs/POLLER.md) | How does it learn something happened without an inbound port? The three endpoints, the rate-limit arithmetic, the adaptive interval, the retry rules, and how a comment payload is mapped to a pull request |
|
|
72
|
+
| [docs/DAEMON.md](docs/DAEMON.md) | What runs continuously, and what is it careful not to do? The cycle, the cold-start spend bound, the two watermark ordering rules, and how it shuts down |
|
|
73
|
+
| [docs/QUEUE.md](docs/QUEUE.md) | Where does an accepted trigger wait, and what stops one review being paid for twice? Dedupe, the per-pull-request lease, why leases expire instead of renewing, and the retry bound |
|
|
74
|
+
| [docs/STORAGE.md](docs/STORAGE.md) | What has to survive a restart, and what does a lost watermark actually cost? Why SQLite, and why a watermark only moves forward |
|
|
75
|
+
| [docs/WORKSPACE.md](docs/WORKSPACE.md) | How does a pull request's code get onto disk, and why is none of it ever run? The bare mirror, the per-run worktree, the untrusted-tree hardening, and the diff-size caps |
|
|
76
|
+
| [docs/BUDGET.md](docs/BUDGET.md) | The rolling windows and the share that guarantees human headroom, reserve-then-settle under concurrency, the degradation ladder, the circuit breaker, and what is still not built |
|
|
77
|
+
| [docs/WORKER.md](docs/WORKER.md) | What drains the queue? The claim-run-settle loop, what a failed run settles at and why, which failures retry and which are permanent, what a run leaves behind, the supervisor, and why not a process per review |
|
|
78
|
+
| [docs/PUBLISHER.md](docs/PUBLISHER.md) | How does a review become visible, and what stops the agent approving anything? The 👀 at claim time, the live `head_sha` re-check, one comment per pull request, `publish.dry_run`, and why a failed publish never costs a second review |
|
|
79
|
+
| [docs/ENGINE.md](docs/ENGINE.md) | How does a different coding agent plug in? The one swappable step, what an engine is given and must return, the capability record, and why every adapter is a CLI subprocess rather than an SDK |
|
|
80
|
+
| [docs/ROADMAP.md](docs/ROADMAP.md) | What is built, what is next, the acceptance checklist, and the known gaps |
|
|
81
|
+
| [DEVELOPER.md](DEVELOPER.md) | How do I set up, test, lint and build this? |
|
|
82
|
+
| [CLAUDE.md](CLAUDE.md) | The behavioural guidelines applied to every change |
|
|
83
|
+
| [AGENTS.md](AGENTS.md) | The coding-assistant conventions |
|
|
84
|
+
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# pr-review-agent
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<b>A locally-hosted LLM pull request reviewer that cannot overspend and cannot
|
|
5
|
+
be summoned by a stranger.</b>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
A single Python asyncio daemon with a SQLite store, running on a private host.
|
|
9
|
+
It polls on pull requests of a GitHub repository for exactly two events:
|
|
10
|
+
|
|
11
|
+
1. a freshly opened pull request whose author is pre-approved;
|
|
12
|
+
2. a comment containing `@claude` whose commenter is pre-approved.
|
|
13
|
+
|
|
14
|
+
and performs code review using Claude CLI and posts review comments on the pull request.
|
|
15
|
+
|
|
16
|
+
## 🚀 Quickstart
|
|
17
|
+
|
|
18
|
+
_Requires_: Python **3.10 – 3.14** and python virtualenv.
|
|
19
|
+
Download [latest release](https://github.com/prasadtalasila/pr-review-agent/releases).
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python -m venv .venv
|
|
23
|
+
source .venv/bin/activate
|
|
24
|
+
|
|
25
|
+
# download the latest release
|
|
26
|
+
pip install pr_review_agent-<version>-py3-none-any.whl
|
|
27
|
+
|
|
28
|
+
pr-review-agent config generate # writes ./config.yaml
|
|
29
|
+
# update config; `config generate --full` writes the commented template
|
|
30
|
+
pr-review-agent config validate
|
|
31
|
+
|
|
32
|
+
# get GitHub PAT with read and write permissions on pull requests
|
|
33
|
+
GITHUB_TOKEN=xxxx pr-review-agent host check # can this host reach it all?
|
|
34
|
+
GITHUB_TOKEN=xxxx pr-review-agent daemon start # reads ./config.yaml
|
|
35
|
+
GITHUB_TOKEN=xxxx pr-review-agent daemon start --config /etc/pr-review-agent/config.yaml
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Installing the package puts `pr-review-agent` on the path. Commands follow a
|
|
39
|
+
`pr-review-agent <noun> <verb>` grammar, grouped by the setup workflow:
|
|
40
|
+
`config` → `host` → `daemon`. `--config` is optional: without it a command
|
|
41
|
+
reads `config.yaml` from the directory it is started in, which is also where
|
|
42
|
+
`state.db` is written.
|
|
43
|
+
|
|
44
|
+
## 🗂 Documentation
|
|
45
|
+
|
|
46
|
+
| Document | Answers |
|
|
47
|
+
| :-- | :-- |
|
|
48
|
+
| [docs/CONFIG.md](docs/CONFIG.md) | What settings exist, what does each accept, and why are unknown keys an error? |
|
|
49
|
+
| [docs/DESIGN.md](docs/DESIGN.md) | Why does this exist and why is it shaped like this? The four constraints, every alternative considered and rejected, the billing-mode question that is still open, and how prompt injection is handled |
|
|
50
|
+
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | What actually runs? The components, the path an event takes, the package layout, and which layer may import which |
|
|
51
|
+
| [docs/TRIGGERS.md](docs/TRIGGERS.md) | What starts a review and what does not? Every reason code and its log level, what counts as a mention, the dedupe keys, and why identity is a number |
|
|
52
|
+
| [docs/POLLER.md](docs/POLLER.md) | How does it learn something happened without an inbound port? The three endpoints, the rate-limit arithmetic, the adaptive interval, the retry rules, and how a comment payload is mapped to a pull request |
|
|
53
|
+
| [docs/DAEMON.md](docs/DAEMON.md) | What runs continuously, and what is it careful not to do? The cycle, the cold-start spend bound, the two watermark ordering rules, and how it shuts down |
|
|
54
|
+
| [docs/QUEUE.md](docs/QUEUE.md) | Where does an accepted trigger wait, and what stops one review being paid for twice? Dedupe, the per-pull-request lease, why leases expire instead of renewing, and the retry bound |
|
|
55
|
+
| [docs/STORAGE.md](docs/STORAGE.md) | What has to survive a restart, and what does a lost watermark actually cost? Why SQLite, and why a watermark only moves forward |
|
|
56
|
+
| [docs/WORKSPACE.md](docs/WORKSPACE.md) | How does a pull request's code get onto disk, and why is none of it ever run? The bare mirror, the per-run worktree, the untrusted-tree hardening, and the diff-size caps |
|
|
57
|
+
| [docs/BUDGET.md](docs/BUDGET.md) | The rolling windows and the share that guarantees human headroom, reserve-then-settle under concurrency, the degradation ladder, the circuit breaker, and what is still not built |
|
|
58
|
+
| [docs/WORKER.md](docs/WORKER.md) | What drains the queue? The claim-run-settle loop, what a failed run settles at and why, which failures retry and which are permanent, what a run leaves behind, the supervisor, and why not a process per review |
|
|
59
|
+
| [docs/PUBLISHER.md](docs/PUBLISHER.md) | How does a review become visible, and what stops the agent approving anything? The 👀 at claim time, the live `head_sha` re-check, one comment per pull request, `publish.dry_run`, and why a failed publish never costs a second review |
|
|
60
|
+
| [docs/ENGINE.md](docs/ENGINE.md) | How does a different coding agent plug in? The one swappable step, what an engine is given and must return, the capability record, and why every adapter is a CLI subprocess rather than an SDK |
|
|
61
|
+
| [docs/ROADMAP.md](docs/ROADMAP.md) | What is built, what is next, the acceptance checklist, and the known gaps |
|
|
62
|
+
| [DEVELOPER.md](DEVELOPER.md) | How do I set up, test, lint and build this? |
|
|
63
|
+
| [CLAUDE.md](CLAUDE.md) | The behavioural guidelines applied to every change |
|
|
64
|
+
| [AGENTS.md](AGENTS.md) | The coding-assistant conventions |
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pr-review-agent"
|
|
3
|
+
version = "0.16.0"
|
|
4
|
+
description = "Locally-hosted LLM-based PR review agent with enforced usage budgets"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10,<3.15"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "The INTO-CPS Association" }]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3.10",
|
|
12
|
+
"Programming Language :: Python :: 3.11",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Programming Language :: Python :: 3.14",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"PyYAML>=6.0",
|
|
19
|
+
"httpx>=0.27",
|
|
20
|
+
"click>=8.1,<9",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/prasadtalasila/pr-review-agent"
|
|
25
|
+
|
|
26
|
+
# Without this the wheel ships the package and no command: `pip install` puts
|
|
27
|
+
# `pr_review_agent` on the path and nothing in `bin/`, so an operator who
|
|
28
|
+
# installed the released artefact had `python -m pr_review_agent.daemon` and
|
|
29
|
+
# no `pr-review-agent`.
|
|
30
|
+
[project.scripts]
|
|
31
|
+
pr-review-agent = "pr_review_agent.cli:main"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["poetry-core>=2.0.0"]
|
|
35
|
+
build-backend = "poetry.core.masonry.api"
|
|
36
|
+
|
|
37
|
+
[tool.poetry]
|
|
38
|
+
packages = [{ include = "pr_review_agent", from = "src" }]
|
|
39
|
+
# The config templates are data, not code, so nothing would pull them into the
|
|
40
|
+
# distribution on its own -- and for thirteen releases nothing did, leaving a
|
|
41
|
+
# quickstart that told the operator to copy a file the wheel had never
|
|
42
|
+
# contained. `config generate` reads them from here.
|
|
43
|
+
include = [
|
|
44
|
+
{ path = "src/pr_review_agent/templates/*.yaml", format = "sdist" },
|
|
45
|
+
{ path = "src/pr_review_agent/templates/*.yaml", format = "wheel" },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.poetry.group.dev.dependencies]
|
|
49
|
+
pytest = "^8.3"
|
|
50
|
+
pytest-cov = "^6.0"
|
|
51
|
+
ruff = "^0.9"
|
|
52
|
+
pylint = "^3.3"
|
|
53
|
+
pyright = "^1.1"
|
|
54
|
+
pytest-asyncio = "^0.24"
|
|
55
|
+
cryptography = ">=42"
|
|
56
|
+
|
|
57
|
+
[tool.poetry.group.docs]
|
|
58
|
+
optional = true
|
|
59
|
+
|
|
60
|
+
[tool.poetry.group.docs.dependencies]
|
|
61
|
+
mkdocs-material = ">=9.7,<10.0"
|
|
62
|
+
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
testpaths = ["tests"]
|
|
65
|
+
pythonpath = ["src"]
|
|
66
|
+
# The client and poller are asyncio-native; every async test in the suite
|
|
67
|
+
# wants the default event loop, so marking each one individually is noise.
|
|
68
|
+
asyncio_mode = "auto"
|
|
69
|
+
# A `live` test runs a real coding agent and spends real tokens, so it is
|
|
70
|
+
# deselected by default and never runs in CI. `-m live` opts in.
|
|
71
|
+
addopts = ["-m", "not live"]
|
|
72
|
+
markers = ["live: runs a real engine binary and spends tokens; opt in with -m live"]
|
|
73
|
+
|
|
74
|
+
[tool.coverage.run]
|
|
75
|
+
source = ["src/pr_review_agent"]
|
|
76
|
+
branch = true
|
|
77
|
+
|
|
78
|
+
[tool.ruff]
|
|
79
|
+
line-length = 88
|
|
80
|
+
target-version = "py310"
|
|
81
|
+
|
|
82
|
+
[tool.ruff.lint]
|
|
83
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
84
|
+
|
|
85
|
+
[tool.pyright]
|
|
86
|
+
include = ["src", "tests"]
|
|
87
|
+
pythonVersion = "3.10"
|
|
88
|
+
typeCheckingMode = "basic"
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Standard-library shims for the Python versions this package supports.
|
|
2
|
+
|
|
3
|
+
The package supports 3.10 through 3.14. Anything here exists solely because a
|
|
4
|
+
name is missing on the oldest of those; each shim is deleted, not rewritten,
|
|
5
|
+
once the floor rises past the version that needs it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
if sys.version_info >= (3, 11):
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
else: # pragma: no cover - exercised on 3.10 only
|
|
15
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
class StrEnum(str, Enum):
|
|
18
|
+
"""`enum.StrEnum` for Python 3.10.
|
|
19
|
+
|
|
20
|
+
A plain ``(str, Enum)`` mixin is not equivalent: on 3.10 it inherits
|
|
21
|
+
``Enum.__str__``, so ``str(member)`` yields ``"Endpoint.OPEN_PULLS"``
|
|
22
|
+
rather than the member's value. Anything that interpolates a member
|
|
23
|
+
into a string -- a log line, a dict key written to disk -- would then
|
|
24
|
+
change meaning with the interpreter version. Delegating ``__str__``
|
|
25
|
+
and ``__format__`` to ``str`` restores the 3.11 behaviour.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
__str__ = str.__str__
|
|
29
|
+
__format__ = str.__format__
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = ["StrEnum"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""What a command-line entry point does before it can do anything else.
|
|
2
|
+
|
|
3
|
+
``host check`` and ``daemon start`` each need the same two things: the GitHub
|
|
4
|
+
token, and a validated ``config.yaml``. Sharing the step keeps one answer to
|
|
5
|
+
"what does exit status 3 mean" rather than two that drift apart.
|
|
6
|
+
|
|
7
|
+
The two halves are separate functions because one command needs only one of
|
|
8
|
+
them. ``config validate`` asks whether a file is loadable, and a combined
|
|
9
|
+
step would have made it refuse to answer on a host that has no token --
|
|
10
|
+
demanding a credential to parse YAML. So the token check is its own call, and
|
|
11
|
+
the commands that need both make both.
|
|
12
|
+
|
|
13
|
+
The token is read from the environment, never from ``config.yaml``, so it can
|
|
14
|
+
come from a systemd ``EnvironmentFile`` or a secret manager without ever
|
|
15
|
+
being a file the repository could swallow. It is never printed: a
|
|
16
|
+
:class:`StartupError` says what was missing, not what was sent.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
from .config import Config, ConfigError
|
|
24
|
+
|
|
25
|
+
TOKEN_ENV = "GITHUB_TOKEN"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StartupError(RuntimeError):
|
|
29
|
+
"""Raised when the token or the configuration file is unusable."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def require_token() -> str:
|
|
33
|
+
"""The GitHub token, or :class:`StartupError` if the environment has none.
|
|
34
|
+
|
|
35
|
+
A non-zero exit rather than a default: running without a token is worse
|
|
36
|
+
than stopping, because every failure it causes surfaces later and
|
|
37
|
+
further from the cause.
|
|
38
|
+
"""
|
|
39
|
+
token = os.environ.get(TOKEN_ENV)
|
|
40
|
+
if not token:
|
|
41
|
+
raise StartupError(f"{TOKEN_ENV} is not set")
|
|
42
|
+
return token
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_config(config_path: str) -> Config:
|
|
46
|
+
"""The validated configuration, or :class:`StartupError` if unusable.
|
|
47
|
+
|
|
48
|
+
``ConfigError`` is re-raised as ``StartupError`` so a caller has one
|
|
49
|
+
exception type to catch for "this host is not set up", whichever half
|
|
50
|
+
of the setup is missing.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
return Config.load(config_path)
|
|
54
|
+
except ConfigError as exc:
|
|
55
|
+
raise StartupError(str(exc)) from exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def startup(config_path: str) -> tuple[Config, str]:
|
|
59
|
+
"""Both halves, token first.
|
|
60
|
+
|
|
61
|
+
The order is load-bearing: a missing token is the cheaper and more
|
|
62
|
+
common misconfiguration, and reporting it before parsing keeps the
|
|
63
|
+
first error an operator sees the one they most likely caused.
|
|
64
|
+
"""
|
|
65
|
+
token = require_token()
|
|
66
|
+
return load_config(config_path), token
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Pre-flight checks to run on the deployment host before the daemon does.
|
|
2
|
+
|
|
3
|
+
The daemon is outbound-only, so the first question about any host is whether
|
|
4
|
+
it can get *out* -- to `api.github.com` for polling and publishing, and to
|
|
5
|
+
`api.anthropic.com` for the review itself. On an allowlist-based firewall
|
|
6
|
+
that is the most common cause of schedule slip, and it is cheap to answer
|
|
7
|
+
before anything else is built on the assumption.
|
|
8
|
+
|
|
9
|
+
Three things are checked, in the order they would bite:
|
|
10
|
+
|
|
11
|
+
**Every watched endpoint answers.** The three repo-wide paths are fetched
|
|
12
|
+
with the same client the poller uses, so a failure here is the failure the
|
|
13
|
+
poller would have had: a blocked route, a bad token, a repository the token
|
|
14
|
+
cannot see.
|
|
15
|
+
|
|
16
|
+
**A repeat request comes back 304.** This is the one worth running even on a
|
|
17
|
+
host with obviously working egress. The whole rate-limit budget rests on
|
|
18
|
+
conditional requests being free, and an intercepting proxy that strips or
|
|
19
|
+
rewrites `ETag` turns every poll into a full 200 -- silently, and only
|
|
20
|
+
visibly once the budget runs out mid-week.
|
|
21
|
+
|
|
22
|
+
**Git can fetch, and is new enough.** The review needs the code on disk, and
|
|
23
|
+
that is a different host from the API -- `github.com`, not `api.github.com`
|
|
24
|
+
-- so on an allowlist-based firewall it is a different rule, and the
|
|
25
|
+
poller's route answering proves nothing about it. The version is checked
|
|
26
|
+
first because below 2.32 `GIT_CONFIG_GLOBAL` is ignored *without an error*,
|
|
27
|
+
which would leave the checkout's whole hardening absent while appearing to
|
|
28
|
+
be in force.
|
|
29
|
+
|
|
30
|
+
**Anthropic is reachable.** No API key is sent and none is needed: any HTTP
|
|
31
|
+
status proves the route exists, and only a transport error is a failure.
|
|
32
|
+
|
|
33
|
+
The token is read from the environment, never from `config.yaml`, and is
|
|
34
|
+
never printed -- the checks report what happened, not what was sent.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import sys
|
|
40
|
+
from collections.abc import Sequence
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
|
|
43
|
+
import httpx
|
|
44
|
+
|
|
45
|
+
from .config import Config
|
|
46
|
+
from .poller.client import GitHubClient, GitHubClientError, PollResult
|
|
47
|
+
from .poller.endpoints import RepoEndpoints
|
|
48
|
+
from .workspace.gitcmd import MINIMUM_GIT_VERSION, WorkspaceError, git_version, run_git
|
|
49
|
+
from .workspace.repo import GITHUB_BASE
|
|
50
|
+
|
|
51
|
+
# Unauthenticated, so it answers 401; that is a route, which is all we ask.
|
|
52
|
+
ANTHROPIC_PROBE_URL = "https://api.anthropic.com/v1/models"
|
|
53
|
+
|
|
54
|
+
_CONDITIONAL = "github conditional GET"
|
|
55
|
+
_GIT_VERSION = "git version"
|
|
56
|
+
_GIT_ROUTE = "git fetch route"
|
|
57
|
+
#: An `ls-remote` that has not answered in half a minute is a blocked route,
|
|
58
|
+
#: not a slow one, and the operator is waiting at a terminal.
|
|
59
|
+
_LS_REMOTE_TIMEOUT = 30.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class CheckResult:
|
|
64
|
+
"""The outcome of one check, in a form an operator can act on."""
|
|
65
|
+
|
|
66
|
+
name: str
|
|
67
|
+
ok: bool
|
|
68
|
+
detail: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def check_github(
|
|
72
|
+
client: GitHubClient, endpoints: RepoEndpoints
|
|
73
|
+
) -> list[CheckResult]:
|
|
74
|
+
"""Fetch all three watched endpoints, then re-fetch one conditionally."""
|
|
75
|
+
results: list[CheckResult] = []
|
|
76
|
+
revisit: tuple[str, str] | None = None
|
|
77
|
+
for endpoint, path in endpoints.all_paths().items():
|
|
78
|
+
name = f"github {endpoint}"
|
|
79
|
+
try:
|
|
80
|
+
result = await client.get(path)
|
|
81
|
+
except GitHubClientError as exc:
|
|
82
|
+
results.append(CheckResult(name, False, str(exc)))
|
|
83
|
+
continue
|
|
84
|
+
results.append(CheckResult(name, True, _budget(result)))
|
|
85
|
+
if revisit is None and result.etag is not None:
|
|
86
|
+
revisit = (path, result.etag)
|
|
87
|
+
results.append(await _check_conditional(client, revisit))
|
|
88
|
+
results.append(await _check_write_scope(client, endpoints))
|
|
89
|
+
return results
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _check_write_scope(
|
|
93
|
+
client: GitHubClient, endpoints: RepoEndpoints
|
|
94
|
+
) -> CheckResult:
|
|
95
|
+
"""Can this token post a review comment?
|
|
96
|
+
|
|
97
|
+
Read-only scope was sufficient while nothing published, and DESIGN.md
|
|
98
|
+
said so. It is not now: without write scope a review is polled for,
|
|
99
|
+
claimed, paid for and computed, and then fails on the last call -- the
|
|
100
|
+
most expensive possible way to find out about a misconfigured token.
|
|
101
|
+
|
|
102
|
+
An absent ``permissions`` object warns rather than fails. A fine-grained
|
|
103
|
+
token need not report one, and refusing to start over a field GitHub
|
|
104
|
+
chose not to send would make the check worse than no check.
|
|
105
|
+
"""
|
|
106
|
+
name = "github write scope"
|
|
107
|
+
try:
|
|
108
|
+
result = await client.get(endpoints.repository())
|
|
109
|
+
except GitHubClientError as exc:
|
|
110
|
+
return CheckResult(name, False, str(exc))
|
|
111
|
+
permissions = (
|
|
112
|
+
result.data.get("permissions") if isinstance(result.data, dict) else None
|
|
113
|
+
)
|
|
114
|
+
if not isinstance(permissions, dict) or "push" not in permissions:
|
|
115
|
+
return CheckResult(
|
|
116
|
+
name, True, "could not determine write scope from this token"
|
|
117
|
+
)
|
|
118
|
+
if not permissions["push"]:
|
|
119
|
+
return CheckResult(
|
|
120
|
+
name, False, "the token has no write access; the publisher cannot post"
|
|
121
|
+
)
|
|
122
|
+
return CheckResult(name, True, "the token may post comments")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def check_git(repo: str, base_url: str = GITHUB_BASE) -> list[CheckResult]:
|
|
126
|
+
"""Is git new enough, and does the fetch route work?
|
|
127
|
+
|
|
128
|
+
The version comes first because it is what makes the rest of the
|
|
129
|
+
checkout's hardening real: below 2.32, ``GIT_CONFIG_GLOBAL`` is ignored
|
|
130
|
+
*without an error*, so the control that neutralises the host's gitconfig
|
|
131
|
+
is simply absent while appearing to be in force.
|
|
132
|
+
|
|
133
|
+
The route is a second question from the poller's. `github.com` and
|
|
134
|
+
`api.github.com` are different hosts and, on an allowlist-based
|
|
135
|
+
firewall, different rules -- so the poller's route answering says
|
|
136
|
+
nothing at all about this one.
|
|
137
|
+
"""
|
|
138
|
+
results: list[CheckResult] = []
|
|
139
|
+
try:
|
|
140
|
+
version = await git_version()
|
|
141
|
+
except WorkspaceError as exc:
|
|
142
|
+
results.append(CheckResult(_GIT_VERSION, False, str(exc)))
|
|
143
|
+
return results
|
|
144
|
+
|
|
145
|
+
found = ".".join(str(part) for part in version)
|
|
146
|
+
wanted = ".".join(str(part) for part in MINIMUM_GIT_VERSION)
|
|
147
|
+
results.append(
|
|
148
|
+
CheckResult(
|
|
149
|
+
_GIT_VERSION,
|
|
150
|
+
version >= MINIMUM_GIT_VERSION,
|
|
151
|
+
f"found {found}, need at least {wanted}",
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
url = f"{base_url.rstrip('/')}/{repo}.git"
|
|
156
|
+
try:
|
|
157
|
+
await run_git("ls-remote", "--heads", url, timeout=_LS_REMOTE_TIMEOUT)
|
|
158
|
+
except WorkspaceError as exc:
|
|
159
|
+
results.append(CheckResult(_GIT_ROUTE, False, str(exc)))
|
|
160
|
+
else:
|
|
161
|
+
results.append(CheckResult(_GIT_ROUTE, True, f"{url} answers"))
|
|
162
|
+
return results
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def check_anthropic(client: httpx.AsyncClient) -> CheckResult:
|
|
166
|
+
"""Prove the route to Anthropic exists; any HTTP status counts."""
|
|
167
|
+
name = "anthropic reachable"
|
|
168
|
+
try:
|
|
169
|
+
response = await client.get(ANTHROPIC_PROBE_URL)
|
|
170
|
+
except httpx.HTTPError as exc:
|
|
171
|
+
return CheckResult(name, False, f"no route: {exc}")
|
|
172
|
+
return CheckResult(name, True, f"HTTP {response.status_code}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def run_checks(config: Config, token: str) -> list[CheckResult]:
|
|
176
|
+
"""Run every check for the repository ``config`` names."""
|
|
177
|
+
endpoints = RepoEndpoints(config.github.owner, config.github.name)
|
|
178
|
+
github = GitHubClient(token)
|
|
179
|
+
anthropic = httpx.AsyncClient()
|
|
180
|
+
try:
|
|
181
|
+
results = await check_github(github, endpoints)
|
|
182
|
+
results.extend(await check_git(config.github.repo))
|
|
183
|
+
results.append(await check_anthropic(anthropic))
|
|
184
|
+
finally:
|
|
185
|
+
await github.aclose()
|
|
186
|
+
await anthropic.aclose()
|
|
187
|
+
return results
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _check_conditional(
|
|
191
|
+
client: GitHubClient, revisit: tuple[str, str] | None
|
|
192
|
+
) -> CheckResult:
|
|
193
|
+
"""Re-request a path with its own ETag and insist on a 304."""
|
|
194
|
+
if revisit is None:
|
|
195
|
+
return CheckResult(_CONDITIONAL, False, "no endpoint returned an ETag")
|
|
196
|
+
path, etag = revisit
|
|
197
|
+
try:
|
|
198
|
+
result = await client.get(path, etag=etag)
|
|
199
|
+
except GitHubClientError as exc:
|
|
200
|
+
return CheckResult(_CONDITIONAL, False, str(exc))
|
|
201
|
+
if result.changed:
|
|
202
|
+
return CheckResult(
|
|
203
|
+
_CONDITIONAL,
|
|
204
|
+
False,
|
|
205
|
+
"got 200, not 304 -- an ETag is being stripped in transit, "
|
|
206
|
+
"or the repository changed between the two requests",
|
|
207
|
+
)
|
|
208
|
+
return CheckResult(_CONDITIONAL, True, "304 Not Modified")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _budget(result: PollResult) -> str:
|
|
212
|
+
if result.rate_limit is None:
|
|
213
|
+
return "no rate-limit headers"
|
|
214
|
+
return f"{result.rate_limit.remaining}/{result.rate_limit.limit} remaining"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def report(results: Sequence[CheckResult]) -> int:
|
|
218
|
+
"""Print every result and return the exit status: 1 if any failed."""
|
|
219
|
+
for result in results:
|
|
220
|
+
print(f"{'PASS' if result.ok else 'FAIL'} {result.name}: {result.detail}")
|
|
221
|
+
failed = [result.name for result in results if not result.ok]
|
|
222
|
+
if failed:
|
|
223
|
+
print(f"\n{len(failed)} check(s) failed: {', '.join(failed)}", file=sys.stderr)
|
|
224
|
+
return 1
|
|
225
|
+
return 0
|