From e50a9e4b5630c21832af629d530a92a77c8f5756 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Sun, 21 Jun 2026 15:40:53 +0200 Subject: [PATCH 1/3] wip: sponsor list --- .github/workflows/sponsors.yml | 34 +++++++++++ .gitignore | 1 + README.md | 16 ++++++ commands/sponsors.js | 31 ++++++++++ github-api.js | 100 ++++++++++++++++++++++++++++++++- index.js | 6 +- 6 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/sponsors.yml create mode 100644 commands/sponsors.js diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml new file mode 100644 index 0000000..8eb4807 --- /dev/null +++ b/.github/workflows/sponsors.yml @@ -0,0 +1,34 @@ +name: List Sponsors + +on: + # Allow manual trigger + workflow_dispatch: + +permissions: + contents: read + +jobs: + list-sponsors: + permissions: + contents: read + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + check-latest: true + node-version: "24" + + - name: Install dependencies + run: npm i --ignore-scripts + + - name: List sponsors + env: + GITHUB_TOKEN: ${{ secrets.SPONSOR_TOKEN }} + run: node index.js sponsors --org fastify diff --git a/.gitignore b/.gitignore index fee5e44..6f7b031 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,4 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vscode/ +sponsors.json diff --git a/README.md b/README.md index 725ecf2..90a7da1 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,22 @@ For the fastify organization, the command would look like: node --env-file=.env index.js emeritus --monthsInactiveThreshold 24 ``` +### List sponsors + +This command reads the organization's sponsors and lists them. +Currently it fetches the GitHub Sponsors of the organization (Open Collective backers +will be added later). The list is logged and written to a `sponsors.json` file. + +```bash +node --env-file=.env index.js sponsors --org +``` + +For the fastify organization, the command would look like: + +```bash +node --env-file=.env index.js sponsors +``` + ## License Licensed under [MIT](./LICENSE). diff --git a/commands/sponsors.js b/commands/sponsors.js new file mode 100644 index 0000000..919e76f --- /dev/null +++ b/commands/sponsors.js @@ -0,0 +1,31 @@ +import { writeFileSync } from 'node:fs' + +const OUTPUT_FILE = './sponsors.json' + +/** + * Fetches and lists all sponsors of an organization. + * Currently fetches GitHub Sponsors; Open Collective backers will be added later. + * The result is logged and written to a JSON file for later inspection. + * @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps - Dependencies. + * @param {{ org: string }} options - Command options. + * @returns {Promise} + */ +export default async function sponsors ({ client, logger }, { org }) { + logger.info('Running sponsors command for organization: %s', org) + + const githubSponsors = await client.getGithubSponsors(org) + logger.info('Total GitHub sponsors: %s', githubSponsors.length) + + for (const sponsor of githubSponsors) { + const displayName = sponsor.name ? `${sponsor.login} (${sponsor.name})` : sponsor.login + logger.info('- @%s — %s', displayName, sponsor.tier ?? 'no tier') + } + + const result = { + github: githubSponsors, + openCollective: [], + } + + writeFileSync(OUTPUT_FILE, JSON.stringify(result, null, 2)) + logger.info('Sponsors written to %s', OUTPUT_FILE) +} diff --git a/github-api.js b/github-api.js index 888c955..ec8f559 100644 --- a/github-api.js +++ b/github-api.js @@ -201,6 +201,69 @@ export default class AdminClient { return membersData } + /** + * Fetches all GitHub Sponsors of an organization using the GraphQL API, handling pagination. + * Public sponsors are always returned; private sponsors are included only when the + * authenticated token belongs to the organization. + * @param {string} orgName - The login name of the GitHub organization. + * @returns {Promise} Array of normalized sponsor objects. + */ + async getGithubSponsors (orgName) { + let cursor = null + let hasNextPage = true + const sponsorsData = [] + + const sponsorsQuery = ` + query ($orgName: String!, $cursor: String) { + organization(login: $orgName) { + sponsorshipsAsMaintainer(first: 100, after: $cursor, includePrivate: true) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + createdAt + privacyLevel + isOneTimePayment + tier { + name + monthlyPriceInDollars + isOneTime + } + sponsorEntity { + __typename + ... on User { + login + name + url + } + ... on Organization { + login + name + url + } + } + } + } + } + } + ` + + while (hasNextPage) { + const variables = { orgName, cursor } + const response = await this.graphqlClient(sponsorsQuery, variables) + + const { nodes, pageInfo } = response.organization.sponsorshipsAsMaintainer + sponsorsData.push(...nodes.map(transformGqlSponsor)) + + cursor = pageInfo.endCursor + hasNextPage = pageInfo.hasNextPage + } + + return sponsorsData + } + /** * Fetches user information from GitHub using the GraphQL API. * @param {string} username - The GitHub username. @@ -341,6 +404,27 @@ function transformGqlMember ({ node }) { } } +/** + * Transforms a GitHub GraphQL sponsorship node into a normalized sponsor object. + * @param {object} sponsorship - The GitHub GraphQL sponsorship node. + * @returns {Sponsor} The normalized sponsor object. + */ +function transformGqlSponsor (sponsorship) { + const entity = sponsorship.sponsorEntity ?? {} + return { + source: 'github', + login: entity.login ?? null, + name: entity.name ?? null, + url: entity.url ?? null, + type: entity.__typename ?? null, + tier: sponsorship.tier?.name ?? null, + monthlyPriceInDollars: sponsorship.tier?.monthlyPriceInDollars ?? null, + isOneTime: sponsorship.isOneTimePayment ?? sponsorship.tier?.isOneTime ?? null, + privacyLevel: sponsorship.privacyLevel ?? null, + createdAt: sponsorship.createdAt ?? null, + } +} + /** * Converts a date string to a Date object, or returns null if the string is falsy. * @param {string} dateStr - The date string to convert. @@ -375,4 +459,18 @@ function toDate (dateStr) { * @property {Date|null} lastIssue - The date of the user's last issue contribution. * @property {Date|null} lastCommit - The date of the user's last commit contribution. * @property {object[]} [socialAccounts] - The user's social accounts. - */ \ No newline at end of file + */ + +/** + * @typedef {object} Sponsor + * @property {string} source - The funding platform the sponsor comes from (e.g. 'github'). + * @property {string|null} login - The sponsor's login/handle. + * @property {string|null} name - The sponsor's display name. + * @property {string|null} url - The sponsor's profile URL. + * @property {string|null} type - The sponsor entity type ('User' or 'Organization'). + * @property {string|null} tier - The sponsorship tier name. + * @property {number|null} monthlyPriceInDollars - The tier's monthly price in dollars. + * @property {boolean|null} isOneTime - Whether the sponsorship is a one-time payment. + * @property {string|null} privacyLevel - The sponsorship privacy level ('PUBLIC' or 'PRIVATE'). + * @property {string|null} createdAt - When the sponsorship started (ISO date string). + */ diff --git a/index.js b/index.js index a21ec82..88f5dbb 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,7 @@ import AdminClient from './github-api.js' import onboard from './commands/onboard.js' import offboard from './commands/offboard.js' import emeritus from './commands/emeritus.js' +import sponsors from './commands/sponsors.js' const logger = pino({ level: 'debug', @@ -19,7 +20,7 @@ const logger = pino({ }) const options = { - commands: ['onboard', 'offboard', 'emeritus'], + commands: ['onboard', 'offboard', 'emeritus', 'sponsors'], options: { dryRun: { type: 'boolean', default: false }, username: { type: 'string', multiple: false, default: undefined }, @@ -71,4 +72,7 @@ switch (command) { case 'emeritus': await emeritus(technicalOptions, { dryRun, org, monthsInactiveThreshold }) break + case 'sponsors': + await sponsors(technicalOptions, { org }) + break } From 843cbda5dcf472051ec2b3582cf5a260f291454d Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Sun, 21 Jun 2026 16:01:14 +0200 Subject: [PATCH 2/3] wip: opencollective --- README.md | 16 +++- commands/sponsors.js | 49 +++++++++-- github-api.js | 191 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 234 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 90a7da1..cacf1e8 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,11 @@ node --env-file=.env index.js emeritus --monthsInactiveThreshold 24 ### List sponsors -This command reads the organization's sponsors and lists them. -Currently it fetches the GitHub Sponsors of the organization (Open Collective backers -will be added later). The list is logged and written to a `sponsors.json` file. +This command reads the organization's sponsors from both GitHub Sponsors and +Open Collective and lists them. Recurring sponsors that stopped paying (cancelled +or overdue) are flagged as `lapsed`. The list is logged and written to a +`sponsors.json` file with three keys: `github`, `openCollective` and `flagged` +(the lapsed sponsors across both sources). ```bash node --env-file=.env index.js sponsors --org @@ -64,6 +66,14 @@ For the fastify organization, the command would look like: node --env-file=.env index.js sponsors ``` +Reading Open Collective backers is public and needs no token. Open Collective +exposes per-charge data, so a lapsed contribution shows its `lastChargedAt` / +`nextChargeDate`. GitHub does not expose individual charges, so a GitHub sponsor +is only flagged as `lapsed` when a recurring sponsorship has been cancelled +(its `lastChargedAt` is always `null`). To raise the Open Collective rate limit +you may optionally set `OC_PERSONAL_TOKEN` in `.env` (a personal token from your +own account — Open Collective has no org-level token). + ## License Licensed under [MIT](./LICENSE). diff --git a/commands/sponsors.js b/commands/sponsors.js index 919e76f..53dffe3 100644 --- a/commands/sponsors.js +++ b/commands/sponsors.js @@ -3,8 +3,8 @@ import { writeFileSync } from 'node:fs' const OUTPUT_FILE = './sponsors.json' /** - * Fetches and lists all sponsors of an organization. - * Currently fetches GitHub Sponsors; Open Collective backers will be added later. + * Fetches and lists all sponsors of an organization from GitHub Sponsors and + * Open Collective. Recurring sponsors that stopped paying are flagged as lapsed. * The result is logged and written to a JSON file for later inspection. * @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps - Dependencies. * @param {{ org: string }} options - Command options. @@ -15,17 +15,54 @@ export default async function sponsors ({ client, logger }, { org }) { const githubSponsors = await client.getGithubSponsors(org) logger.info('Total GitHub sponsors: %s', githubSponsors.length) + logSponsors(logger, githubSponsors) - for (const sponsor of githubSponsors) { - const displayName = sponsor.name ? `${sponsor.login} (${sponsor.name})` : sponsor.login - logger.info('- @%s — %s', displayName, sponsor.tier ?? 'no tier') + const openCollectiveSponsors = await client.getOpenCollectiveSponsors(org) + logger.info('Total Open Collective backers: %s', openCollectiveSponsors.length) + logSponsors(logger, openCollectiveSponsors) + + const flagged = [...githubSponsors, ...openCollectiveSponsors].filter((sponsor) => sponsor.lapsed) + if (flagged.length > 0) { + logger.warn('%s lapsed sponsor(s) need attention:', flagged.length) + for (const sponsor of flagged) { + logger.warn( + ' ⚠ %s [%s] — %s, last charged %s', + displayName(sponsor), + sponsor.source, + sponsor.tier ?? 'no tier', + sponsor.lastChargedAt ?? 'unknown' + ) + } } const result = { github: githubSponsors, - openCollective: [], + openCollective: openCollectiveSponsors, + flagged, } writeFileSync(OUTPUT_FILE, JSON.stringify(result, null, 2)) logger.info('Sponsors written to %s', OUTPUT_FILE) } + +/** + * Logs one line per sponsor, marking lapsed recurring sponsors. + * @param {import('pino').Logger} logger - The logger instance. + * @param {import('../github-api.js').Sponsor[]} sponsorList - The sponsors to log. + * @returns {void} + */ +function logSponsors (logger, sponsorList) { + for (const sponsor of sponsorList) { + const mark = sponsor.lapsed ? '⚠ lapsed' : sponsor.tier ?? 'no tier' + logger.info('- %s — %s', displayName(sponsor), mark) + } +} + +/** + * Builds a human-friendly label for a sponsor. + * @param {import('../github-api.js').Sponsor} sponsor - The sponsor. + * @returns {string} The display label. + */ +function displayName (sponsor) { + return sponsor.name ? `${sponsor.login} (${sponsor.name})` : sponsor.login +} diff --git a/github-api.js b/github-api.js index ec8f559..24e08d0 100644 --- a/github-api.js +++ b/github-api.js @@ -213,10 +213,13 @@ export default class AdminClient { let hasNextPage = true const sponsorsData = [] + // activeOnly: false also returns ended sponsorships, so a recurring sponsor + // that cancelled (isActive: false) can be flagged as lapsed. GitHub does not + // expose individual charges, so there is no per-payment date to read. const sponsorsQuery = ` query ($orgName: String!, $cursor: String) { organization(login: $orgName) { - sponsorshipsAsMaintainer(first: 100, after: $cursor, includePrivate: true) { + sponsorshipsAsMaintainer(first: 100, after: $cursor, includePrivate: true, activeOnly: false) { totalCount pageInfo { hasNextPage @@ -224,6 +227,8 @@ export default class AdminClient { } nodes { createdAt + tierSelectedAt + isActive privacyLevel isOneTimePayment tier { @@ -264,6 +269,101 @@ export default class AdminClient { return sponsorsData } + /** + * Fetches all Open Collective backers of a collective using the public GraphQL v2 API. + * Reading backers is public and needs no authentication; if `OC_PERSONAL_TOKEN` is set + * it is sent to raise rate limits. Drives off incoming orders (the source of payment + * truth) so recurring contributions that lapsed can be flagged. + * @param {string} slug - The Open Collective collective slug (e.g. 'fastify'). + * @returns {Promise} Array of normalized sponsor objects, one per backer. + */ + async getOpenCollectiveSponsors (slug) { + const ordersQuery = ` + query ($slug: String!, $limit: Int!, $offset: Int!) { + account(slug: $slug) { + orders(filter: INCOMING, limit: $limit, offset: $offset) { + totalCount + nodes { + id + status + frequency + lastChargedAt + nextChargeDate + createdAt + amount { value currency } + tier { name } + fromAccount { name slug type imageUrl website } + } + } + } + } + ` + + const limit = 100 + let offset = 0 + let totalCount = Infinity + const orders = [] + + while (offset < totalCount) { + const variables = { slug, limit, offset } + const response = await this.#openCollectiveRequest(ordersQuery, variables) + + const ordersPage = response.account?.orders + if (!ordersPage) { + throw new Error(`Open Collective collective not found: ${slug}`) + } + + totalCount = ordersPage.totalCount + orders.push(...ordersPage.nodes) + offset += limit + } + + // A backer can have several orders over time (e.g. a cancelled monthly plus a + // later one-off). Collapse to one representative order per backer so the lapsed + // flag reflects their current standing: prefer a recurring order, newest first. + const now = new Date() + const byBacker = new Map() + for (const order of orders) { + const key = order.fromAccount?.slug ?? order.id + const current = byBacker.get(key) + if (!current || isMoreRepresentativeOrder(order, current)) { + byBacker.set(key, order) + } + } + + return [...byBacker.values()].map((order) => transformOcOrder(order, now)) + } + + /** + * Sends a GraphQL request to the Open Collective v2 API. + * @param {string} query - The GraphQL query string. + * @param {object} variables - The query variables. + * @returns {Promise} The `data` payload of the response. + */ + async #openCollectiveRequest (query, variables) { + const headers = { 'Content-Type': 'application/json' } + if (env.OC_PERSONAL_TOKEN) { + headers['Personal-Token'] = env.OC_PERSONAL_TOKEN + } + + const response = await fetch('https://api.opencollective.com/graphql/v2', { + method: 'POST', + headers, + body: JSON.stringify({ query, variables }), + }) + + if (!response.ok) { + throw new Error(`Open Collective API request failed: ${response.status} ${response.statusText}`) + } + + const payload = await response.json() + if (payload.errors) { + throw new Error(`Open Collective API error: ${payload.errors.map((e) => e.message).join('; ')}`) + } + + return payload.data + } + /** * Fetches user information from GitHub using the GraphQL API. * @param {string} username - The GitHub username. @@ -406,11 +506,16 @@ function transformGqlMember ({ node }) { /** * Transforms a GitHub GraphQL sponsorship node into a normalized sponsor object. + * A recurring sponsorship that is no longer active is flagged as lapsed; completed + * one-time payments are never lapsed. GitHub exposes no per-charge date, so + * `lastChargedAt`/`nextChargeDate` are always null for this source. * @param {object} sponsorship - The GitHub GraphQL sponsorship node. * @returns {Sponsor} The normalized sponsor object. */ function transformGqlSponsor (sponsorship) { const entity = sponsorship.sponsorEntity ?? {} + const isOneTime = sponsorship.isOneTimePayment ?? sponsorship.tier?.isOneTime ?? false + const isActive = sponsorship.isActive ?? true return { source: 'github', login: entity.login ?? null, @@ -418,10 +523,64 @@ function transformGqlSponsor (sponsorship) { url: entity.url ?? null, type: entity.__typename ?? null, tier: sponsorship.tier?.name ?? null, - monthlyPriceInDollars: sponsorship.tier?.monthlyPriceInDollars ?? null, - isOneTime: sponsorship.isOneTimePayment ?? sponsorship.tier?.isOneTime ?? null, - privacyLevel: sponsorship.privacyLevel ?? null, + amount: sponsorship.tier?.monthlyPriceInDollars ?? null, + currency: 'USD', + frequency: isOneTime ? 'ONETIME' : 'MONTHLY', + isOneTime, + status: isActive ? 'ACTIVE' : 'INACTIVE', + lastChargedAt: null, + nextChargeDate: null, createdAt: sponsorship.createdAt ?? null, + privacyLevel: sponsorship.privacyLevel ?? null, + lapsed: !isOneTime && !isActive, + } +} + +/** + * Determines whether an Open Collective order better represents a backer's current + * standing than the one already kept. Recurring orders win over one-time ones; + * among equals, the most recently created wins. + * @param {object} candidate - The order being considered. + * @param {object} current - The order currently kept for this backer. + * @returns {boolean} True if `candidate` should replace `current`. + */ +function isMoreRepresentativeOrder (candidate, current) { + const recurring = (order) => order.frequency === 'MONTHLY' || order.frequency === 'YEARLY' + if (recurring(candidate) !== recurring(current)) { + return recurring(candidate) + } + return new Date(candidate.createdAt) > new Date(current.createdAt) +} + +/** + * Transforms an Open Collective order node into a normalized sponsor object. + * A recurring order is flagged as lapsed when it is no longer ACTIVE or its next + * charge is already overdue. One-time contributions are never lapsed. + * @param {object} order - The Open Collective order node. + * @param {Date} now - The reference time used to detect overdue charges. + * @returns {Sponsor} The normalized sponsor object. + */ +function transformOcOrder (order, now) { + const entity = order.fromAccount ?? {} + const isRecurring = order.frequency === 'MONTHLY' || order.frequency === 'YEARLY' + const isOverdue = order.nextChargeDate ? new Date(order.nextChargeDate) < now : false + return { + source: 'opencollective', + login: entity.slug ?? null, + name: entity.name ?? null, + url: entity.website ?? (entity.slug ? `https://opencollective.com/${entity.slug}` : null), + type: entity.type ?? null, + tier: order.tier?.name ?? null, + amount: order.amount?.value ?? null, + currency: order.amount?.currency ?? null, + frequency: order.frequency ?? null, + isOneTime: order.frequency === 'ONETIME', + status: order.status ?? null, + lastChargedAt: order.lastChargedAt ?? null, + nextChargeDate: order.nextChargeDate ?? null, + createdAt: order.createdAt ?? null, + privacyLevel: null, + lapsed: isRecurring && (order.status !== 'ACTIVE' || isOverdue), } } @@ -463,14 +622,20 @@ function toDate (dateStr) { /** * @typedef {object} Sponsor - * @property {string} source - The funding platform the sponsor comes from (e.g. 'github'). - * @property {string|null} login - The sponsor's login/handle. + * @property {string} source - The funding platform ('github' or 'opencollective'). + * @property {string|null} login - The sponsor's login/handle/slug. * @property {string|null} name - The sponsor's display name. - * @property {string|null} url - The sponsor's profile URL. - * @property {string|null} type - The sponsor entity type ('User' or 'Organization'). - * @property {string|null} tier - The sponsorship tier name. - * @property {number|null} monthlyPriceInDollars - The tier's monthly price in dollars. - * @property {boolean|null} isOneTime - Whether the sponsorship is a one-time payment. - * @property {string|null} privacyLevel - The sponsorship privacy level ('PUBLIC' or 'PRIVATE'). - * @property {string|null} createdAt - When the sponsorship started (ISO date string). + * @property {string|null} url - The sponsor's profile or website URL. + * @property {string|null} type - The sponsor entity type ('User'/'Organization' or 'INDIVIDUAL'/'ORGANIZATION'). + * @property {string|null} tier - The sponsorship/contribution tier name. + * @property {number|null} amount - The contribution amount in `currency` (monthly price for recurring GitHub tiers). + * @property {string|null} currency - The contribution currency (e.g. 'USD'). + * @property {string|null} frequency - The contribution cadence ('ONETIME', 'MONTHLY' or 'YEARLY'). + * @property {boolean} isOneTime - Whether the contribution is a one-time payment. + * @property {string|null} status - The platform-native status (GitHub 'ACTIVE'/'INACTIVE', Open Collective order status). + * @property {string|null} lastChargedAt - When the recurring contribution last charged (Open Collective only; null for GitHub). + * @property {string|null} nextChargeDate - When the recurring contribution is next due (Open Collective only; null for GitHub). + * @property {string|null} createdAt - When the sponsorship/contribution started (ISO date string). + * @property {string|null} privacyLevel - The GitHub sponsorship privacy level ('PUBLIC'/'PRIVATE'); null for Open Collective. + * @property {boolean} lapsed - Whether a recurring sponsor stopped paying (cancelled/overdue); always false for one-time. */ From 2011d331f4088ddd9d5574d4a3be3af526eadbb9 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Sun, 21 Jun 2026 16:13:02 +0200 Subject: [PATCH 3/3] feat: list sponsors --- README.md | 15 ++++++--- commands/sponsors.js | 77 ++++++++++++++++++++++++++++++++------------ github-api.js | 15 +++++++-- 3 files changed, 81 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index cacf1e8..b1398be 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,17 @@ node --env-file=.env index.js emeritus --monthsInactiveThreshold 24 ### List sponsors This command reads the organization's sponsors from both GitHub Sponsors and -Open Collective and lists them. Recurring sponsors that stopped paying (cancelled -or overdue) are flagged as `lapsed`. The list is logged and written to a -`sponsors.json` file with three keys: `github`, `openCollective` and `flagged` -(the lapsed sponsors across both sources). +Open Collective and lists the **recurring tier sponsors** — those whose normalized +monthly contribution reaches at least the lowest tier. Each sponsor is tagged with +its tier (the highest tier its monthly amount meets, rounding down) and the combined +list is ordered by monthly contribution descending. Recurring sponsors that stopped +paying (cancelled or overdue) are flagged as `lapsed`. + +The default tiers are tier 1 = $5/mo, tier 2 = $50/mo, tier 3 = $100/mo and +tier 4 = $300/mo (configurable in `commands/sponsors.js`). One-time payments and +sub-tier contributions are excluded. The result is logged and written to a +`sponsors.json` file with three keys: `tiers` (the tier definitions), `sponsors` +(the combined tier list) and `flagged` (the lapsed tier sponsors). ```bash node --env-file=.env index.js sponsors --org diff --git a/commands/sponsors.js b/commands/sponsors.js index 53dffe3..b7dd5a6 100644 --- a/commands/sponsors.js +++ b/commands/sponsors.js @@ -3,8 +3,22 @@ import { writeFileSync } from 'node:fs' const OUTPUT_FILE = './sponsors.json' /** - * Fetches and lists all sponsors of an organization from GitHub Sponsors and - * Open Collective. Recurring sponsors that stopped paying are flagged as lapsed. + * Fastify's sponsorship tiers, defined by their minimum monthly contribution in + * dollars. A sponsor is assigned the highest tier whose threshold its monthly + * contribution meets (amounts between tiers round down). Sponsors below tier 1 + * are not considered tier sponsors. + */ +const TIERS = [ + { level: 1, monthly: 5 }, + { level: 2, monthly: 50 }, + { level: 3, monthly: 100 }, + { level: 4, monthly: 300 }, +] + +/** + * Fetches all sponsors of an organization from GitHub Sponsors and Open Collective, + * keeps only the recurring tier sponsors (monthly contribution within a tier), + * sorts them by monthly amount descending and flags those that stopped paying. * The result is logged and written to a JSON file for later inspection. * @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps - Dependencies. * @param {{ org: string }} options - Command options. @@ -14,30 +28,47 @@ export default async function sponsors ({ client, logger }, { org }) { logger.info('Running sponsors command for organization: %s', org) const githubSponsors = await client.getGithubSponsors(org) - logger.info('Total GitHub sponsors: %s', githubSponsors.length) - logSponsors(logger, githubSponsors) + logger.info('Fetched %s GitHub sponsorships', githubSponsors.length) const openCollectiveSponsors = await client.getOpenCollectiveSponsors(org) - logger.info('Total Open Collective backers: %s', openCollectiveSponsors.length) - logSponsors(logger, openCollectiveSponsors) + logger.info('Fetched %s Open Collective backers', openCollectiveSponsors.length) + + // Keep only recurring contributions that reach at least tier 1, tag each with + // its tier, and order the combined list by monthly contribution descending. + const sponsorList = [...githubSponsors, ...openCollectiveSponsors] + .filter((sponsor) => tierFor(sponsor.monthlyAmount) !== null) + .map((sponsor) => ({ ...sponsor, tierLevel: tierFor(sponsor.monthlyAmount) })) + .sort((a, b) => b.monthlyAmount - a.monthlyAmount) + + logger.info('Tier sponsors (%s), ordered by monthly amount:', sponsorList.length) + for (const sponsor of sponsorList) { + logger.info( + '- tier %s · $%s/mo · %s [%s]%s', + sponsor.tierLevel, + sponsor.monthlyAmount, + displayName(sponsor), + sponsor.source, + sponsor.lapsed ? ' · ⚠ lapsed' : '' + ) + } - const flagged = [...githubSponsors, ...openCollectiveSponsors].filter((sponsor) => sponsor.lapsed) + const flagged = sponsorList.filter((sponsor) => sponsor.lapsed) if (flagged.length > 0) { - logger.warn('%s lapsed sponsor(s) need attention:', flagged.length) + logger.warn('%s lapsed tier sponsor(s) need attention:', flagged.length) for (const sponsor of flagged) { logger.warn( - ' ⚠ %s [%s] — %s, last charged %s', + ' ⚠ tier %s · %s [%s] — last charged %s', + sponsor.tierLevel, displayName(sponsor), sponsor.source, - sponsor.tier ?? 'no tier', sponsor.lastChargedAt ?? 'unknown' ) } } const result = { - github: githubSponsors, - openCollective: openCollectiveSponsors, + tiers: TIERS, + sponsors: sponsorList, flagged, } @@ -46,16 +77,22 @@ export default async function sponsors ({ client, logger }, { org }) { } /** - * Logs one line per sponsor, marking lapsed recurring sponsors. - * @param {import('pino').Logger} logger - The logger instance. - * @param {import('../github-api.js').Sponsor[]} sponsorList - The sponsors to log. - * @returns {void} + * Returns the tier level for a given normalized monthly contribution, or null when + * the contribution is missing or below the lowest tier. + * @param {number|null} monthlyAmount - The normalized monthly contribution in dollars. + * @returns {number|null} The tier level (1-4) or null. */ -function logSponsors (logger, sponsorList) { - for (const sponsor of sponsorList) { - const mark = sponsor.lapsed ? '⚠ lapsed' : sponsor.tier ?? 'no tier' - logger.info('- %s — %s', displayName(sponsor), mark) +function tierFor (monthlyAmount) { + if (monthlyAmount === null || monthlyAmount < TIERS[0].monthly) { + return null + } + let level = null + for (const tier of TIERS) { + if (monthlyAmount >= tier.monthly) { + level = tier.level + } } + return level } /** diff --git a/github-api.js b/github-api.js index 24e08d0..5d71203 100644 --- a/github-api.js +++ b/github-api.js @@ -516,6 +516,7 @@ function transformGqlSponsor (sponsorship) { const entity = sponsorship.sponsorEntity ?? {} const isOneTime = sponsorship.isOneTimePayment ?? sponsorship.tier?.isOneTime ?? false const isActive = sponsorship.isActive ?? true + const amount = sponsorship.tier?.monthlyPriceInDollars ?? null return { source: 'github', login: entity.login ?? null, @@ -523,7 +524,8 @@ function transformGqlSponsor (sponsorship) { url: entity.url ?? null, type: entity.__typename ?? null, tier: sponsorship.tier?.name ?? null, - amount: sponsorship.tier?.monthlyPriceInDollars ?? null, + amount, + monthlyAmount: isOneTime ? null : amount, currency: 'USD', frequency: isOneTime ? 'ONETIME' : 'MONTHLY', isOneTime, @@ -564,6 +566,13 @@ function transformOcOrder (order, now) { const entity = order.fromAccount ?? {} const isRecurring = order.frequency === 'MONTHLY' || order.frequency === 'YEARLY' const isOverdue = order.nextChargeDate ? new Date(order.nextChargeDate) < now : false + const amount = order.amount?.value ?? null + let monthlyAmount = null + if (amount !== null && order.frequency === 'MONTHLY') { + monthlyAmount = amount + } else if (amount !== null && order.frequency === 'YEARLY') { + monthlyAmount = amount / 12 + } return { source: 'opencollective', login: entity.slug ?? null, @@ -571,7 +580,8 @@ function transformOcOrder (order, now) { url: entity.website ?? (entity.slug ? `https://opencollective.com/${entity.slug}` : null), type: entity.type ?? null, tier: order.tier?.name ?? null, - amount: order.amount?.value ?? null, + amount, + monthlyAmount, currency: order.amount?.currency ?? null, frequency: order.frequency ?? null, isOneTime: order.frequency === 'ONETIME', @@ -629,6 +639,7 @@ function toDate (dateStr) { * @property {string|null} type - The sponsor entity type ('User'/'Organization' or 'INDIVIDUAL'/'ORGANIZATION'). * @property {string|null} tier - The sponsorship/contribution tier name. * @property {number|null} amount - The contribution amount in `currency` (monthly price for recurring GitHub tiers). + * @property {number|null} monthlyAmount - The normalized monthly contribution (yearly ÷ 12); null for one-time payments. * @property {string|null} currency - The contribution currency (e.g. 'USD'). * @property {string|null} frequency - The contribution cadence ('ONETIME', 'MONTHLY' or 'YEARLY'). * @property {boolean} isOneTime - Whether the contribution is a one-time payment.