diff --git a/.github/scripts/issue-version-triage.cjs b/.github/scripts/issue-version-triage.cjs index 943589ebe..06340f677 100644 --- a/.github/scripts/issue-version-triage.cjs +++ b/.github/scripts/issue-version-triage.cjs @@ -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 diff --git a/.github/scripts/issue-version-triage.test.cjs b/.github/scripts/issue-version-triage.test.cjs index 614a886ea..d247b6260 100644 --- a/.github/scripts/issue-version-triage.test.cjs +++ b/.github/scripts/issue-version-triage.test.cjs @@ -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 }); + } +}); diff --git a/.github/workflows/README.md b/.github/workflows/README.md index c40134f1b..7af1fd856 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -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