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>
114 lines
3.7 KiB
Python
114 lines
3.7 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, 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)
|
|
|
|
# §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()
|
|
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
|