Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions plugins/stewards/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1447,6 +1447,40 @@ binding do it.
once rather than waiting for the adopt pass. The alert's fingerprint is
`owner/repo#<alert number>` (the alert's own numbering), recorded in the
ops fingerprint map so a recurrence is a comment, never a second ticket.
- **The SLA clock is the alert's own `created_at`**, not when this server
first saw it, so a watch binding recreated days later does not hand a
ticket a fresh deadline beside the one the ticket was filed with (SC-111
drifted three days that way and every digest repeated the wrong date).
The plugin owns the deadline: it never reads Linear's `dueDate` (the
source contract does not carry it), and the wake shows the computed date.
- **Adoption of a ticket for a severity `deps.autoAdopt` does not name is
the owner's decision, and the plugin keeps it.** The steward used to
raise one `decision` escalation from the filing thread; when that thread
was deleted the question went with it, and nothing re-raised it. Now the
moment a report lists such a ticket as filed (and at every look, as the
backfill for a ticket inherited, found by dedup, or filed by a thread that
never reported), the plugin records a `deps_decisions` row keyed by ticket
and files a `decide` need on whichever binding watches the repository,
with choices **Adopt**, **Keep tracking** and — only when the playbook's
`may` list declares `dismiss-alert` — **Dismiss as not used** (the
recommended one first: dismiss when no alert has a fixed version). It
shows in `bb stewards needs`, on the Stewards page and in the digest's
"Needs you" until the owner answers, the ticket is labelled, or it
closes; a recreated binding re-files the item on itself. The answer
reaches the dependency steward under "What changed" and the wake says
what each choice means. A `Keep tracking` answer is a snooze: the plugin
records the date the answer's note gives (or one SLA window from today) as
`snoozed_until` and re-files the same decision when it arrives. When the
deadline (or the snooze) has passed the plugin reminds once: it rewords
the item "overdue by N days", adds a note to the project steward's next
wake (so the digest names the deadline rather than promising to raise it
later) and records the reminder on the decision.
- **Provenance without a log table.** The fingerprint map records the thread
that filed a ticket (`filed_by_thread`), and each decision keeps its
raised-at and raised-by. `bb stewards show` prints the fingerprint rows
with the filing thread and every decision with its status, dates and
resolution, so "did anyone ask, and who filed it?" is answered with the
filing thread deleted.
- **critical and high get their own ticket; medium and low coalesce** into
one open ticket per repo per ecosystem. A new alert joins that ticket
when its due date is inside the new alert's deadline, and otherwise
Expand Down
16 changes: 13 additions & 3 deletions plugins/stewards/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,17 @@ bb. [DESIGN.md](DESIGN.md) is the design; this is how it is built and run.
`dependabot_alert` doorbell; the github plugin reads the alerts with the
developer's token and leases each repo as a `dependabot_repo` (a `repo`
lease does not receive alert doorbells). Severity sets the Linear priority
and a due date from `deps.sla`; critical and high get their own ticket,
and a due date from `deps.sla`, clocked from the alert's own `created_at`.
critical and high get their own ticket,
medium and low coalesce into one open ticket per repo per ecosystem, and a
new alert joins or tightens it. A fixed or dismissed alert trims or closes
new alert joins or tightens it. A ticket for a severity `deps.autoAdopt`
does not name gets an adoption decision the plugin keeps by ticket (Adopt,
Keep tracking, and — when the playbook's `may` allows `dismiss-alert` —
Dismiss as not used) in `bb stewards needs` and the digest until the owner
answers; Keep tracking snoozes it and the plugin re-files it on that date,
and a missed deadline gets one overdue reminder. `bb stewards show` prints
the filing thread per fingerprint and each decision. A fixed or dismissed
alert trims or closes
its ticket, and the nightly pass re-lists open alerts as the backstop. A
`dependabot[bot]` version-update pull request is adopted under `deps-prs`,
gets the reviewer round, and once its marker is approved on green checks
Expand Down Expand Up @@ -801,7 +809,9 @@ 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 one overdue reminder).
`deps-alerts.md` and `deps-prs.md` are optional: a project that has not
written them runs no dependency watch. `bb stewards init` writes them, and
`bb stewards check` validates `deps.sla`, `deps.batch` and `deps.autoAdopt`
Expand Down
63 changes: 62 additions & 1 deletion plugins/stewards/deps.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_BATCH, DEFAULT_SLA, alertFingerprint, alertLine, batchAlert, deadlineAt, depsFindingSchema, dueDate, isBatchable, priorityFor, sightingOf } from "./deps.js";
import { ADOPTION_CHOICES, DEFAULT_BATCH, DEFAULT_SLA, adoptionChoices, adoptionHow, adoptionQuestion, alertClockAt, alertFingerprint, alertLine, batchAlert, dateInText, deadlineAt, depsFindingSchema, dueDate, isBatchable, overdue, priorityFor, reminderText, sightingOf, snoozeUntil } from "./deps.js";
import type { DependabotAlert } from "./source-contract.js";

