Files
svn-git-server/svn_mirror/webhook.py
T
uberwald 45fb8c40d0 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).
2026-06-23 09:47:02 +02:00

145 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()