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.
Files changed (48) hide show
  1. pr_review_agent-0.16.0/LICENSE +21 -0
  2. pr_review_agent-0.16.0/PKG-INFO +84 -0
  3. pr_review_agent-0.16.0/README.md +64 -0
  4. pr_review_agent-0.16.0/pyproject.toml +88 -0
  5. pr_review_agent-0.16.0/src/pr_review_agent/__init__.py +0 -0
  6. pr_review_agent-0.16.0/src/pr_review_agent/_compat.py +32 -0
  7. pr_review_agent-0.16.0/src/pr_review_agent/_startup.py +66 -0
  8. pr_review_agent-0.16.0/src/pr_review_agent/bootstrap.py +225 -0
  9. pr_review_agent-0.16.0/src/pr_review_agent/budget.py +722 -0
  10. pr_review_agent-0.16.0/src/pr_review_agent/cli/__init__.py +85 -0
  11. pr_review_agent-0.16.0/src/pr_review_agent/cli/_common.py +59 -0
  12. pr_review_agent-0.16.0/src/pr_review_agent/cli/cmd_config.py +91 -0
  13. pr_review_agent-0.16.0/src/pr_review_agent/cli/cmd_daemon.py +36 -0
  14. pr_review_agent-0.16.0/src/pr_review_agent/cli/cmd_host.py +37 -0
  15. pr_review_agent-0.16.0/src/pr_review_agent/config.py +656 -0
  16. pr_review_agent-0.16.0/src/pr_review_agent/daemon.py +445 -0
  17. pr_review_agent-0.16.0/src/pr_review_agent/engine/__init__.py +46 -0
  18. pr_review_agent-0.16.0/src/pr_review_agent/engine/claude.py +265 -0
  19. pr_review_agent-0.16.0/src/pr_review_agent/engine/cli.py +250 -0
  20. pr_review_agent-0.16.0/src/pr_review_agent/engine/fake.py +56 -0
  21. pr_review_agent-0.16.0/src/pr_review_agent/engine/models.py +183 -0
  22. pr_review_agent-0.16.0/src/pr_review_agent/engine/prompt.py +265 -0
  23. pr_review_agent-0.16.0/src/pr_review_agent/engine/standards.py +73 -0
  24. pr_review_agent-0.16.0/src/pr_review_agent/numbering.py +52 -0
  25. pr_review_agent-0.16.0/src/pr_review_agent/poller/__init__.py +25 -0
  26. pr_review_agent-0.16.0/src/pr_review_agent/poller/client.py +252 -0
  27. pr_review_agent-0.16.0/src/pr_review_agent/poller/endpoints.py +115 -0
  28. pr_review_agent-0.16.0/src/pr_review_agent/poller/etag_store.py +40 -0
  29. pr_review_agent-0.16.0/src/pr_review_agent/poller/interval.py +51 -0
  30. pr_review_agent-0.16.0/src/pr_review_agent/poller/payloads.py +134 -0
  31. pr_review_agent-0.16.0/src/pr_review_agent/poller/poller.py +91 -0
  32. pr_review_agent-0.16.0/src/pr_review_agent/poller/pulls.py +49 -0
  33. pr_review_agent-0.16.0/src/pr_review_agent/publisher.py +323 -0
  34. pr_review_agent-0.16.0/src/pr_review_agent/queue.py +367 -0
  35. pr_review_agent-0.16.0/src/pr_review_agent/runs.py +337 -0
  36. pr_review_agent-0.16.0/src/pr_review_agent/store.py +291 -0
  37. pr_review_agent-0.16.0/src/pr_review_agent/templates/config.example.yaml +269 -0
  38. pr_review_agent-0.16.0/src/pr_review_agent/templates/config.minimal.example.yaml +18 -0
  39. pr_review_agent-0.16.0/src/pr_review_agent/triggers/__init__.py +31 -0
  40. pr_review_agent-0.16.0/src/pr_review_agent/triggers/allowlist.py +62 -0
  41. pr_review_agent-0.16.0/src/pr_review_agent/triggers/classifier.py +168 -0
  42. pr_review_agent-0.16.0/src/pr_review_agent/triggers/mention.py +78 -0
  43. pr_review_agent-0.16.0/src/pr_review_agent/triggers/models.py +147 -0
  44. pr_review_agent-0.16.0/src/pr_review_agent/worker.py +503 -0
  45. pr_review_agent-0.16.0/src/pr_review_agent/workspace/__init__.py +25 -0
  46. pr_review_agent-0.16.0/src/pr_review_agent/workspace/exclusions.py +39 -0
  47. pr_review_agent-0.16.0/src/pr_review_agent/workspace/gitcmd.py +176 -0
  48. 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