@calltelemetry/openclaw-linear 0.7.1 → 0.8.1

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.
Files changed (41) hide show
  1. package/README.md +834 -536
  2. package/index.ts +1 -1
  3. package/openclaw.plugin.json +3 -2
  4. package/package.json +1 -1
  5. package/prompts.yaml +46 -6
  6. package/src/__test__/fixtures/linear-responses.ts +75 -0
  7. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  8. package/src/__test__/helpers.ts +133 -0
  9. package/src/agent/agent.test.ts +192 -0
  10. package/src/agent/agent.ts +26 -1
  11. package/src/api/linear-api.test.ts +93 -1
  12. package/src/api/linear-api.ts +37 -1
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/infra/cli.ts +176 -1
  15. package/src/infra/commands.test.ts +276 -0
  16. package/src/infra/doctor.test.ts +19 -0
  17. package/src/infra/doctor.ts +30 -25
  18. package/src/infra/multi-repo.test.ts +163 -0
  19. package/src/infra/multi-repo.ts +29 -0
  20. package/src/infra/notify.test.ts +155 -16
  21. package/src/infra/notify.ts +26 -15
  22. package/src/infra/observability.test.ts +85 -0
  23. package/src/pipeline/artifacts.test.ts +26 -3
  24. package/src/pipeline/dispatch-state.ts +1 -0
  25. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  26. package/src/pipeline/e2e-planning.test.ts +478 -0
  27. package/src/pipeline/intent-classify.test.ts +285 -0
  28. package/src/pipeline/intent-classify.ts +259 -0
  29. package/src/pipeline/pipeline.test.ts +69 -0
  30. package/src/pipeline/pipeline.ts +47 -18
  31. package/src/pipeline/planner.test.ts +159 -40
  32. package/src/pipeline/planner.ts +108 -60
  33. package/src/pipeline/tier-assess.test.ts +89 -0
  34. package/src/pipeline/webhook.ts +424 -251
  35. package/src/tools/claude-tool.ts +6 -0
  36. package/src/tools/cli-shared.test.ts +155 -0
  37. package/src/tools/code-tool.test.ts +210 -0
  38. package/src/tools/code-tool.ts +2 -2
  39. package/src/tools/dispatch-history-tool.test.ts +315 -0
  40. package/src/tools/planner-tools.test.ts +1 -1
  41. package/src/tools/planner-tools.ts +10 -2
