@calltelemetry/openclaw-linear 0.9.4 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Recorded API responses from sub-issue decomposition smoke test.
3
+ * Auto-generated — do not edit manually.
4
+ * Re-generate by running: npx vitest run src/__test__/smoke-linear-api.test.ts
5
+ * Last recorded: 2026-02-22T01:35:48.289Z
6
+ */
7
+
8
+ export const RECORDED = {
9
+ "teamStates": [
10
+ {
11
+ "id": "cf937197-a76e-4f36-9938-5013442f2137",
12
+ "name": "Todo",
13
+ "type": "unstarted"
14
+ },
15
+ {
16
+ "id": "9e40430a-5404-4fed-be64-544417c7c46c",
17
+ "name": "Duplicate",
18
+ "type": "canceled"
19
+ },
20
+ {
21
+ "id": "9bf913d6-f32d-452f-af43-3139cc8e5f3f",
22
+ "name": "Canceled",
23
+ "type": "canceled"
24
+ },
25
+ {
26
+ "id": "7510263f-555b-42c4-98c3-f4196d755c4b",
27
+ "name": "In Progress",
28
+ "type": "started"
29
+ },
30
+ {
31
+ "id": "6bcb2601-dc7b-487e-a407-416521aa235e",
32
+ "name": "Backlog",
33
+ "type": "backlog"
34
+ },
35
+ {
36
+ "id": "69c39ddf-d135-47f7-8dbe-3fc6a539d504",
37
+ "name": "Done",
38
+ "type": "completed"
39
+ },
40
+ {
41
+ "id": "2a4c7f41-80cd-4798-91e7-0aa6656d73ca",
42
+ "name": "In Review",
43
+ "type": "started"
44
+ }
45
+ ],
46
+ "createParent": {
47
+ "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
48
+ "identifier": "UAT-235"
49
+ },
50
+ "createSubIssue1": {
51
+ "id": "2796434d-9c1e-4ee9-af70-6e0e14fe6109",
52
+ "identifier": "UAT-236"
53
+ },
54
+ "createSubIssue2": {
55
+ "id": "d1b939d0-33f1-4f6d-992a-95f2a3cb842a",
56
+ "identifier": "UAT-237"
57
+ },
58
+ "subIssue1Details": {
59
+ "id": "2796434d-9c1e-4ee9-af70-6e0e14fe6109",
60
+ "identifier": "UAT-236",
61
+ "title": "[SMOKE TEST] Sub-Issue 1: Backend API",
62
+ "description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
63
+ "estimate": 2,
64
+ "state": {
65
+ "name": "Backlog",
66
+ "type": "backlog"
67
+ },
68
+ "creator": {
69
+ "name": "jason.barbee@calltelemetry.com",
70
+ "email": "jason.barbee@calltelemetry.com"
71
+ },
72
+ "assignee": null,
73
+ "labels": {
74
+ "nodes": []
75
+ },
76
+ "team": {
77
+ "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
78
+ "name": "UAT",
79
+ "issueEstimationType": "tShirt"
80
+ },
81
+ "comments": {
82
+ "nodes": []
83
+ },
84
+ "project": null,
85
+ "parent": {
86
+ "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
87
+ "identifier": "UAT-235"
88
+ },
89
+ "relations": {
90
+ "nodes": []
91
+ }
92
+ },
93
+ "subIssue2Details": {
94
+ "id": "d1b939d0-33f1-4f6d-992a-95f2a3cb842a",
95
+ "identifier": "UAT-237",
96
+ "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
97
+ "description": "Build the frontend search UI component.\n\nGiven the search page loads, when the user types a query, then results display in real-time.",
98
+ "estimate": 3,
99
+ "state": {
100
+ "name": "Backlog",
101
+ "type": "backlog"
102
+ },
103
+ "creator": {
104
+ "name": "jason.barbee@calltelemetry.com",
105
+ "email": "jason.barbee@calltelemetry.com"
106
+ },
107
+ "assignee": null,
108
+ "labels": {
109
+ "nodes": []
110
+ },
111
+ "team": {
112
+ "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
113
+ "name": "UAT",
114
+ "issueEstimationType": "tShirt"
115
+ },
116
+ "comments": {
117
+ "nodes": []
118
+ },
119
+ "project": null,
120
+ "parent": {
121
+ "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
122
+ "identifier": "UAT-235"
123
+ },
124
+ "relations": {
125
+ "nodes": []
126
+ }
127
+ },
128
+ "parentDetails": {
129
+ "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
130
+ "identifier": "UAT-235",
131
+ "title": "[SMOKE TEST] Sub-Issue Parent: Search Feature",
132
+ "description": "Auto-generated by smoke test to verify sub-issue decomposition.\n\nThis parent issue should have two sub-issues created under it.\n\nCreated: 2026-02-22T01:35:46.183Z",
133
+ "estimate": null,
134
+ "state": {
135
+ "name": "Backlog",
136
+ "type": "backlog"
137
+ },
138
+ "creator": {
139
+ "name": "jason.barbee@calltelemetry.com",
140
+ "email": "jason.barbee@calltelemetry.com"
141
+ },
142
+ "assignee": null,
143
+ "labels": {
144
+ "nodes": []
145
+ },
146
+ "team": {
147
+ "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
148
+ "name": "UAT",
149
+ "issueEstimationType": "tShirt"
150
+ },
151
+ "comments": {
152
+ "nodes": [
153
+ {
154
+ "body": "This thread is for an agent session with ctclaw.",
155
+ "user": null,
156
+ "createdAt": "2026-02-22T01:35:47.210Z"
157
+ }
158
+ ]
159
+ },
160
+ "project": null,
161
+ "parent": null,
162
+ "relations": {
163
+ "nodes": []
164
+ }
165
+ },
166
+ "createRelation": {
167
+ "id": "f2b33a57-b79a-4dcc-b973-a5453676a78f"
168
+ },
169
+ "subIssue1WithRelation": {
170
+ "id": "2796434d-9c1e-4ee9-af70-6e0e14fe6109",
171
+ "identifier": "UAT-236",
172
+ "title": "[SMOKE TEST] Sub-Issue 1: Backend API",
173
+ "description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
174
+ "estimate": 2,
175
+ "state": {
176
+ "name": "Backlog",
177
+ "type": "backlog"
178
+ },
179
+ "creator": {
180
+ "name": "jason.barbee@calltelemetry.com",
181
+ "email": "jason.barbee@calltelemetry.com"
182
+ },
183
+ "assignee": null,
184
+ "labels": {
185
+ "nodes": []
186
+ },
187
+ "team": {
188
+ "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
189
+ "name": "UAT",
190
+ "issueEstimationType": "tShirt"
191
+ },
192
+ "comments": {
193
+ "nodes": [
194
+ {
195
+ "body": "This thread is for an agent session with ctclaw.",
196
+ "user": null,
197
+ "createdAt": "2026-02-22T01:35:47.302Z"
198
+ }
199
+ ]
200
+ },
201
+ "project": null,
202
+ "parent": {
203
+ "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
204
+ "identifier": "UAT-235"
205
+ },
206
+ "relations": {
207
+ "nodes": [
208
+ {
209
+ "type": "blocks",
210
+ "relatedIssue": {
211
+ "id": "d1b939d0-33f1-4f6d-992a-95f2a3cb842a",
212
+ "identifier": "UAT-237",
213
+ "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI"
214
+ }
215
+ }
216
+ ]
217
+ }
218
+ },
219
+ "subIssue2WithRelation": {
220
+ "id": "d1b939d0-33f1-4f6d-992a-95f2a3cb842a",
221
+ "identifier": "UAT-237",
222
+ "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
223
+ "description": "Build the frontend search UI component.\n\nGiven the search page loads, when the user types a query, then results display in real-time.",
224
+ "estimate": 3,
225
+ "state": {
226
+ "name": "Backlog",
227
+ "type": "backlog"
228
+ },
229
+ "creator": {
230
+ "name": "jason.barbee@calltelemetry.com",
231
+ "email": "jason.barbee@calltelemetry.com"
232
+ },
233
+ "assignee": null,
234
+ "labels": {
235
+ "nodes": []
236
+ },
237
+ "team": {
238
+ "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
239
+ "name": "UAT",
240
+ "issueEstimationType": "tShirt"
241
+ },
242
+ "comments": {
243
+ "nodes": []
244
+ },
245
+ "project": null,
246
+ "parent": {
247
+ "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
248
+ "identifier": "UAT-235"
249
+ },
250
+ "relations": {
251
+ "nodes": []
252
+ }
253
+ }
254
+ };
@@ -8,12 +8,15 @@
8
8
  *
