@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.
- package/README.md +834 -536
- package/index.ts +1 -1
- package/openclaw.plugin.json +3 -2
- package/package.json +1 -1
- package/prompts.yaml +46 -6
- package/src/__test__/fixtures/linear-responses.ts +75 -0
- package/src/__test__/fixtures/webhook-payloads.ts +113 -0
- package/src/__test__/helpers.ts +133 -0
- package/src/agent/agent.test.ts +192 -0
- package/src/agent/agent.ts +26 -1
- package/src/api/linear-api.test.ts +93 -1
- package/src/api/linear-api.ts +37 -1
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/infra/cli.ts +176 -1
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +30 -25
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +29 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +26 -15
- package/src/infra/observability.test.ts +85 -0
- package/src/pipeline/artifacts.test.ts +26 -3
- package/src/pipeline/dispatch-state.ts +1 -0
- package/src/pipeline/e2e-dispatch.test.ts +584 -0
- package/src/pipeline/e2e-planning.test.ts +478 -0
- package/src/pipeline/intent-classify.test.ts +285 -0
- package/src/pipeline/intent-classify.ts +259 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +47 -18
- package/src/pipeline/planner.test.ts +159 -40
- package/src/pipeline/planner.ts +108 -60
- package/src/pipeline/tier-assess.test.ts +89 -0
- package/src/pipeline/webhook.ts +424 -251
- package/src/tools/claude-tool.ts +6 -0
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/code-tool.ts +2 -2
- package/src/tools/dispatch-history-tool.test.ts +315 -0
- package/src/tools/planner-tools.test.ts +1 -1
- 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
|
+
});
|