diff --git a/backend/app/api/forgejo_metrics.py b/backend/app/api/forgejo_metrics.py index a8b3f84..1b72621 100644 --- a/backend/app/api/forgejo_metrics.py +++ b/backend/app/api/forgejo_metrics.py @@ -30,11 +30,12 @@ if TYPE_CHECKING: # --------------------------------------------------------------------------- # Line-stats background cache # --------------------------------------------------------------------------- -# Key: org_id string → (fetched_at, total_additions, total_deletions, has_data) +# Key: org_id string → (fetched_at, total_additions, total_deletions, has_data, day_counts) +# day_counts maps "YYYY-MM-DD" → commit count for the heatmap grid. # Populated by a fire-and-forget asyncio task so the heatmap endpoint never # blocks waiting for Forgejo's 202 "still computing" response. # --------------------------------------------------------------------------- -_line_stats_cache: dict[str, tuple[float, int, int, bool]] = {} +_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 @@ -47,19 +48,23 @@ async def _bg_fetch_line_stats( ) -> None: """Background task: sum commit line stats across all tracked repos and cache.""" - async def _one(owner: str, repo: str, base_url: str, token: str | None) -> tuple[int, 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) except Exception: - return 0, 0 + return 0, 0, {} try: 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) + total_adds = sum(a for a, _, _ in results) + total_dels = sum(d for _, d, _ in results) + merged_days: dict[str, int] = {} + for _, _, day_counts in results: + for day, cnt in day_counts.items(): + merged_days[day] = merged_days.get(day, 0) + cnt # Always mark has_data=True — the commits endpoint is reliable - _line_stats_cache[cache_key] = (_time.monotonic(), total_adds, total_dels, True) + _line_stats_cache[cache_key] = (_time.monotonic(), total_adds, total_dels, True, merged_days) finally: _line_stats_fetching.discard(cache_key) @@ -400,51 +405,8 @@ async def get_forgejo_heatmap( return HeatmapResponse(days=[], max_count=0) repo_ids = [repo.id for repo, _ in repos_with_conns] - counts: dict[str, int] = {} - # Issues created per day - created_rows = ( - await session.exec( - select( - sa_cast(ForgejoIssue.forgejo_created_at, SADate).label("day"), - func.count().label("cnt"), - ).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)) - ) - ).all() - for day, cnt in created_rows: - if day: - key = str(day) - counts[key] = counts.get(key, 0) + int(cnt) - - # Issues closed per day - closed_rows = ( - await session.exec( - select( - sa_cast(ForgejoIssue.forgejo_closed_at, SADate).label("day"), - func.count().label("cnt"), - ).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)) - ) - ).all() - for day, cnt in closed_rows: - if day: - key = str(day) - counts[key] = counts.get(key, 0) + int(cnt) - - 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) - - # Line stats — served from cache; background task refreshes when stale. - # Extract plain values NOW while the DB session is still open. + # 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) cached = _line_stats_cache.get(cache_key) @@ -472,10 +434,58 @@ async def get_forgejo_heatmap( ) if cached is not None: - _, total_additions, total_deletions, has_line_stats = cached + _, total_additions, total_deletions, has_line_stats, commit_day_counts = cached else: total_additions = total_deletions = 0 has_line_stats = False + commit_day_counts = {} + + # Heatmap grid: use commit-per-day counts when available; fall back to + # issue-event counts (from DB) while the background fetch is in progress. + if has_line_stats and commit_day_counts: + 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: + counts: dict[str, int] = {} + + created_rows = ( + await session.exec( + select( + sa_cast(ForgejoIssue.forgejo_created_at, SADate).label("day"), + func.count().label("cnt"), + ).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)) + ) + ).all() + for day, cnt in created_rows: + if day: + key = str(day) + counts[key] = counts.get(key, 0) + int(cnt) + + closed_rows = ( + await session.exec( + select( + sa_cast(ForgejoIssue.forgejo_closed_at, SADate).label("day"), + func.count().label("cnt"), + ).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)) + ) + ).all() + for day, cnt in closed_rows: + if day: + key = str(day) + counts[key] = counts.get(key, 0) + int(cnt) + + 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) return HeatmapResponse( days=days_list, diff --git a/backend/app/services/forgejo_client.py b/backend/app/services/forgejo_client.py index e7574a5..a48cfa3 100644 --- a/backend/app/services/forgejo_client.py +++ b/backend/app/services/forgejo_client.py @@ -374,15 +374,17 @@ class ForgejoAPIClient: async def get_commit_line_stats_since( self, owner: str, repo: str, since_iso: str - ) -> tuple[int, int]: - """Sum additions and deletions across all commits since ``since_iso``. + ) -> tuple[int, int, dict[str, int]]: + """Sum additions/deletions and count commits per day since ``since_iso``. Uses ``GET /repos/{owner}/{repo}/commits?since=…&stat=true`` which returns per-commit ``stats`` objects and is available on all Forgejo/ - Gitea versions. Returns ``(total_additions, total_deletions)``. + Gitea versions. Returns ``(total_additions, total_deletions, day_counts)`` + where ``day_counts`` maps "YYYY-MM-DD" → commit count. """ client = await self._get_client() total_adds = total_dels = 0 + day_counts: dict[str, int] = {} page = 1 # Cap at 5 pages (250 commits) — prevents unbounded pagination on # very active repos where each page requires Forgejo to compute @@ -403,10 +405,17 @@ class ForgejoAPIClient: s = commit.get("stats") or {} total_adds += int(s.get("additions") or 0) total_dels += int(s.get("deletions") or 0) + # Extract commit date for per-day activity counting + 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 and len(date_str) >= 10: + day = date_str[:10] + day_counts[day] = day_counts.get(day, 0) + 1 if len(commits) < 50: break # last page page += 1 - return total_adds, total_dels + return total_adds, total_dels, day_counts def get_forgejo_client(