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/gates.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""#1657: ``coord gates <repo> <issue>`` — read a work row's gate columns
|
|
2
|
+
plus the LIVE gate decision (review / test / merge), without a hand-extracted
|
|
3
|
+
bearer token and a raw ``/board`` curl.
|
|
4
|
+
|
|
5
|
+
Two things were missing before this module existed:
|
|
6
|
+
|
|
7
|
+
1. A CLI surface for the raw columns every gate reads — ``test_state``,
|
|
8
|
+
``smoke_test``, ``test_reason``, ``test_toolchain`` (#1629),
|
|
9
|
+
``review_state``, ``review_verdict``, ``review_of_assignment_id`` — none
|
|
10
|
+
of which ``coord status`` or ``coord diagnose --stage test`` prints (see
|
|
11
|
+
#1657's "diagnose --stage test" repro: it reports the *assignment row*'s
|
|
12
|
+
status, never ``test_state`` itself, which was ``"running"`` at the
|
|
13
|
+
moment that mattered).
|
|
14
|
+
2. The gate *decision*, not just the columns — in particular whether a
|
|
15
|
+
recorded verdict is #1479-stale (recorded against a base/branch SHA that
|
|
16
|
+
has since moved), which is otherwise unexplainable from any surface the
|
|
17
|
+
operator has: a verdict can read ``passed`` while ``coord merge`` still
|
|
18
|
+
refuses with ``smoke_required``.
|
|
19
|
+
|
|
20
|
+
:func:`build_gate_report` is the read-only core (board + config + an
|
|
21
|
+
optional ``gh_ops`` duck-typed seam in, a :class:`GateReport` out); it
|
|
22
|
+
reuses ``coord.merge_queue``'s own review/smoke gate functions
|
|
23
|
+
(:func:`~coord.merge_queue.has_approved_review`,
|
|
24
|
+
:func:`~coord.merge_queue.evaluate_smoke_verdict`) rather than
|
|
25
|
+
re-implementing the #1479 freshness math a second time, so this can never
|
|
26
|
+
drift from what ``coord merge``/``coord merge --plan`` actually decide.
|
|
27
|
+
|
|
28
|
+
Read-only by construction: nothing in this module calls ``save_board``,
|
|
29
|
+
``save_queue``, or any ``gh`` write. The synthetic
|
|
30
|
+
:class:`~coord.merge_queue.QueuedMerge` built in :func:`build_gate_report`
|
|
31
|
+
is never persisted — it exists only to hand the existing gate functions the
|
|
32
|
+
duck-typed shape they expect.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from dataclasses import asdict, dataclass, field
|
|
38
|
+
from typing import TYPE_CHECKING
|
|
39
|
+
|
|
40
|
+
from coord.models import WORK_LIKE_TYPES, effective_issue_number
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING: # avoid import cycles / heavy imports at module load
|
|
43
|
+
from coord.config import Config
|
|
44
|
+
from coord.merge_queue import GhOps
|
|
45
|
+
from coord.models import Assignment, Board
|
|
46
|
+
|
|
47
|
+
# Mirrors the MergeEvent.kind tokens merge_queue.process() already emits for
|
|
48
|
+
# these two refusals — same vocabulary, so grepping the daemon log for
|
|
49
|
+
# "smoke_required" finds both a live merge attempt AND a `coord gates` read.
|
|
50
|
+
REVIEW_REQUIRED = "review_required"
|
|
51
|
+
SMOKE_REQUIRED = "smoke_required"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class AssignmentGateRow:
|
|
56
|
+
"""One board row's gate-relevant columns — the raw-dump half of the report."""
|
|
57
|
+
|
|
58
|
+
assignment_id: str | None
|
|
59
|
+
type: str
|
|
60
|
+
status: str | None
|
|
61
|
+
branch: str | None
|
|
62
|
+
machine_name: str | None
|
|
63
|
+
provider_name: str | None
|
|
64
|
+
dispatched_at: float | None
|
|
65
|
+
is_interactive: bool | None
|
|
66
|
+
# #1730: the two-number reality #1553 introduced — `issue_number` is what
|
|
67
|
+
# the row is BOOKED to (the tracking/epic issue for an oracle-loop
|
|
68
|
+
# slice), `for_issue_number` is what it's actually FOR (the child), when
|
|
69
|
+
# set. Surfaced on every row so a query that only matched via
|
|
70
|
+
# `effective_issue_number` (see `build_gate_report`) is legible rather
|
|
71
|
+
# than silently showing rows under the "wrong" issue with no explanation.
|
|
72
|
+
issue_number: int
|
|
73
|
+
for_issue_number: int | None
|
|
74
|
+
test_state: str | None
|
|
75
|
+
smoke_test: str | None
|
|
76
|
+
test_reason: str | None
|
|
77
|
+
# #1629 (H-2): the toolchain that produced test_state, when resolvable.
|
|
78
|
+
# None for pre-1629 rows or an unresolvable toolchain — rendered as
|
|
79
|
+
# "unknown", never as a mismatch.
|
|
80
|
+
test_toolchain: str | None
|
|
81
|
+
review_state: str | None
|
|
82
|
+
review_verdict: str | None
|
|
83
|
+
review_of_assignment_id: str | None
|
|
84
|
+
# #1956: WHO recorded review_verdict and HOW — "agent" (the parsed
|
|
85
|
+
# common case, or None for pre-#1956 rows), "recovered", or
|
|
86
|
+
# "overridden". See coord.models.Assignment.verdict_source.
|
|
87
|
+
verdict_source: str | None
|
|
88
|
+
verdict_source_reason: str | None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class GateDecision:
|
|
93
|
+
"""The live decision for one gate (``"review"`` | ``"test"`` | ``"merge"``)."""
|
|
94
|
+
|
|
95
|
+
gate: str
|
|
96
|
+
required: bool
|
|
97
|
+
ok: bool
|
|
98
|
+
reason: str | None = None
|
|
99
|
+
# #1479 staleness detail — set only when this gate's refusal is a STALE
|
|
100
|
+
# (not MISSING) verdict.
|
|
101
|
+
anchor: str | None = None # "base" | "branch"
|
|
102
|
+
recorded_sha: str | None = None
|
|
103
|
+
current_sha: str | None = None
|
|
104
|
+
# #1956: True only for the "review" gate, and only when it's blocked
|
|
105
|
+
# because a linked review row finished (status="done") with NO
|
|
106
|
+
# parseable verdict at all — a defect, not a state. Distinguishing this
|
|
107
|
+
# from an ordinary "review required but not approved" (a review that
|
|
108
|
+
# simply hasn't run, or genuinely requested changes) matters: it needs
|
|
109
|
+
# OPERATOR RECOVERY (see `reason` for the exact command), not another
|
|
110
|
+
# dispatched review — re-dispatching just re-derives a conclusion that
|
|
111
|
+
# already exists in the log, per #1956's "Not the fix" section.
|
|
112
|
+
verdict_unparseable: bool = False
|
|
113
|
+
# #2024: WHICH assignment supplied this gate's verdict. Populated for the
|
|
114
|
+
# "test" gate, where the merge gate is deliberately branch-scoped (a Test
|
|
115
|
+
# run measures the (branch, base) pair, #1819) and so is routinely
|
|
116
|
+
# satisfied by a row that is NOT the branch's current work row — the
|
|
117
|
+
# parent of a `--fix-of` round, most often. Without naming the row, `coord
|
|
118
|
+
# gates` printing `test : passed` while `coord drive` holds on `test=-`
|
|
119
|
+
# for the fix row reads as two components disagreeing about one fact
|
|
120
|
+
# instead of two components correctly answering two different questions.
|
|
121
|
+
assignment_id: str | None = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class GateReport:
|
|
126
|
+
repo_name: str
|
|
127
|
+
issue_number: int
|
|
128
|
+
branch: str | None = None
|
|
129
|
+
target_branch: str | None = None
|
|
130
|
+
rows: list[AssignmentGateRow] = field(default_factory=list)
|
|
131
|
+
decisions: list[GateDecision] = field(default_factory=list)
|
|
132
|
+
notes: list[str] = field(default_factory=list)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _row_from_assignment(a: "Assignment") -> AssignmentGateRow:
|
|
136
|
+
return AssignmentGateRow(
|
|
137
|
+
assignment_id=a.assignment_id,
|
|
138
|
+
type=a.type or "work",
|
|
139
|
+
status=a.status,
|
|
140
|
+
branch=a.branch,
|
|
141
|
+
machine_name=a.machine_name,
|
|
142
|
+
provider_name=a.provider_name,
|
|
143
|
+
dispatched_at=a.dispatched_at,
|
|
144
|
+
is_interactive=None, # backfilled by _backfill_is_interactive, best-effort
|
|
145
|
+
issue_number=a.issue_number,
|
|
146
|
+
for_issue_number=a.for_issue_number,
|
|
147
|
+
test_state=a.test_state,
|
|
148
|
+
smoke_test=a.smoke_test,
|
|
149
|
+
test_reason=a.test_reason,
|
|
150
|
+
test_toolchain=a.test_toolchain,
|
|
151
|
+
review_state=a.review_state,
|
|
152
|
+
review_verdict=a.review_verdict,
|
|
153
|
+
review_of_assignment_id=a.review_of_assignment_id,
|
|
154
|
+
verdict_source=a.verdict_source,
|
|
155
|
+
verdict_source_reason=a.verdict_source_reason,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _backfill_is_interactive(rows: list[AssignmentGateRow]) -> None:
|
|
160
|
+
"""Populate ``row.is_interactive`` from the ``assignments`` table.
|
|
161
|
+
|
|
162
|
+
#748/#632: ``is_interactive`` is a real DB/wire column deliberately kept
|
|
163
|
+
OFF the ``Assignment`` dataclass (see ``coord.usage.fetch_usage_rows``'s
|
|
164
|
+
docstring) — every board row read via ``Board``/``Assignment`` therefore
|
|
165
|
+
has no way to answer "was this the #555 interactive-review exclusion?"
|
|
166
|
+
without a second, scoped, read-only query. Mutates *rows* in place;
|
|
167
|
+
best-effort — any DB error (e.g. no local DB on a pure thin-client
|
|
168
|
+
process, though ``coord gates`` always runs where the canonical DB
|
|
169
|
+
lives) leaves every ``is_interactive`` at ``None`` rather than raising.
|
|
170
|
+
"""
|
|
171
|
+
ids = [r.assignment_id for r in rows if r.assignment_id]
|
|
172
|
+
if not ids:
|
|
173
|
+
return
|
|
174
|
+
try:
|
|
175
|
+
from coord.db import get_connection # noqa: PLC0415
|
|
176
|
+
|
|
177
|
+
conn = get_connection()
|
|
178
|
+
placeholders = ",".join("?" for _ in ids)
|
|
179
|
+
found = conn.execute(
|
|
180
|
+
f"SELECT assignment_id, is_interactive FROM assignments "
|
|
181
|
+
f"WHERE assignment_id IN ({placeholders})",
|
|
182
|
+
ids,
|
|
183
|
+
).fetchall()
|
|
184
|
+
flags = {r["assignment_id"]: bool(r["is_interactive"]) for r in found}
|
|
185
|
+
except Exception: # noqa: BLE001 — best-effort enrichment only
|
|
186
|
+
return
|
|
187
|
+
for row in rows:
|
|
188
|
+
if row.assignment_id in flags:
|
|
189
|
+
row.is_interactive = flags[row.assignment_id]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _select_winning_work_assignment(work_like: list["Assignment"]) -> "Assignment":
|
|
193
|
+
"""The work-like row whose branch/verdicts the merge gate actually
|
|
194
|
+
tracks — the most-recently-dispatched one, ties won by the last in
|
|
195
|
+
iteration order. Mirrors
|
|
196
|
+
:func:`coord.merge_queue._select_winning_work_assignment`'s tie-break
|
|
197
|
+
without requiring every row to already be ``status == 'done'`` (unlike
|
|
198
|
+
that helper, this one is used for read-only diagnosis of a row that may
|
|
199
|
+
still be in flight)."""
|
|
200
|
+
winner = work_like[0]
|
|
201
|
+
for a in work_like[1:]:
|
|
202
|
+
if (a.dispatched_at or 0.0) >= (winner.dispatched_at or 0.0):
|
|
203
|
+
winner = a
|
|
204
|
+
return winner
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _find_verdict_unparseable_review(
|
|
208
|
+
entry: "mq.QueuedMerge", board: "Board",
|
|
209
|
+
) -> "Assignment | None":
|
|
210
|
+
"""Return a review row linked to *entry*'s work chain that finished
|
|
211
|
+
(``status == "done"``) with NO parseable ``review_verdict`` at all, or
|
|
212
|
+
``None`` (#1956).
|
|
213
|
+
|
|
214
|
+
This is the exact defect #1956 reports: a review session completes —
|
|
215
|
+
sometimes with a full, well-reasoned body ending in ``END_REVIEW`` — but
|
|
216
|
+
the reviewer never emitted the machine-readable ``REVIEW_VERDICT:``
|
|
217
|
+
header, so ``review_verdict`` stays ``NULL`` forever. Before this,
|
|
218
|
+
``has_approved_review`` reports exactly the same "not approved" for this
|
|
219
|
+
row as it would for a review that simply hasn't run yet, or one that
|
|
220
|
+
genuinely requested changes — three very different situations rendered
|
|
221
|
+
identically. Surfacing this distinctly here lets :func:`build_gate_report`
|
|
222
|
+
give the operator the actual recovery command instead of a generic
|
|
223
|
+
"review required but not approved" that suggests re-dispatching (which
|
|
224
|
+
#1956 explicitly calls out as "Not the fix" — it just re-derives a
|
|
225
|
+
conclusion already sitting in the log, and is a coin flip whether the
|
|
226
|
+
next attempt drops the header too).
|
|
227
|
+
|
|
228
|
+
Reuses :func:`coord.merge_queue._chain_work_ids` (the same branch/
|
|
229
|
+
``review_of_assignment_id`` chain :func:`~coord.merge_queue.has_approved_review`
|
|
230
|
+
walks) so this never drifts from what that function actually considers
|
|
231
|
+
"linked to this entry". When more than one such row exists, the most
|
|
232
|
+
recently dispatched one is returned.
|
|
233
|
+
"""
|
|
234
|
+
from coord import merge_queue as mq # noqa: PLC0415
|
|
235
|
+
|
|
236
|
+
pool = list(getattr(board, "completed", []) or []) + list(getattr(board, "active", []) or [])
|
|
237
|
+
branch_work_ids = mq._chain_work_ids(entry, pool) # noqa: SLF001
|
|
238
|
+
if not branch_work_ids:
|
|
239
|
+
return None
|
|
240
|
+
candidates = [
|
|
241
|
+
a for a in pool
|
|
242
|
+
if getattr(a, "type", None) == "review"
|
|
243
|
+
and getattr(a, "review_of_assignment_id", None) in branch_work_ids
|
|
244
|
+
and getattr(a, "status", None) == "done"
|
|
245
|
+
and getattr(a, "review_verdict", None) is None
|
|
246
|
+
]
|
|
247
|
+
if not candidates:
|
|
248
|
+
return None
|
|
249
|
+
candidates.sort(key=lambda a: getattr(a, "dispatched_at", None) or 0.0)
|
|
250
|
+
return candidates[-1]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def build_gate_report(
|
|
254
|
+
board: "Board",
|
|
255
|
+
config: "Config",
|
|
256
|
+
repo_name: str,
|
|
257
|
+
issue_number: int,
|
|
258
|
+
gh_ops: "GhOps | None" = None,
|
|
259
|
+
) -> GateReport:
|
|
260
|
+
"""Read-only: board rows + the live review/test/merge gate decision for
|
|
261
|
+
one ``(repo_name, issue_number)``.
|
|
262
|
+
|
|
263
|
+
*gh_ops* (optional, duck-typed like ``coord.merge_queue.GhOps`` —
|
|
264
|
+
normally ``coord.github_ops`` itself) backs the #1479 freshness
|
|
265
|
+
comparison with LIVE branch/base SHAs and the branch's patch-id, mirroring
|
|
266
|
+
exactly what ``coord.merge_queue.process()`` populates on a
|
|
267
|
+
:class:`~coord.merge_queue.QueuedMerge` entry before evaluating its
|
|
268
|
+
gates (see that function's #821/#1475/#1479 comments). Passing ``None``
|
|
269
|
+
skips every live lookup — the decision section then reports only what
|
|
270
|
+
the recorded verdict's own stored anchors already imply (the same
|
|
271
|
+
fail-open convention #821/#1475 established), and the target branch
|
|
272
|
+
resolution falls back to ``repo.default_branch`` without a milestone
|
|
273
|
+
lookup.
|
|
274
|
+
"""
|
|
275
|
+
from coord import merge_queue as mq # noqa: PLC0415
|
|
276
|
+
|
|
277
|
+
# #1730: match on the raw `issue_number` (the tracking issue keeps
|
|
278
|
+
# finding its own rows) OR the #1553 *effective* issue — a `for_issue_number`
|
|
279
|
+
# that resolves to *issue_number* means this row's work is FOR the issue
|
|
280
|
+
# being queried even though it's booked to a different (tracking) issue.
|
|
281
|
+
# #1553 taught the TUI this resolution (`Assignment::effective_issue_number`);
|
|
282
|
+
# this CLI had never been updated to match, so `coord gates <repo>
|
|
283
|
+
# <child>` reported "no assignments found" for oracle-loop slices whose
|
|
284
|
+
# only board row carried the tracking issue in `issue_number`.
|
|
285
|
+
matching = [
|
|
286
|
+
a
|
|
287
|
+
for a in (list(board.active) + list(board.completed))
|
|
288
|
+
if a.repo_name == repo_name
|
|
289
|
+
and (a.issue_number == issue_number or effective_issue_number(a) == issue_number)
|
|
290
|
+
]
|
|
291
|
+
report = GateReport(repo_name=repo_name, issue_number=issue_number)
|
|
292
|
+
if not matching:
|
|
293
|
+
report.notes.append(
|
|
294
|
+
f"no assignments found on the board for {repo_name}#{issue_number}"
|
|
295
|
+
)
|
|
296
|
+
return report
|
|
297
|
+
|
|
298
|
+
matching.sort(key=lambda a: a.dispatched_at or 0.0)
|
|
299
|
+
report.rows = [_row_from_assignment(a) for a in matching]
|
|
300
|
+
_backfill_is_interactive(report.rows)
|
|
301
|
+
|
|
302
|
+
repo_cfg = config.repo(repo_name) if config is not None else None
|
|
303
|
+
if repo_cfg is None:
|
|
304
|
+
report.notes.append(
|
|
305
|
+
f"repo {repo_name!r} not in coordinator.yml — gate decision unavailable "
|
|
306
|
+
"(the raw columns above are still authoritative)"
|
|
307
|
+
)
|
|
308
|
+
return report
|
|
309
|
+
|
|
310
|
+
work_like = [a for a in matching if a.type in WORK_LIKE_TYPES]
|
|
311
|
+
if not work_like:
|
|
312
|
+
report.notes.append(
|
|
313
|
+
"no work-like assignment (work/test-author/mock-author) for this "
|
|
314
|
+
"issue — gate decision unavailable"
|
|
315
|
+
)
|
|
316
|
+
return report
|
|
317
|
+
|
|
318
|
+
winner = _select_winning_work_assignment(work_like)
|
|
319
|
+
report.branch = winner.branch
|
|
320
|
+
if not winner.branch:
|
|
321
|
+
report.notes.append(
|
|
322
|
+
f"winning work assignment {winner.assignment_id!r} has no branch — "
|
|
323
|
+
"gate decision unavailable"
|
|
324
|
+
)
|
|
325
|
+
return report
|
|
326
|
+
|
|
327
|
+
if gh_ops is not None:
|
|
328
|
+
from coord.branch_model import resolve_base_branch_for_issue_number # noqa: PLC0415
|
|
329
|
+
|
|
330
|
+
target_branch = resolve_base_branch_for_issue_number(
|
|
331
|
+
repo_cfg, repo_cfg.github, issue_number,
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
target_branch = repo_cfg.default_branch
|
|
335
|
+
report.target_branch = target_branch
|
|
336
|
+
|
|
337
|
+
# A synthetic QueuedMerge — never persisted (this module never calls
|
|
338
|
+
# save_queue) — duck-typed identically to a real queue entry so it can be
|
|
339
|
+
# handed straight to merge_queue's own gate functions instead of a
|
|
340
|
+
# second, driftable reimplementation of the #1479 freshness math.
|
|
341
|
+
#
|
|
342
|
+
# #821/#1479: `mq.live_gate_entry` also populates the freshness anchors
|
|
343
|
+
# LIVE (when *gh_ops* is supplied) — mirrors exactly what
|
|
344
|
+
# merge_queue.process() does before evaluating the review/smoke gates.
|
|
345
|
+
# This matters because has_approved_review does NOT itself backfill
|
|
346
|
+
# branch_head_sha (only evaluate_smoke_verdict opportunistically
|
|
347
|
+
# backfills base/branch SHAs and patch-id on demand) — without doing it
|
|
348
|
+
# here, a row that never went through a live `coord merge`/auto-drain
|
|
349
|
+
# tick would show every staleness check as a silent no-op.
|
|
350
|
+
# #2085: this construction used to live inline here; it is now shared
|
|
351
|
+
# with every other caller that gate-checks a raw work Assignment
|
|
352
|
+
# (`merge_queue.enqueue_approved_work`, `coord.notify`'s stalled-dispatch
|
|
353
|
+
# recovery, `coord.diagnose`'s stage-work recovery, `coord.commands.
|
|
354
|
+
# merge`'s auto-enqueue scan) via `mq.live_gate_entry`, so this report
|
|
355
|
+
# can never drift from what those call sites decide (#2096: one function
|
|
356
|
+
# answering one question, not several that happen to agree today).
|
|
357
|
+
# When *gh_ops* is None the anchors stay unpopulated — and, since #2085,
|
|
358
|
+
# `has_approved_review` treats an unset branch head as UNCONFIRMED
|
|
359
|
+
# rather than "nothing to compare, trust the verdict": a review carrying
|
|
360
|
+
# a `review_head_sha` reports the review gate BLOCKED, not READY, when
|
|
361
|
+
# this diagnostic has no live GitHub access. That is intentional — a
|
|
362
|
+
# `coord gates` run with no `gh_ops` must not report "merge READY" for
|
|
363
|
+
# something a real `coord merge --dry-run` (which always has `gh_ops`)
|
|
364
|
+
# would refuse; see `tests/test_gates.py::
|
|
365
|
+
# test_gh_ops_none_skips_live_lookups_fail_open`.
|
|
366
|
+
entry = mq.live_gate_entry(winner, repo_cfg.github, target_branch, gh_ops)
|
|
367
|
+
|
|
368
|
+
review_required = mq.requires_review(entry, config)
|
|
369
|
+
review_ok = True
|
|
370
|
+
review_reason: str | None = None
|
|
371
|
+
review_verdict_unparseable = False
|
|
372
|
+
if review_required:
|
|
373
|
+
review_ok = mq.has_approved_review(entry, board, gh_ops)
|
|
374
|
+
if not review_ok:
|
|
375
|
+
# #1956: don't lump "verdict capture failed" in with the
|
|
376
|
+
# generic "not approved" — a review row that finished with no
|
|
377
|
+
# parseable verdict needs OPERATOR RECOVERY, not another
|
|
378
|
+
# dispatched review (see _find_verdict_unparseable_review's
|
|
379
|
+
# docstring). Checked only on the refusal path — a review that
|
|
380
|
+
# actually approved never reaches here.
|
|
381
|
+
unparseable = _find_verdict_unparseable_review(entry, board)
|
|
382
|
+
if unparseable is not None:
|
|
383
|
+
review_verdict_unparseable = True
|
|
384
|
+
review_reason = (
|
|
385
|
+
f"review {unparseable.assignment_id!r} finished with NO "
|
|
386
|
+
"parseable verdict (#1956) — needs operator recovery, "
|
|
387
|
+
"not a pending/failed review: `coord report-result "
|
|
388
|
+
f"--assignment {unparseable.assignment_id} --verdict "
|
|
389
|
+
"<approve|request-changes> --verdict-source recovered "
|
|
390
|
+
'--verdict-reason "<why>" --body-file '
|
|
391
|
+
"<extracted-review.md>`"
|
|
392
|
+
)
|
|
393
|
+
else:
|
|
394
|
+
review_reason = "review required but not approved"
|
|
395
|
+
report.decisions.append(
|
|
396
|
+
GateDecision(
|
|
397
|
+
gate="review", required=review_required, ok=review_ok, reason=review_reason,
|
|
398
|
+
verdict_unparseable=review_verdict_unparseable,
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
smoke_required = mq.requires_smoke(entry, config)
|
|
403
|
+
smoke_status = mq.evaluate_smoke_verdict(entry, board, gh_ops) if smoke_required else None
|
|
404
|
+
test_ok = (not smoke_required) or bool(smoke_status and smoke_status.ok)
|
|
405
|
+
test_decision = GateDecision(gate="test", required=smoke_required, ok=test_ok)
|
|
406
|
+
if smoke_status is not None:
|
|
407
|
+
# #2024: recorded on BOTH paths, not just the refusal — "which row is
|
|
408
|
+
# this verdict actually about" is the question a green summary hides.
|
|
409
|
+
test_decision.assignment_id = smoke_status.assignment_id
|
|
410
|
+
if smoke_status is not None and not smoke_status.ok:
|
|
411
|
+
test_decision.reason = smoke_status.message
|
|
412
|
+
test_decision.anchor = smoke_status.anchor
|
|
413
|
+
test_decision.recorded_sha = smoke_status.recorded_sha
|
|
414
|
+
test_decision.current_sha = smoke_status.current_sha
|
|
415
|
+
report.decisions.append(test_decision)
|
|
416
|
+
|
|
417
|
+
# #2024: the two readings, reconciled by SAYING WHICH ROW EACH IS ABOUT.
|
|
418
|
+
# The merge gate is branch-scoped by design (#1819: a Test run measures the
|
|
419
|
+
# (branch, base) pair), so on a `--fix-of` chain — where every round is a
|
|
420
|
+
# new work row on the SAME branch — it is routinely satisfied by the
|
|
421
|
+
# PARENT's verdict. The per-iteration readers (`coord drive`'s
|
|
422
|
+
# `work_test_state`, `dispatch_pending_reviews`, `auto_loop`'s #1612 gate)
|
|
423
|
+
# key on the CURRENT row's own verdict and correctly hold when it is
|
|
424
|
+
# empty. Both are right; only the summary was silent about the difference,
|
|
425
|
+
# which is what turned a blocked pipeline into an invisible one (25 min,
|
|
426
|
+
# then 160 min, on vimcode#635).
|
|
427
|
+
if (
|
|
428
|
+
test_ok
|
|
429
|
+
and smoke_required
|
|
430
|
+
and test_decision.assignment_id
|
|
431
|
+
and winner.assignment_id
|
|
432
|
+
and test_decision.assignment_id != winner.assignment_id
|
|
433
|
+
and winner.test_state not in ("passed", "skipped")
|
|
434
|
+
):
|
|
435
|
+
report.notes.append(
|
|
436
|
+
f"test PASSED is the branch-scoped merge reading: the verdict was "
|
|
437
|
+
f"recorded on {test_decision.assignment_id}, not on this branch's "
|
|
438
|
+
f"current work row {winner.assignment_id} "
|
|
439
|
+
f"(review_iteration={winner.review_iteration or 0}, "
|
|
440
|
+
f"test_state={winner.test_state or 'none'}). Per-iteration readers "
|
|
441
|
+
"— `coord drive`'s Test stage and review auto-dispatch "
|
|
442
|
+
"(pipeline.test_precedes_review) — gate on the CURRENT row's own "
|
|
443
|
+
"verdict, so review dispatch can still be held while this line "
|
|
444
|
+
"reads passed. Record one with `coord test "
|
|
445
|
+
f"{winner.assignment_id} --passed` (or --skipped) once the fix "
|
|
446
|
+
"round has actually been tested (#2024)."
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
merge_blocked_gate: str | None = None
|
|
450
|
+
if review_required and not review_ok:
|
|
451
|
+
merge_blocked_gate = REVIEW_REQUIRED
|
|
452
|
+
elif smoke_required and not test_ok:
|
|
453
|
+
merge_blocked_gate = SMOKE_REQUIRED
|
|
454
|
+
merge_decision = GateDecision(
|
|
455
|
+
gate="merge", required=True, ok=merge_blocked_gate is None, reason=merge_blocked_gate,
|
|
456
|
+
)
|
|
457
|
+
report.decisions.append(merge_decision)
|
|
458
|
+
if merge_decision.ok:
|
|
459
|
+
report.notes.append(
|
|
460
|
+
"merge READY reflects the review/test gates only — CI checks and the "
|
|
461
|
+
"#1318 epic-closing-keyword guard are evaluated live by `coord merge`/"
|
|
462
|
+
"`coord merge --plan`, not by `coord gates`."
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
return report
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _short_sha(sha: str | None) -> str:
|
|
469
|
+
return sha[:7] if sha else "unknown"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def format_gate_report(report: GateReport) -> str:
|
|
473
|
+
"""Human-readable rendering of *report* for the CLI's default (non-JSON) output."""
|
|
474
|
+
lines: list[str] = [f"gates {report.repo_name}#{report.issue_number}"]
|
|
475
|
+
|
|
476
|
+
for row in report.rows:
|
|
477
|
+
lines.append(
|
|
478
|
+
f" [{row.type}] {row.assignment_id or '?'} status={row.status} "
|
|
479
|
+
f"branch={row.branch or '-'} machine={row.machine_name or '-'}"
|
|
480
|
+
+ (f" provider={row.provider_name}" if row.provider_name else "")
|
|
481
|
+
+ (f" interactive={row.is_interactive}" if row.is_interactive is not None else "")
|
|
482
|
+
# #1730: legible two-number reality — only printed when the row's
|
|
483
|
+
# attribution differs from the issue it's booked to, so an
|
|
484
|
+
# ordinary row (no `for_issue_number`, or one equal to
|
|
485
|
+
# `issue_number`) renders exactly as it did before this field
|
|
486
|
+
# existed.
|
|
487
|
+
+ (
|
|
488
|
+
f" booked_to=#{row.issue_number} for=#{row.for_issue_number}"
|
|
489
|
+
if row.for_issue_number is not None and row.for_issue_number != row.issue_number
|
|
490
|
+
else ""
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
lines.append(
|
|
494
|
+
f" test_state={row.test_state} smoke_test={row.smoke_test} "
|
|
495
|
+
f"test_reason={row.test_reason!r}"
|
|
496
|
+
)
|
|
497
|
+
lines.append(
|
|
498
|
+
f" test_toolchain={row.test_toolchain or 'unknown'}"
|
|
499
|
+
)
|
|
500
|
+
lines.append(
|
|
501
|
+
f" review_state={row.review_state} review_verdict={row.review_verdict} "
|
|
502
|
+
f"review_of_assignment_id={row.review_of_assignment_id or '-'}"
|
|
503
|
+
)
|
|
504
|
+
# #1956: only printed when this row actually carries a verdict AND a
|
|
505
|
+
# provenance value that is NOT the default "agent" — a plain
|
|
506
|
+
# "verdict_source=agent" on every ordinary row (the overwhelming
|
|
507
|
+
# majority, including every row `coord report-result --verdict`
|
|
508
|
+
# persists going forward — see `issue_store._persist_verdict_source`,
|
|
509
|
+
# which always stamps a source, never leaves the column NULL) would
|
|
510
|
+
# be exactly the noise this line was written to avoid. `None` and
|
|
511
|
+
# the literal `"agent"` both mean "an agent produced this verdict"
|
|
512
|
+
# (see coord.models.Assignment.verdict_source) — only surface the
|
|
513
|
+
# cases that need a human's attention: recovered/overridden.
|
|
514
|
+
if row.review_verdict is not None and row.verdict_source not in (None, "agent"):
|
|
515
|
+
lines.append(
|
|
516
|
+
f" verdict_source={row.verdict_source}"
|
|
517
|
+
+ (
|
|
518
|
+
f" reason={row.verdict_source_reason!r}"
|
|
519
|
+
if row.verdict_source_reason
|
|
520
|
+
else ""
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
if report.decisions:
|
|
525
|
+
by_gate = {d.gate: d for d in report.decisions}
|
|
526
|
+
lines.append("")
|
|
527
|
+
lines.append(
|
|
528
|
+
f"Gate decision (branch {report.branch or '?'} -> {report.target_branch or '?'}):"
|
|
529
|
+
)
|
|
530
|
+
review = by_gate.get("review")
|
|
531
|
+
if review is not None:
|
|
532
|
+
if not review.required:
|
|
533
|
+
lines.append(" review : not required")
|
|
534
|
+
elif review.ok:
|
|
535
|
+
lines.append(" review : approve")
|
|
536
|
+
elif review.verdict_unparseable:
|
|
537
|
+
# #1956: deliberately NOT "BLOCKED" — that word reads
|
|
538
|
+
# identically to "not yet reviewed" / "requested changes",
|
|
539
|
+
# exactly the ambiguity #1956 reports. This needs operator
|
|
540
|
+
# recovery, not a wait.
|
|
541
|
+
lines.append(f" review : ERROR — {review.reason}")
|
|
542
|
+
else:
|
|
543
|
+
lines.append(f" review : BLOCKED — {review.reason}")
|
|
544
|
+
test = by_gate.get("test")
|
|
545
|
+
if test is not None:
|
|
546
|
+
if not test.required:
|
|
547
|
+
lines.append(" test : not required")
|
|
548
|
+
elif test.ok:
|
|
549
|
+
# #2024: name the row the verdict sits on. `coord gates` and
|
|
550
|
+
# `coord drive` are allowed to answer differently (branch gate
|
|
551
|
+
# vs per-iteration gate) — they are not allowed to do it
|
|
552
|
+
# silently.
|
|
553
|
+
lines.append(
|
|
554
|
+
" test : passed"
|
|
555
|
+
+ (f" (recorded on {test.assignment_id})" if test.assignment_id else "")
|
|
556
|
+
)
|
|
557
|
+
elif test.anchor:
|
|
558
|
+
noun = "base" if test.anchor == "base" else "branch"
|
|
559
|
+
lines.append(
|
|
560
|
+
f" test : STALE — recorded against {noun} "
|
|
561
|
+
f"{_short_sha(test.recorded_sha)}, {noun} now "
|
|
562
|
+
f"{_short_sha(test.current_sha)} (#1479)"
|
|
563
|
+
)
|
|
564
|
+
lines.append(f" {test.reason}")
|
|
565
|
+
else:
|
|
566
|
+
lines.append(f" test : BLOCKED — {test.reason}")
|
|
567
|
+
merge = by_gate.get("merge")
|
|
568
|
+
if merge is not None:
|
|
569
|
+
lines.append(
|
|
570
|
+
" merge : READY" if merge.ok else f" merge : BLOCKED — {merge.reason}"
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
for note in report.notes:
|
|
574
|
+
lines.append(f" note: {note}")
|
|
575
|
+
|
|
576
|
+
return "\n".join(lines)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def report_to_dict(report: GateReport) -> dict:
|
|
580
|
+
"""JSON-safe ``dict`` for the CLI's ``--json`` flag / the ``/gates``
|
|
581
|
+
daemon response — plain nested dicts/lists, no dataclass instances."""
|
|
582
|
+
return asdict(report)
|