@calltelemetry/openclaw-linear 0.7.0 → 0.8.0

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +719 -539
  3. package/index.ts +40 -1
  4. package/openclaw.plugin.json +4 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +19 -5
  7. package/src/__test__/fixtures/linear-responses.ts +75 -0
  8. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  9. package/src/__test__/helpers.ts +133 -0
  10. package/src/agent/agent.test.ts +143 -0
  11. package/src/api/linear-api.test.ts +586 -0
  12. package/src/api/linear-api.ts +50 -11
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/gateway/dispatch-methods.ts +243 -0
  15. package/src/infra/cli.ts +273 -30
  16. package/src/infra/codex-worktree.ts +83 -0
  17. package/src/infra/commands.test.ts +276 -0
  18. package/src/infra/commands.ts +156 -0
  19. package/src/infra/doctor.test.ts +19 -0
  20. package/src/infra/doctor.ts +28 -23
  21. package/src/infra/file-lock.test.ts +61 -0
  22. package/src/infra/file-lock.ts +49 -0
  23. package/src/infra/multi-repo.test.ts +163 -0
  24. package/src/infra/multi-repo.ts +114 -0
  25. package/src/infra/notify.test.ts +155 -16
  26. package/src/infra/notify.ts +137 -26
  27. package/src/infra/observability.test.ts +85 -0
  28. package/src/infra/observability.ts +48 -0
  29. package/src/infra/resilience.test.ts +94 -0
  30. package/src/infra/resilience.ts +101 -0
  31. package/src/pipeline/artifacts.test.ts +26 -3
  32. package/src/pipeline/artifacts.ts +38 -2
  33. package/src/pipeline/dag-dispatch.test.ts +553 -0
  34. package/src/pipeline/dag-dispatch.ts +390 -0
  35. package/src/pipeline/dispatch-service.ts +48 -1
  36. package/src/pipeline/dispatch-state.ts +3 -42
  37. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  38. package/src/pipeline/e2e-planning.test.ts +455 -0
  39. package/src/pipeline/pipeline.test.ts +69 -0
  40. package/src/pipeline/pipeline.ts +132 -29
  41. package/src/pipeline/planner.test.ts +1 -1
  42. package/src/pipeline/planner.ts +18 -31
  43. package/src/pipeline/planning-state.ts +2 -40
  44. package/src/pipeline/tier-assess.test.ts +264 -0
  45. package/src/pipeline/webhook.ts +134 -36
  46. package/src/tools/cli-shared.test.ts +155 -0
  47. package/src/tools/code-tool.test.ts +210 -0
  48. package/src/tools/dispatch-history-tool.test.ts +315 -0
  49. package/src/tools/dispatch-history-tool.ts +201 -0
  50. package/src/tools/orchestration-tools.test.ts +158 -0
