@calltelemetry/openclaw-linear 0.9.14 → 0.9.16

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.
@@ -1,847 +0,0 @@
1
- /**
2
- * smoke-linear-api.test.ts — Live integration tests against the real Linear API.
3
- *
4
- * These tests verify API connectivity, comment lifecycle, and dedup behavior
5
- * using real API calls. Run separately from unit tests:
6
- *
7
- * npx vitest run --config vitest.smoke.config.ts
8
- *
9
- * Requires: ~/.openclaw/auth-profiles.json with a valid linear:api-key profile.
10
- */
11
- import { readFileSync, writeFileSync } from "node:fs";
12
- import { join, dirname } from "node:path";
13
- import { homedir } from "node:os";
14
- import { fileURLToPath } from "node:url";
15
- import { afterAll, beforeAll, describe, expect, it } from "vitest";
16
- import { LinearAgentApi } from "../api/linear-api.js";
17
- import { createLinearIssuesTool } from "../tools/linear-issues-tool.js";
18
-
19
- const __dirname = dirname(fileURLToPath(import.meta.url));
20
-
21
- // ── Setup ──────────────────────────────────────────────────────────
22
-
23
- const AUTH_PROFILES_PATH = join(
24
- homedir(),
25
- ".openclaw",
26
- "auth-profiles.json",
27
- );
28
-
29
- const TEAM_ID = "08cba264-d774-4afd-bc93-ee8213d12ef8";
30
-
31
- function loadApiKey(): string {
32
- const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
33
- const store = JSON.parse(raw);
34
- const profile = store?.profiles?.["linear:api-key"];
35
- const token = profile?.accessToken ?? profile?.access;
36
- if (!token) throw new Error("No linear:api-key profile found in auth-profiles.json");
37
- return token;
38
- }
39
-
40
- function loadOAuthToken(): string | null {
41
- try {
42
- const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
43
- const store = JSON.parse(raw);
44
- const profile = store?.profiles?.["linear:default"];
45
- return profile?.accessToken ?? null;
46
- } catch {
47
- return null;
48
- }
49
- }
50
-
51
- let api: LinearAgentApi;
52
- let oauthApi: LinearAgentApi | null = null;
53
- let smokeIssueId: string | null = null;
54
- const createdCommentIds: string[] = [];
55
-
56
- beforeAll(() => {
57
- const token = loadApiKey();
58
- api = new LinearAgentApi(token);
59
- const oauthToken = loadOAuthToken();
60
- if (oauthToken) {
61
- // Strip "Bearer " prefix if present — LinearAgentApi adds it
62
- const bare = oauthToken.replace(/^Bearer\s+/i, "");
63
- oauthApi = new LinearAgentApi(bare);
64
- }
65
- });
66
-
67
- afterAll(async () => {
68
- // Cleanup: we can't delete comments via API, but they're prefixed
69
- // with [SMOKE TEST] for easy identification.
70
- if (createdCommentIds.length > 0) {
71
- console.log(
72
- `Smoke test created ${createdCommentIds.length} comment(s) prefixed with [SMOKE TEST]. ` +
73
- `Clean up manually if needed.`,
74
- );
75
- }
76
- });
77
-
78
- // ── Tests ──────────────────────────────────────────────────────────
79
-
80
- describe("Linear API smoke tests", () => {
81
- describe("connectivity", () => {
82
- it("resolves viewer ID", async () => {
83
- const viewerId = await api.getViewerId();
84
- expect(viewerId).toBeTruthy();
85
- expect(typeof viewerId).toBe("string");
86
- });
87
- });
88
-
89
- describe("team discovery", () => {
90
- it("lists teams", async () => {
91
- const teams = await api.getTeams();
92
- expect(teams.length).toBeGreaterThan(0);
93
- expect(teams[0]).toHaveProperty("id");
94
- expect(teams[0]).toHaveProperty("name");
95
- expect(teams[0]).toHaveProperty("key");
96
- });
97
-
98
- it("finds our configured team", async () => {
99
- const teams = await api.getTeams();
100
- const ourTeam = teams.find((t) => t.id === TEAM_ID);
101
- expect(ourTeam).toBeTruthy();
102
- });
103
-
104
- it("lists team labels", async () => {
105
- const labels = await api.getTeamLabels(TEAM_ID);
106
- expect(Array.isArray(labels)).toBe(true);
107
- // Labels may or may not exist, but the call should succeed
108
- });
109
-
110
- it("lists team workflow states", async () => {
111
- const states = await api.getTeamStates(TEAM_ID);
112
- expect(states.length).toBeGreaterThan(0);
113
- expect(states[0]).toHaveProperty("id");
114
- expect(states[0]).toHaveProperty("name");
115
- expect(states[0]).toHaveProperty("type");
116
- // Should have at least backlog, started, completed types
117
- const types = states.map((s) => s.type);
118
- expect(types).toContain("backlog");
119
- });
120
- });
121
-
122
- describe("issue operations", () => {
123
- it("creates a smoke test issue", async () => {
124
- const states = await api.getTeamStates(TEAM_ID);
125
- const backlogState = states.find((s) => s.type === "backlog");
126
- expect(backlogState).toBeTruthy();
127
-
128
- const result = await api.createIssue({
129
- teamId: TEAM_ID,
130
- title: "[SMOKE TEST] Linear Plugin Integration Test",
131
- description:
132
- "Auto-generated by smoke tests. Safe to delete.\n\n" +
133
- `Created: ${new Date().toISOString()}`,
134
- stateId: backlogState!.id,
135
- priority: 4, // Low
136
- });
137
-
138
- expect(result.id).toBeTruthy();
139
- expect(result.identifier).toBeTruthy();
140
- smokeIssueId = result.id;
141
- });
142
-
143
- it("reads issue details", async () => {
144
- expect(smokeIssueId).toBeTruthy();
145
- const issue = await api.getIssueDetails(smokeIssueId!);
146
-
147
- expect(issue.id).toBe(smokeIssueId);
148
- expect(issue.identifier).toBeTruthy();
149
- expect(issue.title).toContain("[SMOKE TEST]");
150
- expect(issue.state).toHaveProperty("name");
151
- expect(issue.team).toHaveProperty("id");
152
- expect(issue.team).toHaveProperty("name");
153
- expect(issue.labels).toHaveProperty("nodes");
154
- expect(issue.comments).toHaveProperty("nodes");
155
- });
156
-
157
- it("updates issue fields", async () => {
158
- expect(smokeIssueId).toBeTruthy();
159
- const success = await api.updateIssue(smokeIssueId!, {
160
- estimate: 1,
161
- priority: 4,
162
- });
163
- expect(success).toBe(true);
164
-
165
- // Verify the update
166
- const issue = await api.getIssueDetails(smokeIssueId!);
167
- expect(issue.estimate).toBe(1);
168
- });
169
- });
170
-
171
- describe("comment lifecycle", () => {
172
- it("creates a comment", async () => {
173
- expect(smokeIssueId).toBeTruthy();
174
- const commentId = await api.createComment(
175
- smokeIssueId!,
176
- "[SMOKE TEST] Comment created by integration test.\n\n" +
177
- `Timestamp: ${new Date().toISOString()}`,
178
- );
179
- expect(commentId).toBeTruthy();
180
- expect(typeof commentId).toBe("string");
181
- createdCommentIds.push(commentId);
182
- });
183
-
184
- it("comment appears in issue details", async () => {
185
- expect(smokeIssueId).toBeTruthy();
186
- expect(createdCommentIds.length).toBeGreaterThan(0);
187
-
188
- const issue = await api.getIssueDetails(smokeIssueId!);
189
- const comments = issue.comments.nodes;
190
- const found = comments.some((c) =>
191
- c.body.includes("[SMOKE TEST] Comment created by integration test"),
192
- );
193
- expect(found).toBe(true);
194
- });
195
-
196
- it("creates an agent identity comment (requires OAuth — skipped with API key)", async () => {
197
- expect(smokeIssueId).toBeTruthy();
198
- // createAsUser posts as a named OpenClaw agent (e.g. "Mal", "Kaylee")
199
- // with their avatar. Requires OAuth actor=app mode — personal API keys
200
- // can't use it, so comments fall back to a **[AgentName]** prefix.
201
- try {
202
- const commentId = await api.createComment(
203
- smokeIssueId!,
204
- "[SMOKE TEST] Agent identity comment test.",
205
- {
206
- createAsUser: "Smoke Test Bot",
207
- displayIconUrl: "https://avatars.githubusercontent.com/u/1?v=4",
208
- },
209
- );
210
- expect(commentId).toBeTruthy();
211
- createdCommentIds.push(commentId);
212
- } catch (err) {
213
- const msg = String(err);
214
- if (msg.includes("createAsUser") || msg.includes("actor=app")) {
215
- // Expected with API key — agent identity comments require OAuth
216
- expect(true).toBe(true);
217
- } else {
218
- throw err; // Unexpected error
219
- }
220
- }
221
- });
222
- });
223
-
224
- describe("issue state transitions", () => {
225
- let teamStates: Array<{ id: string; name: string; type: string }>;
226
-
227
- it("fetches team workflow states for transition tests", async () => {
228
- teamStates = await api.getTeamStates(TEAM_ID);
229
- expect(teamStates.length).toBeGreaterThan(0);
230
- });
231
-
232
- it("transitions issue from backlog to started", async () => {
233
- expect(smokeIssueId).toBeTruthy();
234
- const startedState = teamStates.find((s) => s.type === "started");
235
- expect(startedState).toBeTruthy();
236
-
237
- const success = await api.updateIssue(smokeIssueId!, { stateId: startedState!.id });
238
- expect(success).toBe(true);
239
-
240
- const issue = await api.getIssueDetails(smokeIssueId!);
241
- expect(issue.state.type).toBe("started");
242
- });
243
-
244
- it("transitions issue from started to completed", async () => {
245
- expect(smokeIssueId).toBeTruthy();
246
- const completedState = teamStates.find((s) => s.type === "completed");
247
- expect(completedState).toBeTruthy();
248
-
249
- const success = await api.updateIssue(smokeIssueId!, { stateId: completedState!.id });
250
- expect(success).toBe(true);
251
-
252
- const issue = await api.getIssueDetails(smokeIssueId!);
253
- expect(issue.state.type).toBe("completed");
254
- });
255
-
256
- it("transitions issue from completed to canceled", async () => {
257
- expect(smokeIssueId).toBeTruthy();
258
- const canceledState = teamStates.find(
259
- (s) => s.type === "canceled" || s.name.toLowerCase().includes("cancel"),
260
- );
261
- expect(canceledState).toBeTruthy();
262
-
263
- const success = await api.updateIssue(smokeIssueId!, { stateId: canceledState!.id });
264
- expect(success).toBe(true);
265
-
266
- const issue = await api.getIssueDetails(smokeIssueId!);
267
- expect(issue.state.type).toBe("canceled");
268
- });
269
- });
270
-
271
- describe("@mention pattern matching", () => {
272
- it("buildMentionPattern matches configured aliases", async () => {
273
- // Test the pattern logic without needing agent-profiles.json.
274
- // Note: use match() not test() — test() with `g` flag is stateful.
275
- const aliases = ["mal", "kaylee", "inara", "zoe"];
276
- const escaped = aliases.map((a) =>
277
- a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
278
- );
279
- const pattern = new RegExp(`@(${escaped.join("|")})`, "gi");
280
-
281
- expect("@mal please fix this".match(pattern)).toBeTruthy();
282
- expect("Hey @kaylee can you look at this?".match(pattern)).toBeTruthy();
283
- expect("@Inara write a blog post".match(pattern)).toBeTruthy();
284
- expect("No mention here".match(pattern)).toBeNull();
285
- expect("email@mal.com".match(pattern)).toBeTruthy(); // Known edge case
286
- });
287
- });
288
-
289
- describe("dedup dry run", () => {
290
- it("wasRecentlyProcessed returns false first, true second", async () => {
291
- // Import the dedup function
292
- const { _resetForTesting } = await import(
293
- "../pipeline/webhook.js"
294
- );
295
- _resetForTesting();
296
-
297
- // The wasRecentlyProcessed function is not exported directly,
298
- // but we can test it indirectly through the webhook handler.
299
- // For a pure unit test of the dedup function, see webhook-dedup.test.ts.
300
- // Here we just verify the reset works.
301
- expect(typeof _resetForTesting).toBe("function");
302
- });
303
- });
304
-
305
- describe("webhook management", () => {
306
- it("lists webhooks", async () => {
307
- const webhooks = await api.listWebhooks();
308
- expect(Array.isArray(webhooks)).toBe(true);
309
- // Should have at least the shape we expect
310
- if (webhooks.length > 0) {
311
- expect(webhooks[0]).toHaveProperty("id");
312
- expect(webhooks[0]).toHaveProperty("url");
313
- expect(webhooks[0]).toHaveProperty("enabled");
314
- expect(webhooks[0]).toHaveProperty("resourceTypes");
315
- }
316
- });
317
-
318
- it("getWebhookStatus reports issues on misconfigured webhook", async () => {
319
- const { getWebhookStatus } = await import("../../src/infra/webhook-provision.js");
320
- // Use a URL that won't match any real webhook — should return null
321
- const status = await getWebhookStatus(api, "https://nonexistent.example.com/webhook");
322
- expect(status).toBeNull();
323
- });
324
-
325
- it("getWebhookStatus finds our webhook if configured", async () => {
326
- const { getWebhookStatus } = await import("../../src/infra/webhook-provision.js");
327
- const status = await getWebhookStatus(
328
- api,
329
- "https://linear.calltelemetry.com/linear/webhook",
330
- );
331
- // May or may not exist — just check the function works
332
- if (status) {
333
- expect(status.id).toBeTruthy();
334
- expect(status.url).toBe("https://linear.calltelemetry.com/linear/webhook");
335
- expect(Array.isArray(status.issues)).toBe(true);
336
- }
337
- });
338
-
339
- it("provisionWebhook returns already_ok when correctly configured", async () => {
340
- const { provisionWebhook, getWebhookStatus } = await import("../../src/infra/webhook-provision.js");
341
- const status = await getWebhookStatus(
342
- api,
343
- "https://linear.calltelemetry.com/linear/webhook",
344
- );
345
- // Only run if our webhook exists and is correctly configured
346
- if (status && status.issues.length === 0) {
347
- const result = await provisionWebhook(
348
- api,
349
- "https://linear.calltelemetry.com/linear/webhook",
350
- );
351
- expect(result.action).toBe("already_ok");
352
- expect(result.webhookId).toBe(status.id);
353
- }
354
- });
355
- });
356
-
357
- describe("AgentSessionEvent webhook flow", () => {
358
- // Tests two paths that trigger AgentSessionEvent.created:
359
- // Path A: @mention in a comment — user posts "@ctclaw do X" on an issue.
360
- // Linear sees the agent mention, creates an AgentSession, and
361
- // fires the webhook. This is the normal user flow.
362
- // Path B: createSessionOnIssue API call — programmatic session creation
363
- // (requires OAuth token, not API key).
364
- //
365
- // Both should result in the gateway receiving the webhook and spawning
366
- // an agent run. We verify by polling for agent comments on the issue.
367
- //
368
- // Requires:
369
- // - OAuth app webhook → https://linear.calltelemetry.com/linear/webhook
370
- // - Gateway running (systemctl --user status openclaw-gateway)
371
-
372
- let sessionIssueId: string | null = null;
373
- let sessionIssueIdentifier: string | null = null;
374
-
375
- it("creates a test issue for agent session", async () => {
376
- const states = await api.getTeamStates(TEAM_ID);
377
- const backlogState = states.find((s) => s.type === "backlog");
378
- expect(backlogState).toBeTruthy();
379
-
380
- const result = await api.createIssue({
381
- teamId: TEAM_ID,
382
- title: "[SMOKE TEST] AgentSessionEvent webhook test",
383
- description:
384
- "Auto-generated to test AgentSessionEvent webhook flow.\n" +
385
- "Tests both @mention and createSessionOnIssue paths.\n\n" +
386
- `Created: ${new Date().toISOString()}`,
387
- stateId: backlogState!.id,
388
- priority: 4,
389
- });
390
-
391
- expect(result.id).toBeTruthy();
392
- sessionIssueId = result.id;
393
- sessionIssueIdentifier = result.identifier;
394
- console.log(`Created test issue: ${result.identifier} (${result.id})`);
395
- });
396
-
397
- it("Path A: @mention triggers AgentSessionEvent", async () => {
398
- expect(sessionIssueId).toBeTruthy();
399
-
400
- // Post a comment mentioning @ctclaw — this is what real users do.
401
- // Linear should detect the agent @mention, create an AgentSession,
402
- // and fire AgentSessionEvent.created to our webhook.
403
- const commentId = await api.createComment(
404
- sessionIssueId!,
405
- "@ctclaw What is the status of this issue? [SMOKE TEST — ignore this]",
406
- );
407
- expect(commentId).toBeTruthy();
408
- createdCommentIds.push(commentId);
409
- console.log(`Posted @ctclaw mention comment: ${commentId}`);
410
-
411
- // Give Linear time to process the @mention → create session → fire webhook
412
- // → gateway receives → agent spawns → agent posts response
413
- console.log("Waiting 12s for webhook round-trip...");
414
- await new Promise((r) => setTimeout(r, 12000));
415
-
416
- // Check for agent activity on the issue
417
- const issue = await api.getIssueDetails(sessionIssueId!);
418
- const comments = issue.comments?.nodes ?? [];
419
-
420
- // Look for comments that aren't ours (agent responses)
421
- const agentComments = comments.filter(
422
- (c: any) =>
423
- !c.body?.includes("[SMOKE TEST]") &&
424
- c.id !== commentId,
425
- );
426
-
427
- if (agentComments.length > 0) {
428
- console.log(
429
- `@mention flow: ${agentComments.length} agent response(s) found — webhook flow confirmed!`,
430
- );
431
- // Preview first response
432
- const preview = agentComments[0].body?.slice(0, 120) ?? "(empty)";
433
- console.log(` First response: ${preview}...`);
434
- } else {
435
- console.log(
436
- "@mention flow: No agent response yet. Possible causes:\n" +
437
- " - @ctclaw not recognized as an agent mention by Linear\n" +
438
- " - OAuth app webhook not configured or not pointing to gateway\n" +
439
- " - Agent still processing (may take >12s for full response)\n" +
440
- " Check: journalctl --user -u openclaw-gateway --since '1 min ago'",
441
- );
442
- }
443
-
444
- expect(issue.id).toBe(sessionIssueId);
445
- }, 20_000);
446
-
447
- it("Path B: createSessionOnIssue via OAuth (programmatic)", async () => {
448
- expect(sessionIssueId).toBeTruthy();
449
-
450
- // agentSessionCreateOnIssue requires OAuth — personal API keys get 403
451
- const sessionApi = oauthApi ?? api;
452
- const result = await sessionApi.createSessionOnIssue(sessionIssueId!);
453
-
454
- if (result.error) {
455
- if (result.error.includes("apiKey") || result.error.includes("FORBIDDEN")) {
456
- console.warn(
457
- "Path B skipped: createSessionOnIssue requires OAuth token. " +
458
- "Personal API keys get 403. Ensure linear:default has a valid OAuth token.",
459
- );
460
- } else {
461
- console.warn(`createSessionOnIssue error: ${result.error}`);
462
- }
463
- }
464
- if (result.sessionId) {
465
- expect(typeof result.sessionId).toBe("string");
466
- console.log(`Path B: Agent session created: ${result.sessionId}`);
467
- }
468
- });
469
-
470
- it("cleans up agent session test issue", async () => {
471
- if (!sessionIssueId) return;
472
- try {
473
- const states = await api.getTeamStates(TEAM_ID);
474
- const canceledState = states.find(
475
- (s) => s.type === "canceled" || s.name.toLowerCase().includes("cancel"),
476
- );
477
- if (canceledState) {
478
- await api.updateIssue(sessionIssueId, { stateId: canceledState.id });
479
- }
480
- } catch {
481
- // Best effort
482
- }
483
- });
484
- });
485
-
486
- describe("sub-issue decomposition", () => {
487
- let parentIssueId: string | null = null;
488
- let parentIssueIdentifier: string | null = null;
489
- let subIssue1Id: string | null = null;
490
- let subIssue1Identifier: string | null = null;
491
- let subIssue2Id: string | null = null;
492
- let subIssue2Identifier: string | null = null;
493
- const recorded: Record<string, unknown> = {};
494
-
495
- let teamStates: Array<{ id: string; name: string; type: string }>;
496
-
497
- it("fetches team states for sub-issue tests", async () => {
498
- teamStates = await api.getTeamStates(TEAM_ID);
499
- expect(teamStates.length).toBeGreaterThan(0);
500
- recorded.teamStates = teamStates;
501
- });
502
-
503
- it("creates a parent issue", async () => {
504
- const backlogState = teamStates.find((s) => s.type === "backlog");
505
- expect(backlogState).toBeTruthy();
506
-
507
- const result = await api.createIssue({
508
- teamId: TEAM_ID,
509
- title: "[SMOKE TEST] Sub-Issue Parent: Search Feature",
510
- description:
511
- "Auto-generated by smoke test to verify sub-issue decomposition.\n\n" +
512
- "This parent issue should have two sub-issues created under it.\n\n" +
513
- `Created: ${new Date().toISOString()}`,
514
- stateId: backlogState!.id,
515
- priority: 4,
516
- });
517
-
518
- expect(result.id).toBeTruthy();
519
- expect(result.identifier).toBeTruthy();
520
- parentIssueId = result.id;
521
- parentIssueIdentifier = result.identifier;
522
- recorded.createParent = result;
523
- console.log(`Created parent issue: ${result.identifier} (${result.id})`);
524
- });
525
-
526
- it("creates sub-issue 1 under parent (Backend API)", async () => {
527
- expect(parentIssueId).toBeTruthy();
528
- const backlogState = teamStates.find((s) => s.type === "backlog");
529
-
530
- const result = await api.createIssue({
531
- teamId: TEAM_ID,
532
- title: "[SMOKE TEST] Sub-Issue 1: Backend API",
533
- description:
534
- "Implement the backend search API endpoint.\n\n" +
535
- "Given a search query, when the API is called, then matching results are returned.",
536
- stateId: backlogState!.id,
537
- parentId: parentIssueId!,
538
- priority: 3,
539
- estimate: 2,
540
- });
541
-
542
- expect(result.id).toBeTruthy();
543
- expect(result.identifier).toBeTruthy();
544
- subIssue1Id = result.id;
545
- subIssue1Identifier = result.identifier;
546
- recorded.createSubIssue1 = result;
547
- console.log(`Created sub-issue 1: ${result.identifier} (parentId=${parentIssueId})`);
548
- });
549
-
550
- it("creates sub-issue 2 under parent (Frontend UI)", async () => {
551
- expect(parentIssueId).toBeTruthy();
552
- const backlogState = teamStates.find((s) => s.type === "backlog");
553
-
554
- const result = await api.createIssue({
555
- teamId: TEAM_ID,
556
- title: "[SMOKE TEST] Sub-Issue 2: Frontend UI",
557
- description:
558
- "Build the frontend search UI component.\n\n" +
559
- "Given the search page loads, when the user types a query, then results display in real-time.",
560
- stateId: backlogState!.id,
561
- parentId: parentIssueId!,
562
- priority: 3,
563
- estimate: 3,
564
- });
565
-
566
- expect(result.id).toBeTruthy();
567
- expect(result.identifier).toBeTruthy();
568
- subIssue2Id = result.id;
569
- subIssue2Identifier = result.identifier;
570
- recorded.createSubIssue2 = result;
571
- console.log(`Created sub-issue 2: ${result.identifier} (parentId=${parentIssueId})`);
572
- });
573
-
574
- it("sub-issue 1 has parent field pointing to parent", async () => {
575
- expect(subIssue1Id).toBeTruthy();
576
- const details = await api.getIssueDetails(subIssue1Id!);
577
-
578
- expect(details.parent).not.toBeNull();
579
- expect(details.parent!.id).toBe(parentIssueId);
580
- expect(details.parent!.identifier).toBe(parentIssueIdentifier);
581
- recorded.subIssue1Details = details;
582
- console.log(`Sub-issue 1 parent: ${details.parent!.identifier}`);
583
- });
584
-
585
- it("sub-issue 2 has parent field pointing to parent", async () => {
586
- expect(subIssue2Id).toBeTruthy();
587
- const details = await api.getIssueDetails(subIssue2Id!);
588
-
589
- expect(details.parent).not.toBeNull();
590
- expect(details.parent!.id).toBe(parentIssueId);
591
- expect(details.parent!.identifier).toBe(parentIssueIdentifier);
592
- recorded.subIssue2Details = details;
593
- console.log(`Sub-issue 2 parent: ${details.parent!.identifier}`);
594
- });
595
-
596
- it("parent issue has no parent (it is the root)", async () => {
597
- expect(parentIssueId).toBeTruthy();
598
- const details = await api.getIssueDetails(parentIssueId!);
599
-
600
- expect(details.parent).toBeNull();
601
- recorded.parentDetails = details;
602
- });
603
-
604
- it("creates blocks relation (sub-issue 1 blocks sub-issue 2)", async () => {
605
- expect(subIssue1Id).toBeTruthy();
606
- expect(subIssue2Id).toBeTruthy();
607
-
608
- const result = await api.createIssueRelation({
609
- issueId: subIssue1Id!,
610
- relatedIssueId: subIssue2Id!,
611
- type: "blocks",
612
- });
613
-
614
- expect(result.id).toBeTruthy();
615
- recorded.createRelation = result;
616
- console.log(`Created blocks relation: ${subIssue1Identifier} blocks ${subIssue2Identifier}`);
617
- });
618
-
619
- it("sub-issue 1 shows blocks relation to sub-issue 2", async () => {
620
- expect(subIssue1Id).toBeTruthy();
621
- const details = await api.getIssueDetails(subIssue1Id!);
622
- const blocksRels = details.relations.nodes.filter(
623
- (r) => r.type === "blocks",
624
- );
625
-
626
- expect(blocksRels.length).toBeGreaterThan(0);
627
- expect(
628
- blocksRels.some((r) => r.relatedIssue.id === subIssue2Id),
629
- ).toBe(true);
630
- recorded.subIssue1WithRelation = details;
631
- console.log(`Sub-issue 1 blocks: ${blocksRels.map((r) => r.relatedIssue.identifier).join(", ")}`);
632
- });
633
-
634
- it("sub-issue 2 shows inverse relation", async () => {
635
- expect(subIssue2Id).toBeTruthy();
636
- const details = await api.getIssueDetails(subIssue2Id!);
637
-
638
- // Linear may or may not populate an inverse "is_blocked_by" relation.
639
- // Record whatever we get — the mock test replays it as-is.
640
- recorded.subIssue2WithRelation = details;
641
- const rels = details.relations.nodes;
642
- console.log(
643
- `Sub-issue 2 relations: ${rels.length > 0 ? rels.map((r) => `${r.type} ${r.relatedIssue.identifier}`).join(", ") : "(none — inverse may not be returned)"}`,
644
- );
645
- });
646
-
647
- it("saves recorded API responses to fixture file", () => {
648
- // Only write if we actually ran the full flow
649
- if (!parentIssueId || !subIssue1Id || !subIssue2Id) {
650
- console.log("Skipping fixture write — not all issues were created.");
651
- return;
652
- }
653
-
654
- const fixturePath = join(
655
- __dirname,
656
- "fixtures",
657
- "recorded-sub-issue-flow.ts",
658
- );
659
- const content =
660
- `/**\n` +
661
- ` * Recorded API responses from sub-issue decomposition smoke test.\n` +
662
- ` * Auto-generated — do not edit manually.\n` +
663
- ` * Re-generate by running: npx vitest run src/__test__/smoke-linear-api.test.ts\n` +
664
- ` * Last recorded: ${new Date().toISOString()}\n` +
665
- ` */\n\n` +
666
- `export const RECORDED = ${JSON.stringify(recorded, null, 2)};\n`;
667
-
668
- writeFileSync(fixturePath, content, "utf8");
669
- console.log(`Recorded fixture written to: ${fixturePath}`);
670
- });
671
-
672
- it("cleans up: cancels parent and sub-issues", async () => {
673
- const canceledState = teamStates?.find(
674
- (s) => s.type === "canceled" || s.name.toLowerCase().includes("cancel"),
675
- );
676
-
677
- for (const id of [subIssue1Id, subIssue2Id, parentIssueId]) {
678
- if (!id || !canceledState) continue;
679
- try {
680
- await api.updateIssue(id, { stateId: canceledState.id });
681
- } catch {
682
- // Best effort
683
- }
684
- }
685
- });
686
- });
687
-
688
- // ---------------------------------------------------------------------------
689
- // Tool-level sub-issue creation (linear_issues tool)
690
- // ---------------------------------------------------------------------------
691
-
692
- describe("tool-level sub-issue creation (linear_issues tool)", () => {
693
- let toolParentIdentifier: string | null = null;
694
- let toolSubIdentifier: string | null = null;
695
- let toolParentId: string | null = null;
696
- let toolSubId: string | null = null;
697
- let tool: any;
698
-
699
- function parseToolResult(result: any): any {
700
- if (result?.content && Array.isArray(result.content)) {
701
- const textBlock = result.content.find((r: any) => r.type === "text");
702
- if (textBlock) return JSON.parse(textBlock.text);
703
- }
704
- if (result?.details) return result.details;
705
- return typeof result === "string" ? JSON.parse(result) : result;
706
- }
707
-
708
- it("instantiates linear_issues tool with real credentials", () => {
709
- const apiKey = loadApiKey();
710
- const pluginApi = {
711
- logger: {
712
- info: (...args: any[]) => console.log("[tool-smoke]", ...args),
713
- warn: (...args: any[]) => console.warn("[tool-smoke]", ...args),
714
- error: (...args: any[]) => console.error("[tool-smoke]", ...args),
715
- debug: () => {},
716
- },
717
- pluginConfig: { accessToken: apiKey },
718
- };
719
- tool = createLinearIssuesTool(pluginApi as any);
720
- expect(tool).toBeTruthy();
721
- expect(tool.name).toBe("linear_issues");
722
- });
723
-
724
- it("creates a parent issue via tool action=create", async () => {
725
- const result = parseToolResult(
726
- await tool.execute("smoke-call-1", {
727
- action: "create",
728
- title: "[SMOKE TEST] Tool Sub-Issue Parent",
729
- description:
730
- "Auto-generated by tool-level smoke test.\n" +
731
- "Tests linear_issues tool can create parent + sub-issues.\n\n" +
732
- `Created: ${new Date().toISOString()}`,
733
- teamId: TEAM_ID,
734
- priority: 4,
735
- }),
736
- );
737
-
738
- expect(result.error).toBeUndefined();
739
- expect(result.success).toBe(true);
740
- expect(result.identifier).toBeTruthy();
741
- expect(result.id).toBeTruthy();
742
-
743
- toolParentIdentifier = result.identifier;
744
- toolParentId = result.id;
745
- console.log(`Tool created parent: ${result.identifier} (${result.id})`);
746
- });
747
-
748
- it("reads parent issue via tool action=read (by identifier)", async () => {
749
- expect(toolParentIdentifier).toBeTruthy();
750
-
751
- const result = parseToolResult(
752
- await tool.execute("smoke-call-2", {
753
- action: "read",
754
- issueId: toolParentIdentifier!,
755
- }),
756
- );
757
-
758
- expect(result.error).toBeUndefined();
759
- expect(result.identifier).toBe(toolParentIdentifier);
760
- expect(result.title).toContain("[SMOKE TEST]");
761
- expect(result.team.id).toBe(TEAM_ID);
762
- expect(result.parent).toBeNull();
763
- console.log(`Tool read parent: ${result.identifier} (status=${result.status})`);
764
- });
765
-
766
- it("creates a sub-issue via tool action=create with parentIssueId (identifier)", async () => {
767
- expect(toolParentIdentifier).toBeTruthy();
768
-
769
- const result = parseToolResult(
770
- await tool.execute("smoke-call-3", {
771
- action: "create",
772
- title: "[SMOKE TEST] Tool Sub-Issue: Backend work",
773
- description:
774
- "Sub-issue created via linear_issues tool with parentIssueId.\n" +
775
- "Verifies identifier → UUID resolution and teamId inheritance.",
776
- parentIssueId: toolParentIdentifier!,
777
- priority: 3,
778
- estimate: 2,
779
- }),
780
- );
781
-
782
- expect(result.error).toBeUndefined();
783
- expect(result.success).toBe(true);
784
- expect(result.identifier).toBeTruthy();
785
- expect(result.id).toBeTruthy();
786
- expect(result.parentIssueId).toBe(toolParentIdentifier);
787
-
788
- toolSubIdentifier = result.identifier;
789
- toolSubId = result.id;
790
- console.log(`Tool created sub-issue: ${result.identifier} (parent=${toolParentIdentifier})`);
791
- });
792
-
793
- it("verifies sub-issue has correct parent via tool action=read", async () => {
794
- expect(toolSubIdentifier).toBeTruthy();
795
-
796
- const result = parseToolResult(
797
- await tool.execute("smoke-call-4", {
798
- action: "read",
799
- issueId: toolSubIdentifier!,
800
- }),
801
- );
802
-
803
- expect(result.error).toBeUndefined();
804
- expect(result.identifier).toBe(toolSubIdentifier);
805
- expect(result.parent).not.toBeNull();
806
- expect(result.parent.identifier).toBe(toolParentIdentifier);
807
- // teamId was inherited from parent (not provided explicitly in create)
808
- expect(result.team.id).toBe(TEAM_ID);
809
- console.log(`Tool sub-issue parent confirmed: ${result.parent.identifier}`);
810
- });
811
-
812
- it("cleans up: cancels tool-created issues", async () => {
813
- const states = await api.getTeamStates(TEAM_ID);
814
- const canceledState = states.find(
815
- (s) => s.type === "canceled" || s.name.toLowerCase().includes("cancel"),
816
- );
817
-
818
- for (const id of [toolSubId, toolParentId]) {
819
- if (!id || !canceledState) continue;
820
- try {
821
- await api.updateIssue(id, { stateId: canceledState.id });
822
- } catch {
823
- // Best effort
824
- }
825
- }
826
- });
827
- });
828
-
829
- describe("cleanup", () => {
830
- it("cancels the smoke test issue", async () => {
831
- if (!smokeIssueId) return;
832
-
833
- // Move to cancelled state if available, otherwise just leave it
834
- try {
835
- const states = await api.getTeamStates(TEAM_ID);
836
- const cancelledState = states.find(
837
- (s) => s.type === "cancelled" || s.name.toLowerCase().includes("cancel"),
838
- );
839
- if (cancelledState) {
840
- await api.updateIssue(smokeIssueId, { stateId: cancelledState.id });
841
- }
842
- } catch {
843
- // Best effort cleanup
844
- }
845
- });
846
- });
847
- });