"""FastAPI backend for the local PR review dashboard. Endpoints: GET / -> serves the dashboard GET /api/config -> org/filter/model info for the UI GET /api/prs -> live list of PRs matching the filter GET /api/reviews -> cached, pre-computed reviews + per-PR status POST /api/refresh -> ask the background worker to poll now (202) POST /api/review -> run a Fireworks review for one PR on demand POST /api/revise -> re-run review with the user's revision notes POST /api/post -> submit the review to GitHub (side-effectful) A background worker (app/worker.py) pre-reviews non-draft PRs into a SQLite cache (app/store.py) so reviews are ready when opened. Nothing is ever posted automatically. Single-user local tool. """ from __future__ import annotations import hashlib import json from contextlib import asynccontextmanager from pathlib import Path from typing import Any from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from .config import get_config from .github_client import GitHubClient, GitHubError from .handbook import HandbookProvider from .reviewer import Reviewer from .store import ReviewStore from .worker import Worker cfg = get_config() STATIC_DIR = Path(__file__).parent.parent / "static" # lazy singletons so a missing token doesn't crash import _gh: GitHubClient | None = None _reviewer: Reviewer | None = None _store: ReviewStore | None = None _worker: Worker | None = None _handbook: HandbookProvider | None = None def gh() -> GitHubClient: global _gh if _gh is None: _gh = GitHubClient(cfg) return _gh def reviewer() -> Reviewer: global _reviewer if _reviewer is None: _reviewer = Reviewer(cfg) return _reviewer def store() -> ReviewStore | None: return _store def worker() -> Worker | None: return _worker def handbook() -> HandbookProvider | None: return _handbook @asynccontextmanager async def lifespan(app: FastAPI): # Initialize the singletons eagerly, before the worker thread starts, so the # worker and request handlers never race to lazily create them. The handbook # provider is built first, then the reviewer receives its digest callback # (set once at construction; no mutable setter, no circular reference). global _reviewer, _store, _worker, _handbook gh() _handbook = HandbookProvider(cfg) _reviewer = Reviewer(cfg, guidance_provider=_handbook.current_digest) _store = ReviewStore(cfg.CACHE_DB) _worker = Worker(cfg, _gh, _reviewer, _store, handbook=_handbook) _worker.start() try: yield finally: if _worker is not None: _worker.stop() app = FastAPI(title="PR Review Dashboard", lifespan=lifespan) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @app.get("/") def index() -> FileResponse: return FileResponse(STATIC_DIR / "index.html") @app.get("/api/config") def api_config() -> dict[str, Any]: return { "org": cfg.GITHUB_ORG, "filter": cfg.PR_SEARCH_FILTER, "model": cfg.FIREWORKS_MODEL, "mention_authors": cfg.MENTION_AUTHORS, } @app.get("/api/handbook") def api_handbook() -> dict[str, Any]: h = handbook() if h is None: return { "enabled": False, "head_sha": None, "distilled_at": None, "digest_chars": 0, } return h.status() @app.get("/api/prs") def api_prs() -> dict[str, Any]: try: prs = gh().search_prs() return {"prs": prs, "me": gh().whoami()} except GitHubError as e: raise HTTPException(400, str(e)) def _serialize_review(row: dict[str, Any]) -> dict[str, Any]: review = None if row.get("review_json"): try: review = json.loads(row["review_json"]) except (json.JSONDecodeError, TypeError): review = None return { "owner": row["owner"], "repo": row["repo"], "number": row["number"], "title": row.get("pr_title"), "author": row.get("pr_author"), "url": row.get("pr_url"), "status": row["status"], "updated_at": row.get("pr_updated_at"), "created_at": row.get("pr_created_at"), "node_id": row.get("pr_node_id"), "cached_at": row.get("cached_at"), "attempts": row.get("attempts", 0), "error": row.get("error"), "review": review, } @app.get("/api/reviews") def api_reviews() -> dict[str, Any]: s = store() if s is None: raise HTTPException(503, "Review worker is not running yet.") rows = s.list() return { "reviews": [_serialize_review(r) for r in rows], "poll_interval": cfg.POLL_INTERVAL, } @app.post("/api/refresh") def api_refresh() -> JSONResponse: w = worker() if w is None: raise HTTPException(503, "Review worker is not running yet.") w.trigger() # non-blocking: the worker thread runs the cycle return JSONResponse(status_code=202, content={"ok": True, "scheduled": True}) class ReviewReq(BaseModel): owner: str repo: str number: int title: str = "" author: str = "" body: str = "" def _cache_write_through(req: ReviewReq, diff: str, result: dict[str, Any]) -> None: """Best-effort: store an on-demand review in the cache so the dashboard and the worker share one source of truth. Never fails the request.""" if _store is None: return try: _store.upsert( req.owner, req.repo, req.number, status="ready", diff_hash=hashlib.sha256(diff.encode("utf-8", "ignore")).hexdigest(), review_json=json.dumps(result), error=None, attempts=0, pr_title=req.title, pr_author=req.author, ) except Exception: # noqa: BLE001 - caching is best-effort pass @app.post("/api/review") def api_review(req: ReviewReq) -> dict[str, Any]: try: diff = gh().pr_diff(req.owner, req.repo, req.number) pr = req.model_dump() result = reviewer().review(pr, diff) _cache_write_through(req, diff, result) return {"review": result} except GitHubError as e: raise HTTPException(400, str(e)) except Exception as e: # surface Fireworks/parse errors to the UI raise HTTPException(500, f"Review failed: {e}") class ReviseReq(ReviewReq): notes: str # the user's requested changes to the review @app.post("/api/revise") def api_revise(req: ReviseReq) -> dict[str, Any]: try: diff = gh().pr_diff(req.owner, req.repo, req.number) pr = req.model_dump() # Fold the user's revision notes into the diff context as a trusted # reviewer instruction (distinct from the untrusted diff). annotated = ( f"{diff}\n\n--- REVIEWER REVISION NOTES (trusted, from the human " f"reviewer, not from the PR) ---\n{req.notes}" ) result = reviewer().review(pr, annotated) return {"review": result} except GitHubError as e: raise HTTPException(400, str(e)) except Exception as e: # surface Fireworks/parse errors to the UI raise HTTPException(500, f"Revision failed: {e}") class PostReq(BaseModel): owner: str repo: str number: int body: str event: str # COMMENT | APPROVE | REQUEST_CHANGES @app.post("/api/post") def api_post(req: PostReq) -> dict[str, Any]: try: res = gh().submit_review(req.owner, req.repo, req.number, req.body, req.event) return {"ok": True, "html_url": res.get("html_url", "")} except GitHubError as e: raise HTTPException(400, str(e)) class AutomergeReq(BaseModel): owner: str repo: str number: int node_id: str method: str = "SQUASH" # SQUASH | MERGE | REBASE @app.post("/api/automerge") def api_automerge(req: AutomergeReq) -> dict[str, Any]: try: res = gh().enable_automerge(req.node_id, req.method) return {"ok": True, "result": res} except GitHubError as e: raise HTTPException(400, str(e)) def main() -> None: import uvicorn uvicorn.run(app, host=cfg.HOST, port=cfg.PORT) if __name__ == "__main__": main()