Pipeline/backend/app/api/forgejo_metrics.py

348 lines
12 KiB
Python
Raw Normal View History

"""Forgejo issue tracking metrics endpoints."""
from __future__ import annotations
2026-05-20 02:29:58 -05:00
from datetime import timedelta
from typing import TYPE_CHECKING
2026-05-20 01:35:45 -05:00
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
2026-05-20 04:13:32 -05:00
from sqlalchemy import Date as SADate
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
from app.api.deps import ORG_MEMBER_DEP, OrganizationContext
2026-05-20 02:29:58 -05:00
from app.core.time import utcnow
from app.db.session import get_session
from app.models.board_repository_links import BoardRepositoryLink
from app.models.forgejo_issues import ForgejoIssue
from app.models.forgejo_repositories import ForgejoRepository
2026-05-20 03:54:58 -05:00
from app.schemas.metrics import HeatmapDay, HeatmapResponse, MetricsResponse
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
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,
"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,
"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(
None,
description="Filter by organization ID",
),
2026-05-20 01:35:45 -05:00
board_id: UUID | None = Query(
None,
description="Filter by board ID (via linked repositories)",
),
2026-05-20 01:35:45 -05:00
repository_id: UUID | None = Query(
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",
),
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)
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]
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,
)
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,
)
repos = (await session.exec(repo_statement)).all()
2026-05-20 01:35:45 -05:00
repo_ids = list(repos)
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
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,
)
)
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,
)
)
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,
)
)
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] = (
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
repositories_synced = len(repo_ids)
2026-05-20 03:40:07 -05:00
repository_sync_error_count = sum(sync_error_counts.values())
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,
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,
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",
description="Daily issue open+close event counts for the last 365 days, scoped to the caller's organisation.",
)
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:
"""Return per-day issue event counts (created + closed) for the last 365 days."""
if organization_id and organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
since = utcnow() - timedelta(days=365)
repo_ids_result = (
await session.exec(
select(ForgejoRepository.id).where(
ForgejoRepository.organization_id == ctx.organization.id,
)
)
).all()
if not repo_ids_result:
return HeatmapResponse(days=[], max_count=0)
repo_ids = list(repo_ids_result)
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 = [HeatmapDay(date=k, count=v) for k, v in sorted(counts.items())]
max_count = max((d.count for d in days), default=0)
return HeatmapResponse(days=days, max_count=max_count)
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,
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,
last_sync_timestamps={},
sync_error_counts={},
)