#!/usr/bin/env python3 """Generate the tables in plan/README.md from the epics' own frontmatter. The same shape as headquarters' register, written fresh rather than copied: the value is the structure - one file per epic, the id is the commit scope, the tables are output - and a forked copy of another repo's tooling would drift without anyone noticing. Every table sits between an HTML comment fence and is rewritten from the files. Everything outside a fence is hand-written and passed through untouched. Which table an epic lands in follows from where it is and what its status says: plan/complete/ is Complete, and outside it the status picks between the rest. Row order comes from plan/order.txt, which is advisory - an epic it does not name still appears, alphabetically, at the end of its table. So adding an epic is one file and one command, and two branches adding one do not conflict over rows neither of them cared about. No third-party modules: this runs on every commit that touches plan/. scripts/gen-plan-readme.py rewrite the register scripts/gen-plan-readme.py --check ask whether it is current """ import argparse import difflib import re import sys from pathlib import Path REPO = Path(__file__).resolve().parent.parent PLAN = REPO / "plan" COMPLETE = PLAN / "complete" REGISTER = PLAN / "README.md" ORDER = PLAN / "order.txt" # Fence name -> (heading it lives under, which epics belong in it). TABLES = { "complete": lambda epic: epic["_complete"], "open": lambda epic: not epic["_complete"] and epic["status"] in ("open", "blocked"), "continuous": lambda epic: not epic["_complete"] and epic["status"] == "continuous", "declined": lambda epic: not epic["_complete"] and epic["status"] == "declined", } SCALARS = ("id", "title", "status") LISTS = ("dependsOn",) def parse_frontmatter(text: str, path: Path) -> dict: """Enough YAML for the keys an epic carries, and nothing else. A real parser would be a dependency on a hook that runs constantly. The subset is: `key: value`, `key: [a, b]`, and `key: >` folded blocks. """ if not text.startswith("---\n"): raise SystemExit(f"{path}: no frontmatter") end = text.index("\n---\n", 3) body = text[4:end] epic: dict = {"dependsOn": []} key = None folded: list[str] = [] for line in body.splitlines(): if key and (line.startswith(" ") or not line.strip()): folded.append(line.strip()) continue if key: epic[key] = " ".join(part for part in folded if part) key, folded = None, [] if not line.strip() or line.lstrip().startswith("#"): continue name, _, value = line.partition(":") name, value = name.strip(), value.strip() if value == ">": key = name continue if value.startswith("[") and value.endswith("]"): inner = value[1:-1].strip() epic[name] = [v.strip() for v in inner.split(",") if v.strip()] else: epic[name] = value if key: epic[key] = " ".join(part for part in folded if part) for required in SCALARS: if required not in epic: raise SystemExit(f"{path}: frontmatter is missing '{required}'") return epic def load() -> list[dict]: epics = [] for directory, complete in ((PLAN, False), (COMPLETE, True)): if not directory.is_dir(): continue for path in sorted(directory.glob("*.md")): if path.name == "README.md": continue epic = parse_frontmatter(path.read_text(), path) if epic["id"] != path.stem: raise SystemExit(f"{path}: id '{epic['id']}' does not match the filename") epic["_complete"] = complete epic["_path"] = f"complete/{path.name}" if complete else path.name epics.append(epic) return epics def ranking() -> list[str]: if not ORDER.is_file(): return [] return [ line.strip() for line in ORDER.read_text().splitlines() if line.strip() and not line.startswith("#") ] def rows(epics: list[dict], order: list[str], complete: bool) -> str: def sort_key(epic: dict) -> tuple: try: return (0, order.index(epic["id"]), "") except ValueError: return (1, 0, epic["id"]) epics = sorted(epics, key=sort_key) if complete: lines = ["| id | title |", "|---|---|"] for epic in epics: lines.append(f"| [{epic['id']}]({epic['_path']}) | {epic['title']} |") return "\n".join(lines) lines = ["| id | title | status | depends on |", "|---|---|---|---|"] for epic in epics: depends = ", ".join(epic["dependsOn"]) or "-" lines.append( f"| [{epic['id']}]({epic['_path']}) | {epic['title']} | {epic['status']} | {depends} |" ) return "\n".join(lines) def render(current: str, epics: list[dict], order: list[str]) -> str: out = current for name, belongs in TABLES.items(): fence = re.compile(rf"(\n).*?()", re.DOTALL) if not fence.search(out): raise SystemExit(f"plan/README.md has no '{name}' fence") table = rows([e for e in epics if belongs(e)], order, name == "complete") replacement = table + "\n" out = fence.sub(lambda m, r=replacement: m.group(1) + r + m.group(2), out, count=1) return out def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true", help="fail if stale") args = parser.parse_args() epics = load() current = REGISTER.read_text() updated = render(current, epics, ranking()) if args.check: if current != updated: print("plan/README.md is stale. Run scripts/gen-plan-readme.py.", file=sys.stderr) sys.stderr.writelines( difflib.unified_diff( current.splitlines(keepends=True), updated.splitlines(keepends=True), "plan/README.md", "generated", ) ) return 1 return 0 if current != updated: REGISTER.write_text(updated) print(f"rewrote plan/README.md from {len(epics)} epics") else: print("plan/README.md is current") return 0 if __name__ == "__main__": sys.exit(main())