Merge candidate 20260908T105011Z-delivery-trust

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-08 12:04:55 +01:00
3 changed files with 81 additions and 30 deletions
+24 -17
View File
@@ -232,6 +232,28 @@ function keepOnlyReportedVersionLabel(nextLabels, reportedVersion) {
}
}
// Apply only this event's classification delta. Replacing the complete label
// set would overwrite Community changes made after the event was queued.
async function applyLabelDelta(github, context, issue, before, after) {
const target = {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
};
const additions = [...after].filter((name) => !before.has(name)).sort();
if (additions.length) {
await github.rest.issues.addLabels({ ...target, labels: additions });
}
for (const name of [...before].filter((name) => !after.has(name)).sort()) {
try {
await github.rest.issues.removeLabel({ ...target, name });
} catch (error) {
// Another synchronizer may already have removed this label.
if (error.status !== 404) throw error;
}
}
}
async function syncLabels({ github, context, core }) {
const issue = context.payload.issue;
const latestVersion = await getLatestStableVersion(github, context, core);
@@ -255,17 +277,7 @@ async function syncLabels({ github, context, core }) {
if (!isBugLike) {
core.info("Issue is not bug-like after classification. Skipping version triage.");
const labelsChanged =
labelNames.size !== nextLabels.size ||
[...labelNames].some((label) => !nextLabels.has(label));
if (labelsChanged) {
await github.rest.issues.setLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [...nextLabels].sort(),
});
}
await applyLabelDelta(github, context, issue, labelNames, nextLabels);
return;
}
@@ -293,12 +305,7 @@ async function syncLabels({ github, context, core }) {
// Retest labels are community-owned: version metadata cannot establish
// relevant-fix availability or reconcile the live reporter conversation.
await github.rest.issues.setLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [...nextLabels].sort(),
});
await applyLabelDelta(github, context, issue, labelNames, nextLabels);
}
// Compatibility entry points for callers using the old helper API. Version
+56 -12
View File
@@ -17,7 +17,8 @@ function createGithub({
getLabel: [],
getLatestRelease: [],
paginate: [],
setLabels: [],
addLabels: [],
removeLabel: [],
};
const github = {
@@ -37,8 +38,12 @@ function createGithub({
existingLabels.add(payload.name);
return { data: payload };
},
async setLabels(payload) {
calls.setLabels.push(payload);
async addLabels(payload) {
calls.addLabels.push(payload);
return { data: payload };
},
async removeLabel(payload) {
calls.removeLabel.push(payload);
return { data: payload };
},
async createComment(payload) {
@@ -99,8 +104,8 @@ test("syncLabels classifies older bug reports without requesting a retest", asyn
core: createCore(),
});
assert.equal(calls.setLabels.length, 1);
assert.deepEqual(calls.setLabels[0].labels, [
assert.equal(calls.addLabels.length, 1);
assert.deepEqual(calls.addLabels[0].labels, [
"affects-6.0.0-rc.1",
"bug",
]);
@@ -121,8 +126,8 @@ test("syncLabels only adds documentation classification for non-bug v6 feedback"
core: createCore(),
});
assert.equal(calls.setLabels.length, 1);
assert.deepEqual(calls.setLabels[0].labels, ["documentation"]);
assert.equal(calls.addLabels.length, 1);
assert.deepEqual(calls.addLabels[0].labels, ["documentation"]);
});
test("syncLabels marks declared secondary topics for decomposition", async () => {
@@ -149,8 +154,7 @@ test("syncLabels marks declared secondary topics for decomposition", async () =>
assert.deepEqual(calls.createLabel.map((call) => call.name), [
"needs-decomposition",
]);
assert.deepEqual(calls.setLabels[0].labels, [
"enhancement",
assert.deepEqual(calls.addLabels[0].labels, [
"needs-decomposition",
]);
});
@@ -171,7 +175,8 @@ test("syncLabels clears decomposition after every declared topic has a dispositi
});
assert.equal(calls.createLabel.length, 0);
assert.deepEqual(calls.setLabels[0].labels, ["enhancement"]);
assert.equal(calls.addLabels.length, 0);
assert.deepEqual(calls.removeLabel.map(call => call.name), ["needs-decomposition"]);
});
test("additional topic classification is explicit and fail-quiet for legacy forms", () => {
@@ -259,7 +264,8 @@ test("label sync preserves community-owned retest state regardless of version",
labels: [{ name: "bug" }, { name: "needs-retest-on-latest" }],
} }),
});
assert.ok(calls.setLabels[0].labels.includes("needs-retest-on-latest"));
assert.ok(!calls.addLabels[0].labels.includes("needs-retest-on-latest"));
assert.ok(!calls.removeLabel.some(call => call.name === "needs-retest-on-latest"));
assert.equal(calls.createComment.length, 0);
}
});
@@ -352,9 +358,47 @@ test("ambiguous upgrade version requests information without a retest comment",
const args = { github, context: createContext({ issue }), core: createCore() };
await triage.syncLabels(args);
await triage.postRetestComment(args);
const labels = calls.setLabels.at(-1).labels;
const labels = calls.addLabels.at(-1).labels;
assert.ok(labels.includes("needs-version-info"));
assert.ok(!labels.includes("affects-5.1.35"));
assert.ok(!labels.includes("needs-retest-on-latest"));
assert.equal(calls.createComment.length, 0);
});
test("queued label sync cannot overwrite newer Community label decisions", async () => {
for (const communityAdded of [true, false]) {
const { github } = createGithub();
const live = new Set(["bug", "affects-6.0.0", "needs-version-info"]);
if (communityAdded) live.add("needs-retest-on-latest");
live.add("operator-reviewed");
github.rest.issues.setLabels = async ({ labels }) => {
live.clear();
labels.forEach(label => live.add(label));
};
github.rest.issues.addLabels = async ({ labels }) => labels.forEach(label => live.add(label));
github.rest.issues.removeLabel = async ({ name }) => live.delete(name);
await triage.syncLabels({ github, core: createCore(), context: createContext({ issue: {
number: 1200, title: "Bug", body: "## Pulse version\n6.0.1\n",
labels: ["bug", "affects-6.0.0", "needs-version-info",
...(!communityAdded ? ["needs-retest-on-latest"] : [])].map(name => ({ name })),
} }) });
assert.equal(live.has("needs-retest-on-latest"), communityAdded);
assert.ok(live.has("operator-reviewed"));
assert.ok(live.has("affects-6.0.1"));
assert.ok(!live.has("affects-6.0.0"));
assert.ok(!live.has("needs-version-info"));
}
});
test("classification 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" }],
} }) });
if (status === 404) await assert.doesNotReject(run);
else await assert.rejects(run, { status: 403 });
}
});
+1 -1
View File
@@ -226,7 +226,7 @@ verify downloaded files offline against the candidate-builder identity.
- The silent metadata path runs on `opened`, `edited`, and `reopened` issue events. The shared helper at `.github/scripts/issue-version-triage.cjs` maintains version labels, `needs-version-info`, feedback classification and `needs-decomposition`.
- Version-only retest posting and marked-request timeout closure workflows are retired. An older reported version or silence is not evidence of resolution.
- Community owns public follow-up and closure after whole-thread review, relevant-fix verification and confirmed released-source inclusion. The synchronizer neither adds nor removes the community-owned `needs-retest-on-latest` label. Existing labels and comments are context, not proof that a reporter has or has not replied.
- Community owns public follow-up and closure after whole-thread review, relevant-fix verification and confirmed released-source inclusion. The synchronizer neither adds nor removes the community-owned `needs-retest-on-latest` label. Classification uses individual label additions/removals rather than replacing the event snapshot, preserving concurrent Community label decisions. Existing labels and comments are context, not proof that a reporter has or has not replied.
- `needs-decomposition` is driven only by the structured **Additional actionable topics** form field. The [triage contract](../../docs/ISSUE_TRIAGE.md) requires human or agent judgment to create linked dispositions; the workflow does not infer or auto-create issues from free text.
## Update Demo Server