ctrlrelay 0.8.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.8.0 → ctrlrelay-0.8.1}/CHANGELOG.md +22 -1
  2. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/PKG-INFO +1 -1
  3. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/pyproject.toml +1 -1
  4. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/github.py +80 -48
  5. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_github.py +77 -27
  6. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  7. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  8. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  9. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/dependabot.yml +0 -0
  10. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/workflows/build.yml +0 -0
  11. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/workflows/cla.yml +0 -0
  12. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/workflows/pages.yml +0 -0
  13. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/workflows/publish.yml +0 -0
  14. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/workflows/test.yml +0 -0
  15. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.gitignore +0 -0
  16. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/CODE_OF_CONDUCT.md +0 -0
  17. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/CONTRIBUTING.md +0 -0
  18. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/LICENSE +0 -0
  19. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/README.md +0 -0
  20. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/SECURITY.md +0 -0
  21. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/config/orchestrator.yaml.example +0 -0
  22. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/Gemfile +0 -0
  23. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/_config.yml +0 -0
  24. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/architecture.md +0 -0
  25. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/bridge.md +0 -0
  26. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/cli.md +0 -0
  27. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/configuration.md +0 -0
  28. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/development.md +0 -0
  29. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/feedback-loop.md +0 -0
  30. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/getting-started.md +0 -0
  31. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/index.md +0 -0
  32. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/operations.md +0 -0
  33. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/personalization.md +0 -0
  34. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/__init__.py +0 -0
  35. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/__init__.py +0 -0
  36. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/__main__.py +0 -0
  37. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/protocol.py +0 -0
  38. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/server.py +0 -0
  39. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  40. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/cli.py +0 -0
  41. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/__init__.py +0 -0
  42. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/audit.py +0 -0
  43. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/checkpoint.py +0 -0
  44. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/config.py +0 -0
  45. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/dispatcher.py +0 -0
  46. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/obs.py +0 -0
  47. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/poller.py +0 -0
  48. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
  49. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
  50. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/scheduler.py +0 -0
  51. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/state.py +0 -0
  52. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/worktree.py +0 -0
  53. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
  54. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/dashboard/client.py +0 -0
  55. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/gh_protocol.py +0 -0
  56. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/install.py +0 -0
  57. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/personalization/__init__.py +0 -0
  58. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/personalization/manager.py +0 -0
  59. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/personalization/paths.py +0 -0
  60. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
  61. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/base.py +0 -0
  62. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/dev.py +0 -0
  63. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  64. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/secops.py +0 -0
  65. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/task.py +0 -0
  66. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/setup.py +0 -0
  67. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/__init__.py +0 -0
  68. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  69. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  70. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  71. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  72. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  73. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  74. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  75. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/__init__.py +0 -0
  76. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/base.py +0 -0
  77. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/file_mock.py +0 -0
  78. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/socket_client.py +0 -0
  79. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/__init__.py +0 -0
  80. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/conftest.py +0 -0
  81. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_audit.py +0 -0
  82. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_bridge_protocol.py +0 -0
  83. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_bridge_server.py +0 -0
  84. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_checkpoint.py +0 -0
  85. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_cli_ci_wait.py +0 -0
  86. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_cli_dev.py +0 -0
  87. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_cli_repos.py +0 -0
  88. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_cli_secops.py +0 -0
  89. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_cli_start.py +0 -0
  90. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_cli_version.py +0 -0
  91. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_config.py +0 -0
  92. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_dashboard_client.py +0 -0
  93. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_dev_integration.py +0 -0
  94. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_dev_pipeline.py +0 -0
  95. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_dispatcher.py +0 -0
  96. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_docs_site.py +0 -0
  97. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_gh_protocol.py +0 -0
  98. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_install.py +0 -0
  99. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_obs.py +0 -0
  100. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_personalization.py +0 -0
  101. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_pipeline_base.py +0 -0
  102. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_poller.py +0 -0
  103. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_post_merge.py +0 -0
  104. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_pr_verifier.py +0 -0
  105. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_pr_watcher.py +0 -0
  106. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_scheduler.py +0 -0
  107. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_secops_integration.py +0 -0
  108. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_secops_pipeline.py +0 -0
  109. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_setup.py +0 -0
  110. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_state.py +0 -0
  111. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_task_pipeline.py +0 -0
  112. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_telegram_handler.py +0 -0
  113. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_transport.py +0 -0
  114. {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_worktree.py +0 -0
@@ -7,6 +7,26 @@ 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
+
10
30
  ## [0.8.0] - 2026-09-04
11
31
 
12
32
  ### Added
@@ -1247,7 +1267,8 @@ pipeline).
1247
1267
  per-phase implementation plans (Phase 0 through Phase 4).
1248
1268
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1249
1269
 
1250
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.8.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
1251
1272
  [0.8.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.7.0...v0.8.0
1252
1273
  [0.7.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.6.0...v0.7.0
1253
1274
  [0.6.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.5.0...v0.6.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.8.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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.8.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"
@@ -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."""
@@ -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