|
17 | 17 | is fully generated and safe to overwrite on every run. |
18 | 18 |
|
19 | 19 | Usage: |
20 | | - python3 scripts/gen-roadmap.py [--check] |
| 20 | + python3 scripts/gen-roadmap.py [--check | --check-github [--strict]] |
21 | 21 |
|
22 | | - --check Exit non-zero if ROADMAP.md is out of date (does not write). |
23 | | - Useful as a CI canary. |
| 22 | + --check Exit non-zero if ROADMAP.md is out of date (does not write). |
| 23 | + Useful as a CI canary. |
| 24 | + --check-github Cross-check roadmap.yaml against ground truth (does not write): |
| 25 | + live GitHub PR state (via `gh`), spec: links resolving to |
| 26 | + real specs/ dirs, and status sanity. Reports ERROR/WARN and |
| 27 | + exits 1 on any ERROR, 0 otherwise; 2 if `gh` is unavailable. |
| 28 | + --strict With --check-github, promote warnings to errors for the exit |
| 29 | + code (report is unchanged). |
24 | 30 |
|
25 | 31 | Pure stdlib + PyYAML (already used by scripts/check-settings-parity.py). |
26 | 32 | Idempotent: running twice with no source change produces identical output. |
27 | 33 | """ |
28 | 34 | from __future__ import annotations |
29 | 35 |
|
30 | 36 | import argparse |
| 37 | +import json |
31 | 38 | import os |
32 | 39 | import re |
| 40 | +import shutil |
| 41 | +import subprocess |
33 | 42 | import sys |
34 | 43 |
|
35 | 44 | try: |
|
43 | 52 | ROADMAP_MD = os.path.join(REPO_ROOT, "ROADMAP.md") |
44 | 53 | SPECS_DIR = os.path.join(REPO_ROOT, "specs") |
45 | 54 |
|
| 55 | +# GitHub repo `gh` queries target for --check-github. |
| 56 | +REPO_SLUG = "smart-mcp-proxy/mcpproxy-go" |
| 57 | +# A PR ref inside a pr: field, either "#786" or ".../pull/786". |
| 58 | +PR_NUM_RE = re.compile(r"(?:#|/pull/)(\d+)") |
| 59 | + |
46 | 60 | # A checkbox line: "- [ ] ...", "- [x] ...", "- [X] ..." (matches specs/README.md). |
47 | 61 | CHECKBOX_RE = re.compile(r"^- \[([ xX])\]") |
48 | 62 |
|
@@ -244,7 +258,10 @@ def render(data: dict) -> str: |
244 | 258 | out.append("```bash") |
245 | 259 | out.append("python3 scripts/gen-roadmap.py # writes ROADMAP.md") |
246 | 260 | out.append("scripts/gen-roadmap # convenience wrapper (same thing)") |
247 | | - out.append("python3 scripts/gen-roadmap.py --check # CI canary: fail if stale") |
| 261 | + out.append("python3 scripts/gen-roadmap.py --check # CI canary: fail if ROADMAP.md is stale") |
| 262 | + out.append("python3 scripts/gen-roadmap.py --check-github # cross-check statuses vs live GitHub PR state,") |
| 263 | + out.append(" # spec links, and status sanity (add --strict") |
| 264 | + out.append(" # to fail on warnings; needs an authenticated gh)") |
248 | 265 | out.append("```") |
249 | 266 | out.append("") |
250 | 267 | out.append("## roadmap.yaml schema (short form)") |
@@ -281,15 +298,247 @@ def render(data: dict) -> str: |
281 | 298 | return "\n".join(out) |
282 | 299 |
|
283 | 300 |
|
| 301 | +# ── GitHub / ground-truth cross-check (--check-github) ────────────────────── |
| 302 | +class Finding: |
| 303 | + """One report line: an ERROR or WARN against a roadmap item.""" |
| 304 | + __slots__ = ("level", "ref", "reason") |
| 305 | + |
| 306 | + def __init__(self, level: str, ref: str, reason: str): |
| 307 | + self.level = level # "ERROR" | "WARN" |
| 308 | + self.ref = ref |
| 309 | + self.reason = reason |
| 310 | + |
| 311 | + |
| 312 | +def iter_items(data: dict): |
| 313 | + """Yield metadata for every epic and task, in file order. |
| 314 | +
|
| 315 | + Each dict: item (raw), id, kind ('epic'|'task'), epic_id (owning epic), |
| 316 | + has_children (bool). A task's owning epic id lets us attribute a task's |
| 317 | + spec: link back to its epic for double-count detection. |
| 318 | + """ |
| 319 | + for epic in data.get("epics", []): |
| 320 | + children = epic.get("tasks") or [] |
| 321 | + yield {"item": epic, "id": epic["id"], "kind": "epic", |
| 322 | + "epic_id": epic["id"], "has_children": bool(children)} |
| 323 | + for t in children: |
| 324 | + yield {"item": t, "id": t["id"], "kind": "task", |
| 325 | + "epic_id": epic["id"], "has_children": False} |
| 326 | + |
| 327 | + |
| 328 | +def ref_label(meta: dict) -> str: |
| 329 | + if meta["kind"] == "epic": |
| 330 | + return f"{meta['id']} (epic)" |
| 331 | + return f"{meta['id']} (task · epic {meta['epic_id']})" |
| 332 | + |
| 333 | + |
| 334 | +def parse_pr_refs(pr) -> list[int]: |
| 335 | + """Extract PR numbers from a pr: field ("#786", full URL, or a list).""" |
| 336 | + if not pr: |
| 337 | + return [] |
| 338 | + refs = pr if isinstance(pr, list) else [pr] |
| 339 | + nums: list[int] = [] |
| 340 | + for r in refs: |
| 341 | + for m in PR_NUM_RE.finditer(str(r)): |
| 342 | + n = int(m.group(1)) |
| 343 | + if n not in nums: |
| 344 | + nums.append(n) |
| 345 | + return nums |
| 346 | + |
| 347 | + |
| 348 | +def gh_available() -> tuple[bool, str]: |
| 349 | + """(ok, reason). ok=False means skip the live PR cross-check (exit 2).""" |
| 350 | + if not shutil.which("gh"): |
| 351 | + return False, "`gh` CLI not found on PATH" |
| 352 | + try: |
| 353 | + r = subprocess.run(["gh", "auth", "status"], |
| 354 | + capture_output=True, text=True) |
| 355 | + except OSError as e: # pragma: no cover |
| 356 | + return False, f"could not execute `gh`: {e}" |
| 357 | + if r.returncode != 0: |
| 358 | + return False, "`gh` is not authenticated (`gh auth status` failed)" |
| 359 | + return True, "" |
| 360 | + |
| 361 | + |
| 362 | +def gh_pr_state(number: int, repo: str, cache: dict) -> dict: |
| 363 | + """Return {'state','mergedAt'} for a PR, or {'error': msg}. Cached per number.""" |
| 364 | + if number in cache: |
| 365 | + return cache[number] |
| 366 | + r = subprocess.run( |
| 367 | + ["gh", "pr", "view", str(number), "--repo", repo, |
| 368 | + "--json", "state,mergedAt"], |
| 369 | + capture_output=True, text=True) |
| 370 | + if r.returncode != 0: |
| 371 | + cache[number] = {"error": (r.stderr.strip().splitlines() or ["not found"])[-1]} |
| 372 | + else: |
| 373 | + try: |
| 374 | + cache[number] = json.loads(r.stdout) |
| 375 | + except json.JSONDecodeError: |
| 376 | + cache[number] = {"error": "unparseable `gh` JSON output"} |
| 377 | + return cache[number] |
| 378 | + |
| 379 | + |
| 380 | +def check_pr_status(items: list[dict], repo: str, cache: dict) -> list[Finding]: |
| 381 | + """Cross-check every pr: link against live GitHub state. |
| 382 | +
|
| 383 | + MERGED but not done → ERROR; CLOSED (unmerged) but in_progress/in_review → |
| 384 | + ERROR; OPEN but done → ERROR; OPEN but todo → WARN; unresolvable ref → ERROR. |
| 385 | + """ |
| 386 | + out: list[Finding] = [] |
| 387 | + for meta in items: |
| 388 | + status = meta["item"].get("status", "todo") |
| 389 | + for num in parse_pr_refs(meta["item"].get("pr")): |
| 390 | + st = gh_pr_state(num, repo, cache) |
| 391 | + if "error" in st: |
| 392 | + out.append(Finding("ERROR", ref_label(meta), |
| 393 | + f"PR #{num} could not be resolved on GitHub " |
| 394 | + f"({st['error']}) — dangling pr: link.")) |
| 395 | + continue |
| 396 | + state = st.get("state") # OPEN | CLOSED | MERGED |
| 397 | + if state == "MERGED": |
| 398 | + if status != "done": |
| 399 | + out.append(Finding("ERROR", ref_label(meta), |
| 400 | + f"PR #{num} is MERGED but status is '{status}' " |
| 401 | + f"(expected 'done').")) |
| 402 | + elif state == "CLOSED": |
| 403 | + if status in ("in_progress", "in_review"): |
| 404 | + out.append(Finding("ERROR", ref_label(meta), |
| 405 | + f"PR #{num} is CLOSED (unmerged) but status is " |
| 406 | + f"'{status}'.")) |
| 407 | + elif state == "OPEN": |
| 408 | + if status == "done": |
| 409 | + out.append(Finding("ERROR", ref_label(meta), |
| 410 | + f"PR #{num} is OPEN but status is 'done'.")) |
| 411 | + elif status == "todo": |
| 412 | + out.append(Finding("WARN", ref_label(meta), |
| 413 | + f"PR #{num} is OPEN (work started) but status is " |
| 414 | + f"still 'todo'.")) |
| 415 | + return out |
| 416 | + |
| 417 | + |
| 418 | +def check_spec_links(items: list[dict]) -> list[Finding]: |
| 419 | + """Every spec: must resolve to a real specs/<NNN> dir (ERROR if not). |
| 420 | + A spec shared by two different epics double-counts its badge (WARN).""" |
| 421 | + out: list[Finding] = [] |
| 422 | + spec_to_epics: dict[str, set] = {} |
| 423 | + for meta in items: |
| 424 | + spec = meta["item"].get("spec") |
| 425 | + if not spec: |
| 426 | + continue |
| 427 | + if not os.path.isdir(os.path.join(REPO_ROOT, spec)): |
| 428 | + out.append(Finding("ERROR", ref_label(meta), |
| 429 | + f"spec: '{spec}' does not resolve to a directory under specs/.")) |
| 430 | + # Attribute to the owning epic so an epic sharing a spec with its OWN |
| 431 | + # child task is not flagged — only genuinely distinct epics are. |
| 432 | + spec_to_epics.setdefault(spec, set()).add(meta["epic_id"]) |
| 433 | + for spec, epics in sorted(spec_to_epics.items()): |
| 434 | + if len(epics) > 1: |
| 435 | + out.append(Finding("WARN", f"spec {spec}", |
| 436 | + f"shared by {len(epics)} distinct epics " |
| 437 | + f"({', '.join(sorted(epics))}) — the Epics-table progress " |
| 438 | + f"badge double-counts this spec.")) |
| 439 | + return out |
| 440 | + |
| 441 | + |
| 442 | +def check_status_sanity(items: list[dict]) -> list[Finding]: |
| 443 | + """Reviews/in-flight work should have PR evidence; done epics should have |
| 444 | + all children done. |
| 445 | +
|
| 446 | + in_review with no pr: → WARN for any item (an in-review claim with no PR |
| 447 | + anywhere is exactly the drift this audit found). in_progress with no pr: → |
| 448 | + WARN only for leaf items, since an umbrella epic legitimately delegates its |
| 449 | + PRs to child tasks. |
| 450 | + """ |
| 451 | + out: list[Finding] = [] |
| 452 | + for meta in items: |
| 453 | + item = meta["item"] |
| 454 | + status = item.get("status", "todo") |
| 455 | + has_pr = bool(parse_pr_refs(item.get("pr"))) |
| 456 | + if not has_pr: |
| 457 | + if status == "in_review": |
| 458 | + out.append(Finding("WARN", ref_label(meta), |
| 459 | + "status 'in_review' but no pr: link — an in-review item " |
| 460 | + "should link its PR as evidence.")) |
| 461 | + elif status == "in_progress" and not meta["has_children"]: |
| 462 | + out.append(Finding("WARN", ref_label(meta), |
| 463 | + "status 'in_progress' but no pr: link and no child tasks " |
| 464 | + "— nothing links the in-flight work.")) |
| 465 | + if meta["kind"] == "epic" and status == "done": |
| 466 | + for t in item.get("tasks") or []: |
| 467 | + if t.get("status") != "done": |
| 468 | + out.append(Finding("WARN", ref_label(meta), |
| 469 | + f"epic is 'done' but child task '{t['id']}' is " |
| 470 | + f"'{t.get('status', 'todo')}'.")) |
| 471 | + return out |
| 472 | + |
| 473 | + |
| 474 | +def print_report(findings: list[Finding], strict: bool) -> int: |
| 475 | + errors = [f for f in findings if f.level == "ERROR"] |
| 476 | + warnings = [f for f in findings if f.level == "WARN"] |
| 477 | + |
| 478 | + print(f"roadmap.yaml ground-truth cross-check (repo {REPO_SLUG})\n") |
| 479 | + |
| 480 | + def emit(group: list[Finding], head: str): |
| 481 | + print(f"{head} ({len(group)}):") |
| 482 | + if not group: |
| 483 | + print(" none") |
| 484 | + for f in group: |
| 485 | + print(f" [{f.level:<5}] {f.ref}") |
| 486 | + print(f" {f.reason}") |
| 487 | + print() |
| 488 | + |
| 489 | + emit(errors, "ERRORS") |
| 490 | + emit(warnings, "WARNINGS") |
| 491 | + |
| 492 | + print(f"Summary: {len(errors)} error(s), {len(warnings)} warning(s).") |
| 493 | + if strict and warnings: |
| 494 | + print("(--strict: warnings count as errors for the exit code.)") |
| 495 | + if errors or (strict and warnings): |
| 496 | + return 1 |
| 497 | + return 0 |
| 498 | + |
| 499 | + |
| 500 | +def check_github(data: dict, strict: bool) -> int: |
| 501 | + items = list(iter_items(data)) |
| 502 | + findings: list[Finding] = [] |
| 503 | + |
| 504 | + ok, reason = gh_available() |
| 505 | + if ok: |
| 506 | + cache: dict = {} |
| 507 | + findings += check_pr_status(items, REPO_SLUG, cache) |
| 508 | + |
| 509 | + # spec + status checks are offline and always run. |
| 510 | + findings += check_spec_links(items) |
| 511 | + findings += check_status_sanity(items) |
| 512 | + |
| 513 | + if not ok: |
| 514 | + print_report(findings, strict) |
| 515 | + sys.stderr.write( |
| 516 | + f"\nerror: PR cross-check skipped — {reason}. " |
| 517 | + "Install and authenticate `gh` (`gh auth login`) to validate PR " |
| 518 | + "state; offline spec/status checks above still ran.\n") |
| 519 | + return 2 |
| 520 | + |
| 521 | + return print_report(findings, strict) |
| 522 | + |
| 523 | + |
284 | 524 | def main() -> int: |
285 | 525 | ap = argparse.ArgumentParser(description="Generate ROADMAP.md from roadmap.yaml") |
286 | 526 | ap.add_argument("--check", action="store_true", |
287 | 527 | help="exit non-zero if ROADMAP.md is stale (do not write)") |
| 528 | + ap.add_argument("--check-github", action="store_true", |
| 529 | + help="cross-check roadmap.yaml vs live GitHub PR state, " |
| 530 | + "spec links, and status sanity (does not write)") |
| 531 | + ap.add_argument("--strict", action="store_true", |
| 532 | + help="with --check-github, promote warnings to errors " |
| 533 | + "for the exit code") |
288 | 534 | args = ap.parse_args() |
289 | 535 |
|
290 | 536 | with open(ROADMAP_YAML, encoding="utf-8") as fh: |
291 | 537 | data = yaml.safe_load(fh) |
292 | 538 |
|
| 539 | + if args.check_github: |
| 540 | + return check_github(data, args.strict) |
| 541 | + |
293 | 542 | rendered = render(data) |
294 | 543 |
|
295 | 544 | if args.check: |
|
0 commit comments