4565a6cb95
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>
92 lines
3.1 KiB
Python
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
|