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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ jobs:

- run: npm install --ignore-scripts

- run: npm run typecheck

- name: Create minimal openclaw config for tests
run: |
mkdir -p ~/.openclaw
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
[![CI](https://github.com/calltelemetry/openclaw-linear-plugin/actions/workflows/ci.yml/badge.svg)](https://github.com/calltelemetry/openclaw-linear-plugin/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/calltelemetry/openclaw-linear-plugin/graph/badge.svg)](https://codecov.io/gh/calltelemetry/openclaw-linear-plugin)
[![npm](https://img.shields.io/npm/v/@calltelemetry/openclaw-linear)](https://www.npmjs.com/package/@calltelemetry/openclaw-linear)
[![OpenClaw](https://img.shields.io/badge/OpenClaw-v2026.2+-blue)](https://github.com/calltelemetry/openclaw)
[![OpenClaw](https://img.shields.io/badge/OpenClaw-v2026.5.9--beta.1-blue)](https://github.com/openclaw/openclaw/releases/tag/v2026.5.9-beta.1)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)

Connect Linear to AI agents. Issues get triaged, implemented, and audited — automatically.
Expand Down Expand Up @@ -1659,6 +1659,7 @@ This is separate from the main `doctor` because each live test spawns a real CLI

```bash
cd ~/claw-extensions/linear
npm run typecheck # Validate source against the OpenClaw plugin SDK
npx vitest run # Run all tests
npx vitest run --reporter=verbose # See every test name
npx vitest run src/pipeline/ # Just pipeline tests
Expand Down
5 changes: 5 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
coverage:
status:
patch:
default:
informational: true
2 changes: 1 addition & 1 deletion openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"id": "openclaw-linear",
"name": "Linear Agent",
"description": "Linear integration with OAuth support, agent pipeline, and webhook-driven AI agent lifecycle",
"version": "0.8.2",
"version": "0.9.24",
"configSchema": {
"type": "object",
"additionalProperties": false,
Expand Down
6,440 changes: 1,704 additions & 4,736 deletions package-lock.json

Large diffs are not rendered by default.

15 changes: 11 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@calltelemetry/openclaw-linear",
"version": "0.9.23",
"version": "0.9.24",
"description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
"type": "module",
"license": "MIT",
Expand All @@ -18,6 +18,9 @@
"ai-pipeline",
"issue-triage"
],
"engines": {
"node": ">=22.16"
},
"files": [
"index.ts",
"src/",
Expand All @@ -28,15 +31,17 @@
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/node": "^22.19.18",
"@vitest/coverage-v8": "^4.0.18",
"commander": "^14.0.3",
"openclaw": "^2026.4.12",
"openclaw": "^2026.5.9-beta.1",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
Comment thread
calltelemetry-jason marked this conversation as resolved.
Expand All @@ -51,6 +56,8 @@
},
"overrides": {
"tar": "^7.5.1",
"axios": "^1.13.10"
"axios": "^1.13.10",
"basic-ftp": "^6.0.1",
"postcss": "^8.5.14"
}
}
10 changes: 5 additions & 5 deletions src/infra/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ import { loadCodingConfig } from "../tools/code-tool.js";
import { getWebhookStatus, provisionWebhook } from "./webhook-provision.js";

afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
vi.unstubAllGlobals();
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -229,6 +230,8 @@ describe("checkCodingTools", () => {

describe("checkFilesAndDirs", () => {
it("reports dispatch state counts", async () => {
const path = join(mkdtempSync(join(tmpdir(), "claw-doctor-state-")), "state.json");
writeFileSync(path, "{}");
vi.mocked(readDispatchState).mockResolvedValueOnce({
dispatches: {
active: { "API-1": { status: "working" } as any },
Expand All @@ -238,7 +241,7 @@ describe("checkFilesAndDirs", () => {
processedEvents: [],
});

const checks = await checkFilesAndDirs();
const checks = await checkFilesAndDirs({ dispatchStatePath: path });
const stateCheck = checks.find((c) => c.label.includes("Dispatch state"));
expect(stateCheck?.severity).toBe("pass");
expect(stateCheck?.label).toContain("1 active");
Expand Down Expand Up @@ -1469,8 +1472,6 @@ describe("checkFilesAndDirs — worktree & base repo edge cases", () => {

describe("checkFilesAndDirs — tilde path resolution", () => {
it("resolves ~/... dispatch state path", async () => {
vi.mocked(readDispatchState).mockRejectedValueOnce(new Error("ENOENT"));

// Providing a path with ~/ triggers the tilde resolution branch
const checks = await checkFilesAndDirs({
dispatchStatePath: "~/nonexistent-state-file.json",
Expand Down Expand Up @@ -1746,4 +1747,3 @@ describe("checkFilesAndDirs — multi-repo validation", () => {
expect(repoCheck?.label).toContain("not a git repo");
});
});

21 changes: 18 additions & 3 deletions src/infra/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,9 @@ interface BackendSpec {
unsetEnv?: string[];
}

/**
* Resolve configured coding backends into concrete CLI probes.
*/
function resolveBackendSpecs(pluginConfig?: Record<string, unknown>): BackendSpec[] {
const binDir = join(process.env.HOME ?? homedir(), ".npm-global", "bin");
return [
Expand Down Expand Up @@ -958,6 +961,9 @@ function resolveBackendSpecs(pluginConfig?: Record<string, unknown>): BackendSpe
];
}

/**
* Check that a backend binary exists and can report a version.
*/
function checkBackendBinary(spec: BackendSpec): { installed: boolean; checks: CheckResult[] } {
const checks: CheckResult[] = [];

Expand Down Expand Up @@ -990,6 +996,9 @@ function checkBackendBinary(spec: BackendSpec): { installed: boolean; checks: Ch
return { installed: true, checks };
}

/**
* Verify that the backend has an API key from plugin config or environment.
*/
function checkBackendApiKey(spec: BackendSpec, pluginConfig?: Record<string, unknown>): CheckResult {
// Check plugin config first
if (spec.configKey) {
Expand All @@ -1013,6 +1022,9 @@ function checkBackendApiKey(spec: BackendSpec, pluginConfig?: Record<string, unk
);
}

/**
* Execute a bounded live CLI call for a backend that has an installed binary.
*/
function checkBackendLive(spec: BackendSpec, pluginConfig?: Record<string, unknown>): CheckResult {
const env = { ...process.env } as Record<string, string | undefined>;
for (const key of spec.unsetEnv ?? []) delete env[key];
Expand Down Expand Up @@ -1082,15 +1094,18 @@ export async function checkCodeRunDeep(
const { installed, checks: binChecks } = checkBackendBinary(spec);
checks.push(...binChecks);

if (installed) {
// 2. API key check
checks.push(checkBackendApiKey(spec, pluginConfig));
// API key checks do not depend on the CLI binary being present, and keeping
// them visible makes doctor output stable across fresh machines.
checks.push(checkBackendApiKey(spec, pluginConfig));

if (installed) {
// 3. Live invocation test
const liveResult = checkBackendLive(spec, pluginConfig);
checks.push(liveResult);

if (liveResult.severity === "pass") callableCount++;
} else {
checks.push(warn("Live test: skipped (binary missing)"));
}

sections.push({ name: `Code Run: ${spec.label}`, checks });
Expand Down
49 changes: 47 additions & 2 deletions src/pipeline/taskflow-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function makeApi(taskFlowImpl?: unknown) {
error: (msg: string) => logs.push({ level: "error", msg }),
},
runtime: taskFlowImpl
? { taskFlow: taskFlowImpl }
? { tasks: { managedFlows: taskFlowImpl } }
: {},
} as any,
logs,
Expand Down Expand Up @@ -270,10 +270,26 @@ describe("markFlowTerminal", () => {
});

// ---------------------------------------------------------------------------
// Compatibility with `runtime.tasks.flow` namespace
// Compatibility with OpenClaw task-flow namespaces
// ---------------------------------------------------------------------------

describe("namespace fallback", () => {
it("prefers runtime.tasks.managedFlows over deprecated aliases", () => {
const { taskFlow: managedFlowsApi, flow } = makeFlow();
const deprecatedFlow = { bindSession: vi.fn() };
const api = {
logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
runtime: { tasks: { managedFlows: managedFlowsApi, flow: deprecatedFlow }, taskFlow: deprecatedFlow },
} as any;

const next = createManagedFlowForDispatch(api, makeDispatch());

expect(managedFlowsApi.bindSession).toHaveBeenCalled();
expect(deprecatedFlow.bindSession).not.toHaveBeenCalled();
expect(flow.createManaged).toHaveBeenCalled();
expect(next.taskFlowId).toBe("flow-1");
});

it("uses runtime.tasks.flow when api.runtime.taskFlow is absent", () => {
const { taskFlow, flow } = makeFlow();
const api = {
Expand All @@ -287,4 +303,33 @@ describe("namespace fallback", () => {
expect(flow.createManaged).toHaveBeenCalled();
expect(next.taskFlowId).toBe("flow-1");
});

it("skips invalid managedFlows objects when probing fallback namespaces", () => {
const { taskFlow, flow } = makeFlow();
const invalidManagedFlows = { bindSession: "not-a-function" };
const api = {
logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
runtime: { tasks: { managedFlows: invalidManagedFlows, flow: taskFlow } },
} as any;

const next = createManagedFlowForDispatch(api, makeDispatch());

expect(taskFlow.bindSession).toHaveBeenCalled();
expect(flow.createManaged).toHaveBeenCalled();
expect(next.taskFlowId).toBe("flow-1");
});

it("uses runtime.taskFlow for OpenClaw 2026.4 compatibility", () => {
const { taskFlow, flow } = makeFlow();
const api = {
logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
runtime: { taskFlow },
} as any;

const next = createManagedFlowForDispatch(api, makeDispatch());

expect(taskFlow.bindSession).toHaveBeenCalled();
expect(flow.createManaged).toHaveBeenCalled();
expect(next.taskFlowId).toBe("flow-1");
});
});
38 changes: 26 additions & 12 deletions src/pipeline/taskflow-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,10 @@ const CONTROLLER_PREFIX = "linear-dispatch";
// Runtime probing
// ---------------------------------------------------------------------------
//
// `api.runtime.taskFlow` and `api.runtime.tasks.flow` are both typed in the
// 2026.4 plugin SDK — the top-level alias is marked @deprecated in favor of
// `api.runtime.tasks.flows` (the read-only DTO API) but the *mutating*
// surface (createManaged, runTask, setWaiting, finish, fail) only exists on
// PluginRuntimeTaskFlow. Until openclaw exposes a non-deprecated mutation
// path we use whichever surface actually has the methods at runtime.
// OpenClaw 2026.5 exposes the managed mutation surface at
// `api.runtime.tasks.managedFlows`. Older 2026.4 builds exposed the same
// mutation API as `api.runtime.tasks.flow` and `api.runtime.taskFlow`; both are
// retained here as compatibility fallbacks.

/**
* `TaskRuntime` enumerates the runtimes the openclaw task registry knows
Expand Down Expand Up @@ -111,17 +109,24 @@ interface TaskFlowApi {
bindSession(params: { sessionKey: string }): BoundFlow;
}

/**
* Locate the managed task-flow mutation API across OpenClaw SDK versions.
*/
function resolveTaskFlowApi(api: OpenClawPluginApi): TaskFlowApi | null {
const runtime = api.runtime as Record<string, unknown> | undefined;
if (!runtime) return null;
// Prefer `api.runtime.tasks.flow` (post-2026.4 namespace), fall back to
// top-level `api.runtime.taskFlow` (deprecated alias still typed in SDK).
const tasks = runtime.tasks as { flow?: unknown } | undefined;
const candidate = (tasks?.flow ?? runtime.taskFlow) as TaskFlowApi | undefined;
if (!candidate || typeof candidate.bindSession !== "function") return null;
return candidate;
const tasks = runtime.tasks as { managedFlows?: unknown; flow?: unknown } | undefined;
for (const candidate of [tasks?.managedFlows, tasks?.flow, runtime.taskFlow]) {
if (candidate && typeof (candidate as TaskFlowApi).bindSession === "function") {
return candidate as TaskFlowApi;
}
}
return null;
}

/**
* Bind the task-flow API to the session that owns the Linear dispatch.
*/
function bindFlow(api: OpenClawPluginApi, sessionKey: string): BoundFlow | null {
const taskFlow = resolveTaskFlowApi(api);
if (!taskFlow) return null;
Expand All @@ -133,14 +138,23 @@ function bindFlow(api: OpenClawPluginApi, sessionKey: string): BoundFlow | null
}
}

/**
* Normalize thrown values for debug logging without leaking stack traces.
*/
function formatErr(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}

/**
* Build the stable OpenClaw controller identifier for a Linear issue.
*/
function controllerIdFor(dispatch: ActiveDispatch): string {
return `${CONTROLLER_PREFIX}:${dispatch.issueIdentifier}`;
}

/**
* Build the task-flow goal shown in OpenClaw task views.
*/
function goalFor(dispatch: ActiveDispatch): string {
const title = dispatch.issueTitle ?? "(no title)";
return `Resolve ${dispatch.issueIdentifier}: ${title}`;
Expand Down
15 changes: 15 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["index.ts", "src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/__test__/**"]
}
Loading