@calltelemetry/openclaw-linear 0.9.5 → 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,172 +1,254 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Recorded API responses from sub-issue decomposition smoke test.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Last recorded: seed (placeholder)
|
|
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
|
|
8
6
|
*/
|
|
9
7
|
|
|
10
8
|
export const RECORDED = {
|
|
11
|
-
teamStates: [
|
|
12
|
-
{
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
user: { name: string } | null;
|
|
37
|
-
createdAt: string;
|
|
38
|
-
}>,
|
|
39
|
-
},
|
|
40
|
-
project: null as { id: string; name: string } | null,
|
|
41
|
-
parent: null as { id: string; identifier: string } | null,
|
|
42
|
-
relations: {
|
|
43
|
-
nodes: [] as Array<{
|
|
44
|
-
type: string;
|
|
45
|
-
relatedIssue: { id: string; identifier: string; title: string };
|
|
46
|
-
}>,
|
|
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"
|
|
47
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"
|
|
48
53
|
},
|
|
49
|
-
|
|
50
|
-
id: "
|
|
51
|
-
identifier: "UAT-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
createdAt: string;
|
|
67
|
-
}>,
|
|
68
|
-
},
|
|
69
|
-
project: null as { id: string; name: string } | null,
|
|
70
|
-
parent: { id: "parent-001", identifier: "UAT-100" },
|
|
71
|
-
relations: {
|
|
72
|
-
nodes: [] as Array<{
|
|
73
|
-
type: string;
|
|
74
|
-
relatedIssue: { id: string; identifier: string; title: string };
|
|
75
|
-
}>,
|
|
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"
|
|
76
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
|
+
}
|
|
77
92
|
},
|
|
78
|
-
subIssue2Details: {
|
|
79
|
-
id: "
|
|
80
|
-
identifier: "UAT-
|
|
81
|
-
title: "[SMOKE TEST] Sub-Issue 2: Frontend UI",
|
|
82
|
-
description:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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"
|
|
105
123
|
},
|
|
124
|
+
"relations": {
|
|
125
|
+
"nodes": []
|
|
126
|
+
}
|
|
106
127
|
},
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
creator: {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
nodes: [
|
|
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": [
|
|
132
153
|
{
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
},
|
|
139
|
-
},
|
|
140
|
-
],
|
|
154
|
+
"body": "This thread is for an agent session with ctclaw.",
|
|
155
|
+
"user": null,
|
|
156
|
+
"createdAt": "2026-02-22T01:35:47.210Z"
|
|
157
|
+
}
|
|
158
|
+
]
|
|
141
159
|
},
|
|
160
|
+
"project": null,
|
|
161
|
+
"parent": null,
|
|
162
|
+
"relations": {
|
|
163
|
+
"nodes": []
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"createRelation": {
|
|
167
|
+
"id": "f2b33a57-b79a-4dcc-b973-a5453676a78f"
|
|
142
168
|
},
|
|
143
|
-
|
|
144
|
-
id: "
|
|
145
|
-
identifier: "UAT-
|
|
146
|
-
title: "[SMOKE TEST] Sub-Issue
|
|
147
|
-
description:
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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"
|
|
170
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
|
+
}
|
|
171
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
|
+
}
|
|
172
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;
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sub-issue-decomposition.test.ts — Mock replay of sub-issue creation flow.
|
|
3
|
+
*
|
|
4
|
+
* Uses recorded API responses from the smoke test to verify parent-child
|
|
5
|
+
* hierarchy creation, parentId resolution, and issue relation handling.
|
|
6
|
+
*
|
|
7
|
+
* Run with: npx vitest run src/pipeline/sub-issue-decomposition.test.ts
|
|
8
|
+
* No credentials required — all API calls use recorded fixtures.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
11
|
+
|
|
12
|
+
// Mock external dependencies before imports
|
|
13
|
+
vi.mock("openclaw/plugin-sdk", () => ({
|
|
14
|
+
jsonResult: (data: any) => ({ type: "json", data }),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("../api/linear-api.js", () => ({
|
|
18
|
+
LinearAgentApi: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { RECORDED } from "../__test__/fixtures/recorded-sub-issue-flow.js";
|
|
22
|
+
import {
|
|
23
|
+
createPlannerTools,
|
|
24
|
+
setActivePlannerContext,
|
|
25
|
+
clearActivePlannerContext,
|
|
26
|
+
detectCycles,
|
|
27
|
+
auditPlan,
|
|
28
|
+
buildPlanSnapshot,
|
|
29
|
+
} from "../tools/planner-tools.js";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
type ProjectIssue = Parameters<typeof detectCycles>[0][number];
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Helpers
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
function createReplayApi() {
|
|
42
|
+
const api = {
|
|
43
|
+
getTeamStates: vi.fn().mockResolvedValue(RECORDED.teamStates),
|
|
44
|
+
createIssue: vi.fn(),
|
|
45
|
+
getIssueDetails: vi.fn(),
|
|
46
|
+
createIssueRelation: vi.fn().mockResolvedValue(RECORDED.createRelation),
|
|
47
|
+
getProjectIssues: vi.fn(),
|
|
48
|
+
getTeamLabels: vi.fn().mockResolvedValue([]),
|
|
49
|
+
updateIssue: vi.fn().mockResolvedValue(true),
|
|
50
|
+
updateIssueExtended: vi.fn().mockResolvedValue(true),
|
|
51
|
+
getViewerId: vi.fn().mockResolvedValue("viewer-1"),
|
|
52
|
+
createComment: vi.fn().mockResolvedValue("comment-id"),
|
|
53
|
+
emitActivity: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
updateSession: vi.fn().mockResolvedValue(undefined),
|
|
55
|
+
getProject: vi.fn().mockResolvedValue({
|
|
56
|
+
id: "proj-1",
|
|
57
|
+
name: "Test",
|
|
58
|
+
description: "",
|
|
59
|
+
state: "started",
|
|
60
|
+
teams: {
|
|
61
|
+
nodes: [
|
|
62
|
+
{
|
|
63
|
+
id: RECORDED.parentDetails.team.id,
|
|
64
|
+
name: RECORDED.parentDetails.team.name,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Wire up getIssueDetails to return recorded response by ID
|
|
72
|
+
api.getIssueDetails.mockImplementation((id: string) => {
|
|
73
|
+
if (id === RECORDED.createParent.id)
|
|
74
|
+
return Promise.resolve(RECORDED.parentDetails);
|
|
75
|
+
if (id === RECORDED.createSubIssue1.id)
|
|
76
|
+
return Promise.resolve(RECORDED.subIssue1WithRelation);
|
|
77
|
+
if (id === RECORDED.createSubIssue2.id)
|
|
78
|
+
return Promise.resolve(RECORDED.subIssue2WithRelation);
|
|
79
|
+
throw new Error(`Unexpected issue ID in replay: ${id}`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return api;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build a ProjectIssue from recorded detail shapes. */
|
|
86
|
+
function recordedToProjectIssue(
|
|
87
|
+
detail: typeof RECORDED.parentDetails,
|
|
88
|
+
overrides?: Partial<ProjectIssue>,
|
|
89
|
+
): ProjectIssue {
|
|
90
|
+
return {
|
|
91
|
+
id: detail.id,
|
|
92
|
+
identifier: detail.identifier,
|
|
93
|
+
title: detail.title,
|
|
94
|
+
description: detail.description,
|
|
95
|
+
estimate: detail.estimate,
|
|
96
|
+
priority: 0,
|
|
97
|
+
state: detail.state,
|
|
98
|
+
parent: detail.parent,
|
|
99
|
+
labels: detail.labels,
|
|
100
|
+
relations: detail.relations,
|
|
101
|
+
...overrides,
|
|
102
|
+
} as ProjectIssue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ===========================================================================
|
|
106
|
+
// Group A: Direct API hierarchy (mock createIssue / getIssueDetails)
|
|
107
|
+
// ===========================================================================
|
|
108
|
+
|
|
109
|
+
describe("sub-issue decomposition (recorded replay)", () => {
|
|
110
|
+
describe("parent-child hierarchy via direct API", () => {
|
|
111
|
+
it("createIssue with parentId creates a sub-issue", async () => {
|
|
112
|
+
const api = createReplayApi();
|
|
113
|
+
api.createIssue.mockResolvedValueOnce(RECORDED.createSubIssue1);
|
|
114
|
+
|
|
115
|
+
const result = await api.createIssue({
|
|
116
|
+
teamId: RECORDED.parentDetails.team.id,
|
|
117
|
+
title: RECORDED.subIssue1Details.title,
|
|
118
|
+
parentId: RECORDED.createParent.id,
|
|
119
|
+
estimate: 2,
|
|
120
|
+
priority: 3,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result.id).toBe(RECORDED.createSubIssue1.id);
|
|
124
|
+
expect(result.identifier).toBe(RECORDED.createSubIssue1.identifier);
|
|
125
|
+
expect(api.createIssue).toHaveBeenCalledWith(
|
|
126
|
+
expect.objectContaining({
|
|
127
|
+
parentId: RECORDED.createParent.id,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("getIssueDetails of sub-issue returns parent reference", async () => {
|
|
133
|
+
const api = createReplayApi();
|
|
134
|
+
const details = await api.getIssueDetails(
|
|
135
|
+
RECORDED.createSubIssue1.id,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(details.parent).not.toBeNull();
|
|
139
|
+
expect(details.parent!.id).toBe(RECORDED.createParent.id);
|
|
140
|
+
expect(details.parent!.identifier).toBe(
|
|
141
|
+
RECORDED.createParent.identifier,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("getIssueDetails of parent returns null parent (root)", async () => {
|
|
146
|
+
const api = createReplayApi();
|
|
147
|
+
const details = await api.getIssueDetails(RECORDED.createParent.id);
|
|
148
|
+
|
|
149
|
+
expect(details.parent).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("createIssueRelation creates blocks dependency", async () => {
|
|
153
|
+
const api = createReplayApi();
|
|
154
|
+
const result = await api.createIssueRelation({
|
|
155
|
+
issueId: RECORDED.createSubIssue1.id,
|
|
156
|
+
relatedIssueId: RECORDED.createSubIssue2.id,
|
|
157
|
+
type: "blocks",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(result.id).toBe(RECORDED.createRelation.id);
|
|
161
|
+
expect(api.createIssueRelation).toHaveBeenCalledWith({
|
|
162
|
+
issueId: RECORDED.createSubIssue1.id,
|
|
163
|
+
relatedIssueId: RECORDED.createSubIssue2.id,
|
|
164
|
+
type: "blocks",
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("sub-issue details include blocks relation after linking", async () => {
|
|
169
|
+
const api = createReplayApi();
|
|
170
|
+
const details = await api.getIssueDetails(
|
|
171
|
+
RECORDED.createSubIssue1.id,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const blocksRels = details.relations.nodes.filter(
|
|
175
|
+
(r: any) => r.type === "blocks",
|
|
176
|
+
);
|
|
177
|
+
expect(blocksRels.length).toBeGreaterThan(0);
|
|
178
|
+
expect(
|
|
179
|
+
blocksRels.some(
|
|
180
|
+
(r: any) => r.relatedIssue.id === RECORDED.createSubIssue2.id,
|
|
181
|
+
),
|
|
182
|
+
).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// =========================================================================
|
|
187
|
+
// Group B: Planner tools (real tool code, mocked API)
|
|
188
|
+
// =========================================================================
|
|
189
|
+
|
|
190
|
+
describe("planner tools: parentIdentifier resolution", () => {
|
|
191
|
+
let tools: any[];
|
|
192
|
+
let mockApi: ReturnType<typeof createReplayApi>;
|
|
193
|
+
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
vi.clearAllMocks();
|
|
196
|
+
mockApi = createReplayApi();
|
|
197
|
+
setActivePlannerContext({
|
|
198
|
+
linearApi: mockApi as any,
|
|
199
|
+
projectId: "proj-1",
|
|
200
|
+
teamId: RECORDED.parentDetails.team.id,
|
|
201
|
+
api: { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } } as any,
|
|
202
|
+
});
|
|
203
|
+
tools = createPlannerTools();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
afterEach(() => {
|
|
207
|
+
clearActivePlannerContext();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
function findTool(name: string) {
|
|
211
|
+
const tool = tools.find((t: any) => t.name === name) as any;
|
|
212
|
+
if (!tool) throw new Error(`Tool '${name}' not found`);
|
|
213
|
+
return tool;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
it("plan_create_issue resolves parentIdentifier to parentId", async () => {
|
|
217
|
+
// Mock getProjectIssues to return the parent issue
|
|
218
|
+
mockApi.getProjectIssues.mockResolvedValueOnce([
|
|
219
|
+
recordedToProjectIssue(RECORDED.parentDetails),
|
|
220
|
+
]);
|
|
221
|
+
mockApi.createIssue.mockResolvedValueOnce(RECORDED.createSubIssue1);
|
|
222
|
+
|
|
223
|
+
const tool = findTool("plan_create_issue");
|
|
224
|
+
const result = await tool.execute("call-1", {
|
|
225
|
+
title: RECORDED.subIssue1Details.title,
|
|
226
|
+
description: RECORDED.subIssue1Details.description,
|
|
227
|
+
parentIdentifier: RECORDED.createParent.identifier,
|
|
228
|
+
estimate: 2,
|
|
229
|
+
priority: 3,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Verify createIssue was called with resolved parentId (not identifier)
|
|
233
|
+
expect(mockApi.createIssue).toHaveBeenCalledWith(
|
|
234
|
+
expect.objectContaining({
|
|
235
|
+
parentId: RECORDED.createParent.id,
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
expect(result.data.identifier).toBe(
|
|
239
|
+
RECORDED.createSubIssue1.identifier,
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("plan_link_issues creates blocks relation between resolved IDs", async () => {
|
|
244
|
+
// Mock getProjectIssues to return both sub-issues
|
|
245
|
+
mockApi.getProjectIssues.mockResolvedValueOnce([
|
|
246
|
+
recordedToProjectIssue(RECORDED.subIssue1WithRelation),
|
|
247
|
+
recordedToProjectIssue(RECORDED.subIssue2WithRelation),
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
const tool = findTool("plan_link_issues");
|
|
251
|
+
const result = await tool.execute("call-2", {
|
|
252
|
+
fromIdentifier: RECORDED.subIssue1WithRelation.identifier,
|
|
253
|
+
toIdentifier: RECORDED.subIssue2WithRelation.identifier,
|
|
254
|
+
type: "blocks",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(mockApi.createIssueRelation).toHaveBeenCalledWith({
|
|
258
|
+
issueId: RECORDED.subIssue1WithRelation.id,
|
|
259
|
+
relatedIssueId: RECORDED.subIssue2WithRelation.id,
|
|
260
|
+
type: "blocks",
|
|
261
|
+
});
|
|
262
|
+
expect(result.data.id).toBe(RECORDED.createRelation.id);
|
|
263
|
+
expect(result.data.type).toBe("blocks");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("plan_get_project shows hierarchy with parent-child nesting", async () => {
|
|
267
|
+
// Return all 3 issues (parent + 2 subs)
|
|
268
|
+
mockApi.getProjectIssues.mockResolvedValueOnce([
|
|
269
|
+
recordedToProjectIssue(RECORDED.parentDetails),
|
|
270
|
+
recordedToProjectIssue(RECORDED.subIssue1WithRelation),
|
|
271
|
+
recordedToProjectIssue(RECORDED.subIssue2WithRelation),
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
const tool = findTool("plan_get_project");
|
|
275
|
+
const result = await tool.execute("call-3", {});
|
|
276
|
+
const snapshot = result.data?.snapshot ?? result.data?.plan ?? "";
|
|
277
|
+
|
|
278
|
+
// All three identifiers should appear
|
|
279
|
+
expect(snapshot).toContain(RECORDED.createParent.identifier);
|
|
280
|
+
expect(snapshot).toContain(RECORDED.createSubIssue1.identifier);
|
|
281
|
+
expect(snapshot).toContain(RECORDED.createSubIssue2.identifier);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("plan_audit passes valid sub-issue hierarchy", async () => {
|
|
285
|
+
// Build issues that pass audit: descriptions >= 50 chars, estimate, priority set
|
|
286
|
+
const parent = recordedToProjectIssue(RECORDED.parentDetails, {
|
|
287
|
+
priority: 2,
|
|
288
|
+
estimate: 5,
|
|
289
|
+
});
|
|
290
|
+
const sub1 = recordedToProjectIssue(RECORDED.subIssue1WithRelation, {
|
|
291
|
+
priority: 3,
|
|
292
|
+
estimate: 2,
|
|
293
|
+
});
|
|
294
|
+
const sub2 = recordedToProjectIssue(RECORDED.subIssue2WithRelation, {
|
|
295
|
+
priority: 3,
|
|
296
|
+
estimate: 3,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
mockApi.getProjectIssues.mockResolvedValueOnce([parent, sub1, sub2]);
|
|
300
|
+
|
|
301
|
+
const tool = findTool("plan_audit");
|
|
302
|
+
const result = await tool.execute("call-4", {});
|
|
303
|
+
|
|
304
|
+
expect(result.data.pass).toBe(true);
|
|
305
|
+
expect(result.data.problems).toHaveLength(0);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// =========================================================================
|
|
310
|
+
// Group C: auditPlan pure function with recorded data shapes
|
|
311
|
+
// =========================================================================
|
|
312
|
+
|
|
313
|
+
describe("auditPlan with parent-child relationships", () => {
|
|
314
|
+
it("issues with parent are not flagged as orphans", () => {
|
|
315
|
+
const parent = recordedToProjectIssue(RECORDED.parentDetails, {
|
|
316
|
+
priority: 2,
|
|
317
|
+
estimate: 5,
|
|
318
|
+
});
|
|
319
|
+
const sub1 = recordedToProjectIssue(RECORDED.subIssue1Details, {
|
|
320
|
+
priority: 3,
|
|
321
|
+
estimate: 2,
|
|
322
|
+
});
|
|
323
|
+
const sub2 = recordedToProjectIssue(RECORDED.subIssue2Details, {
|
|
324
|
+
priority: 3,
|
|
325
|
+
estimate: 3,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const result = auditPlan([parent, sub1, sub2]);
|
|
329
|
+
|
|
330
|
+
// Sub-issues have parent set, so they're not orphans.
|
|
331
|
+
// Parent may be flagged as orphan (no parent, no relations linking to it)
|
|
332
|
+
// but sub-issues definitely should NOT be orphans.
|
|
333
|
+
const orphanWarnings = result.warnings.filter((w) =>
|
|
334
|
+
w.includes("orphan"),
|
|
335
|
+
);
|
|
336
|
+
const subOrphans = orphanWarnings.filter(
|
|
337
|
+
(w) =>
|
|
338
|
+
w.includes(RECORDED.subIssue1Details.identifier) ||
|
|
339
|
+
w.includes(RECORDED.subIssue2Details.identifier),
|
|
340
|
+
);
|
|
341
|
+
expect(subOrphans).toHaveLength(0);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("blocks relation between sub-issues produces valid DAG", () => {
|
|
345
|
+
const sub1 = recordedToProjectIssue(RECORDED.subIssue1WithRelation, {
|
|
346
|
+
priority: 3,
|
|
347
|
+
estimate: 2,
|
|
348
|
+
});
|
|
349
|
+
const sub2 = recordedToProjectIssue(RECORDED.subIssue2WithRelation, {
|
|
350
|
+
priority: 3,
|
|
351
|
+
estimate: 3,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const cycles = detectCycles([sub1, sub2]);
|
|
355
|
+
expect(cycles).toHaveLength(0);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("buildPlanSnapshot nests sub-issues under parent", () => {
|
|
359
|
+
const parent = recordedToProjectIssue(RECORDED.parentDetails, {
|
|
360
|
+
priority: 2,
|
|
361
|
+
estimate: 5,
|
|
362
|
+
});
|
|
363
|
+
const sub1 = recordedToProjectIssue(RECORDED.subIssue1WithRelation, {
|
|
364
|
+
priority: 3,
|
|
365
|
+
estimate: 2,
|
|
366
|
+
});
|
|
367
|
+
const sub2 = recordedToProjectIssue(RECORDED.subIssue2WithRelation, {
|
|
368
|
+
priority: 3,
|
|
369
|
+
estimate: 3,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const snapshot = buildPlanSnapshot([parent, sub1, sub2]);
|
|
373
|
+
|
|
374
|
+
// Parent should appear
|
|
375
|
+
expect(snapshot).toContain(RECORDED.createParent.identifier);
|
|
376
|
+
// Sub-issues should appear
|
|
377
|
+
expect(snapshot).toContain(RECORDED.createSubIssue1.identifier);
|
|
378
|
+
expect(snapshot).toContain(RECORDED.createSubIssue2.identifier);
|
|
379
|
+
// Sub-issues should be indented (nested under parent)
|
|
380
|
+
const lines = snapshot.split("\n");
|
|
381
|
+
const sub1Line = lines.find((l) =>
|
|
382
|
+
l.includes(RECORDED.createSubIssue1.identifier),
|
|
383
|
+
);
|
|
384
|
+
expect(sub1Line).toBeTruthy();
|
|
385
|
+
expect(sub1Line!.startsWith(" ")).toBe(true);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -24,6 +24,9 @@ const {
|
|
|
24
24
|
resetGuidanceCacheMock,
|
|
25
25
|
setActiveSessionMock,
|
|
26
26
|
clearActiveSessionMock,
|
|
27
|
+
getIssueAffinityMock,
|
|
28
|
+
configureAffinityTtlMock,
|
|
29
|
+
resetAffinityForTestingMock,
|
|
27
30
|
readDispatchStateMock,
|
|
28
31
|
getActiveDispatchMock,
|
|
29
32
|
registerDispatchMock,
|
|
@@ -97,6 +100,9 @@ const {
|
|
|
97
100
|
resetGuidanceCacheMock: vi.fn(),
|
|
98
101
|
setActiveSessionMock: vi.fn(),
|
|
99
102
|
clearActiveSessionMock: vi.fn(),
|
|
103
|
+
getIssueAffinityMock: vi.fn().mockReturnValue(null),
|
|
104
|
+
configureAffinityTtlMock: vi.fn(),
|
|
105
|
+
resetAffinityForTestingMock: vi.fn(),
|
|
100
106
|
readDispatchStateMock: vi.fn().mockResolvedValue({ activeDispatches: {} }),
|
|
101
107
|
getActiveDispatchMock: vi.fn().mockReturnValue(null),
|
|
102
108
|
registerDispatchMock: vi.fn().mockResolvedValue(undefined),
|
|
@@ -167,9 +173,9 @@ vi.mock("./guidance.js", () => ({
|
|
|
167
173
|
vi.mock("./active-session.js", () => ({
|
|
168
174
|
setActiveSession: setActiveSessionMock,
|
|
169
175
|
clearActiveSession: clearActiveSessionMock,
|
|
170
|
-
getIssueAffinity:
|
|
171
|
-
_configureAffinityTtl:
|
|
172
|
-
_resetAffinityForTesting:
|
|
176
|
+
getIssueAffinity: getIssueAffinityMock,
|
|
177
|
+
_configureAffinityTtl: configureAffinityTtlMock,
|
|
178
|
+
_resetAffinityForTesting: resetAffinityForTestingMock,
|
|
173
179
|
}));
|
|
174
180
|
|
|
175
181
|
vi.mock("./dispatch-state.js", () => ({
|
|
@@ -358,6 +364,9 @@ afterEach(() => {
|
|
|
358
364
|
isGuidanceEnabledMock.mockReset().mockReturnValue(false);
|
|
359
365
|
setActiveSessionMock.mockReset();
|
|
360
366
|
clearActiveSessionMock.mockReset();
|
|
367
|
+
getIssueAffinityMock.mockReset().mockReturnValue(null);
|
|
368
|
+
configureAffinityTtlMock.mockReset();
|
|
369
|
+
resetAffinityForTestingMock.mockReset();
|
|
361
370
|
readDispatchStateMock.mockReset().mockResolvedValue({ activeDispatches: {} });
|
|
362
371
|
getActiveDispatchMock.mockReset().mockReturnValue(null);
|
|
363
372
|
registerDispatchMock.mockReset().mockResolvedValue(undefined);
|
package/src/pipeline/webhook.ts
CHANGED
|
@@ -336,6 +336,7 @@ export async function handleLinearWebhook(
|
|
|
336
336
|
const profiles = loadAgentProfiles();
|
|
337
337
|
const mentionPattern = buildMentionPattern(profiles);
|
|
338
338
|
let agentId = resolveAgentId(api);
|
|
339
|
+
let mentionOverride = false;
|
|
339
340
|
if (mentionPattern && userMessage) {
|
|
340
341
|
const mentionMatch = userMessage.match(mentionPattern);
|
|
341
342
|
if (mentionMatch) {
|
|
@@ -344,11 +345,12 @@ export async function handleLinearWebhook(
|
|
|
344
345
|
if (resolved) {
|
|
345
346
|
api.logger.info(`AgentSession routed to ${resolved.agentId} via @${alias} mention`);
|
|
346
347
|
agentId = resolved.agentId;
|
|
348
|
+
mentionOverride = true;
|
|
347
349
|
}
|
|
348
350
|
}
|
|
349
351
|
}
|
|
350
352
|
// Session affinity: if no @mention override, prefer the agent that last handled this issue
|
|
351
|
-
if (
|
|
353
|
+
if (!mentionOverride && issue?.id) {
|
|
352
354
|
const affinityAgent = getIssueAffinity(issue.id);
|
|
353
355
|
if (affinityAgent) {
|
|
354
356
|
api.logger.info(`AgentSession routed to ${affinityAgent} via session affinity for ${issue.identifier ?? issue.id}`);
|
|
@@ -555,6 +557,7 @@ export async function handleLinearWebhook(
|
|
|
555
557
|
const promptedProfiles = loadAgentProfiles();
|
|
556
558
|
const promptedMentionPattern = buildMentionPattern(promptedProfiles);
|
|
557
559
|
let agentId = resolveAgentId(api);
|
|
560
|
+
let mentionOverride = false;
|
|
558
561
|
if (promptedMentionPattern && userMessage) {
|
|
559
562
|
const mentionMatch = userMessage.match(promptedMentionPattern);
|
|
560
563
|
if (mentionMatch) {
|
|
@@ -563,11 +566,12 @@ export async function handleLinearWebhook(
|
|
|
563
566
|
if (resolved) {
|
|
564
567
|
api.logger.info(`AgentSession prompted: routed to ${resolved.agentId} via @${alias} mention`);
|
|
565
568
|
agentId = resolved.agentId;
|
|
569
|
+
mentionOverride = true;
|
|
566
570
|
}
|
|
567
571
|
}
|
|
568
572
|
}
|
|
569
573
|
// Session affinity: if no @mention override, prefer the agent that last handled this issue
|
|
570
|
-
if (
|
|
574
|
+
if (!mentionOverride && issue?.id) {
|
|
571
575
|
const affinityAgent = getIssueAffinity(issue.id);
|
|
572
576
|
if (affinityAgent) {
|
|
573
577
|
api.logger.info(`AgentSession prompted: routed to ${affinityAgent} via session affinity for ${issue.identifier ?? issue.id}`);
|