Preserve community decomposition decisions in issue label sync

An empty form field or unrelated edit cannot prove comment-raised topics were dispositioned. Only label newly declared topics, leaving removal to whole-thread triage and respecting a completed manual disposition.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-29 14:45:33 +01:00
parent 2e346e050f
commit 20c99e2e67
3 changed files with 68 additions and 30 deletions
+12 -7
View File
@@ -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,
+49 -20
View File
@@ -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 });