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
172 changes: 168 additions & 4 deletions src/app/api/metrics/prs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ interface ReviewCommentEvent {
created_at?: string | null;
}

interface GitLabMergeRequestItem {
state: string;
created_at: string;
merged_at?: string | null;
closed_at?: string | null;
}

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

async function fetchGitLabMRMetrics(token: string): Promise<PRMetricsBase> {
const perPage = 100;
let page = 1;
let totalPages: number | null = null;
let totalCount: number | null = null;
const items: GitLabMergeRequestItem[] = [];

while (page > 0) {
const url = new URL("https://gitlab.com/api/v4/merge_requests");
url.searchParams.set("scope", "created_by_me");
url.searchParams.set("state", "all");
url.searchParams.set("per_page", String(perPage));
url.searchParams.set("page", String(page));

const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});

if (!response.ok) {
throw new Error("GitLab API error");
}

if (totalCount === null) {
const totalHeader = response.headers.get("x-total");
const parsedTotal = totalHeader ? Number(totalHeader) : NaN;
if (Number.isFinite(parsedTotal)) {
totalCount = parsedTotal;
}
}

if (totalPages === null) {
const totalPagesHeader = response.headers.get("x-total-pages");
const parsedPages = totalPagesHeader ? Number(totalPagesHeader) : NaN;
if (Number.isFinite(parsedPages) && parsedPages > 0) {
totalPages = parsedPages;
}
}

const pageItems = (await response.json()) as GitLabMergeRequestItem[];
if (!Array.isArray(pageItems) || pageItems.length === 0) {
break;
}

items.push(...pageItems);

const nextPage = response.headers.get("x-next-page");
const parsedNext = nextPage && nextPage !== "0" ? Number(nextPage) : NaN;
if (Number.isFinite(parsedNext)) {
page = parsedNext;
continue;
}

if (totalPages !== null && page < totalPages) {
page += 1;
continue;
}

if (pageItems.length === perPage) {
page += 1;
continue;
}

break;
}

const open = items.filter((mr) => mr.state === "opened").length;
const mergedItems = items.filter(
(mr) => mr.state === "merged" && mr.merged_at
);
const merged = mergedItems.length;
const closed = items.filter((mr) => mr.state === "closed").length;

const reviewDurations = mergedItems
.map((mr) => {
const created = new Date(mr.created_at).getTime();
const mergedAt = new Date(mr.merged_at!).getTime();
if (Number.isNaN(created) || Number.isNaN(mergedAt)) {
return null;
}
return mergedAt - created;
})
.filter((value): value is number => typeof value === "number");

const avgReviewMs =
reviewDurations.length > 0
? reviewDurations.reduce((sum, value) => sum + value, 0) /
reviewDurations.length
: 0;

const sampleTotal = items.length;

return {
open,
merged,
closed,
total: totalCount ?? sampleTotal,
avgReviewHours: Math.round(avgReviewMs / 3600000),
avgFirstReviewHours: null,
mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0,
};
}

async function fetchCachedPRMetrics(
token: string,
cacheContext: { bypass: boolean; userId: string }
Expand All @@ -219,6 +331,24 @@ async function fetchCachedPRMetrics(
);
}

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

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

function formatPRMetrics(metrics: PRMetricsBase) {
return {
open: metrics.open,
Expand All @@ -234,22 +364,55 @@ function formatPRMetrics(metrics: PRMetricsBase) {
};
}

function formatPRMetricsResponse(
metrics: PRMetricsBase,
gitlab: PRMetricsBase | null
) {
return {
...formatPRMetrics(metrics),
...(gitlab ? { gitlab: formatPRMetrics(gitlab) } : {}),
};
}

async function getGitLabMetrics(
token: string | undefined,
cacheContext: { bypass: boolean; userId: string }
) {
if (!token) {
return null;
}

try {
return await fetchCachedGitLabMRMetrics(token, cacheContext);
} catch {
return null;
}
}

export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.accessToken) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const gitlabToken =
typeof session.gitlabToken === "string" ? session.gitlabToken : undefined;

const accountId = req.nextUrl.searchParams.get("accountId");
const bypass = isMetricsCacheBypassed(req);
const gitlabCacheContext = {
bypass,
userId: session.githubId ?? session.githubLogin ?? "primary",
};

if (!accountId) {
try {
const result = await fetchCachedPRMetrics(session.accessToken, {
bypass,
userId: session.githubId ?? session.githubLogin ?? "primary",
});
return Response.json(formatPRMetrics(result));
const gitlab = await getGitLabMetrics(gitlabToken, gitlabCacheContext);
return Response.json(formatPRMetricsResponse(result, gitlab));
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
Expand Down Expand Up @@ -317,8 +480,8 @@ export async function GET(req: NextRequest) {
if (!merged) {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}

return Response.json(formatPRMetrics(merged));
const gitlab = await getGitLabMetrics(gitlabToken, gitlabCacheContext);
return Response.json(formatPRMetricsResponse(merged, gitlab));
}

const token =
Expand All @@ -335,7 +498,8 @@ export async function GET(req: NextRequest) {
bypass,
userId: accountId === session.githubId ? session.githubId : accountId,
});
return Response.json(formatPRMetrics(result));
const gitlab = await getGitLabMetrics(gitlabToken, gitlabCacheContext);
return Response.json(formatPRMetricsResponse(result, gitlab));
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
Expand Down
Loading
Loading