heatmap commits
This commit is contained in:
parent
086fc8fc49
commit
1fc1df5aeb
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue