#!/usr/bin/env node import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { collectIssueFiles } from './update-products-from-issues.mjs'; import { extractIssueIds } from '../web/js/issue-id.js'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const VERSION_RE = /^(?\d+)(?:\.(?\d+))?(?:\.(?\d+))?(?.*)$/; const HOTFIX_RE = /-h\d+(?:\D|$)/i; const ISSUE_HEADING_RE = /^##\s+(.+?)\s*$/gm; function compareNumbers(left, right) { return Number(left) - Number(right); } function productDataFilename(product) { const slug = product.replace(/[^a-z0-9.-]+/gi, '-').replace(/^-+|-+$/g, ''); return `${slug || 'product'}.json`; } function compareVersions(left, right) { const leftParts = left.match(/\d+/g)?.map(Number) ?? []; const rightParts = right.match(/\d+/g)?.map(Number) ?? []; const length = Math.max(leftParts.length, rightParts.length); for (let index = 0; index < length; index++) { const difference = (leftParts[index] ?? -1) - (rightParts[index] ?? -1); if (difference) return difference; } return left.localeCompare(right); } export function extractAddressedIssueIds(markdown) { const ids = []; for (const match of String(markdown).matchAll(ISSUE_HEADING_RE)) { ids.push(...extractIssueIds(match[1])); } return Array.from(new Set(ids)); } export function buildIssuePlotProduct(product, releases) { const versions = Array.from(releases.keys()).sort(compareVersions); const points = []; versions.forEach((version, releaseIndex) => { for (const id of releases.get(version)) { const issueNumber = Number(id.match(/-(\d+)$/)?.[1]); if (Number.isSafeInteger(issueNumber)) points.push([releaseIndex, issueNumber, id]); } }); return { product, releases: versions, points }; } export async function buildIssuePlotData(records, issuesDir) { const products = new Map(); const addressed = records.filter(record => record.issueType === 'addressed'); await Promise.all(addressed.map(async record => { const markdown = await readFile(join(issuesDir, record.product, 'addressed', record.filename), 'utf8'); const ids = extractAddressedIssueIds(markdown); const releases = products.get(record.product) ?? new Map(); releases.set(record.version, ids); products.set(record.product, releases); })); return Array.from(products, ([product, releases]) => buildIssuePlotProduct(product, releases)) .filter(item => item.points.length > 0) .sort((a, b) => a.product.localeCompare(b.product)); } export function buildReleaseStatistics(records) { const products = new Map(); for (const { product, version } of records) { const match = VERSION_RE.exec(version); if (!match?.groups.minor) continue; const productVersions = products.get(product) ?? new Set(); productVersions.add(version); products.set(product, productVersions); } return Array.from(products, ([product, versions]) => { const majors = new Map(); for (const version of versions) { const { major, minor, patch } = VERSION_RE.exec(version).groups; const majorNode = majors.get(major) ?? { version: major, releases: 0, minors: new Map() }; const minorVersion = `${major}.${minor}`; const minorNode = majorNode.minors.get(minor) ?? { version: minorVersion, releases: 0, patches: new Map() }; majorNode.releases += 1; minorNode.releases += 1; if (patch !== undefined) { const patchVersion = `${minorVersion}.${patch}`; const patchNode = minorNode.patches.get(patch) ?? { version: patchVersion, hotfixes: 0 }; if (HOTFIX_RE.test(version)) patchNode.hotfixes += 1; minorNode.patches.set(patch, patchNode); } majorNode.minors.set(minor, minorNode); majors.set(major, majorNode); } return { product, releases: versions.size, majors: Array.from(majors.values()) .sort((a, b) => compareNumbers(b.version, a.version)) .map(major => ({ ...major, minors: Array.from(major.minors.values()) .sort((a, b) => compareNumbers(b.version.split('.')[1], a.version.split('.')[1])) .map(minor => ({ ...minor, patches: Array.from(minor.patches.values()) .sort((a, b) => compareNumbers( b.version.split('.')[2], a.version.split('.')[2] )) })) })) }; }).sort((a, b) => a.product.localeCompare(b.product)); } function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function plural(count, singular, pluralForm = `${singular}s`) { return `${count.toLocaleString('en-US')} ${count === 1 ? singular : pluralForm}`; } function renderProduct({ product, releases, majors }, productIndex) { const rows = majors.flatMap((major, majorIndex) => { const majorId = `p${productIndex}-major-${majorIndex}`; return [ `${plural(major.releases, 'release')}`, ...major.minors.flatMap((minor, minorIndex) => { const minorId = `${majorId}-minor-${minorIndex}`; return [ `${plural(minor.releases, 'release')}`, ...minor.patches.map(patch => `${patch.version}${plural(patch.hotfixes, 'hotfix', 'hotfixes')}` ) ]; }) ]; }).join('\n'); return `

${escapeHtml(product)} ${plural(releases, 'release')}

${rows}
VersionCount
`; } export function renderStatisticsPage(statistics) { return ` Release statistics - Firewall issues tool

Release statistics

Explore addressed Issue-IDs by product and release.

Addressed issues by release

Each dot is an Issue-ID. Hover for details or select a dot to find that issue.

Base release Hotfix release

`; } export async function generateReleaseStatistics({ issuesDir, outputPath, plotDataDir = join(dirname(outputPath), 'data/statistics') }) { const records = await collectIssueFiles(issuesDir); const statistics = buildReleaseStatistics(records); const plotData = await buildIssuePlotData(records, issuesDir); await mkdir(dirname(outputPath), { recursive: true }); await mkdir(plotDataDir, { recursive: true }); const plotProducts = []; for (const item of plotData) { const filename = productDataFilename(item.product); await writeFile(join(plotDataDir, filename), JSON.stringify(item), 'utf8'); plotProducts.push([item.product, filename]); } await writeFile(join(plotDataDir, 'products.json'), JSON.stringify(plotProducts), 'utf8'); await writeFile(outputPath, renderStatisticsPage(statistics), 'utf8'); return { products: statistics.length, releases: statistics.reduce((sum, item) => sum + item.releases, 0), points: plotData.reduce((sum, item) => sum + item.points.length, 0) }; } export async function runGenerateStatisticsCli() { const result = await generateReleaseStatistics({ issuesDir: join(REPO_ROOT, 'web/data/issues'), outputPath: join(REPO_ROOT, 'web/statistics.html') }); console.log(`Generated release statistics for ${result.products} product(s) and ${result.releases} release(s).`); return result; } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { await runGenerateStatisticsCli(); }