diff --git a/.github/workflows/issue-notifier.yml b/.github/workflows/issue-notifier.yml index 99f8b749c30..d93c99264fd 100644 --- a/.github/workflows/issue-notifier.yml +++ b/.github/workflows/issue-notifier.yml @@ -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 @@ -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 @@ -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 }} diff --git a/scripts/issues-scraper/format-slack-message.spec.ts b/scripts/issues-scraper/format-slack-message.spec.ts new file mode 100644 index 00000000000..c771a862be5 --- /dev/null +++ b/scripts/issues-scraper/format-slack-message.spec.ts @@ -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 = { 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: '', + }, + }); + assert.deepEqual(blocks[3], { + type: 'section', + text: { + type: 'mrkdwn', + text: '', + }, + }); + assert.deepEqual(blocks[10], { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: '', + }, + ], + }); + }); + + 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, / { + 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)); + }); +}); diff --git a/scripts/issues-scraper/format-slack-message.ts b/scripts/issues-scraper/format-slack-message.ts index 99861ae1cdb..af6a6aa1e8a 100644 --- a/scripts/issues-scraper/format-slack-message.ts +++ b/scripts/issues-scraper/format-slack-message.ts @@ -1,88 +1,223 @@ -import { ReportData, TrendData } from './model'; -import { getSinceDate } from './scrape-issues'; import { table } from 'markdown-factory'; +import { ReportData, ScopeData, ScopeTrend, TrendData } from './model'; +import { getSinceDate } from './scrape-issues'; -export function getSlackMessageJson(body: string) { - return { - blocks: [ - { - type: 'section', - text: { - text: body, - type: 'mrkdwn', - }, - }, - ], - }; +const SLACK_SECTION_TEXT_LIMIT = 3000; +const TABLE_HEADER_LINES = 2; +const NPM_HEALTH_URL = 'https://npm-burst.com/package/nx/health/'; + +export interface Link { + label: string; + url: string; } +export interface FormattedReport { + title: string; + links: Link[]; + notes: string[]; + tables: { title: string; markdown: string }[]; + footer: Link; +} + +export interface ReportLinks { + unlabeledIssuesUrl: string; + unlabeledPrsUrl: string; +} + +type Row = { + label: string; + data: ScopeData; + trend: ScopeTrend; +}; + export function formatGhReport( currentData: ReportData, trendData: TrendData, - prevData: ReportData, - unlabeledIssuesUrl: string -): string { - const formattedIssueDelta = formatDelta(trendData.totalIssueCount); - const formattedBugDelta = formatDelta(trendData.totalBugCount); - - const header = `Issue Report for ${currentData.collectedDate} <${unlabeledIssuesUrl}|[view unlabeled]> -\`\`\` -Totals, Issues: ${currentData.totalIssueCount} ${formattedIssueDelta} Bugs: ${currentData.totalBugCount} ${formattedBugDelta}\n\n`; - + prevData: Partial, + links: ReportLinks +): FormattedReport { const prevDate = prevData.collectedDate ? new Date(prevData.collectedDate) : undefined; - const closedSinceDate = getSinceDate(prevDate) - .toDateString() - .split(' ') - .slice(1) - .join(' '); - - const bodyLines: string[] = [ - ...(prevData.collectedDate - ? [`Previous Report: ${prevData.collectedDate}`] - : []), - `Untriaged: ${currentData.untriagedIssueCount} ${formatDelta( - trendData.untriagedIssueCount - )}`, - `Closed since ${closedSinceDate}: ${currentData.totalClosed} ${formatDelta( - trendData.totalClosed - )}`, + const sinceDate = formatDate(getSinceDate(prevDate)); + + const rows = ( + sortBy: (d: ScopeData) => number, + activity: (d: ScopeData) => number[] + ): Row[] => [ + { label: 'Everything', data: currentData.all, trend: trendData.all }, + { + label: 'Unscoped', + data: currentData.unscoped, + trend: trendData.unscoped, + }, + ...Object.entries(currentData.scopes) + .filter(([, data]) => activity(data).some((n) => n > 0)) + .sort(([, a], [, b]) => sortBy(b) - sortBy(a)) + .map(([scope, data]) => ({ + label: scope, + data, + trend: trendData.scopes[scope], + })), ]; - const sorted = Object.entries(currentData.scopes) - .sort(([, a], [, b]) => b.count - a.count) - .map(([scope, x]) => ({ - ...x, - scope, - })); - - bodyLines.push( - table(sorted, [ - { - field: 'scope', - label: 'Scope', - }, - { - label: 'Issues', - mapFn: (el) => - `${el.count} ${formatDelta(trendData.scopes[el.scope].count)}`, - }, - { - label: 'Bugs', - mapFn: (el) => - `${el.bugCount} ${formatDelta(trendData.scopes[el.scope].bugCount)}`, - }, - { - label: 'Closed', - mapFn: (el) => - `${el.closed} ${formatDelta(trendData.scopes[el.scope].closed)}`, - }, - ]) + const issueTable = table( + rows( + (d) => d.issues.count, + (d) => [d.issues.count, d.issues.bugCount, d.issues.closed] + ), + [ + { label: 'Scope', field: 'label' }, + count('Issues', (r) => [r.data.issues.count, r.trend.issues.count]), + count('Bugs', (r) => [r.data.issues.bugCount, r.trend.issues.bugCount]), + count('Closed', (r) => [r.data.issues.closed, r.trend.issues.closed]), + age('Avg Age', (r) => [ + r.data.issues.count, + r.data.issues.avgAge, + r.trend.issues.avgAge, + ]), + age('P95 Age', (r) => [ + r.data.issues.count, + r.data.issues.p95Age, + r.trend.issues.p95Age, + ]), + ] + ); + + const prTable = table( + rows( + (d) => d.prs.open, + (d) => [d.prs.open, d.prs.created, d.prs.merged, d.prs.closed] + ), + [ + { label: 'Scope', field: 'label' }, + count('Open', (r) => [r.data.prs.open, r.trend.prs.open]), + count('Created', (r) => [r.data.prs.created, r.trend.prs.created]), + count('Merged', (r) => [r.data.prs.merged, r.trend.prs.merged]), + count('Closed', (r) => [r.data.prs.closed, r.trend.prs.closed]), + age('Avg Age', (r) => [ + r.data.prs.open, + r.data.prs.avgAge, + r.trend.prs.avgAge, + ]), + age('P95 Age', (r) => [ + r.data.prs.open, + r.data.prs.p95Age, + r.trend.prs.p95Age, + ]), + ] ); - const footer = '```'; - return header + bodyLines.join('\n') + footer; + return { + title: `Issue & PR Report for ${currentData.collectedDate}`, + links: [ + { label: 'view unlabeled issues', url: links.unlabeledIssuesUrl }, + { label: 'view unlabeled PRs', url: links.unlabeledPrsUrl }, + ], + notes: [ + ...(prevData.collectedDate + ? [`Previous Report: ${prevData.collectedDate}`] + : []), + `Closed, created and merged counts are since ${sinceDate}. Ages are for open items, in days.`, + ], + tables: [ + { title: 'Issues', markdown: issueTable }, + { title: 'Pull requests', markdown: prTable }, + ], + footer: { label: 'nx package health on npm-burst', url: NPM_HEALTH_URL }, + }; +} + +type SlackBlock = + | { type: 'header'; text: { type: 'plain_text'; text: string } } + | { type: 'section'; text: { type: 'mrkdwn'; text: string } } + | { type: 'context'; elements: { type: 'mrkdwn'; text: string }[] } + | { type: 'divider' }; + +export function toSlackBlocks(report: FormattedReport): SlackBlock[] { + const slackLink = (l: Link) => `<${l.url}|${l.label}>`; + const section = (text: string): SlackBlock => ({ + type: 'section', + text: { type: 'mrkdwn', text }, + }); + const context = (text: string): SlackBlock => ({ + type: 'context', + elements: [{ type: 'mrkdwn', text }], + }); + return [ + { type: 'header', text: { type: 'plain_text', text: report.title } }, + context(report.notes.join('\n')), + ...report.links.map((l) => section(slackLink(l))), + ...report.tables.flatMap((t) => [ + { type: 'divider' } as SlackBlock, + section(`*${t.title}*`), + ...splitIntoBlocks(t.markdown, TABLE_HEADER_LINES).map(section), + ]), + context(slackLink(report.footer)), + ]; +} + +export function toMarkdown(report: FormattedReport): string { + const mdLink = (l: Link) => `[${l.label}](${l.url})`; + return [ + `# ${report.title}`, + report.notes.join(' \n'), + report.links.map(mdLink).join(' ยท '), + ...report.tables.flatMap((t) => [`## ${t.title}`, t.markdown]), + mdLink(report.footer), + ].join('\n\n'); +} + +export function getSlackMessageJson(report: FormattedReport) { + return { text: report.title, blocks: toSlackBlocks(report) }; +} + +function count(label: string, pick: (r: Row) => [number, number | null]) { + return { + label, + mapFn: (r: Row) => { + const [value, delta] = pick(r); + return `${value} ${formatDelta(delta)}`.trim(); + }, + }; +} + +function age(label: string, pick: (r: Row) => [number, number, number | null]) { + return { + label, + mapFn: (r: Row) => { + const [openCount, value, delta] = pick(r); + if (openCount === 0) { + return '-'; + } + return `${value}d ${formatDelta(delta)}`.trim(); + }, + }; +} + +export function splitIntoBlocks(text: string, headerLines = 0): string[] { + const lines = text.split('\n'); + const header = lines.slice(0, headerLines); + const fence = (body: string[]) => `\`\`\`\n${body.join('\n')}\n\`\`\``; + const blocks: string[] = []; + let current = [...header]; + for (const line of lines.slice(headerLines)) { + if ( + current.length > header.length && + fence([...current, line]).length > SLACK_SECTION_TEXT_LIMIT + ) { + blocks.push(fence(current)); + current = [...header]; + } + current.push(line); + } + blocks.push(fence(current)); + return blocks; +} + +function formatDate(date: Date): string { + // Format is like: Mar 03 2023 + return date.toDateString().split(' ').slice(1).join(' '); } function formatDelta(delta: number | null): string { diff --git a/scripts/issues-scraper/index.ts b/scripts/issues-scraper/index.ts index c7779104060..aabb164eea5 100644 --- a/scripts/issues-scraper/index.ts +++ b/scripts/issues-scraper/index.ts @@ -1,10 +1,15 @@ -import { setOutput } from '@actions/core'; +import { setOutput, summary } from '@actions/core'; import { ensureDirSync, readJsonSync, writeJsonSync } from 'fs-extra'; import isCI from 'is-ci'; import { dirname, join } from 'path'; -import { formatGhReport, getSlackMessageJson } from './format-slack-message'; -import { ReportData, ScopeData, TrendData } from './model'; +import { + formatGhReport, + getSlackMessageJson, + toMarkdown, +} from './format-slack-message'; +import { ReportData } from './model'; import { getScopeLabels, scrapeIssues } from './scrape-issues'; +import { getTrendData } from './stats'; const CACHE_FILE = join(__dirname, 'cached', 'data.json'); @@ -14,16 +19,19 @@ async function main() { oldData.collectedDate ? new Date(oldData.collectedDate) : undefined ); const trendData = getTrendData(currentData, oldData); - const formatted = formatGhReport( - currentData, - trendData, - oldData, - getUnlabeledIssuesUrl(await getScopeLabels()) - ); + const scopeLabels = await getScopeLabels(); + const report = formatGhReport(currentData, trendData, oldData, { + unlabeledIssuesUrl: getUnlabeledUrl('issue', scopeLabels), + unlabeledPrsUrl: getUnlabeledUrl('pr', scopeLabels), + }); + const markdown = toMarkdown(report); if (process.env.GITHUB_ACTIONS) { - setOutput('SLACK_MESSAGE', getSlackMessageJson(formatted)); + setOutput('SLACK_MESSAGE', getSlackMessageJson(report)); + } + if (process.env.GITHUB_STEP_SUMMARY) { + await summary.addRaw(markdown).write(); } - console.log(formatted.replace(/\<(.*)\|(.*)\>/g, '[$2]($1)')); + console.log(markdown); saveCacheData(currentData); } @@ -34,33 +42,14 @@ if (require.main === module) { }); } -function getUnlabeledIssuesUrl(scopeLabels: string[]) { +function getUnlabeledUrl(type: 'issue' | 'pr', scopeLabels: string[]) { const labelFilters = scopeLabels.map((s) => `-label:"${s}"`); - return `https://github.com/nrwl/nx/issues/?q=is%3Aopen+is%3Aissue+sort%3Aupdated-desc+${encodeURIComponent( + const path = type === 'issue' ? 'issues' : 'pulls'; + return `https://github.com/nrwl/nx/${path}?q=is%3Aopen+is%3A${type}+sort%3Aupdated-desc+${encodeURIComponent( labelFilters.join(' ') )}`; } -function getTrendData(newData: ReportData, oldData: ReportData): TrendData { - const scopeTrends: Record> = {}; - for (const [scope, data] of Object.entries(newData.scopes)) { - scopeTrends[scope] ??= {}; - scopeTrends[scope].count = data.count - (oldData.scopes[scope]?.count ?? 0); - scopeTrends[scope].bugCount = - data.bugCount - (oldData.scopes[scope]?.bugCount ?? 0); - scopeTrends[scope].closed = - data.closed - (oldData.scopes[scope]?.closed ?? 0); - } - return { - scopes: scopeTrends as Record, - totalBugCount: newData.totalBugCount - oldData.totalBugCount, - totalIssueCount: newData.totalIssueCount - oldData.totalIssueCount, - totalClosed: newData.totalClosed - oldData.totalClosed, - untriagedIssueCount: - newData.untriagedIssueCount - oldData.untriagedIssueCount, - }; -} - function saveCacheData(report: ReportData) { if (isCI) { ensureDirSync(dirname(CACHE_FILE)); @@ -68,16 +57,10 @@ function saveCacheData(report: ReportData) { } } -function getOldData(): ReportData { +function getOldData(): Partial { try { return readJsonSync(CACHE_FILE); } catch (e) { - return { - scopes: {}, - totalBugCount: 0, - totalIssueCount: 0, - untriagedIssueCount: 0, - totalClosed: 0, - }; + return {}; } } diff --git a/scripts/issues-scraper/model.ts b/scripts/issues-scraper/model.ts index f6ead5fbbbf..9c0a1de627a 100644 --- a/scripts/issues-scraper/model.ts +++ b/scripts/issues-scraper/model.ts @@ -1,16 +1,61 @@ -export interface ScopeData { - bugCount: number; +export interface IssueStats { count: number; + bugCount: number; + closed: number; + avgAge: number; + p95Age: number; +} + +export interface PrStats { + open: number; + created: number; + merged: number; closed: number; + avgAge: number; + p95Age: number; +} + +export interface ScopeData { + issues: IssueStats; + prs: PrStats; } export interface ReportData { + all: ScopeData; + unscoped: ScopeData; scopes: Record; - totalBugCount: number; - totalIssueCount: number; - totalClosed: number; - untriagedIssueCount: number; collectedDate?: string; } -export type TrendData = Omit; +export type StatsTrend = Record; + +export interface ScopeTrend { + issues: StatsTrend; + prs: StatsTrend; +} + +export interface TrendData { + all: ScopeTrend; + unscoped: ScopeTrend; + scopes: Record; +} + +export interface ScrapedItem { + scopes: string[]; + createdAt: Date; +} + +export interface ScrapedIssue extends ScrapedItem { + bug: boolean; +} + +export interface ScrapedPr extends ScrapedItem { + merged: boolean; +} + +export interface ScrapedData { + openIssues: ScrapedIssue[]; + closedIssues: ScrapedIssue[]; + openPrs: ScrapedPr[]; + closedPrs: ScrapedPr[]; +} diff --git a/scripts/issues-scraper/scrape-issues.ts b/scripts/issues-scraper/scrape-issues.ts index 257311f57d1..a67b1005f34 100644 --- a/scripts/issues-scraper/scrape-issues.ts +++ b/scripts/issues-scraper/scrape-issues.ts @@ -1,66 +1,61 @@ import { Octokit } from 'octokit'; -import { ReportData, ScopeData } from './model'; +import { ReportData, ScrapedData, ScrapedIssue, ScrapedPr } from './model'; +import { buildReport } from './stats'; const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); const now = new Date(); export async function scrapeIssues(prevDate?: Date): Promise { - let total = 0; - let totalBugs = 0; - let untriagedIssueCount = 0; - let totalClosed = 0; const scopeLabels = await getScopeLabels(); - const scopes: Record = {}; + const sinceDate = getSinceDate(prevDate); + const open: IssueItem[] = []; for await (const { data: slice } of getOpenIssueIterator()) { - for (const issue of slice.filter(isNotPullRequest)) { - const bug = hasLabel(issue, 'type: bug'); - - if (bug) { - totalBugs += 1; - } - total += 1; - - let triaged = false; - for (const scope of scopeLabels) { - if (hasLabel(issue, scope)) { - scopes[scope] ??= { bugCount: 0, count: 0, closed: 0 }; - if (bug) { - scopes[scope].bugCount += 1; - } - scopes[scope].count += 1; - triaged = true; - } - } - if (!triaged) { - untriagedIssueCount += 1; - } - } + open.push(...slice); } - - const sinceDate = getSinceDate(prevDate); + const closed: IssueItem[] = []; for await (const { data: slice } of getClosedIssueIterator(sinceDate)) { - for (const issue of slice.filter(isNotPullRequest)) { - totalClosed += 1; - - for (const scope of scopeLabels) { - if (hasLabel(issue, scope)) { - scopes[scope] ??= { bugCount: 0, count: 0, closed: 0 }; - scopes[scope].closed += 1; - } - } - } + closed.push(...slice); } return { - scopes: scopes, - totalBugCount: totalBugs, - totalIssueCount: total, - totalClosed, - untriagedIssueCount, + ...buildReport( + toScrapedData(open, closed, scopeLabels, sinceDate), + sinceDate, + now + ), // Format is like: Mar 03 2023 - collectedDate: new Date().toDateString().split(' ').slice(1).join(' '), + collectedDate: now.toDateString().split(' ').slice(1).join(' '), + }; +} + +export function toScrapedData( + open: IssueItem[], + closed: IssueItem[], + scopeLabels: string[], + sinceDate: Date +): ScrapedData { + const data: ScrapedData = { + openIssues: [], + closedIssues: [], + openPrs: [], + closedPrs: [], }; + for (const item of open) { + if (isPullRequest(item)) { + data.openPrs.push(toPr(item, scopeLabels)); + } else { + data.openIssues.push(toIssue(item, scopeLabels)); + } + } + for (const item of closed) { + if (!isPullRequest(item)) { + data.closedIssues.push(toIssue(item, scopeLabels)); + } else if (prClosedAt(item) >= sinceDate) { + data.closedPrs.push(toPr(item, scopeLabels)); + } + } + return data; } export function getSinceDate(prevDate?: Date, referenceDate = now): Date { @@ -83,6 +78,8 @@ const getOpenIssueIterator = () => state: 'open', }); +// `since` filters on updated_at, so closed PRs are re-checked against +// their merged_at / closed_at before being counted. const getClosedIssueIterator = (since: Date) => octokit.paginate.iterator('GET /repos/{owner}/{repo}/issues', { owner: 'nrwl', @@ -114,12 +111,36 @@ async function getAllLabels(): Promise { return labels; } -type IssueItem = Awaited< +export type IssueItem = Awaited< ReturnType >['data'][number]; -function isNotPullRequest(issue: IssueItem): boolean { - return !('pull_request' in issue) || issue.pull_request == null; +function isPullRequest(issue: IssueItem): boolean { + return issue.pull_request != null; +} + +function prClosedAt(pr: IssueItem): Date { + return new Date(pr.pull_request?.merged_at ?? pr.closed_at); +} + +function toIssue(issue: IssueItem, scopeLabels: string[]): ScrapedIssue { + return { + scopes: scopesOn(issue, scopeLabels), + createdAt: new Date(issue.created_at), + bug: hasLabel(issue, 'type: bug'), + }; +} + +function toPr(pr: IssueItem, scopeLabels: string[]): ScrapedPr { + return { + scopes: scopesOn(pr, scopeLabels), + createdAt: new Date(pr.created_at), + merged: pr.pull_request?.merged_at != null, + }; +} + +function scopesOn(issue: IssueItem, scopeLabels: string[]): string[] { + return scopeLabels.filter((scope) => hasLabel(issue, scope)); } function hasLabel(issue: IssueItem, labelName: string): boolean { diff --git a/scripts/issues-scraper/stats.spec.ts b/scripts/issues-scraper/stats.spec.ts new file mode 100644 index 00000000000..9e5fcfda621 --- /dev/null +++ b/scripts/issues-scraper/stats.spec.ts @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { ReportData, ScrapedData } from './model'; +import { average, buildReport, getTrendData, percentile } from './stats'; + +const now = new Date('2026-08-30T00:00:00Z'); +const since = new Date('2026-08-23T00:00:00Z'); +const daysAgo = (days: number) => + new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + +describe('percentile', () => { + it('uses nearest-rank so the result is always a real sample', () => { + const values = Array.from({ length: 20 }, (_, i) => i + 1); + assert.equal(percentile(values, 0.95), 19); + assert.equal(percentile([5], 0.95), 5); + }); + + it('is 0 for an empty sample', () => { + assert.equal(percentile([], 0.95), 0); + }); +}); + +describe('average', () => { + it('rounds to whole days', () => { + assert.equal(average([1, 2, 4]), 2); + assert.equal(average([]), 0); + }); +}); + +describe('buildReport', () => { + const data: ScrapedData = { + openIssues: [ + { scopes: ['scope: core'], bug: true, createdAt: daysAgo(10) }, + { + scopes: ['scope: core', 'scope: react'], + bug: false, + createdAt: daysAgo(30), + }, + { scopes: [], bug: true, createdAt: daysAgo(2) }, + ], + closedIssues: [ + { scopes: ['scope: core'], bug: false, createdAt: daysAgo(100) }, + { scopes: [], bug: false, createdAt: daysAgo(100) }, + ], + openPrs: [ + { scopes: ['scope: core'], merged: false, createdAt: daysAgo(3) }, + { scopes: [], merged: false, createdAt: daysAgo(40) }, + ], + closedPrs: [ + { scopes: ['scope: core'], merged: true, createdAt: daysAgo(4) }, + { scopes: ['scope: react'], merged: false, createdAt: daysAgo(20) }, + ], + }; + const report = buildReport(data, since, now); + + it('fills the Everything row from every item', () => { + assert.deepEqual(report.all.issues, { + count: 3, + bugCount: 2, + closed: 2, + avgAge: 14, + p95Age: 30, + }); + assert.deepEqual(report.all.prs, { + open: 2, + created: 2, + merged: 1, + closed: 1, + avgAge: 22, + p95Age: 40, + }); + }); + + it('fills the Unscoped row from items with no scope label', () => { + assert.deepEqual(report.unscoped.issues, { + count: 1, + bugCount: 1, + closed: 1, + avgAge: 2, + p95Age: 2, + }); + assert.deepEqual(report.unscoped.prs, { + open: 1, + created: 0, + merged: 0, + closed: 0, + avgAge: 40, + p95Age: 40, + }); + }); + + it('creates one row per scope label seen on any item', () => { + assert.deepEqual(Object.keys(report.scopes).sort(), [ + 'scope: core', + 'scope: react', + ]); + assert.deepEqual(report.scopes['scope: core'].issues, { + count: 2, + bugCount: 1, + closed: 1, + avgAge: 20, + p95Age: 30, + }); + assert.deepEqual(report.scopes['scope: core'].prs, { + open: 1, + created: 2, + merged: 1, + closed: 0, + avgAge: 3, + p95Age: 3, + }); + assert.deepEqual(report.scopes['scope: react'].prs, { + open: 0, + created: 0, + merged: 0, + closed: 1, + avgAge: 0, + p95Age: 0, + }); + }); +}); + +describe('getTrendData', () => { + const stats = (n: number) => ({ + 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(10), + unscoped: stats(4), + scopes: { 'scope: core': stats(6), 'scope: new': stats(2) }, + }; + + it('subtracts the previous report field by field', () => { + const prev: ReportData = { + all: stats(7), + unscoped: stats(5), + scopes: { 'scope: core': stats(6) }, + }; + const trend = getTrendData(current, prev); + assert.equal(trend.all.issues.count, 3); + assert.equal(trend.all.prs.avgAge, 3); + assert.equal(trend.unscoped.issues.bugCount, -1); + assert.equal(trend.scopes['scope: core'].prs.merged, 0); + }); + + it('reports no delta when there is no previous row', () => { + const trend = getTrendData(current, {}); + assert.equal(trend.all.issues.count, null); + assert.equal(trend.all.issues.avgAge, null); + assert.equal(trend.scopes['scope: new'].prs.open, null); + assert.equal(trend.scopes['scope: new'].prs.p95Age, null); + }); +}); diff --git a/scripts/issues-scraper/stats.ts b/scripts/issues-scraper/stats.ts new file mode 100644 index 00000000000..839290d6e4d --- /dev/null +++ b/scripts/issues-scraper/stats.ts @@ -0,0 +1,157 @@ +import { + IssueStats, + PrStats, + ReportData, + ScopeData, + ScopeTrend, + ScrapedData, + ScrapedIssue, + ScrapedItem, + ScrapedPr, + StatsTrend, + TrendData, +} from './model'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export function average(values: number[]): number { + if (values.length === 0) { + return 0; + } + return Math.round(values.reduce((sum, v) => sum + v, 0) / values.length); +} + +export function percentile(values: number[], p: number): number { + if (values.length === 0) { + return 0; + } + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.max(0, Math.ceil(p * sorted.length) - 1)]; +} + +export function buildReport( + data: ScrapedData, + since: Date, + now: Date +): ReportData { + const scopeLabels = new Set(); + for (const items of Object.values(data)) { + for (const item of items) { + item.scopes.forEach((s) => scopeLabels.add(s)); + } + } + + const scopes: Record = {}; + for (const scope of scopeLabels) { + scopes[scope] = computeScopeData( + filterData(data, (item) => item.scopes.includes(scope)), + since, + now + ); + } + + return { + all: computeScopeData(data, since, now), + unscoped: computeScopeData( + filterData(data, (item) => item.scopes.length === 0), + since, + now + ), + scopes, + }; +} + +function filterData( + data: ScrapedData, + predicate: (item: ScrapedItem) => boolean +): ScrapedData { + return { + openIssues: data.openIssues.filter(predicate), + closedIssues: data.closedIssues.filter(predicate), + openPrs: data.openPrs.filter(predicate), + closedPrs: data.closedPrs.filter(predicate), + }; +} + +function computeScopeData( + data: ScrapedData, + since: Date, + now: Date +): ScopeData { + return { + issues: computeIssueStats(data.openIssues, data.closedIssues, now), + prs: computePrStats(data.openPrs, data.closedPrs, since, now), + }; +} + +function computeIssueStats( + open: ScrapedIssue[], + closed: ScrapedIssue[], + now: Date +): IssueStats { + const ages = open.map((i) => ageInDays(i, now)); + return { + count: open.length, + bugCount: open.filter((i) => i.bug).length, + closed: closed.length, + avgAge: average(ages), + p95Age: percentile(ages, 0.95), + }; +} + +function computePrStats( + open: ScrapedPr[], + closed: ScrapedPr[], + since: Date, + now: Date +): PrStats { + const ages = open.map((pr) => ageInDays(pr, now)); + const createdSince = (pr: ScrapedPr) => pr.createdAt >= since; + return { + open: open.length, + created: + open.filter(createdSince).length + closed.filter(createdSince).length, + merged: closed.filter((pr) => pr.merged).length, + closed: closed.filter((pr) => !pr.merged).length, + avgAge: average(ages), + p95Age: percentile(ages, 0.95), + }; +} + +function ageInDays(item: ScrapedItem, now: Date): number { + return Math.floor((now.getTime() - item.createdAt.getTime()) / MS_PER_DAY); +} + +export function getTrendData( + current: ReportData, + previous: Partial +): TrendData { + const scopes: Record = {}; + for (const [scope, data] of Object.entries(current.scopes)) { + scopes[scope] = scopeTrend(data, previous.scopes?.[scope]); + } + return { + all: scopeTrend(current.all, previous.all), + unscoped: scopeTrend(current.unscoped, previous.unscoped), + scopes, + }; +} + +function scopeTrend(current: ScopeData, previous?: ScopeData): ScopeTrend { + return { + issues: statsTrend(current.issues, previous?.issues), + prs: statsTrend(current.prs, previous?.prs), + }; +} + +function statsTrend>( + current: T, + previous?: T +): StatsTrend { + const trend = {} as StatsTrend; + for (const field of Object.keys(current) as (keyof T)[]) { + const prev = previous?.[field]; + trend[field] = prev === undefined ? null : current[field] - prev; + } + return trend; +}