diff --git a/src/api/routes.ts b/src/api/routes.ts index cf5904d7c4..a64e265efe 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -109,6 +109,7 @@ import { getGlobalAgentFrozenState, setGlobalAgentFrozen, } from "../db/repositories"; +import { probeLinearWorkspaceAccess } from "../integrations/linear-adapter"; import { dedupeSignalSnapshots, pruneExpiredRecords, RETENTION_POLICY } from "../db/retention"; import { backfillOpenPullRequestDetails, @@ -2653,6 +2654,22 @@ export function createApp() { return c.json({ configured: false }); }); + // Maintainer connectivity probe for a configured Linear workspace (#3186). Uses the stored per-repo API key + // to list open projects/milestones without returning any key material or tracker titles. + app.get("/v1/repos/:owner/:repo/linear-workspace-probe", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const gate = await requireRepoWriteAccess(c, fullName); + if (gate instanceof Response) return gate; + const repo = await getRepository(c.env, fullName); + return c.json( + await probeLinearWorkspaceAccess({ + env: c.env, + installationId: repo?.installationId ?? 0, + repoFullName: fullName, + }), + ); + }); + app.post("/v1/repos/:owner/:repo/settings-preview", async (c) => { const identity = await authenticateRequestIdentity(c); const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; @@ -5413,7 +5430,7 @@ function isRepoAiConfigPath(path: string): boolean { // module's own broad path-allowlist BEFORE ever reaching the route's own requireRepoWriteAccess check -- // same shape as isRepoAiConfigPath above, just for the new Linear key route. function isRepoLinearConfigPath(path: string): boolean { - return /^\/v1\/repos\/[^/]+\/[^/]+\/linear-key$/.test(path); + return /^\/v1\/repos\/[^/]+\/[^/]+\/linear-(?:key|workspace-probe)$/.test(path); } async function authenticateRequestIdentity(c: ProtectedRouteContext): Promise { diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index c360062fb9..47ae8e4336 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -119,8 +119,9 @@ export function routeClassForPath(path: string): RateLimitClass { path === "/v1/opportunities/find" || path === "/v1/issue-rag/retrieve" || // Maintainer BYOK config: POST /ai-key and /linear-key both run PBKDF2 (100k iters) + an encrypted D1 + // upsert; GET /linear-workspace-probe also calls the external Linear API. // upsert per request. - /\/(?:ai-(?:key|review)|linear-key)$/.test(path) || + /\/(?:ai-(?:key|review)|linear-(?:key|workspace-probe))$/.test(path) || /^\/v1\/installations\/[^/]+\/repair\/refresh$/.test(path) || path.includes("/upstream/") || path.includes("/internal/jobs/generate-signal-snapshots") || diff --git a/src/integrations/linear-adapter.ts b/src/integrations/linear-adapter.ts index ba8eda5ff8..a0f0cb486e 100644 --- a/src/integrations/linear-adapter.ts +++ b/src/integrations/linear-adapter.ts @@ -31,18 +31,20 @@ 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. + * `attachToProject`/`attachToMilestone` are 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. */ export class LinearAdapter implements ProjectTrackerAdapter { async listOpenProjects(ctx: ProjectTrackerContext): Promise { @@ -68,9 +70,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. @@ -109,6 +129,29 @@ export type LinearNativeLinkResult = { * `{project: null, milestone: null}` on a missing key, a transport error, or no matching attachment/link -- * never throws, so a Linear outage degrades to the fuzzy-matching fallback rather than blocking the feature. */ +export type LinearWorkspaceProbe = { + reachable: boolean; + openProjectCount: number; + openMilestoneCount: number; +}; + +/** + * Best-effort connectivity probe for a repo's configured Linear workspace (#3186). Used by maintainer + * diagnostics to confirm a stored API key can list open projects/milestones before enabling the linear backend. + * Never throws -- a misconfigured key or Linear outage returns `{ reachable: false, ... }`. + */ +export async function probeLinearWorkspaceAccess(ctx: ProjectTrackerContext): Promise { + const apiKey = await getDecryptedRepositoryLinearKey(ctx.env, ctx.repoFullName); + if (!apiKey) return { reachable: false, openProjectCount: 0, openMilestoneCount: 0 }; + const adapter = new LinearAdapter(); + try { + const [projects, milestones] = await Promise.all([adapter.listOpenProjects(ctx), adapter.listOpenMilestones(ctx)]); + return { reachable: true, openProjectCount: projects.length, openMilestoneCount: milestones.length }; + } catch { + return { reachable: false, openProjectCount: 0, openMilestoneCount: 0 }; + } +} + export async function findLinearNativeLink(ctx: ProjectTrackerContext, prUrl: string): Promise { const none: LinearNativeLinkResult = { project: null, milestone: null }; const apiKey = await getDecryptedRepositoryLinearKey(ctx.env, ctx.repoFullName); diff --git a/src/integrations/project-tracker-adapter.ts b/src/integrations/project-tracker-adapter.ts index 5bf9fb6f86..cfec7b3af4 100644 --- a/src/integrations/project-tracker-adapter.ts +++ b/src/integrations/project-tracker-adapter.ts @@ -250,6 +250,41 @@ export class GitHubProjectsAdapter implements ProjectTrackerAdapter { } } +/** + * Bundles {@link GitHubMilestonesAdapter} + {@link GitHubProjectsAdapter} behind the single + * {@link ProjectTrackerAdapter} interface so backend-selection call sites can treat GitHub as one backend (#3186). + */ +export class GitHubCompositeProjectTrackerAdapter implements ProjectTrackerAdapter { + private readonly milestones = new GitHubMilestonesAdapter(); + private readonly projects = new GitHubProjectsAdapter(); + + listOpenProjects(ctx: ProjectTrackerContext): Promise { + return this.projects.listOpenProjects(ctx); + } + + listOpenMilestones(ctx: ProjectTrackerContext): Promise { + return this.milestones.listOpenMilestones(ctx); + } + + attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string): Promise { + return this.projects.attachToProject(ctx, pullNumber, projectId); + } + + attachToMilestone(ctx: ProjectTrackerContext, pullNumber: number, milestoneId: string): Promise { + return this.milestones.attachToMilestone(ctx, pullNumber, milestoneId); + } +} + +/** + * Factory for the configured project/milestone tracker backend (#3186). The orchestration layer + * ({@link resolveProjectTrackerMatches}) still prefers {@link findLinearNativeLink} before fuzzy matching when the + * backend is `"linear"`, but every list/attach surface routes through this selector. + */ +export function createProjectTrackerAdapter(backend: ProjectMilestoneMatchBackendInput): ProjectTrackerAdapter { + if (backend === "linear") return new LinearAdapter(); + return new GitHubCompositeProjectTrackerAdapter(); +} + // Stricter than the duplicate-PR collision gate's 0.58/2 (src/signals/engine.ts) -- misattaching a PR to the // wrong tracker item corrupts tracked progress, whereas a missed duplicate just skips an advisory note. const TRACKER_MATCH_MIN_SCORE = 0.65; @@ -342,20 +377,24 @@ type ProjectMilestoneMatchBackendInput = "github" | "linear" | null | undefined; * projects when no native link is found for either project or milestone. 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 { +export async function resolveProjectTrackerMatches( + ctx: ProjectTrackerContext, + backend: ProjectMilestoneMatchBackendInput, + prTitle: string, + prBody: string | null | undefined, + prUrl: string, +): Promise { if (backend === "linear") { 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) }; } - 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 adapter = createProjectTrackerAdapter(backend); + // Fail-open, independently, for each tracker type: a transient projects lookup must never suppress a valid + // project-milestone match, and vice versa -- either lookup degrading to an empty list is a missed suggestion. + const [projects, milestones] = await Promise.all([ + adapter.listOpenProjects(ctx).catch(() => []), + adapter.listOpenMilestones(ctx).catch(() => []), + ]); return { milestone: matchOpenTrackerItems(prTitle, prBody, milestones), project: matchOpenTrackerItems(prTitle, prBody, projects), @@ -377,7 +416,7 @@ export async function maybeSuggestProjectOrMilestoneMatch( backend: ProjectMilestoneMatchBackendInput, prUrl: string, ): Promise<{ suggested: boolean }> { - const matches = await resolveTrackerMatches(ctx, backend, prTitle, prBody, prUrl); + const matches = await resolveProjectTrackerMatches(ctx, backend, prTitle, prBody, prUrl); if (!matches.milestone && !matches.project) return { suggested: false }; const { owner, repo } = parseRepoFullName(ctx.repoFullName); diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index daebb66309..30443f5aa4 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -122,6 +122,7 @@ describe("private-beta auth and rate limiting", () => { expect(routeClassForPath("/v1/repos/acme/widgets/ai-key")).toBe("expensive"); expect(routeClassForPath("/v1/repos/acme/widgets/ai-review")).toBe("expensive"); expect(routeClassForPath("/v1/repos/acme/widgets/linear-key")).toBe("expensive"); + expect(routeClassForPath("/v1/repos/acme/widgets/linear-workspace-probe")).toBe("expensive"); expect(routeClassForPath("/v1/repos")).toBe("normal"); }); diff --git a/test/unit/linear-adapter.test.ts b/test/unit/linear-adapter.test.ts index 410e7be32c..73137a3cec 100644 --- a/test/unit/linear-adapter.test.ts +++ b/test/unit/linear-adapter.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { generateKeyPairSync } from "node:crypto"; -import { findLinearNativeLink, LinearAdapter } from "../../src/integrations/linear-adapter"; +import { findLinearNativeLink, LinearAdapter, probeLinearWorkspaceAccess } from "../../src/integrations/linear-adapter"; import { maybeSuggestProjectOrMilestoneMatch } from "../../src/integrations/project-tracker-adapter"; import { upsertRepositoryLinearKey } from "../../src/db/repositories"; import { createTestEnv } from "../helpers/d1"; @@ -98,14 +98,143 @@ 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("listOpenProjects stops paginating at the configured page limit even if Linear reports more", 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: { + projects: { + nodes: [{ id: `proj-${requestCount}`, name: `Page ${requestCount}` }], + pageInfo: { hasNextPage: true, endCursor: `cursor-${requestCount}` }, + }, + }, + }); + }); + const adapter = new LinearAdapter(); + const result = await adapter.listOpenProjects({ env, installationId: 123, repoFullName: "acme/widgets" }); + expect(requestCount).toBe(3); + expect(result).toHaveLength(3); + }); + + 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 workspace project-milestones from Linear's projectMilestones query", async () => { + 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) => { + expect(input.toString()).toBe("https://api.linear.app/graphql"); + const body = JSON.parse(String(init?.body ?? "{}")) as { query: string }; + expect(body.query).toContain("projectMilestones"); + 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(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: "Milestone one" }], pageInfo: { hasNextPage: true, endCursor: "cursor-2" } } }, + }); + } + return Response.json({ + data: { projectMilestones: { nodes: [{ id: "mile-2", name: "Milestone 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: "Milestone one" }, + { id: "mile-2", title: "Milestone two" }, + ]); + }); + + 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 remain 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 }); }); }); +describe("probeLinearWorkspaceAccess (#3186)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns unreachable when no Linear key is configured", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await expect(probeLinearWorkspaceAccess({ env, installationId: 123, repoFullName: "acme/widgets" })).resolves.toEqual({ + reachable: false, + openProjectCount: 0, + openMilestoneCount: 0, + }); + }); + + it("returns reachable with counts when both list calls succeed", async () => { + 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) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { query: string }; + if (body.query.includes("projectMilestones")) { + return Response.json({ data: { projectMilestones: { nodes: [{ id: "mile-1", name: "M3" }], pageInfo: { hasNextPage: false, endCursor: null } } } }); + } + return Response.json({ data: { projects: { nodes: [{ id: "proj-1", name: "Roadmap" }, { id: "proj-2", name: "Infra" }], pageInfo: { hasNextPage: false, endCursor: null } } } }); + }); + await expect(probeLinearWorkspaceAccess({ env, installationId: 123, repoFullName: "acme/widgets" })).resolves.toEqual({ + reachable: true, + openProjectCount: 2, + openMilestoneCount: 1, + }); + }); + + it("returns unreachable (never throws) when Linear returns an HTTP error", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await upsertRepositoryLinearKey(env, { repoFullName: "acme/widgets", key: "lin_api_test_key" }); + vi.stubGlobal("fetch", async () => new Response("Service Unavailable", { status: 503 })); + await expect(probeLinearWorkspaceAccess({ env, installationId: 123, repoFullName: "acme/widgets" })).resolves.toEqual({ + reachable: false, + openProjectCount: 0, + openMilestoneCount: 0, + }); + }); +}); + describe("findLinearNativeLink (#3186)", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -156,6 +285,32 @@ describe("findLinearNativeLink (#3186)", () => { expect(result).toEqual({ project: null, milestone: null }); }); + it("returns only a native project match when the linked issue has no projectMilestone", 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({ + data: { attachmentsForURL: { nodes: [{ issue: { project: { id: "proj-1", name: "Roadmap" }, projectMilestone: null } }] } }, + }), + ); + const result = await findLinearNativeLink({ env, installationId: 123, repoFullName: "acme/widgets" }, PR_URL); + expect(result.project?.source).toBe("native"); + expect(result.milestone).toBeNull(); + }); + + it("returns only a native milestone match when the linked issue has no project", 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({ + data: { attachmentsForURL: { nodes: [{ issue: { project: null, projectMilestone: { id: "mile-1", name: "M3" } } }] } }, + }), + ); + const result = await findLinearNativeLink({ env, installationId: 123, repoFullName: "acme/widgets" }, PR_URL); + expect(result.project).toBeNull(); + expect(result.milestone?.source).toBe("native"); + }); + it("degrades to nulls (never throws) on a Linear API error -- best-effort", async () => { const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); await upsertRepositoryLinearKey(env, { repoFullName: "acme/widgets", key: "lin_api_test_key" }); @@ -246,26 +401,61 @@ 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 a project-milestone when no native link is present", 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: "stealth launch m3 readiness" }], 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, + "Ship stealth launch m3 readiness checks", + "Follow-up on the stealth launch m3 readiness rollout", + "linear", + PR_URL, + ); + expect(result).toEqual({ suggested: true }); + expect(posted[0]).toContain("matching milestone"); + expect(posted[0]).not.toContain("stealth launch m3 readiness"); + }); + + it("API-error best-effort path: a Linear 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. + // findLinearNativeLink degrades gracefully (its own .catch), and the fallback list calls now also + // fail-open independently -- a Linear outage is a missed suggestion, not a thrown webhook error. return new Response("Service Unavailable", { status: 503 }); }); - await expect( - maybeSuggestProjectOrMilestoneMatch( - { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, - 4, - "Improve self-host reliability roadmap convergence", - null, - "linear", - PR_URL, - ), - ).rejects.toThrow(/Linear API HTTP 503/); + const result = await maybeSuggestProjectOrMilestoneMatch( + { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, + 4, + "Improve self-host reliability roadmap convergence", + null, + "linear", + PR_URL, + ); + expect(result).toEqual({ suggested: false }); }); }); diff --git a/test/unit/linear-key.test.ts b/test/unit/linear-key.test.ts index f91bb21fd9..c43ba00fdd 100644 --- a/test/unit/linear-key.test.ts +++ b/test/unit/linear-key.test.ts @@ -278,3 +278,45 @@ describe("maintainer Linear key route (session/API-token scoped, #3186)", () => expect(await del.json()).toMatchObject({ error: "insufficient_repo_permission" }); }); }); + +describe("maintainer Linear workspace probe route (#3186)", () => { + const REPO = "acme/widgets"; + const PROBE_PATH = `/v1/repos/${REPO}/linear-workspace-probe`; + + afterEach(() => vi.unstubAllGlobals()); + + function apiHeaders(env: Env): Record { + return { authorization: `Bearer ${env.GITTENSORY_API_TOKEN}`, "content-type": "application/json" }; + } + + it("returns unreachable when no Linear key is configured", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const res = await app.request(PROBE_PATH, { headers: apiHeaders(env) }, env); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ reachable: false, openProjectCount: 0, openMilestoneCount: 0 }); + }); + + it("returns reachable counts when the stored key can list Linear projects and milestones", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await upsertRepositoryLinearKey(env, { repoFullName: REPO, key: "lin_api_route-key-7777" }); + vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { query: string }; + if (body.query.includes("projectMilestones")) { + return Response.json({ data: { projectMilestones: { nodes: [{ id: "mile-1", name: "M3" }], pageInfo: { hasNextPage: false, endCursor: null } } } }); + } + return Response.json({ data: { projects: { nodes: [{ id: "proj-1", name: "Roadmap" }], pageInfo: { hasNextPage: false, endCursor: null } } } }); + }); + const res = await app.request(PROBE_PATH, { headers: apiHeaders(env) }, env); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ reachable: true, openProjectCount: 1, openMilestoneCount: 1 }); + }); + + it("rejects unauthenticated access", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const res = await app.request(PROBE_PATH, {}, env); + expect(res.status).toBe(401); + }); +}); diff --git a/test/unit/project-tracker-adapter.test.ts b/test/unit/project-tracker-adapter.test.ts index dda2006352..496208b7ec 100644 --- a/test/unit/project-tracker-adapter.test.ts +++ b/test/unit/project-tracker-adapter.test.ts @@ -3,13 +3,18 @@ import { generateKeyPairSync } from "node:crypto"; import { GitHubMilestonesAdapter, GitHubProjectsAdapter, + GitHubCompositeProjectTrackerAdapter, + createProjectTrackerAdapter, PROJECT_TRACKER_SUGGEST_COMMENT_MARKER, maybeSuggestMilestoneMatchForPr, maybeSuggestProjectOrMilestoneMatch, matchOpenTrackerItems, resolveProjectV2Fields, + resolveProjectTrackerMatches, type ProjectTrackerRef, } from "../../src/integrations/project-tracker-adapter"; +import { LinearAdapter } from "../../src/integrations/linear-adapter"; +import { upsertRepositoryLinearKey } from "../../src/db/repositories"; import { createTestEnv } from "../helpers/d1"; function generateRsaPrivateKeyPem(): string { @@ -23,6 +28,90 @@ function noOpenProjectsGraphQlBody(): unknown { return { data: { repositoryOwner: { __typename: "User" } } }; } +const SECRET = "example-unit-test-encryption-secret-32-bytes-long"; +const PR_URL = "https://github.com/JSONbored/gittensory/pull/4"; + +function suggestTestEnv() { + return createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET, GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); +} + +describe("createProjectTrackerAdapter (#3186)", () => { + it("returns a LinearAdapter for the linear backend", () => { + const adapter = createProjectTrackerAdapter("linear"); + expect(adapter).toBeInstanceOf(LinearAdapter); + }); + + it("returns a GitHub composite adapter for the github backend and for null/undefined", () => { + for (const backend of ["github", null, undefined] as const) { + const adapter = createProjectTrackerAdapter(backend); + expect(adapter).toBeInstanceOf(GitHubCompositeProjectTrackerAdapter); + } + }); +}); + +describe("GitHubCompositeProjectTrackerAdapter (#3186)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("delegates listOpenMilestones to GitHubMilestonesAdapter", 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.includes("/milestones")) return Response.json([{ number: 14, title: "Self-host reliability roadmap" }]); + return new Response("unexpected", { status: 500 }); + }); + const adapter = new GitHubCompositeProjectTrackerAdapter(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + const result = await adapter.listOpenMilestones({ env, installationId: 123, repoFullName: "JSONbored/gittensory" }); + expect(result).toEqual([{ id: "14", title: "Self-host reliability roadmap" }]); + }); + + it("delegates listOpenProjects to GitHubProjectsAdapter", 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")) { + return Response.json({ + data: { + repositoryOwner: { + __typename: "Organization", + projectsV2: { nodes: [{ id: "PVT_1", title: "Self-host reliability roadmap", closed: false, public: true }], pageInfo: { hasNextPage: false, endCursor: null } }, + }, + }, + }); + } + return new Response("unexpected", { status: 500 }); + }); + const adapter = new GitHubCompositeProjectTrackerAdapter(); + 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("delegates attachToProject and attachToMilestone to the underlying GitHub adapters", async () => { + let patchedBody: 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")) return Response.json({ data: { addProjectV2ItemById: { item: { id: "PVTI_xyz" } } } }); + if (url.includes("/issues/4") && method === "PATCH") { + patchedBody = JSON.parse(String(init?.body ?? "{}")); + return Response.json({ number: 4, milestone: { number: 14 } }); + } + return new Response("unexpected", { status: 500 }); + }); + const adapter = new GitHubCompositeProjectTrackerAdapter(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + const ctx = { env, installationId: 123, repoFullName: "some-org/gittensory" }; + await expect(adapter.attachToProject(ctx, 4, "PVT_1")).resolves.toEqual({ attached: true }); + await expect(adapter.attachToMilestone(ctx, 4, "14")).resolves.toEqual({ attached: true }); + expect(patchedBody).toMatchObject({ milestone: 14 }); + }); +}); + describe("matchOpenTrackerItems (#3183/#3184)", () => { const milestones: ProjectTrackerRef[] = [{ id: "14", title: "Self-host reliability roadmap" }, { id: "9", title: "Bounty Wave 2" }]; @@ -393,6 +482,87 @@ describe("resolveProjectV2Fields (#3184)", () => { }); }); +describe("resolveProjectTrackerMatches production wiring (#3186)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("routes github fuzzy lookups through createProjectTrackerAdapter (GitHubCompositeProjectTrackerAdapter)", 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.includes("/milestones")) return Response.json([]); + if (url.endsWith("/graphql")) return Response.json(noOpenProjectsGraphQlBody()); + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + const adapter = createProjectTrackerAdapter("github"); + expect(adapter).toBeInstanceOf(GitHubCompositeProjectTrackerAdapter); + const matches = await resolveProjectTrackerMatches( + { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, + "github", + "unrelated typo fix", + null, + "https://github.com/JSONbored/gittensory/pull/4", + ); + expect(matches).toEqual({ milestone: null, project: null }); + }); + + it("routes linear fuzzy lookups through createProjectTrackerAdapter (LinearAdapter) after native-link miss", async () => { + const env = suggestTestEnv(); + await upsertRepositoryLinearKey(env, { repoFullName: "JSONbored/gittensory", key: "lin_api_test_key" }); + const adapter = createProjectTrackerAdapter("linear"); + expect(adapter).toBeInstanceOf(LinearAdapter); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + 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: [], pageInfo: { hasNextPage: false, endCursor: null } } } }); + } + return new Response("unexpected", { status: 500 }); + }); + const matches = await resolveProjectTrackerMatches( + { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, + "linear", + "unrelated typo fix", + null, + PR_URL, + ); + expect(matches).toEqual({ milestone: null, project: null }); + }); + + it("short-circuits on a linear native link without calling createProjectTrackerAdapter list methods", async () => { + const env = suggestTestEnv(); + await upsertRepositoryLinearKey(env, { repoFullName: "JSONbored/gittensory", key: "lin_api_test_key" }); + let projectsListed = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + 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: [{ issue: { project: { id: "proj-1", name: "Roadmap" }, projectMilestone: null } }] } } }); + } + projectsListed = true; + return Response.json({ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } }); + } + return new Response("unexpected", { status: 500 }); + }); + const matches = await resolveProjectTrackerMatches( + { env, installationId: 123, repoFullName: "JSONbored/gittensory" }, + "linear", + "any title", + null, + PR_URL, + ); + expect(matches.project?.source).toBe("native"); + expect(projectsListed).toBe(false); + }); +}); + describe("maybeSuggestProjectOrMilestoneMatch (#3183/#3184)", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -882,4 +1052,39 @@ describe("maybeSuggestMilestoneMatchForPr (#3183 webhook-level gating)", () => { expect(logged).toMatchObject({ event: "milestone_suggest_failed", deliveryId: "delivery-42", repoFullName: "JSONbored/gittensory", pullNumber: 4 }); consoleError.mockRestore(); }); + + it("runs the linear backend path when configured, preferring native links over fuzzy project matching", async () => { + const env = suggestTestEnv(); + await upsertRepositoryLinearKey(env, { repoFullName: "JSONbored/gittensory", key: "lin_api_test_key" }); + let projectsListed = 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: [{ issue: { project: { id: "proj-1", name: "Roadmap" }, projectMilestone: null } }] } } }); + } + projectsListed = true; + return Response.json({ data: { projects: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } }); + } + if (url.includes("/comments") && method === "GET") return Response.json([]); + if (url.includes("/comments") && method === "POST") return Response.json({ id: 1 }); + return new Response("unexpected", { status: 500 }); + }); + await maybeSuggestMilestoneMatchForPr(baseArgs({ backend: "linear" })); + expect(projectsListed).toBe(false); + }); + + it("does not throw for the linear backend when Linear is unreachable (fail-open miss)", 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" }); + return new Response("Service Unavailable", { status: 503 }); + }); + await expect(maybeSuggestMilestoneMatchForPr(baseArgs({ backend: "linear", deliveryId: "linear-outage" }))).resolves.toBeUndefined(); + }); });