diff --git a/.scratch/distributed-gguf-runtime/RALPH-CONTEXT.md b/.scratch/distributed-gguf-runtime/RALPH-CONTEXT.md index 221abf1..3ccbd55 100644 --- a/.scratch/distributed-gguf-runtime/RALPH-CONTEXT.md +++ b/.scratch/distributed-gguf-runtime/RALPH-CONTEXT.md @@ -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//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//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. diff --git a/.scratch/distributed-gguf-runtime/gitea-issues.json b/.scratch/distributed-gguf-runtime/gitea-issues.json new file mode 100644 index 0000000..1c4bea2 --- /dev/null +++ b/.scratch/distributed-gguf-runtime/gitea-issues.json @@ -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" + } + } +} diff --git a/scripts/ralph-gitea-run.sh b/scripts/ralph-gitea-run.sh new file mode 100644 index 0000000..812b903 --- /dev/null +++ b/scripts/ralph-gitea-run.sh @@ -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" diff --git a/scripts/ralph_gitea_sync.py b/scripts/ralph_gitea_sync.py new file mode 100644 index 0000000..3bbbefd --- /dev/null +++ b/scripts/ralph_gitea_sync.py @@ -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\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"", 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())