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_issues import ForgejoIssue
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -71,6 +71,76 @@ async def _bg_fetch_line_stats(
|
|||
finally:
|
||||
_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"])
|
||||
SESSION_DEP = Depends(get_session)
|
||||
# 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:
|
||||
"""Return zeroed metrics for empty scopes."""
|
||||
return MetricsResponse(
|
||||
|
|
|
|||
|
|
@ -121,6 +121,17 @@ class ForgejoIssueMetrics(SQLModel):
|
|||
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):
|
||||
"""Single day in the issue activity heatmap."""
|
||||
|
||||
|
|
|
|||
|
|
@ -350,6 +350,24 @@ class ForgejoAPIClient:
|
|||
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]:
|
||||
"""Fetch per-contributor weekly stats for a repository.
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import {
|
|||
import type { ActivityEventRead } from "@/api/generated/model";
|
||||
import {
|
||||
getForgejoHeatmap,
|
||||
getForgejoLastPush,
|
||||
getForgejoMetrics,
|
||||
getForgejoRepositories,
|
||||
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(
|
||||
() =>
|
||||
boardsQuery.data?.status === 200
|
||||
|
|
@ -1243,6 +1255,7 @@ export default function DashboardPage() {
|
|||
totalAdditions={forgejoHeatmapQuery.data?.total_additions ?? 0}
|
||||
totalDeletions={forgejoHeatmapQuery.data?.total_deletions ?? 0}
|
||||
hasLineStats={forgejoHeatmapQuery.data?.has_line_stats ?? false}
|
||||
lastPush={forgejoLastPushQuery.data ?? null}
|
||||
isLoading={forgejoHeatmapQuery.isLoading}
|
||||
/>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { ForgejoHeatmapDay } from "@/lib/api-forgejo";
|
||||
import type { ForgejoHeatmapDay, ForgejoLastPush } from "@/lib/api-forgejo";
|
||||
|
||||
interface ForgejoHeatmapProps {
|
||||
days: ForgejoHeatmapDay[];
|
||||
|
|
@ -9,6 +9,7 @@ interface ForgejoHeatmapProps {
|
|||
totalAdditions?: number;
|
||||
totalDeletions?: number;
|
||||
hasLineStats?: boolean;
|
||||
lastPush?: ForgejoLastPush | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -57,12 +58,24 @@ function fmtLines(n: number): string {
|
|||
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({
|
||||
days,
|
||||
maxCount: _maxCount,
|
||||
totalAdditions = 0,
|
||||
totalDeletions = 0,
|
||||
hasLineStats = false,
|
||||
lastPush = null,
|
||||
isLoading = false,
|
||||
}: ForgejoHeatmapProps) {
|
||||
const { weeks, monthLabels } = useMemo(() => {
|
||||
|
|
@ -176,6 +189,35 @@ export function ForgejoHeatmap({
|
|||
</span>
|
||||
</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 ──────────────────────────────────────────── */}
|
||||
{hasLineStats ? (
|
||||
<div className="flex items-center justify-center gap-8 pt-1">
|
||||
|
|
|
|||
|
|
@ -608,6 +608,27 @@ export interface ForgejoHeatmapDay {
|
|||
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 {
|
||||
days: ForgejoHeatmapDay[];
|
||||
max_count: number;
|
||||
|
|
|
|||
Loading…
Reference in New Issue