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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

267 changes: 192 additions & 75 deletions src/app/api/metrics/prs/route.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import {
getAccountToken,
getAllAccounts,
mergeMetrics,
} from "@/lib/github-accounts";
import { getAccountToken, getAllAccounts } from "@/lib/github-accounts";
import { GITHUB_API } from "@/lib/github";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
metricsCacheKey,
withMetricsCache,
} from "@/lib/metrics-cache";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";
Expand All @@ -26,6 +21,15 @@ interface PRMetricsBase {
avgReviewHours: number;
avgFirstReviewHours: number | null;
mergeRate: number;
avgCycleTime: number;
weeklyTrend: { week: string; avgHours: number }[];
slowestRepos: { repo: string; avgHours: number }[];
}

function getWeekLabel(dateStr: string): string {
const date = new Date(dateStr);
const week = Math.floor(date.getDate() / 7) + 1;
return `${date.toLocaleString("default", { month: "short" })} W${week}`;
}

interface PullRequestSearchItem {
Expand All @@ -45,6 +49,22 @@ interface ReviewCommentEvent {
created_at?: string | null;
}

interface GraphQLPullRequestNode {
createdAt: string;
reviews: {
nodes: { submittedAt: string }[];
};
repository: { nameWithOwner: string };
}

interface GraphQLSearchResponse {
data?: {
search?: {
nodes?: GraphQLPullRequestNode[];
};
};
}

function getRepoFullName(repositoryUrl: string): string | null {
const marker = "/repos/";
const index = repositoryUrl.indexOf(marker);
Expand Down Expand Up @@ -142,9 +162,7 @@ async function fetchPRMetrics(token: string): Promise<PRMetricsBase> {
}
);

if (!searchRes.ok) {
throw new Error("GitHub API error");
}
if (!searchRes.ok) throw new Error("GitHub API error");

const data = (await searchRes.json()) as {
total_count: number;
Expand All @@ -153,45 +171,119 @@ async function fetchPRMetrics(token: string): Promise<PRMetricsBase> {

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;

// 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 avgReviewMs =
mergedPRs.length > 0
? mergedPRs.reduce(
(sum, pr) =>
sum +
(new Date(pr.pull_request!.merged_at!).getTime() -
(new Date(pr.closed_at!).getTime() -
new Date(pr.created_at).getTime()),
0
) / 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);

// GraphQL for review cycle time
const ninetyDaysAgo = new Date();
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
const since = ninetyDaysAgo.toISOString().split("T")[0];

const query = `
query {
search(query: "type:pr reviewed-by:@me created:>${since}", type: ISSUE, first: 100) {
nodes {
... on PullRequest {
createdAt
reviews(first: 1) {
nodes {
submittedAt
}
}
repository { nameWithOwner }
}
}
}
}
`;

const gqlRes = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query }),
cache: "no-store",
});

const gqlJson = (await gqlRes.json()) as GraphQLSearchResponse;
const prs = gqlJson.data?.search?.nodes ?? [];

const reviewedPRs = prs.filter(
(pr) => pr.reviews?.nodes && pr.reviews.nodes.length > 0
);

const cycleTimes = reviewedPRs.map((pr) => ({
hours: Math.round(
(new Date(pr.reviews.nodes[0].submittedAt).getTime() -
new Date(pr.createdAt).getTime()) /
3600000
),
week: getWeekLabel(pr.createdAt),
repo: pr.repository.nameWithOwner,
}));

const avgCycleTime =
cycleTimes.length > 0
? Math.round(
cycleTimes.reduce((sum, ct) => sum + ct.hours, 0) /
cycleTimes.length
)
: 0;

const weeklyMap: Record<string, number[]> = {};
cycleTimes.forEach((ct) => {
if (!weeklyMap[ct.week]) weeklyMap[ct.week] = [];
weeklyMap[ct.week].push(ct.hours);
});

const weeklyTrend = Object.entries(weeklyMap).map(([week, times]) => ({
week,
avgHours: Math.round(
times.reduce((a, b) => a + b, 0) / times.length
),
}));

const repoMap: Record<string, number[]> = {};
cycleTimes.forEach((ct) => {
if (!repoMap[ct.repo]) repoMap[ct.repo] = [];
repoMap[ct.repo].push(ct.hours);
});

const slowestRepos = Object.entries(repoMap)
.map(([repo, times]) => ({
repo,
avgHours: Math.round(
times.reduce((a, b) => a + b, 0) / times.length
),
}))
.sort((a, b) => b.avgHours - a.avgHours)
.slice(0, 3);

