diff --git a/.github/scripts/issue-version-triage.cjs b/.github/scripts/issue-version-triage.cjs index 06340f6776..ca909ced89 100644 --- a/.github/scripts/issue-version-triage.cjs +++ b/.github/scripts/issue-version-triage.cjs @@ -197,12 +197,6 @@ function buildTriageState(issue, core, latestVersion) { } const hasAdditionalActionableTopics = classifyAdditionalActionableTopics(issue.body); - if (hasAdditionalActionableTopics === true) { - core.info("Issue declares additional actionable topics; decomposition is required."); - nextLabels.add(NEEDS_DECOMPOSITION_LABEL); - } else if (hasAdditionalActionableTopics === false) { - nextLabels.delete(NEEDS_DECOMPOSITION_LABEL); - } const reportedVersion = extractPulseVersion(issue.title, issue.body); core.info(`Reported Pulse version: ${reportedVersion || "not found"}`); @@ -265,7 +259,18 @@ async function syncLabels({ github, context, core }) { isBugLike, } = buildTriageState(issue, core, latestVersion); - if (hasAdditionalActionableTopics === true) { + // A form declaration creates a review task only when it is new. A later + // empty field cannot prove that comment topics were dispositioned, and an + // unrelated edit must not restore a label Community deliberately cleared. + const previousBody = context.payload.changes?.body?.from; + const newlyDeclared = hasAdditionalActionableTopics === true && ( + context.payload.action === "opened" || + (context.payload.action === "edited" && previousBody !== undefined && + classifyAdditionalActionableTopics(previousBody) !== true) + ); + if (newlyDeclared) { + core.info("Issue newly declares additional actionable topics; decomposition is required."); + nextLabels.add(NEEDS_DECOMPOSITION_LABEL); await ensureLabel( github, context, diff --git a/.github/scripts/issue-version-triage.test.cjs b/.github/scripts/issue-version-triage.test.cjs index d247b62600..f598d47cdd 100644 --- a/.github/scripts/issue-version-triage.test.cjs +++ b/.github/scripts/issue-version-triage.test.cjs @@ -69,11 +69,12 @@ function createGithub({ return { github, calls }; } -function createContext({ action = "opened", issue }) { +function createContext({ action = "opened", issue, previousBody }) { return { payload: { action, issue, + ...(previousBody === undefined ? {} : { changes: { body: { from: previousBody } } }), }, repo: { owner: "rcourtman", @@ -159,24 +160,52 @@ test("syncLabels marks declared secondary topics for decomposition", async () => ]); }); -test("syncLabels clears decomposition after every declared topic has a disposition", async () => { - const { github, calls } = createGithub({ latestVersion: "6.4.1" }); - const issue = { - number: 1796, - title: "Availability workflow feedback", - body: "## Additional actionable topics\nNone.\n", - labels: [{ name: "enhancement" }, { name: "needs-decomposition" }], - }; +test("syncLabels preserves community decomposition on an empty form field", async () => { + for (const action of ["edited", "reopened"]) { + const { github, calls } = createGithub({ latestVersion: "6.4.1" }); + const issue = { + number: 1796, + title: "Availability workflow feedback", + body: "## Additional actionable topics\nNone.\n", + // A later comment may have raised a distinct topic after this form was filed. + labels: [{ name: "enhancement" }, { name: "needs-decomposition" }], + }; - await triage.syncLabels({ - github, - context: createContext({ action: "edited", issue }), - core: createCore(), - }); + await triage.syncLabels({ + github, + context: createContext({ action, issue }), + core: createCore(), + }); - assert.equal(calls.createLabel.length, 0); - assert.equal(calls.addLabels.length, 0); - assert.deepEqual(calls.removeLabel.map(call => call.name), ["needs-decomposition"]); + assert.equal(calls.createLabel.length, 0); + assert.equal(calls.addLabels.length, 0); + assert.deepEqual(calls.removeLabel, []); + } +}); + +test("syncLabels adds newly declared topics but respects a completed disposition", async () => { + const topicBody = "## Additional actionable topics\nAdd a storage filter.\n"; + const emptyBody = "## Additional actionable topics\nNone.\n"; + for (const { action, previousBody, expectedAdd } of [ + { action: "edited", previousBody: emptyBody, expectedAdd: true }, + { action: "edited", previousBody: topicBody, expectedAdd: false }, + { action: "edited", expectedAdd: false }, + { action: "reopened", expectedAdd: false }, + ]) { + const { github, calls } = createGithub(); + await triage.syncLabels({ + github, + context: createContext({ action, previousBody, issue: { + number: 1796, + title: "Availability workflow feedback", + body: topicBody, + labels: [{ name: "enhancement" }], + } }), + core: createCore(), + }); + assert.deepEqual(calls.addLabels.flatMap(call => call.labels), + expectedAdd ? ["needs-decomposition"] : []); + } }); test("additional topic classification is explicit and fail-quiet for legacy forms", () => { @@ -390,13 +419,13 @@ test("queued label sync cannot overwrite newer Community label decisions", async } }); -test("classification removal tolerates an absent label but propagates access failures", async () => { +test("version-label removal tolerates an absent label but propagates access failures", async () => { for (const status of [404, 403]) { const { github } = createGithub(); github.rest.issues.removeLabel = async () => { throw Object.assign(new Error("API failure"), { status }); }; const run = triage.syncLabels({ github, core: createCore(), context: createContext({ issue: { - number: 1200, title: "Feedback", body: "## Additional actionable topics\nNone.\n", - labels: [{ name: "enhancement" }, { name: "needs-decomposition" }], + number: 1200, title: "Bug", body: "## Pulse version\n6.0.1\n", + labels: [{ name: "bug" }, { name: "affects-6.0.0" }], } }) }); if (status === 404) await assert.doesNotReject(run); else await assert.rejects(run, { status: 403 }); diff --git a/docs/ISSUE_TRIAGE.md b/docs/ISSUE_TRIAGE.md index 4b22611670..4581e0fc74 100644 --- a/docs/ISSUE_TRIAGE.md +++ b/docs/ISSUE_TRIAGE.md @@ -7,9 +7,13 @@ documentation gaps, or operator workflows described in the same report. ## Intake contract Issue forms ask for one primary outcome and provide an **Additional actionable -topics** field. Entering anything other than `None` applies the -`needs-decomposition` label automatically. This is a queue-integrity signal, -not a statement that every topic will be built. +topics** field. Declaring a topic when opening an issue or newly adding one to +this field applies the `needs-decomposition` label automatically. This is a +queue-integrity signal, not a statement that every topic will be built. +The synchronizer never removes this label, nor restores it after a maintainer +has cleared it when an unrelated edit or reopen occurs. A later `None` in the +form does not prove that topics raised in comments were dispositioned; triage +removes the label only after the linked dispositions below are complete. Reporters may still write free-form issues, edit form output, or discover a second topic during discussion. Triage owns decomposition in those cases; it