|
| 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, '&') |
| 31 | + .replace(/</g, '<') |
| 32 | + .replace(/>/g, '>') |
| 33 | + .replace(/"/g, '"') |
| 34 | + .replace(/'/g, '''); |
| 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