Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions .github/workflows/issue-notifier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ on:
schedule:
- cron: "0 0 * * 0"
workflow_dispatch:
inputs:
skip_slack:
description: Skip the Slack message. The report still goes to the job summary.
type: boolean
default: true
skip_cache_upload:
description: Leave the cached report data untouched, so the next scheduled run still diffs against the last real report.
type: boolean
default: true

permissions:
issues: read
Expand All @@ -28,14 +37,6 @@ jobs:
node-version: '24'
cache: 'pnpm'

- name: Cache node_modules
id: cache-modules
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
lookup-only: true
path: '**/node_modules'
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}

- name: Install packages
run: pnpm install --frozen-lockfile

Expand All @@ -56,12 +57,14 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}

- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ !inputs.skip_cache_upload }}
with:
name: cached-issue-data
path: ./scripts/issues-scraper/cached/data.json

- name: Send GitHub Action trigger data to Slack workflow
id: slack
if: ${{ !inputs.skip_slack }}
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
webhook: ${{ secrets.SLACK_ISSUES_REPORT_URL }}
Expand Down
276 changes: 276 additions & 0 deletions scripts/issues-scraper/format-slack-message.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
formatGhReport,
getSlackMessageJson,
splitIntoBlocks,
toMarkdown,
toSlackBlocks,
} from './format-slack-message';
import { ReportData, ScopeData, ScopeTrend, TrendData } from './model';

const stats = (n: number): ScopeData => ({
issues: { count: n, bugCount: n, closed: n, avgAge: n * 10, p95Age: n * 20 },
prs: {
open: n,
created: n,
merged: n,
closed: n,
avgAge: n * 3,
p95Age: n * 4,
},
});
const trend = (n: number | null): ScopeTrend => ({
issues: { count: n, bugCount: n, closed: n, avgAge: n, p95Age: n },
prs: { open: n, created: n, merged: n, closed: n, avgAge: n, p95Age: n },
});

const current: ReportData = {
all: stats(9),
unscoped: stats(2),
scopes: {
'scope: small': stats(1),
'scope: big': stats(5),
'scope: none': { ...stats(0), issues: { ...stats(0).issues, closed: 1 } },
},
collectedDate: 'Aug 30 2026',
};
const trends: TrendData = {
all: trend(1),
unscoped: trend(-1),
scopes: {
'scope: small': trend(0),
'scope: big': trend(2),
'scope: none': trend(null),
},
};
const previous: Partial<ReportData> = { collectedDate: 'Aug 23 2026' };
const links = {
unlabeledIssuesUrl: 'https://example.com/issues',
unlabeledPrsUrl: 'https://example.com/prs',
};

const squash = (s: string) => s.replace(/ +/g, ' ');
const rowLabels = (table: string) =>
table
.split('\n')
.filter((l) => l.startsWith('|'))
.slice(2)
.map((l) => l.split('|')[1].trim());

describe('formatGhReport', () => {
const report = formatGhReport(current, trends, previous, links);
const [issues, prs] = report.tables;

it('describes the report with a title, links, notes, two titled tables and a footer link', () => {
assert.equal(report.title, 'Issue & PR Report for Aug 30 2026');
assert.deepEqual(report.links, [
{ label: 'view unlabeled issues', url: 'https://example.com/issues' },
{ label: 'view unlabeled PRs', url: 'https://example.com/prs' },
]);
assert.deepEqual(report.notes, [
'Previous Report: Aug 23 2026',
'Closed, created and merged counts are since Aug 23 2026. Ages are for open items, in days.',
]);
assert.equal(issues.title, 'Issues');
assert.equal(prs.title, 'Pull requests');
assert.deepEqual(report.footer, {
label: 'nx package health on npm-burst',
url: 'https://npm-burst.com/package/nx/health/',
});
});

it('omits the previous-report note on a first run', () => {
const first = formatGhReport(current, trends, {}, links);
assert.equal(first.notes.length, 1);
assert.doesNotMatch(first.notes[0], /Previous/);
});

it('lists Everything, then Unscoped, then scopes by descending open count', () => {
const expected = ['Everything', 'Unscoped', 'scope: big', 'scope: small'];
assert.deepEqual(rowLabels(issues.markdown), [...expected, 'scope: none']);
assert.deepEqual(rowLabels(prs.markdown), expected);
});

it('renders counts with deltas and ages in days', () => {
assert.match(issues.markdown, /Issues.*Bugs.*Closed.*Avg Age.*P95 Age/);
assert.match(
prs.markdown,
/Open.*Created.*Merged.*Closed.*Avg Age.*P95 Age/
);
assert.match(
squash(issues.markdown),
/\| Everything \| 9 \(\+1\) \| 9 \(\+1\) \| 9 \(\+1\) \| 90d \(\+1\) \| 180d \(\+1\) \|/
);
assert.match(
squash(issues.markdown),
/\| Unscoped \| 2 \(-1\) \| 2 \(-1\) \| 2 \(-1\) \| 20d \(-1\) \| 40d \(-1\) \|/
);
assert.match(
squash(prs.markdown),
/\| scope: small \| 1 \| 1 \| 1 \| 1 \| 3d \| 4d \|/
);
});

it('shows a dash for ages when nothing is open', () => {
assert.match(
squash(issues.markdown),
/\| scope: none \| 0 \| 0 \| 1 \| - \| - \|/
);
});

it('omits scope rows with no activity at all from a table', () => {
assert.doesNotMatch(prs.markdown, /scope: none/);
});
});

