diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index c3c19ef2..e58fe183 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"; @@ -18,6 +17,9 @@ 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 PRMetricsBase { open: number; merged: number; @@ -25,6 +27,9 @@ interface PRMetricsBase { avgReviewHours: number; avgFirstReviewHours: number | null; mergeRate: number; + staleCount: number; + staleThresholdDays: number; + staleSearchUrl: string | null; } interface PullRequestSearchItem { @@ -59,6 +64,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 @@ -132,7 +166,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`, { @@ -151,6 +188,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 @@ -193,14 +240,24 @@ 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), }; } 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( { @@ -208,7 +265,11 @@ async function fetchCachedPRMetrics( key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.prs, }, - () => fetchPRMetrics(token) + () => + fetchPRMetrics(token, { + githubLogin: cacheContext.githubLogin, + staleThresholdDays: cacheContext.staleThresholdDays, + }) ); } @@ -219,6 +280,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)}%` @@ -234,11 +298,14 @@ export async function GET(req: NextRequest) { const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); + const staleThresholdDays = getStaleThresholdDays(req); if (!accountId) { try { const result = await fetchCachedPRMetrics(session.accessToken, { bypass, + githubLogin: session.githubLogin, + staleThresholdDays, userId: session.githubId ?? session.githubLogin ?? "primary", }); return Response.json(formatPRMetrics(result)); @@ -269,7 +336,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, + }) ) ); @@ -299,6 +371,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, }; @@ -311,19 +386,28 @@ export async function GET(req: NextRequest) { return Response.json(formatPRMetrics(merged)); } - 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, }); return Response.json(formatPRMetrics(result)); } catch { diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index b855eaeb..ac7e0649 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -9,6 +9,17 @@ interface PRData { 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; } function formatReviewCycle(hours: number | null): string { @@ -26,6 +37,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 [error, setError] = useState(null); @@ -33,10 +45,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), + }); + + if (selectedAccount !== null) { + params.set("accountId", selectedAccount); + } + + const url = `/api/metrics/prs?${params.toString()}`; fetch(url) .then((r) => { @@ -46,15 +63,22 @@ export default function PRMetrics() { .then((data: PRData) => setMetrics(data)) .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(); }, [fetchMetrics]); - const stats = metrics + const stats: PRStat[] = metrics ? [ { label: "Open PRs", value: metrics.open }, + { + label: `Stale > ${metrics.staleThresholdDays}d`, + value: metrics.staleCount, + href: metrics.staleSearchUrl, + title: `${metrics.staleCount} open PRs are older than ${metrics.staleThresholdDays} days`, + warning: metrics.staleCount > 0, + }, { label: "Merged (30d)", value: metrics.merged }, { label: "Avg Review Time", value: `${metrics.avgReviewHours}h` }, { @@ -68,16 +92,32 @@ export default function PRMetrics() { return (
-

PR Analytics

+
+

PR Analytics

+ +
{loading ? (
Loading PR analytics - {[1, 2, 3, 4].map((i) => ( + {[1, 2, 3, 4, 5, 6].map((i) => ( ) : ( -
- {stats.map((stat) => ( -
-
- {stat.value} +
+ {stats.map((stat) => { + 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}
-
{stat.label}
-
- ))} + ); + })}
)}