@calltelemetry/openclaw-linear 0.9.5 → 0.9.7
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 +1 -1
- package/src/__test__/fixtures/recorded-sub-issue-flow.ts +232 -156
- package/src/__test__/smoke-linear-api.test.ts +208 -3
- package/src/__test__/webhook-scenarios.test.ts +5 -0
- package/src/infra/shared-profiles.ts +59 -1
- package/src/pipeline/sub-issue-decomposition.test.ts +388 -0
- package/src/pipeline/webhook.test.ts +16 -3
- package/src/pipeline/webhook.ts +94 -11
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* resolveAgentFromAlias() implementations that were previously in
|
|
6
6
|
* webhook.ts, intent-classify.ts, and tier-assess.ts.
|
|
7
7
|
*/
|
|
8
|
-
import { readFileSync } from "node:fs";
|
|
8
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
|
|
@@ -107,6 +107,64 @@ export function resolveDefaultAgent(api: { pluginConfig?: Record<string, unknown
|
|
|
107
107
|
return "default";
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Profile validation — returns a user-facing error string or null if OK.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validate that agent-profiles.json exists, is parseable, and has at least
|
|
116
|
+
* one agent. Returns a human-readable error string suitable for posting
|
|
117
|
+
* back to Linear, or null when everything looks good.
|
|
118
|
+
*/
|
|
119
|
+
export function validateProfiles(): string | null {
|
|
120
|
+
if (!existsSync(PROFILES_PATH)) {
|
|
121
|
+
return (
|
|
122
|
+
`**Critical setup error:** \`agent-profiles.json\` not found.\n\n` +
|
|
123
|
+
`The Linear plugin requires this file to route messages to your agent.\n\n` +
|
|
124
|
+
`**Create it now:**\n` +
|
|
125
|
+
"```\n" +
|
|
126
|
+
`cat > ${PROFILES_PATH} << 'EOF'\n` +
|
|
127
|
+
`{\n` +
|
|
128
|
+
` "agents": {\n` +
|
|
129
|
+
` "my-agent": {\n` +
|
|
130
|
+
` "label": "My Agent",\n` +
|
|
131
|
+
` "mission": "AI assistant",\n` +
|
|
132
|
+
` "isDefault": true,\n` +
|
|
133
|
+
` "mentionAliases": ["my-agent"]\n` +
|
|
134
|
+
` }\n` +
|
|
135
|
+
` }\n` +
|
|
136
|
+
`}\n` +
|
|
137
|
+
`EOF\n` +
|
|
138
|
+
"```\n\n" +
|
|
139
|
+
`Then restart the gateway: \`systemctl --user restart openclaw-gateway\`\n\n` +
|
|
140
|
+
`Run \`openclaw openclaw-linear doctor\` to verify your setup.`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let profiles: Record<string, unknown>;
|
|
145
|
+
try {
|
|
146
|
+
const raw = readFileSync(PROFILES_PATH, "utf8");
|
|
147
|
+
profiles = JSON.parse(raw).agents ?? {};
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return (
|
|
150
|
+
`**Critical setup error:** \`agent-profiles.json\` exists but could not be parsed.\n\n` +
|
|
151
|
+
`Error: ${err instanceof Error ? err.message : String(err)}\n\n` +
|
|
152
|
+
`Fix the JSON syntax in \`${PROFILES_PATH}\` and restart the gateway.\n` +
|
|
153
|
+
`Run \`openclaw openclaw-linear doctor\` to verify.`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (Object.keys(profiles).length === 0) {
|
|
158
|
+
return (
|
|
159
|
+
`**Critical setup error:** \`agent-profiles.json\` has no agents configured.\n\n` +
|
|
160
|
+
`Add at least one agent entry to the \`"agents"\` object in \`${PROFILES_PATH}\`.\n` +
|
|
161
|
+
`Run \`openclaw openclaw-linear doctor\` for a guided setup check.`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
110
168
|
// ---------------------------------------------------------------------------
|
|
111
169
|
// Test-only: reset cache
|
|
112
170
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
+
});
|
|
@@ -14,6 +14,7 @@ const {
|
|
|
14
14
|
loadAgentProfilesMock,
|
|
15
15
|
buildMentionPatternMock,
|
|
16
16
|
resolveAgentFromAliasMock,
|
|
17
|
+
validateProfilesMock,
|
|
17
18
|
resetProfilesCacheMock,
|
|
18
19
|
classifyIntentMock,
|
|
19
20
|
extractGuidanceMock,
|
|
@@ -24,6 +25,9 @@ const {
|
|
|
24
25
|
resetGuidanceCacheMock,
|
|
25
26
|
setActiveSessionMock,
|
|
26
27
|
clearActiveSessionMock,
|
|
28
|
+
getIssueAffinityMock,
|
|
29
|
+
configureAffinityTtlMock,
|
|
30
|
+
resetAffinityForTestingMock,
|
|
27
31
|
readDispatchStateMock,
|
|
28
32
|
getActiveDispatchMock,
|
|
29
33
|
registerDispatchMock,
|
|
@@ -83,6 +87,7 @@ const {
|
|
|
83
87
|
}),
|
|
84
88
|
buildMentionPatternMock: vi.fn().mockReturnValue(/@(mal|mason|kaylee|eureka)/i),
|
|
85
89
|
resolveAgentFromAliasMock: vi.fn().mockReturnValue(null),
|
|
90
|
+
validateProfilesMock: vi.fn().mockReturnValue(null),
|
|
86
91
|
resetProfilesCacheMock: vi.fn(),
|
|
87
92
|
classifyIntentMock: vi.fn().mockResolvedValue({
|
|
88
93
|
intent: "general",
|
|
@@ -97,6 +102,9 @@ const {
|
|
|
97
102
|
resetGuidanceCacheMock: vi.fn(),
|
|
98
103
|
setActiveSessionMock: vi.fn(),
|
|
99
104
|
clearActiveSessionMock: vi.fn(),
|
|
105
|
+
getIssueAffinityMock: vi.fn().mockReturnValue(null),
|
|
106
|
+
configureAffinityTtlMock: vi.fn(),
|
|
107
|
+
resetAffinityForTestingMock: vi.fn(),
|
|
100
108
|
readDispatchStateMock: vi.fn().mockResolvedValue({ activeDispatches: {} }),
|
|
101
109
|
getActiveDispatchMock: vi.fn().mockReturnValue(null),
|
|
102
110
|
registerDispatchMock: vi.fn().mockResolvedValue(undefined),
|
|
@@ -148,6 +156,7 @@ vi.mock("../infra/shared-profiles.js", () => ({
|
|
|
148
156
|
loadAgentProfiles: loadAgentProfilesMock,
|
|
149
157
|
buildMentionPattern: buildMentionPatternMock,
|
|
150
158
|
resolveAgentFromAlias: resolveAgentFromAliasMock,
|
|
159
|
+
validateProfiles: validateProfilesMock,
|
|
151
160
|
_resetProfilesCacheForTesting: resetProfilesCacheMock,
|
|
152
161
|
}));
|
|
153
162
|
|
|
@@ -167,9 +176,9 @@ vi.mock("./guidance.js", () => ({
|
|
|
167
176
|
vi.mock("./active-session.js", () => ({
|
|
168
177
|
setActiveSession: setActiveSessionMock,
|
|
169
178
|
clearActiveSession: clearActiveSessionMock,
|
|
170
|
-
getIssueAffinity:
|
|
171
|
-
_configureAffinityTtl:
|
|
172
|
-
_resetAffinityForTesting:
|
|
179
|
+
getIssueAffinity: getIssueAffinityMock,
|
|
180
|
+
_configureAffinityTtl: configureAffinityTtlMock,
|
|
181
|
+
_resetAffinityForTesting: resetAffinityForTestingMock,
|
|
173
182
|
}));
|
|
174
183
|
|
|
175
184
|
vi.mock("./dispatch-state.js", () => ({
|
|
@@ -346,6 +355,7 @@ afterEach(() => {
|
|
|
346
355
|
});
|
|
347
356
|
buildMentionPatternMock.mockReset().mockReturnValue(/@(mal|mason|kaylee|eureka)/i);
|
|
348
357
|
resolveAgentFromAliasMock.mockReset().mockReturnValue(null);
|
|
358
|
+
validateProfilesMock.mockReset().mockReturnValue(null);
|
|
349
359
|
classifyIntentMock.mockReset().mockResolvedValue({
|
|
350
360
|
intent: "general",
|
|
351
361
|
reasoning: "Not actionable",
|
|
@@ -358,6 +368,9 @@ afterEach(() => {
|
|
|
358
368
|
isGuidanceEnabledMock.mockReset().mockReturnValue(false);
|
|
359
369
|
setActiveSessionMock.mockReset();
|
|
360
370
|
clearActiveSessionMock.mockReset();
|
|
371
|
+
getIssueAffinityMock.mockReset().mockReturnValue(null);
|
|
372
|
+
configureAffinityTtlMock.mockReset();
|
|
373
|
+
resetAffinityForTestingMock.mockReset();
|
|
361
374
|
readDispatchStateMock.mockReset().mockResolvedValue({ activeDispatches: {} });
|
|
362
375
|
getActiveDispatchMock.mockReset().mockReturnValue(null);
|
|
363
376
|
registerDispatchMock.mockReset().mockResolvedValue(undefined);
|