@calltelemetry/openclaw-linear 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +834 -536
  2. package/index.ts +1 -1
  3. package/openclaw.plugin.json +3 -2
  4. package/package.json +1 -1
  5. package/prompts.yaml +46 -6
  6. package/src/__test__/fixtures/linear-responses.ts +75 -0
  7. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  8. package/src/__test__/helpers.ts +133 -0
  9. package/src/agent/agent.test.ts +192 -0
  10. package/src/agent/agent.ts +26 -1
  11. package/src/api/linear-api.test.ts +93 -1
  12. package/src/api/linear-api.ts +37 -1
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/infra/cli.ts +176 -1
  15. package/src/infra/commands.test.ts +276 -0
  16. package/src/infra/doctor.test.ts +19 -0
  17. package/src/infra/doctor.ts +30 -25
  18. package/src/infra/multi-repo.test.ts +163 -0
  19. package/src/infra/multi-repo.ts +29 -0
  20. package/src/infra/notify.test.ts +155 -16
  21. package/src/infra/notify.ts +26 -15
  22. package/src/infra/observability.test.ts +85 -0
  23. package/src/pipeline/artifacts.test.ts +26 -3
  24. package/src/pipeline/dispatch-state.ts +1 -0
  25. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  26. package/src/pipeline/e2e-planning.test.ts +478 -0
  27. package/src/pipeline/intent-classify.test.ts +285 -0
  28. package/src/pipeline/intent-classify.ts +259 -0
  29. package/src/pipeline/pipeline.test.ts +69 -0
  30. package/src/pipeline/pipeline.ts +47 -18
  31. package/src/pipeline/planner.test.ts +159 -40
  32. package/src/pipeline/planner.ts +108 -60
  33. package/src/pipeline/tier-assess.test.ts +89 -0
  34. package/src/pipeline/webhook.ts +424 -251
  35. package/src/tools/claude-tool.ts +6 -0
  36. package/src/tools/cli-shared.test.ts +155 -0
  37. package/src/tools/code-tool.test.ts +210 -0
  38. package/src/tools/code-tool.ts +2 -2
  39. package/src/tools/dispatch-history-tool.test.ts +315 -0
  40. package/src/tools/planner-tools.test.ts +1 -1
  41. package/src/tools/planner-tools.ts +10 -2
