code-coordinator 0.5.46__py3-none-any.whl
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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/ci_github.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""GitHub Actions backend for :mod:`coord.ci_store`.
|
|
2
|
+
|
|
3
|
+
Fetches via :func:`coord.github_ops.get_pr_checks` (``gh pr checks <number>
|
|
4
|
+
--repo <slug> --json …`` — #1483: the single ``gh`` sink lives in
|
|
5
|
+
``github_ops``, not here) and maps the response to
|
|
6
|
+
:class:`coord.ci_store.CheckRun`. Results are cached per-(repo, number) for
|
|
7
|
+
``cache_ttl`` seconds so the merge gate (which may iterate over many PRs)
|
|
8
|
+
doesn't hammer ``gh`` — the cost of a stale read in the gate path is at most
|
|
9
|
+
one wasted retry, and the user will re-run ``coord merge`` anyway.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
|
|
20
|
+
from coord import github_ops
|
|
21
|
+
from coord.ci_store import CheckRun, JobRun, JobStep
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_ts(raw: str | None) -> float | None:
|
|
25
|
+
"""Parse an ISO-8601 timestamp from gh (e.g. ``2026-05-24T12:34:56Z``).
|
|
26
|
+
|
|
27
|
+
Returns ``None`` for empty / unparseable input — gh emits an empty string
|
|
28
|
+
when the field is unknown rather than omitting the JSON key.
|
|
29
|
+
"""
|
|
30
|
+
if not raw:
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
# gh emits Zulu; datetime.fromisoformat accepts the +00:00 form.
|
|
34
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
|
|
35
|
+
except (ValueError, TypeError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# #1564: `gh pr checks --json` has no `conclusion` field, and its `state`
|
|
40
|
+
# field is a per-check *verdict* (SUCCESS/FAILURE/SKIPPED/...), not a
|
|
41
|
+
# lifecycle phase — feeding `state` through a QUEUED/IN_PROGRESS/COMPLETED
|
|
42
|
+
# normaliser made every check fall through to the "unknown → in_progress"
|
|
43
|
+
# branch forever, so `failed_checks()` (which only looks at `status ==
|
|
44
|
+
# "completed"` checks) never evaluated anything and the gate blocked every
|
|
45
|
+
# merge unconditionally. `bucket` is gh's own normalisation of `state` into
|
|
46
|
+
# pass / fail / pending / skipping / cancel and is exactly the lifecycle +
|
|
47
|
+
# verdict split CheckRun wants: `pending` is the only in-flight bucket,
|
|
48
|
+
# everything else is a completed verdict.
|
|
49
|
+
_BUCKET_CONCLUSIONS: dict[str, str] = {
|
|
50
|
+
"pass": "success",
|
|
51
|
+
"fail": "failure",
|
|
52
|
+
"skipping": "skipped",
|
|
53
|
+
"cancel": "cancelled",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# #1851: an Actions check `link` is either a *run* URL
|
|
58
|
+
# (".../actions/runs/{run_id}") or a *job* URL
|
|
59
|
+
# (".../actions/runs/{run_id}/job/{job_id}") depending on whether the check
|
|
60
|
+
# has a job breakdown yet. `gh run rerun` takes the *run* id specifically —
|
|
61
|
+
# taking the last path segment (the pre-#1851 behaviour of the `run_id` field
|
|
62
|
+
# below) silently grabs the job id off a job URL instead. Nothing before
|
|
63
|
+
# #1851 ever read `CheckRun.run_id` (see the Phase 1 header in
|
|
64
|
+
# `coord/ci_store.py`), so fixing the extraction here has no back-compat
|
|
65
|
+
# concern.
|
|
66
|
+
_RUN_ID_RE = re.compile(r"/runs/(\d+)")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_id_from_link(link: str) -> str:
|
|
70
|
+
"""Extract the numeric Actions *run* id from a `gh pr checks` `link` URL.
|
|
71
|
+
|
|
72
|
+
Returns ``""`` when *link* is empty or doesn't match the expected Actions
|
|
73
|
+
URL shape (e.g. a third-party check with no GitHub Actions run behind
|
|
74
|
+
it) — callers treat an empty run id as "not rerunnable".
|
|
75
|
+
"""
|
|
76
|
+
match = _RUN_ID_RE.search(link or "")
|
|
77
|
+
return match.group(1) if match else ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_bucket(bucket: str) -> str:
|
|
81
|
+
"""Lowercase/None-safe normalisation of gh's ``bucket`` field, shared by
|
|
82
|
+
:func:`_status_from_bucket` and :func:`_conclusion_from_bucket` so the two
|
|
83
|
+
don't each repeat the same ``(bucket or "").lower()`` guard."""
|
|
84
|
+
return (bucket or "").lower()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _status_from_bucket(bucket: str) -> str:
|
|
88
|
+
"""Map gh's ``bucket`` to the CheckRun lifecycle enum ("in_progress" or
|
|
89
|
+
"completed" — gh's own `--json bucket` doc lists no other pending-like
|
|
90
|
+
value, so anything other than "pending" is treated as decided)."""
|
|
91
|
+
return "in_progress" if _normalize_bucket(bucket) == "pending" else "completed"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _conclusion_from_bucket(bucket: str) -> str | None:
|
|
95
|
+
"""Map gh's ``bucket`` to a CheckRun conclusion.
|
|
96
|
+
|
|
97
|
+
"pending" has no conclusion yet (status is in-flight, see
|
|
98
|
+
:func:`_status_from_bucket`). Anything that isn't one of gh's
|
|
99
|
+
documented buckets (pass/fail/pending/skipping/cancel — e.g. a future
|
|
100
|
+
bucket value this code has never seen) maps to "unknown" rather than
|
|
101
|
+
being silently treated as passing, mirroring #1525's fail-closed
|
|
102
|
+
synthetic-unreadable-check conclusion.
|
|
103
|
+
"""
|
|
104
|
+
b = _normalize_bucket(bucket)
|
|
105
|
+
if b == "pending":
|
|
106
|
+
return None
|
|
107
|
+
return _BUCKET_CONCLUSIONS.get(b, "unknown")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class GitHubCi:
|
|
112
|
+
"""Shell out to ``gh pr checks`` and cache results briefly."""
|
|
113
|
+
|
|
114
|
+
cache_ttl: float = 10.0
|
|
115
|
+
_cache: dict[tuple[str, int], tuple[float, list[CheckRun]]] = field(default_factory=dict)
|
|
116
|
+
# #1904: repo -> (fetched_at, expects_checks). Keyed by repo alone (not
|
|
117
|
+
# (repo, number) like `_cache` above) — whether a repo declares Actions
|
|
118
|
+
# workflows doesn't vary per PR, so every PR in the same repo shares one
|
|
119
|
+
# cached answer instead of paying a `gh api .../actions/workflows` round
|
|
120
|
+
# trip each.
|
|
121
|
+
_workflow_cache: dict[str, tuple[float, bool]] = field(default_factory=dict)
|
|
122
|
+
# #1892: (repo, run_id) -> (fetched_at, jobs). Only ever populated by
|
|
123
|
+
# `list_jobs_for_run`, which callers invoke exclusively on the CI-failure
|
|
124
|
+
# classification path (see that method's docstring) — this cache exists
|
|
125
|
+
# so a board build that re-evaluates the SAME still-failing PR every tick
|
|
126
|
+
# (`plan()` -> `_entry_gate_status`, or a `process()` retry loop) doesn't
|
|
127
|
+
# re-issue the extra `gh api .../jobs` call every time; it shares
|
|
128
|
+
# `cache_ttl` with `_cache` above rather than a second knob.
|
|
129
|
+
_jobs_cache: dict[tuple[str, str], tuple[float, list[JobRun]]] = field(
|
|
130
|
+
default_factory=dict
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def is_available(self) -> bool:
|
|
135
|
+
# gh is a hard dependency of the project (see CLAUDE.md). The
|
|
136
|
+
# subprocess check is cheap but unnecessary; assume True when this
|
|
137
|
+
# backend is constructed and let the actual ``gh pr checks`` call
|
|
138
|
+
# surface the failure if gh is missing.
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
def list_checks_for_pr(self, repo: str, number: int) -> list[CheckRun]:
|
|
142
|
+
key = (repo, number)
|
|
143
|
+
now = time.time()
|
|
144
|
+
cached = self._cache.get(key)
|
|
145
|
+
if cached is not None and (now - cached[0]) < self.cache_ttl:
|
|
146
|
+
return cached[1]
|
|
147
|
+
checks = self._fetch(repo, number)
|
|
148
|
+
self._cache[key] = (now, checks)
|
|
149
|
+
return checks
|
|
150
|
+
|
|
151
|
+
def expects_checks(self, repo: str, number: int) -> bool:
|
|
152
|
+
"""True when *repo* declares at least one GitHub Actions workflow (#1904).
|
|
153
|
+
|
|
154
|
+
*number* is accepted only to satisfy :class:`coord.ci_store.CiStore`'s
|
|
155
|
+
shape — workflow *definitions* are repo-wide, not per-PR, so the
|
|
156
|
+
cache (see ``_workflow_cache`` above) is keyed on *repo* alone.
|
|
157
|
+
|
|
158
|
+
Fails closed (returns ``True``) on any read failure — an
|
|
159
|
+
unreadable/erroring ``gh api .../actions/workflows`` call means "we
|
|
160
|
+
don't know if this repo has CI", and #1525's rule is that unknown
|
|
161
|
+
must read as "checks were expected", not as the free pass that let
|
|
162
|
+
an empty ``checks`` list merge untested code in the first place.
|
|
163
|
+
"""
|
|
164
|
+
now = time.time()
|
|
165
|
+
cached = self._workflow_cache.get(repo)
|
|
166
|
+
if cached is not None and (now - cached[0]) < self.cache_ttl:
|
|
167
|
+
return cached[1]
|
|
168
|
+
result = self._fetch_expects_checks(repo)
|
|
169
|
+
self._workflow_cache[repo] = (now, result)
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
def _fetch_expects_checks(self, repo: str) -> bool:
|
|
173
|
+
try:
|
|
174
|
+
count = github_ops.get_repo_workflow_count(repo)
|
|
175
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, RuntimeError, ValueError):
|
|
176
|
+
return True # fail closed — see expects_checks' docstring
|
|
177
|
+
return count > 0
|
|
178
|
+
|
|
179
|
+
def invalidate(self, repo: str | None = None, number: int | None = None) -> None:
|
|
180
|
+
"""Drop cached entries — pass nothing to clear everything."""
|
|
181
|
+
if repo is None and number is None:
|
|
182
|
+
self._cache.clear()
|
|
183
|
+
return
|
|
184
|
+
for key in list(self._cache):
|
|
185
|
+
if repo is not None and key[0] != repo:
|
|
186
|
+
continue
|
|
187
|
+
if number is not None and key[1] != number:
|
|
188
|
+
continue
|
|
189
|
+
del self._cache[key]
|
|
190
|
+
|
|
191
|
+
def rerun_for_pr(self, repo: str, number: int) -> bool:
|
|
192
|
+
"""Re-run every distinct Actions run behind *repo*#*number*'s current
|
|
193
|
+
checks via ``gh run rerun`` (#1851).
|
|
194
|
+
|
|
195
|
+
A PR's checks can span more than one workflow run — `test.yml` and
|
|
196
|
+
`cargo-test.yml` in this repo, for instance — so this reruns every
|
|
197
|
+
distinct ``run_id`` among the PR's checks, not just the first. Best
|
|
198
|
+
effort throughout: a check with no readable run id (an empty ``link``,
|
|
199
|
+
or a third-party check with no Actions run behind it at all) is
|
|
200
|
+
skipped rather than failing the whole call, and any individual
|
|
201
|
+
``gh run rerun`` failure is logged into the return value rather than
|
|
202
|
+
raised — this is the remedy side of a fail-closed *reading*
|
|
203
|
+
(:func:`coord.ci_store.checks_are_stale`), not itself required to be
|
|
204
|
+
fail-closed: a rerun that only partially succeeds still helps, but
|
|
205
|
+
the caller must not be told it fully worked.
|
|
206
|
+
|
|
207
|
+
Returns ``True`` only when at least one run id was found *and* every
|
|
208
|
+
rerun call it issued exited zero. Returns ``False`` when there was
|
|
209
|
+
nothing rerunnable (no checks, or none with a readable run id) or any
|
|
210
|
+
rerun call failed. Invalidates this PR's checks cache entry AND
|
|
211
|
+
(#1892) any cached :meth:`list_jobs_for_run` entry for the run ids
|
|
212
|
+
being rerun on any success, so neither the next
|
|
213
|
+
:meth:`list_checks_for_pr` nor the next :meth:`list_jobs_for_run`
|
|
214
|
+
can hand back a briefly-cached pre-rerun snapshot — `gh run rerun`
|
|
215
|
+
reruns the SAME Actions run id, so a stale `_jobs_cache` hit within
|
|
216
|
+
`cache_ttl` could otherwise launder a real failure into
|
|
217
|
+
"infrastructure" by pairing a fresh check re-read with stale
|
|
218
|
+
job/step detail from before the rerun.
|
|
219
|
+
"""
|
|
220
|
+
checks = self.list_checks_for_pr(repo, number)
|
|
221
|
+
run_ids = sorted({c.run_id for c in checks if c.run_id})
|
|
222
|
+
if not run_ids:
|
|
223
|
+
return False
|
|
224
|
+
all_ok = True
|
|
225
|
+
any_ok = False
|
|
226
|
+
for run_id in run_ids:
|
|
227
|
+
# #1483: route through the single `gh` sink instead of shelling
|
|
228
|
+
# out here directly.
|
|
229
|
+
if github_ops.rerun_workflow_run(repo, run_id):
|
|
230
|
+
any_ok = True
|
|
231
|
+
else:
|
|
232
|
+
all_ok = False
|
|
233
|
+
if any_ok:
|
|
234
|
+
self.invalidate(repo, number)
|
|
235
|
+
for run_id in run_ids:
|
|
236
|
+
self._jobs_cache.pop((repo, str(run_id)), None)
|
|
237
|
+
return all_ok and any_ok
|
|
238
|
+
|
|
239
|
+
def list_jobs_for_run(self, repo: str, run_id: str) -> list["JobRun"]:
|
|
240
|
+
"""Job/step detail for Actions run *run_id* on *repo* via ``gh api``
|
|
241
|
+
(#1892) — backs :func:`coord.ci_store.is_verdictless_job`.
|
|
242
|
+
|
|
243
|
+
Deliberately best-effort in a way :meth:`list_checks_for_pr` is NOT
|
|
244
|
+
(#1525's fail-closed posture applies to the merge *gate*; this is a
|
|
245
|
+
narrower, purely-advisory retry-accounting read — see
|
|
246
|
+
:class:`coord.ci_store.CiStore`'s own docstring for the same
|
|
247
|
+
distinction drawn for ``list_jobs_for_run``). Any read failure —
|
|
248
|
+
missing ``gh``, timeout, malformed JSON, an expired/nonexistent run
|
|
249
|
+
id — returns ``[]`` rather than raising or synthesizing a failing
|
|
250
|
+
placeholder; the caller's classifier already treats "no job data" as
|
|
251
|
+
"not verdictless" (the safe false-negative direction), so there is
|
|
252
|
+
nothing this method needs to signal beyond an empty result.
|
|
253
|
+
"""
|
|
254
|
+
key = (repo, str(run_id))
|
|
255
|
+
now = time.time()
|
|
256
|
+
cached = self._jobs_cache.get(key)
|
|
257
|
+
if cached is not None and (now - cached[0]) < self.cache_ttl:
|
|
258
|
+
return cached[1]
|
|
259
|
+
jobs = self._fetch_jobs(repo, run_id)
|
|
260
|
+
self._jobs_cache[key] = (now, jobs)
|
|
261
|
+
return jobs
|
|
262
|
+
|
|
263
|
+
def _fetch_jobs(self, repo: str, run_id: str) -> list["JobRun"]:
|
|
264
|
+
try:
|
|
265
|
+
raw = github_ops.get_run_jobs(repo, run_id)
|
|
266
|
+
except (RuntimeError, ValueError):
|
|
267
|
+
# `github_ops.get_run_jobs` only ever raises `RuntimeError`
|
|
268
|
+
# (`GhError`, itself a `RuntimeError` subclass, already wraps
|
|
269
|
+
# gh-not-found/timeout/OSError inside `_gh`/`_gh_json`) or
|
|
270
|
+
# `ValueError` — no need to duplicate the lower-level exception
|
|
271
|
+
# types here.
|
|
272
|
+
return []
|
|
273
|
+
if not isinstance(raw, list):
|
|
274
|
+
return []
|
|
275
|
+
jobs: list[JobRun] = []
|
|
276
|
+
for entry in raw:
|
|
277
|
+
if not isinstance(entry, dict):
|
|
278
|
+
continue
|
|
279
|
+
steps = [
|
|
280
|
+
JobStep(
|
|
281
|
+
name=str(step.get("name", "")),
|
|
282
|
+
conclusion=step.get("conclusion"),
|
|
283
|
+
)
|
|
284
|
+
for step in (entry.get("steps") or [])
|
|
285
|
+
if isinstance(step, dict)
|
|
286
|
+
]
|
|
287
|
+
jobs.append(
|
|
288
|
+
JobRun(
|
|
289
|
+
name=str(entry.get("name", "")),
|
|
290
|
+
conclusion=entry.get("conclusion"),
|
|
291
|
+
runner_name=str(entry.get("runner_name") or ""),
|
|
292
|
+
steps=steps,
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
return jobs
|
|
296
|
+
|
|
297
|
+
# ── Internal ────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
def _fetch(self, repo: str, number: int) -> list[CheckRun]:
|
|
300
|
+
try:
|
|
301
|
+
raw = github_ops.get_pr_checks(repo, number)
|
|
302
|
+
except github_ops.GhTooOldForJsonChecks as e:
|
|
303
|
+
# #1564 Addendum 2: caught *ahead of* the generic RuntimeError
|
|
304
|
+
# branch below — a `gh` too old to support `pr checks --json` at
|
|
305
|
+
# all is a known, fixable host misconfiguration (upgrade gh on
|
|
306
|
+
# whichever host runs the merge gate), not an auth/network flake.
|
|
307
|
+
# `str(e)` already carries the actionable host + version-floor
|
|
308
|
+
# message built by `github_ops._gh_too_old_message`; surfacing it
|
|
309
|
+
# through a distinctly-named synthetic check (rather than folding
|
|
310
|
+
# it into `_unreadable_check`'s generic wording) means an operator
|
|
311
|
+
# reading the merge refusal never has to guess which of the two
|
|
312
|
+
# this was.
|
|
313
|
+
return [_gh_too_old_check(repo, number, str(e))]
|
|
314
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, RuntimeError, ValueError) as e:
|
|
315
|
+
# #1525: a `gh pr checks` read that outright failed (gh missing,
|
|
316
|
+
# timeout, non-zero exit with no stdout, unparseable JSON) used
|
|
317
|
+
# to return `[]` here — indistinguishable from "this PR genuinely
|
|
318
|
+
# has no checks configured", which the merge gate treats as
|
|
319
|
+
# clear to merge. That silent fail-open is the mechanism that let
|
|
320
|
+
# PR #1521 merge 11 minutes after `test (3.12)` recorded FAILURE:
|
|
321
|
+
# a transient read failure at exactly the wrong moment read as
|
|
322
|
+
# "no failing checks" instead of "unknown". Return a synthetic
|
|
323
|
+
# failing check instead so the gate blocks and says why; a caller
|
|
324
|
+
# that genuinely wants "unknown" treated as clear must pass
|
|
325
|
+
# `force_merge=True` explicitly.
|
|
326
|
+
return [_unreadable_check(repo, number, str(e))]
|
|
327
|
+
if not isinstance(raw, list):
|
|
328
|
+
return [_unreadable_check(repo, number, "gh pr checks returned non-list JSON")]
|
|
329
|
+
return [
|
|
330
|
+
CheckRun(
|
|
331
|
+
name=str(entry.get("name", "")),
|
|
332
|
+
status=_status_from_bucket(str(entry.get("bucket", ""))),
|
|
333
|
+
conclusion=_conclusion_from_bucket(str(entry.get("bucket", ""))),
|
|
334
|
+
url=str(entry.get("link", "")),
|
|
335
|
+
run_id=_run_id_from_link(str(entry.get("link", ""))),
|
|
336
|
+
started_at=_parse_ts(entry.get("startedAt")),
|
|
337
|
+
completed_at=_parse_ts(entry.get("completedAt")),
|
|
338
|
+
)
|
|
339
|
+
for entry in raw
|
|
340
|
+
if isinstance(entry, dict)
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _unreadable_check(repo: str, number: int, detail: str) -> CheckRun:
|
|
345
|
+
"""Synthetic :class:`CheckRun` standing in for "could not read CI" (#1525).
|
|
346
|
+
|
|
347
|
+
``conclusion="unknown"`` is not in :data:`coord.ci_store._PASSING_CONCLUSIONS`,
|
|
348
|
+
so ``failed_checks`` picks this up like any other hard failure — the
|
|
349
|
+
merge gate blocks and the reason (surfaced via ``CheckRun.name``) tells
|
|
350
|
+
the operator this was a read failure, not a real CI failure.
|
|
351
|
+
"""
|
|
352
|
+
return CheckRun(
|
|
353
|
+
name=f"coord: could not read CI status for {repo}#{number} ({detail})",
|
|
354
|
+
status="completed",
|
|
355
|
+
conclusion="unknown",
|
|
356
|
+
url="",
|
|
357
|
+
run_id="",
|
|
358
|
+
started_at=None,
|
|
359
|
+
completed_at=None,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _gh_too_old_check(repo: str, number: int, detail: str) -> CheckRun:
|
|
364
|
+
"""Synthetic :class:`CheckRun` for "gh is too old to support `pr checks
|
|
365
|
+
--json` at all" (#1564 Addendum 2) — deliberately worded and named
|
|
366
|
+
differently from :func:`_unreadable_check` so the merge gate's refusal
|
|
367
|
+
is unambiguous about *which* of the two this is: a known, fixable host
|
|
368
|
+
misconfiguration (wrong gh version on the host running the gate), not a
|
|
369
|
+
generic/transient read failure (auth, network, rate-limit). ``detail``
|
|
370
|
+
is :class:`coord.github_ops.GhTooOldForJsonChecks`'s message, which
|
|
371
|
+
already names the offending host and the required gh version.
|
|
372
|
+
|
|
373
|
+
Still ``conclusion="unknown"`` (not in
|
|
374
|
+
:data:`coord.ci_store._PASSING_CONCLUSIONS`) so the gate still fails
|
|
375
|
+
closed and blocks the merge — #1525's fail-closed rule is not weakened,
|
|
376
|
+
only the diagnosis attached to the block is sharper.
|
|
377
|
+
"""
|
|
378
|
+
return CheckRun(
|
|
379
|
+
name=f"coord: gh is too old to read CI status for {repo}#{number} ({detail})",
|
|
380
|
+
status="completed",
|
|
381
|
+
conclusion="unknown",
|
|
382
|
+
url="",
|
|
383
|
+
run_id="",
|
|
384
|
+
started_at=None,
|
|
385
|
+
completed_at=None,
|
|
386
|
+
)
|