feat(forgejo-metrics): add last-push endpoint and related caching logic for recent commits
This commit is contained in:
parent
dd9681925e
commit
7802400970
|
|
@ -21,7 +21,7 @@ from app.models.board_repository_links import BoardRepositoryLink
|
||||||
from app.models.forgejo_connections import ForgejoConnection
|
from app.models.forgejo_connections import ForgejoConnection
|
||||||
from app.models.forgejo_issues import ForgejoIssue
|
from app.models.forgejo_issues import ForgejoIssue
|
||||||
from app.models.forgejo_repositories import ForgejoRepository
|
from app.models.forgejo_repositories import ForgejoRepository
|
||||||
from app.schemas.metrics import HeatmapDay, HeatmapResponse, MetricsResponse
|
from app.schemas.metrics import HeatmapDay, HeatmapResponse, LastPushRead, MetricsResponse
|
||||||
from app.services.forgejo_client import ForgejoAPIClient, get_forgejo_client
|
from app.services.forgejo_client import ForgejoAPIClient, get_forgejo_client
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -71,6 +71,76 @@ async def _bg_fetch_line_stats(
|
||||||
finally:
|
finally:
|
||||||
_line_stats_fetching.discard(cache_key)
|
_line_stats_fetching.discard(cache_key)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/forgejo", tags=["forgejo-metrics"])
|
router = APIRouter(prefix="/forgejo", tags=["forgejo-metrics"])
|
||||||
SESSION_DEP = Depends(get_session)
|
SESSION_DEP = Depends(get_session)
|
||||||
# Use ORG_MEMBER_DEP directly, not wrapped in Depends again
|
# Use ORG_MEMBER_DEP directly, not wrapped in Depends again
|
||||||
|
|
@ -420,6 +490,55 @@ async def get_forgejo_heatmap(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
def _zeroed_metrics() -> MetricsResponse:
|
def _zeroed_metrics() -> MetricsResponse:
|
||||||
"""Return zeroed metrics for empty scopes."""
|
"""Return zeroed metrics for empty scopes."""
|
||||||
return MetricsResponse(
|
return MetricsResponse(
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,17 @@ class ForgejoIssueMetrics(SQLModel):
|
||||||
sync_error_counts: dict[str, int]
|
sync_error_counts: dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
class LastPushRead(SQLModel):
|
||||||
|
"""Most-recent commit pushed across all tracked repositories."""
|
||||||
|
|
||||||
|
sha: str # short (7-char) hash
|
||||||
|
message: str # first line of commit message
|
||||||
|
author: str # display name or login
|
||||||
|
repo: str # "owner/repo"
|
||||||
|
branch: str # branch name
|
||||||
|
pushed_at: str # ISO-8601 timestamp
|
||||||
|
|
||||||
|
|
||||||
class HeatmapDay(SQLModel):
|
class HeatmapDay(SQLModel):
|
||||||
"""Single day in the issue activity heatmap."""
|
"""Single day in the issue activity heatmap."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,24 @@ class ForgejoAPIClient:
|
||||||
return list(data)
|
return list(data)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_last_commit(
|
||||||
|
self, owner: str, repo: str, branch: str | None = None
|
||||||
|
) -> dict | None:
|
||||||
|
"""Return the most-recent commit on the default (or given) branch, or None."""
|
||||||
|
client = await self._get_client()
|
||||||
|
params: dict[str, object] = {"limit": 1}
|
||||||
|
if branch:
|
||||||
|
params["sha"] = branch
|
||||||
|
response = await client.get(
|
||||||
|
f"/api/v1/repos/{owner}/{repo}/commits", params=params
|
||||||
|
)
|
||||||
|
if response.status_code in (404, 409):
|
||||||
|
return None # empty repo or not found
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
commits = data if isinstance(data, list) else data.get("commits") or data.get("data") or []
|
||||||
|
return commits[0] if commits else None
|
||||||
|
|
||||||
async def get_contributor_stats(self, owner: str, repo: str) -> tuple[list[dict], bool]:
|
async def get_contributor_stats(self, owner: str, repo: str) -> tuple[list[dict], bool]:
|
||||||
"""Fetch per-contributor weekly stats for a repository.
|
"""Fetch per-contributor weekly stats for a repository.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ import {
|
||||||
import type { ActivityEventRead } from "@/api/generated/model";
|
import type { ActivityEventRead } from "@/api/generated/model";
|
||||||
import {
|
import {
|
||||||
getForgejoHeatmap,
|
getForgejoHeatmap,
|
||||||
|
getForgejoLastPush,
|
||||||
getForgejoMetrics,
|
getForgejoMetrics,
|
||||||
getForgejoRepositories,
|
getForgejoRepositories,
|
||||||
type ForgejoHeatmapDay,
|
type ForgejoHeatmapDay,
|
||||||
|
|
@ -538,6 +539,17 @@ export default function DashboardPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const forgejoLastPushQuery = useQuery({
|
||||||
|
queryKey: ["dashboard", "forgejo", "last-push", forgejoOrganizationId],
|
||||||
|
enabled: Boolean(isSignedIn && forgejoOrganizationId),
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
queryFn: () => {
|
||||||
|
if (!forgejoOrganizationId) return Promise.resolve(null);
|
||||||
|
return getForgejoLastPush({ organization_id: forgejoOrganizationId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const boards = useMemo(
|
const boards = useMemo(
|
||||||
() =>
|
() =>
|
||||||
boardsQuery.data?.status === 200
|
boardsQuery.data?.status === 200
|
||||||
|
|
@ -1243,6 +1255,7 @@ export default function DashboardPage() {
|
||||||
totalAdditions={forgejoHeatmapQuery.data?.total_additions ?? 0}
|
totalAdditions={forgejoHeatmapQuery.data?.total_additions ?? 0}
|
||||||
totalDeletions={forgejoHeatmapQuery.data?.total_deletions ?? 0}
|
totalDeletions={forgejoHeatmapQuery.data?.total_deletions ?? 0}
|
||||||
hasLineStats={forgejoHeatmapQuery.data?.has_line_stats ?? false}
|
hasLineStats={forgejoHeatmapQuery.data?.has_line_stats ?? false}
|
||||||
|
lastPush={forgejoLastPushQuery.data ?? null}
|
||||||
isLoading={forgejoHeatmapQuery.isLoading}
|
isLoading={forgejoHeatmapQuery.isLoading}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { ForgejoHeatmapDay } from "@/lib/api-forgejo";
|
import type { ForgejoHeatmapDay, ForgejoLastPush } from "@/lib/api-forgejo";
|
||||||
|
|
||||||
interface ForgejoHeatmapProps {
|
interface ForgejoHeatmapProps {
|
||||||
days: ForgejoHeatmapDay[];
|
days: ForgejoHeatmapDay[];
|
||||||
|
|
@ -9,6 +9,7 @@ interface ForgejoHeatmapProps {
|
||||||
totalAdditions?: number;
|
totalAdditions?: number;
|
||||||
totalDeletions?: number;
|
totalDeletions?: number;
|
||||||
hasLineStats?: boolean;
|
hasLineStats?: boolean;
|
||||||
|
lastPush?: ForgejoLastPush | null;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,12 +58,24 @@ function fmtLines(n: number): string {
|
||||||
return n.toLocaleString();
|
return n.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtRelative(iso: string): string {
|
||||||
|
const diffMs = Date.now() - new Date(iso).getTime();
|
||||||
|
const s = Math.floor(diffMs / 1000);
|
||||||
|
if (s < 60) return "just now";
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
return `${Math.floor(h / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
export function ForgejoHeatmap({
|
export function ForgejoHeatmap({
|
||||||
days,
|
days,
|
||||||
maxCount: _maxCount,
|
maxCount: _maxCount,
|
||||||
totalAdditions = 0,
|
totalAdditions = 0,
|
||||||
totalDeletions = 0,
|
totalDeletions = 0,
|
||||||
hasLineStats = false,
|
hasLineStats = false,
|
||||||
|
lastPush = null,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: ForgejoHeatmapProps) {
|
}: ForgejoHeatmapProps) {
|
||||||
const { weeks, monthLabels } = useMemo(() => {
|
const { weeks, monthLabels } = useMemo(() => {
|
||||||
|
|
@ -176,6 +189,35 @@ export function ForgejoHeatmap({
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* ── Last push ───────────────────────────────────────────── */}
|
||||||
|
{lastPush && (
|
||||||
|
<div
|
||||||
|
className="mx-auto flex max-w-lg items-start gap-3 rounded-lg px-4 py-2.5"
|
||||||
|
style={{ background: "rgba(139,92,246,0.08)", border: "1px solid rgba(139,92,246,0.18)" }}
|
||||||
|
>
|
||||||
|
{/* commit hash pill */}
|
||||||
|
<span
|
||||||
|
className="mt-0.5 shrink-0 rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold"
|
||||||
|
style={{ background: "rgba(139,92,246,0.20)", color: VIOLET }}
|
||||||
|
>
|
||||||
|
{lastPush.sha}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium" style={{ color: "rgba(255,255,255,0.90)" }}>
|
||||||
|
{lastPush.message}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs" style={{ color: "rgba(255,255,255,0.50)" }}>
|
||||||
|
{lastPush.author} pushed to{" "}
|
||||||
|
<span style={{ color: "rgba(139,92,246,0.90)" }}>{lastPush.branch}</span>
|
||||||
|
{" · "}
|
||||||
|
{lastPush.repo}
|
||||||
|
{" · "}
|
||||||
|
{fmtRelative(lastPush.pushed_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Line stats ──────────────────────────────────────────── */}
|
{/* ── Line stats ──────────────────────────────────────────── */}
|
||||||
{hasLineStats ? (
|
{hasLineStats ? (
|
||||||
<div className="flex items-center justify-center gap-8 pt-1">
|
<div className="flex items-center justify-center gap-8 pt-1">
|
||||||
|
|
|
||||||
|
|
@ -608,6 +608,27 @@ export interface ForgejoHeatmapDay {
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ForgejoLastPush {
|
||||||
|
sha: string;
|
||||||
|
message: string;
|
||||||
|
author: string;
|
||||||
|
repo: string;
|
||||||
|
branch: string;
|
||||||
|
pushed_at: string; // ISO-8601
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getForgejoLastPush(params?: {
|
||||||
|
organization_id?: string;
|
||||||
|
}): Promise<ForgejoLastPush | null> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.organization_id)
|
||||||
|
searchParams.set("organization_id", params.organization_id);
|
||||||
|
const qs = searchParams.toString();
|
||||||
|
return fetchJson<ForgejoLastPush | null>(
|
||||||
|
`/api/v1/forgejo/last-push${qs ? `?${qs}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ForgejoHeatmapResponse {
|
export interface ForgejoHeatmapResponse {
|
||||||
days: ForgejoHeatmapDay[];
|
days: ForgejoHeatmapDay[];
|
||||||
max_count: number;
|
max_count: number;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue