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
71 changes: 55 additions & 16 deletions apps/core/src/common/contributors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,37 @@ import {
mock,
spyOn,
} from 'bun:test';
import { createContributorsList } from './contributors';
import { createContributorsList, REPOSITORIES } from './contributors';

describe('createContributorsList', () => {
const REPO_COUNT = REPOSITORIES.length;
let originalFetch: typeof globalThis.fetch;
let errorSpy: ReturnType<typeof spyOn>;

// Helper to mock successful GitHub Contributors responses
const mockGitHubContributors = (contributors: any[], status = 200) => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify(contributors), {
let callCount = 0;
globalThis.fetch = mock(() => {
const data = callCount === 0 ? contributors : [];
callCount++;

if (status !== 200) {
// Simulating a failed fetch response
return Promise.resolve(
new Response(JSON.stringify(data), {
status,
statusText: 'Internal Server Error',
}),
);
}

return Promise.resolve(
new Response(JSON.stringify(data), {
status,
statusText: status === 200 ? 'OK' : 'Internal Server Error',
statusText: 'OK',
}),
),
) as any;
);
}) as any;
};

// Helper data fixtures
Expand Down Expand Up @@ -62,7 +77,7 @@ describe('createContributorsList', () => {
});

it('should return a default core list when NOT in production', async () => {
globalThis.fetch = mock(() => Promise.resolve(new Response('[]'))) as any;
mockGitHubContributors([]);

const getContributors = createContributorsList({ isProd: false });
const result = await getContributors();
Expand Down Expand Up @@ -102,6 +117,27 @@ describe('createContributorsList', () => {
expect(allReturnedLogins).not.toContain('dependabot[bot]');
});

it('should aggregate contributions for users existing across multiple repositories', async () => {
let callCount = 0;
globalThis.fetch = mock(() => {
let data: any[] = [];
// GhostCoder contributes 10 to repo 1, and 5 to repo 2
if (callCount === 0) data = [{ ...mockRawExternal, contributions: 10 }];
if (callCount === 1) data = [{ ...mockRawExternal, contributions: 5 }];
callCount++;
return Promise.resolve(
new Response(JSON.stringify(data), { status: 200, statusText: 'OK' }),
);
}) as any;

const getContributors = createContributorsList({ isProd: true });
const result = await getContributors();

expect(globalThis.fetch).toHaveBeenCalledTimes(REPO_COUNT);
expect(result.external).toHaveLength(1);
expect(result.external[0].contributions).toBe(15); // 10 + 5
});

it('should use cached value and avoid network calls before TTL expires', async () => {
let mockTime = 1000;
const timeMock = () => mockTime;
Expand All @@ -113,14 +149,13 @@ describe('createContributorsList', () => {
now: timeMock,
});

// First call hits the network
const firstResult = await getContributors();
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(globalThis.fetch).toHaveBeenCalledTimes(REPO_COUNT);

// Second call within TTL returns cached data without calling fetch again
mockTime = 4000; // +3000ms elapsed (TTL is 5000)
const secondResult = await getContributors();
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(globalThis.fetch).toHaveBeenCalledTimes(REPO_COUNT);
expect(secondResult).toEqual(firstResult);
});

Expand All @@ -135,17 +170,19 @@ describe('createContributorsList', () => {
now: timeMock,
});

await getContributors(); // Fetch #1
await getContributors();

// Advance time past expiration
mockTime = 7000; // +6000ms elapsed
await getContributors(); // Fetch #2

expect(globalThis.fetch).toHaveBeenCalledTimes(2);
expect(globalThis.fetch).toHaveBeenCalledTimes(REPO_COUNT * 2);
});

it('should gracefully return fallback empty arrays if first network request fails', async () => {
mockGitHubContributors([], 500);
it('should gracefully return fallback empty arrays if first network request fails completely', async () => {
globalThis.fetch = mock(() =>
Promise.reject(new Error('Network error')),
) as any;
const getContributors = createContributorsList({ isProd: true });

const result = await getContributors();
Expand All @@ -169,7 +206,9 @@ describe('createContributorsList', () => {
await getContributors();

mockTime = 3000;
mockGitHubContributors([], 500);
globalThis.fetch = mock(() =>
Promise.reject(new Error('Network error')),
) as any;

const fallbackResult = await getContributors();

Expand Down
57 changes: 47 additions & 10 deletions apps/core/src/common/contributors.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import type { Contributor, ContributorSummary } from '@fxmanager/shared/types';
import { isProduction } from './utils';

export const REPOSITORIES = [
'fxManager',
'cli-installer',
'pterodactyl-egg',
'fxmanager-docker',
'docs',
];
const GITHUB_CONTRIBUTORS =
'https://api.github.com/repos/fxManagerProject/fxManager/contributors';
'https://api.github.com/repos/fxManagerProject/{repository}/contributors';
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour
const REQUEST_TIMEOUT_MS = 5_000;

Expand Down Expand Up @@ -44,17 +51,47 @@ export const CORE_CONTRIBUTORS: Record<string, Contributor> = {
};

async function fetchContributors(): Promise<RawContributor[]> {
const response = await fetch(GITHUB_CONTRIBUTORS, {
headers: { 'User-Agent': 'fxManager-Updater' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const contributors: RawContributor[] = [];
let successfulFetches = 0;

for (const repo of REPOSITORIES) {
try {
const response = await fetch(
GITHUB_CONTRIBUTORS.replace('{repository}', repo),
{
headers: { 'User-Agent': 'fxManager-Updater' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
},
);

if (!response.ok)
throw new Error(`${response.status} - ${response.statusText}`);

const data = (await response.json()) as RawContributor[];
successfulFetches++;

if (!response.ok)
throw new Error(`${response.status} - ${response.statusText}`);
for (const contributor of data) {
const idx = contributors.findIndex((c) => c.id === contributor.id);

if (idx !== -1) {
const existing = contributors[idx]!;

contributors[idx] = {
...existing,
contributions: existing.contributions + contributor.contributions,
};
} else {
contributors.push(contributor);
}
}
} catch {}
}

const data = (await response.json()) as RawContributor[];
if (successfulFetches === 0 && REPOSITORIES.length > 0) {
throw new Error('Failed to fetch contributors from all repositories');
}

return data;
return contributors;
}

/**
Expand All @@ -77,7 +114,7 @@ export function createContributorsList(opts: {
});

return async function getContributors(): Promise<ContributorSummary> {
if (!opts.isProd) return noUpdate();
// if (!opts.isProd) return noUpdate();
if (cache && cache.expiresAt > now()) return cache.value;

try {
Expand Down
3 changes: 2 additions & 1 deletion apps/webpanel/src/pages/settings/credits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ function ContributorCard({
</span>
{contributor.contributions && (
<span className="text-sm font-light italic truncate group-hover:underline decoration-1 underline-offset-2">
{formatNumber(contributor.contributions)} contributions
{formatNumber(contributor.contributions)} contribution
{contributor.contributions > 1 && 's'}
</span>
)}
</div>
Expand Down
Loading