Skip to content

Commit 72a4afc

Browse files
feat: automate README contributors section via GitHub Actions
Adds a GitHub Actions workflow that automatically updates the README contributors section whenever a pull request is merged into main, removing the need to manually maintain the contributor list. - Add .github/workflows/update-contributors.yml, triggered on merged pull requests targeting main - Add .github/scripts/update-contributors.js to parse the merge event, classify the contribution type from PR labels/title, and rewrite the contributors table between the CONTRIBUTORS-START/END anchors in README.md - Fetch each contributor's display name and avatar from the GitHub API, falling back to their username if the name is unavailable - Skip bot accounts and prevent duplicate entries for existing contributors - Add a concurrency group to the workflow to prevent race conditions when multiple PRs merge in quick succession - Add unit tests (.github/scripts/update-contributors.test.js) covering contribution-type classification, contributor parsing, and table generation, using Node's built-in assert module Resolves #10
2 parents 715a937 + 7278c4d commit 72a4afc

4 files changed

Lines changed: 541 additions & 0 deletions

File tree

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
const README_PATH = path.join(__dirname, '../../README.md');
5+
6+
function getContributionType(prPayload) {
7+
const pr = prPayload.pull_request;
8+
const labels = (pr.labels || []).map(l => l.name.toLowerCase());
9+
10+
if (labels.some(l => l.includes('frontend') || l.includes('ui') || l.includes('css') || l.includes('design') || l.includes('style'))) {
11+
return '🎨 Frontend';
12+
}
13+
if (labels.some(l => l.includes('design') || l.includes('ux'))) {
14+
return '🎨 Design';
15+
}
16+
if (labels.some(l => l.includes('backend') || l.includes('api') || l.includes('database') || l.includes('db') || l.includes('server'))) {
17+
return '💻 Code';
18+
}
19+
if (labels.some(l => l.includes('docs') || l.includes('documentation') || l.includes('readme') || l.includes('wiki'))) {
20+
return '📖 Docs';
21+
}
22+
if (labels.some(l => l.includes('bug') || l.includes('fix') || l.includes('hotfix') || l.includes('patch'))) {
23+
return '🐛 Bug fix';
24+
}
25+
if (labels.some(l => l.includes('test') || l.includes('testing') || l.includes('spec') || l.includes('jest') || l.includes('pytest'))) {
26+
return '🧪 Tests';
27+
}
28+
if (labels.some(l => l.includes('performance') || l.includes('speed') || l.includes('perf'))) {
29+
return '⚡ Performance';
30+
}
31+
if (labels.some(l => l.includes('security') || l.includes('vuln') || l.includes('auth'))) {
32+
return '🔒 Security';
33+
}
34+
if (labels.some(l => l.includes('infra') || l.includes('ci') || l.includes('cd') || l.includes('workflow') || l.includes('docker') || l.includes('github-actions'))) {
35+
return '🚇 Infrastructure';
36+
}
37+
if (labels.some(l => l.includes('accessibility') || l.includes('a11y'))) {
38+
return '♿ Accessibility';
39+
}
40+
41+
// Fallback to title matching
42+
const title = (pr.title || '').toLowerCase();
43+
if (title.startsWith('feat') || title.includes('frontend') || title.includes('ui')) {
44+
if (title.includes('frontend') || title.includes('ui')) return '🎨 Frontend';
45+
if (title.includes('design')) return '🎨 Design';
46+
if (title.includes('docs') || title.includes('readme')) return '📖 Docs';
47+
if (title.includes('test')) return '🧪 Tests';
48+
if (title.includes('perf')) return '⚡ Performance';
49+
if (title.includes('security') || title.includes('auth')) return '🔒 Security';
50+
if (title.includes('infra') || title.includes('workflow') || title.includes('github action')) return '🚇 Infrastructure';
51+
return '💻 Code';
52+
}
53+
if (title.startsWith('fix') || title.includes('bug')) {
54+
return '🐛 Bug fix';
55+
}
56+
if (title.startsWith('docs')) {
57+
return '📖 Docs';
58+
}
59+
if (title.startsWith('test')) {
60+
return '🧪 Tests';
61+
}
62+
63+
return '💻 Code';
64+
}
65+
66+
function parseExistingContributors(sectionContent) {
67+
const contributors = [];
68+
const regex = /<td align="center">\s*<a href="https:\/\/github\.com\/([^"]+)">\s*<img src="([^"]+)"[^>]*\/>\s*<br \/>\s*<sub><b>([^<]+)<\/b><\/sub>\s*<br \/>\s*<sub>([^<]+)<\/sub>\s*<\/a>\s*<\/td>/gi;
69+
70+
let match;
71+
while ((match = regex.exec(sectionContent)) !== null) {
72+
contributors.push({
73+
username: match[1].trim(),
74+
avatarUrl: match[2].trim(),
75+
name: match[3].trim(),
76+
role: match[4].trim()
77+
});
78+
}
79+
return contributors;
80+
}
81+
82+
function generateTableHTML(contributors) {
83+
const columnsPerRow = 5;
84+
let html = '\n<div align="center">\n\n<table>\n <tbody>\n';
85+
86+
for (let i = 0; i < contributors.length; i += columnsPerRow) {
87+
html += ' <tr>\n';
88+
const chunk = contributors.slice(i, i + columnsPerRow);
89+
for (const c of chunk) {
90+
html += ` <td align="center">
91+
<a href="https://github.com/${c.username}">
92+
<img src="${c.avatarUrl}" width="80px" />
93+
<br />
94+
<sub><b>${c.name}</b></sub>
95+
<br />
96+
<sub>${c.role}</sub>
97+
</a>
98+
</td>\n`;
99+
}
100+
html += ' </tr>\n';
101+
}
102+
103+
html += ' </tbody>\n</table>\n\n</div>\n';
104+
return html;
105+
}
106+
107+
async function main() {
108+
const eventPath = process.env.GITHUB_EVENT_PATH;
109+
if (!eventPath) {
110+
console.error('Error: GITHUB_EVENT_PATH not set.');
111+
process.exit(1);
112+
}
113+
114+
let eventPayload;
115+
try {
116+
eventPayload = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
117+
} catch (err) {
118+
console.error('Error reading event payload:', err.message);
119+
process.exit(1);
120+
}
121+
122+
const pr = eventPayload.pull_request;
123+
if (!pr) {
124+
console.error('Error: Event payload is missing pull_request info.');
125+
process.exit(1);
126+
}
127+
128+
// Handle Bot accounts
129+
const username = pr.user.login;
130+
const isBot = pr.user.type === 'Bot' || username.toLowerCase().endsWith('[bot]') || username.toLowerCase() === 'github-actions';
131+
if (isBot) {
132+
console.log(`Skipping bot contributor: ${username}`);
133+
process.exit(0);
134+
}
135+
136+
const avatarUrl = `https://github.com/${username}.png`;
137+
let name = username; // Default to username
138+
139+
// Fetch real profile name from GitHub API (authenticated if GITHUB_TOKEN is available)
140+
const headers = { 'User-Agent': 'learnhub-contributors-automation' };
141+
if (process.env.GITHUB_TOKEN) {
142+
headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
143+
}
144+
145+
try {
146+
const response = await fetch(`https://api.github.com/users/${username}`, { headers });
147+
if (response.ok) {
148+
const userData = await response.json();
149+
if (userData.name) {
150+
name = userData.name;
151+
console.log(`Fetched display name: "${name}" for user ${username}`);
152+
}
153+
} else {
154+
console.warn(`GitHub API returned status ${response.status} when fetching profile for ${username}`);
155+
}
156+
} catch (err) {
157+
console.warn(`Failed to fetch user profile name from GitHub API, using username instead: ${err.message}`);
158+
}
159+
160+
const role = getContributionType(eventPayload);
161+
162+
// Read README
163+
if (!fs.existsSync(README_PATH)) {
164+
console.error(`Error: README.md not found at ${README_PATH}`);
165+
process.exit(1);
166+
}
167+
168+
let readmeContent = fs.readFileSync(README_PATH, 'utf8');
169+
170+
// Locate the contributors section
171+
const startComment = '<!-- CONTRIBUTORS-START -->';
172+
const endComment = '<!-- CONTRIBUTORS-END -->';
173+
174+
const startIndex = readmeContent.indexOf(startComment);
175+
const endIndex = readmeContent.indexOf(endComment);
176+
177+
if (startIndex === -1 || endIndex === -1 || startIndex >= endIndex) {
178+
console.error('Error: Contributors placeholder comments not found in README.md.');
179+
process.exit(1);
180+
}
181+
182+
const sectionContent = readmeContent.substring(startIndex + startComment.length, endIndex);
183+
const contributors = parseExistingContributors(sectionContent);
184+
185+
// Check for duplicates
186+
const alreadyExists = contributors.some(c => c.username.toLowerCase() === username.toLowerCase());
187+
if (alreadyExists) {
188+
console.log(`Contributor ${username} already exists in README.md. Skipping.`);
189+
process.exit(0);
190+
}
191+
192+
// Add new contributor
193+
contributors.push({
194+
username,
195+
avatarUrl,
196+
name,
197+
role
198+
});
199+
200+
console.log(`Adding new contributor: ${username} with role: ${role}`);
201+
202+
// Generate new HTML table
203+
const newTableHTML = generateTableHTML(contributors);
204+
205+
// Construct new README content
206+
const updatedReadmeContent =
207+
readmeContent.substring(0, startIndex + startComment.length) +
208+
newTableHTML +
209+
readmeContent.substring(endIndex);
210+
211+
fs.writeFileSync(README_PATH, updatedReadmeContent, 'utf8');
212+
console.log('README.md successfully updated.');
213+
}
214+
215+
if (require.main === module) {
216+
main();
217+
}
218+
219+
module.exports = {
220+
getContributionType,
221+
parseExistingContributors,
222+
generateTableHTML
223+
};

0 commit comments

Comments
 (0)