#!/usr/bin/env python3 """Horizon's roadmap: roadmap/roadmap.toml is the source, ROADMAP.md is generated from it. python3 tools/roadmap.py build write ROADMAP.md and roadmap/roadmap.json python3 tools/roadmap.py build --check fail when either is out of date (CI) python3 tools/roadmap.py check --offline the file's own rules (CI) python3 tools/roadmap.py check the same, then GitHub: links resolve, no stale Now item python3 tools/roadmap.py sync [--dry-run] items whose pull requests all merged become shipped, with the release they went out in python3 tools/roadmap.py labels [--dry-run] a `roadmap:` label per item on GitHub, and on the open pull requests the item links The online commands read GitHub with GITHUB_TOKEN, GH_TOKEN or `gh auth token`. Maintenance: roadmap/README.md. """ import argparse import datetime import json import os import re import subprocess import sys import tomllib import urllib.parse import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parent.parent SOURCE = ROOT / "roadmap" / "roadmap.toml" OUTPUT = ROOT / "ROADMAP.md" # What the website's hub reads from main at build time (horizon-rust/horizon-site), and serves at /data/roadmap.json # for Kira and others. SITE_DATA = ROOT / "roadmap" / "roadmap.json" # The journal's weekly summaries, one file per ISO week: roadmap/journal/2026-43.md (roadmap/README.md). JOURNAL = ROOT / "roadmap" / "journal" WEEK = re.compile(r"(\d{4})-(\d{2})\.md$") REPO = "horizon-rust/horizon" SITE = "https://playhorizon.dev/hub" STATUSES = { "now": "Now", "next": "Next", "later": "Later", "help-wanted": "Help wanted", "shipped": "Recently shipped", } PLAIN_MAX = 240 # one post's worth, so a shipped item can be announced as written TITLE_MAX = 70 FOCUS_RANGE = (3, 5) STALE_DAYS = 14 # a Now item needs an open pull request, or one merged this recently LABEL_PREFIX = "roadmap:" # GitHub label colours by status, from the design system's palette. LABEL_COLOURS = {"now": "5b3fd6", "next": "9a86ff", "later": "c5c7d1", "help-wanted": "1d7a48", "shipped": "646875"} ITEM_KEYS = {"id", "title", "status", "area", "plain", "dev", "links", "focus", "ongoing", "shipped_in", "post", "announce"} ISSUE = re.compile(r"#(\d+)$") ID = re.compile(r"[a-z0-9]+(-[a-z0-9]+)*$") # An item is the anchor playhorizon.dev/hub#: it can't take the name of a tab, a section or a heading. RESERVED = {"main", "overview", "roadmap", "board", "journal", "contribute", "report", "suggest", "contributors", "hub-data", *STATUSES} RESERVED_PREFIXES = ("h-", "tab-", "col-", "week-", "journal-") RELEASE = re.compile(r"(v\d[\w.]*|launcher@\d[\w.]*|launcher-v\d[\w.]*|website)$") MONTHS = ("January|February|March|April|June|July|August|September|October|November|December") # Kira's rule: no dates and no promises of when. "Coming soon" is the one allowed phrase. DATE = re.compile( rf"\b(?:{MONTHS})\b|\bMay \d|(? TITLE_MAX: problems.append(f"{where}: the title is longer than {TITLE_MAX} characters") for key in ("plain", "post"): if len(item.get(key, "")) > PLAIN_MAX: problems.append(f"{where}: {key} is {len(item[key])} characters, at most {PLAIN_MAX}") for key in ("title", "plain", "dev", "post"): found = dated(item.get(key, "")) if found: problems.append(f"{where}: {key} names a date or a deadline ({found!r}); the roadmap has none") shipped_in = item.get("shipped_in") if status == "shipped": if not shipped_in: problems.append(f"{where}: a shipped item needs shipped_in (a release tag, or \"website\")") elif not RELEASE.match(shipped_in): problems.append(f"{where}: shipped_in {shipped_in!r} is not a release tag or \"website\"") elif shipped_in: problems.append(f"{where}: only shipped items have shipped_in") for key in ("focus", "ongoing", "announce"): if key in item and not isinstance(item[key], bool): problems.append(f"{where}: {key} is true or false") if item.get("ongoing") and status not in ("now", "next", "later"): problems.append(f"{where}: only Now, Next and Later items are ongoing") if item.get("focus") and status != "now": problems.append(f"{where}: only Now items can be in focus") links = item.get("links", []) if not isinstance(links, list): problems.append(f"{where}: links is a list") links = [] if not links and status != "later": problems.append(f"{where}: needs at least one link (an issue, a pull request or a doc)") for link in links: if ISSUE.match(link) or link.startswith("https://"): continue if link.startswith(("/", "http:")) or ".." in link or not (root / link).exists(): problems.append(f"{where}: link {link!r} is neither #N, https:// nor a path in the repository") problems += weeks()[1] if root == ROOT else [] focus = sum(1 for item in items if item.get("focus")) low, high = FOCUS_RANGE if not low <= focus <= high: problems.append(f"focus: {focus} items are in focus, the overview wants {low} to {high}") return problems def areas_in_order(data): return [(area["id"], area["name"]) for area in data.get("area", [])] def by_area(data, status): """The items of a status, grouped by area in the areas' order, each group in the file's order.""" items = [item for item in data.get("item", []) if item.get("status") == status] return [(name, [item for item in items if item["area"] == key]) for key, name in areas_in_order(data) if any(item["area"] == key for item in items)] def linkify(text, links=()): """Markdown links for the text's #N, and for the item's own paths and URLs where the text names them.""" text = re.sub(r"(?", "", "What is being done on Horizon, what comes next and where you can help. There are no dates: things are" " Now, Next or Later. Each item is said in plain words, with the technical picture folded under it." f" The same roadmap, with a Simple / Technical switch, the board, the journal and how to help, is on" f" [playhorizon.dev/hub]({SITE}). Pull requests carry the `roadmap:` label of their item.", "", "## At a glance", "", overview["today_plain"].strip(), "", "**Being done now**", "", ] out += [f"- **{item['title']}**: {item['plain'].strip()}" for item in items if item.get("focus")] nexts = [item["title"] for item in items if item.get("status") == "next"] if nexts: out += ["", "**Next**: " + "; ".join(nexts) + "."] out += ["", f"**How to help**: {overview['help_plain'].strip()}", "", f"**Developers**: {overview['help_dev'].strip()}"] for status, heading in STATUSES.items(): groups = by_area(data, status) if not groups: continue out += ["", f"## {heading}"] if status == "shipped": # Newest first: the file's order, where `sync` puts each newly shipped item on top. out += [""] + [f"- **{item['title']}** ({item['shipped_in']}): {item['plain'].strip()} " f"{link_list(item['links'])}" for item in items if item.get("status") == status] continue for area, group in groups: out += ["", f"### {area}"] for item in group: out += ["", f"#### {item['title']}", "", item["plain"].strip(), "", "
Technical", "", linkify(item["dev"].strip(), item.get("links", []))] more = [link for link in item.get("links", []) if not re.search(rf"{re.escape(link)}\b", item["dev"])] if more: out += ["", "More: " + link_list(more)] out += ["", "
"] return "\n".join(out) + "\n" def weeks(directory=JOURNAL): """The weekly summaries, newest first: {"week", "date" (its Monday), "title", "plain", "dev"}, and problems.""" entries, problems = [], [] for path in sorted(directory.glob("*.md"), reverse=True) if directory.exists() else []: name = WEEK.match(path.name) text = path.read_text() parts = re.match(r"# (.+)\n\n(.+?)\n\n## Technical\n\n(.+)", text.strip() + "\n", re.S) if not name or not 1 <= int(name[2]) <= 53: problems.append(f"journal/{path.name}: name it by its ISO week, like 2026-43.md") continue if not parts: problems.append(f"journal/{path.name}: needs '# Title', the plain text, then '## Technical' and its text") continue monday = datetime.date.fromisocalendar(int(name[1]), int(name[2]), 1) entries.append({"week": path.stem, "date": monday.isoformat(), "title": parts[1].strip(), "plain": " ".join(parts[2].split()), "dev": " ".join(parts[3].split())}) return entries, problems def site_data(data): """roadmap/roadmap.json: the items as the hub and Kira read them, in the file's order.""" keys = ("id", "title", "status", "area", "plain", "dev", "links", "focus", "ongoing", "shipped_in", "post", "announce") out = { "format": "horizon-roadmap", "version": 1, "source": "roadmap/roadmap.toml", "overview": {key: " ".join(value.split()) for key, value in data["overview"].items()}, "statuses": [{"id": key, "name": name} for key, name in STATUSES.items()], "areas": [{"id": key, "name": name} for key, name in areas_in_order(data)], "items": [{key: (" ".join(item[key].split()) if isinstance(item[key], str) else item[key]) for key in keys if key in item} for item in data.get("item", [])], "journal": weeks()[0], } return json.dumps(out, indent=1, ensure_ascii=False) + "\n" def outputs(data): """Every generated file and its content.""" return {OUTPUT: render(data), SITE_DATA: site_data(data)} # --- GitHub --------------------------------------------------------------------------------------------------- def token(): for name in ("GITHUB_TOKEN", "GH_TOKEN"): if os.environ.get(name): return os.environ[name] try: return subprocess.run(["gh", "auth", "token"], capture_output=True, text=True, check=True).stdout.strip() except (OSError, subprocess.CalledProcessError): sys.exit("roadmap: reading GitHub needs GITHUB_TOKEN, GH_TOKEN or `gh auth login` (or use --offline)") def numbers_in(data): return sorted({int(ISSUE.match(link)[1]) for item in data.get("item", []) for link in item.get("links", []) if ISSUE.match(link)}) def github_states(numbers, auth): """{number: {"kind": "Issue" | "PullRequest", "state", "merged_at", "merge_commit", "paths"}} for those that exist, in one GraphQL request per hundred numbers.""" owner, name = REPO.split("/") states = {} for start in range(0, len(numbers), 100): fields = "\n".join( f"n{n}: issueOrPullRequest(number: {n}) {{ __typename " "... on Issue { state } " "... on PullRequest { state mergedAt mergeCommit { oid } files(first: 100) { nodes { path } } " "labels(first: 50) { nodes { name } } } }" for n in numbers[start:start + 100]) query = f'query {{ repository(owner: "{owner}", name: "{name}") {{ {fields} }} }}' request = urllib.request.Request( "https://api.github.com/graphql", data=json.dumps({"query": query}).encode(), headers={"Authorization": f"bearer {auth}", "User-Agent": "horizon-roadmap"}) with urllib.request.urlopen(request, timeout=30) as response: answer = json.load(response) for key, node in (answer.get("data", {}).get("repository") or {}).items(): if node: states[int(key[1:])] = { "kind": node["__typename"], "state": node["state"], "merged_at": node.get("mergedAt"), "merge_commit": (node.get("mergeCommit") or {}).get("oid"), "paths": [file["path"] for file in ((node.get("files") or {}).get("nodes") or [])], "labels": [label["name"] for label in ((node.get("labels") or {}).get("nodes") or [])], } return states def pull_requests(item, states): return [states[n] for n in (int(ISSUE.match(link)[1]) for link in item.get("links", []) if ISSUE.match(link)) if n in states and states[n]["kind"] == "PullRequest"] def open_issues(item, states): return [n for n in (int(ISSUE.match(link)[1]) for link in item.get("links", []) if ISSUE.match(link)) if n in states and states[n]["kind"] == "Issue" and states[n]["state"] == "OPEN"] def online_problems(data, states, now): """What only GitHub can tell: links that don't exist, Now items nobody works on, Help wanted items already done, items `sync` would ship.""" problems, notes = [], [] recent = now - datetime.timedelta(days=STALE_DAYS) for item in data.get("item", []): where = f"item {item['id']}" for link in item.get("links", []): match = ISSUE.match(link) if match and int(match[1]) not in states: problems.append(f"{where}: {link} is neither an issue nor a pull request of {REPO}") pulls = pull_requests(item, states) status = item.get("status") if status == "now": alive = [p for p in pulls if p["state"] == "OPEN" or (p["merged_at"] and datetime.datetime.fromisoformat(p["merged_at"]) >= recent)] if not alive: problems.append(f"{where}: stale: Now, but no open pull request and none merged in the last " f"{STALE_DAYS} days (link the work, or move it to Next or Later)") if status == "help-wanted": issues = [states[int(ISSUE.match(link)[1])] for link in item.get("links", []) if ISSUE.match(link) and int(ISSUE.match(link)[1]) in states] if issues and all(i["state"] != "OPEN" for i in issues): problems.append(f"{where}: every linked issue and pull request is closed: ship or drop it") if status != "shipped" and pulls and all(p["state"] == "MERGED" for p in pulls): if item.get("ongoing"): notes.append(f"{where}: ongoing, and all its pull requests merged: link the new ones") elif not open_issues(item, states): notes.append(f"{where}: all its pull requests merged: `python3 tools/roadmap.py sync` ships it " "once a release has them") return problems, notes # --- sync ----------------------------------------------------------------------------------------------------- def family(paths): """Which releases carry a change: the launcher's, the website (deployed when merged) or the game's. The site left this repository on 2026-10-26 (horizon-rust/horizon-site), so only older pull requests are website-only; an item the site alone ships takes shipped_in = "website" by hand.""" if paths and all(path.startswith("launcher/") for path in paths): return "launcher" if paths and all(path.startswith("website/") for path in paths): return "website" return "game" def first_release(commit, kind): """The oldest release tag of this kind that contains the commit, or None while none does.""" if kind == "website": return "website" tags = subprocess.run(["git", "tag", "--contains", commit, "--sort=creatordate"], cwd=ROOT, capture_output=True, text=True).stdout.split() wanted = (lambda tag: tag.startswith(("launcher@", "launcher-v"))) if kind == "launcher" else \ (lambda tag: re.match(r"v\d", tag)) return next((tag for tag in tags if wanted(tag)), None) def release_of(pulls): """The release that shipped the last of these merged pull requests, or None.""" kinds = {family(p["paths"]) for p in pulls} kind = "game" if "game" in kinds or len(kinds) > 1 else kinds.pop() latest = max(pulls, key=lambda p: p["merged_at"]) return first_release(latest["merge_commit"], kind) if latest["merge_commit"] else None def item_span(lines, ident): """[start, end) of the item's lines: its [[item]] header to its last key, without the comments that follow.""" starts = [i for i, line in enumerate(lines) if line.strip() == "[[item]]"] for position, start in enumerate(starts): end = starts[position + 1] if position + 1 < len(starts) else len(lines) if not any(re.match(rf'id\s*=\s*"{re.escape(ident)}"\s*$', line) for line in lines[start:end]): continue while end > start and (not lines[end - 1].strip() or re.match(r"#(\s|-|$)", lines[end - 1])): end -= 1 return start, end raise KeyError(ident) def mark_shipped(text, ident, release): """The TOML with this item shipped in `release`, moved to the top of the shipped items (newest first).""" lines = text.split("\n") start, end = item_span(lines, ident) block = [line for line in lines[start:end] if not line.startswith("shipped_in")] block = [f'status = "shipped"\nshipped_in = "{release}"' if re.match(r"status\s*=", line) else line for line in block] block = [line for line in block if not re.match(r"focus\s*=", line)] rest = lines[:start] + lines[end:] # Drop the blank line the block leaves behind. if start < len(rest) and start > 0 and not rest[start - 1].strip() and not rest[start].strip(): del rest[start] shipped = [i for i, line in enumerate(rest) if re.match(r'status\s*=\s*"shipped"', line)] if shipped: header = max(i for i in range(shipped[0] + 1) if rest[i].strip() == "[[item]]") rest[header:header] = block + [""] else: rest += [""] + block return "\n".join(rest) def sync(data, text, states): """Ship every item whose linked pull requests all went out in a release and whose linked issues are all closed, unless it is ongoing. Returns the new text and what happened.""" log = [] for item in data.get("item", []): if item.get("status") == "shipped": continue pulls = pull_requests(item, states) # An ongoing item, or one with an open issue, still has work left whatever its pull requests say. if item.get("ongoing") or open_issues(item, states): continue if not pulls or any(p["state"] != "MERGED" for p in pulls): continue release = release_of(pulls) if release is None: log.append(f"{item['id']}: merged, waiting for a release") continue text = mark_shipped(text, item["id"], release) log.append(f"{item['id']}: shipped in {release}") return text, log # --- labels --------------------------------------------------------------------------------------------------- def label_of(item): """The GitHub label of an item: its name, colour and description (GitHub allows 100 characters).""" description = f"Roadmap, {STATUSES[item['status']].lower()}: {item['title']}" return {"name": LABEL_PREFIX + item["id"], "color": LABEL_COLOURS[item["status"]], "description": description[:100]} def label_changes(data, existing, states): """What `labels` does: [(action, label or number, payload)] for labels to create or update, and open pull requests linked from an item that lack its label. existing: {name: {"color", "description"}}; states as github_states, plus each pull request's "labels".""" changes = [] for item in data.get("item", []): label = label_of(item) have = existing.get(label["name"]) if have is None: changes.append(("create", label["name"], label)) elif (have.get("color", "").lower(), have.get("description") or "") != (label["color"], label["description"]): changes.append(("update", label["name"], label)) for link in item.get("links", []): match = ISSUE.match(link) state = states.get(int(match[1])) if match else None if state and state["kind"] == "PullRequest" and state["state"] == "OPEN" and \ label["name"] not in state.get("labels", []): changes.append(("add", int(match[1]), label["name"])) stale = sorted(set(existing) - {LABEL_PREFIX + item["id"] for item in data.get("item", [])}) return changes, [name for name in stale if name.startswith(LABEL_PREFIX)] def github_rest(method, path, auth, body=None): request = urllib.request.Request( f"https://api.github.com{path}", method=method, data=None if body is None else json.dumps(body).encode(), headers={"Authorization": f"bearer {auth}", "Accept": "application/vnd.github+json", "User-Agent": "horizon-roadmap"}) with urllib.request.urlopen(request, timeout=30) as response: return json.load(response) if response.status != 204 else None def existing_labels(auth): labels, page = {}, 1 while True: batch = github_rest("GET", f"/repos/{REPO}/labels?per_page=100&page={page}", auth) labels.update({label["name"]: label for label in batch}) if len(batch) < 100: return labels page += 1 # --- commands ------------------------------------------------------------------------------------------------- def report(problems): for problem in problems: print(f"roadmap: {problem}", file=sys.stderr) return 1 if problems else 0 def command_build(args): data = load() problems = validate(data) if problems: return report(problems) files = outputs(data) if args.check: stale = [path for path, text in files.items() if not path.exists() or path.read_text() != text] return report([f"{path.relative_to(ROOT)} is out of date: run `python3 tools/roadmap.py build`" for path in stale]) for path, text in files.items(): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text) print(f"roadmap: wrote {path.relative_to(ROOT)}") return 0 def command_check(args): data = load() problems = validate(data) if not problems and not args.offline: states = github_states(numbers_in(data), token()) online, notes = online_problems(data, states, datetime.datetime.now(datetime.timezone.utc)) problems += online for note in notes: print(f"roadmap: {note}") if not problems: print(f"roadmap: {len(data.get('item', []))} items, all good") return report(problems) def command_sync(args): data = load() problems = validate(data) if problems: return report(problems) subprocess.run(["git", "fetch", "--quiet", "--tags", "origin"], cwd=ROOT, check=False) text, log = sync(data, SOURCE.read_text(), github_states(numbers_in(data), token())) for line in log: print(f"roadmap: {line}") if args.dry_run or text == SOURCE.read_text(): return 0 new = tomllib.loads(text) if len(new.get("item", [])) != len(data.get("item", [])) or validate(new): return report(["sync produced an invalid file; nothing written"] + validate(new)) SOURCE.write_text(text) for path, content in outputs(new).items(): path.write_text(content) print("roadmap: wrote roadmap/roadmap.toml, ROADMAP.md and roadmap/roadmap.json") return 0 def command_labels(args): data = load() problems = validate(data) if problems: return report(problems) auth = token() changes, stale = label_changes(data, existing_labels(auth), github_states(numbers_in(data), auth)) for action, target, payload in changes: print(f"roadmap: {action} {target}" + (f" ({payload})" if action == "add" else "")) if args.dry_run: continue if action == "create": github_rest("POST", f"/repos/{REPO}/labels", auth, payload) elif action == "update": github_rest("PATCH", f"/repos/{REPO}/labels/{urllib.parse.quote(target, safe='')}", auth, {"color": payload["color"], "description": payload["description"]}) else: github_rest("POST", f"/repos/{REPO}/issues/{target}/labels", auth, {"labels": [payload]}) for name in stale: print(f"roadmap: {name} names no item any more: rename or delete it by hand (it stays on its pull requests)") if not changes: print("roadmap: labels up to date") return 0 def main(argv=None): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) commands = parser.add_subparsers(dest="command", required=True) build = commands.add_parser("build", help="write ROADMAP.md") build.add_argument("--check", action="store_true", help="fail when ROADMAP.md is out of date") check = commands.add_parser("check", help="the roadmap's rules, and GitHub's state of its links") check.add_argument("--offline", action="store_true", help="skip GitHub") sync_command = commands.add_parser("sync", help="ship the items whose pull requests all merged") sync_command.add_argument("--dry-run", action="store_true", help="say what would change, write nothing") labels = commands.add_parser("labels", help="a roadmap: label per item, on its linked open pull requests") labels.add_argument("--dry-run", action="store_true", help="say what would change, change nothing") args = parser.parse_args(argv) return {"build": command_build, "check": command_check, "sync": command_sync, "labels": command_labels}[args.command](args) if __name__ == "__main__": sys.exit(main())