Files
rfc-app/backend/app/main.py
T
Ben Stull f67d0aa0db Slice 6: notifications per §15
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:09:04 -07:00

117 lines
3.8 KiB
Python

"""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, digest, providers as providers_mod, 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)
digest_sched = digest.DigestScheduler()
# §18 carryover: the multi-provider LLM abstraction. Provider
# construction can fail (missing key, wrong env value) — if it does,
# the rest of the app still serves; chat endpoints surface a clear
# 503 instead of crashing the process.
try:
providers = providers_mod.load_from_config(config)
except Exception:
log.exception("provider construction failed; chat will be disabled")
providers = {}
app.state.config = config
app.state.gitea = gitea
app.state.bot = bot
app.state.reconciler = reconciler
app.state.providers = providers
app.include_router(_oauth_router(config))
app.include_router(api_routes.make_router(config, gitea, bot, providers))
app.include_router(webhooks.make_router(config, gitea))
reconciler.start()
digest_sched.start()
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
try:
yield
finally:
await digest_sched.stop()
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