describe('toSlackBlocks', () => {
const blocks = toSlackBlocks(
formatGhReport(current, trends, previous, links)
);
const types = blocks.map((b) => b.type);

it('lays out header, context notes, links, then a labelled fenced table per section, then a context footer', () => {
assert.deepEqual(types, [
'header',
'context',
'section',
'section',
'divider',
'section',
'section',
'divider',
'section',
'section',
'context',
]);
});

it('puts the title in a plain_text header and the notes in a context block', () => {
assert.deepEqual(blocks[0], {
type: 'header',
text: { type: 'plain_text', text: 'Issue & PR Report for Aug 30 2026' },
});
assert.deepEqual(blocks[1], {
type: 'context',
elements: [
{
type: 'mrkdwn',
text: 'Previous Report: Aug 23 2026\nClosed, created and merged counts are since Aug 23 2026. Ages are for open items, in days.',
},
],
});
});

it('renders each link as its own mrkdwn section and the footer as a context link', () => {
assert.deepEqual(blocks[2], {
type: 'section',
text: {
type: 'mrkdwn',
text: '<https://example.com/issues|view unlabeled issues>',
},
});
assert.deepEqual(blocks[3], {
type: 'section',
text: {
type: 'mrkdwn',
text: '<https://example.com/prs|view unlabeled PRs>',
},
});
assert.deepEqual(blocks[10], {
type: 'context',
elements: [
{
type: 'mrkdwn',
text: '<https://npm-burst.com/package/nx/health/|nx package health on npm-burst>',
},
],
});
});

it('labels each table in bold and fences its chunks', () => {
assert.deepEqual(blocks[5], {
type: 'section',
text: { type: 'mrkdwn', text: '*Issues*' },
});
assert.deepEqual(blocks[8], {
type: 'section',
text: { type: 'mrkdwn', text: '*Pull requests*' },
});
for (const block of [blocks[6], blocks[9]]) {
assert.equal(block.type, 'section');
const text = (block as { text: { text: string } }).text.text;
assert.ok(text.startsWith('```\n| Scope'));
assert.ok(text.endsWith('\n```'));
}
});
});

describe('toMarkdown', () => {
const markdown = toMarkdown(formatGhReport(current, trends, previous, links));

it('renders headings, plain links and unfenced tables for GitHub', () => {
assert.match(markdown, /^# Issue & PR Report for Aug 30 2026\n/);
assert.match(
markdown,
/\[view unlabeled issues\]\(https:\/\/example.com\/issues\)/
);
assert.match(
markdown,
/\[view unlabeled PRs\]\(https:\/\/example.com\/prs\)/
);
assert.match(markdown, /\n## Issues\n\n\| Scope/);
assert.match(markdown, /\n## Pull requests\n\n\| Scope/);
assert.match(
markdown,
/\[nx package health on npm-burst\]\(https:\/\/npm-burst.com\/package\/nx\/health\/\)/
);
assert.doesNotMatch(markdown, /```/);
assert.doesNotMatch(markdown, /<https/);
});
});

describe('splitIntoBlocks', () => {
it('leaves short text as a single fenced block', () => {
assert.deepEqual(splitIntoBlocks('a\nb'), ['```\na\nb\n```']);
});

it('repeats the table header at the top of every continuation block', () => {
const header = ['| Scope | N |', '| ----- | - |'];
const rows = Array.from(
{ length: 200 },
(_, i) => `| row ${i} | ${'x'.repeat(60)} |`
);
const blocks = splitIntoBlocks([...header, ...rows].join('\n'), 2);
assert.ok(blocks.length > 1);
for (const block of blocks) {
assert.deepEqual(block.split('\n').slice(1, 3), header);
}
const rejoined = blocks.flatMap((b) => b.split('\n').slice(3, -1));
assert.deepEqual(rejoined, rows);
});

it('splits on line boundaries so each fenced block fits in a Slack section', () => {
const lines = Array.from(
{ length: 200 },
(_, i) => `row ${i} ${'x'.repeat(60)}`
);
const blocks = splitIntoBlocks(lines.join('\n'));
assert.ok(blocks.length > 1);
for (const block of blocks) {
assert.ok(block.length <= 3000, `block of ${block.length} chars`);
assert.ok(block.startsWith('```\n') && block.endsWith('\n```'));
}
const rejoined = blocks.map((b) => b.slice(4, -4)).join('\n');
assert.equal(rejoined, lines.join('\n'));
});
});

describe('getSlackMessageJson', () => {
it('uses the title as the notification fallback and the rendered blocks', () => {
const report = formatGhReport(current, trends, previous, links);
const json = getSlackMessageJson(report);
assert.equal(json.text, 'Issue & PR Report for Aug 30 2026');
assert.deepEqual(json.blocks, toSlackBlocks(report));
});
});
Loading
Loading