From 41acf9ca6f261bf7355369cad498cff90a1e504d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:51:38 +0000 Subject: [PATCH] Keep feedback classification bound to the visible selection Read only the selected feedback option rather than bug-like words in link destinations, titles, prose or withdrawn examples. Preserve all current dropdown choices, legacy Bug and Regression values, maintainer classifications and unrelated Community labels. Registered consumer controls exercise the silent label delta without contact or release inference. Contract-Neutral: Silent issue-label metadata only; no product runtime, frontend, dependency, access or canonical subsystem contract delta. Change-source: pulse-maintainer --- .github/scripts/issue-version-triage.cjs | 41 +++++---- .github/scripts/issue-version-triage.test.cjs | 90 +++++++++++++++++++ 2 files changed, 112 insertions(+), 19 deletions(-) diff --git a/.github/scripts/issue-version-triage.cjs b/.github/scripts/issue-version-triage.cjs index b671c22681..5bb799a630 100644 --- a/.github/scripts/issue-version-triage.cjs +++ b/.github/scripts/issue-version-triage.cjs @@ -279,26 +279,29 @@ function classifyV6FeedbackType(body) { const feedbackType = extractSectionValue(body, "Feedback type"); if (!feedbackType) return null; - // This is a single-choice field, not the later examples pasted beneath it. - const normalized = firstFieldValue(declarationLines(feedbackType)).toLowerCase(); - if ( - normalized.includes("bug") || - normalized.includes("regression") || - normalized.includes("upgrade / migration issue") || - normalized.includes("performance issue") - ) { - return BUG_LABEL; + // This is a single-choice field, not free-form evidence. A link's target + // or title must not turn documentation/workflow feedback into a bug, and + // prose, images or a withdrawn selection cannot supply a selected option. + const selected = firstFieldValue(declarationLines(feedbackType)); + const blocks = lexer(selected, { gfm: true }); + const visible = blocks.length === 1 && blocks[0].type === "paragraph" + ? inlineDeclarationText(blocks[0].tokens) : selected; + const normalized = visible.trim().toLowerCase().replace(/[ \t]+/g, " "); + switch (normalized) { + case "bug": + case "regression": + case "bug / regression": + case "upgrade / migration issue": + case "performance issue": + return BUG_LABEL; + case "documentation issue": + return DOCS_LABEL; + case "ux / workflow friction": + case "other actionable feedback": + return ENHANCEMENT_LABEL; + default: + return null; } - if (normalized.includes("documentation issue")) { - return DOCS_LABEL; - } - if ( - normalized.includes("ux / workflow friction") || - normalized.includes("other actionable feedback") - ) { - return ENHANCEMENT_LABEL; - } - return null; } function parseCore(version) { diff --git a/.github/scripts/issue-version-triage.test.cjs b/.github/scripts/issue-version-triage.test.cjs index c9b3016e98..71a96c0103 100644 --- a/.github/scripts/issue-version-triage.test.cjs +++ b/.github/scripts/issue-version-triage.test.cjs @@ -204,6 +204,96 @@ test("syncLabels only adds documentation classification for non-bug v6 feedback" assert.deepEqual(calls.addLabels[0].labels, ["documentation"]); }); + +test("feedback classification follows visible selected text, not link destinations or titles", () => { + const { classifyV6FeedbackType } = triage.internals; + for (const newline of ["\n", "\r\n"]) { + for (const [selection, expected] of [ + ['[Documentation issue](https://example.invalid/bugs)', "documentation"], + ['[Documentation issue](https://example.invalid/context "Bug / regression")', "documentation"], + ['[**Documentation issue**](https://example.invalid/bugs "Performance issue")', "documentation"], + ['[UX / workflow friction](https://example.invalid/bugs)', "enhancement"], + ['[Other actionable feedback](https://example.invalid/regression)', "enhancement"], + ['[Unknown](https://example.invalid/bugs "Performance issue")', null], + ['Documentation issue [details](https://example.invalid/bugs)', null], + ]) { + assert.equal(classifyV6FeedbackType([ + "### Feedback type", selection, "", "### Pulse version", "unknown", + ].join(newline)), expected, selection); + } + } +}); + +test("feedback classification accepts each form choice and legacy bug selections", () => { + const form = fs.readFileSync(path.resolve(__dirname, "../ISSUE_TEMPLATE/v6_rc_feedback.yml"), "utf8"); + const field = form.split(" id: feedback_type\n")[1].split(" - type: ")[0]; + const selections = [...field.matchAll(/^ - (.+)$/gm)].map(match => match[1]); + const expected = { + "Bug / regression": "bug", + "Upgrade / migration issue": "bug", + "UX / workflow friction": "enhancement", + "Performance issue": "bug", + "Documentation issue": "documentation", + "Other actionable feedback": "enhancement", + }; + assert.deepEqual(selections.sort(), Object.keys(expected).sort()); + for (const [selection, label] of Object.entries({ ...expected, Bug: "bug", Regression: "bug" })) { + for (const value of [selection, selection.toUpperCase(), `**${selection}**`, + `\`${selection}\``, `[${selection}](https://example.invalid/context)`]) { + assert.equal(triage.internals.classifyV6FeedbackType(`### Feedback type\n${value}\n`), label, value); + } + } +}); + +test("examples, withdrawn selections and prose do not invent a feedback class", () => { + for (const selection of [ + '![Bug / regression](https://example.invalid/image.png)', + '~~Bug / regression~~', '> Bug / regression', 'Bug / regression', + 'Not a bug; asking about debugging', 'Unknown; Performance issue elsewhere', + '_No response_', 'unknown', '', + ]) { + assert.equal(triage.internals.classifyV6FeedbackType( + `### Feedback type\n${selection}\n\n### What happened?\nBug / regression\n`), null, selection); + } +}); + +test("syncLabels consumes the selected feedback class without invented version demands or contact", async () => { + for (const action of ["opened", "edited", "reopened"]) { + for (const [selection, classification] of [ + ['[Documentation issue](https://example.invalid/bugs "Performance issue")', "documentation"], + ['[UX / workflow friction](https://example.invalid/bugs)', "enhancement"], + ['[Unknown](https://example.invalid/bugs)', null], + ['![Bug / regression](https://example.invalid/image.png)', null], + ['~~Bug / regression~~', null], + ]) { + const { github, calls } = createGithub(); + const labels = ["needs-human", "needs-retest-on-latest", "needs-decomposition", "operator-reviewed"]; + const issue = { number: 2930, title: "Existing feedback", labels: labels.map(name => ({ name })), + body: `### Feedback type\n${selection}\n\n### Pulse version\nunknown\n` }; + await syncLabels({ github, core: createCore(), context: createContext({ action, issue }) }); + assert.deepEqual(calls.addLabels.map(call => call.labels), classification ? [[classification]] : [], selection); + assert.deepEqual(calls.createLabel, [], selection); + assert.deepEqual(calls.removeLabel, [], selection); + assert.deepEqual(calls.createComment, [], selection); + assert.deepEqual(calls.getLatestRelease, [], selection); + assert.deepEqual(calls.paginate, [], selection); + } + } +}); + +test("an unrecognised feedback selection cannot undo an existing maintainer bug classification", async () => { + const { github, calls } = createGithub(); + const labels = ["bug", "affects-6.5.0", "needs-human", "needs-retest-on-latest", "needs-decomposition"]; + const issue = { number: 2931, title: "Existing report", labels: labels.map(name => ({ name })), + body: '### Feedback type\n[Unknown](https://example.invalid/bugs)\n\n### Pulse version\n6.5.0\n' }; + await syncLabels({ github, core: createCore(), context: createContext({ issue }) }); + assert.deepEqual(calls.addLabels, []); + assert.deepEqual(calls.removeLabel, []); + assert.deepEqual(calls.createComment, []); + assert.deepEqual(calls.getLatestRelease, []); + assert.deepEqual(calls.paginate, []); +}); + test("syncLabels marks declared secondary topics for decomposition", async () => { const { github, calls } = createGithub({ latestVersion: "6.4.1" }); const issue = {