Files
neuron-tai/scripts/ralph_gitea_sync.py
2026-07-16 23:37:03 +03:00

207 lines
8.3 KiB
Python

#!/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<!-- ralph-story-id: {story['id']} -->\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")
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"<!-- ralph-story-id: DGR-"
body = issue.get("body") or ""
if marker in body:
story_id = body.split(marker, 1)[1].split(" -->", 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 = 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}")
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())