From 7802400970c8d2804b30e8336b0f0ec0bb4adb36 Mon Sep 17 00:00:00 2001
From: null
Date: Fri, 22 May 2026 16:25:47 -0500
Subject: [PATCH] feat(forgejo-metrics): add last-push endpoint and related
caching logic for recent commits
---
backend/app/api/forgejo_metrics.py | 121 +++++++++++++++++-
backend/app/schemas/metrics.py | 11 ++
backend/app/services/forgejo_client.py | 18 +++
frontend/src/app/dashboard/page.tsx | 13 ++
.../src/components/git/ForgejoHeatmap.tsx | 44 ++++++-
frontend/src/lib/api-forgejo.ts | 21 +++
6 files changed, 226 insertions(+), 2 deletions(-)
diff --git a/backend/app/api/forgejo_metrics.py b/backend/app/api/forgejo_metrics.py
index b85d124..1ec375c 100644
--- a/backend/app/api/forgejo_metrics.py
+++ b/backend/app/api/forgejo_metrics.py
@@ -21,7 +21,7 @@ from app.models.board_repository_links import BoardRepositoryLink
from app.models.forgejo_connections import ForgejoConnection
from app.models.forgejo_issues import ForgejoIssue
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
if TYPE_CHECKING:
@@ -71,6 +71,76 @@ async def _bg_fetch_line_stats(
finally:
_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"])
SESSION_DEP = Depends(get_session)
# 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:
"""Return zeroed metrics for empty scopes."""
return MetricsResponse(
diff --git a/backend/app/schemas/metrics.py b/backend/app/schemas/metrics.py
index cedf71e..c6bd76a 100644
--- a/backend/app/schemas/metrics.py
+++ b/backend/app/schemas/metrics.py
@@ -121,6 +121,17 @@ class ForgejoIssueMetrics(SQLModel):
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):
"""Single day in the issue activity heatmap."""
diff --git a/backend/app/services/forgejo_client.py b/backend/app/services/forgejo_client.py
index ec0d013..c453133 100644
--- a/backend/app/services/forgejo_client.py
+++ b/backend/app/services/forgejo_client.py
@@ -350,6 +350,24 @@ class ForgejoAPIClient:
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]:
"""Fetch per-contributor weekly stats for a repository.
diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx
index 57b2d5b..c17ed56 100644
--- a/frontend/src/app/dashboard/page.tsx
+++ b/frontend/src/app/dashboard/page.tsx
@@ -72,6 +72,7 @@ import {
import type { ActivityEventRead } from "@/api/generated/model";
import {
getForgejoHeatmap,
+ getForgejoLastPush,
getForgejoMetrics,
getForgejoRepositories,
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(
() =>
boardsQuery.data?.status === 200
@@ -1243,6 +1255,7 @@ export default function DashboardPage() {
totalAdditions={forgejoHeatmapQuery.data?.total_additions ?? 0}
totalDeletions={forgejoHeatmapQuery.data?.total_deletions ?? 0}
hasLineStats={forgejoHeatmapQuery.data?.has_line_stats ?? false}
+ lastPush={forgejoLastPushQuery.data ?? null}
isLoading={forgejoHeatmapQuery.isLoading}
/>
diff --git a/frontend/src/components/git/ForgejoHeatmap.tsx b/frontend/src/components/git/ForgejoHeatmap.tsx
index 5e32195..d97355b 100644
--- a/frontend/src/components/git/ForgejoHeatmap.tsx
+++ b/frontend/src/components/git/ForgejoHeatmap.tsx
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
-import type { ForgejoHeatmapDay } from "@/lib/api-forgejo";
+import type { ForgejoHeatmapDay, ForgejoLastPush } from "@/lib/api-forgejo";
interface ForgejoHeatmapProps {
days: ForgejoHeatmapDay[];
@@ -9,6 +9,7 @@ interface ForgejoHeatmapProps {
totalAdditions?: number;
totalDeletions?: number;
hasLineStats?: boolean;
+ lastPush?: ForgejoLastPush | null;
isLoading?: boolean;
}
@@ -57,12 +58,24 @@ function fmtLines(n: number): string {
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({
days,
maxCount: _maxCount,
totalAdditions = 0,
totalDeletions = 0,
hasLineStats = false,
+ lastPush = null,
isLoading = false,
}: ForgejoHeatmapProps) {
const { weeks, monthLabels } = useMemo(() => {
@@ -176,6 +189,35 @@ export function ForgejoHeatmap({
+ {/* ── Last push ───────────────────────────────────────────── */}
+ {lastPush && (
+
+ {/* commit hash pill */}
+
+ {lastPush.sha}
+
+
+
+ {lastPush.message}
+
+
+ {lastPush.author} pushed to{" "}
+ {lastPush.branch}
+ {" · "}
+ {lastPush.repo}
+ {" · "}
+ {fmtRelative(lastPush.pushed_at)}
+
+
+
+ )}
+
{/* ── Line stats ──────────────────────────────────────────── */}
{hasLineStats ? (
diff --git a/frontend/src/lib/api-forgejo.ts b/frontend/src/lib/api-forgejo.ts
index 63348b7..524ade8 100644
--- a/frontend/src/lib/api-forgejo.ts
+++ b/frontend/src/lib/api-forgejo.ts
@@ -608,6 +608,27 @@ export interface ForgejoHeatmapDay {
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 {
+ const searchParams = new URLSearchParams();
+ if (params?.organization_id)
+ searchParams.set("organization_id", params.organization_id);
+ const qs = searchParams.toString();
+ return fetchJson(
+ `/api/v1/forgejo/last-push${qs ? `?${qs}` : ""}`,
+ );
+}
+
export interface ForgejoHeatmapResponse {
days: ForgejoHeatmapDay[];
max_count: number;