ctrlrelay 0.7.0__tar.gz → 0.8.1__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.
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/CHANGELOG.md +38 -1
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/PKG-INFO +1 -1
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/config/orchestrator.yaml.example +6 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/configuration.md +26 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/pyproject.toml +1 -1
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/cli.py +2 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/config.py +7 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/github.py +80 -48
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/dev.py +4 -1
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_config.py +64 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_dev_pipeline.py +59 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_github.py +77 -27
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.gitignore +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/LICENSE +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/README.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/SECURITY.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/Gemfile +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/_config.yml +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/architecture.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/bridge.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/cli.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/development.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/getting-started.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/index.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/operations.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/personalization.md +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/__init__.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/conftest.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_audit.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_install.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_obs.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_poller.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_setup.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_state.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_transport.py +0 -0
- {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.1] - 2026-09-04
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`ctrlrelay ci wait` was unconditionally broken on `gh` releases without
|
|
15
|
+
`--json` support on `pr checks`** (confirmed on gh 2.45.0, the current
|
|
16
|
+
Ubuntu 24.04 package — no newer version exists in the distro repos, so
|
|
17
|
+
this wasn't an "apt upgrade" problem for anyone on that platform).
|
|
18
|
+
`GitHubCLI.get_pr_checks` shelled out to `gh pr checks --json ...`; on an
|
|
19
|
+
affected install every single poll failed with `unknown flag: --json`,
|
|
20
|
+
which `PRVerifier.wait_for_checks` treats as transient and retries —
|
|
21
|
+
so every `ci wait` call was guaranteed to eat its full timeout and then
|
|
22
|
+
fail, regardless of the PR's actual state. Reproduced directly and
|
|
23
|
+
confirmed as the likely cause behind some "session never signaled DONE"
|
|
24
|
+
reports. Switched to `gh pr view --json statusCheckRollup`, stable since
|
|
25
|
+
early gh 2.x and always exit-0 regardless of check state, with a new
|
|
26
|
+
normalizer mapping its two GraphQL shapes (`CheckRun` and the legacy
|
|
27
|
+
`StatusContext`) into the `{name, state, bucket, link}` shape
|
|
28
|
+
`PRVerifier` already expects. See #145.
|
|
29
|
+
|
|
30
|
+
## [0.8.0] - 2026-09-04
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **`ci_wait_timeout_seconds`: per-repo cap on the dev pipeline's
|
|
35
|
+
`ctrlrelay ci wait` call.** The dev pipeline prompt tells Claude to run
|
|
36
|
+
`ctrlrelay ci wait --pr <PR> --repo <repo> --timeout 600` before
|
|
37
|
+
signaling DONE — a single blocking call, hardcoded to 10 minutes, that
|
|
38
|
+
generates zero tokens while it polls GitHub. Measured directly on a real
|
|
39
|
+
session: two `ci wait` calls accounted for 12 of a 26-minute run, versus
|
|
40
|
+
under 35K output tokens generated the whole time — the wall-clock cost
|
|
41
|
+
is CI runtime, not model work. `ci_wait_timeout_seconds` (default `600`,
|
|
42
|
+
unchanged behavior) lets a repo with a faster CI suite cap that single
|
|
43
|
+
call lower instead of always budgeting the full 10 minutes.
|
|
44
|
+
|
|
10
45
|
## [0.7.0] - 2026-09-04
|
|
11
46
|
|
|
12
47
|
### Added
|
|
@@ -1232,7 +1267,9 @@ pipeline).
|
|
|
1232
1267
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1233
1268
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1234
1269
|
|
|
1235
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
1270
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.8.1...HEAD
|
|
1271
|
+
[0.8.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.8.0...v0.8.1
|
|
1272
|
+
[0.8.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.7.0...v0.8.0
|
|
1236
1273
|
[0.7.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.6.0...v0.7.0
|
|
1237
1274
|
[0.6.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.5.0...v0.6.0
|
|
1238
1275
|
[0.1.3]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
|
|
5
5
|
Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
|
|
6
6
|
Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
|
|
@@ -146,3 +146,9 @@ repos: []
|
|
|
146
146
|
# # Matching is case-insensitive. Defaults to [] (assignment alone
|
|
147
147
|
# # is sufficient, same as today).
|
|
148
148
|
# require_labels: []
|
|
149
|
+
# # Hard cap, in seconds, on the single `ctrlrelay ci wait` call the
|
|
150
|
+
# # dev pipeline prompt runs before signaling DONE. Lower this to
|
|
151
|
+
# # match how long this repo's CI actually takes — no tokens are
|
|
152
|
+
# # generated while blocked in this call, so a slow default here
|
|
153
|
+
# # just adds idle wall-clock time. Defaults to 600 (10 minutes).
|
|
154
|
+
# ci_wait_timeout_seconds: 600
|
|
@@ -201,6 +201,7 @@ and ask the operator), or `never` (skip).
|
|
|
201
201
|
| `exclude_labels` | `["manual", "operator", "instruction"]` | Issue labels that tell the poller "this isn't for the agent". See [exclude_labels](#reposautomationexclude_labels) below. |
|
|
202
202
|
| `include_labels` | `[]` | Issue labels that opt an issue **into** the dev pipeline regardless of who is (or isn't) assigned. See [include_labels](#reposautomationinclude_labels) below. |
|
|
203
203
|
| `require_labels` | `[]` | Issue labels that gate the plain-assignment trigger: when set, assignment alone is no longer enough — the issue must also carry one of these labels. See [require_labels](#reposautomationrequire_labels) below. |
|
|
204
|
+
| `ci_wait_timeout_seconds` | `600` | Hard cap, in seconds, on the single `ctrlrelay ci wait` call the dev pipeline prompt tells Claude to run before signaling DONE. See [ci_wait_timeout_seconds](#reposautomationci_wait_timeout_seconds) below. |
|
|
204
205
|
|
|
205
206
|
The current secops and dev pipelines read these settings to bias their prompts
|
|
206
207
|
to Claude — they're not enforced by hard-coded checks.
|
|
@@ -345,6 +346,31 @@ repos:
|
|
|
345
346
|
picked up yet.
|
|
346
347
|
- `exclude_labels` is still checked first, same precedence as always.
|
|
347
348
|
|
|
349
|
+
### repos[].automation.ci_wait_timeout_seconds
|
|
350
|
+
|
|
351
|
+
The dev pipeline prompt tells Claude to run `ctrlrelay ci wait --pr <PR>
|
|
352
|
+
--repo <repo> --timeout <N>` before signaling DONE — a single blocking call
|
|
353
|
+
that polls GitHub every 15s until CI finishes, fails, or the timeout hits.
|
|
354
|
+
No tokens get generated while it's blocked; on a repo with a slow CI suite,
|
|
355
|
+
that one call can eat most of a session's wall-clock time.
|
|
356
|
+
|
|
357
|
+
```yaml
|
|
358
|
+
repos:
|
|
359
|
+
- name: "your-org/your-repo"
|
|
360
|
+
local_path: "~/Projects/your-repo"
|
|
361
|
+
automation:
|
|
362
|
+
ci_wait_timeout_seconds: 300
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
- Default: `600` (10 minutes).
|
|
366
|
+
- Lower it to match how long the repo's CI actually takes — a repo with a
|
|
367
|
+
2-minute CI suite gains nothing from a 10-minute cap.
|
|
368
|
+
- A timeout here isn't a failure: `ctrlrelay ci wait` exits 2 (not 1) when
|
|
369
|
+
the deadline hits with checks still pending, and the prompt tells Claude
|
|
370
|
+
to treat that as acceptable and hand off rather than loop.
|
|
371
|
+
- This does not change the overall session timeout (`agent.default_timeout_seconds`,
|
|
372
|
+
default 1800s) — it only bounds the one `ci wait` call.
|
|
373
|
+
|
|
348
374
|
## schedules
|
|
349
375
|
|
|
350
376
|
In-process cron jobs run by the poller daemon. All expressions are
|
|
@@ -748,6 +748,7 @@ def run_dev(
|
|
|
748
748
|
state_db=db,
|
|
749
749
|
transport=None,
|
|
750
750
|
contexts_dir=config.paths.contexts,
|
|
751
|
+
ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
|
|
751
752
|
)
|
|
752
753
|
|
|
753
754
|
try:
|
|
@@ -1273,6 +1274,7 @@ def poller_start(
|
|
|
1273
1274
|
state_db=state_db,
|
|
1274
1275
|
transport=connected_transport,
|
|
1275
1276
|
contexts_dir=config.paths.contexts,
|
|
1277
|
+
ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
|
|
1276
1278
|
)
|
|
1277
1279
|
|
|
1278
1280
|
# Lock-conflict retry hook. The poller marks issues seen
|
|
@@ -201,6 +201,13 @@ class AutomationConfig(BaseModel):
|
|
|
201
201
|
# precedence so "not for the agent at all" overrides "agent does
|
|
202
202
|
# this but differently".
|
|
203
203
|
task_labels: list[str] = Field(default_factory=lambda: ["task"])
|
|
204
|
+
# Hard cap, in seconds, on a single `ctrlrelay ci wait` call the dev
|
|
205
|
+
# pipeline prompt tells Claude to run before signaling DONE. A repo
|
|
206
|
+
# with slow CI can eat most of a session's wall-clock time sitting
|
|
207
|
+
# in this one blocking call generating zero tokens; a fast-CI repo
|
|
208
|
+
# doesn't need anywhere near the 600s default. Lower this per-repo
|
|
209
|
+
# to match how long that repo's CI actually takes.
|
|
210
|
+
ci_wait_timeout_seconds: int = 600
|
|
204
211
|
|
|
205
212
|
|
|
206
213
|
_REPO_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
|
@@ -13,6 +13,65 @@ class GitHubError(Exception):
|
|
|
13
13
|
"""Raised when gh CLI operations fail."""
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
# CheckRun.conclusion values (only present once status == "COMPLETED").
|
|
17
|
+
_CHECK_RUN_PASS = frozenset({"SUCCESS", "NEUTRAL"})
|
|
18
|
+
_CHECK_RUN_SKIP = frozenset({"SKIPPED"})
|
|
19
|
+
_CHECK_RUN_CANCEL = frozenset({"CANCELLED"})
|
|
20
|
+
# StatusContext.state values (the legacy commit-status API, e.g. CLA bots).
|
|
21
|
+
_STATUS_CONTEXT_PASS = frozenset({"SUCCESS"})
|
|
22
|
+
_STATUS_CONTEXT_PENDING = frozenset({"PENDING", "EXPECTED"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _normalize_check(entry: dict[str, Any]) -> dict[str, Any]:
|
|
26
|
+
"""Map one `statusCheckRollup` entry to the `{name, state, bucket,
|
|
27
|
+
link}` shape `PRVerifier` expects (see #145).
|
|
28
|
+
|
|
29
|
+
`statusCheckRollup` mixes two GraphQL shapes: `CheckRun` (Actions
|
|
30
|
+
jobs, CodeQL — `status`/`conclusion`) and `StatusContext` (the
|
|
31
|
+
legacy commit-status API, e.g. CLA bots — `state`). `bucket` is
|
|
32
|
+
the only field the rest of the app actually branches on; `state`
|
|
33
|
+
is carried through for parity/debugging.
|
|
34
|
+
"""
|
|
35
|
+
if entry.get("__typename") == "StatusContext":
|
|
36
|
+
state = entry.get("state", "")
|
|
37
|
+
if state in _STATUS_CONTEXT_PASS:
|
|
38
|
+
bucket = "pass"
|
|
39
|
+
elif state in _STATUS_CONTEXT_PENDING:
|
|
40
|
+
bucket = "pending"
|
|
41
|
+
else:
|
|
42
|
+
bucket = "fail"
|
|
43
|
+
return {
|
|
44
|
+
"name": entry.get("context", ""),
|
|
45
|
+
"state": state,
|
|
46
|
+
"bucket": bucket,
|
|
47
|
+
"link": entry.get("targetUrl", ""),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
status = entry.get("status", "")
|
|
51
|
+
conclusion = entry.get("conclusion")
|
|
52
|
+
if status != "COMPLETED":
|
|
53
|
+
bucket = "pending"
|
|
54
|
+
state = status
|
|
55
|
+
elif conclusion in _CHECK_RUN_PASS:
|
|
56
|
+
bucket = "pass"
|
|
57
|
+
state = conclusion
|
|
58
|
+
elif conclusion in _CHECK_RUN_SKIP:
|
|
59
|
+
bucket = "skipping"
|
|
60
|
+
state = conclusion
|
|
61
|
+
elif conclusion in _CHECK_RUN_CANCEL:
|
|
62
|
+
bucket = "cancel"
|
|
63
|
+
state = conclusion
|
|
64
|
+
else:
|
|
65
|
+
bucket = "fail"
|
|
66
|
+
state = conclusion or "FAILURE"
|
|
67
|
+
return {
|
|
68
|
+
"name": entry.get("name", ""),
|
|
69
|
+
"state": state,
|
|
70
|
+
"bucket": bucket,
|
|
71
|
+
"link": entry.get("detailsUrl", ""),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
16
75
|
def _find_gh() -> str:
|
|
17
76
|
"""Find gh binary, checking common paths if not in PATH."""
|
|
18
77
|
gh = shutil.which("gh")
|
|
@@ -146,57 +205,30 @@ class GitHubCLI:
|
|
|
146
205
|
) -> list[dict[str, Any]]:
|
|
147
206
|
"""Get status checks for a PR.
|
|
148
207
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
208
|
+
Uses `gh pr view --json statusCheckRollup` rather than `gh pr
|
|
209
|
+
checks --json ...` — the latter's `--json` flag isn't available
|
|
210
|
+
on `gh` releases before it was added (confirmed absent on gh
|
|
211
|
+
2.45.0, the current Ubuntu 24.04 package with no newer version
|
|
212
|
+
in the distro repos), which made this unconditionally broken on
|
|
213
|
+
those installs regardless of the PR's actual state (#145).
|
|
214
|
+
|
|
215
|
+
`gh pr view --json` always exits 0 once the PR itself resolves —
|
|
216
|
+
the check states live in the JSON, not the exit code — so unlike
|
|
217
|
+
the old implementation, a non-zero exit here is always a genuine
|
|
218
|
+
failure (auth, network, missing PR): `_run_gh` already raises
|
|
219
|
+
`GitHubError` for that case. No checks yet (or ever) on the PR
|
|
220
|
+
just means an empty `statusCheckRollup` list — the ambiguity
|
|
221
|
+
between "not registered yet" and "no CI configured" is handled
|
|
222
|
+
by `PRVerifier`'s empty-streak retry, not here.
|
|
161
223
|
"""
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
"pr", "checks",
|
|
165
|
-
str(pr_number),
|
|
224
|
+
stdout = await self._run_gh(
|
|
225
|
+
"pr", "view", str(pr_number),
|
|
166
226
|
"--repo", repo,
|
|
167
|
-
"--json", "
|
|
168
|
-
]
|
|
169
|
-
proc = await asyncio.create_subprocess_exec(
|
|
170
|
-
*cmd,
|
|
171
|
-
stdout=asyncio.subprocess.PIPE,
|
|
172
|
-
stderr=asyncio.subprocess.PIPE,
|
|
227
|
+
"--json", "statusCheckRollup",
|
|
173
228
|
)
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
)
|
|
178
|
-
except asyncio.TimeoutError:
|
|
179
|
-
proc.kill()
|
|
180
|
-
try:
|
|
181
|
-
await proc.wait()
|
|
182
|
-
except Exception:
|
|
183
|
-
pass
|
|
184
|
-
raise
|
|
185
|
-
stdout = stdout_bytes.decode().strip()
|
|
186
|
-
stderr = stderr_bytes.decode().strip()
|
|
187
|
-
|
|
188
|
-
# JSON payload on stdout — always trust it, regardless of exit code.
|
|
189
|
-
if stdout:
|
|
190
|
-
return json.loads(stdout)
|
|
191
|
-
|
|
192
|
-
# No stdout. Distinguish "no CI configured" from genuine failures by
|
|
193
|
-
# looking for gh's well-known "no checks reported" message.
|
|
194
|
-
if "no checks reported" in stderr.lower():
|
|
195
|
-
return []
|
|
196
|
-
|
|
197
|
-
# Anything else is an honest-to-goodness failure. Don't pretend the
|
|
198
|
-
# repo has no CI.
|
|
199
|
-
raise GitHubError(f"gh pr checks failed: {stderr}")
|
|
229
|
+
payload = json.loads(stdout)
|
|
230
|
+
rollup = payload.get("statusCheckRollup") or []
|
|
231
|
+
return [_normalize_check(entry) for entry in rollup]
|
|
200
232
|
|
|
201
233
|
def all_checks_passed(self, checks: list[dict[str, Any]]) -> bool:
|
|
202
234
|
"""Check if all PR checks passed."""
|
|
@@ -330,6 +330,7 @@ class DevPipeline:
|
|
|
330
330
|
issue_title = extra.get("issue_title", "")
|
|
331
331
|
issue_body = extra.get("issue_body", "")
|
|
332
332
|
branch_name = extra.get("branch_name", "")
|
|
333
|
+
ci_wait_timeout_seconds = extra.get("ci_wait_timeout_seconds", 600)
|
|
333
334
|
state_file_path = str(state_file) if state_file else "/tmp/state.json"
|
|
334
335
|
|
|
335
336
|
return f"""You are working on issue #{issue_number} in repository {repo}.
|
|
@@ -349,7 +350,7 @@ Execute the following workflow:
|
|
|
349
350
|
4. Push the branch and open a PR that references the issue
|
|
350
351
|
5. Before signaling DONE, verify the PR is mergeable:
|
|
351
352
|
- Wait for CI by running EXACTLY this command (do NOT improvise bash):
|
|
352
|
-
`ctrlrelay ci wait --pr <PR> --repo {repo} --timeout
|
|
353
|
+
`ctrlrelay ci wait --pr <PR> --repo {repo} --timeout {ci_wait_timeout_seconds}`
|
|
353
354
|
Exit codes: 0 = all checks passed, 1 = a check failed (investigate,
|
|
354
355
|
fix, push, then re-run the wait), 2 = hard timeout while CI is still
|
|
355
356
|
pending (treat as acceptable — hand off and let the orchestrator
|
|
@@ -608,6 +609,7 @@ async def run_dev_issue(
|
|
|
608
609
|
max_fix_attempts: int = DEFAULT_MAX_FIX_ATTEMPTS,
|
|
609
610
|
max_blocked_rounds: int = DEFAULT_MAX_BLOCKED_ROUNDS,
|
|
610
611
|
pr_verifier: PRVerifier | None = None,
|
|
612
|
+
ci_wait_timeout_seconds: int = 600,
|
|
611
613
|
) -> PipelineResult:
|
|
612
614
|
"""Run dev pipeline for a single issue."""
|
|
613
615
|
session_id = f"dev-{repo.replace('/', '-')}-{issue_number}-{uuid.uuid4().hex[:8]}"
|
|
@@ -715,6 +717,7 @@ async def run_dev_issue(
|
|
|
715
717
|
"issue_title": issue.get("title", ""),
|
|
716
718
|
"issue_body": issue.get("body", ""),
|
|
717
719
|
"branch_name": branch_name,
|
|
720
|
+
"ci_wait_timeout_seconds": ci_wait_timeout_seconds,
|
|
718
721
|
},
|
|
719
722
|
)
|
|
720
723
|
|
|
@@ -640,3 +640,67 @@ class TestAutomationRequireLabels:
|
|
|
640
640
|
|
|
641
641
|
assert config.repos[0].automation.require_labels == ["ctrlrelay:auto"]
|
|
642
642
|
assert config.repos[1].automation.require_labels == []
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
class TestAutomationCiWaitTimeout:
|
|
646
|
+
"""ci_wait_timeout_seconds caps the single `ctrlrelay ci wait` call the
|
|
647
|
+
dev pipeline prompt tells Claude to run before signaling DONE. Default
|
|
648
|
+
600 preserves today's behavior."""
|
|
649
|
+
|
|
650
|
+
def test_default_ci_wait_timeout_is_600(self) -> None:
|
|
651
|
+
auto = AutomationConfig()
|
|
652
|
+
assert auto.ci_wait_timeout_seconds == 600
|
|
653
|
+
|
|
654
|
+
def test_ci_wait_timeout_override_accepts_int(self) -> None:
|
|
655
|
+
auto = AutomationConfig(ci_wait_timeout_seconds=300)
|
|
656
|
+
assert auto.ci_wait_timeout_seconds == 300
|
|
657
|
+
|
|
658
|
+
def test_config_without_ci_wait_timeout_key_defaults_to_600(
|
|
659
|
+
self, sample_config_dict: dict, tmp_path: Path
|
|
660
|
+
) -> None:
|
|
661
|
+
sample_config_dict["repos"] = [
|
|
662
|
+
{
|
|
663
|
+
"name": "owner/repo",
|
|
664
|
+
"local_path": "~/Projects/repo",
|
|
665
|
+
"automation": {"dependabot_patch": "auto"},
|
|
666
|
+
}
|
|
667
|
+
]
|
|
668
|
+
cfg_path = tmp_path / "cfg.yaml"
|
|
669
|
+
cfg_path.write_text(yaml.dump(sample_config_dict))
|
|
670
|
+
|
|
671
|
+
config = load_config(cfg_path)
|
|
672
|
+
|
|
673
|
+
assert config.repos[0].automation.ci_wait_timeout_seconds == 600
|
|
674
|
+
|
|
675
|
+
def test_ci_wait_timeout_from_yaml(
|
|
676
|
+
self, sample_config_dict: dict, tmp_path: Path
|
|
677
|
+
) -> None:
|
|
678
|
+
sample_config_dict["repos"] = [
|
|
679
|
+
{
|
|
680
|
+
"name": "owner/repo",
|
|
681
|
+
"local_path": "~/Projects/repo",
|
|
682
|
+
"automation": {"ci_wait_timeout_seconds": 300},
|
|
683
|
+
}
|
|
684
|
+
]
|
|
685
|
+
cfg_path = tmp_path / "cfg.yaml"
|
|
686
|
+
cfg_path.write_text(yaml.dump(sample_config_dict))
|
|
687
|
+
|
|
688
|
+
config = load_config(cfg_path)
|
|
689
|
+
|
|
690
|
+
assert config.repos[0].automation.ci_wait_timeout_seconds == 300
|
|
691
|
+
|
|
692
|
+
def test_ci_wait_timeout_rejects_non_int(
|
|
693
|
+
self, sample_config_dict: dict, tmp_path: Path
|
|
694
|
+
) -> None:
|
|
695
|
+
sample_config_dict["repos"] = [
|
|
696
|
+
{
|
|
697
|
+
"name": "owner/repo",
|
|
698
|
+
"local_path": "~/Projects/repo",
|
|
699
|
+
"automation": {"ci_wait_timeout_seconds": "fast"},
|
|
700
|
+
}
|
|
701
|
+
]
|
|
702
|
+
cfg_path = tmp_path / "cfg.yaml"
|
|
703
|
+
cfg_path.write_text(yaml.dump(sample_config_dict))
|
|
704
|
+
|
|
705
|
+
with pytest.raises(ConfigError, match="ci_wait_timeout_seconds"):
|
|
706
|
+
load_config(cfg_path)
|
|
@@ -61,6 +61,65 @@ class TestDevPipeline:
|
|
|
61
61
|
"prompt should explicitly ban `until`/`while` bash CI-wait loops"
|
|
62
62
|
)
|
|
63
63
|
|
|
64
|
+
def test_prompt_uses_default_ci_wait_timeout_when_not_configured(self) -> None:
|
|
65
|
+
"""No ci_wait_timeout_seconds in extra: falls back to 600, matching
|
|
66
|
+
AutomationConfig's default so an unconfigured repo behaves as before."""
|
|
67
|
+
from ctrlrelay.pipelines.dev import DevPipeline
|
|
68
|
+
|
|
69
|
+
pipeline = DevPipeline(
|
|
70
|
+
dispatcher=MagicMock(),
|
|
71
|
+
github=MagicMock(),
|
|
72
|
+
worktree=MagicMock(),
|
|
73
|
+
dashboard=None,
|
|
74
|
+
state_db=MagicMock(),
|
|
75
|
+
transport=None,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
prompt = pipeline._build_prompt(
|
|
79
|
+
repo="owner/repo",
|
|
80
|
+
issue_number=42,
|
|
81
|
+
extra={
|
|
82
|
+
"issue_title": "t",
|
|
83
|
+
"issue_body": "b",
|
|
84
|
+
"branch_name": "fix/issue-42",
|
|
85
|
+
},
|
|
86
|
+
session_id="dev-42",
|
|
87
|
+
state_file=Path("/tmp/state.json"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
assert "ctrlrelay ci wait --pr <PR> --repo owner/repo --timeout 600" in prompt
|
|
91
|
+
|
|
92
|
+
def test_prompt_uses_configured_ci_wait_timeout(self) -> None:
|
|
93
|
+
"""A repo with a slow (or fast) CI can override the 600s default so
|
|
94
|
+
the dev pipeline doesn't spend most of a session blocked in one
|
|
95
|
+
`ctrlrelay ci wait` call."""
|
|
96
|
+
from ctrlrelay.pipelines.dev import DevPipeline
|
|
97
|
+
|
|
98
|
+
pipeline = DevPipeline(
|
|
99
|
+
dispatcher=MagicMock(),
|
|
100
|
+
github=MagicMock(),
|
|
101
|
+
worktree=MagicMock(),
|
|
102
|
+
dashboard=None,
|
|
103
|
+
state_db=MagicMock(),
|
|
104
|
+
transport=None,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
prompt = pipeline._build_prompt(
|
|
108
|
+
repo="owner/repo",
|
|
109
|
+
issue_number=42,
|
|
110
|
+
extra={
|
|
111
|
+
"issue_title": "t",
|
|
112
|
+
"issue_body": "b",
|
|
113
|
+
"branch_name": "fix/issue-42",
|
|
114
|
+
"ci_wait_timeout_seconds": 300,
|
|
115
|
+
},
|
|
116
|
+
session_id="dev-42",
|
|
117
|
+
state_file=Path("/tmp/state.json"),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
assert "ctrlrelay ci wait --pr <PR> --repo owner/repo --timeout 300" in prompt
|
|
121
|
+
assert "--timeout 600" not in prompt
|
|
122
|
+
|
|
64
123
|
@pytest.mark.asyncio
|
|
65
124
|
async def test_run_dispatches_claude_session(self, tmp_path: Path) -> None:
|
|
66
125
|
"""Should dispatch Claude session with issue context."""
|
|
@@ -98,14 +98,55 @@ class TestGitHubCLI:
|
|
|
98
98
|
assert "--squash" in args
|
|
99
99
|
|
|
100
100
|
@pytest.mark.asyncio
|
|
101
|
-
async def
|
|
102
|
-
"""
|
|
101
|
+
async def test_get_pr_checks_uses_pr_view_not_pr_checks(self) -> None:
|
|
102
|
+
"""`gh pr checks --json` isn't available on gh releases before it
|
|
103
|
+
shipped (confirmed absent on gh 2.45.0, the current Ubuntu 24.04
|
|
104
|
+
package - #145) - get_pr_checks must use `gh pr view --json
|
|
105
|
+
statusCheckRollup` instead, which has been stable since early gh 2.x."""
|
|
103
106
|
from ctrlrelay.core.github import GitHubCLI
|
|
104
107
|
|
|
105
|
-
mock_output = json.dumps([
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
mock_output = json.dumps({"statusCheckRollup": []})
|
|
109
|
+
|
|
110
|
+
mock_proc = MagicMock()
|
|
111
|
+
mock_proc.communicate = AsyncMock(return_value=(mock_output.encode(), b""))
|
|
112
|
+
mock_proc.returncode = 0
|
|
113
|
+
|
|
114
|
+
with patch(
|
|
115
|
+
"asyncio.create_subprocess_exec", AsyncMock(return_value=mock_proc)
|
|
116
|
+
) as mock_exec:
|
|
117
|
+
gh = GitHubCLI()
|
|
118
|
+
await gh.get_pr_checks("owner/repo", 42)
|
|
119
|
+
|
|
120
|
+
args = mock_exec.call_args[0]
|
|
121
|
+
assert "checks" not in args
|
|
122
|
+
assert ("pr", "view", "42") == args[1:4]
|
|
123
|
+
assert "--repo" in args and "owner/repo" in args
|
|
124
|
+
assert "statusCheckRollup" in args
|
|
125
|
+
|
|
126
|
+
@pytest.mark.asyncio
|
|
127
|
+
async def test_get_pr_checks_normalizes_check_run_entries(self) -> None:
|
|
128
|
+
"""CheckRun entries (Actions jobs, CodeQL) use status/conclusion,
|
|
129
|
+
not state - must be mapped into the {name, state, bucket, link}
|
|
130
|
+
shape PRVerifier's bucket logic expects."""
|
|
131
|
+
from ctrlrelay.core.github import GitHubCLI
|
|
132
|
+
|
|
133
|
+
mock_output = json.dumps({"statusCheckRollup": [
|
|
134
|
+
{
|
|
135
|
+
"__typename": "CheckRun", "name": "pytest",
|
|
136
|
+
"status": "COMPLETED", "conclusion": "SUCCESS",
|
|
137
|
+
"detailsUrl": "https://example.com/1",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"__typename": "CheckRun", "name": "lint",
|
|
141
|
+
"status": "IN_PROGRESS", "conclusion": None,
|
|
142
|
+
"detailsUrl": "https://example.com/2",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"__typename": "CheckRun", "name": "flaky-e2e",
|
|
146
|
+
"status": "COMPLETED", "conclusion": "FAILURE",
|
|
147
|
+
"detailsUrl": "https://example.com/3",
|
|
148
|
+
},
|
|
149
|
+
]})
|
|
109
150
|
|
|
110
151
|
mock_proc = MagicMock()
|
|
111
152
|
mock_proc.communicate = AsyncMock(return_value=(mock_output.encode(), b""))
|
|
@@ -115,45 +156,54 @@ class TestGitHubCLI:
|
|
|
115
156
|
gh = GitHubCLI()
|
|
116
157
|
checks = await gh.get_pr_checks("owner/repo", 42)
|
|
117
158
|
|
|
118
|
-
|
|
119
|
-
assert
|
|
159
|
+
by_name = {c["name"]: c for c in checks}
|
|
160
|
+
assert by_name["pytest"]["bucket"] == "pass"
|
|
161
|
+
assert by_name["lint"]["bucket"] == "pending"
|
|
162
|
+
assert by_name["flaky-e2e"]["bucket"] == "fail"
|
|
120
163
|
|
|
121
164
|
@pytest.mark.asyncio
|
|
122
|
-
async def
|
|
123
|
-
"""
|
|
124
|
-
|
|
125
|
-
instead of silently returning []."""
|
|
126
|
-
|
|
165
|
+
async def test_get_pr_checks_normalizes_status_context_entries(self) -> None:
|
|
166
|
+
"""StatusContext entries (the legacy commit-status API, e.g. CLA
|
|
167
|
+
bots) use `state` and `context` instead of CheckRun's fields."""
|
|
127
168
|
from ctrlrelay.core.github import GitHubCLI
|
|
128
169
|
|
|
129
|
-
mock_output = json.dumps([
|
|
130
|
-
{
|
|
131
|
-
|
|
170
|
+
mock_output = json.dumps({"statusCheckRollup": [
|
|
171
|
+
{
|
|
172
|
+
"__typename": "StatusContext",
|
|
173
|
+
"context": "verification/cla-signed",
|
|
174
|
+
"state": "SUCCESS",
|
|
175
|
+
"targetUrl": "https://example.com/cla",
|
|
176
|
+
},
|
|
177
|
+
]})
|
|
132
178
|
|
|
133
179
|
mock_proc = MagicMock()
|
|
134
180
|
mock_proc.communicate = AsyncMock(return_value=(mock_output.encode(), b""))
|
|
135
|
-
mock_proc.returncode =
|
|
181
|
+
mock_proc.returncode = 0
|
|
136
182
|
|
|
137
183
|
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_proc)):
|
|
138
184
|
gh = GitHubCLI()
|
|
139
185
|
checks = await gh.get_pr_checks("owner/repo", 42)
|
|
140
186
|
|
|
141
|
-
assert
|
|
142
|
-
|
|
187
|
+
assert checks == [{
|
|
188
|
+
"name": "verification/cla-signed",
|
|
189
|
+
"state": "SUCCESS",
|
|
190
|
+
"bucket": "pass",
|
|
191
|
+
"link": "https://example.com/cla",
|
|
192
|
+
}]
|
|
143
193
|
|
|
144
194
|
@pytest.mark.asyncio
|
|
145
195
|
async def test_get_pr_checks_returns_empty_when_no_checks_reported(self) -> None:
|
|
146
|
-
"""
|
|
147
|
-
|
|
196
|
+
"""A PR with no CI at all (or checks not registered yet) just comes
|
|
197
|
+
back as an empty statusCheckRollup - gh pr view exits 0 either way,
|
|
198
|
+
no stderr-message sniffing needed."""
|
|
148
199
|
|
|
149
200
|
from ctrlrelay.core.github import GitHubCLI
|
|
150
201
|
|
|
151
202
|
mock_proc = MagicMock()
|
|
152
203
|
mock_proc.communicate = AsyncMock(return_value=(
|
|
153
|
-
b"",
|
|
154
|
-
b"no checks reported on the 'fix/issue-10' branch\n",
|
|
204
|
+
json.dumps({"statusCheckRollup": []}).encode(), b"",
|
|
155
205
|
))
|
|
156
|
-
mock_proc.returncode =
|
|
206
|
+
mock_proc.returncode = 0
|
|
157
207
|
|
|
158
208
|
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_proc)):
|
|
159
209
|
gh = GitHubCLI()
|
|
@@ -163,9 +213,9 @@ class TestGitHubCLI:
|
|
|
163
213
|
|
|
164
214
|
@pytest.mark.asyncio
|
|
165
215
|
async def test_get_pr_checks_raises_on_genuine_failure(self) -> None:
|
|
166
|
-
"""Auth/network/missing-PR failures
|
|
167
|
-
|
|
168
|
-
(which would be indistinguishable from 'no CI configured')."""
|
|
216
|
+
"""Auth/network/missing-PR failures exit non-zero with an error on
|
|
217
|
+
stderr. Must raise GitHubError (via _run_gh) rather than silently
|
|
218
|
+
returning [] (which would be indistinguishable from 'no CI configured')."""
|
|
169
219
|
|
|
170
220
|
from ctrlrelay.core.github import GitHubCLI, GitHubError
|
|
171
221
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|