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/ci_store.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""CiStore abstraction over CI check status.
|
|
2
|
+
|
|
3
|
+
Phase 1 (#240) of the CiStore abstraction: a thin Protocol over ``gh pr checks``
|
|
4
|
+
so the merge gate can hard-block on failed checks and the TUI can surface what
|
|
5
|
+
broke. Rerun, polling, and non-GitHub backends are deferred to later phases.
|
|
6
|
+
|
|
7
|
+
Phase 2 (#1851) adds the rerun half: :meth:`CiStore.rerun_for_pr` and the
|
|
8
|
+
:func:`checks_are_stale` predicate. A **green** check can itself be stale —
|
|
9
|
+
GitHub re-runs ``pull_request`` workflows on head ``synchronize``, never on
|
|
10
|
+
base movement, so a passing check proves the composite passed against the
|
|
11
|
+
base *as of the last head push*, not as of now. Polling and non-GitHub
|
|
12
|
+
backends remain deferred.
|
|
13
|
+
|
|
14
|
+
Phase 3 (#1892) adds a **RED** check's own analogue of the same question:
|
|
15
|
+
did this failure say anything about the code at all? :meth:`CiStore.
|
|
16
|
+
list_jobs_for_run` and :func:`is_verdictless_job` distinguish "never assigned
|
|
17
|
+
a runner" / "died before checkout" (a statement about the CI *platform*) from
|
|
18
|
+
a genuine test failure (a statement about the *code*) — used exclusively by
|
|
19
|
+
:mod:`coord.merge_queue`'s drive-retry accounting, never by the merge gate
|
|
20
|
+
itself (:func:`failed_checks` and ``_PASSING_CONCLUSIONS`` are unchanged: a
|
|
21
|
+
verdictless check still blocks the merge, it just doesn't cost a retry).
|
|
22
|
+
|
|
23
|
+
The split between :class:`CiStore` (Protocol) and the concrete backends
|
|
24
|
+
(:class:`coord.ci_github.GitHubCi`, :class:`NoOpCi`) means tests can pass a
|
|
25
|
+
stub through ``ci_store=`` without touching subprocess at all.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Protocol, runtime_checkable
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class CiCheckSummary:
|
|
37
|
+
"""Structured rollup of a PR's CI checks — the board-wire analogue of the
|
|
38
|
+
TUI's Rust ``CiCheckSummary`` (``tui/src/app/types.rs``).
|
|
39
|
+
|
|
40
|
+
Populated server-side by :func:`summarize_counts` and attached to
|
|
41
|
+
:class:`coord.merge_queue.PlannedMerge` so the TUI can render its "2✓ 1✗"
|
|
42
|
+
badges straight from the ``/board`` payload instead of shelling out to
|
|
43
|
+
``gh pr checks`` itself (#1344).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
passed: int
|
|
47
|
+
failed: int
|
|
48
|
+
running: int
|
|
49
|
+
failed_names: list[str]
|
|
50
|
+
first_failed_url: str | None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class JobStep:
|
|
55
|
+
"""One step of a GitHub Actions job (#1892).
|
|
56
|
+
|
|
57
|
+
``conclusion`` mirrors :class:`CheckRun`'s field: ``None`` while the step
|
|
58
|
+
hasn't finished, otherwise success/failure/cancelled/skipped/... — the
|
|
59
|
+
same vocabulary GitHub uses for check-run conclusions, one level down.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
conclusion: str | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class JobRun:
|
|
68
|
+
"""One job of a GitHub Actions run — the step-level detail a
|
|
69
|
+
:class:`CheckRun` doesn't carry (#1892).
|
|
70
|
+
|
|
71
|
+
Populated only on the CI-failure classification path (see
|
|
72
|
+
:func:`is_verdictless_job`): a :class:`CheckRun` names a *check*
|
|
73
|
+
(workflow name, e.g. ``test (3.12)``), and this is the matching *job*
|
|
74
|
+
(same name, fetched via ``gh api repos/{repo}/actions/runs/{id}/jobs``)
|
|
75
|
+
with its steps. ``runner_name`` is empty when GitHub never assigned this
|
|
76
|
+
job a runner at all — the "cancelled at the queue timeout" signature.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
name: str
|
|
80
|
+
conclusion: str | None
|
|
81
|
+
runner_name: str
|
|
82
|
+
steps: list[JobStep] = field(default_factory=list)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class CheckRun:
|
|
87
|
+
"""A single CI check run on a PR.
|
|
88
|
+
|
|
89
|
+
``status`` is the lifecycle phase: queued / in_progress / completed.
|
|
90
|
+
``conclusion`` is only meaningful when ``status == "completed"`` and is
|
|
91
|
+
normally one of success / failure / cancelled / skipped / neutral /
|
|
92
|
+
timed_out / action_required / stale — but GitHub can and does add new
|
|
93
|
+
conclusions over time, and :class:`coord.ci_github.GitHubCi` synthesizes
|
|
94
|
+
the conclusion ``"unknown"`` when it couldn't read a PR's checks at all
|
|
95
|
+
(#1525). ``failed_checks`` below is an **allow-list**: a completed check
|
|
96
|
+
passes only when its conclusion is affirmatively known-benign
|
|
97
|
+
(``success`` / ``skipped`` / ``neutral``); anything else — including a
|
|
98
|
+
conclusion this module has never seen — blocks the merge gate. ``status
|
|
99
|
+
!= "completed"`` is in-flight, handled separately by
|
|
100
|
+
:func:`in_flight_checks`.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
name: str
|
|
104
|
+
status: str
|
|
105
|
+
conclusion: str | None
|
|
106
|
+
url: str
|
|
107
|
+
run_id: str
|
|
108
|
+
started_at: float | None
|
|
109
|
+
completed_at: float | None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@runtime_checkable
|
|
113
|
+
class CiStore(Protocol):
|
|
114
|
+
"""View of CI checks for a PR, plus (#1851) the one write operation this
|
|
115
|
+
abstraction supports: re-running them.
|
|
116
|
+
|
|
117
|
+
``rerun_for_pr`` is deliberately the *only* mutating method — everything
|
|
118
|
+
else stays read-only exactly as Phase 1 (#240) left it, so every existing
|
|
119
|
+
stub-based test (a plain object/dataclass implementing
|
|
120
|
+
``list_checks_for_pr``/``is_available`` with no ``rerun_for_pr`` at all)
|
|
121
|
+
keeps passing unmodified: nothing here reads ``rerun_for_pr`` off a
|
|
122
|
+
``CiStore`` except the #1851 revalidate path, which only ever runs behind
|
|
123
|
+
the ``coord merge --revalidate`` flag.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def list_checks_for_pr(self, repo: str, number: int) -> list[CheckRun]: ...
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def is_available(self) -> bool: ...
|
|
130
|
+
|
|
131
|
+
def expects_checks(self, repo: str, number: int) -> bool:
|
|
132
|
+
"""True when *repo*/*number* should have reported at least one check.
|
|
133
|
+
|
|
134
|
+
#1904: ``checks == []`` is genuinely ambiguous — "no CI is
|
|
135
|
+
configured for this repo" (merging is correct) and "CI exists but
|
|
136
|
+
was never triggered" (a throttled webhook, a wedged run, a
|
|
137
|
+
path-filtered-out workflow — merging is wrong) both produce it, and
|
|
138
|
+
every gate predicate (:func:`failed_checks`, :func:`in_flight_checks`,
|
|
139
|
+
:func:`checks_are_stale`) is a filter that reads an empty list as
|
|
140
|
+
"nothing wrong". This is the one method that answers "which of the
|
|
141
|
+
two is this" *without* looking at any particular PR's checks — it
|
|
142
|
+
asks whether the backend believes CI exists for this repo at all.
|
|
143
|
+
Callers (``coord.merge_queue``'s ``checks_absent`` gate) only
|
|
144
|
+
consult this when ``list_checks_for_pr`` has already come back
|
|
145
|
+
empty; a non-empty check list settles the question on its own.
|
|
146
|
+
|
|
147
|
+
:class:`NoOpCi` answers ``False`` unconditionally — it is the
|
|
148
|
+
supported "this repo has no CI" opt-out (``ci_store: {type:
|
|
149
|
+
none}``), so nothing it reports should ever read as "checks
|
|
150
|
+
absent". A backend that can't determine this at all should default
|
|
151
|
+
to ``True`` (fail closed, mirroring #1525's "unknown reads as
|
|
152
|
+
blocking" posture) rather than silently reopening the hole this
|
|
153
|
+
method exists to close.
|
|
154
|
+
"""
|
|
155
|
+
...
|
|
156
|
+
|
|
157
|
+
def rerun_for_pr(self, repo: str, number: int) -> bool:
|
|
158
|
+
"""Re-run *repo*#*number*'s CI workflows. Returns whether it worked.
|
|
159
|
+
|
|
160
|
+
Cheap remedy for a CI result staled by base movement (#1851): a CI
|
|
161
|
+
re-run on GitHub-hosted runners costs minutes, not a routed Test-stage
|
|
162
|
+
agent dispatch. Never called unattended — see
|
|
163
|
+
:mod:`coord.revalidate`'s module docstring and
|
|
164
|
+
``docs/DRIVE_QUEUE.md`` for why ``--revalidate`` is opt-in and
|
|
165
|
+
auto-drain must never trigger work on its own schedule.
|
|
166
|
+
|
|
167
|
+
#1892: this same method is ALSO the auto-rerun remedy for a
|
|
168
|
+
verdictless CI failure — see :mod:`coord.merge_queue`'s
|
|
169
|
+
``_ci_infra_reason``/``MAX_CI_INFRA_RERUNS``. That call site runs
|
|
170
|
+
unattended (unlike ``--revalidate``), which is safe specifically
|
|
171
|
+
*because* the trigger is narrow (every failing check carries no
|
|
172
|
+
verdict about the code) and bounded (capped, then parked for a
|
|
173
|
+
human) — it is not a general license for auto-drain to rerun CI.
|
|
174
|
+
"""
|
|
175
|
+
...
|
|
176
|
+
|
|
177
|
+
def list_jobs_for_run(self, repo: str, run_id: str) -> list[JobRun]:
|
|
178
|
+
"""Job/step detail for Actions run *run_id* on *repo* (#1892).
|
|
179
|
+
|
|
180
|
+
The one piece of data :class:`CheckRun` doesn't carry and the CI
|
|
181
|
+
gate (``failed_checks``/``_PASSING_CONCLUSIONS``) never needed: which
|
|
182
|
+
step (if any) actually ran before a check failed. Exists solely to
|
|
183
|
+
back :func:`is_verdictless_job` — the drive's retry-accounting
|
|
184
|
+
question "did this failure say anything about the code?", never the
|
|
185
|
+
merge gate itself.
|
|
186
|
+
|
|
187
|
+
Callers MUST only invoke this after a check has already been found
|
|
188
|
+
failing (:func:`failed_checks` non-empty) — never on the passing or
|
|
189
|
+
pending path, and never from a request-time board read (see
|
|
190
|
+
``coord.gate_snapshot``'s Invariant 1: the read path performs no
|
|
191
|
+
third-party I/O). Best-effort: a backend that can't answer this
|
|
192
|
+
returns ``[]``, which :func:`is_verdictless_job` always reads as "no
|
|
193
|
+
job data, therefore not verdictless" — the same false-negative bias
|
|
194
|
+
as an unmatched job (see that function's docstring).
|
|
195
|
+
"""
|
|
196
|
+
...
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class NoOpCi:
|
|
200
|
+
"""Always-available fallback that returns no checks and reruns nothing.
|
|
201
|
+
|
|
202
|
+
Used when the user opts out of CI gating with ``ci_store: { type: none }``
|
|
203
|
+
or when no backend is configured. ``is_available`` is ``False`` so callers
|
|
204
|
+
can distinguish "no CI configured" from "CI says all clear".
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def list_checks_for_pr(self, repo: str, number: int) -> list[CheckRun]:
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def is_available(self) -> bool:
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
def expects_checks(self, repo: str, number: int) -> bool:
|
|
215
|
+
"""Always ``False`` — CI gating is opted out entirely (#1904), so an
|
|
216
|
+
empty check list is never "checks absent", it's "no CI here"."""
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
def rerun_for_pr(self, repo: str, number: int) -> bool:
|
|
220
|
+
"""No-op: CI gating is disabled entirely, so there is nothing to
|
|
221
|
+
re-run and nothing to report as stale (#1851)."""
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
def list_jobs_for_run(self, repo: str, run_id: str) -> list[JobRun]:
|
|
225
|
+
"""No-op: CI gating is disabled entirely, so there is no job/step
|
|
226
|
+
detail to fetch (#1892)."""
|
|
227
|
+
return []
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ── Classification helpers ──────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
# #1525: allow-list of conclusions known to be benign, not a deny-list of
|
|
233
|
+
# conclusions known to be bad. Before this, `_FAILED_CONCLUSIONS` enumerated
|
|
234
|
+
# {"failure", "cancelled", "timed_out", "action_required"} and anything NOT
|
|
235
|
+
# in that set — a `"stale"` conclusion, a future GitHub conclusion this code
|
|
236
|
+
# had never seen, or the synthetic `"unknown"` conclusion GitHubCi emits when
|
|
237
|
+
# a `gh pr checks` read fails — silently read as "not failing", i.e. passing.
|
|
238
|
+
# That is exactly the fail-open shape that let PR #1521 merge over a red
|
|
239
|
+
# `test (3.12)` run: an unrecognised or unreadable conclusion must default to
|
|
240
|
+
# BLOCKING, never to passing.
|
|
241
|
+
_PASSING_CONCLUSIONS = frozenset({"success", "skipped", "neutral"})
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _is_failing_conclusion(conclusion: str | None) -> bool:
|
|
245
|
+
return conclusion not in _PASSING_CONCLUSIONS
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def failed_checks(checks: list[CheckRun]) -> list[CheckRun]:
|
|
249
|
+
"""Return completed checks whose conclusion is not affirmatively passing.
|
|
250
|
+
|
|
251
|
+
Only evaluates ``status == "completed"`` checks — an in-flight check has
|
|
252
|
+
``conclusion is None`` and is handled by :func:`in_flight_checks`
|
|
253
|
+
instead, not counted as failed here.
|
|
254
|
+
"""
|
|
255
|
+
return [
|
|
256
|
+
c for c in checks
|
|
257
|
+
if c.status == "completed" and _is_failing_conclusion(c.conclusion)
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def in_flight_checks(checks: list[CheckRun]) -> list[CheckRun]:
|
|
262
|
+
"""Return checks that are queued or running (not yet completed)."""
|
|
263
|
+
return [c for c in checks if c.status != "completed"]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ── Post-rerun settle wait (#1925) ──────────────────────────────────────────
|
|
267
|
+
#
|
|
268
|
+
# `coord merge --revalidate`'s CI arm (`_apply_ci_revalidation` in
|
|
269
|
+
# `coord/commands/merge.py`) triggers `rerun_for_pr` for an entry whose CI
|
|
270
|
+
# checks are green but stale (predate the current base), then used to hand
|
|
271
|
+
# the entry straight to `merge_queue.process()`'s gate a moment later.
|
|
272
|
+
# `rerun_for_pr` invalidates the check cache, so that very next
|
|
273
|
+
# `list_checks_for_pr` read can land in the few-second window before GitHub
|
|
274
|
+
# has created ANY check-run record for the new workflow run: `gh pr checks`
|
|
275
|
+
# itself errors ("no checks reported on the ... branch"), which
|
|
276
|
+
# `coord.ci_github.GitHubCi._fetch` (correctly, per #1525) turns into a
|
|
277
|
+
# synthetic `conclusion="unknown"` check — and the fail-closed allow-list
|
|
278
|
+
# blocks on that exactly as it should for a GENUINE unreadable status. The
|
|
279
|
+
# bug was evaluating the gate a heartbeat after triggering the very re-run
|
|
280
|
+
# that caused this transient, self-inflicted reading — reliably, every time,
|
|
281
|
+
# since a freshly-triggered run cannot possibly have registered yet.
|
|
282
|
+
#
|
|
283
|
+
# `wait_for_ci_settle` closes that window: after triggering, poll (bounded)
|
|
284
|
+
# until the new run has both registered on GitHub AND finished, so the gate
|
|
285
|
+
# evaluates a real result — a genuine pass or a genuine failure — instead of
|
|
286
|
+
# the registration gap. This is what lets a healthy branch merge in the same
|
|
287
|
+
# `--revalidate` invocation instead of needing a human to come back once CI
|
|
288
|
+
# settles.
|
|
289
|
+
CI_RERUN_POLL_INTERVAL_SECONDS = 10.0
|
|
290
|
+
|
|
291
|
+
# ~3 minutes was the observed real-world settle time for the two 2026-08-07
|
|
292
|
+
# reproductions (#1925). This gives roughly a 2x margin without making a
|
|
293
|
+
# healthy `--revalidate` run hang needlessly long once CI is genuinely done.
|
|
294
|
+
CI_RERUN_MAX_WAIT_SECONDS = 360.0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _is_unreadable_check(check) -> bool:
|
|
298
|
+
"""True for the #1525 synthetic "could not read CI status" / "gh too
|
|
299
|
+
old" stand-ins (``coord.ci_github._unreadable_check`` /
|
|
300
|
+
``_gh_too_old_check``) — the read itself failed, so this says nothing
|
|
301
|
+
about the code yet. Duck-typed on ``.conclusion``/``.name`` rather than
|
|
302
|
+
importing ``coord.ci_github`` — this module must stay backend-agnostic,
|
|
303
|
+
and every stub/fake ``CheckRun``-alike in the test suite already has
|
|
304
|
+
both attributes.
|
|
305
|
+
"""
|
|
306
|
+
return (
|
|
307
|
+
getattr(check, "conclusion", None) == "unknown"
|
|
308
|
+
and str(getattr(check, "name", "")).startswith("coord: ")
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass
|
|
313
|
+
class CiSettleResult:
|
|
314
|
+
"""Outcome of :func:`wait_for_ci_settle` (#1925).
|
|
315
|
+
|
|
316
|
+
``settled`` is the only thing callers need to branch on: ``True`` means
|
|
317
|
+
*checks* is a genuine, resolved result (real pass or real fail) safe to
|
|
318
|
+
hand to the merge gate. ``False`` means the wait budget ran out first —
|
|
319
|
+
``registering`` then distinguishes *why*: ``True`` is the self-inflicted
|
|
320
|
+
"the re-run we just triggered still hasn't registered/resolved" case
|
|
321
|
+
(never a real CI verdict), ``False`` means real checks are in flight and
|
|
322
|
+
have simply not finished yet (an honest, already-correctly-classified
|
|
323
|
+
``checks_pending``).
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
settled: bool
|
|
327
|
+
checks: list[CheckRun]
|
|
328
|
+
waited_seconds: float
|
|
329
|
+
registering: bool = False
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def wait_for_ci_settle(
|
|
333
|
+
ci_store: "CiStore",
|
|
334
|
+
repo: str,
|
|
335
|
+
number: int,
|
|
336
|
+
*,
|
|
337
|
+
timeout: float = CI_RERUN_MAX_WAIT_SECONDS,
|
|
338
|
+
poll_interval: float = CI_RERUN_POLL_INTERVAL_SECONDS,
|
|
339
|
+
echo=None,
|
|
340
|
+
sleep=None,
|
|
341
|
+
clock=None,
|
|
342
|
+
) -> CiSettleResult:
|
|
343
|
+
"""Bounded poll for a just-triggered CI re-run to register and finish.
|
|
344
|
+
|
|
345
|
+
Only ever called right after a successful :meth:`CiStore.rerun_for_pr`
|
|
346
|
+
(#1925) — see this section's header comment for the exact bug this
|
|
347
|
+
closes. Every poll invalidates the backend's cache first (best-effort;
|
|
348
|
+
only :class:`coord.ci_github.GitHubCi` implements ``invalidate``, so a
|
|
349
|
+
stub without it is left alone) so each read is a fresh one, not a cached
|
|
350
|
+
pre-rerun snapshot.
|
|
351
|
+
|
|
352
|
+
A "registering" read — no checks at all yet, or every check present is
|
|
353
|
+
one of the #1525 synthetic unreadable stand-ins (see
|
|
354
|
+
:func:`_is_unreadable_check`) — never counts as settled, however many
|
|
355
|
+
times it's observed; only real, resolved checks do. Genuinely in-flight
|
|
356
|
+
real checks (a run that registered and is now actually executing) also
|
|
357
|
+
keep polling — that's the ordinary "wait for CI to finish" case this
|
|
358
|
+
function exists to cover, not just the registration gap.
|
|
359
|
+
|
|
360
|
+
Returns as soon as *checks* is non-empty, none of it is the synthetic
|
|
361
|
+
unreadable stand-in, and none of it is still in-flight — i.e. a genuine
|
|
362
|
+
resolved result, pass or fail alike; the caller (and ultimately
|
|
363
|
+
``merge_queue.process()``) decides what that result means. Gives up once
|
|
364
|
+
*timeout* elapses, returning whatever was last observed with
|
|
365
|
+
``settled=False``.
|
|
366
|
+
"""
|
|
367
|
+
echo = echo or (lambda msg: None)
|
|
368
|
+
sleep = sleep if sleep is not None else time.sleep
|
|
369
|
+
clock = clock if clock is not None else time.monotonic
|
|
370
|
+
|
|
371
|
+
start = clock()
|
|
372
|
+
checks: list[CheckRun] = []
|
|
373
|
+
announced = False
|
|
374
|
+
while True:
|
|
375
|
+
invalidate = getattr(ci_store, "invalidate", None)
|
|
376
|
+
if callable(invalidate):
|
|
377
|
+
try:
|
|
378
|
+
invalidate(repo, number)
|
|
379
|
+
except Exception: # noqa: BLE001 — best-effort cache-bust only
|
|
380
|
+
pass
|
|
381
|
+
checks = ci_store.list_checks_for_pr(repo, number)
|
|
382
|
+
registering = not checks or all(_is_unreadable_check(c) for c in checks)
|
|
383
|
+
if not registering and not in_flight_checks(checks):
|
|
384
|
+
return CiSettleResult(
|
|
385
|
+
settled=True, checks=checks, waited_seconds=clock() - start,
|
|
386
|
+
)
|
|
387
|
+
elapsed = clock() - start
|
|
388
|
+
if elapsed >= timeout:
|
|
389
|
+
return CiSettleResult(
|
|
390
|
+
settled=False, checks=checks, waited_seconds=elapsed,
|
|
391
|
+
registering=registering,
|
|
392
|
+
)
|
|
393
|
+
if not announced:
|
|
394
|
+
echo(
|
|
395
|
+
f" --revalidate: waiting for the CI re-run on {repo}#{number} "
|
|
396
|
+
"to register and settle before evaluating the gate (#1925)..."
|
|
397
|
+
)
|
|
398
|
+
announced = True
|
|
399
|
+
sleep(poll_interval)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# #1892: two real signatures — recorded from JDonaghy/claude-coordinator run
|
|
403
|
+
# 31117792472 and JDonaghy/vimcode run 31119463000, both 2026-08-06 — for a
|
|
404
|
+
# CI failure that says nothing about the code:
|
|
405
|
+
#
|
|
406
|
+
# 1. Never assigned a runner: cancelled at the queue timeout, `runner_name`
|
|
407
|
+
# empty, `steps` empty. GitHub does create a job record for this (unlike
|
|
408
|
+
# a run that never even reaches job-scheduling), but with zero steps.
|
|
409
|
+
# 2. Got a runner, died before checkout: exactly one step, named literally
|
|
410
|
+
# "Set up job", with a non-passing conclusion — nothing past it ran, so
|
|
411
|
+
# no repo code executed either.
|
|
412
|
+
#
|
|
413
|
+
# Deliberately narrow — see this module's `_PASSING_CONCLUSIONS` comment for
|
|
414
|
+
# the identical lesson learned the hard way (#1525) about allow-lists vs.
|
|
415
|
+
# catch-alls, and the issue's own hazard note: a classifier that is too eager
|
|
416
|
+
# becomes a way to launder real failures into "infrastructure". Prefer false
|
|
417
|
+
# negatives — a platform failure misread as real costs one manual unblock; a
|
|
418
|
+
# real failure misread as platform noise costs a bad merge. So both
|
|
419
|
+
# `check is None`/`job is None` (no job data — including a fetch failure;
|
|
420
|
+
# see `CiStore.list_jobs_for_run`'s docstring) and any shape that isn't
|
|
421
|
+
# EXACTLY one of the two above read as "carries a verdict", never as
|
|
422
|
+
# verdictless.
|
|
423
|
+
_SET_UP_JOB_STEP_NAME = "Set up job"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def is_verdictless_job(check: CheckRun, job: JobRun | None) -> bool:
|
|
427
|
+
"""True when *check* failed for a reason that says nothing about the
|
|
428
|
+
code — see the two signatures documented above (#1892).
|
|
429
|
+
|
|
430
|
+
Only meaningful for a check :func:`failed_checks` already selected
|
|
431
|
+
(``status == "completed"`` and a non-passing conclusion); a check that
|
|
432
|
+
is still in flight, or one this function is asked about with no
|
|
433
|
+
matching *job* record, always reads ``False`` — "carries a verdict",
|
|
434
|
+
the safe default per the false-negative bias above.
|
|
435
|
+
|
|
436
|
+
This is a **narrower** question than the merge gate's own — it does not
|
|
437
|
+
change whether the check counts as failed (:func:`failed_checks` is
|
|
438
|
+
untouched), only whether the failure is evidence about the *code*. Used
|
|
439
|
+
exclusively by the drive's retry accounting (:mod:`coord.merge_queue`'s
|
|
440
|
+
``_ci_infra_reason``), never by the gate itself.
|
|
441
|
+
"""
|
|
442
|
+
if check.status != "completed" or job is None:
|
|
443
|
+
return False
|
|
444
|
+
if check.conclusion == "cancelled":
|
|
445
|
+
return len(job.steps) == 0
|
|
446
|
+
failed_steps = [
|
|
447
|
+
s for s in job.steps if s.conclusion not in (None, "success", "skipped")
|
|
448
|
+
]
|
|
449
|
+
return len(failed_steps) == 1 and failed_steps[0].name == _SET_UP_JOB_STEP_NAME
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def checks_are_stale(checks: list[CheckRun], base_commit_time: float | None) -> bool:
|
|
453
|
+
"""True when a **green** *checks* result predates *base_commit_time* (#1851).
|
|
454
|
+
|
|
455
|
+
GitHub attaches ``pull_request`` check runs to the PR's *head* SHA and
|
|
456
|
+
re-runs them on head ``synchronize`` — never on base movement — so a
|
|
457
|
+
check that started before the base's newest commit landed never saw that
|
|
458
|
+
commit. ``started_at`` (not ``completed_at``) is the comparison point:
|
|
459
|
+
what matters is what the base looked like when the run *began*, not when
|
|
460
|
+
it finished.
|
|
461
|
+
|
|
462
|
+
Callers should apply :func:`failed_checks`/:func:`in_flight_checks`
|
|
463
|
+
first — this function assumes *checks* is the all-passing remainder and
|
|
464
|
+
doesn't re-derive that itself, so it never contradicts "CI failed"/"CI
|
|
465
|
+
running" with a third, competing reading of the same checks. An empty
|
|
466
|
+
*checks* list (nothing to compare) reads as not-stale; the caller's own
|
|
467
|
+
"no checks" handling covers that case — see :meth:`CiStore.expects_checks`
|
|
468
|
+
and ``coord.merge_queue``'s ``checks_absent`` gate (#1904), which now
|
|
469
|
+
implements exactly that handling at all three call sites.
|
|
470
|
+
|
|
471
|
+
Fails closed toward **stale** — mirroring
|
|
472
|
+
:func:`coord.merge_queue._base_move_is_inert`'s documented bias ("a false
|
|
473
|
+
'fresh' merges untested code; a false 'stale' only costs a re-run") —
|
|
474
|
+
whenever the comparison can't be made with confidence: *base_commit_time*
|
|
475
|
+
unreadable/``None``, or any check missing ``started_at``. Clock and
|
|
476
|
+
ordering skew between GitHub's check timestamps and its branch-commit
|
|
477
|
+
timestamps are real; this predicate is not exact, and errs toward the
|
|
478
|
+
cheap re-run rather than the silent stale-green pass.
|
|
479
|
+
"""
|
|
480
|
+
if not checks:
|
|
481
|
+
return False
|
|
482
|
+
if base_commit_time is None:
|
|
483
|
+
return True
|
|
484
|
+
return any(c.started_at is None or c.started_at < base_commit_time for c in checks)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def build_ci_store(ci_store_type: str) -> CiStore:
|
|
488
|
+
"""Construct the CiStore backend named by ``ci_store_type``.
|
|
489
|
+
|
|
490
|
+
Centralised here so callers (merge gate, TUI fetcher, tests) don't need to
|
|
491
|
+
branch on the config value themselves. Unknown types fall back to NoOpCi
|
|
492
|
+
so a typo in coordinator.yml doesn't crash the merge command.
|
|
493
|
+
"""
|
|
494
|
+
if ci_store_type == "github":
|
|
495
|
+
from coord.ci_github import GitHubCi # noqa: PLC0415
|
|
496
|
+
return GitHubCi()
|
|
497
|
+
return NoOpCi()
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def summarize(checks: list[CheckRun]) -> str:
|
|
501
|
+
"""One-line summary: ``2✓ 1✗`` or ``no checks``.
|
|
502
|
+
|
|
503
|
+
Used by the TUI under the Merge stage row and by the CLI when reporting
|
|
504
|
+
why a merge was refused.
|
|
505
|
+
"""
|
|
506
|
+
if not checks:
|
|
507
|
+
return "no checks"
|
|
508
|
+
passed = sum(1 for c in checks if c.conclusion == "success")
|
|
509
|
+
failed = len(failed_checks(checks))
|
|
510
|
+
running = len(in_flight_checks(checks))
|
|
511
|
+
parts: list[str] = []
|
|
512
|
+
if passed:
|
|
513
|
+
parts.append(f"{passed}✓")
|
|
514
|
+
if failed:
|
|
515
|
+
parts.append(f"{failed}✗")
|
|
516
|
+
if running:
|
|
517
|
+
parts.append(f"{running}⋯")
|
|
518
|
+
return " ".join(parts) if parts else "no checks"
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def summarize_counts(checks: list[CheckRun]) -> CiCheckSummary:
|
|
522
|
+
"""Structured rollup of *checks*, mirroring the classification the TUI's
|
|
523
|
+
(now-deleted) ``fetch_ci_check_summary`` used to compute client-side:
|
|
524
|
+
|
|
525
|
+
- not yet ``completed`` → running
|
|
526
|
+
- completed + conclusion NOT in ``_PASSING_CONCLUSIONS`` → failed (name +
|
|
527
|
+
first URL captured); this is an allow-list (#1525), so an unrecognised
|
|
528
|
+
or synthetic ``"unknown"`` conclusion counts as failed
|
|
529
|
+
- completed + conclusion in ``_PASSING_CONCLUSIONS`` (success / skipped /
|
|
530
|
+
neutral) → passed
|
|
531
|
+
|
|
532
|
+
Used to populate :class:`coord.merge_queue.PlannedMerge.ci_summary` so the
|
|
533
|
+
`/board` payload carries everything the TUI renders as CI badges (#1344).
|
|
534
|
+
"""
|
|
535
|
+
# `checks` items are `CheckRun` in production but tests commonly pass
|
|
536
|
+
# lighter duck-typed fakes (see `failed_checks`/`in_flight_checks` above,
|
|
537
|
+
# which only ever touch `.status`/`.conclusion`) — `getattr` with a
|
|
538
|
+
# default keeps this function tolerant of fakes that omit `.url`.
|
|
539
|
+
passed = failed = running = 0
|
|
540
|
+
failed_names: list[str] = []
|
|
541
|
+
first_failed_url: str | None = None
|
|
542
|
+
for c in checks:
|
|
543
|
+
if c.status != "completed":
|
|
544
|
+
running += 1
|
|
545
|
+
continue
|
|
546
|
+
if _is_failing_conclusion(c.conclusion):
|
|
547
|
+
failed += 1
|
|
548
|
+
failed_names.append(c.name)
|
|
549
|
+
url = getattr(c, "url", "") or ""
|
|
550
|
+
if first_failed_url is None and url:
|
|
551
|
+
first_failed_url = url
|
|
552
|
+
else:
|
|
553
|
+
passed += 1
|
|
554
|
+
return CiCheckSummary(
|
|
555
|
+
passed=passed,
|
|
556
|
+
failed=failed,
|
|
557
|
+
running=running,
|
|
558
|
+
failed_names=failed_names,
|
|
559
|
+
first_failed_url=first_failed_url,
|
|
560
|
+
)
|