From d2b126c3f81d59f22f87360157a4c352e205ef07 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:54:10 +0100 Subject: [PATCH] Recognise edited Markdown issue fields without false declarations Use one ATX heading recogniser for version, feedback, secondary topics and field boundaries. Preserve lightly indented headings and separated closing hashes, reject non-headings, and exercise the real label consumer without contact or release lookups. Contract-Neutral: Respect Markdown heading boundaries within the existing issue metadata classifier; no product runtime, API, rendered UI or triage-authority delta. Change-source: pulse-maintainer --- .github/scripts/issue-version-triage.cjs | 30 +++-- .github/scripts/issue-version-triage.test.cjs | 111 ++++++++++++++++++ 2 files changed, 131 insertions(+), 10 deletions(-) diff --git a/.github/scripts/issue-version-triage.cjs b/.github/scripts/issue-version-triage.cjs index edc0ebd406..0a66aa0b2b 100644 --- a/.github/scripts/issue-version-triage.cjs +++ b/.github/scripts/issue-version-triage.cjs @@ -10,6 +10,15 @@ function escapeRegExp(value) { return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function atxHeading(text) { + // Edited issue forms still use Markdown: up to three leading spaces and + // one to six hashes, separated from the text by whitespace. A closing hash + // run is optional, but only closes a heading when preceded by whitespace. + const match = text.match(/^ {0,3}#{1,6}(?:[ \t]+(.*)|$)/); + if (!match) return null; + return (match[1] || "").replace(/(?:^|[ \t]+)#+[ \t]*$/, "").trim(); +} + function declarationLines(body) { let fence = null; return stripHTMLComments(body).split(/\r?\n/).map((text) => { @@ -28,8 +37,8 @@ function declarationLines(body) { } // Pasted examples and logs are evidence, not issue-form structure. Keep // their text as a value under a real field, but never scan it for fields. - return { text, delimiter, declaration: !inCode && !delimiter && - !/^(?: {4}|\t)/.test(text) }; + const declaration = !inCode && !delimiter && !/^(?: {4}|\t)/.test(text); + return { text, delimiter, declaration, heading: declaration ? atxHeading(text) : null }; }); } @@ -41,13 +50,14 @@ function firstFieldValue(lines) { function extractSectionValue(body, heading, followingHeadings = []) { if (!body) return null; const lines = declarationLines(body); - const pattern = new RegExp(`^#+[ \\t]*${escapeRegExp(heading)}[ \\t]*$`, "i"); - const start = lines.findIndex((line) => line.declaration && pattern.test(line.text)); + const pattern = new RegExp(`^${escapeRegExp(heading)}$`, "i"); + const start = lines.findIndex((line) => line.heading !== null && pattern.test(line.heading)); if (start === -1) return null; - const boundary = new RegExp(`^#+[ \\t]*(?:${followingHeadings.length - ? followingHeadings.map(escapeRegExp).join("|") : "[^\\n]+"})[ \\t]*$`, "i"); + const boundary = followingHeadings.length + ? new RegExp(`^(?:${followingHeadings.map(escapeRegExp).join("|")})$`, "i") : null; let end = start + 1; - while (end < lines.length && !(lines[end].declaration && boundary.test(lines[end].text))) { + while (end < lines.length && !(lines[end].heading !== null && + (!boundary || boundary.test(lines[end].heading)))) { end += 1; } // An empty declared field differs from a legacy report with no field. @@ -178,7 +188,7 @@ function extractPulseVersion(title, body) { if (body) { const lines = declarationLines(body); const versionHeading = lines.findIndex((line) => - line.declaration && /^#{1,6}[ \t]+Pulse[ \t]+version[ \t]*$/i.test(line.text) + line.heading !== null && /^Pulse[ \t]+version$/i.test(line.heading) ); if (versionHeading !== -1) { // The explicit running-version field is authoritative, even when it is @@ -188,8 +198,8 @@ function extractPulseVersion(title, body) { } for (let i = 0; i < lines.length; i += 1) { if (!lines[i].declaration) continue; - const line = lines[i].text.replace(/\*\*|__/g, "") - .replace(/^[ \t]*(?:#{1,6}[ \t]+|[-*][ \t]+)/, "").trim(); + const line = (lines[i].heading ?? lines[i].text).replace(/\*\*|__/g, "") + .replace(/^[ \t]*[-*][ \t]+/, "").trim(); const field = line.match( /^Pulse[ \t]*(?:[|-][ \t]*)?version(?:[ \t]*[:|][ \t]*|[ \t]+|$)(.*)$/i ); diff --git a/.github/scripts/issue-version-triage.test.cjs b/.github/scripts/issue-version-triage.test.cjs index 9380c41249..6f9167feb1 100644 --- a/.github/scripts/issue-version-triage.test.cjs +++ b/.github/scripts/issue-version-triage.test.cjs @@ -320,6 +320,117 @@ test("additional topic classification is explicit and fail-quiet for legacy form ); }); +test("edited form headings accept Markdown indentation and closing hashes", () => { + const { extractPulseVersion, classifyAdditionalActionableTopics, classifyV6FeedbackType } = triage.internals; + for (const newline of ["\n", "\r\n"]) { + for (let level = 1; level <= 6; level += 1) { + for (let indent = 0; indent <= 3; indent += 1) { + for (const closing of ["", " #", " ######## \t"]) { + const heading = (name) => " ".repeat(indent) + "#".repeat(level) + "\t" + name + closing; + const body = [heading("Pulse version"), "6.5.0", "", + heading("Feedback type"), "Documentation issue", "", + heading("Additional actionable topics"), "The second host loses its Docker inventory."] + .join(newline); + assert.equal(extractPulseVersion("Upgrade from v6.4.5", body), "6.5.0", body); + assert.equal(classifyV6FeedbackType(body), "documentation", body); + assert.equal(classifyAdditionalActionableTopics(body), true, body); + } + } + } + } +}); + +test("closing hashes and indentation do not let neighbouring fields become topics", () => { + for (const followingField of ["Pulse version", "Additional context", "Logs, screenshots, or diagnostics", "Confirmations"]) { + const body = [" ### Additional actionable topics ###", "None", "", + ` ## ${followingField} ####`, "An error outside the declared topic field."].join("\n"); + assert.equal(triage.internals.classifyAdditionalActionableTopics(body), false, body); + } + const body = [" ### Additional actionable topics #", "None", "", + " #### Separate backup problem ###", "Backups belong to the wrong host.", "", + " ### Pulse version ####", "6.5.0"].join("\n"); + assert.equal(triage.internals.classifyAdditionalActionableTopics(body), true); +}); + +test("plain hash-prefixed text cannot declare issue-form metadata", () => { + const { extractPulseVersion, classifyAdditionalActionableTopics, classifyV6FeedbackType } = triage.internals; + for (const prefix of ["####### ", "###", " ### ", "\t### ", "> ### "]) { + const body = [prefix + "Pulse version", "6.5.0", "", + prefix + "Feedback type", "Bug / regression", "", + prefix + "Additional actionable topics", "Copied template text."].join("\n"); + assert.equal(extractPulseVersion("No version in title", body), null, body); + assert.equal(classifyV6FeedbackType(body), null, body); + assert.equal(classifyAdditionalActionableTopics(body), null, body); + } + // A seven-hash line inside a topic is ordinary field content, not a boundary. + assert.equal(classifyAdditionalActionableTopics( + "### Additional actionable topics\nNone\n####### Pulse version\nA second symptom.\n" + ), true); +}); + +test("only a separated final hash run closes an issue-form heading", () => { + for (const suffix of ["#", " ### example", " ## #"]) { + const body = `### Additional actionable topics${suffix}\nA second bug.\n`; + assert.equal(triage.internals.classifyAdditionalActionableTopics(body), null, body); + } +}); + +test("edited headings stay hidden inside comments and pasted code", () => { + const { extractPulseVersion, classifyAdditionalActionableTopics, classifyV6FeedbackType } = triage.internals; + const copied = [" ### Pulse version ###", "6.4.1", " ### Feedback type ###", + "Bug / regression", " ### Additional actionable topics ###", "Copied example."].join("\n"); + for (const [open, close] of [[""], ["```markdown", "```"], ["~~~markdown", "~~~"]]) { + const body = [open, copied, close, " ### Pulse version ###", "unknown", + " ### Feedback type ###", "Documentation issue", " ### Additional actionable topics ###", "None"] + .join("\n"); + assert.equal(extractPulseVersion("Upgrade from v6.4.5", body), null, body); + assert.equal(classifyV6FeedbackType(body), "documentation", body); + assert.equal(classifyAdditionalActionableTopics(body), false, body); + } +}); + +test("edited version headings retain fenced values and authoritative unknowns", () => { + for (const value of ["6.5.0-rc.1", "unknown", "6.5"]) { + const body = [" ### Pulse version ###", "```text", value, "```", "", + " ### Agent version ###", "6.4.5"].join("\n"); + assert.equal(triage.internals.extractPulseVersion("Upgrade from v6.4.5", body), + value === "6.5.0-rc.1" ? value : null, body); + } +}); + +test("label consumer uses edited headings without changing Community-owned state", async () => { + const { github, calls } = createGithub(); + const issue = { number: 2900, title: "Upgrade from v6.4.5", + body: " ### Feedback type ###\nBug / regression\n ### Pulse version ###\n6.5.0\n" + + " ### Additional actionable topics ###\nThe second host loses Docker inventory.\n", + labels: ["enhancement", "needs-retest-on-latest", "needs-human", "operator-reviewed", "affects-6.4.5"] + .map(name => ({ name })) }; + await syncLabels({ github, core: createCore(), context: createContext({ issue }) }); + assert.deepEqual(calls.addLabels.map(call => call.labels), + [["affects-6.5.0", "bug", "needs-decomposition"]]); + assert.deepEqual(calls.removeLabel.map(call => call.name), ["affects-6.4.5"]); + assert.deepEqual(calls.createComment, []); + assert.deepEqual(calls.getLatestRelease, []); + assert.deepEqual(calls.paginate, []); + assert.deepEqual(calls.getIssue.map(call => call.issue_number), [2900]); +}); + +test("invalid headings cannot create classification or decomposition mutations", async () => { + const { github, calls } = createGithub(); + const issue = { number: 2901, title: "An example in a documentation report", + body: "####### Feedback type\nBug / regression\n####### Pulse version\n6.5.0\n" + + "####### Additional actionable topics\nCopied template text.\n", + labels: ["documentation", "needs-retest-on-latest", "needs-human", "operator-reviewed"] + .map(name => ({ name })) }; + await syncLabels({ github, core: createCore(), context: createContext({ issue }) }); + assert.deepEqual(calls.addLabels, []); + assert.deepEqual(calls.removeLabel, []); + assert.deepEqual(calls.createLabel, []); + assert.deepEqual(calls.createComment, []); + assert.deepEqual(calls.getLatestRelease, []); + assert.deepEqual(calls.paginate, []); +}); + test("topic classification reads later paragraphs after an empty first line", () => { for (const firstLine of ["None", "", "_No response_"]) { const body = [