3bc8fe92af
Per the §19.1 brief: the three-column shape (§8.1) opens on main
in discuss mode (§8.2), supports the §8.3 discuss-vs-contribute
flip on non-main branches, hosts §8.4's per-branch chat with AI
participation (§18's <change> protocol → §8.14 changes rows), the
§8.8 change-card panel with §8.9 accept/decline/edit-before-accept,
the §8.10 tracked-change markup + DiffView toggle, the §8.11
manual-edit flushes with the stale-change mechanic, the §8.12
range and paragraph sub-threads, the §8.13 flag affordance, and
the §8.14 discuss-mode buffer.
Backend: bot.py grew per-RFC-repo write ops (cut_branch_from_main,
commit_accepted_change with the structured original/proposed/reason
body and Change-Id + Source-Message-Id + On-behalf-of trailers,
commit_manual_flush, ensure_rfc_repo_seed). cache.py grew
refresh_rfc_repo and the webhook dispatches on repository.full_name.
providers.py and chat.py port the §18 carryovers — multi-provider
LLM abstraction and SSE-streaming chat against the §5 threads /
thread_messages / changes schema. api_branches.py mounts the §17
branches/<branch>/* and threads/<thread_id>/* routes with the §6
/ §11 permission checks inline.
Frontend: RFCView.jsx rebuilt as the §8 surface; Editor.jsx,
ChatPanel.jsx, ChangePanel.jsx, PromptBar.jsx, SelectionTooltip.jsx,
DiffView.jsx, ModelPicker.jsx, modelStyles.js lifted from the
prototype and adapted to the canonical schema.
Covered by `backend/tests/test_rfc_view_vertical.py` — eleven new
integration tests against an extended FakeGitea (PUT contents,
POST orgs/{org}/repos, seed_rfc_repo): main-view read,
promote-to-branch, accept (with and without edit-before-accept),
decline, manual flush + system message, flag creation, visibility
flip, anonymous read-but-no-contribute, stale-change refusal, and
the chat-streaming path with a fake provider injected. The 5
Slice 1 tests continue to pass alongside.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
91 lines
3.1 KiB
Python
91 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_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
|