pr-reviewer/app/main.py
Adam Moussa 46cd856fb3
Add pr-reviewer local PR review tool
A single-user local dashboard that pulls open PRs from the
Sea-Haven-Industries org, reviews each with a Fireworks model in the
BLOCK/FIX/NIT/QUESTION format, and posts the review to GitHub as the
token owner. Runs only on localhost; secrets stay in a gitignored .env
and never reach the browser.

Structured as an app/ package plus a static/ frontend so the module
imports and static mount resolve. HTTP uses httpx2 (the runtime lib
starlette's TestClient now prefers), pinned in requirements.txt.

Includes a stdlib-only pytest suite (network mocked, no extra test
deps so CI needs only pytest) with a skip-by-default live Fireworks
test, and CI wired to the org ci-python-app reusable workflow to lint
app and tests and run the mocked suite fully offline. Dependabot covers
pip and github-actions.
2026-07-01 13:48:10 -04:00

145 lines
3.9 KiB
Python

"""FastAPI backend for the local PR review dashboard.
Endpoints:
GET / -> serves the dashboard
GET /api/prs -> list PRs matching the filter (no review yet)
POST /api/review -> run a Fireworks review for one PR
POST /api/revise -> re-run review with the user's revision notes
POST /api/post -> submit the review to GitHub (side-effectful)
In-memory store only; state resets on restart. This is a single-user local tool.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from .config import get_config
from .github_client import GitHubClient, GitHubError
from .reviewer import Reviewer
cfg = get_config()
app = FastAPI(title="PR Review Dashboard")
STATIC_DIR = Path(__file__).parent.parent / "static"
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# lazy singletons so a missing token doesn't crash import
_gh: GitHubClient | None = None
_reviewer: Reviewer | 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
@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/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))
class ReviewReq(BaseModel):
owner: str
repo: str
number: int
title: str = ""
author: str = ""
body: str = ""
@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)
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))
def main() -> None:
import uvicorn
uvicorn.run(app, host=cfg.HOST, port=cfg.PORT)
if __name__ == "__main__":
main()