"""Forge push webhook — drift flagging at the moment the repo moves (#2691). The forge POSTs here on every push; changed paths are matched against recorded snippet locations (through repo bindings) and matched verdicts get an ``invalidated_by`` marker that surfaces in the ``verification="attention"`` listing. This is what makes verification scale past tens of records: sessions recheck what pushes flagged instead of sweeping everything. Registering the webhook on the forge is per-instance setup (Settings → Config → Git Forge shows the endpoint and holds the secret) — the server never self-registers on the forge. Contract with the forge's delivery loop: - No secret configured → 404: the endpoint doesn't exist until an operator creates it. Bad signature → 401: that's a caller problem worth signaling. - A PROCESSING failure returns 200 with ``{"ok": false}`` and drops a WARNING + AppLog row (the #2663 canary pattern): repeated 5xx responses make forges mark deliveries failed and operators disable the hook, which would silently turn the feature off — the exact failure mode this milestone exists to prevent. """ from __future__ import annotations import hashlib import hmac import logging import traceback from quart import Blueprint, jsonify, request from scribe.config import Config from scribe.services.background import spawn from scribe.services.coverage import refresh_coverage from scribe.services.repo_bindings import bindings_for_key, normalize_repo_key from scribe.services.settings import get_admin_setting from scribe.services.snippets import invalidate_for_push logger = logging.getLogger(__name__) webhooks_bp = Blueprint("webhooks", __name__, url_prefix="/api/webhooks") FORGE_WEBHOOK_SECRET_KEY = "forge_webhook_secret" def signature_ok(secret: str, body: bytes, signature: str) -> bool: """Validate a push signature: the hex HMAC-SHA256 of the raw body under the webhook secret (Gitea's X-Gitea-Signature verbatim; GitHub's X-Hub-Signature-256 minus its "sha256=" prefix). Constant-time compare.""" if not secret or not signature: return False expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature.strip().lower()) def delivered_signature(headers) -> str: """The HMAC hex a delivery carries, whichever forge sent it: Gitea's X-Gitea-Signature verbatim, or GitHub's X-Hub-Signature-256 minus its "sha256=" scheme prefix (#2693). Empty when neither header is present.""" return headers.get("X-Gitea-Signature", "") or headers.get( "X-Hub-Signature-256", "" ).removeprefix("sha256=") def push_facts(payload: dict) -> tuple[str, list[str], list[str], str]: """(repo identity, changed paths, removed paths, head commit) from a Gitea push payload. Tolerant: absent fields read as empty, never raise.""" repo = payload.get("repository") or {} raw_repo = repo.get("clone_url") or repo.get("html_url") or repo.get("full_name") or "" changed: list[str] = [] removed: list[str] = [] for commit in payload.get("commits") or []: changed.extend(commit.get("added") or []) changed.extend(commit.get("modified") or []) removed.extend(commit.get("removed") or []) # De-dup while keeping order stable for logs. changed = list(dict.fromkeys(changed)) removed = list(dict.fromkeys(removed)) return raw_repo, changed, removed, str(payload.get("after") or "") @webhooks_bp.route("/forge", methods=["POST"]) async def forge_push(): secret = await get_admin_setting(FORGE_WEBHOOK_SECRET_KEY, "") or Config.FORGE_WEBHOOK_SECRET if not secret: # Not "forbidden" — the endpoint is not a thing on this instance. return jsonify({"error": "Not found"}), 404 body = await request.get_data() # The payload shape push_facts reads (repository.clone_url, # commits[].added/modified/removed, after) is common to both forges, so # the signature header is the whole GitHub mapping. if not signature_ok(secret, body, delivered_signature(request.headers)): return jsonify({"error": "Invalid signature"}), 401 try: payload = await request.get_json(force=True) or {} raw_repo, changed, removed, head = push_facts(payload) repo_key = normalize_repo_key(raw_repo) if not repo_key: return jsonify({"ok": True, "flagged": 0, "reason": "no repository in payload"}) flagged = await invalidate_for_push(repo_key, changed, removed, head) if flagged: logger.info( "forge push %s flagged %d snippet(s) for recheck", head[:12], flagged ) # A push is exactly when the coverage number goes stale — recompute it # off the delivery path (#2692). Fire-and-forget: the forge's delivery # loop must not wait on an archive download, and a failure is a # WARNING from spawn(), never a failed delivery. This also SEEDS the # cache on a webhook-configured instance — no manual first refresh. for binding in await bindings_for_key(repo_key): spawn( refresh_coverage(binding.user_id, binding.project_id), site="webhooks.coverage_refresh", ) return jsonify({"ok": True, "flagged": flagged}) except Exception: logger.warning("forge webhook processing failed", exc_info=True) try: from scribe.services.logging import log_error await log_error( endpoint="webhooks/forge", error_type="forge_webhook_failed", error_message="push received but drift flagging failed — " "snippets touched by this push were not marked for recheck", traceback=traceback.format_exc(), ) except Exception: logger.debug("forge webhook canary write failed", exc_info=True) # 200 on purpose — see the module docstring's delivery contract. return jsonify({"ok": False})