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
200 changes: 180 additions & 20 deletions src/app/api/metrics/prs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,133 @@ import { GITHUB_API } from "@/lib/github";
import { supabaseAdmin } from "@/lib/supabase";

export const dynamic = "force-dynamic";

interface PRMetricsBase {
interface PullRequest {
title: string;
created_at: string;
html_url: string;
state: string;
}interface PRMetricsBase {
open: number;
merged: number;
total: number;
avgReviewHours: number;
avgFirstReviewHours: number | null;
mergeRate: number;
prs: PullRequest[];
}

interface PullRequestSearchItem {
title: string;
state: string;
created_at: string;
closed_at: string | null;
number: number;
repository_url: string;
html_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,
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&per_page=100`,
`${GITHUB_API}/search/issues?q=type:pr+author:@me&sort=updated&order=desc&per_page=100`,
{
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
Expand All @@ -32,33 +147,62 @@ async function fetchPRMetrics(token: string): Promise<PRMetricsBase> {
throw new Error("GitHub API error");
}

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

const open = data.items.filter((pr) => pr.state === "open").length;
const merged = data.items.filter((pr) => pr.state === "closed").length;

const closedPRs = data.items.filter((pr) => pr.closed_at);
// 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;

// 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 =
closedPRs.length > 0
? closedPRs.reduce(
mergedPRs.length > 0
? mergedPRs.reduce(
(sum, pr) =>
sum +
(new Date(pr.closed_at!).getTime() -
(new Date(pr.pull_request!.merged_at!).getTime() -
new Date(pr.created_at).getTime()),
0
) / closedPRs.length
) / mergedPRs.length
: 0;
const prs = data.items.map((pr) => ({
title: pr.title,
created_at: pr.created_at,
html_url: pr.html_url,
state: pr.state,
}));

return {
open,
merged,
total: data.total_count,
avgReviewHours: Math.round(avgReviewMs / 3600000),
mergeRate: data.total_count > 0 ? merged / data.total_count : 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,
prs,
};
}

function formatPRMetrics(metrics: PRMetricsBase) {
Expand All @@ -67,10 +211,12 @@ function formatPRMetrics(metrics: PRMetricsBase) {
merged: metrics.merged,
total: metrics.total,
avgReviewHours: metrics.avgReviewHours,
avgFirstReviewHours: metrics.avgFirstReviewHours,
mergeRate:
metrics.total > 0
? `${Math.round(metrics.mergeRate * 100)}%`
: "0%",
prs: metrics.prs,
};
}

Expand Down Expand Up @@ -126,12 +272,26 @@ 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,
prs: [...a.prs, ...b.prs],
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,
};
Expand Down
Loading