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
325 changes: 325 additions & 0 deletions src/app/api/metrics/activity/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import {
getAccountToken,
getAllAccounts,
} from "@/lib/github-accounts";
import { GITHUB_API, fetchUserEvents } from "@/lib/github";
import { supabaseAdmin } from "@/lib/supabase";

export const dynamic = "force-dynamic";

type ActivityType =
| "push"
| "pull_request"
| "issue"
| "release"
| "other";

interface ActivityItem {
id: string;
type: ActivityType;
createdAt: string;
title: string;
subtitle: string;
repo: string;
url: string;
}

interface RawEvent {
id: string;
type: string;
created_at: string;
repo?: { name?: string };
payload?: {
ref?: string;
head?: string;
action?: string;
commits?: Array<{ sha?: string }>;
pull_request?: {
html_url?: string;
number?: number;
title?: string;
merged?: boolean;
};
issue?: {
html_url?: string;
number?: number;
title?: string;
};
release?: {
html_url?: string;
tag_name?: string;
name?: string;
};
};
}

const SUPPORTED_EVENT_TYPES = new Set([
"PushEvent",
"PullRequestEvent",
"IssuesEvent",
"ReleaseEvent",
]);

function getRepoUrl(repoName: string): string {
return `https://github.com/${repoName}`;
}

function capitalize(value: string): string {
return value.length > 0
? value[0].toUpperCase() + value.slice(1)
: "Updated";
}

function formatActivity(event: RawEvent): ActivityItem | null {
const repoName = event.repo?.name;

if (!repoName || !SUPPORTED_EVENT_TYPES.has(event.type)) {
return null;
}

if (event.type === "PushEvent") {
const commitCount = event.payload?.commits?.length ?? 0;
const rawRef = event.payload?.ref ?? "";
const branch = rawRef.replace("refs/heads/", "") || "default branch";
const plural = commitCount === 1 ? "" : "s";

return {
id: event.id,
type: "push",
createdAt: event.created_at,
title: `Pushed ${commitCount} commit${plural} to ${branch}`,
subtitle: repoName,
repo: repoName,
url: event.payload?.head
? `https://github.com/${repoName}/commit/${event.payload.head}`
: getRepoUrl(repoName),
};
}

if (event.type === "PullRequestEvent") {
const action = event.payload?.action ?? "updated";
const pr = event.payload?.pull_request;
const number = pr?.number ? `#${pr.number}` : "PR";
const wasMerged = action === "closed" && pr?.merged === true;
const actionText = wasMerged ? "Merged" : capitalize(action);

return {
id: event.id,
type: "pull_request",
createdAt: event.created_at,
title: `${actionText} pull request ${number}`,
subtitle: pr?.title ?? repoName,
repo: repoName,
url: pr?.html_url ?? getRepoUrl(repoName),
};
}

if (event.type === "IssuesEvent") {
const action = event.payload?.action ?? "updated";
const issue = event.payload?.issue;
const number = issue?.number ? `#${issue.number}` : "Issue";
const actionText = capitalize(action);

return {
id: event.id,
type: "issue",
createdAt: event.created_at,
title: `${actionText} issue ${number}`,
subtitle: issue?.title ?? repoName,
repo: repoName,
url: issue?.html_url ?? getRepoUrl(repoName),
};
}

if (event.type === "ReleaseEvent") {
const action = event.payload?.action ?? "published";
const release = event.payload?.release;
const tag = release?.tag_name ?? "release";
const actionText = capitalize(action);

return {
id: event.id,
type: "release",
createdAt: event.created_at,
title: `${actionText} ${tag}`,
subtitle: release?.name ?? repoName,
repo: repoName,
url: release?.html_url ?? getRepoUrl(repoName),
};
}

return null;
}

async function fetchFormattedActivity(token: string): Promise<ActivityItem[]> {
const events = (await fetchUserEvents(token)) as RawEvent[];

return events
.map(formatActivity)
.filter((item): item is ActivityItem => item !== null)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}

async function fetchPublicEvents(
token: string,
githubLogin: string
): Promise<RawEvent[]> {
const response = await fetch(
`${GITHUB_API}/users/${encodeURIComponent(githubLogin)}/events/public?per_page=100`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
},
cache: "no-store",
}
);

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

return (await response.json()) as RawEvent[];
}

async function fetchFormattedActivityWithFallback(
token: string,
githubLogin?: string
): Promise<ActivityItem[]> {
try {
return await fetchFormattedActivity(token);
} catch {
if (!githubLogin) {
throw new Error("GitHub API error");
}

const events = await fetchPublicEvents(token, githubLogin);

return events
.map(formatActivity)
.filter((item): item is ActivityItem => item !== null)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
}

export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);

if (!session?.accessToken) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const accountId = req.nextUrl.searchParams.get("accountId");

if (!accountId) {
try {
const items = await fetchFormattedActivityWithFallback(
session.accessToken,
session.githubLogin
);
return Response.json({ items: items.slice(0, 15) });
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}

if (!session.githubId || !session.githubLogin) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const { data: userRow } = await supabaseAdmin
.from("users")
.select("id")
.eq("github_id", session.githubId)
.single();

if (!userRow) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

if (accountId === "combined") {
const accounts = await getAllAccounts(
{
token: session.accessToken,
githubId: session.githubId,
githubLogin: session.githubLogin,
},
userRow.id
);

const results = await Promise.allSettled(
accounts.map((account) =>
fetchFormattedActivityWithFallback(account.token, account.githubLogin)
)
);

const merged = results
.filter(
(result): result is PromiseFulfilledResult<ActivityItem[]> =>
result.status === "fulfilled"
)
.flatMap((result) => result.value)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
.slice(0, 15);

if (merged.length === 0 && results.length > 0) {
const allFailed = results.every((result) => result.status === "rejected");
if (allFailed) {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}

return Response.json({ items: merged });
}

if (accountId === session.githubId) {
try {
const items = await fetchFormattedActivityWithFallback(
session.accessToken,
session.githubLogin
);
return Response.json({ items: items.slice(0, 15) });
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}

const token = await getAccountToken(userRow.id, accountId);

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

const { data: accountRow } = await supabaseAdmin
.from("user_github_accounts")
.select("github_login")
.eq("user_id", userRow.id)
.eq("github_id", accountId)
.single();

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

try {
const items = await fetchFormattedActivityWithFallback(
token,
accountRow.github_login
);
return Response.json({ items: items.slice(0, 15) });
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}
6 changes: 6 additions & 0 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import WeeklySummaryCard from "@/components/WeeklySummaryCard";
import ExportButton from "@/components/ExportButton";
import Link from "next/link";
import PersonalRecords from "@/components/PersonalRecords";
import RecentActivity from "@/components/RecentActivity";
import { authOptions } from "@/lib/auth";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
Expand Down Expand Up @@ -91,6 +92,11 @@ export default async function DashboardPage() {
<LanguageBreakdown />
<GoalTracker />
</div>

{/* Row 6: Recent GitHub activity */}
<div className="mt-6">
<RecentActivity />
</div>
</div>
);
}
Loading