From 7edd53091ea97cfff08c088280c6ba172b017d4d Mon Sep 17 00:00:00 2001 From: Maniska Date: Tue, 19 May 2026 19:58:24 +0530 Subject: [PATCH 1/3] Add stale PR indicators with configurable thresholds --- src/app/api/metrics/prs/route.ts | 190 +++++++++++++++++++++++++++++-- src/components/PRMetrics.tsx | 127 ++++++++++++++++++--- 2 files changed, 291 insertions(+), 26 deletions(-) diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index fb28df2b..daa03593 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -11,17 +11,143 @@ import { supabaseAdmin } from "@/lib/supabase"; export const dynamic = "force-dynamic"; +interface PullRequest { + title: string; + created_at: string; + html_url: string; + state: string; +} + interface PRMetricsBase { open: number; merged: number; total: number; avgReviewHours: number; + avgFirstReviewHours: number | null; mergeRate: number; + prs: PullRequest[]; +} + +interface PullRequestSearchItem { + title: string; + state: string; + created_at: string; + closed_at: string | null; + number: number; + repository_url: string; + html_url: string; + pull_request?: { merged_at: string | null }; +} + +interface ReviewEvent { + submitted_at?: string | null; +} + +interface ReviewCommentEvent { + created_at?: string | null; +} + +function getRepoFullName(repositoryUrl: string): string | null { + const marker = "/repos/"; + const index = repositoryUrl.indexOf(marker); + + return index >= 0 ? repositoryUrl.slice(index + marker.length) : null; +} + +function getEarliestTimestamp(values: Array) { + const timestamps = values + .filter((value): value is string => Boolean(value)) + .map((value) => new Date(value).getTime()) + .filter((value) => !Number.isNaN(value)); + + return timestamps.length > 0 ? Math.min(...timestamps) : null; +} + +async function fetchFirstReviewTimestamp( + token: string, + pr: PullRequestSearchItem +): Promise { + const repo = getRepoFullName(pr.repository_url); + + if (!repo) { + return null; + } + + const headers = { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }; + + const [reviewsRes, commentsRes] = await Promise.all([ + fetch( + `${GITHUB_API}/repos/${repo}/pulls/${pr.number}/reviews?per_page=100`, + { + headers, + cache: "no-store", + } + ), + fetch( + `${GITHUB_API}/repos/${repo}/pulls/${pr.number}/comments?per_page=100`, + { + headers, + cache: "no-store", + } + ), + ]); + + if (!reviewsRes.ok || !commentsRes.ok) { + return null; + } + + const reviews = (await reviewsRes.json()) as ReviewEvent[]; + const comments = (await commentsRes.json()) as ReviewCommentEvent[]; + + return getEarliestTimestamp([ + ...reviews.map((review) => review.submitted_at), + ...comments.map((comment) => comment.created_at), + ]); +} + +async function getAverageFirstReviewHours( + token: string, + prs: PullRequestSearchItem[] +): Promise { + const reviewedPrs = await Promise.all( + prs.slice(0, 30).map(async (pr) => { + const firstReviewAt = await fetchFirstReviewTimestamp(token, pr); + + if (!firstReviewAt) { + return null; + } + + const openedAt = new Date(pr.created_at).getTime(); + + if (Number.isNaN(openedAt) || firstReviewAt < openedAt) { + return null; + } + + return (firstReviewAt - openedAt) / 3600000; + }) + ); + + const validDurations = reviewedPrs.filter( + (value): value is number => typeof value === "number" + ); + + if (validDurations.length === 0) { + return null; + } + + const average = + validDurations.reduce((sum, value) => sum + value, 0) / + validDurations.length; + + return Math.round(average * 10) / 10; } async function fetchPRMetrics(token: string): Promise { const searchRes = await fetch( - `${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, + `${GITHUB_API}/search/issues?q=type:pr+author:@me&sort=updated&order=desc&per_page=100`, { headers: { Authorization: `Bearer ${token}` }, cache: "no-store", @@ -34,30 +160,52 @@ async function fetchPRMetrics(token: string): Promise { const data = (await searchRes.json()) as { total_count: number; - items: Array<{ state: string; created_at: string; closed_at: string | null }>; + items: PullRequestSearchItem[]; }; 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 merged = data.items.filter( + (pr) => pr.pull_request?.merged_at != null + ).length; + + const mergedPRs = data.items.filter( + (pr) => pr.pull_request?.merged_at != null + ); + + const prs = data.items.map((pr) => ({ + title: pr.title, + created_at: pr.created_at, + html_url: pr.html_url, + state: pr.state, + })); + const avgReviewMs = - closedPRs.length > 0 - ? closedPRs.reduce( + mergedPRs.length > 0 + ? mergedPRs.reduce( (sum, pr) => sum + - (new Date(pr.closed_at!).getTime() - + (new Date(pr.pull_request!.merged_at!).getTime() - new Date(pr.created_at).getTime()), 0 - ) / closedPRs.length + ) / mergedPRs.length : 0; + const sampleTotal = data.items.length; + + const avgFirstReviewHours = await getAverageFirstReviewHours( + token, + data.items + ); + return { open, merged, total: data.total_count, avgReviewHours: Math.round(avgReviewMs / 3600000), - mergeRate: data.total_count > 0 ? merged / data.total_count : 0, + avgFirstReviewHours, + mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0, + prs, }; } @@ -67,6 +215,8 @@ function formatPRMetrics(metrics: PRMetricsBase) { merged: metrics.merged, total: metrics.total, avgReviewHours: metrics.avgReviewHours, + avgFirstReviewHours: metrics.avgFirstReviewHours, + prs: metrics.prs, mergeRate: metrics.total > 0 ? `${Math.round(metrics.mergeRate * 100)}%` @@ -76,6 +226,7 @@ function formatPRMetrics(metrics: PRMetricsBase) { export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); + if (!session?.accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -85,6 +236,7 @@ export async function GET(req: NextRequest) { if (!accountId) { try { const result = await fetchPRMetrics(session.accessToken); + return Response.json(formatPRMetrics(result)); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); @@ -122,18 +274,35 @@ export async function GET(req: NextRequest) { const merged = mergeMetrics(results, (a, b) => { const total = a.total + b.total; const mergedCount = a.merged + b.merged; + const avgReviewHours = total > 0 ? (a.avgReviewHours * a.total + b.avgReviewHours * b.total) / total : 0; + const reviewedTotal = + (a.avgFirstReviewHours === null ? 0 : a.total) + + (b.avgFirstReviewHours === null ? 0 : b.total); + + const avgFirstReviewHours = + reviewedTotal > 0 + ? ((a.avgFirstReviewHours ?? 0) * a.total + + (b.avgFirstReviewHours ?? 0) * b.total) / + reviewedTotal + : null; + return { open: a.open + b.open, merged: mergedCount, total, avgReviewHours: Math.round(avgReviewHours * 10) / 10, + avgFirstReviewHours: + avgFirstReviewHours === null + ? null + : Math.round(avgFirstReviewHours * 10) / 10, mergeRate: total > 0 ? Math.round((mergedCount / total) * 100) / 100 : 0, + prs: [...a.prs, ...b.prs], }; }); @@ -155,8 +324,9 @@ export async function GET(req: NextRequest) { try { const result = await fetchPRMetrics(token); + return Response.json(formatPRMetrics(result)); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } -} +} \ No newline at end of file diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index 990d13f5..b638f5c5 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -7,14 +7,25 @@ interface PRData { open: number; merged: number; avgReviewHours: number; + avgFirstReviewHours: number | null; mergeRate: string; + prs: PullRequest[]; +} + +interface PullRequest { + title: string; + created_at: string; + html_url: string; + state: string; } export default function PRMetrics() { const { selectedAccount } = useAccount(); + const [metrics, setMetrics] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [staleDays, setStaleDays] = useState(7); const fetchMetrics = useCallback(() => { setLoading(true); @@ -31,7 +42,11 @@ export default function PRMetrics() { return r.json(); }) .then((data: PRData) => setMetrics(data)) - .catch(() => setError("We couldn't load your PR analytics right now. Please try again in a moment.")) + .catch(() => + setError( + "We couldn't load your PR analytics right now. Please try again in a moment." + ) + ) .finally(() => setLoading(false)); }, [selectedAccount]); @@ -39,30 +54,61 @@ export default function PRMetrics() { fetchMetrics(); }, [fetchMetrics]); + const isStale = (createdAt: string) => { + const createdDate = new Date(createdAt); + const now = new Date(); + + const diffTime = now.getTime() - createdDate.getTime(); + const diffDays = diffTime / (1000 * 60 * 60 * 24); + + return diffDays > staleDays; + }; + + const stalePRs = + metrics?.prs.filter( + (pr) => pr.state === "open" && isStale(pr.created_at) + ) || []; + 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 Review Time", + value: `${metrics.avgReviewHours}h`, + }, + { + label: "Avg First Review", + value: + metrics.avgFirstReviewHours === null + ? "—" + : metrics.avgFirstReviewHours < 24 + ? `${metrics.avgFirstReviewHours}h` + : `${Math.round((metrics.avgFirstReviewHours / 24) * 10) / 10}d`, + }, { label: "Merge Rate", value: metrics.mergeRate }, ] : []; return (
-

PR Analytics

+

+ PR Analytics +

+ {loading ? ( -
+
{[1, 2, 3, 4].map((i) => (
))}
) : error ? (

{error}

+
) : ( -
- {stats.map((stat) => ( -
+
+
+ {stalePRs.length} PRs stale > {staleDays} days +
+ + +
+ +
+ {stats.map((stat) => ( +
+
+ {stat.value} +
+ +
+ {stat.label} +
+
+ ))} +
+ + {stalePRs.length > 0 && ( +
+

+ Stale Pull Requests +

+ +
+ {stalePRs.map((pr) => ( + + + {pr.title} + + + + Stale + + + ))}
-
{stat.label}
- ))} -
+ )} + )}
); -} +} \ No newline at end of file From e6bafb7002226bc4b074dbe2e1faa11e73c8b01e Mon Sep 17 00:00:00 2001 From: Maniska Date: Tue, 19 May 2026 20:13:15 +0530 Subject: [PATCH 2/3] Add stale PR indicators with configurable thresholds --- src/components/PRMetrics.tsx | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index b638f5c5..62d71b07 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -19,6 +19,18 @@ interface PullRequest { state: string; } +function formatReviewCycle(hours: number | null): string { + if (hours === null) { + return "—"; + } + + if (hours < 24) { + return `${hours}h`; + } + + return `${Math.round((hours / 24) * 10) / 10}d`; +} + export default function PRMetrics() { const { selectedAccount } = useAccount(); @@ -73,18 +85,11 @@ export default function PRMetrics() { ? [ { label: "Open PRs", value: metrics.open }, { label: "Merged (30d)", value: metrics.merged }, - { - label: "Avg Review Time", - value: `${metrics.avgReviewHours}h`, - }, + { label: "Avg Review Time", value: `${metrics.avgReviewHours}h` }, { label: "Avg First Review", - value: - metrics.avgFirstReviewHours === null - ? "—" - : metrics.avgFirstReviewHours < 24 - ? `${metrics.avgFirstReviewHours}h` - : `${Math.round((metrics.avgFirstReviewHours / 24) * 10) / 10}d`, + value: formatReviewCycle(metrics.avgFirstReviewHours), + title: "Average time from PR open to first review comment or approval", }, { label: "Merge Rate", value: metrics.mergeRate }, ] @@ -140,6 +145,7 @@ export default function PRMetrics() {
{stat.value} From b74cd5ee10d23205e68488c40c92412d1cf08dca Mon Sep 17 00:00:00 2001 From: Maniska Date: Tue, 19 May 2026 20:19:15 +0530 Subject: [PATCH 3/3] Add stale PR indicators with configurable thresholds --- src/app/api/metrics/prs/route.ts | 98 ++++++++---------- src/components/PRMetrics.tsx | 166 +++++++++++++++---------------- 2 files changed, 122 insertions(+), 142 deletions(-) diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index daa03593..200d8510 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -10,15 +10,12 @@ import { GITHUB_API } from "@/lib/github"; import { supabaseAdmin } from "@/lib/supabase"; export const dynamic = "force-dynamic"; - interface PullRequest { title: string; created_at: string; html_url: string; state: string; -} - -interface PRMetricsBase { +}interface PRMetricsBase { open: number; merged: number; total: number; @@ -50,7 +47,6 @@ interface ReviewCommentEvent { function getRepoFullName(repositoryUrl: string): string | null { const marker = "/repos/"; const index = repositoryUrl.indexOf(marker); - return index >= 0 ? repositoryUrl.slice(index + marker.length) : null; } @@ -77,22 +73,15 @@ async function fetchFirstReviewTimestamp( Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", }; - const [reviewsRes, commentsRes] = await Promise.all([ - fetch( - `${GITHUB_API}/repos/${repo}/pulls/${pr.number}/reviews?per_page=100`, - { - headers, - cache: "no-store", - } - ), - fetch( - `${GITHUB_API}/repos/${repo}/pulls/${pr.number}/comments?per_page=100`, - { - headers, - cache: "no-store", - } - ), + fetch(`${GITHUB_API}/repos/${repo}/pulls/${pr.number}/reviews?per_page=100`, { + headers, + cache: "no-store", + }), + fetch(`${GITHUB_API}/repos/${repo}/pulls/${pr.number}/comments?per_page=100`, { + headers, + cache: "no-store", + }), ]); if (!reviewsRes.ok || !commentsRes.ok) { @@ -121,7 +110,6 @@ async function getAverageFirstReviewHours( } const openedAt = new Date(pr.created_at).getTime(); - if (Number.isNaN(openedAt) || firstReviewAt < openedAt) { return null; } @@ -129,7 +117,6 @@ async function getAverageFirstReviewHours( return (firstReviewAt - openedAt) / 3600000; }) ); - const validDurations = reviewedPrs.filter( (value): value is number => typeof value === "number" ); @@ -146,6 +133,8 @@ async function getAverageFirstReviewHours( } async function fetchPRMetrics(token: string): Promise { + + const searchRes = await fetch( `${GITHUB_API}/search/issues?q=type:pr+author:@me&sort=updated&order=desc&per_page=100`, { @@ -158,28 +147,25 @@ async function fetchPRMetrics(token: string): Promise { throw new Error("GitHub API error"); } - const data = (await searchRes.json()) as { - total_count: number; - items: PullRequestSearchItem[]; - }; + const data = (await searchRes.json()) as { + total_count: number; + items: PullRequestSearchItem[]; +}; const open = data.items.filter((pr) => pr.state === "open").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 + // as truly merged so the dashboard does not inflate the merged count. const merged = data.items.filter( (pr) => pr.pull_request?.merged_at != null ).length; + // Average review time: use only actually merged PRs so we measure the time + // from open to merge, not open to close-without-merge. const mergedPRs = data.items.filter( (pr) => pr.pull_request?.merged_at != null ); - - const prs = data.items.map((pr) => ({ - title: pr.title, - created_at: pr.created_at, - html_url: pr.html_url, - state: pr.state, - })); - const avgReviewMs = mergedPRs.length > 0 ? mergedPRs.reduce( @@ -190,23 +176,33 @@ async function fetchPRMetrics(token: string): Promise { 0 ) / mergedPRs.length : 0; - + const prs = data.items.map((pr) => ({ + title: pr.title, + created_at: pr.created_at, + html_url: pr.html_url, + state: pr.state, +})); + + // Use the number of fetched items as the denominator for mergeRate. + // data.total_count is the all-time GitHub total (potentially thousands) + // while data.items is capped at 100, so dividing merged/total_count + // produces a near-zero rate for any active user. The fetched sample + // (open + merged + closed-without-merge) is the correct base. const sampleTotal = data.items.length; - const avgFirstReviewHours = await getAverageFirstReviewHours( token, data.items ); - return { - open, - merged, - total: data.total_count, - avgReviewHours: Math.round(avgReviewMs / 3600000), - avgFirstReviewHours, - mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0, - prs, - }; +return { + open, + merged, + total: data.total_count, + avgReviewHours: Math.round(avgReviewMs / 3600000), + avgFirstReviewHours, + mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0, + prs, +}; } function formatPRMetrics(metrics: PRMetricsBase) { @@ -216,17 +212,16 @@ function formatPRMetrics(metrics: PRMetricsBase) { total: metrics.total, avgReviewHours: metrics.avgReviewHours, avgFirstReviewHours: metrics.avgFirstReviewHours, - prs: metrics.prs, mergeRate: metrics.total > 0 ? `${Math.round(metrics.mergeRate * 100)}%` : "0%", + prs: metrics.prs, }; } export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -236,7 +231,6 @@ export async function GET(req: NextRequest) { if (!accountId) { try { const result = await fetchPRMetrics(session.accessToken); - return Response.json(formatPRMetrics(result)); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); @@ -274,16 +268,13 @@ export async function GET(req: NextRequest) { const merged = mergeMetrics(results, (a, b) => { const total = a.total + b.total; const mergedCount = a.merged + b.merged; - const avgReviewHours = total > 0 ? (a.avgReviewHours * a.total + b.avgReviewHours * b.total) / total : 0; - const reviewedTotal = (a.avgFirstReviewHours === null ? 0 : a.total) + (b.avgFirstReviewHours === null ? 0 : b.total); - const avgFirstReviewHours = reviewedTotal > 0 ? ((a.avgFirstReviewHours ?? 0) * a.total + @@ -293,6 +284,7 @@ export async function GET(req: NextRequest) { return { open: a.open + b.open, + prs: [...a.prs, ...b.prs], merged: mergedCount, total, avgReviewHours: Math.round(avgReviewHours * 10) / 10, @@ -302,7 +294,6 @@ export async function GET(req: NextRequest) { : Math.round(avgFirstReviewHours * 10) / 10, mergeRate: total > 0 ? Math.round((mergedCount / total) * 100) / 100 : 0, - prs: [...a.prs, ...b.prs], }; }); @@ -324,9 +315,8 @@ export async function GET(req: NextRequest) { try { const result = await fetchPRMetrics(token); - return Response.json(formatPRMetrics(result)); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } -} \ No newline at end of file +} diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index 62d71b07..2a752a9b 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -11,7 +11,6 @@ interface PRData { mergeRate: string; prs: PullRequest[]; } - interface PullRequest { title: string; created_at: string; @@ -33,12 +32,10 @@ function formatReviewCycle(hours: number | null): string { export default function PRMetrics() { const { selectedAccount } = useAccount(); - const [metrics, setMetrics] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [staleDays, setStaleDays] = useState(7); - +const [staleDays, setStaleDays] = useState(7); const fetchMetrics = useCallback(() => { setLoading(true); setError(null); @@ -54,11 +51,7 @@ export default function PRMetrics() { return r.json(); }) .then((data: PRData) => setMetrics(data)) - .catch(() => - setError( - "We couldn't load your PR analytics right now. Please try again in a moment." - ) - ) + .catch(() => setError("We couldn't load your PR analytics right now. Please try again in a moment.")) .finally(() => setLoading(false)); }, [selectedAccount]); @@ -67,19 +60,19 @@ export default function PRMetrics() { }, [fetchMetrics]); const isStale = (createdAt: string) => { - const createdDate = new Date(createdAt); - const now = new Date(); + const createdDate = new Date(createdAt); + const now = new Date(); - const diffTime = now.getTime() - createdDate.getTime(); - const diffDays = diffTime / (1000 * 60 * 60 * 24); + const diffTime = now.getTime() - createdDate.getTime(); - return diffDays > staleDays; - }; + const diffDays = diffTime / (1000 * 60 * 60 * 24); - const stalePRs = - metrics?.prs.filter( - (pr) => pr.state === "open" && isStale(pr.created_at) - ) || []; + return diffDays > staleDays; +}; +const stalePRs = + metrics?.prs.filter( + (pr) => pr.state === "open" && isStale(pr.created_at) + ) || []; const stats = metrics ? [ @@ -97,23 +90,20 @@ export default function PRMetrics() { return (
-

- PR Analytics -

- +

PR Analytics

{loading ? ( -
+ +
{[1, 2, 3, 4].map((i) => (
))}
) : error ? (

{error}

-
- ) : ( - <> -
-
- {stalePRs.length} PRs stale > {staleDays} days -
- - +) : ( + <> +
+
+ {stalePRs.length} PRs stale > {staleDays} days +
+ + +
+ +
+ {stats.map((stat) => ( +
+
+ {stat.value}
-
- {stats.map((stat) => ( -
-
- {stat.value} -
- -
- {stat.label} -
-
- ))} +
+ {stat.label}
+
+ ))} +
- {stalePRs.length > 0 && ( -
-

- Stale Pull Requests -

- -
- {stalePRs.map((pr) => ( - - - {pr.title} - - - - Stale - - - ))} -
-
- )} - - )} + {stalePRs.length > 0 && ( +
+

+ Stale Pull Requests +

+ +
+ {stalePRs.map((pr) => ( + + + {pr.title} + + + + Stale + + + ))} +
+
+ )} + +)}
); -} \ No newline at end of file +}