feat: sync Ralph stories with Gitea issues

This commit is contained in:
Dobromir Popov
2026-07-16 23:37:03 +03:00
parent 9db036f91a
commit 9e70b94417
4 changed files with 570 additions and 1 deletions

View File

@@ -36,6 +36,22 @@ Meshnet continues to own registration, coverage, existing route selection/load b
The project-owned `ShardEngine` hides llama.cpp internals. A worker loads one exact artifact/recipe/range identity. Default tests use fake/tiny fixtures. Real runs are opt-in, preserve raw metrics, and never download models under `/home`.
## Gitea issue synchronization
Gitea is a projection of `prd.json`, never a competing source of truth. Before and after every supervised Ralph run, invoke:
```bash
python3 scripts/ralph_gitea_sync.py sync
```
For a complete Ralph invocation with automatic state reconciliation, use:
```bash
scripts/ralph-gitea-run.sh ralph-tui run --prd .scratch/distributed-gguf-runtime/prd.json --agent claude --model sonnet --iterations 1 --no-tui --no-setup --direct-merge --no-sandbox
```
The sync creates/reconciles one Gitea issue per `DGR-*` story, creates missing labels/milestones, closes issues whose `passes` is true, marks the selected next eligible story `status:in-progress`, and marks blocked stories `status:blocked`. `gitea-issues.json` is a derived mapping only.
## Evidence and completion
Each story writes `/run/media/popov/d/DEV/repos/d-popov.com/AI/.claude/worktrees/distributed-gguf-runtime/.scratch/distributed-gguf-runtime/evidence/<DGR-ID>/README.md` with exact files, commands/results, limitations, identities, and dependent-story handoff. Only `prd.json` may record `passes`; all 55 are false now. Generated Markdown cannot override it. One scoped commit per story is expected during future execution, but this plan materialization is intentionally uncommitted.
Each story writes `/run/media/popov/d/DEV/repos/d-popov.com/AI/.claude/worktrees/distributed-gguf-runtime/.scratch/distributed-gguf-runtime/evidence/<DGR-ID>/README.md` with exact files, commands/results, limitations, identities, and dependent-story handoff. Only `prd.json` may record `passes`; DGR-017 and DGR-018 are complete and DGR-019 onward remain false. Generated Markdown and Gitea issues cannot override it. One scoped commit per story is expected during future execution.

View File

