diff --git a/plugins/github-watcher/source-contract.ts b/plugins/github-watcher/source-contract.ts index 72ee6e8d..990228bd 100644 --- a/plugins/github-watcher/source-contract.ts +++ b/plugins/github-watcher/source-contract.ts @@ -222,6 +222,12 @@ export const entitySnapshotSchema = z.object({ */ completedAt: z.string().nullable().optional(), canceledAt: z.string().nullable().optional(), + /** + * Linear: the issue's due date as YYYY-MM-DD, or null. Optional for the + * same reason; stewards' dependency watch reads it as the truth about when + * a ticket is due, over the deadline it computed itself. Null on GitHub. + */ + dueDate: z.string().nullable().optional(), /** The newest comments (and, on a pull request, reviews and review comments), oldest first. */ comments: z.array(snapshotCommentSchema), parent: z.object({ id: z.string(), key: z.string() }).nullable(), diff --git a/plugins/linear/linear.test.ts b/plugins/linear/linear.test.ts index 8d98a2ee..e903a958 100644 --- a/plugins/linear/linear.test.ts +++ b/plugins/linear/linear.test.ts @@ -336,6 +336,7 @@ describe("what stewards reads", () => { updatedAt: "2026-10-02T00:00:00.000Z", completedAt: null, canceledAt: null, + dueDate: null, comments: [ { id: "c-1", author: "someone", did: "commented", body: "Repro?", createdAt: "2026-10-01T01:00:00Z", url: null, signedBy: null }, { id: "c-2", author: "jane", did: "commented", body: "On it.\n\n_— from bb thread thr_abc_", createdAt: "2026-10-01T02:00:00Z", url: "https://linear.app/c/2", signedBy: "thr_abc" }, diff --git a/plugins/linear/linear.ts b/plugins/linear/linear.ts index b8c9949d..7c12a7f5 100644 --- a/plugins/linear/linear.ts +++ b/plugins/linear/linear.ts @@ -447,7 +447,7 @@ export function searchLimit(limit: number | undefined): number { // tools' output should not change because stewards needs more. const SNAPSHOT_FIELDS = - "id identifier title description url priority priorityLabel estimate createdAt updatedAt completedAt canceledAt " + + "id identifier title description url priority priorityLabel estimate createdAt updatedAt completedAt canceledAt dueDate " + "state { name type } team { id key } assignee { name displayName email } creator { name displayName email } " + "labels { nodes { name } } parent { id identifier } " + "children { nodes { id identifier title state { name type } labels { nodes { name } } } } " + @@ -482,6 +482,7 @@ const snapshotIssueSchema = z.object({ updatedAt: z.string().nullish(), completedAt: z.string().nullish(), canceledAt: z.string().nullish(), + dueDate: z.string().nullish(), state: z.object({ name: z.string(), type: z.string().nullish() }).nullish(), team: z.object({ id: z.string(), key: z.string() }), assignee: person, @@ -554,6 +555,7 @@ export function snapshotOfIssue(issue: SnapshotIssue, viewer: string | null): En updatedAt: issue.updatedAt ?? null, completedAt: issue.completedAt ?? null, canceledAt: issue.canceledAt ?? null, + dueDate: issue.dueDate ?? null, comments: comments.map((c) => ({ id: c.id, author: personName(c.user) ?? "someone", diff --git a/plugins/linear/source-contract.ts b/plugins/linear/source-contract.ts index 72ee6e8d..990228bd 100644 --- a/plugins/linear/source-contract.ts +++ b/plugins/linear/source-contract.ts @@ -222,6 +222,12 @@ export const entitySnapshotSchema = z.object({ */ completedAt: z.string().nullable().optional(), canceledAt: z.string().nullable().optional(), + /** + * Linear: the issue's due date as YYYY-MM-DD, or null. Optional for the + * same reason; stewards' dependency watch reads it as the truth about when + * a ticket is due, over the deadline it computed itself. Null on GitHub. + */ + dueDate: z.string().nullable().optional(), /** The newest comments (and, on a pull request, reviews and review comments), oldest first. */ comments: z.array(snapshotCommentSchema), parent: z.object({ id: z.string(), key: z.string() }).nullable(), diff --git a/plugins/stewards/DESIGN.md b/plugins/stewards/DESIGN.md index 664281de..bc4072fa 100644 --- a/plugins/stewards/DESIGN.md +++ b/plugins/stewards/DESIGN.md @@ -1447,6 +1447,37 @@ 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 Linear already carries (SC-111 + drifted three days that way and every digest repeated the wrong date). + Once a ticket exists, **its Linear `dueDate` is the truth**: the look + reads it with the ticket's state and labels, the wake shows it on the + alert and says when the SLA alone would disagree, and the batch plan only + ever tightens it, never computes a looser one beside it. +- **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**, **Dismiss as not used** and **Keep tracking** (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. As the due date nears the plugin reminds + mechanically — at half the time to the due date, the day before, and once + past it — by rewording the item, noting the project steward's next wake + (so the digest names the deadline rather than promising to raise it + later) and logging it. +- **Every ops target keeps an action log** (`ops_log`, keyed by target): + what each steward filed, commented on or reopened, with its thread id, + and each decision raised, reminded or settled. `bb stewards show` prints + it, so "did anyone ask?" is answered without the filing thread. - **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..ef687faf 100644 --- a/plugins/stewards/README.md +++ b/plugins/stewards/README.md @@ -61,9 +61,16 @@ 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`; + once a ticket exists its Linear due date is the one the wake shows. + 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, + Dismiss as not used, Keep tracking) in `bb stewards needs` and the digest + until the owner answers, with reminders as its due date nears; `bb + stewards show` prints the target's action log. 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 +808,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..6674338d 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, adoptionQuestion, alertClockAt, alertFingerprint, alertLine, batchAlert, deadlineAt, depsFindingSchema, dueDate, dueDateToMs, isBatchable, priorityFor, reminderDue, reminderText, sightingOf } from "./deps.js"; import type { DependabotAlert } from "./source-contract.js"; const alert = (over: Partial = {}): DependabotAlert => ({ @@ -137,3 +137,80 @@ 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("reads a Linear due date as an instant on that day, and refuses anything else", () => { + expect(dueDateToMs("2026-11-04")).toBe(Date.parse("2026-11-04T12:00:00Z")); + expect(dueDate(dueDateToMs("2026-11-04")!, "America/New_York")).toBe("2026-11-04"); + expect(dueDate(dueDateToMs("2026-11-04")!, "Asia/Tokyo")).toBe("2026-11-04"); + expect(dueDateToMs("")).toBeNull(); + expect(dueDateToMs("2026-11-04T00:00:00Z")).toBeNull(); + expect(dueDateToMs("2026-13-99")).toBeNull(); + }); +}); + +describe("the adoption decision", () => { + it("leads with Adopt when a fix exists and with Dismiss when none does", () => { + expect(adoptionChoices(true)).toEqual([ADOPTION_CHOICES.adopt, ADOPTION_CHOICES.keep, ADOPTION_CHOICES.dismiss]); + expect(adoptionChoices(false)).toEqual([ADOPTION_CHOICES.dismiss, ADOPTION_CHOICES.keep, ADOPTION_CHOICES.adopt]); + }); + + it("asks one line naming the ticket, the alerts, the fix and the due date", () => { + 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); + }); + + it("reminds at half the way to the due date, the day before, and once overdue, each once", () => { + const raised = Date.parse("2026-10-29T00:20:38Z"); + const due = dueDateToMs("2026-11-04")!; + const tz = "America/New_York"; + expect(reminderDue(raised, due, [], raised, tz)).toBeNull(); + expect(reminderDue(raised, due, [], Date.parse("2026-10-31T00:00:00Z"), tz)).toBeNull(); + expect(reminderDue(raised, due, [], Date.parse("2026-11-01T12:00:00Z"), tz)).toBe("half"); + expect(reminderDue(raised, due, ["half"], Date.parse("2026-11-01T12:00:00Z"), tz)).toBeNull(); + expect(reminderDue(raised, due, ["half"], Date.parse("2026-11-03T12:00:00Z"), tz)).toBe("eve"); + expect(reminderDue(raised, due, ["half", "eve"], Date.parse("2026-11-04T12:00:00Z"), tz)).toBeNull(); + expect(reminderDue(raised, due, ["half", "eve"], Date.parse("2026-11-05T12:00:00Z"), tz)).toBe("overdue"); + expect(reminderDue(raised, due, ["half", "eve", "overdue"], Date.parse("2026-11-09T12:00:00Z"), tz)).toBeNull(); + }); + + it("sends one reminder, not three, when a later stage is already due", () => { + const raised = Date.parse("2026-11-03T20:00:00Z"); + const due = dueDateToMs("2026-11-04")!; + expect(reminderDue(raised, due, [], Date.parse("2026-11-03T21:00:00Z"), "UTC")).toBe("eve"); + expect(reminderDue(raised, due, ["eve"], Date.parse("2026-11-03T22:00:00Z"), "UTC")).toBeNull(); + expect(reminderDue(raised, due, [], Date.parse("2026-11-06T22:00:00Z"), "UTC")).toBe("overdue"); + expect(reminderDue(raised, due, ["overdue"], Date.parse("2026-11-07T22:00:00Z"), "UTC")).toBeNull(); + }); + + it("words each reminder with the days to or past the due date", () => { + expect(reminderText("half", "SC-111", "2026-11-04", Date.parse("2026-11-01T12:00:00Z"), "UTC")).toBe("SC-111 is halfway to its due date (2026-11-04) and still unadopted."); + expect(reminderText("eve", "SC-111", "2026-11-04", Date.parse("2026-11-03T12:00:00Z"), "UTC")).toBe("SC-111 is due tomorrow (2026-11-04) and still unadopted."); + expect(reminderText("eve", "SC-111", "2026-11-04", Date.parse("2026-11-04T12:00:00Z"), "UTC")).toBe("SC-111 is due today (2026-11-04) and still unadopted."); + expect(reminderText("overdue", "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("overdue", "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 due date, 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({ ticketDueDate: null, ticketLabels: [], alertCreatedAt: null }); + }); +}); diff --git a/plugins/stewards/deps.ts b/plugins/stewards/deps.ts index d634b5f5..7047aae8 100644 --- a/plugins/stewards/deps.ts +++ b/plugins/stewards/deps.ts @@ -35,15 +35,34 @@ 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; +} + +/** A Linear due date (YYYY-MM-DD) as an instant on that calendar day: noon UTC, which is that date in every time zone between UTC-12 and UTC+11. */ +export function dueDateToMs(date: string): number | null { + if (!/^\d{4}-\d{2}-\d{2}$/u.test(date)) return null; + const ms = Date.parse(`${date}T12:00:00Z`); + return Number.isNaN(ms) ? null : ms; } /** An open ticket a batched alert may join: one per repo per ecosystem. */ @@ -123,8 +142,12 @@ 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 due date as Linear has it (YYYY-MM-DD), the source of truth once a ticket exists; null when none or unreadable. */ + ticketDueDate: z.string().nullable().default(null), + /** 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 +162,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 +185,78 @@ 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. With a fix available, adopting + * (a worker makes the upgrade) leads; with no patched version for any alert, + * "Recommend adopting" does not fit and dismissing the alerts as unused leads, + * since adopting could only produce a decision anyway. + */ +export function adoptionChoices(hasFix: boolean): string[] { + return hasFix ? [ADOPTION_CHOICES.adopt, ADOPTION_CHOICES.keep, ADOPTION_CHOICES.dismiss] : [ADOPTION_CHOICES.dismiss, ADOPTION_CHOICES.keep, ADOPTION_CHOICES.adopt]; +} + +/** The one line a decision item asks. */ +export function adoptionQuestion(ticketKey: string, repo: string, alerts: ReadonlyArray>, dueDate: string | 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}`; + return `Adopt ${ticketKey}? ${severities.join("/")} Dependabot alert${alerts.length === 1 ? "" : `s (${alerts.length})`} on ${repo}: ${packages.join(", ")} (${ecosystems.join(", ")}), ${fix}${dueDate === null ? "" : `, due ${dueDate}`}.`; +} + +/** What each choice means, for the item's `how` and the steward's wake. */ +export const ADOPTION_HOW = + `${ADOPTION_CHOICES.adopt}: the ticket is labelled and moved to Todo, and a ticket steward builds the upgrade. ` + + `${ADOPTION_CHOICES.dismiss}: the alerts are dismissed on GitHub as "vulnerable code is not actually used" and the ticket is closed with the reason. ` + + `${ADOPTION_CHOICES.keep}: the ticket stays open and unadopted; say the new due date in the note, or it moves out by one SLA window.`; + +/** The reminder stages an unanswered decision goes through, in order. */ +export const REMINDER_STAGES = ["half", "eve", "overdue"] as const; +export type ReminderStage = (typeof REMINDER_STAGES)[number]; + +/** + * The next reminder an unanswered decision is due, or null: `half` once half + * the time from raising to the due date has passed, `eve` from the day before + * the due date, `overdue` once the due date (in the project's time zone) has + * gone by. A stage already sent is skipped; a later stage supersedes an + * earlier one not yet sent, so a decision raised the day before its due date + * gets one reminder, not three. + */ +export function reminderDue(raisedAt: number, dueAt: number, reminded: readonly string[], now: number, timeZone: string): ReminderStage | null { + const today = dueDate(now, timeZone); + const due = dueDate(dueAt, timeZone); + let stage: ReminderStage | null = null; + if (today > due) stage = "overdue"; + else if (dueDate(now + 86_400_000, timeZone) >= due) stage = "eve"; + else if (now >= raisedAt + (dueAt - raisedAt) / 2) stage = "half"; + if (stage === null) return null; + // Everything up to and including the stage due now counts as sent once it is. + const sent = new Set(reminded); + return sent.has(stage) || REMINDER_STAGES.slice(REMINDER_STAGES.indexOf(stage) + 1).some((s) => sent.has(s)) ? null : stage; +} + +/** How a reminder reads: the days to or past the due date, in the project's time zone. */ +export function reminderText(stage: ReminderStage, 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); + if (stage === "overdue") return `${ticketKey} is ${days === 1 ? "a day" : `${days} days`} past its due date (${due}) and still unadopted.`; + if (stage === "eve") return `${ticketKey} is due ${days === 0 ? "today" : "tomorrow"} (${due}) and still unadopted.`; + return `${ticketKey} is halfway to 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..31dadcff 100644 --- a/plugins/stewards/migrations.snapshot.json +++ b/plugins/stewards/migrations.snapshot.json @@ -103,5 +103,9 @@ "595e447b50228f6ebb015a069872948beb36668629d9b2e71f798c83840037c6", "7bc6e0dc10f8aea8f67e3d0d642b87bc67b80241052e6fcfafe947ee7b9538b5", "42be649d859d3c6e3a0970b74f41a8c75c9ef753e247d7a54d2c8cff039ba523", - "fc84bda5d59e556b0a0100249a11fe6df258ad005665d164b56a18bad3c55e1e" + "fc84bda5d59e556b0a0100249a11fe6df258ad005665d164b56a18bad3c55e1e", + "74a463c97f698fdfb03975b98463df4ad000d20d84970fe2b46c9fa459208b24", + "3e5da92669f8bb2875557911e671c7652893d236f58bfef80ce7cab5d7bef2fd", + "5a1c85acd34eeaf53df12cdacd84ee741684a26f2f2f343eef4846e1218e1bde", + "5f8799ed0eb0b442b543080a2235ff5ff55ffbdb64c26b3005f9da0dde85ef40" ] diff --git a/plugins/stewards/ops.ts b/plugins/stewards/ops.ts index f5b2d0e7..6209de32 100644 --- a/plugins/stewards/ops.ts +++ b/plugins/stewards/ops.ts @@ -316,8 +316,8 @@ export type RawLook = Omit & { /** A fingerprint map row, as classification needs it. */ export type FingerprintRow = { fingerprint: string; ticketKey: string | null; firstSeenAt: number; lastCommentedAt: number | 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. `dueDate`, `labels` and `url` are read for the dependency watch. */ +export type TicketState = { state: string; stateType: string | null; closedAt?: string | null; dueDate?: 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..6adfc679 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,148 @@ 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: [], dueDate: "2026-10-16", 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); + const needs = (await t.harness.behavior.runCli(["needs", "--project", "p1"])).stdout; + 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 \(the ticket's due date\)/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); + + // Someone moves the ticket's due date: the next wake says the ticket's date counts and names the drift. + 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: [], dueDate: "2026-10-22", description: "Fingerprint: `acme/web#23`\nFingerprint: `acme/web#24`" })); + t.advance(86_400_000); + await t.hooks.sweep(); + await t.flush(); + expect(latest()).toMatch(/due 2026-10-22 \(the ticket's due date; the SLA alone would say 2026-10-16, but the ticket's date is the one that counts\)/u); + expect(t.hooks.store.openNeedsForProject("p1").find((n) => n.data.adoption === true)!.what).toMatch(/due 2026-10-22\.$/u); + + // Reminders: halfway (raised 10-14, due 10-22 → 10-18), the day before, and once overdue; each lands as a project note and in the log. + 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); // 10-18T16:00Z + await t.hooks.sweep(); + await t.flush(); + expect(t.hooks.store.depsDecision("p1", "ENG-50")!.reminded).toEqual(["half"]); + expect(notes()).toMatch(/ENG-50 is halfway to its due date \(2026-10-22\) and still unadopted\. The decision is need_\S+ \(dependabot\/acme\/web\); it is the owner's to answer/u); + t.advance(3 * 86_400_000); // 10-21 + await t.hooks.sweep(); + await t.flush(); + expect(t.hooks.store.depsDecision("p1", "ENG-50")!.reminded).toEqual(["half", "eve"]); + expect(notes()).toMatch(/ENG-50 is due tomorrow \(2026-10-22\) and still unadopted/u); + t.advance(2 * 86_400_000); // 10-23 + await t.hooks.sweep(); + await t.flush(); + expect(t.hooks.store.depsDecision("p1", "ENG-50")!.reminded).toEqual(["half", "eve", "overdue"]); + expect(notes()).toMatch(/ENG-50 is a day past its due date \(2026-10-22\) and still unadopted/u); + + // 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." }); + + // The target's record answers "did anyone ask?" without the filing thread. + const show = (await t.harness.behavior.runCli(["show", DEPS_TARGET, "--project", "p1"])).stdout; + expect(show).toMatch(/Decision ENG-50 \(resolved, raised 2026-10-14T15:00:00Z by thr_\S+, item need_\S+, reminded half, eve, overdue\): acme\/web#23, acme\/web#24 → Chose: Adopt/u); + expect(show).toMatch(new RegExp(`Action 2026-10-14T15:00:00Z \\(${filedThread}\\): Filed ENG-50 for acme/web#23`, "u")); + expect(show).toMatch(/Action [^\n]*: Raised the adoption decision on ENG-50 \(need_\S+\): Adopt ENG-50\?/u); + expect(show).toMatch(/Action [^\n]*: Reminder \(overdue\): ENG-50 is a day past its due date/u); + expect(show).toMatch(/Action [^\n]*: Adoption decision on ENG-50 settled: 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: [], dueDate: "2026-10-16" })); + 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"], dueDate: "2026-10-15" })); + 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"], dueDate: "2026-10-16" })); + 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: [], dueDate: "2026-11-01", 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: [], dueDate: "2026-11-01", 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("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..91ad5258 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_HOW, adoptionChoices, adoptionQuestion, alertClockAt, alertFingerprint, batchAlert, deadlineAt, depsLookSchema, dueDateToMs, isBatchable, priorityFor, reminderDue, reminderText, repoOfFingerprint, sightingOf, 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), dueDate: e.dueDate ?? 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,10 +3596,15 @@ 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); + // Linear's due date is the truth once the ticket exists; the SLA + // deadline from the alert's clock only stands in while it cannot be + // read. The plan may tighten that date, never compute a looser one + // beside it. + const ticketDue = state?.dueDate === null || state?.dueDate === undefined ? null : dueDateToMs(state.dueDate); + const dueAt = ticketDue ?? 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); + else ticket.dueAt = ticketDue ?? Math.min(ticket.dueAt, dueAt); } return [...byTicket.values()]; } @@ -3631,14 +3636,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 +3665,8 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P state: a.state, url: a.url, dueAt, + ticketDueDate: ticketState?.dueDate ?? null, + ticketLabels: ticketState?.labels ?? [], priority: priorityFor(a.severity), class: f.class, ticket: f.ticket, @@ -3661,16 +3676,161 @@ 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); + 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 as the due date nears, 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); + + /** 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 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, decision: { ticketKey: string; fingerprints: string[] }, alerts: ReadonlyArray, ticket: TicketState, repo: string): Need { + const hasFix = alerts.length > 0 && alerts.some((a) => a.fixedVersion !== null); + const dueDate = ticket?.dueDate ?? null; + return store.fileNeed({ + bindingId: b.id, + kind: "decide", + url: ticket?.url ?? decision.ticketKey, + what: adoptionQuestion(decision.ticketKey, repo, alerts, dueDate), + how: ADOPTION_HOW, + data: { adoption: true, ticket: decision.ticketKey, fingerprints: decision.fingerprints, choices: adoptionChoices(hasFix), dueDate }, + filedBy: "plugin", + }); + } + + /** + * Raise the adoption decision on a ticket, idempotently: the decision row + * if it is new, the decide item on this binding, and a log line. 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): void { + const repo = parseOpsTargetKey(b.entityId); + if (repo === null || repo.kind !== "dependabot") return; + 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; + // 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, decision, alerts, ticket, repo.repo); + 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) { + store.logOps({ projectId: b.projectId, entityId: b.entityId, bindingId: b.id, threadId: b.threadId, ticketKey, text: `Raised the adoption decision on ${ticketKey} (${need.id}): ${need.what}` }); + bb.log.info(`stewards: raised the adoption decision on ${ticketKey} for ${b.entityKey}`); + } + } + + /** Settle a pending decision with how it was settled, close its item, and log it. */ + 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"); + } + store.logOps({ projectId: b.projectId, entityId: b.entityId, bindingId: b.id, threadId: b.threadId, ticketKey: decision.ticketKey, text: `Adoption decision on ${decision.ticketKey} settled: ${resolution}` }); + } + + /** + * 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, and send the reminder due. + * The reminder lands as a reworded item, a note on the project steward's + * next wake (so the digest's "needs you" names the deadline), and a log + * line. 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): 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; + } + if (need !== null && need.status === "done") { + // The owner answered on the Stewards page or with `bb stewards done`. + settleAdoption(b, decision, need.resolution ?? "Answered."); + continue; + } + // The item is missing or on an ended binding: show it here. A + // reworded item keeps the live due date in its question. + raiseAdoption(b, config, ticketKey, ticketRows, ticketAlerts, ticket); + const dueAt = ticket.dueDate === null || ticket.dueDate === undefined ? null : dueDateToMs(ticket.dueDate); + if (dueAt === null) continue; + const stage = reminderDue(decision.raisedAt, dueAt, decision.reminded, now(), config.file.nightly.timezone); + if (stage === null) continue; + const text = reminderText(stage, ticketKey, ticket.dueDate!, now(), config.file.nightly.timezone); + store.patchDepsDecision(b.projectId, ticketKey, { reminded: [...decision.reminded, stage] }); + const current = store.depsDecision(b.projectId, ticketKey); + const needId = current?.needId ?? decision.needId; + store.noteProject(b.projectId, `${text} The decision is ${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.logOps({ projectId: b.projectId, entityId: b.entityId, bindingId: b.id, threadId: b.threadId, ticketKey, text: `Reminder (${stage}): ${text}` }); + 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; + raiseAdoption(b, config, ticketKey, ticketRows, ticketAlerts, ticket); + } + } + /** 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 +3973,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 +4392,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 }); @@ -7714,6 +7875,27 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P ); } for (const t of opsTickets) store.recordTicket(b.projectId, t.fingerprint, t.ticket, t.did); + if (opsTickets.length > 0) { + for (const t of opsTickets) store.logOps({ projectId: b.projectId, entityId: b.entityId, bindingId: b.id, threadId, ticketKey: t.ticket, text: `${t.did === "filed" ? "Filed" : t.did === "commented" ? "Commented on" : "Reopened"} ${t.ticket.toUpperCase()} for ${t.fingerprint}` }); + // 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; + raiseAdoption(b, config, ticketKey, rows, alerts.map((a) => ({ id: a.fingerprint, repo: repoOfFingerprint(a.fingerprint) ?? "", number: a.alertNumber, severity: a.severity, package: a.package, ecosystem: a.ecosystem, manifestPath: a.manifestPath, state: a.state, ghsaId: a.ghsaId, fixedVersion: a.fixedVersion, summary: a.summary, url: a.url, createdAt: null, updatedAt: null })), ticket); + } + } + } // 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 @@ -9630,6 +9812,11 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P 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)}`}`); + 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.reminded.length === 0 ? "" : `, reminded ${d.reminded.join(", ")}`}): ${d.fingerprints.join(", ")}${d.resolution === null ? "" : ` → ${d.resolution}`}`); + // The target's own record, kept across bindings and threads, newest first. + for (const e of store.opsLog(b.projectId, b.entityId)) lines.push(`Action ${iso(e.at)}${e.threadId === null ? "" : ` (${e.threadId})`}: ${e.text}`); + } 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..39f22244 100644 --- a/plugins/stewards/skills/stewards/SKILL.md +++ b/plugins/stewards/skills/stewards/SKILL.md @@ -316,6 +316,14 @@ 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 as the + due date nears. The answer arrives under "What changed" as "The user + chose …"; the wake says what Adopt, Dismiss as not used and Keep + tracking each mean. Once a ticket exists, its Linear due date is the one + the wake shows; the SLA deadline stands in only until then. - **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/source-contract.ts b/plugins/stewards/source-contract.ts index 72ee6e8d..990228bd 100644 --- a/plugins/stewards/source-contract.ts +++ b/plugins/stewards/source-contract.ts @@ -222,6 +222,12 @@ export const entitySnapshotSchema = z.object({ */ completedAt: z.string().nullable().optional(), canceledAt: z.string().nullable().optional(), + /** + * Linear: the issue's due date as YYYY-MM-DD, or null. Optional for the + * same reason; stewards' dependency watch reads it as the truth about when + * a ticket is due, over the deadline it computed itself. Null on GitHub. + */ + dueDate: z.string().nullable().optional(), /** The newest comments (and, on a pull request, reviews and review comments), oldest first. */ comments: z.array(snapshotCommentSchema), parent: z.object({ id: z.string(), key: z.string() }).nullable(), diff --git a/plugins/stewards/store.test.ts b/plugins/stewards/store.test.ts index 0c957e18..345c6d24 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,8 +506,10 @@ 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); + for (const sql of DEPS_DECISION_MIGRATIONS) db.exec(sql); expect(routeOf(db, "esc_me")).toBe("user"); expect(routeOf(db, "esc_project")).toBe("project"); // No other column moved. @@ -518,6 +520,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 +531,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 +546,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 +555,11 @@ 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 (adoption decisions and the ops log) 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,42 @@ describe("the server settings", () => { expect(s.serverSetting("another-key")).toBeNull(); }); }); + +describe("adoption decisions and the ops log (keyed by ticket and target, not by binding)", () => { + it("raises a decision once per ticket, keeps it across bindings, 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: [] }); + // 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: ["half"] })).toMatchObject({ needId: "need_2", reminded: ["half"] }); + 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("keeps a target's action log newest first, with the thread that acted", () => { + const { s, advance } = store(); + s.logOps({ projectId: "p1", entityId: "dependabot/acme/web", bindingId: "bnd_old", threadId: "thr_filed", ticketKey: "sc-111", text: "Filed SC-111 for acme/web#23" }); + advance(1_000); + s.logOps({ projectId: "p1", entityId: "dependabot/acme/web", bindingId: "bnd_new", threadId: null, text: "Reminder (half): SC-111 is halfway to its due date" }); + s.logOps({ projectId: "p1", entityId: "dependabot/acme/other", text: "Filed SC-200" }); + const log = s.opsLog("p1", "dependabot/acme/web"); + expect(log.map((e) => [e.text, e.threadId, e.ticketKey, e.at])).toEqual([ + ["Reminder (half): SC-111 is halfway to its due date", null, null, 2_000], + ["Filed SC-111 for acme/web#23", "thr_filed", "SC-111", 1_000], + ]); + expect(s.opsLog("p1", "dependabot/acme/web", 1)).toHaveLength(1); + expect(s.opsLog("p1", "dependabot/acme/other").map((e) => e.text)).toEqual(["Filed SC-200"]); + }); +}); diff --git a/plugins/stewards/store.ts b/plugins/stewards/store.ts index 7405b621..9598040c 100644 --- a/plugins/stewards/store.ts +++ b/plugins/stewards/store.ts @@ -518,7 +518,45 @@ 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. +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 '[]', + PRIMARY KEY (project_id, ticket_key) + )`, + `CREATE INDEX IF NOT EXISTS deps_decisions_target ON deps_decisions (project_id, entity_id, status)`, + // An ops target's action log: what the plugin and its stewards did about + // the target (filed, commented, raised a decision, reminded, resolved), + // keyed by target so it survives the binding being recreated and the + // filing thread being deleted. + `CREATE TABLE IF NOT EXISTS ops_log ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + entity_id TEXT NOT NULL, + binding_id TEXT, + thread_id TEXT, + ticket_key TEXT, + at INTEGER NOT NULL, + text TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS ops_log_target ON ops_log (project_id, entity_id, at)`, +]; + +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 +1159,31 @@ 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[]; +}; + +/** One line of an ops target's action log. */ +export type OpsLogEntry = { id: string; projectId: string; entityId: string; bindingId: string | null; threadId: string | null; ticketKey: string | null; at: number; text: string }; + 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. */ @@ -1246,6 +1309,21 @@ export function createStore(db: Database.Database, now: () => number, options: S 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)", + ); + const insertOpsLog = lazy<[Record], unknown>("INSERT INTO ops_log (id, project_id, entity_id, binding_id, thread_id, ticket_key, at, text) VALUES (@id, @project_id, @entity_id, @binding_id, @thread_id, @ticket_key, @at, @text)"); + const opsLogForTarget = lazy<[string, string, number], Record>("SELECT * FROM ops_log WHERE project_id = ? AND entity_id = ? ORDER BY at DESC, rowid DESC LIMIT ?"); + 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"); @@ -1429,6 +1507,30 @@ export function createStore(db: Database.Database, now: () => number, options: S 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), []), + }); + const toOpsLogEntry = (r: Record): OpsLogEntry => ({ + id: String(r.id), + projectId: String(r.project_id), + entityId: String(r.entity_id), + bindingId: r.binding_id === null ? null : String(r.binding_id), + threadId: r.thread_id === null ? null : String(r.thread_id), + ticketKey: r.ticket_key === null ? null : String(r.ticket_key), + at: Number(r.at), + text: String(r.text), + }); const toProject = (r: Record): ProjectStewardRow => ({ projectId: String(r.project_id), threadId: r.thread_id === null ? null : String(r.thread_id), @@ -2133,6 +2235,54 @@ 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); + 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; + }, + + // the ops action log, keyed by target + logOps(fields: { projectId: string; entityId: string; bindingId?: string | null; threadId?: string | null; ticketKey?: string | null; text: string }): OpsLogEntry { + const id = newId("log"); + insertOpsLog().run({ id, project_id: fields.projectId, entity_id: fields.entityId, binding_id: fields.bindingId ?? null, thread_id: fields.threadId ?? null, ticket_key: fields.ticketKey === undefined || fields.ticketKey === null ? null : fields.ticketKey.toUpperCase(), at: now(), text: fields.text }); + return toOpsLogEntry(opsLogForTarget().get(fields.projectId, fields.entityId, 1)!); + }, + /** A target's action log, newest first. */ + opsLog(projectId: string, entityId: string, limit = 50): OpsLogEntry[] { + return opsLogForTarget().all(projectId, entityId, limit).map(toOpsLogEntry); + }, + // 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 +2544,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", "ops_log", "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..3ce645d6 100644 --- a/plugins/stewards/templates.ts +++ b/plugins/stewards/templates.ts @@ -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. Once a ticket exists, its Linear due date is the +one that counts, and the wake shows it; the SLA deadline the plugin computed +from the alert's own created_at stands in only until the ticket has one. +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,13 @@ ${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. 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..1fda911c 100644 --- a/plugins/stewards/wake.test.ts +++ b/plugins/stewards/wake.test.ts @@ -558,6 +558,9 @@ describe("the ops and deps wake classification lines (BB-136)", () => { alertNumber: 4, severity: "high", package: "lodash", + ticketDueDate: null, + 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,43 @@ 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 ticket's own due date over the SLA deadline, and says when they disagree (SC-111)", () => { + const sla = Date.parse("2026-11-07T12:00:00Z"); + const matching = depsWake(depsFinding({ class: "recurring", ticket: "SC-111", ticketState: "Backlog", severity: "medium", priority: "medium", dueAt: sla, ticketDueDate: "2026-11-07" })); + expect(matching).toMatch(/\*\*medium\*\* priority medium, due 2026-11-07 \(the ticket's due date\)/u); + const drifted = depsWake(depsFinding({ class: "recurring", ticket: "SC-111", ticketState: "Backlog", severity: "medium", priority: "medium", dueAt: sla, ticketDueDate: "2026-11-04", ticketLabels: ["security"], alertCreatedAt: "2026-10-29T00:19:43Z" })); + expect(drifted).toMatch(/due 2026-11-04 \(the ticket's due date; the SLA alone would say 2026-11-07, but the ticket's date is the one that counts\)/u); + expect(drifted).toMatch(/Ticket labels: security/u); + expect(drifted).toMatch(/opened 2026-10-29T00:19:43Z \(the SLA clock\); first seen by this watch/u); + const unread = depsWake(depsFinding({ class: "recurring", ticket: "SC-111", ticketState: "Backlog", dueAt: sla, ticketDueDate: null })); + expect(unread).toMatch(/SLA due 2026-11-07 \(the ticket's own due date could not be read\)/u); + expect(depsWake(depsFinding({ dueAt: sla }))).toMatch(/priority high, due 2026-11-07\n/u); + }); + + it("lists the adoption decisions waiting on the owner and what each answer means, and says not to ask again", () => { + const at = Date.parse("2026-10-14T15:00:00Z"); + const text = formatDepsWake({ + reason: "requeue", + binding: binding("dependabot/acme/web"), + playbook: parsedPlaybook(DEPS_ALERTS, "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: [{ projectId: "p1", ticketKey: "SC-111", entityId: "dependabot/acme/web", fingerprints: ["acme/web#23", "acme/web#24"], severity: "medium", raisedAt: at - 86_400_000, raisedBy: "thr_filed", needId: "need_1", status: "pending", resolution: null, resolvedAt: null, reminded: ["half"] }], + changes: [], + }); + 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: half/u); + expect(text).toMatch(/Do not ask again\. Adopt: the ticket is labelled and moved to Todo/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: dismiss each of the ticket's alerts on GitHub \(`gh api -X PATCH/u); + expect(text).toMatch(/Keep tracking: keep the ticket open and unadopted and move its due date/u); + expect(depsWake(depsFinding())).not.toMatch(/### Adoption decisions/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..6a067b83 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, ADOPTION_HOW, 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,16 @@ 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}`); - lines.push(`- **${a.severity}** priority ${a.priority}, due ${dueDate(a.dueAt, timeZone)}`); + // Once a ticket exists, its Linear due date is the one that counts; the + // SLA deadline from the alert's created_at is shown beside it only when the + // two disagree, so a drift is said rather than repeated (SC-111). + const sla = dueDate(a.dueAt, timeZone); + if (a.ticket !== null && a.ticketDueDate !== null) lines.push(`- **${a.severity}** priority ${a.priority}, due ${a.ticketDueDate} (the ticket's due date${a.ticketDueDate === sla ? "" : `; the SLA alone would say ${sla}, but the ticket's date is the one that counts`})`); + else if (a.ticket !== null) lines.push(`- **${a.severity}** priority ${a.priority}, SLA due ${sla} (the ticket's own due date could not be read)`); + else lines.push(`- **${a.severity}** priority ${a.priority}, due ${sla}`); + 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 +916,12 @@ 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.`); } + 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. ` + + `${ADOPTION_CHOICES.dismiss}: dismiss each of the ticket's alerts on GitHub (\`gh api -X PATCH /repos///dependabot/alerts/ -f state=dismissed -f dismissed_reason=not_used -f dismissed_comment=\`), then move the ticket to Done with a comment saying the alerts were dismissed as not used and why. ` + + `${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,11 @@ 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.reminded.length === 0 ? "" : `, reminded: ${d.reminded.join(", ")}`}`); + out.push("", `These are on the Stewards page; the plugin reminds the owner as the due date nears. Do not ask again. ${ADOPTION_HOW}`); + } const snapshot = out.splice(0); out.push("", "## 4. What changed since your last turn", "");