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/plans.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"""Pure aggregation for ``coord plans`` — the data backbone for the TUI
|
|
2
|
+
"Plans" panel (#975).
|
|
3
|
+
|
|
4
|
+
Every public function here is **pure / board-driven**: all inputs are plain
|
|
5
|
+
Python values (milestone dicts, issue bodies, a :class:`~coord.models.Board`,
|
|
6
|
+
sets of open/terminal issue numbers). No GitHub calls, no subprocess, no I/O.
|
|
7
|
+
That keeps the stats cheap to unit-test with seeded fixtures.
|
|
8
|
+
|
|
9
|
+
The CLI command (``coord/commands/plans.py``) fetches the raw inputs from
|
|
10
|
+
GitHub and calls :func:`aggregate_plan` for each milestone in each configured
|
|
11
|
+
repo.
|
|
12
|
+
|
|
13
|
+
Design decisions (#974):
|
|
14
|
+
- **tracking issue = "epic"-labelled issue under the milestone**. This is the
|
|
15
|
+
same convention established in :mod:`coord.milestone_order`
|
|
16
|
+
(``TRACKING_ISSUE_LABEL = "epic"``). An open milestone with no such issue is
|
|
17
|
+
reported with ``has_work_order = False`` and a ``"no_work_order"``
|
|
18
|
+
``needs_you`` signal.
|
|
19
|
+
- **Tracking-epic lookup considers closed epics too.** A milestone can stay
|
|
20
|
+
open on GitHub after its tracking epic has been closed (e.g. all
|
|
21
|
+
work-order nodes finished and someone tidied up the epic before
|
|
22
|
+
remembering to close the milestone). Reporting that as ``"no_work_order"``
|
|
23
|
+
would be backwards — it would read as "nobody wrote a work order yet" when
|
|
24
|
+
the real state is "this plan is done." So :func:`aggregate_repo_plans`
|
|
25
|
+
accepts an optional ``closed_tracking_issues`` list (closed, ``"epic"``-
|
|
26
|
+
labelled issues, e.g. from :func:`coord.github_ops.get_closed_epics`) and
|
|
27
|
+
merges it with ``open_issues`` before searching for the tracking issue —
|
|
28
|
+
mirroring the open+closed scan :func:`coord.serve_app` already does for the
|
|
29
|
+
same "find the epic" operation (the #795 Phase 3b per-milestone work-order
|
|
30
|
+
projection).
|
|
31
|
+
- **Work order scope only**. ``done``/``total`` counts count work-order
|
|
32
|
+
nodes, not all issues under the milestone (the work order is the declared
|
|
33
|
+
scope of automated dispatch).
|
|
34
|
+
- **No remote branch check during aggregation**. The ``branch_lookup``
|
|
35
|
+
parameter to :func:`~coord.milestone_order.ready_frontier` defaults to
|
|
36
|
+
``lambda *_: []`` here — a background aggregate over many milestones should
|
|
37
|
+
not spawn N ``gh`` calls for branch checks. ``in_flight`` is derived purely
|
|
38
|
+
from the board's active assignments.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
|
|
46
|
+
from coord.issue_store import diff_audit_goals
|
|
47
|
+
from coord.milestone_order import (
|
|
48
|
+
TRACKING_ISSUE_LABEL,
|
|
49
|
+
WorkOrder,
|
|
50
|
+
parse_work_order,
|
|
51
|
+
ready_frontier,
|
|
52
|
+
)
|
|
53
|
+
from coord.models import Board
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"TRACKING_ISSUE_LABEL",
|
|
58
|
+
"PlanEntry",
|
|
59
|
+
"find_tracking_issue",
|
|
60
|
+
"aggregate_plan",
|
|
61
|
+
"aggregate_repo_plans",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Data model ───────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class PlanEntry:
|
|
70
|
+
"""Aggregated stats for one GitHub milestone in one repo.
|
|
71
|
+
|
|
72
|
+
All counts are scoped to the ``## Work order`` block of the milestone's
|
|
73
|
+
tracking epic. When there is no work order the counts are all 0 and
|
|
74
|
+
``needs_you`` contains ``"no_work_order"``.
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
repo: coord-local repo name (``coordinator.yml`` key).
|
|
78
|
+
title: Milestone title on GitHub.
|
|
79
|
+
milestone_number: GitHub milestone number.
|
|
80
|
+
tracking_issue: GitHub issue number of the epic/tracking issue, or
|
|
81
|
+
``None`` when none exists under this milestone.
|
|
82
|
+
May be a closed issue when the caller supplies
|
|
83
|
+
``closed_tracking_issues`` to
|
|
84
|
+
:func:`aggregate_repo_plans`.
|
|
85
|
+
has_work_order: ``True`` iff the tracking body carries a parsed
|
|
86
|
+
``## Work order`` block with ≥1 node.
|
|
87
|
+
ready_frontier: Number of work-order nodes ready to dispatch now
|
|
88
|
+
(dependencies met, unclaimed, not terminal).
|
|
89
|
+
blocked: Number of nodes blocked by unmet dependencies or a
|
|
90
|
+
conflict (i.e. *not* claimed by an active assignment).
|
|
91
|
+
in_flight: Number of nodes that are claimed by an active board
|
|
92
|
+
assignment or a remote ``issue-N-*`` branch.
|
|
93
|
+
done: Number of terminal (closed) work-order nodes.
|
|
94
|
+
total: Total number of nodes in the work order.
|
|
95
|
+
needs_you: Ordered list of attention signals. Possible values:
|
|
96
|
+
|
|
97
|
+
``"no_work_order"``
|
|
98
|
+
Open milestone with no parseable work order.
|
|
99
|
+
``"ready_waiting"``
|
|
100
|
+
≥1 ready-frontier entry exists to dispatch.
|
|
101
|
+
``"stalled"``
|
|
102
|
+
Open, has a work order, but nothing is ready *or*
|
|
103
|
+
in-flight and the milestone is not done.
|
|
104
|
+
``"chat_pending"``
|
|
105
|
+
A ``type="milestone-chat"`` assignment is
|
|
106
|
+
currently active (running/pending) against the
|
|
107
|
+
tracking issue (#976) — an operator opened
|
|
108
|
+
``coord milestone chat`` and hasn't finalized
|
|
109
|
+
it yet. Orthogonal to the other three signals:
|
|
110
|
+
it can appear alongside any of them (or alone
|
|
111
|
+
on an otherwise "done" milestone).
|
|
112
|
+
|
|
113
|
+
outcome_run_number, outcome_met, outcome_partial, outcome_gap,
|
|
114
|
+
outcome_bottom_line, outcome_diff_summary:
|
|
115
|
+
#886 Phase 2 — the latest Milestone Outcome Audit
|
|
116
|
+
(``--audit-of``) verdict for this milestone's epic,
|
|
117
|
+
independent of the issue-closed counts above. All
|
|
118
|
+
``None`` when no audit has ever run against this
|
|
119
|
+
epic. ``outcome_diff_summary`` is a pre-rendered
|
|
120
|
+
one-line delta vs the previous run (e.g. "closed:
|
|
121
|
+
tests.rs split; still open: #550"), ``None`` on the
|
|
122
|
+
first run (nothing to diff against yet).
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
repo: str
|
|
126
|
+
title: str
|
|
127
|
+
milestone_number: int
|
|
128
|
+
tracking_issue: int | None
|
|
129
|
+
has_work_order: bool
|
|
130
|
+
ready_frontier: int
|
|
131
|
+
blocked: int
|
|
132
|
+
in_flight: int
|
|
133
|
+
done: int
|
|
134
|
+
total: int
|
|
135
|
+
needs_you: list[str] = field(default_factory=list)
|
|
136
|
+
outcome_run_number: int | None = None
|
|
137
|
+
outcome_met: int | None = None
|
|
138
|
+
outcome_partial: int | None = None
|
|
139
|
+
outcome_gap: int | None = None
|
|
140
|
+
outcome_bottom_line: str | None = None
|
|
141
|
+
outcome_diff_summary: str | None = None
|
|
142
|
+
|
|
143
|
+
def to_dict(self) -> dict:
|
|
144
|
+
"""Serialise to a plain dict suitable for ``json.dumps``."""
|
|
145
|
+
return {
|
|
146
|
+
"repo": self.repo,
|
|
147
|
+
"title": self.title,
|
|
148
|
+
"milestone_number": self.milestone_number,
|
|
149
|
+
"tracking_issue": self.tracking_issue,
|
|
150
|
+
"has_work_order": self.has_work_order,
|
|
151
|
+
"ready_frontier": self.ready_frontier,
|
|
152
|
+
"blocked": self.blocked,
|
|
153
|
+
"in_flight": self.in_flight,
|
|
154
|
+
"done": self.done,
|
|
155
|
+
"total": self.total,
|
|
156
|
+
"needs_you": list(self.needs_you),
|
|
157
|
+
"outcome_run_number": self.outcome_run_number,
|
|
158
|
+
"outcome_met": self.outcome_met,
|
|
159
|
+
"outcome_partial": self.outcome_partial,
|
|
160
|
+
"outcome_gap": self.outcome_gap,
|
|
161
|
+
"outcome_bottom_line": self.outcome_bottom_line,
|
|
162
|
+
"outcome_diff_summary": self.outcome_diff_summary,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── Pure helpers ─────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def find_tracking_issue(
|
|
170
|
+
milestone_number: int,
|
|
171
|
+
issues: list[dict],
|
|
172
|
+
) -> dict | None:
|
|
173
|
+
"""Return the first issue under *milestone_number* labelled ``"epic"``.
|
|
174
|
+
|
|
175
|
+
``issues`` is a raw issue-dict list — each item has at least ``"number"``,
|
|
176
|
+
``"labels"`` (list of ``{"name": ...}`` dicts), and ``"milestone"``
|
|
177
|
+
(``None`` or ``{"number": ...}``), matching the shape returned by
|
|
178
|
+
:func:`coord.github_ops.get_open_issues` / :func:`~coord.github_ops.get_closed_epics`.
|
|
179
|
+
This function does not care about issue state — callers that want a
|
|
180
|
+
closed epic to still count as the tracking issue (see
|
|
181
|
+
:func:`aggregate_repo_plans`) pass a list that already includes closed
|
|
182
|
+
epics alongside the open issues.
|
|
183
|
+
|
|
184
|
+
Returns ``None`` when no epic is found for this milestone. The caller
|
|
185
|
+
decides how to report a missing tracking issue.
|
|
186
|
+
"""
|
|
187
|
+
for issue in issues:
|
|
188
|
+
ms = issue.get("milestone") or {}
|
|
189
|
+
if ms.get("number") != milestone_number:
|
|
190
|
+
continue
|
|
191
|
+
labels = [lbl.get("name", "") for lbl in (issue.get("labels") or [])]
|
|
192
|
+
if TRACKING_ISSUE_LABEL in labels:
|
|
193
|
+
return issue
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _has_pending_chat(
|
|
198
|
+
board: Board,
|
|
199
|
+
repo_name: str,
|
|
200
|
+
tracking_issue_number: int | None,
|
|
201
|
+
) -> bool:
|
|
202
|
+
"""True iff an active ``type="milestone-chat"`` assignment targets
|
|
203
|
+
*tracking_issue_number* in *repo_name* (#976).
|
|
204
|
+
|
|
205
|
+
``coord milestone chat <repo> <tracking_issue>``
|
|
206
|
+
(:func:`coord.milestone_chat.dispatch_milestone_chat`) records the
|
|
207
|
+
session as a normal :class:`~coord.models.Assignment` with
|
|
208
|
+
``issue_number=tracking_issue_number`` — the same board state every
|
|
209
|
+
other claim check reads, so this needs no new plumbing. Mirrors the
|
|
210
|
+
``a.status == "failed"`` skip in :func:`coord.claim.find_work_claim`: a
|
|
211
|
+
failed chat dispatch never actually opened, so it isn't "pending."
|
|
212
|
+
``None`` tracking issue (no epic yet) can't have a chat dispatched
|
|
213
|
+
against it — always ``False``.
|
|
214
|
+
"""
|
|
215
|
+
if tracking_issue_number is None:
|
|
216
|
+
return False
|
|
217
|
+
return any(
|
|
218
|
+
a.type == "milestone-chat"
|
|
219
|
+
and a.repo_name == repo_name
|
|
220
|
+
and a.issue_number == tracking_issue_number
|
|
221
|
+
and a.status != "failed"
|
|
222
|
+
for a in board.active
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _latest_audit_outcome(
|
|
227
|
+
board: Board, repo_name: str, tracking_issue_number: int | None,
|
|
228
|
+
) -> dict | None:
|
|
229
|
+
"""The latest ``--audit-of`` verdict for this milestone's epic (#886
|
|
230
|
+
Phase 2), or ``None`` when no audit has ever run against it.
|
|
231
|
+
|
|
232
|
+
Scans ``board.completed`` for ``type="audit"`` rows keyed by
|
|
233
|
+
``(repo_name, tracking_issue_number)`` — the epic's own issue number
|
|
234
|
+
doubles as the audit assignment's ``issue_number`` (see #885's
|
|
235
|
+
``_dispatch_audit_of``) — and picks the highest ``audit_run_number``.
|
|
236
|
+
When a second-highest run also exists, pre-renders a short delta string
|
|
237
|
+
(via :func:`coord.issue_store.diff_audit_goals`) so callers (the TUI)
|
|
238
|
+
don't need to re-derive a diff from two raw JSON blobs. Board-driven and
|
|
239
|
+
pure, like the rest of this module — no DB access here even though the
|
|
240
|
+
Board's assignment rows ultimately came from one.
|
|
241
|
+
"""
|
|
242
|
+
if tracking_issue_number is None:
|
|
243
|
+
return None
|
|
244
|
+
runs = sorted(
|
|
245
|
+
(
|
|
246
|
+
a
|
|
247
|
+
for a in board.completed
|
|
248
|
+
if a.type == "audit"
|
|
249
|
+
and a.repo_name == repo_name
|
|
250
|
+
and a.issue_number == tracking_issue_number
|
|
251
|
+
and a.audit_run_number is not None
|
|
252
|
+
),
|
|
253
|
+
key=lambda a: a.audit_run_number,
|
|
254
|
+
)
|
|
255
|
+
if not runs:
|
|
256
|
+
return None
|
|
257
|
+
latest = runs[-1]
|
|
258
|
+
try:
|
|
259
|
+
latest_goals = json.loads(latest.audit_goals_json or "[]")
|
|
260
|
+
except (TypeError, ValueError):
|
|
261
|
+
latest_goals = []
|
|
262
|
+
met = sum(1 for g in latest_goals if g.get("verdict") == "met")
|
|
263
|
+
gap = sum(1 for g in latest_goals if g.get("verdict") == "gap")
|
|
264
|
+
total = len(latest_goals)
|
|
265
|
+
partial = total - met - gap
|
|
266
|
+
|
|
267
|
+
diff_summary: str | None = None
|
|
268
|
+
if len(runs) >= 2:
|
|
269
|
+
prev = runs[-2]
|
|
270
|
+
try:
|
|
271
|
+
prev_goals = json.loads(prev.audit_goals_json or "[]")
|
|
272
|
+
except (TypeError, ValueError):
|
|
273
|
+
prev_goals = []
|
|
274
|
+
diff = diff_audit_goals(prev_goals, latest_goals)
|
|
275
|
+
parts = []
|
|
276
|
+
if diff["closed"]:
|
|
277
|
+
parts.append(f"closed: {', '.join(diff['closed'])}")
|
|
278
|
+
if diff["regressed"]:
|
|
279
|
+
parts.append(f"REGRESSED: {', '.join(diff['regressed'])}")
|
|
280
|
+
if diff["still_open"]:
|
|
281
|
+
parts.append(f"still open: {', '.join(diff['still_open'])}")
|
|
282
|
+
if parts:
|
|
283
|
+
diff_summary = (
|
|
284
|
+
f"v{prev.audit_run_number}→v{latest.audit_run_number}: "
|
|
285
|
+
+ "; ".join(parts)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
"run_number": latest.audit_run_number,
|
|
290
|
+
"met": met,
|
|
291
|
+
"partial": partial,
|
|
292
|
+
"gap": gap,
|
|
293
|
+
"total": total,
|
|
294
|
+
"bottom_line": latest.audit_bottom_line or "",
|
|
295
|
+
"diff_summary": diff_summary,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def aggregate_plan(
|
|
300
|
+
*,
|
|
301
|
+
milestone_title: str,
|
|
302
|
+
milestone_number: int,
|
|
303
|
+
repo_name: str,
|
|
304
|
+
repo_github: str,
|
|
305
|
+
tracking_issue_number: int | None,
|
|
306
|
+
tracking_body: str | None,
|
|
307
|
+
board: Board,
|
|
308
|
+
open_issue_numbers: set[int],
|
|
309
|
+
) -> PlanEntry:
|
|
310
|
+
"""Compute a :class:`PlanEntry` for one milestone.
|
|
311
|
+
|
|
312
|
+
Parameters
|
|
313
|
+
----------
|
|
314
|
+
milestone_title:
|
|
315
|
+
GitHub milestone title.
|
|
316
|
+
milestone_number:
|
|
317
|
+
GitHub milestone number.
|
|
318
|
+
repo_name:
|
|
319
|
+
coord-local repo name.
|
|
320
|
+
repo_github:
|
|
321
|
+
GitHub slug (``owner/repo``).
|
|
322
|
+
tracking_issue_number:
|
|
323
|
+
Issue number of the epic/tracking issue, or ``None``.
|
|
324
|
+
tracking_body:
|
|
325
|
+
Body text of the tracking issue, or ``None`` when there is no
|
|
326
|
+
tracking issue. An empty string is treated the same as ``None``
|
|
327
|
+
(no work order).
|
|
328
|
+
board:
|
|
329
|
+
Current board state, used to find active assignments for in-flight
|
|
330
|
+
detection.
|
|
331
|
+
open_issue_numbers:
|
|
332
|
+
Set of GitHub issue numbers that are currently *open* under this
|
|
333
|
+
milestone. Any work-order node **not** in this set is assumed
|
|
334
|
+
terminal (closed).
|
|
335
|
+
|
|
336
|
+
Returns
|
|
337
|
+
-------
|
|
338
|
+
PlanEntry
|
|
339
|
+
Aggregated stats. When ``tracking_body`` is ``None`` / empty, or the
|
|
340
|
+
body has no ``## Work order`` block, ``has_work_order`` is ``False``
|
|
341
|
+
and all counts are 0.
|
|
342
|
+
"""
|
|
343
|
+
chat_pending = _has_pending_chat(board, repo_name, tracking_issue_number)
|
|
344
|
+
# #886 Phase 2: the audit outcome is independent of the work-order/issue
|
|
345
|
+
# counts below (the whole point — completion judged by goals met, not
|
|
346
|
+
# issues closed), so it's computed once and attached to EVERY return path,
|
|
347
|
+
# including the has_work_order=False ones.
|
|
348
|
+
outcome = _latest_audit_outcome(board, repo_name, tracking_issue_number)
|
|
349
|
+
outcome_kwargs = (
|
|
350
|
+
{
|
|
351
|
+
"outcome_run_number": outcome["run_number"],
|
|
352
|
+
"outcome_met": outcome["met"],
|
|
353
|
+
"outcome_partial": outcome["partial"],
|
|
354
|
+
"outcome_gap": outcome["gap"],
|
|
355
|
+
"outcome_bottom_line": outcome["bottom_line"],
|
|
356
|
+
"outcome_diff_summary": outcome["diff_summary"],
|
|
357
|
+
}
|
|
358
|
+
if outcome is not None
|
|
359
|
+
else {}
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
_no_work_order_signals = ["no_work_order"]
|
|
363
|
+
if chat_pending:
|
|
364
|
+
_no_work_order_signals.append("chat_pending")
|
|
365
|
+
_no_work_order = PlanEntry(
|
|
366
|
+
repo=repo_name,
|
|
367
|
+
title=milestone_title,
|
|
368
|
+
milestone_number=milestone_number,
|
|
369
|
+
tracking_issue=tracking_issue_number,
|
|
370
|
+
has_work_order=False,
|
|
371
|
+
ready_frontier=0,
|
|
372
|
+
blocked=0,
|
|
373
|
+
in_flight=0,
|
|
374
|
+
done=0,
|
|
375
|
+
total=0,
|
|
376
|
+
needs_you=_no_work_order_signals,
|
|
377
|
+
**outcome_kwargs,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if not tracking_body:
|
|
381
|
+
return _no_work_order
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
work_order: WorkOrder = parse_work_order(tracking_body)
|
|
385
|
+
except Exception: # noqa: BLE001 — malformed work order; treat as absent
|
|
386
|
+
return _no_work_order
|
|
387
|
+
|
|
388
|
+
if not work_order.nodes:
|
|
389
|
+
return _no_work_order
|
|
390
|
+
|
|
391
|
+
# Terminal = declared in the work order but not currently open.
|
|
392
|
+
terminal_issues: set[int] = {
|
|
393
|
+
n.issue_number
|
|
394
|
+
for n in work_order.nodes
|
|
395
|
+
if n.issue_number not in open_issue_numbers
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
total = len(work_order.nodes)
|
|
399
|
+
done = len(terminal_issues)
|
|
400
|
+
|
|
401
|
+
frontier = ready_frontier(
|
|
402
|
+
work_order,
|
|
403
|
+
board,
|
|
404
|
+
repo_name=repo_name,
|
|
405
|
+
repo_github=repo_github,
|
|
406
|
+
terminal_issues=terminal_issues,
|
|
407
|
+
# No remote branch check here: a bulk aggregate must not spawn N gh
|
|
408
|
+
# calls for branch lookups. In-flight is derived from the board only.
|
|
409
|
+
branch_lookup=lambda *_: [],
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
ready_count = len(frontier.ready)
|
|
413
|
+
in_flight_count = sum(1 for b in frontier.blocked if b.claim is not None)
|
|
414
|
+
blocked_count = sum(1 for b in frontier.blocked if b.claim is None)
|
|
415
|
+
|
|
416
|
+
needs_you: list[str] = []
|
|
417
|
+
if ready_count > 0:
|
|
418
|
+
needs_you.append("ready_waiting")
|
|
419
|
+
elif done < total and in_flight_count == 0:
|
|
420
|
+
needs_you.append("stalled")
|
|
421
|
+
if chat_pending:
|
|
422
|
+
needs_you.append("chat_pending")
|
|
423
|
+
|
|
424
|
+
return PlanEntry(
|
|
425
|
+
repo=repo_name,
|
|
426
|
+
title=milestone_title,
|
|
427
|
+
milestone_number=milestone_number,
|
|
428
|
+
tracking_issue=tracking_issue_number,
|
|
429
|
+
has_work_order=True,
|
|
430
|
+
ready_frontier=ready_count,
|
|
431
|
+
blocked=blocked_count,
|
|
432
|
+
in_flight=in_flight_count,
|
|
433
|
+
done=done,
|
|
434
|
+
total=total,
|
|
435
|
+
needs_you=needs_you,
|
|
436
|
+
**outcome_kwargs,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def aggregate_repo_plans(
|
|
441
|
+
*,
|
|
442
|
+
repo_name: str,
|
|
443
|
+
repo_github: str,
|
|
444
|
+
milestones: list[dict],
|
|
445
|
+
open_issues: list[dict],
|
|
446
|
+
board: Board,
|
|
447
|
+
closed_tracking_issues: list[dict] | None = None,
|
|
448
|
+
issue_body_fetcher: "Callable[[int], str | None] | None" = None,
|
|
449
|
+
) -> list[PlanEntry]:
|
|
450
|
+
"""Aggregate plans for all milestones in one repo.
|
|
451
|
+
|
|
452
|
+
Parameters
|
|
453
|
+
----------
|
|
454
|
+
repo_name:
|
|
455
|
+
coord-local repo name.
|
|
456
|
+
repo_github:
|
|
457
|
+
GitHub slug (``owner/repo``).
|
|
458
|
+
milestones:
|
|
459
|
+
List of milestone dicts (``{number, title, ...}``), as returned by
|
|
460
|
+
:func:`coord.github_ops.get_repo_milestones`.
|
|
461
|
+
open_issues:
|
|
462
|
+
List of all open issue dicts for the repo, as returned by
|
|
463
|
+
:func:`coord.github_ops.get_open_issues`.
|
|
464
|
+
board:
|
|
465
|
+
Current board state.
|
|
466
|
+
closed_tracking_issues:
|
|
467
|
+
Optional list of *closed* ``"epic"``-labelled issue dicts (e.g. from
|
|
468
|
+
:func:`coord.github_ops.get_closed_epics`), merged with
|
|
469
|
+
``open_issues`` when searching for each milestone's tracking issue.
|
|
470
|
+
Without this, a milestone whose tracking epic was closed while the
|
|
471
|
+
milestone stayed open would be reported as ``"no_work_order"``
|
|
472
|
+
instead of reflecting its (likely done) work order. ``None``/``[]``
|
|
473
|
+
preserves the open-only lookup.
|
|
474
|
+
issue_body_fetcher:
|
|
475
|
+
Optional callable ``(issue_number: int) -> str | None`` that fetches
|
|
476
|
+
the body of a *single* issue by number. Required only when the
|
|
477
|
+
tracking epic's body is not already in ``open_issues`` /
|
|
478
|
+
``closed_tracking_issues`` (it always should be, but the hook lets
|
|
479
|
+
callers supply a pre-fetched body map for testing). When ``None``,
|
|
480
|
+
body is read from the matching entry in those snapshots only.
|
|
481
|
+
|
|
482
|
+
Returns
|
|
483
|
+
-------
|
|
484
|
+
list[PlanEntry]
|
|
485
|
+
One entry per milestone, in the same order as *milestones*.
|
|
486
|
+
"""
|
|
487
|
+
from typing import Callable # noqa: PLC0415 — keep import cheap
|
|
488
|
+
|
|
489
|
+
closed_tracking_issues = closed_tracking_issues or []
|
|
490
|
+
|
|
491
|
+
# Candidates for tracking-issue lookup: open issues + any closed epics
|
|
492
|
+
# supplied by the caller. Deliberately kept separate from open-issue-only
|
|
493
|
+
# terminal detection below (a closed epic isn't a work-order node).
|
|
494
|
+
tracking_candidates: list[dict] = open_issues + closed_tracking_issues
|
|
495
|
+
|
|
496
|
+
# Build a quick index: issue_number → body from the open + closed-epic
|
|
497
|
+
# snapshots.
|
|
498
|
+
body_index: dict[int, str] = {
|
|
499
|
+
i["number"]: (i.get("body") or "")
|
|
500
|
+
for i in tracking_candidates
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
# Build open-issue-numbers per milestone.
|
|
504
|
+
open_by_milestone: dict[int, set[int]] = {}
|
|
505
|
+
for issue in open_issues:
|
|
506
|
+
ms = issue.get("milestone") or {}
|
|
507
|
+
ms_num = ms.get("number")
|
|
508
|
+
if ms_num is not None:
|
|
509
|
+
open_by_milestone.setdefault(ms_num, set()).add(issue["number"])
|
|
510
|
+
|
|
511
|
+
entries: list[PlanEntry] = []
|
|
512
|
+
for ms in milestones:
|
|
513
|
+
ms_num: int = ms["number"]
|
|
514
|
+
ms_title: str = ms.get("title", f"Milestone #{ms_num}")
|
|
515
|
+
|
|
516
|
+
tracking = find_tracking_issue(ms_num, tracking_candidates)
|
|
517
|
+
tracking_number: int | None = tracking["number"] if tracking is not None else None
|
|
518
|
+
|
|
519
|
+
if tracking_number is not None:
|
|
520
|
+
body: str | None = body_index.get(tracking_number)
|
|
521
|
+
# Call the fetcher when the body is absent or empty — an empty
|
|
522
|
+
# string from the snapshot means the issue was returned without
|
|
523
|
+
# body text, so we fall back to a direct fetch if available.
|
|
524
|
+
if not body and issue_body_fetcher is not None:
|
|
525
|
+
body = issue_body_fetcher(tracking_number)
|
|
526
|
+
else:
|
|
527
|
+
body = None
|
|
528
|
+
|
|
529
|
+
open_nums = open_by_milestone.get(ms_num, set())
|
|
530
|
+
|
|
531
|
+
entry = aggregate_plan(
|
|
532
|
+
milestone_title=ms_title,
|
|
533
|
+
milestone_number=ms_num,
|
|
534
|
+
repo_name=repo_name,
|
|
535
|
+
repo_github=repo_github,
|
|
536
|
+
tracking_issue_number=tracking_number,
|
|
537
|
+
tracking_body=body,
|
|
538
|
+
board=board,
|
|
539
|
+
open_issue_numbers=open_nums,
|
|
540
|
+
)
|
|
541
|
+
entries.append(entry)
|
|
542
|
+
|
|
543
|
+
return entries
|
coord/platform_paths.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Cross-platform resolution of the coordinator's on-disk state root (#1156).
|
|
2
|
+
|
|
3
|
+
Every existing POSIX deployment (Linux daemon boxes, the fleet's agent
|
|
4
|
+
machines) keeps ``~/.coord`` exactly as it always has -- that value is baked
|
|
5
|
+
into every runbook, systemd unit, and support doc, and disturbing it would be
|
|
6
|
+
a needless back-compat break for the fleet that already runs on it.
|
|
7
|
+
|
|
8
|
+
Windows and macOS instead resolve through :mod:`platformdirs` to an OS-native
|
|
9
|
+
application-data directory rather than masquerading as a Unix dotfile under
|
|
10
|
+
``%USERPROFILE%``/``$HOME``.
|
|
11
|
+
|
|
12
|
+
This is the *one* seam the state-root constants across the package
|
|
13
|
+
(``coord.db.COORD_DIR``, ``coord.state.COORD_DIR``, ``coord.config.USER_CONFIG_PATH``,
|
|
14
|
+
``coord.agent.DEFAULT_STATE_DIR``) derive from, so the POSIX/Windows/macOS
|
|
15
|
+
decision lives in exactly one place instead of drifting across four
|
|
16
|
+
independent ``Path.home() / ".coord"`` literals.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
#: Platforms that get an OS-native directory via `platformdirs` instead of
|
|
25
|
+
#: the legacy `~/.coord` dotfile. Deliberately keyed on `sys.platform`, not
|
|
26
|
+
#: `os.name` -- macOS reports `os.name == "posix"` (and every POSIX import
|
|
27
|
+
#: this issue guards -- fcntl/termios/tty -- is present there too) but should
|
|
28
|
+
#: still land in `~/Library/Application Support/coord`, not `~/.coord`.
|
|
29
|
+
_NATIVE_DIR_PLATFORMS = ("win32", "darwin")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def default_coord_dir() -> Path:
|
|
33
|
+
"""Resolve the coordinator's state root for the current platform.
|
|
34
|
+
|
|
35
|
+
POSIX (Linux, and any other non-Windows/non-macOS *nix) keeps ``~/.coord``
|
|
36
|
+
for back-compat with every existing deployment. Windows and macOS resolve
|
|
37
|
+
through ``platformdirs`` to their OS-native application-data directory.
|
|
38
|
+
"""
|
|
39
|
+
if sys.platform in _NATIVE_DIR_PLATFORMS:
|
|
40
|
+
import platformdirs # noqa: PLC0415 -- keep this leaf module import-light on POSIX
|
|
41
|
+
|
|
42
|
+
return Path(platformdirs.user_data_dir("coord", appauthor=False))
|
|
43
|
+
return Path.home() / ".coord"
|
coord/pr_body_lint.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Pre-merge PR-body lint (#1196, hole 2).
|
|
2
|
+
|
|
3
|
+
GitHub's own closing-keyword magic (``Closes #N`` / ``Fixes #N`` / ``Resolves
|
|
4
|
+
#N`` in a merged PR's body) auto-closes the referenced issue on merge — and
|
|
5
|
+
it never calls :func:`coord.github_ops.close_issue`, so that function's
|
|
6
|
+
open-children guard (#1196) cannot stop it. A worker-authored PR is free to
|
|
7
|
+
ask an LLM to write ``Closes #<epic>`` in its body; nothing before this
|
|
8
|
+
scanned, linted, or rejected it (the gap #1196 calls out explicitly:
|
|
9
|
+
``merge_queue.py`` only controls the *keyword the coordinator itself
|
|
10
|
+
writes* via ``_briefing_body``/``CLOSES_ISSUE_TYPES``, not what a worker's
|
|
11
|
+
own ``gh pr create`` puts in the body).
|
|
12
|
+
|
|
13
|
+
This module is pure (no ``gh``/network calls) so the regex/rewrite logic is
|
|
14
|
+
unit-testable in isolation; :func:`coord.merge_queue.process` does the
|
|
15
|
+
network side (fetch body, check each referenced issue for open children via
|
|
16
|
+
``github_ops.has_open_children``, call this, write back via
|
|
17
|
+
``github_ops.edit_pr_body`` when anything changed).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
__all__ = ["find_closing_references", "downgrade_closing_keywords"]
|
|
25
|
+
|
|
26
|
+
# The GitHub closing-keyword vocabulary (case-insensitive): close/closes/
|
|
27
|
+
# closed, fix/fixes/fixed, resolve/resolves/resolved. Deliberately matches
|
|
28
|
+
# only the simple "<keyword> #<N>" shape actually used in this codebase's
|
|
29
|
+
# own PR bodies (`_briefing_body`, `_find_or_open_pr`) — GitHub also accepts
|
|
30
|
+
# comma-lists ("Closes #1, #2") and cross-repo refs, but every body this
|
|
31
|
+
# coordinator or its workers produce uses one keyword per issue reference.
|
|
32
|
+
_CLOSING_RE = re.compile(
|
|
33
|
+
r"\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b(\s+)#(\d+)",
|
|
34
|
+
re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_closing_references(body: str) -> list[int]:
|
|
39
|
+
"""Return every issue number referenced by a GitHub closing keyword in
|
|
40
|
+
*body*, in first-seen order (duplicates removed)."""
|
|
41
|
+
seen: list[int] = []
|
|
42
|
+
for m in _CLOSING_RE.finditer(body or ""):
|
|
43
|
+
n = int(m.group(3))
|
|
44
|
+
if n not in seen:
|
|
45
|
+
seen.append(n)
|
|
46
|
+
return seen
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def downgrade_closing_keywords(body: str, numbers: set[int]) -> tuple[str, list[int]]:
|
|
50
|
+
"""Rewrite ``<keyword> #N`` -> ``Refs #N`` for every ``N`` in *numbers*.
|
|
51
|
+
|
|
52
|
+
Returns ``(possibly-rewritten body, sorted list of numbers actually
|
|
53
|
+
downgraded)``. References to issue numbers not in *numbers* are left
|
|
54
|
+
exactly as written (including the original keyword's casing) — this
|
|
55
|
+
keeps the common case (a regular issue with no children) untouched.
|
|
56
|
+
"""
|
|
57
|
+
downgraded: list[int] = []
|
|
58
|
+
|
|
59
|
+
def _sub(m: re.Match) -> str:
|
|
60
|
+
n = int(m.group(3))
|
|
61
|
+
if n not in numbers:
|
|
62
|
+
return m.group(0)
|
|
63
|
+
downgraded.append(n)
|
|
64
|
+
return f"Refs{m.group(2)}#{n}"
|
|
65
|
+
|
|
66
|
+
new_body = _CLOSING_RE.sub(_sub, body or "")
|
|
67
|
+
return new_body, sorted(set(downgraded))
|