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/github_ops.py ADDED
@@ -0,0 +1,1954 @@
1
+ """GitHub operations via gh CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import socket
8
+ import subprocess
9
+ from datetime import datetime
10
+ from typing import Any
11
+
12
+
13
+ # ── Typed gh errors ─────────────────────────────────────────────────────────
14
+
15
+
16
+ class GhError(RuntimeError):
17
+ """Base class for typed ``gh`` CLI errors.
18
+
19
+ Subclass of :class:`RuntimeError` so existing ``except RuntimeError``
20
+ call sites keep working without modification; catch a subclass specifically
21
+ to distinguish it from a generic ``gh`` failure.
22
+ """
23
+
24
+
25
+ class GhNotFound(GhError):
26
+ """Raised when a GitHub resource (e.g. a label) does not exist in the repo.
27
+
28
+ Callers (e.g. ``serve_app`` HTTP handlers) that need to distinguish
29
+ "the resource doesn't exist" (a 4xx-class client error) from transient
30
+ backend failures (auth, network, rate-limit — which are 5xx) should
31
+ catch this specifically and return an appropriate HTTP status code.
32
+ """
33
+
34
+
35
+ class GhTooOldForJsonChecks(GhError):
36
+ """Raised when the installed ``gh`` doesn't support ``gh pr checks --json``
37
+ *at all* (#1564 Addendum 2), as opposed to supporting ``--json`` but
38
+ rejecting one of the requested field names (a plain :class:`RuntimeError`
39
+ from :func:`get_pr_checks`, e.g. the original ``conclusion`` bug).
40
+
41
+ The fleet that surfaced this issue runs the merge gate across hosts with
42
+ wildly different ``gh`` versions — and because ``coord merge`` re-invokes
43
+ itself on the daemon host (``COORD_MERGE_ON_DAEMON``, see ``serve_app.py``),
44
+ it is the *daemon's* ``gh`` that decides every production merge, not the
45
+ thin client's. A daemon stuck on a too-old ``gh`` must fail with a message
46
+ that names the problem and the fix, not the same undiagnosable
47
+ "could not read CI status" text used for auth/network flakes — see
48
+ :func:`coord.ci_github.GitHubCi._fetch`, which catches this subclass
49
+ ahead of the generic ``RuntimeError`` branch for exactly that reason.
50
+ """
51
+
52
+
53
+ # Keywords indicating a transient/infra failure that should never trigger
54
+ # label auto-creation. Checked against the full lowercase error string.
55
+ _TRANSIENT_ERROR_KEYWORDS: tuple[str, ...] = (
56
+ "http 401",
57
+ "http 403",
58
+ "http 429",
59
+ "authentication required",
60
+ "bad credentials",
61
+ "credentials not found",
62
+ "gh auth login",
63
+ "api rate limit",
64
+ "rate limit exceeded",
65
+ "secondary rate limit",
66
+ "timed out",
67
+ "connection refused",
68
+ "connection reset",
69
+ "connection error",
70
+ "could not resolve host",
71
+ "no such host",
72
+ "dial tcp",
73
+ )
74
+
75
+
76
+ def _is_transient_error(exc: Exception) -> bool:
77
+ """True when the ``gh`` error suggests an auth, rate-limit, or network failure.
78
+
79
+ These failures should never trigger label auto-creation or other
80
+ retry-with-side-effects logic — the root cause is infra, not a missing
81
+ resource.
82
+ """
83
+ msg = str(exc).lower()
84
+ return any(kw in msg for kw in _TRANSIENT_ERROR_KEYWORDS)
85
+
86
+
87
+ def _is_label_not_found(exc: Exception) -> bool:
88
+ """True when ``gh`` reported that a label does not exist in the repository.
89
+
90
+ Returns ``False`` for transient errors (:func:`_is_transient_error`) so
91
+ callers never attempt auto-creation on auth/network/rate-limit failures.
92
+ """
93
+ if _is_transient_error(exc):
94
+ return False
95
+ msg = str(exc).lower()
96
+ # "could not resolve to a label" — the GraphQL error gh surfaces
97
+ # "label 'foo' not found" — the REST error in some gh versions
98
+ return "could not resolve to a label" in msg or (
99
+ "label" in msg and "not found" in msg
100
+ )
101
+
102
+
103
+ def _gh(*args: str) -> str:
104
+ """Run ``gh`` with *args* and return its stdout, or raise :class:`GhError`.
105
+
106
+ #1483: this is the single seam every ``_gh``-backed helper in this module
107
+ funnels through, so it is also the single place that must absorb the
108
+ ways ``gh`` can fail to even run — not just a non-zero exit.
109
+ ``subprocess.run`` raises ``FileNotFoundError`` when the ``gh`` binary
110
+ isn't on PATH (the elitebook incident that prompted #1483: a worker's
111
+ systemd PATH didn't include the linuxbrew-installed ``gh``) and
112
+ ``subprocess.TimeoutExpired`` when it hangs past the 30s budget; both are
113
+ caught here and re-raised as :class:`GhError` (a ``RuntimeError``
114
+ subclass) so every existing ``except RuntimeError`` call site — and any
115
+ future one — gets the same fail-safe behavior the pre-#1483 direct
116
+ ``shutil.which("gh")`` + ``subprocess.run(..., check=False)`` call sites
117
+ had, for free, without each caller having to remember to guard for it.
118
+ """
119
+ try:
120
+ result = subprocess.run(
121
+ ["gh", *args],
122
+ capture_output=True, text=True, timeout=30,
123
+ )
124
+ except FileNotFoundError as exc:
125
+ raise GhError(f"gh {' '.join(args)} failed: gh not found: {exc}") from exc
126
+ except subprocess.TimeoutExpired as exc:
127
+ raise GhError(f"gh {' '.join(args)} failed: timed out: {exc}") from exc
128
+ except OSError as exc:
129
+ raise GhError(f"gh {' '.join(args)} failed: {exc}") from exc
130
+ if result.returncode != 0:
131
+ raise RuntimeError(f"gh {' '.join(args)} failed: {result.stderr.strip()}")
132
+ return result.stdout.strip()
133
+
134
+
135
+ def _json_loads_or(raw: str | None, default: Any = None) -> Any:
136
+ """Decode *raw* as JSON, or return *default* on empty/malformed input.
137
+
138
+ #1353: ``_gh`` above treats a ``gh`` invocation that exits 0 with empty
139
+ stdout as a success — indistinguishable, from ``_gh``'s point of view,
140
+ from a real empty payload. Roughly half of this module's call sites used
141
+ to hand ``_gh``'s output straight to a bare ``json.loads``, so that empty
142
+ string decoded to exactly ``json.JSONDecodeError: Expecting value: line 1
143
+ column 1 (char 0)`` — a one-line, unattributable crash that took down an
144
+ entire ``coord merge`` drain in the incident that prompted this (see
145
+ issue #1353). This is the one guarded decode every such call site now
146
+ routes through, so "gh returned nothing useful" degrades to a documented
147
+ *default* value at every site uniformly, rather than at only the two
148
+ sites (``get_pr_commit_messages``, ``pr_is_merged``) that happened to
149
+ hand-roll their own guard beforehand.
150
+
151
+ Does **not** change ``_gh``'s own failure contract: a non-zero ``gh``
152
+ exit still raises ``RuntimeError``/``GhError`` same as always — this only
153
+ covers the exit-0-but-stdout-is-garbage case that used to reach a bare
154
+ ``json.loads``.
155
+ """
156
+ if raw is None or not raw.strip():
157
+ return default
158
+ try:
159
+ return json.loads(raw)
160
+ except json.JSONDecodeError:
161
+ return default
162
+
163
+
164
+ def _gh_json(*args: str, default: Any = None) -> Any:
165
+ """Run ``gh`` with *args* and JSON-decode its stdout, failing open.
166
+
167
+ Composes :func:`_gh` (still raises on a non-zero ``gh`` exit / missing
168
+ binary / timeout) with :func:`_json_loads_or` (fails open to *default* on
169
+ empty/malformed stdout from an otherwise-successful invocation). See
170
+ :func:`_json_loads_or` for why this exists.
171
+ """
172
+ return _json_loads_or(_gh(*args), default)
173
+
174
+
175
+ def get_open_issues(repo: str) -> list[dict]:
176
+ # #658: raised from 100 → 500 so repos with many open issues don't silently
177
+ # skip old issue numbers during coord sync. GitHub paginates the REST list
178
+ # endpoint at 100 items internally, so this costs ~5 API calls for a large
179
+ # repo — acceptable for a background sync.
180
+ return _gh_json(
181
+ "issue", "list", "--repo", repo, "--state", "open",
182
+ "--json", "number,title,labels,milestone,body,assignees",
183
+ "--limit", "500",
184
+ default=[],
185
+ )
186
+
187
+
188
+ def get_closed_epics(repo: str, *, label: str = "epic") -> list[dict]:
189
+ """Return closed issues in *repo* carrying *label* (default ``"epic"``).
190
+
191
+ Used by ``coord plans`` (#974) so a milestone's tracking epic is still
192
+ found once it has been closed while the milestone itself stays open
193
+ (e.g. all work-order nodes finished and someone tidied up the epic
194
+ before remembering to close the milestone) — see
195
+ :func:`coord.plans.find_tracking_issue`. A small, label-filtered,
196
+ closed-only lookup rather than a full ``--state all`` issue fetch, since
197
+ only closed *epics* are of interest here.
198
+ """
199
+ return _gh_json(
200
+ "issue", "list", "--repo", repo, "--state", "closed", "--label", label,
201
+ "--json", "number,title,labels,milestone,body,assignees",
202
+ "--limit", "500",
203
+ default=[],
204
+ )
205
+
206
+
207
+ def get_issue(repo: str, issue_number: int) -> dict:
208
+ """Fetch a single issue by number.
209
+
210
+ Returns ``{number, title, body, state, milestone, labels, ...}``.
211
+ ``milestone`` is ``None`` when the issue has none, else ``{"number":
212
+ ..., "title": ...}`` — used by ``coord milestone order`` (#768) to
213
+ resolve a tracking issue's milestone and validate node membership
214
+ without a second call. ``labels`` is a list of ``{"name": ..., ...}``
215
+ dicts — #1138's ``enforce_oracle_readiness`` reads issue labels (e.g.
216
+ ``oracle:exempt``) off this same call, so it must be requested here
217
+ too, not just on the list endpoints (``get_open_issues``,
218
+ ``get_closed_epics``).
219
+ """
220
+ return _gh_json(
221
+ "issue", "view", str(issue_number), "--repo", repo,
222
+ "--json", "number,title,body,state,milestone,labels",
223
+ default={},
224
+ )
225
+
226
+
227
+ # ── Sub-issues (#1195) ───────────────────────────────────────────────────────
228
+ #
229
+ # The REST sub-issues API is live on GitHub today but used nowhere in this
230
+ # repo before #1195 — every epic->child relation so far is the `## Work
231
+ # order` / `## Sub-issues` markdown checklist `coord.milestone_order` parses.
232
+ # These wrap the raw endpoints; `coord.parentage_github.GitHubParentage` is
233
+ # the adapter that turns them into the backend-agnostic `coord.parentage`
234
+ # seam shape (`Child`/`ParentRef`).
235
+ #
236
+ # Gotcha verified while filing #1195: the write endpoints (POST/DELETE) take
237
+ # the child's internal database `id`, NOT its issue `number` — resolve via
238
+ # `get_issue`'s `--jq .id` before writing (see `_resolve_issue_id`).
239
+
240
+
241
+ def get_sub_issues(repo: str, issue_number: int) -> list[dict]:
242
+ """The live sub-issues of *issue_number* (``GET .../sub_issues``).
243
+
244
+ Returns ``[]`` for an issue with no sub-issues (confirmed live: this is
245
+ the API's normal response, not a 404/410 — see #1195's filing notes).
246
+ Each item is a full issue object; callers only need ``number``/``state``.
247
+ """
248
+ return _gh_json("api", f"repos/{repo}/issues/{issue_number}/sub_issues", default=[])
249
+
250
+
251
+ def get_issue_parent(repo: str, issue_number: int) -> dict | None:
252
+ """The parent of *issue_number*, or ``None`` when it has none.
253
+
254
+ Reads the ``parent`` field GitHub already includes on ``GET
255
+ /issues/{n}`` (confirmed live while filing #1195 — no preview header
256
+ needed). ``None`` covers both "field absent" and the documented
257
+ ``parent: null`` shape.
258
+ """
259
+ raw = _gh("api", f"repos/{repo}/issues/{issue_number}", "--jq", ".parent")
260
+ stripped = raw.strip()
261
+ if not stripped or stripped == "null":
262
+ return None
263
+ return _json_loads_or(stripped, default=None)
264
+
265
+
266
+ def _resolve_issue_id(repo: str, issue_number: int) -> int:
267
+ """Issue `number` -> internal database `id` (#1195's write-path gotcha:
268
+ the sub-issues POST/DELETE endpoints want the latter, not the former)."""
269
+ raw = _gh("api", f"repos/{repo}/issues/{issue_number}", "--jq", ".id")
270
+ return int(raw.strip())
271
+
272
+
273
+ def add_sub_issue(repo: str, parent_number: int, child_number: int) -> None:
274
+ """Make *child_number* a sub-issue of *parent_number* (``POST
275
+ .../sub_issues``). Resolves *child_number* to its database id first —
276
+ the endpoint wants ``sub_issue_id`` (a database id), not the issue
277
+ number, and 422s if the body doesn't shape up."""
278
+ child_id = _resolve_issue_id(repo, child_number)
279
+ _gh(
280
+ "api", f"repos/{repo}/issues/{parent_number}/sub_issues",
281
+ "-X", "POST",
282
+ "-F", f"sub_issue_id={child_id}",
283
+ )
284
+
285
+
286
+ def remove_sub_issue(repo: str, parent_number: int, child_number: int) -> None:
287
+ """Detach *child_number* from *parent_number* (``DELETE
288
+ .../sub_issue`` — singular, unlike the GET/POST plural; a real GitHub
289
+ API asymmetry, not a typo here)."""
290
+ child_id = _resolve_issue_id(repo, child_number)
291
+ _gh(
292
+ "api", f"repos/{repo}/issues/{parent_number}/sub_issue",
293
+ "-X", "DELETE",
294
+ "-F", f"sub_issue_id={child_id}",
295
+ )
296
+
297
+
298
+ def edit_issue(
299
+ repo: str,
300
+ issue_number: int,
301
+ *,
302
+ title: str | None = None,
303
+ body: str | None = None,
304
+ ) -> None:
305
+ """Edit an issue's title and/or body. The GitHub backend of the
306
+ issue-tracker seam (`state.edit_issue_content`) — GitLab / bare-DB adapters
307
+ slot in alongside this later. The body is piped via stdin (`--body-file -`)
308
+ to avoid arg-length and shell-quoting issues on long markdown bodies."""
309
+ if title is None and body is None:
310
+ return
311
+ args = ["issue", "edit", str(issue_number), "--repo", repo]
312
+ if title is not None:
313
+ args += ["--title", title]
314
+ if body is not None:
315
+ args += ["--body-file", "-"]
316
+ result = subprocess.run(
317
+ ["gh", *args],
318
+ input=body if body is not None else None,
319
+ capture_output=True,
320
+ text=True,
321
+ timeout=30,
322
+ )
323
+ if result.returncode != 0:
324
+ raise RuntimeError(
325
+ f"gh issue edit #{issue_number} failed: {result.stderr.strip()}"
326
+ )
327
+
328
+
329
+ def create_milestone(
330
+ repo: str,
331
+ title: str,
332
+ *,
333
+ description: str | None = None,
334
+ due_on: str | None = None,
335
+ ) -> dict:
336
+ """Create a GitHub milestone via ``gh api POST .../milestones`` (#645 seam).
337
+
338
+ The GitHub backend of the milestone-tracker seam
339
+ (``coord.state.write_milestone``) — GitLab / bare-DB adapters slot in
340
+ alongside this later, same as ``edit_issue``. ``due_on`` is an ISO 8601
341
+ timestamp (e.g. ``"2026-08-01T00:00:00Z"``) per the GitHub API; this
342
+ layer does not validate the format, it just forwards it. Returns the
343
+ created milestone's JSON (``number``, ``title``, ``description``,
344
+ ``due_on``, ``html_url``, ...).
345
+ """
346
+ args = ["api", f"repos/{repo}/milestones", "-f", f"title={title}"]
347
+ if description is not None:
348
+ args += ["-f", f"description={description}"]
349
+ if due_on is not None:
350
+ args += ["-f", f"due_on={due_on}"]
351
+ return _json_loads_or(_gh(*args), default={})
352
+
353
+
354
+ def edit_milestone(
355
+ repo: str,
356
+ number: int,
357
+ *,
358
+ title: str | None = None,
359
+ description: str | None = None,
360
+ due_on: str | None = None,
361
+ ) -> dict:
362
+ """Edit a GitHub milestone's title/description/due date via
363
+ ``gh api -X PATCH .../milestones/{number}`` (#645 seam, mirrors
364
+ ``edit_issue``). A no-op (all three fields ``None``) returns ``{}``
365
+ without shelling out. Returns the updated milestone's JSON."""
366
+ if title is None and description is None and due_on is None:
367
+ return {}
368
+ args = ["api", "-X", "PATCH", f"repos/{repo}/milestones/{number}"]
369
+ if title is not None:
370
+ args += ["-f", f"title={title}"]
371
+ if description is not None:
372
+ args += ["-f", f"description={description}"]
373
+ if due_on is not None:
374
+ args += ["-f", f"due_on={due_on}"]
375
+ return _json_loads_or(_gh(*args), default={})
376
+
377
+
378
+ class IssueHasOpenChildrenError(RuntimeError):
379
+ """Raised by :func:`close_issue` when *issue_number* has open children and
380
+ ``force`` was not passed (#1196).
381
+
382
+ The close-invariant chokepoint: every deterministic close path in this
383
+ codebase (``merge_queue``, ``state._close_issue_local``,
384
+ ``commands/issues``) funnels through :func:`close_issue`, so guarding
385
+ here is the one place that stops an epic reading as "done" while its
386
+ sub-issues are still open — see claude-coordinator#1041, the incident
387
+ that prompted this. A subclass of ``RuntimeError`` so existing
388
+ ``except RuntimeError`` / ``except Exception`` call sites keep working
389
+ without modification; catch this specifically to distinguish "refused,
390
+ has open children" from any other close failure.
391
+ """
392
+
393
+
394
+ def get_issues_live_state(repo: str, numbers: list[int]) -> dict[int, str]:
395
+ """Batch-fetch the *live* open/closed state of each issue in *numbers*.
396
+
397
+ One GraphQL request (one aliased ``issue(number: N)`` field per number)
398
+ rather than N ``gh issue view`` round-trips — see #1354: a close-guard
399
+ that fans out one lookup per child turns closing an epic into N API
400
+ calls. Returns ``{number: "open" | "closed"}``; a number that GitHub
401
+ doesn't resolve (deleted, wrong repo) is simply absent from the result.
402
+
403
+ Returns ``{}`` on any failure — a bad *repo* string, a ``gh`` error, or
404
+ an unparseable response — so callers can treat "no live data" uniformly
405
+ and fall back to their offline signal (see :func:`get_open_children`),
406
+ matching the deliberate fail-open contract on the parent lookup.
407
+ """
408
+ if not numbers:
409
+ return {}
410
+ try:
411
+ owner, name = repo.split("/", 1)
412
+ except ValueError:
413
+ return {}
414
+ unique = sorted(set(numbers))
415
+ fields = "\n".join(
416
+ f" n{n}: issue(number: {n}) {{ number state }}" for n in unique
417
+ )
418
+ query = (
419
+ f"query {{ repository(owner: {json.dumps(owner)}, name: {json.dumps(name)}) {{\n"
420
+ f"{fields}\n"
421
+ f"}} }}"
422
+ )
423
+ try:
424
+ data = _gh_json("api", "graphql", "-f", f"query={query}", default={})
425
+ except RuntimeError:
426
+ return {}
427
+ data_field = data.get("data") if isinstance(data, dict) else None
428
+ repo_data = data_field.get("repository") if isinstance(data_field, dict) else None
429
+ if not isinstance(repo_data, dict):
430
+ return {}
431
+ result: dict[int, str] = {}
432
+ for value in repo_data.values():
433
+ if not isinstance(value, dict):
434
+ continue
435
+ num, state = value.get("number"), value.get("state")
436
+ if num is None or state is None:
437
+ continue
438
+ result[int(num)] = str(state).lower()
439
+ return result
440
+
441
+
442
+ def get_open_children(repo: str, issue_number: int) -> list[dict]:
443
+ """Open children of *issue_number*, per the #1195 parentage seam (#1196).
444
+
445
+ Uses :class:`coord.parentage.MarkdownParentage` over the issue's own
446
+ body (fetched via :func:`get_issue`) to discover *which* issues are
447
+ children — the ``## Sub-issues`` checklist convention (#1008) is the
448
+ primary parentage source; with ``fallback_to_work_order=True`` this also
449
+ falls back to a ``## Work order`` block (#1221) so epics seeded before
450
+ #1008 — which have only a Work order block, not a Sub-issues checklist —
451
+ still register their children instead of silently reading as childless.
452
+
453
+ Each discovered child's reported state then comes from a **live**
454
+ lookup (:func:`get_issues_live_state`, one batched GraphQL call for all
455
+ children at once) rather than the checklist's own ``- [x]``/``- [ ]``
456
+ box (#1354: the box is a proxy that drifts — a closed child's box is
457
+ often never ticked, and a ticked box can just as easily sit over a
458
+ child that was later reopened). The checkbox is used only as the
459
+ per-child fallback when the live lookup doesn't cover that number
460
+ (batch call failed entirely, or GitHub didn't resolve that number) —
461
+ preserving the pre-#1354 offline behavior for exactly the cases where a
462
+ live answer isn't available, rather than treating a lookup failure as
463
+ grounds to refuse or to silently allow the close.
464
+
465
+ The live GitHub sub-issues REST API (:class:`coord.parentage_github.
466
+ GitHubParentage`) is wired but not yet backfilled onto existing epics
467
+ (EP-2, unbuilt), so using it *instead of* the markdown checklist here to
468
+ discover children would silently miss every real epic and defeat the
469
+ guard — only the per-child *state* is live, not the parent->child edges
470
+ themselves.
471
+
472
+ Returns ``[{"number": int, "state": "open"}, ...]``. **Fails open**
473
+ (returns ``[]``) both when the issue lookup itself errors (a transient
474
+ ``gh`` failure must not permanently wedge every close in the system —
475
+ :func:`close_issue` is the enforcement point, not this lookup) and when
476
+ the body's ``## Sub-issues`` or ``## Work order`` block fails to parse
477
+ (a malformed checklist on *this* issue must not block closing some
478
+ *other*, well-formed one — the same per-issue fail-isolation #1195
479
+ already applies to the ``/board`` children display).
480
+ """
481
+ try:
482
+ issue = get_issue(repo, issue_number)
483
+ except RuntimeError:
484
+ return []
485
+ from coord.parentage import MarkdownParentage # noqa: PLC0415
486
+
487
+ body = issue.get("body") or ""
488
+ try:
489
+ children = MarkdownParentage().children(
490
+ repo, issue_number, body=body, fallback_to_work_order=True,
491
+ )
492
+ except Exception: # noqa: BLE001 — malformed checklist: fail open, don't wedge close
493
+ return []
494
+ if not children:
495
+ return []
496
+ live_states = get_issues_live_state(repo, [c.number for c in children])
497
+ return [
498
+ {"number": c.number, "state": live_states.get(c.number, c.state)}
499
+ for c in children
500
+ if live_states.get(c.number, c.state) == "open"
501
+ ]
502
+
503
+
504
+ def has_open_children(repo: str, issue_number: int) -> bool:
505
+ """True when *issue_number* has at least one open child (#1196)."""
506
+ return bool(get_open_children(repo, issue_number))
507
+
508
+
509
+ def is_epic_issue(repo: str, issue_number: int) -> bool:
510
+ """True when *issue_number* carries the tracking/epic label (#1318).
511
+
512
+ Same label :data:`coord.milestone_order.TRACKING_ISSUE_LABEL` that
513
+ ``dispatch.enforce_epic_dispatch_guard`` (#1314) and
514
+ ``plan_followup.pr()`` (#1077/#1314) already check at dispatch/PR-create
515
+ time. This is the merge-time counterpart used by the pre-merge
516
+ epic-closing-keyword guard: a closing keyword anywhere in a PR body or
517
+ commit message that targets an epic must never be allowed to auto-close
518
+ it on merge.
519
+
520
+ Fail-open: any ``gh`` error returns ``False`` — a transient read failure
521
+ must not itself block a merge; the caller still has its other gates.
522
+ """
523
+ from coord.milestone_order import TRACKING_ISSUE_LABEL # noqa: PLC0415
524
+
525
+ try:
526
+ issue_data = get_issue(repo, issue_number)
527
+ except RuntimeError:
528
+ return False
529
+ labels = {lbl.get("name", "") for lbl in (issue_data.get("labels") or [])}
530
+ return TRACKING_ISSUE_LABEL in labels
531
+
532
+
533
+ def close_issue(
534
+ repo: str, issue_number: int, *, comment: str | None = None, force: bool = False,
535
+ ) -> None:
536
+ """Close a GitHub issue, optionally posting *comment* first.
537
+
538
+ The deterministic counterpart to a ``Closes #N`` keyword in a PR body:
539
+ ``coord merge`` calls this after a successful merge so an issue is never
540
+ stranded open when a worker-created PR forgot the keyword (and
541
+ conventional-commit ``fix(#N):`` subjects are *not* GitHub closing
542
+ keywords). Idempotent — closing an already-closed issue is a no-op.
543
+ Raises RuntimeError on any other ``gh`` failure. Part of the
544
+ issue-tracker seam (GitHub backend); GitLab / bare-DB adapters slot in
545
+ alongside this later (#806).
546
+
547
+ #1196: the close-invariant chokepoint. Refuses (raises
548
+ :class:`IssueHasOpenChildrenError`) when *issue_number* has open
549
+ children unless *force* is ``True`` — an epic must not read as "done"
550
+ while its sub-issues are still open/unstarted. Every deterministic
551
+ close path in the codebase funnels through here, so this single guard
552
+ covers all of them.
553
+ """
554
+ if not force:
555
+ open_children = get_open_children(repo, issue_number)
556
+ if open_children:
557
+ numbers = ", ".join(f"#{c['number']}" for c in open_children)
558
+ raise IssueHasOpenChildrenError(
559
+ f"refusing to close {repo}#{issue_number}: open children "
560
+ f"{numbers} — pass force=True (CLI: --force) to override"
561
+ )
562
+ if comment:
563
+ post_issue_comment(repo, issue_number, comment)
564
+ result = subprocess.run(
565
+ ["gh", "issue", "close", str(issue_number), "--repo", repo],
566
+ capture_output=True,
567
+ text=True,
568
+ timeout=30,
569
+ )
570
+ if result.returncode != 0 and "already closed" not in result.stderr.lower():
571
+ raise RuntimeError(
572
+ f"gh issue close #{issue_number} failed: {result.stderr.strip()}"
573
+ )
574
+
575
+
576
+ def reopen_issue(
577
+ repo: str, issue_number: int, *, comment: str | None = None,
578
+ ) -> None:
579
+ """Reopen a GitHub issue, optionally posting *comment* first.
580
+
581
+ Idempotent — reopening an already-open issue is a no-op. Raises
582
+ RuntimeError on any other ``gh`` failure. Part of the issue-tracker seam
583
+ (GitHub backend); GitLab / bare-DB adapters slot in alongside this later
584
+ (#806).
585
+
586
+ Mirror of :func:`close_issue` for the complement operation (issue #1078).
587
+ """
588
+ if comment:
589
+ post_issue_comment(repo, issue_number, comment)
590
+ result = subprocess.run(
591
+ ["gh", "issue", "reopen", str(issue_number), "--repo", repo],
592
+ capture_output=True,
593
+ text=True,
594
+ timeout=30,
595
+ )
596
+ if result.returncode != 0 and "already open" not in result.stderr.lower():
597
+ raise RuntimeError(
598
+ f"gh issue reopen #{issue_number} failed: {result.stderr.strip()}"
599
+ )
600
+
601
+
602
+ def check_pr_mergeable(repo: str, number: int) -> bool | None:
603
+ """Return GitHub's current mergeability verdict for PR *number* (#1477).
604
+
605
+ Used by the merge queue's stale-``CONFLICT`` reconciliation
606
+ (:func:`coord.merge_queue.reconcile_conflict_entries`) to re-test whether
607
+ a parked entry's branch has since become clean — a conflict-fix worker
608
+ landed, or a human pushed a fix by hand — rather than trusting the
609
+ ``gh pr merge`` failure message cached from whenever the queue last
610
+ attempted it.
611
+
612
+ Returns ``True`` when GitHub reports ``MERGEABLE``, ``False`` when it
613
+ reports ``CONFLICTING``, and ``None`` for anything else — including
614
+ ``UNKNOWN`` (GitHub computes mergeability asynchronously; a very recent
615
+ push can read back unresolved for a few seconds) and any ``gh``
616
+ error/timeout. Callers must treat ``None`` the same as ``False`` — an
617
+ inconclusive read is never a green light to unpark an entry.
618
+ """
619
+ try:
620
+ value = _gh_json(
621
+ "pr", "view", str(number), "--repo", repo, "--json", "mergeable",
622
+ default={},
623
+ ).get("mergeable")
624
+ except Exception: # noqa: BLE001 — fail-safe: unknown mergeability blocks nothing
625
+ return None
626
+ if value == "MERGEABLE":
627
+ return True
628
+ if value == "CONFLICTING":
629
+ return False
630
+ return None
631
+
632
+
633
+ def branch_has_merge_commit(repo: str, number: int) -> bool | None:
634
+ """True when any commit on PR *number* has more than one parent (#1467).
635
+
636
+ GitHub refuses to rebase-merge (``gh pr merge --rebase``) any branch
637
+ containing a merge commit — a *linearity* requirement distinct from a
638
+ content conflict. :func:`check_pr_mergeable`'s ``mergeable`` field can't
639
+ detect this: GitHub reports a branch with a merge commit as
640
+ ``MERGEABLE`` right up until the rebase-merge attempt itself fails with
641
+ "This branch can't be rebased". This probe answers the question
642
+ ``check_pr_mergeable`` can't, so :mod:`coord.merge_queue` can fall back
643
+ from ``--rebase`` to ``--squash`` before ever hitting that refusal.
644
+
645
+ Reads ``repos/{repo}/pulls/{number}/commits`` — each commit's
646
+ ``parents`` array has length > 1 only for a merge commit — rather than a
647
+ local ``git rev-list --merges``, because ``coord merge`` runs on the
648
+ daemon host, which has no guaranteed checkout of an arbitrary configured
649
+ repo. Pages up to 100 commits, comfortably above any real worker branch.
650
+
651
+ Returns ``True``/``False`` when determined, and ``None`` on any ``gh``
652
+ failure or malformed response — an inconclusive read. Mirrors
653
+ :func:`check_pr_mergeable`'s fail-closed contract: callers must treat
654
+ ``None`` as "don't know" and never let it drive a behaviour change (here:
655
+ never silently switch merge method, or unpark a queue entry, on an
656
+ inconclusive read).
657
+ """
658
+ try:
659
+ raw = _gh("api", f"repos/{repo}/pulls/{number}/commits?per_page=100")
660
+ commits = json.loads(raw)
661
+ except Exception: # noqa: BLE001 — fail-safe: unknown parents blocks nothing
662
+ return None
663
+ if not isinstance(commits, list):
664
+ return None
665
+ try:
666
+ return any(len(c.get("parents") or []) > 1 for c in commits)
667
+ except (AttributeError, TypeError):
668
+ return None
669
+
670
+
671
+ def get_pr_body(repo: str, number: int) -> str:
672
+ """Return PR *number*'s current body text (empty string if unset)."""
673
+ return _gh_json(
674
+ "pr", "view", str(number), "--repo", repo, "--json", "body", default={},
675
+ ).get("body") or ""
676
+
677
+
678
+ def edit_pr_body(repo: str, number: int, body: str) -> None:
679
+ """Overwrite PR *number*'s body text via ``gh pr edit --body``."""
680
+ _gh("pr", "edit", str(number), "--repo", repo, "--body", body)
681
+
682
+
683
+ def get_pr_commit_messages(repo: str, number: int) -> list[str]:
684
+ """Return the full commit message (headline + body) of every commit on
685
+ PR *number*, in commit order (#1318).
686
+
687
+ GitHub's closing-keyword scanner reads commit messages verbatim once
688
+ they land on the base branch — for ``--rebase``/``--merge`` methods
689
+ that's every original commit, unchanged, so a keyword buried in
690
+ commit-message prose (e.g. explaining a bug fixed elsewhere, in a
691
+ quote) can auto-close an issue on merge exactly like a PR-body keyword
692
+ (#1196) does, and no PR-body edit can neutralize it. Best-effort:
693
+ returns ``[]`` on any ``gh`` failure or malformed response, same
694
+ fail-open posture as :func:`get_pr_body`.
695
+ """
696
+ try:
697
+ raw = _gh("pr", "view", str(number), "--repo", repo, "--json", "commits")
698
+ except RuntimeError:
699
+ return []
700
+ data = _json_loads_or(raw, default={})
701
+ commits = (data.get("commits") if isinstance(data, dict) else None) or []
702
+ messages: list[str] = []
703
+ for c in commits:
704
+ headline = (c.get("messageHeadline") or "").strip()
705
+ body = (c.get("messageBody") or "").strip()
706
+ messages.append(f"{headline}\n\n{body}" if body else headline)
707
+ return messages
708
+
709
+
710
+ def issue_is_closed(repo: str, issue_number: int) -> bool:
711
+ """True when issue ``issue_number`` is closed on GitHub.
712
+
713
+ Best-effort and **fail-open**: any ``gh`` error returns ``False`` so a
714
+ transient GitHub/CLI failure never silently blocks a legitimate dispatch.
715
+ """
716
+ try:
717
+ return get_issue(repo, issue_number).get("state", "").upper() == "CLOSED"
718
+ except (RuntimeError, json.JSONDecodeError):
719
+ return False
720
+
721
+
722
+ def pr_is_merged(repo: str, branch: str) -> bool:
723
+ """True when ``branch``'s *current* tip is a commit that actually merged on ``repo``.
724
+
725
+ Uses ``gh pr list --head <branch> --state all`` rather than ``pr view`` so
726
+ the result survives **branch deletion after merge** and the quadraui case
727
+ where a PR merged into ``develop`` leaves its linked issue OPEN (so
728
+ :func:`issue_is_closed` would miss it). Best-effort and **fail-open**:
729
+ returns ``False`` when there is no PR, the PR is still open, or ``gh``
730
+ errors — never blocks a legitimate dispatch on a transient failure.
731
+
732
+ #1150: branch reuse across merge cycles is a designed pattern
733
+ (``--fix-of``/``--rework-of`` continue on the same branch; ``--force`` can
734
+ re-target a branch name with prior history) — so "a PR with this head ref
735
+ name merged *at some point*" is not proof that the branch's *current*
736
+ commits are merged. To distinguish those cases, once a merged PR is found
737
+ we resolve the branch's current tip via :func:`get_branch_sha` (the same
738
+ GitHub-API SHA lookup #821 uses for stale-review detection) and require it
739
+ to match the merged PR's ``headRefOid`` — the exact commit that landed.
740
+
741
+ When the tip can't be resolved via ``get_branch_sha`` (it fails closed to
742
+ ``None`` on *any* error, transient or not — see its docstring), we do
743
+ **not** blindly trust the historical merge, because that would reintroduce
744
+ this same issue's bug class under a transient-failure trigger: a rate
745
+ limit or network blip at the wrong moment would read as "already merged"
746
+ and callers (``reconcile``'s merge sweep, ``prune_stale_queue_entries``)
747
+ would permanently mark live, unmerged work as done or delete its queue
748
+ entry. Trusting history is only actually safe in the one case where it's
749
+ *structurally* impossible for new commits to exist: the branch was
750
+ positively confirmed deleted (a 404, via :func:`branch_exists_on_remote`,
751
+ which distinguishes "GitHub said not found" from any other failure).
752
+ Every other unresolved case — auth hiccup, timeout, rate limit — fails
753
+ open toward ``False`` ("not yet merged"), matching this function's and
754
+ ``prune_stale_queue_entries``'s documented fail-open convention.
755
+ """
756
+ if not branch:
757
+ return False
758
+ try:
759
+ raw = _gh(
760
+ "pr", "list", "--repo", repo, "--head", branch,
761
+ "--state", "all", "--json", "number,state,mergedAt,headRefOid",
762
+ "--limit", "10",
763
+ )
764
+ except RuntimeError:
765
+ return False
766
+ prs = _json_loads_or(raw, default=[])
767
+ if not isinstance(prs, list):
768
+ return False
769
+ merged = [
770
+ p for p in prs
771
+ if p.get("mergedAt") or p.get("state", "").upper() == "MERGED"
772
+ ]
773
+ if not merged:
774
+ return False
775
+
776
+ current_sha = get_branch_sha(repo, branch)
777
+ if current_sha is not None:
778
+ return any(p.get("headRefOid") == current_sha for p in merged)
779
+ # SHA lookup failed. Only trust the historical merge if we can positively
780
+ # confirm the branch is gone (a 404 means no further commits could have
781
+ # been pushed to it). Any other failure (transient network/auth/rate
782
+ # limit) fails open toward False — see docstring.
783
+ if not branch_exists_on_remote(repo, branch):
784
+ return True # confirmed deleted — no new commits possible; trust history
785
+ return False
786
+
787
+
788
+ def work_is_terminal(
789
+ repo_github: str,
790
+ issue_number: int | None,
791
+ branch: str | None,
792
+ *,
793
+ cache: dict | None = None,
794
+ ) -> bool:
795
+ """True when work is already done on GitHub: **issue closed OR PR merged**.
796
+
797
+ The single chokepoint guard (#522) consulted before any fix/review
798
+ dispatch, so already-merged/closed work can never re-enter the loop (the
799
+ root cause of the 2026-06-09 launch flood: #349 ×4, #194).
800
+
801
+ Best-effort and **fail-open**: any error resolves to ``False`` so a
802
+ transient GitHub/CLI failure never blocks a legitimate dispatch.
803
+
804
+ *cache* — optional ``dict`` shared across a single ``notify`` run, keyed by
805
+ ``(repo_github, issue_number, branch)``, so a burst of transitions for the
806
+ same merged issue costs **one** ``gh`` round-trip, not one per call.
807
+ """
808
+ if not repo_github:
809
+ return False
810
+
811
+ key = (repo_github, issue_number, branch)
812
+ if cache is not None and key in cache:
813
+ return cache[key]
814
+
815
+ terminal = False
816
+ try:
817
+ if issue_number and issue_is_closed(repo_github, issue_number):
818
+ terminal = True
819
+ elif branch and pr_is_merged(repo_github, branch):
820
+ terminal = True
821
+ except Exception: # noqa: BLE001 — fail-open: never block a dispatch
822
+ terminal = False
823
+
824
+ if cache is not None:
825
+ cache[key] = terminal
826
+ return terminal
827
+
828
+
829
+ # ── #873: durable issue_comments mirror — capture-at-write ──────────────────
830
+
831
+ _COMMENT_ID_RE = re.compile(r"issuecomment-(\d+)")
832
+
833
+ # Memoized per-process: the authenticated gh identity, used as a best-effort
834
+ # `author` on capture-at-write rows. One extra `gh api user` call the first
835
+ # time a comment is posted in this process; the backfill sync
836
+ # (state.sync_issue_comments) overwrites it with the real per-comment author
837
+ # regardless, so a stale/failed lookup here is harmless.
838
+ _login_cache: dict[str, str | None] = {}
839
+
840
+
841
+ def parse_comment_id(url: str) -> int | None:
842
+ """Extract the numeric REST comment id from a GitHub comment URL
843
+ (``...#issuecomment-<digits>``) — the format both ``gh issue comment``'s
844
+ stdout and ``gh issue view --json comments``'s ``url`` field use. Returns
845
+ ``None`` when *url* doesn't match (e.g. blank, or gh's output format
846
+ changes)."""
847
+ m = _COMMENT_ID_RE.search(url or "")
848
+ return int(m.group(1)) if m else None
849
+
850
+
851
+ def _current_gh_login() -> str | None:
852
+ if "login" not in _login_cache:
853
+ try:
854
+ _login_cache["login"] = _gh("api", "user", "--jq", ".login") or None
855
+ except Exception: # noqa: BLE001 — best-effort; capture still proceeds without it
856
+ _login_cache["login"] = None
857
+ return _login_cache["login"]
858
+
859
+
860
+ def get_issue_comments(repo: str, issue_number: int) -> list[dict]:
861
+ """All comments on *issue_number*, oldest first (gh's default order).
862
+
863
+ Each dict carries at least ``id`` (a GraphQL node id — NOT the numeric
864
+ REST id; use :func:`parse_comment_id` on ``url`` for that), ``url``,
865
+ ``author`` (``{"login": ...}``), ``body``, ``createdAt``. Used by
866
+ ``state.sync_issue_comments`` (#873) to backfill the ``issue_comments``
867
+ mirror with human + out-of-band comments coord never wrote itself.
868
+ """
869
+ return _gh_json(
870
+ "issue", "view", str(issue_number), "--repo", repo, "--json", "comments",
871
+ default={},
872
+ ).get("comments", [])
873
+
874
+
875
+ def post_issue_comment(repo: str, issue_number: int, body: str):
876
+ url = _gh("issue", "comment", str(issue_number), "--repo", repo, "--body", body)
877
+ _capture_comment_write(repo, issue_number, body, url)
878
+
879
+
880
+ def _capture_comment_write(repo: str, issue_number: int, body: str, url: str) -> None:
881
+ """Best-effort mirror of a just-posted comment into the durable
882
+ ``issue_comments`` table (#873).
883
+
884
+ Capture-at-write: the coord-authored prose message bus (completion
885
+ summaries, review bodies, failure reports) becomes durable and
886
+ machine-independent the instant it posts, rather than depending on a
887
+ later reconciliation/recovery pass. Never raises — a mirror failure must
888
+ never undo (or even surface as an error against) a GitHub comment that
889
+ already landed.
890
+ """
891
+ try:
892
+ from coord import state # noqa: PLC0415 — avoid a github_ops<->state import cycle
893
+
894
+ state.record_issue_comment_capture(
895
+ repo_name=repo,
896
+ issue_number=issue_number,
897
+ body=body,
898
+ gh_comment_id=parse_comment_id(url),
899
+ author=_current_gh_login(),
900
+ )
901
+ except Exception: # noqa: BLE001 — fail-open, see docstring
902
+ pass
903
+
904
+
905
+ def add_issue_labels(repo: str, issue_number: int, labels: list[str]) -> None:
906
+ """Add labels to an issue. Idempotent — `gh issue edit --add-label`
907
+ silently no-ops when the label is already present. Raises RuntimeError
908
+ on `gh` failure; callers should wrap in try/except when labeling is
909
+ best-effort (e.g. post-dispatch auto-tagging)."""
910
+ if not labels:
911
+ return
912
+ args = ["issue", "edit", str(issue_number), "--repo", repo]
913
+ for lbl in labels:
914
+ args.extend(["--add-label", lbl])
915
+ _gh(*args)
916
+
917
+
918
+ def create_label(
919
+ repo: str,
920
+ label: str,
921
+ *,
922
+ color: str | None = None,
923
+ description: str | None = None,
924
+ force: bool = True,
925
+ ) -> None:
926
+ """Create *label* in *repo* via ``gh label create``.
927
+
928
+ ``force=True`` (the default) makes this idempotent — ``gh`` overwrites
929
+ the color/description if the label already exists instead of erroring.
930
+ Raises ``RuntimeError`` (a plain non-zero ``gh`` exit) or its subclass
931
+ :class:`GhError` (``gh`` missing from PATH or timed out — see ``_gh``) on
932
+ failure; callers that treat label pre-creation as best-effort (e.g. a
933
+ concurrent-create race) should catch ``RuntimeError`` to cover both. Used
934
+ by ``coord set-test-mode`` (#1483) to ensure the ``test-mode:*`` labels
935
+ exist before ``change_issue_labels`` tries to add one.
936
+ """
937
+ args = ["label", "create", label, "--repo", repo]
938
+ if color:
939
+ args.extend(["--color", color])
940
+ if description:
941
+ args.extend(["--description", description])
942
+ if force:
943
+ args.append("--force")
944
+ _gh(*args)
945
+
946
+
947
+ def remove_issue_label(repo: str, issue_number: int, label: str) -> None:
948
+ """Remove a label from an issue via ``gh issue edit --remove-label``.
949
+
950
+ Idempotent — ``gh`` silently no-ops if the label is not present.
951
+ Raises RuntimeError on ``gh`` failure.
952
+ """
953
+ _gh("issue", "edit", str(issue_number), "--repo", repo, "--remove-label", label)
954
+
955
+
956
+ def change_issue_labels(
957
+ repo: str,
958
+ issue_number: int,
959
+ *,
960
+ add: set[str],
961
+ remove: set[str],
962
+ ) -> tuple[list[str], bool]:
963
+ """Atomically add and/or remove arbitrary labels on an issue (#802).
964
+
965
+ Fetches the current label set first, computes the minimal delta, and
966
+ runs a single ``gh issue edit`` call only when something actually
967
+ changes — tolerates already-present ``add`` labels and already-absent
968
+ ``remove`` labels (idempotent, matches ``_apply_label_change``'s
969
+ pre-#802 behavior).
970
+
971
+ Returns ``(new_labels, changed)`` where ``new_labels`` is the final
972
+ label list (sorted) and ``changed`` is ``True`` when any labels were
973
+ added or removed. Raises ``RuntimeError`` on ``gh`` failure.
974
+ """
975
+ view_data = _gh_json(
976
+ "issue", "view", str(issue_number), "--repo", repo, "--json", "labels",
977
+ default={},
978
+ )
979
+ current: set[str] = {
980
+ lbl.get("name", "")
981
+ for lbl in view_data.get("labels", [])
982
+ }
983
+
984
+ to_add = add - current
985
+ to_remove = remove & current
986
+ changed = bool(to_add or to_remove)
987
+
988
+ if changed:
989
+ args = ["issue", "edit", str(issue_number), "--repo", repo]
990
+ for lbl in sorted(to_add):
991
+ args.extend(["--add-label", lbl])
992
+ for lbl in sorted(to_remove):
993
+ args.extend(["--remove-label", lbl])
994
+ try:
995
+ _gh(*args)
996
+ except RuntimeError as exc:
997
+ if to_add and _is_label_not_found(exc):
998
+ # A label in ``to_add`` doesn't exist in the repo yet.
999
+ # Auto-create each label and retry the edit once. Only
1000
+ # triggered on the add path (``to_add`` is non-empty) and
1001
+ # only for label-not-found errors — auth, network, and
1002
+ # rate-limit failures are re-raised immediately without
1003
+ # touching GitHub. ``gh label create`` errors are swallowed
1004
+ # so an "already exists" race on a concurrent create is
1005
+ # handled gracefully.
1006
+ for lbl in sorted(to_add):
1007
+ try:
1008
+ _gh("label", "create", lbl, "--repo", repo)
1009
+ except RuntimeError:
1010
+ pass # idempotent: label may already exist
1011
+ try:
1012
+ _gh(*args)
1013
+ except RuntimeError as retry_exc:
1014
+ if _is_label_not_found(retry_exc):
1015
+ raise GhNotFound(str(retry_exc)) from retry_exc
1016
+ raise
1017
+ else:
1018
+ raise
1019
+
1020
+ new_labels = sorted((current - to_remove) | to_add)
1021
+ return new_labels, changed
1022
+
1023
+
1024
+ _TEST_MODE_LABELS = ("test-mode:smoke", "test-mode:auto")
1025
+
1026
+
1027
+ def set_test_mode_label(
1028
+ repo_github: str,
1029
+ repo_name: str,
1030
+ issue_number: int,
1031
+ mode: str,
1032
+ ) -> None:
1033
+ """Persist the per-issue test-mode policy as a GitHub label.
1034
+
1035
+ Removes any existing ``test-mode:*`` label then adds ``test-mode:{mode}``.
1036
+ Also updates the local issues cache so the TUI pipeline reflects the change
1037
+ without waiting for the next ``coord sync``.
1038
+
1039
+ ``repo_github`` — ``owner/name`` slug for the ``gh`` CLI.
1040
+ ``repo_name`` — coordinator-local repo name for the DB cache.
1041
+ ``mode`` — ``"smoke"`` or ``"auto"``.
1042
+ """
1043
+ from coord import state as _state # noqa: PLC0415
1044
+
1045
+ if mode not in ("smoke", "auto"):
1046
+ raise ValueError(f"mode must be 'smoke' or 'auto', got {mode!r}")
1047
+
1048
+ # Step 1: remove any stale test-mode:* labels.
1049
+ for old_label in _TEST_MODE_LABELS:
1050
+ try:
1051
+ remove_issue_label(repo_github, issue_number, old_label)
1052
+ except RuntimeError:
1053
+ pass # already absent — not an error
1054
+
1055
+ # Step 2: add the new label (idempotent).
1056
+ new_label = f"test-mode:{mode}"
1057
+ add_issue_labels(repo_github, issue_number, [new_label])
1058
+
1059
+ # Step 3: refresh the local cache so the TUI sees the update.
1060
+ try:
1061
+ issue_data = get_issue(repo_github, issue_number)
1062
+ current_labels = [lbl.get("name", "") for lbl in issue_data.get("labels", [])]
1063
+ _state.update_issue_labels(repo_name, issue_number, current_labels)
1064
+ except Exception:
1065
+ pass # cache update is best-effort
1066
+
1067
+
1068
+ def get_repo_file(repo: str, path: str, branch: str = "develop") -> str:
1069
+ import base64
1070
+ raw = _gh("api", f"repos/{repo}/contents/{path}?ref={branch}")
1071
+ data = _json_loads_or(raw, default=None)
1072
+ # #1353: an empty/malformed-but-exit-0 response used to bare-json.loads()
1073
+ # into an unattributable JSONDecodeError (or, post-decode, a KeyError on
1074
+ # "content"). Both callers of this function (_default_gate_a_file_exists,
1075
+ # _default_fetch_repo_file) already catch RuntimeError to mean "file
1076
+ # doesn't exist" — raise that instead, so a `gh` hiccup degrades to the
1077
+ # same handled path as a real 404 rather than an uncaught crash.
1078
+ if not isinstance(data, dict) or "content" not in data:
1079
+ raise RuntimeError(
1080
+ f"gh api repos/{repo}/contents/{path}?ref={branch}: "
1081
+ "empty or malformed response"
1082
+ )
1083
+ return base64.b64decode(data["content"]).decode()
1084
+
1085
+
1086
+ def list_repo_dir(repo: str, path: str, branch: str = "develop") -> list[str]:
1087
+ """Filenames (not full paths) directly under *path* on *branch*.
1088
+
1089
+ Same ``contents`` endpoint :func:`get_repo_file` uses, which returns a
1090
+ JSON array (rather than a single file object) when *path* is a
1091
+ directory. Raises like :func:`get_repo_file` (``RuntimeError`` via
1092
+ ``_gh``) when *path* doesn't exist — callers that want a soft "not
1093
+ found" should catch that, mirroring ``_default_gate_a_file_exists``.
1094
+ """
1095
+ raw = _gh("api", f"repos/{repo}/contents/{path}?ref={branch}")
1096
+ data = _json_loads_or(raw, default=None)
1097
+ if not isinstance(data, list):
1098
+ return []
1099
+ return [entry["name"] for entry in data if entry.get("type") == "file"]
1100
+
1101
+
1102
+ def check_branch_exists(repo: str, branch: str) -> bool:
1103
+ try:
1104
+ _gh("api", f"repos/{repo}/branches/{branch}")
1105
+ return True
1106
+ except RuntimeError:
1107
+ return False
1108
+
1109
+
1110
+ def list_remote_branch_names(repo: str) -> set[str]:
1111
+ """Return the set of branch names that currently exist on `repo` (owner/name).
1112
+
1113
+ One paginated ``gh api`` call. Used by ``coord merge`` to skip re-enqueuing
1114
+ done-work whose branch was already merged-and-deleted (the dominant
1115
+ merge-queue clog source). Returns an empty set on error so callers can
1116
+ fail OPEN (treat "couldn't determine" as "don't skip").
1117
+ """
1118
+ try:
1119
+ raw = _gh(
1120
+ "api", "--paginate",
1121
+ f"repos/{repo}/git/refs/heads",
1122
+ "--jq", ".[].ref",
1123
+ )
1124
+ except RuntimeError:
1125
+ return set()
1126
+ prefix = "refs/heads/"
1127
+ return {
1128
+ line.strip()[len(prefix):]
1129
+ for line in raw.splitlines()
1130
+ if line.strip().startswith(prefix)
1131
+ }
1132
+
1133
+
1134
+ def branch_exists_on_remote(repo: str, branch: str) -> bool:
1135
+ """Return True if `branch` currently exists on `repo` (owner/name) at GitHub.
1136
+
1137
+ Uses a targeted ``gh api`` call rather than listing all branches. Fails
1138
+ OPEN (returns True) on any infrastructure problem — an unresponsive ``gh``,
1139
+ a network glitch, or an authentication issue must never prevent a legitimate
1140
+ dispatch. Only returns False when we receive a clear "not found" signal
1141
+ from GitHub (HTTP 4xx in the error output).
1142
+
1143
+ Called by ``dispatch_review`` and ``_dispatch_fix`` (#586) to avoid
1144
+ routing a follow-on assignment to a machine that can't fetch the branch.
1145
+ """
1146
+ try:
1147
+ _gh("api", f"repos/{repo}/git/refs/heads/{branch}")
1148
+ return True
1149
+ except RuntimeError as exc:
1150
+ err = str(exc).lower()
1151
+ # Only return False when GitHub explicitly told us the ref doesn't
1152
+ # exist (HTTP 4xx response). Any other failure (gh not installed,
1153
+ # not authenticated, network timeout) is treated as "unknown" and we
1154
+ # fail OPEN so the guard doesn't block legitimate dispatch.
1155
+ if "http 4" in err or "could not resolve" in err or "not found" in err:
1156
+ return False
1157
+ return True
1158
+
1159
+
1160
+ def delete_remote_branch(repo: str, branch: str) -> bool:
1161
+ """Delete a remote branch. Returns True on success, False on failure."""
1162
+ try:
1163
+ _gh("api", "-X", "DELETE", f"repos/{repo}/git/refs/heads/{branch}")
1164
+ return True
1165
+ except RuntimeError:
1166
+ return False
1167
+
1168
+
1169
+ def create_remote_branch(repo: str, branch: str, sha: str) -> bool:
1170
+ """Create a remote branch (a ``refs/heads/{branch}`` ref) pointing at
1171
+ *sha*. Returns True on success, False on failure.
1172
+
1173
+ #934: used by ``coord.branch_model.ensure_feature_branch_exists`` to
1174
+ create ``feature/ms-NN`` off ``develop`` on demand, idempotently (the
1175
+ caller checks ``branch_exists_on_remote`` first).
1176
+ """
1177
+ try:
1178
+ _gh(
1179
+ "api", "-X", "POST", f"repos/{repo}/git/refs",
1180
+ "-f", f"ref=refs/heads/{branch}",
1181
+ "-f", f"sha={sha}",
1182
+ )
1183
+ return True
1184
+ except RuntimeError:
1185
+ return False
1186
+
1187
+
1188
+ def get_default_branch_head(repo: str, branch: str) -> str:
1189
+ """Return the full commit SHA at the tip of `branch` on `repo` (owner/name)."""
1190
+ raw = _gh("api", f"repos/{repo}/branches/{branch}")
1191
+ data = _json_loads_or(raw, default=None)
1192
+ # #1353: every caller of this already catches RuntimeError to mean "HEAD
1193
+ # lookup failed" — raise that instead of letting an empty/malformed
1194
+ # exit-0 response crash with an unattributable JSONDecodeError/KeyError.
1195
+ if not isinstance(data, dict) or "commit" not in data:
1196
+ raise RuntimeError(
1197
+ f"gh api repos/{repo}/branches/{branch}: empty or malformed response"
1198
+ )
1199
+ return data["commit"]["sha"]
1200
+
1201
+
1202
+ def get_branch_sha(repo: str, branch: str) -> str | None:
1203
+ """Return the current HEAD SHA for *branch* on *repo*, or ``None`` on failure.
1204
+
1205
+ Best-effort wrapper around the GitHub branches API. Returns ``None`` when
1206
+ GitHub is unavailable, ``gh`` is not authenticated, or the branch does not
1207
+ exist — callers treat ``None`` as "SHA tracking unavailable" and skip the
1208
+ commit-bound staleness check introduced in #821.
1209
+ """
1210
+ try:
1211
+ raw = _gh("api", f"repos/{repo}/branches/{branch}")
1212
+ data = _json_loads_or(raw, default={})
1213
+ return data["commit"]["sha"]
1214
+ except Exception: # noqa: BLE001 — fail-safe: unknown SHA is not blocking
1215
+ return None
1216
+
1217
+
1218
+ def get_branch_commit_timestamp(repo: str, branch: str) -> float | None:
1219
+ """Return the unix timestamp of *branch*'s current HEAD commit on *repo*,
1220
+ or ``None`` on failure (#1851).
1221
+
1222
+ The base-side half of :func:`coord.ci_store.checks_are_stale`'s
1223
+ comparison: a green CI check's ``started_at`` predating this timestamp
1224
+ means the check ran before the base's newest commit landed — GitHub only
1225
+ re-runs ``pull_request`` checks on head ``synchronize``, never on base
1226
+ movement, so that check never saw it.
1227
+
1228
+ Same endpoint as :func:`get_branch_sha` (``GET
1229
+ repos/{repo}/branches/{branch}``) — reads the commit's ``committer.date``
1230
+ (when it landed on the branch) rather than ``author.date`` (when it was
1231
+ originally authored, which for a rebased/cherry-picked commit can predate
1232
+ the merge by a wide margin and would understate how fresh the base
1233
+ actually is). Best-effort like :func:`get_branch_sha`: returns ``None``
1234
+ when GitHub is unavailable, ``gh`` is not authenticated, the branch
1235
+ doesn't exist, or the response is missing the expected fields — callers
1236
+ must treat ``None`` as "unknown" and fail closed (stale), never as "the
1237
+ base never moved".
1238
+ """
1239
+ try:
1240
+ raw = _gh("api", f"repos/{repo}/branches/{branch}")
1241
+ data = _json_loads_or(raw, default={})
1242
+ date = data["commit"]["commit"]["committer"]["date"]
1243
+ return datetime.fromisoformat(str(date).replace("Z", "+00:00")).timestamp()
1244
+ except Exception: # noqa: BLE001 — fail-safe: unknown timestamp is stale, not blocking here
1245
+ return None
1246
+
1247
+
1248
+ # ── PR operations (used by the merge queue) ──────────────────────────────
1249
+
1250
+ def find_pr_for_branch(repo: str, branch: str) -> dict | None:
1251
+ """Return the first open PR whose head ref matches `branch`, or None."""
1252
+ items = _gh_json(
1253
+ "pr", "list", "--repo", repo, "--state", "open",
1254
+ "--head", branch,
1255
+ "--json", "number,title,url,headRefName,baseRefName,additions,deletions,mergeable",
1256
+ "--limit", "1",
1257
+ default=[],
1258
+ )
1259
+ return items[0] if items else None
1260
+
1261
+
1262
+ def get_pr_state_for_branch(repo: str, branch: str) -> str | None:
1263
+ """Return the current lifecycle state (``OPEN``/``MERGED``/``CLOSED``) of
1264
+ the PR whose head is *branch*, or ``None`` when no such PR exists (or
1265
+ ``gh`` fails).
1266
+
1267
+ Unlike :func:`find_pr_for_branch` (``pr list --state open`` — only ever
1268
+ finds *open* PRs), this resolves the branch directly via ``gh pr view``,
1269
+ which answers regardless of state. Used by
1270
+ :meth:`coord.drive.GitMergeVerifier.verify_merged` (#1483) to confirm a
1271
+ MERGED PR whose branch may since have been deleted from the remote.
1272
+ """
1273
+ try:
1274
+ state = _gh("pr", "view", branch, "--repo", repo, "--json", "state", "-q", ".state")
1275
+ except RuntimeError:
1276
+ return None
1277
+ return state or None
1278
+
1279
+
1280
+ def get_pr_head_ref(repo: str, number: int) -> str | None:
1281
+ """Return PR *number*'s head branch name, or ``None`` on any ``gh``
1282
+ failure (including "no such PR").
1283
+
1284
+ Used by ``coord test``'s branch-reconciliation fallback (#349, #1483) to
1285
+ recover the PR's actual head ref when the DB's recorded branch name has
1286
+ gone stale.
1287
+ """
1288
+ try:
1289
+ head_ref = _gh(
1290
+ "pr", "view", str(number), "--repo", repo,
1291
+ "--json", "headRefName", "--jq", ".headRefName",
1292
+ )
1293
+ except RuntimeError:
1294
+ return None
1295
+ return head_ref or None
1296
+
1297
+
1298
+ # #1564: `gh pr checks --json` does NOT have a `conclusion` field — it never
1299
+ # has. Requesting it makes `gh` exit 1 with empty stdout, which used to make
1300
+ # every single merge look like an unreadable CI status (fail-closed, so it
1301
+ # blocked every merge rather than passing every one, but neither is correct).
1302
+ # `bucket` is gh's own pass/fail/pending/skipping/cancel rollup of `state`
1303
+ # (which is a per-check verdict like SUCCESS/FAILURE, not a lifecycle phase)
1304
+ # and is exactly what the merge gate wants. Pinned in a regression test
1305
+ # (tests/test_github_ops.py::TestPrChecksJsonFieldsAreValid) that shells out
1306
+ # to `gh pr checks --help` and asserts every field here is one `gh` advertises,
1307
+ # so the next `gh` schema change fails a test instead of silently breaking
1308
+ # the gate again.
1309
+ PR_CHECKS_JSON_FIELDS: tuple[str, ...] = (
1310
+ "name", "state", "bucket", "link", "startedAt", "completedAt",
1311
+ )
1312
+
1313
+ # #1564 Addendum 2: the fleet that surfaced this issue runs `gh` versions that
1314
+ # disagree about whether `gh pr checks` even *has* a `--json` flag —
1315
+ # dellserver's 2.45.0 (Ubuntu's apt package) does not: `gh pr checks --json
1316
+ # name,state,bucket` fails with `unknown flag: --json`, exit 1, empty stdout.
1317
+ # 2.86.0 (elitebook) and 2.92.0 (precision) both support `--json` fine. There
1318
+ # is no gh version floor documented anywhere else in this codebase, so this
1319
+ # constant is the single source of truth for it — surfaced in the actionable
1320
+ # error message below (:func:`_gh_too_old_message`) and in
1321
+ # ``docs/AGENT_OPERATIONS.md``'s daemon-host prerequisites.
1322
+ #
1323
+ # 2.86.0 is simply the *oldest version this fleet has directly observed
1324
+ # working* — nobody has bisected the actual gh release that first shipped
1325
+ # `pr checks --json` support, so treat this as a confirmed-good floor, not a
1326
+ # precisely-researched one. Lower it if a narrower floor is ever confirmed.
1327
+ GH_PR_CHECKS_JSON_MIN_VERSION = "2.86.0"
1328
+
1329
+ # The exact, stable cobra/pflag message `gh` emits for a flag it doesn't
1330
+ # recognise at all — confirmed verbatim against dellserver's gh 2.45.0.
1331
+ # Distinct from (and must be checked before assuming) the "field not valid"
1332
+ # failure a newer gh gives for a bad field *name* (e.g. the original
1333
+ # `conclusion` bug), which instead exits 1 with a "Unknown JSON field" body.
1334
+ _GH_UNKNOWN_JSON_FLAG_MARKER = "unknown flag: --json"
1335
+
1336
+
1337
+ def _gh_version() -> str | None:
1338
+ """Best-effort parse of ``gh --version``'s version string (e.g. "2.45.0").
1339
+
1340
+ Returns ``None`` if ``gh`` is missing, times out, or prints something
1341
+ this can't parse — callers must treat that as "unknown", never fail on it.
1342
+ """
1343
+ try:
1344
+ result = subprocess.run(
1345
+ ["gh", "--version"], capture_output=True, text=True, timeout=10,
1346
+ )
1347
+ except (OSError, subprocess.TimeoutExpired):
1348
+ return None
1349
+ match = re.search(r"gh version (\S+)", result.stdout or "")
1350
+ return match.group(1) if match else None
1351
+
1352
+
1353
+ def _gh_too_old_message(stderr: str) -> str:
1354
+ """Build the actionable error text for :class:`GhTooOldForJsonChecks`.
1355
+
1356
+ Names the host and the installed (if determinable) and required gh
1357
+ versions explicitly — this is the whole point of #1564 Addendum 2: an
1358
+ operator reading a merge refusal should never have to guess whether the
1359
+ gate found a red check, hit a network blip, or is simply running on a
1360
+ `gh` too old to ask the question at all.
1361
+ """
1362
+ host = socket.gethostname()
1363
+ version = _gh_version() or "unknown"
1364
+ return (
1365
+ f"gh on host {host!r} (version {version}) does not support "
1366
+ f"`gh pr checks --json` at all ({stderr!r}) — gh >= "
1367
+ f"{GH_PR_CHECKS_JSON_MIN_VERSION} is required on whichever host runs "
1368
+ f"the CI merge gate. Since `coord merge` re-invokes itself on the "
1369
+ f"daemon (COORD_MERGE_ON_DAEMON), that means the *daemon* host's gh, "
1370
+ f"not the client's. See docs/AGENT_OPERATIONS.md's daemon-host "
1371
+ f"prerequisites."
1372
+ )
1373
+
1374
+
1375
+ def get_repo_workflow_count(repo: str) -> int:
1376
+ """Number of GitHub Actions workflows GitHub recognises for *repo* (#1904).
1377
+
1378
+ Backs :meth:`coord.ci_github.GitHubCi.expects_checks` — the signal that
1379
+ distinguishes "this repo has no CI configured" (an empty ``gh pr checks``
1380
+ result is correct) from "CI exists but never triggered for this PR" (an
1381
+ empty result is a red flag: a throttled webhook, a wedged run, a
1382
+ ``paths:``-filtered-out workflow). Queries the workflow *definitions*
1383
+ GitHub knows about for the repo as a whole — not any particular branch
1384
+ or PR's check runs — so the answer doesn't depend on whether this PR's
1385
+ push ever actually triggered a run, which is exactly the case this
1386
+ exists to catch.
1387
+
1388
+ Raises (``RuntimeError``/:class:`GhError`) on any read failure — auth,
1389
+ rate-limit, malformed response — rather than defaulting to 0. A caller
1390
+ that can't tell "no workflows" from "couldn't check" must not silently
1391
+ treat the latter as the former; see #1525 for the identical reasoning
1392
+ applied to check-run reads themselves.
1393
+ """
1394
+ data = _gh_json("api", f"repos/{repo}/actions/workflows", default=None)
1395
+ if not isinstance(data, dict) or not isinstance(data.get("total_count"), int):
1396
+ raise RuntimeError(
1397
+ f"gh api repos/{repo}/actions/workflows: malformed response"
1398
+ )
1399
+ return data["total_count"]
1400
+
1401
+
1402
+ def get_pr_checks(repo: str, number: int) -> list[dict]:
1403
+ """Return ``gh pr checks``' raw check-run list for PR *number*.
1404
+
1405
+ ``gh pr checks`` exits non-zero when any check has failed, but its JSON
1406
+ stdout is still valid in that case — only raise when stdout is genuinely
1407
+ empty (a real lookup failure: bad PR number, auth, rate-limit, an old gh
1408
+ that doesn't support ``--json`` at all, ...). The single ``gh`` sink for
1409
+ :class:`coord.ci_github.GitHubCi`, the CI backend behind the merge gate
1410
+ (#1483).
1411
+
1412
+ Raises :class:`GhTooOldForJsonChecks` — instead of the generic
1413
+ ``RuntimeError`` below — when the installed ``gh`` doesn't recognise
1414
+ ``--json`` on ``pr checks`` at all (#1564 Addendum 2), so callers can
1415
+ surface a distinct, actionable "upgrade gh" message rather than lumping
1416
+ it in with ordinary read failures.
1417
+ """
1418
+ result = subprocess.run(
1419
+ [
1420
+ "gh", "pr", "checks", str(number),
1421
+ "--repo", repo,
1422
+ "--json", ",".join(PR_CHECKS_JSON_FIELDS),
1423
+ ],
1424
+ capture_output=True, text=True, timeout=30,
1425
+ )
1426
+ stdout = (result.stdout or "").strip()
1427
+ if result.returncode != 0 and not stdout:
1428
+ stderr = result.stderr.strip()
1429
+ if _GH_UNKNOWN_JSON_FLAG_MARKER in stderr:
1430
+ raise GhTooOldForJsonChecks(_gh_too_old_message(stderr))
1431
+ raise RuntimeError(f"gh pr checks failed: {stderr}")
1432
+ # #1525: unlike the fail-open sites elsewhere in this module, a malformed
1433
+ # (non-empty) response here must NOT be swallowed to a quiet ``[]`` —
1434
+ # ``ci_github.GitHubCi._fetch`` deliberately catches the ``ValueError``
1435
+ # (``json.JSONDecodeError`` is a subclass) this raises and turns it into
1436
+ # a synthetic *failing* check, so the merge gate blocks and says why
1437
+ # instead of reading "no checks" as "clear to merge" (the exact silent
1438
+ # fail-open that let PR #1521 merge past a real CI failure). Only the
1439
+ # genuinely-empty-stdout case (``stdout or "[]"``, unchanged from before)
1440
+ # is a deliberate default here — that's ``gh``'s normal "zero checks
1441
+ # configured" response, not a decode failure.
1442
+ return json.loads(stdout or "[]")
1443
+
1444
+
1445
+ def get_run_jobs(repo: str, run_id: str) -> list[dict]:
1446
+ """Return ``gh api .../actions/runs/{run_id}/jobs``' raw ``jobs`` list (#1892).
1447
+
1448
+ The single ``gh`` sink for :meth:`coord.ci_github.GitHubCi.
1449
+ list_jobs_for_run` — the one call that carries per-step detail
1450
+ (``runner_name``, each step's ``name``/``conclusion``) a plain
1451
+ ``gh pr checks`` read never has. Deliberately NOT the ``--json``
1452
+ field-selection style :func:`get_pr_checks` uses: ``gh api`` returns the
1453
+ endpoint's full JSON shape and this needs several nested fields
1454
+ (``steps[].name``, ``steps[].conclusion``, ``runner_name``) that aren't
1455
+ worth hand-picking.
1456
+
1457
+ Raises ``RuntimeError``/``ValueError`` on any read failure — auth,
1458
+ rate-limit, malformed response, or the run id simply not existing
1459
+ (rerun raced a retention window). Callers on the classification path
1460
+ (:mod:`coord.ci_store`'s false-negative bias) must catch and treat a
1461
+ raised error the same as "no job data" — never as evidence either way.
1462
+ """
1463
+ data = _gh_json("api", f"repos/{repo}/actions/runs/{run_id}/jobs", default=None)
1464
+ if not isinstance(data, dict) or not isinstance(data.get("jobs"), list):
1465
+ raise RuntimeError(
1466
+ f"gh api repos/{repo}/actions/runs/{run_id}/jobs: malformed response"
1467
+ )
1468
+ return data["jobs"]
1469
+
1470
+
1471
+ def rerun_workflow_run(repo: str, run_id: str) -> bool:
1472
+ """Re-run Actions workflow run *run_id* on *repo* via ``gh run rerun``.
1473
+
1474
+ The single ``gh`` sink (#1483) for :meth:`coord.ci_github.GitHubCi.
1475
+ rerun_for_pr` (#1851) — the same seam :func:`get_pr_checks` is for
1476
+ reading checks. Returns ``True`` only on a clean (exit 0) rerun; any
1477
+ subprocess failure (missing ``gh``, timeout, non-zero exit — e.g. the run
1478
+ is already in progress, or the id is stale/invalid) returns ``False``
1479
+ rather than raising, matching this module's other best-effort mutators
1480
+ (:func:`merge_pr`, :func:`edit_pr_body`).
1481
+ """
1482
+ try:
1483
+ result = subprocess.run(
1484
+ ["gh", "run", "rerun", str(run_id), "--repo", repo],
1485
+ capture_output=True, text=True, timeout=30,
1486
+ )
1487
+ except (OSError, subprocess.TimeoutExpired):
1488
+ return False
1489
+ return result.returncode == 0
1490
+
1491
+
1492
+ def truncate_diff_text(diff: str, max_chars: int = 60000) -> str:
1493
+ """Truncate *diff* to *max_chars* with a trailing note, if it's over.
1494
+
1495
+ Factored out of :func:`pr_diff` (#1475) so callers that need the full,
1496
+ untruncated diff for content hashing (``compute_patch_id``) can still
1497
+ apply the same display truncation to a *separate* copy shown to a human
1498
+ reviewer or embedded in a briefing, without a second ``gh`` fetch.
1499
+ """
1500
+ if len(diff) > max_chars:
1501
+ return diff[:max_chars] + f"\n... [diff truncated at {max_chars} chars] ..."
1502
+ return diff
1503
+
1504
+
1505
+ def pr_diff(repo_github: str, pr_number: int, *, max_chars: int | None = 60000) -> str | None:
1506
+ """Return the merge-base (three-dot) diff for PR ``pr_number``, or None.
1507
+
1508
+ ``gh pr diff`` is three-dot / merge-base by GitHub semantics, so the output
1509
+ is exactly the branch's own changes (#612) — code merged to the base after
1510
+ the branch was cut never appears as spurious deletions. Truncated to
1511
+ *max_chars* with a trailing note so a huge diff can't blow the briefing
1512
+ size — pass ``max_chars=None`` to get the full, untruncated diff (#1475:
1513
+ needed for content-hashing via ``compute_patch_id``, which must not hash
1514
+ a mutated/truncated string). Best-effort: returns None on any ``gh`` error
1515
+ so the caller falls back to the in-briefing three-dot diff instructions.
1516
+ """
1517
+ try:
1518
+ diff = _gh("pr", "diff", str(pr_number), "--repo", repo_github)
1519
+ except RuntimeError:
1520
+ return None
1521
+ if max_chars is None:
1522
+ return diff
1523
+ return truncate_diff_text(diff, max_chars)
1524
+
1525
+
1526
+ def compute_patch_id(diff_text: str | None) -> str | None:
1527
+ """Return the ``git patch-id --stable`` hash of *diff_text*, or ``None``.
1528
+
1529
+ #1475: a content-addressed fingerprint of a diff — insensitive to commit
1530
+ SHA / line numbers, but sensitive to the surrounding context lines. A
1531
+ pure rebase with no conflict replays the identical diff against a new
1532
+ base and produces the same patch-id even though the commit SHA changed;
1533
+ a conflict resolution or genuine content change produces a different one
1534
+ (that distinction is #1476's job, not this function's).
1535
+
1536
+ ``git patch-id`` operates purely on the piped diff text — no working
1537
+ directory or repo checkout required, so this is safe to call from any
1538
+ process. Returns ``None`` for empty/missing input or on any subprocess
1539
+ failure; callers must fail closed (treat a missing patch-id as "cannot
1540
+ confirm identical content", never as "identical").
1541
+ """
1542
+ if not diff_text or not diff_text.strip():
1543
+ return None
1544
+ try:
1545
+ result = subprocess.run(
1546
+ ["git", "patch-id", "--stable"],
1547
+ input=diff_text, capture_output=True, text=True, timeout=15,
1548
+ )
1549
+ except (OSError, subprocess.TimeoutExpired):
1550
+ return None
1551
+ if result.returncode != 0:
1552
+ return None
1553
+ line = result.stdout.strip().splitlines()[0] if result.stdout.strip() else ""
1554
+ if not line:
1555
+ return None
1556
+ return line.split()[0]
1557
+
1558
+
1559
+ def get_compare_diff(repo: str, base: str, head: str) -> str | None:
1560
+ """Return the raw three-dot (``base...head``) unified diff text, or None.
1561
+
1562
+ *head* may be a branch name or a commit SHA — GitHub's compare API treats
1563
+ them identically, so this also works for a historical SHA that is no
1564
+ longer any branch's tip (e.g. the HEAD a review approved before a
1565
+ conflict-fix rebase moved the branch on, #1476). Factored out of
1566
+ :func:`get_branch_patch_id` so a scoped re-review can fetch the diff for
1567
+ an old SHA as well as the current branch tip, without duplicating the
1568
+ ``gh api compare`` call shape. Returns ``None`` on any ``gh`` failure —
1569
+ callers must fail closed (missing diff ⇒ cannot confirm anything about
1570
+ its content).
1571
+ """
1572
+ try:
1573
+ return _gh(
1574
+ "api", f"repos/{repo}/compare/{base}...{head}",
1575
+ "-H", "Accept: application/vnd.github.v3.diff",
1576
+ )
1577
+ except RuntimeError:
1578
+ return None
1579
+
1580
+
1581
+ def get_compare_files(repo: str, base: str, head: str) -> list[str] | None:
1582
+ """Return the list of file paths changed in the three-dot ``base...head``
1583
+ compare, or ``None`` on any ``gh`` failure.
1584
+
1585
+ #1720: the dispatch-time file-overlap fence needs *which files*, not the
1586
+ diff content — asking the compare API for ``.files[].filename`` directly
1587
+ is cheaper and simpler than fetching :func:`get_compare_diff`'s full
1588
+ unified-diff text and parsing ``diff --git a/... b/...`` headers out of
1589
+ it. Uses the GitHub API (not a local checkout) so it works from any host
1590
+ with `gh` on PATH, matching this module's existing checkout-independent
1591
+ diff helpers (:func:`pr_diff`, :func:`get_compare_diff`) rather than the
1592
+ coordinator assuming it has a local clone of every dispatched repo.
1593
+
1594
+ ``--jq`` on a leaf-string selector (``.filename``, unlike the object
1595
+ selector in :func:`get_repo_milestones`) emits *raw* text, one path per
1596
+ line, not JSON-quoted — so this reads lines directly rather than
1597
+ JSON-decoding them. Returns ``None`` (not ``[]``) on failure so callers
1598
+ can distinguish "no files changed" from "couldn't ask" and fail open
1599
+ accordingly.
1600
+ """
1601
+ try:
1602
+ raw = _gh(
1603
+ "api", f"repos/{repo}/compare/{base}...{head}",
1604
+ "--jq", ".files[].filename",
1605
+ )
1606
+ except RuntimeError:
1607
+ return None
1608
+ return [line for line in (ln.strip() for ln in raw.splitlines()) if line]
1609
+
1610
+
1611
+ def branch_commits_ahead(repo: str, base: str, branch: str) -> int | None:
1612
+ """Commits *branch* is ahead of *base* on the remote, or ``None``.
1613
+
1614
+ #1534: the coordinator usually has no local checkout of a worker's branch,
1615
+ so the zero-commit question has to be asked of GitHub. Uses the same
1616
+ three-dot compare API as :func:`get_branch_diff_size` and reads
1617
+ ``ahead_by`` — no PR required.
1618
+
1619
+ Returns ``None`` (never 0) on any ``gh`` failure, an unparseable payload,
1620
+ or a missing ``ahead_by`` field. Callers MUST treat ``None`` as "unknown,
1621
+ assume non-zero": this exists to *refuse* work on a provably empty branch,
1622
+ and a network blip must not silently become a refusal — that would strand
1623
+ real reviews. This is the opposite polarity from
1624
+ :func:`branch_is_fully_merged`, which returns a plain ``False`` on error
1625
+ because *its* fail-safe direction is "keep the PR open".
1626
+ """
1627
+ if not branch or not base:
1628
+ return None
1629
+ if branch == base:
1630
+ return 0
1631
+ try:
1632
+ raw = _gh("api", f"repos/{repo}/compare/{base}...{branch}")
1633
+ cmp = json.loads(raw)
1634
+ except Exception: # noqa: BLE001 — unknown, not zero
1635
+ return None
1636
+ if not isinstance(cmp, dict):
1637
+ return None
1638
+ ahead = cmp.get("ahead_by")
1639
+ if not isinstance(ahead, int) or isinstance(ahead, bool):
1640
+ return None
1641
+ return ahead
1642
+
1643
+
1644
+ def branch_commits_ahead_for_assignment(assignment: Any, config: Any) -> int | None:
1645
+ """:func:`branch_commits_ahead` for a board *assignment*, or ``None``.
1646
+
1647
+ #1606: `coord retry`'s advisory zero-commit gate
1648
+ (``coord/commands/dispatch.py``'s ``retry()``) and `coord diagnose
1649
+ --stage work`'s ADVISORY-row recovery (``coord/diagnose.py``'s
1650
+ ``_work_advisory_commits_ahead``) both ask GitHub this exact question —
1651
+ they used to do it via two independently-written inline copies of
1652
+ "branch empty -> 0, repo missing -> None, else ask GitHub", which even
1653
+ diverged in how they looked up the repo config (``cfg.repo(name)`` vs.
1654
+ a hand-rolled scan over ``config.repos`` — equivalent, but two copies is
1655
+ how they drift apart later). This is the one copy both now call.
1656
+
1657
+ *assignment* needs only ``.branch`` and ``.repo_name``; *config* needs
1658
+ only ``.repo(name)`` returning an object with ``.github`` /
1659
+ ``.default_branch`` (both ``coord.models.Assignment`` and
1660
+ ``coord.config.Config`` satisfy this — left untyped here to avoid
1661
+ github_ops.py importing either module).
1662
+
1663
+ An assignment with no branch (or a blank one) is treated as 0 commits
1664
+ ahead without ever calling GitHub — there is nothing to compare. A repo
1665
+ that ``config`` doesn't know about returns ``None`` ("cannot confirm"),
1666
+ never a bare 0, matching :func:`branch_commits_ahead`'s own fail-closed
1667
+ polarity: an unconfirmable commit count must never be silently read as
1668
+ "empty branch, safe to touch".
1669
+ """
1670
+ branch = (getattr(assignment, "branch", None) or "").strip()
1671
+ if not branch:
1672
+ return 0
1673
+ repo_cfg = config.repo(assignment.repo_name)
1674
+ if repo_cfg is None:
1675
+ return None
1676
+ base = repo_cfg.default_branch or "main"
1677
+ return branch_commits_ahead(repo_cfg.github, base, branch)
1678
+
1679
+
1680
+ def get_branch_patch_id(repo: str, base: str, branch: str) -> str | None:
1681
+ """Return the content-addressed patch-id for *branch*'s diff against *base*.
1682
+
1683
+ #1475: uses the GitHub three-dot compare API (no PR required, mirroring
1684
+ :func:`get_branch_diff_size`) to fetch the raw unified diff, then hashes
1685
+ it with :func:`compute_patch_id`. Returns ``None`` on any failure — the
1686
+ merge-queue gate treats a missing patch-id as "cannot confirm identical
1687
+ content" and falls back to the pre-#1475 SHA-only staleness check
1688
+ (fail closed).
1689
+ """
1690
+ return compute_patch_id(get_compare_diff(repo, base, branch))
1691
+
1692
+
1693
+ def create_pr(
1694
+ repo: str,
1695
+ *,
1696
+ base: str,
1697
+ head: str,
1698
+ title: str,
1699
+ body: str,
1700
+ ) -> dict:
1701
+ """Open a PR. Returns {number, url}. If one already exists for `head`, returns it."""
1702
+ existing = find_pr_for_branch(repo, head)
1703
+ if existing is not None:
1704
+ return {"number": existing["number"], "url": existing["url"], "existed": True}
1705
+ url = _gh(
1706
+ "pr", "create", "--repo", repo,
1707
+ "--base", base, "--head", head,
1708
+ "--title", title, "--body", body,
1709
+ )
1710
+ # gh pr create returns the URL on the last line of stdout.
1711
+ pr_url = url.strip().splitlines()[-1] if url.strip() else ""
1712
+ number = int(pr_url.rsplit("/", 1)[-1]) if pr_url else 0
1713
+ return {"number": number, "url": pr_url, "existed": False}
1714
+
1715
+
1716
+ def get_pr_size(repo: str, number: int) -> int:
1717
+ """Return additions+deletions for sequencing. 0 on lookup failure."""
1718
+ try:
1719
+ raw = _gh(
1720
+ "pr", "view", str(number), "--repo", repo,
1721
+ "--json", "additions,deletions",
1722
+ )
1723
+ except RuntimeError:
1724
+ return 0
1725
+ data = _json_loads_or(raw, default={})
1726
+ return int(data.get("additions", 0)) + int(data.get("deletions", 0))
1727
+
1728
+
1729
+ def get_branch_diff_size(repo: str, base: str, branch: str) -> int:
1730
+ """Return total diff size (additions+deletions) for *branch* relative to *base*.
1731
+
1732
+ Uses the GitHub three-dot compare API — no PR required. Sums
1733
+ ``additions + deletions`` across all changed files. Returns ``0`` on any
1734
+ failure so callers can treat size as unknown-but-not-blocking.
1735
+
1736
+ Prefer this over :func:`get_pr_size` at enqueue time so size is populated
1737
+ before a PR is opened and the ordering shown to the user matches the
1738
+ ordering used at merge time (#776 size unification).
1739
+ """
1740
+ try:
1741
+ raw = _gh("api", f"repos/{repo}/compare/{base}...{branch}")
1742
+ data = json.loads(raw)
1743
+ return sum(
1744
+ int(f.get("additions", 0)) + int(f.get("deletions", 0))
1745
+ for f in data.get("files", [])
1746
+ )
1747
+ except Exception: # noqa: BLE001 — fail-open: unknown size is not blocking
1748
+ return 0
1749
+
1750
+
1751
+ def merge_pr(repo: str, number: int, method: str = "rebase") -> tuple[bool, str]:
1752
+ """Merge a PR. Returns (success, message).
1753
+
1754
+ Conflict / not-rebaseable cases come back as (False, <gh stderr>). Caller
1755
+ decides whether to retry or surface to the user — we never resolve conflicts
1756
+ here.
1757
+ """
1758
+ flag = {"rebase": "--rebase", "squash": "--squash", "merge": "--merge"}.get(method, "--rebase")
1759
+ try:
1760
+ out = _gh("pr", "merge", str(number), "--repo", repo, flag, "--delete-branch=false")
1761
+ except RuntimeError as e:
1762
+ return False, str(e)
1763
+ return True, out
1764
+
1765
+
1766
+ def list_open_prs(repo: str) -> list[dict]:
1767
+ return _gh_json(
1768
+ "pr", "list", "--repo", repo, "--state", "open",
1769
+ "--json", "number,title,headRefName",
1770
+ default=[],
1771
+ )
1772
+
1773
+
1774
+ def get_recent_develop_commits(repo: str, count: int = 10) -> list[dict]:
1775
+ commits = _gh_json(
1776
+ "api", f"repos/{repo}/commits?sha=develop&per_page={count}",
1777
+ default=[],
1778
+ )
1779
+ return [
1780
+ {"sha": c["sha"][:7], "message": c["commit"]["message"].split("\n")[0]}
1781
+ for c in commits
1782
+ ]
1783
+
1784
+
1785
+ def create_issue(
1786
+ repo: str,
1787
+ title: str,
1788
+ body: str,
1789
+ labels: list[str] | None = None,
1790
+ milestone: str | None = None,
1791
+ ) -> dict:
1792
+ args = ["issue", "create", "--repo", repo, "--title", title, "--body", body]
1793
+ if labels:
1794
+ for label in labels:
1795
+ args.extend(["--label", label])
1796
+ if milestone:
1797
+ args.extend(["--milestone", milestone])
1798
+ raw = _gh(*args)
1799
+ url = raw.strip()
1800
+ number = int(url.rstrip("/").rsplit("/", 1)[-1])
1801
+ return {"number": number, "url": url}
1802
+
1803
+
1804
+ def update_issue_body(repo: str, issue_number: int, body: str) -> None:
1805
+ _gh(
1806
+ "api", "-X", "PATCH",
1807
+ f"repos/{repo}/issues/{issue_number}",
1808
+ "-f", f"body={body}",
1809
+ )
1810
+
1811
+
1812
+ def get_repo_milestones(repo: str, *, state: str = "open") -> list[dict]:
1813
+ """Return milestones for *repo* (open ones by default).
1814
+
1815
+ Each item has at least ``number`` and ``title`` keys, matching the
1816
+ shape returned by the GitHub milestones REST endpoint. Used to resolve a
1817
+ milestone title → number (``coord milestone assign``) without a separate
1818
+ call.
1819
+ """
1820
+ raw = _gh(
1821
+ "api", "--paginate",
1822
+ f"repos/{repo}/milestones?state={state}",
1823
+ "--jq", ".[] | {number: .number, title: .title}",
1824
+ )
1825
+ # --jq emits one JSON object per line when applied to an array. #1353:
1826
+ # a single malformed line used to bare-json.loads() into an unattributable
1827
+ # crash that discarded every other (well-formed) milestone line too — skip
1828
+ # just the bad line instead.
1829
+ results = []
1830
+ for line in raw.splitlines():
1831
+ line = line.strip()
1832
+ if not line:
1833
+ continue
1834
+ parsed = _json_loads_or(line, default=None)
1835
+ if parsed is not None:
1836
+ results.append(parsed)
1837
+ return results
1838
+
1839
+
1840
+ def get_milestone(repo: str, milestone_number: int) -> dict:
1841
+ """Fetch a single milestone by number; returns ``{number, title, ...}``.
1842
+
1843
+ Used to resolve a milestone number → title so the local issues cache
1844
+ ``milestone_title`` column can be populated without listing all milestones.
1845
+ Raises RuntimeError (propagated from ``_gh``) when the milestone does not
1846
+ exist.
1847
+ """
1848
+ return _gh_json("api", f"repos/{repo}/milestones/{milestone_number}", default={})
1849
+
1850
+
1851
+ def get_milestone_issues(
1852
+ repo: str, milestone_title: str, *, state: str = "all"
1853
+ ) -> list[dict]:
1854
+ """Return every issue under *milestone_title* in *repo* (open+closed by default).
1855
+
1856
+ Each item has ``number``, ``title``, ``state`` ("OPEN"/"CLOSED"), and
1857
+ ``labels`` (list of ``{"name": ...}``). ``gh issue list --milestone`` takes
1858
+ the milestone TITLE, not its number (unlike most other milestone-related
1859
+ calls in this module) — matches the existing ``--milestone`` usage in
1860
+ :func:`create_issue`. Used by ``--audit-of`` (#885) to enumerate a
1861
+ milestone's issue states for the audit briefing without a separate call
1862
+ per issue.
1863
+ """
1864
+ return _gh_json(
1865
+ "issue", "list", "--repo", repo, "--milestone", milestone_title,
1866
+ "--state", state, "--json", "number,title,state,labels",
1867
+ "--limit", "200",
1868
+ default=[],
1869
+ )
1870
+
1871
+
1872
+ def assign_issue_milestone(
1873
+ repo: str, issue_number: int, milestone_number: int
1874
+ ) -> None:
1875
+ """Assign *milestone_number* to *issue_number* on *repo* via the GitHub API.
1876
+
1877
+ Uses ``gh api -X PATCH`` with ``-F milestone=<int>`` (capital -F so the
1878
+ value is sent as a JSON integer, as GitHub's REST API requires). Raises
1879
+ RuntimeError on any ``gh`` failure.
1880
+ """
1881
+ _gh(
1882
+ "api", "-X", "PATCH",
1883
+ f"repos/{repo}/issues/{issue_number}",
1884
+ "-F", f"milestone={milestone_number}",
1885
+ )
1886
+
1887
+
1888
+ def unassign_issue_milestone(repo: str, issue_number: int) -> None:
1889
+ """Clear *issue_number*'s milestone on *repo* via the GitHub API (#1003).
1890
+
1891
+ The counterpart to :func:`assign_issue_milestone` — ``-F milestone=null``
1892
+ sends a JSON ``null`` (per ``gh api``'s typed-field convention: literal
1893
+ ``null``/``true``/``false``/numbers are sent as their JSON type, not a
1894
+ string), which GitHub's REST API treats as "remove the milestone".
1895
+ Idempotent — clearing an issue that has no milestone is a no-op on
1896
+ GitHub's side. Raises RuntimeError on any ``gh`` failure.
1897
+ """
1898
+ _gh(
1899
+ "api", "-X", "PATCH",
1900
+ f"repos/{repo}/issues/{issue_number}",
1901
+ "-F", "milestone=null",
1902
+ )
1903
+
1904
+
1905
+ def close_pr(repo: str, number: int, *, comment: str | None = None) -> None:
1906
+ """Close an open PR, optionally posting a comment first.
1907
+
1908
+ Posts *comment* (if given) via ``gh issue comment`` — PRs share the GitHub
1909
+ issue comment stream — then closes the PR via ``gh pr close``. Raises
1910
+ RuntimeError on ``gh`` failure.
1911
+ """
1912
+ if comment:
1913
+ post_issue_comment(repo, number, comment)
1914
+ _gh("pr", "close", str(number), "--repo", repo)
1915
+
1916
+
1917
+ def branch_is_fully_merged(
1918
+ repo: str,
1919
+ branch: str,
1920
+ default_branch: str = "main",
1921
+ ) -> bool:
1922
+ """Return True when *branch* has 0 commits ahead of *default_branch*.
1923
+
1924
+ Uses the GitHub three-dot compare API. Returns False on any error —
1925
+ fail-safe so we never accidentally close a live PR.
1926
+
1927
+ Note: only detects **fast-forward** merges. After a squash or rebase
1928
+ merge the branch's original commits remain "ahead" (different SHAs) even
1929
+ though the work has landed. The ``issue_is_closed`` check is the primary
1930
+ stale-PR signal for those cases.
1931
+ """
1932
+ if not branch or not default_branch or branch == default_branch:
1933
+ return False
1934
+ try:
1935
+ raw = _gh("api", f"repos/{repo}/compare/{default_branch}...{branch}")
1936
+ cmp = json.loads(raw)
1937
+ return isinstance(cmp, dict) and cmp.get("ahead_by") == 0
1938
+ except Exception: # noqa: BLE001 — fail-safe: keep the PR open on any error
1939
+ return False
1940
+
1941
+
1942
+ def post_pr_review(repo: str, number: int, verdict: str, body: str) -> None:
1943
+ """Post a PR review via the gh CLI.
1944
+
1945
+ *verdict* must be ``"approve"`` or ``"request-changes"``. Any other value
1946
+ raises :class:`ValueError` before invoking gh.
1947
+ """
1948
+ if verdict == "approve":
1949
+ flag = "--approve"
1950
+ elif verdict == "request-changes":
1951
+ flag = "--request-changes"
1952
+ else:
1953
+ raise ValueError(f"Invalid review verdict: {verdict!r} (must be 'approve' or 'request-changes')")
1954
+ _gh("pr", "review", str(number), "--repo", repo, flag, "--body", body)