Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 123 additions & 115 deletions src/app/api/metrics/prs/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -26,6 +26,8 @@ interface PRMetricsBase {
avgReviewHours: number;
avgFirstReviewHours: number | null;
mergeRate: number;
reviewsGiven: number;
reviewRatio: number;
}

interface PullRequestSearchItem {
Expand Down Expand Up @@ -133,7 +135,7 @@ async function getAverageFirstReviewHours(
return Math.round(average * 10) / 10;
}

async function fetchPRMetrics(token: string): Promise<PRMetricsBase> {
async function fetchPRMetrics(token: string, githubLogin: string): Promise<PRMetricsBase> {
const searchRes = await fetch(
`${GITHUB_API}/search/issues?q=type:pr+author:@me&sort=updated&order=desc&per_page=100`,
{
Expand All @@ -152,24 +154,10 @@ async function fetchPRMetrics(token: string): Promise<PRMetricsBase> {
};

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(
Expand All @@ -181,16 +169,40 @@ async function fetchPRMetrics(token: string): Promise<PRMetricsBase> {
) / 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,
Expand All @@ -200,11 +212,14 @@ async function fetchPRMetrics(token: string): Promise<PRMetricsBase> {
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<PRMetricsBase> {
const key = metricsCacheKey(cacheContext.userId, "prs");
Expand All @@ -215,7 +230,7 @@ async function fetchCachedPRMetrics(
key,
ttlSeconds: METRICS_CACHE_TTL_SECONDS.prs,
},
() => fetchPRMetrics(token)
() => fetchPRMetrics(token, githubLogin)
);
}

Expand All @@ -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,
Expand All @@ -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 });
}
}
};
37 changes: 33 additions & 4 deletions src/components/PRMetrics.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -11,6 +13,8 @@ interface PRData {
avgReviewHours: number;
avgFirstReviewHours: number | null;
mergeRate: string;
reviewsGiven: number;
reviewRatio: number;
}

function formatReviewCycle(hours: number | null): string {
Expand Down Expand Up @@ -91,12 +95,12 @@ export default function PRMetrics() {
<div className="h-[270px] rounded-lg bg-[var(--card-muted)] animate-pulse" aria-hidden="true" />
</div>
) : error ? (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-400">
<div className="rounded-lg border border-[var(--destructive)]/20 bg-[var(--destructive)]/10 p-4 text-sm text-[var(--destructive)]">
<p>{error}</p>
<button
type="button"
onClick={fetchMetrics}
className="mt-3 rounded-md border border-red-500/30 px-3 py-1.5 text-xs font-medium text-red-300 transition-colors hover:bg-red-500/10"
className="mt-3 rounded-md border border-[var(--destructive)]/30 px-3 py-1.5 text-xs font-medium text-[var(--destructive)] transition-colors hover:bg-[var(--destructive)]/10"
>
Try again
</button>
Expand All @@ -119,6 +123,31 @@ export default function PRMetrics() {
))}
</div>

{/* Review Participation Metrics Section */}
<div className="grid gap-4 md:grid-cols-2">
<Card className="bg-[var(--control)] border-none text-[var(--card-foreground)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-[var(--card-foreground)]">Reviews Given (Last 30 Days)</CardTitle>
<Eye className="h-4 w-4 text-[var(--muted-foreground)]" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metrics?.reviewsGiven ?? 0}</div>
<p className="text-xs text-[var(--muted-foreground)]">Total pull request reviews submitted</p>
</CardContent>
</Card>

<Card className="bg-[var(--control)] border-none text-[var(--card-foreground)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-[var(--card-foreground)]">Review Participation Ratio</CardTitle>
<Percent className="h-4 w-4 text-[var(--muted-foreground)]" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metrics?.reviewRatio ?? 0}x</div>
<p className="text-xs text-[var(--muted-foreground)]">Reviews submitted per authored PR</p>
</CardContent>
</Card>
</div>

{/* PR status donut chart */}
{metrics && (
<div>
Expand All @@ -136,4 +165,4 @@ export default function PRMetrics() {
)}
</div>
);
}
}
Loading