Skip to content

Commit 0dac9fb

Browse files
feat: support merging multiple user contribution calendars via comma-separated list
1 parent 06b45e5 commit 0dac9fb

4 files changed

Lines changed: 159 additions & 6 deletions

File tree

app/api/streak/route.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,4 +1337,51 @@ describe('GET /api/streak', () => {
13371337
expect(body).toContain('strictly for organizations');
13381338
});
13391339
});
1340+
1341+
describe('multi-user skyline merges', () => {
1342+
it('fetches calendars concurrently, aggregates them, and overrides the title in the SVG', async () => {
1343+
vi.mocked(fetchGitHubContributions)
1344+
.mockResolvedValueOnce({
1345+
calendar: mockCalendar,
1346+
repoContributions: [],
1347+
} as unknown as ExtendedContributionData)
1348+
.mockResolvedValueOnce({
1349+
calendar: mockCalendar,
1350+
repoContributions: [],
1351+
} as unknown as ExtendedContributionData);
1352+
1353+
const response = await GET(makeRequest({ user: 'a, b' }));
1354+
expect(response.status).toBe(200);
1355+
1356+
expect(fetchGitHubContributions).toHaveBeenCalledWith('a', expect.any(Object));
1357+
expect(fetchGitHubContributions).toHaveBeenCalledWith('b', expect.any(Object));
1358+
1359+
const body = await response.text();
1360+
expect(body).toContain('A + B');
1361+
});
1362+
1363+
it('gracefully handles partial fetch failures by filtering out failed calendars', async () => {
1364+
vi.mocked(fetchGitHubContributions)
1365+
.mockResolvedValueOnce({
1366+
calendar: mockCalendar,
1367+
repoContributions: [],
1368+
} as unknown as ExtendedContributionData)
1369+
.mockRejectedValueOnce(new Error('GitHub user "b" not found'));
1370+
1371+
const response = await GET(makeRequest({ user: 'a, b' }));
1372+
expect(response.status).toBe(200);
1373+
1374+
const body = await response.text();
1375+
expect(body).toContain('A + B');
1376+
});
1377+
1378+
it('returns a 404/error response when all users in the list fail to load', async () => {
1379+
vi.mocked(fetchGitHubContributions)
1380+
.mockRejectedValueOnce(new Error('GitHub user "a" not found'))
1381+
.mockRejectedValueOnce(new Error('GitHub user "b" not found'));
1382+
1383+
const response = await GET(makeRequest({ user: 'a, b' }));
1384+
expect(response.status).toBe(404);
1385+
});
1386+
});
13401387
});

app/api/streak/route.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { NextResponse } from 'next/server';
44
import { fetchGitHubContributions, getOrgDashboardData } from '@/lib/github';
5-
import { calculateStreak, calculateMonthlyStats } from '@/lib/calculate';
5+
import { calculateStreak, calculateMonthlyStats, aggregateCalendars } from '@/lib/calculate';
66
import {
77
generateNotFoundSVG,
88
generateRateLimitSVG,
@@ -13,7 +13,7 @@ import {
1313
generatePulseSVG,
1414
} from '@/lib/svg/generator';
1515
import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '@/utils/time';
16-
import type { BadgeParams } from '@/types';
16+
import type { BadgeParams, ContributionCalendar } from '@/types';
1717
import { themes } from '@/lib/svg/themes';
1818
import { streakParamsSchema } from '@/lib/validations';
1919

@@ -127,7 +127,15 @@ export async function GET(request: Request) {
127127
})();
128128