@@ -150,6 +150,12 @@ export async function runClaude(
150
150
  const env = { ...process.env };
151
151
  delete env.CLAUDECODE;
152
152
 
153
+ // Pass Anthropic API key if configured (plugin config takes precedence over env)
154
+ const claudeApiKey = pluginConfig?.claudeApiKey as string | undefined;
155
+ if (claudeApiKey) {
156
+ env.ANTHROPIC_API_KEY = claudeApiKey;
157
+ }
158
+
153
159
  const child = spawn(CLAUDE_BIN, args, {
154
160
  stdio: ["ignore", "pipe", "pipe"],
155
161
  cwd: workingDir,
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock the active-session module
4
+ vi.mock("../pipeline/active-session.js", () => ({
5
+ getCurrentSession: vi.fn(() => null),
6
+ getActiveSessionByIdentifier: vi.fn(() => null),
7
+ }));
8
+
9
+ // Mock the linear-api module — LinearAgentApi must be a class (used with `new`)
10
+ vi.mock("../api/linear-api.js", () => {
11
+ const MockClass = vi.fn(function (this: any) {
12
+ return this;
13
+ });
14
+ return {
15
+ resolveLinearToken: vi.fn(() => ({ accessToken: null, source: "none" })),
16
+ LinearAgentApi: MockClass,
17
+ };
18
+ });
19
+
20
+ // Mock the watchdog module (re-exported constants)
21
+ vi.mock("../agent/watchdog.js", () => ({
22
+ DEFAULT_INACTIVITY_SEC: 120,
23
+ DEFAULT_MAX_TOTAL_SEC: 7200,
24
+ DEFAULT_TOOL_TIMEOUT_SEC: 600,
25
+ }));
26
+
27
+ import { getCurrentSession, getActiveSessionByIdentifier } from "../pipeline/active-session.js";
28
+ import { resolveLinearToken, LinearAgentApi } from "../api/linear-api.js";
29
+ import { extractPrompt, resolveSession, buildLinearApi } from "./cli-shared.js";
30
+ import type { CliToolParams } from "./cli-shared.js";
31
+
32
+ const mockedGetCurrentSession = vi.mocked(getCurrentSession);
33
+ const mockedGetActiveByIdentifier = vi.mocked(getActiveSessionByIdentifier);
34
+ const mockedResolveLinearToken = vi.mocked(resolveLinearToken);
35
+ const MockedLinearAgentApi = vi.mocked(LinearAgentApi);
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ });
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // extractPrompt
43
+ // ---------------------------------------------------------------------------
44
+ describe("extractPrompt", () => {
45
+ it("returns prompt field when present", () => {
46
+ const params: CliToolParams = { prompt: "do the thing" };
47
+ expect(extractPrompt(params)).toBe("do the thing");
48
+ });
49
+
50
+ it("falls back to text field", () => {
51
+ const params = { text: "text fallback" } as unknown as CliToolParams;
52
+ expect(extractPrompt(params)).toBe("text fallback");
53
+ });
54
+
55
+ it("falls back to message field", () => {
56
+ const params = { message: "message fallback" } as unknown as CliToolParams;
57
+ expect(extractPrompt(params)).toBe("message fallback");
58
+ });
59
+
60
+ it("falls back to task field", () => {
61
+ const params = { task: "task fallback" } as unknown as CliToolParams;
62
+ expect(extractPrompt(params)).toBe("task fallback");
63
+ });
64
+
65
+ it("returns undefined when no fields present", () => {
66
+ const params = {} as unknown as CliToolParams;
67
+ expect(extractPrompt(params)).toBeUndefined();
68
+ });
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // resolveSession
73
+ // ---------------------------------------------------------------------------
74
+ describe("resolveSession", () => {
75
+ it("uses explicit session params when provided", () => {
76
+ const params: CliToolParams = {
77
+ prompt: "test",
78
+ agentSessionId: "sess-123",
79
+ issueIdentifier: "API-42",
80
+ };
81
+
82
+ const result = resolveSession(params);
83
+ expect(result.agentSessionId).toBe("sess-123");
84
+ expect(result.issueIdentifier).toBe("API-42");
85
+ // Should not consult the registry at all
86
+ expect(mockedGetCurrentSession).not.toHaveBeenCalled();
87
+ expect(mockedGetActiveByIdentifier).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it("falls back to active session registry", () => {
91
+ mockedGetCurrentSession.mockReturnValue({
92
+ agentSessionId: "active-sess-456",
93
+ issueIdentifier: "API-99",
94
+ issueId: "issue-id-99",
95
+ startedAt: Date.now(),
96
+ });
97
+
98
+ const params: CliToolParams = { prompt: "test" };
99
+ const result = resolveSession(params);
100
+
101
+ expect(result.agentSessionId).toBe("active-sess-456");
102
+ expect(result.issueIdentifier).toBe("API-99");
103
+ expect(mockedGetCurrentSession).toHaveBeenCalled();
104
+ });
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // buildLinearApi
109
+ // ---------------------------------------------------------------------------
110
+ describe("buildLinearApi", () => {
111
+ it("returns null when no agentSessionId provided", () => {
112
+ const api = { pluginConfig: {} } as any;
113
+ expect(buildLinearApi(api)).toBeNull();
114
+ expect(buildLinearApi(api, undefined)).toBeNull();
115
+ });
116
+
117
+ it("returns null when no token available", () => {
118
+ mockedResolveLinearToken.mockReturnValue({
119
+ accessToken: null,
120
+ source: "none",
121
+ });
122
+
123
+ const api = { pluginConfig: {} } as any;
124
+ expect(buildLinearApi(api, "sess-123")).toBeNull();
125
+ });
126
+
127
+ it("creates a LinearAgentApi when token is available", () => {
128
+ mockedResolveLinearToken.mockReturnValue({
129
+ accessToken: "lin_tok_abc",
130
+ refreshToken: "refresh_xyz",
131
+ expiresAt: 9999999999,
132
+ source: "profile",
133
+ });
134
+ MockedLinearAgentApi.mockImplementation(function (this: any) {
135
+ this.fake = true;
136
+ return this;
137
+ } as any);
138
+
139
+ const api = {
140
+ pluginConfig: {
141
+ clientId: "cid",
142
+ clientSecret: "csecret",
143
+ },
144
+ } as any;
145
+
146
+ const result = buildLinearApi(api, "sess-123");
147
+ expect(result).not.toBeNull();
148
+ expect(MockedLinearAgentApi).toHaveBeenCalledWith("lin_tok_abc", {
149
+ refreshToken: "refresh_xyz",
150
+ expiresAt: 9999999999,
151
+ clientId: "cid",
152
+ clientSecret: "csecret",
153
+ });
154
+ });
155
+ });
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock node:fs so loadCodingConfig can be tested
4
+ vi.mock("node:fs", () => ({
5
+ readFileSync: vi.fn(),
6
+ }));
7
+
8
+ // Mock heavy runner dependencies — we only test resolution/config logic
9
+ vi.mock("./codex-tool.js", () => ({
10
+ runCodex: vi.fn(),
11
+ }));
12
+ vi.mock("./claude-tool.js", () => ({
13
+ runClaude: vi.fn(),
14
+ }));
15
+ vi.mock("./gemini-tool.js", () => ({
16
+ runGemini: vi.fn(),
17
+ }));
18
+ vi.mock("../pipeline/active-session.js", () => ({
19
+ getCurrentSession: vi.fn(() => null),
20
+ }));
21
+ vi.mock("openclaw/plugin-sdk", () => ({
22
+ jsonResult: vi.fn((v: unknown) => v),
23
+ }));
24
+
25
+ import { readFileSync } from "node:fs";
26
+ import type { CodingToolsConfig } from "./code-tool.js";
27
+ import { loadCodingConfig, resolveCodingBackend } from "./code-tool.js";
28
+
29
+ // buildAliasMap and resolveAlias are not exported, so we test them indirectly
30
+ // through the module's behaviour. However, for direct testing we can re-derive
31
+ // the same logic here with a small helper that mirrors the source (or just
32
+ // test through resolveCodingBackend / createCodeTool).
33
+ //
34
+ // Since buildAliasMap and resolveAlias are private, we import the module source
35
+ // and test their effects through the public API. For unit-level tests we
36
+ // replicate the minimal logic inline.
37
+
38
+ // Inline copies of the private helpers, matching the source exactly.
39
+ // This lets us unit-test alias mapping without exporting internals.
40
+ type CodingBackend = "claude" | "codex" | "gemini";
41
+
42
+ const BACKEND_IDS: CodingBackend[] = ["claude", "codex", "gemini"];
43
+
44
+ function buildAliasMap(config: CodingToolsConfig): Map<string, CodingBackend> {
45
+ const map = new Map<string, CodingBackend>();
46
+ for (const backendId of BACKEND_IDS) {
47
+ map.set(backendId, backendId);
48
+ const aliases = config.backends?.[backendId]?.aliases;
49
+ if (aliases) {
50
+ for (const alias of aliases) {
51
+ map.set(alias.toLowerCase(), backendId);
52
+ }
53
+ }
54
+ }
55
+ return map;
56
+ }
57
+
58
+ function resolveAlias(
59
+ aliasMap: Map<string, CodingBackend>,
60
+ input: string,
61
+ ): CodingBackend | undefined {
62
+ return aliasMap.get(input.toLowerCase());
63
+ }
64
+
65
+ const mockedReadFileSync = vi.mocked(readFileSync);
66
+
67
+ beforeEach(() => {
68
+ vi.clearAllMocks();
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // loadCodingConfig
73
+ // ---------------------------------------------------------------------------
74
+ describe("loadCodingConfig", () => {
75
+ it("loads valid coding-tools.json", () => {
76
+ const validConfig: CodingToolsConfig = {
77
+ codingTool: "gemini",
78
+ agentCodingTools: { kaylee: "codex" },
79
+ backends: {
80
+ gemini: { aliases: ["gem"] },
81
+ },
82
+ };
83
+ mockedReadFileSync.mockReturnValue(JSON.stringify(validConfig));
84
+
85
+ const result = loadCodingConfig();
86
+ expect(result).toEqual(validConfig);
87
+ });
88
+
89
+ it("returns defaults when file not found", () => {
90
+ mockedReadFileSync.mockImplementation(() => {
91
+ throw new Error("ENOENT: no such file or directory");
92
+ });
93
+
94
+ const result = loadCodingConfig();
95
+ expect(result).toEqual({});
96
+ });
97
+
98
+ it("returns defaults for invalid JSON", () => {
99
+ mockedReadFileSync.mockReturnValue("{ not valid json !!!");
100
+
101
+ const result = loadCodingConfig();
102
+ expect(result).toEqual({});
103
+ });
104
+ });
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // buildAliasMap
108
+ // ---------------------------------------------------------------------------
109
+ describe("buildAliasMap", () => {
110
+ it("maps backend IDs as aliases", () => {
111
+ const map = buildAliasMap({});
112
+
113
+ expect(map.get("claude")).toBe("claude");
114
+ expect(map.get("codex")).toBe("codex");
115
+ expect(map.get("gemini")).toBe("gemini");
116
+ });
117
+
118
+ it("includes configured aliases from backends", () => {
119
+ const config: CodingToolsConfig = {
120
+ backends: {
121
+ claude: { aliases: ["CC", "anthropic"] },
122
+ gemini: { aliases: ["Gem", "Google"] },
123
+ },
124
+ };
125
+
126
+ const map = buildAliasMap(config);
127
+
128
+ // Aliases should be lowercased
129
+ expect(map.get("cc")).toBe("claude");
130
+ expect(map.get("anthropic")).toBe("claude");
131
+ expect(map.get("gem")).toBe("gemini");
132
+ expect(map.get("google")).toBe("gemini");
133
+
134
+ // Backend IDs still present
135
+ expect(map.get("claude")).toBe("claude");
136
+ expect(map.get("gemini")).toBe("gemini");
137
+ expect(map.get("codex")).toBe("codex");
138
+ });
139
+ });
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // resolveAlias
143
+ // ---------------------------------------------------------------------------
144
+ describe("resolveAlias", () => {
145
+ const config: CodingToolsConfig = {
146
+ backends: {
147
+ codex: { aliases: ["OpenAI", "ox"] },
148
+ },
149
+ };
150
+ const aliasMap = buildAliasMap(config);
151
+
152
+ it("finds match case-insensitively", () => {
153
+ expect(resolveAlias(aliasMap, "OPENAI")).toBe("codex");
154
+ expect(resolveAlias(aliasMap, "openai")).toBe("codex");
155
+ expect(resolveAlias(aliasMap, "OX")).toBe("codex");
156
+ expect(resolveAlias(aliasMap, "Claude")).toBe("claude");
157
+ expect(resolveAlias(aliasMap, "GEMINI")).toBe("gemini");
158
+ });
159
+
160
+ it("returns undefined for unknown alias", () => {
161
+ expect(resolveAlias(aliasMap, "unknown-backend")).toBeUndefined();
162
+ expect(resolveAlias(aliasMap, "gpt")).toBeUndefined();
163
+ expect(resolveAlias(aliasMap, "")).toBeUndefined();
164
+ });
165
+ });
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // resolveCodingBackend
169
+ // ---------------------------------------------------------------------------
170
+ describe("resolveCodingBackend", () => {
171
+ it("uses explicit backend parameter (per-agent override)", () => {
172
+ const config: CodingToolsConfig = {
173
+ codingTool: "gemini",
174
+ agentCodingTools: { kaylee: "codex" },
175
+ };
176
+
177
+ // Even though global says gemini, kaylee has codex
178
+ expect(resolveCodingBackend(config, "kaylee")).toBe("codex");
179
+ });
180
+
181
+ it("uses per-agent override from agentCodingTools", () => {
182
+ const config: CodingToolsConfig = {
183
+ codingTool: "claude",
184
+ agentCodingTools: {
185
+ inara: "gemini",
186
+ mal: "codex",
187
+ },
188
+ };
189
+
190
+ expect(resolveCodingBackend(config, "inara")).toBe("gemini");
191
+ expect(resolveCodingBackend(config, "mal")).toBe("codex");
192
+ });
193
+
194
+ it("falls back to global codingTool default", () => {
195
+ const config: CodingToolsConfig = {
196
+ codingTool: "gemini",
197
+ agentCodingTools: { kaylee: "codex" },
198
+ };
199
+
200
+ // Agent "mal" has no override, so global "gemini" is used
201
+ expect(resolveCodingBackend(config, "mal")).toBe("gemini");
202
+ // No agent ID at all
203
+ expect(resolveCodingBackend(config)).toBe("gemini");
204
+ });
205
+
206
+ it("falls back to codex when no config provided", () => {
207
+ expect(resolveCodingBackend({})).toBe("codex");
208
+ expect(resolveCodingBackend({}, "anyAgent")).toBe("codex");
209
+ });
210
+ });
@@ -88,7 +88,7 @@ function resolveAlias(aliasMap: Map<string, CodingBackend>, input: string): Codi
88
88
  * Priority:
89
89
  * 1. Per-agent override: config.agentCodingTools[agentId]
90
90
  * 2. Global default: config.codingTool
91
- * 3. Hardcoded fallback: "claude"
91
+ * 3. Hardcoded fallback: "codex"
92
92
  */
93
93
  export function resolveCodingBackend(
94
94
  config: CodingToolsConfig,
@@ -104,7 +104,7 @@ export function resolveCodingBackend(
104
104
  const global = config.codingTool;
105
105
  if (global && global in BACKEND_RUNNERS) return global as CodingBackend;
106
106
 
107
- return "claude";
107
+ return "codex";
108
108
  }
109
109
 
110
110
  /**