2026-05-19 04:02:04 -05:00
|
|
|
"""Forgejo issue tracking metrics endpoints."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-05-22 16:04:32 -05:00
|
|
|
import asyncio
|
2026-05-22 16:17:39 -05:00
|
|
|
import time as _time
|
2026-05-20 02:29:58 -05:00
|
|
|
from datetime import timedelta
|
2026-05-19 04:02:04 -05:00
|
|
|
from typing import TYPE_CHECKING
|
2026-05-20 01:35:45 -05:00
|
|
|
from uuid import UUID
|
2026-05-19 04:02:04 -05:00
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
2026-05-20 04:13:32 -05:00
|
|
|
from sqlalchemy import Date as SADate
|
2026-05-19 04:02:04 -05:00
|
|
|
from sqlalchemy import and_
|
2026-05-20 04:13:32 -05:00
|
|
|
from sqlalchemy import cast as sa_cast
|
2026-05-20 01:35:45 -05:00
|
|
|
from sqlmodel import func, select
|
2026-05-19 04:02:04 -05:00
|
|
|
|
|
|
|
|
from app.api.deps import ORG_MEMBER_DEP, OrganizationContext
|
2026-05-20 02:29:58 -05:00
|
|
|
from app.core.time import utcnow
|
2026-05-19 04:02:04 -05:00
|
|
|
from app.db.session import get_session
|
|
|
|
|
from app.models.board_repository_links import BoardRepositoryLink
|
2026-05-22 16:04:32 -05:00
|
|
|
from app.models.forgejo_connections import ForgejoConnection
|
2026-05-19 04:02:04 -05:00
|
|
|
from app.models.forgejo_issues import ForgejoIssue
|
|
|
|
|
from app.models.forgejo_repositories import ForgejoRepository
|
2026-05-22 16:25:47 -05:00
|
|
|
from app.schemas.metrics import HeatmapDay, HeatmapResponse, LastPushRead, MetricsResponse
|
2026-05-22 17:54:42 -05:00
|
|
|
from app.services.forgejo_client import ForgejoAPIClient
|
2026-05-19 04:02:04 -05:00
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
|
|
2026-05-22 16:17:39 -05:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Line-stats background cache
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-05-24 20:13:04 -05:00
|
|
|
# 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.
|
2026-05-22 16:17:39 -05:00
|
|
|
# Populated by a fire-and-forget asyncio task so the heatmap endpoint never
|
|
|
|
|
# blocks waiting for Forgejo's 202 "still computing" response.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-05-24 20:13:04 -05:00
|
|
|
_line_stats_cache: dict[str, tuple[float, int, int, bool, dict[str, int]]] = {}
|
2026-05-22 16:17:39 -05:00
|
|
|
_line_stats_fetching: set[str] = set()
|
2026-05-22 16:19:15 -05:00
|
|
|
_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
|
2026-05-22 16:17:39 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _bg_fetch_line_stats(
|
|
|
|
|
cache_key: str,
|
|
|
|
|
repos: list[tuple[str, str, str, str | None]], # (owner, repo, base_url, token)
|
2026-05-22 16:40:09 -05:00
|
|
|
since_iso: str, # ISO-8601 date string for the commits?since= filter
|
2026-05-22 16:17:39 -05:00
|
|
|
) -> None:
|
2026-05-22 16:40:09 -05:00
|
|
|
"""Background task: sum commit line stats across all tracked repos and cache."""
|
2026-05-22 16:17:39 -05:00
|
|
|
|
2026-05-24 20:13:04 -05:00
|
|
|
async def _one(owner: str, repo: str, base_url: str, token: str | None) -> tuple[int, int, dict[str, int]]:
|
2026-05-22 16:17:39 -05:00
|
|
|
try:
|
|
|
|
|
async with ForgejoAPIClient(base_url=base_url, token=token) as client:
|
2026-05-22 16:40:09 -05:00
|
|
|
return await client.get_commit_line_stats_since(owner, repo, since_iso)
|
2026-05-22 16:17:39 -05:00
|
|
|
except Exception:
|
2026-05-24 20:13:04 -05:00
|
|
|
return 0, 0, {}
|
2026-05-22 16:17:39 -05:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
results = await asyncio.gather(*[_one(o, r, bu, tok) for o, r, bu, tok in repos])
|
2026-05-24 20:13:04 -05:00
|
|
|
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
|
2026-05-22 16:40:09 -05:00
|
|
|
# Always mark has_data=True — the commits endpoint is reliable
|
2026-05-24 20:13:04 -05:00
|
|
|
_line_stats_cache[cache_key] = (_time.monotonic(), total_adds, total_dels, True, merged_days)
|
2026-05-22 16:17:39 -05:00
|
|
|
finally:
|
|
|
|
|
_line_stats_fetching.discard(cache_key)
|
|
|
|
|
|
2026-05-22 16:25:47 -05:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Last-push background cache
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
_last_push_cache: dict[str, tuple[float, LastPushRead | None]] = {}
|
|
|
|
|
_last_push_fetching: set[str] = set()
|
|
|
|
|
_LAST_PUSH_TTL = 60 # seconds
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _bg_fetch_last_push(
|
|
|
|
|
cache_key: str,
|
|
|
|
|
repos: list[tuple[str, str, str, str | None]], # (owner, repo, base_url, token)
|
|
|
|
|
) -> 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:
|
|
|
|
|
try:
|
|
|
|
|
async with ForgejoAPIClient(base_url=base_url, token=token) as client:
|
|
|
|
|
commit = await client.get_last_commit(owner, repo)
|
|
|
|
|
if not commit:
|
|
|
|
|
return None
|
|
|
|
|
return owner, repo, commit
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
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:
|
|
|
|
|
if item is None:
|
|
|
|
|
continue
|
|
|
|
|
owner, repo_name, commit = item
|
|
|
|
|
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 > best_ts:
|
|
|
|
|
best_ts = date_str
|
|
|
|
|
best = (owner, repo_name, commit)
|
|
|
|
|
|
|
|
|
|
push: LastPushRead | None = None
|
|
|
|
|
if best:
|
|
|
|
|
owner, repo_name, commit = best
|
|
|
|
|
commit_obj = commit.get("commit") or {}
|
|
|
|
|
full_msg: str = commit_obj.get("message") or ""
|
|
|
|
|
first_line = full_msg.splitlines()[0] if full_msg else ""
|
|
|
|
|
sha_full: str = commit.get("sha") or ""
|
|
|
|
|
sha_short = sha_full[:7] if sha_full else ""
|
|
|
|
|
author_name: str = (
|
|
|
|
|
(commit.get("author") or {}).get("login")
|
|
|
|
|
or (commit_obj.get("author") or {}).get("name")
|
|
|
|
|
or "unknown"
|
|
|
|
|
)
|
|
|
|
|
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 "—"
|
|
|
|
|
push = LastPushRead(
|
|
|
|
|
sha=sha_short,
|
|
|
|
|
message=first_line,
|
|
|
|
|
author=author_name,
|
|
|
|
|
repo=f"{owner}/{repo_name}",
|
|
|
|
|
branch=branch,
|
|
|
|
|
pushed_at=date_str,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
_last_push_cache[cache_key] = (_time.monotonic(), push)
|
|
|
|
|
finally:
|
|
|
|
|
_last_push_fetching.discard(cache_key)
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 04:02:04 -05:00
|
|
|
router = APIRouter(prefix="/forgejo", tags=["forgejo-metrics"])
|
|
|
|
|
SESSION_DEP = Depends(get_session)
|
|
|
|
|
# Use ORG_MEMBER_DEP directly, not wrapped in Depends again
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/metrics",
|
|
|
|
|
response_model=MetricsResponse,
|
|
|
|
|
summary="Forgejo issue tracking metrics",
|
|
|
|
|
description=(
|
|
|
|
|
"Get aggregated metrics for Forgejo issues across linked repositories. "
|
|
|
|
|
"Supports filtering by organization_id, board_id, or repository_id. "
|
|
|
|
|
"Empty scope returns zeroed metrics."
|
|
|
|
|
),
|
|
|
|
|
responses={
|
|
|
|
|
status.HTTP_200_OK: {
|
|
|
|
|
"description": "Metrics retrieved successfully",
|
|
|
|
|
"content": {
|
|
|
|
|
"application/json": {
|
|
|
|
|
"example": {
|
|
|
|
|
"open_issues": 25,
|
|
|
|
|
"closed_issues": 150,
|
2026-05-20 03:40:07 -05:00
|
|
|
"closed_in_selected_range": 12,
|
|
|
|
|
"selected_range_days": 7,
|
2026-05-19 04:02:04 -05:00
|
|
|
"closed_last_7_days": 12,
|
|
|
|
|
"closed_last_30_days": 35,
|
|
|
|
|
"stale_open_issues": 5,
|
|
|
|
|
"repositories_synced": 3,
|
2026-05-20 03:40:07 -05:00
|
|
|
"repository_sync_error_count": 1,
|
2026-05-19 04:02:04 -05:00
|
|
|
"last_sync_timestamps": {
|
|
|
|
|
"repo_1": "2026-05-19T03:00:00+00:00",
|
|
|
|
|
"repo_2": "2026-05-19T02:30:00+00:00",
|
|
|
|
|
"repo_3": "2026-05-19T01:00:00+00:00",
|
|
|
|
|
},
|
|
|
|
|
"sync_error_counts": {
|
|
|
|
|
"repo_1": 0,
|
|
|
|
|
"repo_2": 2,
|
|
|
|
|
"repo_3": 0,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
status.HTTP_403_FORBIDDEN: {
|
|
|
|
|
"description": "User lacks access to the board",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
async def get_forgejo_metrics(
|
2026-05-20 01:35:45 -05:00
|
|
|
organization_id: UUID | None = Query(
|
2026-05-19 04:02:04 -05:00
|
|
|
None,
|
|
|
|
|
description="Filter by organization ID",
|
|
|
|
|
),
|
2026-05-20 01:35:45 -05:00
|
|
|
board_id: UUID | None = Query(
|
2026-05-19 04:02:04 -05:00
|
|
|
None,
|
|
|
|
|
description="Filter by board ID (via linked repositories)",
|
|
|
|
|
),
|
2026-05-20 01:35:45 -05:00
|
|
|
repository_id: UUID | None = Query(
|
2026-05-19 04:02:04 -05:00
|
|
|
None,
|
|
|
|
|
description="Filter by specific repository ID",
|
|
|
|
|
),
|
2026-05-20 03:40:07 -05:00
|
|
|
closed_range_days: int = Query(
|
|
|
|
|
7,
|
|
|
|
|
ge=1,
|
|
|
|
|
le=365,
|
|
|
|
|
description="Window (in days) for closed_in_selected_range",
|
|
|
|
|
),
|
2026-05-19 04:02:04 -05:00
|
|
|
session: AsyncSession = SESSION_DEP,
|
|
|
|
|
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
|
2026-05-20 01:35:45 -05:00
|
|
|
if organization_id and organization_id != ctx.organization.id:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
|
|
|
|
2026-05-19 04:02:04 -05:00
|
|
|
if repository_id:
|
|
|
|
|
# Single repository
|
|
|
|
|
if board_id or organization_id:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail="Cannot combine repository_id with board_id or organization_id",
|
|
|
|
|
)
|
2026-05-20 01:35:45 -05:00
|
|
|
repo_statement = select(ForgejoRepository).where(
|
|
|
|
|
ForgejoRepository.id == repository_id,
|
|
|
|
|
ForgejoRepository.organization_id == ctx.organization.id,
|
|
|
|
|
)
|
|
|
|
|
repo = (await session.exec(repo_statement)).first()
|
|
|
|
|
if repo is None:
|
|
|
|
|
return _zeroed_metrics()
|
|
|
|
|
repo_ids = [repo.id]
|
2026-05-19 04:02:04 -05:00
|
|
|
elif board_id:
|
|
|
|
|
# Board-scoped: get linked repositories
|
|
|
|
|
link_statement = select(BoardRepositoryLink).where(
|
2026-05-20 01:35:45 -05:00
|
|
|
BoardRepositoryLink.board_id == board_id,
|
|
|
|
|
BoardRepositoryLink.organization_id == ctx.organization.id,
|
2026-05-19 04:02:04 -05:00
|
|
|
)
|
|
|
|
|
links = (await session.exec(link_statement)).all()
|
|
|
|
|
repo_ids = [link.repository_id for link in links]
|
|
|
|
|
if not repo_ids:
|
|
|
|
|
return _zeroed_metrics()
|
|
|
|
|
elif organization_id:
|
|
|
|
|
# Organization-scoped: all repositories in org
|
|
|
|
|
repo_statement = select(ForgejoRepository.id).where(
|
2026-05-20 01:35:45 -05:00
|
|
|
ForgejoRepository.organization_id == ctx.organization.id,
|
2026-05-19 04:02:04 -05:00
|
|
|
)
|
|
|
|
|
repos = (await session.exec(repo_statement)).all()
|
2026-05-20 01:35:45 -05:00
|
|
|
repo_ids = list(repos)
|
2026-05-19 04:02:04 -05:00
|
|
|
if not repo_ids:
|
|
|
|
|
return _zeroed_metrics()
|
|
|
|
|
else:
|
|
|
|
|
# No filters - return zeroed metrics
|
|
|
|
|
return _zeroed_metrics()
|
|
|
|
|
|
|
|
|
|
# Calculate metrics
|
|
|
|
|
# 1. Open issues count
|
|
|
|
|
open_statement = select(func.count(ForgejoIssue.id)).where(
|
|
|
|
|
and_(
|
|
|
|
|
ForgejoIssue.repository_id.in_(repo_ids),
|
|
|
|
|
ForgejoIssue.state == "open",
|
|
|
|
|
ForgejoIssue.is_pull_request.is_(False),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
open_count = await session.exec(open_statement)
|
|
|
|
|
open_issues = open_count.one_or_none() or 0
|
|
|
|
|
|
|
|
|
|
# 2. Closed issues count
|
|
|
|
|
closed_statement = select(func.count(ForgejoIssue.id)).where(
|
|
|
|
|
and_(
|
|
|
|
|
ForgejoIssue.repository_id.in_(repo_ids),
|
|
|
|
|
ForgejoIssue.state == "closed",
|
|
|
|
|
ForgejoIssue.is_pull_request.is_(False),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
closed_count = await session.exec(closed_statement)
|
|
|
|
|
closed_issues = closed_count.one_or_none() or 0
|
|
|
|
|
|
2026-05-20 03:40:07 -05:00
|
|
|
# 3. Closed in selected range
|
2026-05-20 02:29:58 -05:00
|
|
|
now = utcnow()
|
2026-05-20 03:40:07 -05:00
|
|
|
selected_range_start = now - timedelta(days=closed_range_days)
|
|
|
|
|
closed_selected_statement = select(func.count(ForgejoIssue.id)).where(
|
|
|
|
|
and_(
|
|
|
|
|
ForgejoIssue.repository_id.in_(repo_ids),
|
|
|
|
|
ForgejoIssue.state == "closed",
|
|
|
|
|
ForgejoIssue.is_pull_request.is_(False),
|
|
|
|
|
ForgejoIssue.forgejo_closed_at >= selected_range_start,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
closed_selected_count = await session.exec(closed_selected_statement)
|
|
|
|
|
closed_in_selected_range = closed_selected_count.one_or_none() or 0
|
|
|
|
|
|
|
|
|
|
# 3. Closed in last 7 days
|
2026-05-19 04:02:04 -05:00
|
|
|
seven_days_ago = now - timedelta(days=7)
|
|
|
|
|
closed_7_statement = select(func.count(ForgejoIssue.id)).where(
|
|
|
|
|
and_(
|
|
|
|
|
ForgejoIssue.repository_id.in_(repo_ids),
|
|
|
|
|
ForgejoIssue.state == "closed",
|
|
|
|
|
ForgejoIssue.is_pull_request.is_(False),
|
2026-05-20 02:29:58 -05:00
|
|
|
ForgejoIssue.forgejo_closed_at >= seven_days_ago,
|
2026-05-19 04:02:04 -05:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
closed_7_count = await session.exec(closed_7_statement)
|
|
|
|
|
closed_last_7_days = closed_7_count.one_or_none() or 0
|
|
|
|
|
|
|
|
|
|
# 4. Closed in last 30 days
|
|
|
|
|
thirty_days_ago = now - timedelta(days=30)
|
|
|
|
|
closed_30_statement = select(func.count(ForgejoIssue.id)).where(
|
|
|
|
|
and_(
|
|
|
|
|
ForgejoIssue.repository_id.in_(repo_ids),
|
|
|
|
|
ForgejoIssue.state == "closed",
|
|
|
|
|
ForgejoIssue.is_pull_request.is_(False),
|
2026-05-20 02:29:58 -05:00
|
|
|
ForgejoIssue.forgejo_closed_at >= thirty_days_ago,
|
2026-05-19 04:02:04 -05:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
closed_30_count = await session.exec(closed_30_statement)
|
|
|
|
|
closed_last_30_days = closed_30_count.one_or_none() or 0
|
|
|
|
|
|
|
|
|
|
# 5. Stale open issues (open > 14 days with no update)
|
|
|
|
|
fourteen_days_ago = now - timedelta(days=14)
|
|
|
|
|
stale_statement = select(func.count(ForgejoIssue.id)).where(
|
|
|
|
|
and_(
|
|
|
|
|
ForgejoIssue.repository_id.in_(repo_ids),
|
|
|
|
|
ForgejoIssue.state == "open",
|
|
|
|
|
ForgejoIssue.is_pull_request.is_(False),
|
2026-05-20 02:29:58 -05:00
|
|
|
ForgejoIssue.forgejo_updated_at < fourteen_days_ago,
|
2026-05-19 04:02:04 -05:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
stale_count = await session.exec(stale_statement)
|
|
|
|
|
stale_open_issues = stale_count.one_or_none() or 0
|
|
|
|
|
|
|
|
|
|
# 6. Get sync status per repository
|
|
|
|
|
last_sync_timestamps: dict[str, str] = {}
|
|
|
|
|
sync_error_counts: dict[str, int] = {}
|
|
|
|
|
|
|
|
|
|
for repo_id in repo_ids:
|
|
|
|
|
# Get repository sync info
|
|
|
|
|
repo_statement = select(ForgejoRepository).where(
|
|
|
|
|
ForgejoRepository.id == repo_id
|
|
|
|
|
)
|
|
|
|
|
repo = (await session.exec(repo_statement)).first()
|
|
|
|
|
if repo:
|
2026-05-20 01:35:45 -05:00
|
|
|
repo_key = str(repo_id)
|
|
|
|
|
last_sync_timestamps[repo_key] = (
|
2026-05-19 04:02:04 -05:00
|
|
|
repo.last_sync_at.isoformat()
|
|
|
|
|
if repo.last_sync_at
|
|
|
|
|
else ""
|
|
|
|
|
)
|
2026-05-20 01:35:45 -05:00
|
|
|
sync_error_counts[repo_key] = 1 if repo.last_sync_error else 0
|
2026-05-19 04:02:04 -05:00
|
|
|
|
|
|
|
|
repositories_synced = len(repo_ids)
|
2026-05-20 03:40:07 -05:00
|
|
|
repository_sync_error_count = sum(sync_error_counts.values())
|
2026-05-19 04:02:04 -05:00
|
|
|
|
|
|
|
|
return MetricsResponse(
|
|
|
|
|
open_issues=open_issues,
|
|
|
|
|
closed_issues=closed_issues,
|
2026-05-20 03:40:07 -05:00
|
|
|
closed_in_selected_range=closed_in_selected_range,
|
|
|
|
|
selected_range_days=closed_range_days,
|
2026-05-19 04:02:04 -05:00
|
|
|
closed_last_7_days=closed_last_7_days,
|
|
|
|
|
closed_last_30_days=closed_last_30_days,
|
|
|
|
|
stale_open_issues=stale_open_issues,
|
|
|
|
|
repositories_synced=repositories_synced,
|
2026-05-20 03:40:07 -05:00
|
|
|
repository_sync_error_count=repository_sync_error_count,
|
2026-05-19 04:02:04 -05:00
|
|
|
last_sync_timestamps=last_sync_timestamps,
|
|
|
|
|
sync_error_counts=sync_error_counts,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 03:54:58 -05:00
|
|
|
@router.get(
|
|
|
|
|
"/heatmap",
|
|
|
|
|
response_model=HeatmapResponse,
|
|
|
|
|
summary="Forgejo issue activity heatmap",
|
2026-05-22 17:54:42 -05:00
|
|
|
description="Daily issue open+close event counts for the last year, scoped to the caller's organisation.",
|
2026-05-20 03:54:58 -05:00
|
|
|
)
|
|
|
|
|
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:
|
2026-05-22 17:54:42 -05:00
|
|
|
"""Return per-day issue event counts and total line contributions for the last year."""
|
2026-05-20 03:54:58 -05:00
|
|
|
if organization_id and organization_id != ctx.organization.id:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
|
|
|
|
2026-05-22 17:13:14 -05:00
|
|
|
since = utcnow() - timedelta(days=365)
|
2026-05-24 19:30:15 -05:00
|
|
|
# Line-stats fetch uses a shorter window — querying a full year of commits
|
|
|
|
|
# with stat=true paginates hundreds of pages and takes 30+ seconds on active
|
|
|
|
|
# repos. 90 days is enough context for the dashboard summary numbers.
|
|
|
|
|
line_stats_since = utcnow() - timedelta(days=90)
|
2026-05-20 03:54:58 -05:00
|
|
|
|
2026-05-22 16:04:32 -05:00
|
|
|
# Fetch repos with their connections in one query
|
|
|
|
|
repos_with_conns = (
|
2026-05-20 03:54:58 -05:00
|
|
|
await session.exec(
|
2026-05-22 16:04:32 -05:00
|
|
|
select(ForgejoRepository, ForgejoConnection)
|
|
|
|
|
.join(ForgejoConnection, ForgejoRepository.connection_id == ForgejoConnection.id)
|
|
|
|
|
.where(ForgejoRepository.organization_id == ctx.organization.id)
|
2026-05-20 03:54:58 -05:00
|
|
|
)
|
|
|
|
|
).all()
|
2026-05-22 16:04:32 -05:00
|
|
|
if not repos_with_conns:
|
2026-05-20 03:54:58 -05:00
|
|
|
return HeatmapResponse(days=[], max_count=0)
|
|
|
|
|
|
2026-05-22 16:04:32 -05:00
|
|
|
repo_ids = [repo.id for repo, _ in repos_with_conns]
|
2026-05-20 03:54:58 -05:00
|
|
|
|
2026-05-24 20:13:04 -05:00
|
|
|
# Line stats — served from background cache; fire refresh if stale.
|
2026-05-24 19:30:15 -05:00
|
|
|
since_iso = line_stats_since.strftime("%Y-%m-%dT%H:%M:%SZ")
|
2026-05-22 16:17:39 -05:00
|
|
|
cache_key = str(ctx.organization.id)
|
|
|
|
|
cached = _line_stats_cache.get(cache_key)
|
|
|
|
|
now = _time.monotonic()
|
|
|
|
|
|
2026-05-22 16:19:15 -05:00
|
|
|
ttl = _LINE_STATS_TTL_HIT if (cached and cached[3]) else _LINE_STATS_TTL_MISS
|
2026-05-22 16:17:39 -05:00
|
|
|
if cache_key not in _line_stats_fetching and (
|
2026-05-22 16:19:15 -05:00
|
|
|
cached is None or now - cached[0] > ttl
|
2026-05-22 16:17:39 -05:00
|
|
|
):
|
|
|
|
|
# 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]] = []
|
|
|
|
|
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)))
|
|
|
|
|
|
|
|
|
|
_line_stats_fetching.add(cache_key)
|
|
|
|
|
asyncio.create_task(
|
2026-05-22 16:40:09 -05:00
|
|
|
_bg_fetch_line_stats(cache_key, repo_tuples, since_iso),
|
2026-05-22 16:17:39 -05:00
|
|
|
name=f"line-stats-{cache_key}",
|
|
|
|
|
)
|
2026-05-22 16:04:32 -05:00
|
|
|
|
2026-05-22 16:17:39 -05:00
|
|
|
if cached is not None:
|
2026-05-24 20:13:04 -05:00
|
|
|
_, total_additions, total_deletions, has_line_stats, commit_day_counts = cached
|
2026-05-22 16:17:39 -05:00
|
|
|
else:
|
|
|
|
|
total_additions = total_deletions = 0
|
|
|
|
|
has_line_stats = False
|
2026-05-24 20:13:04 -05:00
|
|
|
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)
|
2026-05-22 16:04:32 -05:00
|
|
|
|
|
|
|
|
return HeatmapResponse(
|
|
|
|
|
days=days_list,
|
|
|
|
|
max_count=max_count,
|
|
|
|
|
total_additions=total_additions,
|
|
|
|
|
total_deletions=total_deletions,
|
|
|
|
|
has_line_stats=has_line_stats,
|
|
|
|
|
)
|
2026-05-20 03:54:58 -05:00
|
|
|
|
|
|
|
|
|
2026-05-22 16:25:47 -05:00
|
|
|
@router.get(
|
|
|
|
|
"/last-push",
|
|
|
|
|
response_model=LastPushRead | None,
|
|
|
|
|
summary="Most-recent commit across all tracked repositories",
|
|
|
|
|
)
|
|
|
|
|
async def get_last_push(
|
|
|
|
|
organization_id: UUID | None = Query(None),
|
|
|
|
|
session: AsyncSession = SESSION_DEP,
|
|
|
|
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
|
|
|
) -> LastPushRead | None:
|
|
|
|
|
"""Return the most-recently committed commit across all tracked repos, served from cache."""
|
|
|
|
|
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)
|
|
|
|
|
)
|
|
|
|
|
).all()
|
|
|
|
|
if not repos_with_conns:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
import re as _re
|
|
|
|
|
cache_key = f"last-push:{ctx.organization.id}"
|
|
|
|
|
cached = _last_push_cache.get(cache_key)
|
|
|
|
|
now = _time.monotonic()
|
|
|
|
|
|
|
|
|
|
if cache_key not in _last_push_fetching and (
|
|
|
|
|
cached is None or now - cached[0] > _LAST_PUSH_TTL
|
|
|
|
|
):
|
|
|
|
|
repo_tuples: list[tuple[str, str, str, str | None]] = []
|
|
|
|
|
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)))
|
|
|
|
|
|
|
|
|
|
_last_push_fetching.add(cache_key)
|
|
|
|
|
asyncio.create_task(
|
|
|
|
|
_bg_fetch_last_push(cache_key, repo_tuples),
|
|
|
|
|
name=f"last-push-{cache_key}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return cached[1] if cached is not None else None
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 04:02:04 -05:00
|
|
|
def _zeroed_metrics() -> MetricsResponse:
|
|
|
|
|
"""Return zeroed metrics for empty scopes."""
|
|
|
|
|
return MetricsResponse(
|
|
|
|
|
open_issues=0,
|
|
|
|
|
closed_issues=0,
|
2026-05-20 03:40:07 -05:00
|
|
|
closed_in_selected_range=0,
|
|
|
|
|
selected_range_days=7,
|
2026-05-19 04:02:04 -05:00
|
|
|
closed_last_7_days=0,
|
|
|
|
|
closed_last_30_days=0,
|
|
|
|
|
stale_open_issues=0,
|
|
|
|
|
repositories_synced=0,
|
2026-05-20 03:40:07 -05:00
|
|
|
repository_sync_error_count=0,
|
2026-05-19 04:02:04 -05:00
|
|
|
last_sync_timestamps={},
|
|
|
|
|
sync_error_counts={},
|
|
|
|
|
)
|