210 lines
6.7 KiB
Python
210 lines
6.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Regenerate .project/objectives/README.md from per-objective frontmatter.
|
|
|
|
SSoT: each .project/objectives/p[012]-NN-*.md file carries its own status in
|
|
YAML frontmatter. This script reads every such file, validates the fields, and
|
|
emits a grouped dashboard. The dashboard is regenerated, not hand-edited.
|
|
|
|
Usage:
|
|
python3 tools/objectives-report.py # write README.md
|
|
python3 tools/objectives-report.py --check # exit 1 if README.md is stale
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
OBJ_DIR = REPO / ".project" / "objectives"
|
|
TEAM_LEADS_DIR = REPO / ".project" / "team-leads"
|
|
OUT = OBJ_DIR / "README.md"
|
|
|
|
VALID_STATUS = {"done", "partial", "stub", "missing", "oos"}
|
|
VALID_PRIORITY = {"p0", "p1", "p2"}
|
|
VALID_SCOPE = {"game1", "game2"}
|
|
|
|
STATUS_ICON = {
|
|
"done": "✅",
|
|
"partial": "🟡",
|
|
"stub": "🔴",
|
|
"missing": "❌",
|
|
"oos": "⚫",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Objective:
|
|
id: str
|
|
title: str
|
|
priority: str
|
|
status: str
|
|
scope: str
|
|
updated_at: str
|
|
path: Path
|
|
owner: str | None = None
|
|
|
|
|
|
def parse_frontmatter(text: str, path: Path) -> dict[str, str]:
|
|
"""Parse a minimal YAML frontmatter block (flat key: value pairs).
|
|
|
|
We deliberately avoid a PyYAML dependency — frontmatter here is flat
|
|
`key: value` plus occasional list fields we don't need to index.
|
|
"""
|
|
if not text.startswith("---\n"):
|
|
raise ValueError(f"{path}: missing frontmatter opener '---'")
|
|
end = text.find("\n---\n", 4)
|
|
if end == -1:
|
|
raise ValueError(f"{path}: missing frontmatter closer '---'")
|
|
body = text[4:end]
|
|
out: dict[str, str] = {}
|
|
current_key: str | None = None
|
|
for raw in body.splitlines():
|
|
line = raw.rstrip()
|
|
if not line or line.lstrip().startswith("#"):
|
|
continue
|
|
# List-item continuation (we ignore list values for now)
|
|
if line.startswith((" ", "\t", "-")):
|
|
current_key = None
|
|
continue
|
|
if ":" not in line:
|
|
raise ValueError(f"{path}: malformed frontmatter line: {raw!r}")
|
|
key, _, value = line.partition(":")
|
|
key = key.strip()
|
|
value = value.strip()
|
|
if value:
|
|
out[key] = value
|
|
current_key = key
|
|
return out
|
|
|
|
|
|
REQUIRED_FIELDS = ("id", "title", "priority", "status", "scope", "updated_at")
|
|
|
|
|
|
def load_objectives() -> list[Objective]:
|
|
out: list[Objective] = []
|
|
for path in sorted(OBJ_DIR.glob("p[012]-*.md")):
|
|
if path.name == "README.md":
|
|
continue
|
|
text = path.read_text(encoding="utf-8")
|
|
fm = parse_frontmatter(text, path)
|
|
missing = [k for k in REQUIRED_FIELDS if k not in fm]
|
|
if missing:
|
|
raise ValueError(f"{path}: missing frontmatter keys: {missing}")
|
|
if fm["priority"] not in VALID_PRIORITY:
|
|
raise ValueError(f"{path}: invalid priority {fm['priority']!r}")
|
|
if fm["status"] not in VALID_STATUS:
|
|
raise ValueError(f"{path}: invalid status {fm['status']!r}")
|
|
if fm["scope"] not in VALID_SCOPE:
|
|
raise ValueError(f"{path}: invalid scope {fm['scope']!r}")
|
|
owner = fm.get("owner")
|
|
if owner is not None:
|
|
lead_file = TEAM_LEADS_DIR / f"{owner}.md"
|
|
if not lead_file.exists():
|
|
raise ValueError(
|
|
f"{path}: owner {owner!r} has no identity file at "
|
|
f"{lead_file.relative_to(REPO)}"
|
|
)
|
|
out.append(Objective(
|
|
id=fm["id"],
|
|
title=fm["title"],
|
|
priority=fm["priority"],
|
|
status=fm["status"],
|
|
scope=fm["scope"],
|
|
updated_at=fm["updated_at"],
|
|
path=path,
|
|
owner=owner,
|
|
))
|
|
out.sort(key=lambda o: (o.priority, o.id))
|
|
return out
|
|
|
|
|
|
def render(objectives: list[Objective]) -> str:
|
|
by_priority: dict[str, list[Objective]] = {"p0": [], "p1": [], "p2": []}
|
|
for o in objectives:
|
|
by_priority[o.priority].append(o)
|
|
|
|
counts = {s: sum(1 for o in objectives if o.status == s) for s in VALID_STATUS}
|
|
total = len(objectives)
|
|
|
|
lines: list[str] = []
|
|
lines.append("# Objectives — Dashboard")
|
|
lines.append("")
|
|
lines.append(
|
|
"> **Generated by `tools/objectives-report.py` — do not hand-edit.** "
|
|
"Source of truth is per-file YAML frontmatter in this directory."
|
|
)
|
|
lines.append("")
|
|
lines.append("## Legend")
|
|
lines.append("")
|
|
lines.append("✅ done · 🟡 partial · 🔴 stub · ❌ missing · ⚫ out-of-scope (Game 2)")
|
|
lines.append("")
|
|
lines.append("## Totals")
|
|
lines.append("")
|
|
lines.append("| Status | Count |")
|
|
lines.append("|---|---|")
|
|
for status in ("done", "partial", "stub", "missing", "oos"):
|
|
lines.append(f"| {STATUS_ICON[status]} {status} | {counts[status]} |")
|
|
lines.append(f"| **total** | **{total}** |")
|
|
lines.append("")
|
|
|
|
priority_heading = {
|
|
"p0": "P0 — Blockers for \"completely playable\"",
|
|
"p1": "P1 — Ship-readiness",
|
|
"p2": "P2 — Polish",
|
|
}
|
|
for prio in ("p0", "p1", "p2"):
|
|
group = by_priority[prio]
|
|
if not group:
|
|
continue
|
|
lines.append(f"## {priority_heading[prio]}")
|
|
lines.append("")
|
|
lines.append("| ID | Status | Title | Owner | Updated |")
|
|
lines.append("|---|---|---|---|---|")
|
|
for o in group:
|
|
link = f"[{o.id}]({o.path.name})"
|
|
icon = STATUS_ICON[o.status]
|
|
owner_cell = (
|
|
f"[{o.owner}](../team-leads/{o.owner}.md)"
|
|
if o.owner else "—"
|
|
)
|
|
lines.append(
|
|
f"| {link} | {icon} {o.status} | {o.title} "
|
|
f"| {owner_cell} | {o.updated_at} |"
|
|
)
|
|
lines.append("")
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help="Exit 1 if README.md is stale instead of writing",
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
try:
|
|
objectives = load_objectives()
|
|
except ValueError as e:
|
|
print(f"error: {e}", file=sys.stderr)
|
|
return 2
|
|
|
|
rendered = render(objectives)
|
|
if args.check:
|
|
existing = OUT.read_text(encoding="utf-8") if OUT.exists() else ""
|
|
if existing != rendered:
|
|
print(f"stale: {OUT.relative_to(REPO)} — run tools/objectives-report.py", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
OUT.write_text(rendered, encoding="utf-8")
|
|
print(f"wrote {OUT.relative_to(REPO)} ({len(objectives)} objectives)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|