Skip to content
Merged
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
117 changes: 103 additions & 14 deletions src/app/api/metrics/prs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import {
getAccountToken,
getAllAccounts,
mergeMetrics,
} from "@/lib/github-accounts";
Expand All @@ -17,12 +16,17 @@ import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";

const STALE_THRESHOLD_OPTIONS = [7, 14, 30] as const;
const DEFAULT_STALE_THRESHOLD_DAYS = 7;

interface ReviewMetrics {
totalReviews: number;
approvalRate: string;
avgFirstReviewHours: number | null;
topRepos: { repo: string; count: number }[];
}

interface PRMetricsBase {
open: number;
merged: number;
Expand All @@ -31,6 +35,9 @@ interface PRMetricsBase {
avgReviewHours: number;
avgFirstReviewHours: number | null;
mergeRate: number;
staleCount: number;
staleThresholdDays: number;
staleSearchUrl: string | null;
}

interface PullRequestSearchItem {
Expand Down Expand Up @@ -72,6 +79,35 @@ function getEarliestTimestamp(values: Array<string | null | undefined>) {
return timestamps.length > 0 ? Math.min(...timestamps) : null;
}

function getStaleThresholdDays(req: NextRequest): number {
const requestedThreshold = Number(
req.nextUrl.searchParams.get("staleThresholdDays") ??
DEFAULT_STALE_THRESHOLD_DAYS
);

return STALE_THRESHOLD_OPTIONS.includes(
requestedThreshold as (typeof STALE_THRESHOLD_OPTIONS)[number]
)
? requestedThreshold
: DEFAULT_STALE_THRESHOLD_DAYS;
}

function getStaleSearchUrl(
githubLogin: string | null | undefined,
staleCutoffMs: number
): string | null {
if (!githubLogin) {
return null;
}

const cutoffDate = new Date(staleCutoffMs).toISOString().slice(0, 10);
const params = new URLSearchParams({
q: `is:pr is:open author:${githubLogin} created:<${cutoffDate}`,
});

return `https://github.com/pulls?${params.toString()}`;
}

async function fetchFirstReviewTimestamp(
token: string,
pr: PullRequestSearchItem
Expand Down Expand Up @@ -145,7 +181,10 @@ async function getAverageFirstReviewHours(
return Math.round(average * 10) / 10;
}

async function fetchPRMetrics(token: string): Promise<PRMetricsBase> {
async function fetchPRMetrics(
token: string,
options: { staleThresholdDays: number; githubLogin?: string | null }
): Promise<PRMetricsBase> {
const searchRes = await fetch(
`${GITHUB_API}/search/issues?q=type:pr+author:@me&sort=updated&order=desc&per_page=100`,
{
Expand All @@ -164,6 +203,16 @@ async function fetchPRMetrics(token: string): Promise<PRMetricsBase> {
};

const open = data.items.filter((pr) => pr.state === "open").length;
const staleCutoffMs =
Date.now() - options.staleThresholdDays * 24 * 60 * 60 * 1000;
const staleCount = data.items.filter((pr) => {
if (pr.state !== "open") {
return false;
}

const createdAt = new Date(pr.created_at).getTime();
return !Number.isNaN(createdAt) && createdAt < staleCutoffMs;
}).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
Expand Down Expand Up @@ -212,6 +261,9 @@ async function fetchPRMetrics(token: string): Promise<PRMetricsBase> {
avgReviewHours: Math.round(avgReviewMs / 3600000),
avgFirstReviewHours,
mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0,
staleCount,
staleThresholdDays: options.staleThresholdDays,
staleSearchUrl: getStaleSearchUrl(options.githubLogin, staleCutoffMs),
};
}

Expand Down Expand Up @@ -317,22 +369,36 @@ async function fetchGitLabMRMetrics(token: string): Promise<PRMetricsBase> {
avgReviewHours: Math.round(avgReviewMs / 3600000),
avgFirstReviewHours: null,
mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0,
staleCount: 0,
staleThresholdDays: DEFAULT_STALE_THRESHOLD_DAYS,
staleSearchUrl: null,
};
}

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

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

Expand Down Expand Up @@ -362,6 +428,9 @@ function formatPRMetrics(metrics: PRMetricsBase) {
total: metrics.total,
avgReviewHours: metrics.avgReviewHours,
avgFirstReviewHours: metrics.avgFirstReviewHours,
staleCount: metrics.staleCount,
staleThresholdDays: metrics.staleThresholdDays,
staleSearchUrl: metrics.staleSearchUrl,
mergeRate:
metrics.total > 0
? `${Math.round(metrics.mergeRate * 100)}%`
Expand Down Expand Up @@ -473,6 +542,7 @@ export async function GET(req: NextRequest) {

const accountId = req.nextUrl.searchParams.get("accountId");
const bypass = isMetricsCacheBypassed(req);
const staleThresholdDays = getStaleThresholdDays(req);
const gitlabCacheContext = {
bypass,
userId: session.githubId ?? session.githubLogin ?? "primary",
Expand All @@ -482,6 +552,8 @@ export async function GET(req: NextRequest) {
try {
const result = await fetchCachedPRMetrics(session.accessToken, {
bypass,
githubLogin: session.githubLogin,
staleThresholdDays,
userId: session.githubId ?? session.githubLogin ?? "primary",
});
const [gitlab, reviews] = await Promise.all([
Expand Down Expand Up @@ -516,7 +588,12 @@ export async function GET(req: NextRequest) {

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

Expand Down Expand Up @@ -548,6 +625,9 @@ export async function GET(req: NextRequest) {
avgFirstReviewHours === null
? null
: Math.round(avgFirstReviewHours * 10) / 10,
staleCount: a.staleCount + b.staleCount,
staleThresholdDays,
staleSearchUrl: null,
mergeRate:
total > 0 ? Math.round((mergedCount / total) * 100) / 100 : 0,
};
Expand All @@ -563,23 +643,32 @@ export async function GET(req: NextRequest) {
return Response.json({ ...formatPRMetricsResponse(merged, gitlab), reviews });
}

const token =
accountId === session.githubId
? session.accessToken
: await getAccountToken(userRow.id, accountId);
const accounts = await getAllAccounts(
{
token: session.accessToken,
githubId: session.githubId,
githubLogin: session.githubLogin,
},
userRow.id
);
const selectedAccount = accounts.find(
(account) => account.githubId === accountId
);

if (!token) {
if (!selectedAccount) {
return Response.json({ error: "Account not found" }, { status: 404 });
}

try {
const result = await fetchCachedPRMetrics(token, {
const result = await fetchCachedPRMetrics(selectedAccount.token, {
bypass,
userId: accountId === session.githubId ? session.githubId : accountId,
githubLogin: selectedAccount.githubLogin,
staleThresholdDays,
userId: selectedAccount.githubId,
});
const [gitlab, reviews] = await Promise.all([
getGitLabMetrics(gitlabToken, gitlabCacheContext),
fetchReviewMetrics(token).catch(() => null),
fetchReviewMetrics(selectedAccount.token).catch(() => null),
]);
return Response.json({ ...formatPRMetricsResponse(result, gitlab), reviews });
} catch {
Expand Down
Loading
Loading