From cb6ce0b3c539c0991e14d3ad6602e14288acd8bd Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Wed, 29 Jul 2026 13:12:46 -0500 Subject: [PATCH] Add option to show known issues scatterplot --- scripts/generate-release-statistics.mjs | 58 ++++++++++++++----- test/checkbox-tree.test.mjs | 12 +++- test/release-statistics.test.mjs | 27 +++++++++ .../statistics/GlobalProtect-Android.json | 2 +- web/data/statistics/GlobalProtect-Linux.json | 2 +- web/data/statistics/GlobalProtect-iOS.json | 2 +- web/data/statistics/GlobalProtect.json | 2 +- web/data/statistics/PAN-OS.json | 2 +- web/data/statistics/Prisma-Access-Agent.json | 2 +- web/js/script.js | 4 +- web/js/statistics.js | 56 ++++++++++++++---- web/js/tree.js | 13 ++++- web/js/ui.js | 6 +- web/statistics.html | 6 ++ web/styles.css | 35 +++++++++++ 15 files changed, 192 insertions(+), 37 deletions(-) diff --git a/scripts/generate-release-statistics.mjs b/scripts/generate-release-statistics.mjs index 3662f16..3851983 100644 --- a/scripts/generate-release-statistics.mjs +++ b/scripts/generate-release-statistics.mjs @@ -38,31 +38,55 @@ export function extractAddressedIssueIds(markdown) { return Array.from(new Set(ids)); } +export function extractIssuePlotEntries(markdown, { detectResolved = false } = {}) { + const text = String(markdown); + const headings = Array.from(text.matchAll(ISSUE_HEADING_RE)); + return headings.flatMap((heading, index) => { + const bodyStart = heading.index + heading[0].length; + const bodyEnd = headings[index + 1]?.index ?? text.length; + const body = text.slice(bodyStart, bodyEnd); + const resolved = detectResolved && Array.from(body.matchAll(/```caveat\s*\r?\n([\s\S]*?)```/gi)) + .some(match => /\bresolved\b/i.test(match[1])); + return extractIssueIds(heading[1]).map(id => ({ id, resolved })); + }); +} + 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)) { + for (const entry of releases.get(version)) { + const { id, resolved = false } = typeof entry === 'string' ? { id: entry } : entry; if (id === BLANK_ISSUE_ID) continue; const issueNumber = Number(id.match(/-(\d+)$/)?.[1]); - if (Number.isSafeInteger(issueNumber)) points.push([releaseIndex, issueNumber, id]); + if (Number.isSafeInteger(issueNumber)) { + points.push(resolved + ? [releaseIndex, issueNumber, id, 1] + : [releaseIndex, issueNumber, id]); + } } }); - return { product, releases: versions, points }; + return { 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); + await Promise.all(records.map(async record => { + const markdown = await readFile(join(issuesDir, record.product, record.issueType, record.filename), 'utf8'); + const entries = extractIssuePlotEntries(markdown, { detectResolved: record.issueType === 'known' }); + const product = products.get(record.product) ?? { + addressed: new Map(), + known: new Map() + }; + product[record.issueType].set(record.version, entries); + products.set(record.product, product); })); - return Array.from(products, ([product, releases]) => buildIssuePlotProduct(product, releases)) - .filter(item => item.points.length > 0) + return Array.from(products, ([product, issueTypes]) => ({ + product, + addressed: buildIssuePlotProduct(product, issueTypes.addressed), + known: buildIssuePlotProduct(product, issueTypes.known) + })) + .filter(item => item.addressed.points.length > 0 || item.known.points.length > 0) .sort((a, b) => a.product.localeCompare(b.product)); } @@ -211,6 +235,11 @@ export function renderStatisticsPage(statistics) { +
+ Issue type + + +
@@ -220,6 +249,7 @@ export function renderStatisticsPage(statistics) {
Base release Hotfix release + Resolved known issue

@@ -247,7 +277,9 @@ export async function generateReleaseStatistics({ issuesDir, outputPath, plotDat return { products: statistics.length, releases: statistics.reduce((sum, item) => sum + item.releases, 0), - points: plotData.reduce((sum, item) => sum + item.points.length, 0) + points: plotData.reduce((sum, item) => + sum + item.addressed.points.length + item.known.points.length + , 0) }; } diff --git a/test/checkbox-tree.test.mjs b/test/checkbox-tree.test.mjs index cdb2768..02d3a2c 100644 --- a/test/checkbox-tree.test.mjs +++ b/test/checkbox-tree.test.mjs @@ -9,7 +9,7 @@ globalThis.Element = dom.window.Element; globalThis.localStorage = dom.window.localStorage; const { CheckboxTree } = await import('../web/vendor/checkbox-tree/checkbox-tree.js'); -const { findAddressedReleaseNode } = await import('../web/js/tree.js'); +const { findAddressedReleaseNode, findIssueReleaseNode } = await import('../web/js/tree.js'); function createTree(options = {}) { const container = document.createElement('div'); @@ -108,10 +108,18 @@ test('rejects duplicate node ids', () => { test('finds an addressed release leaf by product and exact filename', () => { const nodes = [ { id: 'known', metadata: { path: ['PAN-OS'], addressed: [] } }, - { id: 'release', metadata: { path: ['PAN-OS', '11', '11.1'], addressed: ['11.1.13-h3.md'] } }, + { + id: 'release', + metadata: { + path: ['PAN-OS', '11', '11.1'], + addressed: ['11.1.13-h3.md'], + known: ['11.1.13.md'] + } + }, { id: 'other', metadata: { path: ['GlobalProtect'], addressed: ['11.1.13-h3.md'] } } ]; assert.equal(findAddressedReleaseNode(nodes, 'PAN-OS', '11.1.13-h3')?.id, 'release'); assert.equal(findAddressedReleaseNode(nodes, 'PAN-OS', '11.1.13'), undefined); + assert.equal(findIssueReleaseNode(nodes, 'PAN-OS', '11.1.13', 'known')?.id, 'release'); }); diff --git a/test/release-statistics.test.mjs b/test/release-statistics.test.mjs index 5755512..0cf2fad 100644 --- a/test/release-statistics.test.mjs +++ b/test/release-statistics.test.mjs @@ -4,6 +4,7 @@ import { buildIssuePlotProduct, buildReleaseStatistics, extractAddressedIssueIds, + extractIssuePlotEntries, renderStatisticsPage } from '../scripts/generate-release-statistics.mjs'; @@ -32,6 +33,31 @@ test('builds compact plot points in semantic release order', () => { ]); }); +test('detects resolved only inside the issue own caveat block', () => { + const entries = extractIssuePlotEntries(`## PAN-100000 + +\`\`\`caveat +This issue is now resolved. +\`\`\` + +## PAN-200000 + +The word resolved appears in the description only. + +## PAN-300000 + +\`\`\`caveat +Applies to resolved DNS names. +\`\`\` +`, { detectResolved: true }); + + assert.deepEqual(entries, [ + { id: 'PAN-100000', resolved: true }, + { id: 'PAN-200000', resolved: false }, + { id: 'PAN-300000', resolved: true } + ]); +}); + test('counts unique releases at major and minor levels and hotfixes at patch level', () => { const records = [ { product: 'PAN-OS', issueType: 'known', version: '10.1.2' }, @@ -65,5 +91,6 @@ test('renders the graph without displaying the legacy release tables', () => { assert.match(html, /id="issue-plot-canvas"/); assert.match(html, />Base releaseHotfix releaseResolved known issue console.error('Error loading products:', error)); diff --git a/web/js/statistics.js b/web/js/statistics.js index 5a5f1fa..df624ae 100644 --- a/web/js/statistics.js +++ b/web/js/statistics.js @@ -26,6 +26,7 @@ document.addEventListener('click', event => { const productSelect = document.getElementById('issue-plot-product'); const trainSelect = document.getElementById('issue-plot-train'); +const typeInputs = Array.from(document.querySelectorAll('input[name="issue-plot-type"]')); const canvas = document.getElementById('issue-plot-canvas'); const tooltip = document.getElementById('issue-plot-tooltip'); const summary = document.getElementById('issue-plot-summary'); @@ -37,6 +38,7 @@ let hoveredPoint = null; const initialParams = new URLSearchParams(window.location.search); const initialProduct = initialParams.get('product') || 'PAN-OS'; const initialTrain = initialParams.get('train') || '11.1'; +const initialType = initialParams.get('type') === 'known' ? 'known' : 'addressed'; let restoreInitialSelection = true; function releaseTrain(version) { @@ -52,11 +54,26 @@ function fillSelect(select, options) { })); } +function selectedIssueType() { + return typeInputs.find(input => input.checked)?.value ?? 'addressed'; +} + +function currentSeries() { + return currentProduct?.[selectedIssueType()] ?? { releases: [], points: [] }; +} + +function refreshTrainOptions(preferredTrain = trainSelect.value) { + const trains = Array.from(new Set(currentSeries().releases.map(releaseTrain))).reverse(); + fillSelect(trainSelect, trains); + if (trains.includes(preferredTrain)) trainSelect.value = preferredTrain; +} + function updatePlotQuery() { if (!currentProduct || !trainSelect.value) return; const url = new URL(window.location.href); url.searchParams.set('product', currentProduct.product); url.searchParams.set('train', trainSelect.value); + url.searchParams.set('type', selectedIssueType()); history.replaceState(null, '', url); } @@ -77,6 +94,7 @@ function plotColors() { grid: styles.getPropertyValue('--border').trim() || '#d0d7de', base: styles.getPropertyValue('--plot-base').trim() || '#2e8b57', hotfix: styles.getPropertyValue('--plot-hotfix').trim() || '#2c7be5', + resolved: styles.getPropertyValue('--plot-resolved').trim() || '#8b5cf6', hover: styles.getPropertyValue('--plot-hover').trim() || '#d97706' }; } @@ -87,12 +105,14 @@ function formatIssueNumber(value) { function drawPlot() { if (!context || !currentProduct) return; + const issueType = selectedIssueType(); + const series = currentSeries(); const selectedTrain = trainSelect.value; - const releaseIndexes = currentProduct.releases + const releaseIndexes = series.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 points = series.points.filter(point => indexPositions.has(point[0])); const bounds = canvas.getBoundingClientRect(); const ratio = window.devicePixelRatio || 1; @@ -112,9 +132,9 @@ function drawPlot() { 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); + context.fillText(`No ${issueType} 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.`; + summary.textContent = `0 ${issueType} issues across ${releaseIndexes.length.toLocaleString('en-US')} releases.`; return; } const minimum = Math.min(...values); @@ -174,7 +194,8 @@ function drawPlot() { y: yFor(point[1]) - Math.floor(overlap / 3) * 4, id: point[2], issueNumber: point[1], - release: currentProduct.releases[point[0]] + release: series.releases[point[0]], + resolved: Boolean(point[3]) }; }); @@ -184,12 +205,14 @@ function drawPlot() { context.arc(point.x, point.y, hovered ? 5 : 3, 0, Math.PI * 2); context.fillStyle = hovered ? colors.hover - : (/-h\d+(?:\D|$)/i.test(point.release) ? colors.hotfix : colors.base); + : (point.resolved + ? colors.resolved + : (/-h\d+(?:\D|$)/i.test(point.release) ? colors.hotfix : colors.base)); 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'}.`; + summary.textContent = `${points.length.toLocaleString('en-US')} ${issueType} issue${points.length === 1 ? '' : 's'} across ${releaseIndexes.length.toLocaleString('en-US')} release${releaseIndexes.length === 1 ? '' : 's'}.`; } function pointNear(event) { @@ -208,7 +231,7 @@ canvas?.addEventListener('pointermove', event => { hoveredPoint = point; canvas.style.cursor = point ? 'pointer' : 'default'; if (point) { - tooltip.textContent = `${point.id} · ${point.release}`; + tooltip.textContent = `${point.id} · ${point.release}${point.resolved ? ' · resolved' : ''}`; tooltip.hidden = false; const frame = canvas.parentElement.getBoundingClientRect(); tooltip.style.left = `${Math.min(event.clientX - frame.left + 12, frame.width - tooltip.offsetWidth - 8)}px`; @@ -231,7 +254,8 @@ canvas?.addEventListener('click', event => { const params = new URLSearchParams({ issue: point.id, product: currentProduct.product, - release: point.release + release: point.release, + type: selectedIssueType() }); window.location.href = `index.html?${params}`; } @@ -244,10 +268,22 @@ trainSelect?.addEventListener('change', () => { drawPlot(); }); +typeInputs.forEach(input => { + input.checked = input.value === initialType; + input.addEventListener('change', () => { + if (!input.checked || !currentProduct) return; + hoveredPoint = null; + tooltip.hidden = true; + refreshTrainOptions(); + updatePlotQuery(); + drawPlot(); + }); +}); + productSelect?.addEventListener('change', async () => { try { currentProduct = await loadProduct(productSelect.value); - fillSelect(trainSelect, Array.from(new Set(currentProduct.releases.map(releaseTrain))).reverse()); + refreshTrainOptions(restoreInitialSelection ? initialTrain : ''); if (restoreInitialSelection && initialProduct === currentProduct.product && initialTrain && Array.from(trainSelect.options).some(option => option.value === initialTrain)) { trainSelect.value = initialTrain; diff --git a/web/js/tree.js b/web/js/tree.js index 0f23e1a..a72743c 100644 --- a/web/js/tree.js +++ b/web/js/tree.js @@ -63,8 +63,12 @@ export function getCheckedFileRefs() { } export function selectAddressedRelease(product, release) { + return selectIssueRelease(product, release, 'addressed'); +} + +export function selectIssueRelease(product, release, issueType = 'addressed') { if (!productTree || !product || !release) return false; - const node = findAddressedReleaseNode(productTree.nodesById.values(), product, release); + const node = findIssueReleaseNode(productTree.nodesById.values(), product, release, issueType); if (!node) return false; productTree.setSelectedIds([node.id]); @@ -73,10 +77,15 @@ export function selectAddressedRelease(product, release) { } export function findAddressedReleaseNode(nodes, product, release) { + return findIssueReleaseNode(nodes, product, release, 'addressed'); +} + +export function findIssueReleaseNode(nodes, product, release, issueType = 'addressed') { + if (issueType !== 'addressed' && issueType !== 'known') return undefined; const filename = `${release}.md`; return Array.from(nodes).find(candidate => candidate.metadata?.path?.[0] === product && - candidate.metadata?.addressed?.includes(filename) + candidate.metadata?.[issueType]?.includes(filename) ); } diff --git a/web/js/ui.js b/web/js/ui.js index a05fc1c..96dcef2 100644 --- a/web/js/ui.js +++ b/web/js/ui.js @@ -65,8 +65,10 @@ function initializeIssueSearch() { function initializeIssueTypeFilters(onSelectionChange) { const addressedFilter = document.getElementById('filter-addressed'); const knownFilter = document.getElementById('filter-known'); - if (new URLSearchParams(window.location.search).has('release')) { - issueTypeFilters.addressed = true; + const params = new URLSearchParams(window.location.search); + if (params.has('release')) { + const issueType = params.get('type') === 'known' ? 'known' : 'addressed'; + issueTypeFilters[issueType] = true; saveTypeFilters(); } diff --git a/web/statistics.html b/web/statistics.html index b81957e..4f3361b 100644 --- a/web/statistics.html +++ b/web/statistics.html @@ -35,6 +35,11 @@ +
+ Issue type + + +
@@ -44,6 +49,7 @@
Base release Hotfix release + Resolved known issue

diff --git a/web/styles.css b/web/styles.css index e3624fe..b0f0b43 100644 --- a/web/styles.css +++ b/web/styles.css @@ -17,6 +17,7 @@ --button-hover: #2e7d32; --plot-base: #2e8b57; --plot-hotfix: #2c7be5; + --plot-resolved: #8b5cf6; --plot-hover: #d97706; --shadow: rgba(20, 35, 50, 0.14); } @@ -40,6 +41,7 @@ --button-hover: #4ca653; --plot-base: #56b881; --plot-hotfix: #79b8ff; + --plot-resolved: #c4a7ff; --plot-hover: #f2a93b; --shadow: rgba(0, 0, 0, 0.35); } @@ -337,6 +339,35 @@ li:hover { font: inherit; } +.issue-plot-type { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + border: 0; + padding: 0; +} + +.issue-plot-type legend { + margin-bottom: 4px; + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 600; +} + +.issue-plot-type label { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--text); + font-size: 0.85rem; + font-weight: 400; +} + +.issue-plot-type input { + margin: 0; +} + .issue-plot-frame { position: relative; } @@ -393,6 +424,10 @@ li:hover { background: var(--plot-hotfix); } +.issue-plot-key-resolved { + background: var(--plot-resolved); +} + .stats-product { margin-top: 32px; }