const alert = (over: Partial<DependabotAlert> = {}): DependabotAlert => ({
Expand Down Expand Up @@ -137,3 +137,64 @@ describe("the defaults", () => {
expect(DEFAULT_BATCH).toEqual({ medium: 7 * 86_400_000, low: 30 * 86_400_000 });
});
});

describe("the SLA clock is the alert's own created_at", () => {
it("starts at created_at when it parses, else at first seen", () => {
expect(alertClockAt(alert({ createdAt: "2026-10-29T00:19:43Z" }), now)).toBe(Date.parse("2026-10-29T00:19:43Z"));
expect(alertClockAt(alert({ createdAt: null }), now)).toBe(now);
expect(alertClockAt(alert({ createdAt: "not a date" }), now)).toBe(now);
});

it("gives a binding recreated later the same deadline, not a fresh one (SC-111)", () => {
const created = Date.parse("2026-10-29T00:19:43Z");
const filedBy = deadlineAt("medium", DEFAULT_SLA, alertClockAt(alert({ createdAt: "2026-10-29T00:19:43Z" }), created));
const recreatedAt = Date.parse("2026-10-31T15:48:52Z");
const seenAgain = deadlineAt("medium", DEFAULT_SLA, alertClockAt(alert({ createdAt: "2026-10-29T00:19:43Z" }), recreatedAt));
expect(seenAgain).toBe(filedBy);
expect(dueDate(seenAgain, "UTC")).toBe("2026-11-05");
expect(dueDate(seenAgain, "America/New_York")).toBe("2026-11-04");
});

it("snoozes Keep tracking to the note's date, or one SLA window from now when it gives none", () => {
const at = Date.parse("2026-10-29T00:00:00Z");
expect(dateInText("Chose: Keep tracking — 2026-11-20")).toBe(Date.parse("2026-11-20T12:00:00Z"));
expect(dateInText("nothing here")).toBeNull();
expect(snoozeUntil("Chose: Keep tracking — 2026-11-20", "medium", DEFAULT_SLA, at)).toBe(Date.parse("2026-11-20T12:00:00Z"));
expect(snoozeUntil("Chose: Keep tracking", "medium", DEFAULT_SLA, at)).toBe(at + 7 * 86_400_000);
});
});

