Skip to content

Commit 30d6e5a

Browse files
committed
feat: add script to update contributors list from GitHub API
- Add scripts/updateContributors.ts to fetch contributors from Torrust org repos - Add npm script 'update-contributors' to run the script - Update AGENTS.md with instructions for managing contributors list - Script supports GitHub fine-grained personal access tokens - Handles API rate limits and errors gracefully
1 parent 75a346c commit 30d6e5a

File tree

3 files changed

+229
-2
lines changed

3 files changed

+229
-2
lines changed

AGENTS.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,49 @@ Optional fields:
182182
- Images with automatic optimization
183183
- Links with automatic external link handling
184184

185+
## Managing Contributors List
186+
187+
The contributors displayed on the homepage are maintained in a static list in `src/lib/constants/constants.ts`.
188+
189+
**Why Static?**
190+
The GitHub API has a rate limit of 60 requests/hour for anonymous users, which would cause issues for a public website. Instead, we use a local script to update the list manually.
191+
192+
**How to Update:**
193+
194+
```bash
195+
# Update contributors list from GitHub API
196+
npm run update-contributors
197+
198+
# With GitHub token for higher rate limits (REQUIRED for Torrust org)
199+
GITHUB_TOKEN=your_token_here npm run update-contributors
200+
```
201+
202+
**Creating a GitHub Token:**
203+
204+
The Torrust organization requires a **fine-grained personal access token** (classic tokens are not allowed).
205+
206+
1. Go to GitHub Settings → Developer settings → Personal access tokens → Fine-grained tokens
207+
2. Click "Generate new token"
208+
3. Configure the token:
209+
- **Token name**: "Torrust Contributors Script"
210+
- **Expiration**: Your preference (90 days recommended)
211+
- **Resource owner**: Select "torrust" from the dropdown
212+
- **Repository access**: "Public Repositories (read-only)"
213+
- **Permissions**:
214+
- Repository permissions → Metadata: Read-only (automatically set)
215+
- Organization permissions → Members: Read-only (for accessing org repos)
216+
4. Click "Generate token" and copy it
217+
5. Use it with: `GITHUB_TOKEN=your_token_here npm run update-contributors`
218+
219+
**What the Script Does:**
220+
221+
1. Fetches all repositories from the Torrust GitHub organization
222+
2. Fetches contributors from each repository
223+
3. Deduplicates contributors by username
224+
4. Updates the `defaultContributorsList` in `src/lib/constants/constants.ts`
225+
226+
**Note:** Without a token, the script uses anonymous access (60 requests/hour), which may hit rate limits. With a fine-grained token, you get 5,000 requests/hour.
227+
185228
## Image Optimization
186229

187230
- Use the `<Image />` component instead of `<img />` for automatic optimization

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
1212
"format": "prettier --write .",
1313
"lint": "prettier --check . && eslint --ignore-pattern .svelte-kit/output .",
14-
"postbuild": "svelte-sitemap --domain https://torrust.com/"
14+
"postbuild": "svelte-sitemap --domain https://torrust.com/",
15+
"update-contributors": "tsx scripts/updateContributors.ts"
1516
},
1617
"devDependencies": {
1718
"@eslint/compat": "^1.2.5",
@@ -66,4 +67,4 @@
6667
"rehype-pretty-code": "^0.14.0",
6768
"striptags": "^3.2.0"
6869
}
69-
}
70+
}

