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/commands/merge.py
ADDED
|
@@ -0,0 +1,1900 @@
|
|
|
1
|
+
"""`coord merge` and the rest of the merge-queue surface: `verify-merge`,
|
|
2
|
+
`reconcile-merges`, `bounce`, `post-pending-reviews`. Extracted from
|
|
3
|
+
coord/cli.py (#747)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
from coord.commands._common import _CONFIG_OPTION, _load_config
|
|
14
|
+
from coord.models import WORK_LIKE_TYPES
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _machine_for_assignment(board, assignment_id: str | None) -> str | None:
|
|
18
|
+
"""Return the machine name that ran *assignment_id*, or None.
|
|
19
|
+
|
|
20
|
+
Used by ``coord merge`` (#241) to prefer dispatching a conflict-fix to
|
|
21
|
+
the original worker's machine — that machine already has the repo
|
|
22
|
+
checked out, the branch present, and the test deps installed.
|
|
23
|
+
"""
|
|
24
|
+
if assignment_id is None or board is None:
|
|
25
|
+
return None
|
|
26
|
+
target = board.find_by_id(assignment_id)
|
|
27
|
+
return target.machine_name if target is not None else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _apply_revalidation(items, board, config, gh_ops, *, dry_run: bool):
|
|
31
|
+
"""#1769: the ``--revalidate`` arm for the merge lane.
|
|
32
|
+
|
|
33
|
+
Finds the entries in *items* blocked **solely** on a stale-but-``passed``
|
|
34
|
+
smoke verdict (:func:`coord.merge_queue.revalidation_candidates` is the
|
|
35
|
+
whole eligibility policy — review/CI/conflict/missing-verdict blocks are
|
|
36
|
+
never touched), re-tests them against the current base, and lets
|
|
37
|
+
:func:`coord.merge_queue.process` re-evaluate afterwards.
|
|
38
|
+
|
|
39
|
+
Batch (#1715): candidates are grouped by ``(repo, target_branch)`` and each
|
|
40
|
+
group is composed onto its current base and validated by ONE suite run —
|
|
41
|
+
which is exactly what the operator did by hand three times in the
|
|
42
|
+
2026-08-03 session that motivated this. N approved branches on one base
|
|
43
|
+
cost one suite run, not N.
|
|
44
|
+
|
|
45
|
+
A group whose composite fails merges **nothing** on that result and marks
|
|
46
|
+
nothing failed; :func:`coord.revalidate.revalidate_group` then re-tests each
|
|
47
|
+
branch alone so the culprit is named and the innocent branches still merge.
|
|
48
|
+
A failing re-test can never launder a merge in either pass.
|
|
49
|
+
|
|
50
|
+
Under ``--dry-run`` this only *names* the batches and their members — no
|
|
51
|
+
worktree, no suite, no verdict write.
|
|
52
|
+
|
|
53
|
+
Returns the board to hand to ``process()``: a freshly-loaded one when any
|
|
54
|
+
verdict was actually recorded (the in-memory board predates that write and
|
|
55
|
+
would still show the entries as stale), otherwise *board* unchanged.
|
|
56
|
+
"""
|
|
57
|
+
from coord import merge_queue as _mq # noqa: PLC0415
|
|
58
|
+
from coord import revalidate as _rv # noqa: PLC0415
|
|
59
|
+
|
|
60
|
+
candidates = _mq.revalidation_candidates(items, board, config, gh_ops)
|
|
61
|
+
if not candidates:
|
|
62
|
+
click.echo(
|
|
63
|
+
" --revalidate: no entry is blocked solely on a stale test "
|
|
64
|
+
"verdict — nothing to revalidate (review/CI/conflict/missing-"
|
|
65
|
+
"verdict blocks are never revalidated)"
|
|
66
|
+
)
|
|
67
|
+
return board
|
|
68
|
+
|
|
69
|
+
if dry_run:
|
|
70
|
+
for line in _rv.describe_batches(candidates):
|
|
71
|
+
click.echo(line)
|
|
72
|
+
return board
|
|
73
|
+
|
|
74
|
+
for line in _rv.describe_candidates(candidates):
|
|
75
|
+
click.echo(line)
|
|
76
|
+
|
|
77
|
+
recorded_any = False
|
|
78
|
+
total_runs = 0
|
|
79
|
+
groups = _rv.group_candidates(candidates)
|
|
80
|
+
for (repo_name, target_branch), group in groups:
|
|
81
|
+
click.echo(
|
|
82
|
+
f" --revalidate: {repo_name} → {target_branch}: "
|
|
83
|
+
f"{len(group)} entry(ies)"
|
|
84
|
+
)
|
|
85
|
+
batch = _rv.revalidate_group(group, config, echo=click.echo)
|
|
86
|
+
total_runs += batch.suite_runs
|
|
87
|
+
recorded_any = recorded_any or bool(batch.recorded)
|
|
88
|
+
if not batch.composite.ok:
|
|
89
|
+
# The composite's own failure report — stderr, as before.
|
|
90
|
+
for line in _rv.format_failure(batch.composite):
|
|
91
|
+
click.echo(line, err=True)
|
|
92
|
+
# What actually came of it (what merged anyway, who the culprit was)
|
|
93
|
+
# is ordinary output, even when the composite above was red.
|
|
94
|
+
for line in _rv.format_batch(batch):
|
|
95
|
+
click.echo(line)
|
|
96
|
+
|
|
97
|
+
if len(candidates) > 1:
|
|
98
|
+
click.echo(
|
|
99
|
+
f" --revalidate: {total_runs} suite run(s) for "
|
|
100
|
+
f"{len(candidates)} entry(ies)"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not recorded_any:
|
|
104
|
+
return board
|
|
105
|
+
|
|
106
|
+
from coord.models import Board as _Board # noqa: PLC0415
|
|
107
|
+
from coord.state import load_board as _load_board # noqa: PLC0415
|
|
108
|
+
|
|
109
|
+
refreshed = _load_board()
|
|
110
|
+
return refreshed if refreshed is not None else _Board(active=[], completed=[])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _apply_ci_revalidation(
|
|
114
|
+
items, board, config, ci_store, gh_ops, *, dry_run: bool,
|
|
115
|
+
poll_sleep=None, poll_clock=None,
|
|
116
|
+
) -> set[str]:
|
|
117
|
+
"""#1851: the ``--revalidate`` arm for CI staleness — the CI analogue of
|
|
118
|
+
:func:`_apply_revalidation`'s stale-local-verdict arm, resolving a
|
|
119
|
+
different staleness signal (see ``coord/ci_store.py``'s module docstring
|
|
120
|
+
for why the two are distinct: GitHub re-runs ``pull_request`` checks on
|
|
121
|
+
head ``synchronize``, never on base movement, so a green check can
|
|
122
|
+
outlive the base it actually validated even when the local Test verdict
|
|
123
|
+
is perfectly fresh).
|
|
124
|
+
|
|
125
|
+
Unlike :func:`_apply_revalidation` there is nothing to compose and no
|
|
126
|
+
local suite to run — the remedy is :meth:`coord.ci_store.CiStore.
|
|
127
|
+
rerun_for_pr`, a ``gh run rerun`` that costs CI minutes on GitHub's own
|
|
128
|
+
runners, not a routed Test-stage agent. Strictly cheaper than what
|
|
129
|
+
:func:`_apply_revalidation` does for the same reason CI should always be
|
|
130
|
+
preferred when both would establish the same fact (#1851's "Cost
|
|
131
|
+
framing").
|
|
132
|
+
|
|
133
|
+
Opt-in behind ``--revalidate`` exactly like :func:`_apply_revalidation`
|
|
134
|
+
— never called from auto-drain (see ``docs/DRIVE_QUEUE.md``). Under
|
|
135
|
+
``--dry-run`` this only *names* what it would trigger; no ``gh`` mutation
|
|
136
|
+
runs.
|
|
137
|
+
|
|
138
|
+
#1925: triggering a rerun and then handing the entry straight to
|
|
139
|
+
``merge_queue.process()`` used to fail-close on the rerun's OWN
|
|
140
|
+
registration gap — ``gh pr checks`` errors for the few seconds before
|
|
141
|
+
GitHub has created any check-run record, which reads as the #1525
|
|
142
|
+
synthetic ``unknown`` conclusion and blocks exactly like a genuinely
|
|
143
|
+
broken CI would. :func:`coord.ci_store.wait_for_ci_settle` closes that
|
|
144
|
+
gap with a bounded poll right here, so the caller's subsequent
|
|
145
|
+
``process()`` call sees a real, resolved result — pass or fail — for the
|
|
146
|
+
common case.
|
|
147
|
+
|
|
148
|
+
Returns the ``assignment_id``s whose wait ran out the budget while STILL
|
|
149
|
+
only seeing the registration-gap symptom (never a real check) —
|
|
150
|
+
:func:`wait_for_ci_settle`'s ``registering=True`` case. The caller must
|
|
151
|
+
exclude these from the ``process()`` call that follows: evaluating the
|
|
152
|
+
gate against that exact symptom is the bug this fixes, so it must not
|
|
153
|
+
reappear at the timeout edge just because the wait gave up. The entry
|
|
154
|
+
stays ``PENDING`` with an explanatory ``entry.error`` instead — legible
|
|
155
|
+
as "come back shortly", never as "checks failed" (#1925's acceptance:
|
|
156
|
+
an ``unknown`` this command caused must not be presented identically to
|
|
157
|
+
an ``unknown`` from genuinely broken CI).
|
|
158
|
+
"""
|
|
159
|
+
from coord import merge_queue as _mq # noqa: PLC0415
|
|
160
|
+
from coord.ci_store import wait_for_ci_settle # noqa: PLC0415
|
|
161
|
+
|
|
162
|
+
deferred: set[str] = set()
|
|
163
|
+
if ci_store is None or not ci_store.is_available:
|
|
164
|
+
return deferred
|
|
165
|
+
candidates = _mq.ci_revalidation_candidates(items, board, config, ci_store, gh_ops)
|
|
166
|
+
if not candidates:
|
|
167
|
+
click.echo(
|
|
168
|
+
" --revalidate: no entry is blocked solely on stale CI checks "
|
|
169
|
+
"— nothing to re-run"
|
|
170
|
+
)
|
|
171
|
+
return deferred
|
|
172
|
+
for entry in candidates:
|
|
173
|
+
label = f"{entry.repo_name} #{entry.issue_number} ({entry.branch})"
|
|
174
|
+
if dry_run:
|
|
175
|
+
click.echo(
|
|
176
|
+
f" --revalidate: would re-run CI for {label} "
|
|
177
|
+
f"(PR #{entry.pr_number}) — checks predate the current base"
|
|
178
|
+
)
|
|
179
|
+
continue
|
|
180
|
+
ok = ci_store.rerun_for_pr(entry.repo_github, entry.pr_number)
|
|
181
|
+
if not ok:
|
|
182
|
+
click.echo(
|
|
183
|
+
f" --revalidate: could not trigger a CI re-run for {label} "
|
|
184
|
+
f"(PR #{entry.pr_number}) — see gh output above",
|
|
185
|
+
err=True,
|
|
186
|
+
)
|
|
187
|
+
continue
|
|
188
|
+
click.echo(
|
|
189
|
+
f" --revalidate: triggered a CI re-run for {label} "
|
|
190
|
+
f"(PR #{entry.pr_number})"
|
|
191
|
+
)
|
|
192
|
+
result = wait_for_ci_settle(
|
|
193
|
+
ci_store, entry.repo_github, entry.pr_number,
|
|
194
|
+
echo=click.echo, sleep=poll_sleep, clock=poll_clock,
|
|
195
|
+
)
|
|
196
|
+
if result.settled:
|
|
197
|
+
click.echo(
|
|
198
|
+
f" --revalidate: CI re-run for {label} settled after "
|
|
199
|
+
f"{result.waited_seconds:.0f}s — the merge gate will "
|
|
200
|
+
"evaluate the fresh result"
|
|
201
|
+
)
|
|
202
|
+
elif result.registering:
|
|
203
|
+
entry.error = (
|
|
204
|
+
f"{label}: the CI re-run --revalidate just triggered "
|
|
205
|
+
f"(PR #{entry.pr_number}) has not registered on GitHub yet "
|
|
206
|
+
f"after {result.waited_seconds:.0f}s — this is the re-run "
|
|
207
|
+
"THIS command started, not a CI failure; re-run `coord "
|
|
208
|
+
"merge --revalidate` (or plain `coord merge`) shortly (#1925)"
|
|
209
|
+
)
|
|
210
|
+
deferred.add(entry.assignment_id)
|
|
211
|
+
click.echo(f" --revalidate: {entry.error}")
|
|
212
|
+
else:
|
|
213
|
+
click.echo(
|
|
214
|
+
f" --revalidate: CI re-run for {label} is still running "
|
|
215
|
+
f"after {result.waited_seconds:.0f}s — leaving it to the "
|
|
216
|
+
"merge gate this pass (will report as CI still running)"
|
|
217
|
+
)
|
|
218
|
+
return deferred
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _dispatch_conflict_fixes(events, config, *, dry_run: bool) -> None:
|
|
222
|
+
"""#241: classify any conflict events and dispatch a conflict-fix worker
|
|
223
|
+
for the eligible ones. Mutates each conflict event's ``ev.entry.state``
|
|
224
|
+
in place (to ``HUMAN_REQUIRED`` on a retry-cap hit or a non-rebaseable
|
|
225
|
+
classification) — ``ev.entry`` is the same object the caller's own
|
|
226
|
+
items list holds, so its subsequent save-queue step picks the mutation
|
|
227
|
+
up naturally, without a separate write here.
|
|
228
|
+
|
|
229
|
+
Shared by the whole-queue path and the ``--only`` surgical path
|
|
230
|
+
(#1474 review finding): the whole-queue path always ran this block, but
|
|
231
|
+
``--only`` returned before ever reaching it — so a ``--only``-only
|
|
232
|
+
caller (``coord drive``, the TUI's ``--merge-of``) could park an entry
|
|
233
|
+
at ``CONFLICT`` with no conflict-fix ever dispatched and nothing
|
|
234
|
+
watching it, permanently: ``merge_queue.process()`` only ever acts on
|
|
235
|
+
``PENDING`` entries, so a bare ``CONFLICT`` row is never reprocessed and
|
|
236
|
+
never gets a second chance at this classify-and-dispatch step.
|
|
237
|
+
"""
|
|
238
|
+
conflict_events = [ev for ev in events if ev.kind == "conflict"]
|
|
239
|
+
if not conflict_events or dry_run:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
from coord.audit import record_audit # noqa: PLC0415
|
|
243
|
+
from coord.conflict_fix import ( # noqa: PLC0415
|
|
244
|
+
dispatch_conflict_fix,
|
|
245
|
+
has_prior_conflict_fix,
|
|
246
|
+
)
|
|
247
|
+
from coord.merge_queue import ( # noqa: PLC0415
|
|
248
|
+
HUMAN_REQUIRED,
|
|
249
|
+
classify_conflict,
|
|
250
|
+
is_rebase_refusal,
|
|
251
|
+
)
|
|
252
|
+
from coord.state import load_board, save_board # noqa: PLC0415
|
|
253
|
+
|
|
254
|
+
fix_board = load_board()
|
|
255
|
+
if fix_board is None:
|
|
256
|
+
return
|
|
257
|
+
dispatched_any = False
|
|
258
|
+
for ev in conflict_events:
|
|
259
|
+
kind = classify_conflict(ev.entry.error)
|
|
260
|
+
if kind == "rebaseable":
|
|
261
|
+
# Retry cap (#241/#784): if a conflict-fix already ran and
|
|
262
|
+
# failed for this entry in this session, don't loop — mark
|
|
263
|
+
# HUMAN_REQUIRED so the user takes over. A successful
|
|
264
|
+
# prior fix does not trigger this guard (#784).
|
|
265
|
+
if has_prior_conflict_fix(fix_board, ev.entry.assignment_id):
|
|
266
|
+
ev.entry.state = HUMAN_REQUIRED
|
|
267
|
+
click.echo(
|
|
268
|
+
f" {ev.entry.repo_name} #{ev.entry.issue_number}: "
|
|
269
|
+
"conflict-fix retry cap hit — manual resolution required"
|
|
270
|
+
)
|
|
271
|
+
# #1467: a rebase-refusal ("This branch can't be rebased")
|
|
272
|
+
# is fixed by linearising the branch — spell out the exact
|
|
273
|
+
# recovery so it isn't left to archaeology, and give the
|
|
274
|
+
# durable `repo#issue` key (#1477) `--only` accepts.
|
|
275
|
+
if is_rebase_refusal(ev.entry.error):
|
|
276
|
+
_key = f"{ev.entry.repo_name}#{ev.entry.issue_number}"
|
|
277
|
+
click.echo(
|
|
278
|
+
f" recovery: git checkout {ev.entry.branch} && "
|
|
279
|
+
"git rebase origin/"
|
|
280
|
+
f"{ev.entry.target_branch} && "
|
|
281
|
+
"git push --force-with-lease, then "
|
|
282
|
+
f"`coord merge --only {_key} "
|
|
283
|
+
'--override-human-required "rebase refusal '
|
|
284
|
+
'resolved manually"`'
|
|
285
|
+
)
|
|
286
|
+
# #1038: the coordinator's own retry-cap logic made this
|
|
287
|
+
# call, not the human running `coord merge` — operational
|
|
288
|
+
# tier, same as the other automatic conflict-classification
|
|
289
|
+
# outcomes below.
|
|
290
|
+
record_audit(
|
|
291
|
+
tier="operational",
|
|
292
|
+
category="merge",
|
|
293
|
+
event_type="conflict_human_required",
|
|
294
|
+
actor="daemon",
|
|
295
|
+
summary=f"conflict-fix retry cap hit: "
|
|
296
|
+
f"{ev.entry.repo_name}#{ev.entry.issue_number} — "
|
|
297
|
+
"manual resolution required",
|
|
298
|
+
repo=ev.entry.repo_name,
|
|
299
|
+
issue=ev.entry.issue_number,
|
|
300
|
+
assignment_id=ev.entry.assignment_id,
|
|
301
|
+
details={"reason": "retry_cap"},
|
|
302
|
+
)
|
|
303
|
+
continue
|
|
304
|
+
fix = dispatch_conflict_fix(
|
|
305
|
+
ev.entry,
|
|
306
|
+
fix_board,
|
|
307
|
+
config,
|
|
308
|
+
prefer_machine=_machine_for_assignment(
|
|
309
|
+
fix_board, ev.entry.assignment_id,
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
if fix is not None:
|
|
313
|
+
click.echo(
|
|
314
|
+
f" {ev.entry.repo_name} #{ev.entry.issue_number}: "
|
|
315
|
+
f"conflict-fix dispatched to {fix.machine_name}"
|
|
316
|
+
)
|
|
317
|
+
dispatched_any = True
|
|
318
|
+
else:
|
|
319
|
+
click.echo(
|
|
320
|
+
f" {ev.entry.repo_name} #{ev.entry.issue_number}: "
|
|
321
|
+
"conflict-fix not dispatched (no machine / already in flight)"
|
|
322
|
+
)
|
|
323
|
+
elif kind == "human":
|
|
324
|
+
ev.entry.state = HUMAN_REQUIRED
|
|
325
|
+
click.echo(
|
|
326
|
+
f" {ev.entry.repo_name} #{ev.entry.issue_number}: "
|
|
327
|
+
"permission/protection error — manual resolution required"
|
|
328
|
+
)
|
|
329
|
+
record_audit(
|
|
330
|
+
tier="operational",
|
|
331
|
+
category="merge",
|
|
332
|
+
event_type="conflict_human_required",
|
|
333
|
+
actor="daemon",
|
|
334
|
+
summary=f"conflict classified non-rebaseable: "
|
|
335
|
+
f"{ev.entry.repo_name}#{ev.entry.issue_number} — "
|
|
336
|
+
"manual resolution required",
|
|
337
|
+
repo=ev.entry.repo_name,
|
|
338
|
+
issue=ev.entry.issue_number,
|
|
339
|
+
assignment_id=ev.entry.assignment_id,
|
|
340
|
+
details={"reason": "permission_or_protection"},
|
|
341
|
+
)
|
|
342
|
+
if dispatched_any:
|
|
343
|
+
save_board(fix_board)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@click.command(
|
|
347
|
+
"verify-merge",
|
|
348
|
+
help=(
|
|
349
|
+
"Self-check a --merge-of rebase before reporting done (#604). Run from "
|
|
350
|
+
"inside the merge worktree: `coord verify-merge <work_aid>`. Reports how "
|
|
351
|
+
"many commits the branch is still MISSING from the default branch "
|
|
352
|
+
"(`default-ahead`, must be 0), the commits it adds, and any FOREIGN "
|
|
353
|
+
"commits (referencing a different issue) — the signature of a botched "
|
|
354
|
+
"rebase that dragged in unrelated history. Exits non-zero when the "
|
|
355
|
+
"branch is not merge-ready."
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@click.argument("work_aid")
|
|
361
|
+
@click.option(
|
|
362
|
+
"--path",
|
|
363
|
+
"path_opt",
|
|
364
|
+
type=click.Path(file_okay=False),
|
|
365
|
+
default=None,
|
|
366
|
+
help="Worktree to check (default: current directory).",
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@click.option(
|
|
371
|
+
"--repo",
|
|
372
|
+
"repo_opt",
|
|
373
|
+
default=None,
|
|
374
|
+
help=(
|
|
375
|
+
"Repo name — fallback when the assignment is not found on the board "
|
|
376
|
+
"(thin-client machines where the board lives on the daemon, #681)."
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@click.option(
|
|
382
|
+
"--issue-number",
|
|
383
|
+
"issue_number_opt",
|
|
384
|
+
type=int,
|
|
385
|
+
default=None,
|
|
386
|
+
help=(
|
|
387
|
+
"Issue number — fallback when the assignment is not found on the board "
|
|
388
|
+
"(thin-client machines where the board lives on the daemon, #681)."
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@_CONFIG_OPTION
|
|
394
|
+
def verify_merge(
|
|
395
|
+
work_aid: str,
|
|
396
|
+
path_opt: str | None,
|
|
397
|
+
repo_opt: str | None,
|
|
398
|
+
issue_number_opt: int | None,
|
|
399
|
+
config_path: Path,
|
|
400
|
+
) -> None:
|
|
401
|
+
"""``coord verify-merge <work_aid>`` — git-truth check of a merge-prep branch.
|
|
402
|
+
|
|
403
|
+
Resolves the issue + default branch from the *work* assignment id (the same
|
|
404
|
+
id passed to ``coord assign --merge-of``) and runs the shared
|
|
405
|
+
:func:`coord.agent.verify_merge_branch` primitive against the worktree the
|
|
406
|
+
merge agent is sitting in. This is the defense-in-depth twin of the
|
|
407
|
+
coordinator-side gate in :func:`coord.interactive.finalize_interactive_exit`:
|
|
408
|
+
same check, available to the agent before it self-reports.
|
|
409
|
+
|
|
410
|
+
On thin-client machines (where the canonical board lives on a daemon) the
|
|
411
|
+
board is fetched from the daemon automatically (#681). As a last-resort
|
|
412
|
+
fallback, supply ``--repo`` and ``--issue-number`` explicitly so the check
|
|
413
|
+
can run even when the board lookup returns nothing.
|
|
414
|
+
"""
|
|
415
|
+
from coord.agent import ( # noqa: PLC0415
|
|
416
|
+
resolve_closed_issue_numbers,
|
|
417
|
+
verify_merge_branch,
|
|
418
|
+
)
|
|
419
|
+
from coord.board_service import read_board # noqa: PLC0415
|
|
420
|
+
|
|
421
|
+
cfg = _load_config(config_path)
|
|
422
|
+
board = read_board()
|
|
423
|
+
work = board.find_by_id(work_aid)
|
|
424
|
+
if work is None:
|
|
425
|
+
if repo_opt and issue_number_opt is not None:
|
|
426
|
+
# Thin-client fallback: the board lookup found nothing (empty local
|
|
427
|
+
# DB or daemon didn't carry this aid), but the caller supplied the
|
|
428
|
+
# known values explicitly via --repo / --issue-number (#681).
|
|
429
|
+
repo_name = repo_opt
|
|
430
|
+
issue_num = issue_number_opt
|
|
431
|
+
branch_display = "(unknown)"
|
|
432
|
+
else:
|
|
433
|
+
click.echo(
|
|
434
|
+
f"error: no assignment {work_aid!r} on the board "
|
|
435
|
+
"(use the work id from `coord status`, or supply "
|
|
436
|
+
"--repo and --issue-number as a fallback).",
|
|
437
|
+
err=True,
|
|
438
|
+
)
|
|
439
|
+
sys.exit(2)
|
|
440
|
+
else:
|
|
441
|
+
repo_name = work.repo_name
|
|
442
|
+
issue_num = int(work.issue_number)
|
|
443
|
+
branch_display = work.branch or "(unknown)"
|
|
444
|
+
|
|
445
|
+
repo_cfg = cfg.repo(repo_name)
|
|
446
|
+
base = (repo_cfg.default_branch if repo_cfg else None) or "main"
|
|
447
|
+
if repo_cfg is not None and getattr(repo_cfg, "develop_branch", None):
|
|
448
|
+
# #934: verify against `feature/ms-NN` when this issue belongs to a
|
|
449
|
+
# milestone and the repo opted into the git model — falls back to
|
|
450
|
+
# `default_branch` (above) for everything else.
|
|
451
|
+
from coord.branch_model import ( # noqa: PLC0415
|
|
452
|
+
fetch_issue_milestone_number,
|
|
453
|
+
resolve_base_branch,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
milestone_number = fetch_issue_milestone_number(repo_cfg.github, issue_num)
|
|
457
|
+
base = resolve_base_branch(repo_cfg, milestone_number)
|
|
458
|
+
repo_github = repo_cfg.github if repo_cfg else None
|
|
459
|
+
wt_path = Path(path_opt).expanduser() if path_opt else Path.cwd()
|
|
460
|
+
|
|
461
|
+
mv = verify_merge_branch(wt_path, base=base, issue_number=issue_num)
|
|
462
|
+
# #1279: only worth a `gh` round-trip when the cheap git-only pass above
|
|
463
|
+
# actually found blocking foreign commits — corroborate against GitHub's
|
|
464
|
+
# closed-issue state and re-verify with the downgrade signal populated.
|
|
465
|
+
closed = resolve_closed_issue_numbers(repo_github, mv.foreign, issue_num)
|
|
466
|
+
if closed:
|
|
467
|
+
mv = verify_merge_branch(
|
|
468
|
+
wt_path, base=base, issue_number=issue_num, closed_issue_numbers=closed
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
click.echo(f"branch: {branch_display}")
|
|
472
|
+
click.echo(f"target base: {base}")
|
|
473
|
+
click.echo(f"{base}-ahead: {mv.default_ahead} (must be 0)")
|
|
474
|
+
click.echo(f"adds {len(mv.added)} commit(s) over {base}:")
|
|
475
|
+
advisory_set = set(mv.advisory_foreign)
|
|
476
|
+
for sha, subj in mv.added:
|
|
477
|
+
if (sha, subj) in mv.foreign:
|
|
478
|
+
flag = " [FOREIGN — BLOCKING]"
|
|
479
|
+
elif (sha, subj) in advisory_set:
|
|
480
|
+
flag = " [advisory: references closed issue]"
|
|
481
|
+
else:
|
|
482
|
+
flag = ""
|
|
483
|
+
click.echo(f" {sha[:9]} {subj}{flag}")
|
|
484
|
+
|
|
485
|
+
if mv.ok:
|
|
486
|
+
click.echo("✓ merge-ready: base fully contained, no foreign commits.")
|
|
487
|
+
note = mv.advisory_note()
|
|
488
|
+
if note:
|
|
489
|
+
click.echo(f" {note}")
|
|
490
|
+
return
|
|
491
|
+
click.echo(f"✗ NOT merge-ready: {mv.block_summary(base)}", err=True)
|
|
492
|
+
sys.exit(1)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@click.command(
|
|
496
|
+
help=(
|
|
497
|
+
"Bounce the pipeline back to Work after a review requested changes. "
|
|
498
|
+
"Dispatches a fix worker that reads the reviewer's findings as its "
|
|
499
|
+
"briefing and pushes corrections to the same branch."
|
|
500
|
+
),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
@click.argument("review_assignment_id")
|
|
505
|
+
@_CONFIG_OPTION
|
|
506
|
+
def bounce(review_assignment_id: str, config_path: Path) -> None:
|
|
507
|
+
"""Manual trigger for the auto-loop's fix-dispatch path.
|
|
508
|
+
|
|
509
|
+
`coord notify` already runs this automatically the first time a
|
|
510
|
+
review completion is observed, but the auto-loop bails when the
|
|
511
|
+
review log isn't reachable at that moment (remote agent offline /
|
|
512
|
+
log pruned). This command re-runs the same dispatch on demand —
|
|
513
|
+
useful as a recovery path for the user and as the TUI's "Fix"
|
|
514
|
+
button.
|
|
515
|
+
"""
|
|
516
|
+
from coord.auto_loop import process_review_completion
|
|
517
|
+
from coord.board_service import read_board, write_board
|
|
518
|
+
from coord.state import COORD_DIR
|
|
519
|
+
|
|
520
|
+
cfg = _load_config(config_path)
|
|
521
|
+
board = read_board()
|
|
522
|
+
|
|
523
|
+
review = board.find_by_id(review_assignment_id)
|
|
524
|
+
if review is None:
|
|
525
|
+
click.echo(
|
|
526
|
+
f"error: assignment {review_assignment_id!r} not found in board",
|
|
527
|
+
err=True,
|
|
528
|
+
)
|
|
529
|
+
sys.exit(1)
|
|
530
|
+
if review.type != "review":
|
|
531
|
+
click.echo(
|
|
532
|
+
f"error: {review_assignment_id} is type={review.type!r}, not 'review'. "
|
|
533
|
+
f"Pass the review assignment id, not the work assignment id.",
|
|
534
|
+
err=True,
|
|
535
|
+
)
|
|
536
|
+
sys.exit(1)
|
|
537
|
+
if review.review_verdict not in ("request-changes", None):
|
|
538
|
+
click.echo(
|
|
539
|
+
f"info: review verdict is {review.review_verdict!r} — only "
|
|
540
|
+
f"'request-changes' triggers a fix dispatch. Nothing to do.",
|
|
541
|
+
err=True,
|
|
542
|
+
)
|
|
543
|
+
sys.exit(1)
|
|
544
|
+
|
|
545
|
+
# Try local log first; fall back to agent HTTP /logs when the
|
|
546
|
+
# review ran on a remote machine and the file isn't on this
|
|
547
|
+
# coordinator's filesystem.
|
|
548
|
+
machine = next(
|
|
549
|
+
(m for m in cfg.machines if m.name == review.machine_name), None,
|
|
550
|
+
)
|
|
551
|
+
machine_host = machine.host if machine and machine.host else None
|
|
552
|
+
local_log = COORD_DIR / "logs" / f"{review_assignment_id}.log"
|
|
553
|
+
log_path = str(local_log) if local_log.exists() else None
|
|
554
|
+
|
|
555
|
+
actions = process_review_completion(
|
|
556
|
+
review,
|
|
557
|
+
board,
|
|
558
|
+
cfg,
|
|
559
|
+
log_path=log_path,
|
|
560
|
+
machine_host=machine_host,
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
dispatched = any(a.kind == "fix_dispatched" for a in actions)
|
|
564
|
+
# #522: terminal_skip mutates work.review_state="done" in
|
|
565
|
+
# process_review_completion — persist it (same as the notify path) so the
|
|
566
|
+
# row doesn't get re-evaluated, and treat it as a clean (not failed) exit.
|
|
567
|
+
terminal = any(a.kind == "terminal_skip" for a in actions)
|
|
568
|
+
if dispatched or terminal:
|
|
569
|
+
write_board(board)
|
|
570
|
+
|
|
571
|
+
for a in actions:
|
|
572
|
+
click.echo(f"{a.kind}: {a.detail}")
|
|
573
|
+
|
|
574
|
+
if not dispatched:
|
|
575
|
+
# Distinguish clean outcomes (approve / already-merged-or-closed) from
|
|
576
|
+
# genuine failure modes.
|
|
577
|
+
if any(a.kind in ("approved", "terminal_skip") for a in actions):
|
|
578
|
+
sys.exit(0)
|
|
579
|
+
sys.exit(1)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
@click.command(
|
|
583
|
+
"reconcile-merges",
|
|
584
|
+
help=(
|
|
585
|
+
"Reconcile done work assignments against git/GitHub reality.\n\n"
|
|
586
|
+
"Three conservative sweeps:\n"
|
|
587
|
+
" #611 — backfill a missing branch from a matching `issue-N-*` remote "
|
|
588
|
+
"branch (a remote interactive session can finish done with branch=None, "
|
|
589
|
+
"greying the TUI Start review/test/merge buttons);\n"
|
|
590
|
+
" #609 — flip work merged out-of-band (direct GitHub merge or a drained "
|
|
591
|
+
"merge_queue row) to status='merged' so the TUI stops showing a grey "
|
|
592
|
+
"merge box forever;\n"
|
|
593
|
+
" #721 — close open PRs whose work has already landed (issue closed or "
|
|
594
|
+
"branch fully on the default branch) — review-PRs accumulate forever "
|
|
595
|
+
"after squash merges otherwise.\n\n"
|
|
596
|
+
"Acts only when certain; skips and explains otherwise."
|
|
597
|
+
),
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@click.option("--repo", "repo_name", default=None, help="Only reconcile this repo.")
|
|
602
|
+
@click.option(
|
|
603
|
+
"--dry-run", is_flag=True, help="Show what would change without writing."
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@_CONFIG_OPTION
|
|
608
|
+
def reconcile_merges(repo_name: str | None, dry_run: bool, config_path: Path) -> None:
|
|
609
|
+
"""#609/#611: record out-of-band merges and backfill missing branches."""
|
|
610
|
+
# #584: the canonical board + gh live on the daemon host, so on a thin
|
|
611
|
+
# client this would sweep an empty local board and silently do nothing.
|
|
612
|
+
# Route the whole operation to the daemon (mirrors `coord merge`).
|
|
613
|
+
# COORD_RECONCILE_ON_DAEMON guards the daemon against re-routing to itself.
|
|
614
|
+
from coord.board_service import daemon_reroute_target # noqa: PLC0415
|
|
615
|
+
|
|
616
|
+
_svc = daemon_reroute_target("COORD_RECONCILE_ON_DAEMON")
|
|
617
|
+
if _svc is not None:
|
|
618
|
+
_reconcile_via_daemon(_svc, {"repo": repo_name, "dry_run": dry_run})
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
from coord.reconcile import reconcile_board_merges
|
|
622
|
+
from coord.state import build_board, save_board
|
|
623
|
+
|
|
624
|
+
cfg = _load_config(config_path)
|
|
625
|
+
board = build_board()
|
|
626
|
+
actions = reconcile_board_merges(
|
|
627
|
+
board, cfg, repo=repo_name, dry_run=dry_run
|
|
628
|
+
)
|
|
629
|
+
if not dry_run:
|
|
630
|
+
save_board(board)
|
|
631
|
+
if not actions:
|
|
632
|
+
click.echo("Nothing to reconcile.")
|
|
633
|
+
return
|
|
634
|
+
for action in actions:
|
|
635
|
+
click.echo(action)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
@click.command(
|
|
639
|
+
"post-pending-reviews",
|
|
640
|
+
help=(
|
|
641
|
+
"Post unposted review findings for done review assignments.\n\n"
|
|
642
|
+
"Useful when a reviewer finished but notify didn't see the transition "
|
|
643
|
+
"(e.g. agent reported 'cancelled', reap hung, or notify ran at the wrong time). "
|
|
644
|
+
"Idempotent — already-posted findings are never re-posted."
|
|
645
|
+
),
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@_CONFIG_OPTION
|
|
650
|
+
@click.option("--repo", "repo_name", default=None, help="Only process assignments for this repo.")
|
|
651
|
+
def post_pending_reviews(config_path: Path, repo_name: str | None) -> None:
|
|
652
|
+
from coord.notify import post_orphaned_review_findings
|
|
653
|
+
from coord.state import load_done_reviews_needing_post
|
|
654
|
+
|
|
655
|
+
cfg = _load_config(config_path)
|
|
656
|
+
|
|
657
|
+
candidates = load_done_reviews_needing_post(repo_name=repo_name)
|
|
658
|
+
if not candidates:
|
|
659
|
+
click.echo("No pending review assignments found.")
|
|
660
|
+
return
|
|
661
|
+
|
|
662
|
+
click.echo(f"Found {len(candidates)} review assignment(s) with unposted findings:")
|
|
663
|
+
for row in candidates:
|
|
664
|
+
aid = row["assignment_id"]
|
|
665
|
+
click.echo(
|
|
666
|
+
f" {aid} — {row['repo_name']} #{row['issue_number']} "
|
|
667
|
+
f"(machine: {row['machine_name']}, target: {row['review_target'] or 'n/a'})"
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
posted_ids = post_orphaned_review_findings(cfg, repo_name=repo_name)
|
|
671
|
+
|
|
672
|
+
if not posted_ids:
|
|
673
|
+
click.echo("\nNo findings posted (agents may be offline or logs unavailable).")
|
|
674
|
+
return
|
|
675
|
+
|
|
676
|
+
click.echo(f"\nPosted findings for {len(posted_ids)} assignment(s):")
|
|
677
|
+
for aid in posted_ids:
|
|
678
|
+
click.echo(f" {aid}")
|
|
679
|
+
|
|
680
|
+
still_pending = load_done_reviews_needing_post(repo_name=repo_name)
|
|
681
|
+
if still_pending:
|
|
682
|
+
click.echo(f"\n{len(still_pending)} assignment(s) still pending (logs not available):")
|
|
683
|
+
for row in still_pending:
|
|
684
|
+
click.echo(
|
|
685
|
+
f" {row['assignment_id']} — {row['repo_name']} #{row['issue_number']} "
|
|
686
|
+
f"(machine: {row['machine_name']})"
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _load_issue_states() -> tuple[dict[str, set[int]], dict[str, set[int]]]:
|
|
691
|
+
"""Return ``(open_by_repo, known_by_repo)``.
|
|
692
|
+
|
|
693
|
+
- ``open_by_repo[repo]`` = set of issue numbers with state='open'.
|
|
694
|
+
- ``known_by_repo[repo]`` = set of issue numbers with ANY state row in
|
|
695
|
+
the cache.
|
|
696
|
+
|
|
697
|
+
Used by the `coord merge` auto-enqueue path (#242). Filter logic
|
|
698
|
+
(in the caller) is permissive on cache misses:
|
|
699
|
+
|
|
700
|
+
- issue in ``known_by_repo[repo]`` AND not in ``open_by_repo[repo]``
|
|
701
|
+
→ deny (we have explicit "closed" evidence)
|
|
702
|
+
- otherwise → allow
|
|
703
|
+
|
|
704
|
+
The earlier implementation denied any issue whose repo had ANY rows in
|
|
705
|
+
the issues table but no row for the specific number — which silently
|
|
706
|
+
skipped issues created after the cache's most-recent sync (we hit this
|
|
707
|
+
when #278/#280 landed but the local cache stopped at #271).
|
|
708
|
+
"""
|
|
709
|
+
try:
|
|
710
|
+
from coord.db import get_connection
|
|
711
|
+
|
|
712
|
+
conn = get_connection()
|
|
713
|
+
rows = conn.execute(
|
|
714
|
+
"SELECT repo_name, number, state FROM issues"
|
|
715
|
+
).fetchall()
|
|
716
|
+
except Exception: # noqa: BLE001 — caller treats empty as "unknown"
|
|
717
|
+
return {}, {}
|
|
718
|
+
|
|
719
|
+
open_by_repo: dict[str, set[int]] = {}
|
|
720
|
+
known_by_repo: dict[str, set[int]] = {}
|
|
721
|
+
for row in rows:
|
|
722
|
+
repo_name = row[0]
|
|
723
|
+
number = int(row[1])
|
|
724
|
+
known_by_repo.setdefault(repo_name, set()).add(number)
|
|
725
|
+
if row[2] == "open":
|
|
726
|
+
open_by_repo.setdefault(repo_name, set()).add(number)
|
|
727
|
+
return open_by_repo, known_by_repo
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _reconcile_via_daemon(svc, params: dict) -> None:
|
|
731
|
+
"""#584: run ``coord reconcile-merges`` on the daemon host (where the
|
|
732
|
+
canonical DB lives + gh is authenticated) and relay its output, so the
|
|
733
|
+
command does real work from a thin client instead of no-opping against an
|
|
734
|
+
empty local board. Reconcile is gh-bound but quick, hence the shorter
|
|
735
|
+
timeout."""
|
|
736
|
+
from coord.client import post_record # noqa: PLC0415
|
|
737
|
+
|
|
738
|
+
try:
|
|
739
|
+
resp = post_record(svc, "/reconcile-merges", params, timeout=120.0)
|
|
740
|
+
except Exception as exc: # noqa: BLE001
|
|
741
|
+
click.echo(f"error: reconcile-merges via daemon failed: {exc}", err=True)
|
|
742
|
+
sys.exit(1)
|
|
743
|
+
output = resp.get("output") or ""
|
|
744
|
+
if output:
|
|
745
|
+
click.echo(output, nl=False)
|
|
746
|
+
if resp.get("error"):
|
|
747
|
+
click.echo(f"error: {resp['error']}", err=True)
|
|
748
|
+
code = resp.get("exit_code") or 0
|
|
749
|
+
if code:
|
|
750
|
+
sys.exit(int(code))
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _print_merge_plan_entries(planned: list) -> None:
|
|
754
|
+
"""Print a list of PlannedMerge entries grouped by repo → target_branch."""
|
|
755
|
+
if not planned:
|
|
756
|
+
click.echo("Merge queue is empty (nothing to plan).")
|
|
757
|
+
return
|
|
758
|
+
_last_group: tuple[str, str] | None = None
|
|
759
|
+
for _p in planned:
|
|
760
|
+
_gkey = (_p.repo_name, _p.target_branch)
|
|
761
|
+
if _gkey != _last_group:
|
|
762
|
+
if _last_group is not None:
|
|
763
|
+
click.echo("")
|
|
764
|
+
click.echo(f"{_p.repo_name} → {_p.target_branch}")
|
|
765
|
+
_last_group = _gkey
|
|
766
|
+
_size_str = f"+{_p.size}" if _p.size is not None else "?"
|
|
767
|
+
_status_str = _p.status
|
|
768
|
+
if _p.reason:
|
|
769
|
+
_status_str = f"{_p.status} {_p.reason}"
|
|
770
|
+
click.echo(
|
|
771
|
+
f" {_p.rank}. #{_p.issue_number} {_size_str} "
|
|
772
|
+
f"{_status_str} {_p.issue_title}"
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _print_sibling_overlap_warnings(warnings: list) -> None:
|
|
777
|
+
"""#920: print "these approved branches will conflict" warnings.
|
|
778
|
+
|
|
779
|
+
``warnings`` is a list of :class:`coord.merge_queue.SiblingOverlapWarning`
|
|
780
|
+
(or daemon-payload reconstructions of the same shape). No-op when empty —
|
|
781
|
+
callers don't need to guard the call.
|
|
782
|
+
"""
|
|
783
|
+
if not warnings:
|
|
784
|
+
return
|
|
785
|
+
click.echo("")
|
|
786
|
+
click.echo("⚠ Sibling overlap (approved branches aging against a moving main):")
|
|
787
|
+
for w in warnings:
|
|
788
|
+
order = " → ".join(f"#{n}" for n in w.issue_numbers)
|
|
789
|
+
files = list(w.overlapping_files)
|
|
790
|
+
files_str = ", ".join(files[:5])
|
|
791
|
+
if len(files) > 5:
|
|
792
|
+
files_str += f", +{len(files) - 5} more"
|
|
793
|
+
click.echo(f" {w.repo_name} → {w.target_branch}: {order}")
|
|
794
|
+
click.echo(f" overlapping files: {files_str}")
|
|
795
|
+
click.echo(
|
|
796
|
+
f" oldest waiting {w.oldest_age_hours:.1f}h — these will conflict if merged"
|
|
797
|
+
" out of order or later; merge promptly, oldest first"
|
|
798
|
+
" ('coord merge --order <assignment_ids>' to force this order)."
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _board_row_gate_report(a, config, board, gh_ops: "GhOps | None"):
|
|
803
|
+
"""The live, #1479-freshness-aware gate report for board row *a* — the
|
|
804
|
+
SAME evaluation ``coord gates <repo> <issue>`` runs
|
|
805
|
+
(:func:`coord.gates.build_gate_report`), or ``None`` when it couldn't be
|
|
806
|
+
computed (repo not configured, no branch, board/config error, …).
|
|
807
|
+
|
|
808
|
+
Shared by both ``--only`` call sites that used to re-derive gate status
|
|
809
|
+
by calling :func:`coord.merge_queue.merge_gate_failures` directly on a
|
|
810
|
+
raw board ``Assignment`` — the #1845 retry-enqueue probe below and
|
|
811
|
+
:func:`_explain_missing_only_entry`'s diagnostic. A bare ``Assignment``
|
|
812
|
+
has no ``repo_github``/``branch_head_sha``/``target_branch_head_sha``/
|
|
813
|
+
``branch_patch_id`` (those only exist on a
|
|
814
|
+
:class:`~coord.merge_queue.QueuedMerge` that has been through
|
|
815
|
+
:func:`~coord.merge_queue.process`), so every #1479 staleness check
|
|
816
|
+
inside :func:`~coord.merge_queue.evaluate_smoke_verdict` silently no-ops
|
|
817
|
+
against it — the row reads as fresh no matter how stale it really is.
|
|
818
|
+
``build_gate_report`` builds the same synthetic, live-SHA-backed
|
|
819
|
+
``QueuedMerge`` ``coord gates`` does, so the two can never disagree
|
|
820
|
+
(#1926).
|
|
821
|
+
|
|
822
|
+
Never raises: any failure degrades to ``None``, which both callers
|
|
823
|
+
treat as "not gated" / "not evaluated" rather than a guessed pass.
|
|
824
|
+
"""
|
|
825
|
+
from coord.gates import build_gate_report as _build_gate_report # noqa: PLC0415
|
|
826
|
+
from coord.models import effective_issue_number as _effective_issue_number # noqa: PLC0415
|
|
827
|
+
|
|
828
|
+
try:
|
|
829
|
+
return _build_gate_report(
|
|
830
|
+
board,
|
|
831
|
+
config,
|
|
832
|
+
a.repo_name,
|
|
833
|
+
_effective_issue_number(a) or a.issue_number,
|
|
834
|
+
gh_ops=gh_ops,
|
|
835
|
+
)
|
|
836
|
+
except Exception: # noqa: BLE001 — diagnostics/probes must never mask the error
|
|
837
|
+
return None
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _board_row_merge_gate_ok(a, config, board, gh_ops: "GhOps | None") -> bool:
|
|
841
|
+
"""True only when *a*'s live gate report says the merge gate is
|
|
842
|
+
satisfied — see :func:`_board_row_gate_report`. Fails closed to
|
|
843
|
+
``False`` (not gated) whenever the report itself couldn't be computed,
|
|
844
|
+
matching #1926's "silence is safe, optimism is not": a ``False`` here
|
|
845
|
+
just means the caller falls through to a diagnostic, never a wrongly
|
|
846
|
+
attempted enqueue/merge.
|
|
847
|
+
"""
|
|
848
|
+
report = _board_row_gate_report(a, config, board, gh_ops)
|
|
849
|
+
if report is None:
|
|
850
|
+
return False
|
|
851
|
+
merge_decision = next((d for d in report.decisions if d.gate == "merge"), None)
|
|
852
|
+
return merge_decision is not None and merge_decision.ok
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _explain_missing_only_entry(
|
|
856
|
+
key: str, config, gh_ops: "GhOps | None" = None
|
|
857
|
+
) -> list[str]:
|
|
858
|
+
"""#1695: explain why ``coord merge --only <key>`` found no queue entry.
|
|
859
|
+
|
|
860
|
+
Returns the lines to print on stderr. The pre-#1695 message was a single
|
|
861
|
+
line — *"no entry found for 'X' (tried assignment_id, repo#issue, issue
|
|
862
|
+
number, and branch name)"* — which describes a **key-lookup** failure and
|
|
863
|
+
is what sent the #1695 operator hunting for a different identifier for 40
|
|
864
|
+
minutes. In the case that actually happens, the identifier resolved fine
|
|
865
|
+
and the row simply never became an entry because a gate blocked enqueue.
|
|
866
|
+
|
|
867
|
+
So the three cases are now stated separately:
|
|
868
|
+
|
|
869
|
+
1. **No board row either.** The identifier genuinely did not resolve —
|
|
870
|
+
the old wording, now only printed when it is true.
|
|
871
|
+
2. **A board row exists and a gate is failing.** Name the row, name the
|
|
872
|
+
gate, and point at the flag that waives it. Under #1695 the row is
|
|
873
|
+
enqueued (visibly BLOCKED) by the auto-enqueue scan, so the fix is to
|
|
874
|
+
let a plain ``coord merge`` pass run the scan and then retry ``--only``
|
|
875
|
+
with the waiver — hence the explicit next-step line.
|
|
876
|
+
3. **A board row exists and every gate passes.** Then it was one of the
|
|
877
|
+
non-gate skips (issue closed, branch gone from origin, PR already
|
|
878
|
+
merged) or the scan simply has not run yet.
|
|
879
|
+
|
|
880
|
+
#1926: case 2/3 used to call :func:`coord.merge_queue.merge_gate_failures`
|
|
881
|
+
directly on the raw board ``Assignment`` with no *gh_ops* and no live
|
|
882
|
+
SHAs, which makes every #1479 staleness check inside
|
|
883
|
+
:func:`~coord.merge_queue.evaluate_smoke_verdict` a silent no-op (it needs
|
|
884
|
+
``entry.repo_github``/``entry.target_branch_head_sha``/etc., none of which
|
|
885
|
+
a bare work ``Assignment`` carries — those only exist on a
|
|
886
|
+
:class:`~coord.merge_queue.QueuedMerge` that has been through
|
|
887
|
+
:func:`~coord.merge_queue.process`). That let this fallback print "all
|
|
888
|
+
merge gates pass" for a row ``coord gates`` reported ``BLOCKED`` with a
|
|
889
|
+
STALE verdict — the exact false-green this issue is about. Routing
|
|
890
|
+
through :func:`coord.gates.build_gate_report` instead reuses the SAME
|
|
891
|
+
live-SHA-backed evaluation ``coord gates`` runs, so the two can never
|
|
892
|
+
disagree.
|
|
893
|
+
|
|
894
|
+
Never raises: a board/config problem, or a gate report that can't reach a
|
|
895
|
+
merge decision (no branch, repo not configured, …), degrades to an
|
|
896
|
+
honest "not evaluated" line rather than a guessed pass.
|
|
897
|
+
"""
|
|
898
|
+
from coord import merge_queue as _mq # noqa: PLC0415
|
|
899
|
+
from coord.state import load_board as _load_board # noqa: PLC0415
|
|
900
|
+
|
|
901
|
+
not_found = (
|
|
902
|
+
f"merge-queue: no entry found for {key!r} "
|
|
903
|
+
"(tried assignment_id, repo#issue, issue number, and branch name)"
|
|
904
|
+
)
|
|
905
|
+
try:
|
|
906
|
+
board = _load_board()
|
|
907
|
+
rows = _mq.resolve_board_work_key(board, key) if board is not None else []
|
|
908
|
+
except Exception: # noqa: BLE001 — diagnostics must never mask the error
|
|
909
|
+
rows = []
|
|
910
|
+
if not rows:
|
|
911
|
+
return [
|
|
912
|
+
not_found,
|
|
913
|
+
" no done work row on the board matches that identifier either — "
|
|
914
|
+
"the identifier did not resolve.",
|
|
915
|
+
]
|
|
916
|
+
|
|
917
|
+
lines = [
|
|
918
|
+
f"merge-queue: no entry found for {key!r}, but "
|
|
919
|
+
f"{len(rows)} done work row(s) on the board match it:",
|
|
920
|
+
]
|
|
921
|
+
any_blocked = False
|
|
922
|
+
for a in rows:
|
|
923
|
+
where = f"{a.repo_name} #{a.issue_number} (assignment {a.assignment_id}, branch {a.branch})"
|
|
924
|
+
report = _board_row_gate_report(a, config, board, gh_ops)
|
|
925
|
+
merge_decision = (
|
|
926
|
+
next((d for d in report.decisions if d.gate == "merge"), None)
|
|
927
|
+
if report is not None
|
|
928
|
+
else None
|
|
929
|
+
)
|
|
930
|
+
if merge_decision is None:
|
|
931
|
+
# Same evaluation `coord gates` runs couldn't reach a merge
|
|
932
|
+
# decision for this row (repo not configured, no branch on the
|
|
933
|
+
# winning assignment, board/config error, …). Say so instead of
|
|
934
|
+
# guessing — silence is safe, optimism is not (#1926).
|
|
935
|
+
lines.append(
|
|
936
|
+
f" {where} — gate status not evaluated on this path; run "
|
|
937
|
+
f"`coord gates {a.repo_name} {a.issue_number}` for the live "
|
|
938
|
+
"decision."
|
|
939
|
+
)
|
|
940
|
+
continue
|
|
941
|
+
if not merge_decision.ok:
|
|
942
|
+
any_blocked = True
|
|
943
|
+
clauses = []
|
|
944
|
+
for gate_name, waiver_flag in (
|
|
945
|
+
("review", "--skip-review"),
|
|
946
|
+
("test", "--skip-smoke"),
|
|
947
|
+
):
|
|
948
|
+
d = next((x for x in report.decisions if x.gate == gate_name), None)
|
|
949
|
+
if d is not None and not d.ok:
|
|
950
|
+
clauses.append(f"{gate_name} gate — {d.reason} (waive with {waiver_flag})")
|
|
951
|
+
lines.append(f" {where} — enqueue blocked by {'; '.join(clauses)}")
|
|
952
|
+
else:
|
|
953
|
+
lines.append(
|
|
954
|
+
f" {where} — all merge gates pass (#1479 freshness checked); "
|
|
955
|
+
"it was skipped for a non-gate reason (issue closed, branch "
|
|
956
|
+
"missing from origin, or PR already merged), or the "
|
|
957
|
+
"auto-enqueue scan has not run yet."
|
|
958
|
+
)
|
|
959
|
+
if any_blocked:
|
|
960
|
+
lines.append(
|
|
961
|
+
" next: run `coord merge --dry-run` (the auto-enqueue scan "
|
|
962
|
+
"enqueues gate-blocked rows in a visibly BLOCKED state, #1695), "
|
|
963
|
+
"then retry --only with the waiver flag named above."
|
|
964
|
+
)
|
|
965
|
+
return lines
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _show_plan_from_daemon(
|
|
969
|
+
svc,
|
|
970
|
+
*,
|
|
971
|
+
repo_filter: str | None,
|
|
972
|
+
order: str | None,
|
|
973
|
+
) -> None:
|
|
974
|
+
"""#779-fix: display merge plan via /board — never touches /merge.
|
|
975
|
+
|
|
976
|
+
Older daemons (≤v0.4.53 pre-#779) receive ``plan=True`` via ``/merge``
|
|
977
|
+
but have no show_plan handler, so they fall through to a full live merge
|
|
978
|
+
cycle with side effects. The ``merge_plan`` field has been injected into
|
|
979
|
+
``/board`` since #776/v0.4.53, so we fetch that instead — guaranteed
|
|
980
|
+
read-only on every supported daemon version.
|
|
981
|
+
|
|
982
|
+
Exits with an error message if the daemon payload lacks ``merge_plan``
|
|
983
|
+
(daemon predates v0.4.53); the caller should not fall through to a local
|
|
984
|
+
path that would show an empty thin-client queue.
|
|
985
|
+
"""
|
|
986
|
+
from coord.client import fetch_board_payload # noqa: PLC0415
|
|
987
|
+
from coord.merge_queue import PlannedMerge # noqa: PLC0415
|
|
988
|
+
|
|
989
|
+
try:
|
|
990
|
+
payload = fetch_board_payload(svc)
|
|
991
|
+
except Exception as exc: # noqa: BLE001
|
|
992
|
+
click.echo(f"error: fetch board for --plan failed: {exc}", err=True)
|
|
993
|
+
sys.exit(1)
|
|
994
|
+
|
|
995
|
+
if "merge_plan" not in payload:
|
|
996
|
+
click.echo(
|
|
997
|
+
"error: daemon does not expose merge_plan in /board "
|
|
998
|
+
"(upgrade the daemon to v0.4.53+ to use coord merge --plan).",
|
|
999
|
+
err=True,
|
|
1000
|
+
)
|
|
1001
|
+
sys.exit(1)
|
|
1002
|
+
|
|
1003
|
+
raw: list[dict] = payload.get("merge_plan") or []
|
|
1004
|
+
known = set(PlannedMerge.__dataclass_fields__)
|
|
1005
|
+
planned = [PlannedMerge(**{k: v for k, v in d.items() if k in known}) for d in raw]
|
|
1006
|
+
|
|
1007
|
+
if repo_filter:
|
|
1008
|
+
planned = [p for p in planned if p.repo_name == repo_filter]
|
|
1009
|
+
|
|
1010
|
+
if order:
|
|
1011
|
+
_override_ids = [s.strip() for s in order.split(",") if s.strip()]
|
|
1012
|
+
_by_id = {p.assignment_id: p for p in planned}
|
|
1013
|
+
_head = [_by_id[aid] for aid in _override_ids if aid in _by_id]
|
|
1014
|
+
_tail = [p for p in planned if p.assignment_id not in set(_override_ids)]
|
|
1015
|
+
planned = _head + _tail
|
|
1016
|
+
for _i, _p in enumerate(planned, 1):
|
|
1017
|
+
_p.rank = _i
|
|
1018
|
+
|
|
1019
|
+
_print_merge_plan_entries(planned)
|
|
1020
|
+
|
|
1021
|
+
# #920: sibling-overlap warnings — precomputed server-side into /board
|
|
1022
|
+
# (see coord.serve_app's `sibling_overlap_warnings` projection) since a
|
|
1023
|
+
# thin client has no local queue/board to compute them from itself.
|
|
1024
|
+
from coord.merge_queue import SiblingOverlapWarning # noqa: PLC0415
|
|
1025
|
+
|
|
1026
|
+
raw_overlaps: list[dict] = payload.get("sibling_overlap_warnings") or []
|
|
1027
|
+
known_ow = set(SiblingOverlapWarning.__dataclass_fields__)
|
|
1028
|
+
overlaps = [
|
|
1029
|
+
SiblingOverlapWarning(**{
|
|
1030
|
+
k: (tuple(v) if isinstance(v, list) else v)
|
|
1031
|
+
for k, v in d.items() if k in known_ow
|
|
1032
|
+
})
|
|
1033
|
+
for d in raw_overlaps
|
|
1034
|
+
]
|
|
1035
|
+
if repo_filter:
|
|
1036
|
+
overlaps = [w for w in overlaps if w.repo_name == repo_filter]
|
|
1037
|
+
_print_sibling_overlap_warnings(overlaps)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _merge_via_daemon(svc, params: dict) -> None:
|
|
1041
|
+
"""#584: run ``coord merge`` on the daemon host (where the canonical DB +
|
|
1042
|
+
merge queue + gh live) and relay its output, so the TUI 'Go' button and
|
|
1043
|
+
``coord merge`` work from any thin client. Merges can take minutes (PR
|
|
1044
|
+
creation, CI waits), hence the long timeout.
|
|
1045
|
+
|
|
1046
|
+
#1769: a ``--revalidate`` run additionally executes the repo's whole test
|
|
1047
|
+
suite on the daemon host, which is minutes-to-tens-of-minutes on its own —
|
|
1048
|
+
the default 900 s ceiling would abandon the client mid-suite (the daemon
|
|
1049
|
+
keeps going and finishes the merge, so the operator sees a timeout error
|
|
1050
|
+
for a run that actually succeeded).
|
|
1051
|
+
|
|
1052
|
+
#1715-review: batch revalidation's own worst case is 1 (composite) + N
|
|
1053
|
+
(per-entry fallback) serial suite runs, not just one — see
|
|
1054
|
+
:func:`coord.revalidate.revalidate_group`. :func:`coord.revalidate.
|
|
1055
|
+
client_timeout_seconds` sizes the window off that worst case (a
|
|
1056
|
+
documented ceiling on N, since the client posts before any candidate is
|
|
1057
|
+
known) rather than a single :data:`coord.revalidate.
|
|
1058
|
+
DEFAULT_TIMEOUT_SECONDS`, which is the ceiling ONE suite run is killed
|
|
1059
|
+
at."""
|
|
1060
|
+
from coord.client import post_record # noqa: PLC0415
|
|
1061
|
+
from coord.revalidate import client_timeout_seconds # noqa: PLC0415
|
|
1062
|
+
|
|
1063
|
+
timeout = client_timeout_seconds(bool(params.get("revalidate")))
|
|
1064
|
+
|
|
1065
|
+
try:
|
|
1066
|
+
resp = post_record(svc, "/merge", params, timeout=timeout)
|
|
1067
|
+
except Exception as exc: # noqa: BLE001
|
|
1068
|
+
click.echo(f"error: merge via daemon failed: {exc}", err=True)
|
|
1069
|
+
sys.exit(1)
|
|
1070
|
+
output = resp.get("output") or ""
|
|
1071
|
+
if output:
|
|
1072
|
+
click.echo(output, nl=False)
|
|
1073
|
+
if resp.get("error"):
|
|
1074
|
+
click.echo(f"error: {resp['error']}", err=True)
|
|
1075
|
+
code = resp.get("exit_code") or 0
|
|
1076
|
+
if code:
|
|
1077
|
+
sys.exit(int(code))
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
@click.command(help="Process the merge queue: open PRs and merge in sequence.")
|
|
1081
|
+
@_CONFIG_OPTION
|
|
1082
|
+
@click.option("--dry-run", is_flag=True, help="Show the plan without opening or merging PRs.")
|
|
1083
|
+
@click.option(
|
|
1084
|
+
"--plan",
|
|
1085
|
+
"show_plan",
|
|
1086
|
+
is_flag=True,
|
|
1087
|
+
help=(
|
|
1088
|
+
"#779: Print the ranked merge order and per-entry gate status. "
|
|
1089
|
+
"No PRs opened, no merges — purely read-only."
|
|
1090
|
+
),
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
@click.option(
|
|
1095
|
+
"--order",
|
|
1096
|
+
default=None,
|
|
1097
|
+
help="Comma-separated assignment IDs to merge first (overrides size-based sequencing).",
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
@click.option("--repo", "repo_filter", default=None, help="Only process this repo's queue.")
|
|
1102
|
+
@click.option(
|
|
1103
|
+
"--method",
|
|
1104
|
+
type=click.Choice(["rebase", "squash", "merge"]),
|
|
1105
|
+
default="rebase",
|
|
1106
|
+
show_default=True,
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
@click.option(
|
|
1111
|
+
"--force-merge",
|
|
1112
|
+
is_flag=True,
|
|
1113
|
+
help=(
|
|
1114
|
+
"Skip the CI check gate — merge even if checks failed or are still running. "
|
|
1115
|
+
"Also overrides the #1318 epic-closing-keyword guard: merge anyway even "
|
|
1116
|
+
"when a commit message on the branch contains a closing keyword targeting "
|
|
1117
|
+
"an epic (the epic WILL auto-close on GitHub)."
|
|
1118
|
+
),
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
@click.option(
|
|
1123
|
+
"--skip-review",
|
|
1124
|
+
is_flag=True,
|
|
1125
|
+
help=(
|
|
1126
|
+
"Skip the review-approval gate — merge even when no approved review is on "
|
|
1127
|
+
"the board (#253). Local-only: when this run is routed to the daemon "
|
|
1128
|
+
"(thin client / no local canonical DB), the daemon rejects a truthy "
|
|
1129
|
+
"--skip-review outright (non-zero exit, explicit error) rather than "
|
|
1130
|
+
"honouring or silently dropping it — the review gate can never be "
|
|
1131
|
+
"bypassed remotely (#821, #1489)."
|
|
1132
|
+
),
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
@click.option(
|
|
1137
|
+
"--skip-smoke",
|
|
1138
|
+
is_flag=True,
|
|
1139
|
+
help="Skip the interactive smoke-test gate — merge even when no smoke verdict is recorded (#465).",
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
@click.option(
|
|
1144
|
+
"--revalidate",
|
|
1145
|
+
is_flag=True,
|
|
1146
|
+
help=(
|
|
1147
|
+
"#1769/#1715: re-test entries blocked SOLELY on a stale-but-passed "
|
|
1148
|
+
"test verdict against the current base, then merge them. Off by "
|
|
1149
|
+
"default — `coord merge` with no flag is unchanged, and the unattended "
|
|
1150
|
+
"auto-drain never sets it. Applies only to the stale case: an entry "
|
|
1151
|
+
"blocked on review, CI, conflict, or a genuinely missing verdict is "
|
|
1152
|
+
"left untouched. "
|
|
1153
|
+
"BATCH (#1715): when several entries share a base they are composed "
|
|
1154
|
+
"onto it together and validated by ONE suite run, not one run each — "
|
|
1155
|
+
"N approved branches cost 1 run. The honest trade: that validates the "
|
|
1156
|
+
"COMPOSITE, not each branch alone. It is a re-confirmation rather than "
|
|
1157
|
+
"a first proof — every member already holds its own passed verdict "
|
|
1158
|
+
"from an earlier base — and the batch merges against the same base "
|
|
1159
|
+
"snapshot the composite was built on. "
|
|
1160
|
+
"If the composite FAILS, nothing merges and nothing is marked failed; "
|
|
1161
|
+
"each branch is then re-tested alone, so the culprit is named and the "
|
|
1162
|
+
"innocent branches still merge (worst case 1+N runs, typical case 1). "
|
|
1163
|
+
"Runs the repo's own build/test commands locally, so it must run where "
|
|
1164
|
+
"the repo is checked out (on a thin client it routes to the daemon "
|
|
1165
|
+
"host, like the rest of `coord merge`). Distinct from --skip-smoke, "
|
|
1166
|
+
"which waives the gate instead of satisfying it."
|
|
1167
|
+
),
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
@click.option(
|
|
1172
|
+
"--drop",
|
|
1173
|
+
"drop_assignment",
|
|
1174
|
+
default=None,
|
|
1175
|
+
metavar="ASSIGNMENT_ID",
|
|
1176
|
+
help=(
|
|
1177
|
+
"#732: Drop exactly one merge_queue entry — accepts the assignment_id, the "
|
|
1178
|
+
"durable 'repo#issue' form (#1477), a bare issue number, or the branch name "
|
|
1179
|
+
"(#1490) — whichever the board printed, since assignment_id can re-key across "
|
|
1180
|
+
"a drop + re-enqueue (or an auto-enqueue tick) between the read and this call. "
|
|
1181
|
+
"Routes through the daemon so thin clients don't need local DB access."
|
|
1182
|
+
),
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
@click.option(
|
|
1187
|
+
"--only",
|
|
1188
|
+
"only_assignment",
|
|
1189
|
+
default=None,
|
|
1190
|
+
metavar="ASSIGNMENT_ID",
|
|
1191
|
+
help=(
|
|
1192
|
+
"#780: Merge exactly one entry — accepts the assignment_id, the durable "
|
|
1193
|
+
"'repo#issue' form (#1477), e.g. 'acme/api#1461', a bare issue number, or the "
|
|
1194
|
+
"branch name (#1490) — whichever the board printed. Resolution falls back "
|
|
1195
|
+
"through these forms in order, so an id that was re-keyed by a concurrent "
|
|
1196
|
+
"auto-enqueue tick since the board was last read still resolves via issue "
|
|
1197
|
+
"number or branch. Leaves the rest of the queue untouched. Mutually "
|
|
1198
|
+
"exclusive with --order. BLOCKED entries are reported and skipped (use "
|
|
1199
|
+
"--force-merge to override gates)."
|
|
1200
|
+
),
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
@click.option(
|
|
1205
|
+
"--override-human-required",
|
|
1206
|
+
"override_human_required",
|
|
1207
|
+
default=None,
|
|
1208
|
+
metavar="REASON",
|
|
1209
|
+
help=(
|
|
1210
|
+
"#1251: explicit, audited override for a HUMAN_REQUIRED entry — clears the "
|
|
1211
|
+
"flag and requeues it as PENDING so this run's other gates (--skip-review, "
|
|
1212
|
+
"--skip-smoke, --force-merge) can still apply normally. Requires --only "
|
|
1213
|
+
"<assignment_id> and a reason string, which is written to the audit trail "
|
|
1214
|
+
"alongside the original conflict_human_required event. Distinct from "
|
|
1215
|
+
"--force-merge on purpose: human_required means an automated process already "
|
|
1216
|
+
"gave up on this entry, not just that a gate wasn't run."
|
|
1217
|
+
),
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def merge(
|
|
1222
|
+
config_path: Path,
|
|
1223
|
+
dry_run: bool,
|
|
1224
|
+
show_plan: bool,
|
|
1225
|
+
order: str | None,
|
|
1226
|
+
repo_filter: str | None,
|
|
1227
|
+
method: str,
|
|
1228
|
+
force_merge: bool,
|
|
1229
|
+
skip_review: bool,
|
|
1230
|
+
skip_smoke: bool,
|
|
1231
|
+
revalidate: bool,
|
|
1232
|
+
drop_assignment: str | None,
|
|
1233
|
+
only_assignment: str | None,
|
|
1234
|
+
override_human_required: str | None,
|
|
1235
|
+
) -> None:
|
|
1236
|
+
# #1251: --override-human-required is a surgical single-entry override — it
|
|
1237
|
+
# only makes sense paired with --only, which pins down the one entry it
|
|
1238
|
+
# applies to. Validate up front (before any daemon round-trip) so a thin
|
|
1239
|
+
# client fails fast instead of silently no-op'ing the flag on the daemon
|
|
1240
|
+
# side (only_assignment gates the block that actually consumes it below).
|
|
1241
|
+
#
|
|
1242
|
+
# #1251-review: both this check and the later `if override_human_required:`
|
|
1243
|
+
# gate treat an empty/whitespace-only reason as falsy, so
|
|
1244
|
+
# `--override-human-required ""` would otherwise skip *every* validation
|
|
1245
|
+
# and *every* effect — no error, no override, no audit row — leaving the
|
|
1246
|
+
# entry stuck HUMAN_REQUIRED with no feedback that the reason was
|
|
1247
|
+
# rejected. Catch it explicitly first, before the --only check, since an
|
|
1248
|
+
# empty reason is invalid regardless of what else was passed.
|
|
1249
|
+
if override_human_required is not None and not override_human_required.strip():
|
|
1250
|
+
click.echo(
|
|
1251
|
+
"error: --override-human-required requires a non-empty reason string",
|
|
1252
|
+
err=True,
|
|
1253
|
+
)
|
|
1254
|
+
sys.exit(1)
|
|
1255
|
+
if override_human_required and not only_assignment:
|
|
1256
|
+
click.echo(
|
|
1257
|
+
"error: --override-human-required requires --only <assignment_id> — "
|
|
1258
|
+
"it targets exactly one entry, never a repo-wide scan",
|
|
1259
|
+
err=True,
|
|
1260
|
+
)
|
|
1261
|
+
sys.exit(1)
|
|
1262
|
+
|
|
1263
|
+
# #584: the merge queue + board live in the canonical (host-local) DB, so on
|
|
1264
|
+
# a thin client `coord merge` (and the TUI 'Go' button, which shells out to
|
|
1265
|
+
# it) would silently no-op against an empty local board. Route the whole
|
|
1266
|
+
# operation to the daemon — it runs the merge where the DB + gh live and
|
|
1267
|
+
# returns its output. COORD_MERGE_ON_DAEMON guards the daemon against
|
|
1268
|
+
# re-routing to itself (it calls this same command with the env var set).
|
|
1269
|
+
from coord.board_service import daemon_reroute_target # noqa: PLC0415
|
|
1270
|
+
|
|
1271
|
+
_merge_svc = daemon_reroute_target("COORD_MERGE_ON_DAEMON")
|
|
1272
|
+
if _merge_svc is not None:
|
|
1273
|
+
# #779-fix: --plan must never reach /merge on an older daemon — it has
|
|
1274
|
+
# no show_plan handler and falls through to a live merge cycle (side
|
|
1275
|
+
# effects). Route through /board instead; merge_plan has been in the
|
|
1276
|
+
# /board payload since #776/v0.4.53.
|
|
1277
|
+
if show_plan:
|
|
1278
|
+
_show_plan_from_daemon(_merge_svc, repo_filter=repo_filter, order=order)
|
|
1279
|
+
return
|
|
1280
|
+
_merge_via_daemon(_merge_svc, {
|
|
1281
|
+
"dry_run": dry_run, "order": order,
|
|
1282
|
+
"repo_filter": repo_filter, "method": method,
|
|
1283
|
+
"force_merge": force_merge, "skip_review": skip_review,
|
|
1284
|
+
"skip_smoke": skip_smoke, "revalidate": revalidate,
|
|
1285
|
+
"drop": drop_assignment,
|
|
1286
|
+
"only": only_assignment,
|
|
1287
|
+
"override_human_required": override_human_required,
|
|
1288
|
+
})
|
|
1289
|
+
return
|
|
1290
|
+
|
|
1291
|
+
# #732: --drop is a surgical single-entry removal; handle before the full
|
|
1292
|
+
# merge pipeline so it works even when the queue is otherwise busy/blocked.
|
|
1293
|
+
if drop_assignment:
|
|
1294
|
+
from coord import merge_queue as _mq # noqa: PLC0415
|
|
1295
|
+
|
|
1296
|
+
removed = _mq.drop_entry(drop_assignment)
|
|
1297
|
+
if removed:
|
|
1298
|
+
click.echo(f"merge-queue: dropped entry {drop_assignment}")
|
|
1299
|
+
else:
|
|
1300
|
+
click.echo(
|
|
1301
|
+
f"merge-queue: no entry found for {drop_assignment!r} "
|
|
1302
|
+
"(tried assignment_id, repo#issue, issue number, and branch name)",
|
|
1303
|
+
err=True,
|
|
1304
|
+
)
|
|
1305
|
+
sys.exit(1)
|
|
1306
|
+
return
|
|
1307
|
+
|
|
1308
|
+
# #779: --plan is a pure read-only path; handle it before the auto-enqueue
|
|
1309
|
+
# scan so it never causes side effects. When a daemon is present this path
|
|
1310
|
+
# is short-circuited above by _show_plan_from_daemon (/board, not /merge).
|
|
1311
|
+
# This local branch runs on the daemon itself (COORD_MERGE_ON_DAEMON set)
|
|
1312
|
+
# or when no daemon is configured (standalone dev environment).
|
|
1313
|
+
#
|
|
1314
|
+
# #1477-review: the reconcile call just below is a deliberate, narrow
|
|
1315
|
+
# carve-out from "never causes side effects" — it persists CONFLICT ->
|
|
1316
|
+
# PENDING (clearing entry.error) via save_queue() when the reconciled
|
|
1317
|
+
# branch turns out to be clean. That's accepted here because (a) it's a
|
|
1318
|
+
# state *correction*, not a merge action — no PR is touched — and (b)
|
|
1319
|
+
# daemon-fronted setups (the common case) never reach this branch at all,
|
|
1320
|
+
# since --plan is short-circuited above to the read-only /board path.
|
|
1321
|
+
# Only a standalone/no-daemon dev environment running --plan directly
|
|
1322
|
+
# observes this side effect.
|
|
1323
|
+
if show_plan:
|
|
1324
|
+
from coord import github_ops as _plan_gh_ops # noqa: PLC0415
|
|
1325
|
+
from coord import merge_queue as _plan_mq # noqa: PLC0415
|
|
1326
|
+
from coord.ci_store import build_ci_store as _build_ci_store # noqa: PLC0415
|
|
1327
|
+
from coord.state import load_board as _load_board # noqa: PLC0415
|
|
1328
|
+
|
|
1329
|
+
_cfg = _load_config(config_path)
|
|
1330
|
+
_board = _load_board()
|
|
1331
|
+
_ci = _build_ci_store(_cfg.ci_store.type)
|
|
1332
|
+
|
|
1333
|
+
# #1477: re-test any parked CONFLICT entry against GitHub's own
|
|
1334
|
+
# mergeability computation before building the plan — otherwise a
|
|
1335
|
+
# branch repaired by a conflict-fix worker (or by hand) keeps
|
|
1336
|
+
# showing its stale conflict verdict here indefinitely. See the
|
|
1337
|
+
# #1477-review note above the `if show_plan:` line for why this is a
|
|
1338
|
+
# deliberate exception to the "no side effects" contract.
|
|
1339
|
+
for _ev in _plan_mq.reconcile_conflict_entries(_plan_gh_ops):
|
|
1340
|
+
click.echo(
|
|
1341
|
+
f" {_ev.entry.repo_name} #{_ev.entry.issue_number} "
|
|
1342
|
+
f"({_ev.entry.branch}): {_ev.kind} — {_ev.message}"
|
|
1343
|
+
)
|
|
1344
|
+
|
|
1345
|
+
planned = _plan_mq.plan(_board, _cfg, _ci, gh_ops=_plan_gh_ops)
|
|
1346
|
+
|
|
1347
|
+
# --repo scoping
|
|
1348
|
+
if repo_filter:
|
|
1349
|
+
planned = [p for p in planned if p.repo_name == repo_filter]
|
|
1350
|
+
|
|
1351
|
+
# --order: put the named IDs first, then renumber ranks so the display
|
|
1352
|
+
# matches what a subsequent `coord merge --order <ids>` would actually do.
|
|
1353
|
+
if order:
|
|
1354
|
+
_override_ids = [s.strip() for s in order.split(",") if s.strip()]
|
|
1355
|
+
_by_id = {p.assignment_id: p for p in planned}
|
|
1356
|
+
_head = [_by_id[aid] for aid in _override_ids if aid in _by_id]
|
|
1357
|
+
_tail = [p for p in planned if p.assignment_id not in set(_override_ids)]
|
|
1358
|
+
planned = _head + _tail
|
|
1359
|
+
for _i, _p in enumerate(planned, 1):
|
|
1360
|
+
_p.rank = _i
|
|
1361
|
+
|
|
1362
|
+
_print_merge_plan_entries(planned)
|
|
1363
|
+
|
|
1364
|
+
# #920: sibling-overlap warnings, computed live off the same board.
|
|
1365
|
+
try:
|
|
1366
|
+
_overlaps = _plan_mq.find_sibling_overlaps(_board, _cfg)
|
|
1367
|
+
except Exception: # noqa: BLE001 — never let the warning break --plan
|
|
1368
|
+
_overlaps = []
|
|
1369
|
+
if repo_filter:
|
|
1370
|
+
_overlaps = [w for w in _overlaps if w.repo_name == repo_filter]
|
|
1371
|
+
_print_sibling_overlap_warnings(_overlaps)
|
|
1372
|
+
return
|
|
1373
|
+
|
|
1374
|
+
from coord import github_ops as gh_ops
|
|
1375
|
+
from coord import merge_queue as mq
|
|
1376
|
+
from coord.ci_store import build_ci_store
|
|
1377
|
+
from coord.merge_queue import CONFLICT, HUMAN_REQUIRED, PENDING
|
|
1378
|
+
from coord.state import load_board
|
|
1379
|
+
|
|
1380
|
+
# #780: --only is a surgical single-entry merge that leaves all other queue
|
|
1381
|
+
# entries in PENDING state. Handled early — before the full auto-enqueue
|
|
1382
|
+
# scan — so a --only run doesn't touch unrelated entries.
|
|
1383
|
+
if only_assignment:
|
|
1384
|
+
if order:
|
|
1385
|
+
click.echo(
|
|
1386
|
+
"error: --only and --order are mutually exclusive", err=True
|
|
1387
|
+
)
|
|
1388
|
+
sys.exit(1)
|
|
1389
|
+
cfg_only = _load_config(config_path)
|
|
1390
|
+
# #1477: re-test any parked CONFLICT entry before resolving
|
|
1391
|
+
# --only — a branch repaired since the last tick should be eligible
|
|
1392
|
+
# for --only the moment it's clean, not only after a manual --drop.
|
|
1393
|
+
for _ev in mq.reconcile_conflict_entries(gh_ops):
|
|
1394
|
+
click.echo(
|
|
1395
|
+
f" {_ev.entry.repo_name} #{_ev.entry.issue_number} "
|
|
1396
|
+
f"({_ev.entry.branch}): {_ev.kind} — {_ev.message}"
|
|
1397
|
+
)
|
|
1398
|
+
only_queue = mq.load_queue()
|
|
1399
|
+
only_entry = mq.resolve_entry_key(only_queue, only_assignment)
|
|
1400
|
+
if only_entry is None:
|
|
1401
|
+
# #1845: `--only` used to fail outright right here even when the
|
|
1402
|
+
# addressed row is done, fully gated, and simply hasn't been
|
|
1403
|
+
# through the auto-enqueue scan yet — `coord merge` (no --only)
|
|
1404
|
+
# runs that scan on every invocation, but the surgical --only
|
|
1405
|
+
# path went straight to the queue lookup and never triggered it.
|
|
1406
|
+
# A drive's merge stage hits this race routinely: the work
|
|
1407
|
+
# finishes moments before the daemon tick's own scan runs, and
|
|
1408
|
+
# each `--only` attempt in that window used to burn one of the
|
|
1409
|
+
# drive's few merge attempts on a false negative — see the
|
|
1410
|
+
# overnight incident in #1845. `enqueue_approved_work` is the
|
|
1411
|
+
# SAME scan `coord merge` (no --only) runs; when a matching
|
|
1412
|
+
# board row exists and every gate already passes, run it inline
|
|
1413
|
+
# and retry the resolution once before reporting failure.
|
|
1414
|
+
#
|
|
1415
|
+
# #1926: "every gate already passes" is decided by
|
|
1416
|
+
# `_board_row_merge_gate_ok`, NOT a direct
|
|
1417
|
+
# `mq.merge_gate_failures(a, ...)` call on the raw board
|
|
1418
|
+
# `Assignment` — that call silently no-ops every #1479 staleness
|
|
1419
|
+
# check (the Assignment has no repo_github/live-SHA fields), so
|
|
1420
|
+
# a row with a genuinely STALE verdict used to read as "gated"
|
|
1421
|
+
# here and get enqueued (and then merge-attempted) on the same
|
|
1422
|
+
# false-green basis the fallback message below was fixed for.
|
|
1423
|
+
_retry_board = load_board()
|
|
1424
|
+
if _retry_board is not None:
|
|
1425
|
+
_retry_rows = mq.resolve_board_work_key(_retry_board, only_assignment)
|
|
1426
|
+
_retry_all_gated = bool(_retry_rows) and all(
|
|
1427
|
+
_board_row_merge_gate_ok(a, cfg_only, _retry_board, gh_ops)
|
|
1428
|
+
for a in _retry_rows
|
|
1429
|
+
)
|
|
1430
|
+
if _retry_all_gated:
|
|
1431
|
+
mq.enqueue_approved_work(cfg_only, _retry_board)
|
|
1432
|
+
only_queue = mq.load_queue()
|
|
1433
|
+
only_entry = mq.resolve_entry_key(only_queue, only_assignment)
|
|
1434
|
+
if only_entry is None:
|
|
1435
|
+
# #1695: the old message said only "tried assignment_id,
|
|
1436
|
+
# repo#issue, issue number, and branch name", which reads as a
|
|
1437
|
+
# key-lookup problem and sends the operator hunting for a
|
|
1438
|
+
# different identifier. Usually the identifier was fine and a
|
|
1439
|
+
# gate was the reason no entry exists — so say which.
|
|
1440
|
+
for line in _explain_missing_only_entry(only_assignment, cfg_only, gh_ops):
|
|
1441
|
+
click.echo(line, err=True)
|
|
1442
|
+
sys.exit(1)
|
|
1443
|
+
# #1251: --override-human-required is the explicit, audited escape
|
|
1444
|
+
# hatch for an entry an automated conflict-fix (or a permission /
|
|
1445
|
+
# branch-protection classification) already gave up on. It's a
|
|
1446
|
+
# different class of override from --skip-smoke/--skip-review/
|
|
1447
|
+
# --force-merge — those waive a gate that simply wasn't run; this
|
|
1448
|
+
# clears a flag that says "automation gave up, a human must decide" —
|
|
1449
|
+
# so it gets its own flag, its own validation, and its own audit
|
|
1450
|
+
# row, never bundled into --force-merge.
|
|
1451
|
+
if override_human_required:
|
|
1452
|
+
if only_entry.state != HUMAN_REQUIRED:
|
|
1453
|
+
click.echo(
|
|
1454
|
+
"error: --override-human-required only applies to a "
|
|
1455
|
+
f"HUMAN_REQUIRED entry; {only_assignment!r} is in state "
|
|
1456
|
+
f"{only_entry.state!r}",
|
|
1457
|
+
err=True,
|
|
1458
|
+
)
|
|
1459
|
+
sys.exit(1)
|
|
1460
|
+
if dry_run:
|
|
1461
|
+
click.echo(
|
|
1462
|
+
" --override-human-required: (dry run) would clear "
|
|
1463
|
+
f"HUMAN_REQUIRED on {only_assignment!r} — "
|
|
1464
|
+
f"{override_human_required!r}"
|
|
1465
|
+
)
|
|
1466
|
+
else:
|
|
1467
|
+
from coord.audit import record_audit # noqa: PLC0415
|
|
1468
|
+
|
|
1469
|
+
record_audit(
|
|
1470
|
+
tier="business",
|
|
1471
|
+
category="merge",
|
|
1472
|
+
event_type="human_required_override",
|
|
1473
|
+
actor="user",
|
|
1474
|
+
summary=(
|
|
1475
|
+
f"human_required override: {only_entry.repo_name}"
|
|
1476
|
+
f"#{only_entry.issue_number} ({only_assignment}) — "
|
|
1477
|
+
f"{override_human_required}"
|
|
1478
|
+
),
|
|
1479
|
+
repo=only_entry.repo_name,
|
|
1480
|
+
issue=only_entry.issue_number,
|
|
1481
|
+
assignment_id=only_entry.assignment_id,
|
|
1482
|
+
details={"reason": override_human_required},
|
|
1483
|
+
)
|
|
1484
|
+
click.echo(
|
|
1485
|
+
" --override-human-required: cleared HUMAN_REQUIRED on "
|
|
1486
|
+
f"{only_assignment!r} — {override_human_required!r} — "
|
|
1487
|
+
"requeued as PENDING"
|
|
1488
|
+
)
|
|
1489
|
+
# Reset in-memory state either way so the dry-run event stream
|
|
1490
|
+
# below reflects what a real run would do (matching the review/
|
|
1491
|
+
# smoke gate dry-run convention); actual persistence is still
|
|
1492
|
+
# gated on `not dry_run` in the save block further down.
|
|
1493
|
+
only_entry.state = PENDING
|
|
1494
|
+
only_entry.error = None
|
|
1495
|
+
if only_entry.state != PENDING:
|
|
1496
|
+
click.echo(
|
|
1497
|
+
f"merge-queue: entry {only_assignment!r} is in state "
|
|
1498
|
+
f"{only_entry.state!r} (not PENDING) — cannot merge",
|
|
1499
|
+
err=True,
|
|
1500
|
+
)
|
|
1501
|
+
sys.exit(1)
|
|
1502
|
+
# #821: never pass None to process() — use an empty board so
|
|
1503
|
+
# has_smoke_verdict can apply its "no work found → fail open" rule.
|
|
1504
|
+
# process() blocks on board=None when a gate IS required; an empty
|
|
1505
|
+
# board lets the gate function decide.
|
|
1506
|
+
from coord.models import Board as _Board # noqa: PLC0415
|
|
1507
|
+
_raw_board_only = load_board()
|
|
1508
|
+
board_only = _raw_board_only if _raw_board_only is not None else _Board(active=[], completed=[])
|
|
1509
|
+
ci_store_only = build_ci_store(cfg_only.ci_store.type)
|
|
1510
|
+
# #1695: name the blocking gate(s) up front. Under #1695 a gate-blocked
|
|
1511
|
+
# row IS enqueued (visibly BLOCKED) instead of being dropped, so
|
|
1512
|
+
# `--only` now resolves it — and the operator needs to be told, before
|
|
1513
|
+
# process() runs, exactly which gate stands between them and the merge
|
|
1514
|
+
# and which flag waives it. process() still enforces every gate below;
|
|
1515
|
+
# this is a report, not a decision.
|
|
1516
|
+
_only_gate_failures = mq.merge_gate_failures(
|
|
1517
|
+
only_entry, cfg_only, board_only, gh_ops,
|
|
1518
|
+
)
|
|
1519
|
+
for _gf in _only_gate_failures:
|
|
1520
|
+
# NB: --force-merge is deliberately absent here — it waives the CI
|
|
1521
|
+
# gate only (merge_queue.process), never review or smoke.
|
|
1522
|
+
_waived = (
|
|
1523
|
+
(_gf.gate == "review" and skip_review)
|
|
1524
|
+
or (_gf.gate == "smoke" and skip_smoke)
|
|
1525
|
+
)
|
|
1526
|
+
_status = "waived by this run" if _waived else "will block this merge"
|
|
1527
|
+
click.echo(f" gate {_gf.gate}: {_gf.reason} — {_status}")
|
|
1528
|
+
if skip_review:
|
|
1529
|
+
click.echo(" --skip-review: review-approval gate bypassed (#253)")
|
|
1530
|
+
if skip_smoke:
|
|
1531
|
+
click.echo(" --skip-smoke: interactive smoke-test gate bypassed (#465)")
|
|
1532
|
+
only_items = [only_entry]
|
|
1533
|
+
# #1769: re-test a stale-but-passed verdict against the current base
|
|
1534
|
+
# before the gate runs. Opt-in only; `--skip-smoke` is unaffected (it
|
|
1535
|
+
# waives the gate, this satisfies it), and a failing re-test leaves the
|
|
1536
|
+
# entry blocked exactly as it was.
|
|
1537
|
+
if revalidate and not skip_smoke:
|
|
1538
|
+
board_only = _apply_revalidation(
|
|
1539
|
+
only_items, board_only, cfg_only, gh_ops, dry_run=dry_run,
|
|
1540
|
+
)
|
|
1541
|
+
# #1851: CI staleness is a separate gate from the local smoke
|
|
1542
|
+
# verdict — always run under --revalidate, independent of
|
|
1543
|
+
# --skip-smoke (which waives the smoke gate specifically, not CI).
|
|
1544
|
+
deferred_ci: set[str] = set()
|
|
1545
|
+
if revalidate:
|
|
1546
|
+
deferred_ci = _apply_ci_revalidation(
|
|
1547
|
+
only_items, board_only, cfg_only, ci_store_only, gh_ops,
|
|
1548
|
+
dry_run=dry_run,
|
|
1549
|
+
)
|
|
1550
|
+
# #1925: an entry deferred by the CI-settle wait above must not go
|
|
1551
|
+
# through process() this pass — that would immediately re-derive the
|
|
1552
|
+
# exact self-triggered "unknown" reading the wait was just trying to
|
|
1553
|
+
# avoid handing to the gate. It stays PENDING with the explanatory
|
|
1554
|
+
# `entry.error` _apply_ci_revalidation already set.
|
|
1555
|
+
if only_entry.assignment_id in deferred_ci:
|
|
1556
|
+
events_only = []
|
|
1557
|
+
else:
|
|
1558
|
+
events_only = mq.process(
|
|
1559
|
+
only_items, gh_ops,
|
|
1560
|
+
method=method, dry_run=dry_run, presorted=True,
|
|
1561
|
+
ci_store=ci_store_only, force_merge=force_merge,
|
|
1562
|
+
config=cfg_only, board=board_only,
|
|
1563
|
+
skip_review=skip_review, skip_smoke=skip_smoke,
|
|
1564
|
+
)
|
|
1565
|
+
for ev in events_only:
|
|
1566
|
+
e = ev.entry
|
|
1567
|
+
prefix = f" {e.repo_name} #{e.issue_number} ({e.branch})"
|
|
1568
|
+
click.echo(f"{prefix}: {ev.kind} — {ev.message}")
|
|
1569
|
+
# #1474 review: --only used to return here without ever classifying
|
|
1570
|
+
# a fresh conflict — see _dispatch_conflict_fixes's docstring. Run it
|
|
1571
|
+
# before the save below so a retry-cap/non-rebaseable HUMAN_REQUIRED
|
|
1572
|
+
# mutation on only_entry.state is persisted, not lost.
|
|
1573
|
+
_dispatch_conflict_fixes(events_only, cfg_only, dry_run=dry_run)
|
|
1574
|
+
if not dry_run:
|
|
1575
|
+
# Save only the modified entry back; all other entries are untouched.
|
|
1576
|
+
all_items_only = mq.load_queue()
|
|
1577
|
+
by_id_only = {only_entry.assignment_id: only_entry}
|
|
1578
|
+
merged_only = [by_id_only.get(x.assignment_id, x) for x in all_items_only]
|
|
1579
|
+
mq.save_queue(merged_only)
|
|
1580
|
+
click.echo("")
|
|
1581
|
+
click.echo(
|
|
1582
|
+
"Summary (--only): "
|
|
1583
|
+
+ ", ".join(f"{k}={v}" for k, v in sorted(
|
|
1584
|
+
{x.state: 1 for x in only_items}.items()
|
|
1585
|
+
))
|
|
1586
|
+
)
|
|
1587
|
+
return
|
|
1588
|
+
|
|
1589
|
+
cfg = _load_config(config_path)
|
|
1590
|
+
|
|
1591
|
+
# #242: Before processing, scan board.completed for done work assignments
|
|
1592
|
+
# that should be queued but aren't. Without this, `coord merge` silently
|
|
1593
|
+
# no-ops when a work assignment reached "done" via a path that didn't
|
|
1594
|
+
# also trigger the `coord status` enqueue hook (restart, notify-driven
|
|
1595
|
+
# mark_done, etc.). enqueue() is idempotent — by assignment_id — so this
|
|
1596
|
+
# is safe to call on every invocation.
|
|
1597
|
+
#
|
|
1598
|
+
# Filter on issue.state == 'open': a closed issue was almost certainly
|
|
1599
|
+
# already merged externally (or won't-fix'd) and re-attempting a merge
|
|
1600
|
+
# for it would open spurious PRs against branches that may not even
|
|
1601
|
+
# exist anymore. When the issues table has no row for an issue (cache
|
|
1602
|
+
# miss), default to OPEN — that matches the prior coord status enqueue
|
|
1603
|
+
# path which had no such check.
|
|
1604
|
+
# #821: never pass None to process() — use an empty board so
|
|
1605
|
+
# has_smoke_verdict can apply its "no work found → fail open" rule.
|
|
1606
|
+
# process() blocks on board=None when a gate IS required; an empty
|
|
1607
|
+
# board lets the gate function decide.
|
|
1608
|
+
from coord.models import Board as _Board # noqa: PLC0415
|
|
1609
|
+
_raw_board = load_board()
|
|
1610
|
+
board = _raw_board if _raw_board is not None else _Board(active=[], completed=[])
|
|
1611
|
+
open_by_repo, known_by_repo = _load_issue_states()
|
|
1612
|
+
|
|
1613
|
+
auto_enqueued: list[str] = []
|
|
1614
|
+
# Per-repo cache of branches that still exist on origin. Lets us skip
|
|
1615
|
+
# re-enqueuing done-work whose branch was already merged-and-deleted — the
|
|
1616
|
+
# dominant merge-queue clog source. A done assignment for a closed issue
|
|
1617
|
+
# often isn't in the open-only issues cache, so the issue-state filter
|
|
1618
|
+
# above misses it; branch-existence catches every merge path (coord merge,
|
|
1619
|
+
# gh pr merge, manual) uniformly. Fail OPEN on lookup failure.
|
|
1620
|
+
from coord import github_ops as _gho
|
|
1621
|
+
branch_cache: dict[str, set[str]] = {}
|
|
1622
|
+
# #525: per-run cache for work_is_terminal; shared across the whole
|
|
1623
|
+
# auto-enqueue loop so one gh round-trip covers every repeated
|
|
1624
|
+
# (repo, issue, branch) triple.
|
|
1625
|
+
terminal_cache: dict = {}
|
|
1626
|
+
# #934: per-run cache for the issue -> milestone-number lookup, mirroring
|
|
1627
|
+
# terminal_cache above.
|
|
1628
|
+
milestone_cache: dict = {}
|
|
1629
|
+
if board is not None:
|
|
1630
|
+
# #1490: resolve every branch to a single winning work-like row
|
|
1631
|
+
# before any of the filters below run. A fix/bounce cycle piles up
|
|
1632
|
+
# more than one WORK_LIKE_TYPES row on the same branch (the
|
|
1633
|
+
# original dispatch plus every retry), and processing each
|
|
1634
|
+
# independently used to call refresh_entry_assignment once per row
|
|
1635
|
+
# — re-keying the branch's one queue entry, and re-printing
|
|
1636
|
+
# "auto-enqueued", every single time, forever (the exact symptom
|
|
1637
|
+
# #1490 reports: one branch, three identical announcements, on
|
|
1638
|
+
# every `coord merge` pass). Superseded rows are reported once here
|
|
1639
|
+
# and never reach refresh_entry_assignment at all.
|
|
1640
|
+
scoped_completed = [
|
|
1641
|
+
a for a in board.completed
|
|
1642
|
+
if not repo_filter or a.repo_name == repo_filter
|
|
1643
|
+
]
|
|
1644
|
+
for a, superseded in mq.group_branch_candidates(scoped_completed):
|
|
1645
|
+
for row in superseded:
|
|
1646
|
+
auto_enqueued.append(
|
|
1647
|
+
f" superseded: {row.repo_name} #{row.issue_number} "
|
|
1648
|
+
f"(assignment {row.assignment_id}, branch {row.branch}) — "
|
|
1649
|
+
"not the winning row for this branch, skipped (#1490)"
|
|
1650
|
+
)
|
|
1651
|
+
repo_cfg = cfg.repo(a.repo_name)
|
|
1652
|
+
if repo_cfg is None:
|
|
1653
|
+
continue
|
|
1654
|
+
# #1353: isolate the rest of this assignment's scan — a bad
|
|
1655
|
+
# `gh` round-trip (e.g. empty stdout on exit 0, see
|
|
1656
|
+
# github_ops._gh) or a transient decode failure anywhere below
|
|
1657
|
+
# used to raise straight out of this loop and abort auto-enqueue
|
|
1658
|
+
# for every *other* assignment in the batch too, with the CLI
|
|
1659
|
+
# printing nothing before dying. One assignment misbehaving is
|
|
1660
|
+
# not a reason to skip the whole drain — catch it, report which
|
|
1661
|
+
# assignment and why, and keep scanning the rest.
|
|
1662
|
+
try:
|
|
1663
|
+
# Issue-state filter: skip closed issues (probably merged
|
|
1664
|
+
# elsewhere). We deny only when the cache has explicit
|
|
1665
|
+
# evidence the issue is closed — i.e. there's a row for this
|
|
1666
|
+
# (repo, number) and its state isn't 'open'. If the cache
|
|
1667
|
+
# simply has no row for this issue (e.g. it was created
|
|
1668
|
+
# after the last sync), treat as unknown and allow — denying
|
|
1669
|
+
# on cache miss silently skipped post-sync issues
|
|
1670
|
+
# (#278/#280 hit this).
|
|
1671
|
+
known_issues = known_by_repo.get(a.repo_name, set())
|
|
1672
|
+
open_issues = open_by_repo.get(a.repo_name, set())
|
|
1673
|
+
if a.issue_number in known_issues and a.issue_number not in open_issues:
|
|
1674
|
+
continue
|
|
1675
|
+
# Skip work whose branch no longer exists on origin (already
|
|
1676
|
+
# merged + deleted). Fail OPEN: only skip when we got a real
|
|
1677
|
+
# (non-empty) branch list back and the branch isn't in it.
|
|
1678
|
+
origin_branches = branch_cache.get(a.repo_name)
|
|
1679
|
+
if origin_branches is None:
|
|
1680
|
+
origin_branches = _gho.list_remote_branch_names(repo_cfg.github)
|
|
1681
|
+
branch_cache[a.repo_name] = origin_branches
|
|
1682
|
+
if origin_branches and a.branch not in origin_branches:
|
|
1683
|
+
continue
|
|
1684
|
+
# #525: never enqueue work that is already done on GitHub —
|
|
1685
|
+
# issue closed OR PR merged. Mirrors the #522 guard in
|
|
1686
|
+
# review.dispatch_review. Fail OPEN: a transient gh error
|
|
1687
|
+
# must never block a real enqueue.
|
|
1688
|
+
if _gho.work_is_terminal(
|
|
1689
|
+
repo_cfg.github, a.issue_number, a.branch,
|
|
1690
|
+
cache=terminal_cache,
|
|
1691
|
+
):
|
|
1692
|
+
continue
|
|
1693
|
+
# #946: review + smoke gates, via the shared predicate — this
|
|
1694
|
+
# loop was the primary ungated enqueue path (#782/#795 reached
|
|
1695
|
+
# the merge queue with a failed test / no review at all).
|
|
1696
|
+
#
|
|
1697
|
+
# #1695: the gate no longer *drops* the row here. Blocking at
|
|
1698
|
+
# enqueue time made `coord merge --skip-review` structurally
|
|
1699
|
+
# unreachable — the flag waives the gate for an entry that
|
|
1700
|
+
# already exists, but an un-approved row could never become
|
|
1701
|
+
# an entry, so `--only` had nothing to address and the silent
|
|
1702
|
+
# `continue` printed nothing about why. The row is now
|
|
1703
|
+
# enqueued in a visibly BLOCKED state (it will render as
|
|
1704
|
+
# BLOCKED in `--plan`/`--dry-run` via `_entry_gate_status`,
|
|
1705
|
+
# and `--only` can name it), and the gate is enforced where
|
|
1706
|
+
# its override lives: `process()` still refuses to merge it
|
|
1707
|
+
# unless `--skip-review`/`--skip-smoke` is given. Enqueueing
|
|
1708
|
+
# changes visibility, never eligibility — auto-drain only
|
|
1709
|
+
# ever touches PLAN_READY entries, and a blocked entry is
|
|
1710
|
+
# PLAN_BLOCKED, so this is safe with `merge.auto_drain: true`.
|
|
1711
|
+
#
|
|
1712
|
+
# #934: target `feature/ms-NN` when this issue belongs to a
|
|
1713
|
+
# milestone and the repo opted into the git model — the
|
|
1714
|
+
# milestone lookup itself is skipped (no `gh` call) when it
|
|
1715
|
+
# hasn't, falling back to `default_branch` unchanged.
|
|
1716
|
+
# #2085: resolved BEFORE the gate check now (it used to run
|
|
1717
|
+
# after) — `mq.live_gate_entry` below needs a target_branch
|
|
1718
|
+
# to populate the #821/#1479 freshness anchors live.
|
|
1719
|
+
target_branch = repo_cfg.default_branch
|
|
1720
|
+
if getattr(repo_cfg, "develop_branch", None):
|
|
1721
|
+
from coord.branch_model import ( # noqa: PLC0415
|
|
1722
|
+
fetch_issue_milestone_number,
|
|
1723
|
+
resolve_base_branch,
|
|
1724
|
+
)
|
|
1725
|
+
|
|
1726
|
+
milestone_number = fetch_issue_milestone_number(
|
|
1727
|
+
repo_cfg.github, a.issue_number, cache=milestone_cache,
|
|
1728
|
+
)
|
|
1729
|
+
target_branch = resolve_base_branch(repo_cfg, milestone_number)
|
|
1730
|
+
|
|
1731
|
+
# #2085: `a` is a raw board Assignment — no `branch_head_sha`/
|
|
1732
|
+
# `repo_github`/`target_branch` attribute, so handing it
|
|
1733
|
+
# straight to `merge_gate_failures` made the #821
|
|
1734
|
+
# SHA-freshness check inside `has_approved_review`
|
|
1735
|
+
# permanently unconfirmable (fails closed on every review
|
|
1736
|
+
# carrying a real `review_head_sha`, i.e. virtually every
|
|
1737
|
+
# modern approval) — this scan printed "BLOCKED: review
|
|
1738
|
+
# required but not approved" for ordinary fresh approvals on
|
|
1739
|
+
# every single invocation. `mq.live_gate_entry` builds the
|
|
1740
|
+
# same live-anchored synthetic entry
|
|
1741
|
+
# `coord.gates.build_gate_report` uses, backed by `_gho`
|
|
1742
|
+
# (already available in this loop for the terminal-state
|
|
1743
|
+
# check above), so a genuinely fresh approval reads BLOCKED
|
|
1744
|
+
# only when it actually is.
|
|
1745
|
+
gate_entry = mq.live_gate_entry(a, repo_cfg.github, target_branch, _gho)
|
|
1746
|
+
gate_failures = mq.merge_gate_failures(gate_entry, cfg, board, _gho)
|
|
1747
|
+
gate_note = ""
|
|
1748
|
+
if gate_failures:
|
|
1749
|
+
gate_note = (
|
|
1750
|
+
" — BLOCKED: "
|
|
1751
|
+
+ mq.describe_merge_gate_failures(gate_failures)
|
|
1752
|
+
)
|
|
1753
|
+
# #736 / #292: use refresh_entry_assignment (not bare
|
|
1754
|
+
# enqueue) so an existing PENDING entry is re-keyed to the
|
|
1755
|
+
# latest fix assignment when the original assignment_id no
|
|
1756
|
+
# longer matches. Dedup by (repo_github, branch) is
|
|
1757
|
+
# preserved — refresh_entry_assignment is a no-op when the
|
|
1758
|
+
# entry is already correctly keyed.
|
|
1759
|
+
if mq.refresh_entry_assignment(
|
|
1760
|
+
a,
|
|
1761
|
+
repo_github=repo_cfg.github,
|
|
1762
|
+
target_branch=target_branch,
|
|
1763
|
+
):
|
|
1764
|
+
auto_enqueued.append(
|
|
1765
|
+
f" auto-enqueued: {a.repo_name} #{a.issue_number} "
|
|
1766
|
+
f"({a.branch} → {target_branch}){gate_note}"
|
|
1767
|
+
)
|
|
1768
|
+
elif gate_failures:
|
|
1769
|
+
# Already queued and still blocked. Re-state it every pass
|
|
1770
|
+
# rather than once at creation: a blocked entry is exactly
|
|
1771
|
+
# the row the operator is looking for, and #1695's whole
|
|
1772
|
+
# complaint is that this state was invisible. Emitted at
|
|
1773
|
+
# most once per branch — `group_branch_candidates` has
|
|
1774
|
+
# already collapsed the fix/bounce rows (#1490).
|
|
1775
|
+
auto_enqueued.append(
|
|
1776
|
+
f" blocked: {a.repo_name} #{a.issue_number} "
|
|
1777
|
+
f"(assignment {a.assignment_id}, {a.branch} → "
|
|
1778
|
+
f"{target_branch}) — "
|
|
1779
|
+
f"{mq.describe_merge_gate_failures(gate_failures)}"
|
|
1780
|
+
)
|
|
1781
|
+
except Exception as e: # noqa: BLE001
|
|
1782
|
+
auto_enqueued.append(
|
|
1783
|
+
f" skipped: {a.repo_name} #{a.issue_number} "
|
|
1784
|
+
f"(assignment {a.assignment_id}) — auto-enqueue scan "
|
|
1785
|
+
f"failed, skipping this assignment: {e!r}"
|
|
1786
|
+
)
|
|
1787
|
+
for line in auto_enqueued:
|
|
1788
|
+
click.echo(line)
|
|
1789
|
+
|
|
1790
|
+
# #1477: re-test any parked CONFLICT entry against GitHub's own
|
|
1791
|
+
# mergeability computation before the pending scan below — a branch
|
|
1792
|
+
# repaired by a conflict-fix worker (or by hand) since the last tick
|
|
1793
|
+
# must clear itself here, not require a manual --drop + re-enqueue +
|
|
1794
|
+
# --only. Runs unconditionally (even under --dry-run): it's a state
|
|
1795
|
+
# correction, not a merge action, same posture as the auto-enqueue scan
|
|
1796
|
+
# above. #1477-review: unlike every other --dry-run line in this
|
|
1797
|
+
# command, this one really does persist (it corrects previously-cached
|
|
1798
|
+
# state rather than proposing a merge action), so it's called out
|
|
1799
|
+
# explicitly here rather than left to blend in with the "(dry run)
|
|
1800
|
+
# would ..." lines below.
|
|
1801
|
+
for ev in mq.reconcile_conflict_entries(gh_ops):
|
|
1802
|
+
e = ev.entry
|
|
1803
|
+
suffix = " (reconciled — persisted even under --dry-run)" if dry_run else ""
|
|
1804
|
+
click.echo(
|
|
1805
|
+
f" {e.repo_name} #{e.issue_number} ({e.branch}): {ev.kind} — {ev.message}{suffix}"
|
|
1806
|
+
)
|
|
1807
|
+
|
|
1808
|
+
items = mq.load_queue()
|
|
1809
|
+
if repo_filter:
|
|
1810
|
+
items = [x for x in items if x.repo_name == repo_filter]
|
|
1811
|
+
if not items:
|
|
1812
|
+
# Distinguish "nothing in the queue" from "nothing to do because
|
|
1813
|
+
# there's no completed work to merge" — the latter is the common
|
|
1814
|
+
# case before #242 was fixed and was the silent-fail symptom.
|
|
1815
|
+
if board is not None and any(
|
|
1816
|
+
a.type in WORK_LIKE_TYPES and a.status == "done" and a.branch
|
|
1817
|
+
for a in board.completed
|
|
1818
|
+
if (not repo_filter or a.repo_name == repo_filter)
|
|
1819
|
+
):
|
|
1820
|
+
click.echo("Merge queue is empty (all done-work is already merged or has no branch).")
|
|
1821
|
+
else:
|
|
1822
|
+
click.echo("Merge queue is empty (no completed work to merge).")
|
|
1823
|
+
return
|
|
1824
|
+
|
|
1825
|
+
presorted = False
|
|
1826
|
+
if order:
|
|
1827
|
+
ids = [s.strip() for s in order.split(",") if s.strip()]
|
|
1828
|
+
items = mq.reorder(items, ids)
|
|
1829
|
+
presorted = True
|
|
1830
|
+
|
|
1831
|
+
pending = [x for x in items if x.state == PENDING]
|
|
1832
|
+
if not pending:
|
|
1833
|
+
# Still surface terminal states so the user knows what happened.
|
|
1834
|
+
for x in items:
|
|
1835
|
+
click.echo(f" [{x.state}] {x.repo_name} #{x.issue_number} ({x.branch})")
|
|
1836
|
+
return
|
|
1837
|
+
|
|
1838
|
+
ci_store = build_ci_store(cfg.ci_store.type)
|
|
1839
|
+
if skip_review:
|
|
1840
|
+
click.echo(" --skip-review: review-approval gate bypassed (#253)")
|
|
1841
|
+
if skip_smoke:
|
|
1842
|
+
click.echo(" --skip-smoke: interactive smoke-test gate bypassed (#465)")
|
|
1843
|
+
# #1769: the merge lane's stale-verdict resolution. Off unless the operator
|
|
1844
|
+
# asked for it — with no `--revalidate` this block does not run at all and
|
|
1845
|
+
# `coord merge` behaves byte-identically to before.
|
|
1846
|
+
if revalidate and not skip_smoke:
|
|
1847
|
+
board = _apply_revalidation(pending, board, cfg, gh_ops, dry_run=dry_run)
|
|
1848
|
+
# #1851: CI staleness is a separate gate from the local smoke verdict —
|
|
1849
|
+
# always run under --revalidate, independent of --skip-smoke (which
|
|
1850
|
+
# waives the smoke gate specifically, not CI).
|
|
1851
|
+
deferred_ci: set[str] = set()
|
|
1852
|
+
if revalidate:
|
|
1853
|
+
deferred_ci = _apply_ci_revalidation(
|
|
1854
|
+
pending, board, cfg, ci_store, gh_ops, dry_run=dry_run,
|
|
1855
|
+
)
|
|
1856
|
+
# #1925: entries the CI-settle wait above gave up on while still only
|
|
1857
|
+
# seeing the registration-gap symptom must not go through process() this
|
|
1858
|
+
# pass — see `_apply_ci_revalidation`'s docstring. They keep their
|
|
1859
|
+
# PENDING state and the explanatory `entry.error` it already set; `items`
|
|
1860
|
+
# (unfiltered) still carries them through to the save step below.
|
|
1861
|
+
process_items = (
|
|
1862
|
+
[x for x in items if x.assignment_id not in deferred_ci]
|
|
1863
|
+
if deferred_ci else items
|
|
1864
|
+
)
|
|
1865
|
+
events = mq.process(
|
|
1866
|
+
process_items, gh_ops,
|
|
1867
|
+
method=method, dry_run=dry_run, presorted=presorted,
|
|
1868
|
+
ci_store=ci_store, force_merge=force_merge,
|
|
1869
|
+
config=cfg, board=board, skip_review=skip_review, skip_smoke=skip_smoke,
|
|
1870
|
+
)
|
|
1871
|
+
|
|
1872
|
+
for ev in events:
|
|
1873
|
+
e = ev.entry
|
|
1874
|
+
prefix = f" {e.repo_name} #{e.issue_number} ({e.branch})"
|
|
1875
|
+
click.echo(f"{prefix}: {ev.kind} — {ev.message}")
|
|
1876
|
+
|
|
1877
|
+
# #241: classify any conflict events and dispatch a conflict-fix worker
|
|
1878
|
+
# for the eligible ones (extracted to _dispatch_conflict_fixes, #1474
|
|
1879
|
+
# review, so the --only path below can share it).
|
|
1880
|
+
_dispatch_conflict_fixes(events, cfg, dry_run=dry_run)
|
|
1881
|
+
|
|
1882
|
+
# Save state only when we actually moved
|
|
1883
|
+
if not dry_run:
|
|
1884
|
+
# Persist the updated entries by merging back over the on-disk queue.
|
|
1885
|
+
all_items = mq.load_queue()
|
|
1886
|
+
by_id = {x.assignment_id: x for x in items}
|
|
1887
|
+
merged = [by_id.get(x.assignment_id, x) for x in all_items]
|
|
1888
|
+
mq.save_queue(merged)
|
|
1889
|
+
|
|
1890
|
+
# Summary
|
|
1891
|
+
states: dict[str, int] = {}
|
|
1892
|
+
for x in items:
|
|
1893
|
+
states[x.state] = states.get(x.state, 0) + 1
|
|
1894
|
+
click.echo("")
|
|
1895
|
+
click.echo(
|
|
1896
|
+
"Summary: "
|
|
1897
|
+
+ ", ".join(f"{k}={v}" for k, v in sorted(states.items()))
|
|
1898
|
+
)
|
|
1899
|
+
if states.get(CONFLICT):
|
|
1900
|
+
click.echo("note: at least one PR has a conflict — resolve manually, then re-run.")
|