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;