diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index 5c9f7554..2a84a186 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -1,5 +1,5 @@ +import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; -import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { getAccountToken, @@ -26,6 +26,8 @@ interface PRMetricsBase { avgReviewHours: number; avgFirstReviewHours: number | null; mergeRate: number; + reviewsGiven: number; + reviewRatio: number; } interface PullRequestSearchItem { @@ -133,7 +135,7 @@ async function getAverageFirstReviewHours( return Math.round(average * 10) / 10; } -async function fetchPRMetrics(token: string): Promise { +async function fetchPRMetrics(token: string, githubLogin: string): Promise { const searchRes = await fetch( `${GITHUB_API}/search/issues?q=type:pr+author:@me&sort=updated&order=desc&per_page=100`, { @@ -152,24 +154,10 @@ async function fetchPRMetrics(token: string): Promise { }; const open = data.items.filter((pr) => pr.state === "open").length; + const merged = data.items.filter((pr) => pr.pull_request?.merged_at != null).length; + const closed = data.items.filter((pr) => pr.state === "closed" && pr.pull_request?.merged_at == null).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; - - // Closed without merging (rejected / abandoned) - const closed = data.items.filter( - (pr) => pr.state === "closed" && 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 mergedPRs = data.items.filter((pr) => pr.pull_request?.merged_at != null); const avgReviewMs = mergedPRs.length > 0 ? mergedPRs.reduce( @@ -181,16 +169,40 @@ async function fetchPRMetrics(token: string): Promise { ) / mergedPRs.length : 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 - ); + const avgFirstReviewHours = await getAverageFirstReviewHours(token, data.items); + + // Fetch your review metrics using GraphQL + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const dateString = thirtyDaysAgo.toISOString().split("T")[0]; + + const gqlQuery = `query { + reviews: search(type: ISSUE, query: "is:pr reviewed-by:${githubLogin} created:>=${dateString}", first: 1) { issueCount } + authored: search(type: ISSUE, query: "is:pr author:${githubLogin} created:>=${dateString}", first: 1) { issueCount } + }`; + + let reviewsGiven = 0; + let reviewRatio = 0; + + try { + const gqlRes = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query: gqlQuery }), + }); + if (gqlRes.ok) { + const gqlJson = await gqlRes.json(); + reviewsGiven = gqlJson.data?.reviews?.issueCount || 0; + const prsAuthored = gqlJson.data?.authored?.issueCount || 0; + reviewRatio = prsAuthored > 0 ? parseFloat((reviewsGiven / prsAuthored).toFixed(2)) : 0; + } + } catch (e) { + // Fail-safe defaults + } return { open, @@ -200,11 +212,14 @@ async function fetchPRMetrics(token: string): Promise { avgReviewHours: Math.round(avgReviewMs / 3600000), avgFirstReviewHours, mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0, + reviewsGiven, + reviewRatio, }; } async function fetchCachedPRMetrics( token: string, + githubLogin: string, cacheContext: { bypass: boolean; userId: string } ): Promise { const key = metricsCacheKey(cacheContext.userId, "prs"); @@ -215,7 +230,7 @@ async function fetchCachedPRMetrics( key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.prs, }, - () => fetchPRMetrics(token) + () => fetchPRMetrics(token, githubLogin) ); } @@ -227,45 +242,88 @@ function formatPRMetrics(metrics: PRMetricsBase) { total: metrics.total, avgReviewHours: metrics.avgReviewHours, avgFirstReviewHours: metrics.avgFirstReviewHours, - mergeRate: - metrics.total > 0 - ? `${Math.round(metrics.mergeRate * 100)}%` - : "0%", + mergeRate: metrics.total > 0 ? `${Math.round(metrics.mergeRate * 100)}%` : "0%", + reviewsGiven: metrics.reviewsGiven, + reviewRatio: metrics.reviewRatio, }; } -export async function GET(req: NextRequest) { - const session = await getServerSession(authOptions); - if (!session?.accessToken) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } +export const GET = async (req: Request) => { + try { + const session = await getServerSession(authOptions); + if (!session?.accessToken || !session?.githubLogin) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } - const accountId = req.nextUrl.searchParams.get("accountId"); - const bypass = isMetricsCacheBypassed(req); + const urlObj = new URL(req.url); + const accountId = urlObj.searchParams.get("accountId"); + const bypass = isMetricsCacheBypassed(req); - if (!accountId) { - try { - const result = await fetchCachedPRMetrics(session.accessToken, { + if (!accountId) { + const result = await fetchCachedPRMetrics(session.accessToken, session.githubLogin, { bypass, userId: session.githubId ?? session.githubLogin ?? "primary", }); - return Response.json(formatPRMetrics(result)); - } catch { - return Response.json({ error: "GitHub API error" }, { status: 502 }); + return NextResponse.json(formatPRMetrics(result)); } - } - if (!session.githubId || !session.githubLogin) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!session.githubId || !session.githubLogin) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } - const userRow = await resolveAppUser(session.githubId, session.githubLogin); + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + if (!userRow) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } - if (!userRow) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } + if (accountId === "combined") { + const accounts = await getAllAccounts( + { + token: session.accessToken, + githubId: session.githubId, + githubLogin: session.githubLogin, + }, + userRow.id + ); + + const results = await Promise.allSettled( + accounts.map((account) => + fetchCachedPRMetrics(account.token, account.githubLogin, { bypass, userId: account.githubId }) + ) + ); + + const merged = mergeMetrics(results, (a, b) => { + const total = a.total + b.total; + const mergedCount = a.merged + b.merged; + const totalReviews = a.reviewsGiven + b.reviewsGiven; + const avgRatio = parseFloat(((a.reviewRatio + b.reviewRatio) / 2).toFixed(2)); + 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, + closed: a.closed + b.closed, + 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, + reviewsGiven: totalReviews, + reviewRatio: avgRatio, + }; + }); + + return NextResponse.json(formatPRMetrics(merged)); + } - if (accountId === "combined") { + // Individual auxiliary account branch logic const accounts = await getAllAccounts( { token: session.accessToken, @@ -275,68 +333,18 @@ export async function GET(req: NextRequest) { userRow.id ); - const results = await Promise.allSettled( - accounts.map((account) => - fetchCachedPRMetrics(account.token, { bypass, userId: account.githubId }) - ) - ); - - const merged = mergeMetrics(results, (a, b) => { - const total = a.total + b.total; - const mergedCount = a.merged + b.merged; - const closedCount = a.closed + b.closed; - 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, - closed: closedCount, - 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, - }; - }); - - if (!merged) { - return Response.json({ error: "GitHub API error" }, { status: 502 }); + const targetAccount = accounts.find((acc) => acc.githubId === accountId); + if (!targetAccount) { + return NextResponse.json({ error: "Account not found" }, { status: 404 }); } - return Response.json(formatPRMetrics(merged)); - } - - const token = - accountId === session.githubId - ? session.accessToken - : await getAccountToken(userRow.id, accountId); - - if (!token) { - return Response.json({ error: "Account not found" }, { status: 404 }); - } - - try { - const result = await fetchCachedPRMetrics(token, { + const result = await fetchCachedPRMetrics(targetAccount.token, targetAccount.githubLogin, { bypass, - userId: accountId === session.githubId ? session.githubId : accountId, + userId: targetAccount.githubId, }); - return Response.json(formatPRMetrics(result)); - } catch { - return Response.json({ error: "GitHub API error" }, { status: 502 }); + return NextResponse.json(formatPRMetrics(result)); + + } catch (error) { + return NextResponse.json({ error: "Internal Error" }, { status: 500 }); } -} +}; \ No newline at end of file diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index f7f92a92..e76082f9 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -1,7 +1,9 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Eye, Percent } from "lucide-react"; import PRStatusDonutChart from "./PRStatusDonutChart"; interface PRData { @@ -11,6 +13,8 @@ interface PRData { avgReviewHours: number; avgFirstReviewHours: number | null; mergeRate: string; + reviewsGiven: number; + reviewRatio: number; } function formatReviewCycle(hours: number | null): string { @@ -91,12 +95,12 @@ export default function PRMetrics() { ) : error ? ( -
+

{error}

@@ -119,6 +123,31 @@ export default function PRMetrics() { ))}
+ {/* Review Participation Metrics Section */} +
+ + + Reviews Given (Last 30 Days) + + + +
{metrics?.reviewsGiven ?? 0}
+

Total pull request reviews submitted

+
+
+ + + + Review Participation Ratio + + + +
{metrics?.reviewRatio ?? 0}x
+

Reviews submitted per authored PR

+
+
+
+ {/* PR status donut chart */} {metrics && (
@@ -136,4 +165,4 @@ export default function PRMetrics() { )}
); -} +} \ No newline at end of file