Skip to content

Commit ae40fd4

Browse files
feat: credits page (#60)
* feat: credits page with contributors list * feat(core/common): tests for contributors getter * chore(core/contributors): removed leftover debug logging Co-authored-by: andreutu <91362974+andreutu@users.noreply.github.com> --------- Co-authored-by: andreutu <91362974+andreutu@users.noreply.github.com>
1 parent ae957e5 commit ae40fd4

11 files changed

Lines changed: 628 additions & 42 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/** biome-ignore-all lint/suspicious/noExplicitAny lint/complexity/noBannedTypes: explicit any allows mocking global fetch frames */
2+
import {
3+
afterEach,
4+
beforeEach,
5+
describe,
6+
expect,
7+
it,
8+
mock,
9+
spyOn,
10+
} from 'bun:test';
11+
import { createContributorsList } from './contributors';
12+
13+
describe('createContributorsList', () => {
14+
let originalFetch: typeof globalThis.fetch;
15+
let errorSpy: ReturnType<typeof spyOn>;
16+
17+
// Helper to mock successful GitHub Contributors responses
18+
const mockGitHubContributors = (contributors: any[], status = 200) => {
19+
globalThis.fetch = mock(() =>
20+
Promise.resolve(
21+
new Response(JSON.stringify(contributors), {
22+
status,
23+
statusText: status === 200 ? 'OK' : 'Internal Server Error',
24+
}),
25+
),
26+
) as any;
27+
};
28+
29+
// Helper data fixtures
30+
const mockRawCore = {
31+
id: 1,
32+
login: 'Maximus7474',
33+
avatar_url: 'https://avatar.com/max',
34+
type: 'User',
35+
contributions: 42,
36+
};
37+
38+
const mockRawExternal = {
39+
id: 2,
40+
login: 'GhostCoder',
41+
avatar_url: 'https://avatar.com/ghost',
42+
type: 'User',
43+
contributions: 10,
44+
};
45+
46+
const mockRawBot = {
47+
id: 3,
48+
login: 'dependabot[bot]',
49+
avatar_url: 'https://avatar.com/bot',
50+
type: 'Bot',
51+
contributions: 100,
52+
};
53+
54+
beforeEach(() => {
55+
originalFetch = globalThis.fetch;
56+
errorSpy = spyOn(console, 'error').mockImplementation(() => {});
57+
});
58+
59+
afterEach(() => {
60+
globalThis.fetch = originalFetch;
61+
errorSpy.mockRestore();
62+
});
63+
64+
it('should return a default core list when NOT in production', async () => {
65+
globalThis.fetch = mock(() => Promise.resolve(new Response('[]'))) as any;
66+
67+
const getContributors = createContributorsList({ isProd: false });
68+
const result = await getContributors();
69+
70+
expect(result.core).toHaveLength(3);
71+
expect(globalThis.fetch).toBeCalledTimes(0);
72+
});
73+
74+
it('should correctly filter and map core vs external vs bot contributors', async () => {
75+
mockGitHubContributors([mockRawCore, mockRawExternal, mockRawBot]);
76+
const getContributors = createContributorsList({ isProd: true });
77+
78+
const result = await getContributors();
79+
80+
// Check Core matching
81+
expect(result.core).toHaveLength(1);
82+
expect(result.core[0]).toEqual({
83+
kofi: 'Maximus7474',
84+
username: 'Maximus7474',
85+
image: 'https://avatar.com/max',
86+
contributions: 42,
87+
});
88+
89+
// Check External matching
90+
expect(result.external).toHaveLength(1);
91+
expect(result.external[0]).toEqual({
92+
username: 'GhostCoder',
93+
image: 'https://avatar.com/ghost',
94+
contributions: 10,
95+
});
96+
97+
// Bots should be skipped entirely
98+
const allReturnedLogins = [
99+
...result.core.map((c) => c.username),
100+
...result.external.map((c) => c.username),
101+
];
102+
expect(allReturnedLogins).not.toContain('dependabot[bot]');
103+
});
104+
105+
it('should use cached value and avoid network calls before TTL expires', async () => {
106+
let mockTime = 1000;
107+
const timeMock = () => mockTime;
108+
109+
mockGitHubContributors([mockRawCore]);
110+
const getContributors = createContributorsList({
111+
isProd: true,
112+
ttlMs: 5000,
113+
now: timeMock,
114+
});
115+
116+
// First call hits the network
117+
const firstResult = await getContributors();
118+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
119+
120+
// Second call within TTL returns cached data without calling fetch again
121+
mockTime = 4000; // +3000ms elapsed (TTL is 5000)
122+
const secondResult = await getContributors();
123+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
124+
expect(secondResult).toEqual(firstResult);
125+
});
126+
127+
it('should refresh from network after TTL expires', async () => {
128+
let mockTime = 1000;
129+
const timeMock = () => mockTime;
130+
131+
mockGitHubContributors([mockRawCore]);
132+
const getContributors = createContributorsList({
133+
isProd: true,
134+
ttlMs: 5000,
135+
now: timeMock,
136+
});
137+
138+
await getContributors(); // Fetch #1
139+
140+
// Advance time past expiration
141+
mockTime = 7000; // +6000ms elapsed
142+
await getContributors(); // Fetch #2
143+
144+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
145+
});
146+
147+
it('should gracefully return fallback empty arrays if first network request fails', async () => {
148+
mockGitHubContributors([], 500);
149+
const getContributors = createContributorsList({ isProd: true });
150+
151+
const result = await getContributors();
152+
153+
expect(errorSpy).toHaveBeenCalled();
154+
expect(result.core).toHaveLength(3); // Hardcoded fallback defaults
155+
expect(result.external).toHaveLength(0);
156+
});
157+
158+
it('should fall back to last known cached values if network request fails after a success', async () => {
159+
let mockTime = 1000;
160+
const timeMock = () => mockTime;
161+
162+
mockGitHubContributors([mockRawExternal]);
163+
const getContributors = createContributorsList({
164+
isProd: true,
165+
ttlMs: 1000,
166+
now: timeMock,
167+
});
168+
169+
await getContributors();
170+
171+
mockTime = 3000;
172+
mockGitHubContributors([], 500);
173+
174+
const fallbackResult = await getContributors();
175+
176+
expect(errorSpy).toHaveBeenCalled();
177+
expect(fallbackResult.external).toHaveLength(1);
178+
expect(fallbackResult.external?.[0]?.username).toBe('GhostCoder');
179+
});
180+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import type { Contributor, ContributorSummary } from '@fxmanager/shared/types';
2+
import { isProduction } from './utils';
3+
4+
const GITHUB_CONTRIBUTORS =
5+
'https://api.github.com/repos/fxManagerProject/fxManager/contributors';
6+
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour
7+
const REQUEST_TIMEOUT_MS = 5_000;
8+
9+
interface RawContributor {
10+
id: number;
11+
login: string;
12+
node_id: string;
13+
avatar_url: string;
14+
gravatar_id: string;
15+
url: string;
16+
html_url: string;
17+
followers_url: string;
18+
following_url: string;
19+
gists_url: string;
20+
starred_url: string;
21+
subscriptions_url: string;
22+
organizations_url: string;
23+
repos_url: string;
24+
events_url: string;
25+
received_events_url: string;
26+
type: string;
27+
user_view_type: string;
28+
site_admin: boolean;
29+
contributions: number;
30+
}
31+
32+
export const CORE_CONTRIBUTORS: Record<string, Contributor> = {
33+
Maximus7474: {
34+
kofi: 'Maximus7474',
35+
username: 'Maximus7474',
36+
},
37+
FjamZoo: {
38+
kofi: 'FjamZoo',
39+
username: 'FjamZoo',
40+
},
41+
andreutu: {
42+
username: 'andreutu',
43+
},
44+
};
45+
46+
async function fetchContributors(): Promise<RawContributor[]> {
47+
const response = await fetch(GITHUB_CONTRIBUTORS, {
48+
headers: { 'User-Agent': 'fxManager-Updater' },
49+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
50+
});
51+
52+
if (!response.ok)
53+
throw new Error(`${response.status} - ${response.statusText}`);
54+
55+
const data = (await response.json()) as RawContributor[];
56+
57+
return data;
58+
}
59+
60+
/**
61+
* TTL-cached version status provider for the API/UI. Mirrors the
62+
* recommended-artifact fetcher: caches success, falls back to the last known
63+
* value (or a safe "no update" state) on failure so it never blocks callers.
64+
*/
65+
export function createContributorsList(opts: {
66+
ttlMs?: number;
67+
now?: () => number;
68+
isProd: boolean;
69+
}) {
70+
const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;
71+
const now = opts?.now ?? Date.now;
72+
let cache: { value: ContributorSummary; expiresAt: number } | null = null;
73+
74+
const noUpdate = (): ContributorSummary => ({
75+
core: Object.values(CORE_CONTRIBUTORS),
76+
external: [],
77+
});
78+
79+
return async function getContributors(): Promise<ContributorSummary> {
80+
if (!opts.isProd) return noUpdate();
81+
if (cache && cache.expiresAt > now()) return cache.value;
82+
83+
try {
84+
const data = await fetchContributors();
85+
86+
const value: ContributorSummary = {
87+
core: [],
88+
external: [],
89+
};
90+
91+
for (const contributor of data) {
92+
if (CORE_CONTRIBUTORS[contributor.login]) {
93+
value.core.push({
94+
...CORE_CONTRIBUTORS[contributor.login],
95+
username: contributor.login,
96+
image: contributor.avatar_url,
97+
contributions: contributor.contributions,
98+
});
99+
} else if (contributor.type === 'User') {
100+
value.external.push({
101+
username: contributor.login,
102+
image: contributor.avatar_url,
103+
contributions: contributor.contributions,
104+
});
105+
}
106+
}
107+
108+
cache = { value, expiresAt: now() + ttlMs };
109+
return value;
110+
} catch (err) {
111+
console.error(
112+
`[version] Could not fetch contributors:`,
113+
(err as Error).message,
114+
);
115+
return cache?.value ?? noUpdate();
116+
}
117+
};
118+
}
119+
120+
export const getContributorsList = createContributorsList({
121+
isProd: isProduction,
122+
});

apps/core/src/routes/api/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import MigrateModule from './migrate';
1111
import DisconnectsModule from './disconnects';
1212
import PerfModule from './perf';
1313
import ConfigModule from './config';
14+
import { getContributorsList } from '../../common/contributors';
1415

1516
const apiRoutes: RouteModule['handler'] = async (fastify, options) => {
1617
fastify.register(SetupModule.handler, {
@@ -71,6 +72,11 @@ const apiRoutes: RouteModule['handler'] = async (fastify, options) => {
7172
...options,
7273
prefix: ConfigModule.prefix,
7374
});
75+
76+
fastify.get('/contributors', async (_request, reply) => {
77+
const contributors = await getContributorsList();
78+
return reply.code(200).send(contributors);
79+
});
7480
};
7581

7682
export default apiRoutes;

0 commit comments

Comments
 (0)