diff --git a/README.md b/README.md index 84d50ea..c30256f 100644 --- a/README.md +++ b/README.md @@ -11,18 +11,45 @@ This is a fork of [sponsorkit](https://github.com/antfu-collective/sponsorkit) t Supports: -- [**GitHub Sponsors**](https://github.com/sponsors) -- [**Patreon**](https://www.patreon.com/) -- [**OpenCollective**](https://opencollective.com/) -- [**Afdian**](https://afdian.com/) -- [**Polar**](https://polar.sh/) -- [**Liberapay**](https://liberapay.com/) +- Contributors: + - [**CrowdIn**](https://crowdin.com) + - [**GitHub**](https://github.com) + - [**Gitlab**](https://gitlab.com) +- Sponsors: + - [**GitHub Sponsors**](https://github.com/sponsors) + - [**Patreon**](https://www.patreon.com/) + - [**OpenCollective**](https://opencollective.com/) + - [**Afdian**](https://afdian.com/) + - [**Polar**](https://polar.sh/) + - [**Liberapay**](https://liberapay.com/) ## Usage Create `.env` file with: ```ini +;; Contributors + +; CrowdInContributors provider. +CONTRIBKIT_CROWDIN_TOKEN= +CONTRIBKIT_CROWDIN_PROJECT_ID= +CONTRIBKIT_CROWDIN_MIN_TRANSLATIONS=1 + +; GitHubContributors provider. +; Token requires the `public_repo` and `read:user` scopes. +CONTRIBKIT_GITHUB_CONTRIBUTORS_TOKEN= +CONTRIBKIT_GITHUB_CONTRIBUTORS_LOGIN= +CONTRIBKIT_GITHUB_CONTRIBUTORS_MIN=1 +CONTRIBKIT_GITHUB_CONTRIBUTORS_REPO= + +; GitlabContributors provider. +; Token requires the `read_api` and `read_user` scopes. +CONTRIBKIT_GITLAB_CONTRIBUTORS_TOKEN= +CONTRIBKIT_GITLAB_CONTRIBUTORS_MIN=1 +CONTRIBKIT_GITLAB_CONTRIBUTORS_REPO_ID= + +;; Sponsors + ; GitHub provider. ; Token requires the `read:user` and `read:org` scopes. CONTRIBKIT_GITHUB_TOKEN= @@ -64,6 +91,11 @@ CONTRIBKIT_LIBERAPAY_LOGIN= > Only one provider is required to be configured. +> ![NOTE] +> The contributor providers are intended to be separated from each other, unlike the sponsor providers. +> This will require different env variables to be set for each provider, and to be created from separate +> commands. + Run: ```base diff --git a/package.json b/package.json index 9f1ce01..a722e00 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "release": "bumpp && pnpm publish" }, "dependencies": { + "@crowdin/crowdin-api-client": "^1.41.2", "ansis": "^3.17.0", "cac": "^6.7.14", "consola": "^3.4.0", diff --git a/src/configs/env.ts b/src/configs/env.ts index 2333fff..bb8f1ce 100644 --- a/src/configs/env.ts +++ b/src/configs/env.ts @@ -41,6 +41,22 @@ export function loadEnv(): Partial { login: process.env.CONTRIBKIT_LIBERAPAY_LOGIN || process.env.LIBERAPAY_LOGIN, }, outputDir: process.env.CONTRIBKIT_DIR, + githubContributors: { + login: process.env.CONTRIBKIT_GITHUB_CONTRIBUTORS_LOGIN, + token: process.env.CONTRIBKIT_GITHUB_CONTRIBUTORS_TOKEN, + minContributions: Number(process.env.CONTRIBKIT_GITHUB_CONTRIBUTORS_MIN) || 1, + repo: process.env.CONTRIBKIT_GITHUB_CONTRIBUTORS_REPO, + }, + gitlabContributors: { + token: process.env.CONTRIBKIT_GITLAB_CONTRIBUTORS_TOKEN, + minContributions: Number(process.env.CONTRIBKIT_GITLAB_CONTRIBUTORS_MIN) || 1, + repoId: Number(process.env.CONTRIBKIT_GITLAB_CONTRIBUTORS_REPO_ID), + }, + crowdinContributors: { + token: process.env.CONTRIBKIT_CROWDIN_TOKEN, + projectId: Number(process.env.CONTRIBKIT_CROWDIN_PROJECT_ID), + minTranslations: Number(process.env.CONTRIBKIT_CROWDIN_MIN_TRANSLATIONS) || 1, + }, } // remove undefined keys diff --git a/src/configs/index.ts b/src/configs/index.ts index 3934c31..5688c18 100644 --- a/src/configs/index.ts +++ b/src/configs/index.ts @@ -18,11 +18,17 @@ export async function loadConfig(inlineConfig: ContribkitConfig = {}): Promise({ sources: [ { - files: 'sponsorkit.config', + files: 'contrib.config', }, { files: 'contribkit.config', }, + { + files: 'sponsor.config', + }, + { + files: 'sponsorkit.config', + }, ], merge: true, }) diff --git a/src/providers/crowdinContributors.ts b/src/providers/crowdinContributors.ts new file mode 100644 index 0000000..90c7c79 --- /dev/null +++ b/src/providers/crowdinContributors.ts @@ -0,0 +1,94 @@ +import type { Credentials, ReportsModel } from '@crowdin/crowdin-api-client' +import type { Provider, Sponsorship } from '../types' +import { ProjectsGroups, Reports } from '@crowdin/crowdin-api-client' + +interface Member { + id: number + username: string + fullName: string + avatarUrl: string + joinedAt: string +} + +export const CrowdinContributorsProvider: Provider = { + name: 'crowdinContributors', + fetchSponsors(config) { + return fetchCrowdinContributors( + config.crowdinContributors?.token || config.token!, + config.crowdinContributors?.projectId || 0, + config.crowdinContributors?.minTranslations || 1, + ) + }, +} + +export async function fetchCrowdinContributors( + token: string, + projectId: number, + minTranslations = 1, +): Promise { + if (!token) + throw new Error('Crowdin token is required') + if (!projectId) + throw new Error('Crowdin project ID is required') + + const credentials: Credentials = { + token, + } + + // get the project + const projectsGroups: ProjectsGroups = new ProjectsGroups(credentials) + const project = await projectsGroups.getProject(projectId) + + // get top members report + const reports: Reports = new Reports(credentials) + + // today's date in ISO 8601 format + const dateTo = new Date().toISOString() + const dateFrom = project.data.createdAt + + const createReportRequestBody: ReportsModel.GenerateReportRequest = { + name: 'top-members', + schema: { + unit: 'words', + format: 'json', + dateFrom, + dateTo, + }, + } + + const createReport = await reports.generateReport(projectId, createReportRequestBody) + + // get the report + // sleep for 5 seconds + await new Promise(resolve => setTimeout(resolve, 5000)) + const report = await reports.downloadReport(projectId, createReport.data.identifier) + + // build contributors object from looping over the report data + const reportRaw = await fetch(report.data.url) + const reportData = await reportRaw.json() as { data: { user: Member, translated: number }[] } + + const contributors = reportData.data + .filter((entry: { user: Member, translated: number }) => entry.translated > minTranslations) + .map((entry: { user: Member, translated: number }) => ({ + member: entry.user, + translations: entry.translated, + })) + + return contributors + .filter(Boolean) + .map(({ member, translations }: { member: Member, translations: number }) => ({ + sponsor: { + type: 'User', + login: member.username, + name: member.username, // fullName is also available + avatarUrl: member.avatarUrl, + linkUrl: `https://crowdin.com/profile/${member.username}`, + }, + isOneTime: false, + monthlyDollars: translations, + privacyLevel: 'PUBLIC', + tierName: 'Translator', + createdAt: member.joinedAt, + provider: 'crowdinContributors', + })) +} diff --git a/src/providers/githubContributors.ts b/src/providers/githubContributors.ts new file mode 100644 index 0000000..be170b0 --- /dev/null +++ b/src/providers/githubContributors.ts @@ -0,0 +1,90 @@ +import type { Provider, Sponsorship } from '../types' +import { $fetch } from 'ofetch' + +export const GitHubContributorsProvider: Provider = { + name: 'githubContributors', + fetchSponsors(config) { + if (!config.githubContributors?.repo) + throw new Error('GitHub repository is required') + + return fetchGitHubContributors( + config.githubContributors?.token || config.token!, + config.githubContributors?.login || config.login!, + config.githubContributors.repo, + config.githubContributors?.minContributions, + ) + }, +} + +export async function fetchGitHubContributors( + token: string, + login: string, + repo: string, + minContributions = 1, +): Promise { + if (!token) + throw new Error('GitHub token is required') + + if (!login) + throw new Error('GitHub login is required') + + if (!repo) + throw new Error('GitHub repository is required') + + const allContributors: Array<{ + login: string + contributions: number + type: string + url: string + avatar_url: string + }> = [] + + let page = 1 + let hasNextPage = true + + while (hasNextPage) { + const response = await $fetch( + `https://api.github.com/repos/${login}/${repo}/contributors`, + { + query: { + page: String(page), + per_page: '100', + }, + headers: { + Authorization: `bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + }, + }, + ) + + if (!response || !response.length) + break + + allContributors.push(...response) + + // GitHub returns exactly 100 items when there are more pages + hasNextPage = response.length === 100 + page++ + } + + return allContributors + .filter(contributor => + contributor.type === 'User' + && contributor.contributions >= minContributions, + ) + .map(contributor => ({ + sponsor: { + type: 'User', + login: contributor.login, + name: contributor.login, + avatarUrl: contributor.avatar_url, + linkUrl: contributor.url, + }, + isOneTime: false, + monthlyDollars: contributor.contributions, + privacyLevel: 'PUBLIC', + tierName: 'Contributor', + createdAt: new Date().toISOString(), + provider: 'githubContributors', + })) +} diff --git a/src/providers/gitlabContributors.ts b/src/providers/gitlabContributors.ts new file mode 100644 index 0000000..33d5c4b --- /dev/null +++ b/src/providers/gitlabContributors.ts @@ -0,0 +1,115 @@ +import type { Provider, Sponsorship } from '../types' +import { $fetch } from 'ofetch' + +interface GitLabContributor { + name: string + email: string + commits: number +} + +interface GitLabUser { + id: number + username: string + name: string + avatar_url: string + web_url: string +} + +export const GitlabContributorsProvider: Provider = { + name: 'gitlabContributors', + fetchSponsors(config) { + if (!config.gitlabContributors?.repoId) + throw new Error('Gitlab repoId is required') + + return fetchGitlabContributors( + config.gitlabContributors?.token || config.token!, + config.gitlabContributors.repoId, + config.gitlabContributors?.minContributions, + ) + }, +} + +export async function fetchGitlabContributors( + token: string, + repoId: number, + minContributions: number = 1, +): Promise { + if (!token) + throw new Error('Gitlab token is required') + if (!repoId) + throw new Error('Gitlab repoId is required') + + const allContributors: GitLabContributor[] = [] + + let page = 1 + let hasNextPage = true + + while (hasNextPage) { + const response = await $fetch( + `https://gitlab.com/api/v4/projects/${repoId}/repository/contributors`, + { + query: { + page: String(page), + per_page: '100', + sort: 'desc', + }, + headers: { + 'PRIVATE-TOKEN': token, + 'Content-Type': 'application/json', + }, + }, + ) + + if (!response || !response.length) + break + + allContributors.push(...response) + + // Gitlab returns exactly 100 items when there are more pages + hasNextPage = response.length === 100 + page++ + } + + const sponsorships: Sponsorship[] = [] + + for (const contributor of allContributors) { + if (contributor.commits < minContributions) + continue + + try { + const userDetails = await $fetch('https://gitlab.com/api/v4/users', { + query: { + search: contributor.email, + }, + headers: { + 'PRIVATE-TOKEN': token, + 'Content-Type': 'application/json', + }, + }) + + if (userDetails && userDetails.length > 0) { + const user = userDetails[0] + sponsorships.push({ + sponsor: { + type: 'User', + login: user.username, + name: user.username, // user.name is also available + avatarUrl: user.avatar_url, + linkUrl: user.web_url, + }, + isOneTime: false, + monthlyDollars: contributor.commits, + privacyLevel: 'PUBLIC', + tierName: 'Contributor', + createdAt: new Date().toISOString(), + provider: 'gitlabContributors', + }) + } + } + catch (error) { + console.warn(`Failed to fetch user details for ${contributor.email}:`, error) + } + } + + return sponsorships +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 6366ce1..f4ca0f0 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,6 +1,9 @@ import type { ContribkitConfig, Provider, ProviderName } from '../types' import { AfdianProvider } from './afdian' +import { CrowdinContributorsProvider } from './crowdinContributors' import { GitHubProvider } from './github' +import { GitHubContributorsProvider } from './githubContributors' +import { GitlabContributorsProvider } from './gitlabContributors' import { LiberapayProvider } from './liberapay' import { OpenCollectiveProvider } from './opencollective' import { PatreonProvider } from './patreon' @@ -15,6 +18,9 @@ export const ProvidersMap = { afdian: AfdianProvider, polar: PolarProvider, liberapay: LiberapayProvider, + githubContributors: GitHubContributorsProvider, + gitlabContributors: GitlabContributorsProvider, + crowdinContributors: CrowdinContributorsProvider, } export function guessProviders(config: ContribkitConfig) { @@ -37,6 +43,15 @@ export function guessProviders(config: ContribkitConfig) { if (config.liberapay && config.liberapay.login) items.push('liberapay') + if (config.githubContributors?.login && config.githubContributors?.token) + items.push('githubContributors') + + if (config.gitlabContributors?.token && config.gitlabContributors?.repoId) + items.push('gitlabContributors') + + if (config.crowdinContributors?.token && config.crowdinContributors?.projectId) + items.push('crowdinContributors') + // fallback if (!items.length) items.push('github') diff --git a/src/types.ts b/src/types.ts index c7b4ed9..af425e7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,7 +75,7 @@ export const outputFormats = ['svg', 'png', 'webp', 'json'] as const export type OutputFormat = typeof outputFormats[number] -export type ProviderName = 'github' | 'patreon' | 'opencollective' | 'afdian' | 'polar' | 'liberapay' +export type ProviderName = 'github' | 'patreon' | 'opencollective' | 'afdian' | 'polar' | 'liberapay' | 'githubContributors' | 'gitlabContributors' | 'crowdinContributors' export type GitHubAccountType = 'user' | 'organization' @@ -209,6 +209,79 @@ export interface ProvidersConfig { */ login?: string } + + githubContributors?: { + /** + * User id of your GitHub account. + * + * Will read from `CONTRIBKIT_GITHUB_CONTRIBUTORS_LOGIN` environment variable if not set. + */ + login?: string + /** + * GitHub Token that have access to your sponsorships. + * + * Will read from `CONTRIBKIT_GITHUB_CONTRIBUTORS_TOKEN` environment variable if not set. + * + * @deprecated It's not recommended set this value directly, pass from env or use `.env` file. + */ + token?: string + /** + * The minimum number of contributions to be considered a sponsor. + * + * @default 1 + */ + minContributions?: number + /** + * The repository to fetch contributors from. + * + * Will read from `CONTRIBKIT_GITHUB_CONTRIBUTORS_REPO` environment variable if not set. + */ + repo?: string + } + + gitlabContributors?: { + /** + * Gitlab Token that have access contributors. + * + * Will read from `CONTRIBKIT_GITLAB_CONTRIBUTORS_TOKEN` environment variable if not set. + * + * @deprecated It's not recommended set this value directly, pass from env or use `.env` file. + */ + token?: string + /** + * The minimum number of contributions to be considered a sponsor. + * + * @default 1 + */ + minContributions?: number + /** + * The repository ID to fetch contributors from. + * + * Will read from `CONTRIBKIT_GITLAB_CONTRIBUTORS_REPO_ID` environment variable if not set. + */ + repoId?: number + } + + crowdinContributors?: { + /** + * The Crowdin API token. + * + * Will read from `CONTRIBKIT_CROWDIN_TOKEN` environment variable if not set. + */ + token?: string + /** + * The project id on Crowdin. + * + * Will read from `CONTRIBKIT_CROWDIN_PROJECT_ID` environment variable if not set. + */ + projectId?: number + /** + * The minimum number of translations to be considered a sponsor. + * + * @default 100 + */ + minTranslations?: number + } } export interface ContribkitRenderOptions {