Skip to content

Commit eff6f84

Browse files
committed
feat: Implement diff-based update generation and RSS feed creation triggered by pull requests to repos.csv.
1 parent a93c91b commit eff6f84

File tree

4 files changed

+321
-9
lines changed

4 files changed

+321
-9
lines changed

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"Bash(cut -d',' -f1-2 echo \"\" echo \"其他重要项目:\" grep \"^jaegertracing/\\|^open-telemetry/\\|^helm/\" repos.csv)",
99
"Bash(node scripts/update-readme.js:*)",
1010
"Bash(npm run dev:*)",
11-
"Bash(node scripts/fetch-releases.js:*)"
11+
"Bash(node scripts/fetch-releases.js:*)",
12+
"Bash(node scripts/generate-update-from-diff.js:*)"
1213
],
1314
"deny": [],
1415
"ask": []

.github/workflows/auto-update.yml

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
name: Auto Update
22

33
on:
4-
push:
5-
branches:
6-
- main
7-
- master
4+
pull_request:
85
paths:
96
- 'repos.csv'
107

118
permissions:
129
contents: write
10+
pull-requests: write
1311

1412
jobs:
1513
auto-update:
@@ -20,22 +18,30 @@ jobs:
2018
uses: actions/checkout@v4
2119
with:
2220
token: ${{ secrets.GITHUB_TOKEN }}
21+
fetch-depth: 0 # Fetch all history for proper diff
2322

2423
- name: Setup Node.js
2524
uses: actions/setup-node@v4
2625
with:
2726
node-version: '18'
2827

28+
- name: Fetch base branch
29+
run: git fetch origin ${{ github.base_ref }}
30+
2931
- name: Run update README script
3032
run: node scripts/update-readme.js
3133

32-
- name: Run fetch updates and generate RSS
33-
run: node scripts/fetch-releases.js
34+
- name: Generate updates from diff
35+
run: node scripts/generate-update-from-diff.js
3436

