pr-reviewer/app/main.py
Adam Moussa 26f0558589
Group PRs by repo in the sidebar and add auto-merge
Group the review queue into a collapsible section per repo (collapse
state persisted in localStorage), with PRs ordered oldest to newest by
creation date, so a large multi-repo queue is easier to scan.

Add an optional per-PR auto-merge control: a method choice (squash by
default per handbook, merge, or rebase) enables GitHub auto-merge via a
GraphQL mutation, so the PR merges once required checks pass. It only
fires when clicked; nothing merges automatically.

Back this with created_at and node_id from the PR search, two new
nullable store columns added via an idempotent PRAGMA-guarded migration,
and a cheap-gate metadata backfill so already-cached PRs gain node_id
without being re-reviewed. New endpoint POST /api/automerge.
2026-07-01 19:11:09 -04:00

292 lines
8.3 KiB
Python

"""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()