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:
Ben Stull
2026-05-24 04:31:11 -07:00
commit 779ba6db59
42 changed files with 10385 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
"""FastAPI entrypoint.
Wires the §17 routers, the OAuth callbacks, the webhook receiver, and
the background reconciler. Per §4.2, single process, colocated SQLite —
no need for a separate worker.
"""
from __future__ import annotations
import logging
import secrets
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from . import api as api_routes, auth, cache, db, webhooks
from .bot import Bot
from .config import load_config
from .gitea import Gitea
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("rfc_app")
@asynccontextmanager
async def lifespan(app: FastAPI):
config = load_config()
db.run_migrations(config)
db.init(config)
gitea = Gitea(config)
bot = Bot(gitea)
reconciler = cache.Reconciler(config, gitea)
app.state.config = config
app.state.gitea = gitea
app.state.bot = bot
app.state.reconciler = reconciler
app.include_router(_oauth_router(config))
app.include_router(api_routes.make_router(config, gitea, bot))
app.include_router(webhooks.make_router(config, gitea))
reconciler.start()
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
try:
yield
finally:
await reconciler.stop()
await gitea.close()
def create_app() -> FastAPI:
# The secret key is required at app construction (SessionMiddleware
# is added before lifespan runs), so we read just that one value
# eagerly via load_config(). Everything else waits for lifespan.
config = load_config()
app = FastAPI(lifespan=lifespan)
app.add_middleware(
SessionMiddleware,
secret_key=config.secret_key,
session_cookie="rfc_session",
max_age=60 * 60 * 24 * 30,
https_only=False,
)
return app
app = create_app()
def _oauth_router(config) -> APIRouter:
router = APIRouter()
@router.get("/auth/login")
async def login(request: Request):
state = auth.new_state()
request.session[auth.SESSION_STATE_KEY] = state
return RedirectResponse(auth.authorization_url(config, state))
@router.get("/auth/callback")
async def callback(request: Request, code: str = "", state: str = ""):
if not code:
raise HTTPException(400, "Missing code")
stored_state = request.session.get(auth.SESSION_STATE_KEY)
if not stored_state or not secrets.compare_digest(stored_state, state):
raise HTTPException(400, "Invalid state")
token_data = await auth.exchange_code(config, code)
access_token = token_data.get("access_token")
if not access_token:
raise HTTPException(400, "Token exchange failed")
profile = await auth.fetch_user_profile(config, access_token)
user = auth.provision_user(config, profile)
auth.store_session(request, user)
return RedirectResponse("/")
@router.get("/auth/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/")
return router