@@ -0,0 +1,586 @@
1
+ import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
2
+ import { resolveLinearToken, LinearAgentApi, AUTH_PROFILES_PATH } from "./linear-api.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Mocks
6
+ // ---------------------------------------------------------------------------
7
+
8
+ vi.mock("node:fs", () => ({
9
+ readFileSync: vi.fn(),
10
+ writeFileSync: vi.fn(),
11
+ }));
12
+
13
+ vi.mock("./auth.js", () => ({
14
+ refreshLinearToken: vi.fn(),
15
+ }));
16
+
17
+ vi.mock("../infra/resilience.js", () => ({
18
+ withResilience: vi.fn((fn: () => Promise<unknown>) => fn()),
19
+ }));
20
+
21
+ import { readFileSync, writeFileSync } from "node:fs";
22
+ import { refreshLinearToken } from "./auth.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const mockReadFileSync = readFileSync as Mock;
29
+ const mockWriteFileSync = writeFileSync as Mock;
30
+ const mockRefreshLinearToken = refreshLinearToken as Mock;
31
+
32
+ /** Build a minimal successful fetch Response. */
33
+ function okResponse(data: unknown, status = 200): Response {
34
+ return {
35
+ ok: true,
36
+ status,
37
+ json: () => Promise.resolve({ data }),
38
+ text: () => Promise.resolve(JSON.stringify({ data })),
39
+ headers: new Headers(),
40
+ } as unknown as Response;
41
+ }
42
+
43
+ /** Build a failing fetch Response. */
44
+ function errorResponse(status: number, body = "error"): Response {
45
+ return {
46
+ ok: false,
47
+ status,
48
+ json: () => Promise.resolve({ errors: [{ message: body }] }),
49
+ text: () => Promise.resolve(body),
50
+ headers: new Headers(),
51
+ } as unknown as Response;
52
+ }
53
+
54
+ /** Build a response that carries GraphQL-level errors. */
55
+ function gqlErrorResponse(errors: Array<{ message: string }>): Response {
56
+ return {
57
+ ok: true,
58
+ status: 200,
59
+ json: () => Promise.resolve({ errors }),
60
+ text: () => Promise.resolve(JSON.stringify({ errors })),
61
+ headers: new Headers(),
62
+ } as unknown as Response;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Setup
67
+ // ---------------------------------------------------------------------------
68
+
69
+ let fetchMock: Mock;
70
+
71
+ beforeEach(() => {
72
+ vi.restoreAllMocks();
73
+ fetchMock = vi.fn();
74
+ vi.stubGlobal("fetch", fetchMock);
75
+
76
+ // Default: readFileSync throws (no profile file)
77
+ mockReadFileSync.mockImplementation(() => {
78
+ throw new Error("ENOENT");
79
+ });
80
+
81
+ // Clear env vars that could leak between tests
82
+ delete process.env.LINEAR_ACCESS_TOKEN;
83
+ delete process.env.LINEAR_API_KEY;
84
+ });
85
+
86
+ // ===========================================================================
87
+ // resolveLinearToken
88
+ // ===========================================================================
89
+
90
+ describe("resolveLinearToken", () => {
91
+ it("returns token from pluginConfig.accessToken (source: config)", () => {
92
+ const result = resolveLinearToken({ accessToken: "cfg-token-123" });
93
+ expect(result).toEqual({ accessToken: "cfg-token-123", source: "config" });
94
+ });
95
+
96
+ it("returns token from auth profile store when config is empty (source: profile)", () => {
97
+ const profileStore = {
98
+ profiles: {
99
+ "linear:default": {
100
+ accessToken: "oauth-tok",
101
+ refreshToken: "oauth-refresh",
102
+ expiresAt: 9999999999999,
103
+ },
104
+ },
105
+ };
106
+ mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
107
+
108
+ const result = resolveLinearToken();
109
+ expect(result).toEqual({
110
+ accessToken: "oauth-tok",
111
+ refreshToken: "oauth-refresh",
112
+ expiresAt: 9999999999999,
113
+ source: "profile",
114
+ });
115
+ expect(mockReadFileSync).toHaveBeenCalledWith(AUTH_PROFILES_PATH, "utf8");
116
+ });
117
+
118
+ it("returns token from env var LINEAR_ACCESS_TOKEN when config and profile are empty (source: env)", () => {
119
+ process.env.LINEAR_ACCESS_TOKEN = "env-token-abc";
120
+
121
+ const result = resolveLinearToken();
122
+ expect(result).toEqual({ accessToken: "env-token-abc", source: "env" });
123
+ });
124
+
125
+ it("returns token from env var LINEAR_API_KEY as fallback", () => {
126
+ process.env.LINEAR_API_KEY = "api-key-xyz";
127
+
128
+ const result = resolveLinearToken();
129
+ expect(result).toEqual({ accessToken: "api-key-xyz", source: "env" });
130
+ });
131
+
132
+ it("returns null with source 'none' when nothing is configured", () => {
133
+ const result = resolveLinearToken();
134
+ expect(result).toEqual({ accessToken: null, source: "none" });
135
+ });
136
+
137
+ it("respects priority: config > profile > env", () => {
138
+ // Set up all three sources
139
+ const profileStore = {
140
+ profiles: {
141
+ "linear:default": {
142
+ accessToken: "profile-tok",
143
+ refreshToken: "profile-refresh",
144
+ },
145
+ },
146
+ };
147
+ mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
148
+ process.env.LINEAR_ACCESS_TOKEN = "env-tok";
149
+
150
+ // Config wins when present
151
+ const r1 = resolveLinearToken({ accessToken: "config-tok" });
152
+ expect(r1.source).toBe("config");
153
+ expect(r1.accessToken).toBe("config-tok");
154
+
155
+ // Profile wins over env when config is absent
156
+ const r2 = resolveLinearToken();
157
+ expect(r2.source).toBe("profile");
158
+ expect(r2.accessToken).toBe("profile-tok");
159
+
160
+ // Env is used when profile file is unreadable and no config
161
+ mockReadFileSync.mockImplementation(() => {
162
+ throw new Error("ENOENT");
163
+ });
164
+ const r3 = resolveLinearToken();
165
+ expect(r3.source).toBe("env");
166
+ expect(r3.accessToken).toBe("env-tok");
167
+ });
168
+ });
169
+
170
+ // ===========================================================================
171
+ // LinearAgentApi
172
+ // ===========================================================================
173
+
174
+ describe("LinearAgentApi", () => {
175
+ const TOKEN = "test-access-token";
176
+
177
+ // -------------------------------------------------------------------------
178
+ // gql — tested indirectly via public methods
179
+ // -------------------------------------------------------------------------
180
+
181
+ describe("gql (via public methods)", () => {
182
+ it("sends correct headers and body", async () => {
183
+ fetchMock.mockResolvedValueOnce(
184
+ okResponse({ commentCreate: { success: true, comment: { id: "c1" } } }),
185
+ );
186
+
187
+ const api = new LinearAgentApi(TOKEN);
188
+ await api.createComment("issue-1", "hello");
189
+
190
+ expect(fetchMock).toHaveBeenCalledTimes(1);
191
+ const [url, init] = fetchMock.mock.calls[0];
192
+ expect(url).toBe("https://api.linear.app/graphql");
193
+ expect(init.method).toBe("POST");
194
+ expect(init.headers["Content-Type"]).toBe("application/json");
195
+ expect(init.headers["Authorization"]).toBe(TOKEN); // no Bearer — no refreshToken
196
+
197
+ const body = JSON.parse(init.body);
198
+ expect(body.query).toContain("CommentCreate");
199
+ expect(body.variables.input.issueId).toBe("issue-1");
200
+ expect(body.variables.input.body).toBe("hello");
201
+ });
202
+
203
+ it("returns data on success", async () => {
204
+ fetchMock.mockResolvedValueOnce(
205
+ okResponse({
206
+ issueUpdate: { success: true },
207
+ }),
208
+ );
209
+
210
+ const api = new LinearAgentApi(TOKEN);
211
+ const result = await api.updateIssue("i1", { estimate: 3 });
212
+ expect(result).toBe(true);
213
+ });
214
+
215
+ it("throws on non-ok response", async () => {
216
+ fetchMock.mockResolvedValueOnce(errorResponse(500, "Internal Server Error"));
217
+
218
+ const api = new LinearAgentApi(TOKEN);
219
+ await expect(api.updateIssue("i1", { estimate: 1 })).rejects.toThrow(
220
+ /Linear API 500/,
221
+ );
222
+ });
223
+
224
+ it("throws on GraphQL errors", async () => {
225
+ fetchMock.mockResolvedValueOnce(
226
+ gqlErrorResponse([{ message: "Field 'foo' not found" }]),
227
+ );
228
+
229
+ const api = new LinearAgentApi(TOKEN);
230
+ await expect(api.updateIssue("i1", { estimate: 1 })).rejects.toThrow(
231
+ /Linear GraphQL/,
232
+ );
233
+ });
234
+
235
+ it("retries on 401 when refresh token is available", async () => {
236
+ // First call (via withResilience): 401
237
+ fetchMock.mockResolvedValueOnce(errorResponse(401, "Unauthorized"));
238
+ // Retry (direct fetch, not through withResilience): succeeds
239
+ fetchMock.mockResolvedValueOnce(
240
+ okResponse({ issueUpdate: { success: true } }),
241
+ );
242
+
243
+ mockRefreshLinearToken.mockResolvedValueOnce({
244
+ access_token: "new-token",
245
+ refresh_token: "new-refresh",
246
+ expires_in: 3600,
247
+ });
248
+
249
+ // readFileSync/writeFileSync for persistToken
250
+ mockReadFileSync.mockReturnValue(
251
+ JSON.stringify({
252
+ profiles: { "linear:default": { accessToken: "old" } },
253
+ }),
254
+ );
255
+
256
+ // Use expiresAt = 1 (truthy but in the past) so ensureValidToken triggers
257
+ // the refresh on the 401 path when expiresAt is set to 0... actually
258
+ // the code sets expiresAt=0 which is falsy, so ensureValidToken bails.
259
+ // But the retry still happens — let's verify the retry occurs.
260
+ const api = new LinearAgentApi(TOKEN, {
261
+ refreshToken: "refresh-tok",
262
+ expiresAt: Date.now() + 100_000,
263
+ clientId: "cid",
264
+ clientSecret: "csecret",
265
+ });
266
+
267
+ const result = await api.updateIssue("i1", { estimate: 2 });
268
+ expect(result).toBe(true);
269
+
270
+ // Two fetch calls: original (401) + retry after 401 handling
271
+ expect(fetchMock).toHaveBeenCalledTimes(2);
272
+
273
+ // The retry request uses Bearer prefix (refreshToken is still set)
274
+ const retryInit = fetchMock.mock.calls[1][1];
275
+ expect(retryInit.headers["Authorization"]).toContain("Bearer");
276
+ });
277
+
278
+ it("throws after 401 refresh also fails", async () => {
279
+ // First call: 401
280
+ fetchMock.mockResolvedValueOnce(errorResponse(401, "Unauthorized"));
281
+ // After refresh, retry still fails
282
+ fetchMock.mockResolvedValueOnce(errorResponse(403, "Forbidden"));
283
+
284
+ mockRefreshLinearToken.mockResolvedValueOnce({
285
+ access_token: "refreshed-tok",
286
+ expires_in: 3600,
287
+ });
288
+
289
+ mockReadFileSync.mockReturnValue(
290
+ JSON.stringify({
291
+ profiles: { "linear:default": { accessToken: "old" } },
292
+ }),
293
+ );
294
+
295
+ const api = new LinearAgentApi(TOKEN, {
296
+ refreshToken: "r-tok",
297
+ expiresAt: Date.now() + 100_000,
298
+ clientId: "cid",
299
+ clientSecret: "csecret",
300
+ });
301
+
302
+ await expect(api.updateIssue("i1", { estimate: 1 })).rejects.toThrow(
303
+ /Linear API authentication failed/,
304
+ );
305
+ });
306
+ });
307
+
308
+ // -------------------------------------------------------------------------
309
+ // authHeader
310
+ // -------------------------------------------------------------------------
311
+
312
+ describe("authHeader (via request headers)", () => {
313
+ it("uses 'Bearer' prefix when refreshToken is set", async () => {
314
+ fetchMock.mockResolvedValueOnce(
315
+ okResponse({ issueUpdate: { success: true } }),
316
+ );
317
+
318
+ const api = new LinearAgentApi(TOKEN, {
319
+ refreshToken: "r-tok",
320
+ expiresAt: Date.now() + 600_000, // far future — no refresh triggered
321
+ });
322
+ await api.updateIssue("i1", { estimate: 1 });
323
+
324
+ const [, init] = fetchMock.mock.calls[0];
325
+ expect(init.headers["Authorization"]).toBe(`Bearer ${TOKEN}`);
326
+ });
327
+
328
+ it("uses raw token when refreshToken is not set", async () => {
329
+ fetchMock.mockResolvedValueOnce(
330
+ okResponse({ issueUpdate: { success: true } }),
331
+ );
332
+
333
+ const api = new LinearAgentApi(TOKEN);
334
+ await api.updateIssue("i1", { estimate: 1 });
335
+
336
+ const [, init] = fetchMock.mock.calls[0];
337
+ expect(init.headers["Authorization"]).toBe(TOKEN);
338
+ });
339
+ });
340
+
341
+ // -------------------------------------------------------------------------
342
+ // Public methods
343
+ // -------------------------------------------------------------------------
344
+
345
+ describe("emitActivity", () => {
346
+ it("calls the correct mutation with content payload", async () => {
347
+ fetchMock.mockResolvedValueOnce(
348
+ okResponse({ agentActivityCreate: { success: true } }),
349
+ );
350
+
351
+ const api = new LinearAgentApi(TOKEN);
352
+ await api.emitActivity("session-1", { type: "thought", body: "thinking..." });
353
+
354
+ const [, init] = fetchMock.mock.calls[0];
355
+ const body = JSON.parse(init.body);
356
+ expect(body.query).toContain("agentActivityCreate");
357
+ expect(body.variables.input).toEqual({
358
+ agentSessionId: "session-1",
359
+ content: { type: "thought", body: "thinking..." },
360
+ });
361
+ });
362
+ });
363
+
364
+ describe("createComment", () => {
365
+ it("sends correct input and returns comment id", async () => {
366
+ fetchMock.mockResolvedValueOnce(
367
+ okResponse({
368
+ commentCreate: { success: true, comment: { id: "comment-abc" } },
369
+ }),
370
+ );
371
+
372
+ const api = new LinearAgentApi(TOKEN);
373
+ const id = await api.createComment("issue-99", "Test comment body", {
374
+ createAsUser: "user-1",
375
+ displayIconUrl: "https://example.com/icon.png",
376
+ });
377
+
378
+ expect(id).toBe("comment-abc");
379
+
380
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
381
+ expect(body.variables.input).toEqual({
382
+ issueId: "issue-99",
383
+ body: "Test comment body",
384
+ createAsUser: "user-1",
385
+ displayIconUrl: "https://example.com/icon.png",
386
+ });
387
+ });
388
+ });
389
+
390
+ describe("getIssueDetails", () => {
391
+ it("returns expected shape", async () => {
392
+ const issueData = {
393
+ id: "iss-1",
394
+ identifier: "CT-123",
395
+ title: "Fix the bug",
396
+ description: "Something is broken",
397
+ estimate: 3,
398
+ state: { name: "In Progress" },
399
+ assignee: { name: "Alice" },
400
+ labels: { nodes: [{ id: "l1", name: "bug" }] },
401
+ team: { id: "t1", name: "Engineering", issueEstimationType: "fibonacci" },
402
+ comments: {
403
+ nodes: [
404
+ { body: "Looking into it", user: { name: "Bob" }, createdAt: "2026-01-01T00:00:00Z" },
405
+ ],
406
+ },
407
+ project: { id: "p1", name: "Q1 Sprint" },
408
+ parent: null,
409
+ relations: { nodes: [] },
410
+ };
411
+
412
+ fetchMock.mockResolvedValueOnce(okResponse({ issue: issueData }));
413
+
414
+ const api = new LinearAgentApi(TOKEN);
415
+ const result = await api.getIssueDetails("iss-1");
416
+
417
+ expect(result.id).toBe("iss-1");
418
+ expect(result.identifier).toBe("CT-123");
419
+ expect(result.title).toBe("Fix the bug");
420
+ expect(result.description).toBe("Something is broken");
421
+ expect(result.estimate).toBe(3);
422
+ expect(result.state.name).toBe("In Progress");
423
+ expect(result.assignee?.name).toBe("Alice");
424
+ expect(result.labels.nodes).toHaveLength(1);
425
+ expect(result.team.issueEstimationType).toBe("fibonacci");
426
+ expect(result.comments.nodes).toHaveLength(1);
427
+ expect(result.project?.name).toBe("Q1 Sprint");
428
+ expect(result.parent).toBeNull();
429
+ expect(result.relations.nodes).toHaveLength(0);
430
+
431
+ // Verify variables sent
432
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
433
+ expect(body.variables).toEqual({ id: "iss-1" });
434
+ });
435
+ });
436
+
437
+ describe("updateIssue", () => {
438
+ it("calls mutation and returns success boolean", async () => {
439
+ fetchMock.mockResolvedValueOnce(
440
+ okResponse({ issueUpdate: { success: true } }),
441
+ );
442
+
443
+ const api = new LinearAgentApi(TOKEN);
444
+ const success = await api.updateIssue("iss-42", {
445
+ estimate: 5,
446
+ labelIds: ["l1", "l2"],
447
+ stateId: "s1",
448
+ priority: 2,
449
+ });
450
+
451
+ expect(success).toBe(true);
452
+
453
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
454
+ expect(body.query).toContain("issueUpdate");
455
+ expect(body.variables).toEqual({
456
+ id: "iss-42",
457
+ input: {
458
+ estimate: 5,
459
+ labelIds: ["l1", "l2"],
460
+ stateId: "s1",
461
+ priority: 2,
462
+ },
463
+ });
464
+ });
465
+ });
466
+
467
+ describe("getTeams", () => {
468
+ it("returns parsed team list", async () => {
469
+ fetchMock.mockResolvedValueOnce(
470
+ okResponse({
471
+ teams: {
472
+ nodes: [
473
+ { id: "t1", name: "Engineering", key: "ENG" },
474
+ { id: "t2", name: "Design", key: "DES" },
475
+ ],
476
+ },
477
+ }),
478
+ );
479
+
480
+ const api = new LinearAgentApi(TOKEN);
481
+ const teams = await api.getTeams();
482
+ expect(teams).toHaveLength(2);
483
+ expect(teams[0]).toEqual({ id: "t1", name: "Engineering", key: "ENG" });
484
+ expect(teams[1]).toEqual({ id: "t2", name: "Design", key: "DES" });
485
+ });
486
+
487
+ it("handles empty teams list", async () => {
488
+ fetchMock.mockResolvedValueOnce(okResponse({ teams: { nodes: [] } }));
489
+
490
+ const api = new LinearAgentApi(TOKEN);
491
+ const teams = await api.getTeams();
492
+ expect(teams).toEqual([]);
493
+ });
494
+ });
495
+
496
+ describe("createLabel", () => {
497
+ it("sends correct mutation and returns label", async () => {
498
+ fetchMock.mockResolvedValueOnce(
499
+ okResponse({
500
+ issueLabelCreate: {
501
+ success: true,
502
+ issueLabel: { id: "label-1", name: "repo:api" },
503
+ },
504
+ }),
505
+ );
506
+
507
+ const api = new LinearAgentApi(TOKEN);
508
+ const label = await api.createLabel("t1", "repo:api", {
509
+ color: "#5e6ad2",
510
+ description: "Multi-repo dispatch: api",
511
+ });
512
+
513
+ expect(label).toEqual({ id: "label-1", name: "repo:api" });
514
+
515
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
516
+ expect(body.query).toContain("issueLabelCreate");
517
+ expect(body.variables.input).toEqual({
518
+ teamId: "t1",
519
+ name: "repo:api",
520
+ color: "#5e6ad2",
521
+ description: "Multi-repo dispatch: api",
522
+ });
523
+ });
524
+
525
+ it("throws on API failure", async () => {
526
+ fetchMock.mockResolvedValueOnce(
527
+ okResponse({
528
+ issueLabelCreate: { success: false, issueLabel: null },
529
+ }),
530
+ );
531
+
532
+ const api = new LinearAgentApi(TOKEN);
533
+ await expect(
534
+ api.createLabel("t1", "repo:bad"),
535
+ ).rejects.toThrow(/Failed to create label/);
536
+ });
537
+
538
+ it("omits optional fields when not provided", async () => {
539
+ fetchMock.mockResolvedValueOnce(
540
+ okResponse({
541
+ issueLabelCreate: {
542
+ success: true,
543
+ issueLabel: { id: "label-2", name: "repo:frontend" },
544
+ },
545
+ }),
546
+ );
547
+
548
+ const api = new LinearAgentApi(TOKEN);
549
+ await api.createLabel("t1", "repo:frontend");
550
+
551
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
552
+ expect(body.variables.input).toEqual({
553
+ teamId: "t1",
554
+ name: "repo:frontend",
555
+ });
556
+ });
557
+ });
558
+
559
+ describe("createSessionOnIssue", () => {
560
+ it("returns sessionId on success", async () => {
561
+ fetchMock.mockResolvedValueOnce(
562
+ okResponse({
563
+ agentSessionCreateOnIssue: {
564
+ success: true,
565
+ agentSession: { id: "sess-new" },
566
+ },
567
+ }),
568
+ );
569
+
570
+ const api = new LinearAgentApi(TOKEN);
571
+ const result = await api.createSessionOnIssue("iss-1");
572
+ expect(result).toEqual({ sessionId: "sess-new" });
573
+ });
574
+
575
+ it("returns error on failure", async () => {
576
+ fetchMock.mockResolvedValueOnce(errorResponse(500, "Server Error"));
577
+
578
+ const api = new LinearAgentApi(TOKEN);
579
+ const result = await api.createSessionOnIssue("iss-bad");
580
+
581
+ expect(result.sessionId).toBeNull();
582
+ expect(result.error).toBeDefined();
583
+ expect(result.error).toContain("Linear API 500");
584
+ });
585
+ });
586
+ });
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { refreshLinearToken } from "./auth.js";
4
+ import { withResilience } from "../infra/resilience.js";
4
5
 
5
6
  export const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
6
7
  export const AUTH_PROFILES_PATH = join(
@@ -161,13 +162,15 @@ export class LinearAgentApi {
161
162
  ...extraHeaders,
162
163
  };
163
164
 
164
- const res = await fetch(LINEAR_GRAPHQL_URL, {
165
- method: "POST",
166
- headers,
167
- body: JSON.stringify({ query, variables }),
168
- });
165
+ const res = await withResilience(() =>
166
+ fetch(LINEAR_GRAPHQL_URL, {
167
+ method: "POST",
168
+ headers,
169
+ body: JSON.stringify({ query, variables }),
170
+ }),
171
+ );
169
172
 
170
- // If 401, try refreshing token once
173
+ // If 401, try refreshing token once (outside resilience — own retry semantics)
171
174
  if (res.status === 401 && this.refreshToken && this.clientId && this.clientSecret) {
172
175
  this.expiresAt = 0; // force refresh
173
176
  await this.ensureValidToken();
@@ -178,18 +181,18 @@ export class LinearAgentApi {
178
181
  ...extraHeaders,
179
182
  };
180
183
 
181
- const retry = await fetch(LINEAR_GRAPHQL_URL, {
184
+ const retryRes = await fetch(LINEAR_GRAPHQL_URL, {
182
185
  method: "POST",
183
186
  headers: retryHeaders,
184
187
  body: JSON.stringify({ query, variables }),
185
188
  });
186
189
 
187
- if (!retry.ok) {
188
- const text = await retry.text();
189
- throw new Error(`Linear API ${retry.status} (after refresh): ${text}`);
190
+ if (!retryRes.ok) {
191
+ const text = await retryRes.text();
192
+ throw new Error(`Linear API authentication failed (${retryRes.status}). Your token may have expired. Run: openclaw openclaw-linear auth`);
190
193
  }
191
194
 
192
- const payload = await retry.json();
195
+ const payload = await retryRes.json();
193
196
  if (payload.errors?.length) {
194
197
  throw new Error(`Linear GraphQL: ${JSON.stringify(payload.errors)}`);
195
198
  }
@@ -372,6 +375,42 @@ export class LinearAgentApi {
372
375
  return data.team.labels.nodes;
373
376
  }
374
377
 
378
+ async getTeams(): Promise<Array<{ id: string; name: string; key: string }>> {
379
+ const data = await this.gql<{
380
+ teams: { nodes: Array<{ id: string; name: string; key: string }> };
381
+ }>(
382
+ `query { teams { nodes { id name key } } }`,
383
+ );
384
+ return data.teams.nodes;
385
+ }
386
+
387
+ async createLabel(
388
+ teamId: string,
389
+ name: string,
390
+ opts?: { color?: string; description?: string },
391
+ ): Promise<{ id: string; name: string }> {
392
+ const input: Record<string, string> = { teamId, name };
393
+ if (opts?.color) input.color = opts.color;
394
+ if (opts?.description) input.description = opts.description;
395
+
396
+ const data = await this.gql<{
397
+ issueLabelCreate: { success: boolean; issueLabel: { id: string; name: string } };
398
+ }>(
399
+ `mutation CreateLabel($input: IssueLabelCreateInput!) {
400
+ issueLabelCreate(input: $input) {
401
+ success
402
+ issueLabel { id name }
403
+ }
404
+ }`,
405
+ { input },
406
+ );
407
+
408
+ if (!data.issueLabelCreate.success) {
409
+ throw new Error(`Failed to create label "${name}"`);
410
+ }
411
+ return data.issueLabelCreate.issueLabel;
412
+ }
413
+
375
414
  // ---------------------------------------------------------------------------
376
415
  // Planning methods
377
416
  // ---------------------------------------------------------------------------