Skip to content

Weekly Digest

Weekly Digest #11

Workflow file for this run

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();
});