From bbfde53fe9e393e43789610d05118086f4c69842 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 22 May 2026 16:04:32 -0500 Subject: [PATCH] feat(forgejo-metrics): enhance heatmap metrics to include line contributions and adjust time frame to last 6 months --- backend/app/api/forgejo_metrics.py | 59 +++++++-- backend/app/schemas/metrics.py | 5 +- backend/app/services/forgejo_client.py | 30 +++++ frontend/src/app/dashboard/page.tsx | 5 +- .../src/app/settings/ai-providers/page.tsx | 16 ++- .../src/components/git/ForgejoHeatmap.tsx | 117 +++++++++++++----- .../organisms/ProviderNavbarStatus.tsx | 22 ++++ frontend/src/lib/api-forgejo.ts | 3 + 8 files changed, 208 insertions(+), 49 deletions(-) diff --git a/backend/app/api/forgejo_metrics.py b/backend/app/api/forgejo_metrics.py index ac7c218..bf6987a 100644 --- a/backend/app/api/forgejo_metrics.py +++ b/backend/app/api/forgejo_metrics.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import timedelta from typing import TYPE_CHECKING from uuid import UUID @@ -16,9 +17,11 @@ from app.api.deps import ORG_MEMBER_DEP, OrganizationContext from app.core.time import utcnow from app.db.session import get_session 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.services.forgejo_client import get_forgejo_client if TYPE_CHECKING: from sqlmodel.ext.asyncio.session import AsyncSession @@ -261,30 +264,31 @@ async def get_forgejo_metrics( "/heatmap", response_model=HeatmapResponse, summary="Forgejo issue activity heatmap", - description="Daily issue open+close event counts for the last 365 days, scoped to the caller's organisation.", + description="Daily issue open+close event counts for the last 6 months, scoped to the caller's organisation.", ) async def get_forgejo_heatmap( organization_id: UUID | None = Query(None, description="Filter by organisation ID"), session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> HeatmapResponse: - """Return per-day issue event counts (created + closed) for the last 365 days.""" + """Return per-day issue event counts and total line contributions for the last 6 months.""" if organization_id and organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - since = utcnow() - timedelta(days=365) + since = utcnow() - timedelta(days=183) - repo_ids_result = ( + # Fetch repos with their connections in one query + repos_with_conns = ( await session.exec( - select(ForgejoRepository.id).where( - ForgejoRepository.organization_id == ctx.organization.id, - ) + select(ForgejoRepository, ForgejoConnection) + .join(ForgejoConnection, ForgejoRepository.connection_id == ForgejoConnection.id) + .where(ForgejoRepository.organization_id == ctx.organization.id) ) ).all() - if not repo_ids_result: + if not repos_with_conns: return HeatmapResponse(days=[], max_count=0) - repo_ids = list(repo_ids_result) + repo_ids = [repo.id for repo, _ in repos_with_conns] counts: dict[str, int] = {} # Issues created per day @@ -325,9 +329,40 @@ async def get_forgejo_heatmap( key = str(day) counts[key] = counts.get(key, 0) + int(cnt) - days = [HeatmapDay(date=k, count=v) for k, v in sorted(counts.items())] - max_count = max((d.count for d in days), default=0) - return HeatmapResponse(days=days, max_count=max_count) + days_list = [HeatmapDay(date=k, count=v) for k, v in sorted(counts.items())] + max_count = max((d.count for d in days_list), default=0) + + # Fetch contributor line stats concurrently from Forgejo API + since_ts = since.timestamp() + + async def _repo_line_stats(repo: ForgejoRepository, conn: ForgejoConnection) -> tuple[int, int, bool]: + try: + async with get_forgejo_client(conn) as client: + contributors, has_data = await client.get_contributor_stats(repo.owner, repo.repo) + adds = dels = 0 + for contributor in contributors: + for week in contributor.get("weeks", []): + if (week.get("w") or 0) >= since_ts: + adds += week.get("a", 0) or 0 + dels += week.get("d", 0) or 0 + return adds, dels, has_data + except Exception: + return 0, 0, False + + line_results = await asyncio.gather( + *[_repo_line_stats(repo, conn) for repo, conn in repos_with_conns] + ) + total_additions = sum(a for a, _, _ in line_results) + total_deletions = sum(d for _, d, _ in line_results) + has_line_stats = all(ok for _, _, ok in line_results) + + return HeatmapResponse( + days=days_list, + max_count=max_count, + total_additions=total_additions, + total_deletions=total_deletions, + has_line_stats=has_line_stats, + ) def _zeroed_metrics() -> MetricsResponse: diff --git a/backend/app/schemas/metrics.py b/backend/app/schemas/metrics.py index 000d674..cedf71e 100644 --- a/backend/app/schemas/metrics.py +++ b/backend/app/schemas/metrics.py @@ -129,10 +129,13 @@ class HeatmapDay(SQLModel): class HeatmapResponse(SQLModel): - """Issue activity heatmap for the last 365 days.""" + """Issue activity heatmap for the last 6 months.""" days: list[HeatmapDay] max_count: int + total_additions: int = 0 + total_deletions: int = 0 + has_line_stats: bool = False # False when Forgejo is still computing stats (HTTP 202) class MetricsResponse(SQLModel): diff --git a/backend/app/services/forgejo_client.py b/backend/app/services/forgejo_client.py index d5bc3dc..c328dd4 100644 --- a/backend/app/services/forgejo_client.py +++ b/backend/app/services/forgejo_client.py @@ -350,6 +350,36 @@ class ForgejoAPIClient: return list(data) + async def get_contributor_stats(self, owner: str, repo: str) -> tuple[list[dict], bool]: + """Fetch per-contributor weekly stats for a repository. + + Returns (contributors, has_data). On the first call Forgejo may return + HTTP 202 ("computing") — we wait 2 s and retry once so the stats are + available on the next dashboard load even if not this one. + + Each contributor has a ``weeks`` array with ``w`` (Unix timestamp of + week start), ``a`` (additions), and ``d`` (deletions). + """ + import asyncio as _asyncio + + client = await self._get_client() + url = f"/api/v1/repos/{owner}/{repo}/stats/contributors" + response = await client.get(url) + + if response.status_code == 202: + # Forgejo is computing — wait briefly then try once more + await _asyncio.sleep(2) + response = await client.get(url) + + if response.status_code == 202: + return [], False # still computing after retry + if response.status_code == 404: + return [], True # no data, but not a 202 — treat as "has_data" + response.raise_for_status() + data = response.json() + return (data if isinstance(data, list) else []), True + + def get_forgejo_client( connection: object, ) -> ForgejoAPIClient: diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 61bc193..57b2d5b 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -522,7 +522,7 @@ export default function DashboardPage() { }, }); - const forgejoHeatmapQuery = useQuery<{ days: ForgejoHeatmapDay[]; max_count: number } | null, Error>({ + const forgejoHeatmapQuery = useQuery<{ days: ForgejoHeatmapDay[]; max_count: number; total_additions: number; total_deletions: number; has_line_stats: boolean } | null, Error>({ queryKey: ["dashboard", "forgejo", "heatmap", forgejoOrganizationId], enabled: Boolean( isSignedIn && @@ -1240,6 +1240,9 @@ export default function DashboardPage() { diff --git a/frontend/src/app/settings/ai-providers/page.tsx b/frontend/src/app/settings/ai-providers/page.tsx index 3eb563a..0e92a70 100644 --- a/frontend/src/app/settings/ai-providers/page.tsx +++ b/frontend/src/app/settings/ai-providers/page.tsx @@ -592,11 +592,23 @@ function UsageWindowBar({ label, pct, resetInMs, badge }: UsageWindowBarProps) { ); } +function fmtElapsed(since: Date, now: number): string { + const s = Math.round((now - since.getTime()) / 1000); + if (s < 60) return `${s}s ago`; + return `${Math.floor(s / 60)}m ago`; +} + function UsageStrip({ credentialId, provider }: { credentialId: string; provider: string }) { const [usage, setUsage] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastFetched, setLastFetched] = useState(null); + const [now, setNow] = useState(Date.now()); + + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); const fetchUsage = useCallback( async (refresh = false) => { @@ -777,7 +789,7 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider )}
{lastFetched && ( - Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago + Updated {fmtElapsed(lastFetched, now)} )}
@@ -828,7 +840,7 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
{lastFetched && ( - Updated {Math.round((Date.now() - lastFetched.getTime()) / 1000)}s ago + Updated {fmtElapsed(lastFetched, now)} )}