diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index fb28df2b..200d8510 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -10,18 +10,133 @@ import { GITHUB_API } from "@/lib/github"; import { supabaseAdmin } from "@/lib/supabase"; export const dynamic = "force-dynamic"; - -interface PRMetricsBase { +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", @@ -32,33 +147,62 @@ async function fetchPRMetrics(token: string): Promise { throw new Error("GitHub API error"); } - 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 { + total_count: number; + 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); + // 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 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 prs = data.items.map((pr) => ({ + title: pr.title, + created_at: pr.created_at, + html_url: pr.html_url, + state: pr.state, +})); - return { - open, - merged, - total: data.total_count, - avgReviewHours: Math.round(avgReviewMs / 3600000), - mergeRate: data.total_count > 0 ? merged / data.total_count : 0, - }; + // 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, +}; } function formatPRMetrics(metrics: PRMetricsBase) { @@ -67,10 +211,12 @@ function formatPRMetrics(metrics: PRMetricsBase) { merged: metrics.merged, total: metrics.total, avgReviewHours: metrics.avgReviewHours, + avgFirstReviewHours: metrics.avgFirstReviewHours, mergeRate: metrics.total > 0 ? `${Math.round(metrics.mergeRate * 100)}%` : "0%", + prs: metrics.prs, }; } @@ -126,12 +272,26 @@ export async function GET(req: NextRequest) { 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, + prs: [...a.prs, ...b.prs], 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, }; diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index 990d13f5..2a752a9b 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -7,7 +7,27 @@ 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; +} + +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() { @@ -15,7 +35,7 @@ export default function PRMetrics() { 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); setError(null); @@ -39,11 +59,31 @@ 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 First Review", + value: formatReviewCycle(metrics.avgFirstReviewHours), + title: "Average time from PR open to first review comment or approval", + }, { label: "Merge Rate", value: metrics.mergeRate }, ] : []; @@ -52,6 +92,7 @@ export default function PRMetrics() {

PR Analytics

{loading ? ( +
{[1, 2, 3, 4].map((i) => (
- ) : ( -
- {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) => ( + -
- {stat.value} -
-
{stat.label}
-
+ + {pr.title} + + + + Stale + + ))}
- )} +
+ )} + +)}
); }