jekyll-theme-zer0 1.22.0 → 1.23.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 +39 -0
- data/_data/ai.yml +10 -0
- data/_data/backlog.yml +65 -37
- 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/search-modal.html +28 -4
- 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 +1 -0
- 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
- metadata +9 -2
data/_layouts/author.html
CHANGED
|
@@ -68,10 +68,31 @@ hide_intro: true
|
|
|
68
68
|
<!-- ========================== -->
|
|
69
69
|
<!-- BREADCRUMB -->
|
|
70
70
|
<!-- ========================== -->
|
|
71
|
+
{%- comment -%}
|
|
72
|
+
Only link the /authors/ breadcrumb crumb when that index page actually exists
|
|
73
|
+
in the build. A pure remote-theme Pages consumer doesn't get the
|
|
74
|
+
plugin-generated /authors/ index, so rendering the link would produce a 404.
|
|
75
|
+
Mirror the existence guard used in _includes/navigation/breadcrumbs.html
|
|
76
|
+
and _includes/components/author-bio.html. See issue #204.
|
|
77
|
+
{%- endcomment -%}
|
|
78
|
+
{%- assign _authors_url = '/authors/' -%}
|
|
79
|
+
{%- assign _authors_page = site.html_pages | where: "url", _authors_url | first -%}
|
|
80
|
+
{%- unless _authors_page -%}
|
|
81
|
+
{%- for _col in site.collections -%}
|
|
82
|
+
{%- assign _authors_page = _col.docs | where: "url", _authors_url | first -%}
|
|
83
|
+
{%- if _authors_page -%}{%- break -%}{%- endif -%}
|
|
84
|
+
{%- endfor -%}
|
|
85
|
+
{%- endunless -%}
|
|
71
86
|
<nav aria-label="breadcrumb" class="mb-3">
|
|
72
87
|
<ol class="breadcrumb">
|
|
73
88
|
<li class="breadcrumb-item"><a href="{{ '/' | relative_url }}">Home</a></li>
|
|
74
|
-
<li class="breadcrumb-item"
|
|
89
|
+
<li class="breadcrumb-item">
|
|
90
|
+
{%- if _authors_page -%}
|
|
91
|
+
<a href="{{ _authors_url | relative_url }}">Authors</a>
|
|
92
|
+
{%- else -%}
|
|
93
|
+
Authors
|
|
94
|
+
{%- endif -%}
|
|
95
|
+
</li>
|
|
75
96
|
<li class="breadcrumb-item active" aria-current="page">{{ author_name }}</li>
|
|
76
97
|
</ol>
|
|
77
98
|
</nav>
|
data/_layouts/root.html
CHANGED
|
@@ -31,7 +31,21 @@
|
|
|
31
31
|
-->
|
|
32
32
|
|
|
33
33
|
<!doctype html>
|
|
34
|
-
|
|
34
|
+
{%- comment -%}
|
|
35
|
+
Resolve the server-side data-bs-theme:
|
|
36
|
+
dark → dark
|
|
37
|
+
light → light
|
|
38
|
+
auto → dark (inline script in tokens-inline.html corrects this before first paint)
|
|
39
|
+
We also store the raw config value in data-color-mode-default so the inline
|
|
40
|
+
script can read it without a Liquid evaluation.
|
|
41
|
+
{%- endcomment -%}
|
|
42
|
+
{%- assign _cm = site.color_mode_default | default: 'auto' -%}
|
|
43
|
+
{%- if _cm == 'light' -%}
|
|
44
|
+
{%- assign _bs_theme = 'light' -%}
|
|
45
|
+
{%- else -%}
|
|
46
|
+
{%- assign _bs_theme = 'dark' -%}
|
|
47
|
+
{%- endif -%}
|
|
48
|
+
<html lang="{{ site.locale | slice: 0,2 | default: 'en' }}" class="no-js" data-bs-theme="{{ _bs_theme }}" data-color-mode-default="{{ _cm }}" data-theme-skin="{{ site.theme_skin | default: 'dark' }}" data-zer0-bg="{{ site.theme_background.enabled | default: true }}">
|
|
35
49
|
<head>
|
|
36
50
|
<!-- =============================================== -->
|
|
37
51
|
<!-- HEAD SECTION: Meta tags, styles, and SEO setup -->
|
data/scripts/bin/validate
CHANGED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
classify_changes.py — the auto-merge smuggle guard.
|
|
4
|
+
|
|
5
|
+
Reads a list of changed file paths (one per line on stdin, e.g. from
|
|
6
|
+
`gh pr diff <n> --name-only`) and prints the distinct CATEGORIES present, one
|
|
7
|
+
per line, from:
|
|
8
|
+
|
|
9
|
+
content — pages/**, _data/{quests,navigation,...}, assets/** (the site)
|
|
10
|
+
infra — .github/**, scripts/**, Dockerfile, docker-compose, Gemfile*, Makefile
|
|
11
|
+
config — _config*.yml, _data/ai.yml, _data/brand/**, .cms/**, *.json config
|
|
12
|
+
data — other _data/**
|
|
13
|
+
|
|
14
|
+
The content auto-merge workflow refuses to merge any PR whose categories include
|
|
15
|
+
anything other than `content` — so an `auto:content` PR can never quietly carry a
|
|
16
|
+
workflow, dependency, brand-rule, or AI-config change to main without a human.
|
|
17
|
+
Unknown paths classify as `infra` (fail safe — when unsure, require a human).
|
|
18
|
+
|
|
19
|
+
The content category is deliberately broad (it includes theme files like
|
|
20
|
+
`_layouts/**`, `_includes/**`, `_sass/**`). A caller that needs a TIGHTER scope
|
|
21
|
+
than "any content" — e.g. the issue autopilot, whose resolver may only edit
|
|
22
|
+
`pages/**`, `assets/**`, `_data/quests/**` — adds `--allow-globs` to require that
|
|
23
|
+
every changed path also matches one of the given globs. `--content-only` and
|
|
24
|
+
`--allow-globs` compose: both must pass.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
gh pr diff 123 --name-only | python3 scripts/ci/classify_changes.py
|
|
28
|
+
python3 scripts/ci/classify_changes.py a.md b.yml # paths as args
|
|
29
|
+
... | python3 scripts/ci/classify_changes.py --content-only # exit 0 iff content-only
|
|
30
|
+
... | python3 scripts/ci/classify_changes.py --content-only \
|
|
31
|
+
--allow-globs 'pages/**' 'assets/**' '_data/quests/**' # tighter scope
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import sys
|
|
37
|
+
from fnmatch import fnmatch
|
|
38
|
+
|
|
39
|
+
# Order matters: first matching rule wins. Most specific first.
|
|
40
|
+
# THEME REPO: the theme itself (_layouts/_includes/_sass/_plugins/lib/assets) is
|
|
41
|
+
# the PRODUCT, so it is `infra` (never auto-mergeable) — only docs/pages Markdown
|
|
42
|
+
# counts as `content`. This is the key difference from a content site.
|
|
43
|
+
RULES = [
|
|
44
|
+
("config", [
|
|
45
|
+
"_config.yml", "_config*.yml",
|
|
46
|
+
"_data/ai.yml",
|
|
47
|
+
".github/config/*", ".github/config/**",
|
|
48
|
+
"frontmatter.json", ".frontmatter/*", ".frontmatter/**",
|
|
49
|
+
]),
|
|
50
|
+
("infra", [
|
|
51
|
+
".github/*", ".github/**",
|
|
52
|
+
".claude/*", ".claude/**",
|
|
53
|
+
"scripts/*", "scripts/**",
|
|
54
|
+
"lib/*", "lib/**",
|
|
55
|
+
"_layouts/*", "_layouts/**", "_includes/*", "_includes/**",
|
|
56
|
+
"_sass/*", "_sass/**", "_plugins/*", "_plugins/**",
|
|
57
|
+
"assets/*", "assets/**",
|
|
58
|
+
"test/*", "test/**", "templates/*", "templates/**",
|
|
59
|
+
"Dockerfile", "docker-compose*.yml", "docker-compose*.yaml",
|
|
60
|
+
"Gemfile", "Gemfile.lock", "Makefile", "Rakefile", "install.sh",
|
|
61
|
+
"*.gemspec", "package.json", "package-lock.json",
|
|
62
|
+
]),
|
|
63
|
+
("content", [
|
|
64
|
+
"docs/*", "docs/**",
|
|
65
|
+
"pages/*", "pages/**",
|
|
66
|
+
"index.html", "index.md", "*.md",
|
|
67
|
+
]),
|
|
68
|
+
]
|
|
69
|
+
FALLBACK = "infra" # unknown -> require a human (fail safe)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def classify_one(path: str) -> str:
|
|
73
|
+
p = path.strip()
|
|
74
|
+
if not p:
|
|
75
|
+
return ""
|
|
76
|
+
for category, patterns in RULES:
|
|
77
|
+
for pat in patterns:
|
|
78
|
+
if fnmatch(p, pat):
|
|
79
|
+
return category
|
|
80
|
+
return "data" if p.startswith("_data/") else FALLBACK
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv=None) -> int:
|
|
84
|
+
ap = argparse.ArgumentParser(description="Classify changed paths for the auto-merge guard.")
|
|
85
|
+
ap.add_argument("paths", nargs="*", help="paths (else read stdin, one per line)")
|
|
86
|
+
ap.add_argument("--content-only", action="store_true",
|
|
87
|
+
help="exit 0 iff every path is content; exit 1 otherwise")
|
|
88
|
+
ap.add_argument("--allow-globs", nargs="*", default=None, metavar="GLOB",
|
|
89
|
+
help="require every changed path to match one of these globs "
|
|
90
|
+
"(tighter than --content-only); composes with it")
|
|
91
|
+
args = ap.parse_args(argv)
|
|
92
|
+
|
|
93
|
+
raw = [line for line in (args.paths if args.paths else sys.stdin.read().splitlines())
|
|
94
|
+
if line.strip()]
|
|
95
|
+
cats = []
|
|
96
|
+
for line in raw:
|
|
97
|
+
c = classify_one(line)
|
|
98
|
+
if c and c not in cats:
|
|
99
|
+
cats.append(c)
|
|
100
|
+
|
|
101
|
+
# When either gate is requested, ALL requested gates must pass (exit 0/1).
|
|
102
|
+
if args.content_only or args.allow_globs is not None:
|
|
103
|
+
ok = True
|
|
104
|
+
if args.content_only:
|
|
105
|
+
content_ok = bool(cats) and cats == ["content"]
|
|
106
|
+
if not content_ok:
|
|
107
|
+
others = [c for c in cats if c != "content"] or ["<empty diff>"]
|
|
108
|
+
print(f"not content-only: also touches {', '.join(others)}", file=sys.stderr)
|
|
109
|
+
ok = ok and content_ok
|
|
110
|
+
if args.allow_globs is not None:
|
|
111
|
+
offenders = [p.strip() for p in raw
|
|
112
|
+
if not any(fnmatch(p.strip(), g) for g in args.allow_globs)]
|
|
113
|
+
# An empty diff is never "allowed" — there is nothing legitimate to merge.
|
|
114
|
+
if not raw:
|
|
115
|
+
print("no changed paths — refusing (nothing to merge)", file=sys.stderr)
|
|
116
|
+
ok = False
|
|
117
|
+
elif offenders:
|
|
118
|
+
print(f"outside allowed scope ({', '.join(args.allow_globs)}): "
|
|
119
|
+
f"{', '.join(offenders)}", file=sys.stderr)
|
|
120
|
+
ok = False
|
|
121
|
+
return 0 if ok else 1
|
|
122
|
+
|
|
123
|
+
for c in cats:
|
|
124
|
+
print(c)
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
raise SystemExit(main())
|
|
@@ -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())
|