Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 9 additions & 0 deletions .changeset/fix-tracker-linear-composio-fallback.md
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 58 additions & 21 deletions packages/plugins/tracker-linear/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,35 @@ function recordTransportActivityEvent(event: Parameters<typeof recordActivityEve
}
}

/** Thrown by the Composio transport when @composio/core cannot be loaded. */
class ComposioSdkMissingError extends Error {
constructor(cause: unknown) {
super(
"Composio SDK (@composio/core) is not installed. " +
"Install it with: pnpm add @composio/core",
{ cause },
);
this.name = "ComposioSdkMissingError";
}
}

/** Emit tracker.dep_missing at most once per process. */
function emitDepMissingOnce(): void {
if (depMissingEmitted) return;
depMissingEmitted = true;
recordActivityEvent({
source: "tracker",
kind: "tracker.dep_missing",
level: "error",
summary: "Composio SDK (@composio/core) is not installed",
data: {
plugin: "tracker-linear",
package: "@composio/core",
installHint: "pnpm add @composio/core",
},
});
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// ---------------------------------------------------------------------------
// Transport abstraction
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -229,26 +258,7 @@ function createComposioTransport(apiKey: string, entityId: string): GraphQLTrans
msg.includes("Cannot find package") ||
msg.includes("ERR_MODULE_NOT_FOUND")
) {
// User-actionable, system-wide. Emit once per process.
if (!depMissingEmitted) {
depMissingEmitted = true;
recordActivityEvent({
source: "tracker",
kind: "tracker.dep_missing",
level: "error",
summary: "Composio SDK (@composio/core) is not installed",
data: {
plugin: "tracker-linear",
package: "@composio/core",
installHint: "pnpm add @composio/core",
},
});
}
throw new Error(
"Composio SDK (@composio/core) is not installed. " +
"Install it with: pnpm add @composio/core",
{ cause: err },
);
throw new ComposioSdkMissingError(err);
}
throw err;
}
Expand Down Expand Up @@ -823,11 +833,38 @@ export const manifest = {
version: "0.1.0",
};

/**
* Wrap the Composio transport so a missing @composio/core SDK falls back to the
* direct LINEAR_API_KEY transport instead of hard-failing. A bare COMPOSIO_API_KEY
* (often exported globally for unrelated Composio work) must not break an
* otherwise-valid Linear setup. If no LINEAR_API_KEY is available there is no
* fallback, so the dep_missing event is emitted and the error surfaces.
*/
function withComposioFallback(composio: GraphQLTransport): GraphQLTransport {
let active = composio;
return async <T>(query: string, variables?: Record<string, unknown>): Promise<T> => {
try {
return await active<T>(query, variables);
} catch (err) {
if (err instanceof ComposioSdkMissingError) {
if (process.env["LINEAR_API_KEY"]) {
active = createDirectTransport();
return await active<T>(query, variables);
}
emitDepMissingOnce();
}
throw err;
}
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

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());
}
Expand Down
146 changes: 146 additions & 0 deletions packages/plugins/tracker-linear/test/composio-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* 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<string, unknown>;
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<string, unknown>,
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/,
);
});
});
20 changes: 15 additions & 5 deletions packages/plugins/tracker-linear/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,20 +177,30 @@ function mockRequestTimeout() {
describe("tracker-linear plugin", () => {
let tracker: ReturnType<typeof create>;
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 ----------------------------------------------------------
Expand Down
Loading