Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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: 4 additions & 4 deletions apps/site/components/Common/Supporters/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ import type { Supporter } from '#site/types';
import type { FC } from 'react';

type SupportersListProps = {
supporters: Array<Supporter<'opencollective'>>;
supporters: Array<Supporter<'opencollective' | 'github'>>;
};

const SupportersList: FC<SupportersListProps> = ({ supporters }) => (
<div className="flex max-w-full flex-wrap items-center justify-center gap-1">
{supporters.map(({ name, image, profile }) => (
{supporters.map(({ name, image, source, url }) => (
<Avatar
nickname={name}
fallback={getAcronymFromString(name)}
image={image}
key={name}
url={profile}
key={`${source}:${name}`}
url={url}
/>
))}
</div>
Expand Down
221 changes: 216 additions & 5 deletions apps/site/next-data/generators/supportersData.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,85 @@
import { OPENCOLLECTIVE_MEMBERS_URL } from '#site/next.constants.mjs';
import {
OPENCOLLECTIVE_MEMBERS_URL,
GITHUB_GRAPHQL_URL,
GITHUB_READ_API_KEY,
} from '#site/next.constants.mjs';
import { fetchWithRetry } from '#site/next.fetch.mjs';
import { shuffle } from '#site/util/array';

const SPONSORSHIPS_QUERY = `
query ($cursor: String) {
organization(login: "nodejs") {
sponsorshipsAsMaintainer(
first: 100
includePrivate: false
after: $cursor
activeOnly: false
) {
nodes {
sponsor: sponsorEntity {
...on User {
id: databaseId
name
login
avatarUrl
url
websiteUrl
}
...on Organization {
id: databaseId
name
login
avatarUrl
url
websiteUrl
}
}
}
pageInfo {
endCursor
startCursor
hasNextPage
hasPreviousPage
}
}
}
}
`;

const DONATIONS_QUERY = `
query {
organization(login: "nodejs") {
sponsorsActivities(first: 100, includePrivate: false) {
nodes {
id
sponsor {
...on User {
id: databaseId
name
login
avatarUrl
url
websiteUrl
}
...on Organization {
id: databaseId
name
login
avatarUrl
url
websiteUrl
}
}
timestamp
tier: sponsorsTier {
monthlyPriceInDollars
isOneTime
}
}
}
}
}
`;

/**
* Fetches supporters data from Open Collective API, filters active backers,
Expand All @@ -15,12 +95,11 @@ async function fetchOpenCollectiveData() {
const members = payload
.filter(({ role, isActive }) => role === 'BACKER' && isActive)
.sort((a, b) => b.totalAmountDonated - a.totalAmountDonated)
.map(({ name, website, image, profile }) => ({
.map(({ name, image, profile }) => ({
name,
image,
url: website,
// If profile starts with the guest- prefix, it's a non-existing account
profile: profile.startsWith('https://opencollective.com/guest-')
url: profile.startsWith('https://opencollective.com/guest-')
? undefined
: profile,
source: 'opencollective',
Expand All @@ -29,4 +108,136 @@ async function fetchOpenCollectiveData() {
return members;
}

export default fetchOpenCollectiveData;
/**
* Fetches supporters data from Github API, filters active backers,
* and maps it to the Supporters type.
*
* @returns {Promise<Array<import('#site/types/supporters').GitHubSponsorSupporter>>} Array of supporters
*/
async function fetchGithubSponsorsData() {
if (!GITHUB_READ_API_KEY) {
return [];
}

const [sponsorships, donations] = await Promise.all([
fetchSponsorshipsQuery(),
fetchDonationsQuery(),
]);

return [...sponsorships, ...donations];
}

async function fetchSponsorshipsQuery() {
const sponsors = [];
let cursor = null;

while (true) {
const data = await graphql(
SPONSORSHIPS_QUERY,
cursor ? { cursor } : undefined
);

if (data.errors) {
throw new Error(JSON.stringify(data.errors));
}

const nodeRes = data.data.organization?.sponsorshipsAsMaintainer;
if (!nodeRes) {
break;
}

const { nodes, pageInfo } = nodeRes;
const mapped = nodes.map(n => {
const s = n.sponsor || n.sponsorEntity; // support different field names
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GraphQL alias makes n.sponsorEntity fallback unreachable dead code

Low Severity

The || n.sponsorEntity fallback in both fetchSponsorshipsQuery and fetchDonationsQuery is unreachable. In SPONSORSHIPS_QUERY, the GraphQL alias sponsor: sponsorEntity ensures the response field is always sponsorsponsorEntity never appears in the JSON response. In DONATIONS_QUERY, the field is natively named sponsor. So n.sponsorEntity is always undefined, making the fallback dead code with a misleading comment.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 492c9ed. Configure here.

return {
name: s?.name || s?.login || null,
image: s?.avatarUrl || null,
url: s?.url || null,
source: 'github',
};
});

sponsors.push(...mapped);

if (!pageInfo.hasNextPage) {
break;
}

cursor = pageInfo.endCursor;
}

return sponsors;
}

async function fetchDonationsQuery() {
const data = await graphql(DONATIONS_QUERY);

if (data.errors) {
throw new Error(JSON.stringify(data.errors));
}

const nodeRes = data.data.organization?.sponsorsActivities;
if (!nodeRes) {
return [];
}

const { nodes } = nodeRes;
return nodes.map(n => {
const s = n.sponsor || n.sponsorEntity; // support different field names
return {
name: s?.name || s?.login || null,
image: s?.avatarUrl || null,
url: s?.url || null,
source: 'github',
};
});
}

const graphql = async (query, variables = {}) => {
const res = await fetchWithRetry(GITHUB_GRAPHQL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${GITHUB_READ_API_KEY}`,
},
body: JSON.stringify({ query, variables }),
});

if (!res.ok) {
const text = await res.text();
throw new Error(`GitHub API error: ${res.status} ${text}`);
}

return res.json();
};

/**
* Fetches supporters data from Open Collective API and GitHub Sponsors, filters active backers,
* and maps it to the Supporters type.
*
* @returns {Promise<Array<import('#site/types/supporters').OpenCollectiveSupporter | import('#site/types/supporters').GitHubSponsorSupporter>>} Array of supporters
*/
async function sponsorsData() {
const seconds = 300; // Change every 5 minutes
const seed = Math.floor(Date.now() / (seconds * 1000));

const sponsorsResults = await Promise.allSettled([
fetchGithubSponsorsData(),
fetchOpenCollectiveData(),
]);

const sponsors = sponsorsResults.flatMap(result => {
if (result.status === 'fulfilled') {
return result.value;
}

console.error('Supporters data source failed:', result.reason);
return [];
});

const shuffled = await shuffle(sponsors, seed);

return shuffled;
}
Comment on lines +220 to +241
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces substantial new external-fetching logic (GitHub GraphQL pagination, error handling, and merging with OpenCollective) but there are still no generator tests for supportersData.mjs (other generators like releaseData/vulnerabilities have tests). Adding tests that mock fetch to cover pagination, missing token behavior, and de-duping/shape mapping would help prevent regressions.

Copilot uses AI. Check for mistakes.

export default sponsorsData;
7 changes: 6 additions & 1 deletion apps/site/next.constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export const ORAMA_CLOUD_PROJECT_ID =
*
* Note: This has no NEXT_PUBLIC prefix as it should not be exposed to the Browser.
*/
export const GITHUB_API_KEY = process.env.NEXT_GITHUB_API_KEY || '';
export const GITHUB_READ_API_KEY = process.env.NEXT_GITHUB_READ_API_KEY || '';

/**
* The resource we point people to when discussing internationalization efforts.
Expand Down Expand Up @@ -220,3 +220,8 @@ export const VULNERABILITIES_URL =
*/
export const OPENCOLLECTIVE_MEMBERS_URL =
'https://opencollective.com/nodejs/members/all.json';

/**
* The location of the GitHub GraphQL API
*/
export const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql';
4 changes: 2 additions & 2 deletions apps/site/pages/en/about/partners.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ without we can't test and release new versions of Node.js.

## Supporters

Supporters are individuals and organizations that provide financial support through
[OpenCollective](https://opencollective.com/nodejs) of the Node.js project.
Supporters are individuals and organizations who financially support the Node.js project
through [OpenCollective](https://opencollective.com/nodejs) and [GitHub Sponsors](https://github.com/sponsors/nodejs).

<WithSupporters />

Expand Down
1 change: 1 addition & 0 deletions apps/site/types/supporters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export type Supporter<T extends string> = {
};

export type OpenCollectiveSupporter = Supporter<'opencollective'>;
export type GitHubSponsorSupporter = Supporter<'github'>;
Loading