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.
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/CHANGELOG.md +22 -1
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/PKG-INFO +1 -1
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/pyproject.toml +1 -1
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/github.py +80 -48
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_github.py +77 -27
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/.gitignore +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/LICENSE +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/README.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/SECURITY.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/Gemfile +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/_config.yml +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/architecture.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/bridge.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/cli.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/configuration.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/development.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/getting-started.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/index.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/operations.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/docs/personalization.md +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/cli.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/__init__.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/conftest.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_audit.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_config.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_install.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_obs.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_poller.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_setup.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_state.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.8.0 → ctrlrelay-0.8.1}/tests/test_transport.py +0 -0
- {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.
|
|
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.
|
|
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/
|
|
@@ -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."""
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|