From 1b413de68baefab0b7120492abeef564f57a9432 Mon Sep 17 00:00:00 2001 From: carlh171112 Date: Fri, 10 Jul 2026 05:54:55 -0700 Subject: [PATCH] feat(linear-adapter): implement listOpenMilestones method for fetching project milestones Added the listOpenMilestones method to the LinearAdapter, enabling retrieval of non-archived project milestones with pagination support. Updated the class documentation to reflect this new functionality and its integration with the existing project tracking system. Enhanced unit tests to cover various scenarios, including API error handling and pagination across multiple pages. --- src/integrations/linear-adapter.ts | 41 +++- src/integrations/project-tracker-adapter.ts | 14 +- test/unit/linear-adapter.test.ts | 248 +++++++++++++++++++- 3 files changed, 282 insertions(+), 21 deletions(-) diff --git a/src/integrations/linear-adapter.ts b/src/integrations/linear-adapter.ts index ba8eda5ff8..fa7de972ad 100644 --- a/src/integrations/linear-adapter.ts +++ b/src/integrations/linear-adapter.ts @@ -31,18 +31,21 @@ async function linearGraphQl(apiKey: string, query: string, variables: Record } type LinearProjectNode = { id: string; name: string }; +type LinearProjectMilestoneNode = { id: string; name: string }; type ListProjectsResponse = { projects: { nodes: LinearProjectNode[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } }; }; +type ListProjectMilestonesResponse = { + projectMilestones: { nodes: LinearProjectMilestoneNode[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } }; +}; /** - * GraphQL implementation of {@link ProjectTrackerAdapter} for Linear (#3186). Only the Project half maps - * naturally -- Linear's milestone-equivalent (`ProjectMilestone`) is scoped WITHIN a project rather than a - * flat, listable workspace collection the way GitHub milestones are, so `listOpenMilestones` stays inert here - * (a milestone-level match still surfaces through {@link findLinearNativeLink}'s `issue.projectMilestone` - * read when Linear's own GitHub integration has already linked the PR). `attachToProject`/`attachToMilestone` - * are also inert: writing to Linear requires resolving or creating a Linear Issue for this PR first, which is - * a materially bigger design question deferred beyond #3186's suggest-only scope. + * GraphQL implementation of {@link ProjectTrackerAdapter} for Linear (#3186). Lists open workspace projects and + * project-milestones for fuzzy fallback matching when Linear's own GitHub integration has not already linked + * the PR via {@link findLinearNativeLink}. A confirmed native link still wins over any fuzzy guess. + * `ProjectMilestone` has no open/completed status filter like projects do — `includeArchived: false` is the + * workspace-level equivalent of "open". `attachToProject`/`attachToMilestone` stay inert: writing to Linear + * requires resolving or creating a Linear Issue for this PR first, deferred beyond #3186's suggest-only scope. */ export class LinearAdapter implements ProjectTrackerAdapter { async listOpenProjects(ctx: ProjectTrackerContext): Promise { @@ -68,9 +71,27 @@ export class LinearAdapter implements ProjectTrackerAdapter { return projects.map((project) => ({ id: project.id, title: project.name })); } - // Inert -- see the class doc comment above. - async listOpenMilestones(): Promise { - return []; + async listOpenMilestones(ctx: ProjectTrackerContext): Promise { + const apiKey = await getDecryptedRepositoryLinearKey(ctx.env, ctx.repoFullName); + if (!apiKey) return []; + const milestones: LinearProjectMilestoneNode[] = []; + let after: string | null = null; + for (let page = 1; page <= LINEAR_LIST_PAGE_LIMIT; page += 1) { + const data: ListProjectMilestonesResponse = await linearGraphQl( + apiKey, + `query($after: String) { + projectMilestones(first: 100, after: $after, includeArchived: false) { + nodes { id name } + pageInfo { hasNextPage endCursor } + } + }`, + { after }, + ); + milestones.push(...data.projectMilestones.nodes); + if (!data.projectMilestones.pageInfo.hasNextPage) break; + after = data.projectMilestones.pageInfo.endCursor; + } + return milestones.map((milestone) => ({ id: milestone.id, title: milestone.name })); } // Inert -- see the class doc comment above. diff --git a/src/integrations/project-tracker-adapter.ts b/src/integrations/project-tracker-adapter.ts index 5bf9fb6f86..1b5a7d61bf 100644 --- a/src/integrations/project-tracker-adapter.ts +++ b/src/integrations/project-tracker-adapter.ts @@ -339,7 +339,7 @@ type ProjectMilestoneMatchBackendInput = "github" | "linear" | null | undefined; * Resolves this PR's milestone/project matches against whichever backend the repo configured (#3186). The * Linear path tries {@link findLinearNativeLink} FIRST (a confirmed link via Linear's own GitHub integration * beats any guess) and only falls back to {@link matchOpenTrackerItems} fuzzy-matching against Linear's open - * projects when no native link is found for either project or milestone. The GitHub path (default, #3183/#3184) + * projects AND project-milestones when no native link is found for either. The GitHub path (default, #3183/#3184) * has no native-link concept -- it always fuzzy-matches both open Milestones and open Projects v2. */ async function resolveTrackerMatches(ctx: ProjectTrackerContext, backend: ProjectMilestoneMatchBackendInput, prTitle: string, prBody: string | null | undefined, prUrl: string): Promise { @@ -347,8 +347,16 @@ async function resolveTrackerMatches(ctx: ProjectTrackerContext, backend: Projec const nativeLink = await findLinearNativeLink(ctx, prUrl); if (nativeLink.project || nativeLink.milestone) return nativeLink; const linearAdapter = new LinearAdapter(); - const projects = await linearAdapter.listOpenProjects(ctx); - return { milestone: null, project: matchOpenTrackerItems(prTitle, prBody, projects) }; + // Fail-open independently for projects and project-milestones (#3186) — same best-effort pattern as the + // GitHub path below. A transient projects GraphQL error must never suppress a valid milestone fuzzy match. + const [projects, milestones] = await Promise.all([ + linearAdapter.listOpenProjects(ctx).catch(() => []), + linearAdapter.listOpenMilestones(ctx).catch(() => []), + ]); + return { + milestone: matchOpenTrackerItems(prTitle, prBody, milestones), + project: matchOpenTrackerItems(prTitle, prBody, projects), + }; } const milestonesAdapter = new GitHubMilestonesAdapter(); const projectsAdapter = new GitHubProjectsAdapter(); diff --git a/test/unit/linear-adapter.test.ts b/test/unit/linear-adapter.test.ts index 410e7be32c..a285199cba 100644 --- a/test/unit/linear-adapter.test.ts +++ b/test/unit/linear-adapter.test.ts @@ -98,9 +98,105 @@ describe("LinearAdapter (#3186)", () => { await expect(adapter.listOpenProjects({ env, installationId: 123, repoFullName: "acme/widgets" })).rejects.toThrow(/Linear API returned no data/); }); - it("listOpenMilestones, attachToProject, and attachToMilestone are inert placeholders", async () => { + it("listOpenMilestones returns an empty list when no Linear key is configured, without making a network call", async () => { + let called = false; + vi.stubGlobal("fetch", async () => { + called = true; + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const adapter = new LinearAdapter(); + await expect(adapter.listOpenMilestones({ env, installationId: 123, repoFullName: "acme/widgets" })).resolves.toEqual([]); + expect(called).toBe(false); + }); + + it("listOpenMilestones maps non-archived project-milestones (includeArchived: false)", async () => { + let requestBody: unknown; + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await upsertRepositoryLinearKey(env, { repoFullName: "acme/widgets", key: "lin_api_test_key" }); + vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body ?? "{}")); + return Response.json({ + data: { + projectMilestones: { + nodes: [{ id: "mile-1", name: "Stealth Launch M3" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + }); + const adapter = new LinearAdapter(); + const result = await adapter.listOpenMilestones({ env, installationId: 123, repoFullName: "acme/widgets" }); + expect(String((requestBody as { query?: string }).query ?? "")).toContain("includeArchived: false"); + expect(result).toEqual([{ id: "mile-1", title: "Stealth Launch M3" }]); + }); + + it("listOpenMilestones follows cursor pagination across multiple pages", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await upsertRepositoryLinearKey(env, { repoFullName: "acme/widgets", key: "lin_api_test_key" }); + let requestCount = 0; + vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => { + requestCount += 1; + const body = JSON.parse(String(init?.body ?? "{}")) as { variables?: { after?: string | null } }; + if (!body.variables?.after) { + return Response.json({ + data: { + projectMilestones: { + nodes: [{ id: "mile-1", name: "Page one" }], + pageInfo: { hasNextPage: true, endCursor: "cursor-2" }, + }, + }, + }); + } + return Response.json({ + data: { + projectMilestones: { + nodes: [{ id: "mile-2", name: "Page two" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + }); + const adapter = new LinearAdapter(); + const result = await adapter.listOpenMilestones({ env, installationId: 123, repoFullName: "acme/widgets" }); + expect(requestCount).toBe(2); + expect(result).toEqual([ + { id: "mile-1", title: "Page one" }, + { id: "mile-2", title: "Page two" }, + ]); + }); + + it("listOpenMilestones stops at LINEAR_LIST_PAGE_LIMIT even when hasNextPage stays true", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await upsertRepositoryLinearKey(env, { repoFullName: "acme/widgets", key: "lin_api_test_key" }); + let requestCount = 0; + vi.stubGlobal("fetch", async () => { + requestCount += 1; + return Response.json({ + data: { + projectMilestones: { + nodes: [{ id: `mile-${requestCount}`, name: `Page ${requestCount}` }], + pageInfo: { hasNextPage: true, endCursor: `cursor-${requestCount + 1}` }, + }, + }, + }); + }); + const adapter = new LinearAdapter(); + const result = await adapter.listOpenMilestones({ env, installationId: 123, repoFullName: "acme/widgets" }); + expect(requestCount).toBe(3); + expect(result).toHaveLength(3); + }); + + it("listOpenMilestones throws on a Linear API error (propagated for the caller's best-effort handling)", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await upsertRepositoryLinearKey(env, { repoFullName: "acme/widgets", key: "lin_api_test_key" }); + vi.stubGlobal("fetch", async () => Response.json({ errors: [{ message: "invalid API key" }] })); + const adapter = new LinearAdapter(); + await expect(adapter.listOpenMilestones({ env, installationId: 123, repoFullName: "acme/widgets" })).rejects.toThrow(/invalid API key/); + }); + + it("attachToProject and attachToMilestone stay inert placeholders", async () => { const adapter = new LinearAdapter(); - await expect(adapter.listOpenMilestones()).resolves.toEqual([]); await expect(adapter.attachToProject()).resolves.toEqual({ attached: false }); await expect(adapter.attachToMilestone()).resolves.toEqual({ attached: false }); }); @@ -169,10 +265,11 @@ describe("maybeSuggestProjectOrMilestoneMatch with backend: linear (#3186)", () vi.unstubAllGlobals(); }); - it("native-link-present path: prefers the confirmed Linear link and never calls listOpenProjects at all", async () => { + it("native-link-present path: prefers the confirmed Linear link and never lists projects or milestones", async () => { const env = suggestTestEnv(); await upsertRepositoryLinearKey(env, { repoFullName: "JSONbored/gittensory", key: "lin_api_test_key" }); let projectsListed = false; + let milestonesListed = false; const posted: string[] = []; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); @@ -183,6 +280,10 @@ describe("maybeSuggestProjectOrMilestoneMatch with backend: linear (#3186)", () if (body.query.includes("attachmentsForURL")) { return Response.json({ data: { attachmentsForURL: { nodes: [{ issue: { project: { id: "proj-1", name: "Self-host reliability roadmap" }, projectMilestone: { id: "mile-1", name: "Stealth Launch M3" } } }] } } }); } + if (body.query.includes("projectMilestones")) { + milestonesListed = true; + return Response.json({ data: { projectMilestones: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } }); + } projectsListed = true; return Response.json({ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } }); } @@ -204,6 +305,7 @@ describe("maybeSuggestProjectOrMilestoneMatch with backend: linear (#3186)", () ); expect(result).toEqual({ suggested: true }); expect(projectsListed).toBe(false); + expect(milestonesListed).toBe(false); expect(posted[0]).toContain("linked to the project"); expect(posted[0]).toContain("linked to the milestone"); expect(posted[0]).not.toContain("Self-host reliability roadmap"); @@ -222,6 +324,9 @@ describe("maybeSuggestProjectOrMilestoneMatch with backend: linear (#3186)", () if (url === "https://api.linear.app/graphql") { const body = JSON.parse(String(init?.body ?? "{}")) as { query: string }; if (body.query.includes("attachmentsForURL")) return Response.json({ data: { attachmentsForURL: { nodes: [] } } }); + if (body.query.includes("projectMilestones")) { + return Response.json({ data: { projectMilestones: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } }); + } return Response.json({ data: { projects: { nodes: [{ id: "proj-1", name: "Self-host reliability roadmap" }], pageInfo: { hasNextPage: false, endCursor: null } } } }); } if (url.includes("/issues/4/comments") && method === "GET") return Response.json([]); @@ -246,15 +351,142 @@ describe("maybeSuggestProjectOrMilestoneMatch with backend: linear (#3186)", () expect(posted[0]).not.toContain("Self-host reliability roadmap"); }); - it("API-error best-effort path: a Linear outage propagates to the caller instead of silently mismatching", async () => { + it("fallback-matching path: fuzzy-matches Linear project-milestones when no native link exists", async () => { + const env = suggestTestEnv(); + await upsertRepositoryLinearKey(env, { repoFullName: "JSONbored/gittensory", key: "lin_api_test_key" }); + 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 === "https://api.linear.app/graphql") { + const body = JSON.parse(String(init?.body ?? "{}")) as { query: string }; + if (body.query.includes("attachmentsForURL")) return Response.json({ data: { attachmentsForURL: { nodes: [] } } }); + if (body.query.includes("projectMilestones")) { + return Response.json({ + data: { + projectMilestones: { + nodes: [{ id: "mile-1", name: "Self-host reliability roadmap" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + } + return Response.json({ data: { projects: { nodes: [], 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 result = await maybeSuggestProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + "Follow-up on the self-host reliability roadmap work", + "linear", + PR_URL, + ); + expect(result).toEqual({ suggested: true }); + expect(posted[0]).toContain("matching milestone"); + expect(posted[0]).not.toContain("Self-host reliability roadmap"); + }); + + it("fail-open: a projects list outage still allows a milestone fuzzy match", async () => { + const env = suggestTestEnv(); + await upsertRepositoryLinearKey(env, { repoFullName: "JSONbored/gittensory", key: "lin_api_test_key" }); + 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 === "https://api.linear.app/graphql") { + const body = JSON.parse(String(init?.body ?? "{}")) as { query: string }; + if (body.query.includes("attachmentsForURL")) return Response.json({ data: { attachmentsForURL: { nodes: [] } } }); + if (body.query.includes("projectMilestones")) { + return Response.json({ + data: { + projectMilestones: { + nodes: [{ id: "mile-1", name: "Self-host reliability roadmap" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + } + return new Response("Service Unavailable", { status: 503 }); + } + 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 result = await maybeSuggestProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + "Follow-up on the self-host reliability roadmap work", + "linear", + PR_URL, + ); + expect(result).toEqual({ suggested: true }); + expect(posted[0]).toContain("matching milestone"); + }); + + it("fail-open: a milestones list outage still allows a project fuzzy match", async () => { + const env = suggestTestEnv(); + await upsertRepositoryLinearKey(env, { repoFullName: "JSONbored/gittensory", key: "lin_api_test_key" }); + 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 === "https://api.linear.app/graphql") { + const body = JSON.parse(String(init?.body ?? "{}")) as { query: string }; + if (body.query.includes("attachmentsForURL")) return Response.json({ data: { attachmentsForURL: { nodes: [] } } }); + if (body.query.includes("projectMilestones")) return new Response("Service Unavailable", { status: 503 }); + return Response.json({ + data: { + projects: { + nodes: [{ id: "proj-1", name: "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 result = await maybeSuggestProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + "Follow-up on the self-host reliability roadmap work", + "linear", + PR_URL, + ); + expect(result).toEqual({ suggested: true }); + expect(posted[0]).toContain("matching project"); + }); + + it("fail-open: a full Linear list outage degrades to no suggestion instead of throwing", async () => { const env = suggestTestEnv(); await upsertRepositoryLinearKey(env, { repoFullName: "JSONbored/gittensory", key: "lin_api_test_key" }); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - // attachmentsForURL degrades gracefully (findLinearNativeLink's own .catch), but the FALLBACK - // listOpenProjects call has no such guard -- it throws, and the caller (maybeSuggestMilestoneMatchForPr) - // is responsible for the outer best-effort catch, exactly like a GitHub API outage would. + // attachmentsForURL degrades via findLinearNativeLink's .catch; listOpenProjects/listOpenMilestones + // now also fail-open (mirrors the GitHub path) so a Linear outage is a missed suggestion, not a throw. return new Response("Service Unavailable", { status: 503 }); }); await expect( @@ -266,6 +498,6 @@ describe("maybeSuggestProjectOrMilestoneMatch with backend: linear (#3186)", () "linear", PR_URL, ), - ).rejects.toThrow(/Linear API HTTP 503/); + ).resolves.toEqual({ suggested: false }); }); });