332 lines
12 KiB
Python
332 lines
12 KiB
Python
import asyncio
|
|
import math
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from src import gitea_proxy
|
|
from src.gitea_proxy import (
|
|
activity_events,
|
|
current_user,
|
|
is_requested_review,
|
|
issues,
|
|
pull_requests,
|
|
pull_review_detail,
|
|
repos,
|
|
)
|
|
from src.models import Issue, PullRequest, Repo, User
|
|
from src.suggestion_engine import compute
|
|
from src.views import router as frontend_router
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_app: FastAPI):
|
|
gitea_proxy.start_client()
|
|
try:
|
|
yield
|
|
finally:
|
|
await gitea_proxy.stop_client()
|
|
|
|
|
|
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
|
|
CONTEXT_TIMEOUT_SECONDS = 5.0
|
|
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
|
|
READINESS_TIMEOUT_SECONDS = 5.0
|
|
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
|
|
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
|
_live_snapshot_task: asyncio.Task | None = None
|
|
|
|
|
|
class ContextPayloadError(ValueError):
|
|
"""Raised when Gitea returns a structurally invalid context payload."""
|
|
|
|
|
|
class ReadinessPayloadError(ValueError):
|
|
"""Raised when Gitea returns a structurally invalid readiness payload."""
|
|
|
|
|
|
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
|
user_model = User(
|
|
id=user_data["id"],
|
|
login=user_data["login"],
|
|
full_name=user_data.get("full_name") or "",
|
|
email=user_data.get("email") or "",
|
|
)
|
|
repo_models = [
|
|
Repo(
|
|
id=r["id"], name=r["name"], full_name=r["full_name"],
|
|
description=r.get("description") or "", url=r["html_url"],
|
|
updated_at=r.get("updated_at", ""),
|
|
)
|
|
for r in (repo_data or [])[:50]
|
|
if isinstance(r, dict)
|
|
and all(field in r for field in ("id", "name", "full_name", "html_url"))
|
|
]
|
|
issue_models = [
|
|
Issue(
|
|
id=i["id"], number=i["number"], title=i["title"], state=i["state"],
|
|
labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)],
|
|
assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, dict)],
|
|
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
|
|
updated_at=i.get("updated_at") or "", url=i["html_url"],
|
|
)
|
|
for i in (issues_data or [])[:50]
|
|
if isinstance(i, dict)
|
|
and all(field in i for field in ("id", "number", "title", "state", "html_url"))
|
|
]
|
|
pr_models = [
|
|
PullRequest(
|
|
id=p["id"], number=p["number"], title=p["title"], state=p["state"],
|
|
user=p["user"].get("login", "") if isinstance(p.get("user"), dict) else "",
|
|
labels=[label.get("name", "") for label in (p.get("labels") or []) if isinstance(label, dict)],
|
|
assignees=[assignee.get("login", "") for assignee in (p.get("assignees") or []) if isinstance(assignee, dict)],
|
|
work_reasons=[reason for reason in (p.get("work_reasons") or []) if reason in ("assigned_to_me", "review_requested")],
|
|
repository=p["repository"].get("full_name", "") if isinstance(p.get("repository"), dict) else "",
|
|
updated_at=p.get("updated_at") or "", url=p["html_url"],
|
|
)
|
|
for p in (prs_data or [])[:50]
|
|
if isinstance(p, dict)
|
|
and all(field in p for field in ("id", "number", "title", "state", "html_url"))
|
|
]
|
|
return compute(user_model, repo_models, issue_models, pr_models).model_dump()
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=False,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
|
app.include_router(frontend_router)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def prevent_live_api_caching(request, call_next):
|
|
response = await call_next(request)
|
|
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live"} or (
|
|
request.url.path.startswith("/api/v1/repos/")
|
|
and request.url.path.endswith("/review")
|
|
):
|
|
response.headers["Cache-Control"] = "no-store"
|
|
return response
|
|
|
|
|
|
@app.get("/healthz")
|
|
def health() -> dict[str, str]:
|
|
"""Return process liveness without depending on Gitea."""
|
|
return {"status": "ok", "service": "stackchain-dashboard"}
|
|
|
|
|
|
@app.get("/readyz")
|
|
async def readiness():
|
|
"""Return readiness after verifying the configured Gitea connection."""
|
|
try:
|
|
user = await asyncio.wait_for(
|
|
current_user(), timeout=READINESS_TIMEOUT_SECONDS
|
|
)
|
|
if not isinstance(user, dict) or not user.get("login"):
|
|
raise ReadinessPayloadError(
|
|
"Gitea current-user response did not include a login"
|
|
)
|
|
except Exception as exc:
|
|
timed_out = isinstance(exc, TimeoutError)
|
|
error_message = (
|
|
f"Gitea readiness check timed out after {READINESS_TIMEOUT_SECONDS:g}s"
|
|
if timed_out
|
|
else (
|
|
str(exc)
|
|
if isinstance(exc, ReadinessPayloadError)
|
|
else "Gitea readiness check is temporarily unavailable"
|
|
)
|
|
)
|
|
return JSONResponse(
|
|
{
|
|
"status": "not_ready",
|
|
"service": "stackchain-dashboard",
|
|
"error": error_message,
|
|
},
|
|
status_code=503,
|
|
headers={
|
|
"Retry-After": str(max(1, math.ceil(READINESS_TIMEOUT_SECONDS)))
|
|
} if timed_out else None,
|
|
)
|
|
return {
|
|
"status": "ready",
|
|
"service": "stackchain-dashboard",
|
|
"gitea_user": user["login"],
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/context")
|
|
async def context() -> JSONResponse:
|
|
try:
|
|
user_data, repo_data, issues_data, prs_data = await asyncio.wait_for(
|
|
asyncio.gather(current_user(), repos(), issues(), pull_requests()),
|
|
timeout=CONTEXT_TIMEOUT_SECONDS,
|
|
)
|
|
if not isinstance(user_data, dict):
|
|
raise ContextPayloadError("Gitea current-user response was not an object")
|
|
if not all(field in user_data for field in ("id", "login")):
|
|
raise ContextPayloadError(
|
|
"Gitea current-user response did not include id and login"
|
|
)
|
|
if any(
|
|
data is not None and not isinstance(data, list)
|
|
for data in (repo_data, issues_data, prs_data)
|
|
):
|
|
raise ContextPayloadError("Gitea collection response was not a list")
|
|
except Exception as e:
|
|
if isinstance(e, TimeoutError):
|
|
error_message = (
|
|
f"Gitea context request timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"
|
|
)
|
|
elif isinstance(e, ContextPayloadError):
|
|
error_message = str(e)
|
|
else:
|
|
error_message = "Gitea context is temporarily unavailable"
|
|
return JSONResponse({
|
|
"user": {"id": None, "login": "timmy", "full_name": "Timmy Jr", "email": ""},
|
|
"repos": [],
|
|
"issues": [],
|
|
"pull_requests": [],
|
|
"view": "dashboard",
|
|
"deltas": [
|
|
{"panel": "auth", "action": "connect", "target": "stackchain-dashboard backend", "priority": "high"},
|
|
{"panel": "gitea", "action": "set GITEA_URL + GITEA_TOKEN", "target": "/root/stackchain-dashboard/.env or systemd env", "priority": "high"},
|
|
],
|
|
"error": error_message,
|
|
}, headers={
|
|
"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))
|
|
} if isinstance(e, TimeoutError) else None)
|
|
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
|
|
|
|
|
|
async def _load_context_for_user(user_data: dict) -> dict:
|
|
repo_data, issues_data, prs_data = await asyncio.gather(
|
|
repos(), issues(), pull_requests()
|
|
)
|
|
if not all(field in user_data for field in ("id", "login")):
|
|
raise ContextPayloadError("Gitea current-user response did not include id and login")
|
|
if any(
|
|
data is not None and not isinstance(data, list)
|
|
for data in (repo_data, issues_data, prs_data)
|
|
):
|
|
raise ContextPayloadError("Gitea collection response was not a list")
|
|
return _context_payload(user_data, repo_data, issues_data, prs_data)
|
|
|
|
|
|
async def _build_live_snapshot() -> dict:
|
|
user_data = await current_user()
|
|
if not isinstance(user_data, dict) or not user_data.get("login"):
|
|
raise ContextPayloadError("Gitea current-user response was invalid")
|
|
context_result, events_result = await asyncio.gather(
|
|
_load_context_for_user(user_data),
|
|
activity_events(user_data),
|
|
return_exceptions=True,
|
|
)
|
|
context_ok = not isinstance(context_result, BaseException)
|
|
events_ok = not isinstance(events_result, BaseException)
|
|
return {
|
|
"context": context_result if context_ok else None,
|
|
"events": events_result if events_ok else None,
|
|
"sections": {
|
|
"context": "fresh" if context_ok else "temporarily unavailable",
|
|
"events": "fresh" if events_ok else "temporarily unavailable",
|
|
},
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/live")
|
|
async def live_snapshot() -> JSONResponse:
|
|
"""Return a fresh, section-aware snapshot; join only an active identical load."""
|
|
global _live_snapshot_task
|
|
if _live_snapshot_task is None or _live_snapshot_task.done():
|
|
_live_snapshot_task = asyncio.create_task(_build_live_snapshot())
|
|
task = _live_snapshot_task
|
|
try:
|
|
result = await asyncio.wait_for(task, timeout=CONTEXT_TIMEOUT_SECONDS)
|
|
return JSONResponse(result)
|
|
except TimeoutError:
|
|
return JSONResponse(
|
|
{"error": f"Gitea live snapshot timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"},
|
|
status_code=503,
|
|
headers={"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))},
|
|
)
|
|
except Exception:
|
|
return JSONResponse(
|
|
{"error": "Gitea live snapshot is temporarily unavailable"},
|
|
status_code=503,
|
|
)
|
|
finally:
|
|
if task.done() and _live_snapshot_task is task:
|
|
_live_snapshot_task = None
|
|
|
|
|
|
@app.get("/api/v1/events")
|
|
async def event_stream():
|
|
try:
|
|
return await asyncio.wait_for(
|
|
activity_events(), timeout=EVENT_STREAM_TIMEOUT_SECONDS
|
|
)
|
|
except TimeoutError:
|
|
return JSONResponse(
|
|
{
|
|
"error": (
|
|
"Gitea event stream request timed out after "
|
|
f"{EVENT_STREAM_TIMEOUT_SECONDS:g}s"
|
|
)
|
|
},
|
|
status_code=503,
|
|
headers={
|
|
"Retry-After": str(
|
|
max(1, math.ceil(EVENT_STREAM_TIMEOUT_SECONDS))
|
|
)
|
|
},
|
|
)
|
|
except Exception:
|
|
return JSONResponse(
|
|
{"error": "Gitea event stream is temporarily unavailable"},
|
|
status_code=503,
|
|
headers={
|
|
"Retry-After": str(
|
|
max(1, math.ceil(EVENT_STREAM_TIMEOUT_SECONDS))
|
|
)
|
|
},
|
|
)
|
|
|
|
|
|
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review")
|
|
async def review_detail(owner: str, repo: str, number: int):
|
|
async def load_requested_review():
|
|
repository = f"{owner}/{repo}"
|
|
if not await is_requested_review(repository, number):
|
|
raise HTTPException(status_code=404, detail="Review request not found")
|
|
return await pull_review_detail(repository, number)
|
|
|
|
try:
|
|
return await asyncio.wait_for(
|
|
load_requested_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except TimeoutError:
|
|
return JSONResponse(
|
|
{"error": "Pull request review details timed out. Please retry."},
|
|
status_code=503,
|
|
headers={
|
|
"Retry-After": str(
|
|
max(1, math.ceil(REVIEW_DETAIL_TIMEOUT_SECONDS))
|
|
)
|
|
},
|
|
)
|
|
except Exception:
|
|
return JSONResponse(
|
|
{"error": "Pull request review details are temporarily unavailable"},
|
|
status_code=503,
|
|
)
|