From 08eff8269b0d2c9bd2c9b3bfa61b3b604a13c8f6 Mon Sep 17 00:00:00 2001 From: Evan Morgan Date: Fri, 20 Mar 2026 11:37:05 +0000 Subject: [PATCH 1/6] fix(PE-1209): use TRAFFIC_PAT for weekly traffic report --- .github/workflows/weekly-traffic-report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/weekly-traffic-report.yml b/.github/workflows/weekly-traffic-report.yml index 78f5c30..f614f26 100644 --- a/.github/workflows/weekly-traffic-report.yml +++ b/.github/workflows/weekly-traffic-report.yml @@ -29,7 +29,7 @@ jobs: - name: Fetch traffic metrics env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.TRAFFIC_PAT }} run: node .github/scripts/fetch-metrics.js - name: Compare snapshots From d5e8fe54fd67736c1341d5dda70e2450b5a49f85 Mon Sep 17 00:00:00 2001 From: Evan Morgan Date: Fri, 20 Mar 2026 12:05:44 +0000 Subject: [PATCH 2/6] feat(PE-1209): add stars, forks, issues, PRs, referrers, and health metrics to weekly report --- .github/scripts/build-slack-payload.js | 79 +++++++- .github/scripts/build-slack-payload.test.js | 147 ++++++++++++--- .github/scripts/compare-snapshots.js | 99 +++++++++- .github/scripts/compare-snapshots.test.js | 194 ++++++++++++++------ .github/scripts/fetch-metrics.js | 84 +++++---- .github/scripts/github-api.js | 186 +++++++++++++++++++ .github/scripts/github-api.test.js | 94 ++++++++++ 7 files changed, 747 insertions(+), 136 deletions(-) create mode 100644 .github/scripts/github-api.js create mode 100644 .github/scripts/github-api.test.js diff --git a/.github/scripts/build-slack-payload.js b/.github/scripts/build-slack-payload.js index f8388ab..311bc15 100644 --- a/.github/scripts/build-slack-payload.js +++ b/.github/scripts/build-slack-payload.js @@ -17,28 +17,92 @@ function buildDividerBlock() { return { type: 'divider' }; } +function formatMetricText(label, metric) { + if (metric.value == null) return `*${label}*\n${metric.change}`; + return `*${label}*\n${metric.value} ${metric.change}`; +} + function buildMetricSectionBlock(label, metric) { return { type: 'section', - text: { type: 'mrkdwn', text: `*${label}*\n${metric.value} ${metric.change}` }, + text: { type: 'mrkdwn', text: formatMetricText(label, metric) }, + }; +} + +function buildTwoColumnBlock(leftLabel, leftMetric, rightLabel, rightMetric) { + return { + type: 'section', + fields: [ + { type: 'mrkdwn', text: formatMetricText(leftLabel, leftMetric) }, + { type: 'mrkdwn', text: formatMetricText(rightLabel, rightMetric) }, + ], + }; +} + +function formatReferrerLine(entry) { + return `\u2022 ${entry.referrer} (${entry.uniques} unique)`; +} + +function formatPathLine(entry) { + return `\u2022 \`${entry.path}\` (${entry.uniques} unique)`; +} + +function buildListBlock(label, items, formatter) { + const lines = items.map(formatter).join('\n'); + const body = items.length > 0 ? lines : '_No data_'; + return { + type: 'section', + text: { type: 'mrkdwn', text: `*${label}*\n${body}` }, }; } function buildContextBlock() { return { type: 'context', - elements: [{ type: 'mrkdwn', text: 'Data from GitHub Traffic API - BrightDev' }], + elements: [{ type: 'mrkdwn', text: 'Data from GitHub API - BrightDev' }], }; } +function buildCommunityBlocks(c) { + return [ + buildTwoColumnBlock('Stars', c.stars, 'Forks', c.forks), + ]; +} + +function buildTrafficBlocks(c) { + return [ + buildTwoColumnBlock('Unique Visits', c.uniqueVisits, 'Unique Clones', c.uniqueClones), + buildMetricSectionBlock('Conversion Rate', c.conversionRate), + ]; +} + +function buildEngagementBlocks(c) { + return [ + buildTwoColumnBlock('Issues Opened', c.issuesOpenedThisWeek, 'External PRs', c.externalPrsThisWeek), + buildTwoColumnBlock('Open Issues', c.openIssueCount, 'Avg Issue Age (days)', c.openIssueAvgAgeDays), + buildMetricSectionBlock('Avg First Response (hrs)', c.avgFirstResponseHours), + ]; +} + +function buildReferralBlocks(c) { + return [ + buildListBlock('Top Referrers', c.topReferrers, formatReferrerLine), + buildListBlock('Top Content', c.topPaths, formatPathLine), + ]; +} + function buildSlackPayload(comparison) { return { blocks: [ buildHeaderBlock(comparison.date), buildDividerBlock(), - buildMetricSectionBlock('Unique Visits', comparison.uniqueVisits), - buildMetricSectionBlock('Unique Clones', comparison.uniqueClones), - buildMetricSectionBlock('Conversion Rate', comparison.conversionRate), + ...buildCommunityBlocks(comparison), + buildDividerBlock(), + ...buildTrafficBlocks(comparison), + buildDividerBlock(), + ...buildEngagementBlocks(comparison), + buildDividerBlock(), + ...buildReferralBlocks(comparison), buildContextBlock(), ], }; @@ -85,7 +149,12 @@ module.exports = { buildHeaderBlock, buildDividerBlock, buildMetricSectionBlock, + buildTwoColumnBlock, + buildListBlock, buildContextBlock, + formatMetricText, + formatReferrerLine, + formatPathLine, isTestMode, postPayloadToSlackWebhook, deliverPayload, diff --git a/.github/scripts/build-slack-payload.test.js b/.github/scripts/build-slack-payload.test.js index bb5f3a2..df2c7ca 100644 --- a/.github/scripts/build-slack-payload.test.js +++ b/.github/scripts/build-slack-payload.test.js @@ -8,7 +8,12 @@ const { buildHeaderBlock, buildDividerBlock, buildMetricSectionBlock, + buildTwoColumnBlock, + buildListBlock, buildContextBlock, + formatMetricText, + formatReferrerLine, + formatPathLine, isTestMode, postPayloadToSlackWebhook, deliverPayload, @@ -19,8 +24,25 @@ const SAMPLE_COMPARISON = { uniqueVisits: { value: 200, change: '\u2191 +100%' }, uniqueClones: { value: 20, change: '\u2191 +100%' }, conversionRate: { value: 10, change: '\u2192 0%' }, + stars: { value: 150, change: '\u2191 +5' }, + forks: { value: 30, change: '\u2191 +2' }, + issuesOpenedThisWeek: { value: 3, change: '\u2191 +50%' }, + externalPrsThisWeek: { value: 1, change: 'N/A (first run)' }, + openIssueCount: { value: 12, change: '\u2193 -8%' }, + openIssueAvgAgeDays: { value: 15.3, change: '\u2191 +5%' }, + avgFirstResponseHours: { value: 4.2, change: '\u2193 -20%' }, + topReferrers: [ + { referrer: 'google.com', uniques: 10 }, + { referrer: 'dev.to', uniques: 5 }, + ], + topPaths: [ + { path: '/README.md', uniques: 25 }, + { path: '/docs/setup.md', uniques: 12 }, + ], }; +// --- Block builders --- + test('buildHeaderBlock includes date in correct format', () => { const block = buildHeaderBlock('2024-01-12'); assert.equal(block.type, 'header'); @@ -33,7 +55,7 @@ test('buildDividerBlock returns divider type', () => { assert.equal(block.type, 'divider'); }); -test('buildMetricSectionBlock includes label and metric value and change', () => { +test('buildMetricSectionBlock includes label, value, and change', () => { const block = buildMetricSectionBlock('Unique Visits', { value: 200, change: '\u2191 +100%' }); assert.equal(block.type, 'section'); assert.equal(block.text.type, 'mrkdwn'); @@ -42,22 +64,82 @@ test('buildMetricSectionBlock includes label and metric value and change', () => assert.ok(block.text.text.includes('\u2191 +100%')); }); -test('buildContextBlock contains GitHub Traffic API attribution', () => { +test('buildTwoColumnBlock produces section with two fields', () => { + const block = buildTwoColumnBlock( + 'Stars', { value: 150, change: '+5' }, + 'Forks', { value: 30, change: '+2' } + ); + assert.equal(block.type, 'section'); + assert.equal(block.fields.length, 2); + assert.ok(block.fields[0].text.includes('Stars')); + assert.ok(block.fields[0].text.includes('150')); + assert.ok(block.fields[1].text.includes('Forks')); + assert.ok(block.fields[1].text.includes('30')); +}); + +test('buildListBlock renders bulleted list', () => { + const items = [{ name: 'a' }, { name: 'b' }]; + const block = buildListBlock('Test', items, (i) => `\u2022 ${i.name}`); + assert.equal(block.type, 'section'); + assert.ok(block.text.text.includes('Test')); + assert.ok(block.text.text.includes('\u2022 a')); + assert.ok(block.text.text.includes('\u2022 b')); +}); + +test('buildListBlock shows no data message for empty list', () => { + const block = buildListBlock('Empty', [], () => ''); + assert.ok(block.text.text.includes('_No data_')); +}); + +test('buildContextBlock contains GitHub API attribution', () => { const block = buildContextBlock(); assert.equal(block.type, 'context'); - assert.equal(block.elements[0].type, 'mrkdwn'); - assert.ok(block.elements[0].text.includes('Data from GitHub Traffic API - BrightDev')); + assert.ok(block.elements[0].text.includes('Data from GitHub API - BrightDev')); +}); + +// --- formatMetricText --- + +test('formatMetricText displays value and change for normal metric', () => { + const text = formatMetricText('Stars', { value: 150, change: '+5' }); + assert.ok(text.includes('*Stars*')); + assert.ok(text.includes('150')); + assert.ok(text.includes('+5')); +}); + +test('formatMetricText displays only change when value is null', () => { + const text = formatMetricText('Response', { value: null, change: 'N/A' }); + assert.ok(text.includes('*Response*')); + assert.ok(text.includes('N/A')); + assert.ok(!text.includes('null')); }); -test('buildSlackPayload produces six blocks in correct order', () => { +// --- List formatters --- + +test('formatReferrerLine formats referrer with bullet and uniques', () => { + const line = formatReferrerLine({ referrer: 'google.com', uniques: 10 }); + assert.ok(line.includes('\u2022')); + assert.ok(line.includes('google.com')); + assert.ok(line.includes('10 unique')); +}); + +test('formatPathLine formats path with bullet, backticks, and uniques', () => { + const line = formatPathLine({ path: '/README.md', uniques: 25 }); + assert.ok(line.includes('\u2022')); + assert.ok(line.includes('`/README.md`')); + assert.ok(line.includes('25 unique')); +}); + +// --- buildSlackPayload --- + +test('buildSlackPayload produces correct number of blocks', () => { + const payload = buildSlackPayload(SAMPLE_COMPARISON); + assert.equal(payload.blocks.length, 14); +}); + +test('buildSlackPayload starts with header and ends with context', () => { const payload = buildSlackPayload(SAMPLE_COMPARISON); - assert.equal(payload.blocks.length, 6); assert.equal(payload.blocks[0].type, 'header'); - assert.equal(payload.blocks[1].type, 'divider'); - assert.equal(payload.blocks[2].type, 'section'); - assert.equal(payload.blocks[3].type, 'section'); - assert.equal(payload.blocks[4].type, 'section'); - assert.equal(payload.blocks[5].type, 'context'); + assert.equal(payload.blocks[payload.blocks.length - 1].type, 'context'); }); test('buildSlackPayload header shows correct date', () => { @@ -65,28 +147,35 @@ test('buildSlackPayload header shows correct date', () => { assert.ok(payload.blocks[0].text.text.includes('2024-01-12')); }); -test('buildSlackPayload sections contain correct labels', () => { +test('buildSlackPayload contains stars/forks two-column block', () => { const payload = buildSlackPayload(SAMPLE_COMPARISON); - assert.ok(payload.blocks[2].text.text.includes('Unique Visits')); - assert.ok(payload.blocks[3].text.text.includes('Unique Clones')); - assert.ok(payload.blocks[4].text.text.includes('Conversion Rate')); + const starsForks = payload.blocks[2]; + assert.equal(starsForks.type, 'section'); + assert.ok(starsForks.fields); + assert.ok(starsForks.fields[0].text.includes('Stars')); + assert.ok(starsForks.fields[1].text.includes('Forks')); }); -test('buildSlackPayload positive change shows + prefix', () => { +test('buildSlackPayload contains referrer list', () => { const payload = buildSlackPayload(SAMPLE_COMPARISON); - assert.ok(payload.blocks[2].text.text.includes('+')); + const referrerBlock = payload.blocks.find( + (b) => b.text && b.text.text && b.text.text.includes('Top Referrers') + ); + assert.ok(referrerBlock); + assert.ok(referrerBlock.text.text.includes('google.com')); }); -test('buildSlackPayload negative change does not show + prefix', () => { - const comparison = { - ...SAMPLE_COMPARISON, - uniqueVisits: { value: 50, change: '\u2193 -50%' }, - }; - const payload = buildSlackPayload(comparison); - assert.ok(!payload.blocks[2].text.text.includes('+')); - assert.ok(payload.blocks[2].text.text.includes('\u2193')); +test('buildSlackPayload contains content path list', () => { + const payload = buildSlackPayload(SAMPLE_COMPARISON); + const pathBlock = payload.blocks.find( + (b) => b.text && b.text.text && b.text.text.includes('Top Content') + ); + assert.ok(pathBlock); + assert.ok(pathBlock.text.text.includes('/README.md')); }); +// --- isTestMode --- + test('isTestMode returns true when TEST_MODE env is true', () => { const original = process.env.TEST_MODE; process.env.TEST_MODE = 'true'; @@ -108,10 +197,11 @@ test('isTestMode returns false when TEST_MODE env is unset', () => { process.env.TEST_MODE = original; }); +// --- Delivery --- + test('postPayloadToSlackWebhook throws on non-2xx response', async () => { - const fakeFetch = async () => ({ ok: false, status: 500, text: async () => 'Internal Server Error' }); const original = global.fetch; - global.fetch = fakeFetch; + global.fetch = async () => ({ ok: false, status: 500, text: async () => 'Internal Server Error' }); await assert.rejects( () => postPayloadToSlackWebhook('https://example.com', {}), /Slack webhook returned 500/ @@ -120,9 +210,8 @@ test('postPayloadToSlackWebhook throws on non-2xx response', async () => { }); test('postPayloadToSlackWebhook resolves on 2xx response', async () => { - const fakeFetch = async () => ({ ok: true, status: 200 }); const original = global.fetch; - global.fetch = fakeFetch; + global.fetch = async () => ({ ok: true, status: 200 }); await assert.doesNotReject(() => postPayloadToSlackWebhook('https://example.com', {})); global.fetch = original; }); diff --git a/.github/scripts/compare-snapshots.js b/.github/scripts/compare-snapshots.js index 754d4f6..88646d3 100644 --- a/.github/scripts/compare-snapshots.js +++ b/.github/scripts/compare-snapshots.js @@ -8,6 +8,7 @@ const path = require('path'); const SNAPSHOTS_DIR = 'metrics/snapshots'; const FIRST_RUN_LABEL = 'N/A (first run)'; const NO_PRIOR_DATA_LABEL = 'N/A (no prior data)'; +const NO_DATA_LABEL = 'N/A'; function readSortedSnapshotFilenames(dir) { return fs @@ -27,7 +28,7 @@ function calculateRawPercentageChange(current, previous) { return Math.round(((current - previous) / previous) * 100 * 10) / 10; } -function arrowForNumericChange(change) { +function arrowForChange(change) { if (change > 0) return '\u2191'; if (change < 0) return '\u2193'; return '\u2192'; @@ -35,31 +36,110 @@ function arrowForNumericChange(change) { function formatPercentageChange(change) { if (typeof change === 'string') return change; - const arrow = arrowForNumericChange(change); + const arrow = arrowForChange(change); const prefix = change > 0 ? '+' : ''; return `${arrow} ${prefix}${change}%`; } +function formatDelta(delta) { + if (delta === 0) return '\u2192 no change'; + const arrow = arrowForChange(delta); + const prefix = delta > 0 ? '+' : ''; + return `${arrow} ${prefix}${delta}`; +} + function buildMetricChange(currentValue, previousValue) { const change = calculateRawPercentageChange(currentValue, previousValue); return { value: currentValue, change: formatPercentageChange(change) }; } -function buildComparisonFromBaseline(current, baseline) { +function buildDeltaChange(currentValue, previousValue) { + if (previousValue == null) { + return { value: currentValue, change: FIRST_RUN_LABEL }; + } + return { value: currentValue, change: formatDelta(currentValue - previousValue) }; +} + +function buildSafeMetricChange(currentValue, previousValue) { + if (currentValue == null) return { value: null, change: NO_DATA_LABEL }; + if (previousValue == null) return { value: currentValue, change: FIRST_RUN_LABEL }; + return buildMetricChange(currentValue, previousValue); +} + +function buildTrafficComparison(current, baseline) { return { - date: current.date, uniqueVisits: buildMetricChange(current.uniqueVisits, baseline.uniqueVisits), uniqueClones: buildMetricChange(current.uniqueClones, baseline.uniqueClones), conversionRate: buildMetricChange(current.conversionRate, baseline.conversionRate), }; } +function buildCommunityComparison(current, baseline) { + return { + stars: buildDeltaChange(current.stars, baseline.stars), + forks: buildDeltaChange(current.forks, baseline.forks), + issuesOpenedThisWeek: buildSafeMetricChange(current.issuesOpenedThisWeek, baseline.issuesOpenedThisWeek), + externalPrsThisWeek: buildSafeMetricChange(current.externalPrsThisWeek, baseline.externalPrsThisWeek), + }; +} + +function buildHealthComparison(current, baseline) { + return { + openIssueCount: buildSafeMetricChange(current.openIssueCount, baseline.openIssueCount), + openIssueAvgAgeDays: buildSafeMetricChange(current.openIssueAvgAgeDays, baseline.openIssueAvgAgeDays), + avgFirstResponseHours: buildSafeMetricChange(current.avgFirstResponseHours, baseline.avgFirstResponseHours), + }; +} + +function buildComparisonFromBaseline(current, baseline) { + return { + date: current.date, + ...buildTrafficComparison(current, baseline), + ...buildCommunityComparison(current, baseline), + ...buildHealthComparison(current, baseline), + topReferrers: current.topReferrers || [], + topPaths: current.topPaths || [], + }; +} + +function buildFirstRunMetric(value) { + if (value == null) return { value: null, change: NO_DATA_LABEL }; + return { value, change: FIRST_RUN_LABEL }; +} + +function buildFirstRunTrafficMetrics(current) { + return { + uniqueVisits: buildFirstRunMetric(current.uniqueVisits), + uniqueClones: buildFirstRunMetric(current.uniqueClones), + conversionRate: buildFirstRunMetric(current.conversionRate), + }; +} + +function buildFirstRunCommunityMetrics(current) { + return { + stars: buildFirstRunMetric(current.stars), + forks: buildFirstRunMetric(current.forks), + issuesOpenedThisWeek: buildFirstRunMetric(current.issuesOpenedThisWeek), + externalPrsThisWeek: buildFirstRunMetric(current.externalPrsThisWeek), + }; +} + +function buildFirstRunHealthMetrics(current) { + return { + openIssueCount: buildFirstRunMetric(current.openIssueCount), + openIssueAvgAgeDays: buildFirstRunMetric(current.openIssueAvgAgeDays), + avgFirstResponseHours: buildFirstRunMetric(current.avgFirstResponseHours), + }; +} + function buildFirstRunComparison(current) { return { date: current.date, - uniqueVisits: { value: current.uniqueVisits, change: FIRST_RUN_LABEL }, - uniqueClones: { value: current.uniqueClones, change: FIRST_RUN_LABEL }, - conversionRate: { value: current.conversionRate, change: FIRST_RUN_LABEL }, + ...buildFirstRunTrafficMetrics(current), + ...buildFirstRunCommunityMetrics(current), + ...buildFirstRunHealthMetrics(current), + topReferrers: current.topReferrers || [], + topPaths: current.topPaths || [], }; } @@ -76,10 +156,15 @@ module.exports = { compareSnapshots, calculateRawPercentageChange, formatPercentageChange, + formatDelta, buildFirstRunComparison, buildComparisonFromBaseline, + buildMetricChange, + buildDeltaChange, + buildSafeMetricChange, FIRST_RUN_LABEL, NO_PRIOR_DATA_LABEL, + NO_DATA_LABEL, }; if (require.main === module) { diff --git a/.github/scripts/compare-snapshots.test.js b/.github/scripts/compare-snapshots.test.js index 2020654..7b0a1ec 100644 --- a/.github/scripts/compare-snapshots.test.js +++ b/.github/scripts/compare-snapshots.test.js @@ -10,10 +10,15 @@ const { compareSnapshots, calculateRawPercentageChange, formatPercentageChange, + formatDelta, buildFirstRunComparison, buildComparisonFromBaseline, + buildMetricChange, + buildDeltaChange, + buildSafeMetricChange, FIRST_RUN_LABEL, NO_PRIOR_DATA_LABEL, + NO_DATA_LABEL, } = require('./compare-snapshots'); function makeTempDir() { @@ -24,6 +29,27 @@ function writeSnapshot(dir, filename, data) { fs.writeFileSync(path.join(dir, filename), JSON.stringify(data)); } +function makeFullSnapshot(overrides) { + return { + date: '2024-01-12', + uniqueVisits: 200, + uniqueClones: 20, + conversionRate: 10, + stars: 150, + forks: 30, + issuesOpenedThisWeek: 3, + externalPrsThisWeek: 1, + openIssueCount: 12, + openIssueAvgAgeDays: 15.3, + avgFirstResponseHours: 4.2, + topReferrers: [{ referrer: 'google.com', uniques: 10 }], + topPaths: [{ path: '/README.md', uniques: 25 }], + ...overrides, + }; +} + +// --- calculateRawPercentageChange --- + test('calculateRawPercentageChange returns correct value for normal inputs', () => { assert.equal(calculateRawPercentageChange(110, 100), 10); assert.equal(calculateRawPercentageChange(90, 100), -10); @@ -43,23 +69,25 @@ test('calculateRawPercentageChange returns 0 when both are 0', () => { assert.equal(calculateRawPercentageChange(0, 0), 0); }); +// --- formatPercentageChange --- + test('formatPercentageChange shows up arrow and + prefix for positive change', () => { const result = formatPercentageChange(10); - assert.ok(result.includes('\u2191'), 'should include up arrow'); - assert.ok(result.includes('+'), 'should include + prefix'); + assert.ok(result.includes('\u2191')); + assert.ok(result.includes('+')); assert.ok(result.includes('10%')); }); test('formatPercentageChange shows down arrow for negative change', () => { const result = formatPercentageChange(-10); - assert.ok(result.includes('\u2193'), 'should include down arrow'); + assert.ok(result.includes('\u2193')); assert.ok(result.includes('10%')); - assert.ok(!result.includes('+'), 'should not include + prefix'); + assert.ok(!result.includes('+')); }); test('formatPercentageChange shows right arrow for zero change', () => { const result = formatPercentageChange(0); - assert.ok(result.includes('\u2192'), 'should include right arrow'); + assert.ok(result.includes('\u2192')); assert.ok(result.includes('0%')); }); @@ -68,6 +96,68 @@ test('formatPercentageChange passes through string labels unchanged', () => { assert.equal(formatPercentageChange(NO_PRIOR_DATA_LABEL), NO_PRIOR_DATA_LABEL); }); +// --- formatDelta --- + +test('formatDelta shows up arrow and + prefix for positive delta', () => { + const result = formatDelta(5); + assert.ok(result.includes('\u2191')); + assert.ok(result.includes('+5')); +}); + +test('formatDelta shows down arrow for negative delta', () => { + const result = formatDelta(-3); + assert.ok(result.includes('\u2193')); + assert.ok(result.includes('-3')); +}); + +test('formatDelta shows no change for zero delta', () => { + const result = formatDelta(0); + assert.ok(result.includes('\u2192')); + assert.ok(result.includes('no change')); +}); + +// --- buildDeltaChange --- + +test('buildDeltaChange returns FIRST_RUN_LABEL when previous is null', () => { + const result = buildDeltaChange(150, null); + assert.equal(result.value, 150); + assert.equal(result.change, FIRST_RUN_LABEL); +}); + +test('buildDeltaChange returns FIRST_RUN_LABEL when previous is undefined', () => { + const result = buildDeltaChange(150, undefined); + assert.equal(result.value, 150); + assert.equal(result.change, FIRST_RUN_LABEL); +}); + +test('buildDeltaChange returns formatted delta when both values present', () => { + const result = buildDeltaChange(155, 150); + assert.equal(result.value, 155); + assert.ok(result.change.includes('+5')); +}); + +// --- buildSafeMetricChange --- + +test('buildSafeMetricChange returns NO_DATA_LABEL when current is null', () => { + const result = buildSafeMetricChange(null, 10); + assert.equal(result.value, null); + assert.equal(result.change, NO_DATA_LABEL); +}); + +test('buildSafeMetricChange returns FIRST_RUN_LABEL when previous is null', () => { + const result = buildSafeMetricChange(10, null); + assert.equal(result.value, 10); + assert.equal(result.change, FIRST_RUN_LABEL); +}); + +test('buildSafeMetricChange returns percentage change when both present', () => { + const result = buildSafeMetricChange(200, 100); + assert.equal(result.value, 200); + assert.ok(result.change.includes('100%')); +}); + +// --- compareSnapshots --- + test('compareSnapshots throws when directory has no snapshot files', () => { const dir = makeTempDir(); assert.throws(() => compareSnapshots(dir), /No snapshot files found/); @@ -75,74 +165,64 @@ test('compareSnapshots throws when directory has no snapshot files', () => { test('compareSnapshots returns first run comparison when only one snapshot exists', () => { const dir = makeTempDir(); - writeSnapshot(dir, 'weekly-2024-01-05.json', { - date: '2024-01-05', - uniqueVisits: 100, - uniqueClones: 10, - conversionRate: 10, - }); + writeSnapshot(dir, 'weekly-2024-01-05.json', makeFullSnapshot({ date: '2024-01-05' })); const result = compareSnapshots(dir); assert.equal(result.date, '2024-01-05'); assert.equal(result.uniqueVisits.change, FIRST_RUN_LABEL); - assert.equal(result.uniqueClones.change, FIRST_RUN_LABEL); - assert.equal(result.conversionRate.change, FIRST_RUN_LABEL); + assert.equal(result.stars.change, FIRST_RUN_LABEL); + assert.equal(result.avgFirstResponseHours.change, FIRST_RUN_LABEL); }); -test('compareSnapshots uses most recent as current and second-most-recent as baseline', () => { +test('compareSnapshots compares two full snapshots correctly', () => { const dir = makeTempDir(); - writeSnapshot(dir, 'weekly-2024-01-05.json', { - date: '2024-01-05', - uniqueVisits: 100, - uniqueClones: 10, - conversionRate: 10, - }); - writeSnapshot(dir, 'weekly-2024-01-12.json', { - date: '2024-01-12', - uniqueVisits: 200, - uniqueClones: 20, - conversionRate: 10, - }); + const baseline = makeFullSnapshot({ date: '2024-01-05', stars: 100, uniqueVisits: 100 }); + const current = makeFullSnapshot({ date: '2024-01-12', stars: 150, uniqueVisits: 200 }); + writeSnapshot(dir, 'weekly-2024-01-05.json', baseline); + writeSnapshot(dir, 'weekly-2024-01-12.json', current); const result = compareSnapshots(dir); assert.equal(result.date, '2024-01-12'); + assert.equal(result.stars.value, 150); + assert.ok(result.stars.change.includes('+50')); assert.equal(result.uniqueVisits.value, 200); - assert.ok(result.uniqueVisits.change.includes('100%'), 'should show 100% increase'); - assert.ok(result.uniqueVisits.change.includes('\u2191')); + assert.ok(result.uniqueVisits.change.includes('100%')); }); -test('compareSnapshots handles no prior data when previous metric is 0', () => { +test('compareSnapshots handles old-format baseline gracefully', () => { const dir = makeTempDir(); - writeSnapshot(dir, 'weekly-2024-01-05.json', { - date: '2024-01-05', - uniqueVisits: 0, - uniqueClones: 0, - conversionRate: 0, - }); - writeSnapshot(dir, 'weekly-2024-01-12.json', { - date: '2024-01-12', - uniqueVisits: 50, - uniqueClones: 5, - conversionRate: 10, - }); + const oldBaseline = { date: '2024-01-05', uniqueVisits: 100, uniqueClones: 10, conversionRate: 10 }; + const current = makeFullSnapshot({ date: '2024-01-12' }); + writeSnapshot(dir, 'weekly-2024-01-05.json', oldBaseline); + writeSnapshot(dir, 'weekly-2024-01-12.json', current); + const result = compareSnapshots(dir); + assert.equal(result.stars.value, 150); + assert.equal(result.stars.change, FIRST_RUN_LABEL); + assert.equal(result.issuesOpenedThisWeek.change, FIRST_RUN_LABEL); +}); + +test('compareSnapshots handles null avgFirstResponseHours', () => { + const dir = makeTempDir(); + const snapshot = makeFullSnapshot({ date: '2024-01-05', avgFirstResponseHours: null }); + writeSnapshot(dir, 'weekly-2024-01-05.json', snapshot); + const result = compareSnapshots(dir); + assert.equal(result.avgFirstResponseHours.value, null); + assert.equal(result.avgFirstResponseHours.change, NO_DATA_LABEL); +}); + +test('compareSnapshots passes through topReferrers and topPaths', () => { + const dir = makeTempDir(); + const snapshot = makeFullSnapshot({ date: '2024-01-05' }); + writeSnapshot(dir, 'weekly-2024-01-05.json', snapshot); const result = compareSnapshots(dir); - assert.equal(result.uniqueVisits.change, NO_PRIOR_DATA_LABEL); - assert.equal(result.uniqueClones.change, NO_PRIOR_DATA_LABEL); - assert.equal(result.conversionRate.change, NO_PRIOR_DATA_LABEL); + assert.equal(result.topReferrers.length, 1); + assert.equal(result.topReferrers[0].referrer, 'google.com'); + assert.equal(result.topPaths.length, 1); + assert.equal(result.topPaths[0].path, '/README.md'); }); test('compareSnapshots sorts files by date so most recent is used as current', () => { const dir = makeTempDir(); - writeSnapshot(dir, 'weekly-2024-01-12.json', { - date: '2024-01-12', - uniqueVisits: 200, - uniqueClones: 20, - conversionRate: 10, - }); - writeSnapshot(dir, 'weekly-2024-01-05.json', { - date: '2024-01-05', - uniqueVisits: 100, - uniqueClones: 10, - conversionRate: 10, - }); + writeSnapshot(dir, 'weekly-2024-01-12.json', makeFullSnapshot({ date: '2024-01-12' })); + writeSnapshot(dir, 'weekly-2024-01-05.json', makeFullSnapshot({ date: '2024-01-05' })); const result = compareSnapshots(dir); - assert.equal(result.date, '2024-01-12', 'most recent should be current'); + assert.equal(result.date, '2024-01-12'); }); diff --git a/.github/scripts/fetch-metrics.js b/.github/scripts/fetch-metrics.js index ac5a976..cd77970 100644 --- a/.github/scripts/fetch-metrics.js +++ b/.github/scripts/fetch-metrics.js @@ -5,8 +5,8 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); +const api = require('./github-api'); -const GITHUB_API_BASE = 'https://api.github.com'; const SNAPSHOTS_DIR = 'metrics/snapshots'; function getRepoContext() { @@ -15,46 +15,57 @@ function getRepoContext() { return { owner, repo, token }; } -async function fetchTrafficEndpoint(owner, repo, token, endpoint) { - const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/traffic/${endpoint}?per=week`; - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - }, - }); - if (!response.ok) { - throw new Error(`GitHub API error ${response.status} on ${endpoint}: ${await response.text()}`); - } - return response.json(); -} - -async function fetchUniqueViews(owner, repo, token) { - const data = await fetchTrafficEndpoint(owner, repo, token, 'views'); - return data.uniques; -} - -async function fetchUniqueClones(owner, repo, token) { - const data = await fetchTrafficEndpoint(owner, repo, token, 'clones'); - return data.uniques; +function formatDateAsYYYYMMDD(date) { + return date.toISOString().split('T')[0]; } -function calculateConversionRate(uniqueClones, uniqueVisits) { - if (uniqueVisits === 0) return 0; - return Math.round((uniqueClones / uniqueVisits) * 100 * 100) / 100; +function calculateConversionRate(clones, visits) { + if (visits === 0) return 0; + return Math.round((clones / visits) * 100 * 100) / 100; } -function formatDateAsYYYYMMDD(date) { - return date.toISOString().split('T')[0]; +async function fetchAllMetrics(owner, repo, token) { + const since = api.weekAgoDate(); + const [views, clones, stats, referrers, paths, issues, prs, issueStats, responseHours] = + await Promise.all([ + api.fetchTrafficViews(owner, repo, token), + api.fetchTrafficClones(owner, repo, token), + api.fetchRepoStats(owner, repo, token), + api.fetchTopReferrers(owner, repo, token), + api.fetchTopPaths(owner, repo, token), + api.fetchIssuesOpenedSince(owner, repo, token, since), + api.fetchExternalPrsSince(owner, repo, token, since), + api.fetchOpenIssueStats(owner, repo, token), + api.fetchAvgFirstResponseHours(owner, repo, token), + ]); + return { + uniqueVisits: views, + uniqueClones: clones, + ...stats, + topReferrers: referrers, + topPaths: paths, + issuesOpenedThisWeek: issues, + externalPrsThisWeek: prs, + ...issueStats, + avgFirstResponseHours: responseHours, + }; } -function buildSnapshot(date, uniqueVisits, uniqueClones) { +function buildSnapshot(date, metrics) { return { date, - uniqueVisits, - uniqueClones, - conversionRate: calculateConversionRate(uniqueClones, uniqueVisits), + uniqueVisits: metrics.uniqueVisits, + uniqueClones: metrics.uniqueClones, + conversionRate: calculateConversionRate(metrics.uniqueClones, metrics.uniqueVisits), + stars: metrics.stars, + forks: metrics.forks, + issuesOpenedThisWeek: metrics.issuesOpenedThisWeek, + externalPrsThisWeek: metrics.externalPrsThisWeek, + openIssueCount: metrics.openIssueCount, + openIssueAvgAgeDays: metrics.openIssueAvgAgeDays, + avgFirstResponseHours: metrics.avgFirstResponseHours, + topReferrers: metrics.topReferrers, + topPaths: metrics.topPaths, }; } @@ -80,11 +91,8 @@ function commitAndPushSnapshot(date, filePath) { async function main() { const { owner, repo, token } = getRepoContext(); const date = formatDateAsYYYYMMDD(new Date()); - const [uniqueVisits, uniqueClones] = await Promise.all([ - fetchUniqueViews(owner, repo, token), - fetchUniqueClones(owner, repo, token), - ]); - const snapshot = buildSnapshot(date, uniqueVisits, uniqueClones); + const metrics = await fetchAllMetrics(owner, repo, token); + const snapshot = buildSnapshot(date, metrics); const filePath = writeSnapshotToFile(snapshot); commitAndPushSnapshot(date, filePath); console.log(`Snapshot written and committed: ${filePath}`); diff --git a/.github/scripts/github-api.js b/.github/scripts/github-api.js new file mode 100644 index 0000000..4bdd982 --- /dev/null +++ b/.github/scripts/github-api.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +'use strict'; + +const GITHUB_API_BASE = 'https://api.github.com'; +const DAYS_IN_WEEK = 7; +const TOP_LIMIT = 5; + +function buildAuthHeaders(token) { + return { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; +} + +async function githubGet(url, token) { + const response = await fetch(url, { headers: buildAuthHeaders(token) }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`GitHub API ${response.status}: ${body}`); + } + return response.json(); +} + +function repoUrl(owner, repo, endpoint) { + return `${GITHUB_API_BASE}/repos/${owner}/${repo}${endpoint}`; +} + +function searchUrl(query) { + return `${GITHUB_API_BASE}/search/issues?q=${encodeURIComponent(query)}`; +} + +function weekAgoDate() { + const date = new Date(); + date.setDate(date.getDate() - DAYS_IN_WEEK); + return date.toISOString().split('T')[0]; +} + +async function fetchTrafficViews(owner, repo, token) { + const data = await githubGet(repoUrl(owner, repo, '/traffic/views?per=week'), token); + return data.uniques; +} + +async function fetchTrafficClones(owner, repo, token) { + const data = await githubGet(repoUrl(owner, repo, '/traffic/clones?per=week'), token); + return data.uniques; +} + +async function fetchTopReferrers(owner, repo, token) { + const data = await githubGet(repoUrl(owner, repo, '/traffic/popular/referrers'), token); + return data.slice(0, TOP_LIMIT).map(pickReferrerFields); +} + +function pickReferrerFields(entry) { + return { referrer: entry.referrer, uniques: entry.uniques }; +} + +async function fetchTopPaths(owner, repo, token) { + const data = await githubGet(repoUrl(owner, repo, '/traffic/popular/paths'), token); + return data.slice(0, TOP_LIMIT).map(pickPathFields); +} + +function pickPathFields(entry) { + return { path: entry.path, uniques: entry.uniques }; +} + +async function fetchRepoStats(owner, repo, token) { + const data = await githubGet(repoUrl(owner, repo, ''), token); + return { stars: data.stargazers_count, forks: data.forks_count }; +} + +async function fetchIssuesOpenedSince(owner, repo, token, since) { + const query = `repo:${owner}/${repo} type:issue created:>=${since}`; + const data = await githubGet(searchUrl(query), token); + return data.total_count; +} + +async function fetchExternalPrsSince(owner, repo, token, since) { + const query = `repo:${owner}/${repo} type:pr created:>=${since}`; + const data = await githubGet(searchUrl(query), token); + return countExternalItems(data.items || []); +} + +function isExternalContribution(item) { + const internal = ['OWNER', 'MEMBER', 'COLLABORATOR']; + return !internal.includes(item.author_association); +} + +function countExternalItems(items) { + return items.filter(isExternalContribution).length; +} + +function isRealIssue(item) { + return !item.pull_request; +} + +async function fetchOpenIssues(owner, repo, token) { + const url = repoUrl(owner, repo, '/issues?state=open&per_page=100'); + const data = await githubGet(url, token); + return data.filter(isRealIssue); +} + +function daysBetween(startDate, endDate) { + return (endDate - startDate) / (1000 * 60 * 60 * 24); +} + +function averageAgeDays(issues) { + if (issues.length === 0) return 0; + const now = new Date(); + const total = issues.reduce( + (sum, i) => sum + daysBetween(new Date(i.created_at), now), + 0 + ); + return Math.round((total / issues.length) * 10) / 10; +} + +async function fetchOpenIssueStats(owner, repo, token) { + const issues = await fetchOpenIssues(owner, repo, token); + return { + openIssueCount: issues.length, + openIssueAvgAgeDays: averageAgeDays(issues), + }; +} + +async function fetchFirstCommentDate(owner, repo, token, issueNumber) { + const url = repoUrl(owner, repo, `/issues/${issueNumber}/comments?per_page=1`); + const comments = await githubGet(url, token); + if (comments.length === 0) return null; + return new Date(comments[0].created_at); +} + +function hoursBetween(startDate, endDate) { + return (endDate - startDate) / (1000 * 60 * 60); +} + +function averageOfValues(values) { + if (values.length === 0) return null; + const sum = values.reduce((a, b) => a + b, 0); + return Math.round((sum / values.length) * 10) / 10; +} + +async function fetchResponseHoursForIssue(owner, repo, token, issue) { + const firstComment = await fetchFirstCommentDate(owner, repo, token, issue.number); + if (!firstComment) return null; + return hoursBetween(new Date(issue.created_at), firstComment); +} + +async function fetchRespondedIssuesSince(owner, repo, token, since) { + const query = `repo:${owner}/${repo} type:issue created:>=${since} comments:>0`; + const data = await githubGet(searchUrl(query), token); + return data.items || []; +} + +async function fetchAvgFirstResponseHours(owner, repo, token) { + const since = weekAgoDate(); + const issues = await fetchRespondedIssuesSince(owner, repo, token, since); + if (issues.length === 0) return null; + const hours = await Promise.all( + issues.map((i) => fetchResponseHoursForIssue(owner, repo, token, i)) + ); + return averageOfValues(hours.filter((h) => h !== null)); +} + +module.exports = { + fetchTrafficViews, + fetchTrafficClones, + fetchTopReferrers, + fetchTopPaths, + fetchRepoStats, + fetchIssuesOpenedSince, + fetchExternalPrsSince, + fetchOpenIssueStats, + fetchAvgFirstResponseHours, + isExternalContribution, + countExternalItems, + isRealIssue, + averageAgeDays, + averageOfValues, + daysBetween, + hoursBetween, + weekAgoDate, + githubGet, + pickReferrerFields, + pickPathFields, +}; diff --git a/.github/scripts/github-api.test.js b/.github/scripts/github-api.test.js new file mode 100644 index 0000000..d837cd6 --- /dev/null +++ b/.github/scripts/github-api.test.js @@ -0,0 +1,94 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + isExternalContribution, + countExternalItems, + isRealIssue, + averageAgeDays, + averageOfValues, + daysBetween, + hoursBetween, + pickReferrerFields, + pickPathFields, +} = require('./github-api'); + +test('isExternalContribution returns false for OWNER', () => { + assert.equal(isExternalContribution({ author_association: 'OWNER' }), false); +}); + +test('isExternalContribution returns false for MEMBER', () => { + assert.equal(isExternalContribution({ author_association: 'MEMBER' }), false); +}); + +test('isExternalContribution returns false for COLLABORATOR', () => { + assert.equal(isExternalContribution({ author_association: 'COLLABORATOR' }), false); +}); + +test('isExternalContribution returns true for CONTRIBUTOR', () => { + assert.equal(isExternalContribution({ author_association: 'CONTRIBUTOR' }), true); +}); + +test('isExternalContribution returns true for NONE', () => { + assert.equal(isExternalContribution({ author_association: 'NONE' }), true); +}); + +test('countExternalItems counts only external contributions', () => { + const items = [ + { author_association: 'OWNER' }, + { author_association: 'NONE' }, + { author_association: 'CONTRIBUTOR' }, + { author_association: 'MEMBER' }, + ]; + assert.equal(countExternalItems(items), 2); +}); + +test('countExternalItems returns 0 for empty array', () => { + assert.equal(countExternalItems([]), 0); +}); + +test('isRealIssue returns true when no pull_request field', () => { + assert.equal(isRealIssue({ id: 1 }), true); +}); + +test('isRealIssue returns false when pull_request field is present', () => { + assert.equal(isRealIssue({ id: 1, pull_request: { url: '...' } }), false); +}); + +test('daysBetween returns correct number of days', () => { + const start = new Date('2024-01-01T00:00:00Z'); + const end = new Date('2024-01-08T00:00:00Z'); + assert.equal(daysBetween(start, end), 7); +}); + +test('hoursBetween returns correct number of hours', () => { + const start = new Date('2024-01-01T00:00:00Z'); + const end = new Date('2024-01-01T06:00:00Z'); + assert.equal(hoursBetween(start, end), 6); +}); + +test('averageAgeDays returns 0 for empty array', () => { + assert.equal(averageAgeDays([]), 0); +}); + +test('averageOfValues returns null for empty array', () => { + assert.equal(averageOfValues([]), null); +}); + +test('averageOfValues returns average rounded to 1 decimal', () => { + assert.equal(averageOfValues([1, 2, 3]), 2); + assert.equal(averageOfValues([1.5, 2.5]), 2); + assert.equal(averageOfValues([1, 2]), 1.5); +}); + +test('pickReferrerFields extracts referrer and uniques', () => { + const entry = { referrer: 'google.com', uniques: 10, count: 20, extra: 'ignored' }; + assert.deepEqual(pickReferrerFields(entry), { referrer: 'google.com', uniques: 10 }); +}); + +test('pickPathFields extracts path and uniques', () => { + const entry = { path: '/README.md', uniques: 25, count: 50, title: 'ignored' }; + assert.deepEqual(pickPathFields(entry), { path: '/README.md', uniques: 25 }); +}); From 7832040b3fc5b077f92a7d0d175988c28f2922ab Mon Sep 17 00:00:00 2001 From: Evan Morgan Date: Fri, 20 Mar 2026 12:29:44 +0000 Subject: [PATCH 3/6] refactor(PE-1209): drop non-funnel metrics, add emoji to slack report --- .github/scripts/build-slack-payload.js | 40 ++--------- .github/scripts/build-slack-payload.test.js | 11 +-- .github/scripts/compare-snapshots.js | 54 ++------------ .github/scripts/compare-snapshots.test.js | 13 ---- .github/scripts/fetch-metrics.js | 9 +-- .github/scripts/github-api.js | 78 --------------------- .github/scripts/github-api.test.js | 39 ----------- 7 files changed, 14 insertions(+), 230 deletions(-) diff --git a/.github/scripts/build-slack-payload.js b/.github/scripts/build-slack-payload.js index 311bc15..9639a3a 100644 --- a/.github/scripts/build-slack-payload.js +++ b/.github/scripts/build-slack-payload.js @@ -9,7 +9,7 @@ const SNAPSHOTS_DIR = 'metrics/snapshots'; function buildHeaderBlock(date) { return { type: 'header', - text: { type: 'plain_text', text: `BrightDev Traffic Report - w/e ${date}` }, + text: { type: 'plain_text', text: `\ud83d\udcca BrightDev Traffic Report - w/e ${date}` }, }; } @@ -63,46 +63,18 @@ function buildContextBlock() { }; } -function buildCommunityBlocks(c) { - return [ - buildTwoColumnBlock('Stars', c.stars, 'Forks', c.forks), - ]; -} - -function buildTrafficBlocks(c) { - return [ - buildTwoColumnBlock('Unique Visits', c.uniqueVisits, 'Unique Clones', c.uniqueClones), - buildMetricSectionBlock('Conversion Rate', c.conversionRate), - ]; -} - -function buildEngagementBlocks(c) { - return [ - buildTwoColumnBlock('Issues Opened', c.issuesOpenedThisWeek, 'External PRs', c.externalPrsThisWeek), - buildTwoColumnBlock('Open Issues', c.openIssueCount, 'Avg Issue Age (days)', c.openIssueAvgAgeDays), - buildMetricSectionBlock('Avg First Response (hrs)', c.avgFirstResponseHours), - ]; -} - -function buildReferralBlocks(c) { - return [ - buildListBlock('Top Referrers', c.topReferrers, formatReferrerLine), - buildListBlock('Top Content', c.topPaths, formatPathLine), - ]; -} - function buildSlackPayload(comparison) { return { blocks: [ buildHeaderBlock(comparison.date), buildDividerBlock(), - ...buildCommunityBlocks(comparison), - buildDividerBlock(), - ...buildTrafficBlocks(comparison), + buildMetricSectionBlock('\ud83d\udc41\ufe0f Unique Visits', comparison.uniqueVisits), + buildTwoColumnBlock('\u2b50 Stars', comparison.stars, '\ud83c\udf74 Forks', comparison.forks), buildDividerBlock(), - ...buildEngagementBlocks(comparison), + buildTwoColumnBlock('\ud83d\udcdd Issues Opened', comparison.issuesOpenedThisWeek, '\ud83e\udd1d External PRs', comparison.externalPrsThisWeek), buildDividerBlock(), - ...buildReferralBlocks(comparison), + buildListBlock('\ud83d\udd17 Top Referrers', comparison.topReferrers, formatReferrerLine), + buildListBlock('\ud83d\udcc4 Top Content', comparison.topPaths, formatPathLine), buildContextBlock(), ], }; diff --git a/.github/scripts/build-slack-payload.test.js b/.github/scripts/build-slack-payload.test.js index df2c7ca..ce1b05f 100644 --- a/.github/scripts/build-slack-payload.test.js +++ b/.github/scripts/build-slack-payload.test.js @@ -22,15 +22,10 @@ const { const SAMPLE_COMPARISON = { date: '2024-01-12', uniqueVisits: { value: 200, change: '\u2191 +100%' }, - uniqueClones: { value: 20, change: '\u2191 +100%' }, - conversionRate: { value: 10, change: '\u2192 0%' }, stars: { value: 150, change: '\u2191 +5' }, forks: { value: 30, change: '\u2191 +2' }, issuesOpenedThisWeek: { value: 3, change: '\u2191 +50%' }, externalPrsThisWeek: { value: 1, change: 'N/A (first run)' }, - openIssueCount: { value: 12, change: '\u2193 -8%' }, - openIssueAvgAgeDays: { value: 15.3, change: '\u2191 +5%' }, - avgFirstResponseHours: { value: 4.2, change: '\u2193 -20%' }, topReferrers: [ { referrer: 'google.com', uniques: 10 }, { referrer: 'dev.to', uniques: 5 }, @@ -46,7 +41,7 @@ const SAMPLE_COMPARISON = { test('buildHeaderBlock includes date in correct format', () => { const block = buildHeaderBlock('2024-01-12'); assert.equal(block.type, 'header'); - assert.equal(block.text.text, 'BrightDev Traffic Report - w/e 2024-01-12'); + assert.ok(block.text.text.includes('BrightDev Traffic Report - w/e 2024-01-12')); assert.equal(block.text.type, 'plain_text'); }); @@ -133,7 +128,7 @@ test('formatPathLine formats path with bullet, backticks, and uniques', () => { test('buildSlackPayload produces correct number of blocks', () => { const payload = buildSlackPayload(SAMPLE_COMPARISON); - assert.equal(payload.blocks.length, 14); + assert.equal(payload.blocks.length, 10); }); test('buildSlackPayload starts with header and ends with context', () => { @@ -149,7 +144,7 @@ test('buildSlackPayload header shows correct date', () => { test('buildSlackPayload contains stars/forks two-column block', () => { const payload = buildSlackPayload(SAMPLE_COMPARISON); - const starsForks = payload.blocks[2]; + const starsForks = payload.blocks[3]; assert.equal(starsForks.type, 'section'); assert.ok(starsForks.fields); assert.ok(starsForks.fields[0].text.includes('Stars')); diff --git a/.github/scripts/compare-snapshots.js b/.github/scripts/compare-snapshots.js index 88646d3..9cb25a0 100644 --- a/.github/scripts/compare-snapshots.js +++ b/.github/scripts/compare-snapshots.js @@ -66,37 +66,14 @@ function buildSafeMetricChange(currentValue, previousValue) { return buildMetricChange(currentValue, previousValue); } -function buildTrafficComparison(current, baseline) { +function buildComparisonFromBaseline(current, baseline) { return { + date: current.date, uniqueVisits: buildMetricChange(current.uniqueVisits, baseline.uniqueVisits), - uniqueClones: buildMetricChange(current.uniqueClones, baseline.uniqueClones), - conversionRate: buildMetricChange(current.conversionRate, baseline.conversionRate), - }; -} - -function buildCommunityComparison(current, baseline) { - return { stars: buildDeltaChange(current.stars, baseline.stars), forks: buildDeltaChange(current.forks, baseline.forks), issuesOpenedThisWeek: buildSafeMetricChange(current.issuesOpenedThisWeek, baseline.issuesOpenedThisWeek), externalPrsThisWeek: buildSafeMetricChange(current.externalPrsThisWeek, baseline.externalPrsThisWeek), - }; -} - -function buildHealthComparison(current, baseline) { - return { - openIssueCount: buildSafeMetricChange(current.openIssueCount, baseline.openIssueCount), - openIssueAvgAgeDays: buildSafeMetricChange(current.openIssueAvgAgeDays, baseline.openIssueAvgAgeDays), - avgFirstResponseHours: buildSafeMetricChange(current.avgFirstResponseHours, baseline.avgFirstResponseHours), - }; -} - -function buildComparisonFromBaseline(current, baseline) { - return { - date: current.date, - ...buildTrafficComparison(current, baseline), - ...buildCommunityComparison(current, baseline), - ...buildHealthComparison(current, baseline), topReferrers: current.topReferrers || [], topPaths: current.topPaths || [], }; @@ -107,37 +84,14 @@ function buildFirstRunMetric(value) { return { value, change: FIRST_RUN_LABEL }; } -function buildFirstRunTrafficMetrics(current) { +function buildFirstRunComparison(current) { return { + date: current.date, uniqueVisits: buildFirstRunMetric(current.uniqueVisits), - uniqueClones: buildFirstRunMetric(current.uniqueClones), - conversionRate: buildFirstRunMetric(current.conversionRate), - }; -} - -function buildFirstRunCommunityMetrics(current) { - return { stars: buildFirstRunMetric(current.stars), forks: buildFirstRunMetric(current.forks), issuesOpenedThisWeek: buildFirstRunMetric(current.issuesOpenedThisWeek), externalPrsThisWeek: buildFirstRunMetric(current.externalPrsThisWeek), - }; -} - -function buildFirstRunHealthMetrics(current) { - return { - openIssueCount: buildFirstRunMetric(current.openIssueCount), - openIssueAvgAgeDays: buildFirstRunMetric(current.openIssueAvgAgeDays), - avgFirstResponseHours: buildFirstRunMetric(current.avgFirstResponseHours), - }; -} - -function buildFirstRunComparison(current) { - return { - date: current.date, - ...buildFirstRunTrafficMetrics(current), - ...buildFirstRunCommunityMetrics(current), - ...buildFirstRunHealthMetrics(current), topReferrers: current.topReferrers || [], topPaths: current.topPaths || [], }; diff --git a/.github/scripts/compare-snapshots.test.js b/.github/scripts/compare-snapshots.test.js index 7b0a1ec..ffea1d8 100644 --- a/.github/scripts/compare-snapshots.test.js +++ b/.github/scripts/compare-snapshots.test.js @@ -39,9 +39,6 @@ function makeFullSnapshot(overrides) { forks: 30, issuesOpenedThisWeek: 3, externalPrsThisWeek: 1, - openIssueCount: 12, - openIssueAvgAgeDays: 15.3, - avgFirstResponseHours: 4.2, topReferrers: [{ referrer: 'google.com', uniques: 10 }], topPaths: [{ path: '/README.md', uniques: 25 }], ...overrides, @@ -170,7 +167,6 @@ test('compareSnapshots returns first run comparison when only one snapshot exist assert.equal(result.date, '2024-01-05'); assert.equal(result.uniqueVisits.change, FIRST_RUN_LABEL); assert.equal(result.stars.change, FIRST_RUN_LABEL); - assert.equal(result.avgFirstResponseHours.change, FIRST_RUN_LABEL); }); test('compareSnapshots compares two full snapshots correctly', () => { @@ -199,15 +195,6 @@ test('compareSnapshots handles old-format baseline gracefully', () => { assert.equal(result.issuesOpenedThisWeek.change, FIRST_RUN_LABEL); }); -test('compareSnapshots handles null avgFirstResponseHours', () => { - const dir = makeTempDir(); - const snapshot = makeFullSnapshot({ date: '2024-01-05', avgFirstResponseHours: null }); - writeSnapshot(dir, 'weekly-2024-01-05.json', snapshot); - const result = compareSnapshots(dir); - assert.equal(result.avgFirstResponseHours.value, null); - assert.equal(result.avgFirstResponseHours.change, NO_DATA_LABEL); -}); - test('compareSnapshots passes through topReferrers and topPaths', () => { const dir = makeTempDir(); const snapshot = makeFullSnapshot({ date: '2024-01-05' }); diff --git a/.github/scripts/fetch-metrics.js b/.github/scripts/fetch-metrics.js index cd77970..57c4382 100644 --- a/.github/scripts/fetch-metrics.js +++ b/.github/scripts/fetch-metrics.js @@ -26,7 +26,7 @@ function calculateConversionRate(clones, visits) { async function fetchAllMetrics(owner, repo, token) { const since = api.weekAgoDate(); - const [views, clones, stats, referrers, paths, issues, prs, issueStats, responseHours] = + const [views, clones, stats, referrers, paths, issues, prs] = await Promise.all([ api.fetchTrafficViews(owner, repo, token), api.fetchTrafficClones(owner, repo, token), @@ -35,8 +35,6 @@ async function fetchAllMetrics(owner, repo, token) { api.fetchTopPaths(owner, repo, token), api.fetchIssuesOpenedSince(owner, repo, token, since), api.fetchExternalPrsSince(owner, repo, token, since), - api.fetchOpenIssueStats(owner, repo, token), - api.fetchAvgFirstResponseHours(owner, repo, token), ]); return { uniqueVisits: views, @@ -46,8 +44,6 @@ async function fetchAllMetrics(owner, repo, token) { topPaths: paths, issuesOpenedThisWeek: issues, externalPrsThisWeek: prs, - ...issueStats, - avgFirstResponseHours: responseHours, }; } @@ -61,9 +57,6 @@ function buildSnapshot(date, metrics) { forks: metrics.forks, issuesOpenedThisWeek: metrics.issuesOpenedThisWeek, externalPrsThisWeek: metrics.externalPrsThisWeek, - openIssueCount: metrics.openIssueCount, - openIssueAvgAgeDays: metrics.openIssueAvgAgeDays, - avgFirstResponseHours: metrics.avgFirstResponseHours, topReferrers: metrics.topReferrers, topPaths: metrics.topPaths, }; diff --git a/.github/scripts/github-api.js b/.github/scripts/github-api.js index 4bdd982..619671c 100644 --- a/.github/scripts/github-api.js +++ b/.github/scripts/github-api.js @@ -91,77 +91,6 @@ function countExternalItems(items) { return items.filter(isExternalContribution).length; } -function isRealIssue(item) { - return !item.pull_request; -} - -async function fetchOpenIssues(owner, repo, token) { - const url = repoUrl(owner, repo, '/issues?state=open&per_page=100'); - const data = await githubGet(url, token); - return data.filter(isRealIssue); -} - -function daysBetween(startDate, endDate) { - return (endDate - startDate) / (1000 * 60 * 60 * 24); -} - -function averageAgeDays(issues) { - if (issues.length === 0) return 0; - const now = new Date(); - const total = issues.reduce( - (sum, i) => sum + daysBetween(new Date(i.created_at), now), - 0 - ); - return Math.round((total / issues.length) * 10) / 10; -} - -async function fetchOpenIssueStats(owner, repo, token) { - const issues = await fetchOpenIssues(owner, repo, token); - return { - openIssueCount: issues.length, - openIssueAvgAgeDays: averageAgeDays(issues), - }; -} - -async function fetchFirstCommentDate(owner, repo, token, issueNumber) { - const url = repoUrl(owner, repo, `/issues/${issueNumber}/comments?per_page=1`); - const comments = await githubGet(url, token); - if (comments.length === 0) return null; - return new Date(comments[0].created_at); -} - -function hoursBetween(startDate, endDate) { - return (endDate - startDate) / (1000 * 60 * 60); -} - -function averageOfValues(values) { - if (values.length === 0) return null; - const sum = values.reduce((a, b) => a + b, 0); - return Math.round((sum / values.length) * 10) / 10; -} - -async function fetchResponseHoursForIssue(owner, repo, token, issue) { - const firstComment = await fetchFirstCommentDate(owner, repo, token, issue.number); - if (!firstComment) return null; - return hoursBetween(new Date(issue.created_at), firstComment); -} - -async function fetchRespondedIssuesSince(owner, repo, token, since) { - const query = `repo:${owner}/${repo} type:issue created:>=${since} comments:>0`; - const data = await githubGet(searchUrl(query), token); - return data.items || []; -} - -async function fetchAvgFirstResponseHours(owner, repo, token) { - const since = weekAgoDate(); - const issues = await fetchRespondedIssuesSince(owner, repo, token, since); - if (issues.length === 0) return null; - const hours = await Promise.all( - issues.map((i) => fetchResponseHoursForIssue(owner, repo, token, i)) - ); - return averageOfValues(hours.filter((h) => h !== null)); -} - module.exports = { fetchTrafficViews, fetchTrafficClones, @@ -170,15 +99,8 @@ module.exports = { fetchRepoStats, fetchIssuesOpenedSince, fetchExternalPrsSince, - fetchOpenIssueStats, - fetchAvgFirstResponseHours, isExternalContribution, countExternalItems, - isRealIssue, - averageAgeDays, - averageOfValues, - daysBetween, - hoursBetween, weekAgoDate, githubGet, pickReferrerFields, diff --git a/.github/scripts/github-api.test.js b/.github/scripts/github-api.test.js index d837cd6..b8008b7 100644 --- a/.github/scripts/github-api.test.js +++ b/.github/scripts/github-api.test.js @@ -6,11 +6,6 @@ const assert = require('node:assert/strict'); const { isExternalContribution, countExternalItems, - isRealIssue, - averageAgeDays, - averageOfValues, - daysBetween, - hoursBetween, pickReferrerFields, pickPathFields, } = require('./github-api'); @@ -49,40 +44,6 @@ test('countExternalItems returns 0 for empty array', () => { assert.equal(countExternalItems([]), 0); }); -test('isRealIssue returns true when no pull_request field', () => { - assert.equal(isRealIssue({ id: 1 }), true); -}); - -test('isRealIssue returns false when pull_request field is present', () => { - assert.equal(isRealIssue({ id: 1, pull_request: { url: '...' } }), false); -}); - -test('daysBetween returns correct number of days', () => { - const start = new Date('2024-01-01T00:00:00Z'); - const end = new Date('2024-01-08T00:00:00Z'); - assert.equal(daysBetween(start, end), 7); -}); - -test('hoursBetween returns correct number of hours', () => { - const start = new Date('2024-01-01T00:00:00Z'); - const end = new Date('2024-01-01T06:00:00Z'); - assert.equal(hoursBetween(start, end), 6); -}); - -test('averageAgeDays returns 0 for empty array', () => { - assert.equal(averageAgeDays([]), 0); -}); - -test('averageOfValues returns null for empty array', () => { - assert.equal(averageOfValues([]), null); -}); - -test('averageOfValues returns average rounded to 1 decimal', () => { - assert.equal(averageOfValues([1, 2, 3]), 2); - assert.equal(averageOfValues([1.5, 2.5]), 2); - assert.equal(averageOfValues([1, 2]), 1.5); -}); - test('pickReferrerFields extracts referrer and uniques', () => { const entry = { referrer: 'google.com', uniques: 10, count: 20, extra: 'ignored' }; assert.deepEqual(pickReferrerFields(entry), { referrer: 'google.com', uniques: 10 }); From b2841ddd380872b4c10f05dd6e737e2b0250bd29 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 20 Mar 2026 12:31:04 +0000 Subject: [PATCH 4/6] chore: add weekly traffic snapshot 2026-03-20 --- metrics/snapshots/weekly-2026-03-20.json | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 metrics/snapshots/weekly-2026-03-20.json diff --git a/metrics/snapshots/weekly-2026-03-20.json b/metrics/snapshots/weekly-2026-03-20.json new file mode 100644 index 0000000..28aa7d3 --- /dev/null +++ b/metrics/snapshots/weekly-2026-03-20.json @@ -0,0 +1,50 @@ +{ + "date": "2026-03-20", + "uniqueVisits": 39, + "uniqueClones": 82, + "conversionRate": 210.26, + "stars": 12, + "forks": 0, + "issuesOpenedThisWeek": 0, + "externalPrsThisWeek": 0, + "topReferrers": [ + { + "referrer": "github.com", + "uniques": 6 + }, + { + "referrer": "Google", + "uniques": 5 + }, + { + "referrer": "statics.teams.cdn.office.net", + "uniques": 2 + }, + { + "referrer": "mail.zoho.com", + "uniques": 1 + } + ], + "topPaths": [ + { + "path": "/BrightDevelopers/BrightDev", + "uniques": 19 + }, + { + "path": "/BrightDevelopers/BrightDev/pulls", + "uniques": 3 + }, + { + "path": "/BrightDevelopers/BrightDev/tree/main/examples", + "uniques": 10 + }, + { + "path": "/BrightDevelopers/BrightDev/pull/10", + "uniques": 2 + }, + { + "path": "/BrightDevelopers/BrightDev/blob/main/examples/hello-brightsign", + "uniques": 9 + } + ] +} \ No newline at end of file From ffc98da9d016d8463adca7a2c4caa0bc4bc7dab7 Mon Sep 17 00:00:00 2001 From: Evan Morgan Date: Fri, 20 Mar 2026 12:47:23 +0000 Subject: [PATCH 5/6] refactor(PE-1209): align monthly report with weekly improvements --- .github/scripts/aggregate-monthly.js | 567 +++++++++------- .github/scripts/aggregate-monthly.test.js | 676 +++++++++++++------ .github/workflows/monthly-traffic-report.yml | 2 +- 3 files changed, 796 insertions(+), 449 deletions(-) diff --git a/.github/scripts/aggregate-monthly.js b/.github/scripts/aggregate-monthly.js index dc641f7..f6575cf 100644 --- a/.github/scripts/aggregate-monthly.js +++ b/.github/scripts/aggregate-monthly.js @@ -1,234 +1,333 @@ -#!/usr/bin/env node - -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const { - calculateRawPercentageChange, - formatPercentageChange, - FIRST_RUN_LABEL, -} = require('./compare-snapshots'); - -const SNAPSHOTS_DIR = 'metrics/snapshots'; -const LIMITED_DATA_THRESHOLD = 2; - -function readAllWeeklySnapshots(dir) { - return fs - .readdirSync(dir) - .filter((f) => f.startsWith('weekly-') && f.endsWith('.json')) - .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))); -} - -function extractYearMonth(dateStr) { - return dateStr.slice(0, 7); -} - -function groupSnapshotsByMonth(snapshots) { - return snapshots.reduce((acc, snap) => { - const month = extractYearMonth(snap.date); - if (!acc[month]) acc[month] = []; - acc[month].push(snap); - return acc; - }, {}); -} - -function calculateConversionRate(uniqueClones, uniqueVisits) { - if (uniqueVisits === 0) return 0; - return Math.round((uniqueClones / uniqueVisits) * 100 * 100) / 100; -} - -function aggregateMonthlyTotals(snapshots) { - const uniqueVisits = snapshots.reduce((sum, s) => sum + s.uniqueVisits, 0); - const uniqueClones = snapshots.reduce((sum, s) => sum + s.uniqueClones, 0); - return { - uniqueVisits, - uniqueClones, - conversionRate: calculateConversionRate(uniqueClones, uniqueVisits), - snapshotCount: snapshots.length, - }; -} - -function getSortedMonths(grouped) { - return Object.keys(grouped).sort().reverse(); -} - -function formatMonthLabel(yearMonth) { - const [year, month] = yearMonth.split('-'); - const date = new Date(Number(year), Number(month) - 1, 1); - return date.toLocaleString('en-GB', { month: 'long', year: 'numeric' }); -} - -function buildMetricChange(current, previous) { - const change = calculateRawPercentageChange(current, previous); - return { value: current, change: formatPercentageChange(change) }; -} - -function buildMonthlyComparison(currentTotals, previousTotals) { - return { - uniqueVisits: buildMetricChange(currentTotals.uniqueVisits, previousTotals.uniqueVisits), - uniqueClones: buildMetricChange(currentTotals.uniqueClones, previousTotals.uniqueClones), - conversionRate: buildMetricChange(currentTotals.conversionRate, previousTotals.conversionRate), - }; -} - -function buildFirstRunMonthlyComparison(currentTotals) { - return { - uniqueVisits: { value: currentTotals.uniqueVisits, change: FIRST_RUN_LABEL }, - uniqueClones: { value: currentTotals.uniqueClones, change: FIRST_RUN_LABEL }, - conversionRate: { value: currentTotals.conversionRate, change: FIRST_RUN_LABEL }, - }; -} - -function buildFirstRunResult(monthLabel, currentTotals) { - return { - monthLabel, - comparison: buildFirstRunMonthlyComparison(currentTotals), - currentSnapshotCount: currentTotals.snapshotCount, - previousSnapshotCount: null, - }; -} - -function buildComparisonResult(monthLabel, currentTotals, previousSnapshots) { - const previousTotals = aggregateMonthlyTotals(previousSnapshots); - return { - monthLabel, - comparison: buildMonthlyComparison(currentTotals, previousTotals), - currentSnapshotCount: currentTotals.snapshotCount, - previousSnapshotCount: previousTotals.snapshotCount, - }; -} - -function aggregateMonthly(snapshotsDir) { - const snapshots = readAllWeeklySnapshots(snapshotsDir); - const grouped = groupSnapshotsByMonth(snapshots); - const months = getSortedMonths(grouped); - if (months.length === 0) throw new Error('No snapshots found'); - const currentMonth = months[0]; - const currentTotals = aggregateMonthlyTotals(grouped[currentMonth]); - const monthLabel = formatMonthLabel(currentMonth); - if (months.length === 1) return buildFirstRunResult(monthLabel, currentTotals); - return buildComparisonResult(monthLabel, currentTotals, grouped[months[1]]); -} - -function buildLimitedDataWarning(count) { - return `(limited data - ${count} snapshots this month)`; -} - -function collectLimitedDataWarnings(currentSnapshotCount, previousSnapshotCount) { - const warnings = []; - if (currentSnapshotCount < LIMITED_DATA_THRESHOLD) { - warnings.push(buildLimitedDataWarning(currentSnapshotCount)); - } - if (previousSnapshotCount !== null && previousSnapshotCount < LIMITED_DATA_THRESHOLD) { - warnings.push(buildLimitedDataWarning(previousSnapshotCount)); - } - return warnings; -} - -function buildHeaderBlock(monthLabel) { - return { - type: 'header', - text: { type: 'plain_text', text: `BrightDev Monthly Traffic Report - ${monthLabel}` }, - }; -} - -function buildDividerBlock() { - return { type: 'divider' }; -} - -function buildMetricSectionBlock(label, metric) { - return { - type: 'section', - text: { type: 'mrkdwn', text: `*${label}*\n${metric.value} ${metric.change}` }, - }; -} - -function buildContextBlock() { - return { - type: 'context', - elements: [{ type: 'mrkdwn', text: 'Data from GitHub Traffic API - BrightDev' }], - }; -} - -function buildWarningBlock(warnings) { - return { - type: 'context', - elements: [{ type: 'mrkdwn', text: warnings.join('\n') }], - }; -} - -function buildSlackPayload(monthLabel, comparison, warnings) { - const blocks = [ - buildHeaderBlock(monthLabel), - buildDividerBlock(), - buildMetricSectionBlock('Unique Visits', comparison.uniqueVisits), - buildMetricSectionBlock('Unique Clones', comparison.uniqueClones), - buildMetricSectionBlock('Conversion Rate', comparison.conversionRate), - buildContextBlock(), - ]; - if (warnings.length > 0) blocks.push(buildWarningBlock(warnings)); - return { blocks }; -} - -function isTestMode() { - return process.env.TEST_MODE === 'true'; -} - -function printPayloadToLog(payload) { - console.log('TEST MODE - Slack payload:'); - console.log(JSON.stringify(payload, null, 2)); -} - -async function postPayloadToSlack(webhookUrl, payload) { - const res = await fetch(webhookUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!res.ok) { - throw new Error(`Slack webhook returned ${res.status}: ${await res.text()}`); - } -} - -async function deliverPayload(payload) { - if (isTestMode()) { - printPayloadToLog(payload); - return; - } - const webhookUrl = process.env.SLACK_WEBHOOK_URL; - if (!webhookUrl) throw new Error('SLACK_WEBHOOK_URL is not set'); - await postPayloadToSlack(webhookUrl, payload); -} - -async function run() { - const result = aggregateMonthly(SNAPSHOTS_DIR); - const warnings = collectLimitedDataWarnings(result.currentSnapshotCount, result.previousSnapshotCount); - const payload = buildSlackPayload(result.monthLabel, result.comparison, warnings); - await deliverPayload(payload); -} - -module.exports = { - aggregateMonthly, - aggregateMonthlyTotals, - buildSlackPayload, - buildHeaderBlock, - buildDividerBlock, - buildMetricSectionBlock, - buildContextBlock, - buildWarningBlock, - collectLimitedDataWarnings, - buildLimitedDataWarning, - formatMonthLabel, - buildMonthlyComparison, - buildFirstRunMonthlyComparison, - isTestMode, - deliverPayload, -}; - -if (require.main === module) { - run().catch((err) => { - console.error(err.message); - process.exit(1); - }); -} +#!/usr/bin/env node + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { + calculateRawPercentageChange, + formatPercentageChange, + formatDelta, + FIRST_RUN_LABEL, + NO_DATA_LABEL, +} = require('./compare-snapshots'); + +const SNAPSHOTS_DIR = 'metrics/snapshots'; +const LIMITED_DATA_THRESHOLD = 2; + +function readAllWeeklySnapshots(dir) { + return fs + .readdirSync(dir) + .filter((f) => f.startsWith('weekly-') && f.endsWith('.json')) + .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))); +} + +function extractYearMonth(dateStr) { + return dateStr.slice(0, 7); +} + +function groupSnapshotsByMonth(snapshots) { + return snapshots.reduce((acc, snap) => { + const month = extractYearMonth(snap.date); + if (!acc[month]) acc[month] = []; + acc[month].push(snap); + return acc; + }, {}); +} + +function sumField(snapshots, field) { + return snapshots.reduce((sum, s) => sum + (s[field] || 0), 0); +} + +function latestSnapshotByDate(snapshots) { + return [...snapshots].sort((a, b) => a.date.localeCompare(b.date)).pop(); +} + +function collectListEntries(snapshots, field) { + return snapshots.flatMap((s) => s[field] || []); +} + +function mergeListByKey(entries, keyProp, valueProp) { + const merged = {}; + for (const entry of entries) { + merged[entry[keyProp]] = (merged[entry[keyProp]] || 0) + entry[valueProp]; + } + return Object.entries(merged) + .map(([key, value]) => ({ [keyProp]: key, [valueProp]: value })) + .sort((a, b) => b[valueProp] - a[valueProp]); +} + +function mergeReferrers(snapshots) { + return mergeListByKey(collectListEntries(snapshots, 'topReferrers'), 'referrer', 'uniques'); +} + +function mergePaths(snapshots) { + return mergeListByKey(collectListEntries(snapshots, 'topPaths'), 'path', 'uniques'); +} + +function aggregateMonthlyTotals(snapshots) { + const latest = latestSnapshotByDate(snapshots); + return { + uniqueVisits: sumField(snapshots, 'uniqueVisits'), + stars: latest.stars ?? null, + forks: latest.forks ?? null, + issuesOpened: sumField(snapshots, 'issuesOpenedThisWeek'), + externalPrs: sumField(snapshots, 'externalPrsThisWeek'), + topReferrers: mergeReferrers(snapshots), + topPaths: mergePaths(snapshots), + snapshotCount: snapshots.length, + }; +} + +function getSortedMonths(grouped) { + return Object.keys(grouped).sort().reverse(); +} + +function formatMonthLabel(yearMonth) { + const [year, month] = yearMonth.split('-'); + const date = new Date(Number(year), Number(month) - 1, 1); + return date.toLocaleString('en-GB', { month: 'long', year: 'numeric' }); +} + +function buildMetricChange(current, previous) { + const change = calculateRawPercentageChange(current, previous); + return { value: current, change: formatPercentageChange(change) }; +} + +function buildDeltaMetricChange(current, previous) { + if (current == null) return { value: null, change: NO_DATA_LABEL }; + if (previous == null) return { value: current, change: FIRST_RUN_LABEL }; + return { value: current, change: formatDelta(current - previous) }; +} + +function buildSafeMetricChange(current, previous) { + if (current == null) return { value: null, change: NO_DATA_LABEL }; + if (previous == null) return { value: current, change: FIRST_RUN_LABEL }; + return buildMetricChange(current, previous); +} + +function buildMonthlyComparison(currentTotals, previousTotals) { + return { + uniqueVisits: buildMetricChange(currentTotals.uniqueVisits, previousTotals.uniqueVisits), + stars: buildDeltaMetricChange(currentTotals.stars, previousTotals.stars), + forks: buildDeltaMetricChange(currentTotals.forks, previousTotals.forks), + issuesOpened: buildSafeMetricChange(currentTotals.issuesOpened, previousTotals.issuesOpened), + externalPrs: buildSafeMetricChange(currentTotals.externalPrs, previousTotals.externalPrs), + topReferrers: currentTotals.topReferrers, + topPaths: currentTotals.topPaths, + }; +} + +function buildFirstRunMonthlyMetric(value) { + if (value == null) return { value: null, change: NO_DATA_LABEL }; + return { value, change: FIRST_RUN_LABEL }; +} + +function buildFirstRunMonthlyComparison(currentTotals) { + return { + uniqueVisits: { value: currentTotals.uniqueVisits, change: FIRST_RUN_LABEL }, + stars: buildFirstRunMonthlyMetric(currentTotals.stars), + forks: buildFirstRunMonthlyMetric(currentTotals.forks), + issuesOpened: buildFirstRunMonthlyMetric(currentTotals.issuesOpened), + externalPrs: buildFirstRunMonthlyMetric(currentTotals.externalPrs), + topReferrers: currentTotals.topReferrers || [], + topPaths: currentTotals.topPaths || [], + }; +} + +function buildFirstRunResult(monthLabel, currentTotals) { + return { + monthLabel, + comparison: buildFirstRunMonthlyComparison(currentTotals), + currentSnapshotCount: currentTotals.snapshotCount, + previousSnapshotCount: null, + }; +} + +function buildComparisonResult(monthLabel, currentTotals, previousSnapshots) { + const previousTotals = aggregateMonthlyTotals(previousSnapshots); + return { + monthLabel, + comparison: buildMonthlyComparison(currentTotals, previousTotals), + currentSnapshotCount: currentTotals.snapshotCount, + previousSnapshotCount: previousTotals.snapshotCount, + }; +} + +function aggregateMonthly(snapshotsDir) { + const snapshots = readAllWeeklySnapshots(snapshotsDir); + const grouped = groupSnapshotsByMonth(snapshots); + const months = getSortedMonths(grouped); + if (months.length === 0) throw new Error('No snapshots found'); + const currentMonth = months[0]; + const currentTotals = aggregateMonthlyTotals(grouped[currentMonth]); + const monthLabel = formatMonthLabel(currentMonth); + if (months.length === 1) return buildFirstRunResult(monthLabel, currentTotals); + return buildComparisonResult(monthLabel, currentTotals, grouped[months[1]]); +} + +function buildLimitedDataWarning(count) { + return `(limited data - ${count} snapshots this month)`; +} + +function collectLimitedDataWarnings(currentSnapshotCount, previousSnapshotCount) { + const warnings = []; + if (currentSnapshotCount < LIMITED_DATA_THRESHOLD) { + warnings.push(buildLimitedDataWarning(currentSnapshotCount)); + } + if (previousSnapshotCount !== null && previousSnapshotCount < LIMITED_DATA_THRESHOLD) { + warnings.push(buildLimitedDataWarning(previousSnapshotCount)); + } + return warnings; +} + +function buildHeaderBlock(monthLabel) { + return { + type: 'header', + text: { type: 'plain_text', text: `\ud83d\udcca BrightDev Monthly Report - ${monthLabel}` }, + }; +} + +function buildDividerBlock() { + return { type: 'divider' }; +} + +function formatMetricText(label, metric) { + if (metric.value == null) return `*${label}*\n${metric.change}`; + return `*${label}*\n${metric.value} ${metric.change}`; +} + +function buildMetricSectionBlock(label, metric) { + return { + type: 'section', + text: { type: 'mrkdwn', text: formatMetricText(label, metric) }, + }; +} + +function buildTwoColumnBlock(leftLabel, leftMetric, rightLabel, rightMetric) { + return { + type: 'section', + fields: [ + { type: 'mrkdwn', text: formatMetricText(leftLabel, leftMetric) }, + { type: 'mrkdwn', text: formatMetricText(rightLabel, rightMetric) }, + ], + }; +} + +function formatReferrerLine(entry) { + return `\u2022 ${entry.referrer} (${entry.uniques} unique)`; +} + +function formatPathLine(entry) { + return `\u2022 \`${entry.path}\` (${entry.uniques} unique)`; +} + +function buildListBlock(label, items, formatter) { + const lines = items.map(formatter).join('\n'); + const body = items.length > 0 ? lines : '_No data_'; + return { + type: 'section', + text: { type: 'mrkdwn', text: `*${label}*\n${body}` }, + }; +} + +function buildContextBlock() { + return { + type: 'context', + elements: [{ type: 'mrkdwn', text: 'Data from GitHub API - BrightDev' }], + }; +} + +function buildWarningBlock(warnings) { + return { + type: 'context', + elements: [{ type: 'mrkdwn', text: warnings.join('\n') }], + }; +} + +function buildSlackPayload(monthLabel, comparison, warnings) { + const blocks = [ + buildHeaderBlock(monthLabel), + buildDividerBlock(), + buildMetricSectionBlock('\ud83d\udc41\ufe0f Unique Visits', comparison.uniqueVisits), + buildTwoColumnBlock('\u2b50 Stars', comparison.stars, '\ud83c\udf74 Forks', comparison.forks), + buildDividerBlock(), + buildTwoColumnBlock('\ud83d\udcdd Issues Opened', comparison.issuesOpened, '\ud83e\udd1d External PRs', comparison.externalPrs), + buildDividerBlock(), + buildListBlock('\ud83d\udd17 Top Referrers', comparison.topReferrers, formatReferrerLine), + buildListBlock('\ud83d\udcc4 Top Content', comparison.topPaths, formatPathLine), + buildContextBlock(), + ]; + if (warnings.length > 0) blocks.push(buildWarningBlock(warnings)); + return { blocks }; +} + +function isTestMode() { + return process.env.TEST_MODE === 'true'; +} + +function printPayloadToLog(payload) { + console.log('TEST MODE - Slack payload:'); + console.log(JSON.stringify(payload, null, 2)); +} + +async function postPayloadToSlack(webhookUrl, payload) { + const res = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + throw new Error(`Slack webhook returned ${res.status}: ${await res.text()}`); + } +} + +async function deliverPayload(payload) { + if (isTestMode()) { + printPayloadToLog(payload); + return; + } + const webhookUrl = process.env.SLACK_WEBHOOK_URL; + if (!webhookUrl) throw new Error('SLACK_WEBHOOK_URL is not set'); + await postPayloadToSlack(webhookUrl, payload); +} + +async function run() { + const result = aggregateMonthly(SNAPSHOTS_DIR); + const warnings = collectLimitedDataWarnings(result.currentSnapshotCount, result.previousSnapshotCount); + const payload = buildSlackPayload(result.monthLabel, result.comparison, warnings); + await deliverPayload(payload); +} + +module.exports = { + aggregateMonthly, + aggregateMonthlyTotals, + buildSlackPayload, + buildHeaderBlock, + buildDividerBlock, + buildMetricSectionBlock, + buildTwoColumnBlock, + buildListBlock, + buildContextBlock, + buildWarningBlock, + collectLimitedDataWarnings, + buildLimitedDataWarning, + formatMonthLabel, + formatMetricText, + formatReferrerLine, + formatPathLine, + buildMonthlyComparison, + buildFirstRunMonthlyComparison, + buildFirstRunMonthlyMetric, + buildDeltaMetricChange, + buildSafeMetricChange, + isTestMode, + deliverPayload, +}; + +if (require.main === module) { + run().catch((err) => { + console.error(err.message); + process.exit(1); + }); +} diff --git a/.github/scripts/aggregate-monthly.test.js b/.github/scripts/aggregate-monthly.test.js index abaf7a0..e619050 100644 --- a/.github/scripts/aggregate-monthly.test.js +++ b/.github/scripts/aggregate-monthly.test.js @@ -1,214 +1,462 @@ -'use strict'; - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -const { - aggregateMonthly, - aggregateMonthlyTotals, - buildSlackPayload, - buildHeaderBlock, - buildMetricSectionBlock, - buildContextBlock, - buildWarningBlock, - collectLimitedDataWarnings, - buildLimitedDataWarning, - formatMonthLabel, - buildFirstRunMonthlyComparison, - isTestMode, - deliverPayload, -} = require('./aggregate-monthly'); - -function makeTempDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'monthly-snapshots-')); -} - -function writeSnapshot(dir, filename, data) { - fs.writeFileSync(path.join(dir, filename), JSON.stringify(data)); -} - -const SNAPSHOT_JAN_WEEK1 = { date: '2024-01-05', uniqueVisits: 100, uniqueClones: 10, conversionRate: 10 }; -const SNAPSHOT_JAN_WEEK2 = { date: '2024-01-12', uniqueVisits: 200, uniqueClones: 20, conversionRate: 10 }; -const SNAPSHOT_DEC_WEEK1 = { date: '2023-12-29', uniqueVisits: 80, uniqueClones: 8, conversionRate: 10 }; - -test('aggregateMonthly throws when no snapshots exist', () => { - const dir = makeTempDir(); - assert.throws(() => aggregateMonthly(dir), /No snapshots found/); -}); - -test('aggregateMonthly returns first run result for single month', () => { - const dir = makeTempDir(); - writeSnapshot(dir, 'weekly-2024-01-05.json', SNAPSHOT_JAN_WEEK1); - const result = aggregateMonthly(dir); - assert.equal(result.monthLabel, 'January 2024'); - assert.equal(result.comparison.uniqueVisits.change, 'N/A (first run)'); - assert.equal(result.currentSnapshotCount, 1); - assert.equal(result.previousSnapshotCount, null); -}); - -test('aggregateMonthly sums current month and compares with previous month', () => { - const dir = makeTempDir(); - writeSnapshot(dir, 'weekly-2024-01-05.json', SNAPSHOT_JAN_WEEK1); - writeSnapshot(dir, 'weekly-2024-01-12.json', SNAPSHOT_JAN_WEEK2); - writeSnapshot(dir, 'weekly-2023-12-29.json', SNAPSHOT_DEC_WEEK1); - const result = aggregateMonthly(dir); - assert.equal(result.monthLabel, 'January 2024'); - assert.equal(result.comparison.uniqueVisits.value, 300); - assert.equal(result.comparison.uniqueClones.value, 30); - assert.equal(result.currentSnapshotCount, 2); - assert.equal(result.previousSnapshotCount, 1); -}); - -test('aggregateMonthlyTotals sums visits and clones and recalculates conversion rate', () => { - const snapshots = [ - { uniqueVisits: 100, uniqueClones: 10, conversionRate: 10 }, - { uniqueVisits: 200, uniqueClones: 30, conversionRate: 15 }, - ]; - const totals = aggregateMonthlyTotals(snapshots); - assert.equal(totals.uniqueVisits, 300); - assert.equal(totals.uniqueClones, 40); - assert.equal(totals.conversionRate, 13.33); - assert.equal(totals.snapshotCount, 2); -}); - -test('aggregateMonthlyTotals returns 0 conversion rate when visits are 0', () => { - const snapshots = [{ uniqueVisits: 0, uniqueClones: 0, conversionRate: 0 }]; - const totals = aggregateMonthlyTotals(snapshots); - assert.equal(totals.conversionRate, 0); -}); - -test('formatMonthLabel returns full month name and year', () => { - assert.equal(formatMonthLabel('2024-01'), 'January 2024'); - assert.equal(formatMonthLabel('2023-12'), 'December 2023'); -}); - -test('buildHeaderBlock includes month label in correct format', () => { - const block = buildHeaderBlock('January 2024'); - assert.equal(block.type, 'header'); - assert.equal(block.text.text, 'BrightDev Monthly Traffic Report - January 2024'); - assert.equal(block.text.type, 'plain_text'); -}); - -test('buildMetricSectionBlock renders label, value and change', () => { - const block = buildMetricSectionBlock('Unique Visits', { value: 300, change: '\u2191 +50%' }); - assert.equal(block.type, 'section'); - assert.ok(block.text.text.includes('Unique Visits')); - assert.ok(block.text.text.includes('300')); - assert.ok(block.text.text.includes('\u2191 +50%')); -}); - -test('buildContextBlock contains correct attribution', () => { - const block = buildContextBlock(); - assert.equal(block.type, 'context'); - assert.ok(block.elements[0].text.includes('Data from GitHub Traffic API - BrightDev')); -}); - -test('buildLimitedDataWarning returns correct string', () => { - assert.equal(buildLimitedDataWarning(1), '(limited data - 1 snapshots this month)'); - assert.equal(buildLimitedDataWarning(0), '(limited data - 0 snapshots this month)'); -}); - -test('collectLimitedDataWarnings returns warning when current month has fewer than 2 snapshots', () => { - const warnings = collectLimitedDataWarnings(1, 3); - assert.equal(warnings.length, 1); - assert.ok(warnings[0].includes('1 snapshots this month')); -}); - -test('collectLimitedDataWarnings returns warning when previous month has fewer than 2 snapshots', () => { - const warnings = collectLimitedDataWarnings(3, 1); - assert.equal(warnings.length, 1); - assert.ok(warnings[0].includes('1 snapshots this month')); -}); - -test('collectLimitedDataWarnings returns two warnings when both months have limited data', () => { - const warnings = collectLimitedDataWarnings(1, 1); - assert.equal(warnings.length, 2); -}); - -test('collectLimitedDataWarnings returns no warnings when both months have 2+ snapshots', () => { - const warnings = collectLimitedDataWarnings(3, 2); - assert.equal(warnings.length, 0); -}); - -test('collectLimitedDataWarnings ignores previousSnapshotCount when null', () => { - const warnings = collectLimitedDataWarnings(3, null); - assert.equal(warnings.length, 0); -}); - -test('buildWarningBlock renders warnings as mrkdwn context block', () => { - const block = buildWarningBlock(['warning one', 'warning two']); - assert.equal(block.type, 'context'); - assert.ok(block.elements[0].text.includes('warning one')); - assert.ok(block.elements[0].text.includes('warning two')); -}); - -test('buildSlackPayload produces six blocks without warnings', () => { - const comparison = buildFirstRunMonthlyComparison({ uniqueVisits: 100, uniqueClones: 10, conversionRate: 10 }); - const payload = buildSlackPayload('January 2024', comparison, []); - assert.equal(payload.blocks.length, 6); - assert.equal(payload.blocks[0].type, 'header'); - assert.equal(payload.blocks[1].type, 'divider'); - assert.equal(payload.blocks[2].type, 'section'); - assert.equal(payload.blocks[3].type, 'section'); - assert.equal(payload.blocks[4].type, 'section'); - assert.equal(payload.blocks[5].type, 'context'); -}); - -test('buildSlackPayload appends warning block when warnings present', () => { - const comparison = buildFirstRunMonthlyComparison({ uniqueVisits: 100, uniqueClones: 10, conversionRate: 10 }); - const payload = buildSlackPayload('January 2024', comparison, ['(limited data - 1 snapshots this month)']); - assert.equal(payload.blocks.length, 7); - assert.equal(payload.blocks[6].type, 'context'); - assert.ok(payload.blocks[6].elements[0].text.includes('limited data')); -}); - -test('buildSlackPayload sections contain correct labels', () => { - const comparison = buildFirstRunMonthlyComparison({ uniqueVisits: 100, uniqueClones: 10, conversionRate: 10 }); - const payload = buildSlackPayload('January 2024', comparison, []); - assert.ok(payload.blocks[2].text.text.includes('Unique Visits')); - assert.ok(payload.blocks[3].text.text.includes('Unique Clones')); - assert.ok(payload.blocks[4].text.text.includes('Conversion Rate')); -}); - -test('isTestMode returns true when TEST_MODE env is true', () => { - const original = process.env.TEST_MODE; - process.env.TEST_MODE = 'true'; - assert.equal(isTestMode(), true); - process.env.TEST_MODE = original; -}); - -test('isTestMode returns false when TEST_MODE env is false', () => { - const original = process.env.TEST_MODE; - process.env.TEST_MODE = 'false'; - assert.equal(isTestMode(), false); - process.env.TEST_MODE = original; -}); - -test('deliverPayload prints to log in test mode without calling fetch', async () => { - const original = process.env.TEST_MODE; - process.env.TEST_MODE = 'true'; - let fetchCalled = false; - const originalFetch = global.fetch; - global.fetch = async () => { fetchCalled = true; return { ok: true }; }; - const comparison = buildFirstRunMonthlyComparison({ uniqueVisits: 100, uniqueClones: 10, conversionRate: 10 }); - const payload = buildSlackPayload('January 2024', comparison, []); - await assert.doesNotReject(() => deliverPayload(payload)); - assert.equal(fetchCalled, false); - process.env.TEST_MODE = original; - global.fetch = originalFetch; -}); - -test('deliverPayload throws when SLACK_WEBHOOK_URL is not set in non-test mode', async () => { - const originalMode = process.env.TEST_MODE; - const originalUrl = process.env.SLACK_WEBHOOK_URL; - process.env.TEST_MODE = 'false'; - delete process.env.SLACK_WEBHOOK_URL; - const comparison = buildFirstRunMonthlyComparison({ uniqueVisits: 100, uniqueClones: 10, conversionRate: 10 }); - const payload = buildSlackPayload('January 2024', comparison, []); - await assert.rejects(() => deliverPayload(payload), /SLACK_WEBHOOK_URL is not set/); - process.env.TEST_MODE = originalMode; - process.env.SLACK_WEBHOOK_URL = originalUrl; -}); +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + aggregateMonthly, + aggregateMonthlyTotals, + buildSlackPayload, + buildHeaderBlock, + buildMetricSectionBlock, + buildTwoColumnBlock, + buildListBlock, + buildContextBlock, + buildWarningBlock, + collectLimitedDataWarnings, + buildLimitedDataWarning, + formatMonthLabel, + formatMetricText, + formatReferrerLine, + formatPathLine, + buildFirstRunMonthlyComparison, + buildFirstRunMonthlyMetric, + buildDeltaMetricChange, + buildSafeMetricChange, + isTestMode, + deliverPayload, +} = require('./aggregate-monthly'); + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'monthly-snapshots-')); +} + +function writeSnapshot(dir, filename, data) { + fs.writeFileSync(path.join(dir, filename), JSON.stringify(data)); +} + +const SNAPSHOT_JAN_WEEK1 = { + date: '2024-01-05', + uniqueVisits: 100, + uniqueClones: 10, + stars: 10, + forks: 2, + issuesOpenedThisWeek: 3, + externalPrsThisWeek: 1, + topReferrers: [{ referrer: 'google.com', uniques: 50 }], + topPaths: [{ path: '/repo', uniques: 80 }], +}; + +const SNAPSHOT_JAN_WEEK2 = { + date: '2024-01-12', + uniqueVisits: 200, + uniqueClones: 20, + stars: 12, + forks: 3, + issuesOpenedThisWeek: 5, + externalPrsThisWeek: 2, + topReferrers: [ + { referrer: 'google.com', uniques: 30 }, + { referrer: 'github.com', uniques: 20 }, + ], + topPaths: [ + { path: '/repo', uniques: 60 }, + { path: '/repo/issues', uniques: 40 }, + ], +}; + +const SNAPSHOT_DEC_WEEK1 = { + date: '2023-12-29', + uniqueVisits: 80, + uniqueClones: 8, + stars: 8, + forks: 2, + issuesOpenedThisWeek: 2, + externalPrsThisWeek: 0, + topReferrers: [{ referrer: 'bing.com', uniques: 10 }], + topPaths: [{ path: '/repo', uniques: 50 }], +}; + +// --- aggregateMonthly --- + +test('aggregateMonthly throws when no snapshots exist', () => { + const dir = makeTempDir(); + assert.throws(() => aggregateMonthly(dir), /No snapshots found/); +}); + +test('aggregateMonthly returns first run result for single month', () => { + const dir = makeTempDir(); + writeSnapshot(dir, 'weekly-2024-01-05.json', SNAPSHOT_JAN_WEEK1); + const result = aggregateMonthly(dir); + assert.equal(result.monthLabel, 'January 2024'); + assert.equal(result.comparison.uniqueVisits.change, 'N/A (first run)'); + assert.equal(result.comparison.stars.change, 'N/A (first run)'); + assert.equal(result.currentSnapshotCount, 1); + assert.equal(result.previousSnapshotCount, null); +}); + +test('aggregateMonthly sums current month and compares with previous month', () => { + const dir = makeTempDir(); + writeSnapshot(dir, 'weekly-2024-01-05.json', SNAPSHOT_JAN_WEEK1); + writeSnapshot(dir, 'weekly-2024-01-12.json', SNAPSHOT_JAN_WEEK2); + writeSnapshot(dir, 'weekly-2023-12-29.json', SNAPSHOT_DEC_WEEK1); + const result = aggregateMonthly(dir); + assert.equal(result.monthLabel, 'January 2024'); + assert.equal(result.comparison.uniqueVisits.value, 300); + assert.equal(result.comparison.stars.value, 12); + assert.equal(result.comparison.forks.value, 3); + assert.equal(result.currentSnapshotCount, 2); + assert.equal(result.previousSnapshotCount, 1); +}); + +// --- aggregateMonthlyTotals --- + +test('aggregateMonthlyTotals sums visits and activity, takes latest stars and forks', () => { + const totals = aggregateMonthlyTotals([SNAPSHOT_JAN_WEEK1, SNAPSHOT_JAN_WEEK2]); + assert.equal(totals.uniqueVisits, 300); + assert.equal(totals.stars, 12); + assert.equal(totals.forks, 3); + assert.equal(totals.issuesOpened, 8); + assert.equal(totals.externalPrs, 3); + assert.equal(totals.snapshotCount, 2); +}); + +test('aggregateMonthlyTotals merges referrers across snapshots', () => { + const totals = aggregateMonthlyTotals([SNAPSHOT_JAN_WEEK1, SNAPSHOT_JAN_WEEK2]); + assert.equal(totals.topReferrers.length, 2); + assert.equal(totals.topReferrers[0].referrer, 'google.com'); + assert.equal(totals.topReferrers[0].uniques, 80); + assert.equal(totals.topReferrers[1].referrer, 'github.com'); + assert.equal(totals.topReferrers[1].uniques, 20); +}); + +test('aggregateMonthlyTotals merges paths across snapshots', () => { + const totals = aggregateMonthlyTotals([SNAPSHOT_JAN_WEEK1, SNAPSHOT_JAN_WEEK2]); + assert.equal(totals.topPaths.length, 2); + assert.equal(totals.topPaths[0].path, '/repo'); + assert.equal(totals.topPaths[0].uniques, 140); + assert.equal(totals.topPaths[1].path, '/repo/issues'); + assert.equal(totals.topPaths[1].uniques, 40); +}); + +test('aggregateMonthlyTotals handles snapshots without new fields', () => { + const legacy = [ + { date: '2024-01-05', uniqueVisits: 100, uniqueClones: 10 }, + { date: '2024-01-12', uniqueVisits: 200, uniqueClones: 20 }, + ]; + const totals = aggregateMonthlyTotals(legacy); + assert.equal(totals.uniqueVisits, 300); + assert.equal(totals.stars, null); + assert.equal(totals.forks, null); + assert.equal(totals.issuesOpened, 0); + assert.equal(totals.externalPrs, 0); + assert.equal(totals.topReferrers.length, 0); + assert.equal(totals.topPaths.length, 0); +}); + +// --- formatMonthLabel --- + +test('formatMonthLabel returns full month name and year', () => { + assert.equal(formatMonthLabel('2024-01'), 'January 2024'); + assert.equal(formatMonthLabel('2023-12'), 'December 2023'); +}); + +// --- Metric change helpers --- + +test('buildDeltaMetricChange returns delta format for numeric values', () => { + const result = buildDeltaMetricChange(12, 8); + assert.equal(result.value, 12); + assert.ok(result.change.includes('+4')); +}); + +test('buildDeltaMetricChange returns N/A for null current', () => { + const result = buildDeltaMetricChange(null, 8); + assert.equal(result.value, null); + assert.equal(result.change, 'N/A'); +}); + +test('buildDeltaMetricChange returns first run for null previous', () => { + const result = buildDeltaMetricChange(12, null); + assert.equal(result.value, 12); + assert.equal(result.change, 'N/A (first run)'); +}); + +test('buildSafeMetricChange returns percentage format for numeric values', () => { + const result = buildSafeMetricChange(150, 100); + assert.equal(result.value, 150); + assert.ok(result.change.includes('50%')); +}); + +test('buildSafeMetricChange returns N/A for null current', () => { + const result = buildSafeMetricChange(null, 100); + assert.equal(result.value, null); + assert.equal(result.change, 'N/A'); +}); + +test('buildFirstRunMonthlyMetric returns first run for present value', () => { + const result = buildFirstRunMonthlyMetric(10); + assert.equal(result.value, 10); + assert.equal(result.change, 'N/A (first run)'); +}); + +test('buildFirstRunMonthlyMetric returns N/A for null value', () => { + const result = buildFirstRunMonthlyMetric(null); + assert.equal(result.value, null); + assert.equal(result.change, 'N/A'); +}); + +// --- Slack block builders --- + +test('buildHeaderBlock includes emoji and month label', () => { + const block = buildHeaderBlock('January 2024'); + assert.equal(block.type, 'header'); + assert.ok(block.text.text.includes('BrightDev Monthly Report - January 2024')); + assert.equal(block.text.type, 'plain_text'); +}); + +test('buildMetricSectionBlock renders label, value and change', () => { + const block = buildMetricSectionBlock('Unique Visits', { value: 300, change: '\u2191 +50%' }); + assert.equal(block.type, 'section'); + assert.ok(block.text.text.includes('Unique Visits')); + assert.ok(block.text.text.includes('300')); + assert.ok(block.text.text.includes('\u2191 +50%')); +}); + +test('buildTwoColumnBlock produces section with two fields', () => { + const block = buildTwoColumnBlock( + 'Stars', { value: 12, change: '+4' }, + 'Forks', { value: 3, change: '+1' } + ); + assert.equal(block.type, 'section'); + assert.equal(block.fields.length, 2); + assert.ok(block.fields[0].text.includes('Stars')); + assert.ok(block.fields[0].text.includes('12')); + assert.ok(block.fields[1].text.includes('Forks')); + assert.ok(block.fields[1].text.includes('3')); +}); + +test('buildListBlock renders bulleted list', () => { + const items = [{ name: 'a' }, { name: 'b' }]; + const block = buildListBlock('Test', items, (i) => `\u2022 ${i.name}`); + assert.equal(block.type, 'section'); + assert.ok(block.text.text.includes('Test')); + assert.ok(block.text.text.includes('\u2022 a')); + assert.ok(block.text.text.includes('\u2022 b')); +}); + +test('buildListBlock shows no data message for empty list', () => { + const block = buildListBlock('Empty', [], () => ''); + assert.ok(block.text.text.includes('_No data_')); +}); + +test('buildContextBlock contains correct attribution', () => { + const block = buildContextBlock(); + assert.equal(block.type, 'context'); + assert.ok(block.elements[0].text.includes('Data from GitHub API - BrightDev')); +}); + +// --- formatMetricText --- + +test('formatMetricText displays value and change for normal metric', () => { + const text = formatMetricText('Stars', { value: 12, change: '+4' }); + assert.ok(text.includes('*Stars*')); + assert.ok(text.includes('12')); + assert.ok(text.includes('+4')); +}); + +test('formatMetricText displays only change when value is null', () => { + const text = formatMetricText('Stars', { value: null, change: 'N/A' }); + assert.ok(text.includes('*Stars*')); + assert.ok(text.includes('N/A')); + assert.ok(!text.includes('null')); +}); + +// --- List formatters --- + +test('formatReferrerLine formats referrer with bullet and uniques', () => { + const line = formatReferrerLine({ referrer: 'google.com', uniques: 10 }); + assert.ok(line.includes('\u2022')); + assert.ok(line.includes('google.com')); + assert.ok(line.includes('10 unique')); +}); + +test('formatPathLine formats path with bullet, backticks, and uniques', () => { + const line = formatPathLine({ path: '/README.md', uniques: 25 }); + assert.ok(line.includes('\u2022')); + assert.ok(line.includes('`/README.md`')); + assert.ok(line.includes('25 unique')); +}); + +// --- buildLimitedDataWarning / collectLimitedDataWarnings --- + +test('buildLimitedDataWarning returns correct string', () => { + assert.equal(buildLimitedDataWarning(1), '(limited data - 1 snapshots this month)'); + assert.equal(buildLimitedDataWarning(0), '(limited data - 0 snapshots this month)'); +}); + +test('collectLimitedDataWarnings returns warning when current month has fewer than 2 snapshots', () => { + const warnings = collectLimitedDataWarnings(1, 3); + assert.equal(warnings.length, 1); + assert.ok(warnings[0].includes('1 snapshots this month')); +}); + +test('collectLimitedDataWarnings returns warning when previous month has fewer than 2 snapshots', () => { + const warnings = collectLimitedDataWarnings(3, 1); + assert.equal(warnings.length, 1); + assert.ok(warnings[0].includes('1 snapshots this month')); +}); + +test('collectLimitedDataWarnings returns two warnings when both months have limited data', () => { + const warnings = collectLimitedDataWarnings(1, 1); + assert.equal(warnings.length, 2); +}); + +test('collectLimitedDataWarnings returns no warnings when both months have 2+ snapshots', () => { + const warnings = collectLimitedDataWarnings(3, 2); + assert.equal(warnings.length, 0); +}); + +test('collectLimitedDataWarnings ignores previousSnapshotCount when null', () => { + const warnings = collectLimitedDataWarnings(3, null); + assert.equal(warnings.length, 0); +}); + +test('buildWarningBlock renders warnings as mrkdwn context block', () => { + const block = buildWarningBlock(['warning one', 'warning two']); + assert.equal(block.type, 'context'); + assert.ok(block.elements[0].text.includes('warning one')); + assert.ok(block.elements[0].text.includes('warning two')); +}); + +// --- buildSlackPayload --- + +test('buildSlackPayload produces 10 blocks without warnings', () => { + const comparison = buildFirstRunMonthlyComparison({ + uniqueVisits: 100, + stars: 10, + forks: 2, + issuesOpened: 3, + externalPrs: 1, + topReferrers: [], + topPaths: [], + }); + const payload = buildSlackPayload('January 2024', comparison, []); + assert.equal(payload.blocks.length, 10); + assert.equal(payload.blocks[0].type, 'header'); + assert.equal(payload.blocks[9].type, 'context'); +}); + +test('buildSlackPayload appends warning block when warnings present', () => { + const comparison = buildFirstRunMonthlyComparison({ + uniqueVisits: 100, + stars: 10, + forks: 2, + issuesOpened: 3, + externalPrs: 1, + topReferrers: [], + topPaths: [], + }); + const payload = buildSlackPayload('January 2024', comparison, ['(limited data - 1 snapshots this month)']); + assert.equal(payload.blocks.length, 11); + assert.equal(payload.blocks[10].type, 'context'); + assert.ok(payload.blocks[10].elements[0].text.includes('limited data')); +}); + +test('buildSlackPayload contains stars/forks two-column block', () => { + const comparison = buildFirstRunMonthlyComparison({ + uniqueVisits: 100, + stars: 10, + forks: 2, + issuesOpened: 3, + externalPrs: 1, + topReferrers: [], + topPaths: [], + }); + const payload = buildSlackPayload('January 2024', comparison, []); + const starsForks = payload.blocks[3]; + assert.equal(starsForks.type, 'section'); + assert.ok(starsForks.fields); + assert.ok(starsForks.fields[0].text.includes('Stars')); + assert.ok(starsForks.fields[1].text.includes('Forks')); +}); + +test('buildSlackPayload contains issues/PRs two-column block', () => { + const comparison = buildFirstRunMonthlyComparison({ + uniqueVisits: 100, + stars: 10, + forks: 2, + issuesOpened: 3, + externalPrs: 1, + topReferrers: [], + topPaths: [], + }); + const payload = buildSlackPayload('January 2024', comparison, []); + const issuesPrs = payload.blocks[5]; + assert.equal(issuesPrs.type, 'section'); + assert.ok(issuesPrs.fields); + assert.ok(issuesPrs.fields[0].text.includes('Issues Opened')); + assert.ok(issuesPrs.fields[1].text.includes('External PRs')); +}); + +// --- isTestMode --- + +test('isTestMode returns true when TEST_MODE env is true', () => { + const original = process.env.TEST_MODE; + process.env.TEST_MODE = 'true'; + assert.equal(isTestMode(), true); + process.env.TEST_MODE = original; +}); + +test('isTestMode returns false when TEST_MODE env is false', () => { + const original = process.env.TEST_MODE; + process.env.TEST_MODE = 'false'; + assert.equal(isTestMode(), false); + process.env.TEST_MODE = original; +}); + +// --- deliverPayload --- + +test('deliverPayload prints to log in test mode without calling fetch', async () => { + const original = process.env.TEST_MODE; + process.env.TEST_MODE = 'true'; + let fetchCalled = false; + const originalFetch = global.fetch; + global.fetch = async () => { fetchCalled = true; return { ok: true }; }; + const comparison = buildFirstRunMonthlyComparison({ + uniqueVisits: 100, + stars: 10, + forks: 2, + issuesOpened: 3, + externalPrs: 1, + topReferrers: [], + topPaths: [], + }); + const payload = buildSlackPayload('January 2024', comparison, []); + await assert.doesNotReject(() => deliverPayload(payload)); + assert.equal(fetchCalled, false); + process.env.TEST_MODE = original; + global.fetch = originalFetch; +}); + +test('deliverPayload throws when SLACK_WEBHOOK_URL is not set in non-test mode', async () => { + const originalMode = process.env.TEST_MODE; + const originalUrl = process.env.SLACK_WEBHOOK_URL; + process.env.TEST_MODE = 'false'; + delete process.env.SLACK_WEBHOOK_URL; + const comparison = buildFirstRunMonthlyComparison({ + uniqueVisits: 100, + stars: 10, + forks: 2, + issuesOpened: 3, + externalPrs: 1, + topReferrers: [], + topPaths: [], + }); + const payload = buildSlackPayload('January 2024', comparison, []); + await assert.rejects(() => deliverPayload(payload), /SLACK_WEBHOOK_URL is not set/); + process.env.TEST_MODE = originalMode; + process.env.SLACK_WEBHOOK_URL = originalUrl; +}); diff --git a/.github/workflows/monthly-traffic-report.yml b/.github/workflows/monthly-traffic-report.yml index b998f47..7136272 100644 --- a/.github/workflows/monthly-traffic-report.yml +++ b/.github/workflows/monthly-traffic-report.yml @@ -45,6 +45,6 @@ jobs: if: steps.check-date.outputs.is_last_day == 'true' env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.TRAFFIC_PAT }} TEST_MODE: ${{ inputs.test_mode || 'false' }} run: node .github/scripts/aggregate-monthly.js From ca43e882cbeda1118b85f601111057b345cc066a Mon Sep 17 00:00:00 2001 From: Evan Morgan Date: Fri, 20 Mar 2026 12:51:11 +0000 Subject: [PATCH 6/6] refactor(PE-1209): filter PR paths and sort content by uniques in both reports --- .github/scripts/aggregate-monthly.js | 7 ++++- .github/scripts/aggregate-monthly.test.js | 16 +++++++++++ .github/scripts/build-slack-payload.js | 14 +++++++++- .github/scripts/build-slack-payload.test.js | 31 +++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/.github/scripts/aggregate-monthly.js b/.github/scripts/aggregate-monthly.js index f6575cf..d19c6e4 100644 --- a/.github/scripts/aggregate-monthly.js +++ b/.github/scripts/aggregate-monthly.js @@ -61,8 +61,13 @@ function mergeReferrers(snapshots) { return mergeListByKey(collectListEntries(snapshots, 'topReferrers'), 'referrer', 'uniques'); } +function isPullRequestPath(pathStr) { + return pathStr.includes('/pull/') || pathStr.endsWith('/pulls'); +} + function mergePaths(snapshots) { - return mergeListByKey(collectListEntries(snapshots, 'topPaths'), 'path', 'uniques'); + const merged = mergeListByKey(collectListEntries(snapshots, 'topPaths'), 'path', 'uniques'); + return merged.filter((entry) => !isPullRequestPath(entry.path)); } function aggregateMonthlyTotals(snapshots) { diff --git a/.github/scripts/aggregate-monthly.test.js b/.github/scripts/aggregate-monthly.test.js index e619050..6ef7912 100644 --- a/.github/scripts/aggregate-monthly.test.js +++ b/.github/scripts/aggregate-monthly.test.js @@ -142,6 +142,22 @@ test('aggregateMonthlyTotals merges paths across snapshots', () => { assert.equal(totals.topPaths[1].uniques, 40); }); +test('aggregateMonthlyTotals filters pull request paths from merged results', () => { + const snapshots = [{ + date: '2024-01-05', + uniqueVisits: 100, + topPaths: [ + { path: '/repo', uniques: 80 }, + { path: '/repo/pulls', uniques: 10 }, + { path: '/repo/pull/5', uniques: 5 }, + ], + topReferrers: [], + }]; + const totals = aggregateMonthlyTotals(snapshots); + assert.equal(totals.topPaths.length, 1); + assert.equal(totals.topPaths[0].path, '/repo'); +}); + test('aggregateMonthlyTotals handles snapshots without new fields', () => { const legacy = [ { date: '2024-01-05', uniqueVisits: 100, uniqueClones: 10 }, diff --git a/.github/scripts/build-slack-payload.js b/.github/scripts/build-slack-payload.js index 9639a3a..9dce3d0 100644 --- a/.github/scripts/build-slack-payload.js +++ b/.github/scripts/build-slack-payload.js @@ -63,6 +63,16 @@ function buildContextBlock() { }; } +function isPullRequestPath(pathStr) { + return pathStr.includes('/pull/') || pathStr.endsWith('/pulls'); +} + +function filterAndSortPaths(paths) { + return paths + .filter((entry) => !isPullRequestPath(entry.path)) + .sort((a, b) => b.uniques - a.uniques); +} + function buildSlackPayload(comparison) { return { blocks: [ @@ -74,7 +84,7 @@ function buildSlackPayload(comparison) { buildTwoColumnBlock('\ud83d\udcdd Issues Opened', comparison.issuesOpenedThisWeek, '\ud83e\udd1d External PRs', comparison.externalPrsThisWeek), buildDividerBlock(), buildListBlock('\ud83d\udd17 Top Referrers', comparison.topReferrers, formatReferrerLine), - buildListBlock('\ud83d\udcc4 Top Content', comparison.topPaths, formatPathLine), + buildListBlock('\ud83d\udcc4 Top Content', filterAndSortPaths(comparison.topPaths), formatPathLine), buildContextBlock(), ], }; @@ -127,6 +137,8 @@ module.exports = { formatMetricText, formatReferrerLine, formatPathLine, + isPullRequestPath, + filterAndSortPaths, isTestMode, postPayloadToSlackWebhook, deliverPayload, diff --git a/.github/scripts/build-slack-payload.test.js b/.github/scripts/build-slack-payload.test.js index ce1b05f..35638cd 100644 --- a/.github/scripts/build-slack-payload.test.js +++ b/.github/scripts/build-slack-payload.test.js @@ -14,6 +14,8 @@ const { formatMetricText, formatReferrerLine, formatPathLine, + isPullRequestPath, + filterAndSortPaths, isTestMode, postPayloadToSlackWebhook, deliverPayload, @@ -124,6 +126,35 @@ test('formatPathLine formats path with bullet, backticks, and uniques', () => { assert.ok(line.includes('25 unique')); }); +// --- isPullRequestPath / filterAndSortPaths --- + +test('isPullRequestPath matches /pull/ paths', () => { + assert.equal(isPullRequestPath('/repo/pull/10'), true); + assert.equal(isPullRequestPath('/repo/pull/123/files'), true); +}); + +test('isPullRequestPath matches /pulls path', () => { + assert.equal(isPullRequestPath('/repo/pulls'), true); +}); + +test('isPullRequestPath does not match non-PR paths', () => { + assert.equal(isPullRequestPath('/repo/tree/main/src'), false); + assert.equal(isPullRequestPath('/repo/blob/main/README.md'), false); +}); + +test('filterAndSortPaths removes PR paths and sorts by uniques descending', () => { + const paths = [ + { path: '/docs/setup.md', uniques: 12 }, + { path: '/repo/pulls', uniques: 15 }, + { path: '/README.md', uniques: 25 }, + { path: '/repo/pull/10', uniques: 8 }, + ]; + const result = filterAndSortPaths(paths); + assert.equal(result.length, 2); + assert.equal(result[0].path, '/README.md'); + assert.equal(result[1].path, '/docs/setup.md'); +}); + // --- buildSlackPayload --- test('buildSlackPayload produces correct number of blocks', () => {