code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"""Server-side per-issue stage/gate projection (#550, generalizes #776/#778).
|
|
2
|
+
|
|
3
|
+
``coord-tui``'s ``tui/src/app/pipeline.rs`` independently re-derives every
|
|
4
|
+
stage/gate computation from the raw ``/board`` rows — ``stage_status_for``,
|
|
5
|
+
``merge_stage_status_for``, ``test_stage_status_for``, and
|
|
6
|
+
``issue_has_any_approved_review`` (which duplicates
|
|
7
|
+
``coord.merge_queue.has_approved_review``'s intent, but keyed by issue
|
|
8
|
+
number rather than branch). This module computes the same *DB-derivable*
|
|
9
|
+
subset of that logic once, in Python, so it can be injected into ``/board``
|
|
10
|
+
(``coord/serve_app.py``) and consumed by the TUI instead of re-implemented.
|
|
11
|
+
|
|
12
|
+
Deliberately excluded — genuinely TUI-session-local state with no server
|
|
13
|
+
equivalent, so it stays a client-side overlay on top of this projection:
|
|
14
|
+
|
|
15
|
+
* the optimistic "merge just dispatched" flag (``pipeline_inflight_merges``)
|
|
16
|
+
set the instant the Go button is pressed, before the DB round-trip lands;
|
|
17
|
+
* a locally-spawned Phase-1 build subprocess (``test_build_in_flight``);
|
|
18
|
+
* the CI-check cache the TUI itself polls via the GitHub API
|
|
19
|
+
(``pipeline_ci_checks``) — this module uses the server's own ``CiStore``
|
|
20
|
+
instead, which is a *different* (also valid) CI signal source already
|
|
21
|
+
wired into ``coord.merge_queue``'s gate evaluation.
|
|
22
|
+
|
|
23
|
+
Because local-SQLite-mode coord-tui (no ``coord serve`` daemon configured)
|
|
24
|
+
has no server to ask, the Rust functions this mirrors are NOT deleted —
|
|
25
|
+
they remain the local-mode fallback. The daemon path prefers this
|
|
26
|
+
projection when present. See #550 for the full rationale.
|
|
27
|
+
|
|
28
|
+
Pure computation: every function here takes already-loaded data and returns
|
|
29
|
+
plain values — no I/O, no side effects.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import Any, Protocol, runtime_checkable
|
|
35
|
+
|
|
36
|
+
from coord.models import CLOSES_ISSUE_TYPES
|
|
37
|
+
|
|
38
|
+
# ── Stage-status vocabulary — mirrors tui/src/app/pipeline.rs::StageStatus ──
|
|
39
|
+
PENDING = "pending"
|
|
40
|
+
ACTIVE = "active"
|
|
41
|
+
DONE = "done"
|
|
42
|
+
FAILED = "failed"
|
|
43
|
+
STALE = "stale"
|
|
44
|
+
SKIPPED = "skipped"
|
|
45
|
+
|
|
46
|
+
_MERGED_STATES = frozenset({"merged"})
|
|
47
|
+
_ACTIVE_MERGE_STATES = frozenset({"open", "queued"})
|
|
48
|
+
_FAILED_MERGE_STATES = frozenset({"failed", "human_required"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@runtime_checkable
|
|
52
|
+
class _AssignmentLike(Protocol):
|
|
53
|
+
assignment_id: str | None
|
|
54
|
+
type: str
|
|
55
|
+
status: str
|
|
56
|
+
dispatched_at: float | None
|
|
57
|
+
review_verdict: str | None
|
|
58
|
+
review_of_assignment_id: str | None
|
|
59
|
+
test_state: str | None
|
|
60
|
+
repo_name: str
|
|
61
|
+
issue_number: int
|
|
62
|
+
acceptance_state: str | None
|
|
63
|
+
acceptance_total: int | None
|
|
64
|
+
acceptance_passed: int | None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── Helpers ──────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _latest_by_dispatch(assignments: list) -> Any | None:
|
|
71
|
+
"""Return the assignment with the max ``dispatched_at`` (None sorts last,
|
|
72
|
+
matching Rust's ``partial_cmp(...).unwrap_or(Equal)`` on an Option)."""
|
|
73
|
+
if not assignments:
|
|
74
|
+
return None
|
|
75
|
+
return max(
|
|
76
|
+
assignments,
|
|
77
|
+
key=lambda a: (a.dispatched_at is not None, a.dispatched_at or 0.0),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _issue_has_plan_assignment(assignments_for_issue: list) -> bool:
|
|
82
|
+
return any(a.type == "plan" for a in assignments_for_issue)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _work_ids_for_issue(assignments_for_issue: list, seed_work_id: str | None = None) -> set[str]:
|
|
86
|
+
"""Every ``CLOSES_ISSUE_TYPES`` assignment id for this issue, plus
|
|
87
|
+
*seed_work_id* when given (#292: a queue entry keyed to a work id whose
|
|
88
|
+
row has since been pruned from the board)."""
|
|
89
|
+
work_ids = {
|
|
90
|
+
a.assignment_id
|
|
91
|
+
for a in assignments_for_issue
|
|
92
|
+
if a.type in CLOSES_ISSUE_TYPES and a.assignment_id
|
|
93
|
+
}
|
|
94
|
+
if seed_work_id:
|
|
95
|
+
work_ids.add(seed_work_id)
|
|
96
|
+
return work_ids
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _review_verdict_events(assignments_for_issue: list, work_ids: set[str]) -> list:
|
|
100
|
+
"""Every verdict-bearing event connected to *work_ids*: dedicated
|
|
101
|
+
``type="review"`` rows scoped by ``review_of_assignment_id``, AND (#331)
|
|
102
|
+
a verdict stamped directly on a work row itself (self-approval /
|
|
103
|
+
PR-comment-fallback, no separate reviewer dispatched).
|
|
104
|
+
|
|
105
|
+
#2085 (non-blocking finding): shared by :func:`issue_has_any_approved_review`
|
|
106
|
+
and the "review" stage branch of :func:`stage_status_for` so the two can
|
|
107
|
+
never disagree about which events count. Before this was factored out,
|
|
108
|
+
``stage_status_for``'s ``assignments_for_stage(..., "review", ...)`` only
|
|
109
|
+
ever matched ``type == "review"`` rows — a self-approval stamped directly
|
|
110
|
+
on a work row (no dedicated review assignment at all) was invisible to
|
|
111
|
+
it, so ``has_approved_review: True`` could sit next to
|
|
112
|
+
``stages["review"]`` reading ``PENDING``/``SKIPPED`` in the same
|
|
113
|
+
projection — the same READY-vs-refused disagreement #2085 is about, just
|
|
114
|
+
via a different path.
|
|
115
|
+
"""
|
|
116
|
+
return [
|
|
117
|
+
a
|
|
118
|
+
for a in assignments_for_issue
|
|
119
|
+
if a.review_verdict
|
|
120
|
+
and (
|
|
121
|
+
(a.type == "review" and a.review_of_assignment_id in work_ids)
|
|
122
|
+
or a.assignment_id in work_ids
|
|
123
|
+
)
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def assignments_for_stage(
|
|
128
|
+
assignments_for_issue: list,
|
|
129
|
+
stage: str,
|
|
130
|
+
*,
|
|
131
|
+
require_plan: bool,
|
|
132
|
+
) -> list:
|
|
133
|
+
"""Mirrors ``pipeline.rs::assignments_for_stage``.
|
|
134
|
+
|
|
135
|
+
When the pipeline has no Plan stage in this issue's strip (no global
|
|
136
|
+
``require_plan`` and no ``type="plan"`` assignment for this issue),
|
|
137
|
+
plan-typed assignments fold into "work" so a ``--plan-only`` dispatch
|
|
138
|
+
without ``require_plan`` doesn't disappear from the Work stage.
|
|
139
|
+
"""
|
|
140
|
+
fold_plan_into_work = (
|
|
141
|
+
stage == "work"
|
|
142
|
+
and not require_plan
|
|
143
|
+
and not _issue_has_plan_assignment(assignments_for_issue)
|
|
144
|
+
)
|
|
145
|
+
out = []
|
|
146
|
+
for a in assignments_for_issue:
|
|
147
|
+
t = a.type or "work"
|
|
148
|
+
if fold_plan_into_work:
|
|
149
|
+
if t in ("work", "plan"):
|
|
150
|
+
out.append(a)
|
|
151
|
+
elif t == stage:
|
|
152
|
+
out.append(a)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def upstream_max_dispatched_at(
|
|
157
|
+
assignments_for_issue: list,
|
|
158
|
+
stage: str,
|
|
159
|
+
stage_names: list[str],
|
|
160
|
+
*,
|
|
161
|
+
require_plan: bool,
|
|
162
|
+
) -> float | None:
|
|
163
|
+
"""Mirrors ``pipeline.rs::upstream_max_dispatched_at``."""
|
|
164
|
+
if stage not in stage_names:
|
|
165
|
+
return None
|
|
166
|
+
idx = stage_names.index(stage)
|
|
167
|
+
if idx == 0:
|
|
168
|
+
return None
|
|
169
|
+
best: float | None = None
|
|
170
|
+
for s in stage_names[:idx]:
|
|
171
|
+
for a in assignments_for_stage(assignments_for_issue, s, require_plan=require_plan):
|
|
172
|
+
if a.dispatched_at is not None:
|
|
173
|
+
best = a.dispatched_at if best is None else max(best, a.dispatched_at)
|
|
174
|
+
return best
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _has_active_conflict_fix(assignments_for_issue: list) -> bool:
|
|
178
|
+
"""Mirrors ``pipeline.rs::has_active_conflict_fix`` (#241)."""
|
|
179
|
+
return any(
|
|
180
|
+
a.type == "conflict-fix" and a.status in ("running", "pending")
|
|
181
|
+
for a in assignments_for_issue
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _has_active_smoke_session(assignments_for_issue: list) -> bool:
|
|
186
|
+
"""Mirrors ``pipeline.rs::has_active_smoke_session`` (#585)."""
|
|
187
|
+
return any(
|
|
188
|
+
a.type in ("smoke", "test-chat") and a.status in ("running", "pending")
|
|
189
|
+
for a in assignments_for_issue
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _ci_failed_for_entry(merge_entry: Any | None, ci_store: Any | None) -> bool:
|
|
194
|
+
"""Mirrors ``pipeline.rs::ci_failed_for_entry``, sourced from the
|
|
195
|
+
server's ``CiStore`` rather than the TUI's own poll cache."""
|
|
196
|
+
if ci_store is None or not getattr(ci_store, "is_available", False):
|
|
197
|
+
return False
|
|
198
|
+
if merge_entry is None or getattr(merge_entry, "pr_number", None) is None:
|
|
199
|
+
return False
|
|
200
|
+
from coord.ci_store import failed_checks # noqa: PLC0415
|
|
201
|
+
|
|
202
|
+
checks = ci_store.list_checks_for_pr(merge_entry.repo_github, merge_entry.pr_number)
|
|
203
|
+
return bool(failed_checks(checks))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── Per-stage status functions ──────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def stage_status_for_internal_work(
|
|
210
|
+
assignments_for_issue: list,
|
|
211
|
+
*,
|
|
212
|
+
is_closed: bool,
|
|
213
|
+
require_plan: bool,
|
|
214
|
+
) -> str:
|
|
215
|
+
"""Mirrors ``pipeline.rs::stage_status_for_internal_work``."""
|
|
216
|
+
matching = assignments_for_stage(assignments_for_issue, "work", require_plan=require_plan)
|
|
217
|
+
if any(a.status == "running" for a in matching):
|
|
218
|
+
return ACTIVE
|
|
219
|
+
latest = _latest_by_dispatch(matching)
|
|
220
|
+
if latest is not None:
|
|
221
|
+
if latest.status == "done":
|
|
222
|
+
return DONE
|
|
223
|
+
if latest.status == "failed":
|
|
224
|
+
return FAILED
|
|
225
|
+
return SKIPPED if is_closed else PENDING
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_stage_status_for(
|
|
229
|
+
assignments_for_issue: list,
|
|
230
|
+
*,
|
|
231
|
+
is_closed: bool,
|
|
232
|
+
require_plan: bool,
|
|
233
|
+
) -> str:
|
|
234
|
+
"""Mirrors ``pipeline.rs::test_stage_status_for`` (#200/#235/#310/#585/#1395).
|
|
235
|
+
|
|
236
|
+
Excludes the #235 "Phase 1 build in flight" override — that's a locally
|
|
237
|
+
spawned TUI subprocess with no server-side equivalent; the TUI overlays
|
|
238
|
+
it on top of this value.
|
|
239
|
+
"""
|
|
240
|
+
work_status = stage_status_for_internal_work(
|
|
241
|
+
assignments_for_issue, is_closed=is_closed, require_plan=require_plan
|
|
242
|
+
)
|
|
243
|
+
if work_status != DONE:
|
|
244
|
+
return SKIPPED if is_closed else PENDING
|
|
245
|
+
|
|
246
|
+
if _has_active_smoke_session(assignments_for_issue):
|
|
247
|
+
return ACTIVE
|
|
248
|
+
|
|
249
|
+
work = assignments_for_stage(assignments_for_issue, "work", require_plan=require_plan)
|
|
250
|
+
with_verdict = [a for a in work if (a.test_state or "") != ""]
|
|
251
|
+
verdict_assignment = _latest_by_dispatch(with_verdict)
|
|
252
|
+
verdict = verdict_assignment.test_state if verdict_assignment else None
|
|
253
|
+
# #1395: "running" is a transient, non-verdict marker — an unattended
|
|
254
|
+
# driver (scripts/drive-issue.sh) that bypasses dispatch_smoke and runs
|
|
255
|
+
# the suite locally sets it right before the run and overwrites it with a
|
|
256
|
+
# terminal passed/failed/skipped verdict when the run concludes. Without
|
|
257
|
+
# this, the Test box reads Pending (indistinguishable from "not started")
|
|
258
|
+
# for however long the local suite takes — the #1395 invisible-test-stage
|
|
259
|
+
# bug. It must never be treated as a verdict by any gate; the merge gate
|
|
260
|
+
# (`has_smoke_verdict`) and the review gate (`dispatch_pending_reviews`)
|
|
261
|
+
# both key off `test_state in ("passed", "skipped")`, so "running" already
|
|
262
|
+
# fails closed there without any change.
|
|
263
|
+
if verdict == "running":
|
|
264
|
+
return ACTIVE
|
|
265
|
+
if verdict in ("passed", "skipped"):
|
|
266
|
+
return DONE
|
|
267
|
+
if verdict == "failed":
|
|
268
|
+
return FAILED
|
|
269
|
+
# #1672: ``"blocked"`` (``coord.smoke.TEST_STATE_BLOCKED`` — no
|
|
270
|
+
# capability-matched machine could run the Test stage) deliberately falls
|
|
271
|
+
# through to PENDING here rather than mapping to FAILED. FAILED is a
|
|
272
|
+
# statement about the BRANCH, and the TUI acts on it: it would flip
|
|
273
|
+
# `can_redispatch_work_after_test_failure` on (offering to re-dispatch
|
|
274
|
+
# Work that is perfectly fine) and `test_gate_actionable` off (taking away
|
|
275
|
+
# the operator's `B`/verdict keys on the one row that needs them). The
|
|
276
|
+
# blocked reason reaches the operator through `test_reason` instead —
|
|
277
|
+
# rendered by `coord gates` and, since no smoke row exists in this case,
|
|
278
|
+
# by the TUI Summary tab's `board_assignment_reason` on the work row.
|
|
279
|
+
return PENDING
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def acceptance_stage_status_for(assignments_for_issue: list) -> str:
|
|
283
|
+
"""The Acceptance box's status (#932/#944) — reported and gated
|
|
284
|
+
*separately* from the Test box (docs/ORACLE_LOOP.md), so this mirrors
|
|
285
|
+
``test_stage_status_for``'s shape but keys off
|
|
286
|
+
``Assignment.acceptance_state`` rather than a dedicated assignment
|
|
287
|
+
type: ``coord acceptance record`` stamps the verdict directly onto the
|
|
288
|
+
work assignment row (see ``coord/commands/acceptance.py``), it never
|
|
289
|
+
spawns a separate ``type="acceptance"`` assignment.
|
|
290
|
+
|
|
291
|
+
Distinct from Test in one more way: an issue with no acceptance suite
|
|
292
|
+
authored yet (no manifest slice, so ``acceptance record`` was never run
|
|
293
|
+
against it) has no signal at all — SKIPPED rather than PENDING, since
|
|
294
|
+
the Acceptance box only applies to oracle-loop milestones, not every
|
|
295
|
+
issue on the board.
|
|
296
|
+
"""
|
|
297
|
+
work = [a for a in assignments_for_issue if (a.type or "work") == "work"]
|
|
298
|
+
with_state = [a for a in work if (a.acceptance_state or "") != ""]
|
|
299
|
+
if not with_state:
|
|
300
|
+
return SKIPPED
|
|
301
|
+
latest = _latest_by_dispatch(with_state)
|
|
302
|
+
state = latest.acceptance_state
|
|
303
|
+
if state == "passed":
|
|
304
|
+
return DONE
|
|
305
|
+
if state == "failed":
|
|
306
|
+
return FAILED
|
|
307
|
+
return PENDING
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def acceptance_progress_for(assignments_for_issue: list) -> dict[str, int] | None:
|
|
311
|
+
"""``{"passed": p, "total": t}`` from the latest recorded acceptance
|
|
312
|
+
verdict for this issue, or ``None`` when no verdict exists yet or it
|
|
313
|
+
predates #932's per-test counts. Backs the Acceptance box's
|
|
314
|
+
partial-green display (e.g. "3/7 acceptance green") — a growing suite
|
|
315
|
+
is *expected* to read sub-100% until the feature completes
|
|
316
|
+
(docs/ORACLE_LOOP.md), so this is reporting, not a pass/fail gate.
|
|
317
|
+
"""
|
|
318
|
+
work = [a for a in assignments_for_issue if (a.type or "work") == "work"]
|
|
319
|
+
with_state = [a for a in work if (a.acceptance_state or "") != ""]
|
|
320
|
+
if not with_state:
|
|
321
|
+
return None
|
|
322
|
+
latest = _latest_by_dispatch(with_state)
|
|
323
|
+
if latest.acceptance_total is None or latest.acceptance_passed is None:
|
|
324
|
+
return None
|
|
325
|
+
return {"passed": latest.acceptance_passed, "total": latest.acceptance_total}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def merge_stage_status_for(
|
|
329
|
+
assignments_for_issue: list,
|
|
330
|
+
merge_entry: Any | None,
|
|
331
|
+
*,
|
|
332
|
+
is_closed: bool,
|
|
333
|
+
ci_store: Any | None = None,
|
|
334
|
+
) -> str:
|
|
335
|
+
"""Mirrors ``pipeline.rs::merge_stage_status_for`` (#241/#290/#775).
|
|
336
|
+
|
|
337
|
+
Excludes the #290 "just dispatched, DB not yet caught up" optimistic
|
|
338
|
+
flag — that's TUI-session-local; the TUI overlays it on top.
|
|
339
|
+
"""
|
|
340
|
+
if _has_active_conflict_fix(assignments_for_issue):
|
|
341
|
+
return ACTIVE
|
|
342
|
+
|
|
343
|
+
if merge_entry is not None:
|
|
344
|
+
state = merge_entry.state
|
|
345
|
+
if state in _MERGED_STATES:
|
|
346
|
+
return DONE
|
|
347
|
+
if state in _ACTIVE_MERGE_STATES:
|
|
348
|
+
return ACTIVE
|
|
349
|
+
if state in _FAILED_MERGE_STATES:
|
|
350
|
+
return FAILED
|
|
351
|
+
|
|
352
|
+
if _ci_failed_for_entry(merge_entry, ci_store):
|
|
353
|
+
return FAILED
|
|
354
|
+
|
|
355
|
+
# #775: the daemon's merge-reconcile tick prunes the queue row after
|
|
356
|
+
# flipping the work assignment to status="merged" — fall back to the
|
|
357
|
+
# assignment itself as evidence the Merge stage is Done.
|
|
358
|
+
# #1142: gate on CLOSES_ISSUE_TYPES, not a bare `type == "work"` — a
|
|
359
|
+
# merged assignment whose type doesn't actually resolve this issue (e.g.
|
|
360
|
+
# a `coord pr` PR-opening helper for a test-author/mock-author original,
|
|
361
|
+
# whose issue_number is a milestone tracking issue) must not be read as
|
|
362
|
+
# this issue's own work merging. See `coord.models.PR_HELPER_TYPE`.
|
|
363
|
+
if any(a.type in CLOSES_ISSUE_TYPES and a.status == "merged" for a in assignments_for_issue):
|
|
364
|
+
return DONE
|
|
365
|
+
|
|
366
|
+
return SKIPPED if is_closed else PENDING
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def stage_status_for(
|
|
370
|
+
assignments_for_issue: list,
|
|
371
|
+
stage: str,
|
|
372
|
+
*,
|
|
373
|
+
stage_names: list[str],
|
|
374
|
+
is_closed: bool,
|
|
375
|
+
require_plan: bool,
|
|
376
|
+
merge_entry: Any | None = None,
|
|
377
|
+
ci_store: Any | None = None,
|
|
378
|
+
) -> str:
|
|
379
|
+
"""Mirrors ``pipeline.rs::stage_status_for`` — the generic per-stage
|
|
380
|
+
dispatcher, including the #193 "stale downstream verdict" check.
|
|
381
|
+
|
|
382
|
+
#2085: the "review" stage branch deliberately DIVERGES from
|
|
383
|
+
``pipeline.rs`` in one respect — it also folds in a #331 self-approval
|
|
384
|
+
verdict stamped directly on a work row (see the ``stage == "review"``
|
|
385
|
+
block below). That closes a same-projection disagreement this server
|
|
386
|
+
endpoint can produce (``has_approved_review: True`` next to
|
|
387
|
+
``stages["review"]`` reading ``PENDING``); local-mode ``coord-tui``
|
|
388
|
+
(no server) still has the older, narrower behavior until the Rust side
|
|
389
|
+
picks up the equivalent fix.
|
|
390
|
+
"""
|
|
391
|
+
if stage == "merge":
|
|
392
|
+
return merge_stage_status_for(
|
|
393
|
+
assignments_for_issue, merge_entry, is_closed=is_closed, ci_store=ci_store
|
|
394
|
+
)
|
|
395
|
+
if stage == "test":
|
|
396
|
+
return test_stage_status_for(
|
|
397
|
+
assignments_for_issue, is_closed=is_closed, require_plan=require_plan
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
matching = assignments_for_stage(assignments_for_issue, stage, require_plan=require_plan)
|
|
401
|
+
if stage == "review":
|
|
402
|
+
# #2085 (non-blocking finding): `assignments_for_stage`'s strict
|
|
403
|
+
# `type == "review"` filter misses a #331 self-approval verdict
|
|
404
|
+
# stamped directly on a work row — fold in the SAME event set
|
|
405
|
+
# `issue_has_any_approved_review` scans so the "review" stage badge
|
|
406
|
+
# and the projection's `has_approved_review` field can't disagree.
|
|
407
|
+
work_ids = _work_ids_for_issue(assignments_for_issue)
|
|
408
|
+
seen = {id(a) for a in matching}
|
|
409
|
+
matching = matching + [
|
|
410
|
+
a for a in _review_verdict_events(assignments_for_issue, work_ids)
|
|
411
|
+
if id(a) not in seen and a.type != "review"
|
|
412
|
+
]
|
|
413
|
+
# #1566: "finalizing" is a review row whose agent finished but whose
|
|
414
|
+
# verdict hasn't been parsed + persisted yet (`coord notify`'s slower,
|
|
415
|
+
# separate step — see `coord.reconcile.reconcile_completed_assignments`).
|
|
416
|
+
# Treat it exactly like "running" here: without this, the #473/#812
|
|
417
|
+
# verdict-based mapping below would read a still-in-flight review as a
|
|
418
|
+
# terminal "done" with no verdict and paint it FAILED — the same
|
|
419
|
+
# dead-end misread #1566 was filed over, just in the board's colours
|
|
420
|
+
# instead of `coord drive`'s exit.
|
|
421
|
+
if any(a.status in ("running", "finalizing") for a in matching):
|
|
422
|
+
return ACTIVE
|
|
423
|
+
|
|
424
|
+
latest = _latest_by_dispatch(matching)
|
|
425
|
+
if latest is not None:
|
|
426
|
+
mapped: str | None = None
|
|
427
|
+
if latest.status == "done" and stage == "review":
|
|
428
|
+
# #473/#812: key off the verdict, not merely that review ran.
|
|
429
|
+
mapped = DONE if latest.review_verdict == "approve" else FAILED
|
|
430
|
+
elif latest.status == "done":
|
|
431
|
+
mapped = DONE
|
|
432
|
+
elif latest.status == "failed":
|
|
433
|
+
mapped = FAILED
|
|
434
|
+
|
|
435
|
+
if mapped is not None:
|
|
436
|
+
if latest.dispatched_at is not None:
|
|
437
|
+
upstream = upstream_max_dispatched_at(
|
|
438
|
+
assignments_for_issue, stage, stage_names, require_plan=require_plan
|
|
439
|
+
)
|
|
440
|
+
if upstream is not None and upstream > latest.dispatched_at:
|
|
441
|
+
return STALE
|
|
442
|
+
return mapped
|
|
443
|
+
|
|
444
|
+
return SKIPPED if is_closed else PENDING
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def issue_has_any_approved_review(
|
|
448
|
+
assignments_for_issue: list,
|
|
449
|
+
seed_work_id: str | None = None,
|
|
450
|
+
) -> bool:
|
|
451
|
+
"""Mirrors ``pipeline.rs::issue_has_any_approved_review`` (#292/#331).
|
|
452
|
+
|
|
453
|
+
Also the issue-scoped equivalent of
|
|
454
|
+
``coord.merge_queue.has_approved_review`` (which is branch-scoped, keyed
|
|
455
|
+
off a single ``QueuedMerge`` entry) — this collects every work
|
|
456
|
+
assignment for the *issue* so a bounce-created fix worker's approval is
|
|
457
|
+
found even when a merge-queue entry is still keyed to the original work.
|
|
458
|
+
|
|
459
|
+
#2085: before this fix, ANY historical ``approve`` anywhere in the
|
|
460
|
+
issue's work chain counted, forever — including one superseded by a
|
|
461
|
+
later ``request-changes`` on newer commits. That produced a board
|
|
462
|
+
record with ``stages["review"] == "failed"`` (the general
|
|
463
|
+
``stage_status_for`` dispatcher already keys off the *latest* review by
|
|
464
|
+
dispatch order) sitting next to ``has_approved_review: True`` in the
|
|
465
|
+
same projection — self-contradictory on its face, and the exact
|
|
466
|
+
daemon-vs-gate disagreement #2085 traces back to `coord drive` looping
|
|
467
|
+
on a merge that could never land.
|
|
468
|
+
``coord.merge_queue.has_approved_review`` closes the equivalent gap with
|
|
469
|
+
a live commit-SHA/patch-id bind, which this module deliberately has no
|
|
470
|
+
I/O to perform (see module docstring). Dispatch order over the issue's
|
|
471
|
+
own recorded verdict history is the best available DB-only proxy, and —
|
|
472
|
+
crucially — it is the SAME ordering rule ``stage_status_for`` already
|
|
473
|
+
applies to the "review" stage badge computed alongside this field in
|
|
474
|
+
:func:`compute_issue_projection` (both now share
|
|
475
|
+
:func:`_review_verdict_events`, #2085), so the two can no longer disagree
|
|
476
|
+
the way the #2085 board record did — including the #331 self-approval
|
|
477
|
+
case, where earlier the "review" stage badge alone missed the event this
|
|
478
|
+
function already counted.
|
|
479
|
+
"""
|
|
480
|
+
# #1142: CLOSES_ISSUE_TYPES, not a bare `type == "work"` — see the same
|
|
481
|
+
# rationale in `merge_stage_status_for` above.
|
|
482
|
+
work_ids = _work_ids_for_issue(assignments_for_issue, seed_work_id)
|
|
483
|
+
if not work_ids:
|
|
484
|
+
return False
|
|
485
|
+
|
|
486
|
+
# The MOST RECENTLY DISPATCHED event wins — mirrors `stage_status_for`'s
|
|
487
|
+
# own "latest by dispatch" rule for the "review" stage, so an earlier
|
|
488
|
+
# `approve` superseded by a later `request-changes` (on newer commits)
|
|
489
|
+
# no longer counts, matching what the merge gate's commit-bound check
|
|
490
|
+
# would also refuse.
|
|
491
|
+
events = _review_verdict_events(assignments_for_issue, work_ids)
|
|
492
|
+
if not events:
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
return _latest_by_dispatch(events).review_verdict == "approve"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ── Board-level projection ──────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def pipeline_stage_names(default_gates: list[str]) -> list[str]:
|
|
502
|
+
"""Mirrors ``pipeline.rs::pipeline_stage_names`` (module-default, no
|
|
503
|
+
per-issue plan-assignment prepend — see ``issue_stage_names``)."""
|
|
504
|
+
stages = ["work"]
|
|
505
|
+
for g in default_gates:
|
|
506
|
+
# #1429: "merge" is restored to the per-issue stage-name ordering as
|
|
507
|
+
# a read-only observation badge (#738 retired the per-issue *box*
|
|
508
|
+
# with its Go/dispatch affordance — that reasoning covered the
|
|
509
|
+
# affordance, not observation; merge is still initiated solely from
|
|
510
|
+
# the Merge Queue panel). "work"/"plan" stay excluded here since
|
|
511
|
+
# they're prepended explicitly above / by the caller.
|
|
512
|
+
if g not in ("work", "plan"):
|
|
513
|
+
stages.append(g)
|
|
514
|
+
return stages
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def issue_stage_names(assignments_for_issue: list, default_gates: list[str]) -> list[str]:
|
|
518
|
+
"""Mirrors ``pipeline.rs::pipeline_stage_names_for_issue``."""
|
|
519
|
+
stages = pipeline_stage_names(default_gates)
|
|
520
|
+
if stages[0] != "plan" and _issue_has_plan_assignment(assignments_for_issue):
|
|
521
|
+
stages = ["plan", *stages]
|
|
522
|
+
return stages
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def compute_issue_projection(
|
|
526
|
+
assignments_for_issue: list,
|
|
527
|
+
merge_entry: Any | None,
|
|
528
|
+
*,
|
|
529
|
+
is_closed: bool,
|
|
530
|
+
require_plan: bool,
|
|
531
|
+
default_gates: list[str],
|
|
532
|
+
ci_store: Any | None = None,
|
|
533
|
+
) -> dict[str, Any]:
|
|
534
|
+
"""Compute the full per-issue stage badge dict.
|
|
535
|
+
|
|
536
|
+
``stages`` covers every name in this issue's stage strip (``plan``?,
|
|
537
|
+
``work``, then the configured gates minus ``work``/``plan``) — including
|
|
538
|
+
``merge`` (#1429: restored as a read-only observation badge; ``stage_status_for``
|
|
539
|
+
special-cases ``"merge"`` to delegate to ``merge_stage_status_for``, so the
|
|
540
|
+
loop below already produces the correct value) — plus a redundant explicit
|
|
541
|
+
``merge`` assignment as a defensive fallback in case ``merge`` is ever
|
|
542
|
+
absent from ``names`` again (e.g. a future ``default_gates`` without it).
|
|
543
|
+
"""
|
|
544
|
+
names = issue_stage_names(assignments_for_issue, default_gates)
|
|
545
|
+
stages: dict[str, str] = {}
|
|
546
|
+
for name in names:
|
|
547
|
+
stages[name] = stage_status_for(
|
|
548
|
+
assignments_for_issue,
|
|
549
|
+
name,
|
|
550
|
+
stage_names=names,
|
|
551
|
+
is_closed=is_closed,
|
|
552
|
+
require_plan=require_plan,
|
|
553
|
+
merge_entry=merge_entry,
|
|
554
|
+
ci_store=ci_store,
|
|
555
|
+
)
|
|
556
|
+
stages.setdefault(
|
|
557
|
+
"merge",
|
|
558
|
+
merge_stage_status_for(
|
|
559
|
+
assignments_for_issue, merge_entry, is_closed=is_closed, ci_store=ci_store
|
|
560
|
+
),
|
|
561
|
+
)
|
|
562
|
+
# #932: the Acceptance box, computed unconditionally like "merge" above
|
|
563
|
+
# (own box, own verdict — reported separately from the Test stage) and
|
|
564
|
+
# excluded from the per-issue stage-strip ordering that `default_gates`
|
|
565
|
+
# drives, since it only applies to oracle-loop milestones.
|
|
566
|
+
stages["acceptance"] = acceptance_stage_status_for(assignments_for_issue)
|
|
567
|
+
return {
|
|
568
|
+
"stages": stages,
|
|
569
|
+
"acceptance_progress": acceptance_progress_for(assignments_for_issue),
|
|
570
|
+
"has_approved_review": issue_has_any_approved_review(
|
|
571
|
+
assignments_for_issue,
|
|
572
|
+
seed_work_id=merge_entry.assignment_id if merge_entry is not None else None,
|
|
573
|
+
),
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def compute_board_stage_projection(
|
|
578
|
+
*,
|
|
579
|
+
issues: list[dict],
|
|
580
|
+
assignments: list,
|
|
581
|
+
merge_queue_items: list,
|
|
582
|
+
default_gates: list[str],
|
|
583
|
+
require_plan: bool = False,
|
|
584
|
+
ci_store: Any | None = None,
|
|
585
|
+
) -> list[dict[str, Any]]:
|
|
586
|
+
"""Compute the per-issue stage projection for every issue that appears
|
|
587
|
+
on the board — the payload injected into ``GET /board`` as
|
|
588
|
+
``issue_stage_projection``.
|
|
589
|
+
|
|
590
|
+
Issue keys are ``(repo_name, issue_number)`` — the union of the
|
|
591
|
+
``issues`` table (open + recently-synced) and every assignment's
|
|
592
|
+
``(repo_name, issue_number)`` (so closed issues with assignment history
|
|
593
|
+
still get a projection, matching what the TUI's Pipeline tab shows).
|
|
594
|
+
"""
|
|
595
|
+
is_closed_by_key: dict[tuple[str, int], bool] = {
|
|
596
|
+
(i["repo_name"], i["number"]): str(i.get("state", "")).lower() == "closed"
|
|
597
|
+
for i in issues
|
|
598
|
+
}
|
|
599
|
+
issue_title_by_key: dict[tuple[str, int], str] = {
|
|
600
|
+
(i["repo_name"], i["number"]): i.get("title", "") for i in issues
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
assignments_by_key: dict[tuple[str, int], list] = {}
|
|
604
|
+
for a in assignments:
|
|
605
|
+
if not a.repo_name or a.issue_number is None:
|
|
606
|
+
continue
|
|
607
|
+
assignments_by_key.setdefault((a.repo_name, a.issue_number), []).append(a)
|
|
608
|
+
|
|
609
|
+
merge_by_key: dict[tuple[str, int], Any] = {}
|
|
610
|
+
for m in merge_queue_items:
|
|
611
|
+
assignment_type = getattr(m, "assignment_type", "work") or "work"
|
|
612
|
+
if assignment_type not in CLOSES_ISSUE_TYPES:
|
|
613
|
+
# This row's `issue_number` is the milestone's tracking issue, not
|
|
614
|
+
# something this PR resolves (#1077/#1084) — it's the sealed
|
|
615
|
+
# acceptance suite being delivered (docs/ORACLE_LOOP.md), not a
|
|
616
|
+
# merge that closes anyone's work. #1203 tried re-attributing it
|
|
617
|
+
# to the child issue named by the originating assignment's
|
|
618
|
+
# `for_issue_number`, but that just moved the false green from
|
|
619
|
+
# the epic to the child (claude-coordinator#1652) — the child's
|
|
620
|
+
# own work still has to be tested, reviewed, and merged on its
|
|
621
|
+
# own branch. There is no issue whose Merge box this row
|
|
622
|
+
# legitimately describes, so skip it entirely rather than
|
|
623
|
+
# attribute it anywhere.
|
|
624
|
+
continue
|
|
625
|
+
key = (m.repo_name, m.issue_number)
|
|
626
|
+
# First-match-wins, mirroring `.find()` over the id-ordered list —
|
|
627
|
+
# `load_queue()`/`board_projection()` both order by `id` ascending.
|
|
628
|
+
merge_by_key.setdefault(key, m)
|
|
629
|
+
|
|
630
|
+
keys = set(is_closed_by_key) | set(assignments_by_key)
|
|
631
|
+
|
|
632
|
+
result: list[dict[str, Any]] = []
|
|
633
|
+
for repo_name, issue_number in keys:
|
|
634
|
+
key = (repo_name, issue_number)
|
|
635
|
+
issue_assignments = assignments_by_key.get(key, [])
|
|
636
|
+
entry = compute_issue_projection(
|
|
637
|
+
issue_assignments,
|
|
638
|
+
merge_by_key.get(key),
|
|
639
|
+
is_closed=is_closed_by_key.get(key, False),
|
|
640
|
+
require_plan=require_plan,
|
|
641
|
+
default_gates=default_gates,
|
|
642
|
+
ci_store=ci_store,
|
|
643
|
+
)
|
|
644
|
+
entry["repo_name"] = repo_name
|
|
645
|
+
entry["issue_number"] = issue_number
|
|
646
|
+
entry["issue_title"] = issue_title_by_key.get(key, "")
|
|
647
|
+
result.append(entry)
|
|
648
|
+
|
|
649
|
+
result.sort(key=lambda e: (e["repo_name"], e["issue_number"]))
|
|
650
|
+
return result
|