From 6626ba86cbfc3f7f2f6c40c6673b4b1ec15fc9e1 Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Wed, 29 Jul 2026 09:50:05 -0500 Subject: [PATCH] Add scatterplot of releases vs. issue-ids --- scripts/generate-release-statistics.mjs | 95 +++++++- test/release-statistics.test.mjs | 33 ++- .../statistics/GlobalProtect-Android.json | 1 + web/data/statistics/GlobalProtect-Linux.json | 1 + web/data/statistics/GlobalProtect-iOS.json | 1 + web/data/statistics/GlobalProtect.json | 1 + web/data/statistics/PAN-OS.json | 1 + web/data/statistics/Prisma-Access-Agent.json | 1 + web/data/statistics/products.json | 1 + web/js/statistics.js | 228 ++++++++++++++++++ web/js/ui.js | 5 + web/statistics.html | 23 +- web/styles.css | 96 ++++++++ 13 files changed, 481 insertions(+), 6 deletions(-) create mode 100644 web/data/statistics/GlobalProtect-Android.json create mode 100644 web/data/statistics/GlobalProtect-Linux.json create mode 100644 web/data/statistics/GlobalProtect-iOS.json create mode 100644 web/data/statistics/GlobalProtect.json create mode 100644 web/data/statistics/PAN-OS.json create mode 100644 web/data/statistics/Prisma-Access-Agent.json create mode 100644 web/data/statistics/products.json diff --git a/scripts/generate-release-statistics.mjs b/scripts/generate-release-statistics.mjs index 11cd751..beab311 100644 --- a/scripts/generate-release-statistics.mjs +++ b/scripts/generate-release-statistics.mjs @@ -1,17 +1,70 @@ #!/usr/bin/env node -import { mkdir, writeFile } from 'node:fs/promises'; +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(); @@ -143,7 +196,28 @@ export function renderStatisticsPage(statistics) {
-

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

+

Explore addressed Issue-IDs by release, followed by counts of the releases represented in the issue data.

+
+
+
+

Addressed issues by release

+

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

+
+
+ + +
+
+
+ + +
+

