From b96b9e893226ba7c08e3ef4ed056059fff884e75 Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Tue, 10 Nov 2026 05:52:19 +0000 Subject: [PATCH] stewards, github-watcher: an empty dependency graph is reported as such (BB-289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sourceDependabotAlerts` gains an additive, optional `graphEmpty: string[]`: the repositories whose GraphQL `dependencyGraphManifests(first:1){totalCount}` is zero, so an empty alert list means Dependabot cannot see any manifest rather than that there is nothing open. A failed graph read is not an empty graph: the repo stays unflagged. The contract copy is updated in all three plugins, and the deps-alerts wake and `bb stewards check` now say "dependency graph empty" and name the by-hand toggle (repo Settings → Advanced Security → Dependency graph) instead of "no open alerts". Co-Authored-By: Claude Sonnet 5.5 --- plugins/github-watcher/github.ts | 30 +++++++++++++++++++++++ plugins/github-watcher/server.test.ts | 19 +++++++++++++- plugins/github-watcher/server.ts | 18 ++++++++++++-- plugins/github-watcher/source-contract.ts | 8 ++++++ plugins/linear/source-contract.ts | 8 ++++++ plugins/stewards/deps.test.ts | 13 +++++++++- plugins/stewards/deps.ts | 7 ++++++ plugins/stewards/server.test.ts | 30 ++++++++++++++++++++--- plugins/stewards/server.ts | 11 +++++++-- plugins/stewards/source-contract.ts | 8 ++++++ plugins/stewards/wake.ts | 4 +++ 11 files changed, 147 insertions(+), 9 deletions(-) diff --git a/plugins/github-watcher/github.ts b/plugins/github-watcher/github.ts index 50d0e947..49bd9040 100644 --- a/plugins/github-watcher/github.ts +++ b/plugins/github-watcher/github.ts @@ -761,6 +761,36 @@ const severityOf = (value: string | null | undefined): DependabotSeverity => { return (DEPENDABOT_SEVERITIES as readonly string[]).includes(s) ? (s as DependabotSeverity) : "low"; }; +/** + * One repository's dependency-graph manifest count, for the dependency watch's + * "dependency graph empty" flag (BB-289): a graph that has parsed zero + * manifests has an empty Dependabot alert feed for a reason a person must + * turn on by hand, not because there is nothing to find. + */ +export const DEPENDENCY_GRAPH_QUERY = `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { nameWithOwner dependencyGraphManifests(first: 1) { totalCount } } }`; + +const dependencyGraphPayload = z.object({ + data: z.object({ repository: z.object({ nameWithOwner: z.string(), dependencyGraphManifests: z.object({ totalCount: z.number() }).nullish() }).nullable() }).nullable(), +}); + +/** + * How many manifests the repository's dependency graph has parsed, or null + * when the repository is not there, not visible, or the field is absent. + * Throws when GitHub refused the read; the caller must not read a failed read + * as an empty graph. + */ +export function parseDependencyGraphCount(payload: unknown): number | null { + const errors = graphqlErrors(payload); + const parsed = dependencyGraphPayload.safeParse(payload); + if (!parsed.success) throw new Error(errors.length > 0 ? `GitHub: ${errors.join("; ")}` : "GitHub answered without data"); + const repository = parsed.data.data?.repository ?? null; + if (repository === null) { + if (errors.length > 0 && errorTypes(payload).some((t) => t !== "NOT_FOUND")) throw new Error(`GitHub: ${errors.join("; ")}`); + return null; + } + return repository.dependencyGraphManifests?.totalCount ?? null; +} + /** * One page of `GET /repos/{owner}/{repo}/dependabot/alerts`, as the shared * contract wants it. `repo` is the name the caller asked with; GitHub's diff --git a/plugins/github-watcher/server.test.ts b/plugins/github-watcher/server.test.ts index d3cc2ea9..e8e0b2e4 100644 --- a/plugins/github-watcher/server.test.ts +++ b/plugins/github-watcher/server.test.ts @@ -38,7 +38,7 @@ const comment = (body: string, createdAt: string, who = "bob") => ({ author: { l // A GitHub whose GraphQL endpoint a test scripts: one repository with one // pull request, whose bb-managed label validates. function fakeGitHub() { - const state = { node: pullNode() as Record | null, fail: false, rejectSubIssues: false, valid: (token: string) => token.startsWith("gho_"), openPulls: [] as Array>, openIssues: [] as Array>, searchIssues: [] as Array>, labels: { "acme/widgets": ["bb-managed"] } as Record, labelCreates: [] as string[], labelCreateStatus: 201, labelCreateMessage: "Validation Failed", dependabotAlerts: [] as unknown[], dependabotPage2: [] as unknown[], dependabotStatus: 200, dependabotMessage: "Forbidden", deployments: [] as unknown[], deploymentStatuses: {} as Record, files: {} as Record, commits: {} as Record, workflowRuns: [] as unknown[], workflowRun: null as unknown, jobs: [] as unknown[], compares: {} as Record }; + const state = { node: pullNode() as Record | null, fail: false, rejectSubIssues: false, valid: (token: string) => token.startsWith("gho_"), openPulls: [] as Array>, openIssues: [] as Array>, searchIssues: [] as Array>, labels: { "acme/widgets": ["bb-managed"] } as Record, labelCreates: [] as string[], labelCreateStatus: 201, labelCreateMessage: "Validation Failed", dependabotAlerts: [] as unknown[], dependabotPage2: [] as unknown[], dependabotStatus: 200, dependabotMessage: "Forbidden", dependencyGraph: 1 as number | null, dependencyGraphFail: false, deployments: [] as unknown[], deploymentStatuses: {} as Record, files: {} as Record, commits: {} as Record, workflowRuns: [] as unknown[], workflowRun: null as unknown, jobs: [] as unknown[], compares: {} as Record }; const calls: Array<{ token: string; variables: Record }> = []; const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => { const token = ((init?.headers as Record).authorization ?? "").replace(/^Bearer /u, ""); @@ -109,6 +109,10 @@ function fakeGitHub() { const v = body.variables as { owner: string; name: string; number: number; label?: string }; if (!body.query.includes("repository(")) return Response.json({ data: { viewer: { login: "dev" } } }); if (`${v.owner}/${v.name}`.toLowerCase() !== "acme/widgets") return Response.json({ data: { viewer: { login: "dev" }, repository: null }, errors: [{ type: "NOT_FOUND", message: "Could not resolve to a Repository" }] }); + if (body.query.includes("dependencyGraphManifests")) { + if (state.dependencyGraphFail) return Response.json({ data: null, errors: [{ type: "FORBIDDEN", message: "Resource not accessible by integration" }] }); + return Response.json({ data: { viewer: { login: "dev" }, repository: { nameWithOwner: "Acme/Widgets", dependencyGraphManifests: state.dependencyGraph === null ? null : { totalCount: state.dependencyGraph } } } }); + } if (body.query.includes("label(name:")) { const has = (state.labels[`${v.owner}/${v.name}`.toLowerCase()] ?? []).some((n) => n.toLowerCase() === String(v.label).toLowerCase()); return Response.json({ data: { viewer: { login: "dev" }, repository: { nameWithOwner: "Acme/Widgets", label: has ? { name: String(v.label) } : null } } }); @@ -678,10 +682,23 @@ describe("stewards' source contract", () => { }, ], errors: [], + graphEmpty: [], }); expect(await w.harness.callRpc("sourceDependabotAlerts", { units: ["acme/gone", "widgets"], state: "open" }, STEWARDS)).toMatchObject({ alerts: [], errors: ["acme/gone: GitHub answered HTTP 404: Not Found", "widgets: not owner/repo"] }); }); + it("names a repository whose dependency graph parsed no manifests, and leaves a failed graph read unflagged (BB-289)", async () => { + const w = await setup(); + w.github.state.dependencyGraph = 0; + expect(await w.harness.callRpc("sourceDependabotAlerts", { units: ["Acme/Widgets"], state: "open" }, STEWARDS)).toMatchObject({ alerts: [], graphEmpty: ["Acme/Widgets"], errors: [] }); + // A non-empty graph is not flagged: an empty alert list then means nothing is open. + w.github.state.dependencyGraph = 37; + expect(await w.harness.callRpc("sourceDependabotAlerts", { units: ["Acme/Widgets"], state: "open" }, STEWARDS)).toMatchObject({ graphEmpty: [] }); + // A graph read the token was refused is not an empty graph: it stays unflagged and adds no error. + w.github.state.dependencyGraphFail = true; + expect(await w.harness.callRpc("sourceDependabotAlerts", { units: ["Acme/Widgets"], state: "open" }, STEWARDS)).toMatchObject({ graphEmpty: [], errors: [] }); + }); + it("names a repository whose alerts the token cannot read (403) without failing the whole call", async () => { const w = await setup(); w.github.state.dependabotStatus = 403; diff --git a/plugins/github-watcher/server.ts b/plugins/github-watcher/server.ts index 3aef82aa..acc8acf2 100644 --- a/plugins/github-watcher/server.ts +++ b/plugins/github-watcher/server.ts @@ -22,6 +22,7 @@ import { z } from "zod"; import { WATCHES_CHANGED } from "./channels.js"; import { createGhTokenReader, type TokenReader } from "./gh.js"; import { + DEPENDENCY_GRAPH_QUERY, FILE_AT_REF_QUERY, GITHUB_API_URL, GitHubRequestError, @@ -43,6 +44,7 @@ import { labelPath, parseCompare, parseDependabotAlerts, + parseDependencyGraphCount, parseDeploymentStatus, parseDeployments, parseFileAtRef, @@ -746,15 +748,21 @@ export function createGitHubPlugin(deps: GitHubPluginDeps): (bb: BbPluginApi) => // alerts disabled, not visible) is named in `errors`; the rest are // still returned. Reading alerts needs more than read access to the // repository (bb-gate/docs/hooks.md, "Admission, alerts"). + // + // One GraphQL read of `dependencyGraphManifests(first: 1) { totalCount }` + // per repository says whether an empty alert list is the truth or a + // dependency graph that has parsed nothing (BB-289); a failed graph read + // is not an empty graph, so it is left unflagged and adds no error. async sourceDependabotAlerts({ units, state }) { let viewer: string; try { viewer = parseViewer(await query(VIEWER_QUERY, {}, checkSignal())); } catch (error) { - return { viewer: null, alerts: [], errors: [errorMessage(error)] }; + return { viewer: null, alerts: [], errors: [errorMessage(error)], graphEmpty: [] }; } const alerts: ReturnType = []; const errors: string[] = []; + const graphEmpty: string[] = []; for (const unit of units) { const repo = parseRepo(unit); if (repo === null) { @@ -774,8 +782,14 @@ export function createGitHubPlugin(deps: GitHubPluginDeps): (bb: BbPluginApi) => } catch (error) { errors.push(`${unit}: ${errorMessage(error)}`); } + try { + if (parseDependencyGraphCount(await query(DEPENDENCY_GRAPH_QUERY, { ...repo }, checkSignal())) === 0) graphEmpty.push(name); + } catch { + // A read the token was refused is not an empty graph: leave the + // repository unflagged rather than claim Dependabot can see nothing. + } } - return { viewer, alerts, errors }; + return { viewer, alerts, errors, graphEmpty }; }, // A repository's recent deployments, newest first, read with the token diff --git a/plugins/github-watcher/source-contract.ts b/plugins/github-watcher/source-contract.ts index 2d152013..5f63bfc2 100644 --- a/plugins/github-watcher/source-contract.ts +++ b/plugins/github-watcher/source-contract.ts @@ -472,6 +472,13 @@ export const sourceContract = { * github plugin reads them with the developer's token from the REST API; * each repository the token cannot read (no alert access, alerts disabled, * not visible) is named in `errors` rather than failing the whole call. + * + * `graphEmpty` names the repositories whose dependency graph parsed no + * manifests (`dependencyGraphManifests.totalCount` is 0), so an empty alert + * list means Dependabot cannot see anything rather than that there is + * nothing to see (BB-289). Additive and optional: a source that does not + * implement it leaves it empty, and a repository whose graph read failed is + * not named (a failed read is not an empty graph). */ sourceDependabotAlerts: { input: z.object({ @@ -483,6 +490,7 @@ export const sourceContract = { viewer: z.string().nullable(), alerts: z.array(dependabotAlertSchema), errors: z.array(z.string()), + graphEmpty: z.array(z.string()).default([]), }), }, /** diff --git a/plugins/linear/source-contract.ts b/plugins/linear/source-contract.ts index 2d152013..5f63bfc2 100644 --- a/plugins/linear/source-contract.ts +++ b/plugins/linear/source-contract.ts @@ -472,6 +472,13 @@ export const sourceContract = { * github plugin reads them with the developer's token from the REST API; * each repository the token cannot read (no alert access, alerts disabled, * not visible) is named in `errors` rather than failing the whole call. + * + * `graphEmpty` names the repositories whose dependency graph parsed no + * manifests (`dependencyGraphManifests.totalCount` is 0), so an empty alert + * list means Dependabot cannot see anything rather than that there is + * nothing to see (BB-289). Additive and optional: a source that does not + * implement it leaves it empty, and a repository whose graph read failed is + * not named (a failed read is not an empty graph). */ sourceDependabotAlerts: { input: z.object({ @@ -483,6 +490,7 @@ export const sourceContract = { viewer: z.string().nullable(), alerts: z.array(dependabotAlertSchema), errors: z.array(z.string()), + graphEmpty: z.array(z.string()).default([]), }), }, /** diff --git a/plugins/stewards/deps.test.ts b/plugins/stewards/deps.test.ts index 1540d7b3..eba59b84 100644 --- a/plugins/stewards/deps.test.ts +++ b/plugins/stewards/deps.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -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 { ADOPTION_CHOICES, DEFAULT_BATCH, DEFAULT_SLA, adoptionChoices, adoptionHow, adoptionQuestion, alertClockAt, alertFingerprint, alertLine, batchAlert, dateInText, deadlineAt, depsFindingSchema, depsLookSchema, dueDate, isBatchable, overdue, priorityFor, reminderText, sightingOf, snoozeUntil } from "./deps.js"; import type { DependabotAlert } from "./source-contract.js"; const alert = (over: Partial = {}): DependabotAlert => ({ @@ -198,3 +198,14 @@ describe("the adoption decision", () => { expect(parsed).toMatchObject({ ticketLabels: [], alertCreatedAt: null }); }); }); + +describe("the dependency look", () => { + const look = { at: now, target: "dependabot/acme/web", error: null, viewer: "Jane", alerts: [] }; + + it("keeps the graph-empty flag when present and treats a look stored before it as not empty (BB-289)", () => { + expect(depsLookSchema.parse({ ...look, graphEmpty: true }).graphEmpty).toBe(true); + expect(depsLookSchema.parse({ ...look, graphEmpty: false }).graphEmpty).toBe(false); + // A look written before the field parses; absent means false, so the wake says "no open alerts". + expect(depsLookSchema.parse(look).graphEmpty).toBeUndefined(); + }); +}); diff --git a/plugins/stewards/deps.ts b/plugins/stewards/deps.ts index 8f8173b7..ef351589 100644 --- a/plugins/stewards/deps.ts +++ b/plugins/stewards/deps.ts @@ -202,6 +202,13 @@ export const depsLookSchema = z.object({ error: z.string().nullable(), viewer: z.string().nullable(), alerts: z.array(depsFindingSchema), + /** + * Whether the repository's dependency graph parsed no manifests, so an + * empty alert list means Dependabot cannot see anything rather than that + * there is nothing to see (BB-289). Optional so a look stored before this + * field parses; absent means false. + */ + graphEmpty: z.boolean().optional(), }); export type DepsLook = z.infer; diff --git a/plugins/stewards/server.test.ts b/plugins/stewards/server.test.ts index dcc78e42..04d861a4 100644 --- a/plugins/stewards/server.test.ts +++ b/plugins/stewards/server.test.ts @@ -32,6 +32,8 @@ function world() { const entities = new Map(); /** Dependabot alerts the fake github serves, by lowercased repo. */ const dependabotAlerts = new Map(); + /** Repos whose dependency graph parsed no manifests, lowercased, for the graph-empty wake (BB-289). */ + const dependabotGraphEmpty = new Set(); /** Entity ids whose snapshot read throws, for the failed-read test (BB-115). */ const snapshotErrors = new Set(); /** The fake linear dedup search: unsupported (github-style decline) or an error, for BB-136. */ @@ -318,7 +320,7 @@ function world() { } else if (args.method === "sourceDependabotAlerts") { const units = input.units as string[]; - out = { viewer: "jane", alerts: units.flatMap((u) => dependabotAlerts.get(u.toLowerCase()) ?? []), errors: [] }; + out = { viewer: "jane", alerts: units.flatMap((u) => dependabotAlerts.get(u.toLowerCase()) ?? []), errors: [], graphEmpty: units.filter((u) => dependabotGraphEmpty.has(u.toLowerCase())) }; } else if (args.method === "sourceDeployments") { const key = `${String(input.repo).toLowerCase()} ${String(input.environment).toLowerCase()}`; @@ -543,7 +545,7 @@ function world() { }, }; let now = Date.parse("2026-10-14T15:00:00Z"); - return { files, writes, writtenContent, fileContents, repos, primary, entities, projectList, dependabotAlerts, sourceDeployments, sourceFiles, sourceCommits, sourceRuns, sourceCompares, labelEnsureCalls, labelsEnsured, labelEnsureFailures, setLabelEnsureUnavailable: (value: boolean) => { labelEnsureUnavailable = value; }, setPushUnavailable: (value: boolean) => { pushUnavailable = value; }, setLabelEnsureThrow: (value: string | null) => { labelEnsureThrow = value; }, setLayout: (value: "primary" | "directory") => { layout = value; primary = value === "directory" ? null : { dir: "bb", url: "https://github.com/acme/bb.git" }; }, viewers, setLinearMine: (teams: Array<{ unit: string; name: string }>) => { linearMine = teams; }, setMissingStates: (source: "linear" | "github", unit: string, states: string[]) => { missingStates.set(`${source}:${unit.toLowerCase()}`, states); }, setMissingLabel: (source: "linear" | "github", unit: string, labels: string[]) => { missingLabels.set(`${source}:${unit.toLowerCase()}`, labels); }, rpcCalls, githubTrees, githubBranchProtection, githubLabels, setSnapshotError: (id: string, fail: boolean) => { if (fail) snapshotErrors.add(id); else snapshotErrors.delete(id); }, setSearchUnsupported: (value: boolean) => { searchUnsupported = value; }, setSearchError: (value: string | null) => { searchError = value; }, setCreatePullError: (value: string | null) => { createPullError = value; }, setMergePullError: (value: string | null) => { mergePullError = value; }, setMoveHeadOnMerge: (value: boolean) => { moveHeadOnMerge = value; }, setPullDetailsError: (value: string | null) => { pullDetailsError = value; }, setRetryConflict: (value: boolean) => { retryConflict = value; }, setRetryQueued: (value: boolean) => { retryQueued = value; }, threads, deletes, sections, usage, metadata, spawns, sends, retries, retryEvents, queuedDeletes, userTurns, githubComments, githubReviews, githubMerges, githubPulls, githubClosedPulls, githubDefaultBranches, githubBranches, githubFiles, githubFileContents, githubDispatches, setMergeDispatchError: (value: string | null) => { mergeDispatchError = value; }, github, sdk, uiPreferences, uiPreferencesWrites, now: () => now, advance: (ms: number) => (now += ms) }; + return { files, writes, writtenContent, fileContents, repos, primary, entities, projectList, dependabotAlerts, dependabotGraphEmpty, sourceDeployments, sourceFiles, sourceCommits, sourceRuns, sourceCompares, labelEnsureCalls, labelsEnsured, labelEnsureFailures, setLabelEnsureUnavailable: (value: boolean) => { labelEnsureUnavailable = value; }, setPushUnavailable: (value: boolean) => { pushUnavailable = value; }, setLabelEnsureThrow: (value: string | null) => { labelEnsureThrow = value; }, setLayout: (value: "primary" | "directory") => { layout = value; primary = value === "directory" ? null : { dir: "bb", url: "https://github.com/acme/bb.git" }; }, viewers, setLinearMine: (teams: Array<{ unit: string; name: string }>) => { linearMine = teams; }, setMissingStates: (source: "linear" | "github", unit: string, states: string[]) => { missingStates.set(`${source}:${unit.toLowerCase()}`, states); }, setMissingLabel: (source: "linear" | "github", unit: string, labels: string[]) => { missingLabels.set(`${source}:${unit.toLowerCase()}`, labels); }, rpcCalls, githubTrees, githubBranchProtection, githubLabels, setSnapshotError: (id: string, fail: boolean) => { if (fail) snapshotErrors.add(id); else snapshotErrors.delete(id); }, setSearchUnsupported: (value: boolean) => { searchUnsupported = value; }, setSearchError: (value: string | null) => { searchError = value; }, setCreatePullError: (value: string | null) => { createPullError = value; }, setMergePullError: (value: string | null) => { mergePullError = value; }, setMoveHeadOnMerge: (value: boolean) => { moveHeadOnMerge = value; }, setPullDetailsError: (value: string | null) => { pullDetailsError = value; }, setRetryConflict: (value: boolean) => { retryConflict = value; }, setRetryQueued: (value: boolean) => { retryQueued = value; }, threads, deletes, sections, usage, metadata, spawns, sends, retries, retryEvents, queuedDeletes, userTurns, githubComments, githubReviews, githubMerges, githubPulls, githubClosedPulls, githubDefaultBranches, githubBranches, githubFiles, githubFileContents, githubDispatches, setMergeDispatchError: (value: string | null) => { mergeDispatchError = value; }, github, sdk, uiPreferences, uiPreferencesWrites, now: () => now, advance: (ms: number) => (now += ms) }; } async function setup(extra: Partial = {}, settings: Record = {}, seedProvider?: string | null | "fail-list" | "fail-set", seedNavigation?: string | null | "fail-list" | "fail-set") { @@ -9815,13 +9817,14 @@ describe("dependency stewards", () => { ...over, }); - async function watching(alerts: unknown[] = [alert()], project: Record = {}, entities: Array<[string, EntitySnapshot]> = []) { + async function watching(alerts: unknown[] = [alert()], project: Record = {}, entities: Array<[string, EntitySnapshot]> = [], graphEmpty = false) { const t = await setup(); // The fixture's primary (acme/bb) is a stewarded repo too; these tests watch one repo. t.files[PROJECT_FILE] = JSON.stringify({ version: 1, linear: { teams: ["ENG"] }, github: { exclude: ["acme/bb"] }, ...project }); t.files[playbookPath("deps-alerts")] = DEPS_ALERTS; t.files[playbookPath("deps-prs")] = DEPS_PRS; t.dependabotAlerts.set("acme/web", alerts); + if (graphEmpty) t.dependabotGraphEmpty.add("acme/web"); for (const [id, entity] of entities) t.entities.set(id, entity); await t.hooks.sweep(); await t.flush(); @@ -9949,6 +9952,20 @@ 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("says the dependency graph is empty, naming the toggle, instead of 'no open alerts' when the graph parsed nothing (BB-289)", async () => { + const { wakes } = await watching([], {}, [], true); + const first = wakes()[0]!; + expect(first).toMatch(/\*\*Dependency graph empty\.\*\*/u); + expect(first).toMatch(/Dependabot cannot see any manifest/u); + expect(first).toMatch(/Settings → Advanced Security → Dependency graph/u); + expect(first).not.toMatch(/\*\*No open alerts\.\*\*/u); + + // A repo whose graph is readable still says "no open alerts" when there are none. + const { wakes: readable } = await watching([]); + expect(readable()[0]).toMatch(/\*\*No open alerts\.\*\*/u); + expect(readable()[0]).not.toMatch(/Dependency graph empty/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}` }); @@ -10305,6 +10322,13 @@ describe("dependency stewards", () => { expect(check.stdout).toMatch(/deps-prs: deps-prs@/u); expect(check.stdout).toMatch(/deps: watching 1 repo for alerts: acme\/web/u); }); + + it("check says a repo's dependency graph is empty, naming the toggle (BB-289)", async () => { + const { t } = await watching([], {}, [], true); + const check = await t.harness.behavior.runCli(["check"], { projectId: "p1" }); + expect(check.stdout).toMatch(/deps: acme\/web: dependency graph empty/u); + expect(check.stdout).toMatch(/Settings → Advanced Security → Dependency graph/u); + }); }); // BB-235: a project with no Linear team files ops and Dependabot tickets as diff --git a/plugins/stewards/server.ts b/plugins/stewards/server.ts index 879eb0ab..47c4fde4 100644 --- a/plugins/stewards/server.ts +++ b/plugins/stewards/server.ts @@ -4381,6 +4381,9 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P try { const out = await sourceRpc("github", "sourceDependabotAlerts", { units: [target.repo], state: "open" }, sourceContract.sourceDependabotAlerts.output); const alerts: DependabotAlert[] = out.alerts; + // The source names a repo whose dependency graph parsed no manifests, + // so an empty alert list is not "nothing open" (BB-289). + const graphEmpty = out.graphEmpty.some((r) => r.toLowerCase() === target.repo.toLowerCase()); const rows = store.sight( b.projectId, b.id, @@ -4446,11 +4449,11 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P batch: batchAlert(a, dueAt, open), }); } - const look: DepsLook = { 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, graphEmpty }; reconcileDepsDecisions(b, config, look, allRows, tickets, byFingerprint, clockAt); return look; } catch (error) { - return { at, target: opsTargetKey(target), error: errorMessage(error), viewer: null, alerts: [] }; + return { at, target: opsTargetKey(target), error: errorMessage(error), viewer: null, alerts: [], graphEmpty: false }; } } @@ -11276,6 +11279,10 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P const out = await sourceRpc("github", "sourceDependabotAlerts", { units: depsRepos.map((t) => t.repo), state: "open" }, sourceContract.sourceDependabotAlerts.output); if (out.viewer !== null) lines.push(`deps: reading alerts as ${out.viewer}`); for (const e of out.errors) lines.push(`deps: skipping ${e}: the gh login cannot read its alerts (a skip, not a problem)`); + // A repo whose dependency graph parsed nothing has an empty alert + // feed for a reason a person turns on by hand, not because there is + // nothing open (BB-289). + for (const repo of out.graphEmpty) lines.push(`deps: ${repo}: dependency graph empty — Dependabot cannot see any manifest, so "no open alerts" means nothing; turn it on in the repository's Settings → Advanced Security → Dependency graph`); } catch (error) { lines.push(`problem: deps: could not read alerts: ${errorMessage(error)}`); } diff --git a/plugins/stewards/source-contract.ts b/plugins/stewards/source-contract.ts index 2d152013..5f63bfc2 100644 --- a/plugins/stewards/source-contract.ts +++ b/plugins/stewards/source-contract.ts @@ -472,6 +472,13 @@ export const sourceContract = { * github plugin reads them with the developer's token from the REST API; * each repository the token cannot read (no alert access, alerts disabled, * not visible) is named in `errors` rather than failing the whole call. + * + * `graphEmpty` names the repositories whose dependency graph parsed no + * manifests (`dependencyGraphManifests.totalCount` is 0), so an empty alert + * list means Dependabot cannot see anything rather than that there is + * nothing to see (BB-289). Additive and optional: a source that does not + * implement it leaves it empty, and a repository whose graph read failed is + * not named (a failed read is not an empty graph). */ sourceDependabotAlerts: { input: z.object({ @@ -483,6 +490,7 @@ export const sourceContract = { viewer: z.string().nullable(), alerts: z.array(dependabotAlertSchema), errors: z.array(z.string()), + graphEmpty: z.array(z.string()).default([]), }), }, /** diff --git a/plugins/stewards/wake.ts b/plugins/stewards/wake.ts index 346f9fc1..5820317b 100644 --- a/plugins/stewards/wake.ts +++ b/plugins/stewards/wake.ts @@ -1033,6 +1033,10 @@ export function formatDepsWake(input: DepsWakeInput): string { const head = out.splice(0); out.push("", "## 3. The alerts", ""); if (look.error !== null) out.push(`The repository could not be read: ${look.error}. Nothing to do until it can; \`bb stewards check\` says why.`); + else if (look.alerts.length === 0 && look.graphEmpty === true) + out.push( + "**Dependency graph empty.** Dependabot cannot see any manifest for this repository, so an empty alert list is not evidence that there is nothing to fix. Turn the dependency graph on by hand in the repository's Settings → Advanced Security → Dependency graph; the next look will then read real alerts. Nothing to file, comment on or close: report that with steward_report (actor other) and end the turn.", + ); else if (look.alerts.length === 0) out.push("**No open alerts.** Nothing to file, comment on or close: report that with steward_report (actor other) and end the turn."); for (const c of present) { out.push("", `### ${DEPS_CLASS_TITLES[c]}`, "");