Skip to content
Open
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
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.
81 changes: 60 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;
recordTransportActivityEvent({
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,40 @@ 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;
let direct: GraphQLTransport | undefined;
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"]) {
direct ??= createDirectTransport();
active = direct;
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
159 changes: 159 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,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<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/,
);
});

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/,
);
});
});
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