feat: initial import — SVN↔Git bi-directional mirror for Gitea

Server-side sidecar daemon. Uses own SQLite mapping table (not git svn)
so Git commit hashes never change. Pure Python, zero framework deps.

Includes:
- SVN→Git sync engine with nested-directory tree reconstruction
- Git→SVN push with author mapping and working-copy management
- Branch/tag sync (svn copy → git branch/tag and reverse)
- Gitea integration (on-disk push/fetch, webhook receiver)
- Reconciliation tool for divergence recovery (assess + auto-fix)
- Full end-to-end regression test (bash, 34 checks)
- systemd unit and deployment docs

Only external dependency: PyYAML (config parsing).
This commit is contained in:
2026-06-23 09:47:02 +02:00
commit 45fb8c40d0
17 changed files with 3654 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
"""Gitea webhook receiver HTTP server that triggers Git→SVN sync on push."""
import hashlib
import hmac
import json
import logging
from http.server import HTTPServer, BaseHTTPRequestHandler
from typing import Dict, Optional
from .config import MirrorConfig
from .git_to_svn import sync_git_to_svn
from .gitea import fetch_from_gitea
from .mirror import Mirror
logger = logging.getLogger(__name__)
class _Handler(BaseHTTPRequestHandler):
"""HTTP request handler for Gitea webhooks."""
# Set by the caller before starting the server
mirrors: Dict[str, Mirror] = {}
lookup: Dict[str, str] = {} # "owner/repo" → mirror_id
secrets: Dict[str, Optional[str]] = {} # mirror_id → secret (or None)
def log_request(self, code="-", size="-"):
logger.debug("Webhook: %s %s%s", self.command, self.path, code)
def _send_json(self, code: int, body: dict):
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(body).encode())
def _verify_signature(self, body: bytes, secret: Optional[str]) -> bool:
"""Verify X-Gitea-Signature HMAC-SHA256 if a secret is configured."""
if secret is None:
return True # no secret configured trust all
sig = self.headers.get("X-Gitea-Signature", "")
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected)
def do_GET(self):
if self.path == "/health":
self._send_json(200, {"status": "ok"})
else:
self._send_json(404, {"error": "not found"})
def do_POST(self):
if self.path != "/webhook":
self._send_json(404, {"error": "not found"})
return
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
# Determine which mirror this webhook is for
payload = json.loads(body)
owner = (
payload.get("repository", {}).get("owner", {}).get("login")
or payload.get("repository", {}).get("owner", {}).get("username")
)
repo = payload.get("repository", {}).get("name")
if not owner or not repo:
self._send_json(400, {"error": "missing repository owner/name"})
return
key = f"{owner}/{repo}"
mirror_id = self.lookup.get(key)
if mirror_id is None:
logger.warning("No mirror configured for %s", key)
self._send_json(404, {"error": f"no mirror for {key}"})
return
# Verify signature
secret = self.secrets.get(mirror_id)
if not self._verify_signature(body, secret):
logger.warning("Invalid webhook signature for %s", mirror_id)
self._send_json(403, {"error": "invalid signature"})
return
ref = payload.get("ref", "")
logger.info(
"Webhook: %s pushed to %s (%s)",
payload.get("pusher", {}).get("login", "?"), key, ref,
)
mirror = self.mirrors.get(mirror_id)
if mirror is None:
self._send_json(500, {"error": f"mirror {mirror_id} not loaded"})
return
try:
fetch_from_gitea(mirror, ref=ref)
count = sync_git_to_svn(mirror)
self._send_json(200, {
"status": "ok",
"mirror": mirror_id,
"git_to_svn_count": count,
})
except Exception as e:
logger.exception("Webhook processing failed for %s", mirror_id)
self._send_json(500, {"error": str(e)})
do_PUT = do_POST # tolerate PUT for convenience
def _build_lookup(mirrors: Dict[str, Mirror]) -> Dict[str, str]:
"""Build ``owner/repo → mirror_id`` lookup dict."""
lookup: Dict[str, str] = {}
for mid, m in mirrors.items():
cfg = m.config.gitea
key = f"{cfg.owner}/{cfg.repo}"
lookup[key] = mid
return lookup
def build_handler_class(mirrors: Dict[str, Mirror]) -> type:
"""Return a handler class pre-configured with mirror references."""
lookup = _build_lookup(mirrors)
secrets = {
mid: m.config.gitea.webhook_secret
for mid, m in mirrors.items()
}
return type("ConfiguredHandler", (_Handler,), {
"mirrors": mirrors,
"lookup": lookup,
"secrets": secrets,
})
def run_webhook_server(mirrors: Dict[str, Mirror], host: str = "0.0.0.0",
port: int = 8080):
"""Start the webhook HTTP server (blocks until KeyboardInterrupt)."""
handler_cls = build_handler_class(mirrors)
server = HTTPServer((host, port), handler_cls)
logger.info("Webhook server listening on %s:%d", host, port)
logger.info(" POST /webhook Gitea push events")
logger.info(" GET /health health check")
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info("Webhook server stopped")
server.server_close()