code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/milestone_order.py
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
"""Milestone work-order representation — Phase 0 of #767 (+ the render/replace
|
|
2
|
+
write-side helpers Phase 2, #770, uses to persist a chat-proposed order).
|
|
3
|
+
|
|
4
|
+
A milestone's **tracking issue** (the decision-log convention established by
|
|
5
|
+
#645) carries a ``## Work order`` annotated checklist describing which of
|
|
6
|
+
the milestone's issues may run concurrently and which have hard dependency
|
|
7
|
+
edges::
|
|
8
|
+
|
|
9
|
+
## Work order
|
|
10
|
+
- [ ] #762 {group: A} # may run concurrently (cohort A)
|
|
11
|
+
- [ ] #763 {group: A}
|
|
12
|
+
- [ ] #765 {after: #762,#763} # hard dependency edge
|
|
13
|
+
- [ ] #766 {after: #765}
|
|
14
|
+
|
|
15
|
+
This module turns that block into a DAG (:func:`parse_work_order`) and
|
|
16
|
+
computes the **ready frontier** — the subset of nodes eligible to dispatch
|
|
17
|
+
right now given the current board state and which issues have already
|
|
18
|
+
reached a merged/terminal state (:func:`ready_frontier`). It also renders a
|
|
19
|
+
:class:`WorkOrder` back into checklist text and splices it into a tracking
|
|
20
|
+
issue's body (:func:`render_work_order` / :func:`replace_work_order_section`)
|
|
21
|
+
— the write-side counterpart the #770 milestone-chat session (and its
|
|
22
|
+
``coord milestone write-order`` CLI command) uses to persist an
|
|
23
|
+
operator-confirmed order idempotently, never duplicating the section.
|
|
24
|
+
|
|
25
|
+
Deliberately **pure / board-driven** (per #768's acceptance criteria): every
|
|
26
|
+
function here takes plain data (a body string, a :class:`~coord.models.Board`,
|
|
27
|
+
a set of terminal issue numbers, a set of milestone issue numbers) rather
|
|
28
|
+
than reaching out to GitHub itself. That keeps the DAG/frontier logic cheap
|
|
29
|
+
to unit-test with seeded fixtures and keeps the one "mechanical, ~zero
|
|
30
|
+
Claude per decision" property #767 calls for — no LLM, no network call, on
|
|
31
|
+
the hot path. Fetching those inputs from GitHub (the tracking issue body,
|
|
32
|
+
milestone membership, issue open/closed state) is the job of the
|
|
33
|
+
``coord milestone order`` CLI command (``coord/commands/milestone.py``),
|
|
34
|
+
which is thin glue over this module.
|
|
35
|
+
|
|
36
|
+
Design note for later phases (#769+): milestone **membership** is checked
|
|
37
|
+
against "is this issue number under the milestone" — not "is it currently
|
|
38
|
+
open". A dependency that has already merged and closed by the time you
|
|
39
|
+
re-run ``coord milestone order`` is expected and still a valid node; only
|
|
40
|
+
its *readiness* (via ``terminal_issues``) changes, not its validity in the
|
|
41
|
+
DAG. Only a genuinely wrong/foreign issue number raises
|
|
42
|
+
``WorkOrderError``.
|
|
43
|
+
|
|
44
|
+
#1008 adds a second checklist convention living in the *same* tracking-issue
|
|
45
|
+
body: ``## Sub-issues`` — the epic's child-issue list, spliced by ``coord
|
|
46
|
+
milestone add-child`` (:func:`parse_sub_issues` / :func:`render_sub_issues`
|
|
47
|
+
/ :func:`replace_sub_issues_section`). It reuses the identical `- [ ] #N
|
|
48
|
+
{group: ..., after: ...}` checklist grammar and the same
|
|
49
|
+
:class:`WorkOrderNode` / :class:`WorkOrder` shapes as the work order — only
|
|
50
|
+
the section heading differs — so the two conventions can coexist in one
|
|
51
|
+
tracking-issue body without either splice helper disturbing the other.
|
|
52
|
+
|
|
53
|
+
#1061: the `[ ]`/`[x]` checkbox is decorative — parsed into
|
|
54
|
+
:attr:`WorkOrderNode.checked`, preserved, and rendered, but never read for
|
|
55
|
+
readiness (:func:`ready_frontier` keys entirely off live ``terminal_issues``)
|
|
56
|
+
— which is exactly why it silently drifted stale on real epics. Rather than
|
|
57
|
+
sync it, the grammar is migrating to drop it: `- #N {group: ..., after:
|
|
58
|
+
...}`, no checkbox. Both forms parse identically (the checkbox is simply
|
|
59
|
+
optional now) so old bodies keep working during the migration;
|
|
60
|
+
``coord milestone sync`` (:mod:`coord.commands.milestone`) is the write path
|
|
61
|
+
that rewrites an epic's `## Work order` to the checkbox-free form, backfills
|
|
62
|
+
the live GitHub sub-issues API for each referenced child
|
|
63
|
+
(:mod:`coord.parentage_github`), and retires the now-redundant `##
|
|
64
|
+
Sub-issues` section (:func:`remove_sub_issues_section`) — the API + `##
|
|
65
|
+
Work order` together already carry everything that section did.
|
|
66
|
+
|
|
67
|
+
#1412 adds a third, purely *derived* section: `## Progress`. Unlike `##
|
|
68
|
+
Work order`/`## Sub-issues` (both hand-authored plans a human or a milestone
|
|
69
|
+
chat session edits), `## Progress` is never edited directly — it's a
|
|
70
|
+
read-only projection of live status onto the tracking-issue body, refreshed
|
|
71
|
+
by ``coord milestone sync-progress`` (one-shot, operator-triggered) and the
|
|
72
|
+
daemon tick (:func:`coord.serve_app._milestone_progress_tick`, for every
|
|
73
|
+
milestone registered via ``coord milestone dispatch``). :func:`compute_progress`
|
|
74
|
+
derives one :class:`ProgressStatus` per work-order node from exactly the
|
|
75
|
+
inputs ``coord milestone order`` already prints — terminal state + a live
|
|
76
|
+
:func:`ready_frontier` — so there is deliberately no second notion of
|
|
77
|
+
readiness anywhere in this module; `## Progress` can only ever agree with
|
|
78
|
+
what ``coord milestone order``/``dispatch`` would compute right now.
|
|
79
|
+
:func:`render_progress` turns that into checklist-shaped text (a timestamp
|
|
80
|
+
line + one `- [status] #N` line per node) and :func:`parse_progress` is its
|
|
81
|
+
inverse, used only to detect a no-op (re-rendering identical status text
|
|
82
|
+
should not touch the tracking issue, even though the timestamp itself always
|
|
83
|
+
changes) — never for readiness. :func:`replace_progress_section` splices it
|
|
84
|
+
in under its own `## Progress` heading, using the exact same
|
|
85
|
+
splice-not-duplicate mechanics as :func:`replace_work_order_section` /
|
|
86
|
+
:func:`replace_sub_issues_section`, so all three sections coexist in one
|
|
87
|
+
tracking-issue body without stepping on each other.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
from __future__ import annotations
|
|
91
|
+
|
|
92
|
+
import re
|
|
93
|
+
from dataclasses import dataclass, field
|
|
94
|
+
from typing import Callable, Iterable
|
|
95
|
+
|
|
96
|
+
from coord.claim import BranchLookup, Claim, find_work_claim
|
|
97
|
+
from coord.models import Board
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"TRACKING_ISSUE_LABEL",
|
|
102
|
+
"WorkOrderError",
|
|
103
|
+
"WorkOrderNode",
|
|
104
|
+
"WorkOrder",
|
|
105
|
+
"parse_work_order",
|
|
106
|
+
"render_work_order",
|
|
107
|
+
"replace_work_order_section",
|
|
108
|
+
"parse_sub_issues",
|
|
109
|
+
"render_sub_issues",
|
|
110
|
+
"replace_sub_issues_section",
|
|
111
|
+
"remove_sub_issues_section",
|
|
112
|
+
"milestone_work_order_membership",
|
|
113
|
+
"validate_milestone_membership",
|
|
114
|
+
"FrontierEntry",
|
|
115
|
+
"BlockedNode",
|
|
116
|
+
"Frontier",
|
|
117
|
+
"ready_frontier",
|
|
118
|
+
"ProgressStatus",
|
|
119
|
+
"compute_progress",
|
|
120
|
+
"render_progress",
|
|
121
|
+
"parse_progress",
|
|
122
|
+
"replace_progress_section",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
# #645 task 5: the tracking-issue convention. A milestone's tracking issue —
|
|
126
|
+
# the issue whose body carries the `## Work order` block above and whose
|
|
127
|
+
# comment stream is the milestone's decision log — is identified by this
|
|
128
|
+
# label, assigned to the milestone itself. This repo has used ``"epic"`` for
|
|
129
|
+
# that role in practice since before #645 codified it (see #767/#884); a
|
|
130
|
+
# future `--milestone-chat` session (#645 task 2) reads/writes the tracking
|
|
131
|
+
# issue found via ``label:epic milestone:<title>``, and can create one
|
|
132
|
+
# (`coord issue create --label epic --milestone ...`) when a milestone
|
|
133
|
+
# doesn't have one yet.
|
|
134
|
+
TRACKING_ISSUE_LABEL = "epic"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class WorkOrderError(ValueError):
|
|
138
|
+
"""A `## Work order` block failed validation.
|
|
139
|
+
|
|
140
|
+
The message always names the offending issue and the violated
|
|
141
|
+
constraint (duplicate node, unknown annotation key, an ``after`` edge to
|
|
142
|
+
an undeclared issue, a dependency cycle, or milestone-membership
|
|
143
|
+
mismatch) so a human can fix the tracking-issue body without having to
|
|
144
|
+
re-derive what went wrong.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class WorkOrderNode:
|
|
150
|
+
"""One `- [ ] #N {...}` line from the work-order block."""
|
|
151
|
+
|
|
152
|
+
issue_number: int
|
|
153
|
+
group: str | None = None
|
|
154
|
+
after: tuple[int, ...] = field(default_factory=tuple)
|
|
155
|
+
checked: bool = False # `- [x]` vs `- [ ]` in the source block
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class WorkOrder:
|
|
160
|
+
"""The parsed `## Work order` block: a DAG of :class:`WorkOrderNode`."""
|
|
161
|
+
|
|
162
|
+
nodes: tuple[WorkOrderNode, ...] = field(default_factory=tuple)
|
|
163
|
+
|
|
164
|
+
def node(self, issue_number: int) -> WorkOrderNode | None:
|
|
165
|
+
return next((n for n in self.nodes if n.issue_number == issue_number), None)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def issue_numbers(self) -> tuple[int, ...]:
|
|
169
|
+
return tuple(n.issue_number for n in self.nodes)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── Parsing ──────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
_HEADING_RE = re.compile(r"^#{1,6}\s*Work order\s*$", re.IGNORECASE)
|
|
175
|
+
# #1008: the epic's child-issue checklist — same grammar, different heading.
|
|
176
|
+
_SUB_ISSUES_HEADING_RE = re.compile(r"^#{1,6}\s*Sub-issues\s*$", re.IGNORECASE)
|
|
177
|
+
# #1061: the `[ ]`/`[x]` checkbox is now optional — `checked` was parsed,
|
|
178
|
+
# preserved, and rendered but never read for readiness (`ready_frontier`
|
|
179
|
+
# keys entirely off live `terminal_issues`), so it's decorative and the
|
|
180
|
+
# grammar is migrating to drop it (`- #N {...}` instead of `- [ ] #N {...}`).
|
|
181
|
+
# Both forms parse identically during the migration; `coord milestone sync`
|
|
182
|
+
# is what rewrites existing bodies to the checkbox-free form.
|
|
183
|
+
_ITEM_RE = re.compile(r"^-\s*(?:\[([ xX])\]\s*)?#(\d+)\s*(\{([^}]*)\})?")
|
|
184
|
+
# Splits `key: value` pairs on commas that precede the *next* key, so an
|
|
185
|
+
# `after: #762,#763` value (itself comma-separated) isn't cut mid-list.
|
|
186
|
+
_PAIR_RE = re.compile(r"(\w+)\s*:\s*(.*?)(?=,\s*\w+\s*:|$)")
|
|
187
|
+
_AFTER_ITEM_RE = re.compile(r"#?(\d+)")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parse_work_order(body: str) -> WorkOrder:
|
|
191
|
+
"""Parse the `## Work order` block out of a tracking-issue body.
|
|
192
|
+
|
|
193
|
+
Returns an empty :class:`WorkOrder` (no nodes) when the body has no
|
|
194
|
+
`## Work order` heading — callers decide whether an empty work order is
|
|
195
|
+
an error in their context.
|
|
196
|
+
|
|
197
|
+
Raises :class:`WorkOrderError` for:
|
|
198
|
+
- a checklist-shaped line that doesn't match the `#N` convention
|
|
199
|
+
- the same issue number declared more than once
|
|
200
|
+
- an unknown annotation key (only ``group`` and ``after`` are defined)
|
|
201
|
+
- a malformed ``after`` entry (not `#N` / `N`)
|
|
202
|
+
- an ``after`` edge to an issue not itself declared in this block
|
|
203
|
+
- a dependency cycle
|
|
204
|
+
"""
|
|
205
|
+
return _parse_checklist_section(body, _HEADING_RE, "work order")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def parse_sub_issues(body: str) -> WorkOrder:
|
|
209
|
+
"""Parse the `## Sub-issues` block out of an epic tracking-issue body (#1008).
|
|
210
|
+
|
|
211
|
+
Mirrors :func:`parse_work_order` exactly — same checklist grammar
|
|
212
|
+
(`- [ ] #N {group: ..., after: ...}`), same validation (duplicates,
|
|
213
|
+
unknown annotation keys, malformed/undeclared ``after`` targets, cycles)
|
|
214
|
+
— only the section heading (`## Sub-issues` vs `## Work order`) and the
|
|
215
|
+
error-message label differ. Returns an empty :class:`WorkOrder` when the
|
|
216
|
+
body has no `## Sub-issues` heading. Reuses :class:`WorkOrder` /
|
|
217
|
+
:class:`WorkOrderNode` rather than introducing parallel types since the
|
|
218
|
+
shape is identical; ``coord milestone add-child`` is the write-side
|
|
219
|
+
counterpart (mirrors ``coord milestone write-order``'s relationship to
|
|
220
|
+
:func:`parse_work_order`).
|
|
221
|
+
"""
|
|
222
|
+
return _parse_checklist_section(body, _SUB_ISSUES_HEADING_RE, "sub-issues")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def milestone_work_order_membership(issues: Iterable[dict]) -> list[dict]:
|
|
226
|
+
"""Minimal ``milestone_work_orders`` projection: which tracking issue (if
|
|
227
|
+
any) each issue's ``## Work order`` block claims as a member — nothing
|
|
228
|
+
about readiness.
|
|
229
|
+
|
|
230
|
+
#2040: ``coord.serve_app``'s ``/board`` handler computes a RICHER
|
|
231
|
+
``milestone_work_orders`` (ready/blocked/next-up per node, via
|
|
232
|
+
:func:`ready_frontier` over a live :class:`~coord.models.Board` +
|
|
233
|
+
``Config``) for the TUI's Pipeline cards — that version needs I/O this
|
|
234
|
+
function deliberately avoids (a board snapshot, repo config). The ONE
|
|
235
|
+
consumer of ``milestone_work_orders`` outside that HTTP wire shape is
|
|
236
|
+
:func:`coord.drive_state.project`, and it only ever reads
|
|
237
|
+
``tracking_issue`` plus membership (``nodes[].issue_number``) to resolve
|
|
238
|
+
``IssueState.milestone_tracking_issue`` — so this gives
|
|
239
|
+
:class:`~coord.drive_state.BoardFetcher`'s daemon-host path (which has no
|
|
240
|
+
HTTP round trip to lean on) the same answer with no extra I/O beyond the
|
|
241
|
+
``issues`` rows it already has.
|
|
242
|
+
|
|
243
|
+
*issues* is a ``/board``-shaped ``issues`` list (each dict carrying at
|
|
244
|
+
least ``repo_name``, ``number``, ``labels``, ``body``; ``milestone_title``
|
|
245
|
+
is optional). Same fail-open posture as the HTTP handler's block: a
|
|
246
|
+
tracking issue (``TRACKING_ISSUE_LABEL``) with no body, an unparseable
|
|
247
|
+
``## Work order`` block, or an empty one is skipped rather than raising —
|
|
248
|
+
one bad epic must never blank the whole projection.
|
|
249
|
+
"""
|
|
250
|
+
out: list[dict] = []
|
|
251
|
+
for ti in issues:
|
|
252
|
+
labels = ti.get("labels") or []
|
|
253
|
+
if TRACKING_ISSUE_LABEL not in labels:
|
|
254
|
+
continue
|
|
255
|
+
repo_name = ti.get("repo_name") or ""
|
|
256
|
+
if not repo_name:
|
|
257
|
+
continue
|
|
258
|
+
try:
|
|
259
|
+
wo = parse_work_order(ti.get("body") or "")
|
|
260
|
+
except Exception: # noqa: BLE001 — bad work order: skip this tracking issue
|
|
261
|
+
continue
|
|
262
|
+
if not wo.nodes:
|
|
263
|
+
continue
|
|
264
|
+
out.append({
|
|
265
|
+
"repo_name": repo_name,
|
|
266
|
+
"tracking_issue": ti.get("number"),
|
|
267
|
+
"milestone_title": ti.get("milestone_title") or "",
|
|
268
|
+
"nodes": [{"issue_number": n.issue_number} for n in wo.nodes],
|
|
269
|
+
})
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_checklist_section(
|
|
274
|
+
body: str, heading_re: re.Pattern[str], label: str
|
|
275
|
+
) -> WorkOrder:
|
|
276
|
+
"""Shared implementation behind :func:`parse_work_order` /
|
|
277
|
+
:func:`parse_sub_issues`: find *heading_re*'s section in *body*, parse
|
|
278
|
+
its `- [ ] #N {...}` lines into nodes, then validate (no duplicates,
|
|
279
|
+
every ``after`` target declared in the same section, no cycle).
|
|
280
|
+
*label* (e.g. ``"work order"`` / ``"sub-issues"``) is folded into every
|
|
281
|
+
:class:`WorkOrderError` message so a failure is traceable to the right
|
|
282
|
+
section of the tracking-issue body.
|
|
283
|
+
"""
|
|
284
|
+
lines = body.splitlines()
|
|
285
|
+
start = None
|
|
286
|
+
for i, line in enumerate(lines):
|
|
287
|
+
if heading_re.match(line.strip()):
|
|
288
|
+
start = i + 1
|
|
289
|
+
break
|
|
290
|
+
if start is None:
|
|
291
|
+
return WorkOrder(nodes=())
|
|
292
|
+
|
|
293
|
+
nodes: list[WorkOrderNode] = []
|
|
294
|
+
seen: set[int] = set()
|
|
295
|
+
for line in lines[start:]:
|
|
296
|
+
stripped = line.strip()
|
|
297
|
+
if not stripped:
|
|
298
|
+
continue
|
|
299
|
+
if stripped.startswith("#"):
|
|
300
|
+
# A markdown heading — the section has ended.
|
|
301
|
+
break
|
|
302
|
+
m = _ITEM_RE.match(stripped)
|
|
303
|
+
if not m:
|
|
304
|
+
if stripped.startswith("-"):
|
|
305
|
+
raise WorkOrderError(
|
|
306
|
+
f"{label}: unparseable line: {stripped!r} "
|
|
307
|
+
"(expected '- [ ] #N {annotations}')"
|
|
308
|
+
)
|
|
309
|
+
continue
|
|
310
|
+
checked = m.group(1) is not None and m.group(1).lower() == "x"
|
|
311
|
+
issue_number = int(m.group(2))
|
|
312
|
+
if issue_number in seen:
|
|
313
|
+
raise WorkOrderError(
|
|
314
|
+
f"{label}: #{issue_number} is declared more than once"
|
|
315
|
+
)
|
|
316
|
+
seen.add(issue_number)
|
|
317
|
+
group, after = _parse_annotation(issue_number, m.group(4) or "", label)
|
|
318
|
+
nodes.append(WorkOrderNode(issue_number, group, tuple(after), checked))
|
|
319
|
+
|
|
320
|
+
numbers = {n.issue_number for n in nodes}
|
|
321
|
+
for n in nodes:
|
|
322
|
+
for target in n.after:
|
|
323
|
+
if target not in numbers:
|
|
324
|
+
raise WorkOrderError(
|
|
325
|
+
f"{label}: #{n.issue_number} has after:#{target}, "
|
|
326
|
+
f"but #{target} is not declared in the {label} block"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
_check_cycles(nodes, label)
|
|
330
|
+
return WorkOrder(nodes=tuple(nodes))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _parse_annotation(
|
|
334
|
+
issue_number: int, raw: str, label: str = "work order"
|
|
335
|
+
) -> tuple[str | None, list[int]]:
|
|
336
|
+
raw = raw.strip()
|
|
337
|
+
if not raw:
|
|
338
|
+
return None, []
|
|
339
|
+
group: str | None = None
|
|
340
|
+
after: list[int] = []
|
|
341
|
+
matched_any = False
|
|
342
|
+
for pair in _PAIR_RE.finditer(raw):
|
|
343
|
+
key = pair.group(1).strip().lower()
|
|
344
|
+
value = pair.group(2).strip().rstrip(",").strip()
|
|
345
|
+
matched_any = True
|
|
346
|
+
if key == "group":
|
|
347
|
+
group = value
|
|
348
|
+
elif key == "after":
|
|
349
|
+
after = _parse_after_list(issue_number, value, label)
|
|
350
|
+
else:
|
|
351
|
+
raise WorkOrderError(
|
|
352
|
+
f"{label}: #{issue_number} has unknown annotation key "
|
|
353
|
+
f"{key!r} (expected 'group' or 'after')"
|
|
354
|
+
)
|
|
355
|
+
if not matched_any:
|
|
356
|
+
raise WorkOrderError(
|
|
357
|
+
f"{label}: #{issue_number} has an unparseable annotation "
|
|
358
|
+
f"{{{raw}}}"
|
|
359
|
+
)
|
|
360
|
+
return group, after
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _parse_after_list(
|
|
364
|
+
issue_number: int, value: str, label: str = "work order"
|
|
365
|
+
) -> list[int]:
|
|
366
|
+
items: list[int] = []
|
|
367
|
+
for chunk in value.split(","):
|
|
368
|
+
chunk = chunk.strip()
|
|
369
|
+
if not chunk:
|
|
370
|
+
continue
|
|
371
|
+
m = _AFTER_ITEM_RE.fullmatch(chunk)
|
|
372
|
+
if not m:
|
|
373
|
+
raise WorkOrderError(
|
|
374
|
+
f"{label}: #{issue_number} has a malformed after-entry "
|
|
375
|
+
f"{chunk!r} (expected '#N')"
|
|
376
|
+
)
|
|
377
|
+
items.append(int(m.group(1)))
|
|
378
|
+
return items
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def render_work_order(work_order: WorkOrder, *, checkbox: bool = True) -> str:
|
|
382
|
+
"""Render *work_order* back into checklist lines (no `## Work order` heading).
|
|
383
|
+
|
|
384
|
+
Inverse of the checklist half of :func:`parse_work_order` — round-trips
|
|
385
|
+
through it: ``parse_work_order(f"## Work order\\n{render_work_order(wo)}")
|
|
386
|
+
== wo``. Used by :func:`replace_work_order_section` and by
|
|
387
|
+
``coord milestone write-order`` (#770) to persist a chat-proposed order.
|
|
388
|
+
|
|
389
|
+
Heading-agnostic (renders checklist lines only), so it doubles as the
|
|
390
|
+
render step for the `## Sub-issues` checklist (#1008) too — see the
|
|
391
|
+
:func:`render_sub_issues` alias.
|
|
392
|
+
|
|
393
|
+
*checkbox* defaults to ``True`` — preserves every existing caller's
|
|
394
|
+
output (notably ``coord milestone add-child``'s `## Sub-issues`
|
|
395
|
+
rendering) byte-for-byte. Pass ``checkbox=False`` for the #1061
|
|
396
|
+
checkbox-free grammar (`- #N {...}`, no `[ ]`/`[x]`) — what ``coord
|
|
397
|
+
milestone sync`` writes back, since the box was never read for
|
|
398
|
+
readiness (see the module docstring) and is being dropped rather than
|
|
399
|
+
kept in sync.
|
|
400
|
+
"""
|
|
401
|
+
lines: list[str] = []
|
|
402
|
+
for n in work_order.nodes:
|
|
403
|
+
bits: list[str] = []
|
|
404
|
+
if n.group:
|
|
405
|
+
bits.append(f"group: {n.group}")
|
|
406
|
+
if n.after:
|
|
407
|
+
bits.append("after: " + ",".join(f"#{d}" for d in n.after))
|
|
408
|
+
annotation = f" {{{', '.join(bits)}}}" if bits else ""
|
|
409
|
+
box_prefix = f"[{'x' if n.checked else ' '}] " if checkbox else ""
|
|
410
|
+
lines.append(f"- {box_prefix}#{n.issue_number}{annotation}")
|
|
411
|
+
return "\n".join(lines)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# #1008: `render_work_order` already renders checklist lines only — no
|
|
415
|
+
# heading — so it's identical to what a `## Sub-issues` block needs. Aliased
|
|
416
|
+
# (rather than duplicated) so `coord milestone add-child` reads naturally
|
|
417
|
+
# alongside `parse_sub_issues` / `replace_sub_issues_section`.
|
|
418
|
+
render_sub_issues = render_work_order
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def replace_work_order_section(body: str, new_block: str) -> str:
|
|
422
|
+
"""Idempotently insert/replace the `## Work order` section of *body*.
|
|
423
|
+
|
|
424
|
+
``new_block`` is checklist text only (e.g. :func:`render_work_order`'s
|
|
425
|
+
output) — no heading line. Mirrors :func:`parse_work_order`'s own
|
|
426
|
+
section-boundary rule so a round-trip through both functions agrees on
|
|
427
|
+
where the block starts and ends: if *body* already has a `## Work
|
|
428
|
+
order` heading, everything from the line after it up to the next
|
|
429
|
+
markdown heading (or EOF) is replaced in place, and everything else in
|
|
430
|
+
*body* is preserved verbatim — re-running with the same *new_block* is a
|
|
431
|
+
no-op, and re-running with a revised one updates rather than
|
|
432
|
+
duplicates. If *body* has no such heading, `## Work order\\n` +
|
|
433
|
+
*new_block* is appended at the end (blank-line separated).
|
|
434
|
+
"""
|
|
435
|
+
return _splice_checklist_section(body, new_block, _HEADING_RE, "## Work order")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def replace_sub_issues_section(body: str, new_block: str) -> str:
|
|
439
|
+
"""Idempotently insert/replace the `## Sub-issues` section of *body* (#1008).
|
|
440
|
+
|
|
441
|
+
Mirrors :func:`replace_work_order_section` exactly — same
|
|
442
|
+
splice-not-duplicate semantics — keyed on a `## Sub-issues` heading
|
|
443
|
+
instead of `## Work order`, so the two sections can coexist in one
|
|
444
|
+
tracking-issue body and each splice helper only ever touches its own
|
|
445
|
+
section. ``coord milestone add-child`` is the write path that calls
|
|
446
|
+
this (mirrors ``coord milestone write-order`` calling
|
|
447
|
+
:func:`replace_work_order_section`).
|
|
448
|
+
"""
|
|
449
|
+
return _splice_checklist_section(
|
|
450
|
+
body, new_block, _SUB_ISSUES_HEADING_RE, "## Sub-issues"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def remove_sub_issues_section(body: str) -> str:
|
|
455
|
+
"""Fully retire the `## Sub-issues` section of *body* (#1061).
|
|
456
|
+
|
|
457
|
+
The GitHub sub-issues API now owns child membership (:mod:`coord.
|
|
458
|
+
parentage_github`'s ``GitHubParentage``, backfilled per-epic by ``coord
|
|
459
|
+
milestone sync``) and `## Work order` already carries the same
|
|
460
|
+
issue-number list plus the DAG annotations the API can't express — so a
|
|
461
|
+
separately-maintained `## Sub-issues` checklist (#1008) is pure
|
|
462
|
+
duplication going forward. Unlike :func:`replace_sub_issues_section`
|
|
463
|
+
(which always keeps the heading line, even for an empty block, because
|
|
464
|
+
that function's job is "replace the content"), this drops the heading
|
|
465
|
+
line too, so nothing is left behind for a later `parse_sub_issues` call
|
|
466
|
+
to find. No-op — returns *body* unchanged — when there's no `##
|
|
467
|
+
Sub-issues` heading to remove.
|
|
468
|
+
"""
|
|
469
|
+
lines = body.splitlines()
|
|
470
|
+
start = None
|
|
471
|
+
for i, line in enumerate(lines):
|
|
472
|
+
if _SUB_ISSUES_HEADING_RE.match(line.strip()):
|
|
473
|
+
start = i
|
|
474
|
+
break
|
|
475
|
+
if start is None:
|
|
476
|
+
return body
|
|
477
|
+
|
|
478
|
+
end = len(lines)
|
|
479
|
+
for i in range(start + 1, len(lines)):
|
|
480
|
+
if lines[i].strip().startswith("#"):
|
|
481
|
+
end = i
|
|
482
|
+
break
|
|
483
|
+
|
|
484
|
+
# Also swallow one blank separator line immediately before the heading
|
|
485
|
+
# (the blank line `_splice_checklist_section` inserts between sections)
|
|
486
|
+
# so removal doesn't leave a stray double-blank gap behind.
|
|
487
|
+
head_start = start - 1 if start > 0 and not lines[start - 1].strip() else start
|
|
488
|
+
|
|
489
|
+
new_lines = lines[:head_start] + lines[end:]
|
|
490
|
+
result = "\n".join(new_lines).rstrip("\n")
|
|
491
|
+
return f"{result}\n" if result else ""
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _splice_checklist_section(
|
|
495
|
+
body: str, new_block: str, heading_re: re.Pattern[str], heading_line: str
|
|
496
|
+
) -> str:
|
|
497
|
+
"""Shared implementation behind :func:`replace_work_order_section` /
|
|
498
|
+
:func:`replace_sub_issues_section`."""
|
|
499
|
+
lines = body.splitlines()
|
|
500
|
+
start = None
|
|
501
|
+
for i, line in enumerate(lines):
|
|
502
|
+
if heading_re.match(line.strip()):
|
|
503
|
+
start = i + 1
|
|
504
|
+
break
|
|
505
|
+
|
|
506
|
+
new_block_lines = new_block.strip("\n").splitlines() if new_block.strip() else []
|
|
507
|
+
|
|
508
|
+
if start is None:
|
|
509
|
+
prefix = body.rstrip("\n")
|
|
510
|
+
sep = "\n\n" if prefix else ""
|
|
511
|
+
rendered = "\n".join([heading_line, *new_block_lines])
|
|
512
|
+
return f"{prefix}{sep}{rendered}\n"
|
|
513
|
+
|
|
514
|
+
end = len(lines)
|
|
515
|
+
for i in range(start, len(lines)):
|
|
516
|
+
if lines[i].strip().startswith("#"):
|
|
517
|
+
end = i
|
|
518
|
+
break
|
|
519
|
+
|
|
520
|
+
tail = lines[end:]
|
|
521
|
+
if new_block_lines and tail and tail[0].strip():
|
|
522
|
+
tail = ["", *tail]
|
|
523
|
+
|
|
524
|
+
new_lines = lines[:start] + new_block_lines + tail
|
|
525
|
+
return "\n".join(new_lines).rstrip("\n") + "\n"
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _check_cycles(nodes: list[WorkOrderNode], label: str = "work order") -> None:
|
|
529
|
+
by_number = {n.issue_number: n for n in nodes}
|
|
530
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
531
|
+
color = {n.issue_number: WHITE for n in nodes}
|
|
532
|
+
|
|
533
|
+
def visit(num: int, path: list[int]) -> None:
|
|
534
|
+
color[num] = GRAY
|
|
535
|
+
path.append(num)
|
|
536
|
+
for dep in by_number[num].after:
|
|
537
|
+
if color[dep] == GRAY:
|
|
538
|
+
cycle = path[path.index(dep):] + [dep]
|
|
539
|
+
raise WorkOrderError(
|
|
540
|
+
f"{label}: dependency cycle: "
|
|
541
|
+
+ " -> ".join(f"#{n}" for n in cycle)
|
|
542
|
+
)
|
|
543
|
+
if color[dep] == WHITE:
|
|
544
|
+
visit(dep, path)
|
|
545
|
+
path.pop()
|
|
546
|
+
color[num] = BLACK
|
|
547
|
+
|
|
548
|
+
for n in nodes:
|
|
549
|
+
if color[n.issue_number] == WHITE:
|
|
550
|
+
visit(n.issue_number, [])
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def validate_milestone_membership(
|
|
554
|
+
work_order: WorkOrder,
|
|
555
|
+
milestone_issue_numbers: set[int],
|
|
556
|
+
) -> None:
|
|
557
|
+
"""Raise :class:`WorkOrderError` if a node isn't an issue under the milestone.
|
|
558
|
+
|
|
559
|
+
``milestone_issue_numbers`` is the set of issue numbers the caller has
|
|
560
|
+
confirmed belong to the target milestone (open *or* closed — milestone
|
|
561
|
+
membership doesn't change when an issue closes, and a completed
|
|
562
|
+
dependency is an expected, valid node). Fetching that set is the
|
|
563
|
+
caller's job (``coord.github_ops`` / ``coord milestone order``) so this
|
|
564
|
+
stays a pure function tests can call with a plain seeded set.
|
|
565
|
+
"""
|
|
566
|
+
for n in work_order.nodes:
|
|
567
|
+
if n.issue_number not in milestone_issue_numbers:
|
|
568
|
+
raise WorkOrderError(
|
|
569
|
+
f"work order: #{n.issue_number} is not an issue under this "
|
|
570
|
+
"milestone"
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ── Ready frontier ───────────────────────────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@dataclass(frozen=True)
|
|
578
|
+
class FrontierEntry:
|
|
579
|
+
"""A node eligible to dispatch right now."""
|
|
580
|
+
|
|
581
|
+
issue_number: int
|
|
582
|
+
group: str | None = None
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@dataclass(frozen=True)
|
|
586
|
+
class BlockedNode:
|
|
587
|
+
"""A node that is not yet ready, and why."""
|
|
588
|
+
|
|
589
|
+
issue_number: int
|
|
590
|
+
waiting_on_deps: tuple[int, ...] = field(default_factory=tuple)
|
|
591
|
+
claim: Claim | None = None
|
|
592
|
+
conflict: bool = False
|
|
593
|
+
|
|
594
|
+
@property
|
|
595
|
+
def reason(self) -> str:
|
|
596
|
+
if self.waiting_on_deps:
|
|
597
|
+
deps = ", ".join(f"#{d}" for d in self.waiting_on_deps)
|
|
598
|
+
return f"waiting on {deps}"
|
|
599
|
+
if self.claim is not None:
|
|
600
|
+
return f"claimed ({self.claim.source})"
|
|
601
|
+
if self.conflict:
|
|
602
|
+
return "conflict-blocked"
|
|
603
|
+
return "blocked"
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@dataclass(frozen=True)
|
|
607
|
+
class Frontier:
|
|
608
|
+
"""The result of :func:`ready_frontier`: what can dispatch now, and what can't."""
|
|
609
|
+
|
|
610
|
+
ready: tuple[FrontierEntry, ...] = field(default_factory=tuple)
|
|
611
|
+
blocked: tuple[BlockedNode, ...] = field(default_factory=tuple)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def ready_frontier(
|
|
615
|
+
work_order: WorkOrder,
|
|
616
|
+
board: Board,
|
|
617
|
+
*,
|
|
618
|
+
repo_name: str,
|
|
619
|
+
repo_github: str,
|
|
620
|
+
terminal_issues: set[int],
|
|
621
|
+
branch_lookup: BranchLookup | None = None,
|
|
622
|
+
conflict_checker: Callable[[int], bool] | None = None,
|
|
623
|
+
) -> Frontier:
|
|
624
|
+
"""Compute the ready frontier of ``work_order`` given the current board.
|
|
625
|
+
|
|
626
|
+
A node is **ready** when:
|
|
627
|
+
1. it hasn't itself already reached a merged/terminal state
|
|
628
|
+
(``issue_number not in terminal_issues``);
|
|
629
|
+
2. every issue in its ``after`` set has (``after`` ⊆ ``terminal_issues``);
|
|
630
|
+
3. it isn't already claimed — reuses :func:`coord.claim.find_work_claim`
|
|
631
|
+
against the live ``board`` (+ remote branch lookup);
|
|
632
|
+
4. it isn't conflict-blocked — ``conflict_checker(issue_number)``, when
|
|
633
|
+
given, returning ``True`` means "another in-flight assignment likely
|
|
634
|
+
touches the same files." No default conflict inference exists yet
|
|
635
|
+
(today it's an LLM judgment made in ``coord.brain.propose``, not a
|
|
636
|
+
pure function) — omit ``conflict_checker`` to skip this check.
|
|
637
|
+
|
|
638
|
+
Nodes already in ``terminal_issues`` are dropped from both ``ready`` and
|
|
639
|
+
``blocked`` — they're finished work, not part of the frontier either
|
|
640
|
+
way. Pure function: no GitHub or subprocess calls (``find_work_claim``'s
|
|
641
|
+
remote-branch check is injected via ``branch_lookup``, defaulting to the
|
|
642
|
+
live `gh` lookup only when the caller doesn't supply one).
|
|
643
|
+
"""
|
|
644
|
+
ready: list[FrontierEntry] = []
|
|
645
|
+
blocked: list[BlockedNode] = []
|
|
646
|
+
for node in work_order.nodes:
|
|
647
|
+
if node.issue_number in terminal_issues:
|
|
648
|
+
continue
|
|
649
|
+
|
|
650
|
+
waiting = tuple(d for d in node.after if d not in terminal_issues)
|
|
651
|
+
if waiting:
|
|
652
|
+
blocked.append(BlockedNode(node.issue_number, waiting_on_deps=waiting))
|
|
653
|
+
continue
|
|
654
|
+
|
|
655
|
+
claim = find_work_claim(
|
|
656
|
+
node.issue_number,
|
|
657
|
+
repo_name,
|
|
658
|
+
repo_github,
|
|
659
|
+
board,
|
|
660
|
+
branch_lookup=branch_lookup,
|
|
661
|
+
)
|
|
662
|
+
if claim is not None:
|
|
663
|
+
blocked.append(BlockedNode(node.issue_number, claim=claim))
|
|
664
|
+
continue
|
|
665
|
+
|
|
666
|
+
if conflict_checker is not None and conflict_checker(node.issue_number):
|
|
667
|
+
blocked.append(BlockedNode(node.issue_number, conflict=True))
|
|
668
|
+
continue
|
|
669
|
+
|
|
670
|
+
ready.append(FrontierEntry(node.issue_number, node.group))
|
|
671
|
+
|
|
672
|
+
return Frontier(ready=tuple(ready), blocked=tuple(blocked))
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# ── Progress (#1412) ─────────────────────────────────────────────────────────
|
|
676
|
+
# A *derived* section, never hand-authored: `coord milestone sync-progress`
|
|
677
|
+
# (one-shot) and the daemon tick (`coord.serve_app._milestone_progress_tick`,
|
|
678
|
+
# every registered milestone) render it from exactly the same terminal-state +
|
|
679
|
+
# `ready_frontier` inputs `coord milestone order` already prints, then splice
|
|
680
|
+
# it into the tracking issue under its own `## Progress` heading — `##
|
|
681
|
+
# Work order` (and, transitionally, `## Sub-issues`) are never touched.
|
|
682
|
+
|
|
683
|
+
_PROGRESS_HEADING_RE = re.compile(r"^#{1,6}\s*Progress\s*$", re.IGNORECASE)
|
|
684
|
+
_PROGRESS_ITEM_RE = re.compile(
|
|
685
|
+
r"^-\s*\[(?P<status>[a-zA-Z-]+)\]\s*#(?P<num>\d+)"
|
|
686
|
+
r"(?:\s*\(group\s+(?P<group>[^)]+)\))?"
|
|
687
|
+
r"(?:\s*—\s*(?P<detail>.+))?\s*$"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@dataclass(frozen=True)
|
|
692
|
+
class ProgressStatus:
|
|
693
|
+
"""One `## Work order` node's derived, live status (#1412).
|
|
694
|
+
|
|
695
|
+
``status`` is one of ``"done"`` (the issue is in ``terminal_issues``),
|
|
696
|
+
``"ready"`` (in :func:`ready_frontier`'s ``ready`` set right now), or
|
|
697
|
+
``"blocked"`` (in its ``blocked`` set — ``detail`` carries *why*, taken
|
|
698
|
+
verbatim from :attr:`BlockedNode.reason`: waiting on a dependency,
|
|
699
|
+
claimed, or conflict-blocked). There is no fourth bucket: every
|
|
700
|
+
non-terminal node in a work order is, by construction, either ready or
|
|
701
|
+
blocked (:func:`ready_frontier` partitions its whole input that way).
|
|
702
|
+
"""
|
|
703
|
+
|
|
704
|
+
issue_number: int
|
|
705
|
+
status: str
|
|
706
|
+
group: str | None = None
|
|
707
|
+
detail: str | None = None
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def compute_progress(
|
|
711
|
+
work_order: WorkOrder,
|
|
712
|
+
frontier: Frontier,
|
|
713
|
+
terminal_issues: set[int] | frozenset[int],
|
|
714
|
+
) -> tuple[ProgressStatus, ...]:
|
|
715
|
+
"""Derive one :class:`ProgressStatus` per *work_order* node.
|
|
716
|
+
|
|
717
|
+
Pure — takes the same ``terminal_issues`` and a *frontier* already
|
|
718
|
+
computed by :func:`ready_frontier` (which itself needs a live
|
|
719
|
+
:class:`~coord.models.Board`) rather than computing either itself, so
|
|
720
|
+
this stays exactly as easy to unit-test as the rest of the module and
|
|
721
|
+
never becomes a second place that decides what's ready. Iterates
|
|
722
|
+
``work_order.nodes`` (not the frontier) so every declared node gets a
|
|
723
|
+
status line, in declared order, even ones the frontier dropped for
|
|
724
|
+
being terminal.
|
|
725
|
+
"""
|
|
726
|
+
ready_numbers = {e.issue_number for e in frontier.ready}
|
|
727
|
+
blocked_by_number = {b.issue_number: b for b in frontier.blocked}
|
|
728
|
+
statuses: list[ProgressStatus] = []
|
|
729
|
+
for n in work_order.nodes:
|
|
730
|
+
if n.issue_number in terminal_issues:
|
|
731
|
+
statuses.append(ProgressStatus(n.issue_number, "done", n.group))
|
|
732
|
+
elif n.issue_number in ready_numbers:
|
|
733
|
+
statuses.append(ProgressStatus(n.issue_number, "ready", n.group))
|
|
734
|
+
else:
|
|
735
|
+
blocked = blocked_by_number.get(n.issue_number)
|
|
736
|
+
detail = blocked.reason if blocked is not None else "blocked"
|
|
737
|
+
statuses.append(
|
|
738
|
+
ProgressStatus(n.issue_number, "blocked", n.group, detail)
|
|
739
|
+
)
|
|
740
|
+
return tuple(statuses)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def render_progress(statuses: Iterable[ProgressStatus], *, generated_at: str) -> str:
|
|
744
|
+
"""Render *statuses* into the `## Progress` section body (no heading).
|
|
745
|
+
|
|
746
|
+
``generated_at`` is an ISO-8601 timestamp string the caller stamps at
|
|
747
|
+
write time (kept out of this pure function's control so it's trivial to
|
|
748
|
+
assert exact output in a test) — labelled explicitly as generated so a
|
|
749
|
+
reader never mistakes this for a second hand-editable plan. One `- [done|
|
|
750
|
+
ready|blocked] #N (group G) — detail` line per status, in the order
|
|
751
|
+
given, plus a one-line summary. :func:`parse_progress` is the inverse of
|
|
752
|
+
the per-status lines (the summary line and the generated-by note are
|
|
753
|
+
deliberately not round-tripped — they're always fully re-derived from
|
|
754
|
+
the statuses, never compared for the idempotent-no-op check).
|
|
755
|
+
"""
|
|
756
|
+
statuses = tuple(statuses)
|
|
757
|
+
lines = [
|
|
758
|
+
"_Generated by `coord milestone sync-progress` from live board state "
|
|
759
|
+
f"as of {generated_at} — do not hand-edit; the plan itself lives in "
|
|
760
|
+
"`## Work order`._",
|
|
761
|
+
"",
|
|
762
|
+
]
|
|
763
|
+
for s in statuses:
|
|
764
|
+
bits = [f"#{s.issue_number}"]
|
|
765
|
+
if s.group:
|
|
766
|
+
bits.append(f"(group {s.group})")
|
|
767
|
+
line = f"- [{s.status}] " + " ".join(bits)
|
|
768
|
+
if s.detail:
|
|
769
|
+
line += f" — {s.detail}"
|
|
770
|
+
lines.append(line)
|
|
771
|
+
|
|
772
|
+
if statuses:
|
|
773
|
+
done = sum(1 for s in statuses if s.status == "done")
|
|
774
|
+
ready = sum(1 for s in statuses if s.status == "ready")
|
|
775
|
+
blocked = sum(1 for s in statuses if s.status == "blocked")
|
|
776
|
+
lines.append("")
|
|
777
|
+
lines.append(
|
|
778
|
+
f"**{done}/{len(statuses)} done** · {ready} ready · {blocked} blocked"
|
|
779
|
+
)
|
|
780
|
+
return "\n".join(lines)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def parse_progress(body: str) -> tuple[ProgressStatus, ...]:
|
|
784
|
+
"""Parse the `## Progress` section of *body* back into status entries.
|
|
785
|
+
|
|
786
|
+
Only used to detect whether re-rendering would actually change anything
|
|
787
|
+
(the idempotent-no-op check ``coord milestone sync-progress``/the daemon
|
|
788
|
+
tick perform before writing — see the module docstring) — never for
|
|
789
|
+
readiness; that always comes from a fresh :func:`ready_frontier` call.
|
|
790
|
+
Ignores the generated-by note and the summary line (neither is a status
|
|
791
|
+
line), and — like :func:`_parse_checklist_section` — stops at the next
|
|
792
|
+
markdown heading. Returns an empty tuple when *body* has no `##
|
|
793
|
+
Progress` heading. Unlike :func:`parse_work_order`/:func:`parse_sub_issues`,
|
|
794
|
+
an unparseable line is silently skipped rather than raising: this
|
|
795
|
+
section is machine-written, never hand-authored, so a stray line here
|
|
796
|
+
is not a user error to report — worst case it's simply not compared.
|
|
797
|
+
"""
|
|
798
|
+
lines = body.splitlines()
|
|
799
|
+
start = None
|
|
800
|
+
for i, line in enumerate(lines):
|
|
801
|
+
if _PROGRESS_HEADING_RE.match(line.strip()):
|
|
802
|
+
start = i + 1
|
|
803
|
+
break
|
|
804
|
+
if start is None:
|
|
805
|
+
return ()
|
|
806
|
+
|
|
807
|
+
statuses: list[ProgressStatus] = []
|
|
808
|
+
for line in lines[start:]:
|
|
809
|
+
stripped = line.strip()
|
|
810
|
+
if not stripped:
|
|
811
|
+
continue
|
|
812
|
+
if stripped.startswith("#"):
|
|
813
|
+
break
|
|
814
|
+
m = _PROGRESS_ITEM_RE.match(stripped)
|
|
815
|
+
if not m:
|
|
816
|
+
continue
|
|
817
|
+
statuses.append(
|
|
818
|
+
ProgressStatus(
|
|
819
|
+
issue_number=int(m.group("num")),
|
|
820
|
+
status=m.group("status"),
|
|
821
|
+
group=m.group("group"),
|
|
822
|
+
detail=m.group("detail"),
|
|
823
|
+
)
|
|
824
|
+
)
|
|
825
|
+
return tuple(statuses)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def replace_progress_section(body: str, new_block: str) -> str:
|
|
829
|
+
"""Idempotently insert/replace the `## Progress` section of *body* (#1412).
|
|
830
|
+
|
|
831
|
+
Mirrors :func:`replace_work_order_section`/:func:`replace_sub_issues_section`
|
|
832
|
+
exactly — same splice-not-duplicate semantics, keyed on a `## Progress`
|
|
833
|
+
heading — so this section coexists with `## Work order` and (during the
|
|
834
|
+
#1061 migration) `## Sub-issues` without any of the three splice helpers
|
|
835
|
+
ever touching another's region. ``new_block`` is
|
|
836
|
+
:func:`render_progress`'s output (checklist text only, no heading).
|
|
837
|
+
"""
|
|
838
|
+
return _splice_checklist_section(
|
|
839
|
+
body, new_block, _PROGRESS_HEADING_RE, "## Progress"
|
|
840
|
+
)
|