Files
rfc-app/backend/app/webhooks.py
T
Ben Stull 4e7410f90b fix(§22/G-15): make the branch/edit/body subsystem three-tier aware (v0.53.0)
§22 migrated the READ/catalog path to the three-tier (project/collection)
model but the WRITE/branch/body subsystem still hardcoded the default
project's default collection — resolving every meta-resident entry to the
default content repo at rfcs/<slug>.md, ignoring the entry's project (its own
content repo) and collection (a <subfolder>/rfcs/ prefix). An entry outside
the default collection rendered a blank canonical body and every edit/PR/
body-write path hit the wrong file.

- New single resolver: projects.content_repo_for_collection() +
  projects.entry_location(config, cid, slug) -> (org, repo, md_path)
  (collection -> project -> content_repo + subfolder; falls back to the
  default repo for a legacy/unknown collection).
- Every entry write/read path resolves via it: api_branches (body GET + all
  branch write paths), api_prs (pr-draft/open/merge/withdraw/review/
  resolution-branch), api_graduation (graduate/claim/retire/unretire +
  orchestrator + state-flip), api.py mark-reviewed + idea-PR merge/decline/
  withdraw + proposal preview, api_metadata. bot.open_metadata_pr and
  bot.mark_entry_reviewed gained a file_path param. refresh_meta_branches,
  the webhook corpus-refresh dispatch, and the hygiene branch-delete now
  span every project's content repo, not just the default.
- New additive collection-scoped body-read routes:
  GET /api/projects/{pid}/collections/{cid}/rfcs/{slug}/main and
  .../branches/{branch} disambiguate a slug across collections (G-5) and read
  the entry's own repo. Slug-only routes kept (now collection-aware via the
  cached row). Frontend getRFCMain/getBranch take optional pid+cid; RFCView
  threads them (mirrors the v0.52.1 entry-detail fix).

No migration, no config change. Existing default-collection entries
unaffected. Tests: backend resolver + collection-scoped branch/body + graduate-
in-subfolder write path; frontend api unit. backend 677 / frontend 60 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:39:46 -07:00

139 lines
5.8 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
import os
from fastapi import APIRouter, Header, HTTPException, Request
from . import cache, db, projects as projects_mod, registry as registry_mod
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()
# v0.18.0: defense in depth. config.py refuses to start
# when the secret is empty unless `RFC_APP_INSECURE_WEBHOOKS=1`
# is set; this branch catches the dev-bypass case (the only
# path where `config.webhook_secret` can be empty) and surfaces
# it loudly to the client. A POST that lands here with an
# empty secret on a production deployment indicates a
# mis-configuration (somebody flipped the bypass in prod),
# and the loud 500 is the proposal's whole point.
insecure = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1"
if not config.webhook_secret:
if not insecure:
log.error(
"webhook receiver misconfigured: GITEA_WEBHOOK_SECRET is empty "
"and RFC_APP_INSECURE_WEBHOOKS=1 is not set"
)
raise HTTPException(status_code=500, detail="Webhook receiver misconfigured")
log.warning(
"webhook receiver running with RFC_APP_INSECURE_WEBHOOKS=1 — "
"signature verification is DISABLED. Production deployments MUST NOT set this."
)
else:
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 ""
registry_full = f"{config.gitea_org}/{config.registry_repo}"
# §22/G-15: a corpus push can land on ANY project's content_repo, not
# just the default — recognise the full set so a non-default project's
# push triggers the (multi-project) corpus/branch/PR refresh.
content_fulls = {
f"{config.gitea_org}/{r['content_repo']}"
for r in db.conn().execute(
"SELECT content_repo FROM projects "
"WHERE content_repo IS NOT NULL AND content_repo != ''")
}
if not content_fulls:
log.warning("webhook: no project content_repo is known; corpus refresh skipped")
try:
if repo_full == registry_full:
# §22.2: a registry-repo push re-mirrors the projects table.
# Tolerate a malformed projects.yaml (keep last-good rows); let a
# transport error bubble to the outer 500 so an unreachable Gitea
# on a registry push is loud rather than silently dropped.
try:
await registry_mod.refresh_registry(config, gitea)
except registry_mod.RegistryError:
log.exception("registry webhook: invalid projects.yaml; keeping last-good")
elif content_fulls and (repo_full in content_fulls 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)
else:
log.info(
"webhook received for unknown repo: repo_full=%s event=%s "
"(no cached_rfcs row matched; hook may be on a fork or stale)",
repo_full, event,
)
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