diff --git a/src/integrations/project-tracker-adapter.ts b/src/integrations/project-tracker-adapter.ts index 9530bc6794..10f8704221 100644 --- a/src/integrations/project-tracker-adapter.ts +++ b/src/integrations/project-tracker-adapter.ts @@ -23,9 +23,9 @@ export type ProjectTrackerAttachResult = { }; /** - * Pluggable project/milestone tracker backend (#3183). `GitHubMilestonesAdapter` below implements the - * milestone half now; Projects v2 (#3184) and a Linear backend (#3186) implement the same interface without - * reshaping the matching/suggestion logic that calls it. + * Pluggable project/milestone tracker backend (#3183). `GitHubMilestonesAdapter` implements the milestone half; + * `GitHubProjectsAdapter` (#3184) implements the Projects v2 half; a Linear backend (#3186) implements the same + * interface without reshaping the matching/suggestion logic that calls it. */ export interface ProjectTrackerAdapter { listOpenProjects(ctx: ProjectTrackerContext): Promise; @@ -49,9 +49,9 @@ type GitHubMilestone = { title: string; }; -// Bounded pagination for both the milestone list and the comment-marker search below (mirrors -// src/github/comments.ts's COMMENT_SEARCH_PAGE_LIMIT): 3 pages * 100 = 300 items is generously above any -// realistic open-milestone or PR-comment count, while still bounding worst-case GitHub API calls per PR event. +// Bounded pagination for milestones, PR comments (marker search), and Projects v2 (GraphQL cursor pages) below +// (mirrors src/github/comments.ts's COMMENT_SEARCH_PAGE_LIMIT): 3 pages * 100 = 300 items is generously above +// any realistic open-milestone/open-project/PR-comment count, while still bounding worst-case API calls. const GITHUB_LIST_PAGE_LIMIT = 3; /** A positive-integer milestone/issue number as a string, or null if `value` isn't one. Guards against a @@ -62,10 +62,9 @@ function parsePositiveIntegerId(value: string): number | null { } /** GitHub REST implementation of {@link ProjectTrackerAdapter}. Only the Milestone half is real (#3183) -- - * Projects v2 is GraphQL-only and needs a separate `organization_projects` App permission not yet granted, so - * those two methods are inert placeholders until #3184. */ + * Projects v2 lives in the separate {@link GitHubProjectsAdapter} (#3184), so those two methods are inert here. */ export class GitHubMilestonesAdapter implements ProjectTrackerAdapter { - // Inert placeholder until #3184 (Projects v2 needs GraphQL + a separate App permission). + // Inert here -- see GitHubProjectsAdapter. async listOpenProjects(): Promise { return []; } @@ -90,7 +89,7 @@ export class GitHubMilestonesAdapter implements ProjectTrackerAdapter { return milestones.map((milestone) => ({ id: String(milestone.number), title: milestone.title })); } - // Inert placeholder until #3184 (Projects v2 needs GraphQL + a separate App permission). + // Inert here -- see GitHubProjectsAdapter. async attachToProject(): Promise { return { attached: false }; } @@ -111,13 +110,150 @@ export class GitHubMilestonesAdapter implements ProjectTrackerAdapter { } } +// --------------------------------------------------------------------------------------------------------- +// GitHub Projects v2 (#3184) +// --------------------------------------------------------------------------------------------------------- +// +// CONFIRMED PLATFORM LIMITATION (researched during #3184, not merely "unconfirmed community report"): +// GitHub Apps cannot read/write Projects v2 owned by a USER account at all, by design -- only +// ORGANIZATION-owned Projects v2 are reachable via an App's installation token. There is no App permission +// that substitutes for this; the only way to automate a user-owned board is a personal-access-token or +// user-to-server OAuth token acting AS that user, which is a materially different feature (secret storage, +// consent flow) out of scope here. Sources: docs.github.com/en/rest/authentication/permissions-required-for-github-apps +// (the "User permissions" category has no Projects entry at all), and community reports of GitHub Support +// confirming this (github.com/orgs/community/discussions/46681, /64849, /148529). +// +// listOpenProjects below therefore queries `repositoryOwner(login){ __typename ... on Organization { ... } }` +// in ONE GraphQL call: if the repo's owner is a User, `__typename` is "User" and the inline fragment simply +// contributes nothing (no projects, no error) -- this repo's owner not being an Organization degrades to the +// same "no open projects" result as an Organization with zero open projects, never a thrown error. A separate, +// SEPARATE known gap (community-reported, not independently re-verified here since it requires a real private +// Projects v2 board to test against): a GitHub App's Bot actor may be unable to read items in a PRIVATE +// Projects v2 board even when the Organization-level permission is granted (github.com/orgs/community/discussions/148529). +// Both gaps degrade the SAME way -- an empty projects list, never a crash or a wrong match -- so no special +// handling is needed to stay safe; only visibility (this comment + the PR notes) documents the gap. + +type ProjectV2Node = { id: string; title: string; closed: boolean }; + +type ListOpenProjectsGraphQlResponse = { + repositoryOwner: { + __typename: string; + projectsV2?: { nodes: ProjectV2Node[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } }; + } | null; +}; + +type ProjectV2FieldOption = { id: string; name: string }; +type ProjectV2Field = { id: string; name: string; options?: ProjectV2FieldOption[] }; + +type ProjectFieldsGraphQlResponse = { + node: { + fields?: { nodes: ProjectV2Field[] }; + } | null; +}; + +type AddProjectV2ItemGraphQlResponse = { + addProjectV2ItemById: { item: { id: string } | null } | null; +}; + +type PullRequestNodeIdResponse = { + node_id: string; +}; + +/** + * A single Projects v2 field's resolved options (#3184 deliverable: field/option-ID resolution, exposed for + * #3185's auto-apply status-setting, not yet called by attachToProject -- adding a project item and setting a + * custom field are two independent GraphQL mutations, and this PR only needs the first). + */ +export async function resolveProjectV2Fields(ctx: ProjectTrackerContext, projectId: string): Promise { + const token = await createInstallationToken(ctx.env, ctx.installationId); + const octokit = makeInstallationOctokit(ctx.env, token, "live", githubRateLimitAdmissionKeyForInstallation(ctx.installationId)); + const response = await octokit.graphql( + `query($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + fields(first: 50) { + nodes { + ... on ProjectV2FieldCommon { id name } + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + }`, + { projectId }, + ); + return response.node?.fields?.nodes ?? []; +} + +/** GraphQL implementation of {@link ProjectTrackerAdapter} for GitHub Projects v2 (#3184). Only the Project + * half is real -- Milestones live in the separate {@link GitHubMilestonesAdapter} (#3183), so those two + * methods are inert here. See the module-level comment above for the user-vs-organization-owner limitation. */ +export class GitHubProjectsAdapter implements ProjectTrackerAdapter { + async listOpenProjects(ctx: ProjectTrackerContext): Promise { + const { owner } = parseRepoFullName(ctx.repoFullName); + const token = await createInstallationToken(ctx.env, ctx.installationId); + const octokit = makeInstallationOctokit(ctx.env, token, "live", githubRateLimitAdmissionKeyForInstallation(ctx.installationId)); + const projects: ProjectV2Node[] = []; + let after: string | null = null; + for (let page = 1; page <= GITHUB_LIST_PAGE_LIMIT; page += 1) { + const response: ListOpenProjectsGraphQlResponse = await octokit.graphql( + `query($login: String!, $after: String) { + repositoryOwner(login: $login) { + __typename + ... on Organization { + projectsV2(first: 100, after: $after, orderBy: {field: TITLE, direction: ASC}) { + nodes { id title closed } + pageInfo { hasNextPage endCursor } + } + } + } + }`, + { login: owner, after }, + ); + const projectsV2 = response.repositoryOwner?.projectsV2; + if (!projectsV2) break; // owner is a User (or has zero projects) -- see the module-level comment above. + projects.push(...projectsV2.nodes.filter((project) => !project.closed)); + if (!projectsV2.pageInfo.hasNextPage) break; + after = projectsV2.pageInfo.endCursor; + } + return projects.map((project) => ({ id: project.id, title: project.title })); + } + + // Inert here -- see GitHubMilestonesAdapter. + async listOpenMilestones(): Promise { + return []; + } + + async attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string): Promise { + const { owner, repo } = parseRepoFullName(ctx.repoFullName); + const token = await createInstallationToken(ctx.env, ctx.installationId); + const octokit = makeInstallationOctokit(ctx.env, token, "live", githubRateLimitAdmissionKeyForInstallation(ctx.installationId)); + const pr = await octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", { owner, repo, pull_number: pullNumber }); + const contentId = (pr.data as PullRequestNodeIdResponse).node_id; + const response = await octokit.graphql( + `mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { + item { id } + } + }`, + { projectId, contentId }, + ); + return { attached: response.addProjectV2ItemById?.item != null }; + } + + // Inert here -- see GitHubMilestonesAdapter. + async attachToMilestone(): Promise { + return { attached: false }; + } +} + // Stricter than the duplicate-PR collision gate's 0.58/2 (src/signals/engine.ts) -- misattaching a PR to the -// wrong milestone corrupts tracked progress, whereas a missed duplicate just skips an advisory note. -const MILESTONE_MATCH_MIN_SCORE = 0.65; -const MILESTONE_MATCH_MIN_SHARED = 3; +// wrong tracker item corrupts tracked progress, whereas a missed duplicate just skips an advisory note. +const TRACKER_MATCH_MIN_SCORE = 0.65; +const TRACKER_MATCH_MIN_SHARED = 3; export type ProjectTrackerMatch = { - milestone: ProjectTrackerRef; + item: ProjectTrackerRef; score: number; shared: number; }; @@ -128,25 +264,25 @@ function termsFor(value: string): CollisionTerms { } /** - * Match PR title+body text against a list of open milestones (#3183), reusing the same tokenize/termOverlap - * heuristic as duplicate-PR collision detection. Returns null on no match -- AND on an ambiguous multi-match - * (more than one milestone clears the threshold): guessing between two plausible milestones is worse than - * suggesting neither, since a maintainer can always link one manually. + * Match PR title+body text against a list of open tracker items (milestones OR projects -- #3183/#3184), + * reusing the same tokenize/termOverlap heuristic as duplicate-PR collision detection. Returns null on no + * match -- AND on an ambiguous multi-match (more than one item clears the threshold): guessing between two + * plausible items is worse than suggesting neither, since a maintainer can always link one manually. */ -export function matchOpenMilestones(prTitle: string, prBody: string | null | undefined, milestones: ProjectTrackerRef[]): ProjectTrackerMatch | null { - if (milestones.length === 0) return null; +export function matchOpenTrackerItems(prTitle: string, prBody: string | null | undefined, items: ProjectTrackerRef[]): ProjectTrackerMatch | null { + if (items.length === 0) return null; const prTerms = termsFor([prTitle, prBody ?? ""].join(" ")); - const candidates = milestones - .map((milestone) => ({ milestone, ...termOverlap(prTerms, termsFor(milestone.title)) })) - .filter((candidate) => candidate.score >= MILESTONE_MATCH_MIN_SCORE && candidate.shared >= MILESTONE_MATCH_MIN_SHARED); + const candidates = items + .map((item) => ({ item, ...termOverlap(prTerms, termsFor(item.title)) })) + .filter((candidate) => candidate.score >= TRACKER_MATCH_MIN_SCORE && candidate.shared >= TRACKER_MATCH_MIN_SHARED); if (candidates.length !== 1) return null; const best = candidates[0]; /* v8 ignore next -- defensive: candidates.length === 1 above guarantees index 0 exists. */ if (!best) return null; - return { milestone: best.milestone, score: best.score, shared: best.shared }; + return { item: best.item, score: best.score, shared: best.shared }; } -export const MILESTONE_SUGGEST_COMMENT_MARKER = ""; +export const PROJECT_TRACKER_SUGGEST_COMMENT_MARKER = ""; /** Code-formats a maintainer-authored title for safe Markdown embedding: backticks strip any literal backtick * from the title (so it can't break out of the code span) rather than escaping them, since a broken-out title @@ -155,14 +291,21 @@ function codeFormat(title: string): string { return `\`${title.replace(/`/g, "")}\``; } -function renderSuggestionComment(match: ProjectTrackerMatch): string { - const confidencePercent = Math.round(match.score * 100); - return [ - MILESTONE_SUGGEST_COMMENT_MARKER, - `This PR looks like it's part of the ${codeFormat(match.milestone.title)} milestone (${confidencePercent}% title/body term overlap).`, - "", - "This is an advisory suggestion only — nothing has been attached automatically.", - ].join("\n"); +type ProjectTrackerMatches = { + milestone: ProjectTrackerMatch | null; + project: ProjectTrackerMatch | null; +}; + +function renderSuggestionComment(matches: ProjectTrackerMatches): string { + const lines = [PROJECT_TRACKER_SUGGEST_COMMENT_MARKER]; + if (matches.milestone) { + lines.push(`This PR looks like it's part of the ${codeFormat(matches.milestone.item.title)} milestone (${Math.round(matches.milestone.score * 100)}% title/body term overlap).`); + } + if (matches.project) { + lines.push(`This PR looks like it's part of the ${codeFormat(matches.project.item.title)} project (${Math.round(matches.project.score * 100)}% title/body term overlap).`); + } + lines.push("", "This is an advisory suggestion only — nothing has been attached automatically."); + return lines.join("\n"); } type IssueComment = { @@ -171,15 +314,23 @@ type IssueComment = { }; /** - * Best-effort, idempotent suggest-mode comment (#3183): posts ONCE per PR (never updates or reposts), so a - * repeated sweep/webhook pass never spams the thread. Never calls attachToMilestone -- suggest mode only ever - * comments; #3185 wires the real attach path behind the "auto" config value. + * Best-effort, idempotent suggest-mode comment (#3183/#3184): checks BOTH open Milestones and open Projects v2 + * (independently, via each adapter) and posts ONE comment naming whichever matched, ONCE per PR (never updates + * or reposts), so a repeated sweep/webhook pass never spams the thread. Never calls attachToMilestone/ + * attachToProject -- suggest mode only ever comments; #3185 wires the real attach path behind "auto". */ -export async function maybeSuggestMilestoneMatch(ctx: ProjectTrackerContext, pullNumber: number, prTitle: string, prBody: string | null | undefined): Promise<{ suggested: boolean }> { - const adapter = new GitHubMilestonesAdapter(); - const milestones = await adapter.listOpenMilestones(ctx); - const match = matchOpenMilestones(prTitle, prBody, milestones); - if (!match) return { suggested: false }; +export async function maybeSuggestProjectOrMilestoneMatch(ctx: ProjectTrackerContext, pullNumber: number, prTitle: string, prBody: string | null | undefined): Promise<{ suggested: boolean }> { + const milestonesAdapter = new GitHubMilestonesAdapter(); + const projectsAdapter = new GitHubProjectsAdapter(); + // Fail-open, independently, for each tracker type (mirrors this repo's established best-effort pattern): + // a transient milestone REST error must never suppress a valid Projects v2 match, and vice versa -- either + // lookup degrading to an empty list is a missed suggestion, not a broken one, matching the doc comment above. + const [milestones, projects] = await Promise.all([milestonesAdapter.listOpenMilestones(ctx).catch(() => []), projectsAdapter.listOpenProjects(ctx).catch(() => [])]); + const matches: ProjectTrackerMatches = { + milestone: matchOpenTrackerItems(prTitle, prBody, milestones), + project: matchOpenTrackerItems(prTitle, prBody, projects), + }; + if (!matches.milestone && !matches.project) return { suggested: false }; const { owner, repo } = parseRepoFullName(ctx.repoFullName); const token = await createInstallationToken(ctx.env, ctx.installationId); @@ -195,12 +346,12 @@ export async function maybeSuggestMilestoneMatch(ctx: ProjectTrackerContext, pul page, }); const batch = existing.data as IssueComment[]; - alreadyPosted = batch.some((comment) => comment.user?.type === "Bot" && comment.user.login?.toLowerCase() === botLogin.toLowerCase() && comment.body?.includes(MILESTONE_SUGGEST_COMMENT_MARKER)); + alreadyPosted = batch.some((comment) => comment.user?.type === "Bot" && comment.user.login?.toLowerCase() === botLogin.toLowerCase() && comment.body?.includes(PROJECT_TRACKER_SUGGEST_COMMENT_MARKER)); if (batch.length < 100) break; } if (alreadyPosted) return { suggested: false }; - await createIssueComment(ctx.env, ctx.installationId, ctx.repoFullName, pullNumber, renderSuggestionComment(match)); + await createIssueComment(ctx.env, ctx.installationId, ctx.repoFullName, pullNumber, renderSuggestionComment(matches)); return { suggested: true }; } @@ -225,7 +376,7 @@ export async function maybeSuggestMilestoneMatchForPr(args: { if (!args.installationId) return; if (args.prState !== "open") return; if (!args.mode || args.mode === "off") return; - await maybeSuggestMilestoneMatch({ env: args.env, installationId: args.installationId, repoFullName: args.repoFullName }, args.pullNumber, args.prTitle, args.prBody).catch((error) => { + await maybeSuggestProjectOrMilestoneMatch({ env: args.env, installationId: args.installationId, repoFullName: args.repoFullName }, args.pullNumber, args.prTitle, args.prBody).catch((error) => { console.error( JSON.stringify({ level: "warn", diff --git a/test/unit/project-tracker-adapter.test.ts b/test/unit/project-tracker-adapter.test.ts index 2464ea1b4b..e1a6af799d 100644 --- a/test/unit/project-tracker-adapter.test.ts +++ b/test/unit/project-tracker-adapter.test.ts @@ -1,6 +1,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { generateKeyPairSync } from "node:crypto"; -import { GitHubMilestonesAdapter, MILESTONE_SUGGEST_COMMENT_MARKER, maybeSuggestMilestoneMatch, maybeSuggestMilestoneMatchForPr, matchOpenMilestones, type ProjectTrackerRef } from "../../src/integrations/project-tracker-adapter"; +import { + GitHubMilestonesAdapter, + GitHubProjectsAdapter, + PROJECT_TRACKER_SUGGEST_COMMENT_MARKER, + maybeSuggestMilestoneMatchForPr, + maybeSuggestProjectOrMilestoneMatch, + matchOpenTrackerItems, + resolveProjectV2Fields, + type ProjectTrackerRef, +} from "../../src/integrations/project-tracker-adapter"; import { createTestEnv } from "../helpers/d1"; function generateRsaPrivateKeyPem(): string { @@ -8,35 +17,41 @@ function generateRsaPrivateKeyPem(): string { return privateKey.export({ type: "pkcs1", format: "pem" }).toString(); } -describe("matchOpenMilestones (#3183)", () => { +/** A GraphQL response reporting zero open projects (a User-owned repo, or an Organization with none) -- + * the shape most milestone-focused tests below need so the parallel Projects v2 lookup never interferes. */ +function noOpenProjectsGraphQlBody(): unknown { + return { data: { repositoryOwner: { __typename: "User" } } }; +} + +describe("matchOpenTrackerItems (#3183/#3184)", () => { const milestones: ProjectTrackerRef[] = [{ id: "14", title: "Self-host reliability roadmap" }, { id: "9", title: "Bounty Wave 2" }]; - it("returns null when there are no open milestones", () => { - expect(matchOpenMilestones("Fix self-host reliability roadmap flakiness", null, [])).toBeNull(); + it("returns null when there are no open items", () => { + expect(matchOpenTrackerItems("Fix self-host reliability roadmap flakiness", null, [])).toBeNull(); }); - it("returns null when no milestone clears the match threshold", () => { - expect(matchOpenMilestones("Fix a typo in the readme", "no relation to any tracked work", milestones)).toBeNull(); + it("returns null when no item clears the match threshold", () => { + expect(matchOpenTrackerItems("Fix a typo in the readme", "no relation to any tracked work", milestones)).toBeNull(); }); - it("matches a PR whose title/body clearly overlaps one open milestone's title", () => { - const match = matchOpenMilestones("Improve self-host reliability roadmap convergence", "Follow-up on the self-host reliability roadmap work", milestones); - expect(match?.milestone.id).toBe("14"); + it("matches a PR whose title/body clearly overlaps one open item's title", () => { + const match = matchOpenTrackerItems("Improve self-host reliability roadmap convergence", "Follow-up on the self-host reliability roadmap work", milestones); + expect(match?.item.id).toBe("14"); expect(match?.score).toBeGreaterThanOrEqual(0.65); expect(match?.shared).toBeGreaterThanOrEqual(3); }); - it("returns null on an ambiguous multi-match (more than one milestone clears the threshold) rather than guessing", () => { + it("returns null on an ambiguous multi-match (more than one item clears the threshold) rather than guessing", () => { const tied: ProjectTrackerRef[] = [ { id: "1", title: "self host reliability roadmap convergence work" }, { id: "2", title: "self host reliability roadmap convergence effort" }, ]; - expect(matchOpenMilestones("self host reliability roadmap convergence", null, tied)).toBeNull(); + expect(matchOpenTrackerItems("self host reliability roadmap convergence", null, tied)).toBeNull(); }); it("treats a missing PR body as empty text without throwing", () => { - expect(() => matchOpenMilestones("just a title", undefined, milestones)).not.toThrow(); - expect(() => matchOpenMilestones("just a title", null, milestones)).not.toThrow(); + expect(() => matchOpenTrackerItems("just a title", undefined, milestones)).not.toThrow(); + expect(() => matchOpenTrackerItems("just a title", null, milestones)).not.toThrow(); }); }); @@ -45,7 +60,7 @@ describe("GitHubMilestonesAdapter (#3183)", () => { vi.unstubAllGlobals(); }); - it("listOpenProjects and attachToProject are inert placeholders until #3184", async () => { + it("listOpenProjects and attachToProject are inert placeholders (Projects v2 lives in GitHubProjectsAdapter)", async () => { const adapter = new GitHubMilestonesAdapter(); await expect(adapter.listOpenProjects()).resolves.toEqual([]); await expect(adapter.attachToProject()).resolves.toEqual({ attached: false }); @@ -154,7 +169,203 @@ describe("GitHubMilestonesAdapter (#3183)", () => { }); }); -describe("maybeSuggestMilestoneMatch (#3183)", () => { +describe("GitHubProjectsAdapter (#3184)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("listOpenMilestones and attachToMilestone are inert placeholders (Milestones live in GitHubMilestonesAdapter)", async () => { + const adapter = new GitHubProjectsAdapter(); + await expect(adapter.listOpenMilestones()).resolves.toEqual([]); + await expect(adapter.attachToMilestone()).resolves.toEqual({ attached: false }); + }); + + it("listOpenProjects returns projectsV2 for an organization-owned repo", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/graphql")) { + const body = JSON.parse(String(init?.body ?? "{}")) as { variables?: { login?: string } }; + expect(body.variables?.login).toBe("some-org"); + return Response.json({ + data: { + repositoryOwner: { + __typename: "Organization", + projectsV2: { nodes: [{ id: "PVT_1", title: "Self-host reliability roadmap" }], pageInfo: { hasNextPage: false, endCursor: null } }, + }, + }, + }); + } + return new Response("unexpected", { status: 500 }); + }); + const adapter = new GitHubProjectsAdapter(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + const result = await adapter.listOpenProjects({ env, installationId: 123, repoFullName: "some-org/gittensory" }); + expect(result).toEqual([{ id: "PVT_1", title: "Self-host reliability roadmap" }]); + }); + + it("listOpenProjects excludes closed Projects v2 boards (regression: gate-flagged closed-board leak, #3184)", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/graphql")) { + return Response.json({ + data: { + repositoryOwner: { + __typename: "Organization", + projectsV2: { + nodes: [ + { id: "PVT_open", title: "Open roadmap", closed: false }, + { id: "PVT_closed", title: "Closed roadmap", closed: true }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }); + } + return new Response("unexpected", { status: 500 }); + }); + const adapter = new GitHubProjectsAdapter(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + const result = await adapter.listOpenProjects({ env, installationId: 123, repoFullName: "some-org/gittensory" }); + expect(result).toEqual([{ id: "PVT_open", title: "Open roadmap" }]); + }); + + it("listOpenProjects returns an empty list for a USER-owned repo (confirmed GitHub App platform limitation, #3184) without erroring", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); + return new Response("unexpected", { status: 500 }); + }); + const adapter = new GitHubProjectsAdapter(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + await expect(adapter.listOpenProjects({ env, installationId: 123, repoFullName: "JSONbored/gittensory" })).resolves.toEqual([]); + }); + + it("listOpenProjects follows GraphQL cursor pagination across multiple pages", async () => { + let requestCount = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/graphql")) { + requestCount += 1; + const body = JSON.parse(String(init?.body ?? "{}")) as { variables?: { after?: string | null } }; + if (!body.variables?.after) { + return Response.json({ + data: { repositoryOwner: { __typename: "Organization", projectsV2: { nodes: [{ id: "PVT_1", title: "Page one project" }], pageInfo: { hasNextPage: true, endCursor: "cursor-2" } } } }, + }); + } + return Response.json({ + data: { repositoryOwner: { __typename: "Organization", projectsV2: { nodes: [{ id: "PVT_2", title: "Page two project" }], pageInfo: { hasNextPage: false, endCursor: null } } } }, + }); + } + return new Response("unexpected", { status: 500 }); + }); + const adapter = new GitHubProjectsAdapter(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + const result = await adapter.listOpenProjects({ env, installationId: 123, repoFullName: "some-org/gittensory" }); + expect(requestCount).toBe(2); + expect(result).toEqual([ + { id: "PVT_1", title: "Page one project" }, + { id: "PVT_2", title: "Page two project" }, + ]); + }); + + it("attachToProject resolves the PR's node_id then adds it as a project item via GraphQL", async () => { + let mutationVariables: unknown; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/4") && method === "GET") return Response.json({ number: 4, node_id: "PR_kwABC" }); + if (url.endsWith("/graphql")) { + const body = JSON.parse(String(init?.body ?? "{}")) as { variables?: unknown }; + mutationVariables = body.variables; + return Response.json({ data: { addProjectV2ItemById: { item: { id: "PVTI_xyz" } } } }); + } + return new Response("unexpected", { status: 500 }); + }); + const adapter = new GitHubProjectsAdapter(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + const result = await adapter.attachToProject({ env, installationId: 123, repoFullName: "some-org/gittensory" }, 4, "PVT_1"); + expect(result).toEqual({ attached: true }); + expect(mutationVariables).toEqual({ projectId: "PVT_1", contentId: "PR_kwABC" }); + }); + + it("attachToProject reports not-attached when GitHub returns no item (e.g. a permission/visibility gap)", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/4") && method === "GET") return Response.json({ number: 4, node_id: "PR_kwABC" }); + if (url.endsWith("/graphql")) return Response.json({ data: { addProjectV2ItemById: { item: null } } }); + return new Response("unexpected", { status: 500 }); + }); + const adapter = new GitHubProjectsAdapter(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + const result = await adapter.attachToProject({ env, installationId: 123, repoFullName: "some-org/gittensory" }, 4, "PVT_1"); + expect(result).toEqual({ attached: false }); + }); + + it("rejects an invalid repository full name before making any GitHub call", async () => { + const adapter = new GitHubProjectsAdapter(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + await expect(adapter.listOpenProjects({ env, installationId: 123, repoFullName: "invalid" })).rejects.toThrow(/Invalid repository full name/); + await expect(adapter.attachToProject({ env, installationId: 123, repoFullName: "owner/repo/extra" }, 4, "PVT_1")).rejects.toThrow(/Invalid repository full name/); + }); +}); + +describe("resolveProjectV2Fields (#3184)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("resolves a project's fields and single-select options via GraphQL", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/graphql")) { + const body = JSON.parse(String(init?.body ?? "{}")) as { variables?: { projectId?: string } }; + expect(body.variables?.projectId).toBe("PVT_1"); + return Response.json({ + data: { + node: { + fields: { + nodes: [ + { id: "F_1", name: "Title" }, + { id: "F_2", name: "Status", options: [{ id: "O_1", name: "Todo" }, { id: "O_2", name: "Done" }] }, + ], + }, + }, + }, + }); + } + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + const fields = await resolveProjectV2Fields({ env, installationId: 123, repoFullName: "some-org/gittensory" }, "PVT_1"); + expect(fields).toEqual([ + { id: "F_1", name: "Title" }, + { id: "F_2", name: "Status", options: [{ id: "O_1", name: "Todo" }, { id: "O_2", name: "Done" }] }, + ]); + }); + + it("returns an empty list when the project node has no fields (e.g. not found / inaccessible)", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/graphql")) return Response.json({ data: { node: null } }); + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + const fields = await resolveProjectV2Fields({ env, installationId: 123, repoFullName: "some-org/gittensory" }, "PVT_missing"); + expect(fields).toEqual([]); + }); +}); + +describe("maybeSuggestProjectOrMilestoneMatch (#3183/#3184)", () => { afterEach(() => { vi.unstubAllGlobals(); }); @@ -166,6 +377,7 @@ describe("maybeSuggestMilestoneMatch (#3183)", () => { const method = init?.method ?? "GET"; if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); if (url.includes("/issues/4/comments") && method === "POST") { const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; @@ -175,7 +387,7 @@ describe("maybeSuggestMilestoneMatch (#3183)", () => { return new Response("unexpected", { status: 500 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - const result = await maybeSuggestMilestoneMatch( + const result = await maybeSuggestProjectOrMilestoneMatch( { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, 4, "Improve self-host reliability roadmap convergence", @@ -183,8 +395,139 @@ describe("maybeSuggestMilestoneMatch (#3183)", () => { ); expect(result).toEqual({ suggested: true }); expect(posted).toHaveLength(1); - expect(posted[0]).toContain(MILESTONE_SUGGEST_COMMENT_MARKER); + expect(posted[0]).toContain(PROJECT_TRACKER_SUGGEST_COMMENT_MARKER); expect(posted[0]).toContain("Self-host reliability roadmap"); + expect(posted[0]).toContain("milestone"); + expect(posted[0]).not.toContain("project ("); + }); + + it("posts a suggestion comment when a PROJECT matches (no milestone match) and mentions it as a project", async () => { + const posted: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/milestones")) return Response.json([]); + if (url.endsWith("/graphql")) { + return Response.json({ + data: { repositoryOwner: { __typename: "Organization", projectsV2: { nodes: [{ id: "PVT_1", title: "Self-host reliability roadmap" }], pageInfo: { hasNextPage: false, endCursor: null } } } }, + }); + } + if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4/comments") && method === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; + posted.push(body.body ?? ""); + return Response.json({ id: 1 }); + } + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + const result = await maybeSuggestProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "some-org/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + "Follow-up on the self-host reliability roadmap work", + ); + expect(result).toEqual({ suggested: true }); + expect(posted).toHaveLength(1); + expect(posted[0]).toContain("project ("); + expect(posted[0]).not.toContain("milestone ("); + }); + + it("mentions BOTH when a milestone AND a project independently match", async () => { + const posted: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.endsWith("/graphql")) { + return Response.json({ + data: { repositoryOwner: { __typename: "Organization", projectsV2: { nodes: [{ id: "PVT_1", title: "Self-host reliability roadmap" }], pageInfo: { hasNextPage: false, endCursor: null } } } }, + }); + } + if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4/comments") && method === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; + posted.push(body.body ?? ""); + return Response.json({ id: 1 }); + } + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + const result = await maybeSuggestProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "some-org/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + "Follow-up on the self-host reliability roadmap work", + ); + expect(result).toEqual({ suggested: true }); + expect(posted[0]).toContain("milestone ("); + expect(posted[0]).toContain("project ("); + }); + + it("still suggests the milestone match when the Projects v2 GraphQL lookup fails transiently (fail-open, independent-failure-isolation fix)", async () => { + const posted: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + // Projects v2 GraphQL call throws a transient error -- must not suppress the valid milestone match below. + if (url.endsWith("/graphql")) return new Response("boom", { status: 500 }); + if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4/comments") && method === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; + posted.push(body.body ?? ""); + return Response.json({ id: 1 }); + } + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + const result = await maybeSuggestProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "some-org/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + "Follow-up on the self-host reliability roadmap work", + ); + expect(result).toEqual({ suggested: true }); + expect(posted).toHaveLength(1); + expect(posted[0]).toContain("milestone ("); + expect(posted[0]).not.toContain("project ("); + }); + + it("still suggests the project match when the milestones REST lookup fails transiently (fail-open, independent-failure-isolation fix)", async () => { + const posted: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + // Milestones REST call throws a transient error -- must not suppress the valid Projects v2 match below. + if (url.includes("/milestones")) return new Response("boom", { status: 500 }); + if (url.endsWith("/graphql")) { + return Response.json({ + data: { repositoryOwner: { __typename: "Organization", projectsV2: { nodes: [{ id: "PVT_1", title: "Self-host reliability roadmap" }], pageInfo: { hasNextPage: false, endCursor: null } } } }, + }); + } + if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/4/comments") && method === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; + posted.push(body.body ?? ""); + return Response.json({ id: 1 }); + } + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + const result = await maybeSuggestProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "some-org/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + "Follow-up on the self-host reliability roadmap work", + ); + expect(result).toEqual({ suggested: true }); + expect(posted).toHaveLength(1); + expect(posted[0]).toContain("project ("); + expect(posted[0]).not.toContain("milestone ("); }); it("code-formats the milestone title and strips literal backticks, neutralizing markdown/mention injection", async () => { @@ -194,6 +537,7 @@ describe("maybeSuggestMilestoneMatch (#3183)", () => { const method = init?.method ?? "GET"; if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); if (url.includes("/milestones")) return Response.json([{ number: 14, title: "self host reliability roadmap `@everyone` **pwned**" }]); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); if (url.includes("/issues/4/comments") && method === "POST") { const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; @@ -203,7 +547,7 @@ describe("maybeSuggestMilestoneMatch (#3183)", () => { return new Response("unexpected", { status: 500 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await maybeSuggestMilestoneMatch( + await maybeSuggestProjectOrMilestoneMatch( { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, 4, "self host reliability roadmap convergence", @@ -225,11 +569,12 @@ describe("maybeSuggestMilestoneMatch (#3183)", () => { const method = init?.method ?? "GET"; if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); if (url.includes("/issues/4/comments") && method === "GET") { const page = Number(new URL(url).searchParams.get("page") ?? "1"); requestedPages.push(page); if (page === 1) return Response.json(pageOneComments); - if (page === 2) return Response.json([{ body: MILESTONE_SUGGEST_COMMENT_MARKER, user: { type: "Bot", login: "gittensory[bot]" } }]); + if (page === 2) return Response.json([{ body: PROJECT_TRACKER_SUGGEST_COMMENT_MARKER, user: { type: "Bot", login: "gittensory[bot]" } }]); return Response.json([]); } if (url.includes("/issues/4/comments") && method === "POST") { @@ -239,7 +584,7 @@ describe("maybeSuggestMilestoneMatch (#3183)", () => { return new Response("unexpected", { status: 500 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - const result = await maybeSuggestMilestoneMatch( + const result = await maybeSuggestProjectOrMilestoneMatch( { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, 4, "Improve self-host reliability roadmap convergence", @@ -250,13 +595,14 @@ describe("maybeSuggestMilestoneMatch (#3183)", () => { expect(posted).toBe(false); }); - it("does nothing when no milestone matches (never calls the comment POST endpoint)", async () => { + it("does nothing when neither a milestone nor a project matches (never calls the comment POST endpoint)", async () => { let posted = false; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const method = init?.method ?? "GET"; if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); if (url.includes("/comments") && method === "POST") { posted = true; return Response.json({ id: 1 }); @@ -264,7 +610,7 @@ describe("maybeSuggestMilestoneMatch (#3183)", () => { return new Response("unexpected", { status: 500 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - const result = await maybeSuggestMilestoneMatch({ env, installationId: 123, repoFullName: "JSONbored/gittensory" }, 4, "unrelated typo fix", null); + const result = await maybeSuggestProjectOrMilestoneMatch({ env, installationId: 123, repoFullName: "JSONbored/gittensory" }, 4, "unrelated typo fix", null); expect(result).toEqual({ suggested: false }); expect(posted).toBe(false); }); @@ -276,8 +622,9 @@ describe("maybeSuggestMilestoneMatch (#3183)", () => { const method = init?.method ?? "GET"; if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); if (url.includes("/issues/4/comments") && method === "GET") { - return Response.json([{ body: MILESTONE_SUGGEST_COMMENT_MARKER, user: { type: "Bot", login: "gittensory[bot]" } }]); + return Response.json([{ body: PROJECT_TRACKER_SUGGEST_COMMENT_MARKER, user: { type: "Bot", login: "gittensory[bot]" } }]); } if (url.includes("/comments") && method === "POST") { posted = true; @@ -286,7 +633,7 @@ describe("maybeSuggestMilestoneMatch (#3183)", () => { return new Response("unexpected", { status: 500 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - const result = await maybeSuggestMilestoneMatch( + const result = await maybeSuggestProjectOrMilestoneMatch( { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, 4, "Improve self-host reliability roadmap convergence", @@ -303,8 +650,9 @@ describe("maybeSuggestMilestoneMatch (#3183)", () => { const method = init?.method ?? "GET"; if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); if (url.includes("/issues/4/comments") && method === "GET") { - return Response.json([{ body: MILESTONE_SUGGEST_COMMENT_MARKER, user: { type: "User", login: "alice" } }]); + return Response.json([{ body: PROJECT_TRACKER_SUGGEST_COMMENT_MARKER, user: { type: "User", login: "alice" } }]); } if (url.includes("/issues/4/comments") && method === "POST") { posted = true; @@ -313,7 +661,7 @@ describe("maybeSuggestMilestoneMatch (#3183)", () => { return new Response("unexpected", { status: 500 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - const result = await maybeSuggestMilestoneMatch( + const result = await maybeSuggestProjectOrMilestoneMatch( { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, 4, "Improve self-host reliability roadmap convergence", @@ -397,6 +745,7 @@ describe("maybeSuggestMilestoneMatchForPr (#3183 webhook-level gating)", () => { milestonesFetched = true; return Response.json([]); } + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); if (url.includes("/comments") && method === "GET") return Response.json([]); return new Response("unexpected", { status: 500 }); }); @@ -413,6 +762,7 @@ describe("maybeSuggestMilestoneMatchForPr (#3183 webhook-level gating)", () => { milestonesFetched = true; return Response.json([]); } + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); return new Response("unexpected", { status: 500 }); }); await maybeSuggestMilestoneMatchForPr(baseArgs({ mode: "auto" })); @@ -420,10 +770,15 @@ describe("maybeSuggestMilestoneMatchForPr (#3183 webhook-level gating)", () => { }); it("logs a failure instead of throwing", async () => { + // The milestone/project lookups themselves are fail-open (#3183/#3184 fail-open fix) and can no longer + // reach this outer catch -- so this test drives a real match (milestone lookup succeeds) and fails the + // still-unprotected comment-marker search instead, to prove the outer best-effort catch is still live. vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/milestones")) return new Response("boom", { status: 500 }); + if (url.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); + if (url.includes("/issues/4/comments")) return new Response("boom", { status: 500 }); return new Response("unexpected", { status: 500 }); }); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);