392 lines
14 KiB
Python
392 lines
14 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 re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
# Objective filenames are `<priority>-<NN>[a-z]?-<slug>.md`, where priority is
|
|
# `p0`/`p1`/`p2`/`p3` for in-scope work or `g2`/`g3`/`g4`/`g5` for future-game
|
|
# OOS objectives. Anything else in `.project/objectives/` (READMEs, dashboard
|
|
# sidecars produced by other tooling, ad-hoc notes) is not an objective and
|
|
# must be skipped before frontmatter parsing.
|
|
OBJECTIVE_FILENAME_RE = re.compile(r"^[pg]\d+-\d+[a-z]?(?:-|\.md$)")
|
|
|
|
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", "in_progress", "partial", "stub", "missing", "oos", "superseded"}
|
|
VALID_PRIORITY = {"p0", "p1", "p2", "p3"}
|
|
VALID_SCOPE = {"game1", "game2", "game3", "game4", "game5"}
|
|
|
|
# Statuses that appear in the totals tables, Left-To-Do-by-Lead, priority
|
|
# groups, and in the structured JSON export consumed by the guide's
|
|
# ProgressReportPage. `superseded` is intentionally excluded — those files are
|
|
# index stubs that redirect to child objectives; counting them would double-
|
|
# count the children and the guide's ObjectiveStatus union would widen.
|
|
ACTIVE_STATUSES = {"done", "in_progress", "partial", "stub", "missing", "oos"}
|
|
|
|
STATUS_ICON = {
|
|
"done": "✅",
|
|
"in_progress": "🔵",
|
|
"partial": "🟡",
|
|
"stub": "🔴",
|
|
"missing": "❌",
|
|
"oos": "⚫",
|
|
"superseded": "♻️",
|
|
}
|
|
|
|
|
|
@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 not OBJECTIVE_FILENAME_RE.match(path.name):
|
|
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:
|
|
# Index stubs (status == 'superseded') are tracked separately. They are
|
|
# listed at the end of the dashboard but must not contaminate priority
|
|
# totals or the Left-To-Do-by-Lead breakdown.
|
|
active = [o for o in objectives if o.status in ACTIVE_STATUSES]
|
|
superseded = [o for o in objectives if o.status == "superseded"]
|
|
|
|
by_priority: dict[str, list[Objective]] = {"p0": [], "p1": [], "p2": [], "p3": []}
|
|
for o in active:
|
|
by_priority[o.priority].append(o)
|
|
|
|
counts = {s: sum(1 for o in active if o.status == s) for s in ACTIVE_STATUSES}
|
|
total = len(active)
|
|
|
|
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 · 🔵 in-progress · 🟡 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 ACTIVE_STATUSES}
|
|
prio_table.append(
|
|
f"| **{prio_labels[prio]}** "
|
|
f"| {row['done']} | {row['in_progress']} | {row['partial']} | {row['stub']} "
|
|
f"| {row['missing']} | {row['oos']} | {len(grp)} |"
|
|
)
|
|
prio_table.append(
|
|
f"| **total** | **{counts['done']}** | **{counts['in_progress']}** "
|
|
f"| **{counts['partial']}** | **{counts['stub']}** | **{counts['missing']}** "
|
|
f"| **{counts['oos']}** | **{total}** |"
|
|
)
|
|
|
|
# --- left-to-do by team lead (in_progress + partial + stub + missing) ---
|
|
by_lead: dict[str, int] = {}
|
|
for o in active:
|
|
if o.status in ("in_progress", "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 active 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("")
|
|
|
|
if superseded:
|
|
lines.append("## Superseded")
|
|
lines.append("")
|
|
lines.append(
|
|
"> These objectives were split into narrower children. "
|
|
"Files are retained as index stubs so external references don't 404. "
|
|
"The `superseded_by:` frontmatter field names the replacement IDs."
|
|
)
|
|
lines.append("")
|
|
lines.append("| ID | Status | Title | Owner | Updated |")
|
|
lines.append("|---|---|---|---|---|")
|
|
for o in superseded:
|
|
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.
|
|
|
|
`superseded` objectives are index stubs that redirect to child objectives;
|
|
they are excluded here so the guide's `ObjectiveStatus` union (defined in
|
|
`public/games/age-of-dwarves/guide/src/pages/progress-report/types.ts`)
|
|
doesn't have to widen for internal bookkeeping.
|
|
"""
|
|
active = [o for o in objectives if o.status in ACTIVE_STATUSES]
|
|
counts = {s: sum(1 for o in active if o.status == s) for s in ACTIVE_STATUSES}
|
|
counts["total"] = len(active)
|
|
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 active
|
|
],
|
|
}
|
|
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())
|