9
9
  * Requires: ~/.openclaw/auth-profiles.json with a valid linear:api-key profile.
10
10
  */
11
- import { readFileSync } from "node:fs";
12
- import { join } from "node:path";
11
+ import { readFileSync, writeFileSync } from "node:fs";
12
+ import { join, dirname } from "node:path";
13
13
  import { homedir } from "node:os";
14
+ import { fileURLToPath } from "node:url";
14
15
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
15
16
  import { LinearAgentApi } from "../api/linear-api.js";
16
17
 
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+
17
20
  // ── Setup ──────────────────────────────────────────────────────────
18
21
 
19
22
  const AUTH_PROFILES_PATH = join(
@@ -479,6 +482,208 @@ describe("Linear API smoke tests", () => {
479
482
  });
480
483
  });
481
484
 
485
+ describe("sub-issue decomposition", () => {
486
+ let parentIssueId: string | null = null;
487
+ let parentIssueIdentifier: string | null = null;
488
+ let subIssue1Id: string | null = null;
489
+ let subIssue1Identifier: string | null = null;
490
+ let subIssue2Id: string | null = null;
491
+ let subIssue2Identifier: string | null = null;
492
+ const recorded: Record<string, unknown> = {};
493
+
494
+ let teamStates: Array<{ id: string; name: string; type: string }>;
495
+
496
+ it("fetches team states for sub-issue tests", async () => {
497
+ teamStates = await api.getTeamStates(TEAM_ID);
498
+ expect(teamStates.length).toBeGreaterThan(0);
499
+ recorded.teamStates = teamStates;
500
+ });
501
+
502
+ it("creates a parent issue", async () => {
503
+ const backlogState = teamStates.find((s) => s.type === "backlog");
504
+ expect(backlogState).toBeTruthy();
505
+
506
+ const result = await api.createIssue({
507
+ teamId: TEAM_ID,
508
+ title: "[SMOKE TEST] Sub-Issue Parent: Search Feature",
509
+ description:
510
+ "Auto-generated by smoke test to verify sub-issue decomposition.\n\n" +
511
+ "This parent issue should have two sub-issues created under it.\n\n" +
512
+ `Created: ${new Date().toISOString()}`,
513
+ stateId: backlogState!.id,
514
+ priority: 4,
515
+ });
516
+
517
+ expect(result.id).toBeTruthy();
518
+ expect(result.identifier).toBeTruthy();
519
+ parentIssueId = result.id;
520
+ parentIssueIdentifier = result.identifier;
521
+ recorded.createParent = result;
522
+ console.log(`Created parent issue: ${result.identifier} (${result.id})`);
523
+ });
524
+
525
+ it("creates sub-issue 1 under parent (Backend API)", async () => {
526
+ expect(parentIssueId).toBeTruthy();
527
+ const backlogState = teamStates.find((s) => s.type === "backlog");
528
+
529
+ const result = await api.createIssue({
530
+ teamId: TEAM_ID,
531
+ title: "[SMOKE TEST] Sub-Issue 1: Backend API",
532
+ description:
533
+ "Implement the backend search API endpoint.\n\n" +
534
+ "Given a search query, when the API is called, then matching results are returned.",
535
+ stateId: backlogState!.id,
536
+ parentId: parentIssueId!,
537
+ priority: 3,
538
+ estimate: 2,
539
+ });
540
+
541
+ expect(result.id).toBeTruthy();
542
+ expect(result.identifier).toBeTruthy();
543
+ subIssue1Id = result.id;
544
+ subIssue1Identifier = result.identifier;
545
+ recorded.createSubIssue1 = result;
546
+ console.log(`Created sub-issue 1: ${result.identifier} (parentId=${parentIssueId})`);
547
+ });
548
+
549
+ it("creates sub-issue 2 under parent (Frontend UI)", async () => {
550
+ expect(parentIssueId).toBeTruthy();
551
+ const backlogState = teamStates.find((s) => s.type === "backlog");
552
+
553
+ const result = await api.createIssue({
554
+ teamId: TEAM_ID,
555
+ title: "[SMOKE TEST] Sub-Issue 2: Frontend UI",
556
+ description:
557
+ "Build the frontend search UI component.\n\n" +
558
+ "Given the search page loads, when the user types a query, then results display in real-time.",
559
+ stateId: backlogState!.id,
560
+ parentId: parentIssueId!,
561
+ priority: 3,
562
+ estimate: 3,
563
+ });
564
+
565
+ expect(result.id).toBeTruthy();
566
+ expect(result.identifier).toBeTruthy();
567
+ subIssue2Id = result.id;
568
+ subIssue2Identifier = result.identifier;
569
+ recorded.createSubIssue2 = result;
570
+ console.log(`Created sub-issue 2: ${result.identifier} (parentId=${parentIssueId})`);
571
+ });
572
+
573
+ it("sub-issue 1 has parent field pointing to parent", async () => {
574
+ expect(subIssue1Id).toBeTruthy();
575
+ const details = await api.getIssueDetails(subIssue1Id!);
576
+
577
+ expect(details.parent).not.toBeNull();
578
+ expect(details.parent!.id).toBe(parentIssueId);
579
+ expect(details.parent!.identifier).toBe(parentIssueIdentifier);
580
+ recorded.subIssue1Details = details;
581
+ console.log(`Sub-issue 1 parent: ${details.parent!.identifier}`);
582
+ });
583
+
584
+ it("sub-issue 2 has parent field pointing to parent", async () => {
585
+ expect(subIssue2Id).toBeTruthy();
586
+ const details = await api.getIssueDetails(subIssue2Id!);
587
+
588
+ expect(details.parent).not.toBeNull();
589
+ expect(details.parent!.id).toBe(parentIssueId);
590
+ expect(details.parent!.identifier).toBe(parentIssueIdentifier);
591
+ recorded.subIssue2Details = details;
592
+ console.log(`Sub-issue 2 parent: ${details.parent!.identifier}`);
593
+ });
594
+
595
+ it("parent issue has no parent (it is the root)", async () => {
596
+ expect(parentIssueId).toBeTruthy();
597
+ const details = await api.getIssueDetails(parentIssueId!);
598
+
599
+ expect(details.parent).toBeNull();
600
+ recorded.parentDetails = details;
601
+ });
602
+
603
+ it("creates blocks relation (sub-issue 1 blocks sub-issue 2)", async () => {
604
+ expect(subIssue1Id).toBeTruthy();
605
+ expect(subIssue2Id).toBeTruthy();
606
+
607
+ const result = await api.createIssueRelation({
608
+ issueId: subIssue1Id!,
609
+ relatedIssueId: subIssue2Id!,
610
+ type: "blocks",
611
+ });
612
+
613
+ expect(result.id).toBeTruthy();
614
+ recorded.createRelation = result;
615
+ console.log(`Created blocks relation: ${subIssue1Identifier} blocks ${subIssue2Identifier}`);
616
+ });
617
+
618
+ it("sub-issue 1 shows blocks relation to sub-issue 2", async () => {
619
+ expect(subIssue1Id).toBeTruthy();
620
+ const details = await api.getIssueDetails(subIssue1Id!);
621
+ const blocksRels = details.relations.nodes.filter(
622
+ (r) => r.type === "blocks",
623
+ );
624
+
625
+ expect(blocksRels.length).toBeGreaterThan(0);
626
+ expect(
627
+ blocksRels.some((r) => r.relatedIssue.id === subIssue2Id),
628
+ ).toBe(true);
629
+ recorded.subIssue1WithRelation = details;
630
+ console.log(`Sub-issue 1 blocks: ${blocksRels.map((r) => r.relatedIssue.identifier).join(", ")}`);
631
+ });
632
+
633
+ it("sub-issue 2 shows inverse relation", async () => {
634
+ expect(subIssue2Id).toBeTruthy();
635
+ const details = await api.getIssueDetails(subIssue2Id!);
636
+
637
+ // Linear may or may not populate an inverse "is_blocked_by" relation.
638
+ // Record whatever we get — the mock test replays it as-is.
639
+ recorded.subIssue2WithRelation = details;
640
+ const rels = details.relations.nodes;
641
+ console.log(
642
+ `Sub-issue 2 relations: ${rels.length > 0 ? rels.map((r) => `${r.type} ${r.relatedIssue.identifier}`).join(", ") : "(none — inverse may not be returned)"}`,
643
+ );
644
+ });
645
+
646
+ it("saves recorded API responses to fixture file", () => {
647
+ // Only write if we actually ran the full flow
648
+ if (!parentIssueId || !subIssue1Id || !subIssue2Id) {
649
+ console.log("Skipping fixture write — not all issues were created.");
650
+ return;
651
+ }
652
+
653
+ const fixturePath = join(
654
+ __dirname,
655
+ "fixtures",
656
+ "recorded-sub-issue-flow.ts",
657
+ );
658
+ const content =
659
+ `/**\n` +
660
+ ` * Recorded API responses from sub-issue decomposition smoke test.\n` +
661
+ ` * Auto-generated — do not edit manually.\n` +
662
+ ` * Re-generate by running: npx vitest run src/__test__/smoke-linear-api.test.ts\n` +
663
+ ` * Last recorded: ${new Date().toISOString()}\n` +
664
+ ` */\n\n` +
665
+ `export const RECORDED = ${JSON.stringify(recorded, null, 2)};\n`;
666
+
667
+ writeFileSync(fixturePath, content, "utf8");
668
+ console.log(`Recorded fixture written to: ${fixturePath}`);
669
+ });
670
+
671
+ it("cleans up: cancels parent and sub-issues", async () => {
672
+ const canceledState = teamStates?.find(
673
+ (s) => s.type === "canceled" || s.name.toLowerCase().includes("cancel"),
674
+ );
675
+
676
+ for (const id of [subIssue1Id, subIssue2Id, parentIssueId]) {
677
+ if (!id || !canceledState) continue;
678
+ try {
679
+ await api.updateIssue(id, { stateId: canceledState.id });
680
+ } catch {
681
+ // Best effort
682
+ }
683
+ }
684
+ });
685
+ });
686
+
482
687
  describe("cleanup", () => {
483
688
  it("cancels the smoke test issue", async () => {
484
689
  if (!smokeIssueId) return;
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { readFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
+ import { homedir } from "node:os";
10
11
 
11
12
  // ---------------------------------------------------------------------------
12
13
  // Defaults (seconds — matches config units)