jekyll-theme-zer0 1.22.0 → 1.24.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/_data/ai.yml +10 -0
- data/_data/backlog.yml +65 -37
- data/_data/features.yml +847 -34
- data/_data/navigation/docs.yml +4 -0
- data/_data/theme_backgrounds.yml +24 -0
- data/_includes/components/component-showcase.html +77 -16
- data/_includes/components/feature-card.html +15 -0
- data/_includes/components/search-modal.html +28 -4
- data/_includes/components/zer0-env-var.html +7 -1
- data/_includes/core/color-mode-init.html +35 -0
- data/_includes/core/head.html +9 -0
- data/_includes/core/tokens-inline.html +5 -0
- data/_layouts/author.html +22 -1
- data/_layouts/root.html +15 -1
- data/scripts/bin/validate +18 -1
- data/scripts/ci/classify_changes.py +129 -0
- data/scripts/issues/dispatch.py +478 -0
- data/scripts/issues/test_verify_close.py +118 -0
- data/scripts/issues/triage.py +1046 -0
- data/scripts/issues/verify_close.py +179 -0
- data/scripts/validate-features.rb +142 -0
- metadata +10 -2
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
dispatch.py — Issue Autopilot OODA controller / budget gate.
|
|
4
|
+
|
|
5
|
+
Decides what the autopilot should actually act on THIS run, clamped to human
|
|
6
|
+
review speed. It reads the plan produced by triage.py (.issues/plan.json) and the
|
|
7
|
+
backpressure caps (.issues/budget.yml), OBSERVES how many auto:issue PRs are
|
|
8
|
+
already open, ORIENTS the plan's batches into triage-only vs PR-opening, and
|
|
9
|
+
DECIDES how many resolution PRs may be proposed without burying the reviewer.
|
|
10
|
+
|
|
11
|
+
Loop (OODA):
|
|
12
|
+
OBSERVE count open PRs labeled `auto:issue` (via gh).
|
|
13
|
+
ORIENT split plan batches into triage (close/decompose/needs-human; no PR)
|
|
14
|
+
and resolve (resolve-content/resolve-code; opens a PR).
|
|
15
|
+
DECIDE if open auto:issue PRs >= caps.max_open_prs -> backlog_heavy: open
|
|
16
|
+
nothing. Else fill remaining slots, capped by
|
|
17
|
+
max_resolve_batches_per_run; the rest are deferred (with reasons).
|
|
18
|
+
ACT (elsewhere) — this script only emits the dispatch plan.
|
|
19
|
+
|
|
20
|
+
---------------------------------------------------------------------------
|
|
21
|
+
READ / PLAN ONLY. This script NEVER mutates GitHub. It does not open, close,
|
|
22
|
+
comment on, or merge anything. It only reads (gh pr list) and writes
|
|
23
|
+
.issues/dispatch.json plus an optional GitHub Actions matrix line. The
|
|
24
|
+
workflow/agents ACT on this output.
|
|
25
|
+
---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
FAIL-SAFE: if plan.json is missing OR older than caps.plan_max_age_minutes,
|
|
28
|
+
the resolve list is empty, a ::warning:: is printed, and we exit 0.
|
|
29
|
+
|
|
30
|
+
Author: IT-Journey Team | Part of the Issue Autopilot foundation.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import json
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any, Optional
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
import yaml
|
|
44
|
+
except ImportError: # pragma: no cover - environment guard
|
|
45
|
+
print("ERROR: PyYAML required. pip install pyyaml", file=sys.stderr)
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
# --------------------------------------------------------------------------- #
|
|
49
|
+
# Paths
|
|
50
|
+
# --------------------------------------------------------------------------- #
|
|
51
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
52
|
+
REPO_ROOT = SCRIPT_DIR.parent.parent
|
|
53
|
+
ISSUES_DIR = REPO_ROOT / ".issues"
|
|
54
|
+
DEFAULT_PLAN = ISSUES_DIR / "plan.json"
|
|
55
|
+
BUDGET_PATH = ISSUES_DIR / "budget.yml"
|
|
56
|
+
CONFIG_PATH = ISSUES_DIR / "config.yml"
|
|
57
|
+
|
|
58
|
+
# Actions that open a PR (resolve lane) vs triage-only (no new PR).
|
|
59
|
+
RESOLVE_ACTIONS = {"resolve-content", "resolve-code"}
|
|
60
|
+
|
|
61
|
+
# The PR label the autopilot owns (fallback if config is unreadable).
|
|
62
|
+
DEFAULT_AUTO_ISSUE_LABEL = "auto:issue"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------------------------------- #
|
|
66
|
+
# Small utilities
|
|
67
|
+
# --------------------------------------------------------------------------- #
|
|
68
|
+
def warn(msg: str) -> None:
|
|
69
|
+
"""Emit a GitHub-Actions-style warning that is harmless in a plain shell."""
|
|
70
|
+
print(f"::warning::{msg}", file=sys.stderr)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def now_iso() -> str:
|
|
74
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_yaml(path: Path) -> dict[str, Any]:
|
|
78
|
+
"""Load a YAML mapping with a clear error on parse failure."""
|
|
79
|
+
try:
|
|
80
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
81
|
+
data = yaml.safe_load(fh) or {}
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
raise
|
|
84
|
+
except yaml.YAMLError as exc:
|
|
85
|
+
print(f"ERROR: could not parse {path}: {exc}", file=sys.stderr)
|
|
86
|
+
raise
|
|
87
|
+
if not isinstance(data, dict):
|
|
88
|
+
raise ValueError(f"{path} root must be a mapping, got {type(data).__name__}")
|
|
89
|
+
return data
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_plan(path: Path) -> Optional[dict[str, Any]]:
|
|
93
|
+
"""Load plan.json. Returns None (with a warning) if missing/unparseable."""
|
|
94
|
+
try:
|
|
95
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
96
|
+
data = json.load(fh)
|
|
97
|
+
except FileNotFoundError:
|
|
98
|
+
warn(f"plan not found at {path}; nothing to dispatch")
|
|
99
|
+
return None
|
|
100
|
+
except json.JSONDecodeError as exc:
|
|
101
|
+
warn(f"plan at {path} is not valid JSON ({exc}); nothing to dispatch")
|
|
102
|
+
return None
|
|
103
|
+
if not isinstance(data, dict):
|
|
104
|
+
warn(f"plan at {path} is not a JSON object; nothing to dispatch")
|
|
105
|
+
return None
|
|
106
|
+
return data
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def auto_issue_label(config: Optional[dict[str, Any]]) -> str:
|
|
110
|
+
"""Resolve the auto:issue PR label from config, with a safe default."""
|
|
111
|
+
if not config:
|
|
112
|
+
return DEFAULT_AUTO_ISSUE_LABEL
|
|
113
|
+
labels = config.get("labels") or {}
|
|
114
|
+
return str(labels.get("pr") or DEFAULT_AUTO_ISSUE_LABEL)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --------------------------------------------------------------------------- #
|
|
118
|
+
# OBSERVE
|
|
119
|
+
# --------------------------------------------------------------------------- #
|
|
120
|
+
def count_open_auto_prs(repo: str, label: str) -> Optional[int]:
|
|
121
|
+
"""
|
|
122
|
+
Count currently-open PRs carrying the auto:issue label. Returns None if gh is
|
|
123
|
+
unavailable/errors (caller treats None as "unknown" and stays conservative).
|
|
124
|
+
"""
|
|
125
|
+
cmd = [
|
|
126
|
+
"gh", "pr", "list",
|
|
127
|
+
"--repo", repo,
|
|
128
|
+
"--state", "open",
|
|
129
|
+
"--label", label,
|
|
130
|
+
"--json", "number",
|
|
131
|
+
]
|
|
132
|
+
try:
|
|
133
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
134
|
+
except (OSError, FileNotFoundError) as exc:
|
|
135
|
+
warn(f"gh unavailable counting open PRs ({exc}); treating as unknown")
|
|
136
|
+
return None
|
|
137
|
+
if proc.returncode != 0:
|
|
138
|
+
warn(
|
|
139
|
+
f"gh pr list exited {proc.returncode}: "
|
|
140
|
+
f"{proc.stderr.strip() or '(no stderr)'}; treating as unknown"
|
|
141
|
+
)
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
prs = json.loads(proc.stdout or "[]")
|
|
145
|
+
except json.JSONDecodeError as exc:
|
|
146
|
+
warn(f"could not parse gh pr list JSON ({exc}); treating as unknown")
|
|
147
|
+
return None
|
|
148
|
+
if not isinstance(prs, list):
|
|
149
|
+
return None
|
|
150
|
+
return len(prs)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --------------------------------------------------------------------------- #
|
|
154
|
+
# ORIENT
|
|
155
|
+
# --------------------------------------------------------------------------- #
|
|
156
|
+
def orient_batches(
|
|
157
|
+
plan: dict[str, Any],
|
|
158
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
159
|
+
"""
|
|
160
|
+
Split plan batches into (triage_batches, resolve_batches).
|
|
161
|
+
|
|
162
|
+
triage = close-* / decompose / needs-human (no new PR).
|
|
163
|
+
resolve = resolve-content / resolve-code (opens a PR).
|
|
164
|
+
Already-deferred resolve batches stay in resolve so the dispatcher can
|
|
165
|
+
re-evaluate them, but they are eligible for re-deferral below.
|
|
166
|
+
"""
|
|
167
|
+
triage: list[dict[str, Any]] = []
|
|
168
|
+
resolve: list[dict[str, Any]] = []
|
|
169
|
+
for batch in plan.get("batches") or []:
|
|
170
|
+
if not isinstance(batch, dict):
|
|
171
|
+
continue
|
|
172
|
+
if batch.get("action") in RESOLVE_ACTIONS:
|
|
173
|
+
resolve.append(batch)
|
|
174
|
+
else:
|
|
175
|
+
triage.append(batch)
|
|
176
|
+
return triage, resolve
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------------------------- #
|
|
180
|
+
# DECIDE
|
|
181
|
+
# --------------------------------------------------------------------------- #
|
|
182
|
+
def plan_is_stale(plan: dict[str, Any], max_age_minutes: int) -> bool:
|
|
183
|
+
"""True if plan.generated is older than max_age_minutes (or unparseable)."""
|
|
184
|
+
if not max_age_minutes:
|
|
185
|
+
return False
|
|
186
|
+
generated = plan.get("generated")
|
|
187
|
+
if not generated:
|
|
188
|
+
warn("plan has no `generated` timestamp; treating as stale")
|
|
189
|
+
return True
|
|
190
|
+
raw = str(generated)
|
|
191
|
+
if raw.endswith("Z"): # normalize a 'Z' suffix (fromisoformat pre-3.11 can't)
|
|
192
|
+
raw = raw[:-1] + "+00:00"
|
|
193
|
+
try:
|
|
194
|
+
ts = datetime.fromisoformat(raw)
|
|
195
|
+
except ValueError:
|
|
196
|
+
warn(f"plan `generated` ({generated!r}) is unparseable; treating as stale")
|
|
197
|
+
return True
|
|
198
|
+
if ts.tzinfo is None:
|
|
199
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
200
|
+
age_minutes = (datetime.now(timezone.utc) - ts).total_seconds() / 60.0
|
|
201
|
+
return age_minutes > max_age_minutes
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def decide(
|
|
205
|
+
plan: dict[str, Any],
|
|
206
|
+
budget: dict[str, Any],
|
|
207
|
+
open_prs: Optional[int],
|
|
208
|
+
) -> dict[str, Any]:
|
|
209
|
+
"""
|
|
210
|
+
Core OODA decision. Returns the dispatch dict (without `generated`/matrix).
|
|
211
|
+
Never raises on policy; degrades to an empty resolve list when in doubt.
|
|
212
|
+
"""
|
|
213
|
+
caps = budget.get("caps") or {}
|
|
214
|
+
max_open_prs = int(caps.get("max_open_prs", 5) or 0)
|
|
215
|
+
max_resolve = int(caps.get("max_resolve_batches_per_run", 3) or 0)
|
|
216
|
+
|
|
217
|
+
triage_batches, resolve_batches = orient_batches(plan)
|
|
218
|
+
triage_ids = [b.get("id") for b in triage_batches]
|
|
219
|
+
|
|
220
|
+
deferred: list[dict[str, Any]] = []
|
|
221
|
+
|
|
222
|
+
# If we couldn't observe the PR queue, stay conservative: open nothing.
|
|
223
|
+
if open_prs is None:
|
|
224
|
+
warn("open auto:issue PR count unknown; opening no resolution PRs this run")
|
|
225
|
+
for b in resolve_batches:
|
|
226
|
+
deferred.append({
|
|
227
|
+
"id": b.get("id"),
|
|
228
|
+
"reason": "open-PR count unknown (gh unavailable); conservative no-op",
|
|
229
|
+
})
|
|
230
|
+
return {
|
|
231
|
+
"open_auto_issue_prs": None,
|
|
232
|
+
"triage": triage_ids,
|
|
233
|
+
"resolve": [],
|
|
234
|
+
"deferred": deferred,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# Backlog-heavy backpressure: at/over cap → open nothing new.
|
|
238
|
+
if max_open_prs and open_prs >= max_open_prs:
|
|
239
|
+
warn(
|
|
240
|
+
f"backlog_heavy: {open_prs} open auto:issue PR(s) >= "
|
|
241
|
+
f"max_open_prs={max_open_prs}; opening no new resolution PRs"
|
|
242
|
+
)
|
|
243
|
+
for b in resolve_batches:
|
|
244
|
+
deferred.append({
|
|
245
|
+
"id": b.get("id"),
|
|
246
|
+
"reason": (
|
|
247
|
+
f"backlog_heavy: {open_prs} open auto:issue PRs "
|
|
248
|
+
f">= max_open_prs={max_open_prs}"
|
|
249
|
+
),
|
|
250
|
+
})
|
|
251
|
+
return {
|
|
252
|
+
"open_auto_issue_prs": open_prs,
|
|
253
|
+
"triage": triage_ids,
|
|
254
|
+
"resolve": [],
|
|
255
|
+
"deferred": deferred,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
# Normal: fill remaining slots, capped by max_resolve_batches_per_run.
|
|
259
|
+
# max_open_prs == 0 means "no PR-backpressure limit" (consistent with
|
|
260
|
+
# max_resolve == 0 meaning "no per-run cap") rather than "open nothing".
|
|
261
|
+
available_slots = (max(0, max_open_prs - open_prs) if max_open_prs
|
|
262
|
+
else len(resolve_batches))
|
|
263
|
+
take = min(available_slots, max_resolve, len(resolve_batches)) if max_resolve \
|
|
264
|
+
else min(available_slots, len(resolve_batches))
|
|
265
|
+
|
|
266
|
+
resolve_selected = resolve_batches[:take]
|
|
267
|
+
for idx, b in enumerate(resolve_batches[take:], start=take):
|
|
268
|
+
if idx >= available_slots:
|
|
269
|
+
reason = (
|
|
270
|
+
f"no free slot: available_slots="
|
|
271
|
+
f"{available_slots} (max_open_prs={max_open_prs} - "
|
|
272
|
+
f"open={open_prs})"
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
reason = (
|
|
276
|
+
f"exceeds max_resolve_batches_per_run={max_resolve} this run"
|
|
277
|
+
)
|
|
278
|
+
deferred.append({"id": b.get("id"), "reason": reason})
|
|
279
|
+
|
|
280
|
+
if deferred:
|
|
281
|
+
warn(
|
|
282
|
+
f"deferred {len(deferred)} resolve batch(es) this run "
|
|
283
|
+
f"(open={open_prs}, slots={available_slots}, cap={max_resolve})"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
"open_auto_issue_prs": open_prs,
|
|
288
|
+
"triage": triage_ids,
|
|
289
|
+
"resolve": resolve_selected,
|
|
290
|
+
"deferred": deferred,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# --------------------------------------------------------------------------- #
|
|
295
|
+
# EMIT
|
|
296
|
+
# --------------------------------------------------------------------------- #
|
|
297
|
+
def build_matrix(resolve_batches: list[dict[str, Any]]) -> dict[str, Any]:
|
|
298
|
+
"""
|
|
299
|
+
Build a GitHub Actions matrix include-list for the selected resolve batches.
|
|
300
|
+
issue_numbers is a comma-joined string so each matrix entry is a clean scalar.
|
|
301
|
+
Always returns a valid object; empty selection -> {"include": []}.
|
|
302
|
+
"""
|
|
303
|
+
include: list[dict[str, Any]] = []
|
|
304
|
+
for b in resolve_batches:
|
|
305
|
+
numbers = b.get("issue_numbers") or []
|
|
306
|
+
include.append({
|
|
307
|
+
"batch_id": b.get("id"),
|
|
308
|
+
"action": b.get("action"),
|
|
309
|
+
"area": b.get("area"),
|
|
310
|
+
"issue_numbers": ",".join(str(n) for n in numbers),
|
|
311
|
+
})
|
|
312
|
+
return {"include": include}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def append_github_output(path: Path, matrix: dict[str, Any]) -> None:
|
|
316
|
+
"""Append `matrix=<json>` to the $GITHUB_OUTPUT-style file."""
|
|
317
|
+
line = "matrix=" + json.dumps(matrix, ensure_ascii=False, separators=(",", ":"))
|
|
318
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
319
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
320
|
+
fh.write(line + "\n")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def write_dispatch(path: Path, data: dict[str, Any]) -> None:
|
|
324
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
326
|
+
json.dump(data, fh, indent=2, ensure_ascii=False)
|
|
327
|
+
fh.write("\n")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# --------------------------------------------------------------------------- #
|
|
331
|
+
# Main flow
|
|
332
|
+
# --------------------------------------------------------------------------- #
|
|
333
|
+
def run(args: argparse.Namespace) -> int:
|
|
334
|
+
# Config (only used to resolve the auto:issue label + repo fallback).
|
|
335
|
+
config: Optional[dict[str, Any]] = None
|
|
336
|
+
try:
|
|
337
|
+
config = load_yaml(CONFIG_PATH)
|
|
338
|
+
except (FileNotFoundError, ValueError, yaml.YAMLError):
|
|
339
|
+
warn(f"config unreadable at {CONFIG_PATH}; using defaults")
|
|
340
|
+
|
|
341
|
+
# Budget caps.
|
|
342
|
+
try:
|
|
343
|
+
budget = load_yaml(BUDGET_PATH)
|
|
344
|
+
except FileNotFoundError:
|
|
345
|
+
print(f"ERROR: budget not found at {BUDGET_PATH}", file=sys.stderr)
|
|
346
|
+
return 1
|
|
347
|
+
except (ValueError, yaml.YAMLError):
|
|
348
|
+
return 1
|
|
349
|
+
|
|
350
|
+
caps = budget.get("caps") or {}
|
|
351
|
+
max_age = int(caps.get("plan_max_age_minutes", 0) or 0)
|
|
352
|
+
|
|
353
|
+
repo = args.repo or (config.get("repo") if config else None) or ""
|
|
354
|
+
label = auto_issue_label(config)
|
|
355
|
+
|
|
356
|
+
plan_path = Path(args.plan) if args.plan else DEFAULT_PLAN
|
|
357
|
+
plan = load_plan(plan_path)
|
|
358
|
+
|
|
359
|
+
# Always emit a valid (possibly empty) matrix so the workflow never errors.
|
|
360
|
+
empty_matrix = {"include": []}
|
|
361
|
+
|
|
362
|
+
# FAIL-SAFE: missing plan -> empty resolve, exit 0.
|
|
363
|
+
if plan is None:
|
|
364
|
+
dispatch = {
|
|
365
|
+
"generated": now_iso(),
|
|
366
|
+
"open_auto_issue_prs": None,
|
|
367
|
+
"triage": [],
|
|
368
|
+
"resolve": [],
|
|
369
|
+
"deferred": [{"id": None, "reason": "plan.json missing/unreadable"}],
|
|
370
|
+
}
|
|
371
|
+
_emit(args, dispatch, empty_matrix)
|
|
372
|
+
return 0
|
|
373
|
+
|
|
374
|
+
# FAIL-SAFE: stale plan -> empty resolve, exit 0.
|
|
375
|
+
if plan_is_stale(plan, max_age):
|
|
376
|
+
warn(
|
|
377
|
+
f"plan is older than caps.plan_max_age_minutes={max_age}; "
|
|
378
|
+
f"opening no resolution PRs this run"
|
|
379
|
+
)
|
|
380
|
+
triage_batches, resolve_batches = orient_batches(plan)
|
|
381
|
+
dispatch = {
|
|
382
|
+
"generated": now_iso(),
|
|
383
|
+
"open_auto_issue_prs": None,
|
|
384
|
+
"triage": [b.get("id") for b in triage_batches],
|
|
385
|
+
"resolve": [],
|
|
386
|
+
"deferred": [
|
|
387
|
+
{"id": b.get("id"), "reason": "plan stale (exceeds plan_max_age_minutes)"}
|
|
388
|
+
for b in resolve_batches
|
|
389
|
+
],
|
|
390
|
+
}
|
|
391
|
+
_emit(args, dispatch, empty_matrix)
|
|
392
|
+
return 0
|
|
393
|
+
|
|
394
|
+
if not repo:
|
|
395
|
+
warn("no repo (args/config); cannot observe PR queue — staying conservative")
|
|
396
|
+
open_prs = None
|
|
397
|
+
else:
|
|
398
|
+
open_prs = count_open_auto_prs(repo, label)
|
|
399
|
+
|
|
400
|
+
decision = decide(plan, budget, open_prs)
|
|
401
|
+
dispatch = {"generated": now_iso(), **decision}
|
|
402
|
+
matrix = build_matrix(decision["resolve"])
|
|
403
|
+
_emit(args, dispatch, matrix)
|
|
404
|
+
return 0
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _emit(args: argparse.Namespace, dispatch: dict[str, Any],
|
|
408
|
+
matrix: dict[str, Any]) -> None:
|
|
409
|
+
"""Print + (unless dry-run) write dispatch.json and the matrix line."""
|
|
410
|
+
n_resolve = len(dispatch.get("resolve") or [])
|
|
411
|
+
n_triage = len(dispatch.get("triage") or [])
|
|
412
|
+
n_deferred = len(dispatch.get("deferred") or [])
|
|
413
|
+
open_prs = dispatch.get("open_auto_issue_prs")
|
|
414
|
+
|
|
415
|
+
print("Issue Autopilot — dispatch")
|
|
416
|
+
print(f" open auto:issue PRs: {open_prs}")
|
|
417
|
+
print(f" triage batches: {n_triage}")
|
|
418
|
+
print(f" resolve (open PRs): {n_resolve}")
|
|
419
|
+
print(f" deferred: {n_deferred}")
|
|
420
|
+
for b in dispatch.get("resolve") or []:
|
|
421
|
+
print(f" -> {b.get('id')} ({b.get('action')}) issues={b.get('issue_numbers')}")
|
|
422
|
+
for d in dispatch.get("deferred") or []:
|
|
423
|
+
print(f" ~ deferred {d.get('id')}: {d.get('reason')}")
|
|
424
|
+
|
|
425
|
+
if args.dry_run:
|
|
426
|
+
print("\n--- dispatch.json (dry-run, not written) ---")
|
|
427
|
+
print(json.dumps(dispatch, indent=2, ensure_ascii=False))
|
|
428
|
+
print("\n--- matrix (dry-run) ---")
|
|
429
|
+
print("matrix=" + json.dumps(matrix, ensure_ascii=False, separators=(",", ":")))
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
out_path = ISSUES_DIR / "dispatch.json"
|
|
433
|
+
write_dispatch(out_path, dispatch)
|
|
434
|
+
print(f"Wrote {out_path}")
|
|
435
|
+
|
|
436
|
+
if args.github_output:
|
|
437
|
+
append_github_output(Path(args.github_output), matrix)
|
|
438
|
+
print(f"Appended matrix to {args.github_output}")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# --------------------------------------------------------------------------- #
|
|
442
|
+
# CLI
|
|
443
|
+
# --------------------------------------------------------------------------- #
|
|
444
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
445
|
+
parser = argparse.ArgumentParser(
|
|
446
|
+
prog="dispatch.py",
|
|
447
|
+
description=(
|
|
448
|
+
"Issue Autopilot OODA controller / budget gate. READ + PLAN ONLY — "
|
|
449
|
+
"never mutates GitHub. Decides which resolve batches may open a PR "
|
|
450
|
+
"this run without burying the reviewer, and emits dispatch.json."
|
|
451
|
+
),
|
|
452
|
+
)
|
|
453
|
+
parser.add_argument(
|
|
454
|
+
"--repo", default=None, help="owner/name (default: config.repo)"
|
|
455
|
+
)
|
|
456
|
+
parser.add_argument(
|
|
457
|
+
"--plan", default=None,
|
|
458
|
+
help="path to plan.json (default: .issues/plan.json)",
|
|
459
|
+
)
|
|
460
|
+
parser.add_argument(
|
|
461
|
+
"--github-output", default=None,
|
|
462
|
+
help="path of a $GITHUB_OUTPUT-style file to append `matrix=<json>` to",
|
|
463
|
+
)
|
|
464
|
+
parser.add_argument(
|
|
465
|
+
"--dry-run", action="store_true",
|
|
466
|
+
help="compute and print the decision but write nothing",
|
|
467
|
+
)
|
|
468
|
+
return parser
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
472
|
+
parser = build_parser()
|
|
473
|
+
args = parser.parse_args(argv)
|
|
474
|
+
return run(args)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
if __name__ == "__main__":
|
|
478
|
+
sys.exit(main())
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
test_verify_close.py — dependency-free unit tests for the verify-and-close gate.
|
|
4
|
+
|
|
5
|
+
Run: python3 scripts/issues/test_verify_close.py (no pytest needed; exits non-zero on failure)
|
|
6
|
+
|
|
7
|
+
Covers the policy filter (which verdicts may close) and the CI gate's fail-CLOSED
|
|
8
|
+
behaviour. The CI gate is exercised with a stubbed `_gh_api` so the tests never
|
|
9
|
+
touch the network. These are the safety-critical paths: a bug here could close a
|
|
10
|
+
real, unresolved issue.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
18
|
+
import verify_close as vc # noqa: E402
|
|
19
|
+
|
|
20
|
+
PASSED = 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check(name: str, cond: bool) -> None:
|
|
24
|
+
global PASSED
|
|
25
|
+
if not cond:
|
|
26
|
+
print(f"FAIL: {name}")
|
|
27
|
+
raise SystemExit(1)
|
|
28
|
+
PASSED += 1
|
|
29
|
+
print(f"ok: {name}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
PLAN = {
|
|
33
|
+
"repo": "bamr87/zer0-mistakes",
|
|
34
|
+
"issues": [
|
|
35
|
+
{"number": 241, "verify_candidate": True},
|
|
36
|
+
{"number": 242, "verify_candidate": True},
|
|
37
|
+
{"number": 999, "verify_candidate": False}, # protected/bot — never closable
|
|
38
|
+
],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_policy_filter() -> None:
|
|
43
|
+
verify = {"verdicts": [
|
|
44
|
+
{"number": 241, "resolved": True, "confidence": "high", "evidence": "_config.yml:685"},
|
|
45
|
+
{"number": 999, "resolved": True, "confidence": "high", "evidence": "x"}, # not a candidate
|
|
46
|
+
{"number": 242, "resolved": True, "confidence": "low", "evidence": "weak"}, # low confidence
|
|
47
|
+
{"number": 242, "resolved": False, "confidence": "high", "evidence": "open"}, # not resolved
|
|
48
|
+
{"number": 241, "resolved": True, "confidence": "high", "evidence": ""}, # no evidence (dupe)
|
|
49
|
+
]}
|
|
50
|
+
picks = {p["number"] for p in vc.closable(PLAN, verify)}
|
|
51
|
+
check("only the resolved+high+evidenced candidate is closable", picks == {241})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_non_candidate_never_closable() -> None:
|
|
55
|
+
verify = {"verdicts": [
|
|
56
|
+
{"number": 999, "resolved": True, "confidence": "high", "evidence": "real-looking"},
|
|
57
|
+
]}
|
|
58
|
+
check("a non-verify_candidate is dropped (defense in depth)", vc.closable(PLAN, verify) == [])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _stub_api(mapping):
|
|
62
|
+
"""Return a fake _gh_api that yields mapping[path] (substring match) or None."""
|
|
63
|
+
def fake(path: str):
|
|
64
|
+
for key, val in mapping.items():
|
|
65
|
+
if key in path:
|
|
66
|
+
return val
|
|
67
|
+
return None
|
|
68
|
+
return fake
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_ci_gate_green() -> None:
|
|
72
|
+
orig = vc._gh_api
|
|
73
|
+
vc._gh_api = _stub_api({
|
|
74
|
+
"/status": {"state": "success", "total_count": 2},
|
|
75
|
+
"/check-runs": {"check_runs": [
|
|
76
|
+
{"name": "build", "status": "completed", "conclusion": "success"},
|
|
77
|
+
{"name": "lint", "status": "completed", "conclusion": "skipped"},
|
|
78
|
+
]},
|
|
79
|
+
})
|
|
80
|
+
try:
|
|
81
|
+
green, _ = vc.ci_gate("o/r")
|
|
82
|
+
check("all-green status + check-runs passes the gate", green is True)
|
|
83
|
+
finally:
|
|
84
|
+
vc._gh_api = orig
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_ci_gate_fails_closed() -> None:
|
|
88
|
+
orig = vc._gh_api
|
|
89
|
+
cases = {
|
|
90
|
+
"failing check-run": {
|
|
91
|
+
"/status": {"state": "success", "total_count": 1},
|
|
92
|
+
"/check-runs": {"check_runs": [{"name": "build", "status": "completed", "conclusion": "failure"}]},
|
|
93
|
+
},
|
|
94
|
+
"pending check-run": {
|
|
95
|
+
"/status": {"state": "success", "total_count": 1},
|
|
96
|
+
"/check-runs": {"check_runs": [{"name": "build", "status": "in_progress", "conclusion": None}]},
|
|
97
|
+
},
|
|
98
|
+
"failing commit status": {
|
|
99
|
+
"/status": {"state": "failure", "total_count": 1},
|
|
100
|
+
"/check-runs": {"check_runs": []},
|
|
101
|
+
},
|
|
102
|
+
"api error (None)": {}, # _gh_api returns None for everything → fail closed
|
|
103
|
+
}
|
|
104
|
+
try:
|
|
105
|
+
for label, mapping in cases.items():
|
|
106
|
+
vc._gh_api = _stub_api(mapping)
|
|
107
|
+
green, why = vc.ci_gate("o/r")
|
|
108
|
+
check(f"gate fails CLOSED on {label}", green is False and bool(why))
|
|
109
|
+
finally:
|
|
110
|
+
vc._gh_api = orig
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
test_policy_filter()
|
|
115
|
+
test_non_candidate_never_closable()
|
|
116
|
+
test_ci_gate_green()
|
|
117
|
+
test_ci_gate_fails_closed()
|
|
118
|
+
print(f"\nAll {PASSED} verify_close assertions passed.")
|