diff --git a/README.md b/README.md index c30256f..bdae095 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ Supports: - Contributors: - [**CrowdIn**](https://crowdin.com) - - [**GitHub**](https://github.com) + - [**GitHub Contributors**](https://github.com) (contributors to a specific repository) + - [**GitHub Contributions**](https://github.com) (commit-only contributions aggregated by repository owner across all repos for a single user) - [**Gitlab**](https://gitlab.com) - Sponsors: - [**GitHub Sponsors**](https://github.com/sponsors) @@ -37,11 +38,26 @@ CONTRIBKIT_CROWDIN_MIN_TRANSLATIONS=1 ; GitHubContributors provider. ; Token requires the `public_repo` and `read:user` scopes. +; This provider tracks all contributors to a specific repository. CONTRIBKIT_GITHUB_CONTRIBUTORS_TOKEN= CONTRIBKIT_GITHUB_CONTRIBUTORS_LOGIN= CONTRIBKIT_GITHUB_CONTRIBUTORS_MIN=1 CONTRIBKIT_GITHUB_CONTRIBUTORS_REPO= +; GitHubContributions provider. +; Token requires the `read:user` scope. +; This provider aggregates merged pull requests across all repositories by repository owner (user or organization). +; Each owner appears once with the total merged PRs you authored to their repos. +; Avatar and link point to the owner (or to the repo if only one repo per owner). +; Only merged PRs are counted - open or closed-without-merge PRs are excluded. +CONTRIBKIT_GITHUB_CONTRIBUTIONS_TOKEN= +CONTRIBKIT_GITHUB_CONTRIBUTIONS_LOGIN= +; Optional tuning: +; Retry attempts for transient errors (5xx), default 3 +CONTRIBKIT_GITHUB_CONTRIBUTIONS_RETRIES=3 +; Initial retry delay in ms, default 500 (exponential backoff with jitter) +CONTRIBKIT_GITHUB_CONTRIBUTIONS_RETRY_DELAY_MS=500 + ; GitlabContributors provider. ; Token requires the `read_api` and `read_user` scopes. CONTRIBKIT_GITLAB_CONTRIBUTORS_TOKEN= @@ -96,9 +112,27 @@ CONTRIBKIT_LIBERAPAY_LOGIN= > This will require different env variables to be set for each provider, and to be created from separate > commands. +#### GitHub Provider Options + +There are two GitHub contributor providers available: + +- **GitHubContributors**: Tracks all contributors to a specific repository (e.g., `owner/repo`). Each contributor appears once with their actual contribution count to that repository. +- **GitHubContributions**: Aggregates a single user's **merged pull requests** across all repositories, grouped by repository owner (user or organization). Each owner appears once with the total merged PRs. The avatar and link point to the owner (or to the specific repo if only one repo per owner). + +Use **GitHubContributors** when you want to showcase everyone who has contributed to your project with their contribution counts. +Use **GitHubContributions** when you want to understand where a single user's completed contributions (merged PRs) have gone, without overwhelming duplicates per repo under the same owner. + +**GitHubContributions accuracy**: +- Counts only **merged** pull requests - open or closed-without-merge PRs are excluded +- Discovers repos via **2 sources**: + 1. **contributionsCollection** - Yearly commit timeline (full history) for discovering repositories you have committed to + 2. **Search API** - Repositories where you have merged PRs (`is:pr is:merged author:login`) +- When an owner has only one repo, the link points to that repo; otherwise to the owner profile +- Better metric than commits as it represents completed, reviewed contributions + Run: -```base +```bash npx contribkit ``` @@ -133,6 +167,11 @@ export default defineConfig({ // ... }, + // For contributor providers: + githubContributions: { + login: 'username', + }, + // Rendering configs width: 800, renderer: 'tiers', // or 'circles' diff --git a/src/configs/env.ts b/src/configs/env.ts index b262cd6..1822077 100644 --- a/src/configs/env.ts +++ b/src/configs/env.ts @@ -57,6 +57,12 @@ export function loadEnv(): Partial { projectId: Number(process.env.CONTRIBKIT_CROWDIN_PROJECT_ID), minTranslations: Number(process.env.CONTRIBKIT_CROWDIN_MIN_TRANSLATIONS) || 1, }, + githubContributions: { + login: process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_LOGIN, + token: process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_TOKEN, + retries: Number(process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_RETRIES) || 3, + retryDelayMs: Number(process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_RETRY_DELAY_MS) || 500, + }, } // remove undefined keys diff --git a/src/processing/svg.ts b/src/processing/svg.ts index 715866c..b4041af 100644 --- a/src/processing/svg.ts +++ b/src/processing/svg.ts @@ -10,7 +10,9 @@ export function genSvgImage( base64Image: string, imageFormat: ImageFormat, ) { - const cropId = `c${crypto.createHash('md5').update(base64Image).digest('hex').slice(0, 6)}` + // Ensure unique clipPath id per element. Previously, we only hashed the image bytes, + // which caused duplicates when many items shared the same avatar. Include geometry too. + const cropId = `c${crypto.createHash('md5').update(`${x}:${y}:${size}:${radius}:${base64Image}`).digest('hex').slice(0, 6)}` return ` diff --git a/src/providers/githubContributions.ts b/src/providers/githubContributions.ts new file mode 100644 index 0000000..242ea6d --- /dev/null +++ b/src/providers/githubContributions.ts @@ -0,0 +1,277 @@ +import type { Provider, Sponsorship } from '../types' +import { $fetch } from 'ofetch' + +export const GitHubContributionsProvider: Provider = { + name: 'githubContributions', + fetchSponsors(config) { + if (!config.githubContributions?.login) + throw new Error('GitHub login is required for githubContributions provider') + + return fetchGitHubContributions( + config.githubContributions?.token || config.token!, + config.githubContributions.login, + { + retries: config.githubContributions?.retries ?? 3, + retryDelayMs: config.githubContributions?.retryDelayMs ?? 500, + }, + ) + }, +} + +interface RepositoryOwner { + login: string + url: string + avatarUrl: string + __typename: 'User' | 'Organization' +} + +interface RepoNode { + name: string + nameWithOwner: string + url: string + owner: RepositoryOwner +} + +interface FetchOptions { + retries?: number + retryDelayMs?: number +} + +export async function fetchGitHubContributions( + token: string, + login: string, + options: FetchOptions = {}, +): Promise { + if (!token) + throw new Error('GitHub token is required') + + if (!login) + throw new Error('GitHub login is required') + + const retries = Math.max(0, options.retries ?? 3) + const retryDelayMs = Math.max(0, options.retryDelayMs ?? 500) + + async function graphqlFetch(body: any, localRetries = retries, localRetryDelayMs = retryDelayMs): Promise { + let attempt = 0 + let delay = localRetryDelayMs + for (;;) { + try { + return await $fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + Authorization: `bearer ${token}`, + 'Content-Type': 'application/json', + }, + body, + }) + } + catch (e: any) { + const status = e?.status ?? e?.response?.status ?? e?.statusCode + const isTransient = (status && status >= 500 && status < 600) || e?.message?.includes('502') || e?.message?.includes('Bad Gateway') + if (!isTransient || attempt >= localRetries) { + console.error(`[contribkit][githubContributions] GraphQL request failed after ${attempt} retries:`, e.message || e) + throw e + } + console.warn(`[contribkit][githubContributions] transient error (attempt ${attempt + 1}/${localRetries + 1}): ${e.message || status}. Retrying in ${delay}ms...`) + await new Promise(r => setTimeout(r, delay + Math.floor(Math.random() * 100))) + delay *= 2 + attempt += 1 + } + } + } + + // Hybrid discovery (sources kept): + // 1. contributionsCollection (yearly commit timeline) to find historical commit-based repos + // 2. merged PR search (GraphQL search API) to find repos where the user had merged PRs + // Removed: previous sources (topRepositories, repositoriesContributedTo, repositories, events API) for simplicity + + console.log(`[contribkit][githubContributions] discovering repositories (sources: contributionsCollection + merged PR search)...`) + + const repoMap = new Map() // deduplicate by nameWithOwner + + // Source 1: GraphQL contributionsCollection (discover repos via actual commit contributions) + console.log(`[contribkit][githubContributions] fetching contribution timeline to discover more repos...`) + try { + const userInfoQuery = ` + query($login: String!) { + user(login: $login) { + createdAt + } + } + ` + const userInfo = await graphqlFetch<{ data: { user: { createdAt: string } } }>({ + query: userInfoQuery, + variables: { login }, + }) + + const accountCreated = new Date(userInfo.data.user.createdAt) + const now = new Date() + + const years: Array<{ from: string; to: string }> = [] + for (let year = accountCreated.getFullYear(); year <= now.getFullYear(); year++) { + const from = year === accountCreated.getFullYear() + ? accountCreated.toISOString() + : `${year}-01-01T00:00:00Z` + const to = year === now.getFullYear() + ? now.toISOString() + : `${year}-12-31T23:59:59Z` + years.push({ from, to }) + } + + console.log(`[contribkit][githubContributions] querying contributions across ${years.length} years...`) + + for (const { from, to } of years) { + try { + const contributionsQuery = ` + query($login: String!, $from: DateTime!, $to: DateTime!) { + user(login: $login) { + contributionsCollection(from: $from, to: $to) { + commitContributionsByRepository { + repository { + name + nameWithOwner + url + owner { login url avatarUrl __typename } + } + } + } + } + } + ` + type ContributionsResponse = { data: { user: { contributionsCollection: { commitContributionsByRepository: Array<{ repository: RepoNode }> } } } } + const contributionsResp: ContributionsResponse = await graphqlFetch({ + query: contributionsQuery, + variables: { login, from, to }, + }) + for (const item of contributionsResp.data.user.contributionsCollection.commitContributionsByRepository) { + if (item.repository?.nameWithOwner) + repoMap.set(item.repository.nameWithOwner, item.repository) + } + } + catch (e: any) { + console.warn(`[contribkit][githubContributions] failed contributions query for ${from.slice(0, 4)}:`, e.message) + } + } + } + catch (e: any) { + console.warn(`[contribkit][githubContributions] contribution timeline discovery failed:`, e.message) + } + + console.log(`[contribkit][githubContributions] found ${repoMap.size} repos after contribution timeline`) + + // Source 2: GraphQL search for repos with merged PRs (discover via PR activity) + console.log(`[contribkit][githubContributions] searching for repos with merged PRs...`) + try { + const searchQueryBase = `is:pr is:merged author:${login}` + let searchAfter: string | null = null + let page = 0 + const maxPages = 10 + do { + type SearchResponse = { data: { search: { pageInfo: { hasNextPage: boolean; endCursor: string | null }; edges: Array<{ node: { repository?: RepoNode } }> } } } + const response: SearchResponse = await graphqlFetch({ + query: ` + query($searchQuery: String!, $after: String) { + search(query: $searchQuery, type: ISSUE, first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + edges { node { ... on PullRequest { repository { name nameWithOwner url owner { login url avatarUrl __typename } } } } } + } + } + `, + variables: { searchQuery: searchQueryBase, after: searchAfter }, + }) + for (const edge of response.data.search.edges) { + const r = edge.node.repository + if (r?.nameWithOwner) + repoMap.set(r.nameWithOwner, r) + } + searchAfter = response.data.search.pageInfo.endCursor + page++ + if (response.data.search.pageInfo.hasNextPage && page < maxPages) + console.log(`[contribkit][githubContributions] merged PR search page ${page}, ${repoMap.size} repos so far`) + } while (searchAfter && page < maxPages) + } + catch (e: any) { + console.warn(`[contribkit][githubContributions] merged PR search failed:`, e.message) + } + console.log(`[contribkit][githubContributions] found ${repoMap.size} repos after merged PR search`) + + const allRepos = Array.from(repoMap.values()) + console.log(`[contribkit][githubContributions] discovered ${allRepos.length} total unique repositories`) + + // Fetch merged PR counts (completed contributions) + console.log(`[contribkit][githubContributions] fetching merged PR counts per repository...`) + const repoPRs = new Map() + const batchSize = 10 + for (let i = 0; i < allRepos.length; i += batchSize) { + const batch = allRepos.slice(i, i + batchSize) + await Promise.all(batch.map(async (repo) => { + const searchQuery = `repo:${repo.nameWithOwner} is:pr is:merged author:${login}` + try { + const response = await graphqlFetch<{ + data: { search: { issueCount: number } } + }>({ + query: `query($q: String!) { search(query: $q, type: ISSUE) { issueCount } }`, + variables: { q: searchQuery }, + }) + const count = response.data.search.issueCount + if (count > 0) + repoPRs.set(repo.nameWithOwner, count) + } + catch (e: any) { + console.warn(`[contribkit][githubContributions] failed PR count for ${repo.nameWithOwner}:`, e.message) + } + })) + if (i + batchSize < allRepos.length) + console.log(`[contribkit][githubContributions] processed PR batches for ${Math.min(i + batchSize, allRepos.length)}/${allRepos.length} repos...`) + } + console.log(`[contribkit][githubContributions] found merged PR counts for ${repoPRs.size} repositories`) + + const results: { repo: RepoNode; prs: number }[] = [] + for (const repo of allRepos) { + const prs = repoPRs.get(repo.nameWithOwner) || 0 + if (prs > 0) + results.push({ repo, prs }) + } + console.log(`[contribkit][githubContributions] computed merged PR counts for ${results.length} repositories (from ${allRepos.length} total repos with PRs)`) + + // Aggregate by owner + const aggregated = new Map }>() + for (const { repo, prs } of results) { + const key = `${repo.owner.__typename}:${repo.owner.login}` + const existing = aggregated.get(key) + if (existing) { + existing.totalPRs += prs + existing.repos.push({ repo, prs }) + } + else { + aggregated.set(key, { owner: repo.owner, totalPRs: prs, repos: [{ repo, prs }] }) + } + } + + const consolidated = Array.from(aggregated.values()).filter(a => a.repos.length > 1) + if (consolidated.length) { + console.log(`[contribkit][githubContributions] consolidated ${consolidated.length} owners with multiple repos:`) + for (const { owner, repos, totalPRs } of consolidated.sort((a, b) => b.repos.length - a.repos.length).slice(0, 10)) + console.log(` - ${owner.login}: ${repos.length} repos, ${totalPRs} merged PRs`) + if (consolidated.length > 10) + console.log(` ... and ${consolidated.length - 10} more`) + } + + const sponsors: Sponsorship[] = Array.from(aggregated.values()) + .sort((a, b) => b.totalPRs - a.totalPRs) + .map(({ owner, totalPRs, repos }) => { + const linkUrl = repos.length === 1 ? repos[0].repo.url : owner.url + return { + sponsor: { type: owner.__typename, login: owner.login, name: owner.login, avatarUrl: owner.avatarUrl, linkUrl, socialLogins: { github: owner.login } }, + isOneTime: false, + monthlyDollars: totalPRs, + privacyLevel: 'PUBLIC', + tierName: 'Repository', + createdAt: new Date().toISOString(), + provider: 'githubContributions', + raw: { owner, totalPRs, repoCount: repos.length }, + } + }) + + return sponsors +} diff --git a/src/providers/index.ts b/src/providers/index.ts index f4ca0f0..613dd39 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -3,6 +3,7 @@ import { AfdianProvider } from './afdian' import { CrowdinContributorsProvider } from './crowdinContributors' import { GitHubProvider } from './github' import { GitHubContributorsProvider } from './githubContributors' +import { GitHubContributionsProvider } from './githubContributions' import { GitlabContributorsProvider } from './gitlabContributors' import { LiberapayProvider } from './liberapay' import { OpenCollectiveProvider } from './opencollective' @@ -19,6 +20,7 @@ export const ProvidersMap = { polar: PolarProvider, liberapay: LiberapayProvider, githubContributors: GitHubContributorsProvider, + githubContributions: GitHubContributionsProvider, gitlabContributors: GitlabContributorsProvider, crowdinContributors: CrowdinContributorsProvider, } @@ -46,6 +48,9 @@ export function guessProviders(config: ContribkitConfig) { if (config.githubContributors?.login && config.githubContributors?.token) items.push('githubContributors') + if (config.githubContributions?.login && config.githubContributions?.token) + items.push('githubContributions') + if (config.gitlabContributors?.token && config.gitlabContributors?.repoId) items.push('gitlabContributors') diff --git a/src/renders/circles.ts b/src/renders/circles.ts index aac2a8f..0fa3c7e 100644 --- a/src/renders/circles.ts +++ b/src/renders/circles.ts @@ -33,6 +33,9 @@ export const circlesRenderer: ContribkitRenderer = { p.padding(config.width / 400) const circles = p(root as any).descendants().slice(1) + // Draw smaller circles last so they appear on top and are not hidden by larger ones + circles.sort((a, b) => a.r - b.r) + for (const circle of circles) { composer.addRaw(await generateBadge( circle.x - circle.r, diff --git a/src/types.ts b/src/types.ts index 18f5416..1b11c9c 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' | 'githubContributors' | 'gitlabContributors' | 'crowdinContributors' +export type ProviderName = 'github' | 'patreon' | 'opencollective' | 'afdian' | 'polar' | 'liberapay' | 'githubContributors' | 'gitlabContributors' | 'crowdinContributors' | 'githubContributions' export type GitHubAccountType = 'user' | 'organization' @@ -282,6 +282,33 @@ export interface ProvidersConfig { */ minTranslations?: number } + + githubContributions?: { + /** + * GitHub user login to fetch contributions for. + * + * Will read from `CONTRIBKIT_GITHUB_CONTRIBUTIONS_LOGIN` environment variable if not set. + */ + login?: string + /** + * GitHub Token that has access to read user contributions. + * + * Will read from `CONTRIBKIT_GITHUB_CONTRIBUTIONS_TOKEN` environment variable if not set. + * + * @deprecated It's not recommended set this value directly, pass from env or use `.env` file. + */ + token?: string + /** + * Number of retries for GitHub GraphQL requests on transient failures (e.g., 5xx). + * @default 3 + */ + retries?: number + /** + * Initial retry delay in milliseconds for backoff. + * @default 500 + */ + retryDelayMs?: number + } } export interface ContribkitRenderOptions {