From 34549d7f4b1b3899a3d96dce36bd5b62dfaeac18 Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Wed, 4 Nov 2026 19:33:39 +0000 Subject: [PATCH 1/2] stewards: keep unadopted Dependabot adoption decisions and clock the SLA from the alert (BB-227) --- plugins/stewards/DESIGN.md | 34 ++++ plugins/stewards/README.md | 17 +- plugins/stewards/deps.test.ts | 63 +++++- plugins/stewards/deps.ts | 107 +++++++++- plugins/stewards/migrations.snapshot.json | 5 +- plugins/stewards/ops.ts | 6 +- plugins/stewards/server.test.ts | 209 ++++++++++++++++++- plugins/stewards/server.ts | 238 +++++++++++++++++++++- plugins/stewards/skills/stewards/SKILL.md | 12 ++ plugins/stewards/store.test.ts | 52 ++++- plugins/stewards/store.ts | 137 ++++++++++++- plugins/stewards/templates.ts | 28 ++- plugins/stewards/wake.test.ts | 54 +++++ plugins/stewards/wake.ts | 39 +++- 14 files changed, 950 insertions(+), 51 deletions(-) diff --git a/plugins/stewards/DESIGN.md b/plugins/stewards/DESIGN.md index 664281de..2843cfd8 100644 --- a/plugins/stewards/DESIGN.md +++ b/plugins/stewards/DESIGN.md @@ -1447,6 +1447,40 @@ binding do it. once rather than waiting for the adopt pass. The alert's fingerprint is `owner/repo#` (the alert's own numbering), recorded in the ops fingerprint map so a recurrence is a comment, never a second ticket. +- **The SLA clock is the alert's own `created_at`**, not when this server + first saw it, so a watch binding recreated days later does not hand a + ticket a fresh deadline beside the one the ticket was filed with (SC-111 + drifted three days that way and every digest repeated the wrong date). + The plugin owns the deadline: it never reads Linear's `dueDate` (the + source contract does not carry it), and the wake shows the computed date. +- **Adoption of a ticket for a severity `deps.autoAdopt` does not name is + the owner's decision, and the plugin keeps it.** The steward used to + raise one `decision` escalation from the filing thread; when that thread + was deleted the question went with it, and nothing re-raised it. Now the + moment a report lists such a ticket as filed (and at every look, as the + backfill for a ticket inherited, found by dedup, or filed by a thread that + never reported), the plugin records a `deps_decisions` row keyed by ticket + and files a `decide` need on whichever binding watches the repository, + with choices **Adopt**, **Keep tracking** and — only when the playbook's + `may` list declares `dismiss-alert` — **Dismiss as not used** (the + recommended one first: dismiss when no alert has a fixed version). It + shows in `bb stewards needs`, on the Stewards page and in the digest's + "Needs you" until the owner answers, the ticket is labelled, or it + closes; a recreated binding re-files the item on itself. The answer + reaches the dependency steward under "What changed" and the wake says + what each choice means. A `Keep tracking` answer is a snooze: the plugin + records the date the answer's note gives (or one SLA window from today) as + `snoozed_until` and re-files the same decision when it arrives. When the + deadline (or the snooze) has passed the plugin reminds once: it rewords + the item "overdue by N days", adds a note to the project steward's next + wake (so the digest names the deadline rather than promising to raise it + later) and records the reminder on the decision. +- **Provenance without a log table.** The fingerprint map records the thread + that filed a ticket (`filed_by_thread`), and each decision keeps its + raised-at and raised-by. `bb stewards show` prints the fingerprint rows + with the filing thread and every decision with its status, dates and + resolution, so "did anyone ask, and who filed it?" is answered with the + filing thread deleted. - **critical and high get their own ticket; medium and low coalesce** into one open ticket per repo per ecosystem. A new alert joins that ticket when its due date is inside the new alert's deadline, and otherwise diff --git a/plugins/stewards/README.md b/plugins/stewards/README.md index abeec3dc..436f6845 100644 --- a/plugins/stewards/README.md +++ b/plugins/stewards/README.md @@ -61,9 +61,17 @@ bb. [DESIGN.md](DESIGN.md) is the design; this is how it is built and run. `dependabot_alert` doorbell; the github plugin reads the alerts with the developer's token and leases each repo as a `dependabot_repo` (a `repo` lease does not receive alert doorbells). Severity sets the Linear priority - and a due date from `deps.sla`; critical and high get their own ticket, + and a due date from `deps.sla`, clocked from the alert's own `created_at`. + critical and high get their own ticket, medium and low coalesce into one open ticket per repo per ecosystem, and a - new alert joins or tightens it. A fixed or dismissed alert trims or closes + new alert joins or tightens it. A ticket for a severity `deps.autoAdopt` + does not name gets an adoption decision the plugin keeps by ticket (Adopt, + Keep tracking, and — when the playbook's `may` allows `dismiss-alert` — + Dismiss as not used) in `bb stewards needs` and the digest until the owner + answers; Keep tracking snoozes it and the plugin re-files it on that date, + and a missed deadline gets one overdue reminder. `bb stewards show` prints + the filing thread per fingerprint and each decision. A fixed or dismissed + alert trims or closes its ticket, and the nightly pass re-lists open alerts as the backstop. A `dependabot[bot]` version-update pull request is adopted under `deps-prs`, gets the reviewer round, and once its marker is approved on green checks @@ -801,7 +809,10 @@ unchanged. The `deps` object is the dependency watch: `sla` (a duration or `asap` per severity; critical→urgent, high→high, medium→medium, low→low), `batch` (the window a medium or low alert may wait for company), and `autoAdopt` (the -severities whose tickets are labelled and moved to Todo when filed). +severities whose tickets are labelled and moved to Todo when filed; any +other severity's ticket raises the owner's adoption decision, kept by the +plugin until answered, with reminders at half the time to the due date, the +day before, and once overdue). `deps-alerts.md` and `deps-prs.md` are optional: a project that has not written them runs no dependency watch. `bb stewards init` writes them, and `bb stewards check` validates `deps.sla`, `deps.batch` and `deps.autoAdopt` diff --git a/plugins/stewards/deps.test.ts b/plugins/stewards/deps.test.ts index dbe8f256..1540d7b3 100644 --- a/plugins/stewards/deps.test.ts +++ b/plugins/stewards/deps.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_BATCH, DEFAULT_SLA, alertFingerprint, alertLine, batchAlert, deadlineAt, depsFindingSchema, dueDate, isBatchable, priorityFor, sightingOf } from "./deps.js"; +import { ADOPTION_CHOICES, DEFAULT_BATCH, DEFAULT_SLA, adoptionChoices, adoptionHow, adoptionQuestion, alertClockAt, alertFingerprint, alertLine, batchAlert, dateInText, deadlineAt, depsFindingSchema, dueDate, isBatchable, overdue, priorityFor, reminderText, sightingOf, snoozeUntil } from "./deps.js"; import type { DependabotAlert } from "./source-contract.js"; const alert = (over: Partial = {}): DependabotAlert => ({ @@ -137,3 +137,64 @@ describe("the defaults", () => { expect(DEFAULT_BATCH).toEqual({ medium: 7 * 86_400_000, low: 30 * 86_400_000 }); }); }); + +describe("the SLA clock is the alert's own created_at", () => { + it("starts at created_at when it parses, else at first seen", () => { + expect(alertClockAt(alert({ createdAt: "2026-10-29T00:19:43Z" }), now)).toBe(Date.parse("2026-10-29T00:19:43Z")); + expect(alertClockAt(alert({ createdAt: null }), now)).toBe(now); + expect(alertClockAt(alert({ createdAt: "not a date" }), now)).toBe(now); + }); + + it("gives a binding recreated later the same deadline, not a fresh one (SC-111)", () => { + const created = Date.parse("2026-10-29T00:19:43Z"); + const filedBy = deadlineAt("medium", DEFAULT_SLA, alertClockAt(alert({ createdAt: "2026-10-29T00:19:43Z" }), created)); + const recreatedAt = Date.parse("2026-10-31T15:48:52Z"); + const seenAgain = deadlineAt("medium", DEFAULT_SLA, alertClockAt(alert({ createdAt: "2026-10-29T00:19:43Z" }), recreatedAt)); + expect(seenAgain).toBe(filedBy); + expect(dueDate(seenAgain, "UTC")).toBe("2026-11-05"); + expect(dueDate(seenAgain, "America/New_York")).toBe("2026-11-04"); + }); + + it("snoozes Keep tracking to the note's date, or one SLA window from now when it gives none", () => { + const at = Date.parse("2026-10-29T00:00:00Z"); + expect(dateInText("Chose: Keep tracking — 2026-11-20")).toBe(Date.parse("2026-11-20T12:00:00Z")); + expect(dateInText("nothing here")).toBeNull(); + expect(snoozeUntil("Chose: Keep tracking — 2026-11-20", "medium", DEFAULT_SLA, at)).toBe(Date.parse("2026-11-20T12:00:00Z")); + expect(snoozeUntil("Chose: Keep tracking", "medium", DEFAULT_SLA, at)).toBe(at + 7 * 86_400_000); + }); +}); + +describe("the adoption decision", () => { + it("leads with Adopt when a fix exists and with Dismiss when none does", () => { + expect(adoptionChoices(true, true)).toEqual([ADOPTION_CHOICES.adopt, ADOPTION_CHOICES.keep, ADOPTION_CHOICES.dismiss]); + expect(adoptionChoices(false, true)).toEqual([ADOPTION_CHOICES.dismiss, ADOPTION_CHOICES.keep, ADOPTION_CHOICES.adopt]); + }); + + it("offers no Dismiss when the playbook's may list does not allow it", () => { + expect(adoptionChoices(true, false)).toEqual([ADOPTION_CHOICES.adopt, ADOPTION_CHOICES.keep]); + expect(adoptionChoices(false, false)).toEqual([ADOPTION_CHOICES.keep, ADOPTION_CHOICES.adopt]); + expect(adoptionHow(false)).not.toMatch(/Dismiss/u); + expect(adoptionHow(true)).toMatch(/Dismiss as not used/u); + }); + + it("asks one line naming the ticket, the alerts, the fix and the due date, and says when it is overdue", () => { + const echo = { severity: "medium" as const, package: "github.com/labstack/echo/v4", ecosystem: "go", fixedVersion: null }; + expect(adoptionQuestion("SC-111", "bitcomplete/santaclaw", [echo, echo], "2026-11-04")).toBe("Adopt SC-111? medium Dependabot alerts (2) on bitcomplete/santaclaw: github.com/labstack/echo/v4 (go), no fixed version, due 2026-11-04."); + expect(adoptionQuestion("ENG-50", "acme/web", [alert()], null)).toBe("Adopt ENG-50? high Dependabot alert on acme/web: lodash (npm), fix available."); + expect(adoptionQuestion("ENG-50", "acme/web", [alert({ severity: "low" }), alert({ severity: "medium", package: "axios", fixedVersion: null })], "2026-11-01")).toMatch(/^Adopt ENG-50\? medium\/low Dependabot alerts \(2\) on acme\/web: lodash, axios \(npm\), fix for 1 of 2, due 2026-11-01\.$/u); + expect(adoptionQuestion("ENG-50", "acme/web", [alert()], "2026-11-04", 2)).toMatch(/due 2026-11-04\. Overdue by 2 days\.$/u); + expect(adoptionQuestion("ENG-50", "acme/web", [alert()], "2026-11-04", 1)).toMatch(/Overdue by 1 day\.$/u); + }); + + it("words the one overdue reminder with the days past the due date", () => { + expect(overdue(Date.parse("2026-11-04T12:00:00Z"), Date.parse("2026-11-04T12:00:00Z"), "UTC")).toBe(false); + expect(overdue(Date.parse("2026-11-04T12:00:00Z"), Date.parse("2026-11-05T12:00:00Z"), "UTC")).toBe(true); + expect(reminderText("SC-111", "2026-11-04", Date.parse("2026-11-05T12:00:00Z"), "UTC")).toBe("SC-111 is a day past its due date (2026-11-04) and still unadopted."); + expect(reminderText("SC-111", "2026-11-04", Date.parse("2026-11-10T12:00:00Z"), "UTC")).toBe("SC-111 is 6 days past its due date (2026-11-04) and still unadopted."); + }); + + it("parses an older look without the ticket's labels or created_at", () => { + const parsed = depsFindingSchema.parse({ fingerprint: "acme/web#4", alertNumber: 4, severity: "high", package: "lodash", ecosystem: "npm", manifestPath: null, ghsaId: null, fixedVersion: null, summary: "x", state: "open", url: null, dueAt: now, priority: "high", class: "new", ticket: null, ticketState: null, closedAt: null, window: null, assignee: "Jane", firstSeenAt: now, lastCommentedAt: null, batch: { kind: "new" } }); + expect(parsed).toMatchObject({ ticketLabels: [], alertCreatedAt: null }); + }); +}); diff --git a/plugins/stewards/deps.ts b/plugins/stewards/deps.ts index d634b5f5..a32d6deb 100644 --- a/plugins/stewards/deps.ts +++ b/plugins/stewards/deps.ts @@ -35,15 +35,45 @@ export const isBatchable = (severity: DependabotSeverity): boolean => severity = export const alertFingerprint = (alert: Pick): string => `${alert.repo}#${alert.number}`; /** - * When an alert is due: `asap` is due the moment it is first seen, otherwise - * first seen plus the severity's SLA. The clock is `firstSeenAt`, not the - * alert's GitHub `created_at`: a server that missed a doorbell must not - * inherit a deadline already spent, and an alert re-seen after a restart - * keeps the deadline it first got from the fingerprint map. + * When an alert is due: `asap` is due the moment the clock starts, otherwise + * the clock plus the severity's SLA. The clock is the alert's own GitHub + * `created_at` (`alertClockAt`), not when this server first saw it: a watch + * binding recreated days later must not hand the ticket a fresh deadline + * beside the one Linear already carries (SC-111 drifted three days that way). + * A server that first sees an old alert inherits a deadline already spent, + * which is the truth of the matter. */ -export function deadlineAt(severity: DependabotSeverity, sla: Record, firstSeenAt: number): number { +export function deadlineAt(severity: DependabotSeverity, sla: Record, clockAt: number): number { const value = sla[severity]; - return value === "asap" ? firstSeenAt : firstSeenAt + value; + return value === "asap" ? clockAt : clockAt + value; +} + +/** + * When an alert's SLA clock started: its GitHub `created_at` when it parses, + * else when this server first saw it (the fingerprint map's `first_seen_at`). + */ +export function alertClockAt(alert: Pick, firstSeenAt: number): number { + if (alert.createdAt === null) return firstSeenAt; + const parsed = Date.parse(alert.createdAt); + return Number.isNaN(parsed) ? firstSeenAt : parsed; +} + +/** The first YYYY-MM-DD date in a note, as an instant at noon UTC (that date in every time zone from UTC-12 to UTC+11), or null. */ +export function dateInText(text: string): number | null { + const match = /(\d{4}-\d{2}-\d{2})/u.exec(text); + if (match === null) return null; + const ms = Date.parse(`${match[1]}T12:00:00Z`); + return Number.isNaN(ms) ? null : ms; +} + +/** + * When a `Keep tracking` answer snoozes the decision until: the date the + * answer's note gives, or one SLA window from now when it gives none. The + * plugin owns this clock (it never reads Linear's due date), so it is + * computed once, when the answer arrives, and recorded on the decision. + */ +export function snoozeUntil(note: string, severity: DependabotSeverity | null, sla: Record, now: number): number { + return dateInText(note) ?? deadlineAt(severity ?? "medium", sla, now); } /** An open ticket a batched alert may join: one per repo per ecosystem. */ @@ -123,8 +153,10 @@ export const depsFindingSchema = z.object({ summary: z.string(), state: z.string(), url: z.string().nullable(), - /** The SLA deadline, from the severity and when the alert was first seen. */ + /** The SLA deadline, from the severity and the alert's own created_at (`alertClockAt`). */ dueAt: z.number(), + /** The ticket's labels as Linear has them, so the wake can say whether it is adopted. */ + ticketLabels: z.array(z.string()).default([]), /** The Linear priority the severity maps to. */ priority: z.string(), class: z.enum(["new", "recurring", "returned"]), @@ -139,6 +171,8 @@ export const depsFindingSchema = z.object({ /** Set on a new finding when the dedup search could not run or was partial. */ dedupUnavailable: z.boolean().default(false), firstSeenAt: z.number(), + /** The alert's own created_at as GitHub reports it, the SLA clock; null when it did not parse. */ + alertCreatedAt: z.string().nullable().default(null), lastCommentedAt: z.number().nullable(), /** For a medium or low alert, what to do with the open ticket for its repo and ecosystem. */ batch: z.union([ @@ -160,6 +194,63 @@ export const depsLookSchema = z.object({ }); export type DepsLook = z.infer; +// ---- adoption decisions ------------------------------------------------------------ +// +// A ticket filed for a severity `deps.autoAdopt` does not name is nobody's +// until the owner says so. The plugin keeps that question as durable state +// keyed by ticket (store.ts `deps_decisions`), shown as a `decide` need on +// whichever binding watches the repository now, so it survives the filing +// thread being deleted and the binding being recreated (SC-111 sat six days +// in Backlog because the one escalation that was meant to ask got lost). + +/** The owner's choices on an unadopted ticket, the recommended one first. */ +export const ADOPTION_CHOICES = { adopt: "Adopt", dismiss: "Dismiss as not used", keep: "Keep tracking" } as const; + +/** + * The choices offered on an adoption decision, the recommended one first. With + * a fix available, adopting (a worker makes the upgrade) leads; with no + * patched version for any alert, dismissing the alerts as unused leads, since + * adopting could only produce a decision anyway. `Dismiss as not used` is + * offered only when the playbook's `may` list declares `dismiss-alert`. + */ +export function adoptionChoices(hasFix: boolean, dismissAllowed: boolean): string[] { + if (hasFix) return dismissAllowed ? [ADOPTION_CHOICES.adopt, ADOPTION_CHOICES.keep, ADOPTION_CHOICES.dismiss] : [ADOPTION_CHOICES.adopt, ADOPTION_CHOICES.keep]; + return dismissAllowed ? [ADOPTION_CHOICES.dismiss, ADOPTION_CHOICES.keep, ADOPTION_CHOICES.adopt] : [ADOPTION_CHOICES.keep, ADOPTION_CHOICES.adopt]; +} + +/** The one line a decision item asks. `overdueDays` rewords the item once the deadline has passed. */ +export function adoptionQuestion(ticketKey: string, repo: string, alerts: ReadonlyArray>, dueDate: string | null, overdueDays: number | null = null): string { + const severities = [...new Set(alerts.map((a) => a.severity))].sort((a, b) => DEPENDABOT_SEVERITIES.indexOf(a) - DEPENDABOT_SEVERITIES.indexOf(b)); + const packages = [...new Set(alerts.map((a) => a.package))]; + const ecosystems = [...new Set(alerts.map((a) => a.ecosystem))]; + const fixes = alerts.filter((a) => a.fixedVersion !== null).length; + const fix = fixes === 0 ? "no fixed version" : fixes === alerts.length ? "fix available" : `fix for ${fixes} of ${alerts.length}`; + const overdue = overdueDays === null ? "" : ` Overdue by ${overdueDays} day${overdueDays === 1 ? "" : "s"}.`; + return `Adopt ${ticketKey}? ${severities.join("/")} Dependabot alert${alerts.length === 1 ? "" : `s (${alerts.length})`} on ${repo}: ${packages.join(", ")} (${ecosystems.join(", ")}), ${fix}${dueDate === null ? "" : `, due ${dueDate}`}.${overdue}`; +} + +/** What each choice means, for the item's `how` and the steward's wake. */ +export function adoptionHow(dismissAllowed: boolean): string { + const parts = [ + `${ADOPTION_CHOICES.adopt}: the ticket is labelled and moved to Todo, and a ticket steward builds the upgrade.`, + `${ADOPTION_CHOICES.keep}: the ticket stays open and unadopted; the steward moves its due date out, and the plugin asks again when that date arrives.`, + ]; + if (dismissAllowed) parts.push(`${ADOPTION_CHOICES.dismiss}: the ticket's alerts are dismissed on GitHub as "vulnerable code is not actually used" and the ticket is closed with the reason.`); + return parts.join(" "); +} + +/** Whether the decision's due date (the computed deadline, or a snooze) has passed in the project's time zone. */ +export function overdue(dueAt: number, now: number, timeZone: string): boolean { + return dueDate(now, timeZone) > dueDate(dueAt, timeZone); +} + +/** How the one overdue reminder reads: the days past the due date, in the project's time zone. */ +export function reminderText(ticketKey: string, due: string, now: number, timeZone: string): string { + const today = dueDate(now, timeZone); + const days = Math.round((Date.parse(`${today}T00:00:00Z`) - Date.parse(`${due}T00:00:00Z`)) / 86_400_000); + return `${ticketKey} is ${days === 1 ? "a day" : `${days} days`} past its due date (${due}) and still unadopted.`; +} + /** The repo a fingerprint names: "owner/repo#4" → "owner/repo". */ export const repoOfFingerprint = (fingerprint: string): string | null => { const i = fingerprint.lastIndexOf("#"); diff --git a/plugins/stewards/migrations.snapshot.json b/plugins/stewards/migrations.snapshot.json index 5bec2202..b692f5f7 100644 --- a/plugins/stewards/migrations.snapshot.json +++ b/plugins/stewards/migrations.snapshot.json @@ -103,5 +103,8 @@ "595e447b50228f6ebb015a069872948beb36668629d9b2e71f798c83840037c6", "7bc6e0dc10f8aea8f67e3d0d642b87bc67b80241052e6fcfafe947ee7b9538b5", "42be649d859d3c6e3a0970b74f41a8c75c9ef753e247d7a54d2c8cff039ba523", - "fc84bda5d59e556b0a0100249a11fe6df258ad005665d164b56a18bad3c55e1e" + "fc84bda5d59e556b0a0100249a11fe6df258ad005665d164b56a18bad3c55e1e", + "27485e110bfc062528fc26d63a8210ef19664c3ea85b43d7626f81ce8630f4f8", + "3e5da92669f8bb2875557911e671c7652893d236f58bfef80ce7cab5d7bef2fd", + "e2c1b8b97003b2b938c95af600edff53f1eb88750c8b8723813b937a1f5d3a43" ] diff --git a/plugins/stewards/ops.ts b/plugins/stewards/ops.ts index f5b2d0e7..f7229ca5 100644 --- a/plugins/stewards/ops.ts +++ b/plugins/stewards/ops.ts @@ -314,10 +314,10 @@ export type OpsLook = z.infer; export type RawLook = Omit & { sightings: Sighting[] }; /** A fingerprint map row, as classification needs it. */ -export type FingerprintRow = { fingerprint: string; ticketKey: string | null; firstSeenAt: number; lastCommentedAt: number | null }; +export type FingerprintRow = { fingerprint: string; ticketKey: string | null; firstSeenAt: number; lastCommentedAt: number | null; filedByThread?: string | null }; -/** A ticket's state as the linear plugin reports it; null when it could not be read. */ -export type TicketState = { state: string; stateType: string | null; closedAt?: string | null } | null; +/** A ticket's state as the linear plugin reports it; null when it could not be read. `labels` and `url` are read for the dependency watch. */ +export type TicketState = { state: string; stateType: string | null; closedAt?: string | null; labels?: string[]; url?: string | null } | null; /** How long a resolved ticket is still "within two weeks" for a returned problem. */ export const CLOSED_WINDOW_MS = 14 * 86_400_000; diff --git a/plugins/stewards/server.test.ts b/plugins/stewards/server.test.ts index 1fcf4d21..96bc3776 100644 --- a/plugins/stewards/server.test.ts +++ b/plugins/stewards/server.test.ts @@ -7754,7 +7754,9 @@ describe("dependency stewards", () => { expect(first).toMatch(/You have just been made the dependency steward/u); expect(first).toContain("#### acme/web#4"); expect(first).toMatch(/### New: no ticket yet/u); - expect(first).toMatch(/\*\*high\*\* priority high, due 2026-10-15/u); + // The SLA clock is the alert's own created_at (2026-10-01T00:00Z), not when this server first saw it: a day later, in the project's time zone. + expect(first).toMatch(/\*\*high\*\* priority high, due 2026-10-01/u); + expect(first).toMatch(/opened 2026-10-01T00:00:00Z \(the SLA clock\); first seen by this watch 2026-10-14T15:00:00Z/u); expect(first).toMatch(/`lodash` \(npm\) — package-lock.json — GHSA-xxxx-yyyy-zzzz/u); expect(first).toMatch(/Fixed in 4\.17\.21/u); expect(first).toMatch(/This severity gets its own ticket\./u); @@ -7824,11 +7826,12 @@ describe("dependency stewards", () => { it("suppresses the escalation only for the severities deps.autoAdopt names", async () => { const adopted = await watching(); expect(adopted.wakes()[0]).toMatch(/deps\.autoAdopt: a ticket you file for a critical or high alert is adopted at once/u); - expect(adopted.wakes()[0]).toMatch(/a severity it does not name\s+raises one decision escalation/u); + expect(adopted.wakes()[0]).toMatch(/Adoption of any other ticket is the owner's decision, and the plugin asks it/u); const escalate = await watching([alert({ number: 4, severity: "high" })], { deps: { autoAdopt: [] } }); expect(escalate.wakes()[0]).not.toMatch(/deps\.autoAdopt:/u); - expect(escalate.wakes()[0]).toMatch(/a severity it does not name\s+raises one decision escalation/u); + expect(escalate.wakes()[0]).toMatch(/Adoption of any other ticket is the owner's decision, and the plugin asks it/u); + expect(escalate.wakes()[0]).toMatch(/Do not raise a decision escalation for it yourself/u); }); it("wakes on a fixed alert so its ticket is trimmed, and rings on a dependabot_alert doorbell", async () => { @@ -7853,6 +7856,206 @@ describe("dependency stewards", () => { expect(wakes().at(-1)).toMatch(/deps\.autoAdopt: a ticket you file for a critical or high alert is adopted at once/u); }); + it("replays SC-111: the adoption decision is durable plugin state, the SLA clock is the alert's, and reminders land in needs and the digest", async () => { + // Two medium alerts with no fix, opened 2026-10-10; medium SLA 7d → due 2026-10-16 (America/New_York). + const echo = (number: number, ghsaId: string) => alert({ id: `acme/web#${number}`, number, severity: "medium", package: "github.com/labstack/echo/v4", ecosystem: "go", manifestPath: "go.mod", ghsaId, fixedVersion: null, createdAt: "2026-10-10T00:19:43Z", url: `https://github.com/acme/web/security/dependabot/${number}` }); + const { t, steward, wakes } = await watching([echo(23, "GHSA-99jh"), echo(24, "GHSA-246p")]); + const first = wakes()[0]!; + expect(first).toMatch(/\*\*medium\*\* priority medium, due 2026-10-16/u); + expect(first).toMatch(/opened 2026-10-10T00:19:43Z \(the SLA clock\)/u); + expect(first).toMatch(/Adoption of any other ticket is the owner's decision, and the plugin asks it/u); + + // The steward files ENG-50 for both and reports it. Medium is not in autoAdopt, so the plugin raises the decision at once. + const filedThread = steward()!.id; + t.entities.set("ENG-50", issue({ id: "ENG-50", key: "ENG-50", url: "https://linear.app/acme/issue/ENG-50", title: "echo alerts", state: "Backlog", stateType: "backlog", labels: [], description: "Fingerprint: `acme/web#23`\nFingerprint: `acme/web#24`" })); + expect((await t.tool("steward_report", { phase: "watching", summary: "Filed ENG-50.", nextAction: "Watch", actor: "steward", opsTickets: [{ fingerprint: "acme/web#23", ticket: "ENG-50", did: "filed" }, { fingerprint: "acme/web#24", ticket: "ENG-50", did: "filed" }] }, filedThread)).error).toBe(false); + // Case 1: `bb stewards needs` shows the decision with the three choices, dismiss first when no fix exists. + const needs = (await t.harness.behavior.runCli(["needs", "--project", "p1"])).stdout; + // Case 6 provenance: the filing thread is on the fingerprint row. + expect(t.hooks.store.fingerprint("p1", "acme/web#23")).toMatchObject({ ticketKey: "ENG-50", filedByThread: filedThread }); + expect(needs).toMatch(/decide, dependabot\/acme\/web: Adopt ENG-50\? medium Dependabot alerts \(2\) on acme\/web: github.com\/labstack\/echo\/v4 \(go\), no fixed version, due 2026-10-16\. \[choices: Dismiss as not used, Keep tracking, Adopt\] \(https:\/\/linear.app\/acme\/issue\/ENG-50\)/u); + const decision = t.hooks.store.depsDecision("p1", "ENG-50")!; + expect(decision).toMatchObject({ status: "pending", entityId: DEPS_TARGET, fingerprints: ["acme/web#23", "acme/web#24"], severity: "medium", raisedBy: filedThread, reminded: [] }); + const firstNeed = t.hooks.store.need(decision.needId!)!; + expect(firstNeed).toMatchObject({ kind: "decide", filedBy: "plugin", url: "https://linear.app/acme/issue/ENG-50", data: expect.objectContaining({ adoption: true, ticket: "ENG-50" }) }); + // Reporting again does not raise a second decision. + expect((await t.tool("steward_report", { phase: "watching", summary: "Nothing new.", nextAction: "Watch", actor: "steward", opsTickets: [{ fingerprint: "acme/web#23", ticket: "ENG-50", did: "commented" }] }, filedThread)).error).toBe(false); + expect(t.hooks.store.pendingDepsDecisions("p1")).toHaveLength(1); + + // The watch is recreated (the repo leaves and re-enters the watched set; the filing thread is gone). + t.files[PROJECT_FILE] = JSON.stringify({ version: 1, linear: { teams: ["ENG"] }, github: { exclude: ["acme/bb", "acme/web"] } }); + await t.hooks.sweep(); + await t.flush(); + expect((await t.list()).find((b) => b.key === DEPS_TARGET)).toMatchObject({ ended: true }); + t.files[PROJECT_FILE] = JSON.stringify({ version: 1, linear: { teams: ["ENG"] }, github: { exclude: ["acme/bb"] } }); + t.advance(3_600_000); + await t.hooks.sweep(); + await t.flush(); + const current = t.hooks.store.open("p1").find((b) => b.entityKey === DEPS_TARGET)!; + expect(current).toBeDefined(); + const watchers = t.spawns.filter((x) => String(x.title).startsWith("Deps watch")); + const currentThread = current.threadId!; + const latest = () => [...watchers.map((x) => [x.id, String(x.prompt)] as const), ...t.sends.map((x) => [x.threadId, x.text] as const)].filter(([id]) => id === currentThread).map(([, text]) => text).at(-1)!; + // The recreated watch keeps the alert's deadline and shows the ticket's own date; the decision is still pending, now on this binding. + expect(latest()).toMatch(/Recurring on ENG-50 \(open\)/u); + expect(latest()).toMatch(/\*\*medium\*\* priority medium, due 2026-10-16/u); + expect(latest()).not.toMatch(/the ticket's date is the one that counts/u); + expect(latest()).toMatch(/opened 2026-10-10T00:19:43Z \(the SLA clock\)/u); + expect(latest()).toMatch(/### Adoption decisions waiting on the owner\n\n- ENG-50 \(acme\/web#23, acme\/web#24\)/u); + const moved = t.hooks.store.depsDecision("p1", "ENG-50")!; + expect(moved.status).toBe("pending"); + expect(moved.raisedAt).toBe(decision.raisedAt); + const open = t.hooks.store.openNeedsForProject("p1").filter((n) => n.data.adoption === true); + expect(open.map((n) => n.bindingId)).toEqual([current.id]); + expect(moved.needId).toBe(open[0]!.id); + // This harness revives the ended binding rather than making a new one, so the + // item is the same row, still open; a binding with a new id gets a fresh item + // and the old one is closed as moved (raiseAdoption). + expect(t.hooks.store.need(firstNeed.id)).toMatchObject({ status: "open", bindingId: current.id }); + expect((await t.harness.behavior.runCli(["needs", "--project", "p1"])).stdout).toMatch(/Adopt ENG-50\?/u); + + // Case 5: past the deadline the item reads overdue, the digest carries the + // note, and the reminder is recorded once. + const notes = () => t.hooks.store.project("p1").pendingNotes.join("\n"); + expect(t.hooks.store.depsDecision("p1", "ENG-50")!.reminded).toEqual([]); + t.advance(3 * 86_400_000); // 2026-10-17T16:00Z, a day past the 10-16 deadline + await t.hooks.sweep(); + await t.flush(); + expect(t.hooks.store.depsDecision("p1", "ENG-50")!.reminded).toEqual(["overdue"]); + expect(t.hooks.store.openNeedsForProject("p1").find((n) => n.data.adoption === true)!.what).toMatch(/Overdue by 1 day\.$/u); + expect(notes()).toMatch(/ENG-50 is a day past its due date \(2026-10-16\) and still unadopted\. The decision is need_\S+ \(dependabot\/acme\/web\); it is the owner's to answer/u); + // The reminder is once: another look adds no second note. + t.advance(86_400_000); + await t.hooks.sweep(); + await t.flush(); + expect(t.hooks.store.depsDecision("p1", "ENG-50")!.reminded).toEqual(["overdue"]); + expect(notes().match(/past its due date \(2026-10-16\)/gu)).toHaveLength(1); + + // The digest lists it under "Needs you" because the item is open, whatever any earlier digest planned. + await t.hooks.nightlyPass("p1", "manual"); + await t.flush(); + const projectThread = t.hooks.store.project("p1").threadId!; + const digest = t.sends.filter((x) => x.threadId === projectThread).at(-1)!.text; + expect(digest).toMatch(/## Needs you[\s\S]*decide, dependabot\/acme\/web\): Adopt ENG-50\? medium Dependabot alerts \(2\)[^\n]*\[choices: Dismiss as not used, Keep tracking, Adopt\]/u); + expect(digest).toMatch(/ENG-50 is a day past its due date/u); + + // The owner answers from a thread; the steward is woken with the choice and the decision settles on the next look. + const needId = t.hooks.store.depsDecision("p1", "ENG-50")!.needId!; + expect(await t.harness.behavior.runCli(["done", needId, "--choice", "Adopt", "--project", "p1"])).toMatchObject({ exitCode: 0, stdout: expect.stringMatching(/Chose Adopt/u) }); + await t.flush(); + expect(latest()).toMatch(/The user chose Adopt for Adopt ENG-50\?/u); + await t.hooks.sweep(); + await t.flush(); + expect(t.hooks.store.depsDecision("p1", "ENG-50")).toMatchObject({ status: "resolved", resolution: "Chose: Adopt" }); + expect(t.hooks.store.openNeedsForProject("p1").filter((n) => n.data.adoption === true)).toEqual([]); + expect(await t.harness.behavior.runCli(["needs", "--project", "p1"])).toMatchObject({ stdout: "Nothing is waiting on the user." }); + + // Case 6: the target's record answers "did anyone ask, and who filed it?" + // with the filing thread gone: the fingerprint row and every decision. + const show = (await t.harness.behavior.runCli(["show", DEPS_TARGET, "--project", "p1"])).stdout; + expect(show).toMatch(new RegExp(`Fingerprint acme/web#23 → ENG-50; seen \\d+ time\\(s\\), last [^;]+, filed by ${filedThread}`, "u")); + expect(show).toMatch(/Decision ENG-50 \(resolved, raised 2026-10-14T15:00:00Z by thr_\S+, item need_\S+, reminded overdue\): acme\/web#23, acme\/web#24 → Chose: Adopt/u); + }); + + it("settles the decision when the ticket is labelled or closed, and raises none for an autoAdopt severity or a labelled ticket", async () => { + const { t, steward } = await watching([alert({ number: 4, severity: "medium", ghsaId: "GHSA-med" }), alert({ number: 5, severity: "high", ghsaId: "GHSA-high", package: "minimist" })]); + const ops = steward()!.id; + t.entities.set("ENG-50", issue({ id: "ENG-50", key: "ENG-50", url: "https://linear.app/acme/issue/ENG-50", state: "Backlog", stateType: "backlog", labels: [] })); + t.entities.set("ENG-51", issue({ id: "ENG-51", key: "ENG-51", url: "https://linear.app/acme/issue/ENG-51", state: "Todo", stateType: "unstarted", labels: ["bb-managed"] })); + expect((await t.tool("steward_report", { phase: "watching", summary: "Filed ENG-50 and ENG-51.", nextAction: "Watch", actor: "steward", opsTickets: [{ fingerprint: "acme/web#4", ticket: "ENG-50", did: "filed" }, { fingerprint: "acme/web#5", ticket: "ENG-51", did: "filed" }] }, ops)).error).toBe(false); + // Only the medium ticket gets a decision: high is in autoAdopt and ENG-51 is labelled. + expect(t.hooks.store.pendingDepsDecisions("p1").map((d) => d.ticketKey)).toEqual(["ENG-50"]); + expect(t.hooks.store.depsDecision("p1", "ENG-51")).toBeNull(); + + // The owner labels ENG-50 in Linear instead of pressing a button: the next look settles it. + t.entities.set("ENG-50", issue({ id: "ENG-50", key: "ENG-50", url: "https://linear.app/acme/issue/ENG-50", state: "Todo", stateType: "unstarted", labels: ["bb-managed"] })); + t.advance(60_000); + await t.hooks.sweep(); + await t.flush(); + expect(t.hooks.store.depsDecision("p1", "ENG-50")).toMatchObject({ status: "resolved", resolution: "ENG-50 was adopted (labelled bb-managed)." }); + expect(t.hooks.store.openNeedsForProject("p1").filter((n) => n.data.adoption === true)).toEqual([]); + + // A ticket filed by a thread that never reported (found by the dedup search, open and unlabelled) gets its decision from the look, and closing it settles it. + t.entities.set("ENG-60", issue({ id: "ENG-60", key: "ENG-60", url: "https://linear.app/acme/issue/ENG-60", state: "Backlog", stateType: "backlog", labels: [], description: "Fingerprint: `acme/web#6`" })); + t.dependabotAlerts.set("acme/web", [alert({ number: 4, severity: "medium", ghsaId: "GHSA-med" }), alert({ number: 5, severity: "high", ghsaId: "GHSA-high", package: "minimist" }), alert({ number: 6, severity: "low", ghsaId: "GHSA-low", package: "tar" })]); + t.advance(60_000); + await t.hooks.sweep(); + await t.flush(); + expect(t.hooks.store.fingerprint("p1", "acme/web#6")).toMatchObject({ ticketKey: "ENG-60" }); + expect(t.hooks.store.depsDecision("p1", "ENG-60")).toMatchObject({ status: "pending", fingerprints: ["acme/web#6"], severity: "low" }); + t.entities.set("ENG-60", issue({ id: "ENG-60", key: "ENG-60", url: "https://linear.app/acme/issue/ENG-60", state: "Canceled", stateType: "canceled", labels: [], description: "Fingerprint: `acme/web#6`" })); + t.advance(60_000); + await t.hooks.sweep(); + await t.flush(); + expect(t.hooks.store.depsDecision("p1", "ENG-60")).toMatchObject({ status: "resolved", resolution: "ENG-60 was closed (Canceled)." }); + }); + + it("snoozes Keep tracking to the note's date and re-raises the same decision when it arrives", async () => { + const { t, steward } = await watching([alert({ number: 4, severity: "medium", ghsaId: "GHSA-med", fixedVersion: null, createdAt: "2026-10-10T00:00:00Z" })]); + const ops = steward()!.id; + t.entities.set("ENG-50", issue({ id: "ENG-50", key: "ENG-50", url: "https://linear.app/acme/issue/ENG-50", state: "Backlog", stateType: "backlog", labels: [] })); + expect((await t.tool("steward_report", { phase: "watching", summary: "Filed ENG-50.", nextAction: "Watch", actor: "steward", opsTickets: [{ fingerprint: "acme/web#4", ticket: "ENG-50", did: "filed" }] }, ops)).error).toBe(false); + const needId = t.hooks.store.depsDecision("p1", "ENG-50")!.needId!; + expect(await t.harness.behavior.runCli(["done", needId, "--choice", "Keep tracking", "--note", "Revisit 2026-11-20", "--project", "p1"])).toMatchObject({ exitCode: 0, stdout: expect.stringMatching(/Chose Keep tracking/u) }); + t.advance(60_000); + await t.hooks.sweep(); + await t.flush(); + // Case 4: the decision stays pending, snoozed to the note's date, and its item is closed. + expect(t.hooks.store.depsDecision("p1", "ENG-50")).toMatchObject({ status: "pending", snoozedUntil: Date.parse("2026-11-20T12:00:00Z"), reminded: [] }); + expect(t.hooks.store.openNeedsForProject("p1").filter((n) => n.data.adoption === true)).toEqual([]); + expect(await t.harness.behavior.runCli(["needs", "--project", "p1"])).toMatchObject({ stdout: "Nothing is waiting on the user." }); + // The date arrives: the same decision is re-filed, now overdue. + t.advance(40 * 86_400_000); + await t.hooks.sweep(); + await t.flush(); + const raised = t.hooks.store.depsDecision("p1", "ENG-50")!; + expect(raised).toMatchObject({ status: "pending", snoozedUntil: null, reminded: ["overdue"] }); + expect(raised.needId).not.toBe(needId); + expect(t.hooks.store.openNeedsForProject("p1").find((n) => n.data.adoption === true)!.what).toMatch(/Overdue by \d+ days\.$/u); + }); + + it("does not offer or describe Dismiss when the deps-alerts playbook's may list lacks dismiss-alert", async () => { + const t = await setup(); + t.files[PROJECT_FILE] = JSON.stringify({ version: 1, linear: { teams: ["ENG"] }, github: { exclude: ["acme/bb"] } }); + t.files[playbookPath("deps-alerts")] = DEPS_ALERTS.replace(", dismiss-alert]", "]"); + t.files[playbookPath("deps-prs")] = DEPS_PRS; + t.dependabotAlerts.set("acme/web", [alert({ number: 4, severity: "medium", ghsaId: "GHSA-med", fixedVersion: null, createdAt: "2026-10-10T00:00:00Z" })]); + await t.hooks.sweep(); + await t.flush(); + const ops = t.spawns.find((x) => String(x.title).startsWith("Deps watch"))!.id; + t.entities.set("ENG-50", issue({ id: "ENG-50", key: "ENG-50", url: "https://linear.app/acme/issue/ENG-50", state: "Backlog", stateType: "backlog", labels: [] })); + expect((await t.tool("steward_report", { phase: "watching", summary: "Filed ENG-50.", nextAction: "Watch", actor: "steward", opsTickets: [{ fingerprint: "acme/web#4", ticket: "ENG-50", did: "filed" }] }, ops)).error).toBe(false); + const need = t.hooks.store.openNeedsForProject("p1").find((n) => n.data.adoption === true)!; + expect(need.data).toMatchObject({ choices: ["Keep tracking", "Adopt"] }); + expect(need.how).not.toMatch(/Dismiss/u); + expect((await t.harness.behavior.runCli(["needs", "--project", "p1"])).stdout).toMatch(/\[choices: Keep tracking, Adopt\]/u); + // The wake does not describe Dismiss either. + const wakeText = [String(t.spawns.find((x) => x.id === ops)!.prompt), ...t.sends.filter((x) => x.threadId === ops).map((x) => x.text)].join("\n"); + expect(wakeText).not.toMatch(/Dismiss as not used/u); + expect(wakeText).not.toMatch(/gh api -X PATCH/u); + }); + + it("lists each alert in a decision's fingerprints for Dismiss and tells the steward to re-read and skip an already closed one", async () => { + const { t, steward } = await watching([ + alert({ number: 4, severity: "medium", ghsaId: "GHSA-med", fixedVersion: null, createdAt: "2026-10-10T00:00:00Z" }), + alert({ number: 5, severity: "medium", ghsaId: "GHSA-two", package: "minimist", fixedVersion: null, createdAt: "2026-10-10T00:00:00Z" }), + ]); + const ops = steward()!.id; + t.entities.set("ENG-50", issue({ id: "ENG-50", key: "ENG-50", url: "https://linear.app/acme/issue/ENG-50", state: "Backlog", stateType: "backlog", labels: [] })); + expect((await t.tool("steward_report", { phase: "watching", summary: "Filed ENG-50.", nextAction: "Watch", actor: "steward", opsTickets: [{ fingerprint: "acme/web#4", ticket: "ENG-50", did: "filed" }, { fingerprint: "acme/web#5", ticket: "ENG-50", did: "filed" }] }, ops)).error).toBe(false); + // Alert #5 is fixed before the owner answers: it drops out of the open list + // but stays in the decision's fingerprints, so the dismiss instruction names + // it and tells the steward to skip it after re-reading. + t.dependabotAlerts.set("acme/web", [alert({ number: 4, severity: "medium", ghsaId: "GHSA-med", fixedVersion: null, createdAt: "2026-10-10T00:00:00Z" })]); + t.advance(60_000); + await t.hooks.sweep(); + await t.flush(); + expect(t.hooks.store.depsDecision("p1", "ENG-50")).toMatchObject({ fingerprints: ["acme/web#4", "acme/web#5"] }); + const wakeText = [String(t.spawns.find((x) => x.id === ops)!.prompt), ...t.sends.filter((x) => x.threadId === ops).map((x) => x.text)].join("\n"); + expect(wakeText).toMatch(/re-read `\/repos\/acme\/web\/dependabot\/alerts\/4` first and skip it if it is already fixed or dismissed/u); + expect(wakeText).toMatch(/re-read `\/repos\/acme\/web\/dependabot\/alerts\/5` first and skip it if it is already fixed or dismissed/u); + }); + it("adopts a dependabot[bot] pull request under deps-prs", async () => { const t = await setup(); t.files[PROJECT_FILE] = JSON.stringify({ version: 1, linear: { teams: ["ENG"] } }); diff --git a/plugins/stewards/server.ts b/plugins/stewards/server.ts index 9932e77c..05137449 100644 --- a/plugins/stewards/server.ts +++ b/plugins/stewards/server.ts @@ -70,7 +70,7 @@ import { type RolesFile, } from "./config.js"; import { decideFor, decideForPull, decideForRefusal, explainDecision, formatDecisionListing, globMatches, type BigDecision } from "./decisions.js"; -import { alertFingerprint, batchAlert, deadlineAt, depsLookSchema, isBatchable, priorityFor, repoOfFingerprint, sightingOf, type DepsFinding, type DepsLook, type DepsTicket } from "./deps.js"; +import { ADOPTION_CHOICES, adoptionChoices, adoptionHow, adoptionQuestion, alertClockAt, alertFingerprint, batchAlert, deadlineAt, depsLookSchema, dueDate, isBatchable, overdue, priorityFor, reminderText, repoOfFingerprint, sightingOf, snoozeUntil, type DepsFinding, type DepsLook, type DepsTicket } from "./deps.js"; import { hostContract } from "./host-contract.js"; import { MULTI_REPO_ENVIRONMENT_PROVIDER_ID, MULTI_REPO_PLUGIN_ID, multiRepoEnvironmentFlags, multiRepoFreshEnvironment, multiRepoSource, nativeSource, projectSourceLabel, sourceRpcMissing, type FreshEnvironment, type NativeGitRead, type ProjectSource } from "./project-source.js"; import { KUBECTL_VERBS, createKubectlReader, kubectlRefusal, type KubectlResult, type OpsAccess, type OpsReader } from "./kubectl.js"; @@ -99,7 +99,7 @@ import { } from "./source-contract.js"; import { DEPLOY_STATES, allDeployed, applyDeployment, applyDeploymentRead, applyWorkflowRun, awaitingOnRepo, deploymentLines, deploymentReportSchema, matchesTarget, matchesWorkflow, pickWatch, recordMerged, reportDeployment, targetKeyOf, timeOut, timeoutsDue } from "./deploy.js"; import { evaluateCarries, matchCarriesRule, noRuleNoted, type CarriesReader } from "./carries.js"; -import { ACTORS, MIGRATIONS, ESCALATED_NEED_KINDS, NEEDS_KINDS, RESOLUTION_KINDS, createStore, needChoices, type Binding, type Escalation, type EscalationRoute, type Fingerprint, type Lesson, type Need, type Observed, type Question, type ReadyState, type Store, type Worker } from "./store.js"; +import { ACTORS, MIGRATIONS, ESCALATED_NEED_KINDS, NEEDS_KINDS, RESOLUTION_KINDS, createStore, needChoices, type Binding, type DepsDecision, type Escalation, type EscalationRoute, type Fingerprint, type Lesson, type Need, type Observed, type Question, type ReadyState, type Store, type Worker } from "./store.js"; import { hasLessonAnchor, lessonAnchor, planLessonTransitions, planValidations, retiredLessonAnchor, withLessonAnchor } from "./lessons.js"; import { AFTER_THE_FACT_RATIONALE_WHY, REPEAT_RATIONALE_WHY, ROUTINE_RATIONALE_WHY, clusterKey, routineRationaleWanted } from "./rationale.js"; import { GUIDANCE_UNIT_PREFIX, GUIDANCE_UNCITED_DAYS, escalationWeeks, parseGuidanceUnits, uncitedGuidanceUnits, type GuidanceUnit } from "./guidance.js"; @@ -3504,7 +3504,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P for (const key of [...new Set(keys)].slice(0, 20)) { const e = await snapshot("linear", key).catch(() => null); const resolved = e !== null && (e.stateType === "completed" || e.stateType === "canceled"); - out.set(key, e === null ? null : { state: e.state, stateType: e.stateType, closedAt: e.completedAt ?? e.canceledAt ?? (resolved ? (e.updatedAt ?? null) : null) }); + out.set(key, e === null ? null : { state: e.state, stateType: e.stateType, closedAt: e.completedAt ?? e.canceledAt ?? (resolved ? (e.updatedAt ?? null) : null), labels: e.labels, url: e.url }); } return out; } @@ -3587,7 +3587,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P * resolved one. A ticket whose state cannot be read counts as open, as * in `classify`. */ - function openBatchedTickets(rows: readonly Fingerprint[], sla: ProjectFile["deps"]["sla"], tickets: ReadonlyMap): DepsTicket[] { + function openBatchedTickets(rows: readonly Fingerprint[], sla: ProjectFile["deps"]["sla"], tickets: ReadonlyMap, clockAt: (fingerprint: string, firstSeenAt: number) => number): DepsTicket[] { const byTicket = new Map(); for (const r of rows) { if (r.ticketKey === null || r.severity === null || r.ecosystem === null) continue; @@ -3596,7 +3596,10 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P if (state !== null && (state.stateType === "completed" || state.stateType === "canceled")) continue; const repo = repoOfFingerprint(r.fingerprint); if (repo === null) continue; - const dueAt = deadlineAt(r.severity as DependabotSeverity, sla, r.firstSeenAt); + // The plugin owns the deadline: it is the alert's own created_at plus + // the SLA, never Linear's due date (which the source contract does not + // carry). The plan may tighten the ticket's deadline, never loosen it. + const dueAt = deadlineAt(r.severity as DependabotSeverity, sla, clockAt(r.fingerprint, r.firstSeenAt)); const ticket = byTicket.get(r.ticketKey); if (ticket === undefined) byTicket.set(r.ticketKey, { ticketKey: r.ticketKey, repo, ecosystem: r.ecosystem, dueAt }); else ticket.dueAt = Math.min(ticket.dueAt, dueAt); @@ -3631,14 +3634,22 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P const sightings: Sighting[] = alerts.map((a) => ({ fingerprint: alertFingerprint(a), workload: a.package, pods: [], container: null, signature: a.severity, summary: `${a.severity} ${a.package}`, restarts: 0, events: [], logs: null, previous: false })); const tickets = await ticketStates([...allRows].map((r) => r.ticketKey).filter((k): k is string => k !== null)); const findings = classify(sightings, rows, tickets, at, config.linearLogin).map((f) => (adopted === null && f.class === "new" ? { ...f, dedupUnavailable: true } : f)); - const open = openBatchedTickets(allRows, config.file.deps.sla, tickets); const byFingerprint = new Map(alerts.map((a) => [alertFingerprint(a), a])); + // The SLA clock is the alert's own created_at, so a binding recreated + // later does not hand a ticket a fresh deadline (SC-111); a row whose + // alert is no longer listed keeps the first-seen clock it had. + const clockAt = (fingerprint: string, firstSeenAt: number): number => { + const a = byFingerprint.get(fingerprint); + return a === undefined ? firstSeenAt : alertClockAt(a, firstSeenAt); + }; + const open = openBatchedTickets(allRows, config.file.deps.sla, tickets, clockAt); const depsFindings: DepsFinding[] = []; for (const f of findings) { const a = byFingerprint.get(f.fingerprint); if (a === undefined) continue; const firstSeenAt = rows.get(f.fingerprint)?.firstSeenAt ?? at; - const dueAt = deadlineAt(a.severity, config.file.deps.sla, firstSeenAt); + const dueAt = deadlineAt(a.severity, config.file.deps.sla, alertClockAt(a, firstSeenAt)); + const ticketState = f.ticket === null ? null : (tickets.get(f.ticket) ?? null); depsFindings.push({ fingerprint: f.fingerprint, alertNumber: a.number, @@ -3652,6 +3663,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P state: a.state, url: a.url, dueAt, + ticketLabels: ticketState?.labels ?? [], priority: priorityFor(a.severity), class: f.class, ticket: f.ticket, @@ -3661,16 +3673,192 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P assignee: f.assignee, dedupUnavailable: f.dedupUnavailable, firstSeenAt, + alertCreatedAt: a.createdAt !== null && !Number.isNaN(Date.parse(a.createdAt)) ? a.createdAt : null, lastCommentedAt: f.lastCommentedAt, batch: batchAlert(a, dueAt, open), }); } - return { at, target: opsTargetKey(target), error: null, viewer: out.viewer, alerts: depsFindings }; + const look: DepsLook = { at, target: opsTargetKey(target), error: null, viewer: out.viewer, alerts: depsFindings }; + reconcileDepsDecisions(b, config, look, allRows, tickets, byFingerprint, clockAt); + return look; } catch (error) { return { at, target: opsTargetKey(target), error: errorMessage(error), viewer: null, alerts: [] }; } } + // ---- adoption decisions --------------------------------------------------------------------- + // + // A ticket filed for a severity deps.autoAdopt does not name is nobody's + // until the owner says so. The steward used to raise that as one decision + // escalation from the filing thread, and when that thread was deleted the + // question went with it (SC-111 sat six days in Backlog). Now the plugin + // records the decision as durable state keyed by ticket, shows it as a + // `decide` need on whichever binding watches the repository, reminds the + // owner once the deadline (or a snooze) has passed, and resolves it when + // the owner answers, the ticket is labelled, or the ticket closes. + + /** Whether a ticket's severity is one deps.autoAdopt names: such a ticket is adopted when filed and needs no decision. */ + const autoAdopted = (severity: string | null, config: ProjectConfig): boolean => severity !== null && (config.file.deps.autoAdopt as string[]).includes(severity); + + /** Whether the deps-alerts playbook's `may` list lets a steward dismiss an alert on GitHub. */ + const dismissAllowed = (config: ProjectConfig): boolean => config.depsAlerts?.may.includes("dismiss-alert") === true; + + /** The most severe of a ticket's alerts, for the decision's record. */ + function ticketSeverity(severities: ReadonlyArray): string | null { + const order = ["critical", "high", "medium", "low"]; + const known = severities.filter((x): x is string => x !== null).sort((a, b) => order.indexOf(a) - order.indexOf(b)); + return known[0] ?? null; + } + + /** The earliest deadline among a ticket's alert rows: the SLA clock from each alert's own created_at. */ + function decisionDeadline(rows: ReadonlyArray, config: ProjectConfig, clockAt: (fingerprint: string, firstSeenAt: number) => number): number { + return Math.min(...rows.map((r) => deadlineAt((r.severity ?? "medium") as DependabotSeverity, config.file.deps.sla, clockAt(r.fingerprint, r.firstSeenAt)))); + } + + /** The days past a deadline, in the project's time zone, or null when it has not passed. */ + function overdueDays(dueAt: number, config: ProjectConfig): number | null { + const at = now(); + if (!overdue(dueAt, at, config.file.nightly.timezone)) return null; + const today = Date.parse(`${dueDate(at, config.file.nightly.timezone)}T00:00:00Z`); + const due = Date.parse(`${dueDate(dueAt, config.file.nightly.timezone)}T00:00:00Z`); + return Math.max(1, Math.round((today - due) / 86_400_000)); + } + + /** The decide item an adoption decision shows as, filed on the binding that watches the repository now; an open one is reworded. */ + function fileAdoptionNeed(b: Binding, config: ProjectConfig, ticketKey: string, fingerprints: string[], alerts: ReadonlyArray>, ticket: TicketState, repo: string, dueAt: number, overdueBy: number | null): Need { + const hasFix = alerts.length > 0 && alerts.some((a) => a.fixedVersion !== null); + const allowDismiss = dismissAllowed(config); + const due = dueDate(dueAt, config.file.nightly.timezone); + return store.fileNeed({ + bindingId: b.id, + kind: "decide", + url: ticket?.url ?? ticketKey, + what: adoptionQuestion(ticketKey, repo, alerts, due, overdueBy), + how: adoptionHow(allowDismiss), + data: { adoption: true, ticket: ticketKey, fingerprints, choices: adoptionChoices(hasFix, allowDismiss), dueDate: due }, + filedBy: "plugin", + }); + } + + /** + * Raise the adoption decision on a ticket, idempotently: the decision row + * if it is new, and the decide item on this binding (reworded when it is + * already open). Called from the steward's report the moment it files, and + * from every look as the backfill for a ticket inherited by a recreated + * binding, found by the dedup search, or filed by a thread that never + * reported. + */ + function raiseAdoption(b: Binding, config: ProjectConfig, ticketKey: string, rows: ReadonlyArray, alerts: ReadonlyArray>, ticket: TicketState, dueAt: number, overdueBy: number | null = null): Need | null { + const repo = parseOpsTargetKey(b.entityId); + if (repo === null || repo.kind !== "dependabot") return null; + const fingerprints = rows.map((r) => r.fingerprint); + const { decision, raised } = store.raiseDepsDecision({ projectId: b.projectId, ticketKey, entityId: b.entityId, fingerprints, severity: ticketSeverity(rows.map((r) => r.severity)), raisedBy: b.threadId, needId: null }); + if (decision.status !== "pending") return null; + // The item from an earlier binding (recreated, rebound) is closed, not + // left open and invisible on an ended binding: the one filed here is + // the live question. + if (decision.needId !== null) { + const earlier = store.need(decision.needId); + if (earlier !== null && earlier.status === "open" && earlier.bindingId !== b.id) store.closeNeed(earlier.id, `Moved to ${b.id}, which watches the repository now.`, "plugin"); + } + const need = fileAdoptionNeed(b, config, ticketKey, fingerprints, alerts, ticket, repo.repo, dueAt, overdueBy); + if (decision.needId !== need.id || decision.fingerprints.length !== fingerprints.length || decision.entityId !== b.entityId) store.patchDepsDecision(b.projectId, ticketKey, { needId: need.id, fingerprints, entityId: b.entityId }); + if (raised) bb.log.info(`stewards: raised the adoption decision on ${ticketKey} for ${b.entityKey}`); + return need; + } + + /** Settle a pending decision with how it was settled and close its item. */ + function settleAdoption(b: Binding, decision: DepsDecision, resolution: string): void { + if (!store.resolveDepsDecision(b.projectId, decision.ticketKey, resolution)) return; + if (decision.needId !== null) { + const need = store.need(decision.needId); + if (need !== null && need.status === "open") store.closeNeed(need.id, resolution, "plugin"); + } + } + + /** + * Every look: raise the decision for each unadopted ticket in the map that + * has none, re-file the item on this binding when it was on an earlier + * one, settle a decision the owner has answered, the ticket has been + * labelled for, or whose ticket closed, re-raise a snoozed decision when + * its date arrives, and send the one overdue reminder. The reminder + * rewords the item and adds a note to the project steward's next wake (so + * the digest's "needs you" names the deadline); it is recorded once. Only + * tickets the look could read are judged: an unreadable ticket keeps + * whatever state the decision had. + */ + function reconcileDepsDecisions(b: Binding, config: ProjectConfig, look: DepsLook, rows: ReadonlyArray, tickets: ReadonlyMap, alerts: ReadonlyMap, clockAt: (fingerprint: string, firstSeenAt: number) => number): void { + const byTicket = new Map(); + for (const r of rows) { + if (r.ticketKey === null) continue; + const list = byTicket.get(r.ticketKey) ?? []; + list.push(r); + byTicket.set(r.ticketKey, list); + } + const label = lc(config.file.label); + for (const [ticketKey, ticketRows] of byTicket) { + const ticket = tickets.get(ticketKey) ?? null; + const decision = store.depsDecision(b.projectId, ticketKey); + const ticketAlerts = ticketRows.map((r) => alerts.get(r.fingerprint)).filter((a): a is DependabotAlert => a !== undefined); + // A ticket whose state could not be read is left as it is: neither + // raised (it may be adopted) nor settled (it may still be open). + if (ticket === null) continue; + const closed = ticket.stateType === "completed" || ticket.stateType === "canceled"; + const labelled = (ticket.labels ?? []).some((l) => lc(l) === label); + if (decision !== null && decision.status === "pending") { + const need = decision.needId === null ? null : store.need(decision.needId); + if (closed) { + settleAdoption(b, decision, `${ticketKey} was closed (${ticket.state}).`); + continue; + } + if (labelled) { + settleAdoption(b, decision, `${ticketKey} was adopted (labelled ${config.file.label}).`); + continue; + } + // The owner answered on the Stewards page or with `bb stewards done`. + // Only that answer snoozes a decision; the plugin never snoozes or + // dismisses on a steward's word. + if (need !== null && need.status === "done") { + const resolution = need.resolution ?? "Answered."; + if (resolution.startsWith(`Chose: ${ADOPTION_CHOICES.keep}`)) { + if (decision.snoozedUntil === null) { + const until = snoozeUntil(resolution, decision.severity as DependabotSeverity | null, config.file.deps.sla, now()); + store.patchDepsDecision(b.projectId, ticketKey, { snoozedUntil: until, reminded: [] }); + store.note(b.id, `${ticketKey} is snoozed until ${dueDate(until, config.file.nightly.timezone)}; the plugin asks again then.`, true); + } + } else { + settleAdoption(b, decision, resolution); + continue; + } + } + const current = store.depsDecision(b.projectId, ticketKey)!; + // A snoozed decision is quiet until its date arrives; then the item + // is re-filed and the reminder clock runs from the snooze date. + const snoozeEnded = current.snoozedUntil !== null && current.snoozedUntil <= now(); + if (current.snoozedUntil !== null && !snoozeEnded) continue; + const dueAt = current.snoozedUntil ?? decisionDeadline(ticketRows, config, clockAt); + if (snoozeEnded) store.patchDepsDecision(b.projectId, ticketKey, { snoozedUntil: null, reminded: [] }); + const days = overdueDays(dueAt, config); + raiseAdoption(b, config, ticketKey, ticketRows, ticketAlerts, ticket, dueAt, days); + const after = store.depsDecision(b.projectId, ticketKey)!; + if (days === null || after.reminded.includes("overdue")) continue; + const text = reminderText(ticketKey, dueDate(dueAt, config.file.nightly.timezone), now(), config.file.nightly.timezone); + store.patchDepsDecision(b.projectId, ticketKey, { reminded: [...after.reminded, "overdue"] }); + const latest = store.depsDecision(b.projectId, ticketKey); + store.noteProject(b.projectId, `${text} The decision is ${latest?.needId ?? "on the Stewards page"} (${b.entityKey}); it is the owner's to answer, so it belongs under "needs you" in the digest, not in a plan to raise it later.`); + store.note(b.id, text, true); + continue; + } + if (decision !== null) continue; + // No decision yet: an open, unlabelled ticket for a severity + // deps.autoAdopt does not name needs one, whoever filed it and whenever. + if (closed || labelled) continue; + if (ticketRows.every((r) => autoAdopted(r.severity, config))) continue; + const dueAt = decisionDeadline(ticketRows, config, clockAt); + raiseAdoption(b, config, ticketKey, ticketRows, ticketAlerts, ticket, dueAt, overdueDays(dueAt, config)); + } + } + /** What changed between two dependency looks, as the mechanical tier weighs it. */ function depsChanges(before: DepsLook | null, after: DepsLook, ticketFor: (fingerprint: string) => string | null): Change[] { if (after.error !== null) return before === null || before.error === null ? [{ text: `Cannot read ${after.target}: ${after.error}`, actionable: false }] : []; @@ -3813,6 +4001,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P autoAdopt: config.file.deps.autoAdopt, timeZone: config.file.nightly.timezone, escalations: store.openEscalations(b.id), + decisions: store.depsDecisions(b.projectId, b.entityId).filter((d) => d.status === "pending"), changes: [...new Set(changes.map((c) => c.text))], }); const delivery = await deliverWake(b, config, playbook, text); @@ -4231,7 +4420,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P const team = roleOpsTeam(config.role, config.file); const escalations = store.openEscalations(b.id); if (target?.kind === "dependabot" && b.depsLook !== null) { - return formatDepsWake({ reason: "disabled", binding: b, playbook, look: b.depsLook, team, label: config.file.label, owner: config.linearLogin, autoAdopt: config.file.deps.autoAdopt, timeZone: config.file.nightly.timezone, escalations, changes }); + return formatDepsWake({ reason: "disabled", binding: b, playbook, look: b.depsLook, team, label: config.file.label, owner: config.linearLogin, autoAdopt: config.file.deps.autoAdopt, timeZone: config.file.nightly.timezone, escalations, decisions: store.depsDecisions(b.projectId, b.entityId).filter((d) => d.status === "pending"), changes }); } if (target?.kind === "sentry-project" && b.sentryLook !== null) { return formatSentryWake({ reason: "disabled", binding: b, playbook, look: b.sentryLook, team, label: config.file.label, owner: config.linearLogin, autoAdopt: config.role?.ops?.autoAdopt ?? [], escalations, changes }); @@ -7713,7 +7902,31 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P ), ); } - for (const t of opsTickets) store.recordTicket(b.projectId, t.fingerprint, t.ticket, t.did); + for (const t of opsTickets) store.recordTicket(b.projectId, t.fingerprint, t.ticket, t.did, threadId); + if (opsTickets.length > 0) { + // A dependency ticket filed for a severity deps.autoAdopt does not + // name gets its adoption decision now, not at the next look, so + // `bb stewards needs` shows it the moment the ticket exists. + if (config !== null && parseOpsTargetKey(b.entityId)?.kind === "dependabot") { + const filed = new Map(); + for (const t of opsTickets.filter((x) => x.did === "filed" || x.did === "reopened")) { + const row = store.fingerprint(b.projectId, t.fingerprint); + if (row === null || row.ticketKey === null) continue; + filed.set(row.ticketKey, [...(filed.get(row.ticketKey) ?? []), row]); + } + for (const [ticketKey, rows] of filed) { + if (rows.every((r) => autoAdopted(r.severity, config))) continue; + const alerts = (b.depsLook?.alerts ?? []).filter((a) => rows.some((r) => r.fingerprint === a.fingerprint)); + const ticket = await ticketStates([ticketKey]).then((m) => m.get(ticketKey) ?? null).catch(() => null); + if (ticket !== null && (ticket.stateType === "completed" || ticket.stateType === "canceled" || (ticket.labels ?? []).some((l) => lc(l) === lc(config.file.label)))) continue; + // The deadline is the earliest the look computed for the + // ticket's alerts (alert created_at + SLA), the same date the + // wake showed the steward when it filed. + const dueAt = alerts.length > 0 ? Math.min(...alerts.map((a) => a.dueAt)) : deadlineAt((rows[0]!.severity ?? "medium") as DependabotSeverity, config.file.deps.sla, now()); + raiseAdoption(b, config, ticketKey, rows, alerts, ticket, dueAt); + } + } + } // BB-53: a deployment verdict the steward reached from the playbook's // rule. `deployed` settles the watch; `not-yet` puts it back to // awaiting; `failed` marks it failed. Once every watch is deployed @@ -9629,7 +9842,10 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P for (const x of detail.escalations) lines.push(`Escalation ${x.id} ${x.kind} (${x.status}, ${x.route}): ${x.question}${choicesNote(x.choices)}${x.resolution === null ? "" : ` → ${x.resolution}`}`); for (const n of store.needs(b.id, true)) lines.push(`Need ${n.id} ${n.kind} (${n.status}): ${n.what}${choicesNote(needChoices(n))}${n.url === "" ? "" : ` — ${n.url}`}${n.resolution === null ? "" : ` → ${n.resolution}`}`); for (const w of detail.workers) lines.push(`Worker ${w.threadId} ${w.title}: ${w.status}`); - for (const f of store.fingerprints(b.id)) lines.push(`Fingerprint ${f.fingerprint} → ${f.ticketKey ?? "no ticket"}; seen ${f.seenCount} time(s), last ${iso(f.lastSeenAt)}${f.lastCommentedAt === null ? "" : `; last word ${iso(f.lastCommentedAt)}`}`); + for (const f of store.fingerprints(b.id)) lines.push(`Fingerprint ${f.fingerprint} → ${f.ticketKey ?? "no ticket"}; seen ${f.seenCount} time(s), last ${iso(f.lastSeenAt)}${f.filedByThread === null || f.filedByThread === undefined ? "" : `, filed by ${f.filedByThread}`}${f.lastCommentedAt === null ? "" : `; last word ${iso(f.lastCommentedAt)}`}`); + if (b.kind === "ops") { + for (const d of store.depsDecisions(b.projectId, b.entityId)) lines.push(`Decision ${d.ticketKey} (${d.status}, raised ${iso(d.raisedAt)}${d.raisedBy === null ? "" : ` by ${d.raisedBy}`}${d.needId === null ? "" : `, item ${d.needId}`}${d.snoozedUntil === null ? "" : `, snoozed until ${iso(d.snoozedUntil)}`}${d.reminded.length === 0 ? "" : `, reminded ${d.reminded.join(", ")}`}): ${d.fingerprints.join(", ")}${d.resolution === null ? "" : ` → ${d.resolution}`}`); + } return { exitCode: 0, stdout: lines.join("\n") }; }, }), diff --git a/plugins/stewards/skills/stewards/SKILL.md b/plugins/stewards/skills/stewards/SKILL.md index dbbab0b5..6cb11afd 100644 --- a/plugins/stewards/skills/stewards/SKILL.md +++ b/plugins/stewards/skills/stewards/SKILL.md @@ -316,6 +316,18 @@ fingerprint before filing. ticket, did: "filed" | "commented" | "reopened" }] }`, by the fingerprint the wake gives. That is what makes the next look a comment instead of a duplicate. +- **Dependabot tickets (`deps-alerts`) raise no adoption escalation.** For + a severity `deps.autoAdopt` does not name, the plugin files the owner's + adoption decision itself the moment your report lists the ticket as + filed, keeps it by ticket until answered, and reminds the owner once its + deadline has passed. The answer arrives under "What changed" as "The user + chose …"; the wake says what Adopt, Dismiss as not used and Keep tracking + each mean. Dismiss an alert only when the owner answered with that choice + on the ticket's decide item, and re-read each alert first so an already + fixed or dismissed one is skipped; the playbook's `may` list must declare + `dismiss-alert` or the choice is not offered. The plugin owns the + deadline: the alert's own `created_at` plus the SLA, the same date a + recreated binding computes. - **All healthy:** the wake says so every requeue; report it with `actor: other` and end the turn. - **No Sentry token** (the wake says so): raise the escalation it describes diff --git a/plugins/stewards/store.test.ts b/plugins/stewards/store.test.ts index 0c957e18..4596c3c0 100644 --- a/plugins/stewards/store.test.ts +++ b/plugins/stewards/store.test.ts @@ -1,7 +1,7 @@ import Database from "better-sqlite3"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { ACTOR_MIGRATIONS, APPROVAL_MARKER_MIGRATIONS, BASE_MIGRATIONS, CHAIN_MIGRATIONS, ESCALATION_ROUTE_MIGRATIONS, GUIDANCE_MIGRATIONS, IDLE_MIGRATIONS, LESSON_COUNTER_MIGRATIONS, LESSON_PR_MIGRATIONS, LESSON_TIER_MIGRATIONS, MIGRATIONS, PULL_WATCH_MIGRATIONS, RATIONALE_MIGRATIONS, REVIEWER_MODEL_MIGRATIONS, SERVER_SETTINGS_MIGRATIONS, STACK_MIGRATIONS, VALIDATION_MIGRATIONS, WAKE_SOURCE_MIGRATIONS, WORKER_CHECK_MIGRATIONS, createStore, lessonScope, statusRecordSchema, type Binding } from "./store.js"; +import { ACTOR_MIGRATIONS, APPROVAL_MARKER_MIGRATIONS, BASE_MIGRATIONS, CHAIN_MIGRATIONS, DEPS_DECISION_MIGRATIONS, ESCALATION_ROUTE_MIGRATIONS, GUIDANCE_MIGRATIONS, IDLE_MIGRATIONS, LESSON_COUNTER_MIGRATIONS, LESSON_PR_MIGRATIONS, LESSON_TIER_MIGRATIONS, MIGRATIONS, PULL_WATCH_MIGRATIONS, RATIONALE_MIGRATIONS, REVIEWER_MODEL_MIGRATIONS, SERVER_SETTINGS_MIGRATIONS, STACK_MIGRATIONS, VALIDATION_MIGRATIONS, WAKE_SOURCE_MIGRATIONS, WORKER_CHECK_MIGRATIONS, createStore, lessonScope, statusRecordSchema, type Binding } from "./store.js"; /** MIGRATIONS without the groups appended after the ones these order tests assert. */ const beforeActorMigrations = MIGRATIONS.slice(0, MIGRATIONS.indexOf(ACTOR_MIGRATIONS[0]!)); @@ -470,7 +470,7 @@ describe("the actor rename migration (BB-97)", () => { // it. BB-164 appended the pull-watch group after BB-184's server settings, // and BB-215's stack group follows it (migrations.test.ts pins the whole // list, this only checks the group order). - const groups = [ESCALATION_ROUTE_MIGRATIONS, STACK_MIGRATIONS, PULL_WATCH_MIGRATIONS, SERVER_SETTINGS_MIGRATIONS, WAKE_SOURCE_MIGRATIONS, REVIEWER_MODEL_MIGRATIONS, LESSON_PR_MIGRATIONS, ACTOR_MIGRATIONS, APPROVAL_MARKER_MIGRATIONS]; + const groups = [DEPS_DECISION_MIGRATIONS, ESCALATION_ROUTE_MIGRATIONS, STACK_MIGRATIONS, PULL_WATCH_MIGRATIONS, SERVER_SETTINGS_MIGRATIONS, WAKE_SOURCE_MIGRATIONS, REVIEWER_MODEL_MIGRATIONS, LESSON_PR_MIGRATIONS, ACTOR_MIGRATIONS, APPROVAL_MARKER_MIGRATIONS]; let end = MIGRATIONS.length; for (const group of groups) { expect(MIGRATIONS.slice(end - group.length, end)).toEqual([...group]); @@ -506,6 +506,7 @@ describe("the escalation route rename migration (BB-209)", () => { insert(db, "esc_me", "me"); insert(db, "esc_project", "project", "Keep?"); for (const sql of ESCALATION_ROUTE_MIGRATIONS) db.exec(sql); + for (const sql of DEPS_DECISION_MIGRATIONS) db.exec(sql); // A second run matches nothing and changes nothing. for (const sql of ESCALATION_ROUTE_MIGRATIONS) db.exec(sql); expect(routeOf(db, "esc_me")).toBe("user"); @@ -518,6 +519,7 @@ describe("the escalation route rename migration (BB-209)", () => { const db = legacy(); insert(db, "esc_me", "me"); for (const sql of ESCALATION_ROUTE_MIGRATIONS) db.exec(sql); + for (const sql of DEPS_DECISION_MIGRATIONS) db.exec(sql); const route = routeOf(db, "esc_me"); expect(route).toBe("user"); expect(() => readAsOldBuild(route)).not.toThrow(); @@ -528,6 +530,7 @@ describe("the escalation route rename migration (BB-209)", () => { const logs: string[] = []; const db = legacy(); for (const sql of ESCALATION_ROUTE_MIGRATIONS) db.exec(sql); + for (const sql of DEPS_DECISION_MIGRATIONS) db.exec(sql); const s = createStore(db, () => 2000, { log: (m) => logs.push(m) }); const x = s.escalate({ bindingId: "bnd_1", kind: "decision", question: "Q?", meanwhile: "Wait.", route: "me", recommendation: null, projectId: "p1" }); expect(x.route).toBe("user"); @@ -542,6 +545,7 @@ describe("the escalation route rename migration (BB-209)", () => { const logs: string[] = []; const db = legacy(); for (const sql of ESCALATION_ROUTE_MIGRATIONS) db.exec(sql); + for (const sql of DEPS_DECISION_MIGRATIONS) db.exec(sql); const s = createStore(db, () => 2000, { log: (m) => logs.push(m) }); const x = s.escalate({ bindingId: "bnd_1", kind: "decision", question: "Q?", meanwhile: "Wait.", route: "project", recommendation: null, projectId: "p1" }); expect(s.reroute(x.id, "me", null)).toBe(true); @@ -550,8 +554,12 @@ describe("the escalation route rename migration (BB-209)", () => { }); it("is appended at the end of MIGRATIONS, so a shipped statement index is never reused", () => { - expect(MIGRATIONS.slice(-ESCALATION_ROUTE_MIGRATIONS.length)).toEqual([...ESCALATION_ROUTE_MIGRATIONS]); - expect(MIGRATIONS.slice(-(ESCALATION_ROUTE_MIGRATIONS.length + STACK_MIGRATIONS.length), -ESCALATION_ROUTE_MIGRATIONS.length)).toEqual([...STACK_MIGRATIONS]); + // The deps-decision group (the adoption-decision table and the fingerprint + // provenance column) was appended after this one. + const tail = MIGRATIONS.slice(0, -DEPS_DECISION_MIGRATIONS.length); + expect(MIGRATIONS.slice(-DEPS_DECISION_MIGRATIONS.length)).toEqual([...DEPS_DECISION_MIGRATIONS]); + expect(tail.slice(-ESCALATION_ROUTE_MIGRATIONS.length)).toEqual([...ESCALATION_ROUTE_MIGRATIONS]); + expect(tail.slice(-(ESCALATION_ROUTE_MIGRATIONS.length + STACK_MIGRATIONS.length), -ESCALATION_ROUTE_MIGRATIONS.length)).toEqual([...STACK_MIGRATIONS]); }); }); @@ -572,6 +580,7 @@ describe("the rationale migration (BB-35 step 4)", () => { for (const sql of LESSON_PR_MIGRATIONS) db.exec(sql); for (const sql of PULL_WATCH_MIGRATIONS) db.exec(sql); for (const sql of ESCALATION_ROUTE_MIGRATIONS) db.exec(sql); + for (const sql of DEPS_DECISION_MIGRATIONS) db.exec(sql); return db; }; @@ -871,6 +880,7 @@ describe("the escalation-chain migration (BB-30)", () => { for (const sql of LESSON_PR_MIGRATIONS) db.exec(sql); for (const sql of PULL_WATCH_MIGRATIONS) db.exec(sql); for (const sql of ESCALATION_ROUTE_MIGRATIONS) db.exec(sql); + for (const sql of DEPS_DECISION_MIGRATIONS) db.exec(sql); let now = 2000; return { db, s: createStore(db, () => now) }; } @@ -957,3 +967,37 @@ describe("the server settings", () => { expect(s.serverSetting("another-key")).toBeNull(); }); }); + +describe("adoption decisions (keyed by ticket, not by binding)", () => { + it("raises a decision once per ticket, keeps it across bindings, snoozes it, and resolves it once", () => { + const { s, advance } = store(); + const first = s.raiseDepsDecision({ projectId: "p1", ticketKey: "sc-111", entityId: "dependabot/acme/web", fingerprints: ["acme/web#23", "acme/web#24"], severity: "medium", raisedBy: "thr_filed", needId: "need_1" }); + expect(first.raised).toBe(true); + expect(first.decision).toMatchObject({ ticketKey: "SC-111", entityId: "dependabot/acme/web", fingerprints: ["acme/web#23", "acme/web#24"], severity: "medium", raisedBy: "thr_filed", needId: "need_1", status: "pending", resolution: null, reminded: [], snoozedUntil: null }); + // Raised again from a recreated binding: the same row, not a second question. + advance(86_400_000); + const again = s.raiseDepsDecision({ projectId: "p1", ticketKey: "SC-111", entityId: "dependabot/acme/web", fingerprints: ["acme/web#23"], severity: "medium", raisedBy: "thr_new", needId: null }); + expect(again.raised).toBe(false); + expect(again.decision.raisedAt).toBe(first.decision.raisedAt); + expect(s.depsDecision("p1", "SC-111")).toMatchObject({ raisedBy: "thr_filed", needId: "need_1" }); + expect(s.patchDepsDecision("p1", "SC-111", { needId: "need_2", reminded: ["overdue"], snoozedUntil: 9_000 })).toMatchObject({ needId: "need_2", reminded: ["overdue"], snoozedUntil: 9_000 }); + expect(s.pendingDepsDecisions("p1").map((d) => d.ticketKey)).toEqual(["SC-111"]); + expect(s.depsDecisions("p1", "dependabot/acme/web").map((d) => d.ticketKey)).toEqual(["SC-111"]); + expect(s.depsDecisions("p1", "dependabot/acme/other")).toEqual([]); + expect(s.resolveDepsDecision("p1", "sc-111", "Chose: Adopt")).toBe(true); + expect(s.resolveDepsDecision("p1", "SC-111", "again")).toBe(false); + expect(s.depsDecision("p1", "SC-111")).toMatchObject({ status: "resolved", resolution: "Chose: Adopt", resolvedAt: 1_000 + 86_400_000 }); + expect(s.pendingDepsDecisions("p1")).toEqual([]); + expect(s.patchDepsDecision("p1", "SC-999", { needId: "x" })).toBeNull(); + }); + + it("records the filing thread on the fingerprint row as provenance", () => { + const { s } = store(); + s.sight("p1", "bnd_1", [{ fingerprint: "acme/web#23", summary: "medium lodash", severity: "medium", ecosystem: "npm" }]); + expect(s.recordTicket("p1", "acme/web#23", "sc-111", "filed", "thr_filed")).toBe(true); + expect(s.fingerprint("p1", "acme/web#23")).toMatchObject({ ticketKey: "SC-111", filedByThread: "thr_filed" }); + // A comment is not a filing: it leaves the filing thread in place. + s.recordTicket("p1", "acme/web#23", "sc-111", "commented", "thr_other"); + expect(s.fingerprint("p1", "acme/web#23")).toMatchObject({ filedByThread: "thr_filed", lastCommentedAt: expect.any(Number) }); + }); +}); diff --git a/plugins/stewards/store.ts b/plugins/stewards/store.ts index 7405b621..a3d2e1cb 100644 --- a/plugins/stewards/store.ts +++ b/plugins/stewards/store.ts @@ -518,7 +518,38 @@ export const STACK_MIGRATIONS = [`ALTER TABLE bindings ADD COLUMN hold_stack TEX // them and normalises what they wrote. export const ESCALATION_ROUTE_MIGRATIONS = [`UPDATE escalations SET route = 'user' WHERE route = 'me'`]; -export const MIGRATIONS = [...BASE_MIGRATIONS, ...CHAIN_MIGRATIONS, ...LESSON_COUNTER_MIGRATIONS, ...WORKER_CHECK_MIGRATIONS, ...DECLINED_PR_MIGRATIONS, ...DEPLOYMENT_MIGRATIONS, ...FINISHED_AND_DIGEST_MIGRATIONS, ...LESSON_TIER_MIGRATIONS, ...RATIONALE_MIGRATIONS, ...VALIDATION_MIGRATIONS, ...GUIDANCE_MIGRATIONS, ...IDLE_MIGRATIONS, ...APPROVAL_MARKER_MIGRATIONS, ...ACTOR_MIGRATIONS, ...LESSON_PR_MIGRATIONS, ...REVIEWER_MODEL_MIGRATIONS, ...WAKE_SOURCE_MIGRATIONS, ...SERVER_SETTINGS_MIGRATIONS, ...PULL_WATCH_MIGRATIONS, ...STACK_MIGRATIONS, ...ESCALATION_ROUTE_MIGRATIONS]; +// An adoption decision on a dependency ticket filed for a severity +// deps.autoAdopt does not name: pending until the owner answers, the ticket is +// labelled, or it closes. Keyed by ticket, not by binding or thread, so it +// outlives both; `need_id` is the decide item currently showing it and +// `snoozed_until` is the date a Keep tracking answer moved it to. The +// fingerprint map also gains the thread that filed a ticket (`filed_by_thread`), +// which `bb stewards show` prints as provenance with the filing thread gone. +// +// The statements are prepared lazily (see createStore), because the migration +// tests open stores on schemas from before this group. +export const DEPS_DECISION_MIGRATIONS = [ + `CREATE TABLE IF NOT EXISTS deps_decisions ( + project_id TEXT NOT NULL, + ticket_key TEXT NOT NULL, + entity_id TEXT NOT NULL, + fingerprints TEXT NOT NULL DEFAULT '[]', + severity TEXT, + raised_at INTEGER NOT NULL, + raised_by TEXT, + need_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + resolution TEXT, + resolved_at INTEGER, + reminded TEXT NOT NULL DEFAULT '[]', + snoozed_until INTEGER, + PRIMARY KEY (project_id, ticket_key) + )`, + `CREATE INDEX IF NOT EXISTS deps_decisions_target ON deps_decisions (project_id, entity_id, status)`, + `ALTER TABLE ops_fingerprints ADD COLUMN filed_by_thread TEXT`, +]; + +export const MIGRATIONS = [...BASE_MIGRATIONS, ...CHAIN_MIGRATIONS, ...LESSON_COUNTER_MIGRATIONS, ...WORKER_CHECK_MIGRATIONS, ...DECLINED_PR_MIGRATIONS, ...DEPLOYMENT_MIGRATIONS, ...FINISHED_AND_DIGEST_MIGRATIONS, ...LESSON_TIER_MIGRATIONS, ...RATIONALE_MIGRATIONS, ...VALIDATION_MIGRATIONS, ...GUIDANCE_MIGRATIONS, ...IDLE_MIGRATIONS, ...APPROVAL_MARKER_MIGRATIONS, ...ACTOR_MIGRATIONS, ...LESSON_PR_MIGRATIONS, ...REVIEWER_MODEL_MIGRATIONS, ...WAKE_SOURCE_MIGRATIONS, ...SERVER_SETTINGS_MIGRATIONS, ...PULL_WATCH_MIGRATIONS, ...STACK_MIGRATIONS, ...ESCALATION_ROUTE_MIGRATIONS, ...DEPS_DECISION_MIGRATIONS]; // ---- values kept as JSON ------------------------------------------------------------ @@ -1121,6 +1152,30 @@ export type Fingerprint = FingerprintRow & { projectId: string; bindingId: strin /** What a steward reports doing about a fingerprint: filing a ticket, commenting on one, or reopening one. */ export type TicketAction = "filed" | "commented" | "reopened"; +/** + * The owner's pending decision on a dependency ticket nobody has adopted: + * raised when the ticket is filed for a severity `deps.autoAdopt` does not + * name, resolved by the owner's answer, the ticket being labelled, or the + * ticket closing. Keyed by ticket, so it survives the filing thread and the + * binding; `reminded` lists the reminder stages already sent. + */ +export type DepsDecision = { + projectId: string; + ticketKey: string; + entityId: string; + fingerprints: string[]; + severity: string | null; + raisedAt: number; + raisedBy: string | null; + needId: string | null; + status: "pending" | "resolved"; + resolution: string | null; + resolvedAt: number | null; + reminded: string[]; + /** The date a Keep tracking answer moved the decision to; null when it is not snoozed. */ + snoozedUntil: number | null; +}; + export type ProjectStewardRow = { projectId: string; threadId: string | null; digest: string | null; digestAt: number | null; nightlyDate: string | null; digestPending: boolean; title: string | null; pendingNotes: string[] }; /** Guidance repeated within this window is a retry, not new guidance. */ @@ -1242,10 +1297,25 @@ export function createStore(db: Database.Database, now: () => number, options: S ON CONFLICT (project_id, fingerprint) DO UPDATE SET binding_id = @binding_id, summary = @summary, last_seen_at = @at, seen_count = seen_count + 1, severity = COALESCE(@severity, severity), ecosystem = COALESCE(@ecosystem, ecosystem)`, ); - const setTicket = db.prepare("UPDATE ops_fingerprints SET ticket_key = ?, filed_at = ?, last_commented_at = ? WHERE project_id = ? AND fingerprint = ?"); const setCommented = db.prepare("UPDATE ops_fingerprints SET ticket_key = ?, last_commented_at = ? WHERE project_id = ? AND fingerprint = ?"); const setAdopted = db.prepare("UPDATE ops_fingerprints SET ticket_key = ? WHERE project_id = ? AND fingerprint = ?"); + // Prepared on first use: the migration tests open stores on partial schemas + // from before the deps-decision group, and these tables are the newest. + const lazy =

(sql: string): (() => Database.Statement) => { + let prepared: Database.Statement | null = null; + return () => (prepared ??= db.prepare(sql)); + }; + const getDepsDecision = lazy<[string, string], Record>("SELECT * FROM deps_decisions WHERE project_id = ? AND ticket_key = ?"); + const depsDecisionsForTarget = lazy<[string, string], Record>("SELECT * FROM deps_decisions WHERE project_id = ? AND entity_id = ? ORDER BY raised_at"); + const depsDecisionsForProject = lazy<[string], Record>("SELECT * FROM deps_decisions WHERE project_id = ? AND status = 'pending' ORDER BY raised_at"); + const insertDepsDecision = lazy<[Record], unknown>( + "INSERT INTO deps_decisions (project_id, ticket_key, entity_id, fingerprints, severity, raised_at, raised_by, need_id) VALUES (@project_id, @ticket_key, @entity_id, @fingerprints, @severity, @raised_at, @raised_by, @need_id)", + ); + // Touches `filed_by_thread`, so it is prepared lazily too: a legacy store + // opened without the migration must still start (migrations.test.ts). + const setTicket = lazy<[string, number, number, string | null, string, string], unknown>("UPDATE ops_fingerprints SET ticket_key = ?, filed_at = ?, last_commented_at = ?, filed_by_thread = ? WHERE project_id = ? AND fingerprint = ?"); + const getProject = db.prepare<[string], Record>("SELECT * FROM project_stewards WHERE project_id = ?"); const byProjectThread = db.prepare<[string], Record>("SELECT * FROM project_stewards WHERE thread_id = ?"); const upsertProject = db.prepare("INSERT INTO project_stewards (project_id) VALUES (?) ON CONFLICT (project_id) DO NOTHING"); @@ -1426,9 +1496,25 @@ export function createStore(db: Database.Database, now: () => number, options: S seenCount: Number(r.seen_count), filedAt: r.filed_at === null ? null : Number(r.filed_at), lastCommentedAt: r.last_commented_at === null ? null : Number(r.last_commented_at), + filedByThread: r.filed_by_thread === null || r.filed_by_thread === undefined ? null : String(r.filed_by_thread), severity: r.severity === null || r.severity === undefined ? null : String(r.severity), ecosystem: r.ecosystem === null || r.ecosystem === undefined ? null : String(r.ecosystem), }); + const toDepsDecision = (r: Record): DepsDecision => ({ + projectId: String(r.project_id), + ticketKey: String(r.ticket_key), + entityId: String(r.entity_id), + fingerprints: json(z.array(z.string()), r.fingerprints === null ? null : String(r.fingerprints), []), + severity: r.severity === null ? null : String(r.severity), + raisedAt: Number(r.raised_at), + raisedBy: r.raised_by === null ? null : String(r.raised_by), + needId: r.need_id === null ? null : String(r.need_id), + status: r.status === "resolved" ? "resolved" : "pending", + resolution: r.resolution === null ? null : String(r.resolution), + resolvedAt: r.resolved_at === null ? null : Number(r.resolved_at), + reminded: json(z.array(z.string()), r.reminded === null ? null : String(r.reminded), []), + snoozedUntil: r.snoozed_until === null || r.snoozed_until === undefined ? null : Number(r.snoozed_until), + }); const toProject = (r: Record): ProjectStewardRow => ({ projectId: String(r.project_id), threadId: r.thread_id === null ? null : String(r.thread_id), @@ -2117,10 +2203,13 @@ export function createStore(db: Database.Database, now: () => number, options: S * and nothing new; a comment counts as the latest word on it. False when * the map has no such fingerprint. */ - recordTicket(projectId: string, fingerprint: string, ticketKey: string, did: TicketAction): boolean { + recordTicket(projectId: string, fingerprint: string, ticketKey: string, did: TicketAction, threadId: string | null = null): boolean { const at = now(); const key = ticketKey.toUpperCase(); - const changed = did === "commented" ? setCommented.run(key, at, projectId, fingerprint) : setTicket.run(key, at, at, projectId, fingerprint); + // A filing or reopening records the acting thread on the row as + // provenance, so `bb stewards show` answers "who filed it?" after the + // thread is gone. A comment is the same steward's later word, not a filing. + const changed = did === "commented" ? setCommented.run(key, at, projectId, fingerprint) : setTicket().run(key, at, at, threadId, projectId, fingerprint); return changed.changes > 0; }, /** @@ -2133,6 +2222,44 @@ export function createStore(db: Database.Database, now: () => number, options: S return changed.changes > 0; }, + // adoption decisions on dependency tickets, keyed by ticket + depsDecision(projectId: string, ticketKey: string): DepsDecision | null { + const r = getDepsDecision().get(projectId, ticketKey.toUpperCase()); + return r === undefined ? null : toDepsDecision(r); + }, + /** Every decision ever raised for a target, pending and resolved, oldest first. */ + depsDecisions(projectId: string, entityId: string): DepsDecision[] { + return depsDecisionsForTarget().all(projectId, entityId).map(toDepsDecision); + }, + /** The project's pending decisions, oldest first. */ + pendingDepsDecisions(projectId: string): DepsDecision[] { + return depsDecisionsForProject().all(projectId).map(toDepsDecision); + }, + /** Record a pending decision on a ticket; a ticket that already has one keeps it. Returns the row, new or existing. */ + raiseDepsDecision(fields: { projectId: string; ticketKey: string; entityId: string; fingerprints: string[]; severity: string | null; raisedBy: string | null; needId: string | null }): { decision: DepsDecision; raised: boolean } { + const key = fields.ticketKey.toUpperCase(); + const existing = getDepsDecision().get(fields.projectId, key); + if (existing !== undefined) return { decision: toDepsDecision(existing), raised: false }; + insertDepsDecision().run({ project_id: fields.projectId, ticket_key: key, entity_id: fields.entityId, fingerprints: JSON.stringify(fields.fingerprints), severity: fields.severity, raised_at: now(), raised_by: fields.raisedBy, need_id: fields.needId }); + return { decision: toDepsDecision(getDepsDecision().get(fields.projectId, key)!), raised: true }; + }, + patchDepsDecision(projectId: string, ticketKey: string, fields: Partial>): DepsDecision | null { + const key = ticketKey.toUpperCase(); + if (getDepsDecision().get(projectId, key) === undefined) return null; + if (fields.needId !== undefined) db.prepare("UPDATE deps_decisions SET need_id = ? WHERE project_id = ? AND ticket_key = ?").run(fields.needId, projectId, key); + if (fields.reminded !== undefined) db.prepare("UPDATE deps_decisions SET reminded = ? WHERE project_id = ? AND ticket_key = ?").run(JSON.stringify(fields.reminded), projectId, key); + if (fields.fingerprints !== undefined) db.prepare("UPDATE deps_decisions SET fingerprints = ? WHERE project_id = ? AND ticket_key = ?").run(JSON.stringify(fields.fingerprints), projectId, key); + if (fields.entityId !== undefined) db.prepare("UPDATE deps_decisions SET entity_id = ? WHERE project_id = ? AND ticket_key = ?").run(fields.entityId, projectId, key); + if (fields.severity !== undefined) db.prepare("UPDATE deps_decisions SET severity = ? WHERE project_id = ? AND ticket_key = ?").run(fields.severity, projectId, key); + if (fields.snoozedUntil !== undefined) db.prepare("UPDATE deps_decisions SET snoozed_until = ? WHERE project_id = ? AND ticket_key = ?").run(fields.snoozedUntil, projectId, key); + return toDepsDecision(getDepsDecision().get(projectId, key)!); + }, + /** Resolve a pending decision with how it was settled; false when it was not pending. */ + resolveDepsDecision(projectId: string, ticketKey: string, resolution: string): boolean { + const changed = db.prepare("UPDATE deps_decisions SET status = 'resolved', resolution = ?, resolved_at = ? WHERE project_id = ? AND ticket_key = ? AND status = 'pending'").run(resolution, now(), projectId, ticketKey.toUpperCase()); + return changed.changes > 0; + }, + // lessons: the project steward's general judgements, made permanent in `.bb` /** * Record a lesson, pending until its text is found at `.bb` HEAD or the @@ -2394,7 +2521,7 @@ export function createStore(db: Database.Database, now: () => number, options: S db.prepare(`DELETE FROM secret_requesters WHERE thread_id IN (SELECT thread_id FROM workers WHERE binding_id IN (${byBinding}))`).run(projectId); for (const table of ["escalations", "workers", "needs", "project_approvals"]) db.prepare(`DELETE FROM ${table} WHERE binding_id IN (${byBinding})`).run(projectId); db.prepare(`DELETE FROM pending_deliveries WHERE origin_binding_id IN (${byBinding})`).run(projectId); - for (const table of ["ops_fingerprints", "lessons", "guidance_citations", "bindings", "project_stewards"]) db.prepare(`DELETE FROM ${table} WHERE project_id = ?`).run(projectId); + for (const table of ["ops_fingerprints", "deps_decisions", "lessons", "guidance_citations", "bindings", "project_stewards"]) db.prepare(`DELETE FROM ${table} WHERE project_id = ?`).run(projectId); options.onProjectChange?.(projectId, ["purged"]); return ids; })(); diff --git a/plugins/stewards/templates.ts b/plugins/stewards/templates.ts index b3ba75a4..ef757cdd 100644 --- a/plugins/stewards/templates.ts +++ b/plugins/stewards/templates.ts @@ -380,7 +380,7 @@ export const DEPS_ALERTS = `--- name: deps-alerts kind: ops source: github -may: [file-ticket, comment, escalate, set-state] +may: [file-ticket, comment, escalate, set-state, dismiss-alert] models: steward: claude/claude-sonnet-5-5/medium requeue-default: 1d @@ -402,8 +402,14 @@ Severity decides the Linear priority and the due date: the wake gives each alert's priority and deadline. critical and high get a ticket of their own at once; medium and low coalesce into one open ticket per repository per ecosystem. A severity deps.autoAdopt names is adopted when filed (labelled -and moved to Todo) and raises no escalation; a severity it does not name -raises one decision escalation. The wake's batch plan says, for each new +and moved to Todo) and raises no escalation; for a severity it does not +name, the plugin raises the adoption decision with the owner itself, as a +decide item kept by ticket, the moment your report lists the ticket as +filed. Do not raise that decision yourself. The wake says what to do when +the owner's answer arrives, and the plugin owns the deadline: it is the +alert's own created_at plus the SLA, so a binding recreated later computes +the same date the ticket was filed with. +The wake's batch plan says, for each new medium or low alert, whether to file a new ticket, join an open one, or tighten an open ticket's due date; follow it. When you file or comment, list every alert the ticket covers, with its package, ecosystem, manifest path, @@ -423,12 +429,16 @@ ${OPS_QUESTIONS} ## New File one ticket per problem, in the team the wake names, with the priority -and due date the wake gives. Then raise one decision escalation to the -project steward for every ticket filed this turn: "Filed BB-N (high): . Recommend adopting." A severity deps.autoAdopt names is labelled and -moved to Todo when filed, as the wake says; such a ticket needs no -escalation. For a medium or low alert whose plan is to join or tighten, do -that instead of filing. +and due date the wake gives, and list it as filed in your report: that is +what raises the owner's adoption decision. A severity deps.autoAdopt names +is labelled and moved to Todo when filed, as the wake says, and needs no +decision. When no alert on the ticket has a fixed version, say in the ticket +whether the vulnerable code is reachable from this repository, since +"dismiss as not used" is the owner's likely answer; dismiss an alert only +when the owner answered with that choice on the ticket's decide item, and +re-read each alert first so an already fixed or dismissed one is skipped. +For a medium or low alert whose plan is to join or tighten, do that instead +of filing. ## Recurring diff --git a/plugins/stewards/wake.test.ts b/plugins/stewards/wake.test.ts index 8a270e8a..299aff94 100644 --- a/plugins/stewards/wake.test.ts +++ b/plugins/stewards/wake.test.ts @@ -1,5 +1,6 @@ import Database from "better-sqlite3"; import { describe, expect, it } from "vitest"; +import type { DepsDecision } from "./store.js"; import { parsePlaybook } from "./config.js"; import { explainDecision, formatDecisionListing } from "./decisions.js"; import { comment, issue, pull } from "./fixtures.js"; @@ -558,6 +559,8 @@ describe("the ops and deps wake classification lines (BB-136)", () => { alertNumber: 4, severity: "high", package: "lodash", + ticketLabels: [], + alertCreatedAt: null, ecosystem: "npm", manifestPath: "package-lock.json", ghsaId: null, @@ -591,6 +594,7 @@ describe("the ops and deps wake classification lines (BB-136)", () => { autoAdopt: [], timeZone: "UTC", escalations: [], + decisions: [], changes: [], }); @@ -610,6 +614,56 @@ describe("the ops and deps wake classification lines (BB-136)", () => { expect(depsWake(depsFinding({ class: "returned", ticket: "ENG-50", ticketState: "Done", closedAt: "2026-10-01T00:00:00Z", window: "outside" }))).toMatch(/Returned: ENG-50 closed 2026-10-01T00:00:00Z, outside two weeks/u); }); + it("shows the computed SLA deadline and the alert's own clock, never the ticket's Linear date (SC-111)", () => { + const sla = Date.parse("2026-11-07T12:00:00Z"); + const recurring = depsWake(depsFinding({ class: "recurring", ticket: "SC-111", ticketState: "Backlog", severity: "medium", priority: "medium", dueAt: sla, ticketLabels: ["security"], alertCreatedAt: "2026-10-29T00:19:43Z" })); + expect(recurring).toMatch(/\*\*medium\*\* priority medium, due 2026-11-07/u); + expect(recurring).not.toMatch(/the ticket's date is the one that counts/u); + expect(recurring).toMatch(/Ticket labels: security/u); + expect(recurring).toMatch(/opened 2026-10-29T00:19:43Z \(the SLA clock\); first seen by this watch/u); + expect(depsWake(depsFinding({ dueAt: sla }))).toMatch(/priority high, due 2026-11-07\n/u); + }); + + const decision = (over: Partial = {}): DepsDecision => ({ projectId: "p1", ticketKey: "SC-111", entityId: "dependabot/acme/web", fingerprints: ["acme/web#23", "acme/web#24"], severity: "medium", raisedAt: Date.parse("2026-10-13T15:00:00Z"), raisedBy: "thr_filed", needId: "need_1", status: "pending", resolution: null, resolvedAt: null, reminded: ["overdue"], snoozedUntil: null, ...over }); + const decisionsWake = (decisions: DepsDecision[], playbook = DEPS_ALERTS) => { + const at = Date.parse("2026-10-14T15:00:00Z"); + return formatDepsWake({ + reason: "requeue", + binding: binding("dependabot/acme/web"), + playbook: parsedPlaybook(playbook, "deps-alerts"), + look: { at, target: "dependabot/acme/web", error: null, viewer: "jane", alerts: [depsFinding({ class: "recurring", ticket: "SC-111", ticketState: "Backlog" })] }, + team: "ENG", + label: "bb-managed", + owner: "Jane", + autoAdopt: ["critical", "high"], + timeZone: "UTC", + escalations: [], + decisions, + changes: [], + }); + }; + + it("lists the adoption decisions waiting on the owner, with the exact dismiss commands and no steward-initiated dismiss", () => { + const text = decisionsWake([decision()]); + expect(text).toMatch(/### Adoption decisions waiting on the owner\n\n- SC-111 \(acme\/web#23, acme\/web#24\), raised 2026-10-13T15:00:00Z, item need_1, reminded: overdue/u); + expect(text).toMatch(/Adoption of any other ticket is the owner's decision, and the plugin asks it/u); + expect(text).toMatch(/Dismiss as not used: only when the owner answered with it on that ticket's decide item/u); + expect(text).toMatch(/re-read each of that decision's alerts on GitHub first and skip any already fixed or dismissed/u); + expect(text).toMatch(/re-read `\/repos\/acme\/web\/dependabot\/alerts\/23` first and skip it if it is already fixed or dismissed; otherwise `gh api -X PATCH \/repos\/acme\/web\/dependabot\/alerts\/23 -f state=dismissed -f dismissed_reason=not_used/u); + expect(text).toMatch(/alerts\/24/u); + expect(text).toMatch(/Keep tracking: keep the ticket open and unadopted/u); + expect(depsWake(depsFinding())).not.toMatch(/### Adoption decisions/u); + }); + + it("does not offer or describe Dismiss when the playbook's may list lacks dismiss-alert", () => { + const withoutDismiss = DEPS_ALERTS.replace(", dismiss-alert]", "]"); + expect(withoutDismiss).not.toMatch(/dismiss-alert/u); + const text = decisionsWake([decision()], withoutDismiss); + expect(text).not.toMatch(/Dismiss as not used/u); + expect(text).not.toMatch(/gh api -X PATCH/u); + expect(text).toMatch(/Adopt: the ticket is labelled and moved to Todo/u); + }); + it("says unassigned and the dedup note when the login or the search is unavailable", () => { const ops = opsWake(opsFinding({ assignee: "unassigned", dedupUnavailable: true })); expect(ops).toMatch(/Assignee: unassigned \(login unreadable\)/u); diff --git a/plugins/stewards/wake.ts b/plugins/stewards/wake.ts index bb50922c..c1cbc995 100644 --- a/plugins/stewards/wake.ts +++ b/plugins/stewards/wake.ts @@ -11,13 +11,13 @@ import { anyPending, type Deployments, type DeploymentWatch } from "./deploy.js" import { localParts } from "./nightly.js"; import { FINDING_CLASSES, type FindingClass, type OpsFinding, type OpsLook } from "./ops.js"; import type { DepsFinding, DepsLook } from "./deps.js"; -import { dueDate } from "./deps.js"; +import { ADOPTION_CHOICES, adoptionHow, dueDate } from "./deps.js"; import { NO_TOKEN, TOKEN_PAGE, TOKEN_SCOPES, TOKEN_SETTING, type SentryFinding, type SentryLook, type SentryReason } from "./sentry.js"; import { newHumanComments, productGateOptions, reportedItem, unheardWait, type GateState } from "./reconcile.js"; import { prGate, type ProjectApprovalRecord } from "./gate.js"; import { conflictsOf, openPull, type Conflict } from "./schedule.js"; import type { EntitySnapshot, SessionWake, SnapshotComment, WorkflowRun } from "./source-contract.js"; -import { needChoices, ESCALATED_NEED_KINDS, RESOLUTION_KINDS, STEWARD_NEED_KINDS, type Binding, type Escalation, type Lesson, type Need, type Observed, type Question, type Worker } from "./store.js"; +import { needChoices, ESCALATED_NEED_KINDS, RESOLUTION_KINDS, STEWARD_NEED_KINDS, type Binding, type DepsDecision, type Escalation, type Lesson, type Need, type Observed, type Question, type Worker } from "./store.js"; /** A wake longer than this is cut in its snapshot, never in what changed or what to do. */ export const WAKE_MAX_CHARS = 30_000; @@ -854,6 +854,8 @@ export type DepsWakeInput = { /** The project's time zone, which due dates are given in. */ timeZone: string; escalations: Escalation[]; + /** The adoption decisions pending with the owner on this repository's tickets. */ + decisions: DepsDecision[]; changes: string[]; }; @@ -874,9 +876,13 @@ function depsAlertLines(a: DepsFinding, timeZone: string): string[] { if (a.ticket !== null) lines.push(`- ticket ${a.ticket}${a.ticketState === null ? "" : ` (${a.ticketState})`}${a.lastCommentedAt === null ? "" : `, your last word on it ${iso(a.lastCommentedAt)}`}`); lines.push(`- Assignee: ${assigneeLine(a.assignee)}`); if (a.dedupUnavailable) lines.push(`- ${DEDUP_UNAVAILABLE}`); + // The plugin owns the deadline: the alert's own created_at plus the SLA, + // which is the same date a recreated binding computes (SC-111). It never + // reads Linear's due date (the source contract does not carry it). lines.push(`- **${a.severity}** priority ${a.priority}, due ${dueDate(a.dueAt, timeZone)}`); + if (a.ticket !== null && a.ticketLabels.length > 0) lines.push(`- Ticket labels: ${a.ticketLabels.join(", ")}`); lines.push(`- \`${a.package}\` (${a.ecosystem})${a.manifestPath === null ? "" : ` — ${a.manifestPath}`}${a.ghsaId === null ? "" : ` — ${a.ghsaId}`}`); - lines.push(`- ${a.fixedVersion === null ? "No fixed version yet" : `Fixed in ${a.fixedVersion}`}; first seen ${iso(a.firstSeenAt)}${a.url === null ? "" : ` — ${a.url}`}`); + lines.push(`- ${a.fixedVersion === null ? "No fixed version yet" : `Fixed in ${a.fixedVersion}`}; ${a.alertCreatedAt === null ? "" : `opened ${a.alertCreatedAt} (the SLA clock); `}first seen by this watch ${iso(a.firstSeenAt)}${a.url === null ? "" : ` — ${a.url}`}`); if (a.batch.kind === "join") lines.push(`- Batch: join the open ticket ${a.batch.ticketKey} (its due date is already inside this alert's).`); else if (a.batch.kind === "tighten") lines.push(`- Batch: tighten the open ticket ${a.batch.ticketKey}'s due date to no later than ${dueDate(a.batch.dueAt, timeZone)} (the earlier of its current due date and this deadline).`); else if (a.severity === "medium" || a.severity === "low") lines.push("- Batch: no open ticket for this repo and ecosystem; file a new one."); @@ -907,6 +913,15 @@ export function formatDepsWake(input: DepsWakeInput): string { if (input.autoAdopt.length > 0) { out.push(`deps.autoAdopt: a ticket you file for a ${input.autoAdopt.join(" or ")} alert is adopted at once: add the label \`${input.label}\` and move it to Todo when you file it, and raise no escalation for it. Say in your report that you did.`); } + const allowDismiss = playbook.may.includes("dismiss-alert"); + out.push( + `Adoption of any other ticket is the owner's decision, and the plugin asks it: once your report lists the ticket as filed, a decide item appears on the Stewards page and in \`bb stewards needs\`, kept by ticket until the owner answers, the ticket is labelled \`${input.label}\`, or it closes. Do not raise a decision escalation for it yourself, and do not ask the owner in prose. ` + + `The answer arrives under "What changed" as "The user chose for …". ${ADOPTION_CHOICES.adopt}: add the label \`${input.label}\` and move the ticket to Todo. ` + + (allowDismiss + ? `${ADOPTION_CHOICES.dismiss}: only when the owner answered with it on that ticket's decide item — never on a steward's own initiative — re-read each of that decision's alerts on GitHub first and skip any already fixed or dismissed, then dismiss the rest as "not used" and move the ticket to Done with a comment saying why. The item and this wake list the exact alert numbers. ` + : "") + + `${ADOPTION_CHOICES.keep}: keep the ticket open and unadopted and move its due date to the one the note gives, or by one SLA window from today when it gives none, with a comment on the ticket saying so.`, + ); out.push("", "## 2. Guidance, priority and holds", ""); if (binding.guidance.length === 0) out.push("No standing guidance."); @@ -928,6 +943,24 @@ export function formatDepsWake(input: DepsWakeInput): string { out.push("", "### Open escalations", ""); for (const x of open) out.push(`- ${x.id} (${x.kind}, with ${x.route === "user" ? "the user" : "the project steward"}): ${x.question}`); } + if (input.decisions.length > 0) { + out.push("", "### Adoption decisions waiting on the owner", ""); + for (const d of input.decisions) { + out.push(`- ${d.ticketKey} (${d.fingerprints.join(", ")}), raised ${iso(d.raisedAt)}${d.needId === null ? "" : `, item ${d.needId}`}${d.snoozedUntil === null ? "" : `, snoozed until ${iso(d.snoozedUntil)}`}${d.reminded.length === 0 ? "" : `, reminded: ${d.reminded.join(", ")}`}`); + if (allowDismiss) { + // Only the owner's answer dismisses; the exact alert numbers are the + // ones in this decision's fingerprints, in its repo, and each is + // re-read first so an already fixed or dismissed alert is skipped. + const repo = d.entityId.replace(/^dependabot\//u, ""); + for (const fingerprint of d.fingerprints) { + const number = fingerprint.slice(fingerprint.lastIndexOf("#") + 1); + if (!/^\d+$/u.test(number)) continue; + out.push(` - On ${ADOPTION_CHOICES.dismiss} from the owner's answer: re-read \`/repos/${repo}/dependabot/alerts/${number}\` first and skip it if it is already fixed or dismissed; otherwise \`gh api -X PATCH /repos/${repo}/dependabot/alerts/${number} -f state=dismissed -f dismissed_reason=not_used -f dismissed_comment=\`.`); + } + } + } + out.push("", `These are on the Stewards page; the plugin reminds the owner once a deadline has passed. Do not ask again, and never dismiss or snooze on a steward's own initiative. ${adoptionHow(allowDismiss)}`); + } const snapshot = out.splice(0); out.push("", "## 4. What changed since your last turn", ""); From 3968e24af0711b72a9c32429653160c6a5ce25cb Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Wed, 4 Nov 2026 19:40:30 +0000 Subject: [PATCH 2/2] stewards: drop the prototype's half/eve reminders from the README (BB-227) --- plugins/stewards/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/stewards/README.md b/plugins/stewards/README.md index 436f6845..e6c6fcba 100644 --- a/plugins/stewards/README.md +++ b/plugins/stewards/README.md @@ -811,8 +811,7 @@ severity; critical→urgent, high→high, medium→medium, low→low), `batch` ( window a medium or low alert may wait for company), and `autoAdopt` (the severities whose tickets are labelled and moved to Todo when filed; any other severity's ticket raises the owner's adoption decision, kept by the -plugin until answered, with reminders at half the time to the due date, the -day before, and once overdue). +plugin until answered, with one overdue reminder). `deps-alerts.md` and `deps-prs.md` are optional: a project that has not written them runs no dependency watch. `bb stewards init` writes them, and `bb stewards check` validates `deps.sla`, `deps.batch` and `deps.autoAdopt`