describe("the adoption decision", () => {
it("leads with Adopt when a fix exists and with Dismiss when none does", () => {
expect(adoptionChoices(true, true)).toEqual([ADOPTION_CHOICES.adopt, ADOPTION_CHOICES.keep, ADOPTION_CHOICES.dismiss]);
expect(adoptionChoices(false, true)).toEqual([ADOPTION_CHOICES.dismiss, ADOPTION_CHOICES.keep, ADOPTION_CHOICES.adopt]);
});

it("offers no Dismiss when the playbook's may list does not allow it", () => {
expect(adoptionChoices(true, false)).toEqual([ADOPTION_CHOICES.adopt, ADOPTION_CHOICES.keep]);
expect(adoptionChoices(false, false)).toEqual([ADOPTION_CHOICES.keep, ADOPTION_CHOICES.adopt]);
expect(adoptionHow(false)).not.toMatch(/Dismiss/u);
expect(adoptionHow(true)).toMatch(/Dismiss as not used/u);
});

it("asks one line naming the ticket, the alerts, the fix and the due date, and says when it is overdue", () => {
const echo = { severity: "medium" as const, package: "github.com/labstack/echo/v4", ecosystem: "go", fixedVersion: null };
expect(adoptionQuestion("SC-111", "bitcomplete/santaclaw", [echo, echo], "2026-11-04")).toBe("Adopt SC-111? medium Dependabot alerts (2) on bitcomplete/santaclaw: github.com/labstack/echo/v4 (go), no fixed version, due 2026-11-04.");
expect(adoptionQuestion("ENG-50", "acme/web", [alert()], null)).toBe("Adopt ENG-50? high Dependabot alert on acme/web: lodash (npm), fix available.");
expect(adoptionQuestion("ENG-50", "acme/web", [alert({ severity: "low" }), alert({ severity: "medium", package: "axios", fixedVersion: null })], "2026-11-01")).toMatch(/^Adopt ENG-50\? medium\/low Dependabot alerts \(2\) on acme\/web: lodash, axios \(npm\), fix for 1 of 2, due 2026-11-01\.$/u);
expect(adoptionQuestion("ENG-50", "acme/web", [alert()], "2026-11-04", 2)).toMatch(/due 2026-11-04\. Overdue by 2 days\.$/u);
expect(adoptionQuestion("ENG-50", "acme/web", [alert()], "2026-11-04", 1)).toMatch(/Overdue by 1 day\.$/u);
});

it("words the one overdue reminder with the days past the due date", () => {
expect(overdue(Date.parse("2026-11-04T12:00:00Z"), Date.parse("2026-11-04T12:00:00Z"), "UTC")).toBe(false);
expect(overdue(Date.parse("2026-11-04T12:00:00Z"), Date.parse("2026-11-05T12:00:00Z"), "UTC")).toBe(true);
expect(reminderText("SC-111", "2026-11-04", Date.parse("2026-11-05T12:00:00Z"), "UTC")).toBe("SC-111 is a day past its due date (2026-11-04) and still unadopted.");
expect(reminderText("SC-111", "2026-11-04", Date.parse("2026-11-10T12:00:00Z"), "UTC")).toBe("SC-111 is 6 days past its due date (2026-11-04) and still unadopted.");
});

it("parses an older look without the ticket's labels or created_at", () => {
const parsed = depsFindingSchema.parse({ fingerprint: "acme/web#4", alertNumber: 4, severity: "high", package: "lodash", ecosystem: "npm", manifestPath: null, ghsaId: null, fixedVersion: null, summary: "x", state: "open", url: null, dueAt: now, priority: "high", class: "new", ticket: null, ticketState: null, closedAt: null, window: null, assignee: "Jane", firstSeenAt: now, lastCommentedAt: null, batch: { kind: "new" } });
expect(parsed).toMatchObject({ ticketLabels: [], alertCreatedAt: null });
});
});
107 changes: 99 additions & 8 deletions plugins/stewards/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,45 @@ export const isBatchable = (severity: DependabotSeverity): boolean => severity =
export const alertFingerprint = (alert: Pick<DependabotAlert, "repo" | "number">): 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<DependabotSeverity, number | "asap">, firstSeenAt: number): number {
export function deadlineAt(severity: DependabotSeverity, sla: Record<DependabotSeverity, number | "asap">, 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<DependabotAlert, "createdAt">, firstSeenAt: number): number {
if (alert.createdAt === null) return firstSeenAt;
const parsed = Date.parse(alert.createdAt);
return Number.isNaN(parsed) ? firstSeenAt : parsed;
}

/** The first YYYY-MM-DD date in a note, as an instant at noon UTC (that date in every time zone from UTC-12 to UTC+11), or null. */
export function dateInText(text: string): number | null {
const match = /(\d{4}-\d{2}-\d{2})/u.exec(text);
if (match === null) return null;
const ms = Date.parse(`${match[1]}T12:00:00Z`);
return Number.isNaN(ms) ? null : ms;
}

/**
* When a `Keep tracking` answer snoozes the decision until: the date the
* answer's note gives, or one SLA window from now when it gives none. The
* plugin owns this clock (it never reads Linear's due date), so it is
* computed once, when the answer arrives, and recorded on the decision.
*/
export function snoozeUntil(note: string, severity: DependabotSeverity | null, sla: Record<DependabotSeverity, number | "asap">, now: number): number {
return dateInText(note) ?? deadlineAt(severity ?? "medium", sla, now);
}

/** An open ticket a batched alert may join: one per repo per ecosystem. */
Expand Down Expand Up @@ -123,8 +153,10 @@ export const depsFindingSchema = z.object({
summary: z.string(),
state: z.string(),
url: z.string().nullable(),
/** The SLA deadline, from the severity and when the alert was first seen. */
/** The SLA deadline, from the severity and the alert's own created_at (`alertClockAt`). */
dueAt: z.number(),
/** The ticket's labels as Linear has them, so the wake can say whether it is adopted. */
ticketLabels: z.array(z.string()).default([]),
/** The Linear priority the severity maps to. */
priority: z.string(),
class: z.enum(["new", "recurring", "returned"]),
Expand All @@ -139,6 +171,8 @@ export const depsFindingSchema = z.object({
/** Set on a new finding when the dedup search could not run or was partial. */
dedupUnavailable: z.boolean().default(false),
firstSeenAt: z.number(),
/** The alert's own created_at as GitHub reports it, the SLA clock; null when it did not parse. */
alertCreatedAt: z.string().nullable().default(null),
lastCommentedAt: z.number().nullable(),
/** For a medium or low alert, what to do with the open ticket for its repo and ecosystem. */
batch: z.union([
Expand All @@ -160,6 +194,63 @@ export const depsLookSchema = z.object({
});
export type DepsLook = z.infer<typeof depsLookSchema>;

// ---- adoption decisions ------------------------------------------------------------
//
// A ticket filed for a severity `deps.autoAdopt` does not name is nobody's
// until the owner says so. The plugin keeps that question as durable state
// keyed by ticket (store.ts `deps_decisions`), shown as a `decide` need on
// whichever binding watches the repository now, so it survives the filing
// thread being deleted and the binding being recreated (SC-111 sat six days
// in Backlog because the one escalation that was meant to ask got lost).

/** The owner's choices on an unadopted ticket, the recommended one first. */
export const ADOPTION_CHOICES = { adopt: "Adopt", dismiss: "Dismiss as not used", keep: "Keep tracking" } as const;

/**
* The choices offered on an adoption decision, the recommended one first. With
* a fix available, adopting (a worker makes the upgrade) leads; with no
* patched version for any alert, dismissing the alerts as unused leads, since
* adopting could only produce a decision anyway. `Dismiss as not used` is
* offered only when the playbook's `may` list declares `dismiss-alert`.
*/
export function adoptionChoices(hasFix: boolean, dismissAllowed: boolean): string[] {
if (hasFix) return dismissAllowed ? [ADOPTION_CHOICES.adopt, ADOPTION_CHOICES.keep, ADOPTION_CHOICES.dismiss] : [ADOPTION_CHOICES.adopt, ADOPTION_CHOICES.keep];
return dismissAllowed ? [ADOPTION_CHOICES.dismiss, ADOPTION_CHOICES.keep, ADOPTION_CHOICES.adopt] : [ADOPTION_CHOICES.keep, ADOPTION_CHOICES.adopt];
}

/** The one line a decision item asks. `overdueDays` rewords the item once the deadline has passed. */
export function adoptionQuestion(ticketKey: string, repo: string, alerts: ReadonlyArray<Pick<DependabotAlert, "severity" | "package" | "ecosystem" | "fixedVersion">>, dueDate: string | null, overdueDays: number | null = null): string {
const severities = [...new Set(alerts.map((a) => a.severity))].sort((a, b) => DEPENDABOT_SEVERITIES.indexOf(a) - DEPENDABOT_SEVERITIES.indexOf(b));
const packages = [...new Set(alerts.map((a) => a.package))];
const ecosystems = [...new Set(alerts.map((a) => a.ecosystem))];
const fixes = alerts.filter((a) => a.fixedVersion !== null).length;
const fix = fixes === 0 ? "no fixed version" : fixes === alerts.length ? "fix available" : `fix for ${fixes} of ${alerts.length}`;
const overdue = overdueDays === null ? "" : ` Overdue by ${overdueDays} day${overdueDays === 1 ? "" : "s"}.`;
return `Adopt ${ticketKey}? ${severities.join("/")} Dependabot alert${alerts.length === 1 ? "" : `s (${alerts.length})`} on ${repo}: ${packages.join(", ")} (${ecosystems.join(", ")}), ${fix}${dueDate === null ? "" : `, due ${dueDate}`}.${overdue}`;
}

/** What each choice means, for the item's `how` and the steward's wake. */
export function adoptionHow(dismissAllowed: boolean): string {
const parts = [
`${ADOPTION_CHOICES.adopt}: the ticket is labelled and moved to Todo, and a ticket steward builds the upgrade.`,
`${ADOPTION_CHOICES.keep}: the ticket stays open and unadopted; the steward moves its due date out, and the plugin asks again when that date arrives.`,
];
if (dismissAllowed) parts.push(`${ADOPTION_CHOICES.dismiss}: the ticket's alerts are dismissed on GitHub as "vulnerable code is not actually used" and the ticket is closed with the reason.`);
return parts.join(" ");
}

/** Whether the decision's due date (the computed deadline, or a snooze) has passed in the project's time zone. */
export function overdue(dueAt: number, now: number, timeZone: string): boolean {
return dueDate(now, timeZone) > dueDate(dueAt, timeZone);
}

/** How the one overdue reminder reads: the days past the due date, in the project's time zone. */
export function reminderText(ticketKey: string, due: string, now: number, timeZone: string): string {
const today = dueDate(now, timeZone);
const days = Math.round((Date.parse(`${today}T00:00:00Z`) - Date.parse(`${due}T00:00:00Z`)) / 86_400_000);
return `${ticketKey} is ${days === 1 ? "a day" : `${days} days`} past its due date (${due}) and still unadopted.`;
}

/** The repo a fingerprint names: "owner/repo#4" → "owner/repo". */
export const repoOfFingerprint = (fingerprint: string): string | null => {
const i = fingerprint.lastIndexOf("#");
Expand Down
5 changes: 4 additions & 1 deletion plugins/stewards/migrations.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,8 @@
"595e447b50228f6ebb015a069872948beb36668629d9b2e71f798c83840037c6",
"7bc6e0dc10f8aea8f67e3d0d642b87bc67b80241052e6fcfafe947ee7b9538b5",
"42be649d859d3c6e3a0970b74f41a8c75c9ef753e247d7a54d2c8cff039ba523",
"fc84bda5d59e556b0a0100249a11fe6df258ad005665d164b56a18bad3c55e1e"
"fc84bda5d59e556b0a0100249a11fe6df258ad005665d164b56a18bad3c55e1e",
"27485e110bfc062528fc26d63a8210ef19664c3ea85b43d7626f81ce8630f4f8",
"3e5da92669f8bb2875557911e671c7652893d236f58bfef80ce7cab5d7bef2fd",
"e2c1b8b97003b2b938c95af600edff53f1eb88750c8b8723813b937a1f5d3a43"
]
Loading
Loading