@@ -0,0 +1,478 @@
1
+ /**
2
+ * E2E planning pipeline tests.
3
+ *
4
+ * Exercises the real planning lifecycle: initiatePlanningSession → handlePlannerTurn
5
+ * → runPlanAudit → plan_review → (webhook handles approval) → DAG dispatch cascade.
6
+ *
7
+ * Mocked: runAgent, LinearAgentApi, CLI tool runners. Real: planning-state.ts,
8
+ * planner.ts, planner-tools.ts (auditPlan, buildPlanSnapshot), dag-dispatch.ts.
9
+ */
10
+ import { describe, it, expect, beforeEach, vi } from "vitest";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Mocks — external boundaries only
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const { runAgentMock } = vi.hoisted(() => ({
17
+ runAgentMock: vi.fn().mockResolvedValue({ success: true, output: "Mock planner response" }),
18
+ }));
19
+
20
+ vi.mock("../agent/agent.js", () => ({
21
+ runAgent: runAgentMock,
22
+ }));
23
+
24
+ vi.mock("../api/linear-api.js", () => ({}));
25
+ vi.mock("openclaw/plugin-sdk", () => ({}));
26
+ vi.mock("../infra/observability.js", () => ({
27
+ emitDiagnostic: vi.fn(),
28
+ }));
29
+
30
+ // Mock CLI tool runners for cross-model review
31
+ vi.mock("../tools/claude-tool.js", () => ({
32
+ runClaude: vi.fn().mockResolvedValue({ success: true, output: "Claude review: looks good" }),
33
+ }));
34
+ vi.mock("../tools/codex-tool.js", () => ({
35
+ runCodex: vi.fn().mockResolvedValue({ success: true, output: "Codex review: approved" }),
36
+ }));
37
+ vi.mock("../tools/gemini-tool.js", () => ({
38
+ runGemini: vi.fn().mockResolvedValue({ success: true, output: "Gemini review: no issues" }),
39
+ }));
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Imports (AFTER mocks)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
46
+ import { readPlanningState, type PlanningSession } from "./planning-state.js";
47
+ import { writeProjectDispatch, readProjectDispatch, onProjectIssueCompleted, onProjectIssueStuck, type ProjectDispatchState } from "./dag-dispatch.js";
48
+ import { createMockLinearApi, tmpStatePath } from "../__test__/helpers.js";
49
+ import { makeProjectIssue } from "../__test__/fixtures/linear-responses.js";
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Helpers
53
+ // ---------------------------------------------------------------------------
54
+
55
+ function createCtx(configPath: string, overrides?: Record<string, unknown>) {
56
+ const linearApi = createMockLinearApi();
57
+ return {
58
+ ctx: {
59
+ api: {
60
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
61
+ pluginConfig: { planningStatePath: configPath, ...overrides },
62
+ } as any,
63
+ linearApi: linearApi as any,
64
+ pluginConfig: { planningStatePath: configPath, ...overrides },
65
+ },
66
+ linearApi,
67
+ };
68
+ }
69
+
70
+ function createSession(configPath: string, overrides?: Partial<PlanningSession>): PlanningSession {
71
+ return {
72
+ projectId: "proj-1",
73
+ projectName: "Test Project",
74
+ rootIssueId: "issue-1",
75
+ rootIdentifier: "PROJ-1",
76
+ teamId: "team-1",
77
+ status: "interviewing",
78
+ startedAt: new Date().toISOString(),
79
+ turnCount: 0,
80
+ ...overrides,
81
+ };
82
+ }
83
+
84
+ /** Build a set of project issues that will pass auditPlan (description ≥ 50 chars, estimate, priority). */
85
+ function makePassingIssues() {
86
+ return [
87
+ makeProjectIssue("PROJ-2", {
88
+ title: "Implement search API",
89
+ description: "As a user, I want a search API so that I can find content. Given I send a query, When results exist, Then they are returned with pagination.",
90
+ estimate: 3,
91
+ priority: 2,
92
+ labels: ["Epic"],
93
+ }),
94
+ makeProjectIssue("PROJ-3", {
95
+ title: "Build search results page",
96
+ description: "As a user, I want to see search results in a page. Given I perform a search, When results load, Then I see a paginated list of matching items.",
97
+ estimate: 2,
98
+ priority: 2,
99
+ parentIdentifier: "PROJ-2",
100
+ }),
101
+ makeProjectIssue("PROJ-4", {
102
+ title: "Add search autocomplete",
103
+ description: "As a user, I want autocomplete suggestions. Given I type in the search box, When 3+ characters entered, Then suggestions appear from the typeahead API.",
104
+ estimate: 1,
105
+ priority: 3,
106
+ parentIdentifier: "PROJ-2",
107
+ }),
108
+ ];
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Tests
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe("E2E planning pipeline", () => {
116
+ let configPath: string;
117
+
118
+ beforeEach(() => {
119
+ vi.clearAllMocks();
120
+ configPath = tmpStatePath("claw-e2e-plan-");
121
+ runAgentMock.mockResolvedValue({ success: true, output: "Mock planner response" });
122
+ });
123
+
124
+ // =========================================================================
125
+ // Test 1: Full lifecycle — initiate → interview → audit → plan_review
126
+ // =========================================================================
127
+ it("full lifecycle: initiate → interview turns → audit → plan_review", async () => {
128
+ const { ctx, linearApi } = createCtx(configPath);
129
+
130
+ const rootIssue = { id: "issue-1", identifier: "PROJ-1", title: "Root Issue", team: { id: "team-1" } };
131
+
132
+ // Mock getProject and getTeamStates for initiation
133
+ linearApi.getProject.mockResolvedValue({
134
+ id: "proj-1",
135
+ name: "Test Project",
136
+ teams: { nodes: [{ id: "team-1", name: "Team" }] },
137
+ });
138
+ linearApi.getTeamStates.mockResolvedValue([
139
+ { id: "st-1", name: "Backlog", type: "backlog" },
140
+ ]);
141
+
142
+ // Step 1: Initiate planning session
143
+ await initiatePlanningSession(ctx, "proj-1", rootIssue);
144
+
145
+ // Verify welcome comment posted
146
+ expect(linearApi.createComment).toHaveBeenCalledWith(
147
+ "issue-1",
148
+ expect.stringContaining("planning mode"),
149
+ );
150
+
151
+ // Verify session registered
152
+ let state = await readPlanningState(configPath);
153
+ expect(state.sessions["proj-1"]).toBeDefined();
154
+ expect(state.sessions["proj-1"].status).toBe("interviewing");
155
+ expect(state.sessions["proj-1"].turnCount).toBe(0);
156
+
157
+ // Step 2: Interview turn 1
158
+ const session = createSession(configPath);
159
+
160
+ // Mock getProjectIssues and getIssueDetails for interview
161
+ linearApi.getProjectIssues.mockResolvedValue([]);
162
+ linearApi.getIssueDetails.mockResolvedValue({
163
+ id: "issue-1",
164
+ identifier: "PROJ-1",
165
+ title: "Root Issue",
166
+ comments: { nodes: [] },
167
+ project: { id: "proj-1" },
168
+ team: { id: "team-1" },
169
+ });
170
+
171
+ await handlePlannerTurn(ctx, session, {
172
+ issueId: "issue-1",
173
+ commentBody: "Build a search API and results page",
174
+ commentorName: "User",
175
+ });
176
+
177
+ // Verify runAgent called for interview
178
+ expect(runAgentMock).toHaveBeenCalledTimes(1);
179
+
180
+ // Verify agent response posted as comment
181
+ expect(linearApi.createComment).toHaveBeenCalledWith("issue-1", "Mock planner response");
182
+
183
+ // Verify turnCount incremented
184
+ state = await readPlanningState(configPath);
185
+ expect(state.sessions["proj-1"].turnCount).toBe(1);
186
+
187
+ // Step 3: Interview turn 2
188
+ vi.clearAllMocks();
189
+ runAgentMock.mockResolvedValue({ success: true, output: "Great, plan updated." });
190
+
191
+ const session2 = { ...session, turnCount: 1 };
192
+ await handlePlannerTurn(ctx, session2, {
193
+ issueId: "issue-1",
194
+ commentBody: "Add autocomplete too",
195
+ commentorName: "User",
196
+ });
197
+
198
+ expect(runAgentMock).toHaveBeenCalledTimes(1);
199
+ state = await readPlanningState(configPath);
200
+ expect(state.sessions["proj-1"].turnCount).toBe(2);
201
+
202
+ // Step 4: Audit — with passing issues
203
+ // Note: finalize intent detection now happens in webhook.ts, not handlePlannerTurn.
204
+ // We call runPlanAudit directly (as the webhook would after intent classification).
205
+ vi.clearAllMocks();
206
+ runAgentMock.mockResolvedValue({ success: true, output: "Review complete." });
207
+ linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
208
+
209
+ const session3 = { ...session, turnCount: 2 };
210
+ await runPlanAudit(ctx, session3);
211
+
212
+ // Verify "Plan Passed Checks" comment (not "Approved" — that comes from webhook)
213
+ expect(linearApi.createComment).toHaveBeenCalledWith(
214
+ "issue-1",
215
+ expect.stringContaining("Plan Passed Checks"),
216
+ );
217
+
218
+ // Session transitions to plan_review (awaiting user's "approve plan")
219
+ state = await readPlanningState(configPath);
220
+ expect(state.sessions["proj-1"].status).toBe("plan_review");
221
+
222
+ // Cross-model review ran (runAgent called for review prompt)
223
+ expect(runAgentMock).toHaveBeenCalled();
224
+ });
225
+
226
+ // =========================================================================
227
+ // Test 2: Audit fail → fix issues → re-audit → plan_review
228
+ // =========================================================================
229
+ it("audit fail → fix issues → re-audit → plan_review", async () => {
230
+ const { ctx, linearApi } = createCtx(configPath);
231
+ const session = createSession(configPath);
232
+
233
+ // Register session so updatePlanningSession/endPlanningSession can find it
234
+ const { registerPlanningSession } = await import("./planning-state.js");
235
+ await registerPlanningSession("proj-1", session, configPath);
236
+
237
+ linearApi.getIssueDetails.mockResolvedValue({
238
+ id: "issue-1",
239
+ identifier: "PROJ-1",
240
+ title: "Root Issue",
241
+ comments: { nodes: [] },
242
+ });
243
+
244
+ // First audit — with issues that fail (missing descriptions/estimates)
245
+ linearApi.getProjectIssues.mockResolvedValue([
246
+ makeProjectIssue("PROJ-2", {
247
+ title: "Bad issue",
248
+ description: "short", // <50 chars → audit fails
249
+ }),
250
+ ]);
251
+
252
+ await runPlanAudit(ctx, session);
253
+
254
+ // Verify "Plan Audit Failed" comment
255
+ expect(linearApi.createComment).toHaveBeenCalledWith(
256
+ "issue-1",
257
+ expect.stringContaining("Plan Audit Failed"),
258
+ );
259
+
260
+ // Session should still be interviewing (NOT plan_review)
261
+ let state = await readPlanningState(configPath);
262
+ expect(state.sessions["proj-1"].status).toBe("interviewing");
263
+
264
+ // Second audit — with proper issues
265
+ vi.clearAllMocks();
266
+ runAgentMock.mockResolvedValue({ success: true, output: "Review complete." });
267
+ linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
268
+
269
+ await runPlanAudit(ctx, session);
270
+
271
+ // Now should be plan_review (waiting for user approval via webhook)
272
+ expect(linearApi.createComment).toHaveBeenCalledWith(
273
+ "issue-1",
274
+ expect.stringContaining("Plan Passed Checks"),
275
+ );
276
+
277
+ state = await readPlanningState(configPath);
278
+ expect(state.sessions["proj-1"].status).toBe("plan_review");
279
+ });
280
+
281
+ // =========================================================================
282
+ // Test 3: handlePlannerTurn is pure continue — no intent detection
283
+ // =========================================================================
284
+ it("handlePlannerTurn always runs agent regardless of message content", async () => {
285
+ const { ctx, linearApi } = createCtx(configPath);
286
+ const session = createSession(configPath);
287
+
288
+ const { registerPlanningSession } = await import("./planning-state.js");
289
+ await registerPlanningSession("proj-1", session, configPath);
290
+
291
+ linearApi.getProjectIssues.mockResolvedValue([]);
292
+ linearApi.getIssueDetails.mockResolvedValue({
293
+ id: "issue-1",
294
+ identifier: "PROJ-1",
295
+ title: "Root Issue",
296
+ comments: { nodes: [] },
297
+ });
298
+
299
+ // Even "finalize plan" goes through the agent (intent detection is in webhook)
300
+ await handlePlannerTurn(ctx, session, {
301
+ issueId: "issue-1",
302
+ commentBody: "finalize plan",
303
+ commentorName: "User",
304
+ });
305
+
306
+ expect(runAgentMock).toHaveBeenCalledTimes(1);
307
+ expect(linearApi.createComment).toHaveBeenCalledWith("issue-1", "Mock planner response");
308
+ });
309
+
310
+ // =========================================================================
311
+ // Test 4: Audit with warnings still passes
312
+ // =========================================================================
313
+ it("audit passes with warnings (AC warnings do not block)", async () => {
314
+ const { ctx, linearApi } = createCtx(configPath);
315
+ const session = createSession(configPath);
316
+
317
+ const { registerPlanningSession } = await import("./planning-state.js");
318
+ await registerPlanningSession("proj-1", session, configPath);
319
+
320
+ // Issues that pass but lack AC markers → warnings
321
+ linearApi.getProjectIssues.mockResolvedValue([
322
+ makeProjectIssue("PROJ-2", {
323
+ title: "Search feature",
324
+ description: "Build the search API endpoint with filtering and pagination support for the frontend application.",
325
+ estimate: 3,
326
+ priority: 2,
327
+ labels: ["Epic"],
328
+ }),
329
+ ]);
330
+
331
+ runAgentMock.mockResolvedValue({ success: true, output: "Review with warnings." });
332
+
333
+ await runPlanAudit(ctx, session);
334
+
335
+ // Still passes (warnings are not problems)
336
+ expect(linearApi.createComment).toHaveBeenCalledWith(
337
+ "issue-1",
338
+ expect.stringContaining("Plan Passed Checks"),
339
+ );
340
+
341
+ const state = await readPlanningState(configPath);
342
+ expect(state.sessions["proj-1"].status).toBe("plan_review");
343
+ });
344
+
345
+ // =========================================================================
346
+ // Test 5: DAG cascade — full chain
347
+ // =========================================================================
348
+ it("DAG cascade: complete issues in sequence, project completes", async () => {
349
+ const dagConfigPath = tmpStatePath("claw-e2e-dag-");
350
+ const notifyCalls: Array<[string, unknown]> = [];
351
+ const hookCtx = {
352
+ api: {
353
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
354
+ } as any,
355
+ linearApi: {} as any,
356
+ notify: vi.fn(async (kind: string, payload: unknown) => {
357
+ notifyCalls.push([kind, payload]);
358
+ }),
359
+ pluginConfig: {},
360
+ configPath: dagConfigPath,
361
+ };
362
+
363
+ // Set up 3-issue project: A → B → C
364
+ const projectDispatch: ProjectDispatchState = {
365
+ projectId: "proj-1",
366
+ projectName: "Test Project",
367
+ rootIdentifier: "PROJ-1",
368
+ status: "dispatching",
369
+ startedAt: new Date().toISOString(),
370
+ maxConcurrent: 3,
371
+ issues: {
372
+ "PROJ-A": {
373
+ identifier: "PROJ-A",
374
+ issueId: "id-a",
375
+ dependsOn: [],
376
+ unblocks: ["PROJ-B"],
377
+ dispatchStatus: "dispatched",
378
+ },
379
+ "PROJ-B": {
380
+ identifier: "PROJ-B",
381
+ issueId: "id-b",
382
+ dependsOn: ["PROJ-A"],
383
+ unblocks: ["PROJ-C"],
384
+ dispatchStatus: "pending",
385
+ },
386
+ "PROJ-C": {
387
+ identifier: "PROJ-C",
388
+ issueId: "id-c",
389
+ dependsOn: ["PROJ-B"],
390
+ unblocks: [],
391
+ dispatchStatus: "pending",
392
+ },
393
+ },
394
+ };
395
+ await writeProjectDispatch(projectDispatch, dagConfigPath);
396
+
397
+ // Complete A → B becomes ready
398
+ await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-A");
399
+
400
+ let state = await readProjectDispatch("proj-1", dagConfigPath);
401
+ expect(state!.issues["PROJ-A"].dispatchStatus).toBe("done");
402
+ expect(state!.issues["PROJ-B"].dispatchStatus).toBe("dispatched");
403
+ expect(state!.issues["PROJ-C"].dispatchStatus).toBe("pending");
404
+ expect(state!.status).toBe("dispatching");
405
+
406
+ // Complete B → C becomes ready
407
+ await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-B");
408
+
409
+ state = await readProjectDispatch("proj-1", dagConfigPath);
410
+ expect(state!.issues["PROJ-B"].dispatchStatus).toBe("done");
411
+ expect(state!.issues["PROJ-C"].dispatchStatus).toBe("dispatched");
412
+ expect(state!.status).toBe("dispatching");
413
+
414
+ // Complete C → project complete
415
+ await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-C");
416
+
417
+ state = await readProjectDispatch("proj-1", dagConfigPath);
418
+ expect(state!.issues["PROJ-C"].dispatchStatus).toBe("done");
419
+ expect(state!.status).toBe("completed");
420
+
421
+ // Verify notifications
422
+ const progressNotifications = notifyCalls.filter(([k]) => k === "project_progress");
423
+ const completeNotifications = notifyCalls.filter(([k]) => k === "project_complete");
424
+ expect(progressNotifications.length).toBe(2); // after A, after B
425
+ expect(completeNotifications.length).toBe(1); // after C
426
+ });
427
+
428
+ // =========================================================================
429
+ // Test 6: DAG stuck
430
+ // =========================================================================
431
+ it("DAG stuck: stuck issue blocks dependent, project stuck", async () => {
432
+ const dagConfigPath = tmpStatePath("claw-e2e-dag-stuck-");
433
+ const hookCtx = {
434
+ api: {
435
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
436
+ } as any,
437
+ linearApi: {} as any,
438
+ notify: vi.fn().mockResolvedValue(undefined),
439
+ pluginConfig: {},
440
+ configPath: dagConfigPath,
441
+ };
442
+
443
+ // 2-issue project: A → B
444
+ const projectDispatch: ProjectDispatchState = {
445
+ projectId: "proj-1",
446
+ projectName: "Test Project",
447
+ rootIdentifier: "PROJ-1",
448
+ status: "dispatching",
449
+ startedAt: new Date().toISOString(),
450
+ maxConcurrent: 3,
451
+ issues: {
452
+ "PROJ-A": {
453
+ identifier: "PROJ-A",
454
+ issueId: "id-a",
455
+ dependsOn: [],
456
+ unblocks: ["PROJ-B"],
457
+ dispatchStatus: "dispatched",
458
+ },
459
+ "PROJ-B": {
460
+ identifier: "PROJ-B",
461
+ issueId: "id-b",
462
+ dependsOn: ["PROJ-A"],
463
+ unblocks: [],
464
+ dispatchStatus: "pending",
465
+ },
466
+ },
467
+ };
468
+ await writeProjectDispatch(projectDispatch, dagConfigPath);
469
+
470
+ // A gets stuck
471
+ await onProjectIssueStuck(hookCtx, "proj-1", "PROJ-A");
472
+
473
+ const state = await readProjectDispatch("proj-1", dagConfigPath);
474
+ expect(state!.issues["PROJ-A"].dispatchStatus).toBe("stuck");
475
+ expect(state!.issues["PROJ-B"].dispatchStatus).toBe("pending"); // still blocked
476
+ expect(state!.status).toBe("stuck"); // project stuck — no progress possible
477
+ });
478
+ });