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.
@@ -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.")