feat: sync Ralph stories with Gitea issues
This commit is contained in:
12
scripts/ralph-gitea-run.sh
Normal file
12
scripts/ralph-gitea-run.sh
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run one or more supervised Ralph iterations with Gitea state reconciliation.
|
||||
# The canonical PRD remains authoritative; Gitea is updated before and after Ralph.
|
||||
set -u
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
python3 scripts/ralph_gitea_sync.py sync
|
||||
ralph_status=0
|
||||
"$@" || ralph_status=$?
|
||||
python3 scripts/ralph_gitea_sync.py sync
|
||||
exit "$ralph_status"
|
||||
206
scripts/ralph_gitea_sync.py
Normal file
206
scripts/ralph_gitea_sync.py
Normal file
@@ -0,0 +1,206 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user