From b92726df669f06232360b1e3087a16e5360279c3 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 26 May 2026 16:58:30 -0500 Subject: [PATCH] Git Project selecto --- backend/app/api/forgejo_metrics.py | 155 +++++++++++++----- frontend/src/app/dashboard/page.tsx | 93 +++++++---- .../git/ForgejoIssueMetricCards.tsx | 10 +- frontend/src/lib/api-forgejo.ts | 6 + 4 files changed, 193 insertions(+), 71 deletions(-) diff --git a/backend/app/api/forgejo_metrics.py b/backend/app/api/forgejo_metrics.py index ece5de0..43ed6bf 100644 --- a/backend/app/api/forgejo_metrics.py +++ b/backend/app/api/forgejo_metrics.py @@ -22,7 +22,13 @@ from app.models.forgejo_commit_activity import ForgejoCommitDay 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 CommitActivityDay, HeatmapDay, HeatmapResponse, LastPushRead, MetricsResponse +from app.schemas.metrics import ( + CommitActivityDay, + HeatmapDay, + HeatmapResponse, + LastPushRead, + MetricsResponse, +) from app.services.forgejo_client import ForgejoAPIClient if TYPE_CHECKING: @@ -38,8 +44,8 @@ if TYPE_CHECKING: # --------------------------------------------------------------------------- _line_stats_cache: dict[str, tuple[float, int, int, bool, dict[str, int]]] = {} _line_stats_fetching: set[str] = set() -_LINE_STATS_TTL_HIT = 300 # 5 min — re-fetch cadence once real data is cached -_LINE_STATS_TTL_MISS = 30 # 30 s — retry cadence while Forgejo is still computing +_LINE_STATS_TTL_HIT = 300 # 5 min — re-fetch cadence once real data is cached +_LINE_STATS_TTL_MISS = 30 # 30 s — retry cadence while Forgejo is still computing async def _bg_fetch_line_stats( @@ -50,7 +56,9 @@ async def _bg_fetch_line_stats( ) -> None: """Background task: sum commit line stats, persist per-repo day counts to DB.""" - async def _one(owner: str, repo: str, base_url: str, token: str | None) -> tuple[int, int, dict[str, int]]: + async def _one( + owner: str, repo: str, base_url: str, token: str | None + ) -> tuple[int, int, dict[str, int]]: try: async with ForgejoAPIClient(base_url=base_url, token=token) as client: return await client.get_commit_line_stats_since(owner, repo, since_iso) @@ -58,7 +66,9 @@ async def _bg_fetch_line_stats( return 0, 0, {} try: - results = await asyncio.gather(*[_one(o, r, bu, tok) for o, r, bu, tok, _, _ in repos]) + results = await asyncio.gather( + *[_one(o, r, bu, tok) for o, r, bu, tok, _, _ in repos] + ) total_adds = sum(a for a, _, _ in results) total_dels = sum(d for _, d, _ in results) merged_days: dict[str, int] = {} @@ -66,8 +76,11 @@ async def _bg_fetch_line_stats( # Persist per-repo per-day counts to DB (upsert). try: from datetime import date as _date + async with async_session_maker() as session: - for (_, _, _, _, repo_id, org_id), (_, _, day_counts) in zip(repos, results): + for (_, _, _, _, repo_id, org_id), (_, _, day_counts) in zip( + repos, results + ): for day_str, cnt in day_counts.items(): try: day_obj = _date.fromisoformat(day_str) @@ -96,6 +109,7 @@ async def _bg_fetch_line_stats( await session.commit() except Exception as db_exc: from app.core.logging import get_logger as _get_logger + _get_logger(__name__).warning( "commit_activity_db_write_failed", extra={"error": str(db_exc)} ) @@ -104,10 +118,17 @@ async def _bg_fetch_line_stats( for day, cnt in day_counts.items(): merged_days[day] = merged_days.get(day, 0) + cnt - _line_stats_cache[cache_key] = (_time.monotonic(), total_adds, total_dels, True, merged_days) + _line_stats_cache[cache_key] = ( + _time.monotonic(), + total_adds, + total_dels, + True, + merged_days, + ) finally: _line_stats_fetching.discard(cache_key) + # --------------------------------------------------------------------------- # Last-push background cache # --------------------------------------------------------------------------- @@ -122,7 +143,9 @@ async def _bg_fetch_last_push( ) -> 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: + 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) @@ -133,7 +156,9 @@ async def _bg_fetch_last_push( return None try: - results = await asyncio.gather(*[_one(o, r, bu, tok) for o, r, bu, tok in repos]) + 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: @@ -160,7 +185,7 @@ async def _bg_fetch_last_push( or (commit_obj.get("author") or {}).get("name") or "unknown" ) - author_obj = (commit_obj.get("author") or {}) + 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 "—" @@ -249,12 +274,12 @@ async def get_forgejo_metrics( ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> MetricsResponse: """Get Forgejo issue tracking metrics. - + Filters: - organization_id: All boards/repositories in organization - board_id: All repositories linked to board - repository_id: Single repository - + Empty scope (no filters) returns zeroed metrics. """ # Determine scope @@ -388,9 +413,7 @@ async def get_forgejo_metrics( if repo: repo_key = str(repo_id) last_sync_timestamps[repo_key] = ( - repo.last_sync_at.isoformat() - if repo.last_sync_at - else "" + repo.last_sync_at.isoformat() if repo.last_sync_at else "" ) sync_error_counts[repo_key] = 1 if repo.last_sync_error else 0 @@ -420,6 +443,9 @@ async def get_forgejo_metrics( ) async def get_forgejo_heatmap( organization_id: UUID | None = Query(None, description="Filter by organisation ID"), + repository_id: UUID | None = Query( + None, description="Filter by a single repository ID" + ), session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> HeatmapResponse: @@ -433,14 +459,18 @@ async def get_forgejo_heatmap( # repos. 90 days is enough context for the dashboard summary numbers. line_stats_since = utcnow() - timedelta(days=90) - # Fetch repos with their connections in one query - repos_with_conns = ( - await session.exec( - select(ForgejoRepository, ForgejoConnection) - .join(ForgejoConnection, ForgejoRepository.connection_id == ForgejoConnection.id) - .where(ForgejoRepository.organization_id == ctx.organization.id) + # Fetch scoped repos with their connections in one query + repos_statement = ( + select(ForgejoRepository, ForgejoConnection) + .join( + ForgejoConnection, ForgejoRepository.connection_id == ForgejoConnection.id ) - ).all() + .where(ForgejoRepository.organization_id == ctx.organization.id) + ) + if repository_id: + repos_statement = repos_statement.where(ForgejoRepository.id == repository_id) + + repos_with_conns = (await session.exec(repos_statement)).all() if not repos_with_conns: return HeatmapResponse(days=[], max_count=0) @@ -448,7 +478,9 @@ async def get_forgejo_heatmap( # Line stats — served from background cache; fire refresh if stale. since_iso = line_stats_since.strftime("%Y-%m-%dT%H:%M:%SZ") - cache_key = str(ctx.organization.id) + cache_key = ( + f"repo:{repository_id}" if repository_id else f"org:{ctx.organization.id}" + ) cached = _line_stats_cache.get(cache_key) now = _time.monotonic() @@ -459,13 +491,23 @@ async def get_forgejo_heatmap( # Normalise base_url the same way get_forgejo_client() does, eagerly, # so the background task never touches a potentially-closed session. import re as _re + repo_tuples: list[tuple[str, str, str, str | None, UUID, UUID]] = [] 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), repo.id, repo.organization_id)) + repo_tuples.append( + ( + repo.owner, + repo.repo, + bu, + getattr(conn, "token", None), + repo.id, + repo.organization_id, + ) + ) _line_stats_fetching.add(cache_key) asyncio.create_task( @@ -485,7 +527,10 @@ async def get_forgejo_heatmap( # background fetch is in progress. commit_db_rows = ( await session.exec( - select(ForgejoCommitDay.date, func.sum(ForgejoCommitDay.commit_count).label("cnt")) + select( + ForgejoCommitDay.date, + func.sum(ForgejoCommitDay.commit_count).label("cnt"), + ) .where( ForgejoCommitDay.repository_id.in_(repo_ids), ForgejoCommitDay.date >= line_stats_since.date(), @@ -496,11 +541,15 @@ async def get_forgejo_heatmap( ).all() if commit_db_rows: - days_list = [HeatmapDay(date=str(day), count=int(cnt)) for day, cnt in commit_db_rows] + days_list = [ + HeatmapDay(date=str(day), count=int(cnt)) for day, cnt in commit_db_rows + ] max_count = max((d.count for d in days_list), default=0) elif has_line_stats and commit_day_counts: # In-memory cache hit but DB write hasn't landed yet - days_list = [HeatmapDay(date=k, count=v) for k, v in sorted(commit_day_counts.items())] + days_list = [ + HeatmapDay(date=k, count=v) for k, v in sorted(commit_day_counts.items()) + ] max_count = max((d.count for d in days_list), default=0) else: # First-ever load — fall back to issue-event counts from DB while background fetch runs @@ -511,12 +560,14 @@ async def get_forgejo_heatmap( select( sa_cast(ForgejoIssue.forgejo_created_at, SADate).label("day"), func.count().label("cnt"), - ).where( + ) + .where( ForgejoIssue.repository_id.in_(repo_ids), ForgejoIssue.is_pull_request.is_(False), ForgejoIssue.forgejo_created_at.is_not(None), ForgejoIssue.forgejo_created_at >= since, - ).group_by(sa_cast(ForgejoIssue.forgejo_created_at, SADate)) + ) + .group_by(sa_cast(ForgejoIssue.forgejo_created_at, SADate)) ) ).all() for day, cnt in created_rows: @@ -529,12 +580,14 @@ async def get_forgejo_heatmap( select( sa_cast(ForgejoIssue.forgejo_closed_at, SADate).label("day"), func.count().label("cnt"), - ).where( + ) + .where( ForgejoIssue.repository_id.in_(repo_ids), ForgejoIssue.is_pull_request.is_(False), ForgejoIssue.forgejo_closed_at.is_not(None), ForgejoIssue.forgejo_closed_at >= since, - ).group_by(sa_cast(ForgejoIssue.forgejo_closed_at, SADate)) + ) + .group_by(sa_cast(ForgejoIssue.forgejo_closed_at, SADate)) ) ).all() for day, cnt in closed_rows: @@ -561,6 +614,7 @@ async def get_forgejo_heatmap( ) async def get_last_push( organization_id: UUID | None = Query(None), + repository_id: UUID | None = Query(None), session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> LastPushRead | None: @@ -568,18 +622,27 @@ async def get_last_push( 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) + repos_statement = ( + select(ForgejoRepository, ForgejoConnection) + .join( + ForgejoConnection, ForgejoRepository.connection_id == ForgejoConnection.id ) - ).all() + .where(ForgejoRepository.organization_id == ctx.organization.id) + ) + if repository_id: + repos_statement = repos_statement.where(ForgejoRepository.id == repository_id) + + repos_with_conns = (await session.exec(repos_statement)).all() if not repos_with_conns: return None import re as _re - cache_key = f"last-push:{ctx.organization.id}" + + cache_key = ( + f"last-push:repo:{repository_id}" + if repository_id + else f"last-push:org:{ctx.organization.id}" + ) cached = _last_push_cache.get(cache_key) now = _time.monotonic() @@ -592,7 +655,9 @@ async def get_last_push( 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))) + repo_tuples.append( + (repo.owner, repo.repo, bu, getattr(conn, "token", None)) + ) _last_push_fetching.add(cache_key) asyncio.create_task( @@ -615,8 +680,12 @@ async def get_last_push( ) async def get_commit_activity( organization_id: UUID | None = Query(None, description="Filter by organisation ID"), - repository_id: UUID | None = Query(None, description="Filter by a single repository ID"), - days: int = Query(default=90, ge=1, le=365, description="How many days back to return"), + repository_id: UUID | None = Query( + None, description="Filter by a single repository ID" + ), + days: int = Query( + default=90, ge=1, le=365, description="How many days back to return" + ), session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> list[CommitActivityDay]: @@ -675,7 +744,9 @@ async def get_commit_activity( ) ).all() - return [CommitActivityDay(date=str(day), commit_count=int(cnt)) for day, cnt in rows] + return [ + CommitActivityDay(date=str(day), commit_count=int(cnt)) for day, cnt in rows + ] def _zeroed_metrics() -> MetricsResponse: diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index cd04a9e..3792983 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -441,7 +441,6 @@ export default function DashboardPage() { const { isSignedIn } = useAuth(); const [selectedForgejoRepositoryId, setSelectedForgejoRepositoryId] = useState(ALL_FORGEJO_REPOSITORIES); - const [ripleyAutoSelected, setRipleyAutoSelected] = useState(false); const [isRefreshingForgejoSync, setIsRefreshingForgejoSync] = useState(false); const [createForgejoIssueOpen, setCreateForgejoIssueOpen] = useState(false); @@ -552,6 +551,25 @@ export default function DashboardPage() { ?.organization_id ?? null, [forgejoRepositories], ); + const ripleyForgejoRepository = useMemo(() => { + const project = botReportQuery.data?.project; + if (!project || forgejoRepositories.length === 0) return null; + + const needle = project.toLowerCase().trim(); + return ( + forgejoRepositories.find((repo) => { + const repoName = repo.repo.toLowerCase().trim(); + const displayName = repo.display_name.toLowerCase().trim(); + return ( + repoName === needle || + displayName === needle || + repoName.includes(needle) || + needle.includes(repoName) + ); + }) ?? null + ); + }, [botReportQuery.data?.project, forgejoRepositories]); + const ripleyForgejoRepositoryId = ripleyForgejoRepository?.id ?? null; useEffect(() => { if ( @@ -565,25 +583,17 @@ export default function DashboardPage() { } }, [forgejoRepositories, selectedForgejoRepositoryId]); - // Auto-select the Forgejo repo that matches Ripley's reported project. - // Only fires once on initial load while the user hasn't manually chosen. + // Keep the Git Project selector aligned to Ripley's current project. + // A user's manual mismatch stays put until Ripley reports a different project. useEffect(() => { - if (ripleyAutoSelected) return; - const project = botReportQuery.data?.project; - if (!project || forgejoRepositories.length === 0) return; - if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) return; - const needle = project.toLowerCase().trim(); - const match = forgejoRepositories.find((repo) => { - const repoName = repo.repo.toLowerCase().trim(); - const displayName = repo.display_name.toLowerCase().trim(); - return repoName === needle || displayName === needle || - repoName.includes(needle) || needle.includes(repoName); - }); - if (match) { - setSelectedForgejoRepositoryId(match.id); - setRipleyAutoSelected(true); - } - }, [botReportQuery.data, forgejoRepositories, ripleyAutoSelected, selectedForgejoRepositoryId]); + if (!ripleyForgejoRepositoryId) return; + setSelectedForgejoRepositoryId(ripleyForgejoRepositoryId); + }, [ripleyForgejoRepositoryId]); + + const isForgejoSelectionMismatched = Boolean( + ripleyForgejoRepositoryId && + selectedForgejoRepositoryId !== ripleyForgejoRepositoryId, + ); const forgejoMetricsQuery = useQuery({ queryKey: [ @@ -624,12 +634,20 @@ export default function DashboardPage() { } | null, Error >({ - queryKey: ["dashboard", "forgejo", "heatmap", forgejoOrganizationId], + queryKey: [ + "dashboard", + "forgejo", + "heatmap", + forgejoOrganizationId, + selectedForgejoRepositoryId, + ], enabled: Boolean( isSignedIn && - forgejoOrganizationId && !forgejoRepositoriesQuery.isLoading && - !forgejoRepositoriesQuery.error, + !forgejoRepositoriesQuery.error && + (selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES + ? forgejoOrganizationId + : selectedForgejoRepository), ), refetchInterval: (query) => query.state.data?.has_line_stats === false @@ -637,17 +655,38 @@ export default function DashboardPage() { : FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS, refetchOnMount: "always", queryFn: () => { + if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) { + return getForgejoHeatmap({ + repository_id: selectedForgejoRepositoryId, + }); + } if (!forgejoOrganizationId) return Promise.resolve(null); return getForgejoHeatmap({ organization_id: forgejoOrganizationId }); }, }); const forgejoLastPushQuery = useQuery({ - queryKey: ["dashboard", "forgejo", "last-push", forgejoOrganizationId], - enabled: Boolean(isSignedIn && forgejoOrganizationId), + queryKey: [ + "dashboard", + "forgejo", + "last-push", + forgejoOrganizationId, + selectedForgejoRepositoryId, + ], + enabled: Boolean( + isSignedIn && + (selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES + ? forgejoOrganizationId + : selectedForgejoRepository), + ), refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS, refetchOnMount: "always", queryFn: () => { + if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) { + return getForgejoLastPush({ + repository_id: selectedForgejoRepositoryId, + }); + } if (!forgejoOrganizationId) return Promise.resolve(null); return getForgejoLastPush({ organization_id: forgejoOrganizationId }); }, @@ -1405,10 +1444,8 @@ export default function DashboardPage() { repositories={forgejoRepositories} metricRepositories={scopedForgejoRepositories} selectedRepositoryId={selectedForgejoRepositoryId} - onSelectedRepositoryChange={(id) => { - if (id === ALL_FORGEJO_REPOSITORIES) setRipleyAutoSelected(false); - setSelectedForgejoRepositoryId(id); - }} + isSelectedRepositoryMismatched={isForgejoSelectionMismatched} + onSelectedRepositoryChange={setSelectedForgejoRepositoryId} onRefreshLastSync={handleRefreshForgejoLastSync} onCreateIssue={() => setCreateForgejoIssueOpen(true)} isRefreshingLastSync={isRefreshingForgejoSync} diff --git a/frontend/src/components/git/ForgejoIssueMetricCards.tsx b/frontend/src/components/git/ForgejoIssueMetricCards.tsx index fbc8e4c..be3a31e 100644 --- a/frontend/src/components/git/ForgejoIssueMetricCards.tsx +++ b/frontend/src/components/git/ForgejoIssueMetricCards.tsx @@ -32,6 +32,7 @@ type ForgejoIssueMetricCardsProps = { onSelectedRepositoryChange: (repositoryId: string) => void; onRefreshLastSync: () => void; onCreateIssue: () => void; + isSelectedRepositoryMismatched?: boolean; isRefreshingLastSync?: boolean; isLoading?: boolean; error?: string | null; @@ -328,6 +329,7 @@ export function ForgejoIssueMetricCards({ onSelectedRepositoryChange, onRefreshLastSync, onCreateIssue, + isSelectedRepositoryMismatched = false, isRefreshingLastSync = false, isLoading = false, error, @@ -419,7 +421,13 @@ export function ForgejoIssueMetricCards({ onValueChange={onSelectedRepositoryChange} disabled={repositories.length === 0} > - + diff --git a/frontend/src/lib/api-forgejo.ts b/frontend/src/lib/api-forgejo.ts index 5976907..d38eba4 100644 --- a/frontend/src/lib/api-forgejo.ts +++ b/frontend/src/lib/api-forgejo.ts @@ -647,10 +647,13 @@ export interface ForgejoLastPush { export async function getForgejoLastPush(params?: { organization_id?: string; + repository_id?: string; }): Promise { const searchParams = new URLSearchParams(); if (params?.organization_id) searchParams.set("organization_id", params.organization_id); + if (params?.repository_id) + searchParams.set("repository_id", params.repository_id); const qs = searchParams.toString(); return fetchJson( `/api/v1/forgejo/last-push${qs ? `?${qs}` : ""}`, @@ -667,10 +670,13 @@ export interface ForgejoHeatmapResponse { export async function getForgejoHeatmap(params?: { organization_id?: string; + repository_id?: string; }): Promise { const searchParams = new URLSearchParams(); if (params?.organization_id) searchParams.set("organization_id", params.organization_id); + if (params?.repository_id) + searchParams.set("repository_id", params.repository_id); const qs = searchParams.toString(); return fetchJson( `/api/v1/forgejo/heatmap${qs ? `?${qs}` : ""}`,