@jamesives/github-sponsors-readme-action
Version:
This GitHub Action will automatically add your GitHub Sponsors to your README. It can be configured in multiple ways allowing you to display and breakdown your sponsors by price tier with fallbacks.
251 lines (226 loc) • 6.87 kB
text/typescript
import {promises} from 'fs'
import {
ActionInterface,
GitHubResponse,
PrivacyLevel,
Sponsor,
Status,
Urls
} from './constants'
import {render} from 'mustache'
import {
extractErrorMessage,
suppressSensitiveInformation,
sanitizeAndClean,
replaceAll
} from './util'
import {info} from '@actions/core'
/**
* Fetches sponsors from the GitHub Sponsors API.
*/
export async function getSponsors(
action: ActionInterface
): Promise<GitHubResponse> {
try {
info(
`Fetching data from the GitHub API as ${
action.organization ? 'Organization' : 'User'
}… ⚽`
)
const query = `query {
${
action.organization
? `organization (login: "${process.env.GITHUB_REPOSITORY_OWNER}")`
: `viewer`
} {
login
sponsorshipsAsMaintainer(first: 100, orderBy: {field: CREATED_AT, direction: ASC}, includePrivate: ${action.includePrivate}, activeOnly: ${
action.activeOnly
}) {
totalCount
pageInfo {
endCursor
}
nodes {
sponsorEntity {
... on Organization {
name
login
url
websiteUrl
}
... on User {
name
login
url
websiteUrl
}
}
createdAt
privacyLevel
tier {
monthlyPriceInCents
}
}
}
}
}`
const data = await fetch(`${Urls.GITHUB_API}/graphql`, {
method: 'POST',
headers: {
Authorization: `Bearer ${action.token}`,
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
query
})
})
return data.json()
} catch (error) {
throw new Error(
`There was an error with the GitHub API request: ${suppressSensitiveInformation(
extractErrorMessage(error),
action
)} ❌`
)
}
}
/**
* Generates the sponsorship template.
*/
export function generateTemplate(
response: GitHubResponse,
action: ActionInterface
): string {
let template = ``
info('Generating template… ✨')
/**
* Determines if the response is from an organization or a user.
* Performs checks to see if the data is available before we
* reference it as the API results can be somewhat sporadic.
*/
const data =
action.organization && response?.data?.organization
? response?.data?.organization
: response?.data?.viewer
? response?.data?.viewer
: null
const sponsorshipsAsMaintainer = data?.sponsorshipsAsMaintainer
if (sponsorshipsAsMaintainer) {
let filteredSponsors = sponsorshipsAsMaintainer.nodes.filter(
(user: Sponsor) =>
(user.tier && user.tier.monthlyPriceInCents
? user.tier.monthlyPriceInCents
: 0) >= action.minimum
)
/**
* If `includePrivate` is true here, we replace the private sponsors with a placeholder asset and anonymize all data to respect privacy.
*/
if (action.includePrivate) {
filteredSponsors = filteredSponsors.map((user: Sponsor) => {
if (user.privacyLevel === PrivacyLevel.PRIVATE) {
return {
...user,
sponsorEntity: {
name: 'Private Sponsor',
login: '',
url: 'https://github.com',
websiteUrl: 'https://github.com',
avatarUrl:
'https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png'
}
}
}
return user
})
} else {
/**
* If `includePrivate` is false we filter out any priv1ate sponsors. This is a safeguard incase the GitHub API
* decides to return private sponsors for some reason.
*/
filteredSponsors = filteredSponsors.filter(
(user: Sponsor) => user.privacyLevel !== PrivacyLevel.PRIVATE
)
}
if (action.maximum > 0) {
filteredSponsors = filteredSponsors.filter(
(user: Sponsor) =>
(user.tier && user.tier.monthlyPriceInCents
? user.tier.monthlyPriceInCents
: 0) <= action.maximum
)
}
info(
`Found ${filteredSponsors.length} matching ${filteredSponsors.length === 1 ? 'sponsor' : 'sponsors'}… ${filteredSponsors.length > 0 ? '🎉' : '😢'}`
)
/**
* If there are no valid sponsors then we return the provided fallback.
*/
if (!filteredSponsors.length) {
return action.fallback
}
filteredSponsors.map(({sponsorEntity}) => {
/**
* Sanitizes and cleans the sponsor data individually.
*/
const sanitizedSponsorEntity = {
websiteUrl: sanitizeAndClean(
sponsorEntity.websiteUrl || sponsorEntity.url
),
name: sanitizeAndClean(sponsorEntity.name || ''),
login: sanitizeAndClean(sponsorEntity.login),
/**
* The avatar URL provided by the GitHub API includes an expiration token so we circumvent this for now
* by using a path that is always available.
*/
avatarUrl: sponsorEntity.avatarUrl
? sponsorEntity.avatarUrl
: `https://github.com/${sanitizeAndClean(sponsorEntity.login)}.png`
}
/**
* Ensure that the template is safe to render by preventing the usage of triple brackets.
*/
const safeTemplate = replaceAll(
replaceAll(action.template, '{{{', '{{'),
'}}}',
'}}'
)
template = template += render(safeTemplate, sanitizedSponsorEntity)
})
} else {
info(`No sponsorship data was found… ❌`)
return action.fallback
}
return template
}
/**
* Generates the updated file with the attached sponsorship template.
*/
export async function generateFile(
response: GitHubResponse,
action: ActionInterface
): Promise<Status> {
try {
info(`Generating updated ${action.file} file… 📁`)
/** Replaces the content within the comments and re appends/prepends the comments to the replace for follow-up workflow runs. */
const regex = new RegExp(
`(<!-- ${action.marker} -->)[\\s\\S]*?(<!-- ${action.marker} -->)`,
'g'
)
let data = await promises.readFile(action.file, 'utf8')
if (!regex.test(data)) {
return Status.SKIPPED
}
data = data.replace(regex, `$1${generateTemplate(response, action)}$2`)
await promises.writeFile(action.file, data)
return Status.SUCCESS
} catch (error) {
throw new Error(
`There was an error generating the updated file: ${suppressSensitiveInformation(
extractErrorMessage(error),
action
)} ❌`
)
}
}