"""HTTP surface: the Gitea webhook receiver and the provenance API. `POST /webhook` — a Gitea event; parsed and dispatched to the orchestrator. `GET /healthz` — liveness. `GET /provenance?owner=&repo=&issue=` — the run's audit record (never posted to the forge). Webhook signature verification is optional: set a secret and the handler rejects bodies whose `X-Gitea-Signature` HMAC-SHA256 does not match. """ from __future__ import annotations import hmac import json from collections.abc import Callable from hashlib import sha256 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any from urllib.parse import parse_qs, urlparse from .events import parse_event from .lifecycle import Orchestrator from .provenance import build_provenance, ops_from_log, provenance_to_dict from .store import StateStore # (record) -> that run's op-log entries, injected by bootstrap. OpLogReader = Callable[[Any], list[dict[str, Any]]] class WebhookServer(ThreadingHTTPServer): def __init__( self, address: tuple[str, int], *, orchestrator: Orchestrator, store: StateStore, secret: bytes | None = None, op_log_reader: OpLogReader | None = None, ) -> None: super().__init__(address, _Handler) self.orchestrator = orchestrator self.store = store self.secret = secret self.op_log_reader = op_log_reader def verify_signature(secret: bytes, body: bytes, signature: str) -> bool: expected = hmac.new(secret, body, sha256).hexdigest() return hmac.compare_digest(expected, signature or "") class _Handler(BaseHTTPRequestHandler): server: WebhookServer # type: ignore[assignment] def log_message( # pylint: disable=redefined-builtin self, format: str, *args: Any ) -> None: # quiet by default pass def _send(self, code: int, payload: dict[str, Any]) -> None: body = json.dumps(payload).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_POST(self) -> None: # noqa: N802 # pylint: disable=invalid-name if urlparse(self.path).path != "/webhook": self._send(404, {"error": "not found"}) return length = int(self.headers.get("Content-Length", "0")) body = self.rfile.read(length) if self.server.secret is not None: sig = self.headers.get("X-Gitea-Signature", "") if not verify_signature(self.server.secret, body, sig): self._send(401, {"error": "bad signature"}) return try: payload = json.loads(body) except ValueError: self._send(400, {"error": "invalid json"}) return kind = self.headers.get("X-Gitea-Event", "") event = parse_event(kind, payload) if event is not None: self.server.orchestrator.handle(event) self._send(200, {"ok": True, "handled": event is not None}) def do_GET(self) -> None: # noqa: N802 # pylint: disable=invalid-name parsed = urlparse(self.path) if parsed.path == "/healthz": self._send(200, {"ok": True}) return if parsed.path == "/provenance": self._provenance(parse_qs(parsed.query)) return self._send(404, {"error": "not found"}) def _provenance(self, query: dict[str, list[str]]) -> None: try: owner = query["owner"][0] repo = query["repo"][0] issue = int(query["issue"][0]) except (KeyError, IndexError, ValueError): self._send(400, {"error": "owner, repo, issue required"}) return record = self.server.store.get(owner, repo, issue) if record is None: self._send(404, {"error": "no such run"}) return reader = self.server.op_log_reader ops = ops_from_log(reader(record) if reader is not None else []) prov = build_provenance( record, ops=ops, started_at="", finished_at=record.last_checkin_at, exit_code=None, watchdog_fired=False, ) self._send(200, provenance_to_dict(prov))