From 32ee37d11374e02a35bc8622d3b1c60ccc266168 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 21 May 2026 21:04:00 +0530 Subject: [PATCH] feat: add stale PR indicator --- src/app/api/metrics/prs/route.ts | 117 ++++++++++++++--- src/components/PRMetrics.tsx | 207 ++++++++++++++++++++----------- 2 files changed, 237 insertions(+), 87 deletions(-) diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index addf55d5..e9ce8bea 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -2,7 +2,6 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { - getAccountToken, getAllAccounts, mergeMetrics, } from "@/lib/github-accounts"; @@ -17,12 +16,17 @@ import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; export const dynamic = "force-dynamic"; + +const STALE_THRESHOLD_OPTIONS = [7, 14, 30] as const; +const DEFAULT_STALE_THRESHOLD_DAYS = 7; + interface ReviewMetrics { totalReviews: number; approvalRate: string; avgFirstReviewHours: number | null; topRepos: { repo: string; count: number }[]; } + interface PRMetricsBase { open: number; merged: number; @@ -31,6 +35,9 @@ interface PRMetricsBase { avgReviewHours: number; avgFirstReviewHours: number | null; mergeRate: number; + staleCount: number; + staleThresholdDays: number; + staleSearchUrl: string | null; } interface PullRequestSearchItem { @@ -72,6 +79,35 @@ function getEarliestTimestamp(values: Array) { return timestamps.length > 0 ? Math.min(...timestamps) : null; } +function getStaleThresholdDays(req: NextRequest): number { + const requestedThreshold = Number( + req.nextUrl.searchParams.get("staleThresholdDays") ?? + DEFAULT_STALE_THRESHOLD_DAYS + ); + + return STALE_THRESHOLD_OPTIONS.includes( + requestedThreshold as (typeof STALE_THRESHOLD_OPTIONS)[number] + ) + ? requestedThreshold + : DEFAULT_STALE_THRESHOLD_DAYS; +} + +function getStaleSearchUrl( + githubLogin: string | null | undefined, + staleCutoffMs: number +): string | null { + if (!githubLogin) { + return null; + } + + const cutoffDate = new Date(staleCutoffMs).toISOString().slice(0, 10); + const params = new URLSearchParams({ + q: `is:pr is:open author:${githubLogin} created:<${cutoffDate}`, + }); + + return `https://github.com/pulls?${params.toString()}`; +} + async function fetchFirstReviewTimestamp( token: string, pr: PullRequestSearchItem @@ -145,7 +181,10 @@ async function getAverageFirstReviewHours( return Math.round(average * 10) / 10; } -async function fetchPRMetrics(token: string): Promise { +async function fetchPRMetrics( + token: string, + options: { staleThresholdDays: number; githubLogin?: string | null } +): Promise { const searchRes = await fetch( `${GITHUB_API}/search/issues?q=type:pr+author:@me&sort=updated&order=desc&per_page=100`, { @@ -164,6 +203,16 @@ async function fetchPRMetrics(token: string): Promise { }; const open = data.items.filter((pr) => pr.state === "open").length; + const staleCutoffMs = + Date.now() - options.staleThresholdDays * 24 * 60 * 60 * 1000; + const staleCount = data.items.filter((pr) => { + if (pr.state !== "open") { + return false; + } + + const createdAt = new Date(pr.created_at).getTime(); + return !Number.isNaN(createdAt) && createdAt < staleCutoffMs; + }).length; // A PR with state "closed" may have been merged OR closed without merging // (e.g. rejected, abandoned). Only count those with a non-null merged_at @@ -212,6 +261,9 @@ async function fetchPRMetrics(token: string): Promise { avgReviewHours: Math.round(avgReviewMs / 3600000), avgFirstReviewHours, mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0, + staleCount, + staleThresholdDays: options.staleThresholdDays, + staleSearchUrl: getStaleSearchUrl(options.githubLogin, staleCutoffMs), }; } @@ -317,14 +369,24 @@ async function fetchGitLabMRMetrics(token: string): Promise { avgReviewHours: Math.round(avgReviewMs / 3600000), avgFirstReviewHours: null, mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0, + staleCount: 0, + staleThresholdDays: DEFAULT_STALE_THRESHOLD_DAYS, + staleSearchUrl: null, }; } async function fetchCachedPRMetrics( token: string, - cacheContext: { bypass: boolean; userId: string } + cacheContext: { + bypass: boolean; + githubLogin?: string | null; + staleThresholdDays: number; + userId: string; + } ): Promise { - const key = metricsCacheKey(cacheContext.userId, "prs"); + const key = metricsCacheKey(cacheContext.userId, "prs", { + staleThresholdDays: cacheContext.staleThresholdDays, + }); return withMetricsCache( { @@ -332,7 +394,11 @@ async function fetchCachedPRMetrics( key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.prs, }, - () => fetchPRMetrics(token) + () => + fetchPRMetrics(token, { + githubLogin: cacheContext.githubLogin, + staleThresholdDays: cacheContext.staleThresholdDays, + }) ); } @@ -362,6 +428,9 @@ function formatPRMetrics(metrics: PRMetricsBase) { total: metrics.total, avgReviewHours: metrics.avgReviewHours, avgFirstReviewHours: metrics.avgFirstReviewHours, + staleCount: metrics.staleCount, + staleThresholdDays: metrics.staleThresholdDays, + staleSearchUrl: metrics.staleSearchUrl, mergeRate: metrics.total > 0 ? `${Math.round(metrics.mergeRate * 100)}%` @@ -473,6 +542,7 @@ export async function GET(req: NextRequest) { const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); + const staleThresholdDays = getStaleThresholdDays(req); const gitlabCacheContext = { bypass, userId: session.githubId ?? session.githubLogin ?? "primary", @@ -482,6 +552,8 @@ export async function GET(req: NextRequest) { try { const result = await fetchCachedPRMetrics(session.accessToken, { bypass, + githubLogin: session.githubLogin, + staleThresholdDays, userId: session.githubId ?? session.githubLogin ?? "primary", }); const [gitlab, reviews] = await Promise.all([ @@ -516,7 +588,12 @@ export async function GET(req: NextRequest) { const results = await Promise.allSettled( accounts.map((account) => - fetchCachedPRMetrics(account.token, { bypass, userId: account.githubId }) + fetchCachedPRMetrics(account.token, { + bypass, + githubLogin: account.githubLogin, + staleThresholdDays, + userId: account.githubId, + }) ) ); @@ -548,6 +625,9 @@ export async function GET(req: NextRequest) { avgFirstReviewHours === null ? null : Math.round(avgFirstReviewHours * 10) / 10, + staleCount: a.staleCount + b.staleCount, + staleThresholdDays, + staleSearchUrl: null, mergeRate: total > 0 ? Math.round((mergedCount / total) * 100) / 100 : 0, }; @@ -563,23 +643,32 @@ export async function GET(req: NextRequest) { return Response.json({ ...formatPRMetricsResponse(merged, gitlab), reviews }); } - const token = - accountId === session.githubId - ? session.accessToken - : await getAccountToken(userRow.id, accountId); + const accounts = await getAllAccounts( + { + token: session.accessToken, + githubId: session.githubId, + githubLogin: session.githubLogin, + }, + userRow.id + ); + const selectedAccount = accounts.find( + (account) => account.githubId === accountId + ); - if (!token) { + if (!selectedAccount) { return Response.json({ error: "Account not found" }, { status: 404 }); } try { - const result = await fetchCachedPRMetrics(token, { + const result = await fetchCachedPRMetrics(selectedAccount.token, { bypass, - userId: accountId === session.githubId ? session.githubId : accountId, + githubLogin: selectedAccount.githubLogin, + staleThresholdDays, + userId: selectedAccount.githubId, }); const [gitlab, reviews] = await Promise.all([ getGitLabMetrics(gitlabToken, gitlabCacheContext), - fetchReviewMetrics(token).catch(() => null), + fetchReviewMetrics(selectedAccount.token).catch(() => null), ]); return Response.json({ ...formatPRMetricsResponse(result, gitlab), reviews }); } catch { diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index 84cf7185..b52b3b43 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -3,12 +3,14 @@ import { useCallback, useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; import PRStatusDonutChart from "./PRStatusDonutChart"; + interface ReviewMetrics { totalReviews: number; approvalRate: string; avgFirstReviewHours: number | null; topRepos: { repo: string; count: number }[]; } + interface PRMetricsSummary { open: number; merged: number; @@ -16,6 +18,17 @@ interface PRMetricsSummary { avgReviewHours: number; avgFirstReviewHours: number | null; mergeRate: string; + staleCount: number; + staleThresholdDays: number; + staleSearchUrl: string | null; +} + +interface PRStat { + label: string; + value: string | number; + href?: string | null; + title?: string; + warning?: boolean; } interface PRData extends PRMetricsSummary { @@ -25,7 +38,7 @@ interface PRData extends PRMetricsSummary { function formatReviewCycle(hours: number | null): string { if (hours === null) { - return "—"; + return "--"; } if (hours < 24) { @@ -38,6 +51,7 @@ function formatReviewCycle(hours: number | null): string { export default function PRMetrics() { const { selectedAccount } = useAccount(); const [metrics, setMetrics] = useState(null); + const [staleThresholdDays, setStaleThresholdDays] = useState(7); const [loading, setLoading] = useState(true); const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); @@ -48,12 +62,15 @@ export default function PRMetrics() { setLoading(true); setError(null); - const url = - selectedAccount !== null - ? `/api/metrics/prs?accountId=${encodeURIComponent(selectedAccount)}` - : "/api/metrics/prs"; + const params = new URLSearchParams({ + staleThresholdDays: String(staleThresholdDays), + }); - fetch(url) + if (selectedAccount !== null) { + params.set("accountId", selectedAccount); + } + + fetch(`/api/metrics/prs?${params.toString()}`) .then((r) => { if (!r.ok) throw new Error("API error"); return r.json(); @@ -65,7 +82,7 @@ export default function PRMetrics() { }) .catch(() => setError("We couldn't load your PR analytics right now. Please try again in a moment.")) .finally(() => setLoading(false)); - }, [selectedAccount]); + }, [selectedAccount, staleThresholdDays]); useEffect(() => { fetchMetrics(); @@ -92,9 +109,22 @@ export default function PRMetrics() { avgReview: string; avgFirstReview: string; mergeRate: string; - } - ) => [ + stale?: string; + }, + options: { includeStale?: boolean } = {} + ): PRStat[] => [ { label: labels.open, value: source.open }, + ...(options.includeStale + ? [ + { + label: labels.stale ?? `Stale > ${source.staleThresholdDays}d`, + value: source.staleCount, + href: source.staleSearchUrl, + title: `${source.staleCount} open PRs are older than ${source.staleThresholdDays} days`, + warning: source.staleCount > 0, + }, + ] + : []), { label: labels.merged, value: source.merged }, { label: labels.avgReview, value: `${source.avgReviewHours}h` }, { @@ -106,13 +136,17 @@ export default function PRMetrics() { ]; const githubStats = metrics - ? buildStats(metrics, { - open: "Open PRs", - merged: "Merged (30d)", - avgReview: "Avg Review Time", - avgFirstReview: "Avg First Review", - mergeRate: "Merge Rate", - }) + ? buildStats( + metrics, + { + open: "Open PRs", + merged: "Merged (30d)", + avgReview: "Avg Review Time", + avgFirstReview: "Avg First Review", + mergeRate: "Merge Rate", + }, + { includeStale: true } + ) : []; const gitlabStats = metrics?.gitlab @@ -125,33 +159,86 @@ export default function PRMetrics() { }) : []; + const renderStat = (stat: PRStat) => { + const content = ( + <> +
+ {stat.value} +
+
{stat.label}
+ + ); + const className = `rounded-lg p-4 text-center min-w-0 transition-colors ${ + stat.warning + ? "border border-orange-400/30 bg-orange-500/10 hover:bg-orange-500/15" + : "bg-[var(--control)]" + }`; + + return stat.href ? ( + + {content} + + ) : ( +
+ {content} +
+ ); + }; + return (
-
+

PR Analytics

-
- - +
+
+ + +
+
{loading ? ( @@ -162,8 +249,8 @@ export default function PRMetrics() { className="space-y-4" > Loading PR analytics -
- {[1, 2, 3, 4, 5].map((i) => ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( - ) : ( + ) : activeTab === "authored" ? (
- {/* Stat grid */}

GitHub PRs

-
- {githubStats.map((stat) => ( -
-
- {stat.value} -
-
{stat.label}
-
- ))} +
+ {githubStats.map(renderStat)}
- {/* PR status donut chart */} {metrics && (

@@ -223,18 +297,7 @@ export default function PRMetrics() {

GitLab MRs

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

@@ -249,9 +312,7 @@ export default function PRMetrics() {

)}
- )} - {/* Reviews Given Tab */} - {!loading && !error && activeTab === "reviews" && ( + ) : (
{[