Skip to content

Commit d31158d

Browse files
GitHub Sponsors and Open Collective contribution graph (#1649)
Signed-off-by: Lorenzo Lewis <[email protected]> Co-authored-by: Vitor Ayres <[email protected]>
1 parent 521a47b commit d31158d

36 files changed

+7389
-176
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: 'Sync Sponsors and Contributors Data'
2+
3+
on:
4+
schedule:
5+
# once a week
6+
- cron: '0 0 * * 0'
7+
8+
jobs:
9+
fetch-sponsors-data:
10+
name: Sync Sponsors and Contributors Data
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: write
14+
15+
steps:
16+
- name: Checkout repository
17+
uses: actions/checkout@v4
18+
19+
- uses: pnpm/action-setup@v4
20+
21+
- uses: actions/setup-node@v4
22+
with:
23+
node-version: 20
24+
cache: 'pnpm'
25+
26+
- run: pnpm i
27+
28+
- name: fetch-sponsors
29+
run: pnpm --filter fetch-sponsors run build
30+
env:
31+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32+
33+
# tauri-docs PR
34+
- name: Git config
35+
run: |
36+
git config --global user.name "tauri-bot"
37+
git config --global user.email "[email protected]"
38+
39+
- name: Create pull request for updated docs
40+
# soft fork of https://github.com/peter-evans/create-pull-request for security purposes
41+
uses: tauri-apps/[email protected]
42+
if: github.event_name != 'pull_request' && github.event_name != 'push'
43+
with:
44+
token: ${{ secrets.ORG_TAURI_BOT_PAT }}
45+
commit-message: 'chore(docs): Update Sponsors & Contributors Data'
46+
branch: ci/v2/update-data-docs
47+
path: tauri-docs
48+
title: Update Sponsors & Contributors Data
49+
labels: 'bot'
50+

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
# Generated reference docs
99
src/content/docs/reference
10+
src/content/docs/release
1011

1112
# Git Modules
1213
packages/tauri

astro.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,10 +415,14 @@ export default defineConfig({
415415
},
416416
}),
417417
],
418+
image: {
419+
domains: ['tauri.app', 'images.opencollective.com', 'avatars.githubusercontent.com'],
420+
},
418421
markdown: {
419422
shikiConfig: {
420423
langs: ['powershell', 'ts', 'rust', 'bash', 'json', 'toml', 'html', 'js'],
421424
},
425+
422426
rehypePlugins: [
423427
rehypeHeadingIds,
424428
[

packages/fetch-sponsors/config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { fileURLToPath } from 'url';
2+
import path from 'path';
3+
4+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
5+
6+
export const OPEN_COLLECTIVE_FILE = path.resolve(
7+
__dirname,
8+
'../../src/data/openCollectiveData.json'
9+
);
10+
export const GITHUB_SPONSORS_FILE = path.resolve(
11+
__dirname,
12+
'../../src/data/githubSponsorsData.json'
13+
);
14+
export const GITHUB_CONTRIBUTORS_FILE = path.resolve(
15+
__dirname,
16+
'../../src/data/githubContributorsData.json'
17+
);
18+
19+
export const PLATINUM_THRESHOLD = 5_000;
20+
export const GOLD_THRESHOLD = 500;
21+
export const SILVER_THRESHOLD = 100;
22+
23+
export const GH_IMAGE_DIMENSION = 64;
24+
export const OC_IMAGE_DIMENSION = 256;
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { Octokit } from '@octokit/core';
2+
import { paginateGraphQL, type PageInfoForward } from '@octokit/plugin-paginate-graphql';
3+
import { paginateRest } from '@octokit/plugin-paginate-rest';
4+
import { retry } from '@octokit/plugin-retry';
5+
import type { Endpoints } from '@octokit/types';
6+
import { throttling } from '@octokit/plugin-throttling';
7+
8+
import { GITHUB_CONTRIBUTORS_FILE } from './config.ts';
9+
import { GITHUB_TOKEN, saveToFile } from './utils.ts';
10+
11+
export interface Contributor {
12+
login: string;
13+
avatar_url: string;
14+
total_contributions: number;
15+
}
16+
17+
type APIData<T extends keyof Endpoints> = Endpoints[T]['response']['data'];
18+
type Repo = APIData<'GET /orgs/{org}/repos'>[number];
19+
interface Review {
20+
login: string | undefined;
21+
avatarUrl: string | undefined;
22+
prNumber: number;
23+
labels: string[];
24+
}
25+
interface AugmentedRepo extends Repo {
26+
reviewComments: any[];
27+
issues: any[];
28+
reviews: Review[];
29+
}
30+
31+
const OctokitWithPlugins = Octokit.plugin(paginateRest, paginateGraphQL, retry, throttling);
32+
33+
class StatsCollector {
34+
#org: string;
35+
#app: InstanceType<typeof OctokitWithPlugins>;
36+
#contributionThreshold: number;
37+
38+
constructor(opts: { org: string; token: string | undefined; contributionThreshold: number }) {
39+
this.#org = opts.org;
40+
if (!opts.token) {
41+
throw new Error('GITHUB_TOKEN is required');
42+
}
43+
this.#app = new OctokitWithPlugins({
44+
auth: opts.token,
45+
throttle: {
46+
onRateLimit: (retryAfter, options, octokit, retryCount) => {
47+
octokit.log.warn(`Request quota exhausted for request ${options.method} ${options.url}`);
48+
49+
if (retryCount < 1) {
50+
// only retries once
51+
octokit.log.info(`Retrying after ${retryAfter} seconds!`);
52+
return true;
53+
}
54+
},
55+
onSecondaryRateLimit: (retryAfter, options, octokit) => {
56+
// does not retry, only logs a warning
57+
octokit.log.warn(
58+
`SecondaryRateLimit detected for request ${options.method} ${options.url}`
59+
);
60+
},
61+
},
62+
});
63+
this.#contributionThreshold = opts.contributionThreshold;
64+
}
65+
66+
async run(): Promise<Contributor[]> {
67+
const repos = await this.#getReposWithExtraStats();
68+
69+
const contributors: Record<string, Contributor> = {};
70+
71+
for (const repo of repos) {
72+
for (const issue of repo.issues) {
73+
const { user, pull_request } = issue;
74+
if (!user) {
75+
continue;
76+
}
77+
const { avatar_url, login } = user;
78+
const contributor = (contributors[login] =
79+
contributors[login] || this.#newContributor({ avatar_url, login }));
80+
if (pull_request) {
81+
contributor.total_contributions++;
82+
if (pull_request.merged_at) {
83+
contributor.total_contributions++;
84+
}
85+
} else {
86+
// is issue?
87+
contributor.total_contributions++;
88+
}
89+
}
90+
91+
/** Temporary store for deduplicating multiple reviews on the same PR. */
92+
const reviewedPRs: Record<string, Set<number>> = {};
93+
94+
for (const review of repo.reviewComments) {
95+
const { user, pull_request_url } = review;
96+
const prNumber = parseInt(pull_request_url.split('/').pop()!);
97+
if (!user) {
98+
continue;
99+
}
100+
const { avatar_url, login } = user;
101+
const contributor = (contributors[login] =
102+
contributors[login] || this.#newContributor({ avatar_url, login }));
103+
const contributorReviews = (reviewedPRs[login] = reviewedPRs[login] || new Set());
104+
if (!contributorReviews.has(prNumber)) {
105+
contributor.total_contributions++;
106+
contributorReviews.add(prNumber);
107+
}
108+
}
109+
110+
for (const review of repo.reviews) {
111+
const { login, avatarUrl, prNumber } = review;
112+
if (!login || !avatarUrl) {
113+
continue;
114+
}
115+
const contributor = (contributors[login] =
116+
contributors[login] || this.#newContributor({ avatar_url: avatarUrl, login }));
117+
const contributorReviews = (reviewedPRs[login] = reviewedPRs[login] || new Set());
118+
if (!contributorReviews.has(prNumber)) {
119+
contributor.total_contributions++;
120+
contributorReviews.add(prNumber);
121+
}
122+
}
123+
}
124+
125+
// Filter contributors based on threshold
126+
const topContributors = Object.values(contributors)
127+
.filter((contributor) => contributor.total_contributions >= this.#contributionThreshold)
128+
.filter((contributor) => !contributor.login.includes('[bot]'))
129+
.filter((contributor) => !contributor.login.includes('tauri-bot'))
130+
.sort((a, b) => b.total_contributions - a.total_contributions);
131+
132+
console.log(`output ${topContributors.length}/${Object.values(contributors).length}`);
133+
return topContributors;
134+
}
135+
136+
#newContributor({ avatar_url, login }: { avatar_url: string; login: string }): Contributor {
137+
return {
138+
login,
139+
avatar_url,
140+
total_contributions: 0,
141+
};
142+
}
143+
144+
async #getRepos() {
145+
return (
146+
await this.#app.request(`GET /orgs/{org}/repos`, {
147+
org: this.#org,
148+
type: 'sources',
149+
})
150+
).data.filter((repo) => !repo.private);
151+
}
152+
153+
async #getAllIssuesAndPRs(repo: string) {
154+
console.log(`fetching issues and PRs for ${this.#org}/${repo}`);
155+
const issues = await this.#app.paginate('GET /repos/{owner}/{repo}/issues', {
156+
owner: this.#org,
157+
repo,
158+
per_page: 100,
159+
state: 'all',
160+
});
161+
console.log(`found ${issues.length} issues and PRs for ${this.#org}/${repo}`);
162+
return issues;
163+
}
164+
165+
async #getAllReviewComments(repo: string) {
166+
console.log(`fetching PR review comments for ${this.#org}/${repo}`);
167+
const reviews = await this.#app.paginate('GET /repos/{owner}/{repo}/pulls/comments', {
168+
owner: this.#org,
169+
repo,
170+
per_page: 100,
171+
});
172+
console.log(`found ${reviews.length} PR review comments for ${this.#org}/${repo}`);
173+
return reviews;
174+
}
175+
176+
async #getAllReviews(repo: string) {
177+
console.log(`fetching PR reviews for ${this.#org}/${repo}`);
178+
const {
179+
repository: {
180+
pullRequests: { nodes: pullRequests },
181+
},
182+
} = await this.#app.graphql.paginate<{
183+
repository: {
184+
pullRequests: {
185+
pageInfo: PageInfoForward;
186+
nodes: Array<{
187+
number: number;
188+
labels: { nodes: Array<{ name: string }> };
189+
latestReviews: {
190+
nodes: Array<{ author: null | { login: string; avatarUrl: string } }>;
191+
};
192+
}>;
193+
};
194+
};
195+
}>(
196+
`
197+
query ($org: String!, $repo: String!, $cursor: String) {
198+
repository(owner: $org, name: $repo) {
199+
pullRequests(first: 100, after: $cursor) {
200+
nodes {
201+
number
202+
labels(first: 10) {
203+
nodes {
204+
name
205+
}
206+
}
207+
latestReviews(first: 15) {
208+
nodes {
209+
author {
210+
login
211+
avatarUrl
212+
}
213+
}
214+
}
215+
}
216+
pageInfo {
217+
hasNextPage
218+
endCursor
219+
}
220+
}
221+
}
222+
}
223+
`,
224+
{ org: this.#org, repo }
225+
);
226+
const reviews: Review[] = [];
227+
for (const { number, labels, latestReviews } of pullRequests) {
228+
for (const { author } of latestReviews.nodes) {
229+
reviews.push({
230+
prNumber: number,
231+
labels: labels.nodes.map(({ name }) => name),
232+
login: author?.login,
233+
avatarUrl: author?.avatarUrl,
234+
});
235+
}
236+
}
237+
console.log(`found ${reviews.length} PR reviews for ${this.#org}/${repo}`);
238+
return reviews;
239+
}
240+
241+
async #getReposWithExtraStats() {
242+
const repos = await this.#getRepos();
243+
console.log(`found ${repos.length} repos`);
244+
const reposWithStats: AugmentedRepo[] = [];
245+
for (const repo of repos) {
246+
reposWithStats.push({
247+
...repo,
248+
issues: await this.#getAllIssuesAndPRs(repo.name),
249+
reviewComments: await this.#getAllReviewComments(repo.name),
250+
reviews: await this.#getAllReviews(repo.name),
251+
});
252+
}
253+
return reposWithStats;
254+
}
255+
}
256+
257+
export async function fetchGitHubContributorsData() {
258+
const contributionThreshold = 5;
259+
const token = await GITHUB_TOKEN();
260+
try {
261+
const statsCollector = new StatsCollector({
262+
org: 'tauri-apps',
263+
token,
264+
contributionThreshold,
265+
});
266+
await saveToFile(GITHUB_CONTRIBUTORS_FILE, () => statsCollector.run());
267+
} catch (error) {
268+
console.error('Failed to collect contributors data:', error);
269+
}
270+
}

0 commit comments

Comments
 (0)