Integrate reviewed legacy issue version classification repair

Preserve exact reviewed commits 50a1e466c6 and 92cf192103. Retained source-6f3apw81 covers 51 parser and label-flow tests, docs/mirror checks and discriminating parent/compatibility controls. No new product surface or runtime frontend change.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-10 03:31:03 +01:00
4 changed files with 143 additions and 22 deletions
+29 -22
View File
@@ -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);
@@ -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 <!-- example: v6.4.5 -->"), null);
assert.equal(extractPulseVersion("upgrade from v6.4.5", "<!-- ### Pulse version\n6.4.5 -->\nPulse version: unknown"), null);
assert.equal(extractPulseVersion("Bug", "Pulse | Version\n<!-- example\n6.4.5\n-->\nV6.5.0-rc.1"), "6.5.0-rc.1");
assert.equal(extractPulseVersion("Bug", "<!-- ### Pulse version\n6.4.5 -->\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 <!-- example: v6.4.5 -->",
]) {
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 = {
+9
View File
@@ -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.
@@ -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.