Weekly Digest #11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Weekly Digest | |
| on: | |
| schedule: | |
| # Monday 03:30 UTC (9:00 AM IST) | |
| - cron: '30 3 * * 1' | |
| workflow_dispatch: | |
| permissions: | |
| pull-requests: write | |
| issues: read | |
| contents: write | |
| jobs: | |
| digest: | |
| name: Generate weekly digest and changelog | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: master | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Generate digest and update changelog | |
| uses: actions/github-script@v7 | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const https = require('https'); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const now = new Date(); | |
| const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); | |
| // Get open PRs (paginated) | |
| let openPRs = []; | |
| let prPage = 1; | |
| while (true) { | |
| const { data: batch } = await github.rest.pulls.list({ | |
| owner, repo, | |
| state: 'open', | |
| per_page: 100, | |
| page: prPage, | |
| }); | |
| openPRs = openPRs.concat(batch); | |
| if (batch.length < 100) break; | |
| prPage++; | |
| } | |
| const criticalPRs = openPRs.filter((pr) => pr.labels.some((l) => l.name === 'critical')).length; | |
| const readyPRs = openPRs.filter((pr) => pr.labels.some((l) => l.name === 'ready-for-review')).length; | |
| const needsChangesPRs = openPRs.filter((pr) => pr.labels.some((l) => l.name === 'needs-changes')).length; | |
| const stalePRs = openPRs.filter((pr) => pr.labels.some((l) => l.name === 'stale')).length; | |
| const newPRs = openPRs.filter((pr) => new Date(pr.created_at) > weekAgo).length; | |
| // Get merged PRs this week | |
| const { data: closedPRs } = await github.rest.pulls.list({ | |
| owner, repo, | |
| state: 'closed', | |
| sort: 'updated', | |
| direction: 'desc', | |
| per_page: 100, | |
| }); | |
| const mergedThisWeek = closedPRs.filter( | |
| (pr) => pr.merged_at && new Date(pr.merged_at) > weekAgo | |
| ); | |
| // Top contributors | |
| const contributorCounts = {}; | |
| for (const pr of mergedThisWeek) { | |
| contributorCounts[pr.user.login] = (contributorCounts[pr.user.login] || 0) + 1; | |
| } | |
| const topContributors = Object.entries(contributorCounts) | |
| .map(([login, count]) => ({ login, count })) | |
| .sort((a, b) => b.count - a.count) | |
| .slice(0, 5); | |
| // Get open issues | |
| const { data: openIssues } = await github.rest.issues.listForRepo({ | |
| owner, repo, | |
| state: 'open', | |
| per_page: 100, | |
| }); | |
| const actualIssues = openIssues.filter((i) => !i.pull_request); | |
| const newIssues = actualIssues.filter((i) => new Date(i.created_at) > weekAgo).length; | |
| // βββ Batched Changelog βββββββββββββββββββββββββββββββ | |
| // Scan merged data PRs and generate changelog entries | |
| const changelogPath = 'CHANGELOG.md'; | |
| let existingContent = ''; | |
| if (fs.existsSync(changelogPath)) { | |
| existingContent = fs.readFileSync(changelogPath, 'utf8'); | |
| } | |
| const entries = []; | |
| for (const pr of mergedThisWeek) { | |
| // Skip if already in changelog | |
| if (existingContent.includes(`#${pr.number}]`)) continue; | |
| // Get changed files for this PR | |
| let dataFiles; | |
| try { | |
| const { data: files } = await github.rest.pulls.listFiles({ | |
| owner, repo, | |
| pull_number: pr.number, | |
| per_page: 100, | |
| }); | |
| dataFiles = files.filter( | |
| (f) => f.filename.startsWith('contributions/') && f.filename.endsWith('.json') | |
| ); | |
| } catch { | |
| continue; | |
| } | |
| if (dataFiles.length === 0) continue; | |
| // Analyse changes | |
| const changes = { cities: { added: 0, modified: 0 }, states: { added: 0, modified: 0 }, countries: { added: 0, modified: 0 } }; | |
| const locations = new Set(); | |
| for (const file of dataFiles) { | |
| let entityType = null; | |
| const lower = file.filename.toLowerCase(); | |
| if (lower.includes('cities')) entityType = 'cities'; | |
| else if (lower.includes('states')) entityType = 'states'; | |
| else if (lower.includes('countries')) entityType = 'countries'; | |
| if (!entityType) continue; | |
| if (file.status === 'added') changes[entityType].added++; | |
| else if (file.status === 'modified') changes[entityType].modified++; | |
| // Extract country code from city file path (e.g. contributions/cities/US.json β US) | |
| const ccMatch = file.filename.match(/cities\/([A-Z]{2})\.json/i); | |
| if (ccMatch) locations.add(ccMatch[1].toUpperCase()); | |
| } | |
| const parts = []; | |
| for (const [entity, counts] of Object.entries(changes)) { | |
| if (counts.added > 0) parts.push(`Added ${entity}`); | |
| if (counts.modified > 0) parts.push(`Updated ${entity}`); | |
| } | |
| if (parts.length === 0) continue; | |
| let summary = parts.join(', '); | |
| if (locations.size > 0) { | |
| summary += ` (${[...locations].slice(0, 5).join(', ')})`; | |
| } | |
| const date = new Date(pr.merged_at).toISOString().split('T')[0]; | |
| entries.push(`- **${date}** - PR [#${pr.number}](${pr.html_url}): ${summary} (by @${pr.user.login})`); | |
| } | |
| // Write changelog entries if any | |
| let changelogUpdated = false; | |
| if (entries.length > 0) { | |
| const monthHeader = `## ${now.toISOString().substring(0, 7)}`; | |
| const newEntries = entries.join('\n') + '\n'; | |
| let newContent; | |
| if (existingContent.includes(monthHeader)) { | |
| const headerRegex = new RegExp(`^(${monthHeader.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\n`, 'm'); | |
| newContent = existingContent.replace(headerRegex, `$1\n${newEntries}`); | |
| } else { | |
| const mainHeaderEnd = existingContent.indexOf('\n\n'); | |
| if (mainHeaderEnd > -1 && existingContent.startsWith('# ')) { | |
| newContent = | |
| existingContent.substring(0, mainHeaderEnd + 2) + | |
| monthHeader + '\n' + newEntries + '\n' + | |
| existingContent.substring(mainHeaderEnd + 2); | |
| } else { | |
| newContent = `# Changelog\n\n${monthHeader}\n${newEntries}\n${existingContent}`; | |
| } | |
| } | |
| fs.writeFileSync(changelogPath, newContent); | |
| changelogUpdated = true; | |
| core.info(`Changelog: ${entries.length} entries added`); | |
| } else { | |
| core.info('Changelog: no new data PRs to add'); | |
| } | |
| // βββ Commit Changelog via PR βββββββββββββββββββββββββ | |
| if (changelogUpdated) { | |
| const { execSync } = require('child_process'); | |
| const branchName = `changelog/weekly-${now.toISOString().split('T')[0]}`; | |
| try { | |
| execSync('git config user.name "github-actions[bot]"'); | |
| execSync('git config user.email "41898282+github-actions[bot]@users.noreply.github.com"'); | |
| execSync(`git checkout -b ${branchName}`); | |
| execSync(`git add ${changelogPath}`); | |
| execSync(`git commit -m "docs: weekly changelog update (${entries.length} entries)"`); | |
| execSync(`git push origin ${branchName}`); | |
| const { data: changelogPR } = await github.rest.pulls.create({ | |
| owner, repo, | |
| title: `docs: weekly changelog update (${entries.length} entries)`, | |
| head: branchName, | |
| base: 'master', | |
| body: `Weekly batched changelog update.\n\n${entries.join('\n')}`, | |
| }); | |
| try { | |
| await github.rest.pulls.merge({ | |
| owner, repo, | |
| pull_number: changelogPR.number, | |
| merge_method: 'squash', | |
| }); | |
| core.info(`Changelog PR #${changelogPR.number} auto-merged.`); | |
| } catch (mergeErr) { | |
| core.info(`Changelog PR #${changelogPR.number} created, needs manual merge: ${mergeErr.message}`); | |
| } | |
| } catch (err) { | |
| core.warning(`Could not create changelog PR: ${err.message}`); | |
| } | |
| } | |
| // βββ Slack Digest ββββββββββββββββββββββββββββββββββββ | |
| const dateStr = now.toLocaleDateString('en-GB', { | |
| weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', | |
| }); | |
| const blocks = [ | |
| { | |
| type: 'header', | |
| text: { type: 'plain_text', text: `:bar_chart: Weekly CSC Digest - ${dateStr}`, emoji: true }, | |
| }, | |
| { | |
| type: 'section', | |
| fields: [ | |
| { type: 'mrkdwn', text: `*Open PRs:* ${openPRs.length} (${newPRs} new)` }, | |
| { type: 'mrkdwn', text: `*Merged This Week:* ${mergedThisWeek.length}` }, | |
| { type: 'mrkdwn', text: `*Open Issues:* ${actualIssues.length} (${newIssues} new)` }, | |
| { type: 'mrkdwn', text: `*Critical PRs:* ${criticalPRs}` }, | |
| ], | |
| }, | |
| { | |
| type: 'section', | |
| fields: [ | |
| { type: 'mrkdwn', text: `*Ready for Review:* ${readyPRs}` }, | |
| { type: 'mrkdwn', text: `*Needs Changes:* ${needsChangesPRs}` }, | |
| { type: 'mrkdwn', text: `*Stale:* ${stalePRs}` }, | |
| ], | |
| }, | |
| ]; | |
| if (topContributors.length > 0) { | |
| blocks.push({ | |
| type: 'section', | |
| text: { | |
| type: 'mrkdwn', | |
| text: | |
| '*Top Contributors This Week:*\n' + | |
| topContributors | |
| .map((c, i) => `${i + 1}. @${c.login} (${c.count} PR${c.count > 1 ? 's' : ''} merged)`) | |
| .join('\n'), | |
| }, | |
| }); | |
| } | |
| if (mergedThisWeek.length > 0) { | |
| const mergedList = mergedThisWeek | |
| .slice(0, 5) | |
| .map((pr) => `- <${pr.html_url}|#${pr.number}> ${pr.title}`) | |
| .join('\n'); | |
| blocks.push({ | |
| type: 'section', | |
| text: { | |
| type: 'mrkdwn', | |
| text: `*Recently Merged:*\n${mergedList}${mergedThisWeek.length > 5 ? `\n_...and ${mergedThisWeek.length - 5} more_` : ''}`, | |
| }, | |
| }); | |
| } | |
| if (changelogUpdated) { | |
| blocks.push({ | |
| type: 'section', | |
| text: { type: 'mrkdwn', text: `:memo: *Changelog updated:* ${entries.length} new entries added` }, | |
| }); | |
| } | |
| const payload = { attachments: [{ color: '#4A90D9', blocks }] }; | |
| const webhookUrl = process.env.SLACK_WEBHOOK_URL; | |
| if (!webhookUrl) { | |
| core.warning('SLACK_WEBHOOK_URL not set. Skipping digest.'); | |
| core.info(JSON.stringify(payload, null, 2)); | |
| return; | |
| } | |
| const url = new URL(webhookUrl); | |
| const data = JSON.stringify(payload); | |
| await new Promise((resolve) => { | |
| const req = https.request( | |
| { | |
| hostname: url.hostname, | |
| path: url.pathname, | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }, | |
| }, | |
| (res) => { | |
| if (res.statusCode === 200) core.info('Weekly digest sent to Slack.'); | |
| else core.warning(`Slack responded with ${res.statusCode}`); | |
| resolve(); | |
| } | |
| ); | |
| req.on('error', (err) => { | |
| core.warning(`Slack error: ${err.message}`); | |
| resolve(); | |
| }); | |
| req.write(data); | |
| req.end(); | |
| }); |