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/merge_queue.py ADDED
@@ -0,0 +1,4681 @@
1
+ """Merge queue: sequence completed assignments into their target branches.
2
+
3
+ Two-layer design so the logic is testable without hitting `gh`:
4
+
5
+ - Data + sequencing live here (pure functions over QueuedMerge).
6
+ - Wire calls (gh pr create / merge / size) are passed in as `gh_ops` so
7
+ tests can substitute a stub. `coord.cli` wires the real `coord.github_ops`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Iterable, NamedTuple, Protocol
18
+
19
+ from coord.audit import record_audit
20
+ from coord.ci_store import (
21
+ CheckRun,
22
+ CiCheckSummary,
23
+ CiStore,
24
+ JobRun,
25
+ NoOpCi,
26
+ checks_are_stale,
27
+ failed_checks,
28
+ in_flight_checks,
29
+ is_verdictless_job,
30
+ summarize,
31
+ summarize_counts,
32
+ )
33
+ from coord.db import get_connection
34
+ from coord.models import CLOSES_ISSUE_TYPES, WORK_LIKE_TYPES, Assignment
35
+ from coord.pr_body_lint import downgrade_closing_keywords, find_closing_references
36
+ from coord.state import COORD_DIR, dismiss_drive_escalation
37
+
38
+ _log = logging.getLogger(__name__)
39
+
40
+ # Legacy path constant — kept for backward compat with monkeypatch calls in tests.
41
+ QUEUE_FILE = COORD_DIR / "merge_queue.json"
42
+
43
+ # States
44
+ PENDING = "pending"
45
+ MERGING = "merging"
46
+ MERGED = "merged"
47
+ CONFLICT = "conflict"
48
+ SKIPPED = "skipped"
49
+ # Set on a merge entry whose conflict-fix attempt also failed — the user must
50
+ # resolve the conflict by hand. See #241.
51
+ HUMAN_REQUIRED = "human_required"
52
+
53
+
54
+ # ── Conflict classification ─────────────────────────────────────────────────
55
+
56
+ # #1467: the specific subset of GitHub wording that means a --rebase merge
57
+ # was refused purely because the branch contains a merge commit — a
58
+ # *linearity* failure, not a content conflict. This distinction matters for
59
+ # reconcile_conflict_entries: GitHub's `mergeable` field (what
60
+ # check_pr_mergeable reads) only reflects content conflicts and happily
61
+ # reports MERGEABLE for a branch that is clean but not rebase-able, so a
62
+ # plain mergeable check is not evidence that a retried --rebase will
63
+ # succeed. See is_rebase_refusal(). Defined once here and folded into
64
+ # _REBASEABLE_SIGNALS below so the two lists can't drift apart.
65
+ _REBASE_REFUSAL_SIGNALS = (
66
+ "can't be rebased",
67
+ "cannot be rebased",
68
+ )
69
+
70
+ _REBASEABLE_SIGNALS = (
71
+ "could not be rebased",
72
+ # #1467: GitHub's actual wording when a branch contains a merge commit
73
+ # — distinct from "could not be rebased" above (which never matched it)
74
+ # and previously fell through to "unknown", so #241's conflict-fix
75
+ # worker was never dispatched and the entry parked forever. A local
76
+ # `git rebase origin/main` linearises the branch, which is exactly what
77
+ # the dispatched conflict-fix worker attempts.
78
+ *_REBASE_REFUSAL_SIGNALS,
79
+ "merge conflict",
80
+ "not up to date",
81
+ "non-fast-forward",
82
+ "behind the base branch",
83
+ # `gh pr merge` returns this when the PR is behind base and a rebase
84
+ # would be needed. Common on PRs that sat open while main moved.
85
+ "merge commit cannot be cleanly created",
86
+ "not mergeable",
87
+ )
88
+
89
+ _HUMAN_SIGNALS = (
90
+ "required status check",
91
+ "review required",
92
+ "permission",
93
+ "protected branch",
94
+ "branch protection",
95
+ )
96
+
97
+
98
+ def classify_conflict(error: str | None) -> str:
99
+ """Decide what kind of merge failure ``error`` represents.
100
+
101
+ Returns ``"rebaseable"`` (a mechanical rebase conflict an agent can
102
+ attempt), ``"human"`` (permission / branch protection — surface to the
103
+ user), or ``"unknown"`` (don't auto-dispatch; let the user inspect).
104
+
105
+ Used by ``coord merge`` (#241) to decide whether to spawn a
106
+ ``type="conflict-fix"`` assignment or surface the failure as-is.
107
+ """
108
+ if not error:
109
+ return "unknown"
110
+ text = error.lower()
111
+ if any(sig in text for sig in _HUMAN_SIGNALS):
112
+ return "human"
113
+ if any(sig in text for sig in _REBASEABLE_SIGNALS):
114
+ return "rebaseable"
115
+ return "unknown"
116
+
117
+
118
+ def is_rebase_refusal(error: str | None) -> bool:
119
+ """True when ``error`` is specifically GitHub's "branch can't be
120
+ rebased" refusal — a merge commit on the branch, not a content
121
+ conflict (#1467).
122
+
123
+ Narrower than ``classify_conflict(error) == "rebaseable"``, which also
124
+ matches ordinary content conflicts ("merge conflict", "not mergeable",
125
+ …) that GitHub's own ``mergeable`` field already reports accurately.
126
+ This predicate isolates the one failure mode where ``mergeable:
127
+ MERGEABLE`` is *not* proof a retried ``--rebase`` will succeed, so
128
+ :func:`reconcile_conflict_entries` and the ``coord merge`` CLI can treat
129
+ it differently from a plain conflict.
130
+ """
131
+ if not error:
132
+ return False
133
+ text = error.lower()
134
+ return any(sig in text for sig in _REBASE_REFUSAL_SIGNALS)
135
+
136
+
137
+ # ── Work-chain resolution (#567) ────────────────────────────────────────────
138
+
139
+ def _chain_work_ids(entry: "QueuedMerge", pool: list) -> set[str]:
140
+ """Collect every work-assignment id connected to *entry*: by branch
141
+ equality (pre-#567 behaviour) **or** by the ``review_of_assignment_id``
142
+ linkage a bounce-fix worker records back to the assignment it fixes.
143
+
144
+ #567: a fix worker dispatched under the #557 remote-interactive-rework
145
+ gap has ``branch=NULL``, so it never matches ``branch == entry.branch``
146
+ and a verdict recorded on it is invisible to ``has_approved_review`` /
147
+ ``has_smoke_verdict``. Every ``WORK_LIKE_TYPES`` assignment dispatched as
148
+ a fix records ``review_of_assignment_id`` pointing at the assignment it
149
+ fixes (``auto_loop.py`` fix dispatch), so the chain is reconstructable
150
+ without a branch match. Expansion runs to a fixed point so multi-hop
151
+ bounce chains (a fix of a fix) are fully covered, not just one hop.
152
+
153
+ #1601: the walk used to be forward-only — a known PARENT pulled in its
154
+ CHILD (the fix round), but not the reverse. An entry keyed to the child
155
+ (e.g. the fix round's own approved re-review, per #292 Defect 2's
156
+ re-keying) could not walk *backward* to reach the parent's still-useful
157
+ fields (its ``test_state``/``smoke_test`` verdict, when the fix round
158
+ never re-ran one) whenever branch equality alone didn't already bridge
159
+ the two — the same ``branch=NULL`` gap #567 fixed for the forward
160
+ direction. The expansion is now symmetric: a known row pulls in both its
161
+ recorded children AND its own ``review_of_assignment_id`` parent.
162
+ """
163
+ work_ids: set[str] = set()
164
+ if entry.assignment_id:
165
+ work_ids.add(entry.assignment_id)
166
+
167
+ work_assignments = [a for a in pool if getattr(a, "type", None) in WORK_LIKE_TYPES]
168
+
169
+ # Branch equality — the original (#292) expansion.
170
+ for a in work_assignments:
171
+ aid = getattr(a, "assignment_id", None)
172
+ branch = getattr(a, "branch", None)
173
+ if aid and branch and branch == entry.branch:
174
+ work_ids.add(aid)
175
+
176
+ # review_of_assignment_id chain — covers fix workers with branch=NULL,
177
+ # and multi-iteration bounce chains via a fixed-point expansion. Runs in
178
+ # BOTH directions (#1601) so the chain is the same set regardless of
179
+ # which round in it the entry happens to be keyed to.
180
+ changed = True
181
+ while changed:
182
+ changed = False
183
+ for a in work_assignments:
184
+ aid = getattr(a, "assignment_id", None)
185
+ parent = getattr(a, "review_of_assignment_id", None)
186
+ # Forward: a known parent pulls in its child.
187
+ if aid and parent in work_ids and aid not in work_ids:
188
+ work_ids.add(aid)
189
+ changed = True
190
+ # Backward (#1601): a known child pulls in its own parent.
191
+ if aid and aid in work_ids and parent and parent not in work_ids:
192
+ work_ids.add(parent)
193
+ changed = True
194
+
195
+ return work_ids
196
+
197
+
198
+ # ── Branch winner resolution (#1490) ────────────────────────────────────────
199
+ #
200
+ # A fix/bounce cycle dispatches a fresh WORK_LIKE_TYPES assignment for every
201
+ # retry, and every one of them keeps its row in `board.completed` forever —
202
+ # all targeting the same branch. `enqueue_approved_work` (the daemon tick)
203
+ # and `coord merge`'s own auto-enqueue scan both used to process every such
204
+ # row independently and hand each one to `refresh_entry_assignment`, which
205
+ # re-keys the ONE queue row that exists for the branch to whichever
206
+ # assignment_id it was just called with. Processing three rows on one
207
+ # branch in a single pass therefore re-keyed the same entry three times in
208
+ # a row and printed three "auto-enqueued" lines for what is — and always
209
+ # was — a single queue entry; because the gates
210
+ # (`passes_merge_gates`/`has_approved_review`/`has_smoke_verdict`) are
211
+ # resolved over the whole branch chain rather than the specific row passed
212
+ # in, even the row with a *failed* test_state would pass the gate and win a
213
+ # later iteration's re-key, so the "current" key flip-flopped across every
214
+ # row on every single tick, forever (#1490's observed bug).
215
+ #
216
+ # The fix: resolve every branch to a single winner *before* touching the
217
+ # queue at all, and never enqueue (or re-announce) the other rows.
218
+
219
+
220
+ def _select_winning_work_assignment(work_assignments: list) -> "Assignment":
221
+ """Pick the one row in *work_assignments* — all sharing one branch —
222
+ that should key the branch's merge-queue entry.
223
+
224
+ Prefers the most-recently-dispatched row that already carries a fresh
225
+ terminal smoke verdict (``test_state in ('passed', 'skipped')``) — the
226
+ "approved + test-passed" row the issue asks the queue entry to track.
227
+ Falls back to the most-recently-dispatched row overall when none has
228
+ passed yet (the branch is still mid-cycle; it should still enqueue —
229
+ blocked on the smoke gate — rather than vanish). Ties on
230
+ ``dispatched_at`` (including everything being ``None``, e.g. rows from
231
+ tests or pre-#821 data) resolve to the last one in *work_assignments*
232
+ (typically ``board.completed`` insertion order, i.e. the most recently
233
+ seen row), same tie-break convention as :func:`resolve_entry_key`.
234
+ """
235
+ def _dispatched_at(a) -> float:
236
+ return getattr(a, "dispatched_at", None) or 0
237
+
238
+ passed = [
239
+ a for a in work_assignments
240
+ if getattr(a, "test_state", None) in ("passed", "skipped")
241
+ ]
242
+ pool = passed if passed else work_assignments
243
+ winner = pool[0]
244
+ for a in pool[1:]:
245
+ if _dispatched_at(a) >= _dispatched_at(winner):
246
+ winner = a
247
+ return winner
248
+
249
+
250
+ def group_branch_candidates(completed: Iterable) -> list[tuple["Assignment", list]]:
251
+ """Group every done :data:`~coord.models.WORK_LIKE_TYPES` assignment in
252
+ *completed* by ``(repo_name, branch)`` and resolve each group to a
253
+ single winner (#1490).
254
+
255
+ Returns one ``(winner, superseded)`` pair per distinct ``(repo_name,
256
+ branch)`` group, in first-seen order (stable — output doesn't jitter
257
+ run to run). ``superseded`` holds the group's other rows (``[]`` when
258
+ there was only one); callers must log them and never enqueue them —
259
+ see :func:`_select_winning_work_assignment` for how the winner is
260
+ chosen.
261
+
262
+ Rows missing ``branch``/``assignment_id``, not in ``WORK_LIKE_TYPES``,
263
+ or not ``status == "done"`` are dropped from consideration entirely —
264
+ the same ad-hoc filter both call sites (`enqueue_approved_work`, the
265
+ ``coord merge`` auto-enqueue scan) applied before this was extracted.
266
+ """
267
+ order: list[tuple[str, str]] = []
268
+ groups: dict[tuple[str, str], list] = {}
269
+ for a in completed:
270
+ if getattr(a, "type", None) not in WORK_LIKE_TYPES:
271
+ continue
272
+ if getattr(a, "status", None) != "done":
273
+ continue
274
+ branch = getattr(a, "branch", None)
275
+ aid = getattr(a, "assignment_id", None)
276
+ if not branch or not aid:
277
+ continue
278
+ key = (getattr(a, "repo_name", None), branch)
279
+ if key not in groups:
280
+ groups[key] = []
281
+ order.append(key)
282
+ groups[key].append(a)
283
+
284
+ result: list[tuple["Assignment", list]] = []
285
+ for key in order:
286
+ rows = groups[key]
287
+ winner = rows[0] if len(rows) == 1 else _select_winning_work_assignment(rows)
288
+ superseded = [r for r in rows if r is not winner]
289
+ result.append((winner, superseded))
290
+ return result
291
+
292
+
293
+ def _log_superseded(row) -> None:
294
+ """One clear line per row a branch-winner scan skipped (#1490) — so
295
+ "three rows, one queue entry" reads as expected coalescing rather than
296
+ "two got lost"."""
297
+ _log.info(
298
+ "merge-queue: %s#%s assignment %s (branch %s) superseded on this "
299
+ "branch — not enqueued",
300
+ getattr(row, "repo_name", None),
301
+ getattr(row, "issue_number", None),
302
+ getattr(row, "assignment_id", None),
303
+ getattr(row, "branch", None),
304
+ )
305
+
306
+
307
+ # ── Review gate (#253) ──────────────────────────────────────────────────────
308
+
309
+ def requires_review(entry: "QueuedMerge", config) -> bool:
310
+ """True when *entry* must have an approved review before merging.
311
+
312
+ Honours ``config.reviews.enabled`` (the master switch for the
313
+ adversarial review feature) and the *effective* gate list: ``entry``'s
314
+ own ``required_gates`` when set, falling back to
315
+ ``config.pipeline.default_gates`` otherwise (#1213). ``entry`` is
316
+ duck-typed — both ``QueuedMerge`` (``required_gates`` snapshotted at
317
+ :func:`enqueue` time, commit-bound) and ``Assignment`` (``required_gates``
318
+ resolved from ``config.pipeline.labels`` at dispatch time, see
319
+ :func:`coord.brain.resolve_required_gates`) carry the attribute, so
320
+ ``coord.merge_queue.plan`` can pass either. Untagged work — the entry
321
+ has no override — behaves exactly as before this change: the default
322
+ policy applies. Explicit-only overrides (``--skip-review``) remain
323
+ available as a manual escape hatch on top of this.
324
+ """
325
+ if not getattr(config, "reviews", None) or not config.reviews.enabled:
326
+ return False
327
+ pipeline = getattr(config, "pipeline", None)
328
+ if pipeline is None:
329
+ return True
330
+ gates = getattr(entry, "required_gates", None) or (pipeline.default_gates or [])
331
+ return "review" in gates
332
+
333
+
334
+ def _backfill_branch_patch_id(entry: "QueuedMerge", gh_ops: "GhOps | None") -> str | None:
335
+ """Return ``entry.branch_patch_id``, computing and persisting it via
336
+ *gh_ops* when null, or ``None`` when it can't be determined.
337
+
338
+ #1506: ``entry.branch_patch_id`` is normally populated by :func:`process`
339
+ before the review/smoke gates run, but any entry that reaches
340
+ :func:`has_approved_review` / :func:`find_scoped_review_candidate`
341
+ without having gone through that backfill first — most notably every
342
+ queue row whose approved review predates #1475, which never got a
343
+ chance to backfill it — has ``branch_patch_id: None`` forever, and a
344
+ null there previously meant "cannot prove identical", voiding an
345
+ approval for a diff that had not changed by one byte.
346
+
347
+ The base passed is *entry.target_branch* — a branch **name**, resolved
348
+ by GitHub's three-dot compare API (:func:`coord.github_ops.
349
+ get_branch_patch_id`) to the true merge-base of the two refs — never the
350
+ PR's recorded ``baseRefOid``. Using ``baseRefOid`` produces a false
351
+ mismatch once the base branch has advanced past the PR's original fork
352
+ point (#1506's investigation hit exactly this).
353
+
354
+ ``gh_ops=None`` (no client available) or a missing repo/base/branch on
355
+ *entry* returns ``None`` without any I/O — callers fail closed exactly as
356
+ before. A successful computation is written back onto *entry* so the
357
+ ``gh api compare`` round trip happens at most once per entry; the caller
358
+ is responsible for persisting the entry (e.g. ``save_queue``) same as
359
+ the existing ``branch_head_sha``/``branch_patch_id`` backfills in
360
+ :func:`process`.
361
+ """
362
+ if gh_ops is None:
363
+ return None
364
+ repo = getattr(entry, "repo_github", None)
365
+ base = getattr(entry, "target_branch", None)
366
+ branch = getattr(entry, "branch", None)
367
+ if not repo or not base or not branch:
368
+ return None
369
+ try:
370
+ computed = gh_ops.get_branch_patch_id(repo, base, branch)
371
+ except Exception: # noqa: BLE001 — fail-safe: unknown patch-id is not blocking
372
+ return None
373
+ if computed is not None:
374
+ try:
375
+ entry.branch_patch_id = computed
376
+ except Exception: # noqa: BLE001 — best effort; a read-only entry just recomputes next time
377
+ pass
378
+ return computed
379
+
380
+
381
+ class ApprovalScan(NamedTuple):
382
+ """The result of one walk over *board*'s approving reviews for an entry.
383
+
384
+ #2085/#2096: two surfaces ask about the same walk and must not answer it
385
+ with two implementations that merely agree today —
386
+
387
+ - ``approved`` — the merge gate's verdict. :func:`has_approved_review`
388
+ is exactly this field, so every gate caller is unchanged.
389
+ - ``unknown_head`` — True when an approving review was refused *only*
390
+ because the entry's branch head SHA was unknown to this caller
391
+ (``branch_head_sha is None``), rather than because it was confirmed
392
+ to differ from ``review_head_sha``. The two are indistinguishable in
393
+ a bool, but they mean opposite things to a read-only display:
394
+ "confirmed superseded" vs "not yet checked". :func:`display_error`
395
+ needs that distinction (see its docstring); the gate deliberately
396
+ does not — both fail closed there.
397
+ """
398
+
399
+ approved: bool
400
+ unknown_head: bool
401
+
402
+
403
+ def has_approved_review(
404
+ entry: "QueuedMerge", board, gh_ops: "GhOps | None" = None
405
+ ) -> bool:
406
+ """True when a completed review with ``review_verdict='approve'`` exists
407
+ on *board* for the work assignment behind *entry*.
408
+
409
+ Thin wrapper over :func:`scan_approved_reviews` — this is the merge
410
+ gate's verdict and nothing else. See that function for the walk itself.
411
+
412
+ Scans both active and completed assignments — a review whose findings
413
+ were just posted may still be on ``board.active`` for a tick before
414
+ reconcile moves it to ``completed``. We accept either, since the
415
+ verdict is what matters.
416
+
417
+ #292 (Defect 1): after a review bounce the queue entry may be keyed to
418
+ the *original* work assignment while the approved re-review is linked to
419
+ the *fix* work assignment. To handle this we collect **all** work
420
+ assignment IDs connected to the entry — by shared branch, or (#567) by
421
+ the ``review_of_assignment_id`` chain, which also catches fix workers
422
+ dispatched with ``branch=NULL`` — and accept any approved review that
423
+ points to any of them.
424
+
425
+ #1475: a SHA mismatch alone no longer voids the approval outright. When
426
+ the branch's current content-addressed patch-id (``branch_patch_id``)
427
+ matches the patch-id captured at review time (``review_patch_id``), the
428
+ SHA moved but the diff didn't — e.g. a conflict-fix rebase that resolved
429
+ cleanly — so the approval still covers this content. Missing either
430
+ patch-id fails closed to the pre-#1475 behaviour (stale, re-review) —
431
+ UNLESS *gh_ops* is supplied, in which case a null ``branch_patch_id`` is
432
+ computed on demand (#1506) rather than treated as an unrecoverable
433
+ mismatch; see :func:`_backfill_branch_patch_id`.
434
+ """
435
+ return scan_approved_reviews(entry, board, gh_ops).approved
436
+
437
+
438
+ def scan_approved_reviews(
439
+ entry: "QueuedMerge", board, gh_ops: "GhOps | None" = None
440
+ ) -> ApprovalScan:
441
+ """Walk *board* for an approving review covering *entry*'s work chain.
442
+
443
+ The single implementation behind both :func:`has_approved_review` (the
444
+ merge gate) and :func:`display_error`'s read-only recompute — see
445
+ :class:`ApprovalScan` for why the second one needs more than the bool.
446
+ """
447
+ pool = list(getattr(board, "completed", []) or []) + list(getattr(board, "active", []) or [])
448
+
449
+ branch_work_ids = _chain_work_ids(entry, pool)
450
+
451
+ if not branch_work_ids:
452
+ return ApprovalScan(approved=False, unknown_head=False)
453
+
454
+ # #821: commit-bound check. If the entry has a branch_head_sha (set at
455
+ # process() time from the live branch tip) and the review has a
456
+ # review_head_sha (set when the review assignment ran), an approval only
457
+ # counts when the two SHAs match — i.e. no new commits were pushed after
458
+ # the review completed. When the review has no review_head_sha at all
459
+ # (pre-#821 rows, SHA tracking never available for that verdict) the
460
+ # check is skipped — nothing to compare against, unchanged from before
461
+ # #821 existed.
462
+ #
463
+ # #2085: a review WITH a review_head_sha but a caller that cannot supply
464
+ # entry.branch_head_sha (a raw work Assignment — no such attribute; a
465
+ # QueuedMerge whose branch was deleted — attribute present but None) is
466
+ # the opposite case and must fail CLOSED, not skip the check. Before
467
+ # #2085 `current_sha is None` fell through to the same "return True" as
468
+ # "review predates SHA tracking", so any caller that didn't populate
469
+ # branch_head_sha (the board's stage projection, `enqueue_approved_work`,
470
+ # a queue row whose branch had just been deleted) silently accepted a
471
+ # superseded approval — see #2085 for the observed #1966 chain and the
472
+ # deleted-branch READY flip this produced.
473
+ current_sha = getattr(entry, "branch_head_sha", None)
474
+ current_patch_id = getattr(entry, "branch_patch_id", None)
475
+ patch_id_attempted = current_patch_id is not None
476
+ # #2085: set when an approval is refused *purely* because `current_sha`
477
+ # is unknown here — see `ApprovalScan.unknown_head`.
478
+ unknown_head = False
479
+
480
+ for a in pool:
481
+ if getattr(a, "type", None) != "review":
482
+ continue
483
+ if getattr(a, "review_of_assignment_id", None) not in branch_work_ids:
484
+ continue
485
+ if getattr(a, "review_verdict", None) != "approve":
486
+ continue
487
+ review_sha = getattr(a, "review_head_sha", None)
488
+ # #2085: `current_sha is None` (branch head unknown to this caller)
489
+ # is folded into the same "not confirmed fresh" branch as an actual
490
+ # mismatch — previously it skipped straight to `return True` below,
491
+ # which is exactly the fail-open gap #2085 documents. A review with
492
+ # no `review_head_sha` at all (the `review_sha is not None` guard)
493
+ # still takes the legacy no-SHA-to-compare path unchanged.
494
+ if review_sha is not None and (current_sha is None or review_sha != current_sha):
495
+ # #1475: the SHA moved (or is unknown) — before declaring the
496
+ # approval stale/unconfirmed, check whether the underlying
497
+ # content is identical via patch-id. A pure rebase (no conflict)
498
+ # replays the identical diff against a new base and produces the
499
+ # same patch-id even though the commit SHA changed; a conflict
500
+ # resolution or a genuine content change produces a different
501
+ # one. Fail closed when either patch-id is unavailable — which
502
+ # it always is when *entry* has no way to supply one (e.g. a raw
503
+ # work Assignment with no `branch_patch_id` attribute and
504
+ # *gh_ops* is ``None``), so a caller with no live SHA/patch-id
505
+ # access correctly can never confirm freshness and falls to
506
+ # `continue` → ``False``.
507
+ review_patch_id = getattr(a, "review_patch_id", None)
508
+ if review_patch_id is not None:
509
+ if current_patch_id is None and not patch_id_attempted:
510
+ # #1506: compute-once, not fail-closed-forever.
511
+ current_patch_id = _backfill_branch_patch_id(entry, gh_ops)
512
+ patch_id_attempted = True
513
+ if current_patch_id is not None and review_patch_id == current_patch_id:
514
+ # content-identical rebase — approval still covers it
515
+ return ApprovalScan(approved=True, unknown_head=False)
516
+ if current_sha is None:
517
+ # Refused because we don't KNOW the head, not because we
518
+ # checked and it moved. Same (closed) gate verdict, but a
519
+ # display surface must not call this "not approved".
520
+ unknown_head = True
521
+ continue # stale/unconfirmed: cannot prove this approval covers the current head
522
+ return ApprovalScan(approved=True, unknown_head=False)
523
+ return ApprovalScan(approved=False, unknown_head=unknown_head)
524
+
525
+
526
+ def find_scoped_review_candidate(
527
+ entry: "QueuedMerge", board, gh_ops: "GhOps | None" = None
528
+ ) -> Assignment | None:
529
+ """Return the previously-approved review whose approval was voided
530
+ ONLY by a content-changing rebase (#1476), or ``None``.
531
+
532
+ Mirrors :func:`has_approved_review`'s SHA/patch-id staleness walk but
533
+ returns the review :class:`~coord.models.Assignment` itself (not a
534
+ bool) — a scoped re-review needs the prior review's ``review_head_sha``
535
+ (the base to diff the resolution from) and ``briefing``/findings as
536
+ established context, not just a yes/no.
537
+
538
+ Returns ``None`` — meaning "not this path, fall back to a full review"
539
+ — when:
540
+
541
+ - No approved review exists for *entry*'s work chain at all.
542
+ - The branch's current SHA isn't known (can't confirm anything changed),
543
+ or the current patch-id isn't known and can't be computed (#1506: when
544
+ *gh_ops* is supplied, a null ``branch_patch_id`` is backfilled on
545
+ demand via :func:`_backfill_branch_patch_id` instead of failing
546
+ immediately).
547
+ - The most-recently-matched approved review's SHA still matches the
548
+ current one (nothing changed — not stale at all).
549
+ - Its patch-id still matches the current one (content-identical
550
+ rebase — :func:`has_approved_review` already carries this forward,
551
+ there is no delta to scope a review around).
552
+ - Either patch-id is missing (fail closed, same posture as
553
+ ``has_approved_review``: an unconfirmable diff gets a full review,
554
+ never a guessed-at scoped one).
555
+ """
556
+ pool = list(getattr(board, "completed", []) or []) + list(getattr(board, "active", []) or [])
557
+ branch_work_ids = _chain_work_ids(entry, pool)
558
+ if not branch_work_ids:
559
+ return None
560
+
561
+ current_sha = getattr(entry, "branch_head_sha", None)
562
+ current_patch_id = getattr(entry, "branch_patch_id", None)
563
+ if current_patch_id is None:
564
+ current_patch_id = _backfill_branch_patch_id(entry, gh_ops)
565
+ if current_sha is None or current_patch_id is None:
566
+ return None
567
+
568
+ # Walk most-recently-dispatched first so a branch that's been through
569
+ # more than one review-then-rebase cycle picks its latest approval as
570
+ # the diff base, not an older one — a stale pick still produces a safe
571
+ # (over-inclusive, never under-inclusive) delta, but a needlessly large
572
+ # one. ``pool`` is otherwise unordered (completed + active concatenated).
573
+ ordered = sorted(pool, key=lambda a: getattr(a, "dispatched_at", None) or 0, reverse=True)
574
+
575
+ for a in ordered:
576
+ if getattr(a, "type", None) != "review":
577
+ continue
578
+ if getattr(a, "review_of_assignment_id", None) not in branch_work_ids:
579
+ continue
580
+ if getattr(a, "review_verdict", None) != "approve":
581
+ continue
582
+ review_sha = getattr(a, "review_head_sha", None)
583
+ if review_sha is None or review_sha == current_sha:
584
+ continue # not stale, or SHA tracking unavailable — not this path
585
+ review_patch_id = getattr(a, "review_patch_id", None)
586
+ if review_patch_id is None:
587
+ continue # fail closed — cannot confirm scope, full review
588
+ if review_patch_id == current_patch_id:
589
+ continue # content-identical — has_approved_review already covers it
590
+ return a # approval voided ONLY by a content-changing rebase
591
+ return None
592
+
593
+
594
+ def intervening_work_since_review(
595
+ entry: "QueuedMerge", board, review: Assignment
596
+ ) -> list[Assignment]:
597
+ """Return the :data:`~coord.models.WORK_LIKE_TYPES` assignments in
598
+ *entry*'s branch chain that were **dispatched after** *review* was — i.e.
599
+ genuine new commits (a bounce/fix round, a fresh work dispatch), not a
600
+ mechanical rebase.
601
+
602
+ Extracted from :func:`only_conflict_fix_since_review` so callers that need
603
+ to distinguish its two distinct "False" reasons can do so: a non-empty
604
+ list means "another commit landed after the approval" (never reaffirmable
605
+ without a re-review), whereas an empty list plus a ``False`` from
606
+ ``only_conflict_fix_since_review`` merely means "no coord-tracked
607
+ conflict-fix explains the delta" (e.g. the operator rebased by hand) —
608
+ unattributable, but not evidence of new logic. ``#1488``'s
609
+ ``coord review-reaffirm`` hard-refuses the former and warns loudly on the
610
+ latter; the automated dispatcher (``#1476``) declines both.
611
+
612
+ Dispatch order, not completion order, is compared — see
613
+ :func:`only_conflict_fix_since_review` for why.
614
+ """
615
+ pool = list(getattr(board, "completed", []) or []) + list(getattr(board, "active", []) or [])
616
+ branch_work_ids = _chain_work_ids(entry, pool)
617
+ review_dispatched_at = getattr(review, "dispatched_at", None)
618
+ if review_dispatched_at is None:
619
+ return []
620
+
621
+ out: list[Assignment] = []
622
+ for a in pool:
623
+ if getattr(a, "type", None) not in WORK_LIKE_TYPES:
624
+ continue
625
+ if getattr(a, "assignment_id", None) not in branch_work_ids:
626
+ continue
627
+ a_dispatched_at = getattr(a, "dispatched_at", None)
628
+ if a_dispatched_at is not None and a_dispatched_at > review_dispatched_at:
629
+ out.append(a)
630
+ return out
631
+
632
+
633
+ def only_conflict_fix_since_review(entry: "QueuedMerge", board, review: Assignment) -> bool:
634
+ """True when the sole thing that changed *entry*'s branch since *review*
635
+ approved it was one or more successful conflict-fix rebases (#1476's
636
+ scoping guardrail) — i.e. a scoped review is safe to dispatch.
637
+
638
+ False (⇒ the caller must fall back to a full review) when:
639
+
640
+ - No successful (``status="done"``) conflict-fix for this merge entry is
641
+ found at all — there is nothing to attribute the content change to,
642
+ and guessing would be unsound.
643
+ - Any other :data:`~coord.models.WORK_LIKE_TYPES` assignment in the
644
+ branch's work chain (a fix/bounce round, a fresh work dispatch — i.e.
645
+ a genuine new commit, not a rebase) was dispatched after *review* ran.
646
+
647
+ Dispatch order, not completion order, is what's compared against
648
+ *review*'s own dispatch time — a fix round that was *in flight* when the
649
+ review was dispatched (and so is exactly what the review covered) must
650
+ not itself disqualify the scoped path; only a fix/work round that
651
+ started **after** the approval counts as "another commit".
652
+ """
653
+ if intervening_work_since_review(entry, board, review):
654
+ return False # a new work/fix round happened — not conflict-fix-only
655
+
656
+ pool = list(getattr(board, "completed", []) or []) + list(getattr(board, "active", []) or [])
657
+ review_dispatched_at = getattr(review, "dispatched_at", None)
658
+
659
+ for a in pool:
660
+ if getattr(a, "type", None) != "conflict-fix":
661
+ continue
662
+ if getattr(a, "review_of_assignment_id", None) != entry.assignment_id:
663
+ continue
664
+ if getattr(a, "status", None) != "done":
665
+ continue
666
+ a_dispatched_at = getattr(a, "dispatched_at", None)
667
+ if (
668
+ review_dispatched_at is not None
669
+ and a_dispatched_at is not None
670
+ and a_dispatched_at < review_dispatched_at
671
+ ):
672
+ continue # a conflict-fix from BEFORE this review isn't relevant
673
+ return True
674
+ return False
675
+
676
+
677
+ # ── Smoke gate (#465) ──────────────────────────────────────────────────────
678
+
679
+ def requires_smoke(entry: "QueuedMerge", config) -> bool:
680
+ """True when *entry* must have an interactive smoke verdict before merging.
681
+
682
+ Honours the *effective* gate list — ``entry``'s own ``required_gates``
683
+ when set, falling back to ``config.pipeline.default_gates`` otherwise
684
+ (#1213; see :func:`requires_review` for the duck-typing/fallback
685
+ contract shared by both gates). When ``"test"`` is in the resolved
686
+ gate list the user must record ``coord test --passed`` (or ``--skip``)
687
+ before ``coord merge`` proceeds. ``"test"`` absent → gate disabled.
688
+ """
689
+ pipeline = getattr(config, "pipeline", None)
690
+ if pipeline is None:
691
+ return False
692
+ gates = getattr(entry, "required_gates", None) or (pipeline.default_gates or [])
693
+ return "test" in gates
694
+
695
+
696
+ # ── Gate-bypass auditing (#1213) ────────────────────────────────────────────
697
+
698
+ def _bypassed_gates(entry: "QueuedMerge", config) -> list[str]:
699
+ """Which of the default pipeline's gates *entry*'s resolved gate list
700
+ drops.
701
+
702
+ Returns ``[]`` when ``entry`` carries no override (``required_gates``
703
+ empty/absent — falls back to ``config.pipeline.default_gates``, nothing
704
+ to bypass) or when its resolved gates already match the default list.
705
+ Only ``"review"`` and ``"test"`` are reported — ``"merge"`` is the
706
+ terminal action being gated, not a checkpoint that can be "bypassed".
707
+
708
+ ``"review"`` is reported only when ``config.reviews.enabled`` is truthy
709
+ — mirroring the guard :func:`requires_review` applies first. When review
710
+ is globally disabled, dropping ``"review"`` from a label's resolved gate
711
+ list changes nothing (the gate was already off), so it isn't a real
712
+ bypass and reporting it would produce a misleading audit row / CLI note
713
+ (#1213 review finding 1).
714
+ """
715
+ gates = getattr(entry, "required_gates", None)
716
+ if not gates:
717
+ return []
718
+ pipeline = getattr(config, "pipeline", None) if config is not None else None
719
+ default_gates = list(getattr(pipeline, "default_gates", None) or []) if pipeline else []
720
+ reviews_enabled = bool(getattr(config, "reviews", None)) and bool(
721
+ getattr(config.reviews, "enabled", True)
722
+ )
723
+ candidates = [g for g in ("review", "test") if g in default_gates and g not in gates]
724
+ if not reviews_enabled:
725
+ candidates = [g for g in candidates if g != "review"]
726
+ return candidates
727
+
728
+
729
+ def _bypass_label(entry: "QueuedMerge", config) -> str | None:
730
+ """Best-effort reverse lookup of the ``pipeline.labels`` key that
731
+ produced *entry*'s resolved ``required_gates``, for a readable audit
732
+ row / CLI message.
733
+
734
+ Returns ``None`` when no exact match is found (the label was renamed or
735
+ removed from config after enqueue time, or ``pipeline.labels`` is
736
+ empty) — the audit event and CLI note still fire without a name in that
737
+ case, since the gate list itself is the durable evidence. Ambiguous
738
+ when two labels resolve to the same gate list — the first match (dict
739
+ iteration order) wins; this is display-only and never affects gate
740
+ enforcement.
741
+ """
742
+ pipeline = getattr(config, "pipeline", None) if config is not None else None
743
+ labels = getattr(pipeline, "labels", None) if pipeline else None
744
+ gates = getattr(entry, "required_gates", None)
745
+ if not labels or not gates:
746
+ return None
747
+ for label, label_gates in labels.items():
748
+ if list(label_gates) == list(gates):
749
+ return label
750
+ return None
751
+
752
+
753
+ def _bypass_note(entry: "QueuedMerge", config) -> str:
754
+ """Human-readable suffix naming any bypassed gate, or ``""`` when none.
755
+
756
+ Appended to the ``coord merge`` "merged" event message (real and
757
+ dry-run) so a bypass is never silent (#1213). Side-effect free — the
758
+ audit row itself is written separately, only on a real (non-dry-run)
759
+ merge, by the caller in :func:`process`.
760
+ """
761
+ bypassed = _bypassed_gates(entry, config)
762
+ if not bypassed:
763
+ return ""
764
+ label = _bypass_label(entry, config)
765
+ label_desc = f"label {label!r}" if label else "an issue-label override"
766
+ return f" [gate bypass via {label_desc}: {', '.join(bypassed)} skipped]"
767
+
768
+
769
+ def _record_gate_bypass_audit(entry: "QueuedMerge", config) -> list[str]:
770
+ """Emit one ``gate_bypassed`` business-tier audit row per bypassed gate
771
+ set, and return the bypassed gate names (``[]`` if none).
772
+
773
+ Called once per real merge success in :func:`process` — never in
774
+ dry-run, so previews never write phantom audit rows. ``record_audit``
775
+ is itself best-effort (never raises), matching every other write
776
+ choke point in :mod:`coord.state`.
777
+ """
778
+ bypassed = _bypassed_gates(entry, config)
779
+ if not bypassed:
780
+ return []
781
+ label = _bypass_label(entry, config)
782
+ label_desc = f"label {label!r}" if label else "an issue-label override"
783
+ record_audit(
784
+ tier="business",
785
+ category="gate",
786
+ event_type="gate_bypassed",
787
+ actor="user",
788
+ summary=(
789
+ f"Gate bypass via {label_desc}: {', '.join(bypassed)} skipped "
790
+ f"for {entry.repo_name}#{entry.issue_number}"
791
+ ),
792
+ repo=entry.repo_name,
793
+ issue=entry.issue_number,
794
+ assignment_id=entry.assignment_id,
795
+ details={
796
+ "label": label,
797
+ "resolved_gates": list(getattr(entry, "required_gates", None) or []),
798
+ "bypassed_gates": bypassed,
799
+ },
800
+ )
801
+ return bypassed
802
+
803
+
804
+ @dataclass(frozen=True)
805
+ class MergeGateFailure:
806
+ """One un-satisfied merge gate for a work row / queue entry (#1695).
807
+
808
+ :func:`passes_merge_gates` collapses this to a bool, which is all the
809
+ gate *decision* ever needed. What it never carried was *why* — so the
810
+ ``coord merge`` auto-enqueue scan's ``if not passes_merge_gates(...):
811
+ continue`` printed nothing at all, and an operator staring at a branch
812
+ in ``--dry-run`` that ``--only`` could not address had no statement of
813
+ the cause anywhere (#1695's 40-minute diagnosis).
814
+
815
+ ``waiver_flag`` is the ``coord merge`` flag that waives this gate **at
816
+ merge time** — the whole point of #1695 being that the gate and its
817
+ override must live at the same stage. It is a display string; nothing
818
+ here waives anything.
819
+ """
820
+
821
+ gate: str # "review" | "smoke"
822
+ reason: str # short human-readable cause
823
+ waiver_flag: str # "--skip-review" | "--skip-smoke"
824
+
825
+ def __str__(self) -> str:
826
+ return f"{self.gate} gate — {self.reason} (waive with {self.waiver_flag})"
827
+
828
+
829
+ def merge_gate_failures(
830
+ a,
831
+ config,
832
+ board,
833
+ gh_ops: "GhOps | None" = None,
834
+ stop_early: bool = False,
835
+ ) -> list[MergeGateFailure]:
836
+ """Every merge gate *a* has NOT satisfied, in :func:`process` order.
837
+
838
+ The reason-carrying form of :func:`passes_merge_gates` — that function is
839
+ now literally ``not merge_gate_failures(..., stop_early=True)``, so the
840
+ two can never disagree about whether a row is gated (the #946 drift this
841
+ predicate exists to prevent).
842
+
843
+ Returns ``[]`` when every configured gate is satisfied (or none is
844
+ configured — each gate no-ops when ``requires_*`` is False).
845
+
846
+ *stop_early* returns as soon as the first failure is found, preserving
847
+ :func:`passes_merge_gates`'s original short-circuit so the boolean path
848
+ never pays for a second gate evaluation (and, with *gh_ops* supplied,
849
+ never makes a second round trip) just to answer yes/no.
850
+ """
851
+ failures: list[MergeGateFailure] = []
852
+ if requires_review(a, config) and not has_approved_review(a, board, gh_ops):
853
+ failures.append(MergeGateFailure(
854
+ gate="review",
855
+ reason="review required but not approved",
856
+ waiver_flag="--skip-review",
857
+ ))
858
+ if stop_early:
859
+ return failures
860
+ if requires_smoke(a, config):
861
+ smoke = evaluate_smoke_verdict(a, board, gh_ops)
862
+ if not smoke.ok:
863
+ failures.append(MergeGateFailure(
864
+ gate="smoke",
865
+ reason=smoke.short_reason or "test verdict missing",
866
+ waiver_flag="--skip-smoke",
867
+ ))
868
+ if stop_early:
869
+ return failures
870
+ return failures
871
+
872
+
873
+ def describe_merge_gate_failures(failures: "list[MergeGateFailure]") -> str:
874
+ """Render *failures* as one operator-readable clause.
875
+
876
+ ``""`` when *failures* is empty, so call sites can interpolate it
877
+ unconditionally.
878
+ """
879
+ return "; ".join(str(f) for f in failures)
880
+
881
+
882
+ def passes_merge_gates(a, config, board, gh_ops: "GhOps | None" = None) -> bool:
883
+ """True when *a* (a work ``Assignment`` or ``QueuedMerge`` entry) has
884
+ satisfied every gate required before it may merge.
885
+
886
+ Shared predicate (#946) so untested/unreviewed work can never *merge*
887
+ through any path — previously each of the enqueue/merge call sites
888
+ (the daemon's :func:`enqueue_approved_work`, the ``coord merge``
889
+ auto-enqueue loop, the raw :func:`enqueue` helper, :func:`process`)
890
+ re-derived this logic and drifted: only the daemon path actually gated,
891
+ so untested/unreviewed work could sneak into the queue via
892
+ ``coord merge``.
893
+
894
+ #1695 narrows *where* a False answer is allowed to act. It still refuses
895
+ the merge (:func:`process` and :func:`_entry_gate_status` are unchanged —
896
+ a row that fails here can never be merged by any automatic path), but the
897
+ ``coord merge`` auto-enqueue scan no longer treats it as "drop this row
898
+ on the floor": the row is enqueued in a visibly BLOCKED state so it is
899
+ addressable by ``--only``, where ``--skip-review``/``--skip-smoke`` can
900
+ waive the gate. Enqueueing changes an entry's *visibility*, never its
901
+ *eligibility*.
902
+
903
+ Duck-typed on ``entry.assignment_id`` / ``entry.branch`` (both
904
+ ``Assignment`` and ``QueuedMerge`` have them), matching
905
+ :func:`requires_review` / :func:`has_approved_review` / :func:`requires_smoke`
906
+ / :func:`has_smoke_verdict`, which this composes.
907
+
908
+ *gh_ops* (optional, #1601) is forwarded to both gates so a live SHA/
909
+ patch-id lookup can back a fresh ``QueuedMerge`` entry that hasn't been
910
+ through :func:`process` yet — see :func:`has_smoke_verdict`'s docstring.
911
+ """
912
+ return not merge_gate_failures(a, config, board, gh_ops, stop_early=True)
913
+
914
+
915
+ # ── Smoke-verdict outcome kinds (#1640) ─────────────────────────────────────
916
+ # The gate has always been a bool; #1640 splits the *failure* into the two
917
+ # cases an operator has to act on differently:
918
+ #
919
+ # SMOKE_MISSING — nothing terminal was ever recorded. Run the Test stage.
920
+ # SMOKE_STALE — a passing verdict EXISTS but was recorded against a
921
+ # branch/base combination that no longer exists (#1479).
922
+ # Re-verify against the current base, then re-record.
923
+ # (#1732: a ``skipped`` verdict is never SMOKE_STALE — it's
924
+ # a structural claim about the diff, not a measurement at
925
+ # a SHA, so it can't go stale when the base moves.)
926
+ #
927
+ # Before #1640 both collapsed to "smoke test required but no verdict
928
+ # recorded", which is a false statement in the stale case and is exactly what
929
+ # made #1640 get filed as a lost DB write.
930
+ SMOKE_OK = "ok"
931
+ SMOKE_MISSING = "missing"
932
+ SMOKE_STALE = "stale"
933
+
934
+
935
+ def _short_sha(sha: str | None) -> str:
936
+ """7-char display form of *sha*, or ``"unknown"`` when it isn't known."""
937
+ if not sha:
938
+ return "unknown"
939
+ return sha[:7]
940
+
941
+
942
+ @dataclass(frozen=True)
943
+ class SmokeVerdictStatus:
944
+ """Structured outcome of the smoke gate for one entry (#1640).
945
+
946
+ ``ok`` is what :func:`has_smoke_verdict` returns; the remaining fields
947
+ exist so every surface that renders the refusal (``coord merge``, ``coord
948
+ merge --plan``, the ``/board`` staging section, the TUI) can say *which*
949
+ failure it is and against what, instead of all of them printing the
950
+ "no verdict recorded" wording that only fits :data:`SMOKE_MISSING`.
951
+
952
+ ``anchor`` is ``"base"`` when the merge base moved out from under the
953
+ verdict (the #1479-specific condition — the common one on a sequential
954
+ drain, since every merge moves the base for the next entry) and
955
+ ``"branch"`` when the branch's own content changed since the test ran.
956
+
957
+ #1819 adds a third anchor, ``"run"``: no terminal verdict exists, but the
958
+ row is pinned at the transient ``test_state="running"`` marker (#1395)
959
+ with no Test worker left alive to resolve it. That is an *abandoned*
960
+ verdict, not an absent one — see :data:`RUNNING_MARKER_STALE_AFTER`.
961
+
962
+ ``spared_reason`` is the mirror image, set only on a passing (``ok``)
963
+ verdict when the merge base *did* move but one of the #1479 escape
964
+ hatches proved the move couldn't have invalidated the verdict — #1738
965
+ (base move inert), #1778 (branch inert), or #1847 (base move and branch
966
+ touch disjoint files). ``None`` whenever the base didn't move at all, so
967
+ the common unremarkable-fresh case stays silent. See
968
+ :func:`_base_move_spared` for the three wordings.
969
+ """
970
+
971
+ ok: bool
972
+ kind: str # SMOKE_OK | SMOKE_MISSING | SMOKE_STALE
973
+ assignment_id: str | None = None
974
+ anchor: str | None = None # "base" | "branch" | "run" (SMOKE_STALE only)
975
+ recorded_sha: str | None = None
976
+ current_sha: str | None = None
977
+ spared_reason: str | None = None # set only on `ok=True` after a base move (#1847)
978
+
979
+ @property
980
+ def short_reason(self) -> str | None:
981
+ """Compact wording for plan / staging rows (``PlannedMerge.reason``).
982
+
983
+ ``None`` when the gate passes.
984
+ """
985
+ if self.ok:
986
+ return None
987
+ if self.kind == SMOKE_STALE:
988
+ if self.anchor == "run":
989
+ return (
990
+ "test verdict stale (Test stage stuck at 'running' with no "
991
+ "live worker)"
992
+ )
993
+ noun = "base" if self.anchor == "base" else "branch"
994
+ return (
995
+ f"test verdict stale (recorded against {noun} "
996
+ f"{_short_sha(self.recorded_sha)}, {noun} now "
997
+ f"{_short_sha(self.current_sha)})"
998
+ )
999
+ return "test verdict missing"
1000
+
1001
+ @property
1002
+ def message(self) -> str | None:
1003
+ """Full wording for a merge attempt (``QueuedMerge.error`` / the
1004
+ ``smoke_required`` event message). ``None`` when the gate passes."""
1005
+ if self.ok:
1006
+ return None
1007
+ if self.kind == SMOKE_STALE:
1008
+ aid = self.assignment_id or "<assignment>"
1009
+ if self.anchor == "run":
1010
+ return (
1011
+ "smoke test verdict is stale: the Test stage has been "
1012
+ "marked 'running' since before the last Test worker for "
1013
+ "this branch stopped, so no verdict is coming — re-verify "
1014
+ f"against the current base, then `coord test {aid} "
1015
+ "--passed`"
1016
+ )
1017
+ noun = "base" if self.anchor == "base" else "branch"
1018
+ return (
1019
+ f"smoke test verdict is stale: recorded against {noun} "
1020
+ f"{_short_sha(self.recorded_sha)}, {noun} is now "
1021
+ f"{_short_sha(self.current_sha)} — re-verify against the "
1022
+ f"current base, then `coord test {aid} --passed`"
1023
+ )
1024
+ return "smoke test required but no verdict recorded"
1025
+
1026
+
1027
+ # ── Stale-vs-missing: the ONE implementation (#1769) ────────────────────────
1028
+ #
1029
+ # Both wordings a stale (as opposed to never-recorded) smoke verdict can be
1030
+ # reported under are produced *by this module*: `SmokeVerdictStatus.message`
1031
+ # ("smoke test verdict is stale: …", what `process()` stores on `entry.error`
1032
+ # and what lands on the board as `merge_reason`) and
1033
+ # `SmokeVerdictStatus.short_reason` ("test verdict stale (…)", what `plan()` /
1034
+ # the staging rows render). So the predicate that recognises them belongs
1035
+ # here, next to the code that emits them, and NOT copied into every consumer.
1036
+ #
1037
+ # #1738 put a private copy in `coord/drive.py` to give `coord drive` its
1038
+ # re-test arm. #1769 adds the second consumer — `coord merge --revalidate` —
1039
+ # and a *third* string-matching copy in a third module is exactly how #1141
1040
+ # went stale, so the copy was lifted here instead: `coord.drive` and
1041
+ # `coord.revalidate` both import THIS function, and `tests/test_merge_queue.py`
1042
+ # asserts they are the same object.
1043
+ #
1044
+ # Deliberately a strict subset of `coord.drive._SMOKE_GATE_MARKERS`, which
1045
+ # also matches "no verdict at all" ("smoke test required" / "test verdict
1046
+ # missing"). Only the stale case has a safe, bounded, automatable fix:
1047
+ # re-verify against the CURRENT base and let a fresh verdict land. A
1048
+ # missing-verdict block is the #1640 lost-write shape instead — the driver and
1049
+ # the gate disagree about whether a verdict exists at all — which a re-test
1050
+ # cannot safely paper over, so it still escalates to a human.
1051
+ STALE_SMOKE_MARKERS = ("smoke test verdict is stale", "test verdict stale")
1052
+
1053
+
1054
+ def is_stale_smoke_reason(reason: str | None) -> bool:
1055
+ """True when *reason* names a STALE (not missing) smoke verdict.
1056
+
1057
+ The single implementation of the stale-vs-missing distinction over merge
1058
+ *prose* (#1738/#1769). The structured form of the same question is
1059
+ ``evaluate_smoke_verdict(...).kind == SMOKE_STALE``; this string-matching
1060
+ variant exists only for the consumers whose input is a persisted
1061
+ ``merge_reason``/``entry.error`` rather than a live gate evaluation.
1062
+ """
1063
+ r = (reason or "").lower()
1064
+ return any(marker in r for marker in STALE_SMOKE_MARKERS)
1065
+
1066
+
1067
+ # #1738: paths whose content cannot affect a pytest/cargo test result — the
1068
+ # allowlist a base-SHA move is checked against before staling an otherwise-
1069
+ # fresh verdict. Deliberately small and additive (start conservative, widen
1070
+ # later if a real false-stale shows up outside it). Two shapes:
1071
+ # - a directory prefix ("docs/", "scripts/", ...) — everything under it,
1072
+ # recursively, is inert;
1073
+ # - a bare top-level filename pattern ("*.md") — matches ONLY files with no
1074
+ # directory component. This is why `tests/acceptance/foo.md` is NOT
1075
+ # inert despite the `.md` extension: extension alone never qualifies, only
1076
+ # a top-level `*.md` (README.md, CONTRIBUTING.md, ...) does. Any other
1077
+ # path — including any `coord/**`, `tests/**`, `tui/**`, `pyproject.toml`,
1078
+ # or `.github/workflows/**` — stales the verdict exactly as before.
1079
+ _INERT_BASE_DIR_PREFIXES = ("docs/", "scripts/", ".github/ISSUE_TEMPLATE/")
1080
+
1081
+ # #1778: explicit deny-list that takes precedence over
1082
+ # `_INERT_BASE_DIR_PREFIXES` — the executable test surface the Test stage
1083
+ # actually runs. `scripts/` is otherwise allowlisted wholesale, which made
1084
+ # `scripts/coord-test-runner.sh` inert by omission: a base move touching
1085
+ # only the runner didn't stale (wrong — the runner IS what the suite runs),
1086
+ # and worse, a *branch* that edits the runner could point at this same
1087
+ # allowlist to declare itself untestable and skip its own gate
1088
+ # (self-certification). An explicit deny beats trimming the allowlist
1089
+ # because the next executable script added under `scripts/` inherits the
1090
+ # safe (non-inert) default instead of silently inheriting the hole.
1091
+ _INERT_DENY_PATHS = frozenset({
1092
+ "scripts/coord-test-runner.sh",
1093
+ })
1094
+
1095
+
1096
+ def _path_is_inert(path: str) -> bool:
1097
+ """True when *path* matches the #1738 inert-base allowlist.
1098
+
1099
+ Checked on both the base-move side (:func:`_base_move_is_inert`) and the
1100
+ branch side (:func:`_branch_is_inert`) — the deny-list in
1101
+ `_INERT_DENY_PATHS` is consulted first so it wins over the directory
1102
+ allowlist on either side (#1778).
1103
+ """
1104
+ if path in _INERT_DENY_PATHS:
1105
+ return False
1106
+ if any(path.startswith(prefix) for prefix in _INERT_BASE_DIR_PREFIXES):
1107
+ return True
1108
+ return "/" not in path and path.endswith(".md")
1109
+
1110
+
1111
+ def _fetch_compare_files(
1112
+ gh_ops: "GhOps | None",
1113
+ repo_github: str | None,
1114
+ base_sha: str | None,
1115
+ head_sha: str | None,
1116
+ ) -> list[str] | None:
1117
+ """Fetch the file list for one ``get_compare_files(base_sha, head_sha)``
1118
+ compare, failing closed to ``None`` (never raising) whenever the answer
1119
+ can't be established: no *gh_ops*/*repo_github* to ask, a missing SHA on
1120
+ either side, the call raising, or it returning ``None`` (unreadable)
1121
+ itself.
1122
+
1123
+ The single I/O seam behind :func:`_base_move_is_inert`,
1124
+ :func:`_branch_is_inert`, and (#1847) :func:`_base_move_disjoint_from_branch`
1125
+ — all three ultimately ask "what files does this compare touch" of one of
1126
+ the same two compares (``test_base_sha..current_base_sha`` or
1127
+ ``test_base_sha..test_head_sha``), so :func:`_base_move_spared` fetches
1128
+ each side through here at most once and shares the result across every
1129
+ predicate that consults it, rather than each predicate fetching its own
1130
+ copy.
1131
+
1132
+ Note: :class:`coord.gate_snapshot.GateSnapshot` (the ``/board`` display
1133
+ path's ``gh_ops`` stand-in) does not yet cache ``get_compare_files``, so a
1134
+ plain ``AttributeError`` lands here and this fails closed exactly as it
1135
+ does for a genuine lookup failure — the display can show STALE for a
1136
+ base move that a live ``coord merge``/``coord drive`` (real
1137
+ ``coord.github_ops``) correctly treats as fresh. That's the safe
1138
+ direction of disagreement (pessimistic display, correct live gate) —
1139
+ the opposite of the #1640 incident — but wiring this cache through
1140
+ :class:`~coord.gate_snapshot.GateSnapshotRefresher` would close it too.
1141
+ """
1142
+ if gh_ops is None or not repo_github or not base_sha or not head_sha:
1143
+ return None
1144
+ try:
1145
+ return gh_ops.get_compare_files(repo_github, base_sha, head_sha)
1146
+ except Exception: # noqa: BLE001 — fail-safe: unknown diff is not "inert"/disjoint
1147
+ return None
1148
+
1149
+
1150
+ def _files_are_inert(files: list[str] | None) -> bool:
1151
+ """True when *files* — an already-fetched compare file list — is
1152
+ non-``None`` and every entry passes :func:`_path_is_inert`.
1153
+
1154
+ ``None`` (an unreadable compare) fails closed to ``False``. Factored out
1155
+ of :func:`_base_move_is_inert`/:func:`_branch_is_inert` (#1847) so the
1156
+ same predicate can run against a list :func:`_base_move_spared` fetched
1157
+ once, instead of each of the three #1479 escape hatches fetching (and
1158
+ re-checking) its own copy.
1159
+ """
1160
+ if files is None:
1161
+ return False
1162
+ return all(_path_is_inert(f) for f in files)
1163
+
1164
+
1165
+ def _base_move_is_inert(
1166
+ gh_ops: "GhOps | None", repo_github: str | None, old_sha: str, new_sha: str
1167
+ ) -> bool:
1168
+ """True when every file the base moved through (*old_sha*..*new_sha*) is
1169
+ provably inert (#1738) — content that cannot alter a test result, so a
1170
+ fresh verdict recorded against *old_sha* still covers *new_sha*.
1171
+
1172
+ Fails closed (returns ``False``, i.e. "not proven inert, stale as
1173
+ before") whenever inertness can't be established: no *gh_ops*/*repo_github*
1174
+ to ask, the compare call raises, or it comes back ``None`` (unreadable) or
1175
+ empty-but-unconfirmed. The bar set by #1738 is "bias hard toward staling":
1176
+ a false "fresh" merges untested code; a false "stale" only costs a re-run.
1177
+
1178
+ Thin composition of :func:`_fetch_compare_files` + :func:`_files_are_inert`
1179
+ — kept as its own function (rather than inlined at the one call site) so
1180
+ it stays independently testable and so :func:`_base_move_spared`'s
1181
+ single-fetch orchestration reads as "the same predicates, sharing one
1182
+ fetch" rather than a parallel implementation.
1183
+ """
1184
+ return _files_are_inert(_fetch_compare_files(gh_ops, repo_github, old_sha, new_sha))
1185
+
1186
+
1187
+ def _branch_is_inert(
1188
+ gh_ops: "GhOps | None",
1189
+ repo_github: str | None,
1190
+ base_sha: str | None,
1191
+ branch_sha: str | None,
1192
+ ) -> bool:
1193
+ """True when a branch's entire diff against its merge-base is provably
1194
+ inert (#1778) — content the suite cannot see, so
1195
+ ``suite(base + branch) ≡ suite(base)`` and re-running the suite over a
1196
+ moved base tells you nothing about *this branch* that wasn't already
1197
+ known.
1198
+
1199
+ This is the mirror of :func:`_base_move_is_inert`: that function asks
1200
+ "did the base move through anything that matters"; this one asks "does
1201
+ the branch touch anything that matters", independent of whether the base
1202
+ moved at all. The three are consulted together at the base-move staling
1203
+ check (#1479/#1847) by :func:`_base_move_spared` — any one being true is
1204
+ enough to skip staling on a base move alone. None of the three replace
1205
+ the separate branch-*content*-changed check (patch-id comparison) that
1206
+ follows: a branch that is inert today and later gains a `coord/**`
1207
+ commit is caught by that check, not this one.
1208
+
1209
+ Fails closed exactly like :func:`_base_move_is_inert` — returns
1210
+ ``False`` ("not proven inert, stale as before") whenever inertness can't
1211
+ be established: no *gh_ops*/*repo_github*, a raising compare call, or a
1212
+ ``None`` (unreadable) result. A false "fresh" would let an untested
1213
+ branch merge; a false "stale" only costs a redundant re-run.
1214
+
1215
+ Reuses :func:`_path_is_inert` for the allowlist, which is why the
1216
+ `_INERT_DENY_PATHS` exclusion of `scripts/coord-test-runner.sh` matters
1217
+ here specifically: without it, a branch that edits the composed test
1218
+ runner could point at the `scripts/` allowlist and declare its own diff
1219
+ untestable, skipping the gate it is trying to evade.
1220
+ """
1221
+ return _files_are_inert(
1222
+ _fetch_compare_files(gh_ops, repo_github, base_sha, branch_sha)
1223
+ )
1224
+
1225
+
1226
+ def _base_move_disjoint_from_branch(
1227
+ base_files: list[str] | None, branch_files: list[str] | None
1228
+ ) -> bool:
1229
+ """True when the files the base moved through and the files the branch
1230
+ touches share no path (#1847) — the third #1479 base-move escape hatch,
1231
+ alongside :func:`_base_move_is_inert` and :func:`_branch_is_inert`.
1232
+
1233
+ Those two are allowlist-based: each asks "is this one diff inert on its
1234
+ own". This asks a different, cheaper-to-satisfy question — "do these two
1235
+ diffs have anything to do with each other" — which is the shape that
1236
+ actually costs a human intervention on a queue drain: a substantive base
1237
+ move and a substantive branch that simply never touch the same file.
1238
+
1239
+ Fails closed to ``False`` when either list is ``None`` (an unreadable
1240
+ compare on either side), matching the fail-closed posture of the other
1241
+ two checks: a false "disjoint" would let an untested base/branch
1242
+ combination merge; a false "overlapping" only costs a redundant re-run.
1243
+
1244
+ File-level disjointness is *not* semantic independence — a base change
1245
+ to one module can still break a branch that never names it (there is no
1246
+ compiler to catch a moved signature at this granularity, and a shared
1247
+ `conftest.py` fixture makes it worse). Two things bound that risk enough
1248
+ to accept it here rather than requiring semantic analysis:
1249
+
1250
+ * CI already tests the *composite*. `.github/workflows/test.yml` runs
1251
+ pytest and `.github/workflows/cargo-test.yml` runs cargo test on every
1252
+ `pull_request` push, built against branch-merged-into-base, and
1253
+ `coord merge` gates on those checks independently via
1254
+ `coord.ci_store.CiStore` — the local Test verdict this function spares
1255
+ is substantially re-deriving what CI already proves.
1256
+ * GitHub does not re-run PR workflows when only the *base* moves (only
1257
+ on head `synchronize`), which is a real gap — but it is the SAME gap
1258
+ `_base_move_is_inert`/`_branch_is_inert` already accept for their own
1259
+ allowlisted content, not a new one this check introduces. Closing it
1260
+ (re-running CI on a base move) is out of scope here.
1261
+ """
1262
+ if base_files is None or branch_files is None:
1263
+ return False
1264
+ return set(base_files).isdisjoint(branch_files)
1265
+
1266
+
1267
+ def _base_move_spared(
1268
+ gh_ops: "GhOps | None",
1269
+ repo_github: str | None,
1270
+ test_base_sha: str,
1271
+ current_base_sha: str,
1272
+ test_head_sha: str | None,
1273
+ ) -> tuple[bool, str | None]:
1274
+ """Whether a moved base still spares a `passed` verdict recorded against
1275
+ *test_base_sha*, and — when it does — why.
1276
+
1277
+ Tries the three #1479 escape hatches in order, stopping at the first
1278
+ that fires: #1738 (:func:`_base_move_is_inert`), #1778
1279
+ (:func:`_branch_is_inert`), #1847 (:func:`_base_move_disjoint_from_branch`).
1280
+ Ordered cheapest-first and fetch-sharing on purpose: the base-move file
1281
+ list is fetched once and checked for #1738 before the branch file list is
1282
+ fetched at all; the branch file list, once fetched for #1778, is reused
1283
+ for #1847 rather than re-fetched. At most two `get_compare_files` calls
1284
+ total per invocation, regardless of which disjunct fires or whether none
1285
+ do — same worst case as the pre-#1847 `_base_move_is_inert(...) or
1286
+ _branch_is_inert(...)` this replaces at the call site.
1287
+ """
1288
+ base_files = _fetch_compare_files(
1289
+ gh_ops, repo_github, test_base_sha, current_base_sha
1290
+ )
1291
+ if _files_are_inert(base_files):
1292
+ return True, "base move touches only inert paths (#1738)"
1293
+ branch_files = _fetch_compare_files(
1294
+ gh_ops, repo_github, test_base_sha, test_head_sha
1295
+ )
1296
+ if _files_are_inert(branch_files):
1297
+ return True, "branch touches only inert paths (#1778)"
1298
+ if _base_move_disjoint_from_branch(base_files, branch_files):
1299
+ return True, "base move and branch touch disjoint files (#1847)"
1300
+ return False, None
1301
+
1302
+
1303
+ # #1851: the reason string prefix `_entry_gate_status` returns for a CI-stale
1304
+ # entry. Both the wording and the eligibility check in
1305
+ # :func:`ci_revalidation_candidates` key off this constant so the two can
1306
+ # never drift apart the way #1141 warns about (see `STALE_SMOKE_MARKERS`'s
1307
+ # comment above for the same lesson learned the hard way for the smoke gate).
1308
+ CI_STALE_PREFIX = "CI stale:"
1309
+
1310
+
1311
+ # #1891: the reason string prefix `_entry_gate_status` (board-render time)
1312
+ # and `process()`'s live `checks_pending` event (real merge-attempt time)
1313
+ # BOTH use for "checks exist on GitHub but have not reported a conclusion
1314
+ # yet" — as opposed to `checks_failed` (a check that DID report, and
1315
+ # reported red). This is the one piece of vocabulary #1891's incident was
1316
+ # missing: a CI verdict that has not arrived was indistinguishable from one
1317
+ # that arrived and said no, so a drive spent its bounded merge-attempt
1318
+ # budget (and then a drive-queue launch attempt) retrying a merge that only
1319
+ # more real time — never another retry — could resolve.
1320
+ #
1321
+ # `IssueState.merge_reason` (`drive_state._merge_entry`) already falls back
1322
+ # from the live plan's freshly-recomputed `reason` to the raw queue row's
1323
+ # *persisted* `error` whenever the plan's own re-evaluation comes back
1324
+ # empty — which is exactly what happens when `_gate_refresher`'s
1325
+ # periodically-refreshed snapshot (`coord/gate_snapshot.py`) lags or gaps a
1326
+ # live `coord merge` attempt's own fresher read. That makes `merge_reason`
1327
+ # — not `merge_status`, which has no such fallback — the robust signal to
1328
+ # key off. `coord.drive._decide_merge` and `coord.drive_queue`'s `parked`
1329
+ # outcome both import :func:`is_ci_pending_reason` below so the two can
1330
+ # never drift apart the way #1141 warns about.
1331
+ CI_PENDING_PREFIX = "CI running:"
1332
+
1333
+
1334
+ def is_ci_pending_reason(reason: str | None) -> bool:
1335
+ """True when *reason* names checks that exist but have not reported a
1336
+ conclusion yet (#1891) — as opposed to a check that ran and failed.
1337
+
1338
+ The single implementation of the pending-vs-failed distinction over merge
1339
+ prose, the same posture :func:`is_stale_smoke_reason` takes for the smoke
1340
+ gate: callers whose input is a persisted ``merge_reason``/``entry.error``
1341
+ string (not a live ``CheckRun`` list) use this instead of re-deriving it.
1342
+ """
1343
+ return (reason or "").startswith(CI_PENDING_PREFIX)
1344
+
1345
+
1346
+ # #1904: every CI gate predicate — `failed_checks`, `in_flight_checks`,
1347
+ # `_ci_checks_are_stale` — is a filter *over* `checks`, so `checks == []`
1348
+ # satisfies all three vacuously and the pre-#1904 gate fell all the way
1349
+ # through to "merge". `checks == []` is genuinely ambiguous (see
1350
+ # `CiStore.expects_checks`'s docstring): "no CI configured for this repo"
1351
+ # (correct to merge) and "CI exists but never triggered for this PR" (a
1352
+ # throttled webhook, a wedged run, a path-filtered-out workflow — wrong to
1353
+ # merge) both produce it. `CI_ABSENT_PREFIX` names the second reading,
1354
+ # distinctly from `CI_PENDING_PREFIX` ("checks exist, still running") and
1355
+ # `CI_STALE_PREFIX` ("checks exist, green, but predate the base") — an
1356
+ # operator (and `coord drive`) needs to know nothing was ever triggered, not
1357
+ # that something is still in flight.
1358
+ #
1359
+ # #1877: `checks == []` has a THIRD reading — the PR conflicts with its
1360
+ # base, so GitHub never built a merge ref to run a `pull_request`-triggered
1361
+ # workflow against at all. Unlike the "never triggered" reading above, this
1362
+ # one is self-healing: routing it to the #241 conflict-fix path (rather
1363
+ # than blocking here) is the cure. `process()` and `_entry_gate_status`
1364
+ # both consult `GhOps.check_pr_mergeable` before committing to the
1365
+ # `CI_ABSENT_PREFIX` block, specifically to give this reading a chance to
1366
+ # fall through first — see the `#1877` comments at each call site.
1367
+ CI_ABSENT_PREFIX = "CI never ran:"
1368
+
1369
+
1370
+ def is_ci_absent_reason(reason: str | None) -> bool:
1371
+ """True when *reason* names a PR whose CI was expected to run but never
1372
+ reported a single check (#1904) — as opposed to one that ran and failed
1373
+ (``checks_failed``), is still running (:func:`is_ci_pending_reason`), or
1374
+ ran stale (``CI_STALE_PREFIX``)."""
1375
+ return (reason or "").startswith(CI_ABSENT_PREFIX)
1376
+
1377
+
1378
+ # #1892: the reason string prefix for a `checks_failed` entry whose failing
1379
+ # checks carry NO VERDICT ABOUT THE CODE — every one of them either never got
1380
+ # a runner (cancelled at the queue timeout, zero steps) or died at "Set up
1381
+ # job" (before checkout, so no repo code ran). Distinctly named from the
1382
+ # plain "checks failed: ..." wording a genuine red suite produces, so
1383
+ # `coord.drive`'s retry accounting and `coord.drive_queue`'s `parked` state
1384
+ # (both already keying off :data:`CI_PENDING_PREFIX`/`is_ci_pending_reason`
1385
+ # for the #1891 "still running" case) can extend the identical treatment to
1386
+ # this one: don't spend a merge attempt / launch attempt on a failure that
1387
+ # says nothing about whether the branch is any good — see
1388
+ # :func:`_ci_infra_reason` for the classification and `MAX_CI_INFRA_RERUNS`
1389
+ # for the auto-rerun this reason also triggers.
1390
+ #
1391
+ # Deliberately NOT surfaced by `_entry_gate_status` (board-render time,
1392
+ # consumed by `plan()`/`/board`): classifying this needs one extra
1393
+ # `gh api .../actions/runs/{id}/jobs` call per distinct failing run
1394
+ # (`CiStore.list_jobs_for_run`), and `coord.gate_snapshot`'s module
1395
+ # docstring states Invariant 1 — the board *read* path performs no
1396
+ # third-party I/O. Only the LIVE merge path (`process()`, which already
1397
+ # pays for fresh truth — see that module's docstring) computes this and
1398
+ # persists it onto `QueuedMerge.error`; `coord.drive_state._merge_entry`
1399
+ # prefers that raw, more-specific reading over the plan's own generic
1400
+ # re-derivation when the two diverge (mirrors the NEEDS_ATTENTION recovery
1401
+ # a few lines into that function).
1402
+ CI_INFRA_PREFIX = "CI infra:"
1403
+
1404
+
1405
+ def is_ci_infra_reason(reason: str | None) -> bool:
1406
+ """True when *reason* names a `checks_failed` block whose failures were
1407
+ all classified verdictless (#1892) — see :data:`CI_INFRA_PREFIX`."""
1408
+ return (reason or "").startswith(CI_INFRA_PREFIX)
1409
+
1410
+
1411
+ # #1892: auto-reruns `process()` will trigger for a single entry's verdictless
1412
+ # CI failure (via `CiStore.rerun_for_pr`) before giving up and parking it for
1413
+ # a human instead of the queue's own #1891 machinery. A workflow genuinely
1414
+ # broken at the "Set up job" level — a bad `uses:` ref, a deleted action —
1415
+ # would otherwise loop this forever; two tries is enough to ride out a queue-
1416
+ # timeout blip or a transient "Service Unavailable" without masking a
1417
+ # standing breakage.
1418
+ MAX_CI_INFRA_RERUNS = 2
1419
+
1420
+
1421
+ def _ci_infra_reason(
1422
+ ci: "CiStore", repo: str, number: int, failed: "list[CheckRun]"
1423
+ ) -> str | None:
1424
+ """The :data:`CI_INFRA_PREFIX` reason when EVERY check in *failed* is
1425
+ verdictless (#1892), else ``None``.
1426
+
1427
+ Issues at most one :meth:`CiStore.list_jobs_for_run` call per distinct
1428
+ ``run_id`` among *failed* — never when *failed* is empty (the all-green
1429
+ or still-pending path), matching the "only on the failure path" scoping
1430
+ this feature was built to. ``getattr(ci, "list_jobs_for_run", None)``
1431
+ mirrors the same fail-closed-toward-"no answer" pattern
1432
+ ``_ci_checks_are_stale``/``_ci_expects_checks`` already use for a
1433
+ ``CiStore`` stand-in that predates a capability (a duck-typed test stub,
1434
+ or :class:`coord.gate_snapshot.GateSnapshot` — which deliberately does
1435
+ NOT implement this, per Invariant 1 above) — such a store just never
1436
+ produces this classification, falling back to the plain "checks failed"
1437
+ wording exactly like #1892 didn't exist for it.
1438
+ """
1439
+ if not failed:
1440
+ return None
1441
+ list_jobs = getattr(ci, "list_jobs_for_run", None)
1442
+ if list_jobs is None:
1443
+ return None
1444
+ run_ids = sorted({c.run_id for c in failed if c.run_id})
1445
+ jobs_by_run: dict[str, dict[str, JobRun]] = {}
1446
+ for run_id in run_ids:
1447
+ try:
1448
+ jobs = list_jobs(repo, run_id)
1449
+ except Exception: # noqa: BLE001 — classification-only, never raises upward
1450
+ jobs = []
1451
+ jobs_by_run[run_id] = {j.name: j for j in (jobs or [])}
1452
+ all_verdictless = all(
1453
+ is_verdictless_job(c, jobs_by_run.get(c.run_id, {}).get(c.name))
1454
+ for c in failed
1455
+ )
1456
+ if not all_verdictless:
1457
+ return None
1458
+ summary = ", ".join(f"{c.name} ({c.conclusion})" for c in failed)
1459
+ return (
1460
+ f"{CI_INFRA_PREFIX} {summary} — no verdict about the code (never "
1461
+ "assigned a runner, or died before checkout)"
1462
+ )
1463
+
1464
+
1465
+ def _ci_expects_checks(
1466
+ ci_store: "CiStore", repo_github: str | None, pr_number: int | None
1467
+ ) -> bool:
1468
+ """True when *ci_store* believes *repo_github*#*pr_number* should have
1469
+ reported at least one check (#1904) — i.e. an empty
1470
+ ``list_checks_for_pr`` result is suspicious, not a legitimate "no CI
1471
+ here" reading. Callers only consult this once ``list_checks_for_pr`` has
1472
+ already come back empty.
1473
+
1474
+ ``getattr(..., None)`` mirrors `_ci_checks_are_stale`'s own fail-closed
1475
+ posture toward a ``CiStore``/``GhOps`` stand-in that predates a
1476
+ capability (see that function's ``get_branch_commit_timestamp`` probe):
1477
+ a store that hasn't been taught to answer this question yet reads as
1478
+ "checks were expected" rather than silently reopening the #1904 hole for
1479
+ any backend or test double this code doesn't already know about.
1480
+ :class:`coord.ci_store.NoOpCi` and :class:`coord.ci_github.GitHubCi`
1481
+ both implement this explicitly; so does
1482
+ :class:`coord.gate_snapshot.GateSnapshot`.
1483
+ """
1484
+ probe = getattr(ci_store, "expects_checks", None)
1485
+ if probe is None:
1486
+ return True
1487
+ try:
1488
+ return bool(probe(repo_github, pr_number))
1489
+ except Exception: # noqa: BLE001 — fail closed: an erroring probe still means "assume expected"
1490
+ return True
1491
+
1492
+
1493
+ def _ci_checks_are_stale(
1494
+ checks: "list[CheckRun]",
1495
+ gh_ops: "GhOps | None",
1496
+ repo_github: str | None,
1497
+ target_branch: str | None,
1498
+ smoke: "SmokeVerdictStatus | None",
1499
+ ) -> bool:
1500
+ """True when *checks* — already confirmed by the caller to have no
1501
+ failed/in-flight entries — are stale relative to *target_branch*'s
1502
+ current base (#1851).
1503
+
1504
+ GitHub does not re-run ``pull_request`` workflows on a base-only move
1505
+ (only on head ``synchronize``), so a green check can silently outlive the
1506
+ base commit it actually validated. See ``coord/ci_store.py``'s module
1507
+ docstring and :func:`coord.ci_store.checks_are_stale` for the full
1508
+ rationale; this function supplies the two pieces that predicate needs and
1509
+ can't fetch for itself — the base's current commit timestamp, and (#1847
1510
+ reuse) whether the base move can be dismissed without even reading one.
1511
+
1512
+ *smoke*, when given, is the :class:`SmokeVerdictStatus` this same gate
1513
+ pass already computed for the entry's local Test verdict (evaluated only
1514
+ when the smoke gate applies). Its ``spared_reason`` is set exactly when a
1515
+ moved base was proven inert (#1738), the branch's own diff was proven
1516
+ inert (#1778), or the two diffs are file-disjoint (#1847) — any one of
1517
+ which spares the local verdict for the *same* base move, and the same
1518
+ reasoning spares the CI result: "the two checks answer the same question
1519
+ about different evidence" (#1851). When *smoke* is ``None`` or carries no
1520
+ ``spared_reason`` (smoke gate not required/evaluated for this entry, or
1521
+ the base move wasn't spared), this falls back to a pure timestamp
1522
+ comparison — the only evidence available without an anchor.
1523
+
1524
+ Fails closed (returns ``True``) whenever the timestamp comparison itself
1525
+ can't be made: no *gh_ops*, no *target_branch*, a *gh_ops* stand-in with
1526
+ no ``get_branch_commit_timestamp`` (e.g. :class:`coord.gate_snapshot.
1527
+ GateSnapshot`, which doesn't cache it — same documented pessimistic-
1528
+ display tradeoff as :func:`_fetch_compare_files`), or a live lookup that
1529
+ raises or returns ``None``.
1530
+ """
1531
+ if smoke is not None and smoke.spared_reason is not None:
1532
+ return False
1533
+ if gh_ops is None or not repo_github or not target_branch:
1534
+ return True
1535
+ getter = getattr(gh_ops, "get_branch_commit_timestamp", None)
1536
+ if getter is None:
1537
+ return True
1538
+ try:
1539
+ base_commit_time = getter(repo_github, target_branch)
1540
+ except Exception: # noqa: BLE001 — fail-safe: unknown timestamp is stale, not blocking here
1541
+ base_commit_time = None
1542
+ return checks_are_stale(checks, base_commit_time)
1543
+
1544
+
1545
+ def has_smoke_verdict(
1546
+ entry: "QueuedMerge", board, gh_ops: "GhOps | None" = None
1547
+ ) -> bool:
1548
+ """True when the smoke requirement for *entry* is satisfied.
1549
+
1550
+ Thin ``.ok`` projection of :func:`evaluate_smoke_verdict` — kept as the
1551
+ boolean seam every existing gate call site already uses. Callers that
1552
+ need to *render* a refusal should call :func:`evaluate_smoke_verdict`
1553
+ directly so they can distinguish stale from missing (#1640).
1554
+ """
1555
+ return evaluate_smoke_verdict(entry, board, gh_ops).ok
1556
+
1557
+
1558
+ #: #1819: how long a transient ``test_state="running"`` marker (#1395) may
1559
+ #: outlive the last Test worker on its branch before the gate calls it STALE
1560
+ #: rather than MISSING. The marker is written at dispatch and cleared when a
1561
+ #: terminal verdict lands; if the worker died, was reaped, or its verdict write
1562
+ #: was lost, nothing ever clears it and every gate reads "no verdict" forever
1563
+ #: (#1797). ``--revalidate`` — the one tool built for exactly this cascade —
1564
+ #: only ever touches STALE entries, so without this classification the
1565
+ #: operator's escape hatch is unreachable from the state that most needs it
1566
+ #: (the #1640 shape, in its load-bearing form).
1567
+ #:
1568
+ #: Generous on purpose: a real suite run is minutes, so an hour of slack still
1569
+ #: never races a live worker, and the check ALSO requires that no live Test
1570
+ #: worker for the branch remains on the board.
1571
+ RUNNING_MARKER_STALE_AFTER = 60 * 60.0
1572
+
1573
+
1574
+ def _abandoned_running_marker(
1575
+ branch_work: list,
1576
+ board,
1577
+ now: float | None = None,
1578
+ ) -> "Assignment | None":
1579
+ """The work row wedged at ``test_state="running"`` with no live Test worker.
1580
+
1581
+ #1819. Returns the row whose Test stage can never resolve itself, or
1582
+ ``None`` when the marker is absent or a run is plausibly still going.
1583
+
1584
+ Deliberately conservative in two ways:
1585
+
1586
+ * it only fires when coord itself dispatched a Test-stage assignment for
1587
+ the branch. A ``running`` marker with **no** smoke assignment anywhere is
1588
+ the #1395 local-driver shape (``scripts/drive-issue.sh`` sets the marker
1589
+ and runs the suite in-process), and there is no worker row whose age
1590
+ could tell a live run from a dead one — so that case keeps the old
1591
+ MISSING classification rather than risk resetting a driver mid-run;
1592
+ * the window is measured from the newest Test worker's ``dispatched_at``
1593
+ and applies uniformly, whether that worker is still in ``board.active``
1594
+ or already reaped. A live-and-young smoke is obviously a run in
1595
+ progress; a *just*-reaped one still buys the notify path time to land
1596
+ the verdict it produced, so a lost write is never confused with a slow
1597
+ one.
1598
+ """
1599
+ running = [
1600
+ a for a in branch_work if getattr(a, "test_state", None) == "running"
1601
+ ]
1602
+ if not running:
1603
+ return None
1604
+
1605
+ now = time.time() if now is None else now
1606
+ pool = list(getattr(board, "completed", []) or []) + list(
1607
+ getattr(board, "active", []) or []
1608
+ )
1609
+ active_ids = {
1610
+ getattr(a, "assignment_id", None)
1611
+ for a in (getattr(board, "active", []) or [])
1612
+ } - {None}
1613
+ work_ids = {
1614
+ getattr(a, "assignment_id", None) for a in branch_work
1615
+ } - {None}
1616
+
1617
+ smokes = [
1618
+ a for a in pool
1619
+ if getattr(a, "type", None) == "smoke"
1620
+ and getattr(a, "review_of_assignment_id", None) in work_ids
1621
+ ]
1622
+ if not smokes:
1623
+ return None
1624
+ for s in smokes:
1625
+ if getattr(s, "assignment_id", None) not in active_ids:
1626
+ continue # already reaped — cannot still be running
1627
+ if getattr(s, "status", None) in ("done", "failed", "cancelled"):
1628
+ continue
1629
+ if now - (getattr(s, "dispatched_at", None) or 0.0) < RUNNING_MARKER_STALE_AFTER:
1630
+ return None # a Test worker is plausibly still going
1631
+ newest = max(smokes, key=lambda s: getattr(s, "dispatched_at", None) or 0.0)
1632
+ if now - (getattr(newest, "dispatched_at", None) or 0.0) < RUNNING_MARKER_STALE_AFTER:
1633
+ return None
1634
+ return running[0]
1635
+
1636
+
1637
+ def evaluate_smoke_verdict(
1638
+ entry: "QueuedMerge", board, gh_ops: "GhOps | None" = None
1639
+ ) -> SmokeVerdictStatus:
1640
+ """Evaluate the smoke requirement for *entry*, with the reason it failed.
1641
+
1642
+ The gate **fails open**: if no work assignment can be found on the board
1643
+ for the entry's branch (e.g. board was cleared, manual queue entry, or
1644
+ the assignment pre-dates board persistence), this returns ``ok=True`` so
1645
+ that the merge is not silently blocked without evidence.
1646
+
1647
+ The gate **fails closed** (``ok=False``) only when we can positively
1648
+ identify the work assignment(s) on the branch and none of them carries a
1649
+ *fresh* ``test_state in ('passed', 'skipped')`` verdict.
1650
+
1651
+ Collects all work assignment IDs connected to the entry — by shared
1652
+ branch, or (#567) by the ``review_of_assignment_id`` chain, which also
1653
+ catches fix workers dispatched with ``branch=NULL`` (the #557 remote-
1654
+ interactive-rework gap) — to handle bounce/fix-work chains.
1655
+
1656
+ #1479: unlike the pre-existing behaviour, a terminal ``passed`` verdict is
1657
+ not trusted unconditionally — it is checked against the branch/base state
1658
+ it was recorded against (``test_head_sha``/``test_patch_id``/
1659
+ ``test_base_sha``, stamped by ``coord.state._record_test_verdict_local``)
1660
+ the same way ``has_approved_review`` checks ``review_head_sha``/
1661
+ ``review_patch_id``, **plus** one condition the review gate deliberately
1662
+ doesn't have: the *merge base itself* moving. A rebase onto a moved base
1663
+ can break tests without the branch's own diff changing at all (a
1664
+ semantic conflict — upstream renamed something the branch calls), so
1665
+ that combination no longer having been tested must re-block the gate
1666
+ even when ``branch_patch_id`` is unchanged. Content changing (new
1667
+ commits on the branch) also re-blocks, mirroring the review gate. Either
1668
+ anchor missing on either side skips that half of the check (fail open —
1669
+ #821/#1475's existing convention), so rows/entries predating this
1670
+ feature behave exactly as before.
1671
+
1672
+ #1732: ``skipped`` is deliberately excluded from all of the above. It is
1673
+ not a measurement of code at a SHA the way ``passed`` is — it is a
1674
+ structural claim about the diff itself ("contract/fixture-only, nothing
1675
+ to smoke-test", #1076/#1152) that cannot be falsified by the base or
1676
+ branch moving. Treating it like ``passed`` meant a Gate-A slice approved
1677
+ and merge-ready would get refused as "STALE" with an unperformable
1678
+ remedy: there is no suite to re-run, and recording ``passed`` would be a
1679
+ lie about a suite that does not apply. A ``skipped`` verdict short-
1680
+ circuits straight to :data:`SMOKE_OK` before any SHA comparison runs.
1681
+
1682
+ #1601: *gh_ops* (optional, mirroring :func:`has_approved_review`) fetches
1683
+ the branch's/base's *live* SHA (and, via :func:`_backfill_branch_patch_id`,
1684
+ the live patch-id) on demand when *entry* doesn't already carry them.
1685
+ Without it, an entry that has never been through a live :func:`process`
1686
+ pass has ``branch_head_sha``/``target_branch_head_sha``/``branch_patch_id``
1687
+ all ``None``, which makes every staleness check above a no-op — so
1688
+ ``coord merge --plan`` (which calls this via :func:`_entry_gate_status`
1689
+ on a freshly-enqueued entry) could show READY for a verdict that
1690
+ ``coord merge --only`` (whose :func:`process` DOES backfill these before
1691
+ checking) then correctly refuses as stale. Passing *gh_ops* through
1692
+ closes that "plan says ready, only refuses" disagreement — the #1566
1693
+ incident's reader 3 vs. reader 4 split.
1694
+
1695
+ #1640: a *gh_ops* that cannot answer ``get_branch_sha`` /
1696
+ ``get_branch_patch_id`` (the daemon's :class:`coord.gate_snapshot.
1697
+ GateSnapshot` used to be exactly that) reopens the same disagreement
1698
+ through a different door — the ``except Exception`` fallbacks below
1699
+ swallow the ``AttributeError`` and every staleness check silently
1700
+ degrades to a no-op, so ``/board``'s plan shows READY while a live
1701
+ ``coord merge --only`` refuses. ``GateSnapshot`` now serves both lookups
1702
+ from its tick-refreshed data; keep that in lockstep if a new gh_ops
1703
+ stand-in is ever introduced.
1704
+
1705
+ Returns a :class:`SmokeVerdictStatus`: ``ok`` plus, when it fails,
1706
+ whether the verdict is :data:`SMOKE_MISSING` (never recorded) or
1707
+ :data:`SMOKE_STALE` (recorded, but against a branch/base combination
1708
+ that no longer exists) and the SHAs that disagree.
1709
+ """
1710
+ pool = list(getattr(board, "completed", []) or []) + list(
1711
+ getattr(board, "active", []) or []
1712
+ )
1713
+
1714
+ branch_work_ids = _chain_work_ids(entry, pool)
1715
+
1716
+ # Collect work assignments that are explicitly present on the board.
1717
+ branch_work = [
1718
+ a for a in pool
1719
+ if getattr(a, "assignment_id", None) in branch_work_ids
1720
+ and getattr(a, "type", None) in WORK_LIKE_TYPES
1721
+ ]
1722
+ # Fail open: no work assignment found → can't block without evidence.
1723
+ if not branch_work:
1724
+ return SmokeVerdictStatus(ok=True, kind=SMOKE_OK)
1725
+
1726
+ current_base_sha = getattr(entry, "target_branch_head_sha", None)
1727
+ current_branch_sha = getattr(entry, "branch_head_sha", None)
1728
+ current_patch_id = getattr(entry, "branch_patch_id", None)
1729
+ repo_github = getattr(entry, "repo_github", None)
1730
+ entry_branch = getattr(entry, "branch", None)
1731
+ target_branch = getattr(entry, "target_branch", None)
1732
+ base_sha_attempted = current_base_sha is not None
1733
+ branch_sha_attempted = current_branch_sha is not None
1734
+ patch_id_attempted = current_patch_id is not None
1735
+
1736
+ # #1640: the first row rejected purely for staleness, so a refusal can
1737
+ # name the case ("recorded at X, base now Y") instead of claiming no
1738
+ # verdict exists. Only set when a terminal verdict was actually found —
1739
+ # a board with no terminal verdict at all stays SMOKE_MISSING.
1740
+ stale: SmokeVerdictStatus | None = None
1741
+
1742
+ # Work found — check whether any carries a fresh terminal smoke verdict.
1743
+ for a in branch_work:
1744
+ test_state = getattr(a, "test_state", None)
1745
+ if test_state not in ("passed", "skipped"):
1746
+ continue
1747
+
1748
+ # #1732: `skipped` is a structural claim about the *shape of the
1749
+ # diff* ("contract/fixture-only, nothing to smoke-test" — #1076/
1750
+ # #1152), not a claim about code behaving correctly at a particular
1751
+ # SHA. Unlike `passed`, it does not decay when the base or branch
1752
+ # moves — a rename upstream, or new commits on the branch, can't
1753
+ # turn "there is nothing here a smoke test could exercise" into
1754
+ # false. Only `passed` verdicts go through the #1479 base/branch
1755
+ # staleness check below; `skipped` is accepted unconditionally.
1756
+ if test_state == "skipped":
1757
+ return SmokeVerdictStatus(
1758
+ ok=True, kind=SMOKE_OK, assignment_id=getattr(a, "assignment_id", None)
1759
+ )
1760
+
1761
+ # Merge base moved: the tested combination (this branch + that base)
1762
+ # no longer exists, even if the branch's own diff is unchanged.
1763
+ test_base_sha = getattr(a, "test_base_sha", None)
1764
+ if (
1765
+ test_base_sha is not None
1766
+ and current_base_sha is None
1767
+ and not base_sha_attempted
1768
+ and gh_ops is not None
1769
+ and repo_github
1770
+ and target_branch
1771
+ ):
1772
+ try:
1773
+ current_base_sha = gh_ops.get_branch_sha(repo_github, target_branch)
1774
+ except Exception: # noqa: BLE001 — fail-safe: unknown SHA is not blocking
1775
+ current_base_sha = None
1776
+ base_sha_attempted = True
1777
+
1778
+ # #1738/#1778/#1847: the base moved, but a moved SHA doesn't
1779
+ # necessarily mean a content change that could affect a test result.
1780
+ # `_base_move_spared` tries, in order: is the base move itself
1781
+ # provably inert content (docs/scripts/issue-template only, #1738);
1782
+ # failing that, is *this branch*'s entire diff (as actually tested,
1783
+ # test_base_sha..test_head_sha) provably inert (#1778); failing that,
1784
+ # do the two diffs simply touch disjoint files (#1847) — a
1785
+ # substantive base move and a substantive branch that have nothing to
1786
+ # do with each other. Any one being true means the tested combination
1787
+ # is still covered — fall through to the branch-content check below
1788
+ # instead of staling here. If the branch has since gained real
1789
+ # content, that check still catches it independently via the
1790
+ # patch-id compare (#1847 doesn't short-circuit it).
1791
+ base_move_spare_reason: str | None = None
1792
+ if (
1793
+ test_base_sha is not None
1794
+ and current_base_sha is not None
1795
+ and test_base_sha != current_base_sha
1796
+ ):
1797
+ spared, base_move_spare_reason = _base_move_spared(
1798
+ gh_ops,
1799
+ repo_github,
1800
+ test_base_sha,
1801
+ current_base_sha,
1802
+ getattr(a, "test_head_sha", None),
1803
+ )
1804
+ if not spared:
1805
+ # stale: re-verify against the new base
1806
+ if stale is None:
1807
+ stale = SmokeVerdictStatus(
1808
+ ok=False,
1809
+ kind=SMOKE_STALE,
1810
+ assignment_id=getattr(a, "assignment_id", None),
1811
+ anchor="base",
1812
+ recorded_sha=test_base_sha,
1813
+ current_sha=current_base_sha,
1814
+ )
1815
+ continue
1816
+
1817
+ # Branch content changed since the test ran. Same SHA-then-patch-id
1818
+ # fallback as has_approved_review: a content-identical rebase (SHA
1819
+ # moved, patch-id didn't) does not invalidate the verdict.
1820
+ test_head_sha = getattr(a, "test_head_sha", None)
1821
+ if (
1822
+ test_head_sha is not None
1823
+ and current_branch_sha is None
1824
+ and not branch_sha_attempted
1825
+ and gh_ops is not None
1826
+ and repo_github
1827
+ and entry_branch
1828
+ ):
1829
+ try:
1830
+ current_branch_sha = gh_ops.get_branch_sha(repo_github, entry_branch)
1831
+ except Exception: # noqa: BLE001 — fail-safe: unknown SHA is not blocking
1832
+ current_branch_sha = None
1833
+ branch_sha_attempted = True
1834
+ if (
1835
+ test_head_sha is not None
1836
+ and current_branch_sha is not None
1837
+ and test_head_sha != current_branch_sha
1838
+ ):
1839
+ test_patch_id = getattr(a, "test_patch_id", None)
1840
+ if (
1841
+ test_patch_id is not None
1842
+ and current_patch_id is None
1843
+ and not patch_id_attempted
1844
+ and gh_ops is not None
1845
+ ):
1846
+ current_patch_id = _backfill_branch_patch_id(entry, gh_ops)
1847
+ patch_id_attempted = True
1848
+ if not (
1849
+ test_patch_id is not None
1850
+ and current_patch_id is not None
1851
+ and test_patch_id == current_patch_id
1852
+ ):
1853
+ # stale: branch content changed since the test ran
1854
+ if stale is None:
1855
+ stale = SmokeVerdictStatus(
1856
+ ok=False,
1857
+ kind=SMOKE_STALE,
1858
+ assignment_id=getattr(a, "assignment_id", None),
1859
+ anchor="branch",
1860
+ recorded_sha=test_head_sha,
1861
+ current_sha=current_branch_sha,
1862
+ )
1863
+ continue
1864
+
1865
+ return SmokeVerdictStatus(
1866
+ ok=True,
1867
+ kind=SMOKE_OK,
1868
+ assignment_id=getattr(a, "assignment_id", None),
1869
+ spared_reason=base_move_spare_reason,
1870
+ )
1871
+
1872
+ if stale is not None:
1873
+ return stale
1874
+
1875
+ # #1819: no terminal verdict — but is it MISSING, or abandoned? A row
1876
+ # pinned at the transient `running` marker whose Test worker is gone has a
1877
+ # verdict that will never arrive, which is a staleness problem with a
1878
+ # bounded automatic fix (re-run and re-record), not the #1640 "was a write
1879
+ # lost?" question `--revalidate` deliberately refuses to paper over.
1880
+ abandoned = _abandoned_running_marker(branch_work, board)
1881
+ if abandoned is not None:
1882
+ return SmokeVerdictStatus(
1883
+ ok=False,
1884
+ kind=SMOKE_STALE,
1885
+ assignment_id=getattr(abandoned, "assignment_id", None),
1886
+ anchor="run",
1887
+ )
1888
+
1889
+ return SmokeVerdictStatus(
1890
+ ok=False,
1891
+ kind=SMOKE_MISSING,
1892
+ assignment_id=getattr(branch_work[0], "assignment_id", None),
1893
+ )
1894
+
1895
+
1896
+ @dataclass(frozen=True)
1897
+ class RevalidationCandidate:
1898
+ """One queue entry that ``coord merge --revalidate`` may re-test (#1769).
1899
+
1900
+ Built only for entries blocked **solely** on a stale-but-``passed`` smoke
1901
+ verdict — see :func:`revalidation_candidates`, which is the whole of the
1902
+ eligibility policy. ``work_assignment_id`` is the row whose verdict has to
1903
+ be re-recorded for the entry to clear its gate (the one
1904
+ :func:`evaluate_smoke_verdict` named as carrying the stale verdict).
1905
+ """
1906
+
1907
+ entry: "QueuedMerge"
1908
+ work_assignment_id: str | None
1909
+ smoke: SmokeVerdictStatus
1910
+
1911
+
1912
+ def revalidation_candidates(
1913
+ items: Iterable["QueuedMerge"],
1914
+ board,
1915
+ config,
1916
+ gh_ops: "GhOps | None" = None,
1917
+ ) -> list[RevalidationCandidate]:
1918
+ """The subset of *items* ``--revalidate`` is allowed to re-test (#1769).
1919
+
1920
+ An entry qualifies **only** when every one of these holds:
1921
+
1922
+ * it is ``PENDING`` (a ``CONFLICT``/``HUMAN_REQUIRED``/``MERGED`` entry is
1923
+ never re-tested — a conflict is not a staleness problem);
1924
+ * the smoke gate applies to it (:func:`requires_smoke`) and
1925
+ :func:`evaluate_smoke_verdict` reports :data:`SMOKE_STALE` — i.e. a
1926
+ terminal ``passed`` verdict exists but was recorded against a branch/base
1927
+ combination that no longer exists. :data:`SMOKE_MISSING` is deliberately
1928
+ excluded: a re-test cannot safely paper over the #1640 "was a verdict
1929
+ ever written?" disagreement, and #1769's acceptance criteria name a
1930
+ genuinely-missing verdict as out of scope;
1931
+ * **no other gate is failing.** Concretely, the smoke failure is the only
1932
+ entry in :func:`merge_gate_failures`, so an entry that also needs a
1933
+ review is left alone. CI is not evaluated here (it needs a PR number and
1934
+ a live ``gh`` round trip); :func:`process` still enforces it afterwards,
1935
+ so a red-CI entry that was revalidated simply stays blocked on CI —
1936
+ it is never merged.
1937
+
1938
+ This is the *eligibility* half of ``--revalidate``. The re-test itself and
1939
+ the verdict write live in :mod:`coord.revalidate`; nothing here mutates
1940
+ anything, so it is safe to call from ``--dry-run``.
1941
+ """
1942
+ out: list[RevalidationCandidate] = []
1943
+ for entry in items:
1944
+ if getattr(entry, "state", None) != PENDING:
1945
+ continue
1946
+ if config is None or board is None:
1947
+ continue
1948
+ if not requires_smoke(entry, config):
1949
+ continue
1950
+ failures = merge_gate_failures(entry, config, board, gh_ops)
1951
+ # Blocked *solely* on smoke — a review/other block means a human (or
1952
+ # another stage) still owes this entry something a re-test can't give.
1953
+ if len(failures) != 1 or failures[0].gate != "smoke":
1954
+ continue
1955
+ smoke = evaluate_smoke_verdict(entry, board, gh_ops)
1956
+ if smoke.ok or smoke.kind != SMOKE_STALE:
1957
+ continue
1958
+ out.append(RevalidationCandidate(
1959
+ entry=entry,
1960
+ work_assignment_id=smoke.assignment_id,
1961
+ smoke=smoke,
1962
+ ))
1963
+ return out
1964
+
1965
+
1966
+ def ci_revalidation_candidates(
1967
+ items: Iterable["QueuedMerge"],
1968
+ board,
1969
+ config,
1970
+ ci_store: "CiStore | None",
1971
+ gh_ops: "GhOps | None" = None,
1972
+ ) -> list["QueuedMerge"]:
1973
+ """The subset of *items* ``--revalidate`` may trigger a CI re-run for
1974
+ (#1851) — the CI analogue of :func:`revalidation_candidates`.
1975
+
1976
+ An entry qualifies only when it is ``PENDING`` and
1977
+ :func:`_entry_gate_status` blocks it for CI staleness specifically (a
1978
+ reason starting with :data:`CI_STALE_PREFIX`) — which only happens after
1979
+ every gate ahead of CI in that function's evaluation order (review,
1980
+ smoke) has already passed, and the CI checks themselves are neither
1981
+ failed nor still running, only stale. This mirrors
1982
+ :func:`revalidation_candidates`'s "blocked *solely* on..." policy: an
1983
+ entry that also needs a review or a fresh local Test verdict is left
1984
+ alone here exactly as it already is there — a re-run is never offered as
1985
+ a distraction from a block it can't resolve.
1986
+
1987
+ Returns the raw :class:`QueuedMerge` entries (unlike
1988
+ :class:`RevalidationCandidate`, there is no local verdict to re-record —
1989
+ the remedy is :meth:`coord.ci_store.CiStore.rerun_for_pr`, keyed off
1990
+ ``entry.repo_github``/``entry.pr_number`` alone).
1991
+ """
1992
+ if ci_store is None or not ci_store.is_available:
1993
+ return []
1994
+ out: list["QueuedMerge"] = []
1995
+ for entry in items:
1996
+ if getattr(entry, "state", None) != PENDING:
1997
+ continue
1998
+ if not getattr(entry, "pr_number", None):
1999
+ continue
2000
+ status, reason = _entry_gate_status(entry, board, config, ci_store, gh_ops)
2001
+ if status == PLAN_BLOCKED and (reason or "").startswith(CI_STALE_PREFIX):
2002
+ out.append(entry)
2003
+ return out
2004
+
2005
+
2006
+ # Stored error strings that only reflect the gate state *at the moment a
2007
+ # merge attempt ran* (`process()`) — nothing clears them when the approval or
2008
+ # verdict they're waiting on lands outside of a merge attempt (a normal
2009
+ # interactive review, no `coord merge`/auto-loop tick in between). See #420.
2010
+ _STALE_GATE_ERRORS = frozenset({
2011
+ "review required but not approved",
2012
+ "review required but board unavailable to confirm approval",
2013
+ "smoke test required but no verdict recorded",
2014
+ "smoke test required but board unavailable to confirm verdict",
2015
+ })
2016
+
2017
+ # #1640: the stale-verdict wording carries live SHAs, so it can't be matched
2018
+ # by equality against a fixed set. It goes stale for exactly the same reason
2019
+ # the strings above do (recording a fresh verdict clears the condition
2020
+ # without any merge attempt running), so it gets the same recomputation.
2021
+ _STALE_GATE_ERROR_PREFIXES = ("smoke test verdict is stale:",)
2022
+
2023
+ # #2085: the honest third answer for the review gate on a read-only surface —
2024
+ # neither "not approved" (unconfirmed failure) nor cleared (unconfirmed
2025
+ # success). See `display_error`.
2026
+ REVIEW_UNCONFIRMED_ERROR = (
2027
+ "review approved but not yet confirmed against the branch head"
2028
+ )
2029
+
2030
+
2031
+ def _is_recomputable_gate_error(err: str | None) -> bool:
2032
+ """True when *err* is a gate refusal :func:`display_error` may recompute."""
2033
+ if not err:
2034
+ return False
2035
+ return err in _STALE_GATE_ERRORS or err.startswith(_STALE_GATE_ERROR_PREFIXES)
2036
+
2037
+
2038
+ def display_error(entry: "QueuedMerge", board, config) -> str | None:
2039
+ """Return the error to show for *entry* in a read-only display (``coord
2040
+ status``, dashboards) — recomputing the review/smoke gates live instead
2041
+ of trusting the stored ``entry.error`` string verbatim.
2042
+
2043
+ #420: ``entry.error`` is only refreshed by :func:`process` (a real merge
2044
+ attempt) or ``refresh_entry_assignment``. When a review approves — or a
2045
+ smoke verdict is recorded — through the normal path (no ``coord merge``
2046
+ run, no auto-loop tick in between), nothing clears the stored string, so
2047
+ a mergeable entry can keep showing e.g. "review required but not
2048
+ approved" indefinitely. Left unchecked this invites operators to bounce
2049
+ already-approved work back for another round (the #410 real-world case).
2050
+
2051
+ Only the two gate messages known to go stale this way are recomputed
2052
+ here, and recomputation is pure board/config lookups — no I/O. Every
2053
+ other stored error (merge conflicts, CI check results) reflects the
2054
+ outcome of the *last actual attempt* and is left untouched; re-checking
2055
+ CI on every ``coord status`` would mean a live ``gh`` call per queue
2056
+ entry just to render a status line.
2057
+ """
2058
+ if not _is_recomputable_gate_error(entry.error):
2059
+ return entry.error
2060
+ if board is None or config is None:
2061
+ # Can't recompute without both — fall back to the stored string.
2062
+ return entry.error
2063
+ if entry.error.startswith("review"):
2064
+ if not requires_review(entry, config):
2065
+ return None
2066
+ scan = scan_approved_reviews(entry, board)
2067
+ if scan.approved:
2068
+ return None
2069
+ if scan.unknown_head:
2070
+ # #2085 review follow-up: an approval exists and carries a
2071
+ # `review_head_sha`, but this entry's `branch_head_sha` is still
2072
+ # None — it's only populated once a live `process()`/`plan()`
2073
+ # tick has touched the row, so a freshly-approved entry hasn't
2074
+ # got one yet. This recompute is deliberately I/O-free (see
2075
+ # above), so it cannot resolve the freshness question; the
2076
+ # stored "review required but not approved" would be an
2077
+ # unconfirmed *failure* verdict, and clearing it outright would
2078
+ # be an unconfirmed *success* one (the #1640 trap in the smoke
2079
+ # branch below). Say what is actually known instead — this
2080
+ # self-heals to a definite answer on the next live tick.
2081
+ return REVIEW_UNCONFIRMED_ERROR
2082
+ return entry.error
2083
+ if entry.error.startswith("smoke"):
2084
+ if not requires_smoke(entry, config):
2085
+ return None
2086
+ smoke = evaluate_smoke_verdict(entry, board)
2087
+ if not smoke.ok:
2088
+ # Prefer the freshly-computed wording — it names stale-vs-missing
2089
+ # (#1640) even when the stored string predates that distinction.
2090
+ return smoke.message
2091
+ # #1640: this recomputation is deliberately I/O-free (no gh_ops), so
2092
+ # the #1479 freshness anchors are only populated on an entry that a
2093
+ # live `process()` pass already backfilled. Without them "ok" means
2094
+ # "found a terminal verdict", NOT "found a fresh one" — so a stored
2095
+ # staleness refusal must not be cleared on that evidence, or this
2096
+ # read-only surface starts showing green for exactly the entry
2097
+ # `coord merge` refuses. The plain "no verdict recorded" string keeps
2098
+ # its original #420 clear-on-recompute behaviour.
2099
+ if entry.error.startswith(_STALE_GATE_ERROR_PREFIXES):
2100
+ return entry.error
2101
+ return None
2102
+ return entry.error # pragma: no cover — unreachable, kept for safety
2103
+
2104
+
2105
+ @dataclass
2106
+ class QueuedMerge:
2107
+ assignment_id: str
2108
+ repo_name: str
2109
+ repo_github: str
2110
+ branch: str
2111
+ target_branch: str
2112
+ issue_number: int
2113
+ issue_title: str
2114
+ state: str = PENDING
2115
+ pr_number: int | None = None
2116
+ pr_url: str | None = None
2117
+ size: int | None = None
2118
+ last_attempt: float | None = None
2119
+ error: str | None = None
2120
+ enqueued_at: float | None = None
2121
+ # #821: current branch HEAD SHA, populated at process() time from GitHub.
2122
+ # When set, `has_approved_review` checks it against the review assignment's
2123
+ # `review_head_sha` to detect stale approvals (commits pushed after review).
2124
+ # None means SHA tracking is not available for this entry.
2125
+ branch_head_sha: str | None = None
2126
+ # #1475: current content-addressed patch-id for the branch's diff against
2127
+ # `target_branch`, populated at process() time alongside branch_head_sha.
2128
+ # `has_approved_review` falls back to comparing this against the review's
2129
+ # `review_patch_id` when the SHAs differ (e.g. a conflict-fix rebase) —
2130
+ # identical patch-id means the rebase changed no content, so the approval
2131
+ # still covers it. None means patch-id tracking is not available (fails
2132
+ # closed to the pre-#1475 SHA-only staleness check).
2133
+ branch_patch_id: str | None = None
2134
+ # #1479: current HEAD SHA of `target_branch` itself, populated at
2135
+ # process() time alongside branch_head_sha/branch_patch_id.
2136
+ # `has_smoke_verdict` compares this against the test verdict's recorded
2137
+ # `test_base_sha` to detect a merge base that moved since the test ran —
2138
+ # a condition `branch_patch_id` (the branch's own content fingerprint)
2139
+ # cannot see, since a rebase replays the identical diff onto a new base
2140
+ # without changing it. None means base-SHA tracking is not available for
2141
+ # this entry (transient, like branch_head_sha/branch_patch_id — never
2142
+ # persisted to the queue DB).
2143
+ target_branch_head_sha: str | None = None
2144
+ # #1077: the originating assignment's `type` (e.g. "work", "mock-author"),
2145
+ # captured at enqueue time. Drives both the PR-body "Closes #N" vs
2146
+ # "Refs #N" keyword (`_briefing_body`) and whether `process()` closes
2147
+ # `issue_number` deterministically after merge — see
2148
+ # `coord.models.CLOSES_ISSUE_TYPES`. Defaults to "work" for entries
2149
+ # created before this field existed (preserves prior close-on-merge
2150
+ # behavior for old rows).
2151
+ assignment_type: str = "work"
2152
+ # #1213: snapshot of the originating assignment's resolved
2153
+ # required_gates (from config.pipeline.labels via a matching GitHub
2154
+ # issue label, or [] for "no override"), captured at enqueue() time.
2155
+ # requires_review/requires_smoke read this — falling back to
2156
+ # config.pipeline.default_gates when empty — instead of re-resolving
2157
+ # from the live board at merge time, so the effective gate policy for
2158
+ # an entry is commit-bound to when it was enqueued. [] (the default)
2159
+ # means "no override" for both fresh entries and rows predating this
2160
+ # column (NULL decodes to []) — both fall back identically.
2161
+ required_gates: list[str] = field(default_factory=list)
2162
+ # #1892: count of automatic `CiStore.rerun_for_pr` calls `process()` has
2163
+ # issued for this entry's CURRENT run of verdictless CI failures —
2164
+ # capped at `MAX_CI_INFRA_RERUNS`. Persists across ticks (unlike the
2165
+ # transient `branch_head_sha`/`branch_patch_id` fields above) because the
2166
+ # whole point is a durable ceiling: a workflow broken at "Set up job"
2167
+ # must stop auto-rerunning and park for a human, not retry every tick
2168
+ # forever. 0 for every entry that has never hit a verdictless failure,
2169
+ # and for rows predating this column.
2170
+ ci_infra_reruns: int = 0
2171
+
2172
+
2173
+ class GhOps(Protocol):
2174
+ """Minimal interface the queue needs from github_ops. Tests pass a stub."""
2175
+
2176
+ def create_pr(
2177
+ self, repo: str, *, base: str, head: str, title: str, body: str
2178
+ ) -> dict: ...
2179
+
2180
+ def get_pr_size(self, repo: str, number: int) -> int: ...
2181
+
2182
+ def merge_pr(self, repo: str, number: int, method: str = "rebase") -> tuple[bool, str]: ...
2183
+
2184
+ def close_issue(self, repo: str, issue_number: int) -> None: ...
2185
+
2186
+ def get_pr_body(self, repo: str, number: int) -> str:
2187
+ """Return PR *number*'s current body text (#1196, PR-body lint)."""
2188
+ ...
2189
+
2190
+ def edit_pr_body(self, repo: str, number: int, body: str) -> None:
2191
+ """Overwrite PR *number*'s body text (#1196, PR-body lint)."""
2192
+ ...
2193
+
2194
+ def has_open_children(self, repo: str, issue_number: int) -> bool:
2195
+ """True when *issue_number* has an open child (#1196)."""
2196
+ ...
2197
+
2198
+ def is_epic_issue(self, repo: str, issue_number: int) -> bool:
2199
+ """True when *issue_number* carries the tracking/epic label (#1318)."""
2200
+ ...
2201
+
2202
+ def get_pr_commit_messages(self, repo: str, number: int) -> list[str]:
2203
+ """Return every commit message on PR *number* (#1318, epic guard)."""
2204
+ ...
2205
+
2206
+ def get_branch_sha(self, repo: str, branch: str) -> str | None:
2207
+ """Return the current HEAD SHA for *branch*, or None on failure.
2208
+
2209
+ Used to populate ``QueuedMerge.branch_head_sha`` at process() time so
2210
+ ``has_approved_review`` can reject stale approvals (#821). Returning
2211
+ ``None`` (on any network/auth failure) is safe — the staleness check
2212
+ is skipped for rows without a SHA, preserving backward compatibility.
2213
+ """
2214
+ ...
2215
+
2216
+ def get_branch_patch_id(self, repo: str, base: str, branch: str) -> str | None:
2217
+ """Return the content-addressed patch-id for *branch*'s diff against
2218
+ *base*, or None on failure.
2219
+
2220
+ Used to populate ``QueuedMerge.branch_patch_id`` at process() time so
2221
+ ``has_approved_review`` can carry an approval forward across a pure
2222
+ rebase (#1475) even though the branch's HEAD SHA changed. Returning
2223
+ ``None`` is safe — the gate falls back to the pre-#1475 SHA-only
2224
+ staleness check.
2225
+ """
2226
+ ...
2227
+
2228
+ def get_compare_files(self, repo: str, base: str, head: str) -> list[str] | None:
2229
+ """Return the file paths changed in the three-dot *base*...*head*
2230
+ compare, or None on failure.
2231
+
2232
+ #1738: used by :func:`_base_move_is_inert` to tell a content-
2233
+ irrelevant base move (docs/scripts/issue-template only) from one that
2234
+ could actually affect a test result, before staling an otherwise-
2235
+ fresh smoke verdict just because the merge base's SHA moved.
2236
+
2237
+ #1778: also used by :func:`_branch_is_inert`, the mirror check — is
2238
+ the *branch's* own diff (not the base move) entirely inert, so a
2239
+ base move (however substantive) doesn't need to re-verify it either.
2240
+
2241
+ #1847: both file lists (fetched once each, via
2242
+ :func:`_fetch_compare_files`) also feed
2243
+ :func:`_base_move_disjoint_from_branch` — the third #1479 escape
2244
+ hatch, sparing a verdict when the two diffs simply touch no files in
2245
+ common, independent of either being inert on its own.
2246
+ """
2247
+ ...
2248
+
2249
+ def check_pr_mergeable(self, repo: str, number: int) -> bool | None:
2250
+ """Return GitHub's current mergeability verdict for PR *number*.
2251
+
2252
+ ``True`` when cleanly mergeable, ``False`` when conflicting, ``None``
2253
+ when unknown (still computing, or the check itself failed). Used by
2254
+ :func:`reconcile_conflict_entries` (#1477) to re-test a parked
2255
+ ``CONFLICT`` entry rather than trusting the cached verdict from
2256
+ whenever the queue last attempted it.
2257
+ """
2258
+ ...
2259
+
2260
+ def branch_has_merge_commit(self, repo: str, number: int) -> bool | None:
2261
+ """True when any commit on PR *number* has more than one parent.
2262
+
2263
+ ``True``/``False`` when determined, ``None`` when it can't be (any
2264
+ ``gh`` error, or a malformed response) — an inconclusive read, same
2265
+ fail-closed contract as :meth:`check_pr_mergeable`. Used by
2266
+ :func:`process` (#1467) to fall back from ``--rebase`` to
2267
+ ``--squash`` before attempting a merge GitHub would otherwise refuse
2268
+ with "This branch can't be rebased", and by
2269
+ :func:`reconcile_conflict_entries` to avoid unparking an entry whose
2270
+ rebase-refusal will deterministically recur.
2271
+
2272
+ Optional on stub ``GhOps`` implementations: callers detect support
2273
+ via ``getattr(gh_ops, "branch_has_merge_commit", None)`` and treat a
2274
+ missing method the same as an inconclusive (``None``) result, so
2275
+ existing test stubs that predate #1467 keep working unmodified.
2276
+ """
2277
+ ...
2278
+
2279
+ def find_pr_for_branch(self, repo: str, branch: str) -> dict | None:
2280
+ """Return the open PR whose head ref is *branch*, or ``None``.
2281
+
2282
+ Used by :func:`process` (#1624) to resolve an entry's real PR in the
2283
+ ``dry_run`` path — mirroring what ``create_pr`` already does
2284
+ internally on the real path — so a branch with an already-open PR is
2285
+ reported as ``PR #N (existed)`` instead of ``would open PR``, and so
2286
+ the CI gate below has a real PR number to evaluate against instead of
2287
+ silently skipping.
2288
+
2289
+ Optional on stub ``GhOps`` implementations, same contract as
2290
+ :meth:`branch_has_merge_commit`: callers detect support via
2291
+ ``getattr(gh_ops, "find_pr_for_branch", None)`` and treat a missing
2292
+ method (or a lookup failure) the same as "no PR found" — fail closed,
2293
+ never assume a PR exists that couldn't be confirmed.
2294
+ """
2295
+ ...
2296
+
2297
+
2298
+ def live_gate_entry(
2299
+ a: Assignment,
2300
+ repo_github: str,
2301
+ target_branch: str,
2302
+ gh_ops: "GhOps | None",
2303
+ ) -> QueuedMerge:
2304
+ """Build a synthetic, never-persisted :class:`QueuedMerge` from a raw
2305
+ work :class:`~coord.models.Assignment` *a*, with the #821/#1475/#1479
2306
+ freshness anchors (``branch_head_sha``, ``branch_patch_id``,
2307
+ ``target_branch_head_sha``) populated LIVE via *gh_ops* when supplied.
2308
+
2309
+ #2085: :func:`has_approved_review` / :func:`evaluate_smoke_verdict` read
2310
+ those anchors straight off whatever *entry* they're handed — a real
2311
+ ``QueuedMerge`` only carries them once :func:`process` has run at least
2312
+ once. Any caller that needs to gate-check a raw work ``Assignment``
2313
+ *before* it has gone through ``process()`` — the daemon's passive-tick
2314
+ :func:`enqueue_approved_work`, ``coord.notify``'s stalled-dispatch
2315
+ recovery, ``coord.diagnose``'s stage-work recovery, and ``coord.commands.
2316
+ merge``'s auto-enqueue scan — used to hand ``has_approved_review`` the
2317
+ bare ``Assignment`` directly. It has no ``branch_head_sha`` attribute at
2318
+ all, so ``getattr(entry, "branch_head_sha", None)`` always read ``None``;
2319
+ since #2085 made an unconfirmed SHA fail CLOSED (previously it fell
2320
+ open), that made a review carrying a real ``review_head_sha`` —
2321
+ virtually every modern approval — permanently unconfirmable from any of
2322
+ those call sites, not just the superseded-approval case #2085 was filed
2323
+ about. Routing through this helper first — mirroring the construction
2324
+ :func:`coord.gates.build_gate_report` already used inline for the same
2325
+ reason — gives those callers the same live SHA a real ``coord merge``
2326
+ run would see, so a genuinely fresh approval can still confirm.
2327
+
2328
+ This is now the ONE place that construction happens — ``build_gate_report``
2329
+ was refactored to call this too (#2096: two surfaces answering "is this
2330
+ entry's approval still fresh" must call one function, not reimplement it
2331
+ twice and risk drifting apart).
2332
+
2333
+ *gh_ops* ``None`` skips every live lookup (fails open on the freshness
2334
+ anchors themselves, exactly as ``build_gate_report`` does with no live
2335
+ client) — the resulting entry still lets a review with no
2336
+ ``review_head_sha`` at all take the legacy no-SHA-to-compare path, but
2337
+ correctly fails closed for one that has a SHA and can't be confirmed.
2338
+ """
2339
+ entry = QueuedMerge(
2340
+ assignment_id=a.assignment_id or "",
2341
+ repo_name=a.repo_name,
2342
+ repo_github=repo_github,
2343
+ branch=a.branch or "",
2344
+ target_branch=target_branch,
2345
+ issue_number=getattr(a, "issue_number", 0) or 0,
2346
+ issue_title=getattr(a, "issue_title", None) or "",
2347
+ assignment_type=getattr(a, "type", None) or "work",
2348
+ required_gates=list(getattr(a, "required_gates", None) or []),
2349
+ )
2350
+ if gh_ops is not None and entry.branch:
2351
+ try:
2352
+ entry.branch_head_sha = gh_ops.get_branch_sha(entry.repo_github, entry.branch)
2353
+ # #2085: NOT "fail-open, unknown SHA isn't blocking" — an unknown
2354
+ # branch_head_sha now fails has_approved_review CLOSED (not open)
2355
+ # for any review that carries a review_head_sha to compare against.
2356
+ # A transient gh error here degrades to the same conservative
2357
+ # refusal as gh_ops=None, never a silent pass.
2358
+ except Exception: # noqa: BLE001
2359
+ entry.branch_head_sha = None
2360
+ try:
2361
+ entry.target_branch_head_sha = gh_ops.get_branch_sha(
2362
+ entry.repo_github, entry.target_branch
2363
+ )
2364
+ except Exception: # noqa: BLE001
2365
+ entry.target_branch_head_sha = None
2366
+ try:
2367
+ entry.branch_patch_id = gh_ops.get_branch_patch_id(
2368
+ entry.repo_github, entry.target_branch, entry.branch
2369
+ )
2370
+ except Exception: # noqa: BLE001
2371
+ entry.branch_patch_id = None
2372
+ return entry
2373
+
2374
+
2375
+ # ── Persistence ──────────────────────────────────────────────────────────
2376
+
2377
+ def load_queue() -> list[QueuedMerge]:
2378
+ """Load all merge queue entries from the database."""
2379
+ conn = get_connection()
2380
+ rows = conn.execute(
2381
+ "SELECT * FROM merge_queue ORDER BY id"
2382
+ ).fetchall()
2383
+ return [
2384
+ QueuedMerge(
2385
+ assignment_id=row["assignment_id"],
2386
+ repo_name=row["repo_name"],
2387
+ repo_github=row["repo_github"],
2388
+ branch=row["branch"],
2389
+ target_branch=row["target_branch"],
2390
+ issue_number=row["issue_number"],
2391
+ issue_title=row["issue_title"],
2392
+ state=row["state"],
2393
+ pr_number=row["pr_number"],
2394
+ pr_url=row["pr_url"],
2395
+ size=row["size"],
2396
+ last_attempt=row["last_attempt"],
2397
+ error=row["error"],
2398
+ enqueued_at=row["enqueued_at"],
2399
+ # #1077: column added via migration; rows written before it
2400
+ # existed read back as NULL, so fall back to "work" (the
2401
+ # pre-existing close-on-merge behavior for those entries).
2402
+ assignment_type=row["assignment_type"] or "work",
2403
+ # #1213: column added via migration; NULL (pre-migration rows)
2404
+ # and '[]' (explicit "no override") both decode to [] — the
2405
+ # gate falls back to config.pipeline.default_gates for either.
2406
+ required_gates=json.loads(row["required_gates"]) if row["required_gates"] else [],
2407
+ # #1892: column added via migration; NULL (pre-migration rows)
2408
+ # decodes to 0 — no auto-reruns spent yet, same as a fresh entry.
2409
+ ci_infra_reruns=row["ci_infra_reruns"] or 0,
2410
+ )
2411
+ for row in rows
2412
+ ]
2413
+
2414
+
2415
+ def save_queue(items: list[QueuedMerge]) -> None:
2416
+ """Replace the entire merge queue in the database."""
2417
+ conn = get_connection()
2418
+ with conn:
2419
+ conn.execute("DELETE FROM merge_queue")
2420
+ for item in items:
2421
+ conn.execute(
2422
+ """INSERT INTO merge_queue (
2423
+ assignment_id, repo_name, repo_github, branch,
2424
+ target_branch, issue_number, issue_title, state,
2425
+ pr_number, pr_url, size, last_attempt, error, enqueued_at,
2426
+ assignment_type, required_gates, ci_infra_reruns
2427
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
2428
+ (
2429
+ item.assignment_id, item.repo_name, item.repo_github,
2430
+ item.branch, item.target_branch, item.issue_number,
2431
+ item.issue_title, item.state, item.pr_number, item.pr_url,
2432
+ item.size, item.last_attempt, item.error, item.enqueued_at,
2433
+ item.assignment_type, json.dumps(list(item.required_gates or [])),
2434
+ item.ci_infra_reruns,
2435
+ ),
2436
+ )
2437
+
2438
+
2439
+ # ── Enqueue ──────────────────────────────────────────────────────────────
2440
+
2441
+ def enqueue(
2442
+ assignment: Assignment,
2443
+ repo_github: str,
2444
+ target_branch: str,
2445
+ config=None,
2446
+ board=None,
2447
+ gh_ops: "GhOps | None" = None,
2448
+ ) -> QueuedMerge | None:
2449
+ """Add a completed assignment to the queue if it isn't already there.
2450
+
2451
+ Returns the new entry, or None if it was already queued, has no branch,
2452
+ or (#946) *config* was supplied and ``passes_merge_gates`` rejects it —
2453
+ i.e. review/smoke are required but not yet satisfied. ``config`` (and
2454
+ ``board``) are optional and default to ``None`` for backward
2455
+ compatibility with existing callers (notably tests that seed the queue
2456
+ directly); passing ``None`` skips the gate check entirely rather than
2457
+ failing closed, since without a config there's no way to know which
2458
+ gates apply.
2459
+
2460
+ #2085: when *config* IS supplied, the gate check runs against
2461
+ :func:`live_gate_entry` — never against the raw *assignment*. An
2462
+ ``Assignment`` has no ``branch_head_sha``/``branch_patch_id`` attribute
2463
+ at all, so handing it straight to :func:`passes_merge_gates` made
2464
+ :func:`has_approved_review`'s #821 freshness check permanently
2465
+ *unconfirmable*: every review carrying a real ``review_head_sha`` (i.e.
2466
+ virtually every modern approval) failed closed, turning this gate into
2467
+ one that can never pass. ``repo_github`` and ``target_branch`` are
2468
+ already parameters here, so the confirmation data is built rather than
2469
+ demanded — a caller cannot reintroduce that regression by forgetting to
2470
+ thread *gh_ops* through, which is exactly how the dashboard's enqueue
2471
+ path was missed when the other four raw-``Assignment`` gate call sites
2472
+ were fixed.
2473
+
2474
+ *gh_ops* defaults to the live :mod:`coord.github_ops` module (only
2475
+ consulted when *config* is set, so a gate-less enqueue stays as
2476
+ I/O-light as before, and only alongside the ``get_branch_diff_size``
2477
+ call this function already makes). Pass an explicit stub to inject a
2478
+ fake; a lookup failure degrades to the conservative "unconfirmed"
2479
+ refusal, never a silent pass.
2480
+
2481
+ Dedup is by ``(repo_github, branch)`` — the queue's natural key is the
2482
+ branch we'd merge, not the assignment_id. Multiple work assignments
2483
+ routinely target the same branch (original + fix-1 in the auto-loop,
2484
+ original + PR-creator from ``coord pr``); they should not produce
2485
+ duplicate rows. (#274)
2486
+ """
2487
+ if not assignment.branch:
2488
+ return None
2489
+ if config is not None:
2490
+ if gh_ops is None:
2491
+ from coord import github_ops as _live_gh # noqa: PLC0415
2492
+
2493
+ gh_ops = _live_gh
2494
+ gate_entry = live_gate_entry(assignment, repo_github, target_branch, gh_ops)
2495
+ if not passes_merge_gates(gate_entry, config, board, gh_ops=gh_ops):
2496
+ return None
2497
+ items = load_queue()
2498
+ if any(
2499
+ x.assignment_id == assignment.assignment_id
2500
+ or (x.repo_github == repo_github and x.branch == assignment.branch)
2501
+ for x in items
2502
+ ):
2503
+ return None
2504
+ # #776: populate size eagerly at enqueue time via the compare API so the
2505
+ # displayed order matches the merge order without waiting for a PR to be
2506
+ # opened. Fail-open: size=None keeps the entry at the back of the queue.
2507
+ from coord import github_ops as _gho # noqa: PLC0415
2508
+ try:
2509
+ diff_size: int | None = _gho.get_branch_diff_size(
2510
+ repo_github, target_branch, assignment.branch
2511
+ ) or None
2512
+ except Exception: # noqa: BLE001
2513
+ diff_size = None
2514
+
2515
+ entry = QueuedMerge(
2516
+ assignment_id=assignment.assignment_id or "",
2517
+ repo_name=assignment.repo_name,
2518
+ repo_github=repo_github,
2519
+ branch=assignment.branch,
2520
+ target_branch=target_branch,
2521
+ issue_number=assignment.issue_number,
2522
+ issue_title=assignment.issue_title,
2523
+ size=diff_size,
2524
+ enqueued_at=time.time(),
2525
+ assignment_type=assignment.type,
2526
+ # #1213: snapshot the resolved gate list at enqueue time (commit-
2527
+ # bound) rather than leaving requires_review/requires_smoke to
2528
+ # re-resolve it from the live board later.
2529
+ required_gates=list(assignment.required_gates or []),
2530
+ )
2531
+ items.append(entry)
2532
+ save_queue(items)
2533
+ return entry
2534
+
2535
+
2536
+ def enqueue_approved_work(config, board=None) -> list[str]:
2537
+ """Enqueue / re-key merge-queue entries for all approved + tested done work.
2538
+
2539
+ Scans ``board.completed`` for done assignments whose ``type`` is in
2540
+ :data:`coord.models.WORK_LIKE_TYPES` (``"work"`` or ``"mock-author"``,
2541
+ #930) and, for each that satisfies ALL three conditions:
2542
+
2543
+ 1. Review gate OK — ``requires_review(a, config)`` is False, **or** an
2544
+ approved review exists on the board (``has_approved_review``) that
2545
+ still covers the branch's LIVE current head (#2085: confirmed via
2546
+ :func:`live_gate_entry`, not the raw ``Assignment``, which has no SHA
2547
+ to compare against).
2548
+ 2. Smoke gate OK — ``requires_smoke(a, config)`` is False, **or** the
2549
+ work assignment carries a ``test_state in ('passed', 'skipped')``
2550
+ verdict (``has_smoke_verdict``).
2551
+ 3. Not terminal on GitHub — ``work_is_terminal`` returns False (issue still
2552
+ open, or the PR that merged is not this branch's *current* commit —
2553
+ #1150: a historical merge on a reused branch, e.g. from ``--fix-of``
2554
+ continuing on the same branch, must not block enqueue of new commits
2555
+ pushed on top of it). This is checked directly against GitHub per
2556
+ assignment rather than via a queue-derived "already merged" shortcut,
2557
+ since a MERGED entry for the same ``(repo, issue)`` pair may belong to
2558
+ an entirely different branch/commit than the one being considered here.
2559
+
2560
+ …calls :func:`refresh_entry_assignment` so the entry is **created** (when
2561
+ the work was never enqueued) or **re-keyed** to the latest fix assignment
2562
+ (the #292 bounce fix). :func:`enqueue` is *not* used because it cannot
2563
+ update an existing entry's ``assignment_id``; ``refresh_entry_assignment``
2564
+ handles both cases.
2565
+
2566
+ Idempotent: a second call with the same board produces no further changes
2567
+ (``refresh_entry_assignment`` is a no-op when the entry already exists and
2568
+ is keyed correctly).
2569
+
2570
+ #1490: a fix/bounce cycle piles up more than one ``WORK_LIKE_TYPES`` row
2571
+ on the *same* branch (the original dispatch plus every retry), and each
2572
+ stays in ``board.completed`` forever. Processing every such row
2573
+ independently — the pre-#1490 behaviour — re-keyed the branch's one
2574
+ queue entry once per row, every single tick, because the review/smoke
2575
+ gates are resolved over the whole branch chain (so even a *failed*-test
2576
+ row passes them) and there was nothing to stop each row's turn from
2577
+ winning the re-key. :func:`group_branch_candidates` now resolves every
2578
+ branch to a single winner up front (the most-recently-dispatched row
2579
+ with a passed/skipped verdict — falling back to the most recent row
2580
+ overall when none has passed yet); every other row on that branch is
2581
+ logged (:func:`_log_superseded`) and never touches the queue.
2582
+
2583
+ Returns a list of assignment IDs for which an entry was created or updated.
2584
+ Call sites use this list for diagnostic logging; callers that don't need it
2585
+ can discard the return value.
2586
+
2587
+ Called from the daemon passive tick (:func:`coord.serve_app._passive_tick`)
2588
+ on every interval so approved work enters the queue without requiring a
2589
+ manual ``coord merge`` run (#736 / #217 invisible limbo).
2590
+ """
2591
+ from coord import github_ops as _gho # noqa: PLC0415
2592
+
2593
+ if board is None:
2594
+ from coord.state import build_board as _build_board # noqa: PLC0415
2595
+ board = _build_board()
2596
+
2597
+ changed: list[str] = []
2598
+ terminal_cache: dict = {}
2599
+ milestone_cache: dict = {}
2600
+
2601
+ completed = list(getattr(board, "completed", []) or [])
2602
+ existing_queue = load_queue()
2603
+
2604
+ for a, superseded in group_branch_candidates(completed):
2605
+ for row in superseded:
2606
+ _log_superseded(row)
2607
+
2608
+ branch = a.branch
2609
+ aid = a.assignment_id
2610
+ repo_name = a.repo_name
2611
+ repo_cfg = config.repo(repo_name)
2612
+ if repo_cfg is None:
2613
+ continue
2614
+
2615
+ # Skip if the assignment is already in the queue under its own ID.
2616
+ # refresh_entry_assignment would create a second entry when no entry
2617
+ # exists with a matching branch, even if one exists with the same
2618
+ # assignment_id (e.g. seeded with a different branch in the queue).
2619
+ # This guard prevents double-entries; re-keying is still handled
2620
+ # because for fix-work the new aid is NOT yet in the queue.
2621
+ if any(x.assignment_id == aid for x in existing_queue):
2622
+ continue
2623
+
2624
+ # #934: target the milestone's `feature/ms-NN` branch, not
2625
+ # `default_branch`, when this issue belongs to a milestone and the
2626
+ # repo opted into the develop + feature-branch-per-milestone git
2627
+ # model. The milestone lookup itself is skipped entirely (no `gh`
2628
+ # call) when the repo hasn't opted in — fails open to
2629
+ # `default_branch`, today's behavior, unchanged.
2630
+ # #2085: resolved BEFORE the gate check now (it used to run after) —
2631
+ # `live_gate_entry` below needs a target_branch to populate the
2632
+ # #821/#1479 freshness anchors live.
2633
+ from coord.branch_model import resolve_base_branch_for_issue_number # noqa: PLC0415
2634
+
2635
+ target_branch = resolve_base_branch_for_issue_number(
2636
+ repo_cfg,
2637
+ repo_cfg.github,
2638
+ getattr(a, "issue_number", 0),
2639
+ cache=milestone_cache,
2640
+ )
2641
+
2642
+ # Gates 1+2: review + smoke, via the shared predicate (#946) so this
2643
+ # path stays in lockstep with the `coord merge` auto-enqueue loop and
2644
+ # the raw `enqueue()` helper. Only blocks when a gate is configured
2645
+ # AND not satisfied — passes_merge_gates itself no-ops a disabled gate.
2646
+ #
2647
+ # #2085: `a` is a raw work Assignment — it has no `branch_head_sha`/
2648
+ # `branch_patch_id`/`repo_github`/`target_branch` attribute at all,
2649
+ # so handing it straight to `passes_merge_gates` made
2650
+ # `has_approved_review`'s #821 SHA-freshness check permanently
2651
+ # unconfirmable (fails closed on every review that carries a real
2652
+ # `review_head_sha` — i.e. virtually every modern approval, not just
2653
+ # the superseded-approval case this gate exists to catch).
2654
+ # `live_gate_entry` builds the same live-anchored synthetic entry
2655
+ # `coord.gates.build_gate_report` uses, so a genuinely fresh approval
2656
+ # can still be confirmed via `_gho` (already available in this
2657
+ # function for the terminal-state check below).
2658
+ gate_entry = live_gate_entry(a, repo_cfg.github, target_branch, _gho)
2659
+ if not passes_merge_gates(gate_entry, config, board, gh_ops=_gho):
2660
+ continue
2661
+
2662
+ # Gate 3: not already terminal on GitHub (merged / closed). Fail OPEN
2663
+ # on transient gh errors so a network blip never blocks a real enqueue.
2664
+ if _gho.work_is_terminal(
2665
+ repo_cfg.github,
2666
+ getattr(a, "issue_number", 0),
2667
+ branch,
2668
+ cache=terminal_cache,
2669
+ ):
2670
+ continue
2671
+
2672
+ if refresh_entry_assignment(
2673
+ a,
2674
+ repo_github=repo_cfg.github,
2675
+ target_branch=target_branch,
2676
+ ):
2677
+ changed.append(aid)
2678
+
2679
+ return changed
2680
+
2681
+
2682
+ def refresh_entry_assignment(
2683
+ assignment: Assignment,
2684
+ repo_github: str,
2685
+ target_branch: str,
2686
+ ) -> bool:
2687
+ """Ensure a PENDING queue entry exists for *assignment*'s branch and
2688
+ is keyed to *assignment*.
2689
+
2690
+ #292 (Defect 2): after a review bounce the entry was created during an
2691
+ earlier ``coord merge`` run and is keyed to the *original* work
2692
+ assignment. When the fix work gets approved, the entry's
2693
+ ``assignment_id`` must be updated so ``has_approved_review`` (and the
2694
+ matching TUI check) can find the approval.
2695
+
2696
+ - If no entry exists for the branch, one is created (same as
2697
+ ``enqueue``).
2698
+ - If an entry already exists keyed to a different assignment on the
2699
+ same branch and its state is ``PENDING``, its ``assignment_id`` is
2700
+ updated and any stale ``"review required"`` error is cleared.
2701
+ - If the entry is in a terminal state (MERGED, CONFLICT, etc.) it is
2702
+ left untouched.
2703
+
2704
+ Returns ``True`` when a change was made (entry created or updated).
2705
+ """
2706
+ from coord import github_ops as _gho # noqa: PLC0415
2707
+
2708
+ if not assignment.branch or not assignment.assignment_id:
2709
+ return False
2710
+ items = load_queue()
2711
+ # Match by (repo_github, branch) first; also accept a match by
2712
+ # assignment_id alone so that a queue entry with a different branch but
2713
+ # the same assignment_id (e.g. a test-seeded entry or a manually-created
2714
+ # entry) is treated as "already present" rather than spawning a second row.
2715
+ existing = next(
2716
+ (
2717
+ x for x in items
2718
+ if (x.repo_github == repo_github and x.branch == assignment.branch)
2719
+ or x.assignment_id == assignment.assignment_id
2720
+ ),
2721
+ None,
2722
+ )
2723
+ if existing is None:
2724
+ # #776: populate size eagerly (same as enqueue()) and record enqueued_at.
2725
+ try:
2726
+ diff_size: int | None = _gho.get_branch_diff_size(
2727
+ repo_github, target_branch, assignment.branch
2728
+ ) or None
2729
+ except Exception: # noqa: BLE001
2730
+ diff_size = None
2731
+
2732
+ entry = QueuedMerge(
2733
+ assignment_id=assignment.assignment_id,
2734
+ repo_name=assignment.repo_name,
2735
+ repo_github=repo_github,
2736
+ branch=assignment.branch,
2737
+ target_branch=target_branch,
2738
+ issue_number=assignment.issue_number,
2739
+ issue_title=assignment.issue_title,
2740
+ size=diff_size,
2741
+ enqueued_at=time.time(),
2742
+ assignment_type=assignment.type,
2743
+ # #1213: snapshot the resolved gate list, same as enqueue().
2744
+ required_gates=list(assignment.required_gates or []),
2745
+ )
2746
+ items.append(entry)
2747
+ save_queue(items)
2748
+ return True
2749
+ if existing.assignment_id == assignment.assignment_id:
2750
+ return False # already correct
2751
+ if existing.state != PENDING:
2752
+ return False # don't touch terminal entries (MERGED, CONFLICT, etc.)
2753
+ existing.assignment_id = assignment.assignment_id
2754
+ # #1077 (review round 1): do NOT overwrite existing.assignment_type here.
2755
+ # assignment_type is a structural property of the branch/issue pairing,
2756
+ # fixed once at enqueue() time -- not something to refresh from whatever
2757
+ # assignment last touched the branch. A review-bounce fix worker is
2758
+ # unconditionally dispatched with type="work" (auto_loop.py's
2759
+ # _dispatch_fix_for_review), regardless of the original assignment's
2760
+ # type, so re-keying assignment_type here would clobber a "mock-author"
2761
+ # entry back to "work" on every ordinary request-changes round trip --
2762
+ # silently re-enabling the close-on-merge behavior this issue fixed.
2763
+ # assignment_id legitimately needs to track the latest fix (for
2764
+ # approval-lookup purposes via has_approved_review), but assignment_type
2765
+ # does not -- a bounce/fix iteration is conceptually still "fixing the
2766
+ # same PR", so the type set at enqueue() stays authoritative.
2767
+ # Clear a stale "review required" error now that a fresh approval arrived.
2768
+ if existing.error == "review required but not approved":
2769
+ existing.error = None
2770
+ save_queue(items)
2771
+ return True
2772
+
2773
+
2774
+ # ── Stale-conflict reconciliation (#1477) ───────────────────────────────────
2775
+
2776
+ def reconcile_conflict_entries(gh_ops: "GhOps") -> list["MergeEvent"]:
2777
+ """Re-test every ``CONFLICT`` entry's mergeability and clear stale verdicts.
2778
+
2779
+ A ``CONFLICT`` entry caches the ``gh pr merge`` failure message from
2780
+ whenever the queue last attempted it, and ``process()`` never looks at it
2781
+ again — it only ever iterates ``PENDING`` entries. When a conflict-fix
2782
+ worker (#241) lands a rebase, or a human pushes a fix by hand, the branch
2783
+ becomes clean but the entry sits parked on the old verdict forever,
2784
+ requiring the three-step manual incantation described in #1477
2785
+ (``--drop`` → a bare re-enqueue → ``--only``) to notice.
2786
+
2787
+ This re-tests GitHub's own mergeability computation for every
2788
+ ``CONFLICT`` entry that has an open PR and, when it now reports clean,
2789
+ returns the entry to ``PENDING`` and clears the stored error so it
2790
+ re-enters the ordinary merge flow on this tick — no manual archaeology.
2791
+
2792
+ Fail-closed by design: an entry with no PR yet, or whose mergeability
2793
+ can't be determined (``gh`` error, or GitHub still computing it — both
2794
+ surface as ``None`` from :meth:`GhOps.check_pr_mergeable`), is left
2795
+ untouched. Only an explicit ``True`` unparks it — never speculative.
2796
+
2797
+ #1467: a ``MERGEABLE`` verdict only reflects *content* conflicts — it
2798
+ says nothing about whether a ``--rebase`` merge specifically will
2799
+ succeed, because GitHub reports a branch carrying a merge commit as
2800
+ ``MERGEABLE`` even though it flatly refuses to rebase-merge it. An
2801
+ entry parked on that particular refusal (:func:`is_rebase_refusal`)
2802
+ would otherwise unpark here, hit the exact same wall in :func:`process`,
2803
+ and re-park — an infinite loop once auto-drain is on (#1491). For those
2804
+ entries specifically, this also confirms via
2805
+ :meth:`GhOps.branch_has_merge_commit` that the branch has actually gone
2806
+ linear before unparking; an inconclusive read (``None``, or a ``gh_ops``
2807
+ that doesn't support the probe) leaves the entry parked rather than
2808
+ guessing — the same fail-closed posture as the mergeability check above.
2809
+ A plain content conflict (no rebase-refusal wording) is unaffected and
2810
+ keeps the original mergeable-only behaviour.
2811
+
2812
+ Loads and saves the queue directly (same shape as
2813
+ :func:`enqueue_approved_work`), so this is safe to call unconditionally,
2814
+ even under ``--dry-run``: it corrects previously-cached state rather than
2815
+ taking a merge action, mirroring the auto-enqueue scan that already runs
2816
+ regardless of ``--dry-run`` in ``coord merge``.
2817
+
2818
+ Returns the list of :class:`MergeEvent` for entries that were cleared, so
2819
+ callers can echo them the same way they echo ``process()`` events.
2820
+ """
2821
+ items = load_queue()
2822
+ events: list[MergeEvent] = []
2823
+ changed = False
2824
+ for entry in items:
2825
+ if entry.state != CONFLICT or not entry.pr_number:
2826
+ continue
2827
+ try:
2828
+ mergeable = gh_ops.check_pr_mergeable(entry.repo_github, entry.pr_number)
2829
+ except Exception: # noqa: BLE001 — never let a gh hiccup wedge the tick
2830
+ mergeable = None
2831
+ if mergeable is not True:
2832
+ continue
2833
+ if is_rebase_refusal(entry.error):
2834
+ probe = getattr(gh_ops, "branch_has_merge_commit", None)
2835
+ if probe is None:
2836
+ continue # can't confirm linearity — stay parked (#1467)
2837
+ try:
2838
+ has_merge_commit = probe(entry.repo_github, entry.pr_number)
2839
+ except Exception: # noqa: BLE001
2840
+ has_merge_commit = None
2841
+ if has_merge_commit is not False:
2842
+ # Still has a merge commit, or the probe was inconclusive —
2843
+ # unparking now would just reproduce the same refusal.
2844
+ continue
2845
+ entry.state = PENDING
2846
+ entry.error = None
2847
+ changed = True
2848
+ events.append(MergeEvent(
2849
+ entry, "reopened",
2850
+ f"conflict cleared — PR #{entry.pr_number} ({entry.branch}) is "
2851
+ "mergeable again, returned to pending",
2852
+ ))
2853
+ if changed:
2854
+ save_queue(items)
2855
+ return events
2856
+
2857
+
2858
+ def resolve_entry_key(items: list["QueuedMerge"], key: str) -> "QueuedMerge | None":
2859
+ """Resolve *key* to a queue entry by whatever identifier the read path
2860
+ printed — ``assignment_id``, the durable ``repo#issue`` form, a bare
2861
+ issue number, or the branch name (#1477, #1490).
2862
+
2863
+ ``assignment_id`` is volatile across a drop + re-enqueue cycle: a fresh
2864
+ row mints whatever assignment id the board currently shows for that
2865
+ issue, which is not guaranteed to match the id an operator last saw in
2866
+ ``coord status`` (#1477). #1490 sharpens this further: even *without* a
2867
+ drop, a queue entry can legitimately be re-keyed between the moment the
2868
+ board is read and the moment ``--only`` is invoked (a concurrent
2869
+ auto-enqueue tick re-keying the branch's one entry to a newer fix
2870
+ assignment) — so an id that was 100% correct when printed can already
2871
+ be stale by the time it's passed here. Every fallback below resolves by
2872
+ something that does *not* change out from under the operator for the
2873
+ life of the entry.
2874
+
2875
+ Resolution order (first match wins):
2876
+
2877
+ 1. Exact ``assignment_id`` — unchanged, most specific.
2878
+ 2. ``repo#issue`` (or ``repo_github#issue``) — only tried when *key*
2879
+ contains ``#`` (plain ids/branches never do, so this can never
2880
+ accidentally shadow one). A parse failure after ``#`` is a hard
2881
+ miss — no fallthrough to the forms below.
2882
+ 3. A bare issue number — *key* parses as an ``int`` with no ``#``.
2883
+ Matches ``entry.issue_number`` across every repo in *items*;
2884
+ ambiguous only when the same issue number is queued for more than
2885
+ one repo, in which case (like form 2) the most recently added match
2886
+ wins.
2887
+ 4. The entry's own ``branch`` name (#1490) — the most stable identifier
2888
+ there is: it's set once at enqueue time and never changes for the
2889
+ life of the entry, unlike ``assignment_id`` which re-keys on every
2890
+ fix/bounce round. This is the fallback the issue calls out
2891
+ explicitly: "if an ID is genuinely re-keyed between passes, resolve
2892
+ by branch".
2893
+
2894
+ When more than one entry matches forms 2-4, :func:`_pick_ambiguous_match`
2895
+ breaks the tie: an entry still in play (not ``MERGED``/``SKIPPED``) is
2896
+ preferred over one already at rest, and only among equally-actionable
2897
+ (or equally-terminal) candidates does "most recently added" apply
2898
+ (``load_queue()`` returns rows in insertion order) — the #1477 tie-break.
2899
+ #2080: two sealed slices of the same milestone share one *tracking*
2900
+ issue number, so a bare-issue or ``repo#issue`` key is routinely
2901
+ ambiguous between them even though each is a distinct, independently
2902
+ mergeable entry. Plain "most recent wins" used to resolve that ambiguity
2903
+ to whichever slice merged *first* — because merging is what makes an
2904
+ entry's ``load_queue()`` position look "most recent" to a re-read of the
2905
+ file — permanently orphaning the other slice's ``--only <issue>`` runs
2906
+ behind an already-``MERGED`` sibling. Preferring the still-pending entry
2907
+ fixes that without changing behaviour for the (still ambiguous, still a
2908
+ caller error to rely on) case where two matches are equally actionable —
2909
+ use the branch name there.
2910
+
2911
+ Returns ``None`` when nothing matches any form — callers must treat
2912
+ that as an explicit error, never a silent no-op (#1477).
2913
+ """
2914
+ for entry in items:
2915
+ if entry.assignment_id == key:
2916
+ return entry
2917
+ if "#" in key:
2918
+ repo_part, _, issue_part = key.rpartition("#")
2919
+ try:
2920
+ issue_number = int(issue_part)
2921
+ except ValueError:
2922
+ return None
2923
+ matches = [
2924
+ e for e in items
2925
+ if e.issue_number == issue_number and repo_part in (e.repo_name, e.repo_github)
2926
+ ]
2927
+ if matches:
2928
+ return _pick_ambiguous_match(matches)
2929
+ return None
2930
+ try:
2931
+ bare_issue_number = int(key)
2932
+ except ValueError:
2933
+ bare_issue_number = None
2934
+ if bare_issue_number is not None:
2935
+ matches = [e for e in items if e.issue_number == bare_issue_number]
2936
+ if matches:
2937
+ return _pick_ambiguous_match(matches)
2938
+ branch_matches = [e for e in items if e.branch == key]
2939
+ if branch_matches:
2940
+ return branch_matches[-1]
2941
+ return None
2942
+
2943
+
2944
+ # States a queue entry never leaves once reached — resolving a shared key
2945
+ # (durable repo#issue, or bare issue number) against a mix of these and
2946
+ # still-actionable states should never silently prefer the entry that's
2947
+ # already done (#2080).
2948
+ _RESOLVE_TERMINAL_STATES = (MERGED, SKIPPED)
2949
+
2950
+
2951
+ def _pick_ambiguous_match(matches: list["QueuedMerge"]) -> "QueuedMerge":
2952
+ """Break a tie between several :func:`resolve_entry_key` *matches* that
2953
+ share one durable key (#2080).
2954
+
2955
+ Two sealed slices of the same milestone share the milestone's tracking
2956
+ issue number, so ``repo#issue`` and bare-issue-number resolution
2957
+ routinely finds more than one queue entry. Prefer an entry that is still
2958
+ actionable (state not in :data:`_RESOLVE_TERMINAL_STATES`) over one
2959
+ already at rest — a merged sibling is never the row an operator meant by
2960
+ the shared key once anything else with that key is still pending. Among
2961
+ equally-actionable (or equally-terminal) candidates, fall back to
2962
+ "most recently added" (*matches* is in ``load_queue()`` insertion
2963
+ order) — the pre-existing #1477 tie-break, applied only where it can no
2964
+ longer pick a done entry over a live one.
2965
+ """
2966
+ actionable = [e for e in matches if e.state not in _RESOLVE_TERMINAL_STATES]
2967
+ pool = actionable if actionable else matches
2968
+ return pool[-1]
2969
+
2970
+
2971
+ def resolve_board_work_key(board, key: str) -> "list":
2972
+ """Every done work-like board row that *key* addresses (#1695).
2973
+
2974
+ The board-side twin of :func:`resolve_entry_key`, matching the **same
2975
+ four key forms** (``assignment_id``, ``repo#issue``, bare issue number,
2976
+ branch name) against ``board.completed`` instead of the persisted queue.
2977
+
2978
+ Exists purely so ``coord merge --only`` can tell the two failure modes
2979
+ apart when the queue lookup misses:
2980
+
2981
+ * *no board row either* → the identifier genuinely did not resolve, which
2982
+ is what the pre-#1695 message ("tried assignment_id, repo#issue, issue
2983
+ number, and branch name") always claimed and was usually wrong about;
2984
+ * *a board row exists* → the identifier is fine, and the reason there is
2985
+ no entry is a gate — which the caller can then name via
2986
+ :func:`merge_gate_failures`.
2987
+
2988
+ Returns **all** matches (not just the most recent, unlike
2989
+ :func:`resolve_entry_key`'s tie-break) because the caller is producing a
2990
+ diagnostic, not choosing a row to act on: it is more useful to report
2991
+ every candidate and its gate state than to silently pick one. Ordered as
2992
+ they appear in ``board.completed``; ``[]`` when nothing matches or
2993
+ *board* is None.
2994
+ """
2995
+ rows = [
2996
+ a for a in (getattr(board, "completed", None) or [])
2997
+ if getattr(a, "type", None) in WORK_LIKE_TYPES
2998
+ ]
2999
+ exact = [a for a in rows if a.assignment_id == key]
3000
+ if exact:
3001
+ return exact
3002
+ if "#" in key:
3003
+ repo_part, _, issue_part = key.rpartition("#")
3004
+ try:
3005
+ issue_number = int(issue_part)
3006
+ except ValueError:
3007
+ return []
3008
+ return [
3009
+ a for a in rows
3010
+ if a.issue_number == issue_number and a.repo_name == repo_part
3011
+ ]
3012
+ try:
3013
+ bare_issue_number: int | None = int(key)
3014
+ except ValueError:
3015
+ bare_issue_number = None
3016
+ if bare_issue_number is not None:
3017
+ matches = [a for a in rows if a.issue_number == bare_issue_number]
3018
+ if matches:
3019
+ return matches
3020
+ return [a for a in rows if a.branch == key]
3021
+
3022
+
3023
+ # ── Plan-status constants (#776) ─────────────────────────────────────────────
3024
+
3025
+ # Computed status values for PlannedMerge.status — not stored in the DB.
3026
+ PLAN_READY = "READY"
3027
+ PLAN_BLOCKED = "BLOCKED"
3028
+ PLAN_MERGING = "MERGING"
3029
+ PLAN_MERGED = "MERGED"
3030
+ PLAN_NEEDS_ATTENTION = "NEEDS_ATTENTION"
3031
+
3032
+
3033
+ # ── Gate evaluation (#776) ──────────────────────────────────────────────────
3034
+
3035
+ def _entry_gate_status(
3036
+ entry: "QueuedMerge",
3037
+ board,
3038
+ config,
3039
+ ci_store: "CiStore | None" = None,
3040
+ gh_ops: "GhOps | None" = None,
3041
+ ) -> tuple[str, str | None]:
3042
+ """Return *(status, reason)* for a single PENDING merge-queue entry.
3043
+
3044
+ Evaluates gates in the same order as :func:`process` — review → smoke →
3045
+ CI → epic-closing-keyword-in-commit — so the plan shown to the operator
3046
+ is byte-for-byte what merge would do. Both :func:`plan` and :func:`process`
3047
+ delegate to this helper so they can never diverge.
3048
+
3049
+ Returns ``(PLAN_READY, None)`` when all gates pass.
3050
+ Returns ``(PLAN_BLOCKED, reason)`` when any gate blocks.
3051
+
3052
+ The *ci_store* gate is only evaluated when both *ci_store* is provided
3053
+ **and** the entry has a ``pr_number`` (CI is checked per-PR, not per-branch).
3054
+ This mirrors the live-merge behaviour: a ``PENDING`` entry with no PR yet
3055
+ opened is not blocked on CI — the PR hasn't been created yet.
3056
+
3057
+ The *gh_ops* epic-closing-keyword-in-commit gate (#1318) is likewise only
3058
+ evaluated when both *gh_ops* is provided **and** the entry has a
3059
+ ``pr_number`` — mirroring the CI gate's guard, since commit messages can
3060
+ only be read once a PR exists. This gate is never bypassable via
3061
+ ``force_merge`` here (unlike :func:`process`'s live override) — the plan
3062
+ view has no such flag; an operator who wants to see the override outcome
3063
+ reads the ``coord merge --force-merge`` output itself.
3064
+
3065
+ #1851: a CI gate that reads all-green additionally checks *staleness* —
3066
+ whether every passing check predates the target branch's current HEAD
3067
+ commit, which GitHub's own re-run-on-``synchronize``-only behaviour never
3068
+ catches. Reported with the :data:`CI_STALE_PREFIX` wording, distinct from
3069
+ "CI failed"/"CI running" so an operator (and :func:`ci_revalidation_candidates`,
3070
+ which keys off the same prefix) can tell the three apart.
3071
+ """
3072
+ smoke: "SmokeVerdictStatus | None" = None
3073
+ if config is not None and board is not None:
3074
+ # #1506: pass gh_ops through so a null branch_patch_id (e.g. an entry
3075
+ # whose approved review predates #1475) is computed on demand rather
3076
+ # than displaying a stale "review not approved" the plan can't fix.
3077
+ if requires_review(entry, config) and not has_approved_review(entry, board, gh_ops):
3078
+ return PLAN_BLOCKED, "review not approved"
3079
+ if requires_smoke(entry, config):
3080
+ # #1640: render the specific failure. A stale verdict used to be
3081
+ # reported with the same "test verdict missing" wording as one
3082
+ # that was never recorded, which sent operators hunting a lost
3083
+ # write instead of re-verifying against the moved base.
3084
+ smoke = evaluate_smoke_verdict(entry, board, gh_ops)
3085
+ if not smoke.ok:
3086
+ return PLAN_BLOCKED, smoke.short_reason
3087
+ if ci_store is not None and ci_store.is_available and entry.pr_number:
3088
+ checks = ci_store.list_checks_for_pr(entry.repo_github, entry.pr_number)
3089
+ # #1904: an empty check list satisfies every gate below vacuously —
3090
+ # must be handled explicitly, before those gates, or a PR whose CI
3091
+ # never ran at all reads as clear to merge.
3092
+ if not checks and _ci_expects_checks(
3093
+ ci_store, entry.repo_github, entry.pr_number
3094
+ ):
3095
+ # #1877: an empty check list is ALSO what GitHub reports for a
3096
+ # PR that conflicts with its base — it can never build a merge
3097
+ # ref, so no `pull_request`-triggered workflow ever runs. That
3098
+ # is a different fact from "CI never ran on a mergeable PR" and
3099
+ # needs the opposite response: don't block here, mirror what
3100
+ # `process()` does — fall through so `coord merge` attempts the
3101
+ # merge, discovers the real conflict, and routes to the #241
3102
+ # conflict-fix path via the `conflict` event, instead of
3103
+ # pre-empting it with a "CI never ran" block only a human can
3104
+ # clear. `check_pr_mergeable` is duck-typed/optional here
3105
+ # (unlike in `process()`, where `gh_ops` is always a live
3106
+ # client): this function also runs against a `GateSnapshot`
3107
+ # (the `/board` read path, #1336 Invariant 1 — no third-party
3108
+ # I/O), which doesn't implement it — a missing probe reads as
3109
+ # inconclusive, same as a `None`/confirmed-mergeable verdict,
3110
+ # and today's block is left untouched.
3111
+ _mergeable_probe = getattr(gh_ops, "check_pr_mergeable", None)
3112
+ conflicted = False
3113
+ if _mergeable_probe is not None:
3114
+ try:
3115
+ conflicted = (
3116
+ _mergeable_probe(entry.repo_github, entry.pr_number)
3117
+ is False
3118
+ )
3119
+ except Exception: # noqa: BLE001 — inconclusive, not a block override
3120
+ conflicted = False
3121
+ if not conflicted:
3122
+ return (
3123
+ PLAN_BLOCKED,
3124
+ f"{CI_ABSENT_PREFIX} no checks reported for PR #{entry.pr_number} "
3125
+ "though this repo declares CI — merging would run untested code",
3126
+ )
3127
+ failed = failed_checks(checks)
3128
+ if failed:
3129
+ summary = ", ".join(f"{c.name} ({c.conclusion})" for c in failed)
3130
+ return PLAN_BLOCKED, f"CI failed: {summary}"
3131
+ pending = in_flight_checks(checks)
3132
+ if pending:
3133
+ summary = ", ".join(c.name for c in pending)
3134
+ return PLAN_BLOCKED, f"{CI_PENDING_PREFIX} {summary}"
3135
+ if checks and _ci_checks_are_stale(
3136
+ checks, gh_ops, entry.repo_github, entry.target_branch, smoke
3137
+ ):
3138
+ return (
3139
+ PLAN_BLOCKED,
3140
+ f"{CI_STALE_PREFIX} checks predate the current base — "
3141
+ "re-run CI (`coord merge --revalidate`) before merging",
3142
+ )
3143
+ if gh_ops is not None and entry.pr_number:
3144
+ try:
3145
+ commit_messages = gh_ops.get_pr_commit_messages(
3146
+ entry.repo_github, entry.pr_number
3147
+ )
3148
+ except Exception: # noqa: BLE001
3149
+ commit_messages = []
3150
+ commit_referenced: set[int] = set()
3151
+ for message in commit_messages:
3152
+ commit_referenced.update(find_closing_references(message))
3153
+ commit_epic_hits: list[int] = []
3154
+ for n in sorted(commit_referenced):
3155
+ try:
3156
+ if gh_ops.is_epic_issue(entry.repo_github, n):
3157
+ commit_epic_hits.append(n)
3158
+ except Exception: # noqa: BLE001
3159
+ pass
3160
+ if commit_epic_hits:
3161
+ numbers_str = ", ".join(f"#{n}" for n in commit_epic_hits)
3162
+ return (
3163
+ PLAN_BLOCKED,
3164
+ f"commit message contains closing keyword for epic {numbers_str} (#1318)",
3165
+ )
3166
+ return PLAN_READY, None
3167
+
3168
+
3169
+ # ── Merge plan (#776) ────────────────────────────────────────────────────────
3170
+
3171
+ @dataclass
3172
+ class PlannedMerge:
3173
+ """One entry in the server-side merge plan.
3174
+
3175
+ The plan is the single source of truth for ordering and gate-status — it
3176
+ is what the TUI panel, the CLI ``--plan`` flag, and auto-drain all consume.
3177
+ Unlike ``QueuedMerge``, which is the raw DB row, ``PlannedMerge`` carries
3178
+ computed fields (``rank``, ``status``, ``reason``, ``milestone``) that are
3179
+ always fresh and never stale.
3180
+ """
3181
+
3182
+ assignment_id: str
3183
+ repo_name: str
3184
+ repo_github: str
3185
+ branch: str
3186
+ target_branch: str
3187
+ issue_number: int
3188
+ issue_title: str
3189
+ rank: int # 1-based, ordered by true merge sequence
3190
+ size: int | None # diff lines (populated at enqueue; None = unknown)
3191
+ status: str # READY | BLOCKED | MERGING | MERGED | NEEDS_ATTENTION
3192
+ reason: str | None # why it is blocked (None when READY / terminal)
3193
+ enqueued_at: float | None # unix timestamp when the entry was created
3194
+ last_attempt: float | None # unix timestamp of the last merge attempt
3195
+ milestone: str | None # issue milestone title, or None
3196
+ # #1344: structured CI rollup so the TUI can render "2✓ 1✗" badges straight
3197
+ # from `/board` instead of shelling out to `gh pr checks` itself. `None`
3198
+ # when no PR is open yet, or `ci_store` has no checks for this PR.
3199
+ pr_number: int | None = None
3200
+ ci_summary: "CiCheckSummary | None" = None
3201
+
3202
+
3203
+ def _load_milestones_for_queue(
3204
+ items: "list[QueuedMerge]",
3205
+ ) -> "dict[tuple[str, int], str | None]":
3206
+ """Load milestone titles for each (repo_name, issue_number) in *items*.
3207
+
3208
+ Queries the ``issues`` table in bulk and returns a dict keyed by
3209
+ ``(repo_name, issue_number)``. Missing rows (issue not yet synced) map
3210
+ to ``None``. Any DB error returns an empty dict so the plan degrades
3211
+ gracefully.
3212
+ """
3213
+ if not items:
3214
+ return {}
3215
+ try:
3216
+ conn = get_connection()
3217
+ rows = conn.execute(
3218
+ "SELECT repo_name, number, milestone_title FROM issues"
3219
+ ).fetchall()
3220
+ return {
3221
+ (r["repo_name"], r["number"]): r["milestone_title"]
3222
+ for r in rows
3223
+ }
3224
+ except Exception: # noqa: BLE001
3225
+ return {}
3226
+
3227
+
3228
+ def _state_to_plan_status(state: str) -> str:
3229
+ """Map a ``QueuedMerge.state`` to a ``PlannedMerge.status`` constant."""
3230
+ if state == PENDING:
3231
+ return PLAN_READY # will be overridden by gate check if blocked
3232
+ if state == MERGING:
3233
+ return PLAN_MERGING
3234
+ if state == MERGED:
3235
+ return PLAN_MERGED
3236
+ # CONFLICT, HUMAN_REQUIRED, SKIPPED → surface for operator attention.
3237
+ return PLAN_NEEDS_ATTENTION
3238
+
3239
+
3240
+ def plan(
3241
+ board,
3242
+ config,
3243
+ ci_store: "CiStore | None" = None,
3244
+ gh_ops: "GhOps | None" = None,
3245
+ ) -> "list[PlannedMerge]":
3246
+ """Return the **ordered merge plan** — one :class:`PlannedMerge` per queue entry.
3247
+
3248
+ This is the single source of truth for ordering and gate-status consumed by
3249
+ the TUI panel (#B), the CLI ``--plan`` flag (#D), and auto-drain (#E).
3250
+
3251
+ Algorithm
3252
+ ---------
3253
+ 1. Load the queue from the DB.
3254
+ 2. Group entries by ``(repo_github, target_branch)``.
3255
+ 3. Within each group, order PENDING entries by ``sequence()`` (size-ascending
3256
+ with unknown-size last), then append non-PENDING entries in original DB
3257
+ order.
3258
+ 4. Assign a 1-based ``rank`` globally across all groups (i.e. the first
3259
+ PENDING entry across all repos is rank=1 regardless of repo).
3260
+ 5. For each entry:
3261
+ - Derive ``status`` from the raw ``state`` value.
3262
+ - For PENDING entries, override with :func:`_entry_gate_status` which
3263
+ evaluates review / smoke / CI / epic-closing-keyword-in-commit gates
3264
+ live against *board* + *config* + *ci_store* + *gh_ops*.
3265
+ - Look up the issue's milestone from the ``issues`` table.
3266
+
3267
+ The function is intentionally **read-only** — no side effects, no DB writes.
3268
+ Pass ``board=None`` and/or ``config=None`` to skip the review/smoke gates,
3269
+ ``ci_store=None`` to skip the CI gate, and ``gh_ops=None`` to skip the
3270
+ epic-closing-keyword-in-commit gate (useful in test scenarios that only
3271
+ care about ordering).
3272
+ """
3273
+ items = load_queue()
3274
+ milestones = _load_milestones_for_queue(items)
3275
+
3276
+ # ── Group by (repo_github, target_branch) ──────────────────────────────
3277
+ group_order: list[tuple[str, str]] = []
3278
+ groups: dict[tuple[str, str], list[QueuedMerge]] = {}
3279
+ for entry in items:
3280
+ key = (entry.repo_github, entry.target_branch)
3281
+ if key not in groups:
3282
+ group_order.append(key)
3283
+ groups[key] = []
3284
+ groups[key].append(entry)
3285
+
3286
+ # ── Build the ranked plan ───────────────────────────────────────────────
3287
+ result: list[PlannedMerge] = []
3288
+ rank = 0
3289
+
3290
+ for key in group_order:
3291
+ group = groups[key]
3292
+ # PENDING entries sorted by sequence(); all others in DB insertion order.
3293
+ pending = [e for e in group if e.state == PENDING]
3294
+ non_pending = [e for e in group if e.state != PENDING]
3295
+ ordered = sequence(pending) + non_pending
3296
+
3297
+ for entry in ordered:
3298
+ rank += 1
3299
+ base_status = _state_to_plan_status(entry.state)
3300
+ reason: str | None = None
3301
+
3302
+ ci_summary = None
3303
+ if entry.state == PENDING:
3304
+ base_status, reason = _entry_gate_status(
3305
+ entry, board, config, ci_store, gh_ops
3306
+ )
3307
+
3308
+ # #1344: structured CI rollup for the TUI's badges. Deliberately
3309
+ # scoped to PENDING entries only — the same scope as the gate
3310
+ # check above — because `ci_store` is not always the cheap,
3311
+ # tick-refreshed `GateSnapshot` (a dict lookup). Two other
3312
+ # callers pass a freshly-built *live* `CiStore`
3313
+ # (`ci_github.GitHubCi`) instead: `_auto_drain_tick`
3314
+ # (serve_app.py, every ~30s when `merge.auto_drain` is on) and
3315
+ # `coord merge --plan` (commands/merge.py). `merge_queue`
3316
+ # never prunes MERGED entries (see
3317
+ # `prune_stale_queue_entries`), so on a long-lived project the
3318
+ # queue table accumulates unbounded merged history — widening
3319
+ # this to "any entry with a pr_number" would fire one live
3320
+ # `gh pr checks` subprocess per historical merged PR on every
3321
+ # auto-drain tick, reintroducing the exact unbounded-`gh`-
3322
+ # polling failure class #1344 removed from the TUI, just
3323
+ # relocated to the daemon. It also wouldn't gain anything on
3324
+ # the safe /board path: `GateSnapshotRefresher.refresh` itself
3325
+ # only ever populates checks for entries that are PENDING at
3326
+ # refresh time, so a MERGING/MERGED row never had real
3327
+ # snapshot data to render in the first place.
3328
+ if ci_store is not None and ci_store.is_available and entry.pr_number:
3329
+ checks = ci_store.list_checks_for_pr(entry.repo_github, entry.pr_number)
3330
+ if checks:
3331
+ ci_summary = summarize_counts(checks)
3332
+
3333
+ result.append(PlannedMerge(
3334
+ assignment_id=entry.assignment_id,
3335
+ repo_name=entry.repo_name,
3336
+ repo_github=entry.repo_github,
3337
+ branch=entry.branch,
3338
+ target_branch=entry.target_branch,
3339
+ issue_number=entry.issue_number,
3340
+ issue_title=entry.issue_title,
3341
+ rank=rank,
3342
+ size=entry.size,
3343
+ status=base_status,
3344
+ reason=reason,
3345
+ pr_number=entry.pr_number,
3346
+ ci_summary=ci_summary,
3347
+ enqueued_at=entry.enqueued_at,
3348
+ last_attempt=entry.last_attempt,
3349
+ milestone=milestones.get((entry.repo_name, entry.issue_number)),
3350
+ ))
3351
+
3352
+ return result
3353
+
3354
+
3355
+ # ── Sequencing ───────────────────────────────────────────────────────────
3356
+
3357
+ def sequence(items: Iterable[QueuedMerge]) -> list[QueuedMerge]:
3358
+ """Order pending entries. Smaller diffs first; unknown sizes go last."""
3359
+ pending = [x for x in items if x.state == PENDING]
3360
+ return sorted(
3361
+ pending,
3362
+ key=lambda x: (x.size if x.size is not None else 10**9, x.assignment_id),
3363
+ )
3364
+
3365
+
3366
+ def reorder(items: list[QueuedMerge], order: list[str]) -> list[QueuedMerge]:
3367
+ """Return `items` reordered so that assignment_ids in `order` come first
3368
+ in the given sequence. Unknown IDs are dropped from the override."""
3369
+ by_id = {x.assignment_id: x for x in items}
3370
+ head = [by_id[aid] for aid in order if aid in by_id]
3371
+ tail = [x for x in items if x.assignment_id not in set(order)]
3372
+ return head + tail
3373
+
3374
+
3375
+ # ── Sibling overlap warnings (#920) ─────────────────────────────────────────
3376
+ #
3377
+ # The 2026-07-02 mess (docs referenced in #915) was triggered by late
3378
+ # merging of overlapping sibling branches: #769/#645/#770 (+#768) were a
3379
+ # milestone chain all editing the same new files, approved but left sitting
3380
+ # while main moved, so every rebase collided with its siblings' additions.
3381
+ # Nothing warned that these would conflict if merged out of order or late.
3382
+ #
3383
+ # `find_sibling_overlaps` is a pure, read-only heuristic over the merge
3384
+ # queue: it groups PENDING (i.e. approved — see `enqueue_approved_work`'s
3385
+ # review+smoke gate) entries by `(repo_github, target_branch)`, clusters
3386
+ # same-group entries whose originating assignment's `files_allowed`
3387
+ # (the brain's inferred "files likely touched" — the same signal
3388
+ # `compute_do_not_touch` uses pre-dispatch, see `coord.dispatch`) overlap,
3389
+ # and reports a warning once the oldest member of a ≥2-entry cluster has
3390
+ # been sitting in the queue at least `config.merge.sibling_overlap_aging_hours`.
3391
+
3392
+
3393
+ @dataclass(frozen=True)
3394
+ class SiblingOverlapWarning:
3395
+ """≥2 approved, aging queue entries whose branches touch the same files.
3396
+
3397
+ `issue_numbers` is already in the suggested merge order — oldest
3398
+ ``enqueued_at`` first, since that entry has drifted furthest from a
3399
+ moving main and merging it first shrinks the others' eventual rebase.
3400
+ """
3401
+
3402
+ repo_name: str
3403
+ target_branch: str
3404
+ issue_numbers: tuple[int, ...] = field(default_factory=tuple)
3405
+ overlapping_files: tuple[str, ...] = field(default_factory=tuple)
3406
+ oldest_age_hours: float = 0.0
3407
+
3408
+
3409
+ def find_sibling_overlaps(
3410
+ board,
3411
+ config,
3412
+ *,
3413
+ now: float | None = None,
3414
+ ) -> list[SiblingOverlapWarning]:
3415
+ """Detect approved, aging, file-overlapping sibling branches in the queue.
3416
+
3417
+ Pure/read-only: loads the queue via :func:`load_queue`, reads
3418
+ ``files_allowed`` off the matching assignments on *board*
3419
+ (``board.completed`` + ``board.active``), does no GitHub/subprocess
3420
+ calls. ``config.merge.sibling_overlap_aging_hours`` (default 24h) gates
3421
+ how long the oldest entry in an overlapping cluster must have waited
3422
+ before it's worth surfacing — a value of ``0`` (or a missing
3423
+ ``merge`` config) disables the warning entirely.
3424
+
3425
+ Only ``PENDING`` entries are considered: by the time an assignment has a
3426
+ queue entry, :func:`enqueue`/:func:`enqueue_approved_work` have already
3427
+ applied the review+smoke gate, so a PENDING entry is "approved" in the
3428
+ sense #920 means. Entries without a recorded ``enqueued_at`` (pre-#274
3429
+ rows) are skipped — there's no age to measure.
3430
+ """
3431
+ aging_hours = getattr(getattr(config, "merge", None), "sibling_overlap_aging_hours", 24.0)
3432
+ if not aging_hours or aging_hours <= 0:
3433
+ return []
3434
+ if now is None:
3435
+ now = time.time()
3436
+
3437
+ entries = [e for e in load_queue() if e.state == PENDING and e.enqueued_at is not None]
3438
+ if len(entries) < 2:
3439
+ return []
3440
+
3441
+ pool = (
3442
+ list(getattr(board, "completed", []) or [])
3443
+ + list(getattr(board, "active", []) or [])
3444
+ )
3445
+ files_by_aid: dict[str, set[str]] = {}
3446
+ for a in pool:
3447
+ aid = getattr(a, "assignment_id", None)
3448
+ if aid:
3449
+ files_by_aid[aid] = set(getattr(a, "files_allowed", None) or [])
3450
+
3451
+ groups: dict[tuple[str, str], list[QueuedMerge]] = {}
3452
+ for e in entries:
3453
+ groups.setdefault((e.repo_github, e.target_branch), []).append(e)
3454
+
3455
+ warnings: list[SiblingOverlapWarning] = []
3456
+ for (repo_github, target_branch), group in groups.items():
3457
+ if len(group) < 2:
3458
+ continue
3459
+
3460
+ # Union-find: cluster entries transitively sharing >=1 file.
3461
+ parent = {e.assignment_id: e.assignment_id for e in group}
3462
+
3463
+ def find(x: str) -> str:
3464
+ while parent[x] != x:
3465
+ parent[x] = parent[parent[x]]
3466
+ x = parent[x]
3467
+ return x
3468
+
3469
+ def union(a: str, b: str) -> None:
3470
+ ra, rb = find(a), find(b)
3471
+ if ra != rb:
3472
+ parent[ra] = rb
3473
+
3474
+ for i in range(len(group)):
3475
+ files_i = files_by_aid.get(group[i].assignment_id, set())
3476
+ if not files_i:
3477
+ continue
3478
+ for j in range(i + 1, len(group)):
3479
+ files_j = files_by_aid.get(group[j].assignment_id, set())
3480
+ if files_i & files_j:
3481
+ union(group[i].assignment_id, group[j].assignment_id)
3482
+
3483
+ clusters: dict[str, list[QueuedMerge]] = {}
3484
+ for e in group:
3485
+ clusters.setdefault(find(e.assignment_id), []).append(e)
3486
+
3487
+ for members in clusters.values():
3488
+ if len(members) < 2:
3489
+ continue
3490
+ oldest_enqueued = min(m.enqueued_at for m in members)
3491
+ age_hours = (now - oldest_enqueued) / 3600.0
3492
+ if age_hours < aging_hours:
3493
+ continue
3494
+
3495
+ ordered = sorted(members, key=lambda m: (m.enqueued_at, m.assignment_id))
3496
+ overlap_files: set[str] = set()
3497
+ for i in range(len(ordered)):
3498
+ files_i = files_by_aid.get(ordered[i].assignment_id, set())
3499
+ for j in range(i + 1, len(ordered)):
3500
+ files_j = files_by_aid.get(ordered[j].assignment_id, set())
3501
+ overlap_files |= files_i & files_j
3502
+
3503
+ warnings.append(SiblingOverlapWarning(
3504
+ repo_name=members[0].repo_name,
3505
+ target_branch=target_branch,
3506
+ issue_numbers=tuple(m.issue_number for m in ordered),
3507
+ overlapping_files=tuple(sorted(overlap_files)),
3508
+ oldest_age_hours=round(age_hours, 1),
3509
+ ))
3510
+
3511
+ warnings.sort(key=lambda w: (-w.oldest_age_hours, w.repo_name, w.target_branch))
3512
+ return warnings
3513
+
3514
+
3515
+ # ── Staging section (#778) ────────────────────────────────────────────────────
3516
+
3517
+ # Status values for StagingItem.status — never stored in the DB.
3518
+ STAGING_READY = "ready" # all gates pass; will be enqueued on the next tick
3519
+ STAGING_BLOCKED = "blocked" # at least one non-review gate is failing
3520
+
3521
+
3522
+ @dataclass
3523
+ class StagingItem:
3524
+ """One entry in the 'approved but not yet queued' staging section.
3525
+
3526
+ Populated by :func:`staging_items` which scans the board for completed
3527
+ work assignments that have an approved review (or don't need one) but
3528
+ have not yet been admitted to the merge queue. Exposed on ``/board`` so
3529
+ thin clients (TUI, phone webapp) can answer "did my PR make it in?" without
3530
+ a manual ``coord merge --dry-run``.
3531
+ """
3532
+
3533
+ assignment_id: str
3534
+ repo_name: str
3535
+ issue_number: int
3536
+ issue_title: str
3537
+ branch: str
3538
+ status: str # STAGING_READY | STAGING_BLOCKED
3539
+ reason: str | None # None when ready; human-readable gate failure when blocked
3540
+
3541
+
3542
+ # #2085: `_work_has_approved_review_a` (a hand-rolled "any approve on any
3543
+ # connected work id" scan, no SHA/patch-id binding at all) used to live here
3544
+ # as a second implementation of the same question `has_approved_review`
3545
+ # answers — "two surfaces that happen to agree" is exactly the shape #2096
3546
+ # warns about, and this one agreed by being *more* permissive than the real
3547
+ # gate rather than less: a staging item could read READY off a review that
3548
+ # `coord merge` would refuse as stale. `has_approved_review` is already
3549
+ # duck-typed on `.assignment_id`/`.branch` (both present on a raw
3550
+ # `Assignment`) via `_chain_work_ids`, and getattr-defaults every SHA/
3551
+ # patch-id field a raw Assignment doesn't carry to `None` — which, since
3552
+ # #2085, means it fails CLOSED on any review whose approval can't be
3553
+ # confirmed fresh rather than skipping the check. staging_items now calls
3554
+ # `has_approved_review(a, board, gh_ops)` directly below.
3555
+
3556
+
3557
+ def _staging_smoke_entry(a, config):
3558
+ """Build the minimal duck-typed entry :func:`evaluate_smoke_verdict` needs
3559
+ for a staging candidate (#1640).
3560
+
3561
+ A staging item is by definition *not* in the merge queue, so there is no
3562
+ :class:`QueuedMerge` row carrying ``branch_head_sha`` /
3563
+ ``target_branch_head_sha`` / ``branch_patch_id``. The shim supplies the
3564
+ identity fields (``assignment_id``/``branch``/``repo_github``/
3565
+ ``target_branch``) the evaluator needs to *look those up* through a
3566
+ ``gh_ops``, and leaves the SHA fields ``None`` so a ``gh_ops=None`` call
3567
+ stays I/O-free.
3568
+
3569
+ ``target_branch`` is the repo's ``default_branch``, not the #934
3570
+ milestone feature branch: resolving the latter costs a ``gh`` call, and
3571
+ this function is on the ``/board`` read path. A staging candidate on a
3572
+ milestone repo therefore compares against the wrong base and simply fails
3573
+ the freshness check open — the same "anchor missing → skip that half"
3574
+ convention the evaluator already uses, never a false *block*.
3575
+ """
3576
+ repo_cfg = None
3577
+ if config is not None:
3578
+ try:
3579
+ repo_cfg = config.repo(getattr(a, "repo_name", None) or "")
3580
+ except Exception: # noqa: BLE001 — unknown repo: no live lookup possible
3581
+ repo_cfg = None
3582
+ return QueuedMerge(
3583
+ assignment_id=getattr(a, "assignment_id", None) or "",
3584
+ repo_name=getattr(a, "repo_name", None) or "",
3585
+ repo_github=getattr(repo_cfg, "github", None) or "",
3586
+ branch=getattr(a, "branch", None) or "",
3587
+ target_branch=getattr(repo_cfg, "default_branch", None) or "",
3588
+ issue_number=int(getattr(a, "issue_number", 0) or 0),
3589
+ issue_title=getattr(a, "issue_title", None) or "",
3590
+ )
3591
+
3592
+
3593
+ def staging_items(board, config, gh_ops: "GhOps | None" = None) -> list[StagingItem]:
3594
+ """Return work assignments that are done+approved but not yet in the queue.
3595
+
3596
+ Scans ``board.completed`` for ``status=done`` assignments whose ``type``
3597
+ is in :data:`coord.models.WORK_LIKE_TYPES` (``"work"`` or
3598
+ ``"mock-author"``, #930) and returns one :class:`StagingItem` per
3599
+ candidate that has an approved review
3600
+ (or doesn't need one) but hasn't yet been admitted to the merge queue.
3601
+ Each item is classified:
3602
+
3603
+ * ``STAGING_READY`` — all gates pass; will be enqueued on the next daemon
3604
+ tick (typically within 30 s of approval).
3605
+ * ``STAGING_BLOCKED`` — the smoke / test gate is failing; the item cannot
3606
+ enter the queue until the operator records a verdict (``coord test
3607
+ --passed`` / ``--skipped``).
3608
+
3609
+ Items that have NOT received an approved review are silently excluded so
3610
+ that the staging section only shows work the pipeline has already green-lit.
3611
+
3612
+ The function is intentionally **read-only**: no DB writes. Pass
3613
+ ``board=None`` or ``config=None`` to skip gate evaluation (useful in
3614
+ tests that only care about filtering logic).
3615
+
3616
+ #1640: the smoke gate here used to read the raw ``test_state`` column
3617
+ with no freshness check at all, so it printed READY (and ``/board``'s
3618
+ staging section showed green) for an assignment whose verdict
3619
+ :func:`has_smoke_verdict` — the reader ``coord merge`` actually uses —
3620
+ rejects as stale under the #1479 binding. It now routes through the same
3621
+ :func:`evaluate_smoke_verdict` helper as every other smoke reader, so the
3622
+ two can no longer disagree, and reports *which* failure it is.
3623
+
3624
+ *gh_ops* is optional and, when ``None`` (the default), no GitHub call is
3625
+ made: the freshness anchors simply aren't available, and the evaluation
3626
+ degrades to the terminal-verdict check it has always performed. Callers
3627
+ that can supply a client — or the daemon's tick-refreshed
3628
+ :class:`coord.gate_snapshot.GateSnapshot`, which serves the same two
3629
+ lookups without live I/O — get the full freshness binding.
3630
+ """
3631
+ existing_queue = load_queue()
3632
+
3633
+ # Fast-lookup: assignment IDs already in the queue (any state).
3634
+ queued_aids: set[str] = {x.assignment_id for x in existing_queue}
3635
+
3636
+ # Fast-lookup: branches already in the queue (any state). A fix worker
3637
+ # dispatched after the original work was enqueued will have a different
3638
+ # assignment_id but share the same branch — so dedup by branch too.
3639
+ queued_branches: set[str] = {x.branch for x in existing_queue if x.branch}
3640
+
3641
+ # Fast-lookup: (repo_name, issue_number) pairs already MERGED so we skip
3642
+ # issues whose prior attempt was already shipped.
3643
+ already_merged: set[tuple[str, int]] = {
3644
+ (x.repo_name, x.issue_number)
3645
+ for x in existing_queue
3646
+ if x.state == MERGED
3647
+ }
3648
+
3649
+ result: list[StagingItem] = []
3650
+ completed = list(getattr(board, "completed", []) or [])
3651
+
3652
+ for a in completed:
3653
+ if getattr(a, "type", None) not in WORK_LIKE_TYPES:
3654
+ continue
3655
+ if getattr(a, "status", None) != "done":
3656
+ continue
3657
+
3658
+ aid = getattr(a, "assignment_id", None)
3659
+ branch = getattr(a, "branch", None)
3660
+ if not aid or not branch:
3661
+ continue
3662
+
3663
+ repo_name = getattr(a, "repo_name", None) or ""
3664
+ issue_number = int(getattr(a, "issue_number", 0) or 0)
3665
+ issue_title = getattr(a, "issue_title", None) or ""
3666
+
3667
+ # Skip items already tracked in the queue (by assignment_id or branch).
3668
+ # Branch-level dedup catches fix workers that share a branch with an
3669
+ # already-queued original work assignment (#778).
3670
+ if aid in queued_aids or branch in queued_branches:
3671
+ continue
3672
+
3673
+ # Skip if the issue has already been merged via a prior work attempt.
3674
+ if (repo_name, issue_number) in already_merged:
3675
+ continue
3676
+
3677
+ # Gate: review. Skip entirely when review is required but NOT yet
3678
+ # approved — the item isn't "approved" yet and should not appear in the
3679
+ # staging section (it belongs to the pipeline, not the merge staging).
3680
+ if config is not None and board is not None:
3681
+ if requires_review(a, config) and not has_approved_review(a, board, gh_ops):
3682
+ continue
3683
+
3684
+ # Gate: smoke. When the test gate is enabled and no *fresh* verdict
3685
+ # exists, the item appears as BLOCKED rather than being silently
3686
+ # excluded. #1640: shared reader — see the docstring.
3687
+ status = STAGING_READY
3688
+ reason: str | None = None
3689
+ if config is not None and board is not None and requires_smoke(a, config):
3690
+ smoke = evaluate_smoke_verdict(
3691
+ _staging_smoke_entry(a, config), board, gh_ops
3692
+ )
3693
+ if not smoke.ok:
3694
+ status = STAGING_BLOCKED
3695
+ reason = smoke.short_reason
3696
+
3697
+ result.append(StagingItem(
3698
+ assignment_id=aid,
3699
+ repo_name=repo_name,
3700
+ issue_number=issue_number,
3701
+ issue_title=issue_title,
3702
+ branch=branch,
3703
+ status=status,
3704
+ reason=reason,
3705
+ ))
3706
+
3707
+ return result
3708
+
3709
+
3710
+ # ── Processing ───────────────────────────────────────────────────────────
3711
+
3712
+ @dataclass
3713
+ class MergeEvent:
3714
+ entry: QueuedMerge
3715
+ kind: str # "opened" | "sized" | "merged" | "conflict" | "skipped" | "error" | "reopened"
3716
+ message: str = ""
3717
+
3718
+
3719
+ def _briefing_body(entry: QueuedMerge) -> str:
3720
+ # `Closes #N` makes GitHub auto-close the linked issue when the PR
3721
+ # merges — without it the issue stays stranded open and the TUI's
3722
+ # lifecycle ledger shows the row as In-flight forever (the brain
3723
+ # keeps re-synching it as state=open). Quadraui #239/#240/#242 hit
3724
+ # this in 2026-05; closing the issues was a manual cleanup.
3725
+ #
3726
+ # #1077: only emit the closing keyword when this entry's issue_number is
3727
+ # actually resolved by the PR (`CLOSES_ISSUE_TYPES`). A "mock-author"
3728
+ # entry's issue_number is the milestone's tracking issue — closing it on
3729
+ # merge is wrong (the epic reads "done" while its sub-issues are still
3730
+ # open), so it gets the non-closing `Refs #N` instead.
3731
+ keyword = "Closes" if entry.assignment_type in CLOSES_ISSUE_TYPES else "Refs"
3732
+ return (
3733
+ f"{keyword} #{entry.issue_number}\n\n"
3734
+ f"Automated merge from the coordinator for assignment "
3735
+ f"{entry.assignment_id} on issue #{entry.issue_number}.\n\n"
3736
+ f"Worker branch: `{entry.branch}` → `{entry.target_branch}`."
3737
+ )
3738
+
3739
+
3740
+ def process(
3741
+ items: list[QueuedMerge],
3742
+ gh_ops: GhOps,
3743
+ *,
3744
+ method: str = "rebase",
3745
+ dry_run: bool = False,
3746
+ presorted: bool = False,
3747
+ ci_store: CiStore | None = None,
3748
+ force_merge: bool = False,
3749
+ config=None,
3750
+ board=None,
3751
+ skip_review: bool = False,
3752
+ skip_smoke: bool = False,
3753
+ ) -> list[MergeEvent]:
3754
+ """Open PRs, size them, then merge each pending item.
3755
+
3756
+ Items are grouped by (repo_github, target_branch); a **merge conflict**
3757
+ parks the conflicting entry (``CONFLICT`` state; the caller in
3758
+ ``cli.py`` promotes it to ``HUMAN_REQUIRED``) and **continues** with
3759
+ the remaining siblings in that group — each entry's ``gh pr merge`` is
3760
+ independent, so a failed merge does not dirty the target branch for
3761
+ siblings (#735). Within a group, items are merged in input order —
3762
+ call `sequence(group)` first if you want size-based ordering.
3763
+ Set `presorted=True` to make that explicit at call sites.
3764
+
3765
+ When ``ci_store`` is provided and available, each PR is checked against
3766
+ its CI status before merge. A failed check produces a ``checks_failed``
3767
+ event; a still-running check produces ``checks_pending``; a PR whose CI
3768
+ was expected to run but reported zero checks at all — a distinct case
3769
+ from either, see #1904 — produces ``checks_absent``. In all three cases
3770
+ the entry is **skipped** (``continue``) rather than halting the group, so
3771
+ a ready sibling can still merge. ``force_merge=True`` skips this gate.
3772
+
3773
+ #253/#821: When *config* says review is required (``reviews.enabled`` and
3774
+ ``"review"`` in ``pipeline.default_gates``) the gate **fails closed**: if
3775
+ *board* is ``None`` the approval cannot be confirmed so the entry is
3776
+ blocked (``review_required`` event, skip — never merge). When *config*
3777
+ is ``None`` the gate is not applicable (no review policy → no block).
3778
+ ``skip_review=True`` bypasses the gate for explicit local-only overrides.
3779
+ The daemon ``/merge`` endpoint always passes ``skip_review=False`` and
3780
+ ignores any ``skip_review`` flag from the client (#821).
3781
+
3782
+ #465/#821: Same fail-closed semantics for the smoke gate: when *config*
3783
+ says ``"test"`` is in ``pipeline.default_gates`` but *board* is ``None``,
3784
+ the verdict cannot be confirmed → block (``smoke_required`` event).
3785
+ ``skip_smoke=True`` bypasses the gate.
3786
+
3787
+ Dry-run applies the review and smoke gates, and — #1624 — resolves each
3788
+ entry's real PR via ``find_pr_for_branch`` (the same lookup ``create_pr``
3789
+ does internally) and applies the CI gate against it too, so output
3790
+ reflects what a real run would do. CI genuinely cannot be checked for an
3791
+ entry with no PR yet (nothing exists to query); that case is reported as
3792
+ an explicit ``gate: unknown (no PR yet)`` note rather than silently
3793
+ treated as passing.
3794
+
3795
+ #1318: before each merge, both the PR body (#1196) and every commit
3796
+ message on the branch are scanned for a GitHub closing keyword
3797
+ (``Closes``/``Fixes``/``Resolves #N``) targeting an epic-labelled issue
3798
+ — free-text prose in a commit message (even a quote explaining the bug)
3799
+ is enough for GitHub's own scanner to auto-close it once the commit
3800
+ lands on the base branch, and no PR-body edit can undo that. A body hit
3801
+ is downgraded to ``Refs #N`` in place, same as #1196. A commit-message
3802
+ hit can't be rewritten here (no local git checkout in this ``gh``-only
3803
+ wire layer) so it **blocks** the merge (``epic_closing_keyword_in_commit``
3804
+ event) unless ``force_merge=True``, in which case the merge proceeds but
3805
+ an ``epic_closing_keyword_in_commit_forced`` warning event is still
3806
+ emitted — the override is never silent.
3807
+
3808
+ Mutates `items` in place; the caller saves the queue after.
3809
+ """
3810
+ events: list[MergeEvent] = []
3811
+ ci: CiStore = ci_store if ci_store is not None else NoOpCi()
3812
+
3813
+ groups: dict[tuple[str, str], list[QueuedMerge]] = {}
3814
+ for entry in items:
3815
+ if entry.state != PENDING:
3816
+ continue
3817
+ groups.setdefault((entry.repo_github, entry.target_branch), []).append(entry)
3818
+
3819
+ _unset = object()
3820
+
3821
+ for group in groups.values():
3822
+ # #1479-review: every entry in a group shares the same target_branch
3823
+ # (that's the grouping key), so target_branch_head_sha is the same
3824
+ # value for all of them — fetch it once per group instead of once
3825
+ # per entry to avoid N redundant `gh api` calls for an N-entry group.
3826
+ _group_target_branch_head_sha: str | None | object = _unset
3827
+
3828
+ if dry_run:
3829
+ # #1624: resolve each entry's real PR the same way the non-dry
3830
+ # path does (`create_pr` internally calls `find_pr_for_branch`
3831
+ # before ever calling `gh pr create`) instead of unconditionally
3832
+ # announcing "would open PR". A branch can already have an open
3833
+ # PR — from an earlier real attempt that opened one and then
3834
+ # stalled on a gate, or created out-of-band — and the CI gate
3835
+ # below needs a real PR number to evaluate against; without this,
3836
+ # the gate was silently skipped and the entry reported mergeable
3837
+ # even with failing checks (#1624). `find_pr_for_branch` is
3838
+ # optional on GhOps (older test stubs predate #1624): a missing
3839
+ # probe or a lookup failure leaves the PR unresolved, same
3840
+ # fail-closed contract as `branch_has_merge_commit` (#1467).
3841
+ _find_pr = getattr(gh_ops, "find_pr_for_branch", None)
3842
+ for entry in group:
3843
+ if entry.pr_number is not None:
3844
+ events.append(MergeEvent(
3845
+ entry, "opened",
3846
+ f"PR #{entry.pr_number} (existed) for {entry.branch}",
3847
+ ))
3848
+ continue
3849
+ existing = None
3850
+ if _find_pr is not None:
3851
+ try:
3852
+ existing = _find_pr(entry.repo_github, entry.branch)
3853
+ except Exception: # noqa: BLE001
3854
+ existing = None
3855
+ if existing is not None:
3856
+ entry.pr_number = existing.get("number")
3857
+ entry.pr_url = existing.get("url")
3858
+ events.append(MergeEvent(
3859
+ entry, "opened",
3860
+ f"PR #{entry.pr_number} (existed) for {entry.branch}",
3861
+ ))
3862
+ else:
3863
+ events.append(MergeEvent(
3864
+ entry, "opened",
3865
+ f"(dry run) would open PR for {entry.branch}",
3866
+ ))
3867
+ ordered = group if presorted else sequence(group)
3868
+ for entry in ordered:
3869
+ # #821: populate branch_head_sha for the commit-bound approval
3870
+ # staleness check in has_approved_review. Only when the board
3871
+ # is live (board=None blocks unconditionally; no SHA needed).
3872
+ if board is not None and entry.branch_head_sha is None:
3873
+ entry.branch_head_sha = gh_ops.get_branch_sha(
3874
+ entry.repo_github, entry.branch
3875
+ )
3876
+ # #1475/#1479: populate branch_patch_id alongside
3877
+ # branch_head_sha so has_approved_review / has_smoke_verdict
3878
+ # can carry a verdict forward across a content-identical
3879
+ # rebase instead of re-blocking on SHA alone. Only fetch it
3880
+ # when review or smoke is actually required for this entry —
3881
+ # neither gate consults branch_patch_id otherwise, so
3882
+ # skipping here saves a `gh api compare` round trip per entry
3883
+ # per process() tick for the common gate-disabled case.
3884
+ if (
3885
+ board is not None
3886
+ and entry.branch_patch_id is None
3887
+ and config is not None
3888
+ and (
3889
+ (not skip_review and requires_review(entry, config))
3890
+ or (not skip_smoke and requires_smoke(entry, config))
3891
+ )
3892
+ ):
3893
+ entry.branch_patch_id = gh_ops.get_branch_patch_id(
3894
+ entry.repo_github, entry.target_branch, entry.branch
3895
+ )
3896
+ # #1479: populate target_branch_head_sha so has_smoke_verdict
3897
+ # can detect a merge base that moved since the test verdict
3898
+ # was recorded — a condition branch_patch_id can't see, since
3899
+ # a rebase replays the identical diff onto a new base without
3900
+ # changing it. Only fetched when smoke is actually required,
3901
+ # same cost-avoidance as branch_patch_id above.
3902
+ if (
3903
+ board is not None
3904
+ and entry.target_branch_head_sha is None
3905
+ and not skip_smoke
3906
+ and config is not None
3907
+ and requires_smoke(entry, config)
3908
+ ):
3909
+ if _group_target_branch_head_sha is _unset:
3910
+ _group_target_branch_head_sha = gh_ops.get_branch_sha(
3911
+ entry.repo_github, entry.target_branch
3912
+ )
3913
+ entry.target_branch_head_sha = _group_target_branch_head_sha
3914
+ # #292 (Defect 4): apply the review gate in dry-run so output
3915
+ # reflects real behaviour. CI cannot be checked in dry-run
3916
+ # (no PR exists yet), so review and smoke gates are evaluated.
3917
+ # #821: fail closed — if review is required but board is None
3918
+ # (approval cannot be confirmed) block the entry.
3919
+ if (
3920
+ not skip_review
3921
+ and config is not None
3922
+ and requires_review(entry, config)
3923
+ and (board is None or not has_approved_review(entry, board, gh_ops))
3924
+ ):
3925
+ _why = (
3926
+ "board unavailable to confirm review approval"
3927
+ if board is None
3928
+ else "review required but not approved"
3929
+ )
3930
+ events.append(MergeEvent(
3931
+ entry, "review_required",
3932
+ f"(dry run) would be blocked: {_why} for {entry.branch}",
3933
+ ))
3934
+ continue
3935
+ # #465/#821: smoke gate in dry-run — same fail-closed logic.
3936
+ # #1640: when a verdict exists but failed the #1479 freshness
3937
+ # binding, say so (and against which SHA) rather than
3938
+ # reporting it as never recorded.
3939
+ _smoke = None # read below for the "merged" preview note (#1847)
3940
+ if (
3941
+ not skip_smoke
3942
+ and config is not None
3943
+ and requires_smoke(entry, config)
3944
+ ):
3945
+ _smoke = (
3946
+ None
3947
+ if board is None
3948
+ else evaluate_smoke_verdict(entry, board, gh_ops)
3949
+ )
3950
+ if _smoke is None or not _smoke.ok:
3951
+ _why = (
3952
+ "board unavailable to confirm smoke verdict"
3953
+ if _smoke is None
3954
+ else _smoke.message
3955
+ )
3956
+ events.append(MergeEvent(
3957
+ entry, "smoke_required",
3958
+ f"(dry run) would be blocked: {_why} for {entry.branch}",
3959
+ ))
3960
+ continue
3961
+ # CI gate (#240) preview, added by #1624: same check the real
3962
+ # path runs, evaluated here so a dry run can't claim
3963
+ # "would merge" for a PR whose checks are already failing.
3964
+ # Only evaluable when a real PR number is known — either
3965
+ # persisted from an earlier attempt or just resolved above
3966
+ # via `find_pr_for_branch` — since CI is checked per-PR, not
3967
+ # per-branch. A brand-new entry with no PR yet genuinely
3968
+ # cannot be checked; say so explicitly in the "merged"
3969
+ # preview below rather than silently treating "not
3970
+ # evaluated" as "would merge" (#1624). `force_merge` skips
3971
+ # the gate here exactly as it does in the real path.
3972
+ _ci_note = ""
3973
+ if not force_merge and ci.is_available:
3974
+ if entry.pr_number is not None:
3975
+ checks = ci.list_checks_for_pr(entry.repo_github, entry.pr_number)
3976
+ # #1904: same explicit "empty checks" handling the
3977
+ # real path applies below — every gate that follows
3978
+ # is a filter over `checks` and passes vacuously on
3979
+ # `[]`, so a PR whose CI never ran must be caught
3980
+ # here, not silently reported as "would merge".
3981
+ if not checks and _ci_expects_checks(
3982
+ ci, entry.repo_github, entry.pr_number
3983
+ ):
3984
+ # #1877: mirror the real path's fall-through —
3985
+ # an empty check list also means "PR conflicts
3986
+ # with its base, GitHub never built a merge ref
3987
+ # to test" and a real run routes that to the
3988
+ # #241 conflict-fix path rather than blocking
3989
+ # on CI. Preview that accurately instead of
3990
+ # either the misleading "CI never ran" (nothing
3991
+ # here is a CI problem) or letting it fall
3992
+ # through to the "would merge" message below
3993
+ # (the merge attempt itself would fail).
3994
+ conflicted = gh_ops.check_pr_mergeable(
3995
+ entry.repo_github, entry.pr_number
3996
+ ) is False
3997
+ if conflicted:
3998
+ events.append(MergeEvent(
3999
+ entry, "conflict",
4000
+ f"(dry run) {entry.branch} conflicts "
4001
+ f"with {entry.target_branch} and "
4002
+ "reports no checks — a real run would "
4003
+ "route to the #241 conflict-fix path "
4004
+ "rather than block on CI (#1877)",
4005
+ ))
4006
+ continue
4007
+ events.append(MergeEvent(
4008
+ entry, "checks_absent",
4009
+ f"(dry run) would be blocked: {CI_ABSENT_PREFIX} "
4010
+ f"no checks reported for {entry.branch} though "
4011
+ "this repo declares CI",
4012
+ ))
4013
+ continue
4014
+ failed = failed_checks(checks)
4015
+ if failed:
4016
+ # #1892: preview-only — never mutates, never
4017
+ # reruns; just shows the same classification a
4018
+ # live attempt would compute so `--dry-run`
4019
+ # doesn't undersell what's actually blocking.
4020
+ infra_reason = _ci_infra_reason(
4021
+ ci, entry.repo_github, entry.pr_number, failed
4022
+ )
4023
+ summary = ", ".join(
4024
+ f"{c.name} ({c.conclusion})" for c in failed
4025
+ )
4026
+ msg = infra_reason or f"checks failed: {summary}"
4027
+ events.append(MergeEvent(
4028
+ entry, "checks_failed",
4029
+ f"(dry run) would be blocked: {msg}",
4030
+ ))
4031
+ continue
4032
+ pending = in_flight_checks(checks)
4033
+ if pending:
4034
+ summary = ", ".join(c.name for c in pending)
4035
+ events.append(MergeEvent(
4036
+ entry, "checks_pending",
4037
+ f"(dry run) would be blocked: checks still running: {summary}",
4038
+ ))
4039
+ continue
4040
+ # #1851: a green CI result can itself be stale —
4041
+ # GitHub re-runs `pull_request` checks on head
4042
+ # `synchronize`, never on base movement, so a check
4043
+ # that started before the base's newest commit
4044
+ # landed never saw it. Named distinctly from
4045
+ # checks_failed/checks_pending above.
4046
+ if checks and _ci_checks_are_stale(
4047
+ checks, gh_ops, entry.repo_github,
4048
+ entry.target_branch, _smoke,
4049
+ ):
4050
+ events.append(MergeEvent(
4051
+ entry, "checks_stale",
4052
+ "(dry run) would be blocked: CI checks "
4053
+ "predate the current base — re-run CI "
4054
+ f"(`coord merge --revalidate`) for {entry.branch}",
4055
+ ))
4056
+ continue
4057
+ else:
4058
+ _ci_note = " [gate: unknown (no PR yet) — CI cannot be evaluated]"
4059
+ # #1467-review: preview the rebase→squash fallback in
4060
+ # dry-run too. Only reachable when this entry already has a
4061
+ # pr_number — from an earlier (non-dry-run) attempt, or just
4062
+ # resolved above via `find_pr_for_branch` (#1624) — since the
4063
+ # probe needs one to query. A first-time dry-run preview of a
4064
+ # brand-new entry still can't foresee the fallback. Same
4065
+ # fail-closed contract as the real merge path: an
4066
+ # inconclusive probe leaves the previewed method unchanged.
4067
+ _preview_method = method
4068
+ if method == "rebase" and entry.pr_number is not None:
4069
+ _probe = getattr(gh_ops, "branch_has_merge_commit", None)
4070
+ if _probe is not None:
4071
+ try:
4072
+ _has_merge_commit = _probe(
4073
+ entry.repo_github, entry.pr_number
4074
+ )
4075
+ except Exception: # noqa: BLE001
4076
+ _has_merge_commit = None
4077
+ if _has_merge_commit is True:
4078
+ _preview_method = "squash"
4079
+ events.append(MergeEvent(
4080
+ entry, "method_fallback",
4081
+ f"(dry run) PR #{entry.pr_number} ({entry.branch}) "
4082
+ "contains a merge commit and cannot be "
4083
+ "rebase-merged — would fall back to --squash "
4084
+ "(#1467)",
4085
+ ))
4086
+ # #1847: name *why* a base move didn't stale the smoke
4087
+ # verdict, distinctly for each of the three #1479 escape
4088
+ # hatches, so `--dry-run`/the TUI don't just say "fresh" —
4089
+ # they say fresh *because the base move was inert*, *because
4090
+ # the branch was inert*, or *because the two diffs are
4091
+ # disjoint*. Absent whenever the base never moved at all
4092
+ # (the common, unremarkable case stays quiet).
4093
+ _smoke_note = (
4094
+ f" [test verdict fresh: {_smoke.spared_reason}]"
4095
+ if _smoke is not None and _smoke.ok and _smoke.spared_reason
4096
+ else ""
4097
+ )
4098
+ events.append(MergeEvent(
4099
+ entry, "merged",
4100
+ f"(dry run) would merge {entry.branch} → {entry.target_branch} "
4101
+ f"via --{_preview_method}"
4102
+ f"{_bypass_note(entry, config)}"
4103
+ f"{_ci_note}"
4104
+ f"{_smoke_note}",
4105
+ ))
4106
+ continue
4107
+
4108
+ # Open PRs first so every entry has a pr_number when we sort & merge.
4109
+ for entry in group:
4110
+ if entry.pr_number is None:
4111
+ try:
4112
+ pr = gh_ops.create_pr(
4113
+ entry.repo_github,
4114
+ base=entry.target_branch,
4115
+ head=entry.branch,
4116
+ title=f"#{entry.issue_number}: {entry.issue_title}",
4117
+ body=_briefing_body(entry),
4118
+ )
4119
+ except Exception as e: # noqa: BLE001 — surface gh failure as event
4120
+ events.append(MergeEvent(entry, "error", f"create_pr failed: {e}"))
4121
+ continue
4122
+ entry.pr_number = pr.get("number")
4123
+ entry.pr_url = pr.get("url")
4124
+ events.append(MergeEvent(
4125
+ entry, "opened",
4126
+ f"PR #{entry.pr_number} ({'existed' if pr.get('existed') else 'created'}) for {entry.branch}",
4127
+ ))
4128
+ if entry.pr_number and entry.size is None:
4129
+ entry.size = gh_ops.get_pr_size(entry.repo_github, entry.pr_number)
4130
+ events.append(MergeEvent(entry, "sized", f"size={entry.size}"))
4131
+
4132
+ ordered = group if presorted else sequence(group)
4133
+ for entry in ordered:
4134
+ if entry.pr_number is None:
4135
+ continue
4136
+ # #821: populate branch_head_sha for the commit-bound approval
4137
+ # staleness check in has_approved_review. Only when the board
4138
+ # is live (board=None blocks unconditionally; no SHA needed).
4139
+ if board is not None and entry.branch_head_sha is None:
4140
+ entry.branch_head_sha = gh_ops.get_branch_sha(
4141
+ entry.repo_github, entry.branch
4142
+ )
4143
+ # #1475/#1479: populate branch_patch_id alongside branch_head_sha
4144
+ # so has_approved_review / has_smoke_verdict can carry a verdict
4145
+ # forward across a content-identical rebase instead of
4146
+ # re-blocking on SHA alone. Only fetch it when review or smoke is
4147
+ # actually required for this entry — neither gate consults
4148
+ # branch_patch_id otherwise, so skipping here saves a `gh api
4149
+ # compare` round trip per entry per process() tick for the
4150
+ # common gate-disabled case.
4151
+ if (
4152
+ board is not None
4153
+ and entry.branch_patch_id is None
4154
+ and config is not None
4155
+ and (
4156
+ (not skip_review and requires_review(entry, config))
4157
+ or (not skip_smoke and requires_smoke(entry, config))
4158
+ )
4159
+ ):
4160
+ entry.branch_patch_id = gh_ops.get_branch_patch_id(
4161
+ entry.repo_github, entry.target_branch, entry.branch
4162
+ )
4163
+ # #1479: populate target_branch_head_sha so has_smoke_verdict can
4164
+ # detect a merge base that moved since the test verdict was
4165
+ # recorded — a condition branch_patch_id can't see, since a
4166
+ # rebase replays the identical diff onto a new base without
4167
+ # changing it. Only fetched when smoke is actually required,
4168
+ # same cost-avoidance as branch_patch_id above.
4169
+ if (
4170
+ board is not None
4171
+ and entry.target_branch_head_sha is None
4172
+ and not skip_smoke
4173
+ and config is not None
4174
+ and requires_smoke(entry, config)
4175
+ ):
4176
+ if _group_target_branch_head_sha is _unset:
4177
+ _group_target_branch_head_sha = gh_ops.get_branch_sha(
4178
+ entry.repo_github, entry.target_branch
4179
+ )
4180
+ entry.target_branch_head_sha = _group_target_branch_head_sha
4181
+ # Review gate (#253/#821): refuse to merge when a review is required
4182
+ # by the pipeline policy but no approved review is on the board.
4183
+ # --skip-review bypasses for trivial/docs-only merges where the
4184
+ # user has consciously decided review isn't needed.
4185
+ # #292 (Defect 3): skip this entry and try the next one in the
4186
+ # group rather than halting the whole group. An un-reviewed entry
4187
+ # should not prevent a fully-approved sibling from merging.
4188
+ # #821: fail closed — when review is required but board is None
4189
+ # the approval cannot be confirmed; block rather than silently merge.
4190
+ if (
4191
+ not skip_review
4192
+ and config is not None
4193
+ and requires_review(entry, config)
4194
+ and (board is None or not has_approved_review(entry, board, gh_ops))
4195
+ ):
4196
+ msg = (
4197
+ "review required but board unavailable to confirm approval"
4198
+ if board is None
4199
+ else "review required but not approved"
4200
+ )
4201
+ entry.error = msg
4202
+ events.append(MergeEvent(entry, "review_required", msg))
4203
+ continue # #292: skip this entry; try the next in the group
4204
+ # Smoke gate (#465/#821): refuse to merge when the interactive smoke
4205
+ # is required by the pipeline policy but no passing/skipped verdict
4206
+ # is recorded on the work assignment. Same skip-not-halt semantics
4207
+ # as the review gate above.
4208
+ # #821: fail closed — when smoke is required but board is None
4209
+ # the verdict cannot be confirmed; block rather than silently merge.
4210
+ # #1640: distinguish "never recorded" from "recorded but stale
4211
+ # against the current base" — both used to print the former.
4212
+ # #1851: also read below by the CI-staleness check, which reuses
4213
+ # `smoke.spared_reason` when the smoke gate evaluated this same
4214
+ # base move — `None` when smoke wasn't required for this entry.
4215
+ smoke: "SmokeVerdictStatus | None" = None
4216
+ if (
4217
+ not skip_smoke
4218
+ and config is not None
4219
+ and requires_smoke(entry, config)
4220
+ ):
4221
+ smoke = (
4222
+ None if board is None else evaluate_smoke_verdict(entry, board, gh_ops)
4223
+ )
4224
+ if smoke is None or not smoke.ok:
4225
+ msg = (
4226
+ "smoke test required but board unavailable to confirm verdict"
4227
+ if smoke is None
4228
+ else smoke.message
4229
+ )
4230
+ entry.error = msg
4231
+ events.append(MergeEvent(entry, "smoke_required", msg))
4232
+ continue # skip this entry; try the next in the group
4233
+ # CI gate (#240): refuse to merge when checks are failed or
4234
+ # still running. --force-merge overrides for the case where the
4235
+ # user has seen the failures and wants to merge anyway.
4236
+ # #292 (Defect 3): skip-and-proceed for CI gates too, same logic
4237
+ # as the review gate — a pending/failing CI entry should not
4238
+ # block an approved sibling in the same (repo, target) group.
4239
+ if not force_merge and ci.is_available:
4240
+ checks = ci.list_checks_for_pr(entry.repo_github, entry.pr_number)
4241
+ # #1904: `failed_checks`/`in_flight_checks`/`_ci_checks_are_stale`
4242
+ # below are all filters over `checks` — an empty list
4243
+ # satisfies every one of them vacuously, which is exactly
4244
+ # the mechanism that let a PR whose CI never ran (a
4245
+ # throttled webhook, a wedged run, a path-filtered-out
4246
+ # workflow) merge as if it were green. Handled explicitly,
4247
+ # ahead of those gates, and only when `expects_checks` says
4248
+ # this repo actually declares CI — a repo with none
4249
+ # configured (`NoOpCi`, or `GitHubCi` against a repo with no
4250
+ # workflows) must not deadlock on this.
4251
+ if not checks and _ci_expects_checks(
4252
+ ci, entry.repo_github, entry.pr_number
4253
+ ):
4254
+ # #1877: an empty check list is ALSO what GitHub reports
4255
+ # for a PR that conflicts with its base — GitHub can
4256
+ # never build a merge ref for a conflicted PR, so no
4257
+ # `pull_request`-triggered workflow ever runs. That is a
4258
+ # different fact from "CI never ran on a mergeable PR"
4259
+ # and needs the opposite response: fall through to the
4260
+ # merge attempt below, which discovers the real conflict
4261
+ # and (via the `conflict` event / `_dispatch_conflict_
4262
+ # fixes`) dispatches #241's conflict-fix rebase, instead
4263
+ # of pre-empting it here with a "CI never ran" block
4264
+ # that only a human can clear. A confirmed-mergeable or
4265
+ # inconclusive (`None` — still computing, or the `gh`
4266
+ # call itself failed) read leaves today's block
4267
+ # untouched; this is not a license to skip the CI gate
4268
+ # for a merely-slow or unreadable mergeability check.
4269
+ conflicted = gh_ops.check_pr_mergeable(
4270
+ entry.repo_github, entry.pr_number
4271
+ ) is False
4272
+ if not conflicted:
4273
+ msg = (
4274
+ f"{CI_ABSENT_PREFIX} no checks reported for PR "
4275
+ f"#{entry.pr_number} though this repo declares CI "
4276
+ "— merging would run untested code"
4277
+ )
4278
+ entry.error = msg
4279
+ events.append(MergeEvent(entry, "checks_absent", msg))
4280
+ continue # #292: skip, don't halt the group
4281
+ failed = failed_checks(checks)
4282
+ if failed:
4283
+ summary = ", ".join(
4284
+ f"{c.name} ({c.conclusion})" for c in failed
4285
+ )
4286
+ # #1892: classify BEFORE deciding the message/event — a
4287
+ # verdictless failure (every failing check says nothing
4288
+ # about the code: never assigned a runner, or died at
4289
+ # "Set up job") gets auto-rerun instead of the plain
4290
+ # "checks failed" block, up to MAX_CI_INFRA_RERUNS times
4291
+ # per entry. This is the one extra `gh api .../jobs` call
4292
+ # per distinct failing run — never issued above on the
4293
+ # absent/pending paths, only here once something has
4294
+ # actually failed.
4295
+ infra_reason = _ci_infra_reason(
4296
+ ci, entry.repo_github, entry.pr_number, failed
4297
+ )
4298
+ if (
4299
+ infra_reason is not None
4300
+ and entry.ci_infra_reruns < MAX_CI_INFRA_RERUNS
4301
+ ):
4302
+ entry.ci_infra_reruns += 1
4303
+ reran = ci.rerun_for_pr(entry.repo_github, entry.pr_number)
4304
+ _log.info(
4305
+ "#1892 auto-rerun %d/%d for %s#%d (PR #%s): %s "
4306
+ "(rerun_for_pr %s)",
4307
+ entry.ci_infra_reruns, MAX_CI_INFRA_RERUNS,
4308
+ entry.repo_name, entry.issue_number,
4309
+ entry.pr_number, summary,
4310
+ "triggered" if reran else "FAILED",
4311
+ )
4312
+ msg = (
4313
+ f"{infra_reason} — auto-rerun "
4314
+ f"{entry.ci_infra_reruns}/{MAX_CI_INFRA_RERUNS} "
4315
+ f"{'triggered' if reran else 'failed to trigger'}"
4316
+ )
4317
+ entry.error = msg
4318
+ events.append(MergeEvent(entry, "ci_infra_rerun", msg))
4319
+ continue # #292: skip, don't halt the group
4320
+ if infra_reason is not None:
4321
+ # #1892: budget exhausted — a workflow broken at
4322
+ # "Set up job" itself (a bad `uses:` ref, a deleted
4323
+ # action) must stop auto-rerunning and surface to a
4324
+ # human instead of looping forever. Deliberately
4325
+ # WITHOUT the CI_INFRA_PREFIX from here on: this is
4326
+ # no longer something more real time alone resolves,
4327
+ # so it falls back to being treated exactly like a
4328
+ # genuine `checks_failed` block (drive attempts are
4329
+ # spent on it again, same as today).
4330
+ _log.info(
4331
+ "#1892 auto-rerun budget exhausted for %s#%d "
4332
+ "(PR #%s) after %d/%d tries: %s",
4333
+ entry.repo_name, entry.issue_number,
4334
+ entry.pr_number, entry.ci_infra_reruns,
4335
+ MAX_CI_INFRA_RERUNS, summary,
4336
+ )
4337
+ msg = (
4338
+ f"checks failed: {summary} — auto-rerun budget "
4339
+ f"exhausted ({entry.ci_infra_reruns}/"
4340
+ f"{MAX_CI_INFRA_RERUNS}); needs a human"
4341
+ )
4342
+ entry.error = msg
4343
+ events.append(MergeEvent(entry, "checks_failed", msg))
4344
+ continue # #292: skip, don't halt the group
4345
+ msg = f"checks failed: {summary}"
4346
+ entry.error = msg
4347
+ events.append(MergeEvent(entry, "checks_failed", msg))
4348
+ continue # #292: skip, don't halt the group
4349
+ pending = in_flight_checks(checks)
4350
+ if pending:
4351
+ summary = ", ".join(c.name for c in pending)
4352
+ # #1891: same `CI_PENDING_PREFIX` wording `_entry_gate_status`
4353
+ # returns for the board render — this is what lets
4354
+ # `IssueState.merge_reason`'s raw-row fallback (see that
4355
+ # constant's docstring) carry the SAME, recognisable marker
4356
+ # even when it falls back to this persisted `entry.error`
4357
+ # instead of a fresh live re-evaluation.
4358
+ msg = f"{CI_PENDING_PREFIX} {summary}"
4359
+ entry.error = msg
4360
+ events.append(MergeEvent(entry, "checks_pending", msg))
4361
+ continue # #292: skip, don't halt the group
4362
+ # #1892: this line is only reached once BOTH `if failed:`
4363
+ # and `if pending:` above did not fire — a genuine
4364
+ # resolution (checks non-empty*, nothing failed, nothing
4365
+ # pending), not merely "not currently failed". Resetting on
4366
+ # "not failed" alone was a bug: the tick right after this
4367
+ # same code triggers an auto-rerun almost always observes
4368
+ # the rerun as still pending (a real Actions run takes real
4369
+ # wall-clock minutes), which would zero the budget before
4370
+ # the rerun itself ever resolves — so a workflow genuinely
4371
+ # broken at "Set up job" would fail, rerun, get reset to 0
4372
+ # while pending, fail again, rerun again... forever, never
4373
+ # reaching MAX_CI_INFRA_RERUNS and never parking for a
4374
+ # human. Whatever verdictless run the budget was tracking
4375
+ # has now actually resolved one way or another, so a LATER
4376
+ # failure (a fresh push, a flaky green-then-red) starts its
4377
+ # own budget from zero rather than inheriting an unrelated
4378
+ # exhausted count.
4379
+ # * `checks` can still be `[]` here for a repo that doesn't
4380
+ # declare CI at all (`_ci_expects_checks` false, so the
4381
+ # `checks_absent` branch above didn't fire) — resetting in
4382
+ # that case is harmless since such an entry can never have
4383
+ # accrued a nonzero `ci_infra_reruns` to begin with.
4384
+ entry.ci_infra_reruns = 0
4385
+ # #1851: a green CI result can itself be stale relative to the
4386
+ # base — see `_ci_checks_are_stale`'s docstring. Named
4387
+ # distinctly (`checks_stale`) from checks_failed/
4388
+ # checks_pending above so an operator (and `coord merge
4389
+ # --revalidate`, the remedy) can tell the three apart.
4390
+ if checks and _ci_checks_are_stale(
4391
+ checks, gh_ops, entry.repo_github, entry.target_branch, smoke,
4392
+ ):
4393
+ msg = (
4394
+ f"{CI_STALE_PREFIX} checks predate the current base "
4395
+ "— re-run CI (`coord merge --revalidate`) before merging"
4396
+ )
4397
+ entry.error = msg
4398
+ events.append(MergeEvent(entry, "checks_stale", msg))
4399
+ continue # #292: skip, don't halt the group
4400
+ # #1318: cache is_epic_issue lookups for this entry — the same
4401
+ # referenced number can show up in both the PR body and one or
4402
+ # more commit messages below, and each lookup is a `gh` round
4403
+ # trip. Best-effort like every check in this block: a lookup
4404
+ # failure just means "not known to be an epic", never a block.
4405
+ _epic_cache: dict[int, bool] = {}
4406
+
4407
+ def _is_epic(n: int) -> bool:
4408
+ if n not in _epic_cache:
4409
+ try:
4410
+ _epic_cache[n] = gh_ops.is_epic_issue(entry.repo_github, n)
4411
+ except Exception: # noqa: BLE001
4412
+ _epic_cache[n] = False
4413
+ return _epic_cache[n]
4414
+
4415
+ # #1196 hole 2 / #1318: GitHub's own closing-keyword magic reads
4416
+ # the PR body directly at merge time and never calls
4417
+ # `github_ops.close_issue` — that chokepoint's open-children
4418
+ # guard can't stop it. Scan the body for `Closes #N`/`Fixes
4419
+ # #N`/`Resolves #N` and downgrade to `Refs #N` for any N that
4420
+ # either currently has open children (#1196) or carries the
4421
+ # epic/tracking label (#1318 — an epic can have zero open
4422
+ # children today and still be the wrong thing to auto-close),
4423
+ # before the merge lands. Best effort throughout: a lint
4424
+ # failure must never block a merge.
4425
+ try:
4426
+ pr_body = gh_ops.get_pr_body(entry.repo_github, entry.pr_number)
4427
+ except Exception: # noqa: BLE001
4428
+ pr_body = ""
4429
+ if pr_body:
4430
+ referenced = find_closing_references(pr_body)
4431
+ blocking: set[int] = set()
4432
+ for n in referenced:
4433
+ try:
4434
+ if gh_ops.has_open_children(entry.repo_github, n):
4435
+ blocking.add(n)
4436
+ except Exception: # noqa: BLE001
4437
+ pass
4438
+ if _is_epic(n):
4439
+ blocking.add(n)
4440
+ if blocking:
4441
+ new_body, downgraded = downgrade_closing_keywords(pr_body, blocking)
4442
+ if downgraded:
4443
+ try:
4444
+ gh_ops.edit_pr_body(entry.repo_github, entry.pr_number, new_body)
4445
+ events.append(MergeEvent(
4446
+ entry, "pr_body_downgraded",
4447
+ "downgraded closing keyword to Refs for "
4448
+ + ", ".join(f"#{n}" for n in downgraded)
4449
+ + " (open children / epic — #1196/#1318)",
4450
+ ))
4451
+ except Exception as e: # noqa: BLE001
4452
+ events.append(MergeEvent(
4453
+ entry, "pr_body_downgrade_failed",
4454
+ f"could not downgrade PR #{entry.pr_number} body "
4455
+ f"for {', '.join(f'#{n}' for n in downgraded)}: {e}",
4456
+ ))
4457
+
4458
+ # #1318: the PR-body scan above can't help with commit messages
4459
+ # — GitHub's closing-keyword scanner reads those too once they
4460
+ # land on the base branch (every original commit, unchanged, for
4461
+ # `--rebase`/`--merge`; and depending on repo settings, squash's
4462
+ # default commit body can pull the same text). There's no local
4463
+ # git checkout in this `gh`-only wire layer to amend and
4464
+ # force-push a rewritten message, so a hit here **blocks** the
4465
+ # merge rather than silently rewriting history. `force_merge`
4466
+ # overrides (same flag `--force-merge` already uses to skip the
4467
+ # CI gate) but the override is never silent — a warning event
4468
+ # still fires so it shows up in `coord merge` output and the
4469
+ # audit trail.
4470
+ try:
4471
+ commit_messages = gh_ops.get_pr_commit_messages(
4472
+ entry.repo_github, entry.pr_number
4473
+ )
4474
+ except Exception: # noqa: BLE001
4475
+ commit_messages = []
4476
+ commit_referenced: set[int] = set()
4477
+ for message in commit_messages:
4478
+ commit_referenced.update(find_closing_references(message))
4479
+ commit_epic_hits = sorted(n for n in commit_referenced if _is_epic(n))
4480
+ if commit_epic_hits:
4481
+ numbers_str = ", ".join(f"#{n}" for n in commit_epic_hits)
4482
+ msg = (
4483
+ f"a commit message on this branch contains a closing keyword "
4484
+ f"(Closes/Fixes/Resolves) for {numbers_str}, which carries the "
4485
+ f"'epic' label — GitHub auto-closes it on merge regardless of "
4486
+ f"the PR body (#1318). Reword the commit message(s) to "
4487
+ f"'refs #N' / 'epic #N' and push, or pass --force-merge to "
4488
+ f"merge anyway (the epic WILL still auto-close)."
4489
+ )
4490
+ if force_merge:
4491
+ events.append(MergeEvent(
4492
+ entry, "epic_closing_keyword_in_commit_forced", msg,
4493
+ ))
4494
+ else:
4495
+ entry.error = msg
4496
+ events.append(MergeEvent(
4497
+ entry, "epic_closing_keyword_in_commit", msg,
4498
+ ))
4499
+ continue # #1318: refuse — never merge a branch that will
4500
+ # auto-close an epic via a commit message we can't rewrite.
4501
+
4502
+ # #1467: pre-flight linearity check. GitHub refuses to
4503
+ # rebase-merge any branch containing a merge commit ("This
4504
+ # branch can't be rebased") — a distinct failure from a content
4505
+ # conflict, and one GitHub's own `mergeable` field can't predict
4506
+ # (a branch with a merge commit still reads MERGEABLE). Detect
4507
+ # it via the PR's commit list — no local checkout is guaranteed
4508
+ # on the daemon host, so `git rev-list --merges` is the wrong
4509
+ # instrument here — and fall back to squash, which is always
4510
+ # valid and keeps the target branch linear.
4511
+ #
4512
+ # Fail-closed: `branch_has_merge_commit` is optional on `gh_ops`
4513
+ # (older stubs in tests predate #1467) and returns `None` on any
4514
+ # `gh` error or ambiguous response; either case leaves `method`
4515
+ # unchanged rather than guessing.
4516
+ merge_method = method
4517
+ if method == "rebase":
4518
+ _probe = getattr(gh_ops, "branch_has_merge_commit", None)
4519
+ if _probe is not None:
4520
+ try:
4521
+ _has_merge_commit = _probe(entry.repo_github, entry.pr_number)
4522
+ except Exception: # noqa: BLE001
4523
+ _has_merge_commit = None
4524
+ if _has_merge_commit is True:
4525
+ merge_method = "squash"
4526
+ events.append(MergeEvent(
4527
+ entry, "method_fallback",
4528
+ f"PR #{entry.pr_number} ({entry.branch}) contains a "
4529
+ "merge commit and cannot be rebase-merged — "
4530
+ "falling back to --squash (#1467)",
4531
+ ))
4532
+
4533
+ entry.last_attempt = time.time()
4534
+ entry.state = MERGING
4535
+ ok, msg = gh_ops.merge_pr(entry.repo_github, entry.pr_number, method=merge_method)
4536
+ if ok:
4537
+ entry.state = MERGED
4538
+ entry.error = None
4539
+ # #1767: the condition that produced a drive escalation for
4540
+ # this issue (if any) just resolved through the normal
4541
+ # pipeline — clear it so `coord escalate list` doesn't
4542
+ # accumulate phantoms for merged work. Idempotent and a
4543
+ # no-op when there's nothing on file; routes through
4544
+ # `coord.state` so it works (via the daemon) from a thin
4545
+ # client rather than writing the local DB directly.
4546
+ try:
4547
+ dismiss_drive_escalation(entry.repo_name, entry.issue_number)
4548
+ except Exception: # noqa: BLE001 — never fail a merge on this
4549
+ pass
4550
+ # #1213: audit any gate bypassed by a per-issue label override
4551
+ # BEFORE announcing the merge, so the "merged" event message
4552
+ # already carries the bypass note — a bypass is never silent.
4553
+ # Only fires on a real merge (never dry-run, handled above via
4554
+ # the side-effect-free _bypass_note) so previews can't write
4555
+ # phantom audit rows.
4556
+ _record_gate_bypass_audit(entry, config)
4557
+ bypass_note = _bypass_note(entry, config)
4558
+ # Deterministically close the linked issue. GitHub's `Closes #N`
4559
+ # auto-close only fires when the PR *body* carries the keyword
4560
+ # AND it merges into the default branch; the worker-created-PR
4561
+ # path only asks the LLM for it and `fix(#N):` subjects aren't
4562
+ # closing keywords, so issues got stranded open (#806).
4563
+ # Best-effort — a close failure must not undo a successful merge.
4564
+ # Closing on GitHub keeps the daemon the sole DB writer: the next
4565
+ # reconcile/sync flips the cached row to closed (state.py).
4566
+ #
4567
+ # #1077: only for entries whose issue_number is actually
4568
+ # resolved by this PR (CLOSES_ISSUE_TYPES). A "mock-author"
4569
+ # entry's issue_number is the milestone's tracking issue —
4570
+ # closing it here would be the exact #1077 bug regardless of
4571
+ # what the PR body says.
4572
+ if entry.assignment_type in CLOSES_ISSUE_TYPES:
4573
+ try:
4574
+ gh_ops.close_issue(entry.repo_github, entry.issue_number)
4575
+ events.append(MergeEvent(
4576
+ entry, "merged",
4577
+ f"merged PR #{entry.pr_number}; closed issue #{entry.issue_number}"
4578
+ f"{bypass_note}",
4579
+ ))
4580
+ except Exception as e: # noqa: BLE001 — never fail a merge on close
4581
+ events.append(MergeEvent(
4582
+ entry, "merged",
4583
+ f"merged PR #{entry.pr_number} (warning: could not "
4584
+ f"close issue #{entry.issue_number}: {e}){bypass_note}",
4585
+ ))
4586
+ else:
4587
+ events.append(MergeEvent(
4588
+ entry, "merged",
4589
+ f"merged PR #{entry.pr_number}; issue #{entry.issue_number} "
4590
+ f"left open (assignment type {entry.assignment_type!r} "
4591
+ f"does not close its tracking issue, #1077){bypass_note}",
4592
+ ))
4593
+ continue
4594
+ entry.state = CONFLICT
4595
+ entry.error = msg
4596
+ events.append(MergeEvent(entry, "conflict", msg))
4597
+ continue # #735: park this entry; siblings in same group still merge
4598
+
4599
+ return events
4600
+
4601
+
4602
+ # ── Drop / prune (#732) ──────────────────────────────────────────────────
4603
+
4604
+ def drop_entry(assignment_id: str) -> bool:
4605
+ """Remove exactly the merge_queue row keyed to *assignment_id*.
4606
+
4607
+ Returns ``True`` when a row was deleted, ``False`` when no matching row
4608
+ was found. This is the surgical mutation that ``coord merge --drop`` and
4609
+ the TUI "drop" action use; it never touches other rows.
4610
+
4611
+ Because the queue lives on the daemon host, callers on thin clients must
4612
+ route through the daemon (``/merge`` endpoint with ``"drop": aid`` in the
4613
+ body) rather than calling this directly — the daemon guard pattern is the
4614
+ same as ``coord merge`` (#584).
4615
+
4616
+ #1477: *assignment_id* is resolved via :func:`resolve_entry_key`, so the
4617
+ durable ``repo#issue`` form works here too — not just a raw assignment
4618
+ id, which can go stale across a drop + re-enqueue cycle.
4619
+ """
4620
+ conn = get_connection()
4621
+ entry = resolve_entry_key(load_queue(), assignment_id)
4622
+ if entry is None:
4623
+ return False
4624
+ with conn:
4625
+ cursor = conn.execute(
4626
+ "DELETE FROM merge_queue WHERE assignment_id = ?", (entry.assignment_id,)
4627
+ )
4628
+ return cursor.rowcount > 0
4629
+
4630
+
4631
+ def prune_stale_queue_entries(dry_run: bool = False) -> list["QueuedMerge"]:
4632
+ """Remove merge_queue entries whose issue is closed or PR is already merged.
4633
+
4634
+ Returns the list of pruned entries so callers can surface them in output.
4635
+
4636
+ Only non-``MERGED`` entries are inspected — entries already recorded as
4637
+ ``MERGED`` are correct history and are left untouched.
4638
+
4639
+ Uses :func:`coord.github_ops.issue_is_closed` and
4640
+ :func:`coord.github_ops.pr_is_merged`, both of which **fail-open**
4641
+ (return ``False`` on any ``gh`` error) so a transient GitHub/CLI failure
4642
+ never silently prunes a live entry.
4643
+ """
4644
+ from coord import github_ops # noqa: PLC0415
4645
+
4646
+ entries = load_queue()
4647
+ stale: list[QueuedMerge] = []
4648
+ surviving: list[QueuedMerge] = []
4649
+
4650
+ for entry in entries:
4651
+ if entry.state == MERGED:
4652
+ surviving.append(entry)
4653
+ continue
4654
+
4655
+ is_stale = False
4656
+ if github_ops.issue_is_closed(entry.repo_github, entry.issue_number):
4657
+ is_stale = True
4658
+ elif entry.branch and github_ops.pr_is_merged(entry.repo_github, entry.branch):
4659
+ is_stale = True
4660
+
4661
+ if is_stale:
4662
+ stale.append(entry)
4663
+ else:
4664
+ surviving.append(entry)
4665
+
4666
+ if not dry_run and stale:
4667
+ save_queue(surviving)
4668
+
4669
+ return stale
4670
+
4671
+
4672
+ # ── Convenience ──────────────────────────────────────────────────────────
4673
+
4674
+ def pending_summary(items: list[QueuedMerge]) -> dict[str, list[QueuedMerge]]:
4675
+ """Group items for display in `coord status`. Returns {repo_name: [entries]}."""
4676
+ out: dict[str, list[QueuedMerge]] = {}
4677
+ for entry in items:
4678
+ if entry.state in (MERGED, SKIPPED):
4679
+ continue
4680
+ out.setdefault(entry.repo_name, []).append(entry)
4681
+ return out