From 50a1e466c6349ba0940af47b41fc36dc4db9a847 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:06:27 +0100 Subject: [PATCH 1/2] Keep legacy issue version labels bound to the declared value Unknown or incomplete legacy Pulse version fields could inherit an adjacent agent or platform version, an upgrade source in the title, or a hidden template example. Give explicit legacy fields the same authority as headed forms, consume only their first visible conventional value and ignore HTML template comments. Add parser and mocked label-flow regressions plus matching triage guidance; preserve silent classification, Community label ownership and no-refile/no-diagnostic boundaries. Change-source: pulse-maintainer Contract-Neutral: Repairs the existing issue metadata parser without changing form fields, supported labels, public contact or disposition authority, product APIs or subsystem contracts. --- .github/scripts/issue-version-triage.cjs | 51 ++++++----- .github/scripts/issue-version-triage.test.cjs | 88 +++++++++++++++++++ docs/ISSUE_TRIAGE.md | 9 ++ frontend-modern/public/docs/ISSUE_TRIAGE.md | 9 ++ 4 files changed, 135 insertions(+), 22 deletions(-) diff --git a/.github/scripts/issue-version-triage.cjs b/.github/scripts/issue-version-triage.cjs index 1ff4f8a6fb..0088c86ce5 100644 --- a/.github/scripts/issue-version-triage.cjs +++ b/.github/scripts/issue-version-triage.cjs @@ -67,9 +67,18 @@ function normalizeVersion(value) { return match ? match[1] : null; } +function normalizeLegacyVersionValue(value) { + const visible = String(value).trim().replace(/^[`*_]+/, ""); + // Recognise conventional version, image and release-asset values, without + // mining a sentence such as "unknown; agent version 6.5.0" for a number. + return /^(?:v?\d+\.\d+\.\d+\b|(?:[a-z0-9._/-]+\/)?pulse:|pulse[-_])/i.test(visible) + ? normalizeVersion(visible) : null; +} + function extractPulseVersion(title, body) { if (body) { - const lines = body.split(/\r?\n/); + // Hidden template examples and headings are not reporter evidence. + const lines = stripHTMLComments(body).split(/\r?\n/); const versionHeading = lines.findIndex((line) => /^#{1,6}[ \t]+Pulse[ \t]+version[ \t]*$/i.test(line) ); @@ -82,32 +91,30 @@ function extractPulseVersion(title, body) { if (/^#{1,6}[ \t]+/.test(lines[i])) break; value.push(lines[i]); } - return normalizeVersion(stripHTMLComments(value.join("\n"))); + return normalizeVersion(value.join("\n")); } for (let i = 0; i < lines.length; i += 1) { - const line = lines[i] || ""; - if (/pulse\s*(\||-)?\s*version/i.test(line)) { - const inlineVersion = normalizeVersion(line); - if (inlineVersion) return inlineVersion; + const line = lines[i].replace(/\*\*|__/g, "") + .replace(/^[ \t]*(?:#{1,6}[ \t]+|[-*][ \t]+)/, "").trim(); + const field = line.match( + /^Pulse[ \t]*(?:[|-][ \t]*)?version(?:[ \t]*[:|][ \t]*|[ \t]+|$)(.*)$/i + ); + if (!field) continue; - for (let j = i + 1; j < Math.min(i + 6, lines.length); j += 1) { - const nearby = (lines[j] || "").trim(); - if (!nearby) continue; - const nearbyVersion = normalizeVersion(nearby); - if (nearbyVersion) return nearbyVersion; - } + // Legacy inline/standalone fields have the same authority as the form. + // Unknown or incomplete must not borrow an agent/platform version or + // an upgrade's starting version from the title. + const inlineValue = field[1].trim(); + if (inlineValue) return normalizeLegacyVersionValue(inlineValue); + for (let j = i + 1; j < lines.length; j += 1) { + const value = lines[j].trim(); + if (!value || /^(?:```|~~~)[\w-]*$/.test(value)) continue; + // Accept a version or image reference as the first visible value, + // not a neighbouring heading, named field, log or prose paragraph. + return normalizeLegacyVersionValue(value); } + return null; } - - const headingMatch = body.match( - /#+\s*Pulse version[\s\S]{0,80}?(\bv?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b)/i - ); - if (headingMatch) return normalizeVersion(headingMatch[1]); - - const legacyMatch = body.match( - /pulse\s*\|?\s*version[^\n]*?(\bv?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b)/i - ); - if (legacyMatch) return normalizeVersion(legacyMatch[1]); } return normalizeVersion(title); diff --git a/.github/scripts/issue-version-triage.test.cjs b/.github/scripts/issue-version-triage.test.cjs index ba88964d41..ebc34375d2 100644 --- a/.github/scripts/issue-version-triage.test.cjs +++ b/.github/scripts/issue-version-triage.test.cjs @@ -551,6 +551,94 @@ test("structured Pulse version never falls back to an upgrade source or agent ve assert.equal(extractPulseVersion("bug on v5.1.35", "### Pulse version"), null); }); +test("legacy version fields keep unknown and incomplete values authoritative", () => { + const { extractPulseVersion } = triage.internals; + for (const field of ["Pulse version:", "**Pulse Version:**", "Pulse | Version", "## Pulse - Version"]) { + for (const value of ["unknown", "_No response_", "6.4", "6"]) { + for (const neighbour of ["### Agent version\n6.5.0", "Operating system: Debian 13.0.0"]) { + const body = `${field}\n${value}\n\n${neighbour}`; + assert.equal(extractPulseVersion("upgrade from v6.4.5", body), null, body); + } + } + } + for (const value of ["unknown", "6.4", "_No response_"]) { + assert.equal(extractPulseVersion("upgrade from v6.4.5", `Pulse version: ${value}; Agent version 6.5.0`), null); + } +}); + +test("legacy version fields stop at their first visible value", () => { + const { extractPulseVersion } = triage.internals; + for (const value of [ + "### Agent version\n6.5.0", + "Agent version: 6.5.0", + "**OS / environment:** Debian 13.0.0", + "Agent version\n6.5.0", + "Not available; the agent runs 6.5.0", + "```text\nunknown\n```\n### Agent version\n6.5.0", + "", + ]) { + assert.equal(extractPulseVersion("upgrade from v6.4.5", `Pulse | Version\n\n${value}`), null, value); + } + // Prose mentioning a field is not itself a version declaration. + assert.equal(extractPulseVersion("Bug", "I cannot find the Pulse version.\nAgent version: 6.5.0"), null); +}); + +test("legacy version extraction ignores hidden template versions and headings", () => { + const { extractPulseVersion } = triage.internals; + assert.equal(extractPulseVersion("install", "Pulse version: unknown "), null); + assert.equal(extractPulseVersion("upgrade from v6.4.5", "\nPulse version: unknown"), null); + assert.equal(extractPulseVersion("Bug", "Pulse | Version\n\nV6.5.0-rc.1"), "6.5.0-rc.1"); + assert.equal(extractPulseVersion("Bug", "\n**Pulse Version:** 6.5.0"), "6.5.0"); +}); + +test("legacy version formats still classify actual version values", () => { + const { extractPulseVersion } = triage.internals; + for (const body of [ + "Pulse version: V6.5.0-rc.1", + "**Pulse Version:** `v6.5.0-rc.1`", + "- **Pulse version**: 6.5.0-rc.1", + "Pulse | Version\n\nV6.5.0-rc.1\nAgent version: 6.4.5", + "## Pulse - Version\r\n\r\n```text\r\n6.5.0-rc.1\r\n```\r\n", + "Pulse version: rcourtman/pulse:6.5.0-rc.1", + "Pulse | Version\n`rcourtman/pulse:6.5.0-rc.1`", + ]) { + assert.equal(extractPulseVersion("upgrade from v6.4.5", body), "6.5.0-rc.1", body); + } +}); + +test("legacy unknown-version sync cannot fabricate release evidence or contact", async () => { + for (const body of [ + "Pulse version: unknown\n### Agent version\n6.5.0", + "Pulse | Version\nunknown\nOperating system: Debian 13.0.0", + "Pulse version: unknown ", + ]) { + const { github, calls } = createGithub(); + const issue = { + number: 2805, title: "Upgrade from v6.4.5", body, + labels: ["bug", "needs-retest-on-latest", "needs-decomposition", "operator-reviewed"].map(name => ({ name })), + }; + await syncLabels({ github, context: createContext({ issue }), core: createCore() }); + assert.deepEqual(calls.addLabels.map(call => call.labels), [["needs-version-info"]]); + assert.deepEqual(calls.createLabel.map(call => call.name), ["needs-version-info"]); + assert.equal(calls.removeLabel.length, 0); + assert.equal(calls.getLatestRelease.length, 0); + assert.equal(calls.createComment.length, 0); + } +}); + +test("legacy known-version sync updates only classification labels", async () => { + const { github, calls } = createGithub(); + const issue = { + number: 2805, title: "Upgrade from v6.4.5", body: "**Pulse Version:** 6.5.0", + labels: ["bug", "affects-6.4.5", "needs-version-info", "needs-retest-on-latest", "needs-decomposition", "operator-reviewed"].map(name => ({ name })), + }; + await syncLabels({ github, context: createContext({ issue }), core: createCore() }); + assert.deepEqual(calls.addLabels.map(call => call.labels), [["affects-6.5.0"]]); + assert.deepEqual(calls.removeLabel.map(call => call.name), ["affects-6.4.5", "needs-version-info"]); + assert.equal(calls.getLatestRelease.length, 0); + assert.equal(calls.createComment.length, 0); +}); + test("ambiguous upgrade version requests information without a retest comment", async () => { const { github, calls } = createGithub({ latestVersion: "6.4.1" }); const issue = { diff --git a/docs/ISSUE_TRIAGE.md b/docs/ISSUE_TRIAGE.md index 624d089eff..3ea90ff049 100644 --- a/docs/ISSUE_TRIAGE.md +++ b/docs/ISSUE_TRIAGE.md @@ -210,6 +210,15 @@ Version classification does not need the latest release and cannot establish relevant-fix availability. The synchronizer makes no release lookup, public comment, retest request or closure. +An explicit **Pulse version** field is authoritative even in a legacy inline +or standalone report. An unknown or incomplete value must not be replaced by +an upgrade's starting version in the title, a nearby agent/platform version or +a hidden template example. A standalone legacy field uses only its first +visible value, not later fields, logs or prose. A `needs-version-info` label is +not proof that the full thread lacks the version; read existing evidence before +asking only for a consequential remaining distinction. No refile or new +diagnostics are required by version classification. + This boundary is deliberate: preserving an explicit reporter declaration is safe to automate, while deciding whether two observations are one root cause is a product and technical judgment. diff --git a/frontend-modern/public/docs/ISSUE_TRIAGE.md b/frontend-modern/public/docs/ISSUE_TRIAGE.md index 624d089eff..3ea90ff049 100644 --- a/frontend-modern/public/docs/ISSUE_TRIAGE.md +++ b/frontend-modern/public/docs/ISSUE_TRIAGE.md @@ -210,6 +210,15 @@ Version classification does not need the latest release and cannot establish relevant-fix availability. The synchronizer makes no release lookup, public comment, retest request or closure. +An explicit **Pulse version** field is authoritative even in a legacy inline +or standalone report. An unknown or incomplete value must not be replaced by +an upgrade's starting version in the title, a nearby agent/platform version or +a hidden template example. A standalone legacy field uses only its first +visible value, not later fields, logs or prose. A `needs-version-info` label is +not proof that the full thread lacks the version; read existing evidence before +asking only for a consequential remaining distinction. No refile or new +diagnostics are required by version classification. + This boundary is deliberate: preserving an explicit reporter declaration is safe to automate, while deciding whether two observations are one root cause is a product and technical judgment. From 92cf19210323458d64ee809e589aa2ba6b26cfb3 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:12:03 +0100 Subject: [PATCH 2/2] Preserve explicitly shared server and agent versions in legacy reports The complete live-backlog comparison caught the existing report #1788's shared server + pulse-agent v6.3.2 declaration. Keep that explicit field value rather than marking supplied evidence unknown; retain unknown and adjacent-field boundaries. Add a regression for the real report's version format. Change-source: pulse-maintainer Contract-Neutral: Preserves valid input compatibility in the existing silent issue metadata parser; no form, label, contact, product API or subsystem contract change. --- .github/scripts/issue-version-triage.cjs | 4 ++-- .github/scripts/issue-version-triage.test.cjs | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/scripts/issue-version-triage.cjs b/.github/scripts/issue-version-triage.cjs index 0088c86ce5..1c652232c7 100644 --- a/.github/scripts/issue-version-triage.cjs +++ b/.github/scripts/issue-version-triage.cjs @@ -69,9 +69,9 @@ function normalizeVersion(value) { function normalizeLegacyVersionValue(value) { const visible = String(value).trim().replace(/^[`*_]+/, ""); - // Recognise conventional version, image and release-asset values, without + // Keep an explicitly shared server/agent version (as in #1788), without // mining a sentence such as "unknown; agent version 6.5.0" for a number. - return /^(?:v?\d+\.\d+\.\d+\b|(?:[a-z0-9._/-]+\/)?pulse:|pulse[-_])/i.test(visible) + return /^(?:v?\d+\.\d+\.\d+\b|server(?:[ \t]*\+[ \t]*pulse-agent)?[ \t]+v?\d+\.\d+\.\d+\b|(?:[a-z0-9._/-]+\/)?pulse:|pulse[-_])/i.test(visible) ? normalizeVersion(visible) : null; } diff --git a/.github/scripts/issue-version-triage.test.cjs b/.github/scripts/issue-version-triage.test.cjs index ebc34375d2..271801bf29 100644 --- a/.github/scripts/issue-version-triage.test.cjs +++ b/.github/scripts/issue-version-triage.test.cjs @@ -606,6 +606,14 @@ test("legacy version formats still classify actual version values", () => { } }); +test("legacy joint server-agent versions remain reporter evidence", () => { + const { extractPulseVersion } = triage.internals; + const body = "**Pulse version:** server + pulse-agent v6.3.2 (Docker, `rcourtman/pulse:v6.3.2`)\n**Host OS:** Unraid 7.3.2\n**Agent:** Unified Agent v6.3.2, x86_64"; + assert.equal(extractPulseVersion("[Bug]: Unraid missing disks", body), "6.3.2"); + assert.equal(extractPulseVersion("Bug", "Pulse version: server V6.5.0"), "6.5.0"); + assert.equal(extractPulseVersion("Bug", "Pulse version: unknown; server + pulse-agent v6.3.2"), null); +}); + test("legacy unknown-version sync cannot fabricate release evidence or contact", async () => { for (const body of [ "Pulse version: unknown\n### Agent version\n6.5.0",