@deftai/directive-content 0.55.2 → 0.56.1
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 +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- 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/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 +1 -1
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""monitor_pr.py -- thin "wait until ready" helper for long-running PR monitors (#1368).
|
|
3
|
+
|
|
4
|
+
Background
|
|
5
|
+
----------
|
|
6
|
+
Long-running monitors during the #1166 swarm cascade looped on
|
|
7
|
+
``scripts/pr_merge_readiness.py`` and went blind for ~15+ minutes when a
|
|
8
|
+
single ``gh`` capture returned empty / malformed stdout under the Grok
|
|
9
|
+
Build harness. The #1366 ``_safe_subprocess.run_text`` helper closes the
|
|
10
|
+
``UnicodeDecodeError`` root cause; #1368 adds a layered fallback chain to
|
|
11
|
+
``pr_merge_readiness.py`` so a *single* gh failure no longer blinds the
|
|
12
|
+
monitor.
|
|
13
|
+
|
|
14
|
+
This script is the consumer-side counterpart -- a small, deterministic
|
|
15
|
+
"wait until the PR is CLEAN" loop that:
|
|
16
|
+
|
|
17
|
+
* sleeps with an **adaptive cadence** (~1 minute on the first few polls,
|
|
18
|
+
then ~3 minutes, then ~5 minutes -- mirroring the cadence prescribed
|
|
19
|
+
in ``skills/deft-directive-review-cycle/SKILL.md`` Step 4 but at the
|
|
20
|
+
longer timescales appropriate for a *monitor* watching a swarm
|
|
21
|
+
cascade, not a single Greptile review pass);
|
|
22
|
+
|
|
23
|
+
* **tolerates fallback responses** -- a ``via="fallback1"`` /
|
|
24
|
+
``via="fallback2"`` / ``via="error"`` result is treated as "keep
|
|
25
|
+
polling", never as a terminal verdict;
|
|
26
|
+
|
|
27
|
+
* **exits 0 only on a primary or fallback1 CLEAN** -- ``via="fallback2"``
|
|
28
|
+
is the coarse last-resort signal (see ``scripts/pr_merge_readiness.py``
|
|
29
|
+
module docstring) and is NEVER a CLEAN verdict.
|
|
30
|
+
|
|
31
|
+
The helper writes one terse status line per poll to stderr so an
|
|
32
|
+
orchestrator's transcript shows progress, and the final verdict to
|
|
33
|
+
stdout (JSON when ``--json`` is passed, human-readable otherwise) so a
|
|
34
|
+
machine-readable summary survives the loop.
|
|
35
|
+
|
|
36
|
+
Subprocess capture routes through :func:`scripts._safe_subprocess.run_text`
|
|
37
|
+
per the ``AGENTS.md`` `## Safe subprocess capture (#1366)` rule.
|
|
38
|
+
|
|
39
|
+
Usage
|
|
40
|
+
-----
|
|
41
|
+
uv run python scripts/monitor_pr.py 1363 --repo deftai/directive
|
|
42
|
+
uv run python scripts/monitor_pr.py 1363 --repo deftai/directive --cap-minutes 30 --json
|
|
43
|
+
|
|
44
|
+
Exit codes
|
|
45
|
+
----------
|
|
46
|
+
0 -- PR reached a primary/fallback1 CLEAN verdict (merge-ready)
|
|
47
|
+
1 -- poll cap reached without a CLEAN verdict
|
|
48
|
+
2 -- configuration error (gh missing on the monitor host, invalid args)
|
|
49
|
+
3 -- PR was merged or closed before reaching CLEAN (terminal lifecycle)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import argparse
|
|
55
|
+
import json
|
|
56
|
+
import os
|
|
57
|
+
import sys
|
|
58
|
+
import time
|
|
59
|
+
from dataclasses import dataclass
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
|
|
62
|
+
# Make sibling scripts importable both when run as __main__ and when imported by tests.
|
|
63
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
64
|
+
|
|
65
|
+
# UTF-8-safe subprocess capture (#1366) -- per AGENTS.md
|
|
66
|
+
# ``## Safe subprocess capture (#1366)``, any new script that captures
|
|
67
|
+
# gh / python subprocess output MUST route the call through this helper.
|
|
68
|
+
from _safe_subprocess import run_text # noqa: E402
|
|
69
|
+
|
|
70
|
+
# ---- Exit codes -------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
EXIT_CLEAN = 0
|
|
73
|
+
EXIT_CAP_REACHED = 1
|
|
74
|
+
EXIT_CONFIG_ERROR = 2
|
|
75
|
+
EXIT_PR_TERMINAL = 3
|
|
76
|
+
|
|
77
|
+
# ---- Adaptive cadence -------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
# Cadence sequence (seconds). The first few polls run at ~1 minute so
|
|
80
|
+
# fast CLEAN exits are caught quickly; the loop then relaxes to ~3 and
|
|
81
|
+
# ~5 minutes per poll because Greptile re-reviews on a rebase cascade
|
|
82
|
+
# routinely take 2-5 minutes per branch and there is no value in
|
|
83
|
+
# polling more frequently than the upstream cadence (see
|
|
84
|
+
# ``skills/deft-directive-swarm/SKILL.md`` Phase 6 Step 1).
|
|
85
|
+
#
|
|
86
|
+
# Tuple shape: ``(interval_seconds, repeats)``. After all entries are
|
|
87
|
+
# consumed the last interval is held indefinitely until the poll cap is
|
|
88
|
+
# reached.
|
|
89
|
+
_DEFAULT_CADENCE: tuple[tuple[int, int], ...] = (
|
|
90
|
+
(60, 3), # 3 polls at ~1 minute
|
|
91
|
+
(180, 3), # 3 polls at ~3 minutes
|
|
92
|
+
(300, 99), # remaining polls at ~5 minutes
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cadence_intervals(
|
|
97
|
+
cadence: tuple[tuple[int, int], ...] = _DEFAULT_CADENCE,
|
|
98
|
+
) -> list[int]:
|
|
99
|
+
"""Expand the cadence tuple into a flat list of per-poll intervals.
|
|
100
|
+
|
|
101
|
+
The last entry's repeat count is treated as a soft ceiling -- once the
|
|
102
|
+
list is exhausted the caller is expected to break out via the
|
|
103
|
+
``--cap-minutes`` total-elapsed limit, not by polling forever.
|
|
104
|
+
"""
|
|
105
|
+
intervals: list[int] = []
|
|
106
|
+
for interval, repeats in cadence:
|
|
107
|
+
intervals.extend([interval] * repeats)
|
|
108
|
+
return intervals
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---- Readiness call ---------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class PollResult:
|
|
116
|
+
"""One poll iteration's outcome."""
|
|
117
|
+
exit_code: int
|
|
118
|
+
payload: dict
|
|
119
|
+
raw_stdout: str
|
|
120
|
+
raw_stderr: str
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _readiness_script_path() -> Path:
|
|
124
|
+
"""Locate ``scripts/pr_merge_readiness.py`` relative to this helper."""
|
|
125
|
+
return Path(__file__).resolve().parent / "pr_merge_readiness.py"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def call_readiness(
|
|
129
|
+
pr_number: int,
|
|
130
|
+
repo: str,
|
|
131
|
+
*,
|
|
132
|
+
python_executable: str | None = None,
|
|
133
|
+
timeout: float = 90,
|
|
134
|
+
) -> PollResult:
|
|
135
|
+
"""Run ``pr_merge_readiness.py --json`` once and parse the verdict.
|
|
136
|
+
|
|
137
|
+
Always returns a :class:`PollResult` -- a transient gh failure becomes
|
|
138
|
+
a ``via="error"`` payload that the caller treats as "keep polling".
|
|
139
|
+
The helper never raises on a ``pr_merge_readiness`` non-zero exit
|
|
140
|
+
because the monitor must be able to step forward through transient
|
|
141
|
+
failures without going blind.
|
|
142
|
+
"""
|
|
143
|
+
python = python_executable or sys.executable
|
|
144
|
+
cmd = [
|
|
145
|
+
python,
|
|
146
|
+
str(_readiness_script_path()),
|
|
147
|
+
str(pr_number),
|
|
148
|
+
"--repo",
|
|
149
|
+
repo,
|
|
150
|
+
"--json",
|
|
151
|
+
]
|
|
152
|
+
try:
|
|
153
|
+
result = run_text(cmd, timeout=timeout)
|
|
154
|
+
except FileNotFoundError as exc:
|
|
155
|
+
return PollResult(
|
|
156
|
+
exit_code=EXIT_CONFIG_ERROR,
|
|
157
|
+
payload={
|
|
158
|
+
"via": "error",
|
|
159
|
+
"merge_ready": False,
|
|
160
|
+
"error": f"python executable not found: {exc}",
|
|
161
|
+
},
|
|
162
|
+
raw_stdout="",
|
|
163
|
+
raw_stderr=str(exc),
|
|
164
|
+
)
|
|
165
|
+
except Exception as exc: # pragma: no cover -- timeout / OS-level errors
|
|
166
|
+
return PollResult(
|
|
167
|
+
exit_code=EXIT_CONFIG_ERROR,
|
|
168
|
+
payload={
|
|
169
|
+
"via": "error",
|
|
170
|
+
"merge_ready": False,
|
|
171
|
+
"error": f"unexpected exception running readiness: {exc}",
|
|
172
|
+
},
|
|
173
|
+
raw_stdout="",
|
|
174
|
+
raw_stderr=str(exc),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
payload: dict
|
|
178
|
+
if result.stdout.strip():
|
|
179
|
+
try:
|
|
180
|
+
payload = json.loads(result.stdout)
|
|
181
|
+
except json.JSONDecodeError:
|
|
182
|
+
payload = {
|
|
183
|
+
"via": "error",
|
|
184
|
+
"merge_ready": False,
|
|
185
|
+
"error": "pr_merge_readiness emitted non-JSON stdout",
|
|
186
|
+
"raw_stdout_excerpt": result.stdout[:200],
|
|
187
|
+
}
|
|
188
|
+
else:
|
|
189
|
+
payload = {
|
|
190
|
+
"via": "error",
|
|
191
|
+
"merge_ready": False,
|
|
192
|
+
"error": "pr_merge_readiness emitted empty stdout",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return PollResult(
|
|
196
|
+
exit_code=result.returncode,
|
|
197
|
+
payload=payload,
|
|
198
|
+
raw_stdout=result.stdout,
|
|
199
|
+
raw_stderr=result.stderr,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---- Loop -------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _format_poll_status(poll_index: int, poll_result: PollResult) -> str:
|
|
207
|
+
"""One-line stderr status mirror per poll."""
|
|
208
|
+
payload = poll_result.payload
|
|
209
|
+
via = payload.get("via", "?")
|
|
210
|
+
merge_ready = payload.get("merge_ready", False)
|
|
211
|
+
head_sha = payload.get("head_sha") or "<unknown>"
|
|
212
|
+
if isinstance(head_sha, str):
|
|
213
|
+
head_sha = head_sha[:12]
|
|
214
|
+
failures = payload.get("failures") or []
|
|
215
|
+
first_failure = failures[0] if failures else ""
|
|
216
|
+
label = "CLEAN" if merge_ready else "BLOCKED"
|
|
217
|
+
return (
|
|
218
|
+
f"[monitor_pr] poll #{poll_index} via={via} head={head_sha} "
|
|
219
|
+
f"{label} ({len(failures)} failures)"
|
|
220
|
+
+ (f" -- {first_failure[:80]}" if first_failure else "")
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _is_terminal_pr_state(payload: dict) -> bool:
|
|
225
|
+
"""Detect a merged / closed PR via the fallback2 partial_data.
|
|
226
|
+
|
|
227
|
+
Fallback2 carries ``pr_state`` and ``merged`` so a monitor that hits
|
|
228
|
+
the coarse-signal layer after the PR was merged out from under it
|
|
229
|
+
can exit cleanly rather than waiting on a CLEAN that will never
|
|
230
|
+
come.
|
|
231
|
+
"""
|
|
232
|
+
partial = payload.get("partial_data") or {}
|
|
233
|
+
# A closed-but-not-merged PR is also terminal -- the monitor cannot reach
|
|
234
|
+
# CLEAN on a PR that the operator has rejected, so collapse both cases
|
|
235
|
+
# into a single boolean expression (#1368 follow-up: ruff SIM103).
|
|
236
|
+
return partial.get("merged") is True or partial.get("pr_state") == "closed"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def monitor(
|
|
240
|
+
pr_number: int,
|
|
241
|
+
repo: str,
|
|
242
|
+
*,
|
|
243
|
+
cap_minutes: float = 60,
|
|
244
|
+
cadence: tuple[tuple[int, int], ...] = _DEFAULT_CADENCE,
|
|
245
|
+
sleep_fn=time.sleep,
|
|
246
|
+
clock_fn=time.monotonic,
|
|
247
|
+
call_readiness_fn=call_readiness,
|
|
248
|
+
) -> tuple[int, dict, int]:
|
|
249
|
+
"""Loop ``pr_merge_readiness`` with adaptive cadence until CLEAN / cap / terminal.
|
|
250
|
+
|
|
251
|
+
Returns ``(exit_code, last_payload, poll_count)``. ``last_payload`` is
|
|
252
|
+
the final ``pr_merge_readiness`` JSON envelope so callers can attach
|
|
253
|
+
diagnostics to their own report.
|
|
254
|
+
|
|
255
|
+
``sleep_fn``, ``clock_fn``, and ``call_readiness_fn`` are injected for
|
|
256
|
+
tests so the loop runs in fake-time without real ``time.sleep`` cost.
|
|
257
|
+
"""
|
|
258
|
+
intervals = _cadence_intervals(cadence)
|
|
259
|
+
cap_seconds = cap_minutes * 60
|
|
260
|
+
started_at = clock_fn()
|
|
261
|
+
poll_index = 0
|
|
262
|
+
last_payload: dict = {}
|
|
263
|
+
last_exit = EXIT_CAP_REACHED
|
|
264
|
+
|
|
265
|
+
for interval in intervals:
|
|
266
|
+
poll_index += 1
|
|
267
|
+
elapsed = clock_fn() - started_at
|
|
268
|
+
if elapsed > cap_seconds:
|
|
269
|
+
return EXIT_CAP_REACHED, last_payload, poll_index - 1
|
|
270
|
+
|
|
271
|
+
poll_result = call_readiness_fn(pr_number, repo)
|
|
272
|
+
last_payload = poll_result.payload
|
|
273
|
+
last_exit = poll_result.exit_code
|
|
274
|
+
|
|
275
|
+
# Mirror per-poll status to stderr so the orchestrator transcript
|
|
276
|
+
# shows progress without parsing the final JSON envelope.
|
|
277
|
+
print(_format_poll_status(poll_index, poll_result), file=sys.stderr)
|
|
278
|
+
if poll_result.raw_stderr.strip():
|
|
279
|
+
# Surface the readiness script's stderr verbatim -- it carries
|
|
280
|
+
# the gh error message a downstream operator may need.
|
|
281
|
+
sys.stderr.write(poll_result.raw_stderr)
|
|
282
|
+
|
|
283
|
+
via = last_payload.get("via")
|
|
284
|
+
merge_ready = bool(last_payload.get("merge_ready"))
|
|
285
|
+
|
|
286
|
+
# Authoritative CLEAN exit -- only primary or fallback1 can fire.
|
|
287
|
+
if merge_ready and via in ("primary", "fallback1"):
|
|
288
|
+
return EXIT_CLEAN, last_payload, poll_index
|
|
289
|
+
|
|
290
|
+
# Terminal lifecycle -- PR merged or closed out from under us.
|
|
291
|
+
if _is_terminal_pr_state(last_payload):
|
|
292
|
+
return EXIT_PR_TERMINAL, last_payload, poll_index
|
|
293
|
+
|
|
294
|
+
# Otherwise: keep polling. fallback2 + error are NOT terminal --
|
|
295
|
+
# the monitor steps forward until the cap.
|
|
296
|
+
if poll_index < len(intervals):
|
|
297
|
+
# Time-budget guard before sleeping: if the next sleep would
|
|
298
|
+
# push us past the cap, return the current verdict now.
|
|
299
|
+
elapsed_after_poll = clock_fn() - started_at
|
|
300
|
+
remaining = cap_seconds - elapsed_after_poll
|
|
301
|
+
if remaining <= 0:
|
|
302
|
+
return EXIT_CAP_REACHED, last_payload, poll_index
|
|
303
|
+
sleep_fn(min(interval, max(1, int(remaining))))
|
|
304
|
+
|
|
305
|
+
final_exit = (
|
|
306
|
+
last_exit if last_exit == EXIT_CONFIG_ERROR else EXIT_CAP_REACHED
|
|
307
|
+
)
|
|
308
|
+
return final_exit, last_payload, poll_index
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---- CLI --------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
315
|
+
parser = argparse.ArgumentParser(
|
|
316
|
+
prog="monitor_pr",
|
|
317
|
+
description=(
|
|
318
|
+
"Wait-until-ready helper for long-running PR monitors. "
|
|
319
|
+
"Loops pr_merge_readiness.py with adaptive cadence (~1m -> 3m -> "
|
|
320
|
+
"5m) until the PR reaches a primary/fallback1 CLEAN verdict, "
|
|
321
|
+
"the poll cap is reached, or the PR is merged/closed. "
|
|
322
|
+
"Tolerates fallback responses; never exits CLEAN on fallback2."
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
parser.add_argument("pr_number", type=int, help="Pull request number to watch.")
|
|
326
|
+
parser.add_argument(
|
|
327
|
+
"--repo",
|
|
328
|
+
default=None,
|
|
329
|
+
metavar="OWNER/REPO",
|
|
330
|
+
help="Repository in OWNER/REPO form. Defaults to $GH_REPO or current checkout.",
|
|
331
|
+
)
|
|
332
|
+
parser.add_argument(
|
|
333
|
+
"--cap-minutes",
|
|
334
|
+
type=float,
|
|
335
|
+
default=60.0,
|
|
336
|
+
help="Total wall-clock cap for the monitor in minutes (default: 60).",
|
|
337
|
+
)
|
|
338
|
+
parser.add_argument(
|
|
339
|
+
"--json",
|
|
340
|
+
dest="emit_json",
|
|
341
|
+
action="store_true",
|
|
342
|
+
help="Emit the final pr_merge_readiness payload as JSON on stdout.",
|
|
343
|
+
)
|
|
344
|
+
return parser
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def main(argv: list[str] | None = None) -> int:
|
|
348
|
+
args = _build_parser().parse_args(argv)
|
|
349
|
+
|
|
350
|
+
repo = args.repo or os.environ.get("GH_REPO")
|
|
351
|
+
if not repo:
|
|
352
|
+
print(
|
|
353
|
+
"Error: --repo OWNER/REPO is required (or set $GH_REPO).",
|
|
354
|
+
file=sys.stderr,
|
|
355
|
+
)
|
|
356
|
+
return EXIT_CONFIG_ERROR
|
|
357
|
+
|
|
358
|
+
exit_code, payload, poll_count = monitor(
|
|
359
|
+
pr_number=args.pr_number,
|
|
360
|
+
repo=repo,
|
|
361
|
+
cap_minutes=args.cap_minutes,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
summary_label = {
|
|
365
|
+
EXIT_CLEAN: "CLEAN",
|
|
366
|
+
EXIT_CAP_REACHED: "CAP-REACHED",
|
|
367
|
+
EXIT_PR_TERMINAL: "PR-TERMINAL",
|
|
368
|
+
EXIT_CONFIG_ERROR: "CONFIG-ERROR",
|
|
369
|
+
}.get(exit_code, "UNKNOWN")
|
|
370
|
+
print(
|
|
371
|
+
f"[monitor_pr] PR #{args.pr_number} repo={repo} result={summary_label} "
|
|
372
|
+
f"polls={poll_count} via={payload.get('via', '?')}",
|
|
373
|
+
file=sys.stderr,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
if args.emit_json:
|
|
377
|
+
# Wrap the readiness payload with monitor-level context so a
|
|
378
|
+
# consumer parsing the stdout sees both the verdict and the
|
|
379
|
+
# monitor outcome in one envelope.
|
|
380
|
+
envelope = {
|
|
381
|
+
"monitor_result": summary_label,
|
|
382
|
+
"polls": poll_count,
|
|
383
|
+
"readiness": payload,
|
|
384
|
+
}
|
|
385
|
+
print(json.dumps(envelope, indent=2))
|
|
386
|
+
else:
|
|
387
|
+
# Plain-text summary for human consumption.
|
|
388
|
+
print(f"PR #{args.pr_number} monitor result: {summary_label}")
|
|
389
|
+
print(f" polls: {poll_count}")
|
|
390
|
+
print(f" via: {payload.get('via', '?')}")
|
|
391
|
+
if payload.get("error"):
|
|
392
|
+
print(f" error: {payload['error']}")
|
|
393
|
+
if payload.get("failures"):
|
|
394
|
+
for i, fail in enumerate(payload["failures"], 1):
|
|
395
|
+
print(f" [{i}] {fail}")
|
|
396
|
+
|
|
397
|
+
return exit_code
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if __name__ == "__main__":
|
|
401
|
+
sys.exit(main())
|