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
|
@@ -0,0 +1,2081 @@
|
|
|
1
|
+
"""`coord milestone` — Phase 0 (`order`) + Phase 1 (`dispatch`) of #767
|
|
2
|
+
(milestone-driven workflow).
|
|
3
|
+
|
|
4
|
+
Thin CLI glue around ``coord/milestone_order.py`` (the pure DAG/frontier
|
|
5
|
+
parser) and ``coord/milestone_dispatch.py`` (Phase 1's machine-picking +
|
|
6
|
+
dispatch): fetches the tracking issue + milestone membership + issue
|
|
7
|
+
terminal state from GitHub via ``coord.milestone_dispatch.
|
|
8
|
+
fetch_milestone_context`` (shared by both subcommands so they see identical
|
|
9
|
+
inputs), then hands that data to the pure functions and prints the result.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from coord.commands._common import _CONFIG_OPTION, _load_config
|
|
22
|
+
from coord.milestone_dispatch import (
|
|
23
|
+
DispatchOutcome,
|
|
24
|
+
MachinePick,
|
|
25
|
+
MilestoneDispatchError,
|
|
26
|
+
dispatch_entry,
|
|
27
|
+
fetch_milestone_context,
|
|
28
|
+
gate_a_status,
|
|
29
|
+
is_milestone_complete,
|
|
30
|
+
pick_machine,
|
|
31
|
+
plan_dispatch,
|
|
32
|
+
)
|
|
33
|
+
from coord.milestone_order import (
|
|
34
|
+
TRACKING_ISSUE_LABEL,
|
|
35
|
+
WorkOrder,
|
|
36
|
+
WorkOrderError,
|
|
37
|
+
WorkOrderNode,
|
|
38
|
+
compute_progress,
|
|
39
|
+
parse_progress,
|
|
40
|
+
parse_sub_issues,
|
|
41
|
+
parse_work_order,
|
|
42
|
+
remove_sub_issues_section,
|
|
43
|
+
render_progress,
|
|
44
|
+
render_sub_issues,
|
|
45
|
+
render_work_order,
|
|
46
|
+
replace_progress_section,
|
|
47
|
+
replace_sub_issues_section,
|
|
48
|
+
replace_work_order_section,
|
|
49
|
+
validate_milestone_membership,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@click.group("milestone")
|
|
54
|
+
def milestone_group() -> None:
|
|
55
|
+
"""Milestone work-order operations (#767 Phase 0) + the milestone write
|
|
56
|
+
seam (#645)."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@milestone_group.command(
|
|
60
|
+
"create",
|
|
61
|
+
help=(
|
|
62
|
+
"Create a GitHub milestone through the backend-agnostic tracker seam. "
|
|
63
|
+
"REPO is the local repo name from coordinator.yml. Prints the new "
|
|
64
|
+
"milestone number on success. Routes through the daemon seam so "
|
|
65
|
+
"agents (notably the milestone-chat session) never call `gh` "
|
|
66
|
+
"directly."
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
@click.argument("repo")
|
|
70
|
+
@click.option("--title", required=True, help="Milestone title.")
|
|
71
|
+
@click.option("--description", default=None, help="Milestone description (markdown).")
|
|
72
|
+
@click.option(
|
|
73
|
+
"--due-on",
|
|
74
|
+
default=None,
|
|
75
|
+
help="Due date, ISO 8601 (e.g. 2026-08-01T00:00:00Z).",
|
|
76
|
+
)
|
|
77
|
+
@_CONFIG_OPTION
|
|
78
|
+
def milestone_create_cmd(
|
|
79
|
+
repo: str,
|
|
80
|
+
title: str,
|
|
81
|
+
description: str | None,
|
|
82
|
+
due_on: str | None,
|
|
83
|
+
config_path: Path,
|
|
84
|
+
) -> None:
|
|
85
|
+
cfg = _load_config(config_path)
|
|
86
|
+
repo_entry = cfg.repo(repo)
|
|
87
|
+
if repo_entry is None:
|
|
88
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
89
|
+
sys.exit(2)
|
|
90
|
+
|
|
91
|
+
from coord.state import write_milestone
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
result = write_milestone(
|
|
95
|
+
repo,
|
|
96
|
+
title=title,
|
|
97
|
+
description=description,
|
|
98
|
+
due_on=due_on,
|
|
99
|
+
repo_github=repo_entry.github,
|
|
100
|
+
)
|
|
101
|
+
except Exception as e: # noqa: BLE001
|
|
102
|
+
click.echo(f"error: milestone create failed: {e}", err=True)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
click.echo(
|
|
105
|
+
f"milestone #{result.get('number')} created ({repo_entry.github}): "
|
|
106
|
+
f"{result.get('title')}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@milestone_group.command(
|
|
111
|
+
"edit",
|
|
112
|
+
help=(
|
|
113
|
+
"Edit a GitHub milestone's title/description/due date. REPO is the "
|
|
114
|
+
"local repo name from coordinator.yml; NUMBER is the GH milestone "
|
|
115
|
+
"number. Provide at least one of --title/--description/--due-on. "
|
|
116
|
+
"Routes through the backend-agnostic tracker seam."
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
@click.argument("repo")
|
|
120
|
+
@click.argument("number", type=int)
|
|
121
|
+
@click.option("--title", default=None, help="New milestone title.")
|
|
122
|
+
@click.option("--description", default=None, help="New milestone description (markdown).")
|
|
123
|
+
@click.option(
|
|
124
|
+
"--due-on",
|
|
125
|
+
default=None,
|
|
126
|
+
help="New due date, ISO 8601 (e.g. 2026-08-01T00:00:00Z).",
|
|
127
|
+
)
|
|
128
|
+
@_CONFIG_OPTION
|
|
129
|
+
def milestone_edit_cmd(
|
|
130
|
+
repo: str,
|
|
131
|
+
number: int,
|
|
132
|
+
title: str | None,
|
|
133
|
+
description: str | None,
|
|
134
|
+
due_on: str | None,
|
|
135
|
+
config_path: Path,
|
|
136
|
+
) -> None:
|
|
137
|
+
cfg = _load_config(config_path)
|
|
138
|
+
repo_entry = cfg.repo(repo)
|
|
139
|
+
if repo_entry is None:
|
|
140
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
141
|
+
sys.exit(2)
|
|
142
|
+
if title is None and description is None and due_on is None:
|
|
143
|
+
click.echo(
|
|
144
|
+
"error: provide --title and/or --description and/or --due-on",
|
|
145
|
+
err=True,
|
|
146
|
+
)
|
|
147
|
+
sys.exit(2)
|
|
148
|
+
|
|
149
|
+
from coord.state import write_milestone
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
write_milestone(
|
|
153
|
+
repo,
|
|
154
|
+
number=number,
|
|
155
|
+
title=title,
|
|
156
|
+
description=description,
|
|
157
|
+
due_on=due_on,
|
|
158
|
+
repo_github=repo_entry.github,
|
|
159
|
+
)
|
|
160
|
+
except Exception as e: # noqa: BLE001
|
|
161
|
+
click.echo(f"error: milestone edit failed: {e}", err=True)
|
|
162
|
+
sys.exit(1)
|
|
163
|
+
click.echo(f"milestone #{number} updated ({repo_entry.github})")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@milestone_group.command(
|
|
167
|
+
"assign",
|
|
168
|
+
help=(
|
|
169
|
+
"Assign an existing issue to a milestone. REPO is the local repo name "
|
|
170
|
+
"from coordinator.yml; ISSUE is the GH issue number; MILESTONE is the "
|
|
171
|
+
"milestone number (e.g. `5`) or title (e.g. `v1.0`). Titles are "
|
|
172
|
+
"resolved to their number by fetching open milestones from GitHub.\n\n"
|
|
173
|
+
"Routes through the daemon seam (``POST /issue-milestone``) so the "
|
|
174
|
+
"write is always traceable and the local issues cache "
|
|
175
|
+
"``milestone_number``/``milestone_title`` columns are updated "
|
|
176
|
+
"immediately (no need to wait for ``coord sync``)."
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
@click.argument("repo")
|
|
180
|
+
@click.argument("issue", type=int)
|
|
181
|
+
@click.argument("milestone")
|
|
182
|
+
@_CONFIG_OPTION
|
|
183
|
+
def milestone_assign_cmd(
|
|
184
|
+
repo: str,
|
|
185
|
+
issue: int,
|
|
186
|
+
milestone: str,
|
|
187
|
+
config_path: Path,
|
|
188
|
+
) -> None:
|
|
189
|
+
cfg = _load_config(config_path)
|
|
190
|
+
repo_entry = cfg.repo(repo)
|
|
191
|
+
if repo_entry is None:
|
|
192
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
193
|
+
sys.exit(2)
|
|
194
|
+
|
|
195
|
+
from coord import github_ops # noqa: PLC0415
|
|
196
|
+
from coord.state import assign_issue_milestone # noqa: PLC0415
|
|
197
|
+
|
|
198
|
+
# Resolve milestone argument: numeric → number, string → title lookup.
|
|
199
|
+
milestone_number: int
|
|
200
|
+
milestone_title: str | None
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
milestone_number = int(milestone)
|
|
204
|
+
# Resolve the title from the number so the cache can store it.
|
|
205
|
+
try:
|
|
206
|
+
ms_data = github_ops.get_milestone(repo_entry.github, milestone_number)
|
|
207
|
+
milestone_title = ms_data.get("title")
|
|
208
|
+
except RuntimeError as e:
|
|
209
|
+
click.echo(
|
|
210
|
+
f"error: could not fetch milestone #{milestone_number}: {e}", err=True
|
|
211
|
+
)
|
|
212
|
+
sys.exit(1)
|
|
213
|
+
except ValueError:
|
|
214
|
+
# Treat the argument as a title and resolve to a number.
|
|
215
|
+
try:
|
|
216
|
+
all_ms = github_ops.get_repo_milestones(repo_entry.github)
|
|
217
|
+
except RuntimeError as e:
|
|
218
|
+
click.echo(f"error: could not list milestones: {e}", err=True)
|
|
219
|
+
sys.exit(1)
|
|
220
|
+
matches = [m for m in all_ms if m.get("title") == milestone]
|
|
221
|
+
if not matches:
|
|
222
|
+
click.echo(
|
|
223
|
+
f"error: no open milestone with title {milestone!r} in {repo_entry.github}",
|
|
224
|
+
err=True,
|
|
225
|
+
)
|
|
226
|
+
sys.exit(1)
|
|
227
|
+
if len(matches) > 1:
|
|
228
|
+
click.echo(
|
|
229
|
+
f"error: multiple open milestones match {milestone!r} — "
|
|
230
|
+
"use the milestone number instead",
|
|
231
|
+
err=True,
|
|
232
|
+
)
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
milestone_number = matches[0]["number"]
|
|
235
|
+
milestone_title = matches[0].get("title")
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
assign_issue_milestone(
|
|
239
|
+
repo,
|
|
240
|
+
issue,
|
|
241
|
+
milestone_number,
|
|
242
|
+
milestone_title=milestone_title,
|
|
243
|
+
repo_github=repo_entry.github,
|
|
244
|
+
)
|
|
245
|
+
except Exception as e: # noqa: BLE001
|
|
246
|
+
click.echo(f"error: milestone assign failed: {e}", err=True)
|
|
247
|
+
sys.exit(1)
|
|
248
|
+
|
|
249
|
+
ms_label = (
|
|
250
|
+
f"{milestone_title!r} (#{milestone_number})"
|
|
251
|
+
if milestone_title
|
|
252
|
+
else f"#{milestone_number}"
|
|
253
|
+
)
|
|
254
|
+
click.echo(f"#{issue} ({repo_entry.github}) assigned to milestone {ms_label}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@milestone_group.command(
|
|
258
|
+
"remove",
|
|
259
|
+
help=(
|
|
260
|
+
"Unassign an issue from its milestone (#1003) — the counterpart to "
|
|
261
|
+
"`coord milestone assign`. REPO is the local repo name from "
|
|
262
|
+
"coordinator.yml; ISSUE is the GH issue number. Idempotent — "
|
|
263
|
+
"clearing an issue that has no milestone is a no-op. Routes through "
|
|
264
|
+
"the daemon seam (``POST /issue-milestone-remove``) so the local "
|
|
265
|
+
"issues cache ``milestone_number``/``milestone_title`` columns are "
|
|
266
|
+
"cleared immediately (no need to wait for ``coord sync``)."
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
@click.argument("repo")
|
|
270
|
+
@click.argument("issue", type=int)
|
|
271
|
+
@_CONFIG_OPTION
|
|
272
|
+
def milestone_remove_cmd(
|
|
273
|
+
repo: str,
|
|
274
|
+
issue: int,
|
|
275
|
+
config_path: Path,
|
|
276
|
+
) -> None:
|
|
277
|
+
cfg = _load_config(config_path)
|
|
278
|
+
repo_entry = cfg.repo(repo)
|
|
279
|
+
if repo_entry is None:
|
|
280
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
281
|
+
sys.exit(2)
|
|
282
|
+
|
|
283
|
+
from coord.state import unassign_issue_milestone # noqa: PLC0415
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
unassign_issue_milestone(repo, issue, repo_github=repo_entry.github)
|
|
287
|
+
except Exception as e: # noqa: BLE001
|
|
288
|
+
click.echo(f"error: milestone remove failed: {e}", err=True)
|
|
289
|
+
sys.exit(1)
|
|
290
|
+
|
|
291
|
+
click.echo(f"#{issue} ({repo_entry.github}) removed from its milestone")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@milestone_group.command(
|
|
295
|
+
"add-child",
|
|
296
|
+
help=(
|
|
297
|
+
"Idempotent append/remove on an epic tracking issue's `## "
|
|
298
|
+
"Sub-issues` checklist (#1008) — same splice-not-duplicate spirit "
|
|
299
|
+
"as `coord milestone write-order` for `## Work order`, keyed on a "
|
|
300
|
+
"different heading. REPO is the local repo name from "
|
|
301
|
+
"coordinator.yml; EPIC is the GH issue number of the epic tracking "
|
|
302
|
+
"issue; ISSUE is the child issue number to add (or remove, with "
|
|
303
|
+
"--remove).\n\n"
|
|
304
|
+
"Adding an ISSUE already present with identical --group/--after "
|
|
305
|
+
"is a no-op; adding it again with different annotations updates "
|
|
306
|
+
"that line in place (preserving its `[x]` checked state). "
|
|
307
|
+
"--remove drops ISSUE from the checklist (no-op if absent) and "
|
|
308
|
+
"cannot be combined with --group/--after. The resulting checklist "
|
|
309
|
+
"is re-parsed and validated (no duplicate/undeclared-`after`/cycle) "
|
|
310
|
+
"before writing via `github_ops.update_issue_body` — the epic's "
|
|
311
|
+
"`## Work order` section (if any) is left untouched."
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
@click.argument("repo")
|
|
315
|
+
@click.argument("epic", type=int)
|
|
316
|
+
@click.argument("issue", type=int)
|
|
317
|
+
@click.option(
|
|
318
|
+
"--group", default=None, help="Optional `{group: G}` annotation for ISSUE."
|
|
319
|
+
)
|
|
320
|
+
@click.option(
|
|
321
|
+
"--after", "after_raw", default=None,
|
|
322
|
+
help="Optional `{after: N,...}` annotation — comma-separated issue numbers.",
|
|
323
|
+
)
|
|
324
|
+
@click.option(
|
|
325
|
+
"--remove", is_flag=True,
|
|
326
|
+
help="Remove ISSUE from the epic's `## Sub-issues` checklist instead of adding it.",
|
|
327
|
+
)
|
|
328
|
+
@_CONFIG_OPTION
|
|
329
|
+
def milestone_add_child_cmd(
|
|
330
|
+
repo: str,
|
|
331
|
+
epic: int,
|
|
332
|
+
issue: int,
|
|
333
|
+
group: str | None,
|
|
334
|
+
after_raw: str | None,
|
|
335
|
+
remove: bool,
|
|
336
|
+
config_path: Path,
|
|
337
|
+
) -> None:
|
|
338
|
+
cfg = _load_config(config_path)
|
|
339
|
+
repo_entry = cfg.repo(repo)
|
|
340
|
+
if repo_entry is None:
|
|
341
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
342
|
+
sys.exit(2)
|
|
343
|
+
|
|
344
|
+
if remove and (group is not None or after_raw is not None):
|
|
345
|
+
click.echo("error: --remove cannot be combined with --group/--after", err=True)
|
|
346
|
+
sys.exit(2)
|
|
347
|
+
|
|
348
|
+
after: tuple[int, ...] = ()
|
|
349
|
+
if after_raw:
|
|
350
|
+
try:
|
|
351
|
+
after = tuple(
|
|
352
|
+
int(chunk.strip().lstrip("#"))
|
|
353
|
+
for chunk in after_raw.split(",")
|
|
354
|
+
if chunk.strip()
|
|
355
|
+
)
|
|
356
|
+
except ValueError:
|
|
357
|
+
click.echo(
|
|
358
|
+
f"error: --after must be a comma-separated list of issue "
|
|
359
|
+
f"numbers, got {after_raw!r}",
|
|
360
|
+
err=True,
|
|
361
|
+
)
|
|
362
|
+
sys.exit(2)
|
|
363
|
+
|
|
364
|
+
from coord import github_ops # noqa: PLC0415
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
epic_data = github_ops.get_issue(repo_entry.github, epic)
|
|
368
|
+
except RuntimeError as e:
|
|
369
|
+
click.echo(f"error: could not fetch epic #{epic}: {e}", err=True)
|
|
370
|
+
sys.exit(1)
|
|
371
|
+
|
|
372
|
+
if not remove:
|
|
373
|
+
# Catch a typo'd child issue number before it's baked into the
|
|
374
|
+
# epic's body — mirrors write-order's pre-write validation rigor.
|
|
375
|
+
try:
|
|
376
|
+
github_ops.get_issue(repo_entry.github, issue)
|
|
377
|
+
except RuntimeError as e:
|
|
378
|
+
click.echo(f"error: could not fetch issue #{issue}: {e}", err=True)
|
|
379
|
+
sys.exit(1)
|
|
380
|
+
|
|
381
|
+
old_body = epic_data.get("body") or ""
|
|
382
|
+
try:
|
|
383
|
+
current = parse_sub_issues(old_body)
|
|
384
|
+
except WorkOrderError as e:
|
|
385
|
+
click.echo(f"error: existing `## Sub-issues` block is invalid: {e}", err=True)
|
|
386
|
+
sys.exit(1)
|
|
387
|
+
|
|
388
|
+
nodes = list(current.nodes)
|
|
389
|
+
existing_idx = next(
|
|
390
|
+
(i for i, n in enumerate(nodes) if n.issue_number == issue), None
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
if remove:
|
|
394
|
+
if existing_idx is None:
|
|
395
|
+
click.echo(
|
|
396
|
+
f"#{epic} ({repo_entry.github}): #{issue} is not in the "
|
|
397
|
+
"`## Sub-issues` checklist (no-op)"
|
|
398
|
+
)
|
|
399
|
+
return
|
|
400
|
+
nodes.pop(existing_idx)
|
|
401
|
+
else:
|
|
402
|
+
prior_checked = (
|
|
403
|
+
nodes[existing_idx].checked if existing_idx is not None else False
|
|
404
|
+
)
|
|
405
|
+
candidate_node = WorkOrderNode(
|
|
406
|
+
issue_number=issue, group=group, after=after, checked=prior_checked
|
|
407
|
+
)
|
|
408
|
+
if existing_idx is not None:
|
|
409
|
+
if nodes[existing_idx] == candidate_node:
|
|
410
|
+
click.echo(
|
|
411
|
+
f"#{epic} ({repo_entry.github}): #{issue} already in the "
|
|
412
|
+
"`## Sub-issues` checklist (no-op)"
|
|
413
|
+
)
|
|
414
|
+
return
|
|
415
|
+
nodes[existing_idx] = candidate_node
|
|
416
|
+
else:
|
|
417
|
+
nodes.append(candidate_node)
|
|
418
|
+
|
|
419
|
+
new_block = render_sub_issues(WorkOrder(nodes=tuple(nodes)))
|
|
420
|
+
candidate_body = replace_sub_issues_section(old_body, new_block)
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
parse_sub_issues(candidate_body)
|
|
424
|
+
except WorkOrderError as e:
|
|
425
|
+
click.echo(
|
|
426
|
+
f"error: resulting `## Sub-issues` block would be invalid: {e}", err=True
|
|
427
|
+
)
|
|
428
|
+
sys.exit(1)
|
|
429
|
+
|
|
430
|
+
if candidate_body == old_body:
|
|
431
|
+
click.echo(
|
|
432
|
+
f"#{epic} ({repo_entry.github}): `## Sub-issues` unchanged "
|
|
433
|
+
"(idempotent no-op)"
|
|
434
|
+
)
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
github_ops.update_issue_body(repo_entry.github, epic, candidate_body)
|
|
438
|
+
action = "removed from" if remove else "added to"
|
|
439
|
+
click.echo(
|
|
440
|
+
f"#{issue} {action} #{epic}'s ({repo_entry.github}) `## Sub-issues` checklist"
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@milestone_group.command(
|
|
445
|
+
"sync",
|
|
446
|
+
help=(
|
|
447
|
+
"#1061 (EP-2): backfill the live GitHub sub-issues API from an "
|
|
448
|
+
"epic's `## Work order` and/or `## Sub-issues` blocks, and retire "
|
|
449
|
+
"the old checkbox-based grammar on that body. REPO is the local "
|
|
450
|
+
"repo name from coordinator.yml; EPIC is the GH issue number of "
|
|
451
|
+
"the epic tracking issue.\n\n"
|
|
452
|
+
"`## Work order` and `## Sub-issues` are independent, "
|
|
453
|
+
"not-necessarily-overlapping sources for the same parent->children "
|
|
454
|
+
"relationship (a standalone `add-child` epic may have only a `## "
|
|
455
|
+
"Sub-issues` checklist) — sync unions the two before acting, so a "
|
|
456
|
+
"child listed in either section is never silently dropped. For "
|
|
457
|
+
"every issue in that union, links it as a live sub-issue of EPIC "
|
|
458
|
+
"(skipping any already linked — idempotent, safe to re-run). Then "
|
|
459
|
+
"rewrites `## Work order` to the checkbox-free grammar (`- #N "
|
|
460
|
+
"{group: ..., after: ...}`, no `[ ]`/`[x]` — the box was parsed and "
|
|
461
|
+
"rendered but never read for readiness), folding in any `## "
|
|
462
|
+
"Sub-issues`-only children, and removes the `## Sub-issues` block "
|
|
463
|
+
"entirely — membership is now owned by the live API plus `## Work "
|
|
464
|
+
"order`, so the separate checklist (#1008) is pure duplication. A "
|
|
465
|
+
"body already in the target shape with nothing left to link is "
|
|
466
|
+
"reported unchanged, not rewritten."
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
@click.argument("repo")
|
|
470
|
+
@click.argument("epic", type=int)
|
|
471
|
+
@click.option(
|
|
472
|
+
"--dry-run", is_flag=True,
|
|
473
|
+
help="Show what would be linked/rewritten without calling GitHub.",
|
|
474
|
+
)
|
|
475
|
+
@_CONFIG_OPTION
|
|
476
|
+
def milestone_sync_cmd(repo: str, epic: int, dry_run: bool, config_path: Path) -> None:
|
|
477
|
+
cfg = _load_config(config_path)
|
|
478
|
+
repo_entry = cfg.repo(repo)
|
|
479
|
+
if repo_entry is None:
|
|
480
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
481
|
+
sys.exit(2)
|
|
482
|
+
|
|
483
|
+
from coord import github_ops
|
|
484
|
+
from coord.parentage_github import GitHubParentage
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
epic_data = github_ops.get_issue(repo_entry.github, epic)
|
|
488
|
+
except RuntimeError as e:
|
|
489
|
+
click.echo(f"error: could not fetch epic #{epic}: {e}", err=True)
|
|
490
|
+
sys.exit(1)
|
|
491
|
+
|
|
492
|
+
old_body = epic_data.get("body") or ""
|
|
493
|
+
try:
|
|
494
|
+
work_order = parse_work_order(old_body)
|
|
495
|
+
except WorkOrderError as e:
|
|
496
|
+
click.echo(f"error: #{epic}'s `## Work order` block is invalid: {e}", err=True)
|
|
497
|
+
sys.exit(1)
|
|
498
|
+
try:
|
|
499
|
+
sub_issues = parse_sub_issues(old_body)
|
|
500
|
+
except WorkOrderError as e:
|
|
501
|
+
click.echo(f"error: #{epic}'s `## Sub-issues` block is invalid: {e}", err=True)
|
|
502
|
+
sys.exit(1)
|
|
503
|
+
|
|
504
|
+
if not work_order.nodes and not sub_issues.nodes:
|
|
505
|
+
click.echo(
|
|
506
|
+
f"#{epic} ({repo_entry.github}): no `## Work order` or `## "
|
|
507
|
+
"Sub-issues` block found — nothing to sync"
|
|
508
|
+
)
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
# #1061 fix-iteration 1: `## Work order` and `## Sub-issues` are
|
|
512
|
+
# independent, not-necessarily-overlapping sources for the same
|
|
513
|
+
# parent->children relationship (mirrors the `fallback_to_work_order`
|
|
514
|
+
# precedent added to `coord/parentage.py` by #1197) — a standalone
|
|
515
|
+
# `add-child` epic can have only a `## Sub-issues` checklist, and either
|
|
516
|
+
# section can list a child the other doesn't. Union them before deciding
|
|
517
|
+
# what to link/render so neither case silently drops a child; a `##
|
|
518
|
+
# Sub-issues`-only node is folded into the rewritten `## Work order`
|
|
519
|
+
# (preferred per the issue's resolution: `## Work order` + the live API
|
|
520
|
+
# own membership going forward) rather than merely linked and discarded.
|
|
521
|
+
extra_from_sub_issues = [
|
|
522
|
+
n for n in sub_issues.nodes
|
|
523
|
+
if n.issue_number not in work_order.issue_numbers
|
|
524
|
+
]
|
|
525
|
+
if extra_from_sub_issues:
|
|
526
|
+
merged_block = render_work_order(
|
|
527
|
+
WorkOrder(nodes=tuple(work_order.nodes) + tuple(extra_from_sub_issues)),
|
|
528
|
+
checkbox=False,
|
|
529
|
+
)
|
|
530
|
+
try:
|
|
531
|
+
work_order = parse_work_order(f"## Work order\n{merged_block}")
|
|
532
|
+
except WorkOrderError as e:
|
|
533
|
+
click.echo(
|
|
534
|
+
f"error: #{epic}'s `## Work order` and `## Sub-issues` "
|
|
535
|
+
f"blocks conflict when merged: {e}",
|
|
536
|
+
err=True,
|
|
537
|
+
)
|
|
538
|
+
sys.exit(1)
|
|
539
|
+
|
|
540
|
+
parentage = GitHubParentage()
|
|
541
|
+
try:
|
|
542
|
+
already_linked = {c.number for c in parentage.children(repo_entry.github, epic)}
|
|
543
|
+
except RuntimeError as e:
|
|
544
|
+
click.echo(
|
|
545
|
+
f"error: could not fetch #{epic}'s live sub-issues: {e}", err=True
|
|
546
|
+
)
|
|
547
|
+
sys.exit(1)
|
|
548
|
+
|
|
549
|
+
to_link = [n.issue_number for n in work_order.nodes if n.issue_number not in already_linked]
|
|
550
|
+
|
|
551
|
+
new_block = render_work_order(work_order, checkbox=False)
|
|
552
|
+
candidate_body = replace_work_order_section(old_body, new_block)
|
|
553
|
+
stripped_body = remove_sub_issues_section(candidate_body)
|
|
554
|
+
had_sub_issues = stripped_body != candidate_body
|
|
555
|
+
candidate_body = stripped_body
|
|
556
|
+
body_changed = candidate_body != old_body
|
|
557
|
+
|
|
558
|
+
node_source = (
|
|
559
|
+
"`## Work order` + `## Sub-issues`" if extra_from_sub_issues else "`## Work order`"
|
|
560
|
+
)
|
|
561
|
+
click.echo(
|
|
562
|
+
f"#{epic} ({repo_entry.github}): {len(work_order.nodes)} node(s) in "
|
|
563
|
+
f"{node_source}"
|
|
564
|
+
)
|
|
565
|
+
if to_link:
|
|
566
|
+
verb = "would link" if dry_run else "linking"
|
|
567
|
+
click.echo(f" {verb} as live sub-issues: " + ", ".join(f"#{n}" for n in to_link))
|
|
568
|
+
else:
|
|
569
|
+
click.echo(" live sub-issues API already up to date (no-op)")
|
|
570
|
+
|
|
571
|
+
if body_changed:
|
|
572
|
+
bits = ["checkbox-free `## Work order`"]
|
|
573
|
+
if extra_from_sub_issues:
|
|
574
|
+
bits.append(
|
|
575
|
+
"folded "
|
|
576
|
+
+ ", ".join(f"#{n.issue_number}" for n in extra_from_sub_issues)
|
|
577
|
+
+ " in from `## Sub-issues`"
|
|
578
|
+
)
|
|
579
|
+
if had_sub_issues:
|
|
580
|
+
bits.append("removed `## Sub-issues`")
|
|
581
|
+
verb = "would rewrite" if dry_run else "rewriting"
|
|
582
|
+
click.echo(f" {verb} body ({', '.join(bits)})")
|
|
583
|
+
else:
|
|
584
|
+
click.echo(" body already in the target shape (no-op)")
|
|
585
|
+
|
|
586
|
+
if dry_run:
|
|
587
|
+
click.echo("(dry run — nothing changed)")
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
failed: list[tuple[int, str]] = []
|
|
591
|
+
for issue_number in to_link:
|
|
592
|
+
try:
|
|
593
|
+
parentage.add_child(repo_entry.github, epic, issue_number)
|
|
594
|
+
except RuntimeError as e:
|
|
595
|
+
failed.append((issue_number, str(e)))
|
|
596
|
+
|
|
597
|
+
for issue_number, err in failed:
|
|
598
|
+
click.echo(
|
|
599
|
+
f" error: could not link #{issue_number} as a sub-issue of #{epic}: {err}",
|
|
600
|
+
err=True,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
if body_changed:
|
|
604
|
+
github_ops.update_issue_body(repo_entry.github, epic, candidate_body)
|
|
605
|
+
|
|
606
|
+
if failed:
|
|
607
|
+
sys.exit(1)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@milestone_group.command(
|
|
611
|
+
"sync-progress",
|
|
612
|
+
help=(
|
|
613
|
+
"#1412 deliverable 2: render live per-item status into a separate "
|
|
614
|
+
"`## Progress` section of an epic's tracking-issue body — `## Work "
|
|
615
|
+
"order` (and, transitionally, `## Sub-issues`) are never touched. "
|
|
616
|
+
"REPO is the local repo name from coordinator.yml; TRACKING_ISSUE "
|
|
617
|
+
"is the GH issue number of the tracking issue.\n\n"
|
|
618
|
+
"Derives each node's status from exactly the same live inputs "
|
|
619
|
+
"`coord milestone order` already prints (terminal state + the "
|
|
620
|
+
"current ready frontier) — never a second notion of readiness. "
|
|
621
|
+
"Idempotent: a fresh run whose computed statuses match what's "
|
|
622
|
+
"already in `## Progress` is a no-op (not even the timestamp is "
|
|
623
|
+
"touched), so this is safe to run on a timer — the daemon tick "
|
|
624
|
+
"does exactly that for every milestone registered via a "
|
|
625
|
+
"non-dry-run `coord milestone dispatch`, regardless of whether "
|
|
626
|
+
"`milestone.auto_dispatch` is enabled (this command dispatches "
|
|
627
|
+
"nothing, so there's no reason to gate it on that flag)."
|
|
628
|
+
),
|
|
629
|
+
)
|
|
630
|
+
@click.argument("repo")
|
|
631
|
+
@click.argument("tracking_issue", type=int)
|
|
632
|
+
@click.option(
|
|
633
|
+
"--dry-run", is_flag=True,
|
|
634
|
+
help="Show the computed status without writing anything.",
|
|
635
|
+
)
|
|
636
|
+
@_CONFIG_OPTION
|
|
637
|
+
def milestone_sync_progress_cmd(
|
|
638
|
+
repo: str, tracking_issue: int, dry_run: bool, config_path: Path
|
|
639
|
+
) -> None:
|
|
640
|
+
cfg = _load_config(config_path)
|
|
641
|
+
repo_entry = cfg.repo(repo)
|
|
642
|
+
if repo_entry is None:
|
|
643
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
644
|
+
sys.exit(2)
|
|
645
|
+
|
|
646
|
+
from coord import board_service, github_ops
|
|
647
|
+
from coord.milestone_order import ready_frontier
|
|
648
|
+
|
|
649
|
+
try:
|
|
650
|
+
ctx = fetch_milestone_context(repo_entry, tracking_issue)
|
|
651
|
+
except MilestoneDispatchError as e:
|
|
652
|
+
click.echo(f"error: {e}", err=True)
|
|
653
|
+
sys.exit(1)
|
|
654
|
+
|
|
655
|
+
if not ctx.work_order.nodes:
|
|
656
|
+
click.echo(
|
|
657
|
+
f"#{tracking_issue}: no `## Work order` block found — nothing to "
|
|
658
|
+
"report progress on"
|
|
659
|
+
)
|
|
660
|
+
return
|
|
661
|
+
|
|
662
|
+
board = board_service.read_board()
|
|
663
|
+
frontier = ready_frontier(
|
|
664
|
+
ctx.work_order,
|
|
665
|
+
board,
|
|
666
|
+
repo_name=repo_entry.name,
|
|
667
|
+
repo_github=repo_entry.github,
|
|
668
|
+
terminal_issues=set(ctx.terminal_issues),
|
|
669
|
+
)
|
|
670
|
+
statuses = compute_progress(ctx.work_order, frontier, ctx.terminal_issues)
|
|
671
|
+
|
|
672
|
+
try:
|
|
673
|
+
issue_data = github_ops.get_issue(repo_entry.github, tracking_issue)
|
|
674
|
+
except RuntimeError as e:
|
|
675
|
+
click.echo(f"error: could not fetch #{tracking_issue}: {e}", err=True)
|
|
676
|
+
sys.exit(1)
|
|
677
|
+
old_body = issue_data.get("body") or ""
|
|
678
|
+
|
|
679
|
+
click.echo(
|
|
680
|
+
f"Progress for #{tracking_issue} (milestone #{ctx.milestone_number}):"
|
|
681
|
+
)
|
|
682
|
+
for s in statuses:
|
|
683
|
+
bits = [f"[{s.status}]", f"#{s.issue_number}"]
|
|
684
|
+
if s.group:
|
|
685
|
+
bits.append(f"(group {s.group})")
|
|
686
|
+
line = " " + " ".join(bits)
|
|
687
|
+
if s.detail:
|
|
688
|
+
line += f" — {s.detail}"
|
|
689
|
+
click.echo(line)
|
|
690
|
+
|
|
691
|
+
if parse_progress(old_body) == statuses:
|
|
692
|
+
click.echo("`## Progress` already up to date (no-op)")
|
|
693
|
+
return
|
|
694
|
+
|
|
695
|
+
if dry_run:
|
|
696
|
+
click.echo("(dry run — would update `## Progress`)")
|
|
697
|
+
return
|
|
698
|
+
|
|
699
|
+
from datetime import datetime, timezone
|
|
700
|
+
|
|
701
|
+
generated_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
702
|
+
new_block = render_progress(statuses, generated_at=generated_at)
|
|
703
|
+
candidate_body = replace_progress_section(old_body, new_block)
|
|
704
|
+
github_ops.update_issue_body(repo_entry.github, tracking_issue, candidate_body)
|
|
705
|
+
click.echo(f"#{tracking_issue}: updated `## Progress` ({len(statuses)} item(s))")
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
_DEFAULT_CAPTURE_BODY = (
|
|
709
|
+
"Captured via coord-tui fast plan capture — no work order yet. "
|
|
710
|
+
"Promote to a full epic with `coord milestone chat`."
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
@milestone_group.command(
|
|
715
|
+
"capture",
|
|
716
|
+
help=(
|
|
717
|
+
"Fast-capture a lightweight plan stub (#977): creates a new milestone "
|
|
718
|
+
"titled TEXT, a plain issue (no `epic` label) under it, and assigns "
|
|
719
|
+
"the issue to the milestone — all through the existing write seams "
|
|
720
|
+
"(`coord milestone create` + `coord issue create` + `coord milestone "
|
|
721
|
+
"assign` composed in one step). REPO is the local repo name from "
|
|
722
|
+
"coordinator.yml. Appears immediately in the `coord plans` / TUI "
|
|
723
|
+
"Plans-panel roster flagged `no_work_order`, ready to be promoted to "
|
|
724
|
+
"a full epic later via `coord milestone chat`."
|
|
725
|
+
),
|
|
726
|
+
)
|
|
727
|
+
@click.argument("repo")
|
|
728
|
+
@click.option(
|
|
729
|
+
"--title",
|
|
730
|
+
required=True,
|
|
731
|
+
help="Plan title (used for both the milestone and the issue).",
|
|
732
|
+
)
|
|
733
|
+
@click.option(
|
|
734
|
+
"--body",
|
|
735
|
+
default=None,
|
|
736
|
+
help="Issue body (markdown). Defaults to a short note pointing at `coord milestone chat`.",
|
|
737
|
+
)
|
|
738
|
+
@_CONFIG_OPTION
|
|
739
|
+
def milestone_capture_cmd(
|
|
740
|
+
repo: str,
|
|
741
|
+
title: str,
|
|
742
|
+
body: str | None,
|
|
743
|
+
config_path: Path,
|
|
744
|
+
) -> None:
|
|
745
|
+
cfg = _load_config(config_path)
|
|
746
|
+
repo_entry = cfg.repo(repo)
|
|
747
|
+
if repo_entry is None:
|
|
748
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
749
|
+
sys.exit(2)
|
|
750
|
+
slug = repo_entry.github
|
|
751
|
+
|
|
752
|
+
from coord.state import assign_issue_milestone, create_issue, write_milestone # noqa: PLC0415
|
|
753
|
+
|
|
754
|
+
try:
|
|
755
|
+
ms = write_milestone(repo, title=title, repo_github=slug)
|
|
756
|
+
except Exception as e: # noqa: BLE001
|
|
757
|
+
click.echo(f"error: plan capture failed creating milestone: {e}", err=True)
|
|
758
|
+
sys.exit(1)
|
|
759
|
+
|
|
760
|
+
try:
|
|
761
|
+
issue = create_issue(repo, title, body or _DEFAULT_CAPTURE_BODY, repo_github=slug)
|
|
762
|
+
except Exception as e: # noqa: BLE001
|
|
763
|
+
click.echo(
|
|
764
|
+
f"error: plan capture failed creating issue (milestone #{ms.get('number')} "
|
|
765
|
+
f"was already created): {e}",
|
|
766
|
+
err=True,
|
|
767
|
+
)
|
|
768
|
+
sys.exit(1)
|
|
769
|
+
|
|
770
|
+
try:
|
|
771
|
+
assign_issue_milestone(
|
|
772
|
+
repo,
|
|
773
|
+
issue["number"],
|
|
774
|
+
ms["number"],
|
|
775
|
+
milestone_title=title,
|
|
776
|
+
repo_github=slug,
|
|
777
|
+
)
|
|
778
|
+
except Exception as e: # noqa: BLE001
|
|
779
|
+
click.echo(
|
|
780
|
+
f"error: plan capture failed assigning issue #{issue.get('number')} to "
|
|
781
|
+
f"milestone #{ms.get('number')}: {e}",
|
|
782
|
+
err=True,
|
|
783
|
+
)
|
|
784
|
+
sys.exit(1)
|
|
785
|
+
|
|
786
|
+
click.echo(
|
|
787
|
+
f"plan captured: milestone #{ms['number']} ({slug}) — issue #{issue['number']} "
|
|
788
|
+
"— no work order yet. Promote via `coord milestone chat`."
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
@milestone_group.command(
|
|
793
|
+
"order",
|
|
794
|
+
help=(
|
|
795
|
+
"Parse the `## Work order` block from a milestone tracking issue and "
|
|
796
|
+
"print the DAG + current ready frontier. REPO is the local repo name "
|
|
797
|
+
"from coordinator.yml; TRACKING_ISSUE is the GH issue number of the "
|
|
798
|
+
"tracking issue (its body holds the `## Work order` block)."
|
|
799
|
+
),
|
|
800
|
+
)
|
|
801
|
+
@click.argument("repo")
|
|
802
|
+
@click.argument("tracking_issue", type=int)
|
|
803
|
+
@_CONFIG_OPTION
|
|
804
|
+
def milestone_order_cmd(repo: str, tracking_issue: int, config_path: Path) -> None:
|
|
805
|
+
cfg = _load_config(config_path)
|
|
806
|
+
repo_entry = cfg.repo(repo)
|
|
807
|
+
if repo_entry is None:
|
|
808
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
809
|
+
sys.exit(2)
|
|
810
|
+
|
|
811
|
+
from coord import board_service
|
|
812
|
+
|
|
813
|
+
try:
|
|
814
|
+
ctx = fetch_milestone_context(repo_entry, tracking_issue)
|
|
815
|
+
except MilestoneDispatchError as e:
|
|
816
|
+
click.echo(f"error: {e}", err=True)
|
|
817
|
+
sys.exit(1)
|
|
818
|
+
|
|
819
|
+
if not ctx.work_order.nodes:
|
|
820
|
+
click.echo(f"#{tracking_issue}: no `## Work order` block found")
|
|
821
|
+
return
|
|
822
|
+
|
|
823
|
+
board = board_service.read_board()
|
|
824
|
+
from coord.milestone_order import ready_frontier
|
|
825
|
+
|
|
826
|
+
frontier = ready_frontier(
|
|
827
|
+
ctx.work_order,
|
|
828
|
+
board,
|
|
829
|
+
repo_name=repo_entry.name,
|
|
830
|
+
repo_github=repo_entry.github,
|
|
831
|
+
terminal_issues=set(ctx.terminal_issues),
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
click.echo(
|
|
835
|
+
f"Work order for #{tracking_issue} (milestone #{ctx.milestone_number}):"
|
|
836
|
+
)
|
|
837
|
+
for node in ctx.work_order.nodes:
|
|
838
|
+
state = "done" if node.issue_number in ctx.terminal_issues else "open"
|
|
839
|
+
bits = [f"[{state}]"]
|
|
840
|
+
if node.group:
|
|
841
|
+
bits.append(f"group:{node.group}")
|
|
842
|
+
if node.after:
|
|
843
|
+
bits.append("after:" + ",".join(f"#{d}" for d in node.after))
|
|
844
|
+
click.echo(f" #{node.issue_number} {' '.join(bits)}")
|
|
845
|
+
|
|
846
|
+
click.echo()
|
|
847
|
+
click.echo("Ready frontier:")
|
|
848
|
+
if frontier.ready:
|
|
849
|
+
for entry in frontier.ready:
|
|
850
|
+
suffix = f" (group {entry.group})" if entry.group else ""
|
|
851
|
+
click.echo(f" #{entry.issue_number}{suffix}")
|
|
852
|
+
else:
|
|
853
|
+
click.echo(" (none)")
|
|
854
|
+
|
|
855
|
+
if frontier.blocked:
|
|
856
|
+
click.echo()
|
|
857
|
+
click.echo("Blocked:")
|
|
858
|
+
for b in frontier.blocked:
|
|
859
|
+
click.echo(f" #{b.issue_number}: {b.reason}")
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _echo_pick(pick: MachinePick) -> None:
|
|
863
|
+
suffix = f" (group {pick.entry.group})" if pick.entry.group else ""
|
|
864
|
+
click.echo(f" #{pick.entry.issue_number} -> {pick.machine.name}{suffix}")
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _echo_outcome(outcome: DispatchOutcome) -> None:
|
|
868
|
+
if outcome.ok:
|
|
869
|
+
click.echo(
|
|
870
|
+
f" #{outcome.issue_number} -> {outcome.machine_name} "
|
|
871
|
+
f"(dispatched, assignment {outcome.assignment_id})"
|
|
872
|
+
)
|
|
873
|
+
if outcome.model_reason:
|
|
874
|
+
click.echo(f" model: {outcome.model_reason}")
|
|
875
|
+
if outcome.provider_reason:
|
|
876
|
+
click.echo(f" provider: {outcome.provider_reason}")
|
|
877
|
+
else:
|
|
878
|
+
click.echo(
|
|
879
|
+
f" #{outcome.issue_number}: dispatch failed: {outcome.error}", err=True
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
@milestone_group.command(
|
|
884
|
+
"dispatch",
|
|
885
|
+
help=(
|
|
886
|
+
"Promote a milestone into the pipeline: dispatch its ready frontier "
|
|
887
|
+
"in parallel (up to idle/capable machines), then keep draining it as "
|
|
888
|
+
"declared-order dependencies complete. REPO is the local repo name "
|
|
889
|
+
"from coordinator.yml; TRACKING_ISSUE is the GH issue number of the "
|
|
890
|
+
"tracking issue (its body holds the `## Work order` block). This is "
|
|
891
|
+
"the single explicit approval for the whole declared work order — "
|
|
892
|
+
"it does not expand scope beyond what `## Work order` lists."
|
|
893
|
+
),
|
|
894
|
+
)
|
|
895
|
+
@click.argument("repo")
|
|
896
|
+
@click.argument("tracking_issue", type=int)
|
|
897
|
+
@click.option(
|
|
898
|
+
"--dry-run", is_flag=True,
|
|
899
|
+
help="Show what would dispatch now vs. what's waiting (and on what), without dispatching.",
|
|
900
|
+
)
|
|
901
|
+
@click.option(
|
|
902
|
+
"--next", "next_", is_flag=True,
|
|
903
|
+
help=(
|
|
904
|
+
"Single-pick mode: show up to 3 ready-frontier items and dispatch "
|
|
905
|
+
"only the one you choose. Lighter-weight than draining the whole "
|
|
906
|
+
"milestone — does not register for daemon auto-drain."
|
|
907
|
+
),
|
|
908
|
+
)
|
|
909
|
+
@click.option(
|
|
910
|
+
"--pick", "pick_issue", type=int, default=None,
|
|
911
|
+
help=(
|
|
912
|
+
"#1003: non-interactive companion to --next — dispatch this "
|
|
913
|
+
"specific ready-frontier issue number without the interactive "
|
|
914
|
+
"prompt (the coord-tui Plans-panel/MilestoneDag \"Dispatch next…\" "
|
|
915
|
+
"action's backend; a bare TTY-less `--next` would otherwise hang "
|
|
916
|
+
"on `click.prompt`). Errors if the issue isn't currently in the "
|
|
917
|
+
"ready-to-dispatch frontier."
|
|
918
|
+
),
|
|
919
|
+
)
|
|
920
|
+
@_CONFIG_OPTION
|
|
921
|
+
def milestone_dispatch_cmd(
|
|
922
|
+
repo: str,
|
|
923
|
+
tracking_issue: int,
|
|
924
|
+
dry_run: bool,
|
|
925
|
+
next_: bool,
|
|
926
|
+
pick_issue: int | None,
|
|
927
|
+
config_path: Path,
|
|
928
|
+
) -> None:
|
|
929
|
+
if pick_issue is not None and not next_:
|
|
930
|
+
click.echo("error: --pick requires --next", err=True)
|
|
931
|
+
sys.exit(2)
|
|
932
|
+
cfg = _load_config(config_path)
|
|
933
|
+
repo_entry = cfg.repo(repo)
|
|
934
|
+
if repo_entry is None:
|
|
935
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
936
|
+
sys.exit(2)
|
|
937
|
+
|
|
938
|
+
from coord import board_service
|
|
939
|
+
|
|
940
|
+
# #1930 (epic #1440, S-2): a milestone under gate control (`coord
|
|
941
|
+
# milestone drive`) is drained EXCLUSIVELY by the daemon's gate tick, as
|
|
942
|
+
# its `work` state — see `coord.milestone_gate`'s module docstring. This
|
|
943
|
+
# manual command is the second dispatch path #1440 exists to close: an
|
|
944
|
+
# operator who runs it against a gate-controlled milestone would launch
|
|
945
|
+
# the same frontier the daemon is about to (or just did) launch, racing
|
|
946
|
+
# it rather than being refused or delegating to it. Checked before the
|
|
947
|
+
# GitHub fetch below (and before Gate A, `--dry-run`, `--next`) so the
|
|
948
|
+
# refusal costs nothing and can never be silently bypassed by a flag.
|
|
949
|
+
from coord import state as state_mod # noqa: PLC0415
|
|
950
|
+
|
|
951
|
+
try:
|
|
952
|
+
existing_gate = state_mod.get_milestone_gate(
|
|
953
|
+
repo_name=repo_entry.name, tracking_issue=tracking_issue
|
|
954
|
+
)
|
|
955
|
+
except Exception as e: # noqa: BLE001 — see coord.client.fetch_milestone_gate:
|
|
956
|
+
# a thin client that can't reach the daemon has no way to know
|
|
957
|
+
# whether this milestone is gate-controlled, so refuse rather than
|
|
958
|
+
# risk racing a gate tick it can't see (#1930, epic #1440) — the
|
|
959
|
+
# same "refuse, never race" posture as the guard below, just for
|
|
960
|
+
# "couldn't determine" instead of "confirmed gated".
|
|
961
|
+
click.echo(
|
|
962
|
+
f"error: could not check #{tracking_issue}'s gate status ({e}) "
|
|
963
|
+
"— refusing to dispatch rather than risk racing a gate tick "
|
|
964
|
+
"this client can't see. Retry once the daemon is reachable, or "
|
|
965
|
+
"run this on the daemon host directly.",
|
|
966
|
+
err=True,
|
|
967
|
+
)
|
|
968
|
+
sys.exit(1)
|
|
969
|
+
if existing_gate is not None:
|
|
970
|
+
from coord import milestone_gate as mg # noqa: PLC0415
|
|
971
|
+
|
|
972
|
+
gate_name = str(existing_gate.get("gate") or "")
|
|
973
|
+
gate_label = (
|
|
974
|
+
mg.GATE_LABELS.get(gate_name, gate_name) if gate_name else "an unknown gate"
|
|
975
|
+
)
|
|
976
|
+
click.echo(
|
|
977
|
+
f"error: #{tracking_issue} is under gate control, currently at "
|
|
978
|
+
f"{gate_label} — the daemon's gate tick is the sole driver of "
|
|
979
|
+
"this milestone's frontier (epic #1440). Running `coord "
|
|
980
|
+
"milestone dispatch` here would race it, not replace it. Use "
|
|
981
|
+
"`coord milestone drive --dry-run` to see the planned sequence, "
|
|
982
|
+
"or wait for the daemon to reach `work`.",
|
|
983
|
+
err=True,
|
|
984
|
+
)
|
|
985
|
+
sys.exit(1)
|
|
986
|
+
|
|
987
|
+
try:
|
|
988
|
+
ctx = fetch_milestone_context(repo_entry, tracking_issue)
|
|
989
|
+
except MilestoneDispatchError as e:
|
|
990
|
+
click.echo(f"error: {e}", err=True)
|
|
991
|
+
sys.exit(1)
|
|
992
|
+
|
|
993
|
+
# Gate A (#930, docs/ORACLE_LOOP.md): refuse to dispatch any of this
|
|
994
|
+
# milestone's issues until its black-box contract exists. Checked before
|
|
995
|
+
# everything else — including `--dry-run`/`--next` — so the gate is
|
|
996
|
+
# never silently bypassable.
|
|
997
|
+
block_reason = gate_a_status(repo_entry, cfg, ctx.milestone_number)
|
|
998
|
+
if block_reason:
|
|
999
|
+
click.echo(f"error: {block_reason}", err=True)
|
|
1000
|
+
sys.exit(1)
|
|
1001
|
+
|
|
1002
|
+
if not ctx.work_order.nodes:
|
|
1003
|
+
click.echo(f"#{tracking_issue}: no `## Work order` block found")
|
|
1004
|
+
return
|
|
1005
|
+
|
|
1006
|
+
board = board_service.read_board()
|
|
1007
|
+
plan = plan_dispatch(ctx.work_order, board, cfg, repo_entry, ctx.terminal_issues)
|
|
1008
|
+
|
|
1009
|
+
click.echo(
|
|
1010
|
+
f"Work order for #{tracking_issue} (milestone #{ctx.milestone_number}):"
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
if next_:
|
|
1014
|
+
if not plan.to_dispatch:
|
|
1015
|
+
click.echo("No ready frontier item can dispatch right now.")
|
|
1016
|
+
if plan.skipped:
|
|
1017
|
+
click.echo("Ready but no idle machine:")
|
|
1018
|
+
for s in plan.skipped:
|
|
1019
|
+
click.echo(f" #{s.entry.issue_number}: {s.reason}")
|
|
1020
|
+
if plan.waiting:
|
|
1021
|
+
click.echo("Waiting:")
|
|
1022
|
+
for b in plan.waiting:
|
|
1023
|
+
click.echo(f" #{b.issue_number}: {b.reason}")
|
|
1024
|
+
return
|
|
1025
|
+
|
|
1026
|
+
# #1003: --pick short-circuits the interactive prompt entirely — the
|
|
1027
|
+
# operator (or, via coord-tui, a client that already knows the
|
|
1028
|
+
# issue number it wants) names the issue directly instead of
|
|
1029
|
+
# picking an index off a numbered list. Search the FULL
|
|
1030
|
+
# to-dispatch set, not just the top-3 `choices` shown to a human —
|
|
1031
|
+
# a non-interactive caller has no reason to be limited to the
|
|
1032
|
+
# truncated display.
|
|
1033
|
+
if pick_issue is not None:
|
|
1034
|
+
chosen = next(
|
|
1035
|
+
(p for p in plan.to_dispatch if p.entry.issue_number == pick_issue),
|
|
1036
|
+
None,
|
|
1037
|
+
)
|
|
1038
|
+
if chosen is None:
|
|
1039
|
+
click.echo(
|
|
1040
|
+
f"error: #{pick_issue} is not in the ready-to-dispatch "
|
|
1041
|
+
"frontier right now",
|
|
1042
|
+
err=True,
|
|
1043
|
+
)
|
|
1044
|
+
sys.exit(1)
|
|
1045
|
+
if dry_run:
|
|
1046
|
+
click.echo(
|
|
1047
|
+
f"(dry run — would dispatch #{pick_issue} -> {chosen.machine.name})"
|
|
1048
|
+
)
|
|
1049
|
+
return
|
|
1050
|
+
outcome = dispatch_entry(
|
|
1051
|
+
chosen, repo_entry, cfg, board, tracking_issue=tracking_issue
|
|
1052
|
+
)
|
|
1053
|
+
_echo_outcome(outcome)
|
|
1054
|
+
if not outcome.ok:
|
|
1055
|
+
sys.exit(1)
|
|
1056
|
+
return
|
|
1057
|
+
|
|
1058
|
+
choices = plan.to_dispatch[:3]
|
|
1059
|
+
click.echo("Ready frontier — pick one to dispatch:")
|
|
1060
|
+
for i, pick in enumerate(choices, 1):
|
|
1061
|
+
suffix = f" (group {pick.entry.group})" if pick.entry.group else ""
|
|
1062
|
+
click.echo(f" {i}. #{pick.entry.issue_number} -> {pick.machine.name}{suffix}")
|
|
1063
|
+
|
|
1064
|
+
if dry_run:
|
|
1065
|
+
click.echo("(dry run — not dispatched)")
|
|
1066
|
+
return
|
|
1067
|
+
|
|
1068
|
+
idx = click.prompt(
|
|
1069
|
+
"Pick one", type=click.IntRange(1, len(choices)), default=1
|
|
1070
|
+
)
|
|
1071
|
+
chosen = choices[idx - 1]
|
|
1072
|
+
outcome = dispatch_entry(chosen, repo_entry, cfg, board, tracking_issue=tracking_issue)
|
|
1073
|
+
_echo_outcome(outcome)
|
|
1074
|
+
if not outcome.ok:
|
|
1075
|
+
sys.exit(1)
|
|
1076
|
+
return
|
|
1077
|
+
|
|
1078
|
+
# Bulk mode: dispatch the entire current ready frontier.
|
|
1079
|
+
click.echo("Will dispatch now:")
|
|
1080
|
+
if plan.to_dispatch:
|
|
1081
|
+
for pick in plan.to_dispatch:
|
|
1082
|
+
_echo_pick(pick)
|
|
1083
|
+
else:
|
|
1084
|
+
click.echo(" (none)")
|
|
1085
|
+
|
|
1086
|
+
if plan.skipped:
|
|
1087
|
+
click.echo()
|
|
1088
|
+
click.echo("Ready but no idle machine:")
|
|
1089
|
+
for s in plan.skipped:
|
|
1090
|
+
click.echo(f" #{s.entry.issue_number}: {s.reason}")
|
|
1091
|
+
|
|
1092
|
+
if plan.waiting:
|
|
1093
|
+
click.echo()
|
|
1094
|
+
click.echo("Waiting:")
|
|
1095
|
+
for b in plan.waiting:
|
|
1096
|
+
click.echo(f" #{b.issue_number}: {b.reason}")
|
|
1097
|
+
|
|
1098
|
+
if dry_run:
|
|
1099
|
+
click.echo()
|
|
1100
|
+
click.echo("(dry run — not dispatched)")
|
|
1101
|
+
return
|
|
1102
|
+
|
|
1103
|
+
if not plan.to_dispatch:
|
|
1104
|
+
click.echo()
|
|
1105
|
+
click.echo("Nothing to dispatch right now.")
|
|
1106
|
+
return
|
|
1107
|
+
|
|
1108
|
+
click.echo()
|
|
1109
|
+
failures = 0
|
|
1110
|
+
for pick in plan.to_dispatch:
|
|
1111
|
+
outcome = dispatch_entry(pick, repo_entry, cfg, board, tracking_issue=tracking_issue)
|
|
1112
|
+
_echo_outcome(outcome)
|
|
1113
|
+
if not outcome.ok:
|
|
1114
|
+
failures += 1
|
|
1115
|
+
|
|
1116
|
+
if not is_milestone_complete(ctx):
|
|
1117
|
+
from coord.state import register_milestone_drain
|
|
1118
|
+
|
|
1119
|
+
register_milestone_drain(repo_name=repo_entry.name, tracking_issue=tracking_issue)
|
|
1120
|
+
click.echo()
|
|
1121
|
+
click.echo(
|
|
1122
|
+
f"Milestone #{tracking_issue} registered for daemon auto-drain "
|
|
1123
|
+
"(requires `milestone.auto_dispatch: true` in coordinator.yml + a "
|
|
1124
|
+
"daemon restart to activate; `coord milestone dispatch` still "
|
|
1125
|
+
"works as a manual re-drain either way)."
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
if failures:
|
|
1129
|
+
sys.exit(1)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
@milestone_group.command(
|
|
1133
|
+
"drive",
|
|
1134
|
+
help=(
|
|
1135
|
+
"Put a milestone under gate control (#1929, epic #1440): the daemon "
|
|
1136
|
+
"walks it through Gate A (contract) -> work -> Gate B (architecture) "
|
|
1137
|
+
"-> Gate C (acceptance) -> Gate D (ship), one step per tick, from a "
|
|
1138
|
+
"durable board-backed gate record. The `work` state IS the frontier "
|
|
1139
|
+
"drain, so a gate-driven milestone is NOT also drained by the "
|
|
1140
|
+
"independently-gated `milestone.auto_dispatch` path. Every gate edge "
|
|
1141
|
+
"other than the A->work and work->B ones is currently an explicit, "
|
|
1142
|
+
"logged HOLD (the individual edge behaviours are epic #1440's other "
|
|
1143
|
+
"children) — never a silent fall-through. Use --dry-run to print the "
|
|
1144
|
+
"full planned sequence without dispatching or writing anything."
|
|
1145
|
+
),
|
|
1146
|
+
)
|
|
1147
|
+
@click.argument("repo")
|
|
1148
|
+
@click.argument("tracking_issue", type=int)
|
|
1149
|
+
@click.option(
|
|
1150
|
+
"--dry-run", is_flag=True,
|
|
1151
|
+
help=(
|
|
1152
|
+
"Print the full planned gate sequence + the work order's ready "
|
|
1153
|
+
"frontier and exit. Dispatches nothing and writes no gate record."
|
|
1154
|
+
),
|
|
1155
|
+
)
|
|
1156
|
+
@_CONFIG_OPTION
|
|
1157
|
+
def milestone_drive_cmd(
|
|
1158
|
+
repo: str, tracking_issue: int, dry_run: bool, config_path: Path
|
|
1159
|
+
) -> None:
|
|
1160
|
+
import dataclasses
|
|
1161
|
+
import time as _time
|
|
1162
|
+
|
|
1163
|
+
from coord import board_service, state
|
|
1164
|
+
from coord import milestone_gate as mg
|
|
1165
|
+
|
|
1166
|
+
cfg = _load_config(config_path)
|
|
1167
|
+
repo_entry = cfg.repo(repo)
|
|
1168
|
+
if repo_entry is None:
|
|
1169
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
1170
|
+
sys.exit(2)
|
|
1171
|
+
|
|
1172
|
+
try:
|
|
1173
|
+
ctx = fetch_milestone_context(repo_entry, tracking_issue)
|
|
1174
|
+
except MilestoneDispatchError as e:
|
|
1175
|
+
click.echo(f"error: {e}", err=True)
|
|
1176
|
+
sys.exit(1)
|
|
1177
|
+
|
|
1178
|
+
# Resume, don't restart: an existing record is the source of truth for
|
|
1179
|
+
# which gate this milestone is in. `drive` on an already-driven milestone
|
|
1180
|
+
# re-reports its position rather than rewinding the walk to Gate A.
|
|
1181
|
+
try:
|
|
1182
|
+
existing = state.get_milestone_gate(
|
|
1183
|
+
repo_name=repo_entry.name, tracking_issue=tracking_issue
|
|
1184
|
+
)
|
|
1185
|
+
except Exception as e: # noqa: BLE001 — see coord.client.fetch_milestone_gate:
|
|
1186
|
+
# a thin client that can't reach the daemon can't tell "never
|
|
1187
|
+
# driven" from "already driven, record unreadable right now" —
|
|
1188
|
+
# restarting at Gate A below would silently clobber real progress
|
|
1189
|
+
# once `save_milestone_gate` lands its wholesale upsert (#1930,
|
|
1190
|
+
# epic #1440's non-blocking follow-up: same root cause as the
|
|
1191
|
+
# dispatch guard above).
|
|
1192
|
+
click.echo(
|
|
1193
|
+
f"error: could not read #{tracking_issue}'s existing gate record "
|
|
1194
|
+
f"({e}) — refusing to drive rather than risk restarting an "
|
|
1195
|
+
"in-progress walk this client can't see. Retry once the daemon "
|
|
1196
|
+
"is reachable, or run this on the daemon host directly.",
|
|
1197
|
+
err=True,
|
|
1198
|
+
)
|
|
1199
|
+
sys.exit(1)
|
|
1200
|
+
record = mg.GateRecord.from_dict(existing) if existing else None
|
|
1201
|
+
if record is None:
|
|
1202
|
+
if existing is not None:
|
|
1203
|
+
# A record was found but couldn't be parsed (unknown schema, or
|
|
1204
|
+
# a `gate` value this build doesn't recognize — e.g. written by
|
|
1205
|
+
# a different-schema client via the permissive `/milestone-gate`
|
|
1206
|
+
# endpoint). Rewinding silently to Gate A here would re-run
|
|
1207
|
+
# gates this milestone may have already cleared — the exact
|
|
1208
|
+
# thing GateRecord exists to prevent (see its `from_dict`
|
|
1209
|
+
# docstring). Loudly say so instead of guessing, mirroring the
|
|
1210
|
+
# warning `_milestone_gate_tick` logs for the same situation.
|
|
1211
|
+
click.echo(
|
|
1212
|
+
f"warning: existing gate record for {repo_entry.name}"
|
|
1213
|
+
f"#{tracking_issue} could not be parsed (raw={existing!r}) — "
|
|
1214
|
+
"starting a fresh walk at Gate A. If this milestone had "
|
|
1215
|
+
"already cleared gates, that history is now lost; inspect "
|
|
1216
|
+
"or delete the malformed record out of band before "
|
|
1217
|
+
"re-driving if that matters.",
|
|
1218
|
+
err=True,
|
|
1219
|
+
)
|
|
1220
|
+
now = _time.time()
|
|
1221
|
+
record = mg.GateRecord(
|
|
1222
|
+
repo_name=repo_entry.name,
|
|
1223
|
+
tracking_issue=tracking_issue,
|
|
1224
|
+
gate=mg.GATE_A,
|
|
1225
|
+
entered_at=now,
|
|
1226
|
+
updated_at=now,
|
|
1227
|
+
milestone_number=ctx.milestone_number,
|
|
1228
|
+
)
|
|
1229
|
+
else:
|
|
1230
|
+
record = dataclasses.replace(record, milestone_number=ctx.milestone_number)
|
|
1231
|
+
|
|
1232
|
+
board = board_service.read_board()
|
|
1233
|
+
probes = mg.probe_milestone(ctx, board, cfg, repo_entry)
|
|
1234
|
+
steps = mg.plan_sequence(record, probes)
|
|
1235
|
+
|
|
1236
|
+
# The frontier half of the dry-run report. Computed for both modes so the
|
|
1237
|
+
# non-dry-run confirmation shows what the first `work` tick will do.
|
|
1238
|
+
if ctx.work_order.nodes:
|
|
1239
|
+
plan = plan_dispatch(
|
|
1240
|
+
ctx.work_order, board, cfg, repo_entry, ctx.terminal_issues
|
|
1241
|
+
)
|
|
1242
|
+
to_dispatch, skipped, waiting = plan.to_dispatch, plan.skipped, plan.waiting
|
|
1243
|
+
else:
|
|
1244
|
+
to_dispatch, skipped, waiting = [], [], []
|
|
1245
|
+
|
|
1246
|
+
if dry_run:
|
|
1247
|
+
for line in mg.format_plan(
|
|
1248
|
+
record, steps,
|
|
1249
|
+
to_dispatch=to_dispatch, skipped=skipped, waiting=waiting,
|
|
1250
|
+
):
|
|
1251
|
+
click.echo(line)
|
|
1252
|
+
return
|
|
1253
|
+
|
|
1254
|
+
if not ctx.work_order.nodes:
|
|
1255
|
+
click.echo(
|
|
1256
|
+
f"error: #{tracking_issue} has no `## Work order` block — nothing "
|
|
1257
|
+
"to drive (write one with `coord milestone write-order`)",
|
|
1258
|
+
err=True,
|
|
1259
|
+
)
|
|
1260
|
+
sys.exit(1)
|
|
1261
|
+
|
|
1262
|
+
# #1930: this call is the ENTIRE side effect of `drive` — it persists the
|
|
1263
|
+
# record (unchanged, if one already existed) but never calls
|
|
1264
|
+
# `mg.apply_step` and never dispatches. Only the daemon's
|
|
1265
|
+
# `_milestone_gate_tick` advances the walk or drains `work`, so running
|
|
1266
|
+
# `drive` again (same operator, a second operator, a second machine) is
|
|
1267
|
+
# inert: same key, same record, nothing to race. See
|
|
1268
|
+
# `coord.milestone_gate`'s "Exactly one overseer" module docstring.
|
|
1269
|
+
state.save_milestone_gate(record.to_dict())
|
|
1270
|
+
for line in mg.format_plan(
|
|
1271
|
+
record, steps, to_dispatch=to_dispatch, skipped=skipped, waiting=waiting,
|
|
1272
|
+
footer=False,
|
|
1273
|
+
):
|
|
1274
|
+
click.echo(line)
|
|
1275
|
+
click.echo()
|
|
1276
|
+
click.echo(
|
|
1277
|
+
f"Milestone #{tracking_issue} is now under gate control at "
|
|
1278
|
+
f"{mg.GATE_LABELS.get(record.gate, record.gate)}. The daemon advances "
|
|
1279
|
+
"it one gate per tick; `coord milestone drive --dry-run` re-prints the "
|
|
1280
|
+
"plan at any time."
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _resolve_milestone_membership(
|
|
1285
|
+
repo_entry, milestone_number: int, work_order: WorkOrder
|
|
1286
|
+
) -> tuple[set[int], set[int]]:
|
|
1287
|
+
"""Resolve which of *work_order*'s issue numbers belong to the milestone
|
|
1288
|
+
and which have already reached a closed/terminal state.
|
|
1289
|
+
|
|
1290
|
+
Open issues under the milestone come free from one ``get_open_issues``
|
|
1291
|
+
call; any work-order node not in that set gets an individual
|
|
1292
|
+
``get_issue`` lookup (closed, or foreign) to classify it. Used by
|
|
1293
|
+
``coord milestone write-order`` to validate proposed work-order content
|
|
1294
|
+
before writing it (``coord milestone order``/``dispatch`` resolve
|
|
1295
|
+
membership via ``milestone_dispatch.fetch_milestone_context`` instead).
|
|
1296
|
+
"""
|
|
1297
|
+
from coord import github_ops
|
|
1298
|
+
|
|
1299
|
+
open_issues = github_ops.get_open_issues(repo_entry.github)
|
|
1300
|
+
milestone_issue_numbers = {
|
|
1301
|
+
i["number"]
|
|
1302
|
+
for i in open_issues
|
|
1303
|
+
if (i.get("milestone") or {}).get("number") == milestone_number
|
|
1304
|
+
}
|
|
1305
|
+
terminal_issues: set[int] = set()
|
|
1306
|
+
for node in work_order.nodes:
|
|
1307
|
+
if node.issue_number in milestone_issue_numbers:
|
|
1308
|
+
continue
|
|
1309
|
+
node_data = github_ops.get_issue(repo_entry.github, node.issue_number)
|
|
1310
|
+
node_milestone_number = (node_data.get("milestone") or {}).get("number")
|
|
1311
|
+
if node_milestone_number == milestone_number:
|
|
1312
|
+
milestone_issue_numbers.add(node.issue_number)
|
|
1313
|
+
if node_data.get("state", "").upper() == "CLOSED":
|
|
1314
|
+
terminal_issues.add(node.issue_number)
|
|
1315
|
+
return milestone_issue_numbers, terminal_issues
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
@milestone_group.command(
|
|
1319
|
+
"write-order",
|
|
1320
|
+
help=(
|
|
1321
|
+
"#770 (Phase 2 of #767): validate and write a `## Work order` block "
|
|
1322
|
+
"into a milestone tracking issue.\n\n"
|
|
1323
|
+
"Reads the new checklist lines (e.g. `- #762 {group: A}`, no "
|
|
1324
|
+
"heading) from --file or stdin, splices them into the tracking "
|
|
1325
|
+
"issue's current body (replacing any existing `## Work order` "
|
|
1326
|
+
"section — idempotent, never duplicated), re-parses and validates "
|
|
1327
|
+
"the result (cycles, unknown `after` targets, milestone membership) "
|
|
1328
|
+
"BEFORE writing, then calls `github_ops.update_issue_body` — the "
|
|
1329
|
+
"`coord`, never-raw-`gh` write path #645/#770 require. Also ensures "
|
|
1330
|
+
"the tracking issue carries the `epic` label (#1057) — added if "
|
|
1331
|
+
"missing, even when the checklist body itself is unchanged — since "
|
|
1332
|
+
"`coord plans`/the TUI Plans panel find a milestone's tracking epic "
|
|
1333
|
+
"by that label, not by the presence of a `## Work order` block.\n\n"
|
|
1334
|
+
"REPO is the local repo name from coordinator.yml; TRACKING_ISSUE is "
|
|
1335
|
+
"the GH issue number of the tracking issue (must carry a milestone)."
|
|
1336
|
+
),
|
|
1337
|
+
)
|
|
1338
|
+
@click.argument("repo")
|
|
1339
|
+
@click.argument("tracking_issue", type=int)
|
|
1340
|
+
@click.option(
|
|
1341
|
+
"--file",
|
|
1342
|
+
"block_file",
|
|
1343
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
1344
|
+
default=None,
|
|
1345
|
+
help="Path to a file with the new checklist lines. Reads stdin when omitted.",
|
|
1346
|
+
)
|
|
1347
|
+
@_CONFIG_OPTION
|
|
1348
|
+
def milestone_write_order_cmd(
|
|
1349
|
+
repo: str, tracking_issue: int, block_file: Path | None, config_path: Path
|
|
1350
|
+
) -> None:
|
|
1351
|
+
cfg = _load_config(config_path)
|
|
1352
|
+
repo_entry = cfg.repo(repo)
|
|
1353
|
+
if repo_entry is None:
|
|
1354
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
1355
|
+
sys.exit(2)
|
|
1356
|
+
|
|
1357
|
+
new_block = (
|
|
1358
|
+
block_file.read_text() if block_file is not None else sys.stdin.read()
|
|
1359
|
+
)
|
|
1360
|
+
if not new_block.strip():
|
|
1361
|
+
click.echo("error: no work-order content given (empty --file/stdin)", err=True)
|
|
1362
|
+
sys.exit(2)
|
|
1363
|
+
|
|
1364
|
+
from coord import github_ops
|
|
1365
|
+
|
|
1366
|
+
try:
|
|
1367
|
+
issue_data = github_ops.get_issue(repo_entry.github, tracking_issue)
|
|
1368
|
+
except RuntimeError as e:
|
|
1369
|
+
click.echo(f"error: could not fetch #{tracking_issue}: {e}", err=True)
|
|
1370
|
+
sys.exit(1)
|
|
1371
|
+
|
|
1372
|
+
milestone = issue_data.get("milestone") or {}
|
|
1373
|
+
milestone_number = milestone.get("number")
|
|
1374
|
+
if milestone_number is None:
|
|
1375
|
+
click.echo(f"error: #{tracking_issue} has no milestone", err=True)
|
|
1376
|
+
sys.exit(1)
|
|
1377
|
+
|
|
1378
|
+
old_body = issue_data.get("body") or ""
|
|
1379
|
+
candidate_body = replace_work_order_section(old_body, new_block)
|
|
1380
|
+
|
|
1381
|
+
try:
|
|
1382
|
+
work_order = parse_work_order(candidate_body)
|
|
1383
|
+
except WorkOrderError as e:
|
|
1384
|
+
click.echo(f"error: proposed work order is invalid: {e}", err=True)
|
|
1385
|
+
sys.exit(1)
|
|
1386
|
+
|
|
1387
|
+
if not work_order.nodes:
|
|
1388
|
+
click.echo(
|
|
1389
|
+
"error: proposed work order is empty — refusing to write an "
|
|
1390
|
+
"empty `## Work order` block",
|
|
1391
|
+
err=True,
|
|
1392
|
+
)
|
|
1393
|
+
sys.exit(1)
|
|
1394
|
+
|
|
1395
|
+
try:
|
|
1396
|
+
milestone_issue_numbers, _terminal = _resolve_milestone_membership(
|
|
1397
|
+
repo_entry, milestone_number, work_order
|
|
1398
|
+
)
|
|
1399
|
+
except RuntimeError as e:
|
|
1400
|
+
click.echo(f"error: could not resolve milestone membership: {e}", err=True)
|
|
1401
|
+
sys.exit(1)
|
|
1402
|
+
|
|
1403
|
+
try:
|
|
1404
|
+
validate_milestone_membership(work_order, milestone_issue_numbers)
|
|
1405
|
+
except WorkOrderError as e:
|
|
1406
|
+
click.echo(f"error: {e}", err=True)
|
|
1407
|
+
sys.exit(1)
|
|
1408
|
+
|
|
1409
|
+
# #1057: a valid `## Work order` block alone doesn't make this issue
|
|
1410
|
+
# findable as the milestone's tracking epic — `coord plans`/the TUI
|
|
1411
|
+
# Plans panel key off the `epic` label (TRACKING_ISSUE_LABEL). Ensure it
|
|
1412
|
+
# here so promotion is self-consistent, and do it even when the
|
|
1413
|
+
# checklist body below turns out to be an idempotent no-op (the #1051
|
|
1414
|
+
# repro: a valid work order already present, label missing).
|
|
1415
|
+
current_labels = {lbl.get("name") for lbl in issue_data.get("labels") or []}
|
|
1416
|
+
label_added = False
|
|
1417
|
+
if TRACKING_ISSUE_LABEL not in current_labels:
|
|
1418
|
+
github_ops.add_issue_labels(
|
|
1419
|
+
repo_entry.github, tracking_issue, [TRACKING_ISSUE_LABEL]
|
|
1420
|
+
)
|
|
1421
|
+
label_added = True
|
|
1422
|
+
click.echo(
|
|
1423
|
+
f"#{tracking_issue}: added missing '{TRACKING_ISSUE_LABEL}' label "
|
|
1424
|
+
"(tracking issues must carry it to be found as an epic)"
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
if candidate_body == old_body:
|
|
1428
|
+
click.echo(f"#{tracking_issue}: work order unchanged (idempotent no-op)")
|
|
1429
|
+
return
|
|
1430
|
+
|
|
1431
|
+
github_ops.update_issue_body(repo_entry.github, tracking_issue, candidate_body)
|
|
1432
|
+
click.echo(
|
|
1433
|
+
f"#{tracking_issue}: wrote `## Work order` block "
|
|
1434
|
+
f"({len(work_order.nodes)} node(s))"
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
@milestone_group.command(
|
|
1439
|
+
"chat",
|
|
1440
|
+
help=(
|
|
1441
|
+
"#770 (Phase 2 of #767) + #1009: dispatch a milestone-steward chat "
|
|
1442
|
+
"session.\n\n"
|
|
1443
|
+
"Seeds a `type=\"milestone-chat\"` `claude -p` worker with the "
|
|
1444
|
+
"milestone tracking issue's current body and the open issues filed "
|
|
1445
|
+
"under the milestone, then prints the new assignment id to stdout. "
|
|
1446
|
+
"The steward discusses the milestone, proposes a `## Work order` "
|
|
1447
|
+
"block inferring parallel cohorts (`group`) vs. hard dependencies "
|
|
1448
|
+
"(`after`) from the issue bodies, and can also propose creating/"
|
|
1449
|
+
"editing the milestone, assigning an issue to it, or splicing a "
|
|
1450
|
+
"sub-issue onto its epic — each written only once the operator "
|
|
1451
|
+
"confirms in the conversation, via `coord milestone write-order`/"
|
|
1452
|
+
"`create`/`edit`/`assign`/`add-child` (never raw `gh`).\n\n"
|
|
1453
|
+
"REPO is the local repo name from coordinator.yml; TRACKING_ISSUE is "
|
|
1454
|
+
"the GH issue number of the tracking issue (must carry a milestone). "
|
|
1455
|
+
"Pass `--new` instead of TRACKING_ISSUE to start a chat for a "
|
|
1456
|
+
"brand-new milestone that doesn't have a tracking issue yet. Pass "
|
|
1457
|
+
"`--add-child ISSUE` alongside TRACKING_ISSUE (#1017) to seed an "
|
|
1458
|
+
"\"add sub-issue\" chat about splicing ISSUE onto the epic's `## "
|
|
1459
|
+
"Sub-issues` checklist via `coord milestone add-child`."
|
|
1460
|
+
),
|
|
1461
|
+
)
|
|
1462
|
+
@click.argument("repo")
|
|
1463
|
+
@click.argument("tracking_issue", type=int, required=False, default=None)
|
|
1464
|
+
@click.option(
|
|
1465
|
+
"--new",
|
|
1466
|
+
"is_new",
|
|
1467
|
+
is_flag=True,
|
|
1468
|
+
help="Start a chat to create a brand-new milestone (no tracking issue yet) instead of discussing an existing one.",
|
|
1469
|
+
)
|
|
1470
|
+
@click.option(
|
|
1471
|
+
"--title",
|
|
1472
|
+
"seed_title",
|
|
1473
|
+
default=None,
|
|
1474
|
+
help="Optional seed title for --new (the operator can still change it in conversation).",
|
|
1475
|
+
)
|
|
1476
|
+
@click.option(
|
|
1477
|
+
"--seed",
|
|
1478
|
+
"seed_prompt",
|
|
1479
|
+
default=None,
|
|
1480
|
+
help="Optional seed prompt for --new describing the milestone's goal/scope.",
|
|
1481
|
+
)
|
|
1482
|
+
@click.option(
|
|
1483
|
+
"--add-child",
|
|
1484
|
+
"add_child_issue",
|
|
1485
|
+
type=int,
|
|
1486
|
+
default=None,
|
|
1487
|
+
help="Seed an \"add sub-issue\" chat (#1017) about splicing this candidate issue onto TRACKING_ISSUE's epic via `coord milestone add-child`. Requires TRACKING_ISSUE; invalid with --new.",
|
|
1488
|
+
)
|
|
1489
|
+
@click.option(
|
|
1490
|
+
"--machine",
|
|
1491
|
+
default=None,
|
|
1492
|
+
help="Override machine selection (default: first unpaused machine that lists the repo).",
|
|
1493
|
+
)
|
|
1494
|
+
@_CONFIG_OPTION
|
|
1495
|
+
def milestone_chat_cmd(
|
|
1496
|
+
repo: str,
|
|
1497
|
+
tracking_issue: int | None,
|
|
1498
|
+
is_new: bool,
|
|
1499
|
+
seed_title: str | None,
|
|
1500
|
+
seed_prompt: str | None,
|
|
1501
|
+
add_child_issue: int | None,
|
|
1502
|
+
machine: str | None,
|
|
1503
|
+
config_path: Path,
|
|
1504
|
+
) -> None:
|
|
1505
|
+
cfg = _load_config(config_path)
|
|
1506
|
+
repo_entry = cfg.repo(repo)
|
|
1507
|
+
if repo_entry is None:
|
|
1508
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
1509
|
+
sys.exit(2)
|
|
1510
|
+
|
|
1511
|
+
if is_new:
|
|
1512
|
+
if tracking_issue is not None:
|
|
1513
|
+
click.echo("error: pass either TRACKING_ISSUE or --new, not both", err=True)
|
|
1514
|
+
sys.exit(2)
|
|
1515
|
+
if add_child_issue is not None:
|
|
1516
|
+
click.echo("error: --add-child requires TRACKING_ISSUE, not --new", err=True)
|
|
1517
|
+
sys.exit(2)
|
|
1518
|
+
|
|
1519
|
+
from coord.milestone_chat import dispatch_new_milestone_chat
|
|
1520
|
+
|
|
1521
|
+
try:
|
|
1522
|
+
assignment_id, _picked_machine = dispatch_new_milestone_chat(
|
|
1523
|
+
repo,
|
|
1524
|
+
cfg,
|
|
1525
|
+
seed_title=seed_title,
|
|
1526
|
+
seed_prompt=seed_prompt,
|
|
1527
|
+
machine_override=machine,
|
|
1528
|
+
)
|
|
1529
|
+
except RuntimeError as exc:
|
|
1530
|
+
click.echo(f"error: {exc}", err=True)
|
|
1531
|
+
sys.exit(1)
|
|
1532
|
+
except httpx.HTTPError as exc:
|
|
1533
|
+
# #1017 review: dispatch_with_retry raises httpx.HTTPError (not
|
|
1534
|
+
# RuntimeError) after exhausting retries on a transient network
|
|
1535
|
+
# failure — previously this escaped as an unhandled traceback,
|
|
1536
|
+
# which looked to the TUI operator like a silent no-op (the
|
|
1537
|
+
# subprocess still exited non-zero, but no clean one-line
|
|
1538
|
+
# `error:` reason was there for `first_meaningful_stderr_line`
|
|
1539
|
+
# to surface as a toast).
|
|
1540
|
+
from coord.network import classify_error
|
|
1541
|
+
|
|
1542
|
+
state, reason = classify_error(exc)
|
|
1543
|
+
click.echo(f"error: dispatch failed — {state}: {reason}", err=True)
|
|
1544
|
+
sys.exit(1)
|
|
1545
|
+
except ValueError as exc:
|
|
1546
|
+
# dispatch() raises ValueError for an unknown machine or a
|
|
1547
|
+
# missing repo_path — likewise not a RuntimeError, likewise
|
|
1548
|
+
# deserves a clean one-line message instead of a traceback.
|
|
1549
|
+
click.echo(f"error: dispatch failed — {exc}", err=True)
|
|
1550
|
+
sys.exit(1)
|
|
1551
|
+
else:
|
|
1552
|
+
if tracking_issue is None:
|
|
1553
|
+
click.echo("error: TRACKING_ISSUE is required unless --new is given", err=True)
|
|
1554
|
+
sys.exit(2)
|
|
1555
|
+
if seed_title is not None or seed_prompt is not None:
|
|
1556
|
+
click.echo("error: --title/--seed only apply with --new", err=True)
|
|
1557
|
+
sys.exit(2)
|
|
1558
|
+
|
|
1559
|
+
from coord.milestone_chat import dispatch_milestone_chat
|
|
1560
|
+
|
|
1561
|
+
try:
|
|
1562
|
+
assignment_id, _picked_machine = dispatch_milestone_chat(
|
|
1563
|
+
repo,
|
|
1564
|
+
tracking_issue,
|
|
1565
|
+
cfg,
|
|
1566
|
+
machine_override=machine,
|
|
1567
|
+
add_child_issue=add_child_issue,
|
|
1568
|
+
)
|
|
1569
|
+
except RuntimeError as exc:
|
|
1570
|
+
click.echo(f"error: {exc}", err=True)
|
|
1571
|
+
sys.exit(1)
|
|
1572
|
+
except httpx.HTTPError as exc:
|
|
1573
|
+
# See the --new branch above (#1017 review) for why this and the
|
|
1574
|
+
# ValueError case are caught separately from RuntimeError.
|
|
1575
|
+
from coord.network import classify_error
|
|
1576
|
+
|
|
1577
|
+
state, reason = classify_error(exc)
|
|
1578
|
+
click.echo(f"error: dispatch failed — {state}: {reason}", err=True)
|
|
1579
|
+
sys.exit(1)
|
|
1580
|
+
except ValueError as exc:
|
|
1581
|
+
click.echo(f"error: dispatch failed — {exc}", err=True)
|
|
1582
|
+
sys.exit(1)
|
|
1583
|
+
|
|
1584
|
+
# Print the assignment id as the LAST stdout line so callers (the TUI,
|
|
1585
|
+
# eventually — #771/#645) can capture it with a "last non-empty line"
|
|
1586
|
+
# parse, matching refine-chat / new-issue-chat.
|
|
1587
|
+
click.echo(assignment_id)
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
@milestone_group.command(
|
|
1591
|
+
"gate-c",
|
|
1592
|
+
help=(
|
|
1593
|
+
"Gate C (docs/ORACLE_LOOP.md, #932): the milestone's FULL "
|
|
1594
|
+
"accumulated acceptance suite must be green before it ships — "
|
|
1595
|
+
"catches the integration gaps *between* issues that per-issue "
|
|
1596
|
+
"acceptance runs miss. Runs the repo's acceptance driver once "
|
|
1597
|
+
"against the current checkout (`--path`, default: the repo's "
|
|
1598
|
+
"configured local checkout) and reports pass/fail with a "
|
|
1599
|
+
"non-zero exit on red, alongside a per-issue rollup of the "
|
|
1600
|
+
"milestone's own Acceptance box state (e.g. \"3/7 acceptance "
|
|
1601
|
+
"green\") for visibility. This is a manual, read-only check the "
|
|
1602
|
+
"operator runs before shipping — it mutates no git state and "
|
|
1603
|
+
"blocks nothing automatically. The ship step itself is `coord "
|
|
1604
|
+
"milestone ship` (Gate D, #934), which merges `feature/ms-NN → "
|
|
1605
|
+
"develop`."
|
|
1606
|
+
),
|
|
1607
|
+
)
|
|
1608
|
+
@click.argument("repo")
|
|
1609
|
+
@click.argument("tracking_issue", type=int)
|
|
1610
|
+
@click.option(
|
|
1611
|
+
"--path", "path_opt", type=click.Path(file_okay=False), default=None,
|
|
1612
|
+
help="Repo checkout to run the driver in (default: repo_paths in coordinator.yml).",
|
|
1613
|
+
)
|
|
1614
|
+
@click.option(
|
|
1615
|
+
"--for-path", "route_path", default=None,
|
|
1616
|
+
help=(
|
|
1617
|
+
"Repo-relative path (e.g. 'coord/foo.py') used to resolve a "
|
|
1618
|
+
"routed acceptance driver (acceptance.drivers.<repo>.routes) — "
|
|
1619
|
+
"required when the repo's driver is routed; unused/ignored for a "
|
|
1620
|
+
"flat (unrouted) driver. NOT the same as --path (the checkout dir)."
|
|
1621
|
+
),
|
|
1622
|
+
)
|
|
1623
|
+
@_CONFIG_OPTION
|
|
1624
|
+
def milestone_gate_c_cmd(
|
|
1625
|
+
repo: str,
|
|
1626
|
+
tracking_issue: int,
|
|
1627
|
+
path_opt: str | None,
|
|
1628
|
+
route_path: str | None,
|
|
1629
|
+
config_path: Path,
|
|
1630
|
+
) -> None:
|
|
1631
|
+
from coord.acceptance import build_verdict, ms_dirname
|
|
1632
|
+
from coord.acceptance_drivers import DriverError, run_driver
|
|
1633
|
+
|
|
1634
|
+
cfg = _load_config(config_path)
|
|
1635
|
+
repo_entry = cfg.repo(repo)
|
|
1636
|
+
if repo_entry is None:
|
|
1637
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
1638
|
+
sys.exit(2)
|
|
1639
|
+
|
|
1640
|
+
driver_cfg = cfg.acceptance.driver_for(repo, route_path)
|
|
1641
|
+
if driver_cfg is None:
|
|
1642
|
+
if cfg.acceptance.has_driver(repo):
|
|
1643
|
+
click.echo(
|
|
1644
|
+
f"error: repo {repo!r} has a routed acceptance driver "
|
|
1645
|
+
"(acceptance.drivers routes) but no route matched — pass "
|
|
1646
|
+
"--for-path to select the subtree (e.g. 'coord/**')",
|
|
1647
|
+
err=True,
|
|
1648
|
+
)
|
|
1649
|
+
else:
|
|
1650
|
+
click.echo(
|
|
1651
|
+
f"error: no acceptance driver configured for repo {repo!r} "
|
|
1652
|
+
"(add it under acceptance.drivers in coordinator.yml)",
|
|
1653
|
+
err=True,
|
|
1654
|
+
)
|
|
1655
|
+
sys.exit(1)
|
|
1656
|
+
|
|
1657
|
+
if path_opt is not None:
|
|
1658
|
+
repo_dir = Path(path_opt).expanduser()
|
|
1659
|
+
else:
|
|
1660
|
+
from coord.test_orchestrator import find_local_repo_path
|
|
1661
|
+
|
|
1662
|
+
found = find_local_repo_path(repo, cfg)
|
|
1663
|
+
if found is None:
|
|
1664
|
+
click.echo(
|
|
1665
|
+
f"error: no local repo checkout found for {repo!r} "
|
|
1666
|
+
"(repo_paths in coordinator.yml) — pass --path",
|
|
1667
|
+
err=True,
|
|
1668
|
+
)
|
|
1669
|
+
sys.exit(1)
|
|
1670
|
+
repo_dir = found
|
|
1671
|
+
|
|
1672
|
+
try:
|
|
1673
|
+
ctx = fetch_milestone_context(repo_entry, tracking_issue)
|
|
1674
|
+
except MilestoneDispatchError as e:
|
|
1675
|
+
click.echo(f"error: {e}", err=True)
|
|
1676
|
+
sys.exit(1)
|
|
1677
|
+
|
|
1678
|
+
click.echo(
|
|
1679
|
+
f"Gate C for #{tracking_issue} (milestone #{ctx.milestone_number}) "
|
|
1680
|
+
f"— running the full accumulated acceptance suite in {repo_dir}..."
|
|
1681
|
+
)
|
|
1682
|
+
# #1125 review finding 2: gate-c already knows the milestone number
|
|
1683
|
+
# directly (no manifest lookup needed, unlike the per-issue `run`/
|
|
1684
|
+
# `record` commands) — resolve `{ms}` from it so a routed
|
|
1685
|
+
# `run: "pytest tests/acceptance/{ms}"` driver actually runs the right
|
|
1686
|
+
# suite dir for Gate C's "full accumulated suite" semantics.
|
|
1687
|
+
ms = ms_dirname(ctx.milestone_number)
|
|
1688
|
+
try:
|
|
1689
|
+
result = run_driver(driver_cfg.kind, driver_cfg.run, cwd=str(repo_dir), ms=ms)
|
|
1690
|
+
except DriverError as e:
|
|
1691
|
+
click.echo(f"error: {e}", err=True)
|
|
1692
|
+
sys.exit(1)
|
|
1693
|
+
|
|
1694
|
+
if not result.tests and result.exit_code != 0:
|
|
1695
|
+
click.echo(result.raw_output, err=True)
|
|
1696
|
+
|
|
1697
|
+
verdict = build_verdict(result.tests, scope="all")
|
|
1698
|
+
click.echo(json.dumps(verdict, indent=2))
|
|
1699
|
+
|
|
1700
|
+
# Per-issue rollup: how many of the milestone's own issues already have
|
|
1701
|
+
# a passed Acceptance box, for visibility alongside the full-suite
|
|
1702
|
+
# verdict (docs/ORACLE_LOOP.md's "3/7 acceptance green" example is a
|
|
1703
|
+
# per-milestone-member reading, not this full-suite one).
|
|
1704
|
+
if ctx.work_order.nodes:
|
|
1705
|
+
from coord import board_service
|
|
1706
|
+
from coord.diagnose import stage_assignments
|
|
1707
|
+
|
|
1708
|
+
board = board_service.read_board()
|
|
1709
|
+
passed = 0
|
|
1710
|
+
with_signal = 0
|
|
1711
|
+
for node in ctx.work_order.nodes:
|
|
1712
|
+
work = stage_assignments(board, repo, node.issue_number, "work")
|
|
1713
|
+
with_state = [a for a in work if (a.acceptance_state or "") != ""]
|
|
1714
|
+
if not with_state:
|
|
1715
|
+
continue
|
|
1716
|
+
with_signal += 1
|
|
1717
|
+
if with_state[0].acceptance_state == "passed":
|
|
1718
|
+
passed += 1
|
|
1719
|
+
click.echo(
|
|
1720
|
+
f"\nMilestone Acceptance boxes: {passed}/{with_signal} passed "
|
|
1721
|
+
f"({len(ctx.work_order.nodes)} issue(s) total, "
|
|
1722
|
+
f"{len(ctx.work_order.nodes) - with_signal} with no verdict yet)"
|
|
1723
|
+
)
|
|
1724
|
+
|
|
1725
|
+
if verdict["total"] == 0 or not verdict["green"]:
|
|
1726
|
+
click.echo(f"\nGate C RED for #{tracking_issue} — milestone is not ready to ship.")
|
|
1727
|
+
sys.exit(1)
|
|
1728
|
+
click.echo(f"\nGate C GREEN for #{tracking_issue}.")
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
@milestone_group.command(
|
|
1732
|
+
"gate-b",
|
|
1733
|
+
help=(
|
|
1734
|
+
"Gate B (docs/PIPELINE_V2.md, #933): after every issue in the "
|
|
1735
|
+
"milestone has landed, dispatch an independent architecture review "
|
|
1736
|
+
"that checks the ASSEMBLED result against the Gate-A contract — "
|
|
1737
|
+
"was it implemented to spec, did the pieces integrate. Routes "
|
|
1738
|
+
"through the review pipeline (type=review, gh access via the "
|
|
1739
|
+
"coordinator posting on the reviewer's behalf) rather than "
|
|
1740
|
+
"`coord assign`, whose workers have `gh` denied. The verdict is "
|
|
1741
|
+
"posted as a comment on TRACKING_ISSUE when the reviewer finishes; "
|
|
1742
|
+
"request-changes means bounce, not ship — this command itself is a "
|
|
1743
|
+
"manual, non-automated gate, same posture as `coord milestone "
|
|
1744
|
+
"gate-c`. The ship step itself is `coord milestone ship` (Gate D, "
|
|
1745
|
+
"#934), which merges `feature/ms-NN -> develop`."
|
|
1746
|
+
),
|
|
1747
|
+
)
|
|
1748
|
+
@click.argument("repo")
|
|
1749
|
+
@click.argument("tracking_issue", type=int)
|
|
1750
|
+
@click.option(
|
|
1751
|
+
"--machine", "machine_name", default=None,
|
|
1752
|
+
help="Dispatch to this machine by name instead of the first idle/capable one.",
|
|
1753
|
+
)
|
|
1754
|
+
@click.option(
|
|
1755
|
+
"--dry-run", is_flag=True,
|
|
1756
|
+
help="Show the target machine + issue list without dispatching.",
|
|
1757
|
+
)
|
|
1758
|
+
@_CONFIG_OPTION
|
|
1759
|
+
def milestone_gate_b_cmd(
|
|
1760
|
+
repo: str,
|
|
1761
|
+
tracking_issue: int,
|
|
1762
|
+
machine_name: str | None,
|
|
1763
|
+
dry_run: bool,
|
|
1764
|
+
config_path: Path,
|
|
1765
|
+
) -> None:
|
|
1766
|
+
from coord import board_service
|
|
1767
|
+
from coord.gate_b import GateBError, dispatch_gate_b_review
|
|
1768
|
+
|
|
1769
|
+
cfg = _load_config(config_path)
|
|
1770
|
+
repo_entry = cfg.repo(repo)
|
|
1771
|
+
if repo_entry is None:
|
|
1772
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
1773
|
+
sys.exit(2)
|
|
1774
|
+
|
|
1775
|
+
try:
|
|
1776
|
+
ctx = fetch_milestone_context(repo_entry, tracking_issue)
|
|
1777
|
+
except MilestoneDispatchError as e:
|
|
1778
|
+
click.echo(f"error: {e}", err=True)
|
|
1779
|
+
sys.exit(1)
|
|
1780
|
+
|
|
1781
|
+
# Gate A must already be satisfied — Gate B's rubric IS the Gate-A
|
|
1782
|
+
# contract, so there is nothing to review against otherwise.
|
|
1783
|
+
block_reason = gate_a_status(repo_entry, cfg, ctx.milestone_number)
|
|
1784
|
+
if block_reason:
|
|
1785
|
+
click.echo(f"error: {block_reason}", err=True)
|
|
1786
|
+
sys.exit(1)
|
|
1787
|
+
|
|
1788
|
+
if not ctx.work_order.nodes:
|
|
1789
|
+
click.echo(
|
|
1790
|
+
f"#{tracking_issue}: no `## Work order` block found — nothing for "
|
|
1791
|
+
"Gate B to review",
|
|
1792
|
+
err=True,
|
|
1793
|
+
)
|
|
1794
|
+
sys.exit(1)
|
|
1795
|
+
|
|
1796
|
+
if not is_milestone_complete(ctx):
|
|
1797
|
+
incomplete = [
|
|
1798
|
+
n.issue_number
|
|
1799
|
+
for n in ctx.work_order.nodes
|
|
1800
|
+
if n.issue_number not in ctx.terminal_issues
|
|
1801
|
+
]
|
|
1802
|
+
click.echo(
|
|
1803
|
+
"error: Gate B runs only after every issue in the milestone has "
|
|
1804
|
+
"landed — still open: "
|
|
1805
|
+
+ ", ".join(f"#{n}" for n in incomplete),
|
|
1806
|
+
err=True,
|
|
1807
|
+
)
|
|
1808
|
+
sys.exit(1)
|
|
1809
|
+
|
|
1810
|
+
board = board_service.read_board()
|
|
1811
|
+
|
|
1812
|
+
if machine_name is not None:
|
|
1813
|
+
machine = next((m for m in cfg.machines if m.name == machine_name), None)
|
|
1814
|
+
if machine is None:
|
|
1815
|
+
click.echo(f"error: unknown machine {machine_name!r}", err=True)
|
|
1816
|
+
sys.exit(2)
|
|
1817
|
+
else:
|
|
1818
|
+
machine = pick_machine(repo, board, cfg)
|
|
1819
|
+
if machine is None:
|
|
1820
|
+
click.echo(
|
|
1821
|
+
f"error: no idle, capable, unpaused machine available for {repo!r}",
|
|
1822
|
+
err=True,
|
|
1823
|
+
)
|
|
1824
|
+
sys.exit(1)
|
|
1825
|
+
|
|
1826
|
+
click.echo(
|
|
1827
|
+
f"Gate B for #{tracking_issue} (milestone #{ctx.milestone_number}), "
|
|
1828
|
+
f"{len(ctx.work_order.nodes)} issue(s) -> {machine.name}"
|
|
1829
|
+
)
|
|
1830
|
+
|
|
1831
|
+
if dry_run:
|
|
1832
|
+
click.echo("(dry run — not dispatched)")
|
|
1833
|
+
return
|
|
1834
|
+
|
|
1835
|
+
try:
|
|
1836
|
+
assignment = dispatch_gate_b_review(
|
|
1837
|
+
repo_cfg=repo_entry,
|
|
1838
|
+
config=cfg,
|
|
1839
|
+
machine=machine,
|
|
1840
|
+
tracking_issue=tracking_issue,
|
|
1841
|
+
milestone_number=ctx.milestone_number,
|
|
1842
|
+
work_order=ctx.work_order,
|
|
1843
|
+
board=board,
|
|
1844
|
+
)
|
|
1845
|
+
except GateBError as e:
|
|
1846
|
+
click.echo(f"error: {e}", err=True)
|
|
1847
|
+
sys.exit(1)
|
|
1848
|
+
|
|
1849
|
+
click.echo(f"dispatched: assignment {assignment.assignment_id} on {machine.name}")
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
@milestone_group.command(
|
|
1853
|
+
"ship",
|
|
1854
|
+
help=(
|
|
1855
|
+
"Gate D (docs/PIPELINE_V2.md, #934): ship a milestone's "
|
|
1856
|
+
"`feature/ms-NN` branch to `develop` — the last step of the "
|
|
1857
|
+
"develop + feature-branch-per-milestone git model. Refuses unless "
|
|
1858
|
+
"the repo has opted in (`develop_branch` set in coordinator.yml) "
|
|
1859
|
+
"AND both Gate B (an `approve` verdict — `coord milestone gate-b`) "
|
|
1860
|
+
"AND Gate C (the full acceptance suite, re-run live here, the same "
|
|
1861
|
+
"check `coord milestone gate-c` performs) are green. On success, "
|
|
1862
|
+
"opens (or reuses) and merges a PR from `feature/ms-NN` into "
|
|
1863
|
+
"`develop_branch`."
|
|
1864
|
+
),
|
|
1865
|
+
)
|
|
1866
|
+
@click.argument("repo")
|
|
1867
|
+
@click.argument("tracking_issue", type=int)
|
|
1868
|
+
@click.option(
|
|
1869
|
+
"--path", "path_opt", type=click.Path(file_okay=False), default=None,
|
|
1870
|
+
help="Repo checkout to run Gate C's driver in (default: repo_paths in coordinator.yml).",
|
|
1871
|
+
)
|
|
1872
|
+
@click.option(
|
|
1873
|
+
"--for-path", "route_path", default=None,
|
|
1874
|
+
help="Repo-relative path used to resolve a routed acceptance driver (mirrors `gate-c`).",
|
|
1875
|
+
)
|
|
1876
|
+
@click.option(
|
|
1877
|
+
"--method", "merge_method", default="merge",
|
|
1878
|
+
type=click.Choice(["merge", "squash", "rebase"]),
|
|
1879
|
+
help=(
|
|
1880
|
+
"PR merge method for feature/ms-NN -> develop (default: 'merge' — "
|
|
1881
|
+
"a real merge commit, so the milestone's own commit history is "
|
|
1882
|
+
"preserved as a unit rather than flattened/replayed onto develop)."
|
|
1883
|
+
),
|
|
1884
|
+
)
|
|
1885
|
+
@click.option(
|
|
1886
|
+
"--dry-run", is_flag=True,
|
|
1887
|
+
help="Check both gates and report the ship plan without opening/merging a PR.",
|
|
1888
|
+
)
|
|
1889
|
+
@_CONFIG_OPTION
|
|
1890
|
+
def milestone_ship_cmd(
|
|
1891
|
+
repo: str,
|
|
1892
|
+
tracking_issue: int,
|
|
1893
|
+
path_opt: str | None,
|
|
1894
|
+
route_path: str | None,
|
|
1895
|
+
merge_method: str,
|
|
1896
|
+
dry_run: bool,
|
|
1897
|
+
config_path: Path,
|
|
1898
|
+
) -> None:
|
|
1899
|
+
from coord import board_service, github_ops
|
|
1900
|
+
from coord.acceptance import build_verdict, ms_dirname
|
|
1901
|
+
from coord.acceptance_drivers import DriverError, run_driver
|
|
1902
|
+
from coord.branch_model import feature_branch_name
|
|
1903
|
+
from coord.gate_b import latest_gate_b_verdict
|
|
1904
|
+
|
|
1905
|
+
cfg = _load_config(config_path)
|
|
1906
|
+
repo_entry = cfg.repo(repo)
|
|
1907
|
+
if repo_entry is None:
|
|
1908
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
1909
|
+
sys.exit(2)
|
|
1910
|
+
|
|
1911
|
+
if not repo_entry.develop_branch:
|
|
1912
|
+
click.echo(
|
|
1913
|
+
f"error: repo {repo!r} has not opted into the develop + "
|
|
1914
|
+
"feature-branch-per-milestone git model (#934) — set "
|
|
1915
|
+
"develop_branch in coordinator.yml to ship",
|
|
1916
|
+
err=True,
|
|
1917
|
+
)
|
|
1918
|
+
sys.exit(1)
|
|
1919
|
+
|
|
1920
|
+
try:
|
|
1921
|
+
ctx = fetch_milestone_context(repo_entry, tracking_issue)
|
|
1922
|
+
except MilestoneDispatchError as e:
|
|
1923
|
+
click.echo(f"error: {e}", err=True)
|
|
1924
|
+
sys.exit(1)
|
|
1925
|
+
|
|
1926
|
+
if not is_milestone_complete(ctx):
|
|
1927
|
+
incomplete = [
|
|
1928
|
+
n.issue_number
|
|
1929
|
+
for n in ctx.work_order.nodes
|
|
1930
|
+
if n.issue_number not in ctx.terminal_issues
|
|
1931
|
+
]
|
|
1932
|
+
click.echo(
|
|
1933
|
+
"error: cannot ship — still open: "
|
|
1934
|
+
+ ", ".join(f"#{n}" for n in incomplete),
|
|
1935
|
+
err=True,
|
|
1936
|
+
)
|
|
1937
|
+
sys.exit(1)
|
|
1938
|
+
|
|
1939
|
+
feature_branch = feature_branch_name(ctx.milestone_number)
|
|
1940
|
+
|
|
1941
|
+
# ── Gate B ────────────────────────────────────────────────────────────
|
|
1942
|
+
board = board_service.read_board()
|
|
1943
|
+
gate_b_verdict = latest_gate_b_verdict(
|
|
1944
|
+
board, repo_entry.name, tracking_issue, ctx.milestone_number
|
|
1945
|
+
)
|
|
1946
|
+
if gate_b_verdict != "approve":
|
|
1947
|
+
seen = gate_b_verdict or "no Gate B review found"
|
|
1948
|
+
click.echo(
|
|
1949
|
+
f"error: Gate B is not green ({seen}) — run `coord milestone "
|
|
1950
|
+
f"gate-b {repo} {tracking_issue}` first",
|
|
1951
|
+
err=True,
|
|
1952
|
+
)
|
|
1953
|
+
sys.exit(1)
|
|
1954
|
+
click.echo(f"Gate B: approve (milestone #{ctx.milestone_number})")
|
|
1955
|
+
|
|
1956
|
+
# ── Gate C — re-run live, same as `coord milestone gate-c` ─────────────
|
|
1957
|
+
driver_cfg = cfg.acceptance.driver_for(repo, route_path)
|
|
1958
|
+
if driver_cfg is None:
|
|
1959
|
+
if cfg.acceptance.has_driver(repo):
|
|
1960
|
+
click.echo(
|
|
1961
|
+
f"error: repo {repo!r} has a routed acceptance driver "
|
|
1962
|
+
"(acceptance.drivers routes) but no route matched — pass "
|
|
1963
|
+
"--for-path to select the subtree (e.g. 'coord/**')",
|
|
1964
|
+
err=True,
|
|
1965
|
+
)
|
|
1966
|
+
else:
|
|
1967
|
+
click.echo(
|
|
1968
|
+
f"error: no acceptance driver configured for repo {repo!r} "
|
|
1969
|
+
"(add it under acceptance.drivers in coordinator.yml)",
|
|
1970
|
+
err=True,
|
|
1971
|
+
)
|
|
1972
|
+
sys.exit(1)
|
|
1973
|
+
|
|
1974
|
+
if path_opt is not None:
|
|
1975
|
+
repo_dir = Path(path_opt).expanduser()
|
|
1976
|
+
else:
|
|
1977
|
+
from coord.test_orchestrator import find_local_repo_path
|
|
1978
|
+
|
|
1979
|
+
found = find_local_repo_path(repo, cfg)
|
|
1980
|
+
if found is None:
|
|
1981
|
+
click.echo(
|
|
1982
|
+
f"error: no local repo checkout found for {repo!r} "
|
|
1983
|
+
"(repo_paths in coordinator.yml) — pass --path",
|
|
1984
|
+
err=True,
|
|
1985
|
+
)
|
|
1986
|
+
sys.exit(1)
|
|
1987
|
+
repo_dir = found
|
|
1988
|
+
|
|
1989
|
+
click.echo(f"Gate C: running the full accumulated acceptance suite in {repo_dir}...")
|
|
1990
|
+
ms = ms_dirname(ctx.milestone_number)
|
|
1991
|
+
try:
|
|
1992
|
+
result = run_driver(driver_cfg.kind, driver_cfg.run, cwd=str(repo_dir), ms=ms)
|
|
1993
|
+
except DriverError as e:
|
|
1994
|
+
click.echo(f"error: {e}", err=True)
|
|
1995
|
+
sys.exit(1)
|
|
1996
|
+
|
|
1997
|
+
if not result.tests and result.exit_code != 0:
|
|
1998
|
+
click.echo(result.raw_output, err=True)
|
|
1999
|
+
|
|
2000
|
+
verdict = build_verdict(result.tests, scope="all")
|
|
2001
|
+
if verdict["total"] == 0 or not verdict["green"]:
|
|
2002
|
+
click.echo(
|
|
2003
|
+
f"error: Gate C is RED for #{tracking_issue} — milestone is not ready to ship",
|
|
2004
|
+
err=True,
|
|
2005
|
+
)
|
|
2006
|
+
sys.exit(1)
|
|
2007
|
+
click.echo(f"Gate C: GREEN ({verdict['passed']}/{verdict['total']})")
|
|
2008
|
+
|
|
2009
|
+
# ── Ship: feature/ms-NN -> develop ──────────────────────────────────────
|
|
2010
|
+
if dry_run:
|
|
2011
|
+
click.echo(
|
|
2012
|
+
f"(dry run) would open/merge a PR: {feature_branch} -> "
|
|
2013
|
+
f"{repo_entry.develop_branch} (method={merge_method}), then close "
|
|
2014
|
+
f"tracking issue #{tracking_issue} on success"
|
|
2015
|
+
)
|
|
2016
|
+
return
|
|
2017
|
+
|
|
2018
|
+
if not github_ops.branch_exists_on_remote(repo_entry.github, feature_branch):
|
|
2019
|
+
click.echo(
|
|
2020
|
+
f"error: {feature_branch} does not exist on {repo_entry.github} — "
|
|
2021
|
+
"nothing to ship (no issue in this milestone ever dispatched?)",
|
|
2022
|
+
err=True,
|
|
2023
|
+
)
|
|
2024
|
+
sys.exit(1)
|
|
2025
|
+
|
|
2026
|
+
pr = github_ops.create_pr(
|
|
2027
|
+
repo_entry.github,
|
|
2028
|
+
base=repo_entry.develop_branch,
|
|
2029
|
+
head=feature_branch,
|
|
2030
|
+
title=f"Ship milestone #{ctx.milestone_number}: {feature_branch} -> {repo_entry.develop_branch}",
|
|
2031
|
+
body=(
|
|
2032
|
+
f"Gate B + Gate C both green for milestone #{ctx.milestone_number} "
|
|
2033
|
+
f"(tracking issue #{tracking_issue}). Opened by `coord milestone ship`."
|
|
2034
|
+
),
|
|
2035
|
+
)
|
|
2036
|
+
ok, message = github_ops.merge_pr(repo_entry.github, pr["number"], method=merge_method)
|
|
2037
|
+
if not ok:
|
|
2038
|
+
click.echo(
|
|
2039
|
+
f"error: PR #{pr['number']} ({pr['url']}) opened but merge failed: {message}",
|
|
2040
|
+
err=True,
|
|
2041
|
+
)
|
|
2042
|
+
sys.exit(1)
|
|
2043
|
+
|
|
2044
|
+
click.echo(
|
|
2045
|
+
f"shipped: {feature_branch} -> {repo_entry.develop_branch} "
|
|
2046
|
+
f"(PR #{pr['number']}, {pr['url']})"
|
|
2047
|
+
)
|
|
2048
|
+
|
|
2049
|
+
# ── Close the tracking issue ─────────────────────────────────────────
|
|
2050
|
+
# This is the completion signal Gate D (`coord milestone drive`,
|
|
2051
|
+
# `coord.milestone_gate.probe_milestone`'s `shipped` probe) watches for
|
|
2052
|
+
# — nothing else in the pipeline closes it. `force=True`: this command
|
|
2053
|
+
# already proved every work-order node terminal (`is_milestone_complete`
|
|
2054
|
+
# above) by the milestone's own definition of "done", which is not
|
|
2055
|
+
# necessarily the same set of issues GitHub's sub-issues API would list
|
|
2056
|
+
# as this issue's children, so the #1196 open-children guard would be
|
|
2057
|
+
# the wrong gate to also apply here. A close failure must not undo the
|
|
2058
|
+
# merge that already happened — report it and tell the operator how to
|
|
2059
|
+
# finish by hand rather than exiting non-zero.
|
|
2060
|
+
try:
|
|
2061
|
+
github_ops.close_issue(
|
|
2062
|
+
repo_entry.github,
|
|
2063
|
+
tracking_issue,
|
|
2064
|
+
comment=(
|
|
2065
|
+
f"Closed by `coord milestone ship`: {feature_branch} merged "
|
|
2066
|
+
f"into {repo_entry.develop_branch} (PR #{pr['number']})."
|
|
2067
|
+
),
|
|
2068
|
+
force=True,
|
|
2069
|
+
)
|
|
2070
|
+
click.echo(
|
|
2071
|
+
f"closed tracking issue #{tracking_issue} "
|
|
2072
|
+
"(coord milestone drive's Gate D will observe this on its next tick)"
|
|
2073
|
+
)
|
|
2074
|
+
except RuntimeError as e:
|
|
2075
|
+
click.echo(
|
|
2076
|
+
f"warning: shipped but could not close tracking issue "
|
|
2077
|
+
f"#{tracking_issue}: {e} — close it manually (`gh issue close "
|
|
2078
|
+
f"{tracking_issue} --repo {repo_entry.github}`), otherwise Gate D "
|
|
2079
|
+
"will hold forever waiting for it",
|
|
2080
|
+
err=True,
|
|
2081
|
+
)
|