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
8 changes: 8 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ MONGODB_URI=mongodb+srv://<username>:<password>@cluster0.mongodb.net/commitpulse
# Generate at: https://github.com/settings/tokens (No scopes required)
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# GitHub OAuth App (https://github.com/settings/developers)
# Callback URL: http://localhost:3000/api/auth/callback/github
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

# Generate with: openssl rand -base64 32
AUTH_SECRET=

# Required for encrypting stored third-party API tokens.
# Use a unique random secret with at least 32 characters.
ENCRYPTION_KEY=
Expand Down
8 changes: 7 additions & 1 deletion app/(root)/dashboard/[username]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Metadata } from 'next';
import DashboardClient from '@/components/dashboard/DashboardClient';
import { getFullDashboardData, fetchUserProfile, fetchUserRepos } from '@/lib/github';
import { getUserGitHubToken } from '@/lib/githubtoken';

import type { RepoActivityInfo } from '@/types/dashboard';
import { notFound, redirect } from 'next/navigation';
import { resolveDashboardPeriod } from '@/utils/dashboardPeriod';
Expand Down Expand Up @@ -90,6 +92,7 @@ export default async function DashboardPage({
from: resolvedSearchParams?.from,
to: resolvedSearchParams?.to,
});
const userToken = await getUserGitHubToken();

let data;

