Skip to content

Commit 0857af8

Browse files
feat(providers): add indivudal GitHub contributions
1 parent d4e3c8e commit 0857af8

File tree

6 files changed

+311
-4
lines changed

6 files changed

+311
-4
lines changed

README.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ Supports:
1313

1414
- Contributors:
1515
- [**CrowdIn**](https://crowdin.com)
16-
- [**GitHub**](https://github.com)
16+
- [**GitHub Contributors**](https://github.com) (contributors to a specific repository)
17+
- [**GitHub Contributions**](https://github.com) (merged PRs aggregated by repository owner across all repos for a single user)
1718
- [**Gitlab**](https://gitlab.com)
1819
- Sponsors:
1920
- [**GitHub Sponsors**](https://github.com/sponsors)
@@ -37,11 +38,21 @@ CONTRIBKIT_CROWDIN_MIN_TRANSLATIONS=1
3738

3839
; GitHubContributors provider.
3940
; Token requires the `public_repo` and `read:user` scopes.
41+
; This provider tracks all contributors to a specific repository.
4042
CONTRIBKIT_GITHUB_CONTRIBUTORS_TOKEN=
4143
CONTRIBKIT_GITHUB_CONTRIBUTORS_LOGIN=
4244
CONTRIBKIT_GITHUB_CONTRIBUTORS_MIN=1
4345
CONTRIBKIT_GITHUB_CONTRIBUTORS_REPO=
4446

47+
; GitHubContributions provider.
48+
; Token requires the `read:user` scope.
49+
; This provider aggregates merged pull requests across all repositories by repository owner (user or organization).
50+
; Each owner appears once with the total merged PRs you authored to their repos.
51+
; Avatar and link point to the owner (or to the repo if only one repo per owner).
52+
; Only merged PRs are counted - open or closed-without-merge PRs are excluded.
53+
CONTRIBKIT_GITHUB_CONTRIBUTIONS_TOKEN=
54+
CONTRIBKIT_GITHUB_CONTRIBUTIONS_LOGIN=
55+
4556
; GitlabContributors provider.
4657
; Token requires the `read_api` and `read_user` scopes.
4758
CONTRIBKIT_GITLAB_CONTRIBUTORS_TOKEN=
@@ -96,9 +107,26 @@ CONTRIBKIT_LIBERAPAY_LOGIN=
96107
> This will require different env variables to be set for each provider, and to be created from separate
97108
> commands.
98109
110+
#### GitHub Provider Options
111+
112+
There are two GitHub contributor providers available:
113+
114+
- **GitHubContributors**: Tracks all contributors to a specific repository (e.g., `owner/repo`). Each contributor appears once with their actual contribution count to that repository.
115+
- **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).
116+
117+
Use **GitHubContributors** when you want to showcase everyone who has contributed to your project with their contribution counts.
118+
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.
119+
120+
**GitHubContributions accuracy**:
121+
- Counts only **merged** pull requests - open or closed-without-merge PRs are excluded
122+
- Discovers repos via **2 sources**:
123+
1. **contributionsCollection** - Yearly commit timeline (full history) for discovering repositories you have committed to
124+
2. **Search API** - Repositories where you have merged PRs (`is:pr is:merged author:login`)
125+
- When an owner has only one repo, the link points to that repo; otherwise to the owner profile
126+
99127
Run:
100128

101-
```base
129+
```bash
102130
npx contribkit
103131
```
104132

@@ -133,6 +161,11 @@ export default defineConfig({
133161
// ...
134162
},
135163

164+
// For contributor providers:
165+
githubContributions: {
166+
login: 'username',
167+
},
168+
136169
// Rendering configs
137170
width: 800,
138171
renderer: 'tiers', // or 'circles'

src/configs/env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ export function loadEnv(): Partial<ContribkitConfig> {
5757
projectId: Number(process.env.CONTRIBKIT_CROWDIN_PROJECT_ID),
5858
minTranslations: Number(process.env.CONTRIBKIT_CROWDIN_MIN_TRANSLATIONS) || 1,
5959
},
60+
githubContributions: {
61+
login: process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_LOGIN,
62+
token: process.env.CONTRIBKIT_GITHUB_CONTRIBUTIONS_TOKEN,
63+
},
6064
}
6165

6266
// remove undefined keys

src/processing/svg.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ export function genSvgImage(
1010
base64Image: string,
1111
imageFormat: ImageFormat,
1212
) {
13-
const cropId = `c${crypto.createHash('md5').update(base64Image).digest('hex').slice(0, 6)}`
13+
// Unique clipPath id per element, ensuring duplicated images are properly rendered.
14+
const hashInput = `${x}:${y}:${size}:${radius}:${base64Image}`
15+
const cropId = `c${crypto.createHash('sha256').update(hashInput).digest('hex').slice(0, 6)}`
1416
return `
1517
<clipPath id="${cropId}">
1618
<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${size * radius}" ry="${size * radius}" />
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import type { Provider, Sponsorship } from '../types'
2+
import { $fetch } from 'ofetch'
3+
4+
export const GitHubContributionsProvider: Provider = {
5+
name: 'githubContributions',
6+
fetchSponsors(config) {
7+
if (!config.githubContributions?.login)
8+
throw new Error('GitHub login is required for githubContributions provider')
9+
10+
return fetchGitHubContributions(
11+
config.githubContributions?.token || config.token!,
12+
config.githubContributions.login,
13+
)
14+
},
15+
}
16+
17+
interface RepositoryOwner {
18+
login: string
19+
url: string
20+
avatarUrl: string
21+
__typename: 'User' | 'Organization'
22+
}
23+
24+
interface RepoNode {
25+
name: string
26+
nameWithOwner: string
27+
url: string
28+
owner: RepositoryOwner
29+
}
30+
31+
export async function fetchGitHubContributions(
32+
token: string,
33+
login: string,
34+
): Promise<Sponsorship[]> {
35+
if (!token)
36+
throw new Error('GitHub token is required')
37+
38+
if (!login)
39+
throw new Error('GitHub login is required')
40+
41+
async function graphqlFetch<T>(body: any): Promise<T> {
42+
return await $fetch<T>('https://api.github.com/graphql', {
43+
method: 'POST',
44+
headers: {
45+
Authorization: `bearer ${token}`,
46+
'Content-Type': 'application/json',
47+
},
48+
body,
49+
})
50+
}
51+
52+
// Hybrid discovery (sources kept):
53+
// 1. contributionsCollection (yearly commit timeline) to find historical commit-based repos
54+
// 2. merged PR search (GraphQL search API) to find repos where the user had merged PRs
55+
// Removed: previous sources (topRepositories, repositoriesContributedTo, repositories, events API) for simplicity
56+
57+
console.log(`[contribkit][githubContributions] discovering repositories (sources: contributionsCollection + merged PR search)...`)
58+
59+
const repoMap = new Map<string, RepoNode>() // deduplicate by nameWithOwner
60+
61+
// Source 1: GraphQL contributionsCollection (discover repos via actual commit contributions)
62+
console.log(`[contribkit][githubContributions] fetching contribution timeline to discover more repos...`)
63+
try {
64+
const userInfoQuery = `
65+
query($login: String!) {
66+
user(login: $login) {
67+
createdAt
68+
}
69+
}
70+
`
71+
const userInfo = await graphqlFetch<{ data: { user: { createdAt: string } } }>({
72+
query: userInfoQuery,
73+
variables: { login },
74+
})
75+
76+
const accountCreated = new Date(userInfo.data.user.createdAt)
77+
const now = new Date()
78+
79+
const years: Array<{ from: string; to: string }> = []
80+
for (let year = accountCreated.getFullYear(); year <= now.getFullYear(); year++) {
81+
const from = year === accountCreated.getFullYear()
82+
? accountCreated.toISOString()
83+
: `${year}-01-01T00:00:00Z`
84+
const to = year === now.getFullYear()
85+
? now.toISOString()
86+
: `${year}-12-31T23:59:59Z`
87+
years.push({ from, to })
88+
}
89+
90+
console.log(`[contribkit][githubContributions] querying contributions across ${years.length} years...`)
91+
92+
for (const { from, to } of years) {
93+
try {
94+
const contributionsQuery = `
95+
query($login: String!, $from: DateTime!, $to: DateTime!) {
96+
user(login: $login) {
97+
contributionsCollection(from: $from, to: $to) {
98+
commitContributionsByRepository {
99+
repository {
100+
name
101+
nameWithOwner
102+
url
103+
owner { login url avatarUrl __typename }
104+
}
105+
}
106+
}
107+
}
108+
}
109+
`
110+
type ContributionsResponse = { data: { user: { contributionsCollection: { commitContributionsByRepository: Array<{ repository: RepoNode }> } } } }
111+
const contributionsResp: ContributionsResponse = await graphqlFetch<ContributionsResponse>({
112+
query: contributionsQuery,
113+
variables: { login, from, to },
114+
})
115+
for (const item of contributionsResp.data.user.contributionsCollection.commitContributionsByRepository) {
116+
if (item.repository?.nameWithOwner)
117+
repoMap.set(item.repository.nameWithOwner, item.repository)
118+
}
119+
}
120+
catch (e: any) {
121+
console.warn(`[contribkit][githubContributions] failed contributions query for ${from.slice(0, 4)}:`, e.message)
122+
}
123+
}
124+
}
125+
catch (e: any) {
126+
console.warn(`[contribkit][githubContributions] contribution timeline discovery failed:`, e.message)
127+
}
128+
129+
console.log(`[contribkit][githubContributions] found ${repoMap.size} repos after contribution timeline`)
130+
131+
// Source 2: GraphQL search for repos with merged PRs (discover via PR activity)
132+
console.log(`[contribkit][githubContributions] searching for repos with merged PRs...`)
133+
try {
134+
const searchQueryBase = `is:pr is:merged author:${login}`
135+
let searchAfter: string | null = null
136+
let page = 0
137+
const maxPages = 10
138+
do {
139+
type SearchResponse = { data: { search: { pageInfo: { hasNextPage: boolean; endCursor: string | null }; edges: Array<{ node: { repository?: RepoNode } }> } } }
140+
const response: SearchResponse = await graphqlFetch<SearchResponse>({
141+
query: `
142+
query($searchQuery: String!, $after: String) {
143+
search(query: $searchQuery, type: ISSUE, first: 100, after: $after) {
144+
pageInfo { hasNextPage endCursor }
145+
edges { node { ... on PullRequest { repository { name nameWithOwner url owner { login url avatarUrl __typename } } } } }
146+
}
147+
}
148+
`,
149+
variables: { searchQuery: searchQueryBase, after: searchAfter },
150+
})
151+
for (const edge of response.data.search.edges) {
152+
const r = edge.node.repository
153+
if (r?.nameWithOwner)
154+
repoMap.set(r.nameWithOwner, r)
155+
}
156+
searchAfter = response.data.search.pageInfo.endCursor
157+
page++
158+
if (response.data.search.pageInfo.hasNextPage && page < maxPages)
159+
console.log(`[contribkit][githubContributions] merged PR search page ${page}, ${repoMap.size} repos so far`)
160+
} while (searchAfter && page < maxPages)
161+
}
162+
catch (e: any) {
163+
console.warn(`[contribkit][githubContributions] merged PR search failed:`, e.message)
164+
}
165+
console.log(`[contribkit][githubContributions] found ${repoMap.size} repos after merged PR search`)
166+
167+
const allRepos = Array.from(repoMap.values())
168+
console.log(`[contribkit][githubContributions] discovered ${allRepos.length} total unique repositories`)
169+
170+
// Fetch merged PR counts (completed contributions)
171+
console.log(`[contribkit][githubContributions] fetching merged PR counts per repository...`)
172+
const repoPRs = new Map<string, number>()
173+
const batchSize = 10
174+
for (let i = 0; i < allRepos.length; i += batchSize) {
175+
const batch = allRepos.slice(i, i + batchSize)
176+
await Promise.all(batch.map(async (repo) => {
177+
const searchQuery = `repo:${repo.nameWithOwner} is:pr is:merged author:${login}`
178+
try {
179+
const response = await graphqlFetch<{
180+
data: { search: { issueCount: number } }
181+
}>({
182+
query: `query($q: String!) { search(query: $q, type: ISSUE) { issueCount } }`,
183+
variables: { q: searchQuery },
184+
})
185+
const count = response.data.search.issueCount
186+
if (count > 0)
187+
repoPRs.set(repo.nameWithOwner, count)
188+
}
189+
catch (e: any) {
190+
console.warn(`[contribkit][githubContributions] failed PR count for ${repo.nameWithOwner}:`, e.message)
191+
}
192+
}))
193+
if (i + batchSize < allRepos.length)
194+
console.log(`[contribkit][githubContributions] processed PR batches for ${Math.min(i + batchSize, allRepos.length)}/${allRepos.length} repos...`)
195+
}
196+
console.log(`[contribkit][githubContributions] found merged PR counts for ${repoPRs.size} repositories`)
197+
198+
const results: { repo: RepoNode; prs: number }[] = []
199+
for (const repo of allRepos) {
200+
const prs = repoPRs.get(repo.nameWithOwner) || 0
201+
if (prs > 0)
202+
results.push({ repo, prs })
203+
}
204+
console.log(`[contribkit][githubContributions] computed merged PR counts for ${results.length} repositories (from ${allRepos.length} total repos with PRs)`)
205+
206+
// Aggregate by owner
207+
const aggregated = new Map<string, { owner: RepositoryOwner; totalPRs: number; repos: Array<{ repo: RepoNode; prs: number }> }>()
208+
for (const { repo, prs } of results) {
209+
const key = `${repo.owner.__typename}:${repo.owner.login}`
210+
const existing = aggregated.get(key)
211+
if (existing) {
212+
existing.totalPRs += prs
213+
existing.repos.push({ repo, prs })
214+
}
215+
else {
216+
aggregated.set(key, { owner: repo.owner, totalPRs: prs, repos: [{ repo, prs }] })
217+
}
218+
}
219+
220+
const consolidated = Array.from(aggregated.values()).filter(a => a.repos.length > 1)
221+
if (consolidated.length) {
222+
console.log(`[contribkit][githubContributions] consolidated ${consolidated.length} owners with multiple repos:`)
223+
for (const { owner, repos, totalPRs } of consolidated.toSorted((a, b) => b.repos.length - a.repos.length).slice(0, 10))
224+
console.log(` - ${owner.login}: ${repos.length} repos, ${totalPRs} merged PRs`)
225+
if (consolidated.length > 10)
226+
console.log(` ... and ${consolidated.length - 10} more`)
227+
}
228+
229+
const sponsors: Sponsorship[] = Array.from(aggregated.values())
230+
.sort((a, b) => b.totalPRs - a.totalPRs)
231+
.map(({ owner, totalPRs, repos }) => {
232+
const linkUrl = repos.length === 1 ? repos[0].repo.url : owner.url
233+
return {
234+
sponsor: { type: owner.__typename, login: owner.login, name: owner.login, avatarUrl: owner.avatarUrl, linkUrl, socialLogins: { github: owner.login } },
235+
isOneTime: false,
236+
monthlyDollars: totalPRs,
237+
privacyLevel: 'PUBLIC',
238+
tierName: 'Repository',
239+
createdAt: new Date().toISOString(),
240+
provider: 'githubContributions',
241+
raw: { owner, totalPRs, repoCount: repos.length },
242+
}
243+
})
244+
245+
return sponsors
246+
}

src/providers/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AfdianProvider } from './afdian'
33
import { CrowdinContributorsProvider } from './crowdinContributors'
44
import { GitHubProvider } from './github'
55
import { GitHubContributorsProvider } from './githubContributors'
6+
import { GitHubContributionsProvider } from './githubContributions'
67
import { GitlabContributorsProvider } from './gitlabContributors'
78
import { LiberapayProvider } from './liberapay'
89
import { OpenCollectiveProvider } from './opencollective'
@@ -19,6 +20,7 @@ export const ProvidersMap = {
1920
polar: PolarProvider,
2021
liberapay: LiberapayProvider,
2122
githubContributors: GitHubContributorsProvider,
23+
githubContributions: GitHubContributionsProvider,
2224
gitlabContributors: GitlabContributorsProvider,
2325
crowdinContributors: CrowdinContributorsProvider,
2426
}
@@ -46,6 +48,9 @@ export function guessProviders(config: ContribkitConfig) {
4648
if (config.githubContributors?.login && config.githubContributors?.token)
4749
items.push('githubContributors')
4850

51+
if (config.githubContributions?.login && config.githubContributions?.token)
52+
items.push('githubContributions')
53+
4954
if (config.gitlabContributors?.token && config.gitlabContributors?.repoId)
5055
items.push('gitlabContributors')
5156

src/types.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export const outputFormats = ['svg', 'png', 'webp', 'json'] as const
7575

7676
export type OutputFormat = typeof outputFormats[number]
7777

78-
export type ProviderName = 'github' | 'patreon' | 'opencollective' | 'afdian' | 'polar' | 'liberapay' | 'githubContributors' | 'gitlabContributors' | 'crowdinContributors'
78+
export type ProviderName = 'github' | 'patreon' | 'opencollective' | 'afdian' | 'polar' | 'liberapay' | 'githubContributors' | 'gitlabContributors' | 'crowdinContributors' | 'githubContributions'
7979

8080
export type GitHubAccountType = 'user' | 'organization'
8181

@@ -282,6 +282,23 @@ export interface ProvidersConfig {
282282
*/
283283
minTranslations?: number
284284
}
285+
286+
githubContributions?: {
287+
/**
288+
* GitHub user login to fetch contributions for.
289+
*
290+
* Will read from `CONTRIBKIT_GITHUB_CONTRIBUTIONS_LOGIN` environment variable if not set.
291+
*/
292+
login?: string
293+
/**
294+
* GitHub Token that has access to read user contributions.
295+
*
296+
* Will read from `CONTRIBKIT_GITHUB_CONTRIBUTIONS_TOKEN` environment variable if not set.
297+
*
298+
* @deprecated It's not recommended set this value directly, pass from env or use `.env` file.
299+
*/
300+
token?: string
301+
}
285302
}
286303

287304
export interface ContribkitRenderOptions {

0 commit comments

Comments
 (0)