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
11 changes: 3 additions & 8 deletions apps/site/components/Common/Supporters/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,13 @@ 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 }, i) => (
<Avatar
nickname={name}
image={image}
key={`${name}-${i}`}
url={profile}
/>
{supporters.map(({ name, image, url }, i) => (
<Avatar nickname={name} image={image} key={`${name}-${i}`} url={url} />
))}
</div>
);
Expand Down
201 changes: 196 additions & 5 deletions apps/site/next-data/generators/supportersData.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { OPENCOLLECTIVE_MEMBERS_URL } from '#site/next.constants.mjs';
import {
OPENCOLLECTIVE_MEMBERS_URL,
GITHUB_GRAPHQL_URL,
GITHUB_API_KEY,
} from '#site/next.constants.mjs';
import { shuffle } from '#site/util/array';
import { fetchWithRetry } from '#site/util/fetch';

/**
Expand All @@ -15,15 +20,201 @@ 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,
profile,
url: profile,
source: 'opencollective',
}));

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() {
Copy link
Member

Choose a reason for hiding this comment

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

I think we can break this function in two: fetchSponsorshipsQuery and fetchDonationsQuery, just to make them a little easier to read.

Copy link
Member Author

Choose a reason for hiding this comment

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

sgtm

if (!GITHUB_API_KEY) {
return [];
}

const sponsors = [];

// Fetch sponsorship pages
let cursor = null;

while (true) {
const query = sponsorshipsQuery(cursor);
const data = await graphql(query);

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 || n.sponsorEntity; // support different field names
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;
}

const query = donationsQuery();
const data = await graphql(query);

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

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

const { nodes } = nodeRes;
Comment on lines 89 to 94

This comment was marked as outdated.

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

sponsors.push(...mapped);
Comment on lines +95 to +105

This comment was marked as outdated.


return sponsors;
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.

The two GitHub queries (sponsorshipsAsMaintainer and sponsorsActivities) will likely return overlapping sponsors, but the code concatenates both result sets without de-duplication. Consider de-duping by a stable key (e.g., sponsor databaseId/login/url) before shuffling to avoid repeated avatars.

Suggested change
return sponsors;
// De-duplicate sponsors from both queries using a stable key
const seen = new Set();
const uniqueSponsors = [];
for (const sponsor of sponsors) {
const key = sponsor.url || `${sponsor.name || ''}::${sponsor.image || ''}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
uniqueSponsors.push(sponsor);
}
return uniqueSponsors;

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

hmm, even though it’s a good idea to try to deduplicate it, I don’t think this is the right approach. I’ll have to look into the logic of https://github.com/antfu-collective/sponsorkit33

}

function sponsorshipsQuery(cursor = null) {
return `
query {
organization(login: "nodejs") {
sponsorshipsAsMaintainer (first: 100, includePrivate: false, after: "${cursor}", activeOnly: false) {
Comment on lines +113 to +114
Copy link
Member Author

Choose a reason for hiding this comment

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

definitely, I’d prefer that we only show sponsors that are active, but why do we want to show everyone? see #8268.

Copy link
Member Author

Choose a reason for hiding this comment

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

wdyt @nodejs/nodejs-website @nodejs/marketing ?

nodes {
sponsor: sponsorEntity {
...on User {
id: databaseId,
name,
login,
avatarUrl,
url,
websiteUrl
}
...on Organization {
id: databaseId,
name,
login,
avatarUrl,
url,
websiteUrl
}
},
}
Comment on lines +114 to +134
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.

The GraphQL query strings contain trailing commas after fields (e.g., id: databaseId,, name,, and a comma after the sponsorEntity { ... } block). GitHub’s GraphQL API does not accept comma-separated field selections, so this query will fail to parse. Remove the commas (and any trailing commas) from the query documents.

Copilot uses AI. Check for mistakes.
pageInfo {
endCursor
startCursor
hasNextPage
hasPreviousPage
}
}
}
}`;
}

function donationsQuery() {
return `
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
}
}
}
}
}`;
}

Comment on lines +110 to +181
Copy link
Member

Choose a reason for hiding this comment

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

These can be constants, cursor can be a GraphQL variable

const graphql = async (query, variables = {}) => {
const res = await fetch(GITHUB_GRAPHQL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${GITHUB_API_KEY}`,
},
body: JSON.stringify({ query, variables }),
});
Comment on lines +182 to +190
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.

For consistency/reliability, consider using fetchWithRetry for the GitHub GraphQL POST as well (this file already uses it for OpenCollective). That would reduce transient build/runtime failures due to network timeouts when generating supporters data.

Copilot uses AI. Check for mistakes.

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 sponsors = await Promise.all([
fetchGithubSponsorsData(),
fetchOpenCollectiveData(),
]);

const shuffled = await shuffle(sponsors.flat(), seed);

return shuffled;
}
Comment on lines +206 to +218
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;
5 changes: 5 additions & 0 deletions apps/site/next.constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,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';
2 changes: 1 addition & 1 deletion apps/site/pages/en/about/partners.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ 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.
[OpenCollective](https://opencollective.com/nodejs) and [GitHub Sponsors](https://github.com/sponsors/nodejs) of the Node.js project.
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.

The sentence reads a bit ungrammatically as written (“through … and … of the Node.js project”). Consider rephrasing to “through … and … for the Node.js project” (or similar) to make the relationship clear.

Copilot uses AI. Check for mistakes.

<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'>;
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.

Type name uses Github... capitalization. Elsewhere the codebase predominantly uses GitHub (e.g., GitHubApiFile, GitHubIcon). Consider renaming this to GitHubSponsorSupporter for consistency (and update JSDoc imports/usages accordingly).

Suggested change
export type GithubSponsorSupporter = Supporter<'github'>;
export type GitHubSponsorSupporter = Supporter<'github'>;

Copilot uses AI. Check for mistakes.
Loading