Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- [**GitHub Contributions**](https://github.com) (commit-only contributions aggregated by repository owner across all repos for a single user)
- [**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)
Expand All @@ -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
Comment on lines +55 to +59
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
; 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

Not necessary.


; GitlabContributors provider.
; Token requires the `read_api` and `read_user` scopes.
CONTRIBKIT_GITLAB_CONTRIBUTORS_TOKEN=
Expand Down Expand Up @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Better metric than commits as it represents completed, reviewed contributions


Run:

```base
```bash
npx contribkit
```

Expand Down Expand Up @@ -133,6 +167,11 @@ export default defineConfig({
// ...
},

// For contributor providers:
githubContributions: {
login: 'username',
},

// Rendering configs
width: 800,
renderer: 'tiers', // or 'circles'
Expand Down
6 changes: 6 additions & 0 deletions src/configs/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export function loadEnv(): Partial<ContribkitConfig> {
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,
Comment on lines +63 to +64
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
retries: Number(process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_RETRIES) || 3,
retryDelayMs: Number(process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_RETRY_DELAY_MS) || 500,

},
}

// remove undefined keys
Expand Down
4 changes: 3 additions & 1 deletion src/processing/svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
Comment on lines +13 to +15
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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)}`
// Unique clipPath id per element, ensuring duplicated images are properly rendered.
const cropId = `c${crypto.createHash('md5').update(`${x}:${y}:${size}:${radius}:${base64Image}`).digest('hex').slice(0, 6)}`

return `
<clipPath id="${cropId}">
<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${size * radius}" ry="${size * radius}" />
Expand Down
277 changes: 277 additions & 0 deletions src/providers/githubContributions.ts
Original file line number Diff line number Diff line change
@@ -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,
},
Comment on lines +13 to +16
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{
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<Sponsorship[]> {
Comment on lines +35 to +44
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
interface FetchOptions {
retries?: number
retryDelayMs?: number
}
export async function fetchGitHubContributions(
token: string,
login: string,
options: FetchOptions = {},
): Promise<Sponsorship[]> {
export async function fetchGitHubContributions(
token: string,
login: string,
): Promise<Sponsorship[]> {

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)

Comment on lines +51 to +53
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const retries = Math.max(0, options.retries ?? 3)
const retryDelayMs = Math.max(0, options.retryDelayMs ?? 500)

async function graphqlFetch<T>(body: any, localRetries = retries, localRetryDelayMs = retryDelayMs): Promise<T> {
let attempt = 0
let delay = localRetryDelayMs
for (;;) {
try {
return await $fetch<T>('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<string, RepoNode>() // 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<ContributionsResponse>({
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<SearchResponse>({
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<string, number>()
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<string, { owner: RepositoryOwner; totalPRs: number; repos: Array<{ repo: RepoNode; prs: number }> }>()
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
}
Loading