feat(forgejo-metrics): add last-push endpoint and related caching logic for recent commits

This commit is contained in:
null 2026-05-22 16:25:47 -05:00
parent dd9681925e
commit 7802400970
6 changed files with 226 additions and 2 deletions

View File

@ -21,7 +21,7 @@ from app.models.board_repository_links import BoardRepositoryLink
from app.models.forgejo_connections import ForgejoConnection from app.models.forgejo_connections import ForgejoConnection
from app.models.forgejo_issues import ForgejoIssue from app.models.forgejo_issues import ForgejoIssue
from app.models.forgejo_repositories import ForgejoRepository from app.models.forgejo_repositories import ForgejoRepository
from app.schemas.metrics import HeatmapDay, HeatmapResponse, MetricsResponse from app.schemas.metrics import HeatmapDay, HeatmapResponse, LastPushRead, MetricsResponse
from app.services.forgejo_client import ForgejoAPIClient, get_forgejo_client from app.services.forgejo_client import ForgejoAPIClient, get_forgejo_client
if TYPE_CHECKING: if TYPE_CHECKING:
@ -71,6 +71,76 @@ async def _bg_fetch_line_stats(
finally: finally:
_line_stats_fetching.discard(cache_key) _line_stats_fetching.discard(cache_key)
# ---------------------------------------------------------------------------
# Last-push background cache
# ---------------------------------------------------------------------------
_last_push_cache: dict[str, tuple[float, LastPushRead | None]] = {}
_last_push_fetching: set[str] = set()
_LAST_PUSH_TTL = 60 # seconds
async def _bg_fetch_last_push(
cache_key: str,
repos: list[tuple[str, str, str, str | None]], # (owner, repo, base_url, token)
) -> None:
"""Background task: find the most-recent commit across all tracked repos."""
async def _one(owner: str, repo: str, base_url: str, token: str | None) -> tuple[str, str, dict] | None:
try:
async with ForgejoAPIClient(base_url=base_url, token=token) as client:
commit = await client.get_last_commit(owner, repo)
if not commit:
return None
return owner, repo, commit
except Exception:
return None
try:
results = await asyncio.gather(*[_one(o, r, bu, tok) for o, r, bu, tok in repos])
best: tuple[str, str, dict] | None = None
best_ts: str = ""
for item in results:
if item is None:
continue
owner, repo_name, commit = item
commit_obj = commit.get("commit") or {}
author_obj = commit_obj.get("author") or {}
date_str: str = author_obj.get("date") or commit.get("created") or ""
if date_str > best_ts:
best_ts = date_str
best = (owner, repo_name, commit)
push: LastPushRead | None = None
if best:
owner, repo_name, commit = best
commit_obj = commit.get("commit") or {}
full_msg: str = commit_obj.get("message") or ""
first_line = full_msg.splitlines()[0] if full_msg else ""
sha_full: str = commit.get("sha") or ""
sha_short = sha_full[:7] if sha_full else ""
author_name: str = (
(commit.get("author") or {}).get("login")
or (commit_obj.get("author") or {}).get("name")
or "unknown"
)
author_obj = (commit_obj.get("author") or {})
date_str = author_obj.get("date") or commit.get("created") or ""
# Infer branch from commit refs if available, otherwise "—"
branch = (commit.get("branch") or "").strip() or ""
push = LastPushRead(
sha=sha_short,
message=first_line,
author=author_name,
repo=f"{owner}/{repo_name}",
branch=branch,
pushed_at=date_str,
)
_last_push_cache[cache_key] = (_time.monotonic(), push)
finally:
_last_push_fetching.discard(cache_key)
router = APIRouter(prefix="/forgejo", tags=["forgejo-metrics"]) router = APIRouter(prefix="/forgejo", tags=["forgejo-metrics"])
SESSION_DEP = Depends(get_session) SESSION_DEP = Depends(get_session)
# Use ORG_MEMBER_DEP directly, not wrapped in Depends again # Use ORG_MEMBER_DEP directly, not wrapped in Depends again
@ -420,6 +490,55 @@ async def get_forgejo_heatmap(
) )
@router.get(
"/last-push",
response_model=LastPushRead | None,
summary="Most-recent commit across all tracked repositories",
)
async def get_last_push(
organization_id: UUID | None = Query(None),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> LastPushRead | None:
"""Return the most-recently committed commit across all tracked repos, served from cache."""
if organization_id and organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
repos_with_conns = (
await session.exec(
select(ForgejoRepository, ForgejoConnection)
.join(ForgejoConnection, ForgejoRepository.connection_id == ForgejoConnection.id)
.where(ForgejoRepository.organization_id == ctx.organization.id)
)
).all()
if not repos_with_conns:
return None
import re as _re
cache_key = f"last-push:{ctx.organization.id}"
cached = _last_push_cache.get(cache_key)
now = _time.monotonic()
if cache_key not in _last_push_fetching and (
cached is None or now - cached[0] > _LAST_PUSH_TTL
):
repo_tuples: list[tuple[str, str, str, str | None]] = []
for repo, conn in repos_with_conns:
bu = (conn.base_url or "").rstrip("/")
if "/api/v1" in bu:
m = _re.match(r"(https?://[^/]+)", bu)
bu = m.group(1).rstrip("/") if m else bu
repo_tuples.append((repo.owner, repo.repo, bu, getattr(conn, "token", None)))
_last_push_fetching.add(cache_key)
asyncio.create_task(
_bg_fetch_last_push(cache_key, repo_tuples),
name=f"last-push-{cache_key}",
)
return cached[1] if cached is not None else None
def _zeroed_metrics() -> MetricsResponse: def _zeroed_metrics() -> MetricsResponse:
"""Return zeroed metrics for empty scopes.""" """Return zeroed metrics for empty scopes."""
return MetricsResponse( return MetricsResponse(

View File

@ -121,6 +121,17 @@ class ForgejoIssueMetrics(SQLModel):
sync_error_counts: dict[str, int] sync_error_counts: dict[str, int]
class LastPushRead(SQLModel):
"""Most-recent commit pushed across all tracked repositories."""
sha: str # short (7-char) hash
message: str # first line of commit message
author: str # display name or login
repo: str # "owner/repo"
branch: str # branch name
pushed_at: str # ISO-8601 timestamp
class HeatmapDay(SQLModel): class HeatmapDay(SQLModel):
"""Single day in the issue activity heatmap.""" """Single day in the issue activity heatmap."""

View File

@ -350,6 +350,24 @@ class ForgejoAPIClient:
return list(data) return list(data)
async def get_last_commit(
self, owner: str, repo: str, branch: str | None = None
) -> dict | None:
"""Return the most-recent commit on the default (or given) branch, or None."""
client = await self._get_client()
params: dict[str, object] = {"limit": 1}
if branch:
params["sha"] = branch
response = await client.get(
f"/api/v1/repos/{owner}/{repo}/commits", params=params
)
if response.status_code in (404, 409):
return None # empty repo or not found
response.raise_for_status()
data = response.json()
commits = data if isinstance(data, list) else data.get("commits") or data.get("data") or []
return commits[0] if commits else None
async def get_contributor_stats(self, owner: str, repo: str) -> tuple[list[dict], bool]: async def get_contributor_stats(self, owner: str, repo: str) -> tuple[list[dict], bool]:
"""Fetch per-contributor weekly stats for a repository. """Fetch per-contributor weekly stats for a repository.

View File

@ -72,6 +72,7 @@ import {
import type { ActivityEventRead } from "@/api/generated/model"; import type { ActivityEventRead } from "@/api/generated/model";
import { import {
getForgejoHeatmap, getForgejoHeatmap,
getForgejoLastPush,
getForgejoMetrics, getForgejoMetrics,
getForgejoRepositories, getForgejoRepositories,
type ForgejoHeatmapDay, type ForgejoHeatmapDay,
@ -538,6 +539,17 @@ export default function DashboardPage() {
}, },
}); });
const forgejoLastPushQuery = useQuery({
queryKey: ["dashboard", "forgejo", "last-push", forgejoOrganizationId],
enabled: Boolean(isSignedIn && forgejoOrganizationId),
refetchInterval: 60_000,
refetchOnMount: "always",
queryFn: () => {
if (!forgejoOrganizationId) return Promise.resolve(null);
return getForgejoLastPush({ organization_id: forgejoOrganizationId });
},
});
const boards = useMemo( const boards = useMemo(
() => () =>
boardsQuery.data?.status === 200 boardsQuery.data?.status === 200
@ -1243,6 +1255,7 @@ export default function DashboardPage() {
totalAdditions={forgejoHeatmapQuery.data?.total_additions ?? 0} totalAdditions={forgejoHeatmapQuery.data?.total_additions ?? 0}
totalDeletions={forgejoHeatmapQuery.data?.total_deletions ?? 0} totalDeletions={forgejoHeatmapQuery.data?.total_deletions ?? 0}
hasLineStats={forgejoHeatmapQuery.data?.has_line_stats ?? false} hasLineStats={forgejoHeatmapQuery.data?.has_line_stats ?? false}
lastPush={forgejoLastPushQuery.data ?? null}
isLoading={forgejoHeatmapQuery.isLoading} isLoading={forgejoHeatmapQuery.isLoading}
/> />
</section> </section>

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import type { ForgejoHeatmapDay } from "@/lib/api-forgejo"; import type { ForgejoHeatmapDay, ForgejoLastPush } from "@/lib/api-forgejo";
interface ForgejoHeatmapProps { interface ForgejoHeatmapProps {
days: ForgejoHeatmapDay[]; days: ForgejoHeatmapDay[];
@ -9,6 +9,7 @@ interface ForgejoHeatmapProps {
totalAdditions?: number; totalAdditions?: number;
totalDeletions?: number; totalDeletions?: number;
hasLineStats?: boolean; hasLineStats?: boolean;
lastPush?: ForgejoLastPush | null;
isLoading?: boolean; isLoading?: boolean;
} }
@ -57,12 +58,24 @@ function fmtLines(n: number): string {
return n.toLocaleString(); return n.toLocaleString();
} }
function fmtRelative(iso: string): string {
const diffMs = Date.now() - new Date(iso).getTime();
const s = Math.floor(diffMs / 1000);
if (s < 60) return "just now";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export function ForgejoHeatmap({ export function ForgejoHeatmap({
days, days,
maxCount: _maxCount, maxCount: _maxCount,
totalAdditions = 0, totalAdditions = 0,
totalDeletions = 0, totalDeletions = 0,
hasLineStats = false, hasLineStats = false,
lastPush = null,
isLoading = false, isLoading = false,
}: ForgejoHeatmapProps) { }: ForgejoHeatmapProps) {
const { weeks, monthLabels } = useMemo(() => { const { weeks, monthLabels } = useMemo(() => {
@ -176,6 +189,35 @@ export function ForgejoHeatmap({
</span> </span>
</p> </p>
{/* ── Last push ───────────────────────────────────────────── */}
{lastPush && (
<div
className="mx-auto flex max-w-lg items-start gap-3 rounded-lg px-4 py-2.5"
style={{ background: "rgba(139,92,246,0.08)", border: "1px solid rgba(139,92,246,0.18)" }}
>
{/* commit hash pill */}
<span
className="mt-0.5 shrink-0 rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold"
style={{ background: "rgba(139,92,246,0.20)", color: VIOLET }}
>
{lastPush.sha}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium" style={{ color: "rgba(255,255,255,0.90)" }}>
{lastPush.message}
</p>
<p className="mt-0.5 text-xs" style={{ color: "rgba(255,255,255,0.50)" }}>
{lastPush.author} pushed to{" "}
<span style={{ color: "rgba(139,92,246,0.90)" }}>{lastPush.branch}</span>
{" · "}
{lastPush.repo}
{" · "}
{fmtRelative(lastPush.pushed_at)}
</p>
</div>
</div>
)}
{/* ── Line stats ──────────────────────────────────────────── */} {/* ── Line stats ──────────────────────────────────────────── */}
{hasLineStats ? ( {hasLineStats ? (
<div className="flex items-center justify-center gap-8 pt-1"> <div className="flex items-center justify-center gap-8 pt-1">

View File

@ -608,6 +608,27 @@ export interface ForgejoHeatmapDay {
count: number; count: number;
} }
export interface ForgejoLastPush {
sha: string;
message: string;
author: string;
repo: string;
branch: string;
pushed_at: string; // ISO-8601
}
export async function getForgejoLastPush(params?: {
organization_id?: string;
}): Promise<ForgejoLastPush | null> {
const searchParams = new URLSearchParams();
if (params?.organization_id)
searchParams.set("organization_id", params.organization_id);
const qs = searchParams.toString();
return fetchJson<ForgejoLastPush | null>(
`/api/v1/forgejo/last-push${qs ? `?${qs}` : ""}`,
);
}
export interface ForgejoHeatmapResponse { export interface ForgejoHeatmapResponse {
days: ForgejoHeatmapDay[]; days: ForgejoHeatmapDay[];
max_count: number; max_count: number;