diff --git a/.github/scripts/issue-version-triage.cjs b/.github/scripts/issue-version-triage.cjs index 1ff4f8a6fb..1c652232c7 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(/^[`*_]+/, ""); + // 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|server(?:[ \t]*\+[ \t]*pulse-agent)?[ \t]+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..271801bf29 100644 --- a/.github/scripts/issue-version-triage.test.cjs +++ b/.github/scripts/issue-version-triage.test.cjs @@ -551,6 +551,102 @@ 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 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", + "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.