heatmap commits

This commit is contained in:
null 2026-05-24 20:13:04 -05:00
parent 086fc8fc49
commit 1fc1df5aeb
2 changed files with 75 additions and 56 deletions

View File

@ -30,11 +30,12 @@ if TYPE_CHECKING:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Line-stats background cache # 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 # Populated by a fire-and-forget asyncio task so the heatmap endpoint never
# blocks waiting for Forgejo's 202 "still computing" response. # 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_fetching: set[str] = set()
_LINE_STATS_TTL_HIT = 300 # 5 min — re-fetch cadence once real data is cached _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_MISS = 30 # 30 s — retry cadence while Forgejo is still computing
@ -47,19 +48,23 @@ async def _bg_fetch_line_stats(
) -> None: ) -> None:
"""Background task: sum commit line stats across all tracked repos and cache.""" """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: try:
async with ForgejoAPIClient(base_url=base_url, token=token) as client: async with ForgejoAPIClient(base_url=base_url, token=token) as client:
return await client.get_commit_line_stats_since(owner, repo, since_iso) return await client.get_commit_line_stats_since(owner, repo, since_iso)
except Exception: except Exception:
return 0, 0 return 0, 0, {}
try: 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_adds = sum(a for a, _, _ in results)
total_dels = sum(d for _, d 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 # 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: finally:
_line_stats_fetching.discard(cache_key) _line_stats_fetching.discard(cache_key)
@ -400,51 +405,8 @@ async def get_forgejo_heatmap(
return HeatmapResponse(days=[], max_count=0) return HeatmapResponse(days=[], max_count=0)
repo_ids = [repo.id for repo, _ in repos_with_conns] repo_ids = [repo.id for repo, _ in repos_with_conns]
counts: dict[str, int] = {}
# Issues created per day # Line stats — served from background cache; fire refresh if stale.
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.
since_iso = line_stats_since.strftime("%Y-%m-%dT%H:%M:%SZ") since_iso = line_stats_since.strftime("%Y-%m-%dT%H:%M:%SZ")
cache_key = str(ctx.organization.id) cache_key = str(ctx.organization.id)
cached = _line_stats_cache.get(cache_key) cached = _line_stats_cache.get(cache_key)
@ -472,10 +434,58 @@ async def get_forgejo_heatmap(
) )
if cached is not None: 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: else:
total_additions = total_deletions = 0 total_additions = total_deletions = 0
has_line_stats = False 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( return HeatmapResponse(
days=days_list, days=days_list,

View File

@ -374,15 +374,17 @@ class ForgejoAPIClient:
async def get_commit_line_stats_since( async def get_commit_line_stats_since(
self, owner: str, repo: str, since_iso: str self, owner: str, repo: str, since_iso: str
) -> tuple[int, int]: ) -> tuple[int, int, dict[str, int]]:
"""Sum additions and deletions across all commits since ``since_iso``. """Sum additions/deletions and count commits per day since ``since_iso``.
Uses ``GET /repos/{owner}/{repo}/commits?since=&stat=true`` which Uses ``GET /repos/{owner}/{repo}/commits?since=&stat=true`` which
returns per-commit ``stats`` objects and is available on all Forgejo/ 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() client = await self._get_client()
total_adds = total_dels = 0 total_adds = total_dels = 0
day_counts: dict[str, int] = {}
page = 1 page = 1
# Cap at 5 pages (250 commits) — prevents unbounded pagination on # Cap at 5 pages (250 commits) — prevents unbounded pagination on
# very active repos where each page requires Forgejo to compute # very active repos where each page requires Forgejo to compute
@ -403,10 +405,17 @@ class ForgejoAPIClient:
s = commit.get("stats") or {} s = commit.get("stats") or {}
total_adds += int(s.get("additions") or 0) total_adds += int(s.get("additions") or 0)
total_dels += int(s.get("deletions") 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: if len(commits) < 50:
break # last page break # last page
page += 1 page += 1
return total_adds, total_dels return total_adds, total_dels, day_counts
def get_forgejo_client( def get_forgejo_client(