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/github_ops.py
ADDED
|
@@ -0,0 +1,1954 @@
|
|
|
1
|
+
"""GitHub operations via gh CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── Typed gh errors ─────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GhError(RuntimeError):
|
|
17
|
+
"""Base class for typed ``gh`` CLI errors.
|
|
18
|
+
|
|
19
|
+
Subclass of :class:`RuntimeError` so existing ``except RuntimeError``
|
|
20
|
+
call sites keep working without modification; catch a subclass specifically
|
|
21
|
+
to distinguish it from a generic ``gh`` failure.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GhNotFound(GhError):
|
|
26
|
+
"""Raised when a GitHub resource (e.g. a label) does not exist in the repo.
|
|
27
|
+
|
|
28
|
+
Callers (e.g. ``serve_app`` HTTP handlers) that need to distinguish
|
|
29
|
+
"the resource doesn't exist" (a 4xx-class client error) from transient
|
|
30
|
+
backend failures (auth, network, rate-limit — which are 5xx) should
|
|
31
|
+
catch this specifically and return an appropriate HTTP status code.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GhTooOldForJsonChecks(GhError):
|
|
36
|
+
"""Raised when the installed ``gh`` doesn't support ``gh pr checks --json``
|
|
37
|
+
*at all* (#1564 Addendum 2), as opposed to supporting ``--json`` but
|
|
38
|
+
rejecting one of the requested field names (a plain :class:`RuntimeError`
|
|
39
|
+
from :func:`get_pr_checks`, e.g. the original ``conclusion`` bug).
|
|
40
|
+
|
|
41
|
+
The fleet that surfaced this issue runs the merge gate across hosts with
|
|
42
|
+
wildly different ``gh`` versions — and because ``coord merge`` re-invokes
|
|
43
|
+
itself on the daemon host (``COORD_MERGE_ON_DAEMON``, see ``serve_app.py``),
|
|
44
|
+
it is the *daemon's* ``gh`` that decides every production merge, not the
|
|
45
|
+
thin client's. A daemon stuck on a too-old ``gh`` must fail with a message
|
|
46
|
+
that names the problem and the fix, not the same undiagnosable
|
|
47
|
+
"could not read CI status" text used for auth/network flakes — see
|
|
48
|
+
:func:`coord.ci_github.GitHubCi._fetch`, which catches this subclass
|
|
49
|
+
ahead of the generic ``RuntimeError`` branch for exactly that reason.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Keywords indicating a transient/infra failure that should never trigger
|
|
54
|
+
# label auto-creation. Checked against the full lowercase error string.
|
|
55
|
+
_TRANSIENT_ERROR_KEYWORDS: tuple[str, ...] = (
|
|
56
|
+
"http 401",
|
|
57
|
+
"http 403",
|
|
58
|
+
"http 429",
|
|
59
|
+
"authentication required",
|
|
60
|
+
"bad credentials",
|
|
61
|
+
"credentials not found",
|
|
62
|
+
"gh auth login",
|
|
63
|
+
"api rate limit",
|
|
64
|
+
"rate limit exceeded",
|
|
65
|
+
"secondary rate limit",
|
|
66
|
+
"timed out",
|
|
67
|
+
"connection refused",
|
|
68
|
+
"connection reset",
|
|
69
|
+
"connection error",
|
|
70
|
+
"could not resolve host",
|
|
71
|
+
"no such host",
|
|
72
|
+
"dial tcp",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_transient_error(exc: Exception) -> bool:
|
|
77
|
+
"""True when the ``gh`` error suggests an auth, rate-limit, or network failure.
|
|
78
|
+
|
|
79
|
+
These failures should never trigger label auto-creation or other
|
|
80
|
+
retry-with-side-effects logic — the root cause is infra, not a missing
|
|
81
|
+
resource.
|
|
82
|
+
"""
|
|
83
|
+
msg = str(exc).lower()
|
|
84
|
+
return any(kw in msg for kw in _TRANSIENT_ERROR_KEYWORDS)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_label_not_found(exc: Exception) -> bool:
|
|
88
|
+
"""True when ``gh`` reported that a label does not exist in the repository.
|
|
89
|
+
|
|
90
|
+
Returns ``False`` for transient errors (:func:`_is_transient_error`) so
|
|
91
|
+
callers never attempt auto-creation on auth/network/rate-limit failures.
|
|
92
|
+
"""
|
|
93
|
+
if _is_transient_error(exc):
|
|
94
|
+
return False
|
|
95
|
+
msg = str(exc).lower()
|
|
96
|
+
# "could not resolve to a label" — the GraphQL error gh surfaces
|
|
97
|
+
# "label 'foo' not found" — the REST error in some gh versions
|
|
98
|
+
return "could not resolve to a label" in msg or (
|
|
99
|
+
"label" in msg and "not found" in msg
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _gh(*args: str) -> str:
|
|
104
|
+
"""Run ``gh`` with *args* and return its stdout, or raise :class:`GhError`.
|
|
105
|
+
|
|
106
|
+
#1483: this is the single seam every ``_gh``-backed helper in this module
|
|
107
|
+
funnels through, so it is also the single place that must absorb the
|
|
108
|
+
ways ``gh`` can fail to even run — not just a non-zero exit.
|
|
109
|
+
``subprocess.run`` raises ``FileNotFoundError`` when the ``gh`` binary
|
|
110
|
+
isn't on PATH (the elitebook incident that prompted #1483: a worker's
|
|
111
|
+
systemd PATH didn't include the linuxbrew-installed ``gh``) and
|
|
112
|
+
``subprocess.TimeoutExpired`` when it hangs past the 30s budget; both are
|
|
113
|
+
caught here and re-raised as :class:`GhError` (a ``RuntimeError``
|
|
114
|
+
subclass) so every existing ``except RuntimeError`` call site — and any
|
|
115
|
+
future one — gets the same fail-safe behavior the pre-#1483 direct
|
|
116
|
+
``shutil.which("gh")`` + ``subprocess.run(..., check=False)`` call sites
|
|
117
|
+
had, for free, without each caller having to remember to guard for it.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
result = subprocess.run(
|
|
121
|
+
["gh", *args],
|
|
122
|
+
capture_output=True, text=True, timeout=30,
|
|
123
|
+
)
|
|
124
|
+
except FileNotFoundError as exc:
|
|
125
|
+
raise GhError(f"gh {' '.join(args)} failed: gh not found: {exc}") from exc
|
|
126
|
+
except subprocess.TimeoutExpired as exc:
|
|
127
|
+
raise GhError(f"gh {' '.join(args)} failed: timed out: {exc}") from exc
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
raise GhError(f"gh {' '.join(args)} failed: {exc}") from exc
|
|
130
|
+
if result.returncode != 0:
|
|
131
|
+
raise RuntimeError(f"gh {' '.join(args)} failed: {result.stderr.strip()}")
|
|
132
|
+
return result.stdout.strip()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _json_loads_or(raw: str | None, default: Any = None) -> Any:
|
|
136
|
+
"""Decode *raw* as JSON, or return *default* on empty/malformed input.
|
|
137
|
+
|
|
138
|
+
#1353: ``_gh`` above treats a ``gh`` invocation that exits 0 with empty
|
|
139
|
+
stdout as a success — indistinguishable, from ``_gh``'s point of view,
|
|
140
|
+
from a real empty payload. Roughly half of this module's call sites used
|
|
141
|
+
to hand ``_gh``'s output straight to a bare ``json.loads``, so that empty
|
|
142
|
+
string decoded to exactly ``json.JSONDecodeError: Expecting value: line 1
|
|
143
|
+
column 1 (char 0)`` — a one-line, unattributable crash that took down an
|
|
144
|
+
entire ``coord merge`` drain in the incident that prompted this (see
|
|
145
|
+
issue #1353). This is the one guarded decode every such call site now
|
|
146
|
+
routes through, so "gh returned nothing useful" degrades to a documented
|
|
147
|
+
*default* value at every site uniformly, rather than at only the two
|
|
148
|
+
sites (``get_pr_commit_messages``, ``pr_is_merged``) that happened to
|
|
149
|
+
hand-roll their own guard beforehand.
|
|
150
|
+
|
|
151
|
+
Does **not** change ``_gh``'s own failure contract: a non-zero ``gh``
|
|
152
|
+
exit still raises ``RuntimeError``/``GhError`` same as always — this only
|
|
153
|
+
covers the exit-0-but-stdout-is-garbage case that used to reach a bare
|
|
154
|
+
``json.loads``.
|
|
155
|
+
"""
|
|
156
|
+
if raw is None or not raw.strip():
|
|
157
|
+
return default
|
|
158
|
+
try:
|
|
159
|
+
return json.loads(raw)
|
|
160
|
+
except json.JSONDecodeError:
|
|
161
|
+
return default
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _gh_json(*args: str, default: Any = None) -> Any:
|
|
165
|
+
"""Run ``gh`` with *args* and JSON-decode its stdout, failing open.
|
|
166
|
+
|
|
167
|
+
Composes :func:`_gh` (still raises on a non-zero ``gh`` exit / missing
|
|
168
|
+
binary / timeout) with :func:`_json_loads_or` (fails open to *default* on
|
|
169
|
+
empty/malformed stdout from an otherwise-successful invocation). See
|
|
170
|
+
:func:`_json_loads_or` for why this exists.
|
|
171
|
+
"""
|
|
172
|
+
return _json_loads_or(_gh(*args), default)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_open_issues(repo: str) -> list[dict]:
|
|
176
|
+
# #658: raised from 100 → 500 so repos with many open issues don't silently
|
|
177
|
+
# skip old issue numbers during coord sync. GitHub paginates the REST list
|
|
178
|
+
# endpoint at 100 items internally, so this costs ~5 API calls for a large
|
|
179
|
+
# repo — acceptable for a background sync.
|
|
180
|
+
return _gh_json(
|
|
181
|
+
"issue", "list", "--repo", repo, "--state", "open",
|
|
182
|
+
"--json", "number,title,labels,milestone,body,assignees",
|
|
183
|
+
"--limit", "500",
|
|
184
|
+
default=[],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def get_closed_epics(repo: str, *, label: str = "epic") -> list[dict]:
|
|
189
|
+
"""Return closed issues in *repo* carrying *label* (default ``"epic"``).
|
|
190
|
+
|
|
191
|
+
Used by ``coord plans`` (#974) so a milestone's tracking epic is still
|
|
192
|
+
found once it has been closed while the milestone itself stays open
|
|
193
|
+
(e.g. all work-order nodes finished and someone tidied up the epic
|
|
194
|
+
before remembering to close the milestone) — see
|
|
195
|
+
:func:`coord.plans.find_tracking_issue`. A small, label-filtered,
|
|
196
|
+
closed-only lookup rather than a full ``--state all`` issue fetch, since
|
|
197
|
+
only closed *epics* are of interest here.
|
|
198
|
+
"""
|
|
199
|
+
return _gh_json(
|
|
200
|
+
"issue", "list", "--repo", repo, "--state", "closed", "--label", label,
|
|
201
|
+
"--json", "number,title,labels,milestone,body,assignees",
|
|
202
|
+
"--limit", "500",
|
|
203
|
+
default=[],
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_issue(repo: str, issue_number: int) -> dict:
|
|
208
|
+
"""Fetch a single issue by number.
|
|
209
|
+
|
|
210
|
+
Returns ``{number, title, body, state, milestone, labels, ...}``.
|
|
211
|
+
``milestone`` is ``None`` when the issue has none, else ``{"number":
|
|
212
|
+
..., "title": ...}`` — used by ``coord milestone order`` (#768) to
|
|
213
|
+
resolve a tracking issue's milestone and validate node membership
|
|
214
|
+
without a second call. ``labels`` is a list of ``{"name": ..., ...}``
|
|
215
|
+
dicts — #1138's ``enforce_oracle_readiness`` reads issue labels (e.g.
|
|
216
|
+
``oracle:exempt``) off this same call, so it must be requested here
|
|
217
|
+
too, not just on the list endpoints (``get_open_issues``,
|
|
218
|
+
``get_closed_epics``).
|
|
219
|
+
"""
|
|
220
|
+
return _gh_json(
|
|
221
|
+
"issue", "view", str(issue_number), "--repo", repo,
|
|
222
|
+
"--json", "number,title,body,state,milestone,labels",
|
|
223
|
+
default={},
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── Sub-issues (#1195) ───────────────────────────────────────────────────────
|
|
228
|
+
#
|
|
229
|
+
# The REST sub-issues API is live on GitHub today but used nowhere in this
|
|
230
|
+
# repo before #1195 — every epic->child relation so far is the `## Work
|
|
231
|
+
# order` / `## Sub-issues` markdown checklist `coord.milestone_order` parses.
|
|
232
|
+
# These wrap the raw endpoints; `coord.parentage_github.GitHubParentage` is
|
|
233
|
+
# the adapter that turns them into the backend-agnostic `coord.parentage`
|
|
234
|
+
# seam shape (`Child`/`ParentRef`).
|
|
235
|
+
#
|
|
236
|
+
# Gotcha verified while filing #1195: the write endpoints (POST/DELETE) take
|
|
237
|
+
# the child's internal database `id`, NOT its issue `number` — resolve via
|
|
238
|
+
# `get_issue`'s `--jq .id` before writing (see `_resolve_issue_id`).
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def get_sub_issues(repo: str, issue_number: int) -> list[dict]:
|
|
242
|
+
"""The live sub-issues of *issue_number* (``GET .../sub_issues``).
|
|
243
|
+
|
|
244
|
+
Returns ``[]`` for an issue with no sub-issues (confirmed live: this is
|
|
245
|
+
the API's normal response, not a 404/410 — see #1195's filing notes).
|
|
246
|
+
Each item is a full issue object; callers only need ``number``/``state``.
|
|
247
|
+
"""
|
|
248
|
+
return _gh_json("api", f"repos/{repo}/issues/{issue_number}/sub_issues", default=[])
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def get_issue_parent(repo: str, issue_number: int) -> dict | None:
|
|
252
|
+
"""The parent of *issue_number*, or ``None`` when it has none.
|
|
253
|
+
|
|
254
|
+
Reads the ``parent`` field GitHub already includes on ``GET
|
|
255
|
+
/issues/{n}`` (confirmed live while filing #1195 — no preview header
|
|
256
|
+
needed). ``None`` covers both "field absent" and the documented
|
|
257
|
+
``parent: null`` shape.
|
|
258
|
+
"""
|
|
259
|
+
raw = _gh("api", f"repos/{repo}/issues/{issue_number}", "--jq", ".parent")
|
|
260
|
+
stripped = raw.strip()
|
|
261
|
+
if not stripped or stripped == "null":
|
|
262
|
+
return None
|
|
263
|
+
return _json_loads_or(stripped, default=None)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _resolve_issue_id(repo: str, issue_number: int) -> int:
|
|
267
|
+
"""Issue `number` -> internal database `id` (#1195's write-path gotcha:
|
|
268
|
+
the sub-issues POST/DELETE endpoints want the latter, not the former)."""
|
|
269
|
+
raw = _gh("api", f"repos/{repo}/issues/{issue_number}", "--jq", ".id")
|
|
270
|
+
return int(raw.strip())
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def add_sub_issue(repo: str, parent_number: int, child_number: int) -> None:
|
|
274
|
+
"""Make *child_number* a sub-issue of *parent_number* (``POST
|
|
275
|
+
.../sub_issues``). Resolves *child_number* to its database id first —
|
|
276
|
+
the endpoint wants ``sub_issue_id`` (a database id), not the issue
|
|
277
|
+
number, and 422s if the body doesn't shape up."""
|
|
278
|
+
child_id = _resolve_issue_id(repo, child_number)
|
|
279
|
+
_gh(
|
|
280
|
+
"api", f"repos/{repo}/issues/{parent_number}/sub_issues",
|
|
281
|
+
"-X", "POST",
|
|
282
|
+
"-F", f"sub_issue_id={child_id}",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def remove_sub_issue(repo: str, parent_number: int, child_number: int) -> None:
|
|
287
|
+
"""Detach *child_number* from *parent_number* (``DELETE
|
|
288
|
+
.../sub_issue`` — singular, unlike the GET/POST plural; a real GitHub
|
|
289
|
+
API asymmetry, not a typo here)."""
|
|
290
|
+
child_id = _resolve_issue_id(repo, child_number)
|
|
291
|
+
_gh(
|
|
292
|
+
"api", f"repos/{repo}/issues/{parent_number}/sub_issue",
|
|
293
|
+
"-X", "DELETE",
|
|
294
|
+
"-F", f"sub_issue_id={child_id}",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def edit_issue(
|
|
299
|
+
repo: str,
|
|
300
|
+
issue_number: int,
|
|
301
|
+
*,
|
|
302
|
+
title: str | None = None,
|
|
303
|
+
body: str | None = None,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Edit an issue's title and/or body. The GitHub backend of the
|
|
306
|
+
issue-tracker seam (`state.edit_issue_content`) — GitLab / bare-DB adapters
|
|
307
|
+
slot in alongside this later. The body is piped via stdin (`--body-file -`)
|
|
308
|
+
to avoid arg-length and shell-quoting issues on long markdown bodies."""
|
|
309
|
+
if title is None and body is None:
|
|
310
|
+
return
|
|
311
|
+
args = ["issue", "edit", str(issue_number), "--repo", repo]
|
|
312
|
+
if title is not None:
|
|
313
|
+
args += ["--title", title]
|
|
314
|
+
if body is not None:
|
|
315
|
+
args += ["--body-file", "-"]
|
|
316
|
+
result = subprocess.run(
|
|
317
|
+
["gh", *args],
|
|
318
|
+
input=body if body is not None else None,
|
|
319
|
+
capture_output=True,
|
|
320
|
+
text=True,
|
|
321
|
+
timeout=30,
|
|
322
|
+
)
|
|
323
|
+
if result.returncode != 0:
|
|
324
|
+
raise RuntimeError(
|
|
325
|
+
f"gh issue edit #{issue_number} failed: {result.stderr.strip()}"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def create_milestone(
|
|
330
|
+
repo: str,
|
|
331
|
+
title: str,
|
|
332
|
+
*,
|
|
333
|
+
description: str | None = None,
|
|
334
|
+
due_on: str | None = None,
|
|
335
|
+
) -> dict:
|
|
336
|
+
"""Create a GitHub milestone via ``gh api POST .../milestones`` (#645 seam).
|
|
337
|
+
|
|
338
|
+
The GitHub backend of the milestone-tracker seam
|
|
339
|
+
(``coord.state.write_milestone``) — GitLab / bare-DB adapters slot in
|
|
340
|
+
alongside this later, same as ``edit_issue``. ``due_on`` is an ISO 8601
|
|
341
|
+
timestamp (e.g. ``"2026-08-01T00:00:00Z"``) per the GitHub API; this
|
|
342
|
+
layer does not validate the format, it just forwards it. Returns the
|
|
343
|
+
created milestone's JSON (``number``, ``title``, ``description``,
|
|
344
|
+
``due_on``, ``html_url``, ...).
|
|
345
|
+
"""
|
|
346
|
+
args = ["api", f"repos/{repo}/milestones", "-f", f"title={title}"]
|
|
347
|
+
if description is not None:
|
|
348
|
+
args += ["-f", f"description={description}"]
|
|
349
|
+
if due_on is not None:
|
|
350
|
+
args += ["-f", f"due_on={due_on}"]
|
|
351
|
+
return _json_loads_or(_gh(*args), default={})
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def edit_milestone(
|
|
355
|
+
repo: str,
|
|
356
|
+
number: int,
|
|
357
|
+
*,
|
|
358
|
+
title: str | None = None,
|
|
359
|
+
description: str | None = None,
|
|
360
|
+
due_on: str | None = None,
|
|
361
|
+
) -> dict:
|
|
362
|
+
"""Edit a GitHub milestone's title/description/due date via
|
|
363
|
+
``gh api -X PATCH .../milestones/{number}`` (#645 seam, mirrors
|
|
364
|
+
``edit_issue``). A no-op (all three fields ``None``) returns ``{}``
|
|
365
|
+
without shelling out. Returns the updated milestone's JSON."""
|
|
366
|
+
if title is None and description is None and due_on is None:
|
|
367
|
+
return {}
|
|
368
|
+
args = ["api", "-X", "PATCH", f"repos/{repo}/milestones/{number}"]
|
|
369
|
+
if title is not None:
|
|
370
|
+
args += ["-f", f"title={title}"]
|
|
371
|
+
if description is not None:
|
|
372
|
+
args += ["-f", f"description={description}"]
|
|
373
|
+
if due_on is not None:
|
|
374
|
+
args += ["-f", f"due_on={due_on}"]
|
|
375
|
+
return _json_loads_or(_gh(*args), default={})
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class IssueHasOpenChildrenError(RuntimeError):
|
|
379
|
+
"""Raised by :func:`close_issue` when *issue_number* has open children and
|
|
380
|
+
``force`` was not passed (#1196).
|
|
381
|
+
|
|
382
|
+
The close-invariant chokepoint: every deterministic close path in this
|
|
383
|
+
codebase (``merge_queue``, ``state._close_issue_local``,
|
|
384
|
+
``commands/issues``) funnels through :func:`close_issue`, so guarding
|
|
385
|
+
here is the one place that stops an epic reading as "done" while its
|
|
386
|
+
sub-issues are still open — see claude-coordinator#1041, the incident
|
|
387
|
+
that prompted this. A subclass of ``RuntimeError`` so existing
|
|
388
|
+
``except RuntimeError`` / ``except Exception`` call sites keep working
|
|
389
|
+
without modification; catch this specifically to distinguish "refused,
|
|
390
|
+
has open children" from any other close failure.
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def get_issues_live_state(repo: str, numbers: list[int]) -> dict[int, str]:
|
|
395
|
+
"""Batch-fetch the *live* open/closed state of each issue in *numbers*.
|
|
396
|
+
|
|
397
|
+
One GraphQL request (one aliased ``issue(number: N)`` field per number)
|
|
398
|
+
rather than N ``gh issue view`` round-trips — see #1354: a close-guard
|
|
399
|
+
that fans out one lookup per child turns closing an epic into N API
|
|
400
|
+
calls. Returns ``{number: "open" | "closed"}``; a number that GitHub
|
|
401
|
+
doesn't resolve (deleted, wrong repo) is simply absent from the result.
|
|
402
|
+
|
|
403
|
+
Returns ``{}`` on any failure — a bad *repo* string, a ``gh`` error, or
|
|
404
|
+
an unparseable response — so callers can treat "no live data" uniformly
|
|
405
|
+
and fall back to their offline signal (see :func:`get_open_children`),
|
|
406
|
+
matching the deliberate fail-open contract on the parent lookup.
|
|
407
|
+
"""
|
|
408
|
+
if not numbers:
|
|
409
|
+
return {}
|
|
410
|
+
try:
|
|
411
|
+
owner, name = repo.split("/", 1)
|
|
412
|
+
except ValueError:
|
|
413
|
+
return {}
|
|
414
|
+
unique = sorted(set(numbers))
|
|
415
|
+
fields = "\n".join(
|
|
416
|
+
f" n{n}: issue(number: {n}) {{ number state }}" for n in unique
|
|
417
|
+
)
|
|
418
|
+
query = (
|
|
419
|
+
f"query {{ repository(owner: {json.dumps(owner)}, name: {json.dumps(name)}) {{\n"
|
|
420
|
+
f"{fields}\n"
|
|
421
|
+
f"}} }}"
|
|
422
|
+
)
|
|
423
|
+
try:
|
|
424
|
+
data = _gh_json("api", "graphql", "-f", f"query={query}", default={})
|
|
425
|
+
except RuntimeError:
|
|
426
|
+
return {}
|
|
427
|
+
data_field = data.get("data") if isinstance(data, dict) else None
|
|
428
|
+
repo_data = data_field.get("repository") if isinstance(data_field, dict) else None
|
|
429
|
+
if not isinstance(repo_data, dict):
|
|
430
|
+
return {}
|
|
431
|
+
result: dict[int, str] = {}
|
|
432
|
+
for value in repo_data.values():
|
|
433
|
+
if not isinstance(value, dict):
|
|
434
|
+
continue
|
|
435
|
+
num, state = value.get("number"), value.get("state")
|
|
436
|
+
if num is None or state is None:
|
|
437
|
+
continue
|
|
438
|
+
result[int(num)] = str(state).lower()
|
|
439
|
+
return result
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def get_open_children(repo: str, issue_number: int) -> list[dict]:
|
|
443
|
+
"""Open children of *issue_number*, per the #1195 parentage seam (#1196).
|
|
444
|
+
|
|
445
|
+
Uses :class:`coord.parentage.MarkdownParentage` over the issue's own
|
|
446
|
+
body (fetched via :func:`get_issue`) to discover *which* issues are
|
|
447
|
+
children — the ``## Sub-issues`` checklist convention (#1008) is the
|
|
448
|
+
primary parentage source; with ``fallback_to_work_order=True`` this also
|
|
449
|
+
falls back to a ``## Work order`` block (#1221) so epics seeded before
|
|
450
|
+
#1008 — which have only a Work order block, not a Sub-issues checklist —
|
|
451
|
+
still register their children instead of silently reading as childless.
|
|
452
|
+
|
|
453
|
+
Each discovered child's reported state then comes from a **live**
|
|
454
|
+
lookup (:func:`get_issues_live_state`, one batched GraphQL call for all
|
|
455
|
+
children at once) rather than the checklist's own ``- [x]``/``- [ ]``
|
|
456
|
+
box (#1354: the box is a proxy that drifts — a closed child's box is
|
|
457
|
+
often never ticked, and a ticked box can just as easily sit over a
|
|
458
|
+
child that was later reopened). The checkbox is used only as the
|
|
459
|
+
per-child fallback when the live lookup doesn't cover that number
|
|
460
|
+
(batch call failed entirely, or GitHub didn't resolve that number) —
|
|
461
|
+
preserving the pre-#1354 offline behavior for exactly the cases where a
|
|
462
|
+
live answer isn't available, rather than treating a lookup failure as
|
|
463
|
+
grounds to refuse or to silently allow the close.
|
|
464
|
+
|
|
465
|
+
The live GitHub sub-issues REST API (:class:`coord.parentage_github.
|
|
466
|
+
GitHubParentage`) is wired but not yet backfilled onto existing epics
|
|
467
|
+
(EP-2, unbuilt), so using it *instead of* the markdown checklist here to
|
|
468
|
+
discover children would silently miss every real epic and defeat the
|
|
469
|
+
guard — only the per-child *state* is live, not the parent->child edges
|
|
470
|
+
themselves.
|
|
471
|
+
|
|
472
|
+
Returns ``[{"number": int, "state": "open"}, ...]``. **Fails open**
|
|
473
|
+
(returns ``[]``) both when the issue lookup itself errors (a transient
|
|
474
|
+
``gh`` failure must not permanently wedge every close in the system —
|
|
475
|
+
:func:`close_issue` is the enforcement point, not this lookup) and when
|
|
476
|
+
the body's ``## Sub-issues`` or ``## Work order`` block fails to parse
|
|
477
|
+
(a malformed checklist on *this* issue must not block closing some
|
|
478
|
+
*other*, well-formed one — the same per-issue fail-isolation #1195
|
|
479
|
+
already applies to the ``/board`` children display).
|
|
480
|
+
"""
|
|
481
|
+
try:
|
|
482
|
+
issue = get_issue(repo, issue_number)
|
|
483
|
+
except RuntimeError:
|
|
484
|
+
return []
|
|
485
|
+
from coord.parentage import MarkdownParentage # noqa: PLC0415
|
|
486
|
+
|
|
487
|
+
body = issue.get("body") or ""
|
|
488
|
+
try:
|
|
489
|
+
children = MarkdownParentage().children(
|
|
490
|
+
repo, issue_number, body=body, fallback_to_work_order=True,
|
|
491
|
+
)
|
|
492
|
+
except Exception: # noqa: BLE001 — malformed checklist: fail open, don't wedge close
|
|
493
|
+
return []
|
|
494
|
+
if not children:
|
|
495
|
+
return []
|
|
496
|
+
live_states = get_issues_live_state(repo, [c.number for c in children])
|
|
497
|
+
return [
|
|
498
|
+
{"number": c.number, "state": live_states.get(c.number, c.state)}
|
|
499
|
+
for c in children
|
|
500
|
+
if live_states.get(c.number, c.state) == "open"
|
|
501
|
+
]
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def has_open_children(repo: str, issue_number: int) -> bool:
|
|
505
|
+
"""True when *issue_number* has at least one open child (#1196)."""
|
|
506
|
+
return bool(get_open_children(repo, issue_number))
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def is_epic_issue(repo: str, issue_number: int) -> bool:
|
|
510
|
+
"""True when *issue_number* carries the tracking/epic label (#1318).
|
|
511
|
+
|
|
512
|
+
Same label :data:`coord.milestone_order.TRACKING_ISSUE_LABEL` that
|
|
513
|
+
``dispatch.enforce_epic_dispatch_guard`` (#1314) and
|
|
514
|
+
``plan_followup.pr()`` (#1077/#1314) already check at dispatch/PR-create
|
|
515
|
+
time. This is the merge-time counterpart used by the pre-merge
|
|
516
|
+
epic-closing-keyword guard: a closing keyword anywhere in a PR body or
|
|
517
|
+
commit message that targets an epic must never be allowed to auto-close
|
|
518
|
+
it on merge.
|
|
519
|
+
|
|
520
|
+
Fail-open: any ``gh`` error returns ``False`` — a transient read failure
|
|
521
|
+
must not itself block a merge; the caller still has its other gates.
|
|
522
|
+
"""
|
|
523
|
+
from coord.milestone_order import TRACKING_ISSUE_LABEL # noqa: PLC0415
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
issue_data = get_issue(repo, issue_number)
|
|
527
|
+
except RuntimeError:
|
|
528
|
+
return False
|
|
529
|
+
labels = {lbl.get("name", "") for lbl in (issue_data.get("labels") or [])}
|
|
530
|
+
return TRACKING_ISSUE_LABEL in labels
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def close_issue(
|
|
534
|
+
repo: str, issue_number: int, *, comment: str | None = None, force: bool = False,
|
|
535
|
+
) -> None:
|
|
536
|
+
"""Close a GitHub issue, optionally posting *comment* first.
|
|
537
|
+
|
|
538
|
+
The deterministic counterpart to a ``Closes #N`` keyword in a PR body:
|
|
539
|
+
``coord merge`` calls this after a successful merge so an issue is never
|
|
540
|
+
stranded open when a worker-created PR forgot the keyword (and
|
|
541
|
+
conventional-commit ``fix(#N):`` subjects are *not* GitHub closing
|
|
542
|
+
keywords). Idempotent — closing an already-closed issue is a no-op.
|
|
543
|
+
Raises RuntimeError on any other ``gh`` failure. Part of the
|
|
544
|
+
issue-tracker seam (GitHub backend); GitLab / bare-DB adapters slot in
|
|
545
|
+
alongside this later (#806).
|
|
546
|
+
|
|
547
|
+
#1196: the close-invariant chokepoint. Refuses (raises
|
|
548
|
+
:class:`IssueHasOpenChildrenError`) when *issue_number* has open
|
|
549
|
+
children unless *force* is ``True`` — an epic must not read as "done"
|
|
550
|
+
while its sub-issues are still open/unstarted. Every deterministic
|
|
551
|
+
close path in the codebase funnels through here, so this single guard
|
|
552
|
+
covers all of them.
|
|
553
|
+
"""
|
|
554
|
+
if not force:
|
|
555
|
+
open_children = get_open_children(repo, issue_number)
|
|
556
|
+
if open_children:
|
|
557
|
+
numbers = ", ".join(f"#{c['number']}" for c in open_children)
|
|
558
|
+
raise IssueHasOpenChildrenError(
|
|
559
|
+
f"refusing to close {repo}#{issue_number}: open children "
|
|
560
|
+
f"{numbers} — pass force=True (CLI: --force) to override"
|
|
561
|
+
)
|
|
562
|
+
if comment:
|
|
563
|
+
post_issue_comment(repo, issue_number, comment)
|
|
564
|
+
result = subprocess.run(
|
|
565
|
+
["gh", "issue", "close", str(issue_number), "--repo", repo],
|
|
566
|
+
capture_output=True,
|
|
567
|
+
text=True,
|
|
568
|
+
timeout=30,
|
|
569
|
+
)
|
|
570
|
+
if result.returncode != 0 and "already closed" not in result.stderr.lower():
|
|
571
|
+
raise RuntimeError(
|
|
572
|
+
f"gh issue close #{issue_number} failed: {result.stderr.strip()}"
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def reopen_issue(
|
|
577
|
+
repo: str, issue_number: int, *, comment: str | None = None,
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Reopen a GitHub issue, optionally posting *comment* first.
|
|
580
|
+
|
|
581
|
+
Idempotent — reopening an already-open issue is a no-op. Raises
|
|
582
|
+
RuntimeError on any other ``gh`` failure. Part of the issue-tracker seam
|
|
583
|
+
(GitHub backend); GitLab / bare-DB adapters slot in alongside this later
|
|
584
|
+
(#806).
|
|
585
|
+
|
|
586
|
+
Mirror of :func:`close_issue` for the complement operation (issue #1078).
|
|
587
|
+
"""
|
|
588
|
+
if comment:
|
|
589
|
+
post_issue_comment(repo, issue_number, comment)
|
|
590
|
+
result = subprocess.run(
|
|
591
|
+
["gh", "issue", "reopen", str(issue_number), "--repo", repo],
|
|
592
|
+
capture_output=True,
|
|
593
|
+
text=True,
|
|
594
|
+
timeout=30,
|
|
595
|
+
)
|
|
596
|
+
if result.returncode != 0 and "already open" not in result.stderr.lower():
|
|
597
|
+
raise RuntimeError(
|
|
598
|
+
f"gh issue reopen #{issue_number} failed: {result.stderr.strip()}"
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def check_pr_mergeable(repo: str, number: int) -> bool | None:
|
|
603
|
+
"""Return GitHub's current mergeability verdict for PR *number* (#1477).
|
|
604
|
+
|
|
605
|
+
Used by the merge queue's stale-``CONFLICT`` reconciliation
|
|
606
|
+
(:func:`coord.merge_queue.reconcile_conflict_entries`) to re-test whether
|
|
607
|
+
a parked entry's branch has since become clean — a conflict-fix worker
|
|
608
|
+
landed, or a human pushed a fix by hand — rather than trusting the
|
|
609
|
+
``gh pr merge`` failure message cached from whenever the queue last
|
|
610
|
+
attempted it.
|
|
611
|
+
|
|
612
|
+
Returns ``True`` when GitHub reports ``MERGEABLE``, ``False`` when it
|
|
613
|
+
reports ``CONFLICTING``, and ``None`` for anything else — including
|
|
614
|
+
``UNKNOWN`` (GitHub computes mergeability asynchronously; a very recent
|
|
615
|
+
push can read back unresolved for a few seconds) and any ``gh``
|
|
616
|
+
error/timeout. Callers must treat ``None`` the same as ``False`` — an
|
|
617
|
+
inconclusive read is never a green light to unpark an entry.
|
|
618
|
+
"""
|
|
619
|
+
try:
|
|
620
|
+
value = _gh_json(
|
|
621
|
+
"pr", "view", str(number), "--repo", repo, "--json", "mergeable",
|
|
622
|
+
default={},
|
|
623
|
+
).get("mergeable")
|
|
624
|
+
except Exception: # noqa: BLE001 — fail-safe: unknown mergeability blocks nothing
|
|
625
|
+
return None
|
|
626
|
+
if value == "MERGEABLE":
|
|
627
|
+
return True
|
|
628
|
+
if value == "CONFLICTING":
|
|
629
|
+
return False
|
|
630
|
+
return None
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def branch_has_merge_commit(repo: str, number: int) -> bool | None:
|
|
634
|
+
"""True when any commit on PR *number* has more than one parent (#1467).
|
|
635
|
+
|
|
636
|
+
GitHub refuses to rebase-merge (``gh pr merge --rebase``) any branch
|
|
637
|
+
containing a merge commit — a *linearity* requirement distinct from a
|
|
638
|
+
content conflict. :func:`check_pr_mergeable`'s ``mergeable`` field can't
|
|
639
|
+
detect this: GitHub reports a branch with a merge commit as
|
|
640
|
+
``MERGEABLE`` right up until the rebase-merge attempt itself fails with
|
|
641
|
+
"This branch can't be rebased". This probe answers the question
|
|
642
|
+
``check_pr_mergeable`` can't, so :mod:`coord.merge_queue` can fall back
|
|
643
|
+
from ``--rebase`` to ``--squash`` before ever hitting that refusal.
|
|
644
|
+
|
|
645
|
+
Reads ``repos/{repo}/pulls/{number}/commits`` — each commit's
|
|
646
|
+
``parents`` array has length > 1 only for a merge commit — rather than a
|
|
647
|
+
local ``git rev-list --merges``, because ``coord merge`` runs on the
|
|
648
|
+
daemon host, which has no guaranteed checkout of an arbitrary configured
|
|
649
|
+
repo. Pages up to 100 commits, comfortably above any real worker branch.
|
|
650
|
+
|
|
651
|
+
Returns ``True``/``False`` when determined, and ``None`` on any ``gh``
|
|
652
|
+
failure or malformed response — an inconclusive read. Mirrors
|
|
653
|
+
:func:`check_pr_mergeable`'s fail-closed contract: callers must treat
|
|
654
|
+
``None`` as "don't know" and never let it drive a behaviour change (here:
|
|
655
|
+
never silently switch merge method, or unpark a queue entry, on an
|
|
656
|
+
inconclusive read).
|
|
657
|
+
"""
|
|
658
|
+
try:
|
|
659
|
+
raw = _gh("api", f"repos/{repo}/pulls/{number}/commits?per_page=100")
|
|
660
|
+
commits = json.loads(raw)
|
|
661
|
+
except Exception: # noqa: BLE001 — fail-safe: unknown parents blocks nothing
|
|
662
|
+
return None
|
|
663
|
+
if not isinstance(commits, list):
|
|
664
|
+
return None
|
|
665
|
+
try:
|
|
666
|
+
return any(len(c.get("parents") or []) > 1 for c in commits)
|
|
667
|
+
except (AttributeError, TypeError):
|
|
668
|
+
return None
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def get_pr_body(repo: str, number: int) -> str:
|
|
672
|
+
"""Return PR *number*'s current body text (empty string if unset)."""
|
|
673
|
+
return _gh_json(
|
|
674
|
+
"pr", "view", str(number), "--repo", repo, "--json", "body", default={},
|
|
675
|
+
).get("body") or ""
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def edit_pr_body(repo: str, number: int, body: str) -> None:
|
|
679
|
+
"""Overwrite PR *number*'s body text via ``gh pr edit --body``."""
|
|
680
|
+
_gh("pr", "edit", str(number), "--repo", repo, "--body", body)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def get_pr_commit_messages(repo: str, number: int) -> list[str]:
|
|
684
|
+
"""Return the full commit message (headline + body) of every commit on
|
|
685
|
+
PR *number*, in commit order (#1318).
|
|
686
|
+
|
|
687
|
+
GitHub's closing-keyword scanner reads commit messages verbatim once
|
|
688
|
+
they land on the base branch — for ``--rebase``/``--merge`` methods
|
|
689
|
+
that's every original commit, unchanged, so a keyword buried in
|
|
690
|
+
commit-message prose (e.g. explaining a bug fixed elsewhere, in a
|
|
691
|
+
quote) can auto-close an issue on merge exactly like a PR-body keyword
|
|
692
|
+
(#1196) does, and no PR-body edit can neutralize it. Best-effort:
|
|
693
|
+
returns ``[]`` on any ``gh`` failure or malformed response, same
|
|
694
|
+
fail-open posture as :func:`get_pr_body`.
|
|
695
|
+
"""
|
|
696
|
+
try:
|
|
697
|
+
raw = _gh("pr", "view", str(number), "--repo", repo, "--json", "commits")
|
|
698
|
+
except RuntimeError:
|
|
699
|
+
return []
|
|
700
|
+
data = _json_loads_or(raw, default={})
|
|
701
|
+
commits = (data.get("commits") if isinstance(data, dict) else None) or []
|
|
702
|
+
messages: list[str] = []
|
|
703
|
+
for c in commits:
|
|
704
|
+
headline = (c.get("messageHeadline") or "").strip()
|
|
705
|
+
body = (c.get("messageBody") or "").strip()
|
|
706
|
+
messages.append(f"{headline}\n\n{body}" if body else headline)
|
|
707
|
+
return messages
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def issue_is_closed(repo: str, issue_number: int) -> bool:
|
|
711
|
+
"""True when issue ``issue_number`` is closed on GitHub.
|
|
712
|
+
|
|
713
|
+
Best-effort and **fail-open**: any ``gh`` error returns ``False`` so a
|
|
714
|
+
transient GitHub/CLI failure never silently blocks a legitimate dispatch.
|
|
715
|
+
"""
|
|
716
|
+
try:
|
|
717
|
+
return get_issue(repo, issue_number).get("state", "").upper() == "CLOSED"
|
|
718
|
+
except (RuntimeError, json.JSONDecodeError):
|
|
719
|
+
return False
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def pr_is_merged(repo: str, branch: str) -> bool:
|
|
723
|
+
"""True when ``branch``'s *current* tip is a commit that actually merged on ``repo``.
|
|
724
|
+
|
|
725
|
+
Uses ``gh pr list --head <branch> --state all`` rather than ``pr view`` so
|
|
726
|
+
the result survives **branch deletion after merge** and the quadraui case
|
|
727
|
+
where a PR merged into ``develop`` leaves its linked issue OPEN (so
|
|
728
|
+
:func:`issue_is_closed` would miss it). Best-effort and **fail-open**:
|
|
729
|
+
returns ``False`` when there is no PR, the PR is still open, or ``gh``
|
|
730
|
+
errors — never blocks a legitimate dispatch on a transient failure.
|
|
731
|
+
|
|
732
|
+
#1150: branch reuse across merge cycles is a designed pattern
|
|
733
|
+
(``--fix-of``/``--rework-of`` continue on the same branch; ``--force`` can
|
|
734
|
+
re-target a branch name with prior history) — so "a PR with this head ref
|
|
735
|
+
name merged *at some point*" is not proof that the branch's *current*
|
|
736
|
+
commits are merged. To distinguish those cases, once a merged PR is found
|
|
737
|
+
we resolve the branch's current tip via :func:`get_branch_sha` (the same
|
|
738
|
+
GitHub-API SHA lookup #821 uses for stale-review detection) and require it
|
|
739
|
+
to match the merged PR's ``headRefOid`` — the exact commit that landed.
|
|
740
|
+
|
|
741
|
+
When the tip can't be resolved via ``get_branch_sha`` (it fails closed to
|
|
742
|
+
``None`` on *any* error, transient or not — see its docstring), we do
|
|
743
|
+
**not** blindly trust the historical merge, because that would reintroduce
|
|
744
|
+
this same issue's bug class under a transient-failure trigger: a rate
|
|
745
|
+
limit or network blip at the wrong moment would read as "already merged"
|
|
746
|
+
and callers (``reconcile``'s merge sweep, ``prune_stale_queue_entries``)
|
|
747
|
+
would permanently mark live, unmerged work as done or delete its queue
|
|
748
|
+
entry. Trusting history is only actually safe in the one case where it's
|
|
749
|
+
*structurally* impossible for new commits to exist: the branch was
|
|
750
|
+
positively confirmed deleted (a 404, via :func:`branch_exists_on_remote`,
|
|
751
|
+
which distinguishes "GitHub said not found" from any other failure).
|
|
752
|
+
Every other unresolved case — auth hiccup, timeout, rate limit — fails
|
|
753
|
+
open toward ``False`` ("not yet merged"), matching this function's and
|
|
754
|
+
``prune_stale_queue_entries``'s documented fail-open convention.
|
|
755
|
+
"""
|
|
756
|
+
if not branch:
|
|
757
|
+
return False
|
|
758
|
+
try:
|
|
759
|
+
raw = _gh(
|
|
760
|
+
"pr", "list", "--repo", repo, "--head", branch,
|
|
761
|
+
"--state", "all", "--json", "number,state,mergedAt,headRefOid",
|
|
762
|
+
"--limit", "10",
|
|
763
|
+
)
|
|
764
|
+
except RuntimeError:
|
|
765
|
+
return False
|
|
766
|
+
prs = _json_loads_or(raw, default=[])
|
|
767
|
+
if not isinstance(prs, list):
|
|
768
|
+
return False
|
|
769
|
+
merged = [
|
|
770
|
+
p for p in prs
|
|
771
|
+
if p.get("mergedAt") or p.get("state", "").upper() == "MERGED"
|
|
772
|
+
]
|
|
773
|
+
if not merged:
|
|
774
|
+
return False
|
|
775
|
+
|
|
776
|
+
current_sha = get_branch_sha(repo, branch)
|
|
777
|
+
if current_sha is not None:
|
|
778
|
+
return any(p.get("headRefOid") == current_sha for p in merged)
|
|
779
|
+
# SHA lookup failed. Only trust the historical merge if we can positively
|
|
780
|
+
# confirm the branch is gone (a 404 means no further commits could have
|
|
781
|
+
# been pushed to it). Any other failure (transient network/auth/rate
|
|
782
|
+
# limit) fails open toward False — see docstring.
|
|
783
|
+
if not branch_exists_on_remote(repo, branch):
|
|
784
|
+
return True # confirmed deleted — no new commits possible; trust history
|
|
785
|
+
return False
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def work_is_terminal(
|
|
789
|
+
repo_github: str,
|
|
790
|
+
issue_number: int | None,
|
|
791
|
+
branch: str | None,
|
|
792
|
+
*,
|
|
793
|
+
cache: dict | None = None,
|
|
794
|
+
) -> bool:
|
|
795
|
+
"""True when work is already done on GitHub: **issue closed OR PR merged**.
|
|
796
|
+
|
|
797
|
+
The single chokepoint guard (#522) consulted before any fix/review
|
|
798
|
+
dispatch, so already-merged/closed work can never re-enter the loop (the
|
|
799
|
+
root cause of the 2026-06-09 launch flood: #349 ×4, #194).
|
|
800
|
+
|
|
801
|
+
Best-effort and **fail-open**: any error resolves to ``False`` so a
|
|
802
|
+
transient GitHub/CLI failure never blocks a legitimate dispatch.
|
|
803
|
+
|
|
804
|
+
*cache* — optional ``dict`` shared across a single ``notify`` run, keyed by
|
|
805
|
+
``(repo_github, issue_number, branch)``, so a burst of transitions for the
|
|
806
|
+
same merged issue costs **one** ``gh`` round-trip, not one per call.
|
|
807
|
+
"""
|
|
808
|
+
if not repo_github:
|
|
809
|
+
return False
|
|
810
|
+
|
|
811
|
+
key = (repo_github, issue_number, branch)
|
|
812
|
+
if cache is not None and key in cache:
|
|
813
|
+
return cache[key]
|
|
814
|
+
|
|
815
|
+
terminal = False
|
|
816
|
+
try:
|
|
817
|
+
if issue_number and issue_is_closed(repo_github, issue_number):
|
|
818
|
+
terminal = True
|
|
819
|
+
elif branch and pr_is_merged(repo_github, branch):
|
|
820
|
+
terminal = True
|
|
821
|
+
except Exception: # noqa: BLE001 — fail-open: never block a dispatch
|
|
822
|
+
terminal = False
|
|
823
|
+
|
|
824
|
+
if cache is not None:
|
|
825
|
+
cache[key] = terminal
|
|
826
|
+
return terminal
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
# ── #873: durable issue_comments mirror — capture-at-write ──────────────────
|
|
830
|
+
|
|
831
|
+
_COMMENT_ID_RE = re.compile(r"issuecomment-(\d+)")
|
|
832
|
+
|
|
833
|
+
# Memoized per-process: the authenticated gh identity, used as a best-effort
|
|
834
|
+
# `author` on capture-at-write rows. One extra `gh api user` call the first
|
|
835
|
+
# time a comment is posted in this process; the backfill sync
|
|
836
|
+
# (state.sync_issue_comments) overwrites it with the real per-comment author
|
|
837
|
+
# regardless, so a stale/failed lookup here is harmless.
|
|
838
|
+
_login_cache: dict[str, str | None] = {}
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def parse_comment_id(url: str) -> int | None:
|
|
842
|
+
"""Extract the numeric REST comment id from a GitHub comment URL
|
|
843
|
+
(``...#issuecomment-<digits>``) — the format both ``gh issue comment``'s
|
|
844
|
+
stdout and ``gh issue view --json comments``'s ``url`` field use. Returns
|
|
845
|
+
``None`` when *url* doesn't match (e.g. blank, or gh's output format
|
|
846
|
+
changes)."""
|
|
847
|
+
m = _COMMENT_ID_RE.search(url or "")
|
|
848
|
+
return int(m.group(1)) if m else None
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _current_gh_login() -> str | None:
|
|
852
|
+
if "login" not in _login_cache:
|
|
853
|
+
try:
|
|
854
|
+
_login_cache["login"] = _gh("api", "user", "--jq", ".login") or None
|
|
855
|
+
except Exception: # noqa: BLE001 — best-effort; capture still proceeds without it
|
|
856
|
+
_login_cache["login"] = None
|
|
857
|
+
return _login_cache["login"]
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def get_issue_comments(repo: str, issue_number: int) -> list[dict]:
|
|
861
|
+
"""All comments on *issue_number*, oldest first (gh's default order).
|
|
862
|
+
|
|
863
|
+
Each dict carries at least ``id`` (a GraphQL node id — NOT the numeric
|
|
864
|
+
REST id; use :func:`parse_comment_id` on ``url`` for that), ``url``,
|
|
865
|
+
``author`` (``{"login": ...}``), ``body``, ``createdAt``. Used by
|
|
866
|
+
``state.sync_issue_comments`` (#873) to backfill the ``issue_comments``
|
|
867
|
+
mirror with human + out-of-band comments coord never wrote itself.
|
|
868
|
+
"""
|
|
869
|
+
return _gh_json(
|
|
870
|
+
"issue", "view", str(issue_number), "--repo", repo, "--json", "comments",
|
|
871
|
+
default={},
|
|
872
|
+
).get("comments", [])
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def post_issue_comment(repo: str, issue_number: int, body: str):
|
|
876
|
+
url = _gh("issue", "comment", str(issue_number), "--repo", repo, "--body", body)
|
|
877
|
+
_capture_comment_write(repo, issue_number, body, url)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _capture_comment_write(repo: str, issue_number: int, body: str, url: str) -> None:
|
|
881
|
+
"""Best-effort mirror of a just-posted comment into the durable
|
|
882
|
+
``issue_comments`` table (#873).
|
|
883
|
+
|
|
884
|
+
Capture-at-write: the coord-authored prose message bus (completion
|
|
885
|
+
summaries, review bodies, failure reports) becomes durable and
|
|
886
|
+
machine-independent the instant it posts, rather than depending on a
|
|
887
|
+
later reconciliation/recovery pass. Never raises — a mirror failure must
|
|
888
|
+
never undo (or even surface as an error against) a GitHub comment that
|
|
889
|
+
already landed.
|
|
890
|
+
"""
|
|
891
|
+
try:
|
|
892
|
+
from coord import state # noqa: PLC0415 — avoid a github_ops<->state import cycle
|
|
893
|
+
|
|
894
|
+
state.record_issue_comment_capture(
|
|
895
|
+
repo_name=repo,
|
|
896
|
+
issue_number=issue_number,
|
|
897
|
+
body=body,
|
|
898
|
+
gh_comment_id=parse_comment_id(url),
|
|
899
|
+
author=_current_gh_login(),
|
|
900
|
+
)
|
|
901
|
+
except Exception: # noqa: BLE001 — fail-open, see docstring
|
|
902
|
+
pass
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def add_issue_labels(repo: str, issue_number: int, labels: list[str]) -> None:
|
|
906
|
+
"""Add labels to an issue. Idempotent — `gh issue edit --add-label`
|
|
907
|
+
silently no-ops when the label is already present. Raises RuntimeError
|
|
908
|
+
on `gh` failure; callers should wrap in try/except when labeling is
|
|
909
|
+
best-effort (e.g. post-dispatch auto-tagging)."""
|
|
910
|
+
if not labels:
|
|
911
|
+
return
|
|
912
|
+
args = ["issue", "edit", str(issue_number), "--repo", repo]
|
|
913
|
+
for lbl in labels:
|
|
914
|
+
args.extend(["--add-label", lbl])
|
|
915
|
+
_gh(*args)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def create_label(
|
|
919
|
+
repo: str,
|
|
920
|
+
label: str,
|
|
921
|
+
*,
|
|
922
|
+
color: str | None = None,
|
|
923
|
+
description: str | None = None,
|
|
924
|
+
force: bool = True,
|
|
925
|
+
) -> None:
|
|
926
|
+
"""Create *label* in *repo* via ``gh label create``.
|
|
927
|
+
|
|
928
|
+
``force=True`` (the default) makes this idempotent — ``gh`` overwrites
|
|
929
|
+
the color/description if the label already exists instead of erroring.
|
|
930
|
+
Raises ``RuntimeError`` (a plain non-zero ``gh`` exit) or its subclass
|
|
931
|
+
:class:`GhError` (``gh`` missing from PATH or timed out — see ``_gh``) on
|
|
932
|
+
failure; callers that treat label pre-creation as best-effort (e.g. a
|
|
933
|
+
concurrent-create race) should catch ``RuntimeError`` to cover both. Used
|
|
934
|
+
by ``coord set-test-mode`` (#1483) to ensure the ``test-mode:*`` labels
|
|
935
|
+
exist before ``change_issue_labels`` tries to add one.
|
|
936
|
+
"""
|
|
937
|
+
args = ["label", "create", label, "--repo", repo]
|
|
938
|
+
if color:
|
|
939
|
+
args.extend(["--color", color])
|
|
940
|
+
if description:
|
|
941
|
+
args.extend(["--description", description])
|
|
942
|
+
if force:
|
|
943
|
+
args.append("--force")
|
|
944
|
+
_gh(*args)
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def remove_issue_label(repo: str, issue_number: int, label: str) -> None:
|
|
948
|
+
"""Remove a label from an issue via ``gh issue edit --remove-label``.
|
|
949
|
+
|
|
950
|
+
Idempotent — ``gh`` silently no-ops if the label is not present.
|
|
951
|
+
Raises RuntimeError on ``gh`` failure.
|
|
952
|
+
"""
|
|
953
|
+
_gh("issue", "edit", str(issue_number), "--repo", repo, "--remove-label", label)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def change_issue_labels(
|
|
957
|
+
repo: str,
|
|
958
|
+
issue_number: int,
|
|
959
|
+
*,
|
|
960
|
+
add: set[str],
|
|
961
|
+
remove: set[str],
|
|
962
|
+
) -> tuple[list[str], bool]:
|
|
963
|
+
"""Atomically add and/or remove arbitrary labels on an issue (#802).
|
|
964
|
+
|
|
965
|
+
Fetches the current label set first, computes the minimal delta, and
|
|
966
|
+
runs a single ``gh issue edit`` call only when something actually
|
|
967
|
+
changes — tolerates already-present ``add`` labels and already-absent
|
|
968
|
+
``remove`` labels (idempotent, matches ``_apply_label_change``'s
|
|
969
|
+
pre-#802 behavior).
|
|
970
|
+
|
|
971
|
+
Returns ``(new_labels, changed)`` where ``new_labels`` is the final
|
|
972
|
+
label list (sorted) and ``changed`` is ``True`` when any labels were
|
|
973
|
+
added or removed. Raises ``RuntimeError`` on ``gh`` failure.
|
|
974
|
+
"""
|
|
975
|
+
view_data = _gh_json(
|
|
976
|
+
"issue", "view", str(issue_number), "--repo", repo, "--json", "labels",
|
|
977
|
+
default={},
|
|
978
|
+
)
|
|
979
|
+
current: set[str] = {
|
|
980
|
+
lbl.get("name", "")
|
|
981
|
+
for lbl in view_data.get("labels", [])
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
to_add = add - current
|
|
985
|
+
to_remove = remove & current
|
|
986
|
+
changed = bool(to_add or to_remove)
|
|
987
|
+
|
|
988
|
+
if changed:
|
|
989
|
+
args = ["issue", "edit", str(issue_number), "--repo", repo]
|
|
990
|
+
for lbl in sorted(to_add):
|
|
991
|
+
args.extend(["--add-label", lbl])
|
|
992
|
+
for lbl in sorted(to_remove):
|
|
993
|
+
args.extend(["--remove-label", lbl])
|
|
994
|
+
try:
|
|
995
|
+
_gh(*args)
|
|
996
|
+
except RuntimeError as exc:
|
|
997
|
+
if to_add and _is_label_not_found(exc):
|
|
998
|
+
# A label in ``to_add`` doesn't exist in the repo yet.
|
|
999
|
+
# Auto-create each label and retry the edit once. Only
|
|
1000
|
+
# triggered on the add path (``to_add`` is non-empty) and
|
|
1001
|
+
# only for label-not-found errors — auth, network, and
|
|
1002
|
+
# rate-limit failures are re-raised immediately without
|
|
1003
|
+
# touching GitHub. ``gh label create`` errors are swallowed
|
|
1004
|
+
# so an "already exists" race on a concurrent create is
|
|
1005
|
+
# handled gracefully.
|
|
1006
|
+
for lbl in sorted(to_add):
|
|
1007
|
+
try:
|
|
1008
|
+
_gh("label", "create", lbl, "--repo", repo)
|
|
1009
|
+
except RuntimeError:
|
|
1010
|
+
pass # idempotent: label may already exist
|
|
1011
|
+
try:
|
|
1012
|
+
_gh(*args)
|
|
1013
|
+
except RuntimeError as retry_exc:
|
|
1014
|
+
if _is_label_not_found(retry_exc):
|
|
1015
|
+
raise GhNotFound(str(retry_exc)) from retry_exc
|
|
1016
|
+
raise
|
|
1017
|
+
else:
|
|
1018
|
+
raise
|
|
1019
|
+
|
|
1020
|
+
new_labels = sorted((current - to_remove) | to_add)
|
|
1021
|
+
return new_labels, changed
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
_TEST_MODE_LABELS = ("test-mode:smoke", "test-mode:auto")
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def set_test_mode_label(
|
|
1028
|
+
repo_github: str,
|
|
1029
|
+
repo_name: str,
|
|
1030
|
+
issue_number: int,
|
|
1031
|
+
mode: str,
|
|
1032
|
+
) -> None:
|
|
1033
|
+
"""Persist the per-issue test-mode policy as a GitHub label.
|
|
1034
|
+
|
|
1035
|
+
Removes any existing ``test-mode:*`` label then adds ``test-mode:{mode}``.
|
|
1036
|
+
Also updates the local issues cache so the TUI pipeline reflects the change
|
|
1037
|
+
without waiting for the next ``coord sync``.
|
|
1038
|
+
|
|
1039
|
+
``repo_github`` — ``owner/name`` slug for the ``gh`` CLI.
|
|
1040
|
+
``repo_name`` — coordinator-local repo name for the DB cache.
|
|
1041
|
+
``mode`` — ``"smoke"`` or ``"auto"``.
|
|
1042
|
+
"""
|
|
1043
|
+
from coord import state as _state # noqa: PLC0415
|
|
1044
|
+
|
|
1045
|
+
if mode not in ("smoke", "auto"):
|
|
1046
|
+
raise ValueError(f"mode must be 'smoke' or 'auto', got {mode!r}")
|
|
1047
|
+
|
|
1048
|
+
# Step 1: remove any stale test-mode:* labels.
|
|
1049
|
+
for old_label in _TEST_MODE_LABELS:
|
|
1050
|
+
try:
|
|
1051
|
+
remove_issue_label(repo_github, issue_number, old_label)
|
|
1052
|
+
except RuntimeError:
|
|
1053
|
+
pass # already absent — not an error
|
|
1054
|
+
|
|
1055
|
+
# Step 2: add the new label (idempotent).
|
|
1056
|
+
new_label = f"test-mode:{mode}"
|
|
1057
|
+
add_issue_labels(repo_github, issue_number, [new_label])
|
|
1058
|
+
|
|
1059
|
+
# Step 3: refresh the local cache so the TUI sees the update.
|
|
1060
|
+
try:
|
|
1061
|
+
issue_data = get_issue(repo_github, issue_number)
|
|
1062
|
+
current_labels = [lbl.get("name", "") for lbl in issue_data.get("labels", [])]
|
|
1063
|
+
_state.update_issue_labels(repo_name, issue_number, current_labels)
|
|
1064
|
+
except Exception:
|
|
1065
|
+
pass # cache update is best-effort
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def get_repo_file(repo: str, path: str, branch: str = "develop") -> str:
|
|
1069
|
+
import base64
|
|
1070
|
+
raw = _gh("api", f"repos/{repo}/contents/{path}?ref={branch}")
|
|
1071
|
+
data = _json_loads_or(raw, default=None)
|
|
1072
|
+
# #1353: an empty/malformed-but-exit-0 response used to bare-json.loads()
|
|
1073
|
+
# into an unattributable JSONDecodeError (or, post-decode, a KeyError on
|
|
1074
|
+
# "content"). Both callers of this function (_default_gate_a_file_exists,
|
|
1075
|
+
# _default_fetch_repo_file) already catch RuntimeError to mean "file
|
|
1076
|
+
# doesn't exist" — raise that instead, so a `gh` hiccup degrades to the
|
|
1077
|
+
# same handled path as a real 404 rather than an uncaught crash.
|
|
1078
|
+
if not isinstance(data, dict) or "content" not in data:
|
|
1079
|
+
raise RuntimeError(
|
|
1080
|
+
f"gh api repos/{repo}/contents/{path}?ref={branch}: "
|
|
1081
|
+
"empty or malformed response"
|
|
1082
|
+
)
|
|
1083
|
+
return base64.b64decode(data["content"]).decode()
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def list_repo_dir(repo: str, path: str, branch: str = "develop") -> list[str]:
|
|
1087
|
+
"""Filenames (not full paths) directly under *path* on *branch*.
|
|
1088
|
+
|
|
1089
|
+
Same ``contents`` endpoint :func:`get_repo_file` uses, which returns a
|
|
1090
|
+
JSON array (rather than a single file object) when *path* is a
|
|
1091
|
+
directory. Raises like :func:`get_repo_file` (``RuntimeError`` via
|
|
1092
|
+
``_gh``) when *path* doesn't exist — callers that want a soft "not
|
|
1093
|
+
found" should catch that, mirroring ``_default_gate_a_file_exists``.
|
|
1094
|
+
"""
|
|
1095
|
+
raw = _gh("api", f"repos/{repo}/contents/{path}?ref={branch}")
|
|
1096
|
+
data = _json_loads_or(raw, default=None)
|
|
1097
|
+
if not isinstance(data, list):
|
|
1098
|
+
return []
|
|
1099
|
+
return [entry["name"] for entry in data if entry.get("type") == "file"]
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def check_branch_exists(repo: str, branch: str) -> bool:
|
|
1103
|
+
try:
|
|
1104
|
+
_gh("api", f"repos/{repo}/branches/{branch}")
|
|
1105
|
+
return True
|
|
1106
|
+
except RuntimeError:
|
|
1107
|
+
return False
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def list_remote_branch_names(repo: str) -> set[str]:
|
|
1111
|
+
"""Return the set of branch names that currently exist on `repo` (owner/name).
|
|
1112
|
+
|
|
1113
|
+
One paginated ``gh api`` call. Used by ``coord merge`` to skip re-enqueuing
|
|
1114
|
+
done-work whose branch was already merged-and-deleted (the dominant
|
|
1115
|
+
merge-queue clog source). Returns an empty set on error so callers can
|
|
1116
|
+
fail OPEN (treat "couldn't determine" as "don't skip").
|
|
1117
|
+
"""
|
|
1118
|
+
try:
|
|
1119
|
+
raw = _gh(
|
|
1120
|
+
"api", "--paginate",
|
|
1121
|
+
f"repos/{repo}/git/refs/heads",
|
|
1122
|
+
"--jq", ".[].ref",
|
|
1123
|
+
)
|
|
1124
|
+
except RuntimeError:
|
|
1125
|
+
return set()
|
|
1126
|
+
prefix = "refs/heads/"
|
|
1127
|
+
return {
|
|
1128
|
+
line.strip()[len(prefix):]
|
|
1129
|
+
for line in raw.splitlines()
|
|
1130
|
+
if line.strip().startswith(prefix)
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def branch_exists_on_remote(repo: str, branch: str) -> bool:
|
|
1135
|
+
"""Return True if `branch` currently exists on `repo` (owner/name) at GitHub.
|
|
1136
|
+
|
|
1137
|
+
Uses a targeted ``gh api`` call rather than listing all branches. Fails
|
|
1138
|
+
OPEN (returns True) on any infrastructure problem — an unresponsive ``gh``,
|
|
1139
|
+
a network glitch, or an authentication issue must never prevent a legitimate
|
|
1140
|
+
dispatch. Only returns False when we receive a clear "not found" signal
|
|
1141
|
+
from GitHub (HTTP 4xx in the error output).
|
|
1142
|
+
|
|
1143
|
+
Called by ``dispatch_review`` and ``_dispatch_fix`` (#586) to avoid
|
|
1144
|
+
routing a follow-on assignment to a machine that can't fetch the branch.
|
|
1145
|
+
"""
|
|
1146
|
+
try:
|
|
1147
|
+
_gh("api", f"repos/{repo}/git/refs/heads/{branch}")
|
|
1148
|
+
return True
|
|
1149
|
+
except RuntimeError as exc:
|
|
1150
|
+
err = str(exc).lower()
|
|
1151
|
+
# Only return False when GitHub explicitly told us the ref doesn't
|
|
1152
|
+
# exist (HTTP 4xx response). Any other failure (gh not installed,
|
|
1153
|
+
# not authenticated, network timeout) is treated as "unknown" and we
|
|
1154
|
+
# fail OPEN so the guard doesn't block legitimate dispatch.
|
|
1155
|
+
if "http 4" in err or "could not resolve" in err or "not found" in err:
|
|
1156
|
+
return False
|
|
1157
|
+
return True
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def delete_remote_branch(repo: str, branch: str) -> bool:
|
|
1161
|
+
"""Delete a remote branch. Returns True on success, False on failure."""
|
|
1162
|
+
try:
|
|
1163
|
+
_gh("api", "-X", "DELETE", f"repos/{repo}/git/refs/heads/{branch}")
|
|
1164
|
+
return True
|
|
1165
|
+
except RuntimeError:
|
|
1166
|
+
return False
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def create_remote_branch(repo: str, branch: str, sha: str) -> bool:
|
|
1170
|
+
"""Create a remote branch (a ``refs/heads/{branch}`` ref) pointing at
|
|
1171
|
+
*sha*. Returns True on success, False on failure.
|
|
1172
|
+
|
|
1173
|
+
#934: used by ``coord.branch_model.ensure_feature_branch_exists`` to
|
|
1174
|
+
create ``feature/ms-NN`` off ``develop`` on demand, idempotently (the
|
|
1175
|
+
caller checks ``branch_exists_on_remote`` first).
|
|
1176
|
+
"""
|
|
1177
|
+
try:
|
|
1178
|
+
_gh(
|
|
1179
|
+
"api", "-X", "POST", f"repos/{repo}/git/refs",
|
|
1180
|
+
"-f", f"ref=refs/heads/{branch}",
|
|
1181
|
+
"-f", f"sha={sha}",
|
|
1182
|
+
)
|
|
1183
|
+
return True
|
|
1184
|
+
except RuntimeError:
|
|
1185
|
+
return False
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def get_default_branch_head(repo: str, branch: str) -> str:
|
|
1189
|
+
"""Return the full commit SHA at the tip of `branch` on `repo` (owner/name)."""
|
|
1190
|
+
raw = _gh("api", f"repos/{repo}/branches/{branch}")
|
|
1191
|
+
data = _json_loads_or(raw, default=None)
|
|
1192
|
+
# #1353: every caller of this already catches RuntimeError to mean "HEAD
|
|
1193
|
+
# lookup failed" — raise that instead of letting an empty/malformed
|
|
1194
|
+
# exit-0 response crash with an unattributable JSONDecodeError/KeyError.
|
|
1195
|
+
if not isinstance(data, dict) or "commit" not in data:
|
|
1196
|
+
raise RuntimeError(
|
|
1197
|
+
f"gh api repos/{repo}/branches/{branch}: empty or malformed response"
|
|
1198
|
+
)
|
|
1199
|
+
return data["commit"]["sha"]
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def get_branch_sha(repo: str, branch: str) -> str | None:
|
|
1203
|
+
"""Return the current HEAD SHA for *branch* on *repo*, or ``None`` on failure.
|
|
1204
|
+
|
|
1205
|
+
Best-effort wrapper around the GitHub branches API. Returns ``None`` when
|
|
1206
|
+
GitHub is unavailable, ``gh`` is not authenticated, or the branch does not
|
|
1207
|
+
exist — callers treat ``None`` as "SHA tracking unavailable" and skip the
|
|
1208
|
+
commit-bound staleness check introduced in #821.
|
|
1209
|
+
"""
|
|
1210
|
+
try:
|
|
1211
|
+
raw = _gh("api", f"repos/{repo}/branches/{branch}")
|
|
1212
|
+
data = _json_loads_or(raw, default={})
|
|
1213
|
+
return data["commit"]["sha"]
|
|
1214
|
+
except Exception: # noqa: BLE001 — fail-safe: unknown SHA is not blocking
|
|
1215
|
+
return None
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def get_branch_commit_timestamp(repo: str, branch: str) -> float | None:
|
|
1219
|
+
"""Return the unix timestamp of *branch*'s current HEAD commit on *repo*,
|
|
1220
|
+
or ``None`` on failure (#1851).
|
|
1221
|
+
|
|
1222
|
+
The base-side half of :func:`coord.ci_store.checks_are_stale`'s
|
|
1223
|
+
comparison: a green CI check's ``started_at`` predating this timestamp
|
|
1224
|
+
means the check ran before the base's newest commit landed — GitHub only
|
|
1225
|
+
re-runs ``pull_request`` checks on head ``synchronize``, never on base
|
|
1226
|
+
movement, so that check never saw it.
|
|
1227
|
+
|
|
1228
|
+
Same endpoint as :func:`get_branch_sha` (``GET
|
|
1229
|
+
repos/{repo}/branches/{branch}``) — reads the commit's ``committer.date``
|
|
1230
|
+
(when it landed on the branch) rather than ``author.date`` (when it was
|
|
1231
|
+
originally authored, which for a rebased/cherry-picked commit can predate
|
|
1232
|
+
the merge by a wide margin and would understate how fresh the base
|
|
1233
|
+
actually is). Best-effort like :func:`get_branch_sha`: returns ``None``
|
|
1234
|
+
when GitHub is unavailable, ``gh`` is not authenticated, the branch
|
|
1235
|
+
doesn't exist, or the response is missing the expected fields — callers
|
|
1236
|
+
must treat ``None`` as "unknown" and fail closed (stale), never as "the
|
|
1237
|
+
base never moved".
|
|
1238
|
+
"""
|
|
1239
|
+
try:
|
|
1240
|
+
raw = _gh("api", f"repos/{repo}/branches/{branch}")
|
|
1241
|
+
data = _json_loads_or(raw, default={})
|
|
1242
|
+
date = data["commit"]["commit"]["committer"]["date"]
|
|
1243
|
+
return datetime.fromisoformat(str(date).replace("Z", "+00:00")).timestamp()
|
|
1244
|
+
except Exception: # noqa: BLE001 — fail-safe: unknown timestamp is stale, not blocking here
|
|
1245
|
+
return None
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
# ── PR operations (used by the merge queue) ──────────────────────────────
|
|
1249
|
+
|
|
1250
|
+
def find_pr_for_branch(repo: str, branch: str) -> dict | None:
|
|
1251
|
+
"""Return the first open PR whose head ref matches `branch`, or None."""
|
|
1252
|
+
items = _gh_json(
|
|
1253
|
+
"pr", "list", "--repo", repo, "--state", "open",
|
|
1254
|
+
"--head", branch,
|
|
1255
|
+
"--json", "number,title,url,headRefName,baseRefName,additions,deletions,mergeable",
|
|
1256
|
+
"--limit", "1",
|
|
1257
|
+
default=[],
|
|
1258
|
+
)
|
|
1259
|
+
return items[0] if items else None
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def get_pr_state_for_branch(repo: str, branch: str) -> str | None:
|
|
1263
|
+
"""Return the current lifecycle state (``OPEN``/``MERGED``/``CLOSED``) of
|
|
1264
|
+
the PR whose head is *branch*, or ``None`` when no such PR exists (or
|
|
1265
|
+
``gh`` fails).
|
|
1266
|
+
|
|
1267
|
+
Unlike :func:`find_pr_for_branch` (``pr list --state open`` — only ever
|
|
1268
|
+
finds *open* PRs), this resolves the branch directly via ``gh pr view``,
|
|
1269
|
+
which answers regardless of state. Used by
|
|
1270
|
+
:meth:`coord.drive.GitMergeVerifier.verify_merged` (#1483) to confirm a
|
|
1271
|
+
MERGED PR whose branch may since have been deleted from the remote.
|
|
1272
|
+
"""
|
|
1273
|
+
try:
|
|
1274
|
+
state = _gh("pr", "view", branch, "--repo", repo, "--json", "state", "-q", ".state")
|
|
1275
|
+
except RuntimeError:
|
|
1276
|
+
return None
|
|
1277
|
+
return state or None
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def get_pr_head_ref(repo: str, number: int) -> str | None:
|
|
1281
|
+
"""Return PR *number*'s head branch name, or ``None`` on any ``gh``
|
|
1282
|
+
failure (including "no such PR").
|
|
1283
|
+
|
|
1284
|
+
Used by ``coord test``'s branch-reconciliation fallback (#349, #1483) to
|
|
1285
|
+
recover the PR's actual head ref when the DB's recorded branch name has
|
|
1286
|
+
gone stale.
|
|
1287
|
+
"""
|
|
1288
|
+
try:
|
|
1289
|
+
head_ref = _gh(
|
|
1290
|
+
"pr", "view", str(number), "--repo", repo,
|
|
1291
|
+
"--json", "headRefName", "--jq", ".headRefName",
|
|
1292
|
+
)
|
|
1293
|
+
except RuntimeError:
|
|
1294
|
+
return None
|
|
1295
|
+
return head_ref or None
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
# #1564: `gh pr checks --json` does NOT have a `conclusion` field — it never
|
|
1299
|
+
# has. Requesting it makes `gh` exit 1 with empty stdout, which used to make
|
|
1300
|
+
# every single merge look like an unreadable CI status (fail-closed, so it
|
|
1301
|
+
# blocked every merge rather than passing every one, but neither is correct).
|
|
1302
|
+
# `bucket` is gh's own pass/fail/pending/skipping/cancel rollup of `state`
|
|
1303
|
+
# (which is a per-check verdict like SUCCESS/FAILURE, not a lifecycle phase)
|
|
1304
|
+
# and is exactly what the merge gate wants. Pinned in a regression test
|
|
1305
|
+
# (tests/test_github_ops.py::TestPrChecksJsonFieldsAreValid) that shells out
|
|
1306
|
+
# to `gh pr checks --help` and asserts every field here is one `gh` advertises,
|
|
1307
|
+
# so the next `gh` schema change fails a test instead of silently breaking
|
|
1308
|
+
# the gate again.
|
|
1309
|
+
PR_CHECKS_JSON_FIELDS: tuple[str, ...] = (
|
|
1310
|
+
"name", "state", "bucket", "link", "startedAt", "completedAt",
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
# #1564 Addendum 2: the fleet that surfaced this issue runs `gh` versions that
|
|
1314
|
+
# disagree about whether `gh pr checks` even *has* a `--json` flag —
|
|
1315
|
+
# dellserver's 2.45.0 (Ubuntu's apt package) does not: `gh pr checks --json
|
|
1316
|
+
# name,state,bucket` fails with `unknown flag: --json`, exit 1, empty stdout.
|
|
1317
|
+
# 2.86.0 (elitebook) and 2.92.0 (precision) both support `--json` fine. There
|
|
1318
|
+
# is no gh version floor documented anywhere else in this codebase, so this
|
|
1319
|
+
# constant is the single source of truth for it — surfaced in the actionable
|
|
1320
|
+
# error message below (:func:`_gh_too_old_message`) and in
|
|
1321
|
+
# ``docs/AGENT_OPERATIONS.md``'s daemon-host prerequisites.
|
|
1322
|
+
#
|
|
1323
|
+
# 2.86.0 is simply the *oldest version this fleet has directly observed
|
|
1324
|
+
# working* — nobody has bisected the actual gh release that first shipped
|
|
1325
|
+
# `pr checks --json` support, so treat this as a confirmed-good floor, not a
|
|
1326
|
+
# precisely-researched one. Lower it if a narrower floor is ever confirmed.
|
|
1327
|
+
GH_PR_CHECKS_JSON_MIN_VERSION = "2.86.0"
|
|
1328
|
+
|
|
1329
|
+
# The exact, stable cobra/pflag message `gh` emits for a flag it doesn't
|
|
1330
|
+
# recognise at all — confirmed verbatim against dellserver's gh 2.45.0.
|
|
1331
|
+
# Distinct from (and must be checked before assuming) the "field not valid"
|
|
1332
|
+
# failure a newer gh gives for a bad field *name* (e.g. the original
|
|
1333
|
+
# `conclusion` bug), which instead exits 1 with a "Unknown JSON field" body.
|
|
1334
|
+
_GH_UNKNOWN_JSON_FLAG_MARKER = "unknown flag: --json"
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _gh_version() -> str | None:
|
|
1338
|
+
"""Best-effort parse of ``gh --version``'s version string (e.g. "2.45.0").
|
|
1339
|
+
|
|
1340
|
+
Returns ``None`` if ``gh`` is missing, times out, or prints something
|
|
1341
|
+
this can't parse — callers must treat that as "unknown", never fail on it.
|
|
1342
|
+
"""
|
|
1343
|
+
try:
|
|
1344
|
+
result = subprocess.run(
|
|
1345
|
+
["gh", "--version"], capture_output=True, text=True, timeout=10,
|
|
1346
|
+
)
|
|
1347
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1348
|
+
return None
|
|
1349
|
+
match = re.search(r"gh version (\S+)", result.stdout or "")
|
|
1350
|
+
return match.group(1) if match else None
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def _gh_too_old_message(stderr: str) -> str:
|
|
1354
|
+
"""Build the actionable error text for :class:`GhTooOldForJsonChecks`.
|
|
1355
|
+
|
|
1356
|
+
Names the host and the installed (if determinable) and required gh
|
|
1357
|
+
versions explicitly — this is the whole point of #1564 Addendum 2: an
|
|
1358
|
+
operator reading a merge refusal should never have to guess whether the
|
|
1359
|
+
gate found a red check, hit a network blip, or is simply running on a
|
|
1360
|
+
`gh` too old to ask the question at all.
|
|
1361
|
+
"""
|
|
1362
|
+
host = socket.gethostname()
|
|
1363
|
+
version = _gh_version() or "unknown"
|
|
1364
|
+
return (
|
|
1365
|
+
f"gh on host {host!r} (version {version}) does not support "
|
|
1366
|
+
f"`gh pr checks --json` at all ({stderr!r}) — gh >= "
|
|
1367
|
+
f"{GH_PR_CHECKS_JSON_MIN_VERSION} is required on whichever host runs "
|
|
1368
|
+
f"the CI merge gate. Since `coord merge` re-invokes itself on the "
|
|
1369
|
+
f"daemon (COORD_MERGE_ON_DAEMON), that means the *daemon* host's gh, "
|
|
1370
|
+
f"not the client's. See docs/AGENT_OPERATIONS.md's daemon-host "
|
|
1371
|
+
f"prerequisites."
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def get_repo_workflow_count(repo: str) -> int:
|
|
1376
|
+
"""Number of GitHub Actions workflows GitHub recognises for *repo* (#1904).
|
|
1377
|
+
|
|
1378
|
+
Backs :meth:`coord.ci_github.GitHubCi.expects_checks` — the signal that
|
|
1379
|
+
distinguishes "this repo has no CI configured" (an empty ``gh pr checks``
|
|
1380
|
+
result is correct) from "CI exists but never triggered for this PR" (an
|
|
1381
|
+
empty result is a red flag: a throttled webhook, a wedged run, a
|
|
1382
|
+
``paths:``-filtered-out workflow). Queries the workflow *definitions*
|
|
1383
|
+
GitHub knows about for the repo as a whole — not any particular branch
|
|
1384
|
+
or PR's check runs — so the answer doesn't depend on whether this PR's
|
|
1385
|
+
push ever actually triggered a run, which is exactly the case this
|
|
1386
|
+
exists to catch.
|
|
1387
|
+
|
|
1388
|
+
Raises (``RuntimeError``/:class:`GhError`) on any read failure — auth,
|
|
1389
|
+
rate-limit, malformed response — rather than defaulting to 0. A caller
|
|
1390
|
+
that can't tell "no workflows" from "couldn't check" must not silently
|
|
1391
|
+
treat the latter as the former; see #1525 for the identical reasoning
|
|
1392
|
+
applied to check-run reads themselves.
|
|
1393
|
+
"""
|
|
1394
|
+
data = _gh_json("api", f"repos/{repo}/actions/workflows", default=None)
|
|
1395
|
+
if not isinstance(data, dict) or not isinstance(data.get("total_count"), int):
|
|
1396
|
+
raise RuntimeError(
|
|
1397
|
+
f"gh api repos/{repo}/actions/workflows: malformed response"
|
|
1398
|
+
)
|
|
1399
|
+
return data["total_count"]
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def get_pr_checks(repo: str, number: int) -> list[dict]:
|
|
1403
|
+
"""Return ``gh pr checks``' raw check-run list for PR *number*.
|
|
1404
|
+
|
|
1405
|
+
``gh pr checks`` exits non-zero when any check has failed, but its JSON
|
|
1406
|
+
stdout is still valid in that case — only raise when stdout is genuinely
|
|
1407
|
+
empty (a real lookup failure: bad PR number, auth, rate-limit, an old gh
|
|
1408
|
+
that doesn't support ``--json`` at all, ...). The single ``gh`` sink for
|
|
1409
|
+
:class:`coord.ci_github.GitHubCi`, the CI backend behind the merge gate
|
|
1410
|
+
(#1483).
|
|
1411
|
+
|
|
1412
|
+
Raises :class:`GhTooOldForJsonChecks` — instead of the generic
|
|
1413
|
+
``RuntimeError`` below — when the installed ``gh`` doesn't recognise
|
|
1414
|
+
``--json`` on ``pr checks`` at all (#1564 Addendum 2), so callers can
|
|
1415
|
+
surface a distinct, actionable "upgrade gh" message rather than lumping
|
|
1416
|
+
it in with ordinary read failures.
|
|
1417
|
+
"""
|
|
1418
|
+
result = subprocess.run(
|
|
1419
|
+
[
|
|
1420
|
+
"gh", "pr", "checks", str(number),
|
|
1421
|
+
"--repo", repo,
|
|
1422
|
+
"--json", ",".join(PR_CHECKS_JSON_FIELDS),
|
|
1423
|
+
],
|
|
1424
|
+
capture_output=True, text=True, timeout=30,
|
|
1425
|
+
)
|
|
1426
|
+
stdout = (result.stdout or "").strip()
|
|
1427
|
+
if result.returncode != 0 and not stdout:
|
|
1428
|
+
stderr = result.stderr.strip()
|
|
1429
|
+
if _GH_UNKNOWN_JSON_FLAG_MARKER in stderr:
|
|
1430
|
+
raise GhTooOldForJsonChecks(_gh_too_old_message(stderr))
|
|
1431
|
+
raise RuntimeError(f"gh pr checks failed: {stderr}")
|
|
1432
|
+
# #1525: unlike the fail-open sites elsewhere in this module, a malformed
|
|
1433
|
+
# (non-empty) response here must NOT be swallowed to a quiet ``[]`` —
|
|
1434
|
+
# ``ci_github.GitHubCi._fetch`` deliberately catches the ``ValueError``
|
|
1435
|
+
# (``json.JSONDecodeError`` is a subclass) this raises and turns it into
|
|
1436
|
+
# a synthetic *failing* check, so the merge gate blocks and says why
|
|
1437
|
+
# instead of reading "no checks" as "clear to merge" (the exact silent
|
|
1438
|
+
# fail-open that let PR #1521 merge past a real CI failure). Only the
|
|
1439
|
+
# genuinely-empty-stdout case (``stdout or "[]"``, unchanged from before)
|
|
1440
|
+
# is a deliberate default here — that's ``gh``'s normal "zero checks
|
|
1441
|
+
# configured" response, not a decode failure.
|
|
1442
|
+
return json.loads(stdout or "[]")
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def get_run_jobs(repo: str, run_id: str) -> list[dict]:
|
|
1446
|
+
"""Return ``gh api .../actions/runs/{run_id}/jobs``' raw ``jobs`` list (#1892).
|
|
1447
|
+
|
|
1448
|
+
The single ``gh`` sink for :meth:`coord.ci_github.GitHubCi.
|
|
1449
|
+
list_jobs_for_run` — the one call that carries per-step detail
|
|
1450
|
+
(``runner_name``, each step's ``name``/``conclusion``) a plain
|
|
1451
|
+
``gh pr checks`` read never has. Deliberately NOT the ``--json``
|
|
1452
|
+
field-selection style :func:`get_pr_checks` uses: ``gh api`` returns the
|
|
1453
|
+
endpoint's full JSON shape and this needs several nested fields
|
|
1454
|
+
(``steps[].name``, ``steps[].conclusion``, ``runner_name``) that aren't
|
|
1455
|
+
worth hand-picking.
|
|
1456
|
+
|
|
1457
|
+
Raises ``RuntimeError``/``ValueError`` on any read failure — auth,
|
|
1458
|
+
rate-limit, malformed response, or the run id simply not existing
|
|
1459
|
+
(rerun raced a retention window). Callers on the classification path
|
|
1460
|
+
(:mod:`coord.ci_store`'s false-negative bias) must catch and treat a
|
|
1461
|
+
raised error the same as "no job data" — never as evidence either way.
|
|
1462
|
+
"""
|
|
1463
|
+
data = _gh_json("api", f"repos/{repo}/actions/runs/{run_id}/jobs", default=None)
|
|
1464
|
+
if not isinstance(data, dict) or not isinstance(data.get("jobs"), list):
|
|
1465
|
+
raise RuntimeError(
|
|
1466
|
+
f"gh api repos/{repo}/actions/runs/{run_id}/jobs: malformed response"
|
|
1467
|
+
)
|
|
1468
|
+
return data["jobs"]
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def rerun_workflow_run(repo: str, run_id: str) -> bool:
|
|
1472
|
+
"""Re-run Actions workflow run *run_id* on *repo* via ``gh run rerun``.
|
|
1473
|
+
|
|
1474
|
+
The single ``gh`` sink (#1483) for :meth:`coord.ci_github.GitHubCi.
|
|
1475
|
+
rerun_for_pr` (#1851) — the same seam :func:`get_pr_checks` is for
|
|
1476
|
+
reading checks. Returns ``True`` only on a clean (exit 0) rerun; any
|
|
1477
|
+
subprocess failure (missing ``gh``, timeout, non-zero exit — e.g. the run
|
|
1478
|
+
is already in progress, or the id is stale/invalid) returns ``False``
|
|
1479
|
+
rather than raising, matching this module's other best-effort mutators
|
|
1480
|
+
(:func:`merge_pr`, :func:`edit_pr_body`).
|
|
1481
|
+
"""
|
|
1482
|
+
try:
|
|
1483
|
+
result = subprocess.run(
|
|
1484
|
+
["gh", "run", "rerun", str(run_id), "--repo", repo],
|
|
1485
|
+
capture_output=True, text=True, timeout=30,
|
|
1486
|
+
)
|
|
1487
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1488
|
+
return False
|
|
1489
|
+
return result.returncode == 0
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def truncate_diff_text(diff: str, max_chars: int = 60000) -> str:
|
|
1493
|
+
"""Truncate *diff* to *max_chars* with a trailing note, if it's over.
|
|
1494
|
+
|
|
1495
|
+
Factored out of :func:`pr_diff` (#1475) so callers that need the full,
|
|
1496
|
+
untruncated diff for content hashing (``compute_patch_id``) can still
|
|
1497
|
+
apply the same display truncation to a *separate* copy shown to a human
|
|
1498
|
+
reviewer or embedded in a briefing, without a second ``gh`` fetch.
|
|
1499
|
+
"""
|
|
1500
|
+
if len(diff) > max_chars:
|
|
1501
|
+
return diff[:max_chars] + f"\n... [diff truncated at {max_chars} chars] ..."
|
|
1502
|
+
return diff
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def pr_diff(repo_github: str, pr_number: int, *, max_chars: int | None = 60000) -> str | None:
|
|
1506
|
+
"""Return the merge-base (three-dot) diff for PR ``pr_number``, or None.
|
|
1507
|
+
|
|
1508
|
+
``gh pr diff`` is three-dot / merge-base by GitHub semantics, so the output
|
|
1509
|
+
is exactly the branch's own changes (#612) — code merged to the base after
|
|
1510
|
+
the branch was cut never appears as spurious deletions. Truncated to
|
|
1511
|
+
*max_chars* with a trailing note so a huge diff can't blow the briefing
|
|
1512
|
+
size — pass ``max_chars=None`` to get the full, untruncated diff (#1475:
|
|
1513
|
+
needed for content-hashing via ``compute_patch_id``, which must not hash
|
|
1514
|
+
a mutated/truncated string). Best-effort: returns None on any ``gh`` error
|
|
1515
|
+
so the caller falls back to the in-briefing three-dot diff instructions.
|
|
1516
|
+
"""
|
|
1517
|
+
try:
|
|
1518
|
+
diff = _gh("pr", "diff", str(pr_number), "--repo", repo_github)
|
|
1519
|
+
except RuntimeError:
|
|
1520
|
+
return None
|
|
1521
|
+
if max_chars is None:
|
|
1522
|
+
return diff
|
|
1523
|
+
return truncate_diff_text(diff, max_chars)
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def compute_patch_id(diff_text: str | None) -> str | None:
|
|
1527
|
+
"""Return the ``git patch-id --stable`` hash of *diff_text*, or ``None``.
|
|
1528
|
+
|
|
1529
|
+
#1475: a content-addressed fingerprint of a diff — insensitive to commit
|
|
1530
|
+
SHA / line numbers, but sensitive to the surrounding context lines. A
|
|
1531
|
+
pure rebase with no conflict replays the identical diff against a new
|
|
1532
|
+
base and produces the same patch-id even though the commit SHA changed;
|
|
1533
|
+
a conflict resolution or genuine content change produces a different one
|
|
1534
|
+
(that distinction is #1476's job, not this function's).
|
|
1535
|
+
|
|
1536
|
+
``git patch-id`` operates purely on the piped diff text — no working
|
|
1537
|
+
directory or repo checkout required, so this is safe to call from any
|
|
1538
|
+
process. Returns ``None`` for empty/missing input or on any subprocess
|
|
1539
|
+
failure; callers must fail closed (treat a missing patch-id as "cannot
|
|
1540
|
+
confirm identical content", never as "identical").
|
|
1541
|
+
"""
|
|
1542
|
+
if not diff_text or not diff_text.strip():
|
|
1543
|
+
return None
|
|
1544
|
+
try:
|
|
1545
|
+
result = subprocess.run(
|
|
1546
|
+
["git", "patch-id", "--stable"],
|
|
1547
|
+
input=diff_text, capture_output=True, text=True, timeout=15,
|
|
1548
|
+
)
|
|
1549
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1550
|
+
return None
|
|
1551
|
+
if result.returncode != 0:
|
|
1552
|
+
return None
|
|
1553
|
+
line = result.stdout.strip().splitlines()[0] if result.stdout.strip() else ""
|
|
1554
|
+
if not line:
|
|
1555
|
+
return None
|
|
1556
|
+
return line.split()[0]
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def get_compare_diff(repo: str, base: str, head: str) -> str | None:
|
|
1560
|
+
"""Return the raw three-dot (``base...head``) unified diff text, or None.
|
|
1561
|
+
|
|
1562
|
+
*head* may be a branch name or a commit SHA — GitHub's compare API treats
|
|
1563
|
+
them identically, so this also works for a historical SHA that is no
|
|
1564
|
+
longer any branch's tip (e.g. the HEAD a review approved before a
|
|
1565
|
+
conflict-fix rebase moved the branch on, #1476). Factored out of
|
|
1566
|
+
:func:`get_branch_patch_id` so a scoped re-review can fetch the diff for
|
|
1567
|
+
an old SHA as well as the current branch tip, without duplicating the
|
|
1568
|
+
``gh api compare`` call shape. Returns ``None`` on any ``gh`` failure —
|
|
1569
|
+
callers must fail closed (missing diff ⇒ cannot confirm anything about
|
|
1570
|
+
its content).
|
|
1571
|
+
"""
|
|
1572
|
+
try:
|
|
1573
|
+
return _gh(
|
|
1574
|
+
"api", f"repos/{repo}/compare/{base}...{head}",
|
|
1575
|
+
"-H", "Accept: application/vnd.github.v3.diff",
|
|
1576
|
+
)
|
|
1577
|
+
except RuntimeError:
|
|
1578
|
+
return None
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def get_compare_files(repo: str, base: str, head: str) -> list[str] | None:
|
|
1582
|
+
"""Return the list of file paths changed in the three-dot ``base...head``
|
|
1583
|
+
compare, or ``None`` on any ``gh`` failure.
|
|
1584
|
+
|
|
1585
|
+
#1720: the dispatch-time file-overlap fence needs *which files*, not the
|
|
1586
|
+
diff content — asking the compare API for ``.files[].filename`` directly
|
|
1587
|
+
is cheaper and simpler than fetching :func:`get_compare_diff`'s full
|
|
1588
|
+
unified-diff text and parsing ``diff --git a/... b/...`` headers out of
|
|
1589
|
+
it. Uses the GitHub API (not a local checkout) so it works from any host
|
|
1590
|
+
with `gh` on PATH, matching this module's existing checkout-independent
|
|
1591
|
+
diff helpers (:func:`pr_diff`, :func:`get_compare_diff`) rather than the
|
|
1592
|
+
coordinator assuming it has a local clone of every dispatched repo.
|
|
1593
|
+
|
|
1594
|
+
``--jq`` on a leaf-string selector (``.filename``, unlike the object
|
|
1595
|
+
selector in :func:`get_repo_milestones`) emits *raw* text, one path per
|
|
1596
|
+
line, not JSON-quoted — so this reads lines directly rather than
|
|
1597
|
+
JSON-decoding them. Returns ``None`` (not ``[]``) on failure so callers
|
|
1598
|
+
can distinguish "no files changed" from "couldn't ask" and fail open
|
|
1599
|
+
accordingly.
|
|
1600
|
+
"""
|
|
1601
|
+
try:
|
|
1602
|
+
raw = _gh(
|
|
1603
|
+
"api", f"repos/{repo}/compare/{base}...{head}",
|
|
1604
|
+
"--jq", ".files[].filename",
|
|
1605
|
+
)
|
|
1606
|
+
except RuntimeError:
|
|
1607
|
+
return None
|
|
1608
|
+
return [line for line in (ln.strip() for ln in raw.splitlines()) if line]
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def branch_commits_ahead(repo: str, base: str, branch: str) -> int | None:
|
|
1612
|
+
"""Commits *branch* is ahead of *base* on the remote, or ``None``.
|
|
1613
|
+
|
|
1614
|
+
#1534: the coordinator usually has no local checkout of a worker's branch,
|
|
1615
|
+
so the zero-commit question has to be asked of GitHub. Uses the same
|
|
1616
|
+
three-dot compare API as :func:`get_branch_diff_size` and reads
|
|
1617
|
+
``ahead_by`` — no PR required.
|
|
1618
|
+
|
|
1619
|
+
Returns ``None`` (never 0) on any ``gh`` failure, an unparseable payload,
|
|
1620
|
+
or a missing ``ahead_by`` field. Callers MUST treat ``None`` as "unknown,
|
|
1621
|
+
assume non-zero": this exists to *refuse* work on a provably empty branch,
|
|
1622
|
+
and a network blip must not silently become a refusal — that would strand
|
|
1623
|
+
real reviews. This is the opposite polarity from
|
|
1624
|
+
:func:`branch_is_fully_merged`, which returns a plain ``False`` on error
|
|
1625
|
+
because *its* fail-safe direction is "keep the PR open".
|
|
1626
|
+
"""
|
|
1627
|
+
if not branch or not base:
|
|
1628
|
+
return None
|
|
1629
|
+
if branch == base:
|
|
1630
|
+
return 0
|
|
1631
|
+
try:
|
|
1632
|
+
raw = _gh("api", f"repos/{repo}/compare/{base}...{branch}")
|
|
1633
|
+
cmp = json.loads(raw)
|
|
1634
|
+
except Exception: # noqa: BLE001 — unknown, not zero
|
|
1635
|
+
return None
|
|
1636
|
+
if not isinstance(cmp, dict):
|
|
1637
|
+
return None
|
|
1638
|
+
ahead = cmp.get("ahead_by")
|
|
1639
|
+
if not isinstance(ahead, int) or isinstance(ahead, bool):
|
|
1640
|
+
return None
|
|
1641
|
+
return ahead
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def branch_commits_ahead_for_assignment(assignment: Any, config: Any) -> int | None:
|
|
1645
|
+
""":func:`branch_commits_ahead` for a board *assignment*, or ``None``.
|
|
1646
|
+
|
|
1647
|
+
#1606: `coord retry`'s advisory zero-commit gate
|
|
1648
|
+
(``coord/commands/dispatch.py``'s ``retry()``) and `coord diagnose
|
|
1649
|
+
--stage work`'s ADVISORY-row recovery (``coord/diagnose.py``'s
|
|
1650
|
+
``_work_advisory_commits_ahead``) both ask GitHub this exact question —
|
|
1651
|
+
they used to do it via two independently-written inline copies of
|
|
1652
|
+
"branch empty -> 0, repo missing -> None, else ask GitHub", which even
|
|
1653
|
+
diverged in how they looked up the repo config (``cfg.repo(name)`` vs.
|
|
1654
|
+
a hand-rolled scan over ``config.repos`` — equivalent, but two copies is
|
|
1655
|
+
how they drift apart later). This is the one copy both now call.
|
|
1656
|
+
|
|
1657
|
+
*assignment* needs only ``.branch`` and ``.repo_name``; *config* needs
|
|
1658
|
+
only ``.repo(name)`` returning an object with ``.github`` /
|
|
1659
|
+
``.default_branch`` (both ``coord.models.Assignment`` and
|
|
1660
|
+
``coord.config.Config`` satisfy this — left untyped here to avoid
|
|
1661
|
+
github_ops.py importing either module).
|
|
1662
|
+
|
|
1663
|
+
An assignment with no branch (or a blank one) is treated as 0 commits
|
|
1664
|
+
ahead without ever calling GitHub — there is nothing to compare. A repo
|
|
1665
|
+
that ``config`` doesn't know about returns ``None`` ("cannot confirm"),
|
|
1666
|
+
never a bare 0, matching :func:`branch_commits_ahead`'s own fail-closed
|
|
1667
|
+
polarity: an unconfirmable commit count must never be silently read as
|
|
1668
|
+
"empty branch, safe to touch".
|
|
1669
|
+
"""
|
|
1670
|
+
branch = (getattr(assignment, "branch", None) or "").strip()
|
|
1671
|
+
if not branch:
|
|
1672
|
+
return 0
|
|
1673
|
+
repo_cfg = config.repo(assignment.repo_name)
|
|
1674
|
+
if repo_cfg is None:
|
|
1675
|
+
return None
|
|
1676
|
+
base = repo_cfg.default_branch or "main"
|
|
1677
|
+
return branch_commits_ahead(repo_cfg.github, base, branch)
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def get_branch_patch_id(repo: str, base: str, branch: str) -> str | None:
|
|
1681
|
+
"""Return the content-addressed patch-id for *branch*'s diff against *base*.
|
|
1682
|
+
|
|
1683
|
+
#1475: uses the GitHub three-dot compare API (no PR required, mirroring
|
|
1684
|
+
:func:`get_branch_diff_size`) to fetch the raw unified diff, then hashes
|
|
1685
|
+
it with :func:`compute_patch_id`. Returns ``None`` on any failure — the
|
|
1686
|
+
merge-queue gate treats a missing patch-id as "cannot confirm identical
|
|
1687
|
+
content" and falls back to the pre-#1475 SHA-only staleness check
|
|
1688
|
+
(fail closed).
|
|
1689
|
+
"""
|
|
1690
|
+
return compute_patch_id(get_compare_diff(repo, base, branch))
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
def create_pr(
|
|
1694
|
+
repo: str,
|
|
1695
|
+
*,
|
|
1696
|
+
base: str,
|
|
1697
|
+
head: str,
|
|
1698
|
+
title: str,
|
|
1699
|
+
body: str,
|
|
1700
|
+
) -> dict:
|
|
1701
|
+
"""Open a PR. Returns {number, url}. If one already exists for `head`, returns it."""
|
|
1702
|
+
existing = find_pr_for_branch(repo, head)
|
|
1703
|
+
if existing is not None:
|
|
1704
|
+
return {"number": existing["number"], "url": existing["url"], "existed": True}
|
|
1705
|
+
url = _gh(
|
|
1706
|
+
"pr", "create", "--repo", repo,
|
|
1707
|
+
"--base", base, "--head", head,
|
|
1708
|
+
"--title", title, "--body", body,
|
|
1709
|
+
)
|
|
1710
|
+
# gh pr create returns the URL on the last line of stdout.
|
|
1711
|
+
pr_url = url.strip().splitlines()[-1] if url.strip() else ""
|
|
1712
|
+
number = int(pr_url.rsplit("/", 1)[-1]) if pr_url else 0
|
|
1713
|
+
return {"number": number, "url": pr_url, "existed": False}
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def get_pr_size(repo: str, number: int) -> int:
|
|
1717
|
+
"""Return additions+deletions for sequencing. 0 on lookup failure."""
|
|
1718
|
+
try:
|
|
1719
|
+
raw = _gh(
|
|
1720
|
+
"pr", "view", str(number), "--repo", repo,
|
|
1721
|
+
"--json", "additions,deletions",
|
|
1722
|
+
)
|
|
1723
|
+
except RuntimeError:
|
|
1724
|
+
return 0
|
|
1725
|
+
data = _json_loads_or(raw, default={})
|
|
1726
|
+
return int(data.get("additions", 0)) + int(data.get("deletions", 0))
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def get_branch_diff_size(repo: str, base: str, branch: str) -> int:
|
|
1730
|
+
"""Return total diff size (additions+deletions) for *branch* relative to *base*.
|
|
1731
|
+
|
|
1732
|
+
Uses the GitHub three-dot compare API — no PR required. Sums
|
|
1733
|
+
``additions + deletions`` across all changed files. Returns ``0`` on any
|
|
1734
|
+
failure so callers can treat size as unknown-but-not-blocking.
|
|
1735
|
+
|
|
1736
|
+
Prefer this over :func:`get_pr_size` at enqueue time so size is populated
|
|
1737
|
+
before a PR is opened and the ordering shown to the user matches the
|
|
1738
|
+
ordering used at merge time (#776 size unification).
|
|
1739
|
+
"""
|
|
1740
|
+
try:
|
|
1741
|
+
raw = _gh("api", f"repos/{repo}/compare/{base}...{branch}")
|
|
1742
|
+
data = json.loads(raw)
|
|
1743
|
+
return sum(
|
|
1744
|
+
int(f.get("additions", 0)) + int(f.get("deletions", 0))
|
|
1745
|
+
for f in data.get("files", [])
|
|
1746
|
+
)
|
|
1747
|
+
except Exception: # noqa: BLE001 — fail-open: unknown size is not blocking
|
|
1748
|
+
return 0
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def merge_pr(repo: str, number: int, method: str = "rebase") -> tuple[bool, str]:
|
|
1752
|
+
"""Merge a PR. Returns (success, message).
|
|
1753
|
+
|
|
1754
|
+
Conflict / not-rebaseable cases come back as (False, <gh stderr>). Caller
|
|
1755
|
+
decides whether to retry or surface to the user — we never resolve conflicts
|
|
1756
|
+
here.
|
|
1757
|
+
"""
|
|
1758
|
+
flag = {"rebase": "--rebase", "squash": "--squash", "merge": "--merge"}.get(method, "--rebase")
|
|
1759
|
+
try:
|
|
1760
|
+
out = _gh("pr", "merge", str(number), "--repo", repo, flag, "--delete-branch=false")
|
|
1761
|
+
except RuntimeError as e:
|
|
1762
|
+
return False, str(e)
|
|
1763
|
+
return True, out
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
def list_open_prs(repo: str) -> list[dict]:
|
|
1767
|
+
return _gh_json(
|
|
1768
|
+
"pr", "list", "--repo", repo, "--state", "open",
|
|
1769
|
+
"--json", "number,title,headRefName",
|
|
1770
|
+
default=[],
|
|
1771
|
+
)
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def get_recent_develop_commits(repo: str, count: int = 10) -> list[dict]:
|
|
1775
|
+
commits = _gh_json(
|
|
1776
|
+
"api", f"repos/{repo}/commits?sha=develop&per_page={count}",
|
|
1777
|
+
default=[],
|
|
1778
|
+
)
|
|
1779
|
+
return [
|
|
1780
|
+
{"sha": c["sha"][:7], "message": c["commit"]["message"].split("\n")[0]}
|
|
1781
|
+
for c in commits
|
|
1782
|
+
]
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
def create_issue(
|
|
1786
|
+
repo: str,
|
|
1787
|
+
title: str,
|
|
1788
|
+
body: str,
|
|
1789
|
+
labels: list[str] | None = None,
|
|
1790
|
+
milestone: str | None = None,
|
|
1791
|
+
) -> dict:
|
|
1792
|
+
args = ["issue", "create", "--repo", repo, "--title", title, "--body", body]
|
|
1793
|
+
if labels:
|
|
1794
|
+
for label in labels:
|
|
1795
|
+
args.extend(["--label", label])
|
|
1796
|
+
if milestone:
|
|
1797
|
+
args.extend(["--milestone", milestone])
|
|
1798
|
+
raw = _gh(*args)
|
|
1799
|
+
url = raw.strip()
|
|
1800
|
+
number = int(url.rstrip("/").rsplit("/", 1)[-1])
|
|
1801
|
+
return {"number": number, "url": url}
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def update_issue_body(repo: str, issue_number: int, body: str) -> None:
|
|
1805
|
+
_gh(
|
|
1806
|
+
"api", "-X", "PATCH",
|
|
1807
|
+
f"repos/{repo}/issues/{issue_number}",
|
|
1808
|
+
"-f", f"body={body}",
|
|
1809
|
+
)
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
def get_repo_milestones(repo: str, *, state: str = "open") -> list[dict]:
|
|
1813
|
+
"""Return milestones for *repo* (open ones by default).
|
|
1814
|
+
|
|
1815
|
+
Each item has at least ``number`` and ``title`` keys, matching the
|
|
1816
|
+
shape returned by the GitHub milestones REST endpoint. Used to resolve a
|
|
1817
|
+
milestone title → number (``coord milestone assign``) without a separate
|
|
1818
|
+
call.
|
|
1819
|
+
"""
|
|
1820
|
+
raw = _gh(
|
|
1821
|
+
"api", "--paginate",
|
|
1822
|
+
f"repos/{repo}/milestones?state={state}",
|
|
1823
|
+
"--jq", ".[] | {number: .number, title: .title}",
|
|
1824
|
+
)
|
|
1825
|
+
# --jq emits one JSON object per line when applied to an array. #1353:
|
|
1826
|
+
# a single malformed line used to bare-json.loads() into an unattributable
|
|
1827
|
+
# crash that discarded every other (well-formed) milestone line too — skip
|
|
1828
|
+
# just the bad line instead.
|
|
1829
|
+
results = []
|
|
1830
|
+
for line in raw.splitlines():
|
|
1831
|
+
line = line.strip()
|
|
1832
|
+
if not line:
|
|
1833
|
+
continue
|
|
1834
|
+
parsed = _json_loads_or(line, default=None)
|
|
1835
|
+
if parsed is not None:
|
|
1836
|
+
results.append(parsed)
|
|
1837
|
+
return results
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def get_milestone(repo: str, milestone_number: int) -> dict:
|
|
1841
|
+
"""Fetch a single milestone by number; returns ``{number, title, ...}``.
|
|
1842
|
+
|
|
1843
|
+
Used to resolve a milestone number → title so the local issues cache
|
|
1844
|
+
``milestone_title`` column can be populated without listing all milestones.
|
|
1845
|
+
Raises RuntimeError (propagated from ``_gh``) when the milestone does not
|
|
1846
|
+
exist.
|
|
1847
|
+
"""
|
|
1848
|
+
return _gh_json("api", f"repos/{repo}/milestones/{milestone_number}", default={})
|
|
1849
|
+
|
|
1850
|
+
|
|
1851
|
+
def get_milestone_issues(
|
|
1852
|
+
repo: str, milestone_title: str, *, state: str = "all"
|
|
1853
|
+
) -> list[dict]:
|
|
1854
|
+
"""Return every issue under *milestone_title* in *repo* (open+closed by default).
|
|
1855
|
+
|
|
1856
|
+
Each item has ``number``, ``title``, ``state`` ("OPEN"/"CLOSED"), and
|
|
1857
|
+
``labels`` (list of ``{"name": ...}``). ``gh issue list --milestone`` takes
|
|
1858
|
+
the milestone TITLE, not its number (unlike most other milestone-related
|
|
1859
|
+
calls in this module) — matches the existing ``--milestone`` usage in
|
|
1860
|
+
:func:`create_issue`. Used by ``--audit-of`` (#885) to enumerate a
|
|
1861
|
+
milestone's issue states for the audit briefing without a separate call
|
|
1862
|
+
per issue.
|
|
1863
|
+
"""
|
|
1864
|
+
return _gh_json(
|
|
1865
|
+
"issue", "list", "--repo", repo, "--milestone", milestone_title,
|
|
1866
|
+
"--state", state, "--json", "number,title,state,labels",
|
|
1867
|
+
"--limit", "200",
|
|
1868
|
+
default=[],
|
|
1869
|
+
)
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
def assign_issue_milestone(
|
|
1873
|
+
repo: str, issue_number: int, milestone_number: int
|
|
1874
|
+
) -> None:
|
|
1875
|
+
"""Assign *milestone_number* to *issue_number* on *repo* via the GitHub API.
|
|
1876
|
+
|
|
1877
|
+
Uses ``gh api -X PATCH`` with ``-F milestone=<int>`` (capital -F so the
|
|
1878
|
+
value is sent as a JSON integer, as GitHub's REST API requires). Raises
|
|
1879
|
+
RuntimeError on any ``gh`` failure.
|
|
1880
|
+
"""
|
|
1881
|
+
_gh(
|
|
1882
|
+
"api", "-X", "PATCH",
|
|
1883
|
+
f"repos/{repo}/issues/{issue_number}",
|
|
1884
|
+
"-F", f"milestone={milestone_number}",
|
|
1885
|
+
)
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
def unassign_issue_milestone(repo: str, issue_number: int) -> None:
|
|
1889
|
+
"""Clear *issue_number*'s milestone on *repo* via the GitHub API (#1003).
|
|
1890
|
+
|
|
1891
|
+
The counterpart to :func:`assign_issue_milestone` — ``-F milestone=null``
|
|
1892
|
+
sends a JSON ``null`` (per ``gh api``'s typed-field convention: literal
|
|
1893
|
+
``null``/``true``/``false``/numbers are sent as their JSON type, not a
|
|
1894
|
+
string), which GitHub's REST API treats as "remove the milestone".
|
|
1895
|
+
Idempotent — clearing an issue that has no milestone is a no-op on
|
|
1896
|
+
GitHub's side. Raises RuntimeError on any ``gh`` failure.
|
|
1897
|
+
"""
|
|
1898
|
+
_gh(
|
|
1899
|
+
"api", "-X", "PATCH",
|
|
1900
|
+
f"repos/{repo}/issues/{issue_number}",
|
|
1901
|
+
"-F", "milestone=null",
|
|
1902
|
+
)
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
def close_pr(repo: str, number: int, *, comment: str | None = None) -> None:
|
|
1906
|
+
"""Close an open PR, optionally posting a comment first.
|
|
1907
|
+
|
|
1908
|
+
Posts *comment* (if given) via ``gh issue comment`` — PRs share the GitHub
|
|
1909
|
+
issue comment stream — then closes the PR via ``gh pr close``. Raises
|
|
1910
|
+
RuntimeError on ``gh`` failure.
|
|
1911
|
+
"""
|
|
1912
|
+
if comment:
|
|
1913
|
+
post_issue_comment(repo, number, comment)
|
|
1914
|
+
_gh("pr", "close", str(number), "--repo", repo)
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
def branch_is_fully_merged(
|
|
1918
|
+
repo: str,
|
|
1919
|
+
branch: str,
|
|
1920
|
+
default_branch: str = "main",
|
|
1921
|
+
) -> bool:
|
|
1922
|
+
"""Return True when *branch* has 0 commits ahead of *default_branch*.
|
|
1923
|
+
|
|
1924
|
+
Uses the GitHub three-dot compare API. Returns False on any error —
|
|
1925
|
+
fail-safe so we never accidentally close a live PR.
|
|
1926
|
+
|
|
1927
|
+
Note: only detects **fast-forward** merges. After a squash or rebase
|
|
1928
|
+
merge the branch's original commits remain "ahead" (different SHAs) even
|
|
1929
|
+
though the work has landed. The ``issue_is_closed`` check is the primary
|
|
1930
|
+
stale-PR signal for those cases.
|
|
1931
|
+
"""
|
|
1932
|
+
if not branch or not default_branch or branch == default_branch:
|
|
1933
|
+
return False
|
|
1934
|
+
try:
|
|
1935
|
+
raw = _gh("api", f"repos/{repo}/compare/{default_branch}...{branch}")
|
|
1936
|
+
cmp = json.loads(raw)
|
|
1937
|
+
return isinstance(cmp, dict) and cmp.get("ahead_by") == 0
|
|
1938
|
+
except Exception: # noqa: BLE001 — fail-safe: keep the PR open on any error
|
|
1939
|
+
return False
|
|
1940
|
+
|
|
1941
|
+
|
|
1942
|
+
def post_pr_review(repo: str, number: int, verdict: str, body: str) -> None:
|
|
1943
|
+
"""Post a PR review via the gh CLI.
|
|
1944
|
+
|
|
1945
|
+
*verdict* must be ``"approve"`` or ``"request-changes"``. Any other value
|
|
1946
|
+
raises :class:`ValueError` before invoking gh.
|
|
1947
|
+
"""
|
|
1948
|
+
if verdict == "approve":
|
|
1949
|
+
flag = "--approve"
|
|
1950
|
+
elif verdict == "request-changes":
|
|
1951
|
+
flag = "--request-changes"
|
|
1952
|
+
else:
|
|
1953
|
+
raise ValueError(f"Invalid review verdict: {verdict!r} (must be 'approve' or 'request-changes')")
|
|
1954
|
+
_gh("pr", "review", str(number), "--repo", repo, flag, "--body", body)
|