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
96 changes: 89 additions & 7 deletions src/app/api/metrics/prs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";

interface ReviewMetrics {
totalReviews: number;
approvalRate: string;
avgFirstReviewHours: number | null;
topRepos: { repo: string; count: number }[];
}
interface PRMetricsBase {
open: number;
merged: number;
Expand Down Expand Up @@ -388,7 +393,75 @@ async function getGitLabMetrics(
return null;
}
}
async function fetchReviewMetrics(token: string): Promise<ReviewMetrics> {
const query = `
query {
viewer {
contributionsCollection {
pullRequestReviewContributions(first: 100) {
nodes {
occurredAt
pullRequestReview {
state
pullRequest {
repository {
nameWithOwner
}
}
}
}
}
}
}
}
`;

const res = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query }),
cache: "no-store",
});

if (!res.ok) throw new Error("GitHub GraphQL error");

const json = await res.json();
const nodes =
json?.data?.viewer?.contributionsCollection
?.pullRequestReviewContributions?.nodes ?? [];

const totalReviews = nodes.length;
const approvals = nodes.filter(
(n: { pullRequestReview: { state: string } }) =>
n.pullRequestReview?.state === "APPROVED"
).length;

const approvalRate =
totalReviews > 0
? `${Math.round((approvals / totalReviews) * 100)}%`
: "0%";

const repoCounts: Record<string, number> = {};
for (const node of nodes) {
const repo = node.pullRequestReview?.pullRequest?.repository?.nameWithOwner;
if (repo) repoCounts[repo] = (repoCounts[repo] ?? 0) + 1;
}

const topRepos = Object.entries(repoCounts)
.map(([repo, count]) => ({ repo, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 5);

return {
totalReviews,
approvalRate,
avgFirstReviewHours: null,
topRepos,
};
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.accessToken) {
Expand All @@ -411,8 +484,11 @@ export async function GET(req: NextRequest) {
bypass,
userId: session.githubId ?? session.githubLogin ?? "primary",
});
const gitlab = await getGitLabMetrics(gitlabToken, gitlabCacheContext);
return Response.json(formatPRMetricsResponse(result, gitlab));
const [gitlab, reviews] = await Promise.all([
getGitLabMetrics(gitlabToken, gitlabCacheContext),
fetchReviewMetrics(session.accessToken).catch(() => null),
]);
return Response.json({ ...formatPRMetricsResponse(result, gitlab), reviews });
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
Expand Down Expand Up @@ -480,8 +556,11 @@ export async function GET(req: NextRequest) {
if (!merged) {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
const gitlab = await getGitLabMetrics(gitlabToken, gitlabCacheContext);
return Response.json(formatPRMetricsResponse(merged, gitlab));
const [gitlab, reviews] = await Promise.all([
getGitLabMetrics(gitlabToken, gitlabCacheContext),
fetchReviewMetrics(session.accessToken).catch(() => null),
]);
return Response.json({ ...formatPRMetricsResponse(merged, gitlab), reviews });
}

const token =
Expand All @@ -498,8 +577,11 @@ export async function GET(req: NextRequest) {
bypass,
userId: accountId === session.githubId ? session.githubId : accountId,
});
const gitlab = await getGitLabMetrics(gitlabToken, gitlabCacheContext);
return Response.json(formatPRMetricsResponse(result, gitlab));
const [gitlab, reviews] = await Promise.all([
getGitLabMetrics(gitlabToken, gitlabCacheContext),
fetchReviewMetrics(token).catch(() => null),
]);
return Response.json({ ...formatPRMetricsResponse(result, gitlab), reviews });
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
Expand Down
71 changes: 69 additions & 2 deletions src/components/PRMetrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
import { useCallback, useEffect, useState } from "react";
import { useAccount } from "@/components/AccountContext";
import PRStatusDonutChart from "./PRStatusDonutChart";

interface ReviewMetrics {
totalReviews: number;
approvalRate: string;
avgFirstReviewHours: number | null;
topRepos: { repo: string; count: number }[];
}
interface PRMetricsSummary {
open: number;
merged: number;
Expand All @@ -15,6 +20,7 @@ interface PRMetricsSummary {

interface PRData extends PRMetricsSummary {
gitlab?: PRMetricsSummary;
reviews?: ReviewMetrics;
}

function formatReviewCycle(hours: number | null): string {
Expand All @@ -36,6 +42,7 @@ export default function PRMetrics() {
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [minutesAgo, setMinutesAgo] = useState(0);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"authored" | "reviews">("authored");

const fetchMetrics = useCallback(() => {
setLoading(true);
Expand Down Expand Up @@ -120,7 +127,33 @@ export default function PRMetrics() {

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>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-[var(--card-foreground)]">PR Analytics</h2>
<div className="flex gap-2">
<button
type="button"
onClick={() => setActiveTab("authored")}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === "authored"
? "bg-[var(--accent)] text-white"
: "bg-[var(--control)] text-[var(--muted-foreground)] hover:bg-[var(--card-muted)]"
}`}
>
PRs Authored
</button>
<button
type="button"
onClick={() => setActiveTab("reviews")}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === "reviews"
? "bg-[var(--accent)] text-white"
: "bg-[var(--control)] text-[var(--muted-foreground)] hover:bg-[var(--card-muted)]"
}`}
>
Reviews Given
</button>
</div>
</div>
{loading ? (
<div
role="status"
Expand Down Expand Up @@ -217,6 +250,40 @@ export default function PRMetrics() {
)}
</div>
)}
{/* Reviews Given Tab */}
{!loading && !error && activeTab === "reviews" && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{[
{ label: "Total Reviews Given", value: metrics?.reviews?.totalReviews ?? 0 },
{ label: "Approval Rate", value: metrics?.reviews?.approvalRate ?? "0%" },
].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>
{metrics?.reviews?.topRepos && metrics.reviews.topRepos.length > 0 && (
<div>
<p className="mb-3 text-sm font-medium text-[var(--muted-foreground)]">Most Reviewed Repos</p>
<div className="space-y-2">
{metrics.reviews.topRepos.map((item) => (
<div key={item.repo} className="flex items-center justify-between rounded-lg bg-[var(--control)] px-4 py-2">
<span className="truncate text-sm text-[var(--card-foreground)]">{item.repo}</span>
<span className="ml-4 shrink-0 text-sm font-semibold text-[var(--accent)]">
{item.count} review{item.count !== 1 ? "s" : ""}
</span>
</div>
))}
</div>
</div>
)}
{(metrics?.reviews?.totalReviews ?? 0) === 0 && (
<p className="text-sm text-[var(--muted-foreground)]">No reviews found for this period.</p>
)}
</div>
)}
{lastUpdated && (
<p className="text-xs text-[var(--muted-foreground)] mt-2 text-right">
{minutesAgo === 0 ? "Updated just now" : `Updated ${minutesAgo} min ago`}
Expand Down
4 changes: 4 additions & 0 deletions src/types/css.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module '*.css' {
const content: Record<string, string>;
export default content;
}
Loading