3537
- name: Commit and push if changed
3638
run: |
3739
git config --local user.email "github-actions[bot]@users.noreply.github.com"
3840
git config --local user.name "github-actions[bot]"
3941
git add README.md data/updates.json public/rss.xml
40-
git diff --staged --quiet || git commit -m "chore: auto-update README, updates and RSS from repos.csv"
41-
git push
42+
43+
# Check if there are changes
44+
if ! git diff --staged --quiet; then
45+
git commit -m "chore: auto-update README, updates and RSS from repos.csv"
46+
git push origin HEAD:${{ github.head_ref }}
47+
fi

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"lint": "eslint",
1010
"update-repos": "npx tsx scripts/update-repos.ts",
1111
"update-readme": "node scripts/update-readme.js",
12+
"update-from-diff": "node scripts/generate-update-from-diff.js",
1213
"fetch-updates": "node scripts/fetch-releases.js"
1314
},
1415
"dependencies": {
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const { execSync } = require('child_process');
4+
5+
function parseCSVLine(line) {
6+
const result = [];
7+
let current = '';
8+
let inQuotes = false;
9+
10+
for (let i = 0; i < line.length; i++) {
11+
const char = line[i];
12+
13+
if (char === '"') {
14+
inQuotes = !inQuotes;
15+
} else if (char === ',' && !inQuotes) {
16+
result.push(current.trim());
17+
current = '';
18+
} else {
19+
current += char;
20+
}
21+
}
22+
23+
result.push(current.trim());
24+
return result;
25+
}
26+
27+
function escapeXml(unsafe) {
28+
if (!unsafe) return '';
29+
return unsafe
30+
.replace(/&/g, '&amp;')
31+
.replace(/</g, '&lt;')
32+
.replace(/>/g, '&gt;')
33+
.replace(/"/g, '&quot;')
34+
.replace(/'/g, '&apos;');
35+
}
36+
37+
function generateRSS(updates) {
38+
try {
39+
const siteUrl = 'https://open-source-jobs.com';
40+
const rssUrl = `${siteUrl}/rss.xml`;
41+
42+
let rss = `<?xml version="1.0" encoding="UTF-8"?>
43+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
44+
<channel>
45+
<title>Open Source Jobs - Updates</title>
46+
<link>${siteUrl}</link>
47+
<description>Latest job opportunities from open source projects</description>
48+
<language>en-us</language>
49+
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
50+
<atom:link href="${rssUrl}" rel="self" type="application/rss+xml" />
51+
`;
52+
53+
updates.forEach(update => {
54+
const title = escapeXml(update.title);
55+
const link = escapeXml(update.html_url);
56+
const pubDate = new Date(update.date).toUTCString();
57+
const guid = update.id;
58+
59+
let description = '';
60+
61+
if (update.changes) {
62+
if (update.changes.added.length > 0) {
63+
description += `<h3>Added ${update.changes.added.length} ${update.changes.added.length === 1 ? 'repository' : 'repositories'}:</h3><ul>`;
64+
65+
update.changes.added.forEach(repo => {
66+
const repoUrl = `https://github.com/${repo.repository}`;
67+
description += `<li>`;
68+
description += `<strong><a href="${escapeXml(repoUrl)}">${escapeXml(repo.repository)}</a></strong>`;
69+
if (repo.companyName) {
70+
description += ` by ${escapeXml(repo.companyName)}`;
71+
}
72+
if (repo.description) {
73+
description += `<br/>${escapeXml(repo.description)}`;
74+
}
75+
if (repo.careerUrl) {
76+
description += `<br/><a href="${escapeXml(repo.careerUrl)}">Apply for job</a>`;
77+
}
78+
description += `</li>`;
79+
});
80+
81+
description += '</ul>';
82+
}
83+
84+
if (update.changes.removed.length > 0) {
85+
description += `<p>Removed ${update.changes.removed.length} ${update.changes.removed.length === 1 ? 'repository' : 'repositories'}</p>`;
86+
}
87+
}
88+
89+
rss += `
90+
<item>
91+
<title>${title}</title>
92+
<link>${link}</link>
93+
<guid isPermaLink="false">${guid}</guid>
94+
<pubDate>${pubDate}</pubDate>
95+
<description><![CDATA[${description}]]></description>
96+
</item>`;
97+
});
98+
99+
rss += `
100+
</channel>
101+
</rss>`;
102+
103+
const outputPath = path.join(__dirname, '..', 'public', 'rss.xml');
104+
fs.writeFileSync(outputPath, rss, 'utf8');
105+
106+
console.log(`✅ RSS feed generated`);
107+
} catch (error) {
108+
console.error('❌ Error generating RSS:', error.message);
109+
throw error;
110+
}
111+
}
112+
113+
async function generateUpdateFromDiff() {
114+
console.log('Generating update from repos.csv diff...');
115+
116+
try {
117+
const rootDir = path.join(__dirname, '..');
118+
119+
// Determine what to compare
120+
// In GitHub Actions PR: compare base branch with HEAD
121+
// In GitHub Actions push: compare HEAD~1 with HEAD
122+
// Locally: compare HEAD~1 with HEAD
123+
const baseBranch = process.env.GITHUB_BASE_REF;
124+
let diffCommand;
125+
126+
if (baseBranch) {
127+
// In a PR
128+
console.log(`Comparing against base branch: origin/${baseBranch}`);
129+
diffCommand = `git diff origin/${baseBranch}...HEAD -- repos.csv`;
130+
} else {
131+
// Direct push or local
132+
console.log('Comparing HEAD~1...HEAD');
133+
diffCommand = `git diff HEAD~1 HEAD -- repos.csv`;
134+
}
135+
136+
const diff = execSync(diffCommand, {
137+
encoding: 'utf8',
138+
cwd: rootDir
139+
});
140+
141+
if (!diff.trim()) {
142+
console.log('No changes detected in repos.csv');
143+
return;
144+
}
145+
146+
// Parse added and removed lines
147+
const addedLines = diff
148+
.split('\n')
149+
.filter(line => line.startsWith('+') && !line.startsWith('+++'))
150+
.map(line => line.substring(1).trim())
151+
.filter(line => line && !line.startsWith('Repository,'));
152+
153+
const removedLines = diff
154+
.split('\n')
155+
.filter(line => line.startsWith('-') && !line.startsWith('---'))
156+
.map(line => line.substring(1).trim())
157+
.filter(line => line && !line.startsWith('Repository,'));
158+
159+
// Extract repository info
160+
const addedRepos = addedLines.map(line => {
161+
try {
162+
const columns = parseCSVLine(line);
163+
return {
164+
repository: columns[0] || '',
165+
companyName: columns[1] || '',
166+
companyUrl: columns[2] || '',
167+
careerUrl: columns[3] || '',
168+
tags: columns[4] || '',
169+
language: columns[5] || '',
170+
description: columns[6] || ''
171+
};
172+
} catch (e) {
173+
return null;
174+
}
175+
}).filter(repo => repo && repo.repository);
176+
177+
const removedRepos = removedLines.map(line => {
178+
try {
179+
const columns = parseCSVLine(line);
180+
return {
181+
repository: columns[0] || '',
182+
companyName: columns[1] || '',
183+
companyUrl: columns[2] || '',
184+
careerUrl: columns[3] || '',
185+
tags: columns[4] || '',
186+
language: columns[5] || '',
187+
description: columns[6] || ''
188+
};
189+
} catch (e) {
190+
return null;
191+
}
192+
}).filter(repo => repo && repo.repository);
193+
194+
// Filter out repos that appear in both added and removed
195+
const addedRepoNames = new Set(addedRepos.map(r => r.repository));
196+
const removedRepoNames = new Set(removedRepos.map(r => r.repository));
197+
198+
const duplicateRepos = new Set(
199+
[...addedRepoNames].filter(name => removedRepoNames.has(name))
200+
);
201+
202+
const finalAddedRepos = addedRepos.filter(r => !duplicateRepos.has(r.repository));
203+
const finalRemovedRepos = removedRepos.filter(r => !duplicateRepos.has(r.repository));
204+
205+
if (finalAddedRepos.length === 0 && finalRemovedRepos.length === 0) {
206+
console.log('No new repos added or removed (only modifications detected)');
207+
return;
208+
}
209+
210+
// Get current commit info
211+
const commitHash = execSync('git rev-parse HEAD', {
212+
encoding: 'utf8',
213+
cwd: rootDir
214+
}).trim();
215+
216+
const commitMessage = execSync('git log -1 --pretty=%s', {
217+
encoding: 'utf8',
218+
cwd: rootDir
219+
}).trim();
220+
221+
const commitAuthor = execSync('git log -1 --pretty=%an', {
222+
encoding: 'utf8',
223+
cwd: rootDir
224+
}).trim();
225+
226+
const commitEmail = execSync('git log -1 --pretty=%ae', {
227+
encoding: 'utf8',
228+
cwd: rootDir
229+
}).trim();
230+
231+
const commitTimestamp = execSync('git log -1 --pretty=%at', {
232+
encoding: 'utf8',
233+
cwd: rootDir
234+
}).trim();
235+
236+
// Create new update entry
237+
const newUpdate = {
238+
id: commitHash,
239+
type: 'repo-update',
240+
title: commitMessage,
241+
message: commitMessage,
242+
date: new Date(parseInt(commitTimestamp) * 1000).toISOString(),
243+
html_url: `https://github.com/timqian/open-source-jobs/commit/${commitHash}`,
244+
author: {
245+
login: commitAuthor,
246+
email: commitEmail,
247+
avatar_url: null,
248+
html_url: null
249+
},
250+
changes: {
251+
added: finalAddedRepos,
252+
removed: finalRemovedRepos
253+
}
254+
};
255+
256+
// Read existing updates
257+
const updatesPath = path.join(__dirname, '..', 'data', 'updates.json');
258+
let existingUpdates = [];
259+
260+
if (fs.existsSync(updatesPath)) {
261+
try {
262+
existingUpdates = JSON.parse(fs.readFileSync(updatesPath, 'utf8'));
263+
} catch (e) {
264+
console.log('Could not parse existing updates.json, starting fresh');
265+
}
266+
}
267+
268+
// Check if this commit already exists
269+
const existingIndex = existingUpdates.findIndex(u => u.id === commitHash);
270+
if (existingIndex !== -1) {
271+
// Update existing entry
272+
existingUpdates[existingIndex] = newUpdate;
273+
console.log(`Updated existing entry for commit ${commitHash.substring(0, 7)}`);
274+
} else {
275+
// Add new entry at the beginning
276+
existingUpdates.unshift(newUpdate);
277+
console.log(`Added new entry for commit ${commitHash.substring(0, 7)}`);
278+
}
279+
280+
// Keep only last 100 updates
281+
existingUpdates = existingUpdates.slice(0, 100);
282+
283+
// Save updates
284+
const dataDir = path.join(__dirname, '..', 'data');
285+
if (!fs.existsSync(dataDir)) {
286+
fs.mkdirSync(dataDir, { recursive: true });
287+
}
288+
289+
fs.writeFileSync(updatesPath, JSON.stringify(existingUpdates, null, 2), 'utf8');
290+
291+
console.log(`✅ Updates saved to ${updatesPath}`);
292+
console.log(`📊 Added: ${finalAddedRepos.length}, Removed: ${finalRemovedRepos.length}`);
293+
294+
// Generate RSS feed
295+
console.log('\n📡 Generating RSS feed...');
296+
generateRSS(existingUpdates);
297+
298+
} catch (error) {
299+
console.error('❌ Error generating update:', error.message);
300+
process.exit(1);
301+
}
302+
}
303+
304+
generateUpdateFromDiff();

0 commit comments

Comments
 (0)