Skip to content

Commit 80f235e

Browse files
authored
feat: sponsees mode (#113)
1 parent 38cb66b commit 80f235e

12 files changed

Lines changed: 466 additions & 45 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ Create `.env` file with:
2222
; Token requires the `read:user` and `read:org` scopes.
2323
SPONSORKIT_GITHUB_TOKEN=
2424
SPONSORKIT_GITHUB_LOGIN=
25+
; Optional data mode:
26+
; - sponsors: people sponsoring you (default)
27+
; - sponsees: people you have sponsored, including past sponsorships
28+
SPONSORKIT_MODE=sponsors
2529

2630
; Patreon provider.
2731
; Create v2 API key at https://www.patreon.com/portal/registration/register-clients
@@ -75,6 +79,11 @@ Create `sponsorkit.config.js` file with:
7579
import { defineConfig, tierPresets } from 'sponsorkit'
7680

7781
export default defineConfig({
82+
// Data mode:
83+
// - sponsors: people sponsoring you (default)
84+
// - sponsees: people you have sponsored, including past sponsorships
85+
mode: 'sponsors',
86+
7887
// Providers configs
7988
github: {
8089
login: 'antfu',

src/configs/defaults.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,13 @@ text {
4545
`
4646

4747
export const defaultConfig: SponsorkitConfig = {
48+
mode: 'sponsors',
4849
width: 800,
4950
outputDir: './sponsorkit',
5051
cacheFile: '.cache.json',
52+
cacheFileSponsees: '.cache.sponsees.json',
5153
formats: ['json', 'svg', 'png'],
5254
tiers: defaultTiers,
53-
name: 'sponsors',
5455
includePrivate: false,
5556
svgInlineCSS: defaultInlineCSS,
5657
}

src/configs/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function loadEnv(): Partial<SponsorkitConfig> {
1313
dotenv.config({ quiet: true })
1414

1515
const config: Partial<SponsorkitConfig> = {
16+
mode: process.env.SPONSORKIT_MODE as SponsorkitConfig['mode'] | undefined,
1617
github: {
1718
login: process.env.SPONSORKIT_GITHUB_LOGIN || process.env.GITHUB_LOGIN || getDeprecatedEnv('SPONSORKIT_LOGIN', 'SPONSORKIT_GITHUB_LOGIN'),
1819
token: process.env.SPONSORKIT_GITHUB_TOKEN || process.env.GITHUB_TOKEN || getDeprecatedEnv('SPONSORKIT_TOKEN', 'SPONSORKIT_GITHUB_TOKEN'),

src/configs/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ export async function loadConfig(inlineConfig: SponsorkitConfig = {}): Promise<R
5858
},
5959
} as Required<SponsorkitConfig>
6060

61+
if (!['sponsors', 'sponsees'].includes(resolved.mode))
62+
throw new Error(`Invalid mode: ${resolved.mode}. Expected "sponsors" or "sponsees".`)
63+
64+
resolved.name = inlineConfig.name || config.name || env.name || resolved.mode
65+
6166
return resolved
6267
}
6368

src/providers/afdian.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import { $fetch } from 'ofetch'
77
export const AfdianProvider: Provider = {
88
name: 'afdian',
99
fetchSponsors(config) {
10+
if (config.mode === 'sponsees') {
11+
console.warn('[sponsorkit] Afdian provider does not support `mode: "sponsees"` yet')
12+
return Promise.resolve([])
13+
}
14+
1015
return fetchAfdianSponsors(config.afdian)
1116
},
1217
}

src/providers/github.ts

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ const graphql = String.raw
4040
export const GitHubProvider: Provider = {
4141
name: 'github',
4242
fetchSponsors(config) {
43+
if (config.mode === 'sponsees') {
44+
return fetchGitHubSponsoringAsSponsorships(
45+
config.github?.token || config.token!,
46+
config.github?.login || config.login!,
47+
config.github?.type || 'user',
48+
)
49+
}
50+
4351
return fetchGitHubSponsors(
4452
config.github?.token || config.token!,
4553
config.github?.login || config.login!,
@@ -176,3 +184,309 @@ export function makeQuery(
176184
}
177185
}`
178186
}
187+
188+
export interface GitHubSponsoringRecord {
189+
sponsorable: {
190+
type: 'User' | 'Organization'
191+
login: string
192+
name: string
193+
avatarUrl: string
194+
websiteUrl?: string
195+
linkUrl: string
196+
}
197+
monthlyDollars: number
198+
monthlyCents: number
199+
tierName: string
200+
isOneTime: boolean
201+
privacyLevel?: 'PUBLIC' | 'PRIVATE'
202+
createdAt: string
203+
isActive: boolean
204+
raw: any
205+
}
206+
207+
export interface GitHubSponsoringTotalOptions {
208+
since?: string
209+
until?: string
210+
sponsorableLogins?: string[]
211+
}
212+
213+
function assertGitHubSponsoringParams(
214+
token: string,
215+
login: string,
216+
type: GitHubAccountType,
217+
) {
218+
if (!token)
219+
throw new Error('GitHub token is required')
220+
if (!login)
221+
throw new Error('GitHub login is required')
222+
if (!['user', 'organization'].includes(type))
223+
throw new Error('GitHub type must be either `user` or `organization`')
224+
}
225+
226+
async function requestGitHubSponsoringGraphQL(token: string, query: string): Promise<any> {
227+
const data = await $fetch(API, {
228+
method: 'POST',
229+
body: { query },
230+
headers: {
231+
'Authorization': `bearer ${token}`,
232+
'Content-Type': 'application/json',
233+
},
234+
}) as any
235+
236+
if (!data)
237+
throw new Error(`Get no response on requesting ${API}`)
238+
else if (data.errors?.[0]?.type === 'INSUFFICIENT_SCOPES')
239+
throw new Error('Token is missing the `read:user` and/or `read:org` scopes')
240+
else if (data.errors?.length)
241+
throw new Error(`GitHub API error:\n${JSON.stringify(data.errors, null, 2)}`)
242+
243+
return data
244+
}
245+
246+
async function fetchGitHubSponsoringNodes(
247+
token: string,
248+
login: string,
249+
type: GitHubAccountType,
250+
activeOnly: boolean,
251+
): Promise<any[]> {
252+
const nodes: any[] = []
253+
let cursor: string | undefined
254+
255+
do {
256+
const query = makeSponsoringQuery(login, type, activeOnly, cursor)
257+
const data = await requestGitHubSponsoringGraphQL(token, query)
258+
const page = data.data?.[type]?.sponsorshipsAsSponsor
259+
if (!page)
260+
throw new Error('Invalid GitHub response: `sponsorshipsAsSponsor` is missing')
261+
262+
nodes.push(...(page.nodes || []))
263+
cursor = page.pageInfo?.hasNextPage
264+
? page.pageInfo.endCursor
265+
: undefined
266+
} while (cursor)
267+
268+
return nodes
269+
}
270+
271+
function toGitHubSponsoringRecord(raw: any): GitHubSponsoringRecord {
272+
return {
273+
sponsorable: {
274+
type: raw.sponsorable.__typename,
275+
login: raw.sponsorable.login,
276+
name: raw.sponsorable.name || raw.sponsorable.login,
277+
avatarUrl: raw.sponsorable.avatarUrl,
278+
websiteUrl: normalizeUrl(raw.sponsorable.websiteUrl),
279+
linkUrl: `https://github.com/${raw.sponsorable.login}`,
280+
},
281+
monthlyDollars: raw.tier.monthlyPriceInDollars,
282+
monthlyCents: raw.tier.monthlyPriceInCents,
283+
tierName: raw.tier.name,
284+
isOneTime: raw.tier.isOneTime,
285+
privacyLevel: raw.privacyLevel,
286+
createdAt: raw.createdAt,
287+
isActive: raw.isActive,
288+
raw,
289+
}
290+
}
291+
292+
function groupSponsoringRecordsByLogin(records: GitHubSponsoringRecord[]) {
293+
const recordsBySponsorable = new Map<string, GitHubSponsoringRecord[]>()
294+
for (const record of records) {
295+
const list = recordsBySponsorable.get(record.sponsorable.login)
296+
if (list)
297+
list.push(record)
298+
else
299+
recordsBySponsorable.set(record.sponsorable.login, [record])
300+
}
301+
return recordsBySponsorable
302+
}
303+
304+
async function fetchTotalCentsBySponsorable(
305+
token: string,
306+
login: string,
307+
type: GitHubAccountType,
308+
sponsorableLogins: string[],
309+
) {
310+
return new Map(
311+
await Promise.all(
312+
sponsorableLogins.map(async (sponsorableLogin) => {
313+
const totalInCents = await fetchGitHubTotalSponsorshipAmountAsSponsor(
314+
token,
315+
login,
316+
type,
317+
{ sponsorableLogins: [sponsorableLogin] },
318+
)
319+
return [sponsorableLogin, totalInCents] as const
320+
}),
321+
),
322+
)
323+
}
324+
325+
function summarizeSponsoringRecords(records: GitHubSponsoringRecord[]) {
326+
const [first, ...rest] = records
327+
let latest = first
328+
let firstCreatedAt = first.createdAt
329+
let isOneTime = first.isOneTime
330+
const raws = [first.raw]
331+
332+
for (const record of rest) {
333+
raws.push(record.raw)
334+
if (Date.parse(record.createdAt) > Date.parse(latest.createdAt))
335+
latest = record
336+
if (record.createdAt.localeCompare(firstCreatedAt) < 0)
337+
firstCreatedAt = record.createdAt
338+
isOneTime &&= record.isOneTime
339+
}
340+
341+
return {
342+
latest,
343+
firstCreatedAt,
344+
isOneTime,
345+
raws,
346+
}
347+
}
348+
349+
export async function fetchGitHubSponsoring(
350+
token: string,
351+
login: string,
352+
type: GitHubAccountType,
353+
activeOnly = true,
354+
): Promise<GitHubSponsoringRecord[]> {
355+
assertGitHubSponsoringParams(token, login, type)
356+
357+
return (await fetchGitHubSponsoringNodes(token, login, type, activeOnly))
358+
.filter((raw: any) => !!raw.tier && !!raw.sponsorable)
359+
.map(toGitHubSponsoringRecord)
360+
}
361+
362+
export async function fetchGitHubSponsoringAsSponsorships(
363+
token: string,
364+
login: string,
365+
type: GitHubAccountType,
366+
): Promise<Sponsorship[]> {
367+
// Sponsees mode always loads full history regardless of active status.
368+
const records = await fetchGitHubSponsoring(token, login, type, false)
369+
const recordsBySponsorable = groupSponsoringRecordsByLogin(records)
370+
const totalBySponsorable = await fetchTotalCentsBySponsorable(
371+
token,
372+
login,
373+
type,
374+
[...recordsBySponsorable.keys()],
375+
)
376+
377+
return [...recordsBySponsorable.entries()].map(([sponsorableLogin, list]) => {
378+
const summary = summarizeSponsoringRecords(list)
379+
const totalInCents = totalBySponsorable.get(sponsorableLogin) || 0
380+
381+
return {
382+
sponsor: {
383+
type: summary.latest.sponsorable.type,
384+
login: summary.latest.sponsorable.login,
385+
name: summary.latest.sponsorable.name,
386+
avatarUrl: summary.latest.sponsorable.avatarUrl,
387+
websiteUrl: summary.latest.sponsorable.websiteUrl,
388+
linkUrl: summary.latest.sponsorable.linkUrl,
389+
socialLogins: {
390+
github: summary.latest.sponsorable.login,
391+
},
392+
},
393+
isOneTime: summary.isOneTime,
394+
// In sponsees mode, ranking/tiers are based on lifetime sponsored amount.
395+
monthlyDollars: totalInCents / 100,
396+
privacyLevel: summary.latest.privacyLevel,
397+
tierName: summary.latest.tierName,
398+
createdAt: summary.firstCreatedAt,
399+
raw: {
400+
records: summary.raws,
401+
totalSponsoredCents: totalInCents,
402+
},
403+
}
404+
})
405+
}
406+
407+
export async function fetchGitHubTotalSponsorshipAmountAsSponsor(
408+
token: string,
409+
login: string,
410+
type: GitHubAccountType,
411+
options: GitHubSponsoringTotalOptions = {},
412+
): Promise<number> {
413+
assertGitHubSponsoringParams(token, login, type)
414+
415+
const query = makeSponsoringTotalAmountQuery(login, type, options)
416+
const data = await requestGitHubSponsoringGraphQL(token, query)
417+
418+
const totalInCents = data.data?.[type]?.totalSponsorshipAmountAsSponsorInCents
419+
if (typeof totalInCents !== 'number')
420+
throw new Error('Invalid GitHub response: `totalSponsorshipAmountAsSponsorInCents` is missing')
421+
422+
return totalInCents
423+
}
424+
425+
export function makeSponsoringQuery(
426+
login: string,
427+
type: GitHubAccountType,
428+
activeOnly = true,
429+
cursor?: string,
430+
) {
431+
return graphql`{
432+
${type}(login: "${login}") {
433+
sponsorshipsAsSponsor(activeOnly: ${Boolean(activeOnly)}, first: 100${cursor ? ` after: "${cursor}"` : ''}) {
434+
totalCount
435+
pageInfo {
436+
endCursor
437+
hasNextPage
438+
}
439+
nodes {
440+
createdAt
441+
privacyLevel
442+
isActive
443+
tier {
444+
name
445+
isOneTime
446+
monthlyPriceInCents
447+
monthlyPriceInDollars
448+
}
449+
sponsorable {
450+
__typename
451+
...on Organization {
452+
login
453+
name
454+
avatarUrl
455+
websiteUrl
456+
}
457+
...on User {
458+
login
459+
name
460+
avatarUrl
461+
websiteUrl
462+
}
463+
}
464+
}
465+
}
466+
}
467+
}`
468+
}
469+
470+
export function makeSponsoringTotalAmountQuery(
471+
login: string,
472+
type: GitHubAccountType,
473+
options: GitHubSponsoringTotalOptions = {},
474+
) {
475+
const args: string[] = []
476+
if (options.since)
477+
args.push(`since: ${JSON.stringify(options.since)}`)
478+
if (options.until)
479+
args.push(`until: ${JSON.stringify(options.until)}`)
480+
if (options.sponsorableLogins?.length)
481+
args.push(`sponsorableLogins: [${options.sponsorableLogins.map(v => JSON.stringify(v)).join(', ')}]`)
482+
483+
const parameters = args.length
484+
? `(${args.join(', ')})`
485+
: ''
486+
487+
return graphql`{
488+
${type}(login: "${login}") {
489+
totalSponsorshipAmountAsSponsorInCents${parameters}
490+
}
491+
}`
492+
}

0 commit comments

Comments
 (0)