scripts/updateContributors.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
4+
const BASE_URL = 'https://api.github.com';
5+
const ORG_NAME = 'torrust';
6+
const CONSTANTS_FILE = 'src/lib/constants/constants.ts';
7+
8+
type Contributor = {
9+
login: string;
10+
avatar_url: string;
11+
html_url: string;
12+
};
13+
14+
type Repo = {
15+
name: string;
16+
};
17+
18+
async function fetchWithAuth(url: string): Promise<Response> {
19+
const headers: HeadersInit = {
20+
Accept: 'application/vnd.github.v3+json',
21+
'User-Agent': 'Torrust-Website-Contributors-Script'
22+
};
23+
24+
// Use GITHUB_TOKEN if available for higher rate limits
25+
const token = process.env.GITHUB_TOKEN;
26+
if (token) {
27+
// GitHub supports both formats: 'token xxx' for classic, 'Bearer xxx' for fine-grained
28+
headers.Authorization = `token ${token}`;
29+
console.log('✓ Using GitHub token for authentication');
30+
} else {
31+
console.log('Using anonymous GitHub API (rate limit: 60 requests/hour)');
32+
console.log(' Set GITHUB_TOKEN environment variable for higher limits');
33+
}
34+
35+
return fetch(url, { headers });
36+
}
37+
38+
async function fetchRepos(): Promise<string[]> {
39+
console.log(`\nFetching repositories from ${ORG_NAME} organization...`);
40+
const response = await fetchWithAuth(`${BASE_URL}/orgs/${ORG_NAME}/repos?per_page=100`);
41+
42+
if (!response.ok) {
43+
const errorBody = await response.text();
44+
console.error(`Response status: ${response.status} ${response.statusText}`);
45+
console.error(`Error details: ${errorBody}`);
46+
throw new Error(`Failed to fetch repos: ${response.statusText}`);
47+
}
48+
49+
const repos: Repo[] = await response.json();
50+
console.log(`✓ Found ${repos.length} repositories`);
51+
52+
return repos.map((repo) => repo.name);
53+
}
54+
55+
async function fetchContributorsForRepo(repoName: string): Promise<Contributor[]> {
56+
try {
57+
const response = await fetchWithAuth(
58+
`${BASE_URL}/repos/${ORG_NAME}/${repoName}/contributors?per_page=100`
59+
);
60+
61+
if (!response.ok) {
62+
console.error(`✗ Failed to fetch contributors for ${repoName}: ${response.statusText}`);
63+
return [];
64+
}
65+
66+
// Check if response has content before parsing
67+
const text = await response.text();
68+
if (!text || text.trim().length === 0) {
69+
console.error(`✗ Empty response for ${repoName}`);
70+
return [];
71+
}
72+
73+
try {
74+
return JSON.parse(text);
75+
} catch (parseError) {
76+
console.error(`✗ Invalid JSON for ${repoName}:`, parseError instanceof Error ? parseError.message : parseError);
77+
return [];
78+
}
79+
} catch (error) {
80+
console.error(`✗ Error fetching contributors for ${repoName}:`, error instanceof Error ? error.message : error);
81+
return [];
82+
}
83+
}
84+
85+
async function fetchAllContributors(): Promise<Contributor[]> {
86+
const repos = await fetchRepos();
87+
88+
console.log('\nFetching contributors from all repositories...');
89+
const contributorPromises = repos.map(async (repo) => {
90+
const contributors = await fetchContributorsForRepo(repo);
91+
console.log(` ✓ ${repo}: ${contributors.length} contributors`);
92+
return contributors;
93+
});
94+
95+
const contributorArrays = await Promise.all(contributorPromises);
96+
const allContributors = contributorArrays.flat();
97+
98+
// Deduplicate by login
99+
const uniqueContributors = Array.from(
100+
new Map(allContributors.map((c) => [c.login, c])).values()
101+
);
102+
103+
console.log(`\n✓ Total unique contributors: ${uniqueContributors.length}`);
104+
return uniqueContributors;
105+
}
106+
107+
function updateConstantsFile(contributors: Contributor[]): void {
108+
const filePath = path.resolve(CONSTANTS_FILE);
109+
110+
if (!fs.existsSync(filePath)) {
111+
throw new Error(`Constants file not found: ${CONSTANTS_FILE}`);
112+
}
113+
114+
let content = fs.readFileSync(filePath, 'utf-8');
115+
116+
// Find the defaultContributorsList array
117+
const startMarker = 'export const defaultContributorsList = [';
118+
const startIndex = content.indexOf(startMarker);
119+
120+
if (startIndex === -1) {
121+
throw new Error('Could not find defaultContributorsList in constants.ts');
122+
}
123+
124+
// Find the closing bracket for the array
125+
let bracketCount = 0;
126+
let endIndex = startIndex + startMarker.length;
127+
let inString = false;
128+
let stringChar = '';
129+
130+
for (let i = endIndex; i < content.length; i++) {
131+
const char = content[i];
132+
133+
if ((char === '"' || char === "'") && content[i - 1] !== '\\') {
134+
if (!inString) {
135+
inString = true;
136+
stringChar = char;
137+
} else if (char === stringChar) {
138+
inString = false;
139+
}
140+
}
141+
142+
if (!inString) {
143+
if (char === '[') bracketCount++;
144+
if (char === ']') {
145+
if (bracketCount === 0) {
146+
endIndex = i + 2; // Include '];'
147+
break;
148+
}
149+
bracketCount--;
150+
}
151+
}
152+
}
153+
154+
// Format contributors list
155+
const contributorEntries = contributors
156+
.map(
157+
(c) =>
158+
`\t{\n\t\thtml_url: '${c.login}',\n\t\tavatar_url: '${c.avatar_url}'\n\t}`
159+
)
160+
.join(',\n');
161+
162+
const newList = `export const defaultContributorsList = [\n${contributorEntries}\n];`;
163+
164+
// Replace the old list with the new one
165+
const newContent = content.substring(0, startIndex) + newList + content.substring(endIndex);
166+
167+
fs.writeFileSync(filePath, newContent, 'utf-8');
168+
console.log(`\n✅ Updated ${CONSTANTS_FILE}`);
169+
}
170+
171+
async function main() {
172+
try {
173+
console.log('🚀 Updating contributors list...');
174+
const contributors = await fetchAllContributors();
175+
updateConstantsFile(contributors);
176+
console.log('✅ Contributors list updated successfully!');
177+
} catch (error) {
178+
console.error('❌ Error updating contributors:', error);
179+
process.exit(1);
180+
}
181+
}
182+
183+
main();

0 commit comments

Comments
 (0)