Expand All @@ -99,13 +102,15 @@ export default async function DashboardPage({
from: period.from,
to: period.to,
rangeLabel: period.label,
token: userToken,
});
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
let fallbackProfile;
try {
fallbackProfile = await fetchUserProfile(username, {
bypassCache,
token: userToken,
});
} catch {
return notFound();
Expand All @@ -120,7 +125,7 @@ export default async function DashboardPage({

let allRepos: RepoActivityInfo[] = [];
try {
const reposData = await fetchUserRepos(username, { bypassCache });
const reposData = await fetchUserRepos(username, { bypassCache, token: userToken });
allRepos = reposData.map((r) => ({
name: r.name,
url: `https://github.com/${username}/${r.name}`,
Expand All @@ -136,6 +141,7 @@ export default async function DashboardPage({
try {
compareData = await getFullDashboardData(compareUsername, {
bypassCache,
token: userToken,
});
} catch {
compareData = null;
Expand Down
6 changes: 4 additions & 2 deletions app/(root)/dashboard/[username]/wrapped/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import GithubWrapped from '@/components/dashboard/GithubWrapped';
import { getFullDashboardData, getWrappedData } from '@/lib/github';
import { getUserGitHubToken } from '@/lib/githubtoken';

export async function generateMetadata({
params,
Expand All @@ -25,6 +26,7 @@ export default async function WrappedPage({
const { username } = await params;
const resolvedSearchParams = await searchParams;
const targetYear = resolvedSearchParams?.year || new Date().getFullYear().toString();
const userToken = await getUserGitHubToken();

// 1. Fetch data safely.
// If this fails, the error will bubble up to the nearest error.tsx file.
Expand All @@ -33,8 +35,8 @@ export default async function WrappedPage({

try {
[dashboardData, wrappedData] = await Promise.all([
getFullDashboardData(username),
getWrappedData(username, targetYear),
getFullDashboardData(username, { token: userToken }),
getWrappedData(username, targetYear, { token: userToken }),
]);
} catch (error) {
console.error('[Wrapped] Failed to load wrapped data:', error);
Expand Down
4 changes: 3 additions & 1 deletion app/(root)/dashboard/org/[orgname]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Heatmap from '@/components/dashboard/Heatmap';
import AIInsights from '@/components/dashboard/AIInsights';
import Achievements from '@/components/dashboard/Achievements';
import { getOrgDashboardData, buildCommitClock, generateAchievements } from '@/lib/github';
import { getUserGitHubToken } from '@/lib/githubtoken';

export const revalidate = 3600; // Cache for 1 hour

Expand Down Expand Up @@ -53,11 +54,12 @@ export default async function OrgDashboardPage({
const { orgname } = await params;
const refreshParams = await searchParams;
const bypassCache = refreshParams?.refresh === 'true';
const userToken = await getUserGitHubToken();

let data;

try {
data = await getOrgDashboardData(orgname, { bypassCache });
data = await getOrgDashboardData(orgname, { bypassCache, token: userToken });
} catch (error) {
console.error(error);
return notFound();
Expand Down
3 changes: 3 additions & 0 deletions app/api/Auth/Nextauthgithub/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { handlers } from '@/auth';

export const { GET, POST } = handlers;
4 changes: 3 additions & 1 deletion app/api/achievements/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
import { getFullDashboardData } from '@/lib/github';
import type {
AchievementDef,
AchievementLevelDef,

Check warning on line 6 in app/api/achievements/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'AchievementLevelDef' is defined but never used
AchievementCategory,
AchievementRarity,

Check warning on line 8 in app/api/achievements/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'AchievementRarity' is defined but never used
AchievementState,
AchievementData,
AchievementsResponse,
} from '@/types/achievements';
import { getUserGitHubToken } from '@/lib/githubtoken';

const ACHIEVEMENT_DEFS: AchievementDef[] = [
// 🔥 Contribution
Expand Down Expand Up @@ -555,7 +556,8 @@
}

try {
const dashboardData = await getFullDashboardData(username);
const userToken = await getUserGitHubToken();
const dashboardData = await getFullDashboardData(username, { token: userToken });

const { profile, stats, languages } = dashboardData;
const totalStars = profile.stats.stars;
Expand Down Expand Up @@ -606,7 +608,7 @@
const totalEngagement = totalStars + totalForks + stats.totalIssues + stats.totalPRs;

const totalContributions = stats.totalContributions;
const currentStreak = stats.currentStreak;

Check warning on line 611 in app/api/achievements/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'currentStreak' is assigned a value but never used
const longestStreak = stats.peakStreak;

const allCategories: AchievementCategory[] = [
Expand Down
2 changes: 1 addition & 1 deletion app/api/ci-analytics/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,6 @@ describe('GET /api/ci-analytics', () => {
const response = await GET(new Request('http://localhost/api/ci-analytics?username=octocat'));

expect(response.status).toBe(200);
expect(fetchCIAnalytics).toHaveBeenCalledWith('octocat');
expect(fetchCIAnalytics).toHaveBeenCalledWith('octocat', undefined);
});
});
4 changes: 3 additions & 1 deletion app/api/ci-analytics/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { fetchCIAnalytics } from '@/services/github/ci-analytics';
import { getUserGitHubToken } from '@/lib/githubtoken';
import { validateGitHubUsername } from '@/lib/validations';
import { RateLimiter } from '@/lib/rate-limit';

Expand All @@ -25,7 +26,8 @@ export async function GET(request: Request) {
}

try {
const data = await fetchCIAnalytics(username);
const userToken = await getUserGitHubToken();
const data = await fetchCIAnalytics(username, userToken);
return NextResponse.json(data);
} catch (error: unknown) {
console.error('Error fetching CI analytics:', error);
Expand Down
18 changes: 15 additions & 3 deletions app/api/compare/route.mouse-interactivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,20 @@ describe('ApiCompareRoute Tests', () => {
expect(json.user1.profile.username).toBe('testuser');
expect(json.user2.profile.username).toBe('testuser');
expect(getFullDashboardData).toHaveBeenCalledTimes(2);
expect(getFullDashboardData).toHaveBeenNthCalledWith(1, 'octocat');
expect(getFullDashboardData).toHaveBeenNthCalledWith(2, 'defunkt');
expect(getFullDashboardData).toHaveBeenNthCalledWith(
1,
'octocat',
expect.objectContaining({
token: undefined,
})
);
expect(getFullDashboardData).toHaveBeenNthCalledWith(
2,
'defunkt',
expect.objectContaining({
token: undefined,
})
);
});

it('returns 404 Not Found when a user does not exist on GitHub', async () => {
Expand Down Expand Up @@ -88,7 +100,7 @@ describe('ApiCompareRoute Tests', () => {
it('returns 502 Bad Gateway on unexpected upstream API failures', async () => {
vi.mocked(getFullDashboardData).mockRejectedValueOnce(new Error('Unexpected network crash'));

const request = makeRequest({ user1: 'octocat', user2: 'defunkt' });
const request = makeRequest({ user1: 'octocat', user2: 'defunkt', token: 'sometoken' });
const response = await GET(request);

expect(response.status).toBe(502);
Expand Down
6 changes: 4 additions & 2 deletions app/api/compare/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { getFullDashboardData } from '@/lib/github';
import { getUserGitHubToken } from '@/lib/githubtoken';
import { compareParamsSchema } from '@/lib/validations';
import crypto from 'crypto';

Expand Down Expand Up @@ -65,9 +66,10 @@ export async function GET(request: Request) {
const { user1, user2 } = parseResult.data;

try {
const userToken = await getUserGitHubToken();
const [result1, result2] = await Promise.allSettled([
getFullDashboardData(user1),
getFullDashboardData(user2),
getFullDashboardData(user1, { token: userToken }),
getFullDashboardData(user2, { token: userToken }),
]);

if (result1.status === 'rejected') {
Expand Down
2 changes: 1 addition & 1 deletion app/api/pr-insights/route.empty-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,6 @@ describe('PR Insights Route Empty/Missing Inputs Verification', () => {
const response = await GET(request);

expect(response.status).toBe(200);
expect(fetchPRInsights).toHaveBeenCalledWith('octocat');
expect(fetchPRInsights).toHaveBeenCalledWith('octocat', undefined);
});
});
2 changes: 1 addition & 1 deletion app/api/pr-insights/route.mouse-interactivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('pr-insights mouse interactivity contract', () => {

await GET(request);

expect(fetchPRInsights).toHaveBeenCalledWith('aanya');
expect(fetchPRInsights).toHaveBeenCalledWith('aanya', undefined);
});

it('returns fetched data on success', async () => {
Expand Down
4 changes: 2 additions & 2 deletions app/api/pr-insights/route.theme-contrast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ describe('GET /api/pr-insights', () => {
const response = await GET(makeRequest({ username: ' octocat ' }));

expect(response.status).toBe(200);
expect(fetchPRInsights).toHaveBeenCalledWith('octocat');
expect(fetchPRInsights).toHaveBeenCalledWith('octocat', undefined);
});

it('returns the full PR insights payload on a successful fetch', async () => {
Expand All @@ -94,7 +94,7 @@ describe('GET /api/pr-insights', () => {
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual(mockInsights);
expect(fetchPRInsights).toHaveBeenCalledWith('octocat');
expect(fetchPRInsights).toHaveBeenCalledWith('octocat', undefined);
});

it('returns 500 with the error message when fetchPRInsights throws an Error', async () => {
Expand Down
5 changes: 4 additions & 1 deletion app/api/pr-insights/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { fetchPRInsights } from '@/services/github/pr-insights';
import { validateGitHubUsername } from '@/lib/validations';
import { getRateLimitHeaders, RateLimiter } from '@/lib/rate-limit';
import { getUserGitHubToken } from '@/lib/githubtoken';

const prInsightsLimiter = new RateLimiter(10, 60_000, 1);

Expand All @@ -27,7 +28,9 @@ export async function GET(request: Request) {
}

try {
const data = await fetchPRInsights(trimmed);
const userToken = await getUserGitHubToken();
const data = await fetchPRInsights(trimmed, userToken);

return NextResponse.json(data);
} catch (error: unknown) {
console.error('Error fetching PR insights:', error);
Expand Down
22 changes: 18 additions & 4 deletions app/api/repo-burnout/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,14 @@ describe('GET /api/repo-burnout', () => {
);
expect(response.status).toBe(200);
expect(response.headers.get('X-Cache-Status')).toBe('MISS');
expect(fetchBurnoutAnalysis).toHaveBeenCalledWith('octocat', 'hello-world', {
bypassCache: true,
});
expect(fetchBurnoutAnalysis).toHaveBeenCalledWith(
'octocat',
'hello-world',
expect.objectContaining({
bypassCache: true,
token: undefined,
})
);
});

it('returns 429 when GitHub API quota is low and refresh is requested', async () => {
Expand Down Expand Up @@ -111,8 +116,17 @@ describe('GET /api/repo-burnout', () => {
);
expect(response.status).toBe(200);
expect(response.headers.get('X-Cache-Status')).toBe('MISS');
expect(fetchBurnoutAnalysis).toHaveBeenCalledWith(
'octocat',
'hello-world',
expect.objectContaining({
bypassCache: false,
token: undefined,
})
);
expect(fetchBurnoutAnalysis).toHaveBeenCalledWith('octocat', 'hello-world', {
bypassCache: true,
bypassCache: false,
token: undefined,
});
});

Expand Down
8 changes: 7 additions & 1 deletion app/api/repo-burnout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { z } from 'zod';
import { fetchBurnoutAnalysis } from '@/services/github/burnout-analyzer';
import { quotaMonitor } from '@/services/github/quota-monitor';
import { getClientIp } from '@/utils/getClientIp';
import { getUserGitHubToken } from '@/lib/githubtoken';

import { refreshPolicy } from '@/services/github/refresh-policy';
import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter';

Expand Down Expand Up @@ -89,7 +91,11 @@ export async function GET(request: Request) {
}

try {
const data = await fetchBurnoutAnalysis(owner, repo, { bypassCache: shouldBypassCache });
const userToken = await getUserGitHubToken();
const data = await fetchBurnoutAnalysis(owner, repo, {
bypassCache: refresh,
token: userToken,
});

const cacheControl = shouldBypassCache
? 'no-cache, no-store, must-revalidate'
Expand Down
9 changes: 7 additions & 2 deletions app/api/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getClientIp } from '@/utils/getClientIp';
import { quotaMonitor } from '@/services/github/quota-monitor';
import { refreshPolicy } from '@/services/github/refresh-policy';
import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter';
import { getUserGitHubToken } from '@/lib/githubtoken';

function logSecurityEvent(event: string, details: Record<string, unknown>) {
console.warn(
Expand Down Expand Up @@ -121,8 +122,12 @@ export async function GET(request: Request) {
}

try {
const userData = await fetchGitHubContributions(user, { bypassCache: shouldBypassCache });

// Authenticated -> user's OAuth token (their quota); anonymous -> undefined (global PAT).
const userToken = await getUserGitHubToken();
const userData = await fetchGitHubContributions(user, {
bypassCache: shouldBypassCache,
token: userToken,
});
if (shouldBypassCache) {
refreshPolicy.recordRefresh(user);
}
Expand Down
27 changes: 27 additions & 0 deletions auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { encryptToken } from '@/lib/crypto';

export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
authorization: { params: { scope: 'read:user public_repo' } },
}),
],
session: { strategy: 'jwt' },
callbacks: {
async jwt({ token, account }) {
if (account?.access_token) {
token.ghToken = encryptToken(account.access_token);
}
return token;
},
async session({ session, token }) {
session.hasGitHubToken = Boolean(token.ghToken);
session.ghToken = token.ghToken as string | undefined;
return session;
},
},
});
11 changes: 11 additions & 0 deletions components/GithubAuthButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use client';
import { signIn, signOut, useSession } from 'next-auth/react';

export function GitHubAuthButton() {
const { data: session } = useSession();
return session ? (
<button onClick={() => signOut()}>Sign out</button>
) : (
<button onClick={() => signIn('github')}>Sign in with GitHub</button>
);
}
Loading
Loading