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
30 changes: 30 additions & 0 deletions plugins/github-watcher/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion plugins/github-watcher/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null, fail: false, rejectSubIssues: false, valid: (token: string) => token.startsWith("gho_"), openPulls: [] as Array<Record<string, unknown>>, openIssues: [] as Array<Record<string, unknown>>, searchIssues: [] as Array<Record<string, unknown>>, labels: { "acme/widgets": ["bb-managed"] } as Record<string, string[]>, labelCreates: [] as string[], labelCreateStatus: 201, labelCreateMessage: "Validation Failed", dependabotAlerts: [] as unknown[], dependabotPage2: [] as unknown[], dependabotStatus: 200, dependabotMessage: "Forbidden", deployments: [] as unknown[], deploymentStatuses: {} as Record<number, unknown[]>, files: {} as Record<string, string>, commits: {} as Record<string, string>, workflowRuns: [] as unknown[], workflowRun: null as unknown, jobs: [] as unknown[], compares: {} as Record<string, string> };
const state = { node: pullNode() as Record<string, unknown> | null, fail: false, rejectSubIssues: false, valid: (token: string) => token.startsWith("gho_"), openPulls: [] as Array<Record<string, unknown>>, openIssues: [] as Array<Record<string, unknown>>, searchIssues: [] as Array<Record<string, unknown>>, labels: { "acme/widgets": ["bb-managed"] } as Record<string, string[]>, 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<number, unknown[]>, files: {} as Record<string, string>, commits: {} as Record<string, string>, workflowRuns: [] as unknown[], workflowRun: null as unknown, jobs: [] as unknown[], compares: {} as Record<string, string> };
const calls: Array<{ token: string; variables: Record<string, unknown> }> = [];
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
const token = ((init?.headers as Record<string, string>).authorization ?? "").replace(/^Bearer /u, "");
Expand Down Expand Up @@ -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 } } });
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 16 additions & 2 deletions plugins/github-watcher/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -43,6 +44,7 @@ import {
labelPath,
parseCompare,
parseDependabotAlerts,
parseDependencyGraphCount,
parseDeploymentStatus,
parseDeployments,
parseFileAtRef,
Expand Down Expand Up @@ -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<typeof parseDependabotAlerts> = [];
const errors: string[] = [];
const graphEmpty: string[] = [];
for (const unit of units) {
const repo = parseRepo(unit);
if (repo === null) {
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions plugins/github-watcher/source-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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([]),
}),
},
/**
Expand Down
8 changes: 8 additions & 0 deletions plugins/linear/source-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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([]),
}),
},
/**
Expand Down
13 changes: 12 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 { 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> = {}): DependabotAlert => ({
Expand Down Expand Up @@ -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();
});
});
7 changes: 7 additions & 0 deletions plugins/stewards/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof depsLookSchema>;

Expand Down
Loading
Loading