-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(providers): add indivudal GitHub contributions #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||
|
Comment on lines
+55
to
+59
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Not necessary. |
||||||||||||
|
|
||||||||||||
| ; 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 | ||||||||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||
|
|
||||||||||||
| 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' | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| }, | ||||||
| } | ||||||
|
|
||||||
| // remove undefined keys | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||
| return ` | ||||||||||||
| <clipPath id="${cropId}"> | ||||||||||||
| <rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${size * radius}" ry="${size * radius}" /> | ||||||||||||
|
|
||||||||||||
| 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||
| 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.