129129
// If 'org' is provided, we use it as the display user
130-
const targetEntity = org || user;
130+
const targetEntity =
131+
org ||
132+
(user.includes(',')
133+
? user
134+
.split(',')
135+
.map((u) => u.trim())
136+
.slice(0, 2)
137+
.join(' + ')
138+
: user);
131139
const borderParam = searchParams.get('border');
132140
const sanitizedBorder = borderParam ? borderParam.replace(/[^a-fA-F0-9]/g, '') : undefined;
133141
const animate = searchParams.get('animate') !== 'false';
@@ -176,6 +184,34 @@ export async function GET(request: Request) {
176184
to,
177185
});
178186
calendar = orgData.calendar;
187+
} else if (user.includes(',')) {
188+
const users = user
189+
.split(',')
190+
.map((u) => u.trim())
191+
.filter(Boolean);
192+
let lastError: unknown = null;
193+
const fetchedCalendars = await Promise.all(
194+
users.map(async (u) => {
195+
try {
196+
const userData = await fetchGitHubContributions(u, {
197+
bypassCache: refresh,
198+
from,
199+
to,
200+
});
201+
return userData.calendar;
202+
} catch (err) {
203+
lastError = err;
204+
return null;
205+
}
206+
})
207+
);
208+
const successfulCalendars = fetchedCalendars.filter(
209+
(c): c is ContributionCalendar => c !== null
210+
);
211+
if (successfulCalendars.length === 0) {
212+
throw lastError || new Error('No successful calendars fetched');
213+
}
214+
calendar = aggregateCalendars(successfulCalendars);
179215
} else {
180216
const userData = await fetchGitHubContributions(user, {
181217
bypassCache: refresh,

lib/validations.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,47 @@ describe('streakParamsSchema user validation', () => {
9898

9999
expect(result.success).toBe(true);
100100
});
101+
102+
it('should pass when user is a comma-separated list of valid usernames', () => {
103+
const result = streakParamsSchema.safeParse({
104+
user: 'octocat, JhaSourav07, nishtha-agarwal-211',
105+
});
106+
107+
expect(result.success).toBe(true);
108+
});
109+
110+
it('should fail when one of the usernames in the list is invalid', () => {
111+
const result = streakParamsSchema.safeParse({
112+
user: 'octocat, invalid_name_with_spaces',
113+
});
114+
115+
expect(result.success).toBe(false);
116+
if (!result.success) {
117+
expect(result.error.issues[0]?.message).toBe('Invalid GitHub username');
118+
}
119+
});
120+
121+
it('should fail when one of the usernames in the list exceeds 39 characters', () => {
122+
const result = streakParamsSchema.safeParse({
123+
user: `octocat, ${'a'.repeat(40)}`,
124+
});
125+
126+
expect(result.success).toBe(false);
127+
if (!result.success) {
128+
expect(result.error.issues[0]?.message).toBe('GitHub username cannot exceed 39 characters');
129+
}
130+
});
131+
132+
it('should fail when list has empty usernames due to consecutive or trailing commas', () => {
133+
const result = streakParamsSchema.safeParse({
134+
user: 'octocat, , JhaSourav07',
135+
});
136+
137+
expect(result.success).toBe(false);
138+
if (!result.success) {
139+
expect(result.error.issues[0]?.message).toBe('Invalid GitHub username');
140+
}
141+
});
101142
});
102143

103144
describe('streakParamsSchema', () => {

lib/validations.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,38 @@ const baseStreakParamsSchema = z.object({
6565
user: z
6666
.string({ error: 'Missing user parameter' })
6767
.min(1, { message: 'Missing user parameter' })
68-
.max(39, { message: 'GitHub username cannot exceed 39 characters' })
69-
.regex(GITHUB_USERNAME_REGEX, {
70-
message: 'Invalid GitHub username',
68+
.superRefine((val, ctx) => {
69+
const users = val.split(',').map((u) => u.trim());
70+
if (users.length === 0) {
71+
ctx.addIssue({
72+
code: z.ZodIssueCode.custom,
73+
message: 'Missing user parameter',
74+
});
75+
return;
76+
}
77+
for (const u of users) {
78+
if (u.length === 0) {
79+
ctx.addIssue({
80+
code: z.ZodIssueCode.custom,
81+
message: 'Invalid GitHub username',
82+
});
83+
return;
84+
}
85+
if (u.length > 39) {
86+
ctx.addIssue({
87+
code: z.ZodIssueCode.custom,
88+
message: 'GitHub username cannot exceed 39 characters',
89+
});
90+
return;
91+
}
92+
if (!GITHUB_USERNAME_REGEX.test(u)) {
93+
ctx.addIssue({
94+
code: z.ZodIssueCode.custom,
95+
message: 'Invalid GitHub username',
96+
});
97+
return;
98+
}
99+
}
71100
}),
72101

73102
theme: z.string().default('dark'),

0 commit comments

Comments
 (0)