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,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
|
||||
Reference in New Issue
Block a user