207 lines
8.3 KiB
Python
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())
|