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
143 changes: 118 additions & 25 deletions src/app/api/metrics/prs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,51 @@ import { authOptions } from "@/lib/auth";
export const dynamic = "force-dynamic";

const GITHUB_API = "https://api.github.com";
const GITLAB_API = "https://gitlab.com/api/v4";

interface GitHubPRItem {
state: string;
created_at: string;
closed_at: string | null;
}

interface GitHubSearchResponse {
total_count: number;
items: GitHubPRItem[];
}

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

interface ReviewStats {
open: number;
merged: number;
total: number;
reviewMs: number;
reviewCount: number;
}

function buildResponse(stats: ReviewStats) {
const avgReviewHours =
stats.reviewCount > 0
? Math.round(stats.reviewMs / stats.reviewCount / 3600000)
: 0;

return {
open: stats.open,
merged: stats.merged,
total: stats.total,
avgReviewHours,
mergeRate:
stats.total > 0
? `${Math.round((stats.merged / stats.total) * 100)}%`
: "0%",
};
}

export async function GET() {
const session = await getServerSession(authOptions);
Expand All @@ -23,34 +68,82 @@ export async function GET() {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}

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 GitHubSearchResponse;

const githubStats: ReviewStats = {
open: 0,
merged: 0,
total: data.total_count,
reviewMs: 0,
reviewCount: 0,
};

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);
const avgReviewMs =
closedPRs.length > 0
? closedPRs.reduce(
(sum, pr) =>
sum +
(new Date(pr.closed_at!).getTime() -
new Date(pr.created_at).getTime()),
0
) / closedPRs.length
: 0;
for (const pr of data.items) {
if (pr.state === "open") githubStats.open++;
if (pr.state === "closed") githubStats.merged++;
if (pr.closed_at) {
githubStats.reviewMs +=
new Date(pr.closed_at).getTime() - new Date(pr.created_at).getTime();
githubStats.reviewCount++;
}
}

let gitlabStats: ReviewStats | null = null;
const gitlabToken =
typeof session.gitlabToken === "string" ? session.gitlabToken : null;

if (gitlabToken) {
try {
const gitlabRes = await fetch(
`${GITLAB_API}/merge_requests?scope=created_by_me&state=all&per_page=100`,
{
headers: { Authorization: `Bearer ${gitlabToken}` },
cache: "no-store",
}
);

if (gitlabRes.ok) {
const mrs = (await gitlabRes.json()) as GitLabMRItem[];
gitlabStats = {
open: 0,
merged: 0,
total: mrs.length,
reviewMs: 0,
reviewCount: 0,
};

for (const mr of mrs) {
if (mr.state === "opened") gitlabStats.open++;
if (mr.state === "merged") gitlabStats.merged++;

const closedAt = mr.merged_at ?? mr.closed_at;
if (closedAt) {
gitlabStats.reviewMs +=
new Date(closedAt).getTime() - new Date(mr.created_at).getTime();
gitlabStats.reviewCount++;
}
}
}
} catch {
// Non-fatal: keep GitHub-only response if GitLab fails.
}
}

const combinedStats: ReviewStats = gitlabStats
? {
open: githubStats.open + gitlabStats.open,
merged: githubStats.merged + gitlabStats.merged,
total: githubStats.total + gitlabStats.total,
reviewMs: githubStats.reviewMs + gitlabStats.reviewMs,
reviewCount: githubStats.reviewCount + gitlabStats.reviewCount,
}
: githubStats;

return Response.json({
open,
merged,
total: data.total_count,
avgReviewHours: Math.round(avgReviewMs / 3600000),
mergeRate:
data.total_count > 0
? `${Math.round((merged / data.total_count) * 100)}%`
: "0%",
...buildResponse(combinedStats),
sources: {
github: buildResponse(githubStats),
gitlab: gitlabStats ? buildResponse(gitlabStats) : null,
},
});
}
90 changes: 77 additions & 13 deletions src/components/PRMetrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,23 @@

import { useEffect, useState } from "react";

interface PRData {
interface PRSourceMetrics {
open: number;
merged: number;
total: number;
avgReviewHours: number;
mergeRate: string;
}

interface PRMetricsResponse extends PRSourceMetrics {
sources?: {
github?: PRSourceMetrics;
gitlab?: PRSourceMetrics | null;
};
}

export default function PRMetrics() {
const [metrics, setMetrics] = useState<PRData | null>(null);
const [metrics, setMetrics] = useState<PRMetricsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [minutesAgo, setMinutesAgo] = useState(0);
Expand All @@ -25,7 +33,7 @@ export default function PRMetrics() {
if (!r.ok) throw new Error("API error");
return r.json();
})
.then((data: PRData) => setMetrics(data))
.then((data: PRMetricsResponse) => setMetrics(data))
.catch(() => setError("We couldn't load your PR analytics right now. Please try again in a moment."))
.finally(() => {
setLoading(false);
Expand Down Expand Up @@ -54,6 +62,14 @@ export default function PRMetrics() {
]
: [];

const sourceSections =
metrics?.sources?.gitlab && metrics.sources.github
? [
{ label: "GitHub", metrics: metrics.sources.github },
{ label: "GitLab", metrics: metrics.sources.gitlab },
]
: [];

return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-[var(--card-foreground)]">PR Analytics</h2>
Expand All @@ -78,18 +94,66 @@ export default function PRMetrics() {
</button>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.map((stat) => (
<div
key={stat.label}
className="rounded-lg bg-[var(--control)] p-4 text-center"
>
<div className="text-2xl font-bold text-[var(--accent)]">
{stat.value}
<div className="grid gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.map((stat) => (
<div
key={stat.label}
className="rounded-lg bg-[var(--control)] p-4 text-center"
>
<div className="text-2xl font-bold text-[var(--accent)]">
{stat.value}
</div>
<div className="mt-1 text-sm text-[var(--muted-foreground)]">{stat.label}</div>
</div>
))}
</div>

{sourceSections.length > 0 && (
<div className="border-t border-[var(--border)] pt-4">
<div className="text-xs font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
Sources
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{sourceSections.map((section) => (
<div
key={section.label}
className="rounded-lg bg-[var(--control)] p-4"
>
<div className="text-sm font-semibold text-[var(--card-foreground)] mb-3">
{section.label}
</div>
<div className="grid grid-cols-2 gap-3 text-center">
<div>
<div className="text-lg font-semibold text-[var(--accent)]">
{section.metrics?.open ?? 0}
</div>
<div className="text-xs text-[var(--muted-foreground)]">Open</div>
</div>
<div>
<div className="text-lg font-semibold text-[var(--accent)]">
{section.metrics?.merged ?? 0}
</div>
<div className="text-xs text-[var(--muted-foreground)]">Merged</div>
</div>
<div>
<div className="text-lg font-semibold text-[var(--accent)]">
{section.metrics?.avgReviewHours ?? 0}h
</div>
<div className="text-xs text-[var(--muted-foreground)]">Avg Review</div>
</div>
<div>
<div className="text-lg font-semibold text-[var(--accent)]">
{section.metrics?.mergeRate ?? "0%"}
</div>
<div className="text-xs text-[var(--muted-foreground)]">Merge Rate</div>
</div>
</div>
</div>
))}
</div>
<div className="mt-1 text-sm text-[var(--muted-foreground)]">{stat.label}</div>
</div>
))}
)}
</div>
)}
{lastUpdated && (
Expand Down