return {
open,
merged,
Expand All @@ -200,6 +292,9 @@ async function fetchPRMetrics(token: string): Promise<PRMetricsBase> {
avgReviewHours: Math.round(avgReviewMs / 3600000),
avgFirstReviewHours,
mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0,
avgCycleTime,
weeklyTrend,
slowestRepos,
};
}

Expand Down Expand Up @@ -231,6 +326,9 @@ function formatPRMetrics(metrics: PRMetricsBase) {
metrics.total > 0
? `${Math.round(metrics.mergeRate * 100)}%`
: "0%",
avgCycleTime: metrics.avgCycleTime,
weeklyTrend: metrics.weeklyTrend,
slowestRepos: metrics.slowestRepos,
};
}

Expand Down Expand Up @@ -266,63 +364,82 @@ export async function GET(req: NextRequest) {
}

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, { 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,
try {
const allAccounts = await getAllAccounts(userRow.id);

const metricsPromises = allAccounts.map(async (acc) => {
const token = acc.github_id === session.githubId
? session.accessToken
: await getAccountToken(userRow.id, acc.github_id);
if (!token) return null;
return fetchCachedPRMetrics(token, { bypass, userId: acc.github_id });
});

const resultsRaw = await Promise.allSettled(metricsPromises);
const results = resultsRaw
.filter((r): r is PromiseFulfilledResult<PRMetricsBase> => r.status === "fulfilled" && r.value !== null)
.map(r => r.value);

if (results.length === 0) {
return Response.json({ error: "No accounts found" }, { status: 404 });
}

const combinedTotal = results.reduce((sum, r) => sum + r.total, 0);
const combinedMerged = results.reduce((sum, r) => sum + r.merged, 0);
const combinedClosed = results.reduce((sum, r) => sum + r.closed, 0);
const combinedOpen = results.reduce((sum, r) => sum + r.open, 0);

const avgReviewHours = combinedTotal > 0
? results.reduce((sum, r) => sum + (r.avgReviewHours * r.total), 0) / combinedTotal
: 0;

const reviewedTotal = results.reduce((sum, r) => sum + (r.avgFirstReviewHours === null ? 0 : r.total), 0);
const avgFirstReviewHours = reviewedTotal > 0
? results.reduce((sum, r) => sum + ((r.avgFirstReviewHours ?? 0) * r.total), 0) / reviewedTotal
: null;

const combinedCycleTime = results.length > 0
? Math.round(results.reduce((sum, r) => sum + r.avgCycleTime, 0) / results.length)
: 0;

const weeklyTrendsMap: Record<string, number[]> = {};
results.forEach(r => {
r.weeklyTrend.forEach(wt => {
if (!weeklyTrendsMap[wt.week]) weeklyTrendsMap[wt.week] = [];
weeklyTrendsMap[wt.week].push(wt.avgHours);
});
});
const combinedWeeklyTrend = Object.entries(weeklyTrendsMap).map(([week, hoursArray]) => ({
week,
avgHours: Math.round(hoursArray.reduce((a, b) => a + b, 0) / hoursArray.length)
}));

const combinedSlowest = results
.flatMap(r => r.slowestRepos)
.sort((a, b) => b.avgHours - a.avgHours)
.slice(0, 3);

const combinedMetrics: PRMetricsBase = {
open: combinedOpen,
merged: combinedMerged,
closed: combinedClosed,
total: combinedTotal,
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,
avgFirstReviewHours: avgFirstReviewHours === null ? null : Math.round(avgFirstReviewHours * 10) / 10,
mergeRate: combinedTotal > 0 ? combinedMerged / combinedTotal : 0,
avgCycleTime: combinedCycleTime,
weeklyTrend: combinedWeeklyTrend,
slowestRepos: combinedSlowest
};
});

if (!merged) {
return Response.json({ error: "GitHub API error" }, { status: 502 });
return Response.json(formatPRMetrics(combinedMetrics));
} catch {
return Response.json({ error: "Failed to compile combined profile metrics" }, { status: 502 });
}

return Response.json(formatPRMetrics(merged));
}

const token =
accountId === session.githubId
!accountId || accountId === session.githubId
? session.accessToken
: await getAccountToken(userRow.id, accountId);

Expand All @@ -339,4 +456,4 @@ export async function GET(req: NextRequest) {
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}
}
Loading
Loading