diff --git a/.changeset/fix-tracker-linear-composio-fallback.md b/.changeset/fix-tracker-linear-composio-fallback.md new file mode 100644 index 0000000000..3c73d3f30a --- /dev/null +++ b/.changeset/fix-tracker-linear-composio-fallback.md @@ -0,0 +1,9 @@ +--- +"@aoagents/ao-plugin-tracker-linear": patch +--- + +fix(tracker-linear): fall back to the direct Linear transport when @composio/core is missing + +The Linear tracker selects its transport by sniffing env: if `COMPOSIO_API_KEY` is set it routes through the Composio SDK, otherwise it uses the direct `LINEAR_API_KEY` API. But `@composio/core` is an optional dependency that isn't installed with the plugin, so any user who had `COMPOSIO_API_KEY` exported (commonly set globally for unrelated Composio work) got a hard `"Composio SDK (@composio/core) is not installed"` failure on every tracker call — even when a perfectly valid `LINEAR_API_KEY` was available. + +The Composio transport now throws a typed `ComposioSdkMissingError` when the SDK can't be loaded. When that happens and a `LINEAR_API_KEY` is present, the tracker transparently falls back to the direct transport instead of failing. The `tracker.dep_missing` event is emitted only when there is genuinely no fallback (no `LINEAR_API_KEY`), so a successful fallback no longer raises a false error-level event. diff --git a/packages/plugins/tracker-linear/src/index.ts b/packages/plugins/tracker-linear/src/index.ts index 850431e309..f3f89df50c 100644 --- a/packages/plugins/tracker-linear/src/index.ts +++ b/packages/plugins/tracker-linear/src/index.ts @@ -38,6 +38,35 @@ function recordTransportActivityEvent(event: Parameters(query: string, variables?: Record): Promise => { + try { + return await active(query, variables); + } catch (err) { + if (err instanceof ComposioSdkMissingError) { + if (process.env["LINEAR_API_KEY"]) { + direct ??= createDirectTransport(); + active = direct; + return await active(query, variables); + } + emitDepMissingOnce(); + } + throw err; + } + }; +} + export function create(): Tracker { const composioKey = process.env["COMPOSIO_API_KEY"]; if (composioKey) { const entityId = process.env["COMPOSIO_ENTITY_ID"] ?? "default"; - return createLinearTracker(createComposioTransport(composioKey, entityId)); + return createLinearTracker( + withComposioFallback(createComposioTransport(composioKey, entityId)), + ); } return createLinearTracker(createDirectTransport()); } diff --git a/packages/plugins/tracker-linear/test/composio-fallback.test.ts b/packages/plugins/tracker-linear/test/composio-fallback.test.ts new file mode 100644 index 0000000000..b1fbd4f10d --- /dev/null +++ b/packages/plugins/tracker-linear/test/composio-fallback.test.ts @@ -0,0 +1,159 @@ +/** + * Regression tests for Composio→direct transport fallback. + * + * When COMPOSIO_API_KEY is present but @composio/core cannot be loaded, the + * tracker must fall back to the direct LINEAR_API_KEY transport instead of + * hard-failing — provided a LINEAR_API_KEY is available. A bare COMPOSIO_API_KEY + * (commonly exported globally for unrelated Composio work) must not break an + * otherwise-valid Linear setup. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; + +const { requestMock, recordActivityEventMock } = vi.hoisted(() => ({ + requestMock: vi.fn(), + recordActivityEventMock: vi.fn(), +})); + +vi.mock("node:https", () => ({ + request: requestMock, +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +// @composio/core is intentionally not installed — the real dynamic import +// fails with ERR_MODULE_NOT_FOUND, exercising the fallback path. + +import { create, _resetDepMissingEmittedForTesting } from "../src/index.js"; +import type { ProjectConfig } from "@aoagents/ao-core"; + +const project: ProjectConfig = { + name: "test", + repo: "acme/integrator", + path: "/tmp/repo", + defaultBranch: "main", + sessionPrefix: "test", + tracker: { plugin: "linear", teamId: "team-uuid-1", workspaceSlug: "acme" }, +}; + +const sampleIssueNode = { + id: "uuid-123", + identifier: "INT-123", + title: "Fix login bug", + description: "Users can't log in with SSO", + url: "https://linear.app/acme/issue/INT-123", + priority: 2, + branchName: "feat/INT-123", + state: { name: "In Progress", type: "started" }, + labels: { nodes: [{ name: "bug" }] }, + assignee: { name: "Alice Smith", displayName: "Alice" }, + team: { key: "INT" }, +}; + +/** Queue a successful Linear API response for the direct transport. */ +function mockLinearAPI(responseData: unknown, statusCode = 200) { + const body = JSON.stringify({ data: responseData }); + requestMock.mockImplementationOnce( + ( + _opts: Record, + callback: (res: EventEmitter & { statusCode: number }) => void, + ) => { + const req = Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(() => { + const res = Object.assign(new EventEmitter(), { statusCode }); + callback(res); + process.nextTick(() => { + res.emit("data", Buffer.from(body)); + res.emit("end"); + }); + }), + destroy: vi.fn(), + setTimeout: vi.fn(), + }); + return req; + }, + ); +} + +let savedComposioKey: string | undefined; +let savedComposioEntity: string | undefined; +let savedLinearKey: string | undefined; + +beforeEach(() => { + vi.clearAllMocks(); + requestMock.mockReset(); + recordActivityEventMock.mockReset(); + _resetDepMissingEmittedForTesting(); + savedComposioKey = process.env["COMPOSIO_API_KEY"]; + savedComposioEntity = process.env["COMPOSIO_ENTITY_ID"]; + savedLinearKey = process.env["LINEAR_API_KEY"]; +}); + +afterEach(() => { + if (savedComposioKey === undefined) delete process.env["COMPOSIO_API_KEY"]; + else process.env["COMPOSIO_API_KEY"] = savedComposioKey; + if (savedComposioEntity === undefined) delete process.env["COMPOSIO_ENTITY_ID"]; + else process.env["COMPOSIO_ENTITY_ID"] = savedComposioEntity; + if (savedLinearKey === undefined) delete process.env["LINEAR_API_KEY"]; + else process.env["LINEAR_API_KEY"] = savedLinearKey; +}); + +describe("Composio→direct transport fallback", () => { + it("falls back to the direct transport when @composio/core is missing but LINEAR_API_KEY is set", async () => { + process.env["COMPOSIO_API_KEY"] = "composio-key"; + process.env["LINEAR_API_KEY"] = "lin_api_test_key"; + mockLinearAPI({ issue: sampleIssueNode }); + + const tracker = create(); + const issue = await tracker.getIssue("INT-123", project); + + expect(issue.id).toBe("INT-123"); + expect(issue.title).toBe("Fix login bug"); + expect(requestMock).toHaveBeenCalled(); + }); + + it("does not emit tracker.dep_missing when fallback succeeds", async () => { + process.env["COMPOSIO_API_KEY"] = "composio-key"; + process.env["LINEAR_API_KEY"] = "lin_api_test_key"; + mockLinearAPI({ issue: sampleIssueNode }); + + const tracker = create(); + await tracker.getIssue("INT-123", project); + + const depMissingCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event?.kind === "tracker.dep_missing", + ); + expect(depMissingCalls).toHaveLength(0); + }); + + it("still throws when @composio/core is missing and no LINEAR_API_KEY is available", async () => { + process.env["COMPOSIO_API_KEY"] = "composio-key"; + delete process.env["LINEAR_API_KEY"]; + + const tracker = create(); + await expect(tracker.getIssue("INT-123", project)).rejects.toThrow( + /Composio SDK.*not installed/, + ); + }); + + it("surfaces the SDK-missing error even when activity logging throws", async () => { + process.env["COMPOSIO_API_KEY"] = "composio-key"; + delete process.env["LINEAR_API_KEY"]; + recordActivityEventMock.mockImplementation(() => { + throw new Error("activity sink failed"); + }); + + const tracker = create(); + await expect(tracker.getIssue("INT-123", project)).rejects.toThrow( + /Composio SDK.*not installed/, + ); + }); +}); diff --git a/packages/plugins/tracker-linear/test/index.test.ts b/packages/plugins/tracker-linear/test/index.test.ts index d31f562ac1..b5075112fe 100644 --- a/packages/plugins/tracker-linear/test/index.test.ts +++ b/packages/plugins/tracker-linear/test/index.test.ts @@ -177,20 +177,30 @@ function mockRequestTimeout() { describe("tracker-linear plugin", () => { let tracker: ReturnType; let savedApiKey: string | undefined; + let savedComposioKey: string | undefined; + let savedComposioEntity: string | undefined; beforeEach(() => { vi.clearAllMocks(); savedApiKey = process.env["LINEAR_API_KEY"]; + savedComposioKey = process.env["COMPOSIO_API_KEY"]; + savedComposioEntity = process.env["COMPOSIO_ENTITY_ID"]; + // This suite exercises the direct LINEAR_API_KEY transport. Clear any + // ambient COMPOSIO_API_KEY (commonly exported for unrelated Composio work) + // so transport selection is deterministic regardless of the shell. process.env["LINEAR_API_KEY"] = "lin_api_test_key"; + delete process.env["COMPOSIO_API_KEY"]; + delete process.env["COMPOSIO_ENTITY_ID"]; tracker = create(); }); afterEach(() => { - if (savedApiKey === undefined) { - delete process.env["LINEAR_API_KEY"]; - } else { - process.env["LINEAR_API_KEY"] = savedApiKey; - } + if (savedApiKey === undefined) delete process.env["LINEAR_API_KEY"]; + else process.env["LINEAR_API_KEY"] = savedApiKey; + if (savedComposioKey === undefined) delete process.env["COMPOSIO_API_KEY"]; + else process.env["COMPOSIO_API_KEY"] = savedComposioKey; + if (savedComposioEntity === undefined) delete process.env["COMPOSIO_ENTITY_ID"]; + else process.env["COMPOSIO_ENTITY_ID"] = savedComposioEntity; }); // ---- manifest ----------------------------------------------------------