@calltelemetry/openclaw-linear 0.8.5 → 0.8.6

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.
@@ -0,0 +1,453 @@
1
+ /**
2
+ * linear-issues-tool.test.ts — Tests for the native linear_issues tool.
3
+ *
4
+ * Mocks LinearAgentApi and resolveLinearToken to test each action handler
5
+ * (read, update, comment, list_states, list_labels) in isolation.
6
+ */
7
+ import { describe, expect, it, vi, beforeEach } from "vitest";
8
+ import { makeIssueDetails } from "../__test__/fixtures/linear-responses.js";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Mocks — use vi.hoisted() so they're available in the hoisted vi.mock factory
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const {
15
+ mockGetIssueDetails,
16
+ mockUpdateIssueExtended,
17
+ mockCreateComment,
18
+ mockCreateIssue,
19
+ mockGetTeamStates,
20
+ mockGetTeamLabels,
21
+ mockResolveLinearToken,
22
+ } = vi.hoisted(() => ({
23
+ mockGetIssueDetails: vi.fn(),
24
+ mockUpdateIssueExtended: vi.fn(),
25
+ mockCreateComment: vi.fn(),
26
+ mockCreateIssue: vi.fn(),
27
+ mockGetTeamStates: vi.fn(),
28
+ mockGetTeamLabels: vi.fn(),
29
+ mockResolveLinearToken: vi.fn(() => ({
30
+ accessToken: "test-token",
31
+ source: "env" as const,
32
+ })),
33
+ }));
34
+
35
+ vi.mock("../api/linear-api.js", () => ({
36
+ resolveLinearToken: mockResolveLinearToken,
37
+ LinearAgentApi: class MockLinearAgentApi {
38
+ getIssueDetails = mockGetIssueDetails;
39
+ updateIssueExtended = mockUpdateIssueExtended;
40
+ createComment = mockCreateComment;
41
+ createIssue = mockCreateIssue;
42
+ getTeamStates = mockGetTeamStates;
43
+ getTeamLabels = mockGetTeamLabels;
44
+ },
45
+ }));
46
+
47
+ import { createLinearIssuesTool } from "./linear-issues-tool.js";
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Helpers
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function makeApi() {
54
+ return {
55
+ logger: {
56
+ info: vi.fn(),
57
+ warn: vi.fn(),
58
+ error: vi.fn(),
59
+ debug: vi.fn(),
60
+ },
61
+ pluginConfig: {},
62
+ } as any;
63
+ }
64
+
65
+ async function executeTool(params: Record<string, unknown>) {
66
+ const tool = createLinearIssuesTool(makeApi());
67
+ return (tool as any).execute("call-1", params);
68
+ }
69
+
70
+ function parseResult(result: any): any {
71
+ // jsonResult returns { content: [{ type: "text", text: JSON.stringify(...) }], details: payload }
72
+ if (result?.content && Array.isArray(result.content)) {
73
+ const textBlock = result.content.find((r: any) => r.type === "text");
74
+ if (textBlock) return JSON.parse(textBlock.text);
75
+ }
76
+ // Direct details access
77
+ if (result?.details) return result.details;
78
+ return result;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Tests
83
+ // ---------------------------------------------------------------------------
84
+
85
+ beforeEach(() => {
86
+ vi.clearAllMocks();
87
+ // Reset default token mock
88
+ mockResolveLinearToken.mockReturnValue({
89
+ accessToken: "test-token",
90
+ source: "env" as const,
91
+ });
92
+ });
93
+
94
+ describe("linear_issues tool", () => {
95
+ describe("read action", () => {
96
+ it("returns formatted issue details", async () => {
97
+ const issue = makeIssueDetails({
98
+ comments: {
99
+ nodes: [
100
+ { body: "First comment", user: { name: "Alice" }, createdAt: "2025-01-01T00:00:00Z" },
101
+ ],
102
+ },
103
+ labels: { nodes: [{ id: "label-1", name: "bug" }] },
104
+ });
105
+ mockGetIssueDetails.mockResolvedValueOnce(issue);
106
+
107
+ const result = parseResult(await executeTool({ action: "read", issueId: "ENG-123" }));
108
+
109
+ expect(mockGetIssueDetails).toHaveBeenCalledWith("ENG-123");
110
+ expect(result.identifier).toBe("ENG-123");
111
+ expect(result.title).toBe("Fix webhook routing");
112
+ expect(result.status).toBe("In Progress");
113
+ expect(result.labels).toEqual(["bug"]);
114
+ expect(result.recentComments).toHaveLength(1);
115
+ expect(result.recentComments[0].author).toBe("Alice");
116
+ });
117
+
118
+ it("returns error when issueId missing", async () => {
119
+ const result = parseResult(await executeTool({ action: "read" }));
120
+ expect(result.error).toMatch(/issueId is required/);
121
+ });
122
+ });
123
+
124
+ describe("update action", () => {
125
+ it("resolves status name to stateId", async () => {
126
+ mockGetIssueDetails.mockResolvedValueOnce(makeIssueDetails());
127
+ mockGetTeamStates.mockResolvedValueOnce([
128
+ { id: "state-1", name: "Backlog", type: "backlog" },
129
+ { id: "state-2", name: "In Progress", type: "started" },
130
+ { id: "state-3", name: "Done", type: "completed" },
131
+ ]);
132
+ mockUpdateIssueExtended.mockResolvedValueOnce(true);
133
+
134
+ const result = parseResult(await executeTool({
135
+ action: "update",
136
+ issueId: "ENG-123",
137
+ status: "Done",
138
+ }));
139
+
140
+ expect(mockGetTeamStates).toHaveBeenCalledWith("team-1");
141
+ expect(mockUpdateIssueExtended).toHaveBeenCalledWith("ENG-123", { stateId: "state-3" });
142
+ expect(result.success).toBe(true);
143
+ expect(result.changes).toContain("status → Done");
144
+ });
145
+
146
+ it("resolves status name case-insensitively", async () => {
147
+ mockGetIssueDetails.mockResolvedValueOnce(makeIssueDetails());
148
+ mockGetTeamStates.mockResolvedValueOnce([
149
+ { id: "state-2", name: "In Progress", type: "started" },
150
+ ]);
151
+ mockUpdateIssueExtended.mockResolvedValueOnce(true);
152
+
153
+ const result = parseResult(await executeTool({
154
+ action: "update",
155
+ issueId: "ENG-123",
156
+ status: "in progress",
157
+ }));
158
+
159
+ expect(mockUpdateIssueExtended).toHaveBeenCalledWith("ENG-123", { stateId: "state-2" });
160
+ expect(result.success).toBe(true);
161
+ });
162
+
163
+ it("resolves label names to labelIds", async () => {
164
+ mockGetIssueDetails.mockResolvedValueOnce(makeIssueDetails());
165
+ mockGetTeamLabels.mockResolvedValueOnce([
166
+ { id: "label-1", name: "bug" },
167
+ { id: "label-2", name: "urgent" },
168
+ { id: "label-3", name: "feature" },
169
+ ]);
170
+ mockUpdateIssueExtended.mockResolvedValueOnce(true);
171
+
172
+ const result = parseResult(await executeTool({
173
+ action: "update",
174
+ issueId: "ENG-123",
175
+ labels: ["bug", "urgent"],
176
+ }));
177
+
178
+ expect(mockGetTeamLabels).toHaveBeenCalledWith("team-1");
179
+ expect(mockUpdateIssueExtended).toHaveBeenCalledWith("ENG-123", {
180
+ labelIds: ["label-1", "label-2"],
181
+ });
182
+ expect(result.success).toBe(true);
183
+ });
184
+
185
+ it("returns error for unknown status", async () => {
186
+ mockGetIssueDetails.mockResolvedValueOnce(makeIssueDetails());
187
+ mockGetTeamStates.mockResolvedValueOnce([
188
+ { id: "state-1", name: "Backlog", type: "backlog" },
189
+ { id: "state-2", name: "In Progress", type: "started" },
190
+ ]);
191
+
192
+ const result = parseResult(await executeTool({
193
+ action: "update",
194
+ issueId: "ENG-123",
195
+ status: "Nonexistent",
196
+ }));
197
+
198
+ expect(result.error).toMatch(/Status "Nonexistent" not found/);
199
+ expect(result.error).toMatch(/Available states:/);
200
+ expect(mockUpdateIssueExtended).not.toHaveBeenCalled();
201
+ });
202
+
203
+ it("returns error for unknown labels", async () => {
204
+ mockGetIssueDetails.mockResolvedValueOnce(makeIssueDetails());
205
+ mockGetTeamLabels.mockResolvedValueOnce([
206
+ { id: "label-1", name: "bug" },
207
+ ]);
208
+
209
+ const result = parseResult(await executeTool({
210
+ action: "update",
211
+ issueId: "ENG-123",
212
+ labels: ["bug", "nonexistent"],
213
+ }));
214
+
215
+ expect(result.error).toMatch(/Labels not found: nonexistent/);
216
+ expect(mockUpdateIssueExtended).not.toHaveBeenCalled();
217
+ });
218
+
219
+ it("updates priority and estimate together", async () => {
220
+ mockGetIssueDetails.mockResolvedValueOnce(makeIssueDetails());
221
+ mockUpdateIssueExtended.mockResolvedValueOnce(true);
222
+
223
+ const result = parseResult(await executeTool({
224
+ action: "update",
225
+ issueId: "ENG-123",
226
+ priority: 2,
227
+ estimate: 5,
228
+ }));
229
+
230
+ expect(mockUpdateIssueExtended).toHaveBeenCalledWith("ENG-123", {
231
+ priority: 2,
232
+ estimate: 5,
233
+ });
234
+ expect(result.success).toBe(true);
235
+ expect(result.changes).toContain("priority → 2");
236
+ expect(result.changes).toContain("estimate → 5");
237
+ });
238
+
239
+ it("returns error when issueId missing", async () => {
240
+ const result = parseResult(await executeTool({ action: "update", status: "Done" }));
241
+ expect(result.error).toMatch(/issueId is required/);
242
+ });
243
+
244
+ it("returns error when no fields provided", async () => {
245
+ const result = parseResult(await executeTool({ action: "update", issueId: "ENG-123" }));
246
+ expect(result.error).toMatch(/At least one field/);
247
+ });
248
+ });
249
+
250
+ describe("create action", () => {
251
+ it("creates a new issue with teamId", async () => {
252
+ mockCreateIssue.mockResolvedValueOnce({ id: "issue-new", identifier: "ENG-200" });
253
+
254
+ const result = parseResult(await executeTool({
255
+ action: "create",
256
+ title: "New feature",
257
+ description: "Build the thing",
258
+ teamId: "team-1",
259
+ priority: 2,
260
+ estimate: 3,
261
+ }));
262
+
263
+ expect(mockCreateIssue).toHaveBeenCalledWith({
264
+ teamId: "team-1",
265
+ title: "New feature",
266
+ description: "Build the thing",
267
+ priority: 2,
268
+ estimate: 3,
269
+ });
270
+ expect(result.success).toBe(true);
271
+ expect(result.identifier).toBe("ENG-200");
272
+ expect(result.parentIssueId).toBeNull();
273
+ });
274
+
275
+ it("creates a sub-issue under a parent", async () => {
276
+ const parentIssue = makeIssueDetails({
277
+ project: { id: "proj-1", name: "My Project" },
278
+ });
279
+ mockGetIssueDetails.mockResolvedValueOnce(parentIssue);
280
+ mockCreateIssue.mockResolvedValueOnce({ id: "issue-sub", identifier: "ENG-201" });
281
+
282
+ const result = parseResult(await executeTool({
283
+ action: "create",
284
+ title: "Sub-task: handle edge case",
285
+ description: "Fix the edge case for empty input",
286
+ parentIssueId: "ENG-123",
287
+ }));
288
+
289
+ expect(mockGetIssueDetails).toHaveBeenCalledWith("ENG-123");
290
+ expect(mockCreateIssue).toHaveBeenCalledWith({
291
+ teamId: "team-1",
292
+ projectId: "proj-1",
293
+ title: "Sub-task: handle edge case",
294
+ description: "Fix the edge case for empty input",
295
+ parentId: "ENG-123",
296
+ });
297
+ expect(result.success).toBe(true);
298
+ expect(result.identifier).toBe("ENG-201");
299
+ expect(result.parentIssueId).toBe("ENG-123");
300
+ });
301
+
302
+ it("inherits teamId from parent when not provided", async () => {
303
+ mockGetIssueDetails.mockResolvedValueOnce(makeIssueDetails());
304
+ mockCreateIssue.mockResolvedValueOnce({ id: "issue-sub", identifier: "ENG-202" });
305
+
306
+ const result = parseResult(await executeTool({
307
+ action: "create",
308
+ title: "Child issue",
309
+ description: "Some work",
310
+ parentIssueId: "ENG-123",
311
+ }));
312
+
313
+ expect(result.success).toBe(true);
314
+ // teamId inherited from parent's team.id ("team-1" in makeIssueDetails)
315
+ expect(mockCreateIssue).toHaveBeenCalledWith(
316
+ expect.objectContaining({ teamId: "team-1" }),
317
+ );
318
+ });
319
+
320
+ it("resolves label names when creating", async () => {
321
+ mockGetTeamLabels.mockResolvedValueOnce([
322
+ { id: "label-1", name: "bug" },
323
+ { id: "label-2", name: "backend" },
324
+ ]);
325
+ mockCreateIssue.mockResolvedValueOnce({ id: "issue-new", identifier: "ENG-203" });
326
+
327
+ const result = parseResult(await executeTool({
328
+ action: "create",
329
+ title: "Bug fix",
330
+ description: "Fix it",
331
+ teamId: "team-1",
332
+ labels: ["bug", "backend"],
333
+ }));
334
+
335
+ expect(mockGetTeamLabels).toHaveBeenCalledWith("team-1");
336
+ expect(mockCreateIssue).toHaveBeenCalledWith(
337
+ expect.objectContaining({ labelIds: ["label-1", "label-2"] }),
338
+ );
339
+ expect(result.success).toBe(true);
340
+ });
341
+
342
+ it("returns error when title missing", async () => {
343
+ const result = parseResult(await executeTool({ action: "create", teamId: "team-1" }));
344
+ expect(result.error).toMatch(/title is required/);
345
+ });
346
+
347
+ it("returns error when teamId missing and no parent", async () => {
348
+ const result = parseResult(await executeTool({
349
+ action: "create",
350
+ title: "Orphan issue",
351
+ }));
352
+ expect(result.error).toMatch(/teamId is required/);
353
+ });
354
+
355
+ it("returns error for unknown labels", async () => {
356
+ mockGetTeamLabels.mockResolvedValueOnce([
357
+ { id: "label-1", name: "bug" },
358
+ ]);
359
+
360
+ const result = parseResult(await executeTool({
361
+ action: "create",
362
+ title: "Test",
363
+ description: "Test",
364
+ teamId: "team-1",
365
+ labels: ["bug", "nonexistent"],
366
+ }));
367
+
368
+ expect(result.error).toMatch(/Labels not found: nonexistent/);
369
+ expect(mockCreateIssue).not.toHaveBeenCalled();
370
+ });
371
+ });
372
+
373
+ describe("comment action", () => {
374
+ it("posts comment and returns ID", async () => {
375
+ mockCreateComment.mockResolvedValueOnce("comment-42");
376
+
377
+ const result = parseResult(await executeTool({
378
+ action: "comment",
379
+ issueId: "ENG-123",
380
+ body: "This is a test comment",
381
+ }));
382
+
383
+ expect(mockCreateComment).toHaveBeenCalledWith("ENG-123", "This is a test comment");
384
+ expect(result.success).toBe(true);
385
+ expect(result.commentId).toBe("comment-42");
386
+ });
387
+
388
+ it("returns error when body missing", async () => {
389
+ const result = parseResult(await executeTool({ action: "comment", issueId: "ENG-123" }));
390
+ expect(result.error).toMatch(/body is required/);
391
+ });
392
+ });
393
+
394
+ describe("list_states action", () => {
395
+ it("returns team workflow states", async () => {
396
+ const states = [
397
+ { id: "s1", name: "Backlog", type: "backlog" },
398
+ { id: "s2", name: "In Progress", type: "started" },
399
+ { id: "s3", name: "Done", type: "completed" },
400
+ ];
401
+ mockGetTeamStates.mockResolvedValueOnce(states);
402
+
403
+ const result = parseResult(await executeTool({ action: "list_states", teamId: "team-1" }));
404
+
405
+ expect(mockGetTeamStates).toHaveBeenCalledWith("team-1");
406
+ expect(result.states).toEqual(states);
407
+ });
408
+
409
+ it("returns error when teamId missing", async () => {
410
+ const result = parseResult(await executeTool({ action: "list_states" }));
411
+ expect(result.error).toMatch(/teamId is required/);
412
+ });
413
+ });
414
+
415
+ describe("list_labels action", () => {
416
+ it("returns team labels", async () => {
417
+ const labels = [
418
+ { id: "l1", name: "bug" },
419
+ { id: "l2", name: "feature" },
420
+ ];
421
+ mockGetTeamLabels.mockResolvedValueOnce(labels);
422
+
423
+ const result = parseResult(await executeTool({ action: "list_labels", teamId: "team-1" }));
424
+
425
+ expect(mockGetTeamLabels).toHaveBeenCalledWith("team-1");
426
+ expect(result.labels).toEqual(labels);
427
+ });
428
+ });
429
+
430
+ describe("error handling", () => {
431
+ it("returns error when no token available", async () => {
432
+ mockResolveLinearToken.mockReturnValueOnce({
433
+ accessToken: null,
434
+ source: "none" as const,
435
+ });
436
+
437
+ const result = parseResult(await executeTool({ action: "read", issueId: "ENG-123" }));
438
+ expect(result.error).toMatch(/No Linear access token/);
439
+ });
440
+
441
+ it("returns error for unknown action", async () => {
442
+ const result = parseResult(await executeTool({ action: "delete" }));
443
+ expect(result.error).toMatch(/Unknown action: delete/);
444
+ });
445
+
446
+ it("catches API errors gracefully", async () => {
447
+ mockGetIssueDetails.mockRejectedValueOnce(new Error("API timeout"));
448
+
449
+ const result = parseResult(await executeTool({ action: "read", issueId: "ENG-123" }));
450
+ expect(result.error).toMatch(/API timeout/);
451
+ });
452
+ });
453
+ });