replica-omnisciente/scripts/sync-mirrors.py
Raphael Cautus (Maestro) 2f26f2d836 feat(scripts): onboarding, GWS, lifestream, muscriptor, music, data-sharing
- onboard-client.py: client replica scaffolding CLI
- gws/: Google Workspace sync (Gmail, Calendar, Drive)
- lifestream/: life event stream collector
- muscriptor-mcp/: audio → MIDI MCP server
- music-mcp/: music library MCP server
- data_sharing/: consent-gated data sharing (Python + TS)
- sync-mirrors.py: GitHub → Forgejo mirror engine
- brain-to-gbrain.py, vault-sync.py, test-all.sh
- shared/: TS data-sharing library + index
- dirac: provider registry update
- .gitignore: exclude Rust build artifacts

Co-authored-by: Álvaro de Campos <campos@portugalfuturista.org>
2026-07-31 14:58:02 +01:00

788 lines
34 KiB
Python

#!/usr/bin/env python3
"""
Mirror pipeline: replicate upstream sources (GitHub, Jira, Confluence) into
self-hosted mirrors (Forgejo, Plane, Outline).
Single source of truth: .aurelio/mirrors/sync-mirrors.yaml
Generated dist: .aurelio/mirrors/dist/*.json (catalog for surfaces)
Three modes:
--write-in-place Regenerate dist/ mirrors from the YAML registry.
--check CI guard: exit 1 if dist/ is stale.
--sync [TARGET] Run live mirror sync (forgejo | plane | outline | all).
--emit-trajectory-rewards Append RL reward signals from last sync drift.
Auth: tokens are read from environment variables named in the YAML (token_env).
They are NEVER hardcoded. Fetch from Vaultwarden and export before --sync.
Usage:
export FORGEJO_MIRROR_TOKEN=...
export GITHUB_MIRROR_TOKEN=...
python3 scripts/sync-mirrors.py --sync forgejo
python3 scripts/sync-mirrors.py --write-in-place
python3 scripts/sync-mirrors.py --check
The --sync path is idempotent: re-running only touches what changed. Each
target's sync returns a summary dict; failures are logged but don't abort
siblings unless defaults.fail_fast is true.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError:
print("PyYAML required: pip install pyyaml", file=sys.stderr)
sys.exit(2)
REPLICA_ROOT = Path(__file__).resolve().parent.parent
REGISTRY = REPLICA_ROOT / ".aurelio" / "mirrors" / "sync-mirrors.yaml"
DIST_DIR = REPLICA_ROOT / ".aurelio" / "mirrors" / "dist"
REWARD_PATH = REPLICA_ROOT / ".aurelio" / "brain" / "trajectory-rewards" / "mirror-sync.jsonl"
# ── YAML load ──────────────────────────────────────────────────────────────
def load_registry() -> dict[str, Any]:
with REGISTRY.open("r", encoding="utf-8") as f:
return yaml.safe_load(f)
def _env(name: str | None) -> str | None:
if not name:
return None
return os.environ.get(name)
# ── HTTP helper ────────────────────────────────────────────────────────────
def _request(
method: str,
url: str,
*,
token: str | None = None,
basic_auth: tuple[str, str] | None = None,
json_body: dict | None = None,
accept: str = "application/json",
timeout: int = 30,
user_agent: str = "replica-omnisciente-mirror/1.0",
) -> tuple[int, dict | str]:
"""Minimal urllib wrapper. Returns (status_code, parsed_json | raw_text)."""
headers = {"Accept": accept, "User-Agent": user_agent}
data = None
if json_body is not None:
data = json.dumps(json_body).encode()
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
if basic_auth:
import base64
cred = base64.b64encode(f"{basic_auth[0]}:{basic_auth[1]}".encode()).decode()
headers["Authorization"] = f"Basic {cred}"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode("utf-8")
try:
return resp.status, json.loads(body)
except json.JSONDecodeError:
return resp.status, body
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
try:
return e.code, json.loads(body)
except json.JSONDecodeError:
return e.code, body
except urllib.error.URLError as e:
return 0, str(e.reason)
# ════════════════════════════════════════════════════════════════════════════
# FORGEJO: GitHub → Forgejo code mirror
# ════════════════════════════════════════════════════════════════════════════
def _forgejo_headers(token: str) -> dict:
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
def _github_list_repos(org: str | None, user: str | None, token: str, ua: str) -> list[dict]:
"""List repos from a GitHub org or user account."""
repos = []
base = "https://api.github.com"
if org:
url = f"{base}/orgs/{org}/repos?per_page=100&type=all"
elif user:
url = f"{base}/users/{user}/repos?per_page=100&type=all"
else:
return []
page = 1
while True:
paged_url = f"{url}&page={page}"
status, data = _request("GET", paged_url, token=token, user_agent=ua)
if status != 200 or not isinstance(data, list):
break
repos.extend(data)
if len(data) < 100:
break
page += 1
return repos
def sync_forgejo(reg: dict, target_cfg: dict, dry: bool = False) -> dict:
"""Mirror GitHub repos into Forgejo using the migration API."""
forgejo_url = target_cfg["endpoint"]
forgejo_token = _env(target_cfg.get("token_env", ""))
default_owner = target_cfg.get("default_owner", "mirror")
ua = reg.get("defaults", {}).get("user_agent", "replica-mirror/1.0")
summary: dict[str, Any] = {"target": "forgejo", "mirrored": [], "skipped": [], "errors": []}
if not forgejo_token and not dry:
summary["errors"].append(f"missing env {target_cfg.get('token_env')} — export it first")
return summary
for src in target_cfg.get("sources", []):
gh_token = _env(src.get("token_env", "")) or ""
include_forks = src.get("include_forks", False)
if dry and not gh_token:
# In dry-run without a token, report intent without hitting the API.
summary["mirrored"].append({
"source": src.get("id"),
"dry": True,
"note": f"would list {src.get('type')} and create mirrors",
})
continue
if src["type"] == "github-org":
repos = _github_list_repos(org=src["org"], user=None, token=gh_token, ua=ua)
elif src["type"] == "github-user":
repos = _github_list_repos(org=None, user=src["user"], token=gh_token, ua=ua)
# Apply include/exclude globs.
import fnmatch
inc = src.get("include_patterns", ["*"])
exc = src.get("exclude_patterns", [])
repos = [
r for r in repos
if any(fnmatch.fnmatch(r["name"], p) for p in inc)
and not any(fnmatch.fnmatch(r["name"], p) for p in exc)
]
else:
summary["errors"].append(f"unknown source type {src['type']} for forgejo")
continue
for repo in repos:
if repo.get("fork") and not include_forks:
summary["skipped"].append({"repo": repo["full_name"], "reason": "fork"})
continue
if repo.get("archived"):
summary["skipped"].append({"repo": repo["full_name"], "reason": "archived"})
continue
owner = default_owner
repo_name = repo["name"]
clone_url = repo["clone_url"] # HTTPS
if src.get("clone_transport") == "ssh":
clone_url = repo.get("ssh_url", clone_url)
# Check if mirror already exists in Forgejo.
check_url = f"{forgejo_url}/api/v1/repos/{owner}/{repo_name}"
if not dry and forgejo_token:
st, _ = _request("GET", check_url, token=forgejo_token, user_agent=ua)
if st == 200:
summary["skipped"].append({"repo": f"{owner}/{repo_name}", "reason": "exists"})
continue
# Create migration (mirror=true makes Forgejo poll upstream).
migrate_url = f"{forgejo_url}/api/v1/repos/migrate"
body = {
"clone_addr": clone_url,
"repo_owner": owner,
"repo_name": repo_name,
"service": "github",
"auth_token": gh_token or None,
"mirror": True,
"private": repo.get("private", False),
"description": repo.get("description", "")[:255],
"wiki": False,
"issues": False,
"labels": src.get("topic_labels", False),
"pull_requests": False,
"releases": True,
}
if dry:
summary["mirrored"].append({"repo": f"{owner}/{repo_name}", "dry": True})
continue
st, resp = _request("POST", migrate_url, token=forgejo_token, json_body=body, user_agent=ua, timeout=120)
if st in (200, 201):
summary["mirrored"].append({"repo": f"{owner}/{repo_name}", "id": resp.get("id") if isinstance(resp, dict) else None})
else:
summary["errors"].append({"repo": repo["full_name"], "status": st, "detail": str(resp)[:300]})
return summary
# ════════════════════════════════════════════════════════════════════════════
# PLANE: Jira → Plane issue mirror
# ════════════════════════════════════════════════════════════════════════════
def _jira_get(url: str, email: str, token: str, ua: str, params: str = "") -> dict:
full = f"{url}{params}"
st, data = _request("GET", full, basic_auth=(email, token), user_agent=ua)
return {"status": st, "data": data}
def _plane_headers(token: str) -> dict:
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
def sync_plane(reg: dict, target_cfg: dict, dry: bool = False) -> dict:
"""Mirror Jira issues into Plane."""
plane_url = target_cfg["endpoint"].rstrip("/")
plane_token = _env(target_cfg.get("token_env", ""))
ws = target_cfg.get("workspace_slug", "savearth")
ua = reg.get("defaults", {}).get("user_agent", "replica-mirror/1.0")
summary: dict[str, Any] = {"target": "plane", "projects": [], "issues": {"synced": 0, "skipped": 0}, "errors": []}
if not plane_token and not dry:
summary["errors"].append(f"missing env {target_cfg.get('token_env')}")
return summary
for src in target_cfg.get("sources", []):
if src["type"] != "jira-cloud":
summary["errors"].append(f"unknown source type {src['type']} for plane")
continue
jira_url = src.get("endpoint")
if not jira_url:
summary["errors"].append({"source": src["id"], "reason": "Jira endpoint is null — set it in sync-mirrors.yaml"})
continue
jira_token = _env(src.get("token_env", "")) or ""
jira_email = _env(src.get("email_env", "")) or ""
if not dry and (not jira_token or not jira_email):
summary["errors"].append(f"missing Jira creds ({src.get('token_env')} / {src.get('email_env')})")
continue
# 1. List Jira projects.
projects_resp = _jira_get(jira_url, jira_email, jira_token, ua, "/rest/api/3/project")
if projects_resp["status"] != 200:
summary["errors"].append({"step": "jira projects", "status": projects_resp["status"], "detail": str(projects_resp["data"])[:300]})
continue
jira_projects = projects_resp["data"] or []
if src.get("projects"):
wanted = set(src["projects"])
jira_projects = [p for p in jira_projects if p.get("key") in wanted]
for jp in jira_projects:
pkey = jp["key"]
pname = jp.get("name", pkey)
# 2. Ensure Plane project exists.
if dry:
summary["projects"].append({"key": pkey, "name": pname, "dry": True})
continue
# List existing Plane projects to find by name.
st, existing = _request("GET", f"{plane_url}/api/v1/workspaces/{ws}/projects/", token=plane_token, user_agent=ua)
project_id = None
if st == 200 and isinstance(existing, list):
for ep in existing:
if ep.get("name", "").lower() == pname.lower() or ep.get("identifier", "").upper() == pkey:
project_id = ep.get("id")
break
if not project_id:
body = {"name": pname, "identifier": pkey[:8]}
st, resp = _request("POST", f"{plane_url}/api/v1/workspaces/{ws}/projects/", token=plane_token, json_body=body, user_agent=ua)
if st in (200, 201) and isinstance(resp, dict):
project_id = resp.get("id")
summary["projects"].append({"key": pkey, "id": project_id, "created": True})
else:
summary["errors"].append({"step": f"create plane project {pkey}", "status": st, "detail": str(resp)[:300]})
continue
else:
summary["projects"].append({"key": pkey, "id": project_id, "existed": True})
if not project_id:
continue
# 3. Fetch Jira issues for this project.
jql = f"project = {pkey} ORDER BY updated DESC"
issues_resp = _jira_get(jira_url, jira_email, jira_token, ua, f"/rest/api/3/search?jql={urllib.parse.quote(jql)}&maxResults=100")
if issues_resp["status"] != 200:
summary["errors"].append({"step": f"jira issues {pkey}", "status": issues_resp["status"]})
continue
jira_issues = (issues_resp["data"] or {}).get("issues", [])
# 4. List existing Plane issues to avoid duplicates (by external_id or name match).
st, plane_issues = _request("GET", f"{plane_url}/api/v1/workspaces/{ws}/projects/{project_id}/issues/", token=plane_token, user_agent=ua)
existing_names = set()
if st == 200 and isinstance(plane_issues, list):
for ei in plane_issues:
existing_names.add(ei.get("name", ""))
# 5. Fetch Plane states + priorities for mapping.
st, states = _request("GET", f"{plane_url}/api/v1/workspaces/{ws}/projects/{project_id}/states/", token=plane_token, user_agent=ua)
st, priorities = _request("GET", f"{plane_url}/api/v1/workspaces/{ws}/projects/{project_id}/priorities/", token=plane_token, user_agent=ua)
for ji in jira_issues:
fields = ji.get("fields", {})
name = fields.get("summary", f"{pkey}-{ji.get('key','')}")
if name in existing_names:
summary["issues"]["skipped"] += 1
continue
# Convert Jira description (ADF) to simple HTML/text for Plane.
desc = _adf_to_html(fields.get("description"))
body = {
"name": name,
"description_html": desc,
"priority": _map_priority(fields.get("priority", {}).get("name", "")),
}
if dry:
summary["issues"]["synced"] += 1
continue
st, resp = _request("POST", f"{plane_url}/api/v1/workspaces/{ws}/projects/{project_id}/issues/", token=plane_token, json_body=body, user_agent=ua)
if st in (200, 201):
summary["issues"]["synced"] += 1
else:
summary["errors"].append({"issue": ji.get("key"), "status": st, "detail": str(resp)[:200]})
return summary
def _adf_to_html(adf: Any) -> str:
"""Convert Atlassian Document Format (ADF) to minimal HTML for Plane."""
if adf is None:
return ""
if isinstance(adf, str):
return f"<p>{adf}</p>"
if not isinstance(adf, dict):
return ""
parts = []
for block in adf.get("content", []):
btype = block.get("type", "")
texts = []
for node in block.get("content", []):
if node.get("type") == "text":
texts.append(node.get("text", ""))
text = "".join(texts)
if btype == "heading":
level = block.get("attrs", {}).get("level", 2)
parts.append(f"<h{level}>{text}</h{level}>")
elif btype == "paragraph":
parts.append(f"<p>{text}</p>")
elif btype == "codeBlock":
parts.append(f"<pre><code>{text}</code></pre>")
elif btype == "bulletList":
parts.append(f"<ul><li>{text}</li></ul>")
else:
parts.append(f"<p>{text}</p>")
return "\n".join(parts)
def _map_priority(jira_prio: str) -> str:
"""Map Jira priority names to Plane urgency values."""
m = {
"Highest": "urgent",
"High": "high",
"Medium": "medium",
"Low": "low",
"Lowest": "low",
}
return m.get(jira_prio, "none")
# ════════════════════════════════════════════════════════════════════════════
# OUTLINE: Confluence → Outline doc mirror
# ════════════════════════════════════════════════════════════════════════════
def sync_outline(reg: dict, target_cfg: dict, dry: bool = False) -> dict:
"""Mirror Confluence pages into Outline documents."""
outline_url = target_cfg["endpoint"].rstrip("/")
outline_token = _env(target_cfg.get("token_env", ""))
collection_name = target_cfg.get("collection_name", "Mirror")
ua = reg.get("defaults", {}).get("user_agent", "replica-mirror/1.0")
summary: dict[str, Any] = {"target": "outline", "pages": {"synced": 0, "skipped": 0}, "collection": None, "errors": []}
if not outline_token and not dry:
summary["errors"].append(f"missing env {target_cfg.get('token_env')}")
return summary
for src in target_cfg.get("sources", []):
if src["type"] != "confluence-cloud":
summary["errors"].append(f"unknown source type {src['type']} for outline")
continue
cf_url = src.get("endpoint")
if not cf_url:
summary["errors"].append({"source": src["id"], "reason": "Confluence endpoint is null — set it in sync-mirrors.yaml"})
continue
cf_token = _env(src.get("token_env", "")) or ""
cf_email = _env(src.get("email_env", "")) or ""
if not dry and (not cf_token or not cf_email):
summary["errors"].append(f"missing Confluence creds ({src.get('token_env')} / {src.get('email_env')})")
continue
# 1. Ensure Outline collection exists.
collection_id = None
if not dry:
st, cols = _request("GET", f"{outline_url}/api/collections.list", token=outline_token, user_agent=ua)
if st == 200 and isinstance(cols, dict):
for c in cols.get("data", []):
if c.get("name", "").lower() == collection_name.lower():
collection_id = c.get("id")
break
if not collection_id:
body = {"name": collection_name}
st, resp = _request("POST", f"{outline_url}/api/collections.create", token=outline_token, json_body=body, user_agent=ua)
if st in (200, 201) and isinstance(resp, dict):
collection_id = (resp.get("data") or {}).get("id")
summary["collection"] = {"id": collection_id, "created": True}
else:
summary["errors"].append({"step": "create collection", "status": st, "detail": str(resp)[:300]})
continue
else:
summary["collection"] = {"id": collection_id, "existed": True}
else:
summary["collection"] = {"name": collection_name, "dry": True}
# 2. List Confluence spaces.
spaces_resp = _jira_get(cf_url, cf_email, cf_token, ua, "/wiki/api/v2/spaces?limit=100")
if spaces_resp["status"] != 200:
summary["errors"].append({"step": "confluence spaces", "status": spaces_resp["status"], "detail": str(spaces_resp["data"])[:300]})
continue
spaces = (spaces_resp["data"] or {}).get("results", [])
if src.get("spaces"):
wanted = set(src["spaces"])
spaces = [s for s in spaces if s.get("key") in wanted]
for space in spaces:
space_key = space["key"]
# 3. List pages in space.
pages_resp = _jira_get(cf_url, cf_email, cf_token, ua, f"/wiki/api/v2/spaces/{space_key}/pages?limit=100&body-format=storage")
if pages_resp["status"] != 200:
summary["errors"].append({"step": f"confluence pages {space_key}", "status": pages_resp["status"]})
continue
pages = (pages_resp["data"] or {}).get("results", [])
# 4. List existing Outline docs to dedupe by title.
existing_titles = set()
if not dry and collection_id:
st, docs = _request("GET", f"{outline_url}/api/documents.list", token=outline_token, user_agent=ua)
if st == 200 and isinstance(docs, dict):
for d in docs.get("data", []):
if (d.get("collectionId") or "") == collection_id:
existing_titles.add(d.get("title", "").lower())
for page in pages:
title = page.get("title", f"Untitled-{page.get('id')}")
if title.lower() in existing_titles:
summary["pages"]["skipped"] += 1
continue
# Convert Confluence storage format → Markdown.
raw_body = (page.get("body") or {})
storage_xml = raw_body.get("storage", {}).get("value", "") if isinstance(raw_body, dict) else str(raw_body)
md = _confluence_storage_to_md(storage_xml)
if dry:
summary["pages"]["synced"] += 1
continue
body = {
"title": f"[{space_key}] {title}",
"text": md,
"collectionId": collection_id,
"publish": True,
}
st, resp = _request("POST", f"{outline_url}/api/documents.create", token=outline_token, json_body=body, user_agent=ua)
if st in (200, 201):
summary["pages"]["synced"] += 1
else:
summary["errors"].append({"page": page.get("id"), "status": st, "detail": str(resp)[:200]})
return summary
def _confluence_storage_to_md(xml: str) -> str:
"""Minimal Confluence storage-format (XHTML) → Markdown conversion.
Handles the common constructs: headings, paragraphs, lists, code blocks,
links, bold/italic. Full Confluence XHTML is complex; this covers the 90%
case and leaves unknown tags as stripped text.
"""
import re
if not xml:
return ""
# Remove XML namespaces for simpler matching.
text = re.sub(r'xmlns[^"]*"[^"]*"', "", xml)
text = re.sub(r"<ac:structured-macro[^>]*>.*?</ac:structured-macro>", "[macro]", text, flags=re.DOTALL)
# Headings.
for i in range(6, 0, -1):
text = re.sub(rf"<h{i}[^>]*>(.*?)</h{i}>", lambda m, lvl=i: "#" * lvl + " " + m.group(1).strip(), text, flags=re.DOTALL)
# Code blocks: <pre><code>...</code></pre> → fenced, before inline <code>.
text = re.sub(r"<pre[^>]*>\s*<code[^>]*>(.*?)</code>\s*</pre>", lambda m: f"```\n{m.group(1).strip()}\n```", text, flags=re.DOTALL)
text = re.sub(r"<ac:plain-text-body[^>]*><!\[CDATA\[(.*?)\]\]></ac:plain-text-body>", lambda m: f"```\n{m.group(1)}\n```", text, flags=re.DOTALL)
text = re.sub(r"<code[^>]*>(.*?)</code>", lambda m: f"`{m.group(1).strip()}`", text, flags=re.DOTALL)
# Bold / italic.
text = re.sub(r"<b>(.*?)</b>", r"**\1**", text, flags=re.DOTALL)
text = re.sub(r"<strong>(.*?)</strong>", r"**\1**", text, flags=re.DOTALL)
text = re.sub(r"<i>(.*?)</i>", r"*\1*", text, flags=re.DOTALL)
text = re.sub(r"<em>(.*?)</em>", r"*\1*", text, flags=re.DOTALL)
# Links.
text = re.sub(r'<a[^>]*href="([^"]*)"[^>]*>(.*?)</a>', lambda m: f"[{m.group(2).strip()}]({m.group(1)})", text, flags=re.DOTALL)
# Lists.
text = re.sub(r"<li[^>]*>(.*?)</li>", lambda m: f"- {m.group(1).strip()}\n", text, flags=re.DOTALL)
text = re.sub(r"</?[ou]l[^>]*>", "", text)
# Paragraphs / line breaks.
text = re.sub(r"<p[^>]*>", "\n", text)
text = re.sub(r"</p>", "\n", text)
text = re.sub(r"<br\s*/?>", "\n", text)
# Strip remaining tags.
text = re.sub(r"<[^>]+>", "", text)
# Collapse excessive whitespace.
text = re.sub(r"\n{3,}", "\n\n", text).strip()
return text
# ════════════════════════════════════════════════════════════════════════════
# RL feedback: emit trajectory reward signals from sync drift
# ════════════════════════════════════════════════════════════════════════════
def emit_trajectory_rewards(reg: dict, sync_summaries: list[dict] | None = None) -> dict:
"""Append RL reward signals to the trajectory-rewards JSONL.
Called after each --sync run. Reads the sync summaries (what changed,
what drifted) and emits reward signals the RL pipeline consumes.
"""
rl_cfg = reg.get("rl_feedback", {})
if not rl_cfg.get("enabled", False):
return {"skipped": "rl_feedback disabled"}
output = REPLICA_ROOT / rl_cfg.get("output", str(REWARD_PATH))
output.parent.mkdir(parents=True, exist_ok=True)
signals = rl_cfg.get("signals", {})
events: list[dict] = []
ts = datetime.now(timezone.utc).isoformat()
if sync_summaries:
for s in sync_summaries:
target = s.get("target", "unknown")
# Positive: sync succeeded with no errors.
if not s.get("errors"):
events.append({
"type": "trajectory_reward",
"timestamp": ts,
"target": target,
"signal": "sync_hit",
"reward": signals.get("sync_hit", 0.1),
"detail": f"{target} sync clean",
})
else:
# Negative: errors mean drift / staleness.
events.append({
"type": "trajectory_reward",
"timestamp": ts,
"target": target,
"signal": "sync_miss",
"reward": signals.get("sync_miss", -0.2),
"detail": f"{target} sync had {len(s['errors'])} errors",
})
# If items were skipped because they already existed, that's neutral.
with output.open("a", encoding="utf-8") as f:
for ev in events:
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
return {"emitted": len(events), "output": str(output)}
# ════════════════════════════════════════════════════════════════════════════
# Dist generation (--write-in-place / --check)
# ════════════════════════════════════════════════════════════════════════════
def generate_dist(reg: dict) -> dict[str, str]:
"""Produce the downstream catalog files from the YAML registry."""
targets_out = {}
for tname, tcfg in reg.get("targets", {}).items():
entry = {
"id": tname,
"kind": tcfg.get("kind"),
"label": tcfg.get("label", tname),
"endpoint": tcfg.get("endpoint"),
"token_env": tcfg.get("token_env"),
"sources": [],
}
for src in tcfg.get("sources", []):
entry["sources"].append({
"id": src.get("id"),
"type": src.get("type"),
"label": src.get("label"),
"status": src.get("status", "declared"),
"endpoint": src.get("endpoint"),
})
targets_out[tname] = entry
catalog = {
"version": reg.get("version", 1),
"defaults": reg.get("defaults", {}),
"targets": targets_out,
"rl_feedback": reg.get("rl_feedback", {}),
}
return {
"mirrors.catalog.json": json.dumps(catalog, indent=2) + "\n",
}
def write_in_place() -> None:
reg = load_registry()
DIST_DIR.mkdir(parents=True, exist_ok=True)
for name, content in generate_dist(reg).items():
(DIST_DIR / name).write_text(content, encoding="utf-8")
print(f"wrote {DIST_DIR / name}")
def check() -> int:
reg = load_registry()
stale = []
for name, want in generate_dist(reg).items():
p = DIST_DIR / name
if not p.exists() or p.read_text(encoding="utf-8") != want:
stale.append(name)
if stale:
print(f"stale mirror catalog (run --write-in-place): {', '.join(stale)}", file=sys.stderr)
return 1
print("mirror catalog up to date")
return 0
# ════════════════════════════════════════════════════════════════════════════
# CLI
# ════════════════════════════════════════════════════════════════════════════
SYNC_DISPATCH = {
"forgejo": sync_forgejo,
"plane": sync_plane,
"outline": sync_outline,
}
def run_sync(target: str | None, dry: bool, emit_rewards: bool) -> int:
reg = load_registry()
targets = reg.get("targets", {})
names = [target] if target else list(targets.keys())
fail_fast = reg.get("defaults", {}).get("fail_fast", False)
summaries: list[dict] = []
rc = 0
for name in names:
if name not in targets:
print(f"unknown target: {name} (known: {', '.join(targets)})", file=sys.stderr)
rc = 1
continue
fn = SYNC_DISPATCH.get(targets[name].get("kind"))
if not fn:
# Map kind → fn for aliases.
kind = targets[name].get("kind", "")
if "git" in kind:
fn = sync_forgejo
elif "issue" in kind:
fn = sync_plane
elif "doc" in kind:
fn = sync_outline
if not fn:
print(f"no sync handler for target {name} (kind={targets[name].get('kind')})", file=sys.stderr)
rc = 1
continue
print(f"[{name}] syncing (dry={dry})...")
try:
s = fn(reg, targets[name], dry=dry)
summaries.append(s)
_print_summary(s)
except Exception as exc:
summaries.append({"target": name, "errors": [str(exc)]})
print(f"[{name}] FAILED: {exc}", file=sys.stderr)
if fail_fast:
return 1
rc = 1
if emit_rewards:
r = emit_trajectory_rewards(reg, summaries)
print(f"[rl] reward signals: {r}")
# Persist last-sync summary for drift detection on next run.
state_dir = REPLICA_ROOT / ".aurelio" / "mirrors" / "state"
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "last-sync.json").write_text(
json.dumps({"timestamp": datetime.now(timezone.utc).isoformat(), "summaries": summaries}, indent=2),
encoding="utf-8",
)
return rc
def _print_summary(s: dict) -> None:
target = s.get("target", "?")
errs = s.get("errors", [])
if target == "forgejo":
print(f" mirrored: {len(s.get('mirrored', []))} skipped: {len(s.get('skipped', []))} errors: {len(errs)}")
elif target == "plane":
print(f" projects: {len(s.get('projects', []))} issues synced: {s.get('issues', {}).get('synced', 0)} skipped: {s.get('issues', {}).get('skipped', 0)} errors: {len(errs)}")
elif target == "outline":
print(f" pages synced: {s.get('pages', {}).get('synced', 0)} skipped: {s.get('pages', {}).get('skipped', 0)} errors: {len(errs)}")
for e in errs[:5]:
print(f" ! {e}")
def main() -> int:
import urllib.parse # noqa: F401 — used in query quoting above
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
mode = ap.add_mutually_exclusive_group()
mode.add_argument("--write-in-place", action="store_true", help="Regenerate dist/ from YAML registry.")
mode.add_argument("--check", action="store_true", help="CI guard: exit 1 if dist/ is stale.")
mode.add_argument("--sync", nargs="?", const="all", default=None, help="Run live mirror sync (target name or 'all').")
mode.add_argument("--emit-trajectory-rewards", action="store_true", help="Emit RL reward signals from last sync.")
ap.add_argument("--dry-run", action="store_true", help="Preview what would sync without making API calls.")
args = ap.parse_args()
if args.check:
return check()
if args.write_in_place:
write_in_place()
return 0
if args.emit_trajectory_rewards:
reg = load_registry()
state = REPLICA_ROOT / ".aurelio" / "mirrors" / "state" / "last-sync.json"
sums = []
if state.exists():
sums = json.loads(state.read_text()).get("summaries", [])
r = emit_trajectory_rewards(reg, sums)
print(json.dumps(r, indent=2))
return 0
if args.sync is not None:
target = None if args.sync == "all" else args.sync
return run_sync(target, dry=args.dry_run, emit_rewards=True)
ap.print_help()
return 0
if __name__ == "__main__":
raise SystemExit(main())