#!/usr/bin/env node import { mkdir, 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'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const VERSION_RE = /^(?\d+)(?:\.(?\d+))?(?:\.(?\d+))?(?.*)$/; const HOTFIX_RE = /-h\d+(?:\D|$)/i; function compareNumbers(left, right) { return Number(left) - Number(right); } 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

Unique releases represented in the issue data. Major and minor rows include all releases beneath them; patch rows count hotfix releases.

${statistics.map(renderProduct).join('\n')}
`; } export async function generateReleaseStatistics({ issuesDir, outputPath }) { const records = await collectIssueFiles(issuesDir); const statistics = buildReleaseStatistics(records); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, renderStatisticsPage(statistics), 'utf8'); return { products: statistics.length, releases: statistics.reduce((sum, item) => sum + item.releases, 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(); }