Files
rfc-app/backend/app/webhooks.py
T
Ben Stull 4565a6cb95 Slice 4: super-draft body editing per §9.5 + §9.6
The §17 routing-collapse rule lands in api_branches.py and
api_prs.py — every branches/<branch>/... and prs/<n>/... route
dispatches on the entry's state to pick the right Gitea repo, and
the body extracted from the entry's frontmatter envelope is what
the editor and the diff see. The bot grows open_metadata_pr;
cache grows refresh_meta_branches. Two §17 routes added:
start-edit-branch and metadata. The §9.4 super-draft view replaces
RFCView.jsx's Slice 2 placeholder; a metadata pane modal opens
from the breadcrumb. Branch naming uses edit-<slug>-<6hex> to
dodge the §19.2 path-routing candidate while preserving §9.5's
structural shape.

Covered by tests/test_super_draft_vertical.py (10 tests). The
full Slices 1-4 suite is 35/35 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:43:21 -07:00

92 lines
3.1 KiB
Python

"""Gitea webhook receiver per §4.1.
Both the webhook receiver and the reconciler are §4.1 cache writers.
On a meaningful event — meta-repo push or PR change — we re-read just
what changed from Gitea and update the cache. The signature is verified
against the configured shared secret so spurious POSTs cannot poison
the cache.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
from fastapi import APIRouter, Header, HTTPException, Request
from . import cache, db
from .config import Config
from .gitea import Gitea
log = logging.getLogger(__name__)
EVENTS_OF_INTEREST = {
"push", # meta-repo or RFC-repo commits
"pull_request", # opened / closed / merged
"create", # branch or repo created
"delete", # branch deleted
"repository", # repo created or deleted
}
def make_router(config: Config, gitea: Gitea) -> APIRouter:
router = APIRouter()
@router.post("/api/webhooks/gitea")
async def receive(
request: Request,
x_gitea_event: str = Header(default=""),
x_gitea_signature: str = Header(default=""),
):
body = await request.body()
if config.webhook_secret:
if not _verify_signature(body, x_gitea_signature, config.webhook_secret):
raise HTTPException(status_code=401, detail="Invalid signature")
event = x_gitea_event.lower()
if event not in EVENTS_OF_INTEREST:
return {"ok": True, "ignored": event}
# Identify the originating repo. For the meta repo we refresh
# the entry cache + meta-PR cache; for a per-RFC repo we refresh
# just that repo's branches/PRs/main body. The handler stays
# generous in what it accepts — refreshes are idempotent and
# small enough that overlapping events do not pile up.
try:
payload = json.loads(body) if body else {}
except Exception:
payload = {}
repo_full = (payload.get("repository") or {}).get("full_name") or ""
meta_full = f"{config.gitea_org}/{config.meta_repo}"
try:
if repo_full == meta_full or not repo_full:
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
else:
slug = _slug_for_repo(repo_full)
if slug:
await cache.refresh_rfc_repo(config, gitea, slug)
except Exception:
log.exception("webhook refresh failed")
raise HTTPException(status_code=500, detail="Refresh failed")
return {"ok": True}
return router
def _verify_signature(body: bytes, header: str, secret: str) -> bool:
if not header:
return False
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
def _slug_for_repo(repo_full: str) -> str | None:
row = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE repo = ?", (repo_full,)
).fetchone()
return row["slug"] if row else None