code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/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
|