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.
Files changed (114) hide show
  1. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/CHANGELOG.md +38 -1
  2. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/PKG-INFO +1 -1
  3. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/config/orchestrator.yaml.example +6 -0
  4. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/configuration.md +26 -0
  5. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/pyproject.toml +1 -1
  6. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/cli.py +2 -0
  7. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/config.py +7 -0
  8. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/github.py +80 -48
  9. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/dev.py +4 -1
  10. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_config.py +64 -0
  11. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_dev_pipeline.py +59 -0
  12. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_github.py +77 -27
  13. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  14. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  15. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  16. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/dependabot.yml +0 -0
  17. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/workflows/build.yml +0 -0
  18. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/workflows/cla.yml +0 -0
  19. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/workflows/pages.yml +0 -0
  20. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/workflows/publish.yml +0 -0
  21. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.github/workflows/test.yml +0 -0
  22. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/.gitignore +0 -0
  23. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/CODE_OF_CONDUCT.md +0 -0
  24. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/CONTRIBUTING.md +0 -0
  25. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/LICENSE +0 -0
  26. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/README.md +0 -0
  27. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/SECURITY.md +0 -0
  28. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/Gemfile +0 -0
  29. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/_config.yml +0 -0
  30. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/architecture.md +0 -0
  31. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/bridge.md +0 -0
  32. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/cli.md +0 -0
  33. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/development.md +0 -0
  34. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/feedback-loop.md +0 -0
  35. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/getting-started.md +0 -0
  36. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/index.md +0 -0
  37. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/operations.md +0 -0
  38. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/docs/personalization.md +0 -0
  39. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/__init__.py +0 -0
  40. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/__init__.py +0 -0
  41. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/__main__.py +0 -0
  42. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/protocol.py +0 -0
  43. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/server.py +0 -0
  44. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  45. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/__init__.py +0 -0
  46. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/audit.py +0 -0
  47. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/checkpoint.py +0 -0
  48. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/dispatcher.py +0 -0
  49. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/obs.py +0 -0
  50. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/poller.py +0 -0
  51. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
  52. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
  53. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/scheduler.py +0 -0
  54. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/state.py +0 -0
  55. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/worktree.py +0 -0
  56. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
  57. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/dashboard/client.py +0 -0
  58. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/gh_protocol.py +0 -0
  59. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/install.py +0 -0
  60. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/personalization/__init__.py +0 -0
  61. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/personalization/manager.py +0 -0
  62. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/personalization/paths.py +0 -0
  63. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
  64. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/base.py +0 -0
  65. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  66. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/secops.py +0 -0
  67. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/task.py +0 -0
  68. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/setup.py +0 -0
  69. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/__init__.py +0 -0
  70. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  71. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  72. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  73. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  74. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  75. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  76. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  77. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/__init__.py +0 -0
  78. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/base.py +0 -0
  79. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/file_mock.py +0 -0
  80. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/socket_client.py +0 -0
  81. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/__init__.py +0 -0
  82. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/conftest.py +0 -0
  83. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_audit.py +0 -0
  84. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_bridge_protocol.py +0 -0
  85. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_bridge_server.py +0 -0
  86. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_checkpoint.py +0 -0
  87. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_cli_ci_wait.py +0 -0
  88. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_cli_dev.py +0 -0
  89. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_cli_repos.py +0 -0
  90. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_cli_secops.py +0 -0
  91. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_cli_start.py +0 -0
  92. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_cli_version.py +0 -0
  93. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_dashboard_client.py +0 -0
  94. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_dev_integration.py +0 -0
  95. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_dispatcher.py +0 -0
  96. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_docs_site.py +0 -0
  97. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_gh_protocol.py +0 -0
  98. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_install.py +0 -0
  99. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_obs.py +0 -0
  100. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_personalization.py +0 -0
  101. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_pipeline_base.py +0 -0
  102. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_poller.py +0 -0
  103. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_post_merge.py +0 -0
  104. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_pr_verifier.py +0 -0
  105. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_pr_watcher.py +0 -0
  106. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_scheduler.py +0 -0
  107. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_secops_integration.py +0 -0
  108. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_secops_pipeline.py +0 -0
  109. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_setup.py +0 -0
  110. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_state.py +0 -0
  111. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_task_pipeline.py +0 -0
  112. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_telegram_handler.py +0 -0
  113. {ctrlrelay-0.7.0 → ctrlrelay-0.8.1}/tests/test_transport.py +0 -0
  114. {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.7.0...HEAD
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.7.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.7.0"
3
+ version = "0.8.1"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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
- Bypasses `_run_gh` because we need to inspect stdout, stderr, and the
150
- exit code independently. `gh pr checks`:
151
- - prints a JSON array on stdout when checks exist (exits non-zero
152
- when any are pending or failing — the payload is still valid)
153
- - prints "no checks reported on the '<branch>' branch" to stderr
154
- and exits non-zero when the PR has no checks at all
155
- - emits arbitrary errors to stderr (auth, network, missing PR) on
156
- non-zero exit with empty stdout
157
-
158
- We return [] only for the "no checks reported" case. Real failures
159
- raise GitHubError so callers can distinguish "no CI configured" from
160
- "gh is broken".
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
- cmd = [
163
- self.gh_binary,
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", "name,state,bucket,link",
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
- try:
175
- stdout_bytes, stderr_bytes = await asyncio.wait_for(
176
- proc.communicate(), timeout=self.timeout,
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 600`
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 test_get_pr_checks(self) -> None:
102
- """Should get PR check status using the bucket field."""
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
- {"name": "tests", "state": "SUCCESS", "bucket": "pass"},
107
- {"name": "lint", "state": "SUCCESS", "bucket": "pass"},
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
- assert len(checks) == 2
119
- assert all(c["bucket"] == "pass" for c in checks)
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 test_get_pr_checks_parses_json_on_nonzero_exit(self) -> None:
123
- """`gh pr checks` exits non-zero while checks are pending/failing, but
124
- still prints the JSON payload to stdout. get_pr_checks must parse it
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
- {"name": "ci", "state": "IN_PROGRESS", "bucket": "pending"},
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 = 1 # non-zero, checks still pending
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 len(checks) == 1
142
- assert checks[0]["bucket"] == "pending"
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
- """gh prints 'no checks reported on the <branch> branch' to stderr
147
- and exits non-zero when the PR genuinely has no CI. Treat that as []."""
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 = 1
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 print errors to stderr and leave
167
- stdout empty. Must raise GitHubError rather than silently returning []
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