@@ -0,0 +1,335 @@
{
"repository": "https://git.d-popov.com/popov/neuron-tai",
"stories": {
"DGR-017": {
"number": 1,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/1",
"state": "closed",
"status": "completed"
},
"DGR-018": {
"number": 2,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/2",
"state": "closed",
"status": "completed"
},
"DGR-019": {
"number": 3,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/3",
"state": "open",
"status": "ready"
},
"DGR-020": {
"number": 4,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/4",
"state": "open",
"status": "blocked"
},
"DGR-021": {
"number": 5,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/5",
"state": "open",
"status": "in-progress"
},
"DGR-022": {
"number": 6,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/6",
"state": "open",
"status": "blocked"
},
"DGR-023": {
"number": 7,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/7",
"state": "open",
"status": "blocked"
},
"DGR-024": {
"number": 8,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/8",
"state": "open",
"status": "blocked"
},
"DGR-025": {
"number": 9,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/9",
"state": "open",
"status": "blocked"
},
"DGR-026": {
"number": 10,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/10",
"state": "open",
"status": "blocked"
},
"DGR-027": {
"number": 11,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/11",
"state": "open",
"status": "ready"
},
"DGR-028": {
"number": 12,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/12",
"state": "open",
"status": "blocked"
},
"DGR-029": {
"number": 13,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/13",
"state": "open",
"status": "blocked"
},
"DGR-030": {
"number": 14,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/14",
"state": "open",
"status": "blocked"
},
"DGR-031": {
"number": 15,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/15",
"state": "open",
"status": "blocked"
},
"DGR-032": {
"number": 16,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/16",
"state": "open",
"status": "blocked"
},
"DGR-033": {
"number": 17,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/17",
"state": "open",
"status": "blocked"
},
"DGR-034": {
"number": 18,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/18",
"state": "open",
"status": "blocked"
},
"DGR-035": {
"number": 19,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/19",
"state": "open",
"status": "blocked"
},
"DGR-036": {
"number": 20,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/20",
"state": "open",
"status": "blocked"
},
"DGR-037": {
"number": 21,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/21",
"state": "open",
"status": "blocked"
},
"DGR-038": {
"number": 22,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/22",
"state": "open",
"status": "blocked"
},
"DGR-039": {
"number": 23,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/23",
"state": "open",
"status": "blocked"
},
"DGR-040": {
"number": 24,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/24",
"state": "open",
"status": "blocked"
},
"DGR-041": {
"number": 25,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/25",
"state": "open",
"status": "blocked"
},
"DGR-042": {
"number": 26,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/26",
"state": "open",
"status": "blocked"
},
"DGR-043": {
"number": 27,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/27",
"state": "open",
"status": "blocked"
},
"DGR-044": {
"number": 28,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/28",
"state": "open",
"status": "blocked"
},
"DGR-045": {
"number": 29,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/29",
"state": "open",
"status": "blocked"
},
"DGR-046": {
"number": 30,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/30",
"state": "open",
"status": "blocked"
},
"DGR-047": {
"number": 31,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/31",
"state": "open",
"status": "blocked"
},
"DGR-048": {
"number": 32,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/32",
"state": "open",
"status": "blocked"
},
"DGR-049": {
"number": 33,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/33",
"state": "open",
"status": "blocked"
},
"DGR-050": {
"number": 34,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/34",
"state": "open",
"status": "blocked"
},
"DGR-051": {
"number": 35,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/35",
"state": "open",
"status": "blocked"
},
"DGR-052": {
"number": 36,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/36",
"state": "open",
"status": "blocked"
},
"DGR-053": {
"number": 37,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/37",
"state": "open",
"status": "blocked"
},
"DGR-054": {
"number": 38,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/38",
"state": "open",
"status": "blocked"
},
"DGR-055": {
"number": 39,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/39",
"state": "open",
"status": "blocked"
},
"DGR-056": {
"number": 40,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/40",
"state": "open",
"status": "blocked"
},
"DGR-057": {
"number": 41,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/41",
"state": "open",
"status": "blocked"
},
"DGR-058": {
"number": 42,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/42",
"state": "open",
"status": "blocked"
},
"DGR-059": {
"number": 43,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/43",
"state": "open",
"status": "blocked"
},
"DGR-060": {
"number": 44,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/44",
"state": "open",
"status": "blocked"
},
"DGR-061": {
"number": 45,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/45",
"state": "open",
"status": "blocked"
},
"DGR-062": {
"number": 46,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/46",
"state": "open",
"status": "blocked"
},
"DGR-063": {
"number": 47,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/47",
"state": "open",
"status": "blocked"
},
"DGR-064": {
"number": 48,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/48",
"state": "open",
"status": "blocked"
},
"DGR-065": {
"number": 49,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/49",
"state": "open",
"status": "blocked"
},
"DGR-066": {
"number": 50,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/50",
"state": "open",
"status": "blocked"
},
"DGR-067": {
"number": 51,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/51",
"state": "open",
"status": "blocked"
},
"DGR-068": {
"number": 52,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/52",
"state": "open",
"status": "blocked"
},
"DGR-069": {
"number": 53,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/53",
"state": "open",
"status": "blocked"
},
"DGR-070": {
"number": 54,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/54",
"state": "open",
"status": "blocked"
},
"DGR-071": {
"number": 55,
"url": "https://git.d-popov.com/popov/neuron-tai/issues/55",
"state": "open",
"status": "blocked"
}
}
}

View 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
View 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())