@@ -40,6 +40,14 @@ const graphql = String.raw
4040export 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