myxce/scripts/gitea-sync.py
Rongsong Shen 643741fad5
fix(gitea-sync): unwrap buglog.json's {version,bugs} wrapper before iterating (bug-1677)
sync_bugs() read buglog.json as a bare list and wrote it back the
same way, but the real format is {"version": 1, "bugs": [...]}. Any
real run would have crashed on the first bug entry (enumerate() over
a dict yields its keys, not bug objects) and, had it somehow gotten
past that, a write-back would have destroyed the wrapper. Never
actually run against production Gitea, per the buglog entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgYRU3zDTqNGV5Jk8aiKLg
2026-08-24 14:23:31 +00:00

877 lines
30 KiB
Python
Executable file

#!/usr/bin/env python3
"""
xce-gitea-sync: Sync cc-sdd specs and OpenWolf buglog to Gitea issues.
Reads .kiro/specs/<feature>/ (spec.json, brief.md, requirements.md, tasks.md)
and .wolf/buglog.json as ground truth. Creates/updates Gitea milestones, issues,
and labels to mirror the local state.
Sync is one-way: files -> Gitea. Never Gitea -> files.
Sync state (issue numbers) is stored in the source files for idempotency.
Usage:
python scripts/gitea-sync.py # sync all specs + bugs
python scripts/gitea-sync.py --spec <name> # sync one spec
python scripts/gitea-sync.py --bugs # sync bugs only
python scripts/gitea-sync.py --dry-run # show what would change
Environment:
GITEA_URL - base URL (default: https://gitea.shenrs.eu)
GITEA_TOKEN - API token (required: write:issue, write:milestone scopes)
GITEA_OWNER - repo owner (default: nexa)
GITEA_REPO - repo name (default: xce)
"""
import argparse
import datetime
import hashlib
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
# ── Helpers ──────────────────────────────────────────────────────────────────
def die(msg):
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
def warn(msg):
print(f"WARN: {msg}", file=sys.stderr)
def _env(key, default=None, required=False):
val = os.environ.get(key, default)
if required and not val:
die(f"{key} environment variable is required")
return val
def _discover_gitea_token():
"""Discover Gitea API token from multiple sources.
Priority:
1. GITEA_TOKEN environment variable
2. ~/.claude.json → mcpServers.gitea.env.GITEA_ACCESS_TOKEN
3. GITEA_ACCESS_TOKEN environment variable
"""
# Explicit override
token = _env("GITEA_TOKEN")
if token:
return token
# Try Claude Code MCP config
claude_config_path = Path.home() / ".claude.json"
if claude_config_path.exists():
try:
config = json.loads(claude_config_path.read_text())
gitea_cfg = config.get("mcpServers", {}).get("gitea", {})
token = gitea_cfg.get("env", {}).get("GITEA_ACCESS_TOKEN")
if token:
return token
except (json.JSONDecodeError, OSError):
pass
# MCP env var
return _env("GITEA_ACCESS_TOKEN")
# ── Configuration ────────────────────────────────────────────────────────────
GITEA_URL = _env("GITEA_URL", "https://gitea.shenrs.eu").rstrip("/")
GITEA_TOKEN = _discover_gitea_token()
GITEA_OWNER = _env("GITEA_OWNER", "nexa")
GITEA_REPO = _env("GITEA_REPO", "xce")
API_BASE = f"{GITEA_URL}/api/v1"
REPO_API = f"{API_BASE}/repos/{GITEA_OWNER}/{GITEA_REPO}"
# Project roots (relative to script or cwd)
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SCRIPT_DIR.parent
SPECS_DIR = PROJECT_ROOT / ".kiro" / "specs"
BUGLOG_PATH = PROJECT_ROOT / ".wolf" / "buglog.json"
# ── Label Definitions ────────────────────────────────────────────────────────
LABELS = [
# Spec phase labels
{"name": "phase:requirements", "color": "6C5CE7",
"description": "Spec requirements phase — EARS entries, acceptance criteria"},
{"name": "phase:design", "color": "6C5CE7",
"description": "Spec design phase — architecture, data flow, API contracts"},
{"name": "phase:tasks", "color": "6C5CE7",
"description": "Spec tasks phase — implementation task breakdown"},
{"name": "phase:implementation", "color": "F39C12",
"description": "Spec implementation phase — code in progress"},
{"name": "phase:complete", "color": "27AE60",
"description": "Spec complete — all tasks done, validated, merged"},
# Top-level type labels
{"name": "spec", "color": "3498DB",
"description": "Spec-driven development tracked issue"},
{"name": "bug", "color": "E74C3C",
"description": "Bug found during development"},
{"name": "bug:fixed", "color": "27AE60",
"description": "Bug resolved and fix committed"},
# State labels
{"name": "blocked", "color": "E74C3C",
"description": "Blocked on external dependency or upstream spec"},
]
# Map buglog tags to Gitea labels (prefix-based matching)
TAG_TO_LABEL = {
"asan": "bug:asan",
"ubsan": "bug:asan",
"SEGV": "bug:crash",
"double-free": "bug:crash",
"heap-use-after-free": "bug:crash",
"UAF": "bug:crash",
"data-loss": "bug:data-loss",
"race": "bug:race",
"toctou": "bug:race",
"data-race": "bug:race",
"ha": "bug:ha",
"raft": "bug:ha",
"election": "bug:ha",
"failover": "bug:ha",
}
def api_url(path):
"""Build full API URL. All paths are repo-scoped."""
return f"{REPO_API}/{path.lstrip('/')}"
def request(method, path, body=None):
"""Make a Gitea API request. Returns (status, response_body) or None on 404."""
url = api_url(path)
data = json.dumps(body).encode("utf-8") if body else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"token {GITEA_TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if e.code == 404:
return None, None
body_text = ""
try:
body_text = e.read().decode("utf-8")
except Exception:
pass
raise RuntimeError(
f"Gitea API error: {method} {path} -> {e.code}\n{body_text}"
) from e
except urllib.error.URLError as e:
raise RuntimeError(
f"Gitea API unreachable: {method} {path}\n{e.reason}"
) from e
# ── GiteaAPI ─────────────────────────────────────────────────────────────────