Skip to content
Closed
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
6 changes: 6 additions & 0 deletions plugins/github-watcher/source-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions plugins/linear/linear.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
4 changes: 3 additions & 1 deletion plugins/linear/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } } } " +
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions plugins/linear/source-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
31 changes: 31 additions & 0 deletions plugins/stewards/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1447,6 +1447,37 @@ 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 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
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,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
Expand Down Expand Up @@ -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`
Expand Down
79 changes: 78 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, 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> = {}): DependabotAlert => ({
Expand Down Expand Up @@ -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 });
});
});
Loading
Loading