#!/usr/bin/env python3 """Create the roadmap milestones in Gitea. Milestones mirror the phases in llmgw-plan.md §11 and the versions in CHANGELOG.md, so an issue can be filed against the phase that owns it. Idempotent: a milestone whose title already exists is left untouched, including its state and due date. Nothing is ever deleted. Usage: GITEA_TOKEN= scripts/gitea/milestones.py [--repo ilker/llmgw] [--dry-run] The token needs write access to the repository. Create one under Settings → Applications → Access Tokens with the `write:issue` scope. """ import argparse import json import os import sys import urllib.error import urllib.request DEFAULT_INSTANCE = "https://gitea.mynodes.xyz" DEFAULT_REPO = "ilker/llmgw" # Due dates are derived from the durations in plan §11 laid end to end from # 2026-09-16. They are estimates, not commitments; adjust them in Gitea rather # than here once reality disagrees. MILESTONES = [ { "title": "v0.1.0 — Phase 0: skeleton and sim-engine", "description": ( "CLI skeleton plus the fake inference engine everything else is tested " "against. Exit criterion: sim-engine models batching, KV blocks, prefix " "caching and cancellation, and exposes the §7.6 metric contract." ), "due_on": "2026-09-16T12:00:00Z", "closed": True, }, { "title": "v0.2.0 — Phase 1: static router", "description": ( "OpenAI handlers, streaming proxy with cancellation propagation, a static " "endpoint list, the filter/score/pick pipeline and metric scraping.\n\n" "Exit criterion: 1000 concurrent streams behind 3 sim-engines; upstream " "cancellation on client disconnect verified; router overhead p99 < 2 ms." ), "due_on": "2026-09-30T12:00:00Z", }, { "title": "v0.3.0 — Phase 1.5: admission control", "description": ( "Saturation detector, priority bands, round-robin tenant fairness, queue " "capacity and TTL.\n\n" "Exit criterion: while 500 concurrent batch requests flow through the " "bronze band, interactive TTFT p99 in the platinum band stays under 1.5x " "its idle value. This test stays in CI permanently." ), "due_on": "2026-10-07T12:00:00Z", }, { "title": "v0.4.0 — Phase 2: agent", "description": ( "GPU and IB discovery, the engine interface, the process supervisor and " "the control plane stream.\n\n" "Exit criterion: start and stop a model on a real node via ctl." ), "due_on": "2026-10-21T12:00:00Z", }, { "title": "v0.5.0 — Phase 3: control plane", "description": ( "SQLite store, catalog, inventory, single-node placement, reconciler, and " "a dynamic EndpointSource for the router.\n\n" "Exit criterion: shutting down a node moves its replicas elsewhere while " "the router keeps serving." ), "due_on": "2026-11-11T12:00:00Z", }, { "title": "v0.6.0 — Phase 4: multi-node and fabrics", "description": ( "Fabric identity, multi-node placement under the fabric constraint, NCCL " "environment generation, replica-level failure handling.\n\n" "Exit criterion: a 2-node replica on each fabric; killing a node rebuilds " "the replica on the same fabric and never spreads it across two." ), "due_on": "2026-11-25T12:00:00Z", }, { "title": "v1.0.0 — Phase 5: production features", "description": ( "Tenancy and budgets, routing profiles from configuration, the approximate " "prefix tree, fallback models and pool spillover, ctl bench, tracing, " "systemd units and documentation." ), "due_on": "2026-12-16T12:00:00Z", }, { "title": "Phase 6 — v2 ideas", "description": ( "No due date; a parking lot. Precise KV index, admission displacement, " "autoscaling with preemption, control plane HA, prefill/decode " "disaggregation, LoRA routing, external providers, admin UI." ), }, ] def request(method, url, token, payload=None): data = json.dumps(payload).encode() if payload is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Authorization", f"token {token}") req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req, timeout=30) as resp: body = resp.read() return json.loads(body) if body else None except urllib.error.HTTPError as e: detail = e.read().decode(errors="replace")[:300] raise SystemExit(f"{method} {url} failed: {e.code} {detail}") from e def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--instance", default=os.environ.get("GITEA_INSTANCE", DEFAULT_INSTANCE)) ap.add_argument("--repo", default=os.environ.get("GITEA_REPO", DEFAULT_REPO)) ap.add_argument("--dry-run", action="store_true", help="show what would be created") args = ap.parse_args() token = os.environ.get("GITEA_TOKEN", "") if not token and not args.dry_run: sys.exit("GITEA_TOKEN is not set (use --dry-run to preview)") base = f"{args.instance.rstrip('/')}/api/v1/repos/{args.repo}/milestones" existing = {} if not args.dry_run: for m in request("GET", f"{base}?state=all&limit=100", token) or []: existing[m["title"]] = m for spec in MILESTONES: title = spec["title"] if title in existing: print(f"skip {title} (already exists, id={existing[title]['id']})") continue if args.dry_run: print(f"create {title}") continue payload = {"title": title, "description": spec.get("description", "")} if "due_on" in spec: payload["due_on"] = spec["due_on"] created = request("POST", base, token, payload) print(f"create {title} (id={created['id']})") # The create endpoint ignores `state`, so close it separately. if spec.get("closed"): request("PATCH", f"{base}/{created['id']}", token, {"state": "closed"}) print(f"close {title}") if __name__ == "__main__": main()