Slice 1: scaffolding + propose-to-super-draft vertical
Brings the §1 bot wrapper, the §4 cache (webhook + reconciler), the §5 schema (six numbered migrations), Gitea OAuth + §6 user provisioning, the §7 catalog left pane, and the propose-to-merge vertical: propose modal opens an idea PR against the meta repo, an owner merges from the pending-idea view, the cache picks it up via webhook or reconciler sweep, and the catalog renders the new super-draft. Per §1 the bot is the only Git writer; every commit, branch creation, and PR merge carries the §6.5 On-behalf-of: trailer and an `actions` audit row. Per §4 the cache is never written from a user action — it's webhook+reconciler only. Covered by `backend/tests/test_propose_vertical.py` against an in-process Gitea simulator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"""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 logging
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from . import cache
|
||||
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}
|
||||
|
||||
# Slice 1 only acts on meta-repo events; per-RFC-repo events
|
||||
# land in their respective slices. The handler is generous in
|
||||
# what it accepts — any meta-repo change is a cue to refresh
|
||||
# the whole meta-repo cache, since the cache is small and the
|
||||
# refresh is idempotent.
|
||||
try:
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
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)
|
||||
Reference in New Issue
Block a user