magicciv/tools/objectives-report.py
Natalie 8dc3312dc8 feat(@projects/@magic-civilization): update oos objectives structure
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-17 13:26:35 -07:00

347 lines
12 KiB
Python

#!/usr/bin/env python3
"""Regenerate .project/objectives/README.md + public objectives.json.
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 (1) a grouped dashboard for humans and (2) a structured JSON export for
the in-guide Progress Report page.
Usage:
python3 tools/objectives-report.py # write README.md + objectives.json
python3 tools/objectives-report.py --check # exit 1 if either file is stale
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
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"
JSON_OUT = REPO / "public" / "games" / "age-of-dwarves" / "data" / "objectives.json"
VALID_STATUS = {"done", "partial", "stub", "missing", "oos"}
VALID_PRIORITY = {"p0", "p1", "p2", "p3"}
VALID_SCOPE = {"game1", "game2", "game3"}
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
summary: str
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 extract_summary(text: str) -> str:
"""Extract the body paragraph(s) under the first `## Summary` heading.
Returns the text between `## Summary` and the next `## ` heading, trimmed.
Empty string if no Summary section exists — every objective should have one
but we don't fail if it's missing (prose-level editorial, not a gate).
"""
marker = "\n## Summary\n"
start = text.find(marker)
if start == -1:
return ""
body_start = start + len(marker)
# Next top-level section heading terminates the summary block.
next_h2 = text.find("\n## ", body_start)
block = text[body_start:] if next_h2 == -1 else text[body_start:next_h2]
return block.strip()
def load_objectives() -> list[Objective]:
out: list[Objective] = []
for path in sorted(OBJ_DIR.glob("*.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,
summary=extract_summary(text),
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": [], "p3": []}
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 / Game 3)")
lines.append("")
lines.append("## Totals")
lines.append("")
# --- by-priority breakdown ---
prio_labels = {"p0": "P0", "p1": "P1", "p2": "P2", "p3": "P3 (oos)"}
prio_table: list[str] = [
"| Priority | ✅ | 🟡 | 🔴 | ❌ | ⚫ | Total |",
"|---|---|---|---|---|---|---|",
]
for prio in ("p0", "p1", "p2", "p3"):
grp = by_priority[prio]
row = {s: sum(1 for o in grp if o.status == s) for s in VALID_STATUS}
prio_table.append(
f"| **{prio_labels[prio]}** "
f"| {row['done']} | {row['partial']} | {row['stub']} "
f"| {row['missing']} | {row['oos']} | {len(grp)} |"
)
prio_table.append(
f"| **total** | **{counts['done']}** | **{counts['partial']}** "
f"| **{counts['stub']}** | **{counts['missing']}** "
f"| **{counts['oos']}** | **{total}** |"
)
# --- left-to-do by team lead (partial + stub + missing only) ---
by_lead: dict[str, int] = {}
for o in objectives:
if o.status in ("partial", "stub", "missing") and o.owner:
by_lead[o.owner] = by_lead.get(o.owner, 0) + 1
lead_table: list[str] = ["| Team Lead | Remaining |", "|---|---|"]
for lead, cnt in sorted(by_lead.items(), key=lambda x: -x[1]):
lead_table.append(f"| [{lead}](../team-leads/{lead}.md) | {cnt} |")
if not by_lead:
lead_table.append("| — | 0 |")
# side-by-side via HTML (works in Forgejo/GitHub markdown)
lines.append("<table><tr><td valign='top'>")
lines.append("")
lines.append("**By Priority**")
lines.append("")
lines.extend(prio_table)
lines.append("")
lines.append("</td><td valign='top' style='padding-left:2em'>")
lines.append("")
lines.append("**Left To Do by Lead**")
lines.append("")
lines.extend(lead_table)
lines.append("")
lines.append("</td></tr></table>")
lines.append("")
priority_heading = {
"p0": "P0 — Blockers for \"completely playable\"",
"p1": "P1 — Ship-readiness",
"p2": "P2 — Polish",
}
def render_row(o: Objective) -> str:
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 ""
)
return (
f"| {link} | {icon} {o.status} | {o.title} "
f"| {owner_cell} | {o.updated_at} |"
)
# Priority sections render only in-scope (non-oos) objectives. OOS items
# are collected and rendered in a separate trailing section so they don't
# compete with active work for attention.
for prio in ("p0", "p1", "p2"):
group = [o for o in by_priority[prio] if o.status != "oos"]
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:
lines.append(render_row(o))
lines.append("")
oos_items = [o for o in objectives if o.status == "oos"]
if oos_items:
lines.append("## Out of Scope (Game 2 / Game 3)")
lines.append("")
lines.append(
"> These objectives are explicitly future-scope. "
"**Game 2 (Age of Kzzykt)** items introduce leylines, the Green school, "
"and spacefaring. **Game 3 (Age of Elves)** items cover the full "
"five-school magic system, Archons, and Arcane Ascension. "
"None are part of the Game 1 Early Access release."
)
lines.append("")
lines.append("| ID | Status | Title | Owner | Updated |")
lines.append("|---|---|---|---|---|")
for o in oos_items:
lines.append(render_row(o))
lines.append("")
return "\n".join(lines) + "\n"
def render_json(objectives: list[Objective]) -> str:
"""Serialise objectives into the structured form consumed by the guide."""
counts = {s: sum(1 for o in objectives if o.status == s) for s in VALID_STATUS}
counts["total"] = len(objectives)
payload = {
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"totals": counts,
"objectives": [
{
"id": o.id,
"title": o.title,
"priority": o.priority,
"status": o.status,
"scope": o.scope,
"owner": o.owner,
"updated_at": o.updated_at,
"summary": o.summary,
}
for o in objectives
],
}
return json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
def _json_body(rendered: str) -> object:
"""Strip the volatile `generated_at` field before diffing for --check.
`generated_at` changes on every run; comparing it would make --check always
fail. We diff the payload minus that one timestamp.
"""
data = json.loads(rendered)
data.pop("generated_at", None)
return data
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--check",
action="store_true",
help="Exit 1 if README.md or objectives.json 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)
rendered_json = render_json(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
existing_json = JSON_OUT.read_text(encoding="utf-8") if JSON_OUT.exists() else ""
try:
existing_body = _json_body(existing_json) if existing_json else None
except json.JSONDecodeError:
existing_body = None
if existing_body != _json_body(rendered_json):
print(f"stale: {JSON_OUT.relative_to(REPO)} — run tools/objectives-report.py", file=sys.stderr)
return 1
return 0
OUT.write_text(rendered, encoding="utf-8")
JSON_OUT.parent.mkdir(parents=True, exist_ok=True)
JSON_OUT.write_text(rendered_json, encoding="utf-8")
print(f"wrote {OUT.relative_to(REPO)} ({len(objectives)} objectives)")
print(f"wrote {JSON_OUT.relative_to(REPO)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())