mirror of
https://github.com/Sea-Haven-Industries/pr-reviewer.git
synced 2026-09-30 17:23:16 +00:00
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.
292 lines
8.3 KiB
Python
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()
|