diff --git a/README.md b/README.md index c30256f..902e087 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) (merged PRs 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,25 @@ 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: Cap the maximum contribution count per org/user (useful for circles visualization) +CONTRIBKIT_GITHUB_CONTRIBUTIONS_MAX= +; Optional: Apply logarithmic scaling to reduce dominance of high contributors (true/false) +CONTRIBKIT_GITHUB_CONTRIBUTIONS_LOGARITHMIC= + ; GitlabContributors provider. ; Token requires the `read_api` and `read_user` scopes. CONTRIBKIT_GITLAB_CONTRIBUTORS_TOKEN= @@ -96,9 +111,26 @@ 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 + Run: -```base +```bash npx contribkit ``` @@ -133,6 +165,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..f74a317 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, + maxContributions: Number(process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_MAX) || undefined, + logarithmicScaling: process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_LOGARITHMIC === 'true', + }, } // remove undefined keys diff --git a/src/processing/svg.ts b/src/processing/svg.ts index 715866c..2f7177b 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)}` + // Unique clipPath id per element, ensuring duplicated images are properly rendered. + const hashInput = `${x}:${y}:${size}:${radius}:${base64Image}` + const cropId = `c${crypto.createHash('sha256').update(hashInput).digest('hex').slice(0, 6)}` return ` diff --git a/src/providers/githubContributions.ts b/src/providers/githubContributions.ts new file mode 100644 index 0000000..262bbab --- /dev/null +++ b/src/providers/githubContributions.ts @@ -0,0 +1,355 @@ +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, + config.githubContributions.maxContributions, + config.githubContributions.logarithmicScaling, + ) + }, +} + +interface RepositoryOwner { + login: string + url: string + avatarUrl: string + __typename: 'User' | 'Organization' +} + +interface RepoNode { + name: string + nameWithOwner: string + url: string + owner: RepositoryOwner +} + +type GraphQLFetch = (body: any) => Promise + +function createGraphQLFetch(token: string): GraphQLFetch { + return async (body: any): Promise => { + return await $fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + Authorization: `bearer ${token}`, + 'Content-Type': 'application/json', + }, + body, + }) + } +} + +async function fetchUserCreationDate(graphqlFetch: GraphQLFetch, login: string): Promise { + const userInfoQuery = ` + query($login: String!) { + user(login: $login) { + createdAt + } + } + ` + const userInfo = await graphqlFetch<{ data: { user: { createdAt: string } } }>({ + query: userInfoQuery, + variables: { login }, + }) + return new Date(userInfo.data.user.createdAt) +} + +function generateYearRanges(accountCreated: Date, now: Date): Array<{ from: string; to: string }> { + 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 }) + } + return years +} + +async function fetchContributionsForYear( + graphqlFetch: GraphQLFetch, + login: string, + from: string, + to: string, +): Promise { + 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 }, + }) + return contributionsResp.data.user.contributionsCollection.commitContributionsByRepository + .map(item => item.repository) + .filter(repo => repo?.nameWithOwner) +} + +async function discoverReposFromContributions( + graphqlFetch: GraphQLFetch, + login: string, + repoMap: Map, +): Promise { + console.log(`[contribkit][githubContributions] fetching contribution timeline to discover more repos...`) + try { + const accountCreated = await fetchUserCreationDate(graphqlFetch, login) + const now = new Date() + const years = generateYearRanges(accountCreated, now) + + console.log(`[contribkit][githubContributions] querying contributions across ${years.length} years...`) + + for (const { from, to } of years) { + try { + const repos = await fetchContributionsForYear(graphqlFetch, login, from, to) + for (const repo of repos) { + repoMap.set(repo.nameWithOwner, repo) + } + } + 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) + } +} + +async function discoverReposFromMergedPRs( + graphqlFetch: GraphQLFetch, + login: string, + repoMap: Map, +): Promise { + 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) + } +} + +async function fetchPRCountForRepo( + graphqlFetch: GraphQLFetch, + repo: RepoNode, + login: string, +): Promise { + 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 }, + }) + return response.data.search.issueCount + } + catch (e: any) { + console.warn(`[contribkit][githubContributions] failed PR count for ${repo.nameWithOwner}:`, e.message) + return 0 + } +} + +async function fetchMergedPRCounts( + graphqlFetch: GraphQLFetch, + allRepos: RepoNode[], + login: string, +): Promise> { + 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) + const counts = await Promise.all(batch.map(repo => fetchPRCountForRepo(graphqlFetch, repo, login))) + + for (let index = 0; index < batch.length; index++) { + const count = counts[index] + if (count > 0) + repoPRs.set(batch[index].nameWithOwner, count) + } + + 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`) + return repoPRs +} + +function aggregateByOwner(results: Array<{ repo: RepoNode; prs: number }>): Map }> { + 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 }] }) + } + } + + return aggregated +} + +function logConsolidatedOwners(aggregated: Map }>): void { + 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.toSorted((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`) + } +} + +function applyContributionScaling( + totalPRs: number, + maxContributions?: number, + logarithmicScaling?: boolean, +): number { + let scaled = totalPRs + + // Apply logarithmic scaling if enabled + if (logarithmicScaling && scaled > 0) { + // Use log10(x + 1) to handle values smoothly + // Multiply by 10 to keep numbers in a reasonable range + scaled = Math.log10(scaled + 1) * 10 + } + + // Apply max cap if specified + if (maxContributions !== undefined && scaled > maxContributions) { + scaled = maxContributions + } + + return scaled +} + +function convertToSponsorships( + aggregated: Map }>, + maxContributions?: number, + logarithmicScaling?: boolean, +): Sponsorship[] { + return Array.from(aggregated.values()) + .sort((a, b) => b.totalPRs - a.totalPRs) + .map(({ owner, totalPRs, repos }) => { + const scaledPRs = applyContributionScaling(totalPRs, maxContributions, logarithmicScaling) + 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: scaledPRs, + privacyLevel: 'PUBLIC', + tierName: 'Repository', + createdAt: new Date().toISOString(), + provider: 'githubContributions', + raw: { owner, totalPRs, scaledPRs, repoCount: repos.length }, + } + }) +} + +export async function fetchGitHubContributions( + token: string, + login: string, + maxContributions?: number, + logarithmicScaling?: boolean, +): Promise { + if (!token) + throw new Error('GitHub token is required') + + if (!login) + throw new Error('GitHub login is required') + + const graphqlFetch = createGraphQLFetch(token) + + console.log(`[contribkit][githubContributions] discovering repositories (sources: contributionsCollection + merged PR search)...`) + + const repoMap = new Map() + + await discoverReposFromContributions(graphqlFetch, login, repoMap) + console.log(`[contribkit][githubContributions] found ${repoMap.size} repos after contribution timeline`) + + await discoverReposFromMergedPRs(graphqlFetch, login, repoMap) + 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`) + + const repoPRs = await fetchMergedPRCounts(graphqlFetch, allRepos, login) + + const results: Array<{ 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)`) + + const aggregated = aggregateByOwner(results) + logConsolidatedOwners(aggregated) + + const scalingInfo = [] + if (maxContributions !== undefined) + scalingInfo.push(`max cap: ${maxContributions}`) + if (logarithmicScaling) + scalingInfo.push('logarithmic scaling enabled') + if (scalingInfo.length > 0) + console.log(`[contribkit][githubContributions] applying contribution scaling: ${scalingInfo.join(', ')}`) + + return convertToSponsorships(aggregated, maxContributions, logarithmicScaling) +} 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/types.ts b/src/types.ts index 18f5416..0aea45f 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,37 @@ 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 + /** + * Cap the maximum contribution count per organization/user. + * Useful to prevent one dominant contributor from overshadowing others in visualizations. + * + * @example 100 // Cap all contributions at 100 PRs max + */ + maxContributions?: number + /** + * Apply logarithmic scaling to contribution counts. + * Useful to reduce the visual dominance of high contributors while maintaining relative differences. + * + * @default false + */ + logarithmicScaling?: boolean + } } export interface ContribkitRenderOptions {