#!/usr/bin/env python3 """Synchronize the authoritative Ralph PRD with Gitea issues. Gitea is a projection of prd.json. Story completion remains authoritative in prd.json; this tool only creates/reconciles issue bodies, labels, milestones, and open/closed/in-progress presentation. """ from __future__ import annotations import argparse import base64 import hashlib import json import os import subprocess import sys import urllib.error import urllib.parse import urllib.request from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] DEFAULT_FEATURE = ROOT / ".scratch" / "distributed-gguf-runtime" STATUS_LABELS = { "ready": ("status:ready", "Ready for Ralph execution"), "in-progress": ("status:in-progress", "Currently selected by Ralph"), "blocked": ("status:blocked", "Blocked by incomplete dependencies or human gate"), "completed": ("status:completed", "Completed and verified in prd.json"), } def credential_for(host: str) -> tuple[str, str]: result = subprocess.run( ["git", "credential", "fill"], input=f"protocol=https\nhost={host}\n\n", text=True, capture_output=True, check=True, ) values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) username, password = values.get("username"), values.get("password") if not password: raise RuntimeError(f"No Git credential available for {host}") return username or "", password class Gitea: def __init__(self, base_url: str, username: str, password: str, dry_run: bool = False): self.base = base_url.rstrip("/") self.auth = "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode() self.dry_run = dry_run def request(self, method: str, path: str, payload: Any = None) -> Any: url = self.base + path body = None if payload is None else json.dumps(payload).encode() req = urllib.request.Request( url, data=body, method=method, headers={ "Authorization": self.auth, "Accept": "application/json", "Content-Type": "application/json", }, ) if self.dry_run and method not in {"GET", "HEAD"}: print(f"DRY-RUN {method} {path}") return {} try: with urllib.request.urlopen(req, timeout=60) as response: raw = response.read() return json.loads(raw) if raw else {} except urllib.error.HTTPError as exc: detail = exc.read().decode(errors="replace")[:500] raise RuntimeError(f"Gitea {method} {path} -> HTTP {exc.code}: {detail}") from exc def paginate(self, path: str) -> list[dict[str, Any]]: result: list[dict[str, Any]] = [] page = 1 while True: separator = "&" if "?" in path else "?" batch = self.request("GET", f"{path}{separator}limit=50&page={page}") if not batch: return result result.extend(batch) if len(batch) < 50: return result page += 1 def color(name: str) -> str: return "#" + hashlib.sha256(name.encode()).hexdigest()[:6] def load_schema(feature: Path) -> Any: module_path = ROOT / "scripts" / "ralph_prd_schema.py" import importlib.util spec = importlib.util.spec_from_file_location("ralph_prd_schema", module_path) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def issue_body(schema: Any, story: dict[str, Any]) -> str: data = schema.load_prd(DEFAULT_FEATURE / "prd.json") return schema.render_issue_markdown(story, data) + f"\n\n" def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("command", choices=["sync"]) parser.add_argument("--feature-dir", type=Path, default=DEFAULT_FEATURE) parser.add_argument("--repo-url", default="https://git.d-popov.com/popov/neuron-tai") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--reset-in-progress", action="store_true", help="Do not claim a next story after a failed Ralph start") args = parser.parse_args() feature = args.feature_dir.resolve() prd_path = feature / "prd.json" data = json.loads(prd_path.read_text()) stories = data["userStories"] repo = urllib.parse.urlparse(args.repo_url) if not repo.hostname or not repo.path.strip("/"): raise SystemExit("--repo-url must be an HTTPS Gitea repository URL") owner, name = repo.path.strip("/").removesuffix(".git").split("/", 1) api_base = f"{repo.scheme}://{repo.hostname}/api/v1/repos/{owner}/{name}" username, password = credential_for(repo.hostname) gitea = Gitea(api_base, username, password, args.dry_run) schema = load_schema(feature) schema_errors = schema.validate_backlog(data) if schema_errors: raise SystemExit("PRD validation failed:\n" + "\n".join(schema_errors)) labels = {x["name"]: x for x in gitea.paginate("/labels")} milestones = {x["title"]: x for x in gitea.paginate("/milestones?state=all")} issue_list = gitea.paginate("/issues?state=all") by_story = {} for issue in issue_list: marker = f"", 1)[0] by_story["DGR-" + story_id] = issue elif issue.get("title", "").startswith("DGR-"): by_story[issue["title"].split(":", 1)[0]] = issue def ensure_label(name_: str, description: str = "") -> dict[str, Any]: if name_ in labels: return labels[name_] created = gitea.request("POST", "/labels", {"name": name_, "color": color(name_), "description": description}) if args.dry_run: created = {"id": -len(labels) - 1, "name": name_} labels[name_] = created return created for story in stories: for label in story["labels"]: ensure_label(label) for label, description in STATUS_LABELS.values(): ensure_label(label, description) for milestone in data.get("milestones", []): title = milestone["id"] if title not in milestones: milestones[title] = gitea.request("POST", "/milestones", {"title": title, "description": milestone["name"] + ": " + milestone["outcome"]}) if args.dry_run: milestones[title] = {"id": -len(milestones), "title": title} passed = {s["id"] for s in stories if s.get("passes")} next_story = None if args.reset_in_progress else next( ( s for s in stories if not s.get("passes") and s.get("triage") == "ready-for-agent" and all(d in passed for d in s["dependsOn"]) ), None, ) mapping = {} for story in stories: sid = story["id"] desired_status = "completed" if story.get("passes") else ("in-progress" if next_story and sid == next_story["id"] else ("ready" if all(d in passed for d in story["dependsOn"]) else "blocked")) label_names = list(dict.fromkeys(story["labels"] + [STATUS_LABELS[desired_status][0]])) label_ids = [ensure_label(label)["id"] for label in label_names] milestone_id = milestones[story["milestone"]]["id"] title = f"{sid}: {story['title']}" body = issue_body(schema, story) issue = by_story.get(sid) payload = {"title": title, "body": body, "labels": label_ids, "milestone": milestone_id, "state": "closed" if story.get("passes") else "open"} if issue is None: issue = gitea.request("POST", "/issues", payload) print(f"created {sid} #{issue.get('number')} {desired_status}") else: issue = gitea.request("PATCH", f"/issues/{issue['number']}", payload) print(f"reconciled {sid} #{issue.get('number')} {desired_status}") # Gitea's issue PATCH does not reliably replace labels; use the # dedicated endpoint so stale status:in-progress labels are removed. gitea.request("PUT", f"/issues/{issue['number']}/labels", {"labels": label_ids}) mapping[sid] = {"number": issue.get("number"), "url": issue.get("html_url"), "state": "closed" if story.get("passes") else "open", "status": desired_status} if not args.dry_run: (feature / "gitea-issues.json").write_text(json.dumps({"repository": args.repo_url, "stories": mapping}, indent=2) + "\n") print(f"synced={len(stories)} next={next_story['id'] if next_story else 'none'} dry_run={args.dry_run}") return 0 if __name__ == "__main__": raise SystemExit(main())