From 3b9b74d84e78d48abd7195e2a3afe16ad6eae767 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:39:33 -0700 Subject: [PATCH] fix(linear): stop fuzzy matching workspace milestones --- src/integrations/linear-adapter.ts | 39 ++--- src/integrations/project-tracker-adapter.ts | 14 +- test/unit/linear-adapter.test.ts | 161 ++------------------ 3 files changed, 25 insertions(+), 189 deletions(-) diff --git a/src/integrations/linear-adapter.ts b/src/integrations/linear-adapter.ts index fa7de972ad..51c5555d5f 100644 --- a/src/integrations/linear-adapter.ts +++ b/src/integrations/linear-adapter.ts @@ -31,20 +31,16 @@ 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). 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 + * GraphQL implementation of {@link ProjectTrackerAdapter} for Linear (#3186). Lists open workspace projects 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. Workspace-level Linear + * project-milestones are deliberately not listed for fuzzy matching because their names may be internal to + * unrelated private workspace work. `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 { @@ -71,27 +67,10 @@ export class LinearAdapter implements ProjectTrackerAdapter { return projects.map((project) => ({ id: project.id, title: project.name })); } - 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 })); + async listOpenMilestones(): Promise { + // Linear project-milestones are workspace-scoped, so fuzzy matching them against public PR text creates + // an existence oracle for internal milestone names. Use only confirmed native links for Linear milestones. + return []; } // Inert -- see the class doc comment above. diff --git a/src/integrations/project-tracker-adapter.ts b/src/integrations/project-tracker-adapter.ts index 2108ff916b..24871ca93c 100644 --- a/src/integrations/project-tracker-adapter.ts +++ b/src/integrations/project-tracker-adapter.ts @@ -346,7 +346,8 @@ 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 AND project-milestones when no native link is found for either. The GitHub path (default, #3183/#3184) + * projects when no native link is found. Linear workspace project-milestones are not fuzzy-matched because + * public yes/no suggestions would reveal internal milestone existence. 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 { @@ -354,14 +355,11 @@ async function resolveTrackerMatches(ctx: ProjectTrackerContext, backend: Projec const nativeLink = await findLinearNativeLink(ctx, prUrl); if (nativeLink.project || nativeLink.milestone) return nativeLink; const linearAdapter = new LinearAdapter(); - // 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(() => []), - ]); + // Fail-open for Linear projects (#3186), matching the GitHub best-effort pattern below. Do not fuzzy-match + // Linear workspace milestones: a public redacted suggestion still reveals that a guessed internal milestone exists. + const projects = await linearAdapter.listOpenProjects(ctx).catch(() => []); return { - milestone: matchOpenTrackerItems(prTitle, prBody, milestones), + milestone: null, project: matchOpenTrackerItems(prTitle, prBody, projects), }; } diff --git a/test/unit/linear-adapter.test.ts b/test/unit/linear-adapter.test.ts index a285199cba..a6f040f078 100644 --- a/test/unit/linear-adapter.test.ts +++ b/test/unit/linear-adapter.test.ts @@ -98,101 +98,17 @@ describe("LinearAdapter (#3186)", () => { await expect(adapter.listOpenProjects({ env, installationId: 123, repoFullName: "acme/widgets" })).rejects.toThrow(/Linear API returned no data/); }); - it("listOpenMilestones returns an empty list when no Linear key is configured, without making a network call", async () => { + it("listOpenMilestones stays inert without reading the workspace milestone list", 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/); + await expect(adapter.listOpenMilestones()).resolves.toEqual([]); + expect(called).toBe(false); }); it("attachToProject and attachToMilestone stay inert placeholders", async () => { @@ -351,35 +267,22 @@ describe("maybeSuggestProjectOrMilestoneMatch with backend: linear (#3186)", () expect(posted[0]).not.toContain("Self-host reliability roadmap"); }); - it("fallback-matching path: fuzzy-matches Linear project-milestones when no native link exists", async () => { + it("fallback-matching path: does not fuzzy-match Linear project-milestones when no native link exists (regression: milestone existence oracle)", async () => { const env = suggestTestEnv(); await upsertRepositoryLinearKey(env, { repoFullName: "JSONbored/gittensory", key: "lin_api_test_key" }); - const posted: string[] = []; + let milestonesListed = 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 === "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 }, - }, - }, - }); + milestonesListed = true; + 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( @@ -390,55 +293,11 @@ describe("maybeSuggestProjectOrMilestoneMatch with backend: linear (#3186)", () "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"); + expect(result).toEqual({ suggested: false }); + expect(milestonesListed).toBe(false); }); - it("fail-open: a milestones list outage still allows a project fuzzy match", async () => { + it("fallback-matching path: project fuzzy matching does not query Linear milestones", async () => { const env = suggestTestEnv(); await upsertRepositoryLinearKey(env, { repoFullName: "JSONbored/gittensory", key: "lin_api_test_key" }); const posted: string[] = [];