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
39 changes: 9 additions & 30 deletions src/integrations/linear-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,16 @@ async function linearGraphQl<T>(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 {
Expand All @@ -71,27 +67,10 @@ export class LinearAdapter implements ProjectTrackerAdapter {
return projects.map((project) => ({ id: project.id, title: project.name }));
}

async listOpenMilestones(ctx: ProjectTrackerContext): Promise<ProjectTrackerRef[]> {
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<ProjectTrackerRef[]> {
// 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.
Expand Down
14 changes: 6 additions & 8 deletions src/integrations/project-tracker-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,22 +346,20 @@ 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<ProjectTrackerMatches> {
if (backend === "linear") {
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),
};
}
Expand Down
161 changes: 10 additions & 151 deletions test/unit/linear-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(
Expand All @@ -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[] = [];
Expand Down