diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index 26f4d10f..8cdc04a1 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -4,6 +4,51 @@ import { authOptions } from "@/lib/auth"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; +const GITLAB_API = "https://gitlab.com/api/v4"; + +interface GitHubPRItem { + state: string; + created_at: string; + closed_at: string | null; +} + +interface GitHubSearchResponse { + total_count: number; + items: GitHubPRItem[]; +} + +interface GitLabMRItem { + state: string; + created_at: string; + merged_at: string | null; + closed_at: string | null; +} + +interface ReviewStats { + open: number; + merged: number; + total: number; + reviewMs: number; + reviewCount: number; +} + +function buildResponse(stats: ReviewStats) { + const avgReviewHours = + stats.reviewCount > 0 + ? Math.round(stats.reviewMs / stats.reviewCount / 3600000) + : 0; + + return { + open: stats.open, + merged: stats.merged, + total: stats.total, + avgReviewHours, + mergeRate: + stats.total > 0 + ? `${Math.round((stats.merged / stats.total) * 100)}%` + : "0%", + }; +} export async function GET() { const session = await getServerSession(authOptions); @@ -23,34 +68,82 @@ export async function GET() { return Response.json({ error: "GitHub API error" }, { status: 502 }); } - const data = (await searchRes.json()) as { - total_count: number; - items: Array<{ state: string; created_at: string; closed_at: string | null }>; + const data = (await searchRes.json()) as GitHubSearchResponse; + + const githubStats: ReviewStats = { + open: 0, + merged: 0, + total: data.total_count, + reviewMs: 0, + reviewCount: 0, }; - const open = data.items.filter((pr) => pr.state === "open").length; - const merged = data.items.filter((pr) => pr.state === "closed").length; - - const closedPRs = data.items.filter((pr) => pr.closed_at); - const avgReviewMs = - closedPRs.length > 0 - ? closedPRs.reduce( - (sum, pr) => - sum + - (new Date(pr.closed_at!).getTime() - - new Date(pr.created_at).getTime()), - 0 - ) / closedPRs.length - : 0; + for (const pr of data.items) { + if (pr.state === "open") githubStats.open++; + if (pr.state === "closed") githubStats.merged++; + if (pr.closed_at) { + githubStats.reviewMs += + new Date(pr.closed_at).getTime() - new Date(pr.created_at).getTime(); + githubStats.reviewCount++; + } + } + + let gitlabStats: ReviewStats | null = null; + const gitlabToken = + typeof session.gitlabToken === "string" ? session.gitlabToken : null; + + if (gitlabToken) { + try { + const gitlabRes = await fetch( + `${GITLAB_API}/merge_requests?scope=created_by_me&state=all&per_page=100`, + { + headers: { Authorization: `Bearer ${gitlabToken}` }, + cache: "no-store", + } + ); + + if (gitlabRes.ok) { + const mrs = (await gitlabRes.json()) as GitLabMRItem[]; + gitlabStats = { + open: 0, + merged: 0, + total: mrs.length, + reviewMs: 0, + reviewCount: 0, + }; + + for (const mr of mrs) { + if (mr.state === "opened") gitlabStats.open++; + if (mr.state === "merged") gitlabStats.merged++; + + const closedAt = mr.merged_at ?? mr.closed_at; + if (closedAt) { + gitlabStats.reviewMs += + new Date(closedAt).getTime() - new Date(mr.created_at).getTime(); + gitlabStats.reviewCount++; + } + } + } + } catch { + // Non-fatal: keep GitHub-only response if GitLab fails. + } + } + + const combinedStats: ReviewStats = gitlabStats + ? { + open: githubStats.open + gitlabStats.open, + merged: githubStats.merged + gitlabStats.merged, + total: githubStats.total + gitlabStats.total, + reviewMs: githubStats.reviewMs + gitlabStats.reviewMs, + reviewCount: githubStats.reviewCount + gitlabStats.reviewCount, + } + : githubStats; return Response.json({ - open, - merged, - total: data.total_count, - avgReviewHours: Math.round(avgReviewMs / 3600000), - mergeRate: - data.total_count > 0 - ? `${Math.round((merged / data.total_count) * 100)}%` - : "0%", + ...buildResponse(combinedStats), + sources: { + github: buildResponse(githubStats), + gitlab: gitlabStats ? buildResponse(gitlabStats) : null, + }, }); } diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index d40d4ad0..754641f2 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -2,15 +2,23 @@ import { useEffect, useState } from "react"; -interface PRData { +interface PRSourceMetrics { open: number; merged: number; + total: number; avgReviewHours: number; mergeRate: string; } +interface PRMetricsResponse extends PRSourceMetrics { + sources?: { + github?: PRSourceMetrics; + gitlab?: PRSourceMetrics | null; + }; +} + export default function PRMetrics() { - const [metrics, setMetrics] = useState(null); + const [metrics, setMetrics] = useState(null); const [loading, setLoading] = useState(true); const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); @@ -25,7 +33,7 @@ export default function PRMetrics() { if (!r.ok) throw new Error("API error"); return r.json(); }) - .then((data: PRData) => setMetrics(data)) + .then((data: PRMetricsResponse) => setMetrics(data)) .catch(() => setError("We couldn't load your PR analytics right now. Please try again in a moment.")) .finally(() => { setLoading(false); @@ -54,6 +62,14 @@ export default function PRMetrics() { ] : []; + const sourceSections = + metrics?.sources?.gitlab && metrics.sources.github + ? [ + { label: "GitHub", metrics: metrics.sources.github }, + { label: "GitLab", metrics: metrics.sources.gitlab }, + ] + : []; + return (

PR Analytics

@@ -78,18 +94,66 @@ export default function PRMetrics() {
) : ( -
- {stats.map((stat) => ( -
-
- {stat.value} +
+
+ {stats.map((stat) => ( +
+
+ {stat.value} +
+
{stat.label}
+
+ ))} +
+ + {sourceSections.length > 0 && ( +
+
+ Sources +
+
+ {sourceSections.map((section) => ( +
+
+ {section.label} +
+
+
+
+ {section.metrics?.open ?? 0} +
+
Open
+
+
+
+ {section.metrics?.merged ?? 0} +
+
Merged
+
+
+
+ {section.metrics?.avgReviewHours ?? 0}h +
+
Avg Review
+
+
+
+ {section.metrics?.mergeRate ?? "0%"} +
+
Merge Rate
+
+
+
+ ))}
-
{stat.label}
- ))} + )}
)} {lastUpdated && (