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.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. 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
+ )