From 59ba3be1a5b5a7f76f0286834e5046ce5199234c Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Thu, 23 Jul 2026 19:24:28 +0200 Subject: [PATCH 1/2] refactor(core/contributors): include other main repositories in crediting users --- apps/core/src/common/contributors.test.ts | 71 ++++++++++++++++++----- apps/core/src/common/contributors.ts | 57 ++++++++++++++---- 2 files changed, 102 insertions(+), 26 deletions(-) diff --git a/apps/core/src/common/contributors.test.ts b/apps/core/src/common/contributors.test.ts index aa8b1f9..af5996c 100644 --- a/apps/core/src/common/contributors.test.ts +++ b/apps/core/src/common/contributors.test.ts @@ -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; // 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 @@ -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(); @@ -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; @@ -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); }); @@ -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(); @@ -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(); diff --git a/apps/core/src/common/contributors.ts b/apps/core/src/common/contributors.ts index 91df35e..af17611 100644 --- a/apps/core/src/common/contributors.ts +++ b/apps/core/src/common/contributors.ts @@ -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; @@ -44,17 +51,47 @@ export const CORE_CONTRIBUTORS: Record = { }; async function fetchContributors(): Promise { - 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; } /** @@ -77,7 +114,7 @@ export function createContributorsList(opts: { }); return async function getContributors(): Promise { - if (!opts.isProd) return noUpdate(); + // if (!opts.isProd) return noUpdate(); if (cache && cache.expiresAt > now()) return cache.value; try { From 6730d993edf5524e8ba2e3a3cdf503f0a35e6f96 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Thu, 23 Jul 2026 19:25:02 +0200 Subject: [PATCH 2/2] tweak(webpanel/credits): only shows contributionS if more then one -> otherwise it looks weird --- apps/webpanel/src/pages/settings/credits.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/webpanel/src/pages/settings/credits.tsx b/apps/webpanel/src/pages/settings/credits.tsx index 693ee6c..255f2a4 100644 --- a/apps/webpanel/src/pages/settings/credits.tsx +++ b/apps/webpanel/src/pages/settings/credits.tsx @@ -148,7 +148,8 @@ function ContributorCard({ {contributor.contributions && ( - {formatNumber(contributor.contributions)} contributions + {formatNumber(contributor.contributions)} contribution + {contributor.contributions > 1 && 's'} )}