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
231 changes: 42 additions & 189 deletions src/app/api/metrics/prs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,7 @@ import {
mergeMetrics,
} 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 @@ -23,147 +16,47 @@ interface PRMetricsBase {
merged: number;
total: number;
avgReviewHours: number;
avgFirstReviewHours: number | null;
mergeRate: number;
reviewsGiven: number;
}

interface PullRequestSearchItem {
state: string;
created_at: string;
closed_at: string | null;
number: number;
repository_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<string | null | undefined>) {
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<number | null> {
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,
async function fetchPRMetrics(token: string, githubLogin: string): Promise<PRMetricsBase> {
const searchRes = await fetch(
`${GITHUB_API}/search/issues?q=type:pr+author:${githubLogin}&per_page=100`,
{
headers: { Authorization: `Bearer ${token}` },
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<number | null> {
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<PRMetricsBase> {
const searchRes = await fetch(
`${GITHUB_API}/search/issues?q=type:pr+author:@me&sort=updated&order=desc&per_page=100`,
const reviewRes = await fetch(
`${GITHUB_API}/search/issues?q=type:pr+reviewed-by:${githubLogin}&per_page=100`,
{
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
}
);

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

const data = (await searchRes.json()) as {
total_count: number;
items: PullRequestSearchItem[];
items: Array<{
state: string;
created_at: string;
closed_at: string | null;
pull_request?: { merged_at: string | null };
}>;
};

const open = data.items.filter((pr) => pr.state === "open").length;
const reviewData = (await reviewRes.json()) as { total_count: number };

// 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;
const open = data.items.filter((pr) => pr.state === "open").length;
const mergedPRs = data.items.filter((pr) => pr.pull_request?.merged_at != null);
const merged = mergedPRs.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(
Expand All @@ -175,83 +68,61 @@ 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
);

return {
open,
merged,
total: data.total_count,
avgReviewHours: Math.round(avgReviewMs / 3600000),
avgFirstReviewHours,
mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0,
reviewsGiven: reviewData.total_count,
};
}

async function fetchCachedPRMetrics(
token: string,
cacheContext: { bypass: boolean; userId: string }
): Promise<PRMetricsBase> {
const key = metricsCacheKey(cacheContext.userId, "prs");

return withMetricsCache(
{
bypass: cacheContext.bypass,
key,
ttlSeconds: METRICS_CACHE_TTL_SECONDS.prs,
},
() => fetchPRMetrics(token)
);
}

function formatPRMetrics(metrics: PRMetricsBase) {
const ratio = metrics.total > 0 ? (metrics.reviewsGiven / metrics.total).toFixed(2) : "0.00";

return {
open: metrics.open,
merged: metrics.merged,
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: ratio,
};
}

export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.accessToken) {

if (!session?.accessToken || !session?.githubLogin) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const accountId = req.nextUrl.searchParams.get("accountId");
const bypass = isMetricsCacheBypassed(req);
const requestedUsername = req.nextUrl.searchParams.get("username");
const username = requestedUsername === session.githubLogin ? requestedUsername : session.githubLogin;

if (!accountId) {
try {
const result = await fetchCachedPRMetrics(session.accessToken, {
bypass,
userId: session.githubId ?? session.githubLogin ?? "primary",
});
const result = await fetchPRMetrics(session.accessToken, username);
return Response.json(formatPRMetrics(result));
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}

if (!session.githubId || !session.githubLogin) {
if (!session.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const userRow = await resolveAppUser(session.githubId, session.githubLogin);
const { data: userRow } = await supabaseAdmin
.from("users")
.select("id")
.eq("github_id", session.githubId)
.single();

if (!userRow) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
Expand All @@ -268,9 +139,7 @@ export async function GET(req: NextRequest) {
);

const results = await Promise.allSettled(
accounts.map((account) =>
fetchCachedPRMetrics(account.token, { bypass, userId: account.githubId })
)
accounts.map((account) => fetchPRMetrics(account.token, account.githubLogin))
);

const merged = mergeMetrics(results, (a, b) => {
Expand All @@ -280,27 +149,14 @@ 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,
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,
mergeRate: total > 0 ? Math.round((mergedCount / total) * 100) / 100 : 0,
reviewsGiven: a.reviewsGiven + b.reviewsGiven,
};
});

Expand All @@ -321,12 +177,9 @@ export async function GET(req: NextRequest) {
}

try {
const result = await fetchCachedPRMetrics(token, {
bypass,
userId: accountId === session.githubId ? session.githubId : accountId,
});
const result = await fetchPRMetrics(token, username);
return Response.json(formatPRMetrics(result));
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}
}
Loading