diff --git a/.github/workflows/dependabot-alerts.yml b/.github/workflows/dependabot-alerts.yml index 4e1f3f38..7fe631f9 100644 --- a/.github/workflows/dependabot-alerts.yml +++ b/.github/workflows/dependabot-alerts.yml @@ -55,6 +55,16 @@ on: - cron: "17 6 * * *" # 06:17 UTC daily; alerts are not minute-sensitive workflow_dispatch: +# The marker check is a read-before-write, not an atomic one, and nothing stops +# a `workflow_dispatch` from landing on top of the scheduled run. Two overlapping +# runs would both see no open issue and both file one, which is the exact +# duplicate this sweep's whole idempotency design exists to prevent (Copilot). +# `cancel-in-progress: false` because the queued run must WAIT and then re-read +# the state the first run wrote — cancelling it would drop a sweep instead. +concurrency: + group: dependabot-alert-sweep + cancel-in-progress: false + permissions: contents: read issues: write diff --git a/scripts/dependabot-alerts.mjs b/scripts/dependabot-alerts.mjs index 1cb67411..dc6b596a 100644 --- a/scripts/dependabot-alerts.mjs +++ b/scripts/dependabot-alerts.mjs @@ -308,6 +308,27 @@ export function groupAlerts(alerts) { ); } +/** + * The milestone a new issue takes: the open one with the NEAREST due date. + * + * ⚠️ Selected here rather than in a `jq` expression because jq sorts `null` + * BEFORE every string, so a `sort_by(.due_on) | .[0]` over the raw list hands + * back an undated milestone in preference to every dated one (Copilot). An + * undated bucket has no due date and so cannot be the nearest; it is dropped + * rather than sorted last, and if nothing dated is open the issue is filed + * unmilestoned and triage places it. + * + * @param {Array<{title: string, state?: string, due_on?: string | null}>} milestones + * @returns {string | null} + */ +export function pickMilestone(milestones) { + const dated = (milestones ?? []).filter( + (m) => (m.state ?? "open") === "open" && m.due_on, + ); + if (dated.length === 0) return null; + return dated.sort((a, b) => a.due_on.localeCompare(b.due_on))[0].title; +} + /** * Narrow a group to the advisories that actually apply to what is installed. * @@ -683,16 +704,11 @@ function announcedAdvisories(repo, number, spawn) { } function currentMilestone(repo, spawn) { - const result = gh(spawn, [ - "api", - `repos/${repo}/milestones`, - "--jq", - 'map(select(.state=="open")) | sort_by(.due_on) | .[0].title // empty', - ]); + const result = gh(spawn, ["api", `repos/${repo}/milestones?state=open`]); if (result.status !== 0) { throw new Error(`milestone lookup failed: ${(result.stderr ?? "").trim()}`); } - return result.stdout.trim() || null; + return pickMilestone(JSON.parse(result.stdout || "[]")); } /** diff --git a/scripts/dependabot-alerts.test.mjs b/scripts/dependabot-alerts.test.mjs index 076d4a9d..08d2edc7 100644 --- a/scripts/dependabot-alerts.test.mjs +++ b/scripts/dependabot-alerts.test.mjs @@ -16,6 +16,7 @@ import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { buildCommentMarker, + pickMilestone, narrowToApplicable, lockfileEntries, remediation, @@ -334,6 +335,31 @@ test("buildIssueBody escapes a disjoint range so the table survives it", () => { assert.ok(row.includes(String.raw`\|\|`), "the range's own pipes survive"); }); +test("pickMilestone takes the nearest due date, never an undated bucket", () => { + // jq sorts null before every string, so `sort_by(.due_on) | .[0]` over this + // list would return "Backlog" — an open bucket with no release date at all. + const milestones = [ + { title: "Backlog", state: "open", due_on: null }, + { title: "v2.7.0", state: "open", due_on: "2026-09-16T00:00:00Z" }, + { title: "v2.6.0", state: "open", due_on: "2026-09-09T00:00:00Z" }, + ]; + assert.equal(pickMilestone(milestones), "v2.6.0"); +}); + +test("pickMilestone ignores closed milestones and empty input", () => { + assert.equal( + pickMilestone([ + { title: "v2.5.0", state: "closed", due_on: "2026-01-01T00:00:00Z" }, + ]), + null, + ); + // Nothing dated and open means no bucket to take: filed unmilestoned, and + // the board write is skipped so triage places it. + assert.equal(pickMilestone([{ title: "Backlog", due_on: null }]), null); + assert.equal(pickMilestone([]), null); + assert.equal(pickMilestone(undefined), null); +}); + test("buildIssueTitle names the bump and pluralizes the advisory count", () => { const [many] = groupAlerts([ alert({ ghsa: "GHSA-a" }), @@ -530,7 +556,20 @@ function fakeSpawn({ // `--slurp` yields one array PER PAGE; main() must flatten them. return ok(JSON.stringify(alertPages)); } - if (joined.includes("milestones")) return ok(milestone); + if (joined.includes("milestones")) + return ok( + JSON.stringify( + milestone + ? [ + { + title: milestone, + state: "open", + due_on: "2026-09-09T00:00:00Z", + }, + ] + : [], + ), + ); // `--slurp`, so one array per page — `issues` may be a flat list or pages. if (joined.includes("/issues?")) return ok(JSON.stringify(Array.isArray(issues[0]) ? issues : [issues]));