Skip to content

Commit efe2286

Browse files
authored
feat: fresh sponsor list output (#37)
1 parent 8642835 commit efe2286

6 files changed

Lines changed: 453 additions & 2 deletions

File tree

.github/workflows/sponsors.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: List Sponsors
2+
3+
on:
4+
# Allow manual trigger
5+
workflow_dispatch:
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
list-sponsors:
12+
permissions:
13+
contents: read
14+
runs-on: ubuntu-latest
15+
16+
steps:
17+
- name: Checkout repository
18+
uses: actions/checkout@v6
19+
with:
20+
persist-credentials: false
21+
22+
- name: Setup Node.js
23+
uses: actions/setup-node@v6
24+
with:
25+
check-latest: true
26+
node-version: "24"
27+
28+
- name: Install dependencies
29+
run: npm i --ignore-scripts
30+
31+
- name: List sponsors
32+
env:
33+
GITHUB_TOKEN: ${{ secrets.SPONSOR_TOKEN }}
34+
run: node index.js sponsors --org fastify

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,4 @@ dist
138138
vite.config.js.timestamp-*
139139
vite.config.ts.timestamp-*
140140
.vscode/
141+
sponsors.json

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,39 @@ For the fastify organization, the command would look like:
4848
node --env-file=.env index.js emeritus --monthsInactiveThreshold 24
4949
```
5050

51+
### List sponsors
52+
53+
This command reads the organization's sponsors from both GitHub Sponsors and
54+
Open Collective and lists the **recurring tier sponsors** — those whose normalized
55+
monthly contribution reaches at least the lowest tier. Each sponsor is tagged with
56+
its tier (the highest tier its monthly amount meets, rounding down) and the combined
57+
list is ordered by monthly contribution descending. Recurring sponsors that stopped
58+
paying (cancelled or overdue) are flagged as `lapsed`.
59+
60+
The default tiers are tier 1 = $5/mo, tier 2 = $50/mo, tier 3 = $100/mo and
61+
tier 4 = $300/mo (configurable in `commands/sponsors.js`). One-time payments and
62+
sub-tier contributions are excluded. The result is logged and written to a
63+
`sponsors.json` file with three keys: `tiers` (the tier definitions), `sponsors`
64+
(the combined tier list) and `flagged` (the lapsed tier sponsors).
65+
66+
```bash
67+
node --env-file=.env index.js sponsors --org <org>
68+
```
69+
70+
For the fastify organization, the command would look like:
71+
72+
```bash
73+
node --env-file=.env index.js sponsors
74+
```
75+
76+
Reading Open Collective backers is public and needs no token. Open Collective
77+
exposes per-charge data, so a lapsed contribution shows its `lastChargedAt` /
78+
`nextChargeDate`. GitHub does not expose individual charges, so a GitHub sponsor
79+
is only flagged as `lapsed` when a recurring sponsorship has been cancelled
80+
(its `lastChargedAt` is always `null`). To raise the Open Collective rate limit
81+
you may optionally set `OC_PERSONAL_TOKEN` in `.env` (a personal token from your
82+
own account — Open Collective has no org-level token).
83+
5184
## License
5285

5386
Licensed under [MIT](./LICENSE).

commands/sponsors.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { writeFileSync } from 'node:fs'
2+
3+
const OUTPUT_FILE = './sponsors.json'
4+
5+
/**
6+
* Fastify's sponsorship tiers, defined by their minimum monthly contribution in
7+
* dollars. A sponsor is assigned the highest tier whose threshold its monthly
8+
* contribution meets (amounts between tiers round down). Sponsors below tier 1
9+
* are not considered tier sponsors.
10+
*/
11+
const TIERS = [
12+
{ level: 1, monthly: 5 },
13+
{ level: 2, monthly: 50 },
14+
{ level: 3, monthly: 100 },
15+
{ level: 4, monthly: 300 },
16+
]
17+
18+
/**
19+
* Fetches all sponsors of an organization from GitHub Sponsors and Open Collective,
20+
* keeps only the recurring tier sponsors (monthly contribution within a tier),
21+
* sorts them by monthly amount descending and flags those that stopped paying.
22+
* The result is logged and written to a JSON file for later inspection.
23+
* @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps - Dependencies.
24+
* @param {{ org: string }} options - Command options.
25+
* @returns {Promise<void>}
26+
*/
27+
export default async function sponsors ({ client, logger }, { org }) {
28+
logger.info('Running sponsors command for organization: %s', org)
29+
30+
const githubSponsors = await client.getGithubSponsors(org)
31+
logger.info('Fetched %s GitHub sponsorships', githubSponsors.length)
32+
33+
const openCollectiveSponsors = await client.getOpenCollectiveSponsors(org)
34+
logger.info('Fetched %s Open Collective backers', openCollectiveSponsors.length)
35+
36+
// Keep only recurring contributions that reach at least tier 1, tag each with
37+
// its tier, and order the combined list by monthly contribution descending.
38+
const sponsorList = [...githubSponsors, ...openCollectiveSponsors]
39+
.filter((sponsor) => tierFor(sponsor.monthlyAmount) !== null)
40+
.map((sponsor) => ({ ...sponsor, tierLevel: tierFor(sponsor.monthlyAmount) }))
41+
.sort((a, b) => b.monthlyAmount - a.monthlyAmount)
42+
43+
logger.info('Tier sponsors (%s), ordered by monthly amount:', sponsorList.length)
44+
for (const sponsor of sponsorList) {
45+
logger.info(
46+
'- tier %s · $%s/mo · %s [%s]%s',
47+
sponsor.tierLevel,
48+
sponsor.monthlyAmount,
49+
displayName(sponsor),
50+
sponsor.source,
51+
sponsor.lapsed ? ' · ⚠ lapsed' : ''
52+
)
53+
}
54+
55+
const flagged = sponsorList.filter((sponsor) => sponsor.lapsed)
56+
if (flagged.length > 0) {
57+
logger.warn('%s lapsed tier sponsor(s) need attention:', flagged.length)
58+
for (const sponsor of flagged) {
59+
logger.warn(
60+
' ⚠ tier %s · %s [%s] — last charged %s',
61+
sponsor.tierLevel,
62+
displayName(sponsor),
63+
sponsor.source,
64+
sponsor.lastChargedAt ?? 'unknown'
65+
)
66+
}
67+
}
68+
69+
const result = {
70+
tiers: TIERS,
71+
sponsors: sponsorList,
72+
flagged,
73+
}
74+
75+
writeFileSync(OUTPUT_FILE, JSON.stringify(result, null, 2))
76+
logger.info('Sponsors written to %s', OUTPUT_FILE)
77+
}
78+
79+
/**
80+
* Returns the tier level for a given normalized monthly contribution, or null when
81+
* the contribution is missing or below the lowest tier.
82+
* @param {number|null} monthlyAmount - The normalized monthly contribution in dollars.
83+
* @returns {number|null} The tier level (1-4) or null.
84+
*/
85+
function tierFor (monthlyAmount) {
86+
if (monthlyAmount === null || monthlyAmount < TIERS[0].monthly) {
87+
return null
88+
}
89+
let level = null
90+
for (const tier of TIERS) {
91+
if (monthlyAmount >= tier.monthly) {
92+
level = tier.level
93+
}
94+
}
95+
return level
96+
}
97+
98+
/**
99+
* Builds a human-friendly label for a sponsor.
100+
* @param {import('../github-api.js').Sponsor} sponsor - The sponsor.
101+
* @returns {string} The display label.
102+
*/
103+
function displayName (sponsor) {
104+
return sponsor.name ? `${sponsor.login} (${sponsor.name})` : sponsor.login
105+
}

0 commit comments

Comments
 (0)