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:
@@ -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()
|
||||
Reference in New Issue
Block a user