@calltelemetry/openclaw-linear 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -17
- package/index.ts +18 -22
- package/openclaw.plugin.json +35 -2
- package/package.json +1 -1
- package/prompts.yaml +47 -0
- package/src/api/linear-api.ts +180 -9
- package/src/infra/cli.ts +214 -0
- package/src/infra/doctor.test.ts +399 -0
- package/src/infra/doctor.ts +759 -0
- package/src/infra/notify.test.ts +357 -108
- package/src/infra/notify.ts +114 -35
- package/src/pipeline/planner.test.ts +334 -0
- package/src/pipeline/planner.ts +282 -0
- package/src/pipeline/planning-state.test.ts +236 -0
- package/src/pipeline/planning-state.ts +216 -0
- package/src/pipeline/webhook.ts +69 -17
- package/src/tools/planner-tools.test.ts +535 -0
- package/src/tools/planner-tools.ts +450 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock openclaw/plugin-sdk
|
|
4
|
+
vi.mock("openclaw/plugin-sdk", () => ({
|
|
5
|
+
jsonResult: (data: any) => ({ type: "json", data }),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// Mock LinearAgentApi
|
|
9
|
+
vi.mock("../api/linear-api.js", () => ({
|
|
10
|
+
LinearAgentApi: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createPlannerTools,
|
|
15
|
+
setActivePlannerContext,
|
|
16
|
+
clearActivePlannerContext,
|
|
17
|
+
detectCycles,
|
|
18
|
+
auditPlan,
|
|
19
|
+
buildPlanSnapshot,
|
|
20
|
+
} from "./planner-tools.js";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Helpers
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
type ProjectIssue = Parameters<typeof detectCycles>[0][number];
|
|
27
|
+
|
|
28
|
+
function makeIssue(overrides: Partial<ProjectIssue> & { identifier: string; title: string }): ProjectIssue {
|
|
29
|
+
return {
|
|
30
|
+
id: overrides.id ?? overrides.identifier.toLowerCase().replace("-", "_"),
|
|
31
|
+
identifier: overrides.identifier,
|
|
32
|
+
title: overrides.title,
|
|
33
|
+
description: "description" in overrides ? overrides.description : "A sufficiently long description that is at least fifty characters for validation",
|
|
34
|
+
estimate: "estimate" in overrides ? overrides.estimate : 3,
|
|
35
|
+
priority: "priority" in overrides ? overrides.priority : 2,
|
|
36
|
+
labels: overrides.labels ?? { nodes: [] },
|
|
37
|
+
parent: overrides.parent ?? null,
|
|
38
|
+
relations: overrides.relations ?? { nodes: [] },
|
|
39
|
+
} as ProjectIssue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeEpicIssue(overrides: Partial<ProjectIssue> & { identifier: string; title: string }): ProjectIssue {
|
|
43
|
+
return makeIssue({
|
|
44
|
+
...overrides,
|
|
45
|
+
labels: { nodes: [{ id: "lbl-epic", name: "Epic" }] } as any,
|
|
46
|
+
estimate: overrides.estimate as any,
|
|
47
|
+
priority: overrides.priority as any,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Mock Linear API
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
const mockLinearApi = {
|
|
56
|
+
createIssue: vi.fn().mockResolvedValue({ id: "new-id", identifier: "PROJ-5" }),
|
|
57
|
+
createIssueRelation: vi.fn().mockResolvedValue({ id: "rel-id" }),
|
|
58
|
+
getProjectIssues: vi.fn().mockResolvedValue([]),
|
|
59
|
+
getTeamLabels: vi.fn().mockResolvedValue([]),
|
|
60
|
+
updateIssueExtended: vi.fn().mockResolvedValue(true),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// detectCycles (pure function — no mocks needed)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe("detectCycles", () => {
|
|
68
|
+
it("returns empty array for valid linear chain (A→B→C)", () => {
|
|
69
|
+
const issues: ProjectIssue[] = [
|
|
70
|
+
makeIssue({
|
|
71
|
+
identifier: "PROJ-1",
|
|
72
|
+
title: "A",
|
|
73
|
+
relations: {
|
|
74
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } }],
|
|
75
|
+
} as any,
|
|
76
|
+
}),
|
|
77
|
+
makeIssue({
|
|
78
|
+
identifier: "PROJ-2",
|
|
79
|
+
title: "B",
|
|
80
|
+
relations: {
|
|
81
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-3" } }],
|
|
82
|
+
} as any,
|
|
83
|
+
}),
|
|
84
|
+
makeIssue({ identifier: "PROJ-3", title: "C" }),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
expect(detectCycles(issues)).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("detects simple 2-node cycle (A→B→A)", () => {
|
|
91
|
+
const issues: ProjectIssue[] = [
|
|
92
|
+
makeIssue({
|
|
93
|
+
identifier: "PROJ-1",
|
|
94
|
+
title: "A",
|
|
95
|
+
relations: {
|
|
96
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } }],
|
|
97
|
+
} as any,
|
|
98
|
+
}),
|
|
99
|
+
makeIssue({
|
|
100
|
+
identifier: "PROJ-2",
|
|
101
|
+
title: "B",
|
|
102
|
+
relations: {
|
|
103
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-1" } }],
|
|
104
|
+
} as any,
|
|
105
|
+
}),
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
const cycleNodes = detectCycles(issues);
|
|
109
|
+
expect(cycleNodes).toContain("PROJ-1");
|
|
110
|
+
expect(cycleNodes).toContain("PROJ-2");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("detects 3-node cycle (A→B→C→A)", () => {
|
|
114
|
+
const issues: ProjectIssue[] = [
|
|
115
|
+
makeIssue({
|
|
116
|
+
identifier: "PROJ-1",
|
|
117
|
+
title: "A",
|
|
118
|
+
relations: {
|
|
119
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } }],
|
|
120
|
+
} as any,
|
|
121
|
+
}),
|
|
122
|
+
makeIssue({
|
|
123
|
+
identifier: "PROJ-2",
|
|
124
|
+
title: "B",
|
|
125
|
+
relations: {
|
|
126
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-3" } }],
|
|
127
|
+
} as any,
|
|
128
|
+
}),
|
|
129
|
+
makeIssue({
|
|
130
|
+
identifier: "PROJ-3",
|
|
131
|
+
title: "C",
|
|
132
|
+
relations: {
|
|
133
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-1" } }],
|
|
134
|
+
} as any,
|
|
135
|
+
}),
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
const cycleNodes = detectCycles(issues);
|
|
139
|
+
expect(cycleNodes).toHaveLength(3);
|
|
140
|
+
expect(cycleNodes).toContain("PROJ-1");
|
|
141
|
+
expect(cycleNodes).toContain("PROJ-2");
|
|
142
|
+
expect(cycleNodes).toContain("PROJ-3");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("passes valid diamond DAG (A→B, A→C, B→D, C→D)", () => {
|
|
146
|
+
const issues: ProjectIssue[] = [
|
|
147
|
+
makeIssue({
|
|
148
|
+
identifier: "PROJ-1",
|
|
149
|
+
title: "A",
|
|
150
|
+
relations: {
|
|
151
|
+
nodes: [
|
|
152
|
+
{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } },
|
|
153
|
+
{ type: "blocks", relatedIssue: { identifier: "PROJ-3" } },
|
|
154
|
+
],
|
|
155
|
+
} as any,
|
|
156
|
+
}),
|
|
157
|
+
makeIssue({
|
|
158
|
+
identifier: "PROJ-2",
|
|
159
|
+
title: "B",
|
|
160
|
+
relations: {
|
|
161
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-4" } }],
|
|
162
|
+
} as any,
|
|
163
|
+
}),
|
|
164
|
+
makeIssue({
|
|
165
|
+
identifier: "PROJ-3",
|
|
166
|
+
title: "C",
|
|
167
|
+
relations: {
|
|
168
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-4" } }],
|
|
169
|
+
} as any,
|
|
170
|
+
}),
|
|
171
|
+
makeIssue({ identifier: "PROJ-4", title: "D" }),
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
expect(detectCycles(issues)).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("returns empty for issues with no relations", () => {
|
|
178
|
+
const issues: ProjectIssue[] = [
|
|
179
|
+
makeIssue({ identifier: "PROJ-1", title: "A" }),
|
|
180
|
+
makeIssue({ identifier: "PROJ-2", title: "B" }),
|
|
181
|
+
makeIssue({ identifier: "PROJ-3", title: "C" }),
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
expect(detectCycles(issues)).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// auditPlan (pure function)
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
describe("auditPlan", () => {
|
|
193
|
+
it("passes valid plan with descriptions, estimates, priorities", () => {
|
|
194
|
+
const issues: ProjectIssue[] = [
|
|
195
|
+
makeIssue({
|
|
196
|
+
identifier: "PROJ-1",
|
|
197
|
+
title: "Task A",
|
|
198
|
+
relations: {
|
|
199
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } }],
|
|
200
|
+
} as any,
|
|
201
|
+
}),
|
|
202
|
+
makeIssue({ identifier: "PROJ-2", title: "Task B", parent: { identifier: "PROJ-1" } as any }),
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
const result = auditPlan(issues);
|
|
206
|
+
expect(result.pass).toBe(true);
|
|
207
|
+
expect(result.problems).toHaveLength(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("fails: issue missing description", () => {
|
|
211
|
+
const issues: ProjectIssue[] = [
|
|
212
|
+
makeIssue({
|
|
213
|
+
identifier: "PROJ-1",
|
|
214
|
+
title: "No desc",
|
|
215
|
+
description: "" as any,
|
|
216
|
+
}),
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
const result = auditPlan(issues);
|
|
220
|
+
expect(result.pass).toBe(false);
|
|
221
|
+
expect(result.problems.some((p) => p.includes("PROJ-1") && p.includes("description"))).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("fails: description too short (<50 chars)", () => {
|
|
225
|
+
const issues: ProjectIssue[] = [
|
|
226
|
+
makeIssue({
|
|
227
|
+
identifier: "PROJ-1",
|
|
228
|
+
title: "Short desc",
|
|
229
|
+
description: "Too short",
|
|
230
|
+
}),
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
const result = auditPlan(issues);
|
|
234
|
+
expect(result.pass).toBe(false);
|
|
235
|
+
expect(result.problems.some((p) => p.includes("PROJ-1") && p.includes("description"))).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("fails: non-epic missing estimate", () => {
|
|
239
|
+
const issues: ProjectIssue[] = [
|
|
240
|
+
makeIssue({
|
|
241
|
+
identifier: "PROJ-1",
|
|
242
|
+
title: "No estimate",
|
|
243
|
+
estimate: null as any,
|
|
244
|
+
}),
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
const result = auditPlan(issues);
|
|
248
|
+
expect(result.pass).toBe(false);
|
|
249
|
+
expect(result.problems.some((p) => p.includes("PROJ-1") && p.includes("estimate"))).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("fails: non-epic missing priority (priority=0)", () => {
|
|
253
|
+
const issues: ProjectIssue[] = [
|
|
254
|
+
makeIssue({
|
|
255
|
+
identifier: "PROJ-1",
|
|
256
|
+
title: "No priority",
|
|
257
|
+
priority: 0,
|
|
258
|
+
}),
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
const result = auditPlan(issues);
|
|
262
|
+
expect(result.pass).toBe(false);
|
|
263
|
+
expect(result.problems.some((p) => p.includes("PROJ-1") && p.includes("priority"))).toBe(true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("fails: cycle detected", () => {
|
|
267
|
+
const issues: ProjectIssue[] = [
|
|
268
|
+
makeIssue({
|
|
269
|
+
identifier: "PROJ-1",
|
|
270
|
+
title: "A",
|
|
271
|
+
relations: {
|
|
272
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } }],
|
|
273
|
+
} as any,
|
|
274
|
+
}),
|
|
275
|
+
makeIssue({
|
|
276
|
+
identifier: "PROJ-2",
|
|
277
|
+
title: "B",
|
|
278
|
+
relations: {
|
|
279
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-1" } }],
|
|
280
|
+
} as any,
|
|
281
|
+
}),
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
const result = auditPlan(issues);
|
|
285
|
+
expect(result.pass).toBe(false);
|
|
286
|
+
expect(result.problems.some((p) => p.includes("cycle") || p.includes("Cycle"))).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("warns: orphan issue (no parent or relations)", () => {
|
|
290
|
+
const issues: ProjectIssue[] = [
|
|
291
|
+
makeIssue({ identifier: "PROJ-1", title: "Lonely issue" }),
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
const result = auditPlan(issues);
|
|
295
|
+
expect(result.warnings.some((w) => w.includes("PROJ-1") && w.includes("orphan"))).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("skips estimate/priority checks for epic issues", () => {
|
|
299
|
+
const issues: ProjectIssue[] = [
|
|
300
|
+
makeEpicIssue({
|
|
301
|
+
identifier: "PROJ-1",
|
|
302
|
+
title: "Epic without estimate/priority",
|
|
303
|
+
estimate: null as any,
|
|
304
|
+
priority: 0 as any,
|
|
305
|
+
}),
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
const result = auditPlan(issues);
|
|
309
|
+
const estimateProblems = result.problems.filter((p) => p.includes("estimate") || p.includes("priority"));
|
|
310
|
+
expect(estimateProblems).toHaveLength(0);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// buildPlanSnapshot
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
describe("buildPlanSnapshot", () => {
|
|
319
|
+
it("formats empty project", () => {
|
|
320
|
+
const result = buildPlanSnapshot([]);
|
|
321
|
+
expect(result).toBe("_No issues created yet._");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("formats tree with epics + sub-issues + relations", () => {
|
|
325
|
+
const epic = makeEpicIssue({
|
|
326
|
+
identifier: "PROJ-1",
|
|
327
|
+
title: "Auth Epic",
|
|
328
|
+
estimate: null as any,
|
|
329
|
+
priority: 2,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const child = makeIssue({
|
|
333
|
+
identifier: "PROJ-2",
|
|
334
|
+
title: "Login page",
|
|
335
|
+
parent: { identifier: "PROJ-1" } as any,
|
|
336
|
+
estimate: 3,
|
|
337
|
+
priority: 3,
|
|
338
|
+
relations: {
|
|
339
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-3" } }],
|
|
340
|
+
} as any,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const standalone = makeIssue({
|
|
344
|
+
identifier: "PROJ-3",
|
|
345
|
+
title: "Dashboard",
|
|
346
|
+
estimate: 5,
|
|
347
|
+
priority: 1,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const result = buildPlanSnapshot([epic, child, standalone]);
|
|
351
|
+
|
|
352
|
+
// Should contain epic section
|
|
353
|
+
expect(result).toContain("### Epics (1)");
|
|
354
|
+
expect(result).toContain("PROJ-1");
|
|
355
|
+
expect(result).toContain("Auth Epic");
|
|
356
|
+
|
|
357
|
+
// Child should appear indented under epic
|
|
358
|
+
expect(result).toContain("PROJ-2");
|
|
359
|
+
expect(result).toContain("Login page");
|
|
360
|
+
|
|
361
|
+
// Standalone section
|
|
362
|
+
expect(result).toContain("### Standalone issues (1)");
|
|
363
|
+
expect(result).toContain("PROJ-3");
|
|
364
|
+
expect(result).toContain("Dashboard");
|
|
365
|
+
|
|
366
|
+
// Relations formatting
|
|
367
|
+
expect(result).toContain("blocks PROJ-3");
|
|
368
|
+
|
|
369
|
+
// Priority labels
|
|
370
|
+
expect(result).toContain("pri: Urgent");
|
|
371
|
+
expect(result).toContain("pri: Medium");
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Tool execution tests
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
describe("createPlannerTools", () => {
|
|
380
|
+
const PROJECT_ID = "proj-123";
|
|
381
|
+
const TEAM_ID = "team-456";
|
|
382
|
+
|
|
383
|
+
let tools: ReturnType<typeof createPlannerTools>;
|
|
384
|
+
|
|
385
|
+
beforeEach(() => {
|
|
386
|
+
vi.clearAllMocks();
|
|
387
|
+
setActivePlannerContext({
|
|
388
|
+
linearApi: mockLinearApi as any,
|
|
389
|
+
projectId: PROJECT_ID,
|
|
390
|
+
teamId: TEAM_ID,
|
|
391
|
+
});
|
|
392
|
+
tools = createPlannerTools();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
afterEach(() => {
|
|
396
|
+
clearActivePlannerContext();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
function findTool(name: string) {
|
|
400
|
+
const tool = tools.find((t: any) => t.name === name) as any;
|
|
401
|
+
if (!tool) throw new Error(`Tool '${name}' not found`);
|
|
402
|
+
return tool;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---- plan_create_issue ----
|
|
406
|
+
|
|
407
|
+
it("plan_create_issue: creates issue with teamId and projectId", async () => {
|
|
408
|
+
const tool = findTool("plan_create_issue");
|
|
409
|
+
|
|
410
|
+
const result = await tool.execute("call-1", {
|
|
411
|
+
title: "New feature",
|
|
412
|
+
description: "Implement the new feature with all necessary components",
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
expect(mockLinearApi.createIssue).toHaveBeenCalledWith(
|
|
416
|
+
expect.objectContaining({
|
|
417
|
+
teamId: TEAM_ID,
|
|
418
|
+
projectId: PROJECT_ID,
|
|
419
|
+
title: "New feature",
|
|
420
|
+
description: "Implement the new feature with all necessary components",
|
|
421
|
+
}),
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
expect(result).toEqual({
|
|
425
|
+
type: "json",
|
|
426
|
+
data: {
|
|
427
|
+
id: "new-id",
|
|
428
|
+
identifier: "PROJ-5",
|
|
429
|
+
title: "New feature",
|
|
430
|
+
isEpic: false,
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// ---- plan_link_issues ----
|
|
436
|
+
|
|
437
|
+
it("plan_link_issues: creates blocks relation", async () => {
|
|
438
|
+
mockLinearApi.getProjectIssues.mockResolvedValueOnce([
|
|
439
|
+
makeIssue({ identifier: "PROJ-1", title: "A", id: "id-1" }),
|
|
440
|
+
makeIssue({ identifier: "PROJ-2", title: "B", id: "id-2" }),
|
|
441
|
+
]);
|
|
442
|
+
|
|
443
|
+
const tool = findTool("plan_link_issues");
|
|
444
|
+
|
|
445
|
+
const result = await tool.execute("call-2", {
|
|
446
|
+
fromIdentifier: "PROJ-1",
|
|
447
|
+
toIdentifier: "PROJ-2",
|
|
448
|
+
type: "blocks",
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
expect(mockLinearApi.createIssueRelation).toHaveBeenCalledWith({
|
|
452
|
+
issueId: "id-1",
|
|
453
|
+
relatedIssueId: "id-2",
|
|
454
|
+
type: "blocks",
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
expect(result).toEqual({
|
|
458
|
+
type: "json",
|
|
459
|
+
data: {
|
|
460
|
+
id: "rel-id",
|
|
461
|
+
from: "PROJ-1",
|
|
462
|
+
to: "PROJ-2",
|
|
463
|
+
type: "blocks",
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("plan_link_issues: throws on unknown identifier", async () => {
|
|
469
|
+
mockLinearApi.getProjectIssues.mockResolvedValueOnce([
|
|
470
|
+
makeIssue({ identifier: "PROJ-1", title: "A", id: "id-1" }),
|
|
471
|
+
]);
|
|
472
|
+
|
|
473
|
+
const tool = findTool("plan_link_issues");
|
|
474
|
+
|
|
475
|
+
await expect(
|
|
476
|
+
tool.execute("call-3", {
|
|
477
|
+
fromIdentifier: "PROJ-1",
|
|
478
|
+
toIdentifier: "PROJ-999",
|
|
479
|
+
type: "blocks",
|
|
480
|
+
}),
|
|
481
|
+
).rejects.toThrow("Unknown issue identifier: PROJ-999");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ---- plan_get_project ----
|
|
485
|
+
|
|
486
|
+
it("plan_get_project: returns formatted snapshot", async () => {
|
|
487
|
+
mockLinearApi.getProjectIssues.mockResolvedValueOnce([
|
|
488
|
+
makeIssue({ identifier: "PROJ-1", title: "Task A" }),
|
|
489
|
+
]);
|
|
490
|
+
|
|
491
|
+
const tool = findTool("plan_get_project");
|
|
492
|
+
const result = await tool.execute("call-4", {});
|
|
493
|
+
|
|
494
|
+
expect(mockLinearApi.getProjectIssues).toHaveBeenCalledWith(PROJECT_ID);
|
|
495
|
+
expect(result).toEqual({
|
|
496
|
+
type: "json",
|
|
497
|
+
data: {
|
|
498
|
+
issueCount: 1,
|
|
499
|
+
plan: expect.stringContaining("PROJ-1"),
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// ---- plan_audit ----
|
|
505
|
+
|
|
506
|
+
it("plan_audit: returns audit result", async () => {
|
|
507
|
+
mockLinearApi.getProjectIssues.mockResolvedValueOnce([
|
|
508
|
+
makeIssue({
|
|
509
|
+
identifier: "PROJ-1",
|
|
510
|
+
title: "Good issue",
|
|
511
|
+
relations: {
|
|
512
|
+
nodes: [{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } }],
|
|
513
|
+
} as any,
|
|
514
|
+
}),
|
|
515
|
+
makeIssue({
|
|
516
|
+
identifier: "PROJ-2",
|
|
517
|
+
title: "Another good issue",
|
|
518
|
+
parent: { identifier: "PROJ-1" } as any,
|
|
519
|
+
}),
|
|
520
|
+
]);
|
|
521
|
+
|
|
522
|
+
const tool = findTool("plan_audit");
|
|
523
|
+
const result = await tool.execute("call-5", {});
|
|
524
|
+
|
|
525
|
+
expect(mockLinearApi.getProjectIssues).toHaveBeenCalledWith(PROJECT_ID);
|
|
526
|
+
expect(result).toEqual({
|
|
527
|
+
type: "json",
|
|
528
|
+
data: {
|
|
529
|
+
pass: true,
|
|
530
|
+
problems: [],
|
|
531
|
+
warnings: [],
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
});
|