@deftai/directive-content 0.55.1 → 0.56.0
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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +13 -3
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +82 -11
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/skills/deft-directive-swarm/SKILL.md +7 -26
- package/skills/deft-directive-sync/SKILL.md +1 -1
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +2 -2
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""pr_wait_mergeable.py -- Resilient cascade automation helper (#1369).
|
|
3
|
+
|
|
4
|
+
Wraps the Wave-2 resilient wait-until-ready helper (`scripts/monitor_pr.py`,
|
|
5
|
+
#1368) and the Layer-3 protected-issue pre-merge link inspector
|
|
6
|
+
(`scripts/pr_check_protected_issues.py`, #701) into a single end-to-end
|
|
7
|
+
cascade automation surface so a swarm monitor can request
|
|
8
|
+
"wait until this PR is mergeable, then merge it" in one invocation without
|
|
9
|
+
hand-rolling the loop.
|
|
10
|
+
|
|
11
|
+
Background
|
|
12
|
+
----------
|
|
13
|
+
The 2026-05-26 #1166 swarm cascade for #1363 + Wave 3 saw the monitor
|
|
14
|
+
babysitting individual PRs because there was no first-class
|
|
15
|
+
"wait-until-ready, then merge" primitive that survived the documented
|
|
16
|
+
Grok Build harness fragility (#1353 / #1366). The Wave-1+2 work made the
|
|
17
|
+
underlying primitives reliable:
|
|
18
|
+
|
|
19
|
+
* ``scripts/_safe_subprocess.py::run_text`` (#1366) -- UTF-8-safe subprocess
|
|
20
|
+
capture; closes the ``Thread-3 (_readerthread) UnicodeDecodeError``
|
|
21
|
+
blind spot on Windows + Grok Build.
|
|
22
|
+
* ``scripts/pr_merge_readiness.py`` (#1368) -- layered fallback chain
|
|
23
|
+
(primary -> fallback1 -> fallback2) with a ``via`` discriminator on
|
|
24
|
+
every JSON response; fallback2 is structurally never CLEAN.
|
|
25
|
+
* ``scripts/monitor_pr.py`` (#1368) -- adaptive 1m/3m/5m cadence loop
|
|
26
|
+
around ``pr_merge_readiness`` that tolerates layered fallbacks and
|
|
27
|
+
exits 0 only on a primary/fallback1 CLEAN verdict.
|
|
28
|
+
|
|
29
|
+
This helper composes those primitives. The flow is strictly:
|
|
30
|
+
|
|
31
|
+
1. **Layer-3 protected-issue link inspection** -- if any ``--protected
|
|
32
|
+
<issue-numbers>`` were supplied, run ``scripts/pr_check_protected_issues.py``
|
|
33
|
+
BEFORE the wait loop. A persistent ``closingIssuesReferences`` link
|
|
34
|
+
to a protected issue is a structural pre-condition failure: it cannot
|
|
35
|
+
be resolved by waiting, so the helper exits 1 (escalation) without
|
|
36
|
+
ever invoking the wait loop or the merge call. This is the
|
|
37
|
+
"exit 1 BEFORE merge call" path the tests pin.
|
|
38
|
+
|
|
39
|
+
2. **Wait until CLEAN** -- delegate to ``scripts/monitor_pr.py``. The
|
|
40
|
+
monitor's exit code maps onto this helper's three-state exit:
|
|
41
|
+
|
|
42
|
+
* monitor exit 0 (PR reached a primary/fallback1 CLEAN) -> proceed to merge
|
|
43
|
+
* monitor exit 1 (poll cap reached without CLEAN) -> helper exit 1
|
|
44
|
+
* monitor exit 2 (gh missing / invalid args) -> helper exit 2
|
|
45
|
+
* monitor exit 3 (PR merged or closed out from under) -> helper exit 0
|
|
46
|
+
when ``merged=True`` (the cascade goal was reached, just by a
|
|
47
|
+
sibling cascade); helper exit 1 when ``state="closed"`` and not
|
|
48
|
+
merged (operator rejected the PR mid-loop, escalate).
|
|
49
|
+
|
|
50
|
+
3. **Squash-merge** -- run
|
|
51
|
+
``gh pr merge <N> --squash --delete-branch --admin`` (per
|
|
52
|
+
``skills/deft-directive-swarm/SKILL.md`` Phase 6 Step 1). The per-PR
|
|
53
|
+
atomic gate ``task pr:merge-ready && gh pr merge`` documented in
|
|
54
|
+
the swarm skill still applies at the merge-time freshness window:
|
|
55
|
+
the wait loop's last CLEAN verdict is at most one poll interval old,
|
|
56
|
+
and the merge call itself is the freshness boundary.
|
|
57
|
+
|
|
58
|
+
Three-state exit (mirrors the rest of the framework's verb scripts):
|
|
59
|
+
|
|
60
|
+
0 -- PR is now merged (either by this helper or by a sibling cascade)
|
|
61
|
+
1 -- timeout or escalation: the PR was not merged. Reasons surfaced
|
|
62
|
+
to stderr include cap-reached (no CLEAN within the cap window),
|
|
63
|
+
protected-issue-link-present (Layer-3 false-positive on
|
|
64
|
+
``closingIssuesReferences``), PR closed without merge, or a
|
|
65
|
+
non-zero exit from ``gh pr merge`` itself.
|
|
66
|
+
2 -- configuration error: ``gh`` missing on the monitor host,
|
|
67
|
+
invalid CLI args, malformed --protected tokens, or any failure
|
|
68
|
+
from a chained script that mapped to config-error semantics.
|
|
69
|
+
|
|
70
|
+
Subprocess capture routes through :func:`scripts._safe_subprocess.run_text`
|
|
71
|
+
per the ``AGENTS.md`` ``## Safe subprocess capture (#1366)`` rule. All
|
|
72
|
+
external subprocess invocations (`monitor_pr.py`, `pr_check_protected_issues.py`,
|
|
73
|
+
`gh pr merge`) are exposed as module-level functions so tests can
|
|
74
|
+
monkey-patch them without hitting the network.
|
|
75
|
+
|
|
76
|
+
Usage
|
|
77
|
+
-----
|
|
78
|
+
|
|
79
|
+
# Minimal -- wait for CLEAN, then merge.
|
|
80
|
+
uv run python scripts/pr_wait_mergeable.py 1370 --repo deftai/directive
|
|
81
|
+
|
|
82
|
+
# Layer-3 protected-issue gate ahead of the wait loop.
|
|
83
|
+
uv run python scripts/pr_wait_mergeable.py 1370 \\
|
|
84
|
+
--repo deftai/directive \\
|
|
85
|
+
--protected 1119,1140
|
|
86
|
+
|
|
87
|
+
# Tune the wait cap and emit a JSON envelope for a parent monitor.
|
|
88
|
+
uv run python scripts/pr_wait_mergeable.py 1370 \\
|
|
89
|
+
--repo deftai/directive \\
|
|
90
|
+
--cap-minutes 45 \\
|
|
91
|
+
--json
|
|
92
|
+
|
|
93
|
+
Exit codes
|
|
94
|
+
----------
|
|
95
|
+
0 -- PR is merged (or already merged on entry)
|
|
96
|
+
1 -- timeout / escalation (PR not merged; reason surfaced to stderr)
|
|
97
|
+
2 -- configuration error (gh missing, invalid args, malformed --protected)
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
from __future__ import annotations
|
|
101
|
+
|
|
102
|
+
import argparse
|
|
103
|
+
import json
|
|
104
|
+
import os
|
|
105
|
+
import subprocess
|
|
106
|
+
import sys
|
|
107
|
+
from dataclasses import dataclass, field
|
|
108
|
+
from pathlib import Path
|
|
109
|
+
from typing import Any
|
|
110
|
+
|
|
111
|
+
# Make sibling scripts importable both when run as __main__ and when imported
|
|
112
|
+
# by tests.
|
|
113
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
114
|
+
|
|
115
|
+
# UTF-8-safe subprocess capture (#1366) -- per AGENTS.md
|
|
116
|
+
# ``## Safe subprocess capture (#1366)``, any new script that captures
|
|
117
|
+
# gh / python subprocess output MUST route the call through this helper.
|
|
118
|
+
from _safe_subprocess import run_text # noqa: E402
|
|
119
|
+
|
|
120
|
+
# ---- Exit codes -------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
EXIT_MERGED = 0
|
|
123
|
+
EXIT_TIMEOUT_OR_ESCALATION = 1
|
|
124
|
+
EXIT_CONFIG_ERROR = 2
|
|
125
|
+
|
|
126
|
+
# ---- Companion script paths -------------------------------------------------
|
|
127
|
+
|
|
128
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
129
|
+
_MONITOR_SCRIPT = _SCRIPTS_DIR / "monitor_pr.py"
|
|
130
|
+
_PROTECTED_SCRIPT = _SCRIPTS_DIR / "pr_check_protected_issues.py"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---- Result envelope --------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class WaitMergeableResult:
|
|
138
|
+
"""Structured outcome of one ``pr_wait_mergeable`` invocation.
|
|
139
|
+
|
|
140
|
+
The envelope mirrors the shape ``scripts/monitor_pr.py`` emits so a
|
|
141
|
+
parent monitor parsing both stdouts sees a familiar field layout.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
pr_number: int
|
|
145
|
+
repo: str | None
|
|
146
|
+
outcome: str # "merged" | "cap-reached" | "pr-closed" |
|
|
147
|
+
# "protected-linked" | "merge-failed" | "config-error"
|
|
148
|
+
exit_code: int
|
|
149
|
+
monitor_result: dict = field(default_factory=dict)
|
|
150
|
+
protected_check: dict = field(default_factory=dict)
|
|
151
|
+
merge_stdout: str = ""
|
|
152
|
+
merge_stderr: str = ""
|
|
153
|
+
error: str | None = None
|
|
154
|
+
|
|
155
|
+
def to_dict(self) -> dict:
|
|
156
|
+
payload: dict[str, Any] = {
|
|
157
|
+
"pr_number": self.pr_number,
|
|
158
|
+
"repo": self.repo,
|
|
159
|
+
"outcome": self.outcome,
|
|
160
|
+
"exit_code": self.exit_code,
|
|
161
|
+
}
|
|
162
|
+
if self.monitor_result:
|
|
163
|
+
payload["monitor_result"] = self.monitor_result
|
|
164
|
+
if self.protected_check:
|
|
165
|
+
payload["protected_check"] = self.protected_check
|
|
166
|
+
if self.merge_stdout:
|
|
167
|
+
payload["merge_stdout"] = self.merge_stdout
|
|
168
|
+
if self.merge_stderr:
|
|
169
|
+
payload["merge_stderr"] = self.merge_stderr
|
|
170
|
+
if self.error is not None:
|
|
171
|
+
payload["error"] = self.error
|
|
172
|
+
return payload
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---- Chained subprocess wrappers --------------------------------------------
|
|
176
|
+
#
|
|
177
|
+
# Each wrapper is a module-level function so tests can monkey-patch the
|
|
178
|
+
# external call without going near a real ``gh`` invocation. The wrappers
|
|
179
|
+
# return uniform ``(returncode, stdout, stderr)`` tuples and route every
|
|
180
|
+
# text capture through ``_safe_subprocess.run_text`` per the #1366
|
|
181
|
+
# AGENTS.md rule.
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def run_protected_check(
|
|
185
|
+
pr_number: int,
|
|
186
|
+
repo: str | None,
|
|
187
|
+
protected: list[int],
|
|
188
|
+
*,
|
|
189
|
+
python_executable: str | None = None,
|
|
190
|
+
timeout: float = 60,
|
|
191
|
+
) -> tuple[int, str, str]:
|
|
192
|
+
"""Invoke ``scripts/pr_check_protected_issues.py`` and return its result.
|
|
193
|
+
|
|
194
|
+
Returns ``(returncode, stdout, stderr)``. Exit 0 means no protected
|
|
195
|
+
link; exit 1 means a protected link is present; exit 2 means an
|
|
196
|
+
external/config error from the inspection. The caller maps these
|
|
197
|
+
onto the helper's three-state exit.
|
|
198
|
+
|
|
199
|
+
``protected`` is the explicit issue-number list. The helper joins it
|
|
200
|
+
with commas onto a single ``--protected`` flag (the underlying
|
|
201
|
+
script supports comma-separated as well as repeated-flag forms; we
|
|
202
|
+
use the comma form for shell-quoting simplicity).
|
|
203
|
+
"""
|
|
204
|
+
python = python_executable or sys.executable
|
|
205
|
+
cmd: list[str] = [
|
|
206
|
+
python,
|
|
207
|
+
str(_PROTECTED_SCRIPT),
|
|
208
|
+
str(pr_number),
|
|
209
|
+
"--protected",
|
|
210
|
+
",".join(str(n) for n in protected),
|
|
211
|
+
]
|
|
212
|
+
if repo:
|
|
213
|
+
cmd.extend(["--repo", repo])
|
|
214
|
+
try:
|
|
215
|
+
result = run_text(cmd, timeout=timeout)
|
|
216
|
+
except FileNotFoundError as exc:
|
|
217
|
+
return -1, "", f"python executable not found: {exc}"
|
|
218
|
+
except subprocess.TimeoutExpired:
|
|
219
|
+
return -1, "", f"protected-issue check timed out after {timeout}s"
|
|
220
|
+
return result.returncode, result.stdout, result.stderr
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def run_monitor(
|
|
224
|
+
pr_number: int,
|
|
225
|
+
repo: str,
|
|
226
|
+
cap_minutes: float,
|
|
227
|
+
*,
|
|
228
|
+
python_executable: str | None = None,
|
|
229
|
+
timeout: float | None = None,
|
|
230
|
+
) -> tuple[int, str, str]:
|
|
231
|
+
"""Invoke ``scripts/monitor_pr.py --json`` and return its result.
|
|
232
|
+
|
|
233
|
+
Returns ``(returncode, stdout, stderr)``. The monitor's three-state
|
|
234
|
+
exit (plus an additional PR-terminal exit 3) is preserved verbatim;
|
|
235
|
+
the caller maps onto the helper's three-state exit.
|
|
236
|
+
|
|
237
|
+
``timeout`` defaults to ``cap_minutes * 60 + 60`` seconds (one
|
|
238
|
+
minute of slack past the monitor's cap so a TimeoutExpired only
|
|
239
|
+
fires when the monitor itself is hung, not when it is mid-cap).
|
|
240
|
+
"""
|
|
241
|
+
python = python_executable or sys.executable
|
|
242
|
+
cmd: list[str] = [
|
|
243
|
+
python,
|
|
244
|
+
str(_MONITOR_SCRIPT),
|
|
245
|
+
str(pr_number),
|
|
246
|
+
"--repo",
|
|
247
|
+
repo,
|
|
248
|
+
"--cap-minutes",
|
|
249
|
+
str(cap_minutes),
|
|
250
|
+
"--json",
|
|
251
|
+
]
|
|
252
|
+
if timeout is None:
|
|
253
|
+
timeout = cap_minutes * 60 + 60
|
|
254
|
+
try:
|
|
255
|
+
result = run_text(cmd, timeout=timeout)
|
|
256
|
+
except FileNotFoundError as exc:
|
|
257
|
+
return -1, "", f"python executable not found: {exc}"
|
|
258
|
+
except subprocess.TimeoutExpired:
|
|
259
|
+
return -1, "", f"monitor_pr timed out after {timeout}s"
|
|
260
|
+
return result.returncode, result.stdout, result.stderr
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def run_gh_merge(
|
|
264
|
+
pr_number: int,
|
|
265
|
+
repo: str | None,
|
|
266
|
+
*,
|
|
267
|
+
timeout: float = 120,
|
|
268
|
+
) -> tuple[int, str, str]:
|
|
269
|
+
"""Invoke ``gh pr merge --squash --delete-branch --admin`` and return result.
|
|
270
|
+
|
|
271
|
+
The merge call is the freshness boundary of the cascade: the wait
|
|
272
|
+
loop's last CLEAN verdict is at most one monitor poll interval old,
|
|
273
|
+
and ``gh pr merge`` fails non-zero if a sibling rebase has landed in
|
|
274
|
+
the elapsed window (which is the per-merge atomic gate the swarm
|
|
275
|
+
skill mandates).
|
|
276
|
+
"""
|
|
277
|
+
cmd: list[str] = [
|
|
278
|
+
"gh",
|
|
279
|
+
"pr",
|
|
280
|
+
"merge",
|
|
281
|
+
str(pr_number),
|
|
282
|
+
"--squash",
|
|
283
|
+
"--delete-branch",
|
|
284
|
+
"--admin",
|
|
285
|
+
]
|
|
286
|
+
if repo:
|
|
287
|
+
cmd.extend(["--repo", repo])
|
|
288
|
+
try:
|
|
289
|
+
result = run_text(cmd, timeout=timeout)
|
|
290
|
+
except FileNotFoundError:
|
|
291
|
+
return -1, "", "gh CLI not found. Install GitHub CLI."
|
|
292
|
+
except subprocess.TimeoutExpired:
|
|
293
|
+
return -1, "", f"gh pr merge timed out after {timeout}s"
|
|
294
|
+
return result.returncode, result.stdout, result.stderr
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---- Argument parsing -------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _parse_protected(values: list[str]) -> list[int]:
|
|
301
|
+
"""Flatten comma-separated and repeated ``--protected`` flags.
|
|
302
|
+
|
|
303
|
+
Mirrors :func:`scripts.pr_check_protected_issues._parse_protected`
|
|
304
|
+
semantics so the helper rejects the same malformed tokens (Unicode
|
|
305
|
+
superscripts, non-decimal junk) and gives the same user-facing
|
|
306
|
+
error rather than letting the underlying script surface its own.
|
|
307
|
+
|
|
308
|
+
Raises :class:`ValueError` on any non-decimal token so the caller
|
|
309
|
+
can map to ``EXIT_CONFIG_ERROR``.
|
|
310
|
+
"""
|
|
311
|
+
out: set[int] = set()
|
|
312
|
+
for chunk in values:
|
|
313
|
+
for tok in chunk.split(","):
|
|
314
|
+
tok = tok.strip().lstrip("#")
|
|
315
|
+
if not tok:
|
|
316
|
+
continue
|
|
317
|
+
# ``isdecimal()`` (vs ``isdigit()``) ONLY matches base-10 0-9 so
|
|
318
|
+
# superscript '\u00b2' is rejected with the actionable error
|
|
319
|
+
# rather than crashing inside int().
|
|
320
|
+
if not tok.isdecimal():
|
|
321
|
+
raise ValueError(f"Invalid protected issue token: {tok!r}")
|
|
322
|
+
out.add(int(tok))
|
|
323
|
+
return sorted(out)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
327
|
+
parser = argparse.ArgumentParser(
|
|
328
|
+
prog="pr_wait_mergeable",
|
|
329
|
+
description=(
|
|
330
|
+
"Resilient cascade automation helper (#1369). Polls "
|
|
331
|
+
"mergeability via scripts/monitor_pr.py (#1368), runs the "
|
|
332
|
+
"Layer-3 protected-issue link inspection (#701) ahead of the "
|
|
333
|
+
"wait loop, and merges with `gh pr merge --squash "
|
|
334
|
+
"--delete-branch --admin` only after the readiness call exits "
|
|
335
|
+
"CLEAN on the current HEAD. Three-state exit: 0 merged, 1 "
|
|
336
|
+
"timeout/escalation, 2 config error."
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
parser.add_argument(
|
|
340
|
+
"pr_number",
|
|
341
|
+
type=int,
|
|
342
|
+
help="Pull request number to wait on and merge.",
|
|
343
|
+
)
|
|
344
|
+
parser.add_argument(
|
|
345
|
+
"--repo",
|
|
346
|
+
default=None,
|
|
347
|
+
metavar="OWNER/REPO",
|
|
348
|
+
help=(
|
|
349
|
+
"Repository in OWNER/REPO form. Defaults to $GH_REPO or the "
|
|
350
|
+
"current checkout's remote."
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
parser.add_argument(
|
|
354
|
+
"--cap-minutes",
|
|
355
|
+
type=float,
|
|
356
|
+
default=60.0,
|
|
357
|
+
help=(
|
|
358
|
+
"Total wall-clock cap for the wait loop in minutes (default: "
|
|
359
|
+
"60). Forwarded to scripts/monitor_pr.py."
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
parser.add_argument(
|
|
363
|
+
"--protected",
|
|
364
|
+
action="append",
|
|
365
|
+
default=[],
|
|
366
|
+
metavar="ISSUE_NUMBERS",
|
|
367
|
+
help=(
|
|
368
|
+
"Comma-separated list of protected (umbrella / staying-OPEN) "
|
|
369
|
+
"issue numbers; may be passed multiple times. Inspected via "
|
|
370
|
+
"scripts/pr_check_protected_issues.py (#701) BEFORE the wait "
|
|
371
|
+
"loop -- a persistent link causes immediate exit 1 with no "
|
|
372
|
+
"merge call."
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
parser.add_argument(
|
|
376
|
+
"--json",
|
|
377
|
+
dest="emit_json",
|
|
378
|
+
action="store_true",
|
|
379
|
+
help=(
|
|
380
|
+
"Emit a structured JSON envelope on stdout summarising the "
|
|
381
|
+
"monitor result, protected-issue check, merge output, and "
|
|
382
|
+
"final outcome."
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
return parser
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ---- Outcome / exit mapping -------------------------------------------------
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _classify_monitor_outcome(
|
|
392
|
+
monitor_returncode: int,
|
|
393
|
+
monitor_payload: dict,
|
|
394
|
+
) -> tuple[str, int]:
|
|
395
|
+
"""Map monitor_pr's exit code onto a helper outcome + exit code.
|
|
396
|
+
|
|
397
|
+
The mapping is intentionally narrow so a future addition to
|
|
398
|
+
monitor_pr's exit table surfaces here as a config error rather than
|
|
399
|
+
silently turning into a merged-claim.
|
|
400
|
+
"""
|
|
401
|
+
if monitor_returncode == 0:
|
|
402
|
+
# CLEAN -- the caller proceeds to the merge call.
|
|
403
|
+
return ("clean", EXIT_MERGED)
|
|
404
|
+
if monitor_returncode == 1:
|
|
405
|
+
return ("cap-reached", EXIT_TIMEOUT_OR_ESCALATION)
|
|
406
|
+
if monitor_returncode == 2:
|
|
407
|
+
return ("config-error", EXIT_CONFIG_ERROR)
|
|
408
|
+
if monitor_returncode == 3:
|
|
409
|
+
# PR-TERMINAL: merged-out-from-under-us or closed-without-merge.
|
|
410
|
+
# Map merged=True -> EXIT_MERGED (cascade goal reached); else
|
|
411
|
+
# treat as escalation (operator rejected the PR mid-loop).
|
|
412
|
+
readiness = (
|
|
413
|
+
monitor_payload.get("readiness", {})
|
|
414
|
+
if isinstance(monitor_payload, dict)
|
|
415
|
+
else {}
|
|
416
|
+
)
|
|
417
|
+
partial = (
|
|
418
|
+
readiness.get("partial_data", {})
|
|
419
|
+
if isinstance(readiness, dict)
|
|
420
|
+
else {}
|
|
421
|
+
)
|
|
422
|
+
if partial.get("merged") is True:
|
|
423
|
+
return ("merged-by-sibling", EXIT_MERGED)
|
|
424
|
+
return ("pr-closed", EXIT_TIMEOUT_OR_ESCALATION)
|
|
425
|
+
# Unknown monitor exit -- treat as config error so it surfaces loudly.
|
|
426
|
+
return ("config-error", EXIT_CONFIG_ERROR)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _parse_monitor_payload(stdout: str) -> dict:
|
|
430
|
+
"""Parse the monitor's --json envelope. Returns ``{}`` on failure."""
|
|
431
|
+
if not stdout or not stdout.strip():
|
|
432
|
+
return {}
|
|
433
|
+
try:
|
|
434
|
+
payload = json.loads(stdout)
|
|
435
|
+
except json.JSONDecodeError:
|
|
436
|
+
return {}
|
|
437
|
+
if isinstance(payload, dict):
|
|
438
|
+
return payload
|
|
439
|
+
return {}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ---- Main orchestration -----------------------------------------------------
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def wait_mergeable_and_merge(
|
|
446
|
+
pr_number: int,
|
|
447
|
+
repo: str,
|
|
448
|
+
*,
|
|
449
|
+
cap_minutes: float,
|
|
450
|
+
protected: list[int],
|
|
451
|
+
protected_fn=None,
|
|
452
|
+
monitor_fn=None,
|
|
453
|
+
merge_fn=None,
|
|
454
|
+
) -> WaitMergeableResult:
|
|
455
|
+
"""Run the protected-check -> wait -> merge cascade.
|
|
456
|
+
|
|
457
|
+
Subprocess wrappers are injected as keyword arguments so tests can
|
|
458
|
+
drive the cascade without spawning real processes. ``None`` (the
|
|
459
|
+
default) resolves the wrapper via :func:`globals` lookup at call
|
|
460
|
+
time so a ``monkeypatch.setattr(pwm, "run_monitor", fake)`` on the
|
|
461
|
+
module attribute reaches the cascade -- binding the function in the
|
|
462
|
+
default value would freeze the reference at function-definition
|
|
463
|
+
time and silently bypass the patch. The function body is the single
|
|
464
|
+
source of truth for the helper's state machine and is exhaustively
|
|
465
|
+
exercised by ``tests/cli/test_pr_wait_mergeable.py``.
|
|
466
|
+
"""
|
|
467
|
+
# Late-bind via the module dict so monkeypatch.setattr on the module
|
|
468
|
+
# attribute takes effect; explicit-kwarg overrides still win.
|
|
469
|
+
protected_fn = protected_fn or globals()["run_protected_check"]
|
|
470
|
+
monitor_fn = monitor_fn or globals()["run_monitor"]
|
|
471
|
+
merge_fn = merge_fn or globals()["run_gh_merge"]
|
|
472
|
+
# --- Step 1: Layer-3 protected-issue link inspection (#701) ----------
|
|
473
|
+
protected_check_payload: dict = {}
|
|
474
|
+
if protected:
|
|
475
|
+
prc_rc, prc_stdout, prc_stderr = protected_fn(pr_number, repo, protected)
|
|
476
|
+
protected_check_payload = {
|
|
477
|
+
"returncode": prc_rc,
|
|
478
|
+
"stdout": prc_stdout,
|
|
479
|
+
"stderr": prc_stderr,
|
|
480
|
+
"protected": list(protected),
|
|
481
|
+
}
|
|
482
|
+
if prc_rc == 1:
|
|
483
|
+
# Persistent link present -- escalation, do NOT run monitor or merge.
|
|
484
|
+
return WaitMergeableResult(
|
|
485
|
+
pr_number=pr_number,
|
|
486
|
+
repo=repo,
|
|
487
|
+
outcome="protected-linked",
|
|
488
|
+
exit_code=EXIT_TIMEOUT_OR_ESCALATION,
|
|
489
|
+
protected_check=protected_check_payload,
|
|
490
|
+
error=(
|
|
491
|
+
"PR has a persistent closingIssuesReferences link to a "
|
|
492
|
+
"protected issue (#701). Unlink via the PR's Development "
|
|
493
|
+
"sidebar before re-running."
|
|
494
|
+
),
|
|
495
|
+
)
|
|
496
|
+
if prc_rc not in (0,):
|
|
497
|
+
# Any non-zero non-1 exit collapses to a config error -- the
|
|
498
|
+
# inspection cannot run, so the gate cannot affirm safety.
|
|
499
|
+
return WaitMergeableResult(
|
|
500
|
+
pr_number=pr_number,
|
|
501
|
+
repo=repo,
|
|
502
|
+
outcome="config-error",
|
|
503
|
+
exit_code=EXIT_CONFIG_ERROR,
|
|
504
|
+
protected_check=protected_check_payload,
|
|
505
|
+
error=(
|
|
506
|
+
f"protected-issue check exited {prc_rc} (config error). "
|
|
507
|
+
f"stderr: {prc_stderr.strip()}"
|
|
508
|
+
),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# --- Step 2: Wait until CLEAN (#1368) --------------------------------
|
|
512
|
+
mon_rc, mon_stdout, mon_stderr = monitor_fn(pr_number, repo, cap_minutes)
|
|
513
|
+
monitor_payload = _parse_monitor_payload(mon_stdout)
|
|
514
|
+
outcome, monitor_exit = _classify_monitor_outcome(mon_rc, monitor_payload)
|
|
515
|
+
|
|
516
|
+
if outcome != "clean":
|
|
517
|
+
# cap-reached, pr-closed, config-error, merged-by-sibling.
|
|
518
|
+
# The merged-by-sibling outcome is a SUCCESS path (exit_code 0)
|
|
519
|
+
# even though it lives in the non-clean branch, so it MUST NOT
|
|
520
|
+
# carry an ``error`` string -- a downstream consumer parsing the
|
|
521
|
+
# JSON envelope sees ``exit_code: 0`` and would treat a non-None
|
|
522
|
+
# ``error`` field as a self-contradiction (Greptile P2 finding
|
|
523
|
+
# on PR #1377).
|
|
524
|
+
if monitor_exit == EXIT_MERGED:
|
|
525
|
+
error_payload: str | None = None
|
|
526
|
+
else:
|
|
527
|
+
error_payload = (
|
|
528
|
+
f"monitor exited {mon_rc} (outcome={outcome}). "
|
|
529
|
+
f"stderr tail: {mon_stderr.strip()[-200:]}"
|
|
530
|
+
if mon_stderr.strip()
|
|
531
|
+
else f"monitor exited {mon_rc} (outcome={outcome})"
|
|
532
|
+
)
|
|
533
|
+
return WaitMergeableResult(
|
|
534
|
+
pr_number=pr_number,
|
|
535
|
+
repo=repo,
|
|
536
|
+
outcome=outcome,
|
|
537
|
+
exit_code=monitor_exit,
|
|
538
|
+
monitor_result=monitor_payload,
|
|
539
|
+
protected_check=protected_check_payload,
|
|
540
|
+
error=error_payload,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# --- Step 3: Squash-merge --------------------------------------------
|
|
544
|
+
merge_rc, merge_stdout, merge_stderr = merge_fn(pr_number, repo)
|
|
545
|
+
if merge_rc == 0:
|
|
546
|
+
return WaitMergeableResult(
|
|
547
|
+
pr_number=pr_number,
|
|
548
|
+
repo=repo,
|
|
549
|
+
outcome="merged",
|
|
550
|
+
exit_code=EXIT_MERGED,
|
|
551
|
+
monitor_result=monitor_payload,
|
|
552
|
+
protected_check=protected_check_payload,
|
|
553
|
+
merge_stdout=merge_stdout,
|
|
554
|
+
merge_stderr=merge_stderr,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# gh pr merge failed. The ``run_gh_merge`` wrapper signals "gh
|
|
558
|
+
# binary missing" (FileNotFoundError) and "gh runtime/IO timeout"
|
|
559
|
+
# by returning ``returncode == -1`` -- these are CONFIGURATION
|
|
560
|
+
# errors (the cascade gate cannot run), NOT merge-time escalations,
|
|
561
|
+
# and MUST surface as EXIT_CONFIG_ERROR per the documented
|
|
562
|
+
# three-state contract so automated callers keying on exit 2 to
|
|
563
|
+
# skip retries do not loop indefinitely (Greptile P1 finding on
|
|
564
|
+
# PR #1377). Mirrors ``run_protected_check``'s rc=-1 path that
|
|
565
|
+
# already collapses to EXIT_CONFIG_ERROR a few lines above.
|
|
566
|
+
if merge_rc == -1:
|
|
567
|
+
return WaitMergeableResult(
|
|
568
|
+
pr_number=pr_number,
|
|
569
|
+
repo=repo,
|
|
570
|
+
outcome="config-error",
|
|
571
|
+
exit_code=EXIT_CONFIG_ERROR,
|
|
572
|
+
monitor_result=monitor_payload,
|
|
573
|
+
protected_check=protected_check_payload,
|
|
574
|
+
merge_stdout=merge_stdout,
|
|
575
|
+
merge_stderr=merge_stderr,
|
|
576
|
+
error=(
|
|
577
|
+
f"gh pr merge wrapper failed at OS layer (rc=-1). "
|
|
578
|
+
f"stderr: {merge_stderr.strip()[-200:]}"
|
|
579
|
+
if merge_stderr.strip()
|
|
580
|
+
else "gh pr merge wrapper failed at OS layer (rc=-1)."
|
|
581
|
+
),
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
# Non-zero non-sentinel exit -- sibling rebase landed in the
|
|
585
|
+
# freshness window, branch-protection refusal, network blip mid-
|
|
586
|
+
# merge, etc. The cascade goal was not reached but a retry MAY
|
|
587
|
+
# succeed; surface as escalation (exit 1).
|
|
588
|
+
return WaitMergeableResult(
|
|
589
|
+
pr_number=pr_number,
|
|
590
|
+
repo=repo,
|
|
591
|
+
outcome="merge-failed",
|
|
592
|
+
exit_code=EXIT_TIMEOUT_OR_ESCALATION,
|
|
593
|
+
monitor_result=monitor_payload,
|
|
594
|
+
protected_check=protected_check_payload,
|
|
595
|
+
merge_stdout=merge_stdout,
|
|
596
|
+
merge_stderr=merge_stderr,
|
|
597
|
+
error=(
|
|
598
|
+
f"gh pr merge exited {merge_rc}. stderr: {merge_stderr.strip()[-200:]}"
|
|
599
|
+
if merge_stderr.strip()
|
|
600
|
+
else f"gh pr merge exited {merge_rc}"
|
|
601
|
+
),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# ---- CLI --------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def main(argv: list[str] | None = None) -> int:
|
|
609
|
+
args = _build_parser().parse_args(argv)
|
|
610
|
+
|
|
611
|
+
# Resolve --repo: explicit flag wins, then $GH_REPO. We do NOT auto-
|
|
612
|
+
# detect from the current checkout here because cascade automation
|
|
613
|
+
# is normally invoked from a non-clone harness (the swarm monitor's
|
|
614
|
+
# working directory may not be a git checkout of the target repo).
|
|
615
|
+
repo = args.repo or os.environ.get("GH_REPO")
|
|
616
|
+
if not repo:
|
|
617
|
+
print(
|
|
618
|
+
"Error: --repo OWNER/REPO is required (or set $GH_REPO).",
|
|
619
|
+
file=sys.stderr,
|
|
620
|
+
)
|
|
621
|
+
return EXIT_CONFIG_ERROR
|
|
622
|
+
|
|
623
|
+
# Flatten --protected before the cascade so a malformed token is a
|
|
624
|
+
# pre-flight config error rather than a mid-cascade surprise.
|
|
625
|
+
try:
|
|
626
|
+
protected = _parse_protected(args.protected)
|
|
627
|
+
except ValueError as exc:
|
|
628
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
629
|
+
return EXIT_CONFIG_ERROR
|
|
630
|
+
|
|
631
|
+
result = wait_mergeable_and_merge(
|
|
632
|
+
pr_number=args.pr_number,
|
|
633
|
+
repo=repo,
|
|
634
|
+
cap_minutes=args.cap_minutes,
|
|
635
|
+
protected=protected,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
summary_label = {
|
|
639
|
+
EXIT_MERGED: "MERGED",
|
|
640
|
+
EXIT_TIMEOUT_OR_ESCALATION: "TIMEOUT-OR-ESCALATION",
|
|
641
|
+
EXIT_CONFIG_ERROR: "CONFIG-ERROR",
|
|
642
|
+
}.get(result.exit_code, "UNKNOWN")
|
|
643
|
+
|
|
644
|
+
# Per-poll status mirror lands on stderr from monitor_pr already; the
|
|
645
|
+
# final verdict goes on stdout so a consumer parsing the cascade
|
|
646
|
+
# output sees the outcome regardless of --json mode.
|
|
647
|
+
print(
|
|
648
|
+
f"[pr_wait_mergeable] PR #{result.pr_number} repo={result.repo} "
|
|
649
|
+
f"result={summary_label} outcome={result.outcome}",
|
|
650
|
+
file=sys.stderr,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
if args.emit_json:
|
|
654
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
655
|
+
else:
|
|
656
|
+
print(f"PR #{result.pr_number} wait-mergeable-and-merge result: {summary_label}")
|
|
657
|
+
print(f" outcome: {result.outcome}")
|
|
658
|
+
if result.error:
|
|
659
|
+
print(f" error: {result.error}")
|
|
660
|
+
if result.merge_stdout.strip():
|
|
661
|
+
print(" merge stdout:")
|
|
662
|
+
for line in result.merge_stdout.strip().splitlines():
|
|
663
|
+
print(f" {line}")
|
|
664
|
+
|
|
665
|
+
return result.exit_code
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
if __name__ == "__main__":
|
|
669
|
+
sys.exit(main())
|