From 941804ea75c94af3399ca336eb256d38ad513aae Mon Sep 17 00:00:00 2001 From: Akanksha-020 Date: Thu, 21 May 2026 22:13:50 +0530 Subject: [PATCH 1/2] Add GitLab MR metrics to PR analytics --- src/app/api/metrics/prs/route.ts | 172 ++++++++++++++++++++++++++++++- src/components/PRMetrics.tsx | 114 +++++++++++++++----- 2 files changed, 257 insertions(+), 29 deletions(-) diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index 5c9f7554..0f14593a 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -45,6 +45,13 @@ interface ReviewCommentEvent { created_at?: string | null; } +interface GitLabMergeRequestItem { + state: string; + created_at: string; + merged_at?: string | null; + closed_at?: string | null; +} + function getRepoFullName(repositoryUrl: string): string | null { const marker = "/repos/"; const index = repositoryUrl.indexOf(marker); @@ -203,6 +210,111 @@ async function fetchPRMetrics(token: string): Promise { }; } +async function fetchGitLabMRMetrics(token: string): Promise { + const perPage = 100; + let page = 1; + let totalPages: number | null = null; + let totalCount: number | null = null; + const items: GitLabMergeRequestItem[] = []; + + while (page > 0) { + const url = new URL("https://gitlab.com/api/v4/merge_requests"); + url.searchParams.set("scope", "created_by_me"); + url.searchParams.set("state", "all"); + url.searchParams.set("per_page", String(perPage)); + url.searchParams.set("page", String(page)); + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error("GitLab API error"); + } + + if (totalCount === null) { + const totalHeader = response.headers.get("x-total"); + const parsedTotal = totalHeader ? Number(totalHeader) : NaN; + if (Number.isFinite(parsedTotal)) { + totalCount = parsedTotal; + } + } + + if (totalPages === null) { + const totalPagesHeader = response.headers.get("x-total-pages"); + const parsedPages = totalPagesHeader ? Number(totalPagesHeader) : NaN; + if (Number.isFinite(parsedPages) && parsedPages > 0) { + totalPages = parsedPages; + } + } + + const pageItems = (await response.json()) as GitLabMergeRequestItem[]; + if (!Array.isArray(pageItems) || pageItems.length === 0) { + break; + } + + items.push(...pageItems); + + const nextPage = response.headers.get("x-next-page"); + const parsedNext = nextPage && nextPage !== "0" ? Number(nextPage) : NaN; + if (Number.isFinite(parsedNext)) { + page = parsedNext; + continue; + } + + if (totalPages !== null && page < totalPages) { + page += 1; + continue; + } + + if (pageItems.length === perPage) { + page += 1; + continue; + } + + break; + } + + const open = items.filter((mr) => mr.state === "opened").length; + const mergedItems = items.filter( + (mr) => mr.state === "merged" && mr.merged_at + ); + const merged = mergedItems.length; + const closed = items.filter((mr) => mr.state === "closed").length; + + const reviewDurations = mergedItems + .map((mr) => { + const created = new Date(mr.created_at).getTime(); + const mergedAt = new Date(mr.merged_at!).getTime(); + if (Number.isNaN(created) || Number.isNaN(mergedAt)) { + return null; + } + return mergedAt - created; + }) + .filter((value): value is number => typeof value === "number"); + + const avgReviewMs = + reviewDurations.length > 0 + ? reviewDurations.reduce((sum, value) => sum + value, 0) / + reviewDurations.length + : 0; + + const sampleTotal = items.length; + + return { + open, + merged, + closed, + total: totalCount ?? sampleTotal, + avgReviewHours: Math.round(avgReviewMs / 3600000), + avgFirstReviewHours: null, + mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0, + }; +} + async function fetchCachedPRMetrics( token: string, cacheContext: { bypass: boolean; userId: string } @@ -219,6 +331,24 @@ async function fetchCachedPRMetrics( ); } +async function fetchCachedGitLabMRMetrics( + token: string, + cacheContext: { bypass: boolean; userId: string } +): Promise { + const key = metricsCacheKey(cacheContext.userId, "prs", { + source: "gitlab", + }); + + return withMetricsCache( + { + bypass: cacheContext.bypass, + key, + ttlSeconds: METRICS_CACHE_TTL_SECONDS.prs, + }, + () => fetchGitLabMRMetrics(token) + ); +} + function formatPRMetrics(metrics: PRMetricsBase) { return { open: metrics.open, @@ -234,14 +364,46 @@ function formatPRMetrics(metrics: PRMetricsBase) { }; } +function formatPRMetricsResponse( + metrics: PRMetricsBase, + gitlab: PRMetricsBase | null +) { + return { + ...formatPRMetrics(metrics), + ...(gitlab ? { gitlab: formatPRMetrics(gitlab) } : {}), + }; +} + +async function getGitLabMetrics( + token: string | undefined, + cacheContext: { bypass: boolean; userId: string } +) { + if (!token) { + return null; + } + + try { + return await fetchCachedGitLabMRMetrics(token, cacheContext); + } catch { + return null; + } +} + export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); if (!session?.accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } + const gitlabToken = + typeof session.gitlabToken === "string" ? session.gitlabToken : undefined; + const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); + const gitlabCacheContext = { + bypass, + userId: session.githubId ?? session.githubLogin ?? "primary", + }; if (!accountId) { try { @@ -249,7 +411,8 @@ export async function GET(req: NextRequest) { bypass, userId: session.githubId ?? session.githubLogin ?? "primary", }); - return Response.json(formatPRMetrics(result)); + const gitlab = await getGitLabMetrics(gitlabToken, gitlabCacheContext); + return Response.json(formatPRMetricsResponse(result, gitlab)); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } @@ -317,8 +480,8 @@ export async function GET(req: NextRequest) { if (!merged) { return Response.json({ error: "GitHub API error" }, { status: 502 }); } - - return Response.json(formatPRMetrics(merged)); + const gitlab = await getGitLabMetrics(gitlabToken, gitlabCacheContext); + return Response.json(formatPRMetricsResponse(merged, gitlab)); } const token = @@ -335,7 +498,8 @@ export async function GET(req: NextRequest) { bypass, userId: accountId === session.githubId ? session.githubId : accountId, }); - return Response.json(formatPRMetrics(result)); + const gitlab = await getGitLabMetrics(gitlabToken, gitlabCacheContext); + return Response.json(formatPRMetricsResponse(result, gitlab)); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index f7f92a92..0fa082c8 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; import PRStatusDonutChart from "./PRStatusDonutChart"; -interface PRData { +interface PRMetricsSummary { open: number; merged: number; closed: number; @@ -13,6 +13,10 @@ interface PRData { mergeRate: string; } +interface PRData extends PRMetricsSummary { + gitlab?: PRMetricsSummary; +} + function formatReviewCycle(hours: number | null): string { if (hours === null) { return "—"; @@ -54,18 +58,45 @@ export default function PRMetrics() { fetchMetrics(); }, [fetchMetrics]); - const stats = metrics - ? [ - { label: "Open PRs", value: metrics.open }, - { label: "Merged (30d)", value: metrics.merged }, - { label: "Avg Review Time", value: `${metrics.avgReviewHours}h` }, - { - label: "Avg First Review", - value: formatReviewCycle(metrics.avgFirstReviewHours), - title: "Average time from PR open to first review comment or approval", - }, - { label: "Merge Rate", value: metrics.mergeRate }, - ] + const buildStats = ( + source: PRMetricsSummary, + labels: { + open: string; + merged: string; + avgReview: string; + avgFirstReview: string; + mergeRate: string; + } + ) => [ + { label: labels.open, value: source.open }, + { label: labels.merged, value: source.merged }, + { label: labels.avgReview, value: `${source.avgReviewHours}h` }, + { + label: labels.avgFirstReview, + value: formatReviewCycle(source.avgFirstReviewHours), + title: "Average time from PR open to first review comment or approval", + }, + { label: labels.mergeRate, value: source.mergeRate }, + ]; + + const githubStats = metrics + ? buildStats(metrics, { + open: "Open PRs", + merged: "Merged (30d)", + avgReview: "Avg Review Time", + avgFirstReview: "Avg First Review", + mergeRate: "Merge Rate", + }) + : []; + + const gitlabStats = metrics?.gitlab + ? buildStats(metrics.gitlab, { + open: "Open MRs", + merged: "Merged (30d)", + avgReview: "Avg Review Time", + avgFirstReview: "Avg First Review", + mergeRate: "Merge Rate", + }) : []; return ( @@ -104,19 +135,22 @@ export default function PRMetrics() { ) : (
{/* Stat grid */} -
- {stats.map((stat) => ( -
-
- {stat.value} +
+

GitHub PRs

+
+ {githubStats.map((stat) => ( +
+
+ {stat.value} +
+
{stat.label}
-
{stat.label}
-
- ))} + ))} +
{/* PR status donut chart */} @@ -132,6 +166,36 @@ export default function PRMetrics() { />
)} + + {metrics?.gitlab && ( +
+

GitLab MRs

+
+ {gitlabStats.map((stat) => ( +
+
+ {stat.value} +
+
{stat.label}
+
+ ))} +
+
+

+ MR Status Distribution +

+ +
+
+ )}
)}
From 7f139d13ba8ea80773e53f17fe23b7df5d5aa529 Mon Sep 17 00:00:00 2001 From: Akanksha-020 Date: Thu, 21 May 2026 22:22:12 +0530 Subject: [PATCH 2/2] Resolve PRMetrics conflict --- src/components/PRMetrics.tsx | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index 0fa082c8..27556249 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -33,6 +33,8 @@ export default function PRMetrics() { const { selectedAccount } = useAccount(); const [metrics, setMetrics] = useState(null); const [loading, setLoading] = useState(true); + const [lastUpdated, setLastUpdated] = useState(null); + const [minutesAgo, setMinutesAgo] = useState(0); const [error, setError] = useState(null); const fetchMetrics = useCallback(() => { @@ -49,7 +51,11 @@ export default function PRMetrics() { if (!r.ok) throw new Error("API error"); return r.json(); }) - .then((data: PRData) => setMetrics(data)) + .then((data: PRData) => { + setMetrics(data); + setLastUpdated(new Date()); + setMinutesAgo(0); + }) .catch(() => setError("We couldn't load your PR analytics right now. Please try again in a moment.")) .finally(() => setLoading(false)); }, [selectedAccount]); @@ -58,6 +64,19 @@ export default function PRMetrics() { fetchMetrics(); }, [fetchMetrics]); + useEffect(() => { + if (!lastUpdated) { + return; + } + + const interval = setInterval(() => { + const diff = Math.floor((Date.now() - lastUpdated.getTime()) / 60000); + setMinutesAgo(diff); + }, 60000); + + return () => clearInterval(interval); + }, [lastUpdated]); + const buildStats = ( source: PRMetricsSummary, labels: { @@ -198,6 +217,11 @@ export default function PRMetrics() { )} )} + {lastUpdated && ( +

+ {minutesAgo === 0 ? "Updated just now" : `Updated ${minutesAgo} min ago`} +

+ )} ); }