+
${statistics.map(renderProduct).join('\n')}
@@ -152,12 +226,25 @@ ${statistics.map(renderProduct).join('\n')} `; } -export async function generateReleaseStatistics({ issuesDir, outputPath }) { +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) }; + 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() { diff --git a/test/release-statistics.test.mjs b/test/release-statistics.test.mjs index fce05ae..18ecf5f 100644 --- a/test/release-statistics.test.mjs +++ b/test/release-statistics.test.mjs @@ -1,6 +1,36 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildReleaseStatistics, renderStatisticsPage } from '../scripts/generate-release-statistics.mjs'; +import { + buildIssuePlotProduct, + buildReleaseStatistics, + extractAddressedIssueIds, + renderStatisticsPage +} from '../scripts/generate-release-statistics.mjs'; + +test('extracts unique Issue-IDs only from Markdown issue headings', () => { + const markdown = `## PAN-123456 + +Mentions PAN-999999 in the description. + +## GPC-001234 + +## PAN-123456 +`; + assert.deepEqual(extractAddressedIssueIds(markdown), ['PAN-123456', 'GPC-001234']); +}); + +test('builds compact plot points in semantic release order', () => { + const plot = buildIssuePlotProduct('PAN-OS', new Map([ + ['10.2.10-h1', ['PAN-130000']], + ['10.2.9', ['PAN-100000', 'WF500-120000']] + ])); + assert.deepEqual(plot.releases, ['10.2.9', '10.2.10-h1']); + assert.deepEqual(plot.points, [ + [0, 100000, 'PAN-100000'], + [0, 120000, 'WF500-120000'], + [1, 130000, 'PAN-130000'] + ]); +}); test('counts unique releases at major and minor levels and hotfixes at patch level', () => { const records = [ @@ -36,6 +66,7 @@ test('renders the nested levels into a static HTML page', () => { assert.match(html, /class="stats-patch"[^>]+hidden/); assert.match(html, /class="stats-toggle"[^>]+aria-expanded="false"/); assert.match(html, /src="js\/statistics\.js"/); + assert.match(html, /id="issue-plot-canvas"/); assert.match(html, />1 hotfix { if (expanded) collapseDescendants(row); }); }); + +const productSelect = document.getElementById('issue-plot-product'); +const trainSelect = document.getElementById('issue-plot-train'); +const canvas = document.getElementById('issue-plot-canvas'); +const tooltip = document.getElementById('issue-plot-tooltip'); +const summary = document.getElementById('issue-plot-summary'); +const context = canvas?.getContext('2d'); +const productCache = new Map(); +let currentProduct = null; +let renderedPoints = []; +let hoveredPoint = null; + +function releaseTrain(version) { + return version.match(/^\d+\.\d+/)?.[0] ?? version; +} + +function fillSelect(select, options) { + select.replaceChildren(...options.map(value => { + const option = document.createElement('option'); + option.value = value; + option.textContent = value; + return option; + })); +} + +async function loadProduct(filename) { + if (!productCache.has(filename)) { + productCache.set(filename, fetch(`data/statistics/${filename}`).then(response => { + if (!response.ok) throw new Error(`Unable to load graph data (${response.status})`); + return response.json(); + })); + } + return productCache.get(filename); +} + +function plotColors() { + const styles = getComputedStyle(document.documentElement); + return { + text: styles.getPropertyValue('--text-muted').trim() || '#59636e', + grid: styles.getPropertyValue('--border').trim() || '#d0d7de', + point: styles.getPropertyValue('--link').trim() || '#0969da', + hover: styles.getPropertyValue('--focus').trim() || '#bf8700' + }; +} + +function formatIssueNumber(value) { + return Number(value).toLocaleString('en-US'); +} + +function drawPlot() { + if (!context || !currentProduct) return; + const selectedTrain = trainSelect.value; + const releaseIndexes = currentProduct.releases + .map((version, index) => ({ version, index })) + .filter(item => releaseTrain(item.version) === selectedTrain); + const indexPositions = new Map(releaseIndexes.map((item, position) => [item.index, position])); + const points = currentProduct.points.filter(point => indexPositions.has(point[0])); + + const bounds = canvas.getBoundingClientRect(); + const ratio = window.devicePixelRatio || 1; + canvas.width = Math.max(1, Math.round(bounds.width * ratio)); + canvas.height = Math.max(1, Math.round(bounds.height * ratio)); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + + const width = bounds.width; + const height = bounds.height; + const margin = { top: 22, right: 18, bottom: 92, left: 76 }; + const plotWidth = Math.max(1, width - margin.left - margin.right); + const plotHeight = Math.max(1, height - margin.top - margin.bottom); + const colors = plotColors(); + const values = points.map(point => point[1]); + if (values.length === 0) { + context.clearRect(0, 0, bounds.width, bounds.height); + context.fillStyle = colors.text; + context.font = '14px system-ui, sans-serif'; + context.textAlign = 'center'; + context.fillText('No addressed Issue-IDs in this release train.', bounds.width / 2, bounds.height / 2); + renderedPoints = []; + summary.textContent = `0 addressed issues across ${releaseIndexes.length.toLocaleString('en-US')} releases.`; + return; + } + const minimum = Math.min(...values); + const maximum = Math.max(...values); + const range = Math.max(1, maximum - minimum); + const yMin = Math.max(0, minimum - range * 0.04); + const yMax = maximum + range * 0.04; + const xFor = position => margin.left + ( + releaseIndexes.length === 1 ? plotWidth / 2 : position * plotWidth / (releaseIndexes.length - 1) + ); + const yFor = value => margin.top + (yMax - value) / (yMax - yMin) * plotHeight; + + context.clearRect(0, 0, width, height); + context.font = '12px system-ui, sans-serif'; + context.fillStyle = colors.text; + context.strokeStyle = colors.grid; + context.lineWidth = 1; + + for (let tick = 0; tick <= 5; tick++) { + const y = margin.top + tick * plotHeight / 5; + const value = yMax - tick * (yMax - yMin) / 5; + context.beginPath(); + context.moveTo(margin.left, y); + context.lineTo(width - margin.right, y); + context.stroke(); + context.textAlign = 'right'; + context.textBaseline = 'middle'; + context.fillText(formatIssueNumber(Math.round(value)), margin.left - 9, y); + } + + context.save(); + context.translate(16, margin.top + plotHeight / 2); + context.rotate(-Math.PI / 2); + context.textAlign = 'center'; + context.fillText('Issue-ID number', 0, 0); + context.restore(); + + releaseIndexes.forEach((item, position) => { + const x = xFor(position); + context.save(); + context.translate(x, height - margin.bottom + 10); + context.rotate(-Math.PI / 3); + context.textAlign = 'right'; + context.textBaseline = 'middle'; + context.fillText(item.version, 0, 0); + context.restore(); + }); + + const overlaps = new Map(); + renderedPoints = points.map(point => { + const position = indexPositions.get(point[0]); + const overlapKey = `${point[0]}:${point[1]}`; + const overlap = overlaps.get(overlapKey) ?? 0; + overlaps.set(overlapKey, overlap + 1); + return { + x: xFor(position) + (overlap % 3 - 1) * 4, + y: yFor(point[1]) - Math.floor(overlap / 3) * 4, + id: point[2], + issueNumber: point[1], + release: currentProduct.releases[point[0]] + }; + }); + + renderedPoints.forEach(point => { + context.beginPath(); + const hovered = point.id === hoveredPoint?.id && point.release === hoveredPoint?.release; + context.arc(point.x, point.y, hovered ? 5 : 3, 0, Math.PI * 2); + context.fillStyle = hovered ? colors.hover : colors.point; + context.globalAlpha = hovered ? 1 : 0.72; + context.fill(); + }); + context.globalAlpha = 1; + summary.textContent = `${points.length.toLocaleString('en-US')} addressed issue${points.length === 1 ? '' : 's'} across ${releaseIndexes.length.toLocaleString('en-US')} release${releaseIndexes.length === 1 ? '' : 's'}.`; +} + +function pointNear(event) { + const bounds = canvas.getBoundingClientRect(); + const x = event.clientX - bounds.left; + const y = event.clientY - bounds.top; + return renderedPoints.reduce((nearest, point) => { + const distance = Math.hypot(point.x - x, point.y - y); + return distance <= 9 && (!nearest || distance < nearest.distance) ? { point, distance } : nearest; + }, null)?.point ?? null; +} + +canvas?.addEventListener('pointermove', event => { + const point = pointNear(event); + if (point === hoveredPoint) return; + hoveredPoint = point; + canvas.style.cursor = point ? 'pointer' : 'default'; + if (point) { + tooltip.textContent = `${point.id} ยท ${point.release}`; + tooltip.hidden = false; + const frame = canvas.parentElement.getBoundingClientRect(); + tooltip.style.left = `${Math.min(event.clientX - frame.left + 12, frame.width - tooltip.offsetWidth - 8)}px`; + tooltip.style.top = `${Math.max(8, event.clientY - frame.top - tooltip.offsetHeight - 10)}px`; + } else { + tooltip.hidden = true; + } + drawPlot(); +}); + +canvas?.addEventListener('pointerleave', () => { + hoveredPoint = null; + tooltip.hidden = true; + drawPlot(); +}); + +canvas?.addEventListener('click', event => { + const point = pointNear(event); + if (point) window.location.href = `index.html?issue=${encodeURIComponent(point.id)}`; +}); + +trainSelect?.addEventListener('change', () => { + hoveredPoint = null; + tooltip.hidden = true; + drawPlot(); +}); + +productSelect?.addEventListener('change', async () => { + try { + currentProduct = await loadProduct(productSelect.value); + fillSelect(trainSelect, Array.from(new Set(currentProduct.releases.map(releaseTrain))).reverse()); + drawPlot(); + } catch (error) { + summary.textContent = error.message; + } +}); + +if (canvas) { + new ResizeObserver(drawPlot).observe(canvas); + fetch('data/statistics/products.json') + .then(response => { + if (!response.ok) throw new Error(`Unable to load graph products (${response.status})`); + return response.json(); + }) + .then(products => { + productSelect.replaceChildren(...products.map(([product, filename]) => { + const option = document.createElement('option'); + option.value = filename; + option.textContent = product; + return option; + })); + productSelect.dispatchEvent(new Event('change')); + }) + .catch(error => { + summary.textContent = error.message; + }); +} diff --git a/web/js/ui.js b/web/js/ui.js index 63906bc..691d58d 100644 --- a/web/js/ui.js +++ b/web/js/ui.js @@ -51,6 +51,11 @@ export function applyIssueSearchFilter() { function initializeIssueSearch() { const searchInput = document.getElementById('issue-search'); + const initialQuery = new URLSearchParams(window.location.search).get('issue'); + if (initialQuery) { + searchInput.value = initialQuery; + issueSearchTerms = initialQuery.trim().toLowerCase().split(/\s+/).filter(Boolean); + } searchInput.addEventListener('input', () => { issueSearchTerms = searchInput.value.trim().toLowerCase().split(/\s+/).filter(Boolean); applyIssueSearchFilter(); diff --git a/web/statistics.html b/web/statistics.html index 51179e3..311b9f9 100644 --- a/web/statistics.html +++ b/web/statistics.html @@ -21,7 +21,28 @@
-

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

+

Explore addressed Issue-IDs by release, followed by counts of the releases represented in the issue data.

+
+
+
+

Addressed issues by release

+

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

+
+
+ + +
+
+
+ + +
+

+

GlobalProtect 50 releases

diff --git a/web/styles.css b/web/styles.css index 88ffb23..c5f0fdc 100644 --- a/web/styles.css +++ b/web/styles.css @@ -279,6 +279,87 @@ li:hover { line-height: 1.5; } +.issue-plot { + margin: 0 0 42px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 20px; + background: var(--surface); +} + +.issue-plot-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 16px; +} + +.issue-plot-heading h2, +.issue-plot-heading p, +.issue-plot-summary { + margin: 0; +} + +.issue-plot-heading p, +.issue-plot-summary { + color: var(--text-muted); + font-size: 0.9rem; +} + +.issue-plot-controls { + display: flex; + flex: 0 0 auto; + gap: 12px; +} + +.issue-plot-controls label { + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 600; +} + +.issue-plot-controls select { + display: block; + min-width: 150px; + margin-top: 4px; + border: 1px solid var(--border); + border-radius: 4px; + padding: 7px 28px 7px 9px; + background: var(--surface); + color: var(--text); + font: inherit; +} + +.issue-plot-frame { + position: relative; + min-height: 460px; +} + +#issue-plot-canvas { + display: block; + width: 100%; + height: 460px; +} + +.issue-plot-tooltip { + position: absolute; + z-index: 1; + pointer-events: none; + border-radius: 4px; + padding: 6px 9px; + background: var(--header); + color: var(--header-text); + font-size: 0.82rem; + white-space: nowrap; +} + +.issue-plot-summary { + min-height: 1.3em; + margin-top: 8px; + text-align: center; +} + .stats-product { margin-top: 32px; } @@ -418,6 +499,21 @@ li:hover { gap: 3px; } + .issue-plot { + padding: 14px 10px; + } + + .issue-plot-heading, + .issue-plot-controls { + align-items: stretch; + flex-direction: column; + gap: 10px; + } + + .issue-plot-controls select { + width: 100%; + } + .stats-table th, .stats-table td { padding: 8px 10px;