@calltelemetry/openclaw-linear 0.9.0 → 0.9.2
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 +355 -223
- package/package.json +1 -1
- package/src/__test__/smoke-linear-api.test.ts +147 -0
- package/src/infra/doctor.test.ts +763 -3
- package/src/pipeline/dag-dispatch.test.ts +444 -0
- package/src/pipeline/pipeline.test.ts +1247 -1
- package/src/pipeline/planner.test.ts +457 -3
- package/src/pipeline/planning-state.test.ts +164 -3
- package/src/pipeline/webhook-dedup.test.ts +1 -1
- package/src/pipeline/webhook.test.ts +2438 -19
- package/src/tools/planner-tools.test.ts +722 -0
|
@@ -1,36 +1,244 @@
|
|
|
1
1
|
import type { AddressInfo } from "node:net";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
const {
|
|
6
|
+
// ── Hoisted mock values ──────────────────────────────────────────────
|
|
7
|
+
const {
|
|
8
|
+
runPlannerStageMock,
|
|
9
|
+
runFullPipelineMock,
|
|
10
|
+
resumePipelineMock,
|
|
11
|
+
spawnWorkerMock,
|
|
12
|
+
resolveLinearTokenMock,
|
|
13
|
+
mockLinearApiInstance,
|
|
14
|
+
loadAgentProfilesMock,
|
|
15
|
+
buildMentionPatternMock,
|
|
16
|
+
resolveAgentFromAliasMock,
|
|
17
|
+
resetProfilesCacheMock,
|
|
18
|
+
classifyIntentMock,
|
|
19
|
+
extractGuidanceMock,
|
|
20
|
+
formatGuidanceAppendixMock,
|
|
21
|
+
cacheGuidanceForTeamMock,
|
|
22
|
+
getCachedGuidanceForTeamMock,
|
|
23
|
+
isGuidanceEnabledMock,
|
|
24
|
+
resetGuidanceCacheMock,
|
|
25
|
+
setActiveSessionMock,
|
|
26
|
+
clearActiveSessionMock,
|
|
27
|
+
readDispatchStateMock,
|
|
28
|
+
getActiveDispatchMock,
|
|
29
|
+
registerDispatchMock,
|
|
30
|
+
updateDispatchStatusMock,
|
|
31
|
+
completeDispatchMock,
|
|
32
|
+
removeActiveDispatchMock,
|
|
33
|
+
assessTierMock,
|
|
34
|
+
createWorktreeMock,
|
|
35
|
+
createMultiWorktreeMock,
|
|
36
|
+
prepareWorkspaceMock,
|
|
37
|
+
resolveReposMock,
|
|
38
|
+
isMultiRepoMock,
|
|
39
|
+
ensureClawDirMock,
|
|
40
|
+
writeManifestMock,
|
|
41
|
+
writeDispatchMemoryMock,
|
|
42
|
+
resolveOrchestratorWorkspaceMock,
|
|
43
|
+
readPlanningStateMock,
|
|
44
|
+
isInPlanningModeMock,
|
|
45
|
+
getPlanningSessionMock,
|
|
46
|
+
endPlanningSessionMock,
|
|
47
|
+
initiatePlanningSessionMock,
|
|
48
|
+
handlePlannerTurnMock,
|
|
49
|
+
runPlanAuditMock,
|
|
50
|
+
startProjectDispatchMock,
|
|
51
|
+
emitDiagnosticMock,
|
|
52
|
+
createNotifierFromConfigMock,
|
|
53
|
+
runAgentMock,
|
|
54
|
+
} = vi.hoisted(() => ({
|
|
8
55
|
runPlannerStageMock: vi.fn().mockResolvedValue("mock plan"),
|
|
9
56
|
runFullPipelineMock: vi.fn().mockResolvedValue(undefined),
|
|
10
57
|
resumePipelineMock: vi.fn().mockResolvedValue(undefined),
|
|
58
|
+
spawnWorkerMock: vi.fn().mockResolvedValue(undefined),
|
|
59
|
+
resolveLinearTokenMock: vi.fn().mockReturnValue({
|
|
60
|
+
accessToken: "test-token",
|
|
61
|
+
refreshToken: "test-refresh",
|
|
62
|
+
expiresAt: Date.now() + 86400000,
|
|
63
|
+
source: "env",
|
|
64
|
+
}),
|
|
65
|
+
mockLinearApiInstance: {
|
|
66
|
+
emitActivity: vi.fn().mockResolvedValue(undefined),
|
|
67
|
+
createComment: vi.fn().mockResolvedValue("comment-id"),
|
|
68
|
+
getIssueDetails: vi.fn().mockResolvedValue(null),
|
|
69
|
+
updateSession: vi.fn().mockResolvedValue(undefined),
|
|
70
|
+
getViewerId: vi.fn().mockResolvedValue("viewer-1"),
|
|
71
|
+
createSessionOnIssue: vi.fn().mockResolvedValue({ sessionId: "sess-new" }),
|
|
72
|
+
updateIssue: vi.fn().mockResolvedValue(undefined),
|
|
73
|
+
getTeamLabels: vi.fn().mockResolvedValue([]),
|
|
74
|
+
getTeamStates: vi.fn().mockResolvedValue([
|
|
75
|
+
{ id: "st-1", name: "Backlog", type: "backlog" },
|
|
76
|
+
{ id: "st-2", name: "In Progress", type: "started" },
|
|
77
|
+
{ id: "st-3", name: "Done", type: "completed" },
|
|
78
|
+
]),
|
|
79
|
+
},
|
|
80
|
+
loadAgentProfilesMock: vi.fn().mockReturnValue({
|
|
81
|
+
mal: { label: "Mal", mission: "captain", mentionAliases: ["mal", "mason"], isDefault: true, avatarUrl: "https://example.com/mal.png" },
|
|
82
|
+
kaylee: { label: "Kaylee", mission: "builder", mentionAliases: ["kaylee", "eureka"], avatarUrl: "https://example.com/kaylee.png" },
|
|
83
|
+
}),
|
|
84
|
+
buildMentionPatternMock: vi.fn().mockReturnValue(/@(mal|mason|kaylee|eureka)/i),
|
|
85
|
+
resolveAgentFromAliasMock: vi.fn().mockReturnValue(null),
|
|
86
|
+
resetProfilesCacheMock: vi.fn(),
|
|
87
|
+
classifyIntentMock: vi.fn().mockResolvedValue({
|
|
88
|
+
intent: "general",
|
|
89
|
+
reasoning: "Not actionable",
|
|
90
|
+
fromFallback: false,
|
|
91
|
+
}),
|
|
92
|
+
extractGuidanceMock: vi.fn().mockReturnValue({ guidance: null, source: null }),
|
|
93
|
+
formatGuidanceAppendixMock: vi.fn().mockReturnValue(""),
|
|
94
|
+
cacheGuidanceForTeamMock: vi.fn(),
|
|
95
|
+
getCachedGuidanceForTeamMock: vi.fn().mockReturnValue(null),
|
|
96
|
+
isGuidanceEnabledMock: vi.fn().mockReturnValue(false),
|
|
97
|
+
resetGuidanceCacheMock: vi.fn(),
|
|
98
|
+
setActiveSessionMock: vi.fn(),
|
|
99
|
+
clearActiveSessionMock: vi.fn(),
|
|
100
|
+
readDispatchStateMock: vi.fn().mockResolvedValue({ activeDispatches: {} }),
|
|
101
|
+
getActiveDispatchMock: vi.fn().mockReturnValue(null),
|
|
102
|
+
registerDispatchMock: vi.fn().mockResolvedValue(undefined),
|
|
103
|
+
updateDispatchStatusMock: vi.fn().mockResolvedValue(undefined),
|
|
104
|
+
completeDispatchMock: vi.fn().mockResolvedValue(undefined),
|
|
105
|
+
removeActiveDispatchMock: vi.fn().mockResolvedValue(undefined),
|
|
106
|
+
assessTierMock: vi.fn().mockResolvedValue({ tier: "medium", model: "anthropic/claude-sonnet-4-6", reasoning: "moderate complexity" }),
|
|
107
|
+
createWorktreeMock: vi.fn().mockReturnValue({ path: "/tmp/worktree", branch: "codex/ENG-123", resumed: false }),
|
|
108
|
+
createMultiWorktreeMock: vi.fn().mockReturnValue({ parentPath: "/tmp/multi", worktrees: [] }),
|
|
109
|
+
prepareWorkspaceMock: vi.fn().mockReturnValue({ pulled: true, submodulesInitialized: false, errors: [] }),
|
|
110
|
+
resolveReposMock: vi.fn().mockReturnValue({ repos: [{ name: "main", path: "/home/claw/ai-workspace" }], source: "config_default" }),
|
|
111
|
+
isMultiRepoMock: vi.fn().mockReturnValue(false),
|
|
112
|
+
ensureClawDirMock: vi.fn(),
|
|
113
|
+
writeManifestMock: vi.fn(),
|
|
114
|
+
writeDispatchMemoryMock: vi.fn(),
|
|
115
|
+
resolveOrchestratorWorkspaceMock: vi.fn().mockReturnValue("/tmp/workspace"),
|
|
116
|
+
readPlanningStateMock: vi.fn().mockResolvedValue({ sessions: {} }),
|
|
117
|
+
isInPlanningModeMock: vi.fn().mockReturnValue(false),
|
|
118
|
+
getPlanningSessionMock: vi.fn().mockReturnValue(null),
|
|
119
|
+
endPlanningSessionMock: vi.fn().mockResolvedValue(undefined),
|
|
120
|
+
initiatePlanningSessionMock: vi.fn().mockResolvedValue(undefined),
|
|
121
|
+
handlePlannerTurnMock: vi.fn().mockResolvedValue(undefined),
|
|
122
|
+
runPlanAuditMock: vi.fn().mockResolvedValue(undefined),
|
|
123
|
+
startProjectDispatchMock: vi.fn().mockResolvedValue(undefined),
|
|
124
|
+
emitDiagnosticMock: vi.fn(),
|
|
125
|
+
createNotifierFromConfigMock: vi.fn().mockReturnValue(vi.fn().mockResolvedValue(undefined)),
|
|
126
|
+
runAgentMock: vi.fn().mockResolvedValue({ success: true, output: "Agent response text" }),
|
|
11
127
|
}));
|
|
12
128
|
|
|
129
|
+
// ── Module mocks ─────────────────────────────────────────────────────
|
|
130
|
+
|
|
13
131
|
vi.mock("./pipeline.js", () => ({
|
|
14
132
|
runPlannerStage: runPlannerStageMock,
|
|
15
133
|
runFullPipeline: runFullPipelineMock,
|
|
16
134
|
resumePipeline: resumePipelineMock,
|
|
135
|
+
spawnWorker: spawnWorkerMock,
|
|
17
136
|
}));
|
|
18
137
|
|
|
19
|
-
// Mock the linear-api module
|
|
20
138
|
vi.mock("../api/linear-api.js", () => ({
|
|
21
139
|
LinearAgentApi: class MockLinearAgentApi {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
updateSession = vi.fn().mockResolvedValue(undefined);
|
|
140
|
+
constructor() {
|
|
141
|
+
return mockLinearApiInstance;
|
|
142
|
+
}
|
|
26
143
|
},
|
|
27
|
-
resolveLinearToken:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
144
|
+
resolveLinearToken: resolveLinearTokenMock,
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
vi.mock("../infra/shared-profiles.js", () => ({
|
|
148
|
+
loadAgentProfiles: loadAgentProfilesMock,
|
|
149
|
+
buildMentionPattern: buildMentionPatternMock,
|
|
150
|
+
resolveAgentFromAlias: resolveAgentFromAliasMock,
|
|
151
|
+
_resetProfilesCacheForTesting: resetProfilesCacheMock,
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
vi.mock("./intent-classify.js", () => ({
|
|
155
|
+
classifyIntent: classifyIntentMock,
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
vi.mock("./guidance.js", () => ({
|
|
159
|
+
extractGuidance: extractGuidanceMock,
|
|
160
|
+
formatGuidanceAppendix: formatGuidanceAppendixMock,
|
|
161
|
+
cacheGuidanceForTeam: cacheGuidanceForTeamMock,
|
|
162
|
+
getCachedGuidanceForTeam: getCachedGuidanceForTeamMock,
|
|
163
|
+
isGuidanceEnabled: isGuidanceEnabledMock,
|
|
164
|
+
_resetGuidanceCacheForTesting: resetGuidanceCacheMock,
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
vi.mock("./active-session.js", () => ({
|
|
168
|
+
setActiveSession: setActiveSessionMock,
|
|
169
|
+
clearActiveSession: clearActiveSessionMock,
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
vi.mock("./dispatch-state.js", () => ({
|
|
173
|
+
readDispatchState: readDispatchStateMock,
|
|
174
|
+
getActiveDispatch: getActiveDispatchMock,
|
|
175
|
+
registerDispatch: registerDispatchMock,
|
|
176
|
+
updateDispatchStatus: updateDispatchStatusMock,
|
|
177
|
+
completeDispatch: completeDispatchMock,
|
|
178
|
+
removeActiveDispatch: removeActiveDispatchMock,
|
|
179
|
+
}));
|
|
180
|
+
|
|
181
|
+
vi.mock("./tier-assess.js", () => ({
|
|
182
|
+
assessTier: assessTierMock,
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
vi.mock("../infra/codex-worktree.js", () => ({
|
|
186
|
+
createWorktree: createWorktreeMock,
|
|
187
|
+
createMultiWorktree: createMultiWorktreeMock,
|
|
188
|
+
prepareWorkspace: prepareWorkspaceMock,
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
vi.mock("../infra/multi-repo.js", () => ({
|
|
192
|
+
resolveRepos: resolveReposMock,
|
|
193
|
+
isMultiRepo: isMultiRepoMock,
|
|
194
|
+
}));
|
|
195
|
+
|
|
196
|
+
vi.mock("./artifacts.js", () => ({
|
|
197
|
+
ensureClawDir: ensureClawDirMock,
|
|
198
|
+
writeManifest: writeManifestMock,
|
|
199
|
+
writeDispatchMemory: writeDispatchMemoryMock,
|
|
200
|
+
resolveOrchestratorWorkspace: resolveOrchestratorWorkspaceMock,
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
vi.mock("./planning-state.js", () => ({
|
|
204
|
+
readPlanningState: readPlanningStateMock,
|
|
205
|
+
isInPlanningMode: isInPlanningModeMock,
|
|
206
|
+
getPlanningSession: getPlanningSessionMock,
|
|
207
|
+
endPlanningSession: endPlanningSessionMock,
|
|
208
|
+
}));
|
|
209
|
+
|
|
210
|
+
vi.mock("./planner.js", () => ({
|
|
211
|
+
initiatePlanningSession: initiatePlanningSessionMock,
|
|
212
|
+
handlePlannerTurn: handlePlannerTurnMock,
|
|
213
|
+
runPlanAudit: runPlanAuditMock,
|
|
31
214
|
}));
|
|
32
215
|
|
|
33
|
-
|
|
216
|
+
vi.mock("./dag-dispatch.js", () => ({
|
|
217
|
+
startProjectDispatch: startProjectDispatchMock,
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
vi.mock("../infra/observability.js", () => ({
|
|
221
|
+
emitDiagnostic: emitDiagnosticMock,
|
|
222
|
+
}));
|
|
223
|
+
|
|
224
|
+
vi.mock("../infra/notify.js", () => ({
|
|
225
|
+
createNotifierFromConfig: createNotifierFromConfigMock,
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
vi.mock("../agent/agent.js", () => ({
|
|
229
|
+
runAgent: runAgentMock,
|
|
230
|
+
}));
|
|
231
|
+
|
|
232
|
+
import {
|
|
233
|
+
handleLinearWebhook,
|
|
234
|
+
sanitizePromptInput,
|
|
235
|
+
readJsonBody,
|
|
236
|
+
_resetForTesting,
|
|
237
|
+
_configureDedupTtls,
|
|
238
|
+
_getDedupTtlMs,
|
|
239
|
+
_addActiveRunForTesting,
|
|
240
|
+
_markAsProcessedForTesting,
|
|
241
|
+
} from "./webhook.js";
|
|
34
242
|
|
|
35
243
|
function createApi(): OpenClawPluginApi {
|
|
36
244
|
return {
|
|
@@ -68,14 +276,20 @@ async function postWebhook(payload: unknown, path = "/linear/webhook") {
|
|
|
68
276
|
const api = createApi();
|
|
69
277
|
let status = 0;
|
|
70
278
|
let body = "";
|
|
279
|
+
// Track when the handler finishes (important: handleLinearWebhook does
|
|
280
|
+
// async work AFTER res.end(), so the HTTP response arrives before the
|
|
281
|
+
// handler completes). We capture the handler promise and wait for it.
|
|
282
|
+
let handlerDone: Promise<void> | undefined;
|
|
71
283
|
|
|
72
284
|
await withServer(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
285
|
+
(req, res) => {
|
|
286
|
+
handlerDone = (async () => {
|
|
287
|
+
const handled = await handleLinearWebhook(api, req, res);
|
|
288
|
+
if (!handled) {
|
|
289
|
+
res.statusCode = 404;
|
|
290
|
+
res.end("not found");
|
|
291
|
+
}
|
|
292
|
+
})();
|
|
79
293
|
},
|
|
80
294
|
async (baseUrl) => {
|
|
81
295
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
@@ -88,16 +302,84 @@ async function postWebhook(payload: unknown, path = "/linear/webhook") {
|
|
|
88
302
|
|
|
89
303
|
status = response.status;
|
|
90
304
|
body = await response.text();
|
|
305
|
+
// Wait for the handler to fully complete (including async work after res.end)
|
|
306
|
+
if (handlerDone) await handlerDone;
|
|
91
307
|
},
|
|
92
308
|
);
|
|
93
309
|
|
|
94
310
|
return { api, status, body };
|
|
95
311
|
}
|
|
96
312
|
|
|
313
|
+
beforeEach(() => {
|
|
314
|
+
_resetForTesting();
|
|
315
|
+
});
|
|
316
|
+
|
|
97
317
|
afterEach(() => {
|
|
98
318
|
runPlannerStageMock.mockReset().mockResolvedValue("mock plan");
|
|
99
319
|
runFullPipelineMock.mockReset().mockResolvedValue(undefined);
|
|
100
320
|
resumePipelineMock.mockReset().mockResolvedValue(undefined);
|
|
321
|
+
spawnWorkerMock.mockReset().mockResolvedValue(undefined);
|
|
322
|
+
mockLinearApiInstance.emitActivity.mockReset().mockResolvedValue(undefined);
|
|
323
|
+
mockLinearApiInstance.createComment.mockReset().mockResolvedValue("comment-id");
|
|
324
|
+
mockLinearApiInstance.getIssueDetails.mockReset().mockResolvedValue(null);
|
|
325
|
+
mockLinearApiInstance.getViewerId.mockReset().mockResolvedValue("viewer-1");
|
|
326
|
+
mockLinearApiInstance.createSessionOnIssue.mockReset().mockResolvedValue({ sessionId: "sess-new" });
|
|
327
|
+
mockLinearApiInstance.updateIssue.mockReset().mockResolvedValue(undefined);
|
|
328
|
+
mockLinearApiInstance.getTeamLabels.mockReset().mockResolvedValue([]);
|
|
329
|
+
mockLinearApiInstance.getTeamStates.mockReset().mockResolvedValue([
|
|
330
|
+
{ id: "st-1", name: "Backlog", type: "backlog" },
|
|
331
|
+
{ id: "st-2", name: "In Progress", type: "started" },
|
|
332
|
+
{ id: "st-3", name: "Done", type: "completed" },
|
|
333
|
+
]);
|
|
334
|
+
resolveLinearTokenMock.mockReset().mockReturnValue({
|
|
335
|
+
accessToken: "test-token",
|
|
336
|
+
refreshToken: "test-refresh",
|
|
337
|
+
expiresAt: Date.now() + 86400000,
|
|
338
|
+
source: "env",
|
|
339
|
+
});
|
|
340
|
+
loadAgentProfilesMock.mockReset().mockReturnValue({
|
|
341
|
+
mal: { label: "Mal", mission: "captain", mentionAliases: ["mal", "mason"], isDefault: true, avatarUrl: "https://example.com/mal.png" },
|
|
342
|
+
kaylee: { label: "Kaylee", mission: "builder", mentionAliases: ["kaylee", "eureka"], avatarUrl: "https://example.com/kaylee.png" },
|
|
343
|
+
});
|
|
344
|
+
buildMentionPatternMock.mockReset().mockReturnValue(/@(mal|mason|kaylee|eureka)/i);
|
|
345
|
+
resolveAgentFromAliasMock.mockReset().mockReturnValue(null);
|
|
346
|
+
classifyIntentMock.mockReset().mockResolvedValue({
|
|
347
|
+
intent: "general",
|
|
348
|
+
reasoning: "Not actionable",
|
|
349
|
+
fromFallback: false,
|
|
350
|
+
});
|
|
351
|
+
extractGuidanceMock.mockReset().mockReturnValue({ guidance: null, source: null });
|
|
352
|
+
formatGuidanceAppendixMock.mockReset().mockReturnValue("");
|
|
353
|
+
cacheGuidanceForTeamMock.mockReset();
|
|
354
|
+
getCachedGuidanceForTeamMock.mockReset().mockReturnValue(null);
|
|
355
|
+
isGuidanceEnabledMock.mockReset().mockReturnValue(false);
|
|
356
|
+
setActiveSessionMock.mockReset();
|
|
357
|
+
clearActiveSessionMock.mockReset();
|
|
358
|
+
readDispatchStateMock.mockReset().mockResolvedValue({ activeDispatches: {} });
|
|
359
|
+
getActiveDispatchMock.mockReset().mockReturnValue(null);
|
|
360
|
+
registerDispatchMock.mockReset().mockResolvedValue(undefined);
|
|
361
|
+
updateDispatchStatusMock.mockReset().mockResolvedValue(undefined);
|
|
362
|
+
removeActiveDispatchMock.mockReset().mockResolvedValue(undefined);
|
|
363
|
+
assessTierMock.mockReset().mockResolvedValue({ tier: "medium", model: "anthropic/claude-sonnet-4-6", reasoning: "moderate complexity" });
|
|
364
|
+
createWorktreeMock.mockReset().mockReturnValue({ path: "/tmp/worktree", branch: "codex/ENG-123", resumed: false });
|
|
365
|
+
prepareWorkspaceMock.mockReset().mockReturnValue({ pulled: true, submodulesInitialized: false, errors: [] });
|
|
366
|
+
resolveReposMock.mockReset().mockReturnValue({ repos: [{ name: "main", path: "/home/claw/ai-workspace" }], source: "config_default" });
|
|
367
|
+
isMultiRepoMock.mockReset().mockReturnValue(false);
|
|
368
|
+
ensureClawDirMock.mockReset();
|
|
369
|
+
writeManifestMock.mockReset();
|
|
370
|
+
writeDispatchMemoryMock.mockReset();
|
|
371
|
+
resolveOrchestratorWorkspaceMock.mockReset().mockReturnValue("/tmp/workspace");
|
|
372
|
+
readPlanningStateMock.mockReset().mockResolvedValue({ sessions: {} });
|
|
373
|
+
isInPlanningModeMock.mockReset().mockReturnValue(false);
|
|
374
|
+
getPlanningSessionMock.mockReset().mockReturnValue(null);
|
|
375
|
+
endPlanningSessionMock.mockReset().mockResolvedValue(undefined);
|
|
376
|
+
initiatePlanningSessionMock.mockReset().mockResolvedValue(undefined);
|
|
377
|
+
handlePlannerTurnMock.mockReset().mockResolvedValue(undefined);
|
|
378
|
+
runPlanAuditMock.mockReset().mockResolvedValue(undefined);
|
|
379
|
+
startProjectDispatchMock.mockReset().mockResolvedValue(undefined);
|
|
380
|
+
emitDiagnosticMock.mockReset();
|
|
381
|
+
createNotifierFromConfigMock.mockReset().mockReturnValue(vi.fn().mockResolvedValue(undefined));
|
|
382
|
+
runAgentMock.mockReset().mockResolvedValue({ success: true, output: "Agent response text" });
|
|
101
383
|
});
|
|
102
384
|
|
|
103
385
|
describe("handleLinearWebhook", () => {
|
|
@@ -336,4 +618,2141 @@ describe("readJsonBody", () => {
|
|
|
336
618
|
expect(result.ok).toBe(false);
|
|
337
619
|
expect(result.error).toBe("payload too large");
|
|
338
620
|
});
|
|
621
|
+
|
|
622
|
+
it("returns error on stream error event", async () => {
|
|
623
|
+
const { PassThrough } = await import("node:stream");
|
|
624
|
+
const fakeReq = new PassThrough() as unknown as import("node:http").IncomingMessage;
|
|
625
|
+
|
|
626
|
+
setTimeout(() => {
|
|
627
|
+
(fakeReq as any).destroy(new Error("connection reset"));
|
|
628
|
+
}, 10);
|
|
629
|
+
|
|
630
|
+
const result = await readJsonBody(fakeReq, 1024, 5000);
|
|
631
|
+
expect(result.ok).toBe(false);
|
|
632
|
+
// Could be "request error" or "Request body timeout" depending on timing
|
|
633
|
+
expect(result.error).toBeTruthy();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it("returns error for invalid JSON", async () => {
|
|
637
|
+
const { PassThrough } = await import("node:stream");
|
|
638
|
+
const fakeReq = new PassThrough() as unknown as import("node:http").IncomingMessage;
|
|
639
|
+
|
|
640
|
+
setTimeout(() => {
|
|
641
|
+
(fakeReq as any).write(Buffer.from("not valid json{{{"));
|
|
642
|
+
(fakeReq as any).end();
|
|
643
|
+
}, 10);
|
|
644
|
+
|
|
645
|
+
const result = await readJsonBody(fakeReq, 1024, 5000);
|
|
646
|
+
expect(result.ok).toBe(false);
|
|
647
|
+
expect(result.error).toBe("invalid json");
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// ---------------------------------------------------------------------------
|
|
652
|
+
// _configureDedupTtls / _getDedupTtlMs — test-only exports
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
describe("_configureDedupTtls", () => {
|
|
656
|
+
it("uses defaults when pluginConfig is undefined", () => {
|
|
657
|
+
_configureDedupTtls(undefined);
|
|
658
|
+
expect(_getDedupTtlMs()).toBe(60_000);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("uses defaults when pluginConfig is empty", () => {
|
|
662
|
+
_configureDedupTtls({});
|
|
663
|
+
expect(_getDedupTtlMs()).toBe(60_000);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("applies custom dedupTtlMs from pluginConfig", () => {
|
|
667
|
+
_configureDedupTtls({ dedupTtlMs: 120_000 });
|
|
668
|
+
expect(_getDedupTtlMs()).toBe(120_000);
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
// _addActiveRunForTesting / _markAsProcessedForTesting
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
|
|
676
|
+
describe("dedup test helpers", () => {
|
|
677
|
+
it("_addActiveRunForTesting causes activeRuns guard to trigger on AgentSession created", async () => {
|
|
678
|
+
_addActiveRunForTesting("issue-guard");
|
|
679
|
+
|
|
680
|
+
const result = await postWebhook({
|
|
681
|
+
type: "AgentSessionEvent",
|
|
682
|
+
action: "created",
|
|
683
|
+
agentSession: {
|
|
684
|
+
id: "sess-guard",
|
|
685
|
+
issue: { id: "issue-guard", identifier: "ENG-GUARD" },
|
|
686
|
+
},
|
|
687
|
+
previousComments: [],
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
expect(result.status).toBe(200);
|
|
691
|
+
// Should log that it skipped due to active run
|
|
692
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
693
|
+
expect(infoCalls.some((msg: string) => msg.includes("skipping session"))).toBe(true);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("_markAsProcessedForTesting causes dedup to trigger on session", async () => {
|
|
697
|
+
_markAsProcessedForTesting("session:sess-dedup");
|
|
698
|
+
|
|
699
|
+
const result = await postWebhook({
|
|
700
|
+
type: "AgentSessionEvent",
|
|
701
|
+
action: "created",
|
|
702
|
+
agentSession: {
|
|
703
|
+
id: "sess-dedup",
|
|
704
|
+
issue: { id: "issue-dedup", identifier: "ENG-DEDUP" },
|
|
705
|
+
},
|
|
706
|
+
previousComments: [],
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
expect(result.status).toBe(200);
|
|
710
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
711
|
+
expect(infoCalls.some((msg: string) => msg.includes("already handled"))).toBe(true);
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// ---------------------------------------------------------------------------
|
|
716
|
+
// AppUserNotification — ignored path
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
|
|
719
|
+
describe("AppUserNotification handling", () => {
|
|
720
|
+
it("responds 200 and ignores AppUserNotification payloads", async () => {
|
|
721
|
+
const result = await postWebhook({
|
|
722
|
+
type: "AppUserNotification",
|
|
723
|
+
action: "create",
|
|
724
|
+
notification: { type: "issueAssigned" },
|
|
725
|
+
appUserId: "app-user-1",
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
expect(result.status).toBe(200);
|
|
729
|
+
expect(result.body).toBe("ok");
|
|
730
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
731
|
+
expect(infoCalls.some((msg: string) => msg.includes("AppUserNotification ignored"))).toBe(true);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// ---------------------------------------------------------------------------
|
|
736
|
+
// AgentSessionEvent.created — full flow
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
describe("AgentSessionEvent.created full flow", () => {
|
|
740
|
+
it("resolves agent, fetches issue details, and runs agent for valid session", async () => {
|
|
741
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
742
|
+
id: "issue-ase",
|
|
743
|
+
identifier: "ENG-ASE",
|
|
744
|
+
title: "Test ASE",
|
|
745
|
+
description: "Test description",
|
|
746
|
+
state: { name: "Backlog", type: "backlog" },
|
|
747
|
+
assignee: { name: "User1" },
|
|
748
|
+
team: { id: "team-1" },
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const result = await postWebhook({
|
|
752
|
+
type: "AgentSessionEvent",
|
|
753
|
+
action: "created",
|
|
754
|
+
agentSession: {
|
|
755
|
+
id: "sess-ase-full",
|
|
756
|
+
issue: { id: "issue-ase", identifier: "ENG-ASE", title: "Test ASE" },
|
|
757
|
+
},
|
|
758
|
+
previousComments: [
|
|
759
|
+
{ body: "Can you investigate?", user: { name: "Dev" } },
|
|
760
|
+
],
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
expect(result.status).toBe(200);
|
|
764
|
+
expect(result.body).toBe("ok");
|
|
765
|
+
// Allow async handler to run
|
|
766
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
767
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
768
|
+
expect(setActiveSessionMock).toHaveBeenCalled();
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it("skips when no Linear access token", async () => {
|
|
772
|
+
resolveLinearTokenMock.mockReturnValue({ accessToken: null, source: "none" });
|
|
773
|
+
|
|
774
|
+
const result = await postWebhook({
|
|
775
|
+
type: "AgentSessionEvent",
|
|
776
|
+
action: "created",
|
|
777
|
+
agentSession: {
|
|
778
|
+
id: "sess-no-token",
|
|
779
|
+
issue: { id: "issue-no-token", identifier: "ENG-NT" },
|
|
780
|
+
},
|
|
781
|
+
previousComments: [],
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
expect(result.status).toBe(200);
|
|
785
|
+
const errorCalls = (result.api.logger.error as any).mock.calls.map((c: any[]) => c[0]);
|
|
786
|
+
expect(errorCalls.some((msg: string) => msg.includes("No Linear access token"))).toBe(true);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it("routes to mentioned agent when @mention is present", async () => {
|
|
790
|
+
resolveAgentFromAliasMock.mockReturnValue({ agentId: "kaylee", profile: { label: "Kaylee" } });
|
|
791
|
+
|
|
792
|
+
const result = await postWebhook({
|
|
793
|
+
type: "AgentSessionEvent",
|
|
794
|
+
action: "created",
|
|
795
|
+
agentSession: {
|
|
796
|
+
id: "sess-mention",
|
|
797
|
+
issue: { id: "issue-mention", identifier: "ENG-MENTION" },
|
|
798
|
+
},
|
|
799
|
+
previousComments: [
|
|
800
|
+
{ body: "@kaylee please fix this", user: { name: "Dev" } },
|
|
801
|
+
],
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
expect(result.status).toBe(200);
|
|
805
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
806
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
807
|
+
expect(infoCalls.some((msg: string) => msg.includes("routed to kaylee"))).toBe(true);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("caches guidance for team when guidance is present", async () => {
|
|
811
|
+
extractGuidanceMock.mockReturnValue({ guidance: "Always run tests", source: "webhook" });
|
|
812
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
813
|
+
id: "issue-guid",
|
|
814
|
+
identifier: "ENG-GUID",
|
|
815
|
+
title: "Guidance Test",
|
|
816
|
+
description: "desc",
|
|
817
|
+
state: { name: "Backlog", type: "backlog" },
|
|
818
|
+
team: { id: "team-guid" },
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
const result = await postWebhook({
|
|
822
|
+
type: "AgentSessionEvent",
|
|
823
|
+
action: "created",
|
|
824
|
+
agentSession: {
|
|
825
|
+
id: "sess-guid",
|
|
826
|
+
issue: { id: "issue-guid", identifier: "ENG-GUID" },
|
|
827
|
+
},
|
|
828
|
+
previousComments: [],
|
|
829
|
+
guidance: "Always run tests",
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
expect(result.status).toBe(200);
|
|
833
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
834
|
+
expect(cacheGuidanceForTeamMock).toHaveBeenCalledWith("team-guid", "Always run tests");
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it("handles agent error and emits error activity", async () => {
|
|
838
|
+
runAgentMock.mockRejectedValue(new Error("agent crashed"));
|
|
839
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
840
|
+
id: "issue-err",
|
|
841
|
+
identifier: "ENG-ERR",
|
|
842
|
+
title: "Error Test",
|
|
843
|
+
description: "desc",
|
|
844
|
+
state: { name: "Backlog", type: "backlog" },
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
const result = await postWebhook({
|
|
848
|
+
type: "AgentSessionEvent",
|
|
849
|
+
action: "created",
|
|
850
|
+
agentSession: {
|
|
851
|
+
id: "sess-err",
|
|
852
|
+
issue: { id: "issue-err", identifier: "ENG-ERR" },
|
|
853
|
+
},
|
|
854
|
+
previousComments: [],
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
expect(result.status).toBe(200);
|
|
858
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
859
|
+
// Error should have been emitted
|
|
860
|
+
expect(mockLinearApiInstance.emitActivity).toHaveBeenCalledWith(
|
|
861
|
+
"sess-err",
|
|
862
|
+
expect.objectContaining({ type: "error" }),
|
|
863
|
+
);
|
|
864
|
+
// Active session should be cleared
|
|
865
|
+
expect(clearActiveSessionMock).toHaveBeenCalledWith("issue-err");
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it("falls back to comment when emitActivity fails for response", async () => {
|
|
869
|
+
// emitActivity fails for 'response' type but succeeds for 'thought'
|
|
870
|
+
mockLinearApiInstance.emitActivity
|
|
871
|
+
.mockImplementation((_sessionId: string, content: any) => {
|
|
872
|
+
if (content.type === "response") return Promise.reject(new Error("emit fail"));
|
|
873
|
+
return Promise.resolve(undefined);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
const result = await postWebhook({
|
|
877
|
+
type: "AgentSessionEvent",
|
|
878
|
+
action: "created",
|
|
879
|
+
agentSession: {
|
|
880
|
+
id: "sess-fallback",
|
|
881
|
+
issue: { id: "issue-fallback", identifier: "ENG-FB" },
|
|
882
|
+
},
|
|
883
|
+
previousComments: [],
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
expect(result.status).toBe(200);
|
|
887
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
888
|
+
// Should have fallen back to createComment
|
|
889
|
+
expect(mockLinearApiInstance.createComment).toHaveBeenCalled();
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("posts failure message when agent returns success=false", async () => {
|
|
893
|
+
runAgentMock.mockResolvedValue({ success: false, output: "Something broke" });
|
|
894
|
+
|
|
895
|
+
const result = await postWebhook({
|
|
896
|
+
type: "AgentSessionEvent",
|
|
897
|
+
action: "created",
|
|
898
|
+
agentSession: {
|
|
899
|
+
id: "sess-fail-result",
|
|
900
|
+
issue: { id: "issue-fail-result", identifier: "ENG-FR" },
|
|
901
|
+
},
|
|
902
|
+
previousComments: [],
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
expect(result.status).toBe(200);
|
|
906
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
907
|
+
// Should emit a response with the failure message
|
|
908
|
+
const emitCalls = mockLinearApiInstance.emitActivity.mock.calls;
|
|
909
|
+
const responseCall = emitCalls.find((c: any[]) => c[1]?.type === "response");
|
|
910
|
+
if (responseCall) {
|
|
911
|
+
expect(responseCall[1].body).toContain("Something went wrong");
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
// ---------------------------------------------------------------------------
|
|
917
|
+
// AgentSession.prompted — full flow
|
|
918
|
+
// ---------------------------------------------------------------------------
|
|
919
|
+
|
|
920
|
+
describe("AgentSessionEvent.prompted full flow", () => {
|
|
921
|
+
it("responds 200 and ignores when session/issue data is missing", async () => {
|
|
922
|
+
const result = await postWebhook({
|
|
923
|
+
type: "AgentSessionEvent",
|
|
924
|
+
action: "prompted",
|
|
925
|
+
agentSession: { id: null },
|
|
926
|
+
issue: null,
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
expect(result.status).toBe(200);
|
|
930
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
931
|
+
expect(infoCalls.some((msg: string) => msg.includes("missing session or issue"))).toBe(true);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it("ignores when activeRuns has the issue (feedback loop)", async () => {
|
|
935
|
+
_addActiveRunForTesting("issue-feedback");
|
|
936
|
+
|
|
937
|
+
const result = await postWebhook({
|
|
938
|
+
type: "AgentSessionEvent",
|
|
939
|
+
action: "prompted",
|
|
940
|
+
agentSession: {
|
|
941
|
+
id: "sess-fb",
|
|
942
|
+
issue: { id: "issue-feedback", identifier: "ENG-FB" },
|
|
943
|
+
},
|
|
944
|
+
agentActivity: { content: { body: "Some follow-up" } },
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
expect(result.status).toBe(200);
|
|
948
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
949
|
+
expect(infoCalls.some((msg: string) => msg.includes("agent active, ignoring (feedback)"))).toBe(true);
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it("deduplicates by webhookId", async () => {
|
|
953
|
+
_markAsProcessedForTesting("webhook:wh-123");
|
|
954
|
+
|
|
955
|
+
const result = await postWebhook({
|
|
956
|
+
type: "AgentSessionEvent",
|
|
957
|
+
action: "prompted",
|
|
958
|
+
agentSession: {
|
|
959
|
+
id: "sess-wh-dedup",
|
|
960
|
+
issue: { id: "issue-wh-dedup", identifier: "ENG-WHD" },
|
|
961
|
+
},
|
|
962
|
+
agentActivity: { content: { body: "Follow-up" } },
|
|
963
|
+
webhookId: "wh-123",
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
expect(result.status).toBe(200);
|
|
967
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
968
|
+
expect(infoCalls.some((msg: string) => msg.includes("already processed"))).toBe(true);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it("ignores when no user message is present", async () => {
|
|
972
|
+
const result = await postWebhook({
|
|
973
|
+
type: "AgentSessionEvent",
|
|
974
|
+
action: "prompted",
|
|
975
|
+
agentSession: {
|
|
976
|
+
id: "sess-no-msg",
|
|
977
|
+
issue: { id: "issue-no-msg", identifier: "ENG-NM" },
|
|
978
|
+
},
|
|
979
|
+
agentActivity: { content: { body: "" } },
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
expect(result.status).toBe(200);
|
|
983
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
984
|
+
expect(infoCalls.some((msg: string) => msg.includes("no user message found"))).toBe(true);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it("runs agent for valid follow-up message", async () => {
|
|
988
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
989
|
+
id: "issue-prompted",
|
|
990
|
+
identifier: "ENG-P",
|
|
991
|
+
title: "Prompted Issue",
|
|
992
|
+
description: "some desc",
|
|
993
|
+
state: { name: "In Progress", type: "started" },
|
|
994
|
+
assignee: { name: "User" },
|
|
995
|
+
team: { id: "team-p" },
|
|
996
|
+
comments: { nodes: [] },
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
const result = await postWebhook({
|
|
1000
|
+
type: "AgentSessionEvent",
|
|
1001
|
+
action: "prompted",
|
|
1002
|
+
agentSession: {
|
|
1003
|
+
id: "sess-valid-prompt",
|
|
1004
|
+
issue: { id: "issue-prompted", identifier: "ENG-P" },
|
|
1005
|
+
},
|
|
1006
|
+
agentActivity: { content: { body: "Can you also check the tests?" } },
|
|
1007
|
+
webhookId: "wh-new-1",
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
expect(result.status).toBe(200);
|
|
1011
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1012
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
1013
|
+
expect(setActiveSessionMock).toHaveBeenCalled();
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it("extracts user message from activity.body fallback", async () => {
|
|
1017
|
+
const result = await postWebhook({
|
|
1018
|
+
type: "AgentSessionEvent",
|
|
1019
|
+
action: "prompted",
|
|
1020
|
+
agentSession: {
|
|
1021
|
+
id: "sess-body-fallback",
|
|
1022
|
+
issue: { id: "issue-bf", identifier: "ENG-BF" },
|
|
1023
|
+
},
|
|
1024
|
+
agentActivity: { body: "Fallback body text" },
|
|
1025
|
+
webhookId: "wh-bf-1",
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
expect(result.status).toBe(200);
|
|
1029
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1030
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it("routes to mentioned agent in prompted follow-up", async () => {
|
|
1034
|
+
resolveAgentFromAliasMock.mockReturnValue({ agentId: "kaylee", profile: { label: "Kaylee" } });
|
|
1035
|
+
|
|
1036
|
+
const result = await postWebhook({
|
|
1037
|
+
type: "AgentSessionEvent",
|
|
1038
|
+
action: "prompted",
|
|
1039
|
+
agentSession: {
|
|
1040
|
+
id: "sess-prompt-mention",
|
|
1041
|
+
issue: { id: "issue-pm", identifier: "ENG-PM" },
|
|
1042
|
+
},
|
|
1043
|
+
agentActivity: { content: { body: "@kaylee can you look at this?" } },
|
|
1044
|
+
webhookId: "wh-pm-1",
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
expect(result.status).toBe(200);
|
|
1048
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1049
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1050
|
+
expect(infoCalls.some((msg: string) => msg.includes("routed to kaylee"))).toBe(true);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("skips when no Linear access token for prompted", async () => {
|
|
1054
|
+
resolveLinearTokenMock.mockReturnValue({ accessToken: null, source: "none" });
|
|
1055
|
+
|
|
1056
|
+
const result = await postWebhook({
|
|
1057
|
+
type: "AgentSessionEvent",
|
|
1058
|
+
action: "prompted",
|
|
1059
|
+
agentSession: {
|
|
1060
|
+
id: "sess-no-token-p",
|
|
1061
|
+
issue: { id: "issue-no-token-p", identifier: "ENG-NTP" },
|
|
1062
|
+
},
|
|
1063
|
+
agentActivity: { content: { body: "Some message" } },
|
|
1064
|
+
webhookId: "wh-ntp-1",
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
expect(result.status).toBe(200);
|
|
1068
|
+
const errorCalls = (result.api.logger.error as any).mock.calls.map((c: any[]) => c[0]);
|
|
1069
|
+
expect(errorCalls.some((msg: string) => msg.includes("No Linear access token"))).toBe(true);
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// ---------------------------------------------------------------------------
|
|
1074
|
+
// Comment.create — intent routing
|
|
1075
|
+
// ---------------------------------------------------------------------------
|
|
1076
|
+
|
|
1077
|
+
describe("Comment.create intent routing", () => {
|
|
1078
|
+
it("responds 200 and logs missing issue data", async () => {
|
|
1079
|
+
const result = await postWebhook({
|
|
1080
|
+
type: "Comment",
|
|
1081
|
+
action: "create",
|
|
1082
|
+
data: { id: "comment-no-issue", body: "test", user: { id: "u1", name: "User" } },
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
expect(result.status).toBe(200);
|
|
1086
|
+
const errorCalls = (result.api.logger.error as any).mock.calls.map((c: any[]) => c[0]);
|
|
1087
|
+
expect(errorCalls.some((msg: string) => msg.includes("missing issue data"))).toBe(true);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it("deduplicates by comment ID", async () => {
|
|
1091
|
+
_markAsProcessedForTesting("comment:comment-dup");
|
|
1092
|
+
|
|
1093
|
+
const result = await postWebhook({
|
|
1094
|
+
type: "Comment",
|
|
1095
|
+
action: "create",
|
|
1096
|
+
data: {
|
|
1097
|
+
id: "comment-dup",
|
|
1098
|
+
body: "test",
|
|
1099
|
+
user: { id: "u1", name: "User" },
|
|
1100
|
+
issue: { id: "issue-dup", identifier: "ENG-DUP" },
|
|
1101
|
+
},
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
expect(result.status).toBe(200);
|
|
1105
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1106
|
+
expect(infoCalls.some((msg: string) => msg.includes("already processed"))).toBe(true);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it("skips bot's own comments", async () => {
|
|
1110
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("bot-user-1");
|
|
1111
|
+
|
|
1112
|
+
const result = await postWebhook({
|
|
1113
|
+
type: "Comment",
|
|
1114
|
+
action: "create",
|
|
1115
|
+
data: {
|
|
1116
|
+
id: "comment-bot",
|
|
1117
|
+
body: "Bot response",
|
|
1118
|
+
user: { id: "bot-user-1", name: "Bot" },
|
|
1119
|
+
issue: { id: "issue-bot", identifier: "ENG-BOT" },
|
|
1120
|
+
},
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
expect(result.status).toBe(200);
|
|
1124
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1125
|
+
expect(infoCalls.some((msg: string) => msg.includes("skipping our own comment"))).toBe(true);
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it("skips when active run exists for the issue", async () => {
|
|
1129
|
+
_addActiveRunForTesting("issue-active");
|
|
1130
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("bot-user-2");
|
|
1131
|
+
|
|
1132
|
+
const result = await postWebhook({
|
|
1133
|
+
type: "Comment",
|
|
1134
|
+
action: "create",
|
|
1135
|
+
data: {
|
|
1136
|
+
id: "comment-active",
|
|
1137
|
+
body: "Some comment",
|
|
1138
|
+
user: { id: "human-1", name: "Human" },
|
|
1139
|
+
issue: { id: "issue-active", identifier: "ENG-ACT" },
|
|
1140
|
+
},
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
expect(result.status).toBe(200);
|
|
1144
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1145
|
+
expect(infoCalls.some((msg: string) => msg.includes("active run"))).toBe(true);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it("uses @mention fast path when comment mentions an agent", async () => {
|
|
1149
|
+
resolveAgentFromAliasMock.mockReturnValue({ agentId: "kaylee", profile: { label: "Kaylee" } });
|
|
1150
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1151
|
+
id: "issue-mention-fast",
|
|
1152
|
+
identifier: "ENG-MF",
|
|
1153
|
+
title: "Mention Test",
|
|
1154
|
+
description: "desc",
|
|
1155
|
+
state: { name: "In Progress", type: "started" },
|
|
1156
|
+
assignee: { name: "User" },
|
|
1157
|
+
team: { id: "team-mf" },
|
|
1158
|
+
comments: { nodes: [{ user: { name: "Someone" }, body: "Prior comment" }] },
|
|
1159
|
+
creator: { name: "Creator", email: "c@test.com" },
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
const result = await postWebhook({
|
|
1163
|
+
type: "Comment",
|
|
1164
|
+
action: "create",
|
|
1165
|
+
data: {
|
|
1166
|
+
id: "comment-mention-fast",
|
|
1167
|
+
body: "@kaylee please check this",
|
|
1168
|
+
user: { id: "human-2", name: "Human" },
|
|
1169
|
+
issue: { id: "issue-mention-fast", identifier: "ENG-MF" },
|
|
1170
|
+
},
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
expect(result.status).toBe(200);
|
|
1174
|
+
// Wait for fire-and-forget dispatchCommentToAgent
|
|
1175
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1176
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1177
|
+
expect(infoCalls.some((msg: string) => msg.includes("@mention fast path"))).toBe(true);
|
|
1178
|
+
// Verify agent was run via dispatchCommentToAgent
|
|
1179
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
it("handles 'general' intent by logging and doing nothing", async () => {
|
|
1183
|
+
classifyIntentMock.mockResolvedValue({ intent: "general", reasoning: "Not actionable", fromFallback: false });
|
|
1184
|
+
|
|
1185
|
+
const result = await postWebhook({
|
|
1186
|
+
type: "Comment",
|
|
1187
|
+
action: "create",
|
|
1188
|
+
data: {
|
|
1189
|
+
id: "comment-general",
|
|
1190
|
+
body: "Thanks for the update",
|
|
1191
|
+
user: { id: "human-3", name: "Human" },
|
|
1192
|
+
issue: { id: "issue-general", identifier: "ENG-GEN" },
|
|
1193
|
+
},
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
expect(result.status).toBe(200);
|
|
1197
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1198
|
+
expect(infoCalls.some((msg: string) => msg.includes("Comment intent general"))).toBe(true);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
it("routes ask_agent intent to specific agent and dispatches", async () => {
|
|
1202
|
+
classifyIntentMock.mockResolvedValue({
|
|
1203
|
+
intent: "ask_agent",
|
|
1204
|
+
agentId: "kaylee",
|
|
1205
|
+
reasoning: "User asked Kaylee",
|
|
1206
|
+
fromFallback: false,
|
|
1207
|
+
});
|
|
1208
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1209
|
+
id: "issue-ask-agent",
|
|
1210
|
+
identifier: "ENG-AA",
|
|
1211
|
+
title: "Ask Agent",
|
|
1212
|
+
description: "desc",
|
|
1213
|
+
state: { name: "In Progress", type: "started" },
|
|
1214
|
+
team: { id: "team-aa" },
|
|
1215
|
+
comments: { nodes: [] },
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
const result = await postWebhook({
|
|
1219
|
+
type: "Comment",
|
|
1220
|
+
action: "create",
|
|
1221
|
+
data: {
|
|
1222
|
+
id: "comment-ask-agent",
|
|
1223
|
+
body: "Ask kaylee to build this",
|
|
1224
|
+
user: { id: "human-4", name: "Human" },
|
|
1225
|
+
issue: { id: "issue-ask-agent", identifier: "ENG-AA" },
|
|
1226
|
+
},
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
expect(result.status).toBe(200);
|
|
1230
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1231
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1232
|
+
expect(infoCalls.some((msg: string) => msg.includes("ask_agent"))).toBe(true);
|
|
1233
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
it("routes request_work intent to default agent and dispatches", async () => {
|
|
1237
|
+
classifyIntentMock.mockResolvedValue({
|
|
1238
|
+
intent: "request_work",
|
|
1239
|
+
reasoning: "User wants work done",
|
|
1240
|
+
fromFallback: false,
|
|
1241
|
+
});
|
|
1242
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1243
|
+
id: "issue-request-work",
|
|
1244
|
+
identifier: "ENG-RW",
|
|
1245
|
+
title: "Request Work",
|
|
1246
|
+
description: "desc",
|
|
1247
|
+
state: { name: "Backlog", type: "backlog" },
|
|
1248
|
+
team: { id: "team-rw" },
|
|
1249
|
+
comments: { nodes: [] },
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
const result = await postWebhook({
|
|
1253
|
+
type: "Comment",
|
|
1254
|
+
action: "create",
|
|
1255
|
+
data: {
|
|
1256
|
+
id: "comment-request-work",
|
|
1257
|
+
body: "Please implement this feature",
|
|
1258
|
+
user: { id: "human-5", name: "Human" },
|
|
1259
|
+
issue: { id: "issue-request-work", identifier: "ENG-RW" },
|
|
1260
|
+
},
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
expect(result.status).toBe(200);
|
|
1264
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1265
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1266
|
+
expect(infoCalls.some((msg: string) => msg.includes("request_work"))).toBe(true);
|
|
1267
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
it("routes question intent to default agent and dispatches", async () => {
|
|
1271
|
+
classifyIntentMock.mockResolvedValue({
|
|
1272
|
+
intent: "question",
|
|
1273
|
+
reasoning: "User has a question",
|
|
1274
|
+
fromFallback: false,
|
|
1275
|
+
});
|
|
1276
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1277
|
+
id: "issue-question",
|
|
1278
|
+
identifier: "ENG-Q",
|
|
1279
|
+
title: "Question",
|
|
1280
|
+
description: "desc",
|
|
1281
|
+
state: { name: "Backlog", type: "backlog" },
|
|
1282
|
+
team: { id: "team-q" },
|
|
1283
|
+
comments: { nodes: [] },
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
const result = await postWebhook({
|
|
1287
|
+
type: "Comment",
|
|
1288
|
+
action: "create",
|
|
1289
|
+
data: {
|
|
1290
|
+
id: "comment-question",
|
|
1291
|
+
body: "How does this work?",
|
|
1292
|
+
user: { id: "human-6", name: "Human" },
|
|
1293
|
+
issue: { id: "issue-question", identifier: "ENG-Q" },
|
|
1294
|
+
},
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
expect(result.status).toBe(200);
|
|
1298
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1299
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1300
|
+
expect(infoCalls.some((msg: string) => msg.includes("question"))).toBe(true);
|
|
1301
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
it("routes close_issue intent to handleCloseIssue", async () => {
|
|
1305
|
+
classifyIntentMock.mockResolvedValue({
|
|
1306
|
+
intent: "close_issue",
|
|
1307
|
+
reasoning: "User wants to close this",
|
|
1308
|
+
fromFallback: false,
|
|
1309
|
+
});
|
|
1310
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1311
|
+
id: "issue-close",
|
|
1312
|
+
identifier: "ENG-CLOSE",
|
|
1313
|
+
title: "Close Test",
|
|
1314
|
+
description: "desc",
|
|
1315
|
+
state: { name: "In Progress", type: "started" },
|
|
1316
|
+
assignee: { name: "User" },
|
|
1317
|
+
team: { id: "team-close" },
|
|
1318
|
+
comments: { nodes: [{ user: { name: "User" }, body: "Done now" }] },
|
|
1319
|
+
creator: { name: "Creator" },
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
const result = await postWebhook({
|
|
1323
|
+
type: "Comment",
|
|
1324
|
+
action: "create",
|
|
1325
|
+
data: {
|
|
1326
|
+
id: "comment-close",
|
|
1327
|
+
body: "This is done, please close",
|
|
1328
|
+
user: { id: "human-7", name: "Human" },
|
|
1329
|
+
issue: { id: "issue-close", identifier: "ENG-CLOSE" },
|
|
1330
|
+
},
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
expect(result.status).toBe(200);
|
|
1334
|
+
// Wait for fire-and-forget handleCloseIssue
|
|
1335
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1336
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1337
|
+
expect(infoCalls.some((msg: string) => msg.includes("close_issue"))).toBe(true);
|
|
1338
|
+
// handleCloseIssue should have run agent and attempted to close
|
|
1339
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
1340
|
+
expect(mockLinearApiInstance.updateIssue).toHaveBeenCalled();
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
it("skips when no Linear access token for comment", async () => {
|
|
1344
|
+
resolveLinearTokenMock.mockReturnValue({ accessToken: null, source: "none" });
|
|
1345
|
+
|
|
1346
|
+
const result = await postWebhook({
|
|
1347
|
+
type: "Comment",
|
|
1348
|
+
action: "create",
|
|
1349
|
+
data: {
|
|
1350
|
+
id: "comment-no-token",
|
|
1351
|
+
body: "Some comment",
|
|
1352
|
+
user: { id: "human-8", name: "Human" },
|
|
1353
|
+
issue: { id: "issue-no-token", identifier: "ENG-NT2" },
|
|
1354
|
+
},
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
expect(result.status).toBe(200);
|
|
1358
|
+
const errorCalls = (result.api.logger.error as any).mock.calls.map((c: any[]) => c[0]);
|
|
1359
|
+
expect(errorCalls.some((msg: string) => msg.includes("No Linear access token"))).toBe(true);
|
|
1360
|
+
});
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
// ---------------------------------------------------------------------------
|
|
1364
|
+
// Comment.create — planning intents
|
|
1365
|
+
// ---------------------------------------------------------------------------
|
|
1366
|
+
|
|
1367
|
+
describe("Comment.create planning intents", () => {
|
|
1368
|
+
it("plan_start initiates planning when project exists", async () => {
|
|
1369
|
+
classifyIntentMock.mockResolvedValue({
|
|
1370
|
+
intent: "plan_start",
|
|
1371
|
+
reasoning: "User wants to start planning",
|
|
1372
|
+
fromFallback: false,
|
|
1373
|
+
});
|
|
1374
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1375
|
+
id: "issue-plan-start",
|
|
1376
|
+
identifier: "ENG-PS",
|
|
1377
|
+
title: "Plan Start",
|
|
1378
|
+
state: { name: "Backlog" },
|
|
1379
|
+
project: { id: "proj-1" },
|
|
1380
|
+
team: { id: "team-1" },
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
const result = await postWebhook({
|
|
1384
|
+
type: "Comment",
|
|
1385
|
+
action: "create",
|
|
1386
|
+
data: {
|
|
1387
|
+
id: "comment-plan-start",
|
|
1388
|
+
body: "Start planning this project",
|
|
1389
|
+
user: { id: "human-ps", name: "Human" },
|
|
1390
|
+
issue: { id: "issue-plan-start", identifier: "ENG-PS", project: { id: "proj-1" } },
|
|
1391
|
+
},
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
expect(result.status).toBe(200);
|
|
1395
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1396
|
+
expect(initiatePlanningSessionMock).toHaveBeenCalled();
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
it("plan_start is ignored when no project", async () => {
|
|
1400
|
+
classifyIntentMock.mockResolvedValue({
|
|
1401
|
+
intent: "plan_start",
|
|
1402
|
+
reasoning: "User wants to start planning",
|
|
1403
|
+
fromFallback: false,
|
|
1404
|
+
});
|
|
1405
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1406
|
+
id: "issue-plan-noproj",
|
|
1407
|
+
identifier: "ENG-PNP",
|
|
1408
|
+
title: "No Project",
|
|
1409
|
+
state: { name: "Backlog" },
|
|
1410
|
+
project: null,
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
const result = await postWebhook({
|
|
1414
|
+
type: "Comment",
|
|
1415
|
+
action: "create",
|
|
1416
|
+
data: {
|
|
1417
|
+
id: "comment-plan-noproj",
|
|
1418
|
+
body: "Start planning",
|
|
1419
|
+
user: { id: "human-pnp", name: "Human" },
|
|
1420
|
+
issue: { id: "issue-plan-noproj", identifier: "ENG-PNP" },
|
|
1421
|
+
},
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
expect(result.status).toBe(200);
|
|
1425
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1426
|
+
expect(infoCalls.some((msg: string) => msg.includes("plan_start but no project"))).toBe(true);
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
it("plan_start treats as plan_continue when already planning", async () => {
|
|
1430
|
+
classifyIntentMock.mockResolvedValue({
|
|
1431
|
+
intent: "plan_start",
|
|
1432
|
+
reasoning: "User wants to start planning",
|
|
1433
|
+
fromFallback: false,
|
|
1434
|
+
});
|
|
1435
|
+
isInPlanningModeMock.mockReturnValue(true);
|
|
1436
|
+
getPlanningSessionMock.mockReturnValue({
|
|
1437
|
+
projectId: "proj-1",
|
|
1438
|
+
projectName: "Test Project",
|
|
1439
|
+
rootIssueId: "root-1",
|
|
1440
|
+
status: "interviewing",
|
|
1441
|
+
});
|
|
1442
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1443
|
+
id: "issue-plan-dup",
|
|
1444
|
+
identifier: "ENG-PD",
|
|
1445
|
+
title: "Already Planning",
|
|
1446
|
+
state: { name: "Backlog" },
|
|
1447
|
+
project: { id: "proj-1" },
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
const result = await postWebhook({
|
|
1451
|
+
type: "Comment",
|
|
1452
|
+
action: "create",
|
|
1453
|
+
data: {
|
|
1454
|
+
id: "comment-plan-dup",
|
|
1455
|
+
body: "Start planning again",
|
|
1456
|
+
user: { id: "human-pd", name: "Human" },
|
|
1457
|
+
issue: { id: "issue-plan-dup", identifier: "ENG-PD", project: { id: "proj-1" } },
|
|
1458
|
+
},
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
expect(result.status).toBe(200);
|
|
1462
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1463
|
+
expect(handlePlannerTurnMock).toHaveBeenCalled();
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
it("plan_finalize approves when plan is in review status", async () => {
|
|
1467
|
+
classifyIntentMock.mockResolvedValue({
|
|
1468
|
+
intent: "plan_finalize",
|
|
1469
|
+
reasoning: "User wants to finalize",
|
|
1470
|
+
fromFallback: false,
|
|
1471
|
+
});
|
|
1472
|
+
isInPlanningModeMock.mockReturnValue(true);
|
|
1473
|
+
getPlanningSessionMock.mockReturnValue({
|
|
1474
|
+
projectId: "proj-fin",
|
|
1475
|
+
projectName: "Finalize Project",
|
|
1476
|
+
rootIssueId: "root-fin",
|
|
1477
|
+
status: "plan_review",
|
|
1478
|
+
});
|
|
1479
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1480
|
+
id: "issue-fin",
|
|
1481
|
+
identifier: "ENG-FIN",
|
|
1482
|
+
title: "Finalize Test",
|
|
1483
|
+
state: { name: "Backlog" },
|
|
1484
|
+
project: { id: "proj-fin" },
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
const result = await postWebhook({
|
|
1488
|
+
type: "Comment",
|
|
1489
|
+
action: "create",
|
|
1490
|
+
data: {
|
|
1491
|
+
id: "comment-finalize",
|
|
1492
|
+
body: "Finalize the plan",
|
|
1493
|
+
user: { id: "human-fin", name: "Human" },
|
|
1494
|
+
issue: { id: "issue-fin", identifier: "ENG-FIN", project: { id: "proj-fin" } },
|
|
1495
|
+
},
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
expect(result.status).toBe(200);
|
|
1499
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1500
|
+
expect(endPlanningSessionMock).toHaveBeenCalledWith("proj-fin", "approved", undefined);
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
it("plan_finalize runs audit when still interviewing", async () => {
|
|
1504
|
+
classifyIntentMock.mockResolvedValue({
|
|
1505
|
+
intent: "plan_finalize",
|
|
1506
|
+
reasoning: "User wants to finalize",
|
|
1507
|
+
fromFallback: false,
|
|
1508
|
+
});
|
|
1509
|
+
isInPlanningModeMock.mockReturnValue(true);
|
|
1510
|
+
getPlanningSessionMock.mockReturnValue({
|
|
1511
|
+
projectId: "proj-aud",
|
|
1512
|
+
projectName: "Audit Project",
|
|
1513
|
+
rootIssueId: "root-aud",
|
|
1514
|
+
status: "interviewing",
|
|
1515
|
+
});
|
|
1516
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1517
|
+
id: "issue-aud",
|
|
1518
|
+
identifier: "ENG-AUD",
|
|
1519
|
+
title: "Audit Test",
|
|
1520
|
+
state: { name: "Backlog" },
|
|
1521
|
+
project: { id: "proj-aud" },
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
const result = await postWebhook({
|
|
1525
|
+
type: "Comment",
|
|
1526
|
+
action: "create",
|
|
1527
|
+
data: {
|
|
1528
|
+
id: "comment-audit",
|
|
1529
|
+
body: "Finalize plan",
|
|
1530
|
+
user: { id: "human-aud", name: "Human" },
|
|
1531
|
+
issue: { id: "issue-aud", identifier: "ENG-AUD", project: { id: "proj-aud" } },
|
|
1532
|
+
},
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
expect(result.status).toBe(200);
|
|
1536
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1537
|
+
expect(runPlanAuditMock).toHaveBeenCalled();
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
it("plan_finalize is ignored when not in planning mode", async () => {
|
|
1541
|
+
classifyIntentMock.mockResolvedValue({
|
|
1542
|
+
intent: "plan_finalize",
|
|
1543
|
+
reasoning: "User wants to finalize",
|
|
1544
|
+
fromFallback: false,
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
const result = await postWebhook({
|
|
1548
|
+
type: "Comment",
|
|
1549
|
+
action: "create",
|
|
1550
|
+
data: {
|
|
1551
|
+
id: "comment-fin-nope",
|
|
1552
|
+
body: "Finalize plan",
|
|
1553
|
+
user: { id: "human-fn", name: "Human" },
|
|
1554
|
+
issue: { id: "issue-fin-nope", identifier: "ENG-FN" },
|
|
1555
|
+
},
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
expect(result.status).toBe(200);
|
|
1559
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1560
|
+
expect(infoCalls.some((msg: string) => msg.includes("plan_finalize but not in planning mode"))).toBe(true);
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
it("plan_abandon ends planning session", async () => {
|
|
1564
|
+
classifyIntentMock.mockResolvedValue({
|
|
1565
|
+
intent: "plan_abandon",
|
|
1566
|
+
reasoning: "User wants to abandon",
|
|
1567
|
+
fromFallback: false,
|
|
1568
|
+
});
|
|
1569
|
+
isInPlanningModeMock.mockReturnValue(true);
|
|
1570
|
+
getPlanningSessionMock.mockReturnValue({
|
|
1571
|
+
projectId: "proj-ab",
|
|
1572
|
+
projectName: "Abandon Project",
|
|
1573
|
+
rootIssueId: "root-ab",
|
|
1574
|
+
status: "interviewing",
|
|
1575
|
+
});
|
|
1576
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1577
|
+
id: "issue-ab",
|
|
1578
|
+
identifier: "ENG-AB",
|
|
1579
|
+
title: "Abandon Test",
|
|
1580
|
+
state: { name: "Backlog" },
|
|
1581
|
+
project: { id: "proj-ab" },
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
const result = await postWebhook({
|
|
1585
|
+
type: "Comment",
|
|
1586
|
+
action: "create",
|
|
1587
|
+
data: {
|
|
1588
|
+
id: "comment-abandon",
|
|
1589
|
+
body: "Abandon planning",
|
|
1590
|
+
user: { id: "human-ab", name: "Human" },
|
|
1591
|
+
issue: { id: "issue-ab", identifier: "ENG-AB", project: { id: "proj-ab" } },
|
|
1592
|
+
},
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
expect(result.status).toBe(200);
|
|
1596
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1597
|
+
expect(endPlanningSessionMock).toHaveBeenCalledWith("proj-ab", "abandoned", undefined);
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
it("plan_abandon is ignored when not planning", async () => {
|
|
1601
|
+
classifyIntentMock.mockResolvedValue({
|
|
1602
|
+
intent: "plan_abandon",
|
|
1603
|
+
reasoning: "User wants to abandon",
|
|
1604
|
+
fromFallback: false,
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1607
|
+
const result = await postWebhook({
|
|
1608
|
+
type: "Comment",
|
|
1609
|
+
action: "create",
|
|
1610
|
+
data: {
|
|
1611
|
+
id: "comment-ab-nope",
|
|
1612
|
+
body: "Abandon",
|
|
1613
|
+
user: { id: "human-abn", name: "Human" },
|
|
1614
|
+
issue: { id: "issue-ab-nope", identifier: "ENG-ABN" },
|
|
1615
|
+
},
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
expect(result.status).toBe(200);
|
|
1619
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1620
|
+
expect(infoCalls.some((msg: string) => msg.includes("plan_abandon but not in planning mode"))).toBe(true);
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
it("plan_continue dispatches planner turn when planning", async () => {
|
|
1624
|
+
classifyIntentMock.mockResolvedValue({
|
|
1625
|
+
intent: "plan_continue",
|
|
1626
|
+
reasoning: "User continuing planning",
|
|
1627
|
+
fromFallback: false,
|
|
1628
|
+
});
|
|
1629
|
+
isInPlanningModeMock.mockReturnValue(true);
|
|
1630
|
+
getPlanningSessionMock.mockReturnValue({
|
|
1631
|
+
projectId: "proj-cont",
|
|
1632
|
+
projectName: "Continue Project",
|
|
1633
|
+
rootIssueId: "root-cont",
|
|
1634
|
+
status: "interviewing",
|
|
1635
|
+
});
|
|
1636
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1637
|
+
id: "issue-cont",
|
|
1638
|
+
identifier: "ENG-CONT",
|
|
1639
|
+
title: "Continue Test",
|
|
1640
|
+
state: { name: "Backlog" },
|
|
1641
|
+
project: { id: "proj-cont" },
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
const result = await postWebhook({
|
|
1645
|
+
type: "Comment",
|
|
1646
|
+
action: "create",
|
|
1647
|
+
data: {
|
|
1648
|
+
id: "comment-continue",
|
|
1649
|
+
body: "Add a login page too",
|
|
1650
|
+
user: { id: "human-cont", name: "Human" },
|
|
1651
|
+
issue: { id: "issue-cont", identifier: "ENG-CONT", project: { id: "proj-cont" } },
|
|
1652
|
+
},
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
expect(result.status).toBe(200);
|
|
1656
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1657
|
+
expect(handlePlannerTurnMock).toHaveBeenCalled();
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
it("plan_continue dispatches to default agent when not in planning mode", async () => {
|
|
1661
|
+
classifyIntentMock.mockResolvedValue({
|
|
1662
|
+
intent: "plan_continue",
|
|
1663
|
+
reasoning: "User continuing",
|
|
1664
|
+
fromFallback: false,
|
|
1665
|
+
});
|
|
1666
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1667
|
+
id: "issue-cont-noplan",
|
|
1668
|
+
identifier: "ENG-CNP",
|
|
1669
|
+
title: "Continue No Plan",
|
|
1670
|
+
state: { name: "Backlog", type: "backlog" },
|
|
1671
|
+
project: null,
|
|
1672
|
+
team: { id: "team-cnp" },
|
|
1673
|
+
comments: { nodes: [] },
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
const result = await postWebhook({
|
|
1677
|
+
type: "Comment",
|
|
1678
|
+
action: "create",
|
|
1679
|
+
data: {
|
|
1680
|
+
id: "comment-cont-noplan",
|
|
1681
|
+
body: "Continue with this",
|
|
1682
|
+
user: { id: "human-cnp", name: "Human" },
|
|
1683
|
+
issue: { id: "issue-cont-noplan", identifier: "ENG-CNP" },
|
|
1684
|
+
},
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
expect(result.status).toBe(200);
|
|
1688
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1689
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1690
|
+
expect(infoCalls.some((msg: string) => msg.includes("plan_continue but not in planning mode"))).toBe(true);
|
|
1691
|
+
// Should dispatch to default agent
|
|
1692
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
1693
|
+
});
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
// ---------------------------------------------------------------------------
|
|
1697
|
+
// Issue.update — dispatch flow
|
|
1698
|
+
// ---------------------------------------------------------------------------
|
|
1699
|
+
|
|
1700
|
+
describe("Issue.update dispatch flow", () => {
|
|
1701
|
+
it("skips when activeRuns has the issue", async () => {
|
|
1702
|
+
_addActiveRunForTesting("issue-update-active");
|
|
1703
|
+
|
|
1704
|
+
const result = await postWebhook({
|
|
1705
|
+
type: "Issue",
|
|
1706
|
+
action: "update",
|
|
1707
|
+
data: {
|
|
1708
|
+
id: "issue-update-active",
|
|
1709
|
+
identifier: "ENG-UA",
|
|
1710
|
+
assigneeId: "viewer-1",
|
|
1711
|
+
},
|
|
1712
|
+
updatedFrom: { assigneeId: null },
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
expect(result.status).toBe(200);
|
|
1716
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1717
|
+
expect(infoCalls.some((msg: string) => msg.includes("active run"))).toBe(true);
|
|
1718
|
+
});
|
|
1719
|
+
|
|
1720
|
+
it("skips when no assignment or delegation change", async () => {
|
|
1721
|
+
const result = await postWebhook({
|
|
1722
|
+
type: "Issue",
|
|
1723
|
+
action: "update",
|
|
1724
|
+
data: {
|
|
1725
|
+
id: "issue-no-change",
|
|
1726
|
+
identifier: "ENG-NC",
|
|
1727
|
+
assigneeId: "user-1",
|
|
1728
|
+
delegateId: null,
|
|
1729
|
+
},
|
|
1730
|
+
updatedFrom: {
|
|
1731
|
+
assigneeId: "user-1", // same as current = no change
|
|
1732
|
+
delegateId: null,
|
|
1733
|
+
},
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
expect(result.status).toBe(200);
|
|
1737
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1738
|
+
expect(infoCalls.some((msg: string) => msg.includes("no assignment/delegation change"))).toBe(true);
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
it("skips when assignment is not to our viewer", async () => {
|
|
1742
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("viewer-1");
|
|
1743
|
+
|
|
1744
|
+
const result = await postWebhook({
|
|
1745
|
+
type: "Issue",
|
|
1746
|
+
action: "update",
|
|
1747
|
+
data: {
|
|
1748
|
+
id: "issue-not-us",
|
|
1749
|
+
identifier: "ENG-NU",
|
|
1750
|
+
assigneeId: "someone-else",
|
|
1751
|
+
},
|
|
1752
|
+
updatedFrom: { assigneeId: null },
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
expect(result.status).toBe(200);
|
|
1756
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1757
|
+
expect(infoCalls.some((msg: string) => msg.includes("not us"))).toBe(true);
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
it("dispatches when assigned to our viewer", async () => {
|
|
1761
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("viewer-1");
|
|
1762
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1763
|
+
id: "issue-assigned",
|
|
1764
|
+
identifier: "ENG-ASSGN",
|
|
1765
|
+
title: "Assigned Issue",
|
|
1766
|
+
description: "Do this",
|
|
1767
|
+
state: { name: "In Progress", type: "started" },
|
|
1768
|
+
team: { id: "team-1" },
|
|
1769
|
+
labels: { nodes: [] },
|
|
1770
|
+
comments: { nodes: [] },
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
const result = await postWebhook({
|
|
1774
|
+
type: "Issue",
|
|
1775
|
+
action: "update",
|
|
1776
|
+
data: {
|
|
1777
|
+
id: "issue-assigned",
|
|
1778
|
+
identifier: "ENG-ASSGN",
|
|
1779
|
+
assigneeId: "viewer-1",
|
|
1780
|
+
},
|
|
1781
|
+
updatedFrom: { assigneeId: null },
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
expect(result.status).toBe(200);
|
|
1785
|
+
// Wait for fire-and-forget handleDispatch
|
|
1786
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1787
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1788
|
+
expect(infoCalls.some((msg: string) => msg.includes("assigned to our app user"))).toBe(true);
|
|
1789
|
+
// handleDispatch should have run tier assessment and created worktree
|
|
1790
|
+
expect(assessTierMock).toHaveBeenCalled();
|
|
1791
|
+
expect(createWorktreeMock).toHaveBeenCalled();
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
it("dispatches when delegated to our viewer", async () => {
|
|
1795
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("viewer-1");
|
|
1796
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1797
|
+
id: "issue-delegated",
|
|
1798
|
+
identifier: "ENG-DEL",
|
|
1799
|
+
title: "Delegated Issue",
|
|
1800
|
+
description: "Do this via delegation",
|
|
1801
|
+
state: { name: "In Progress", type: "started" },
|
|
1802
|
+
team: { id: "team-1" },
|
|
1803
|
+
labels: { nodes: [] },
|
|
1804
|
+
comments: { nodes: [] },
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
const result = await postWebhook({
|
|
1808
|
+
type: "Issue",
|
|
1809
|
+
action: "update",
|
|
1810
|
+
data: {
|
|
1811
|
+
id: "issue-delegated",
|
|
1812
|
+
identifier: "ENG-DEL",
|
|
1813
|
+
assigneeId: null,
|
|
1814
|
+
delegateId: "viewer-1",
|
|
1815
|
+
},
|
|
1816
|
+
updatedFrom: { delegateId: null },
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
expect(result.status).toBe(200);
|
|
1820
|
+
// Wait for fire-and-forget handleDispatch
|
|
1821
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1822
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1823
|
+
expect(infoCalls.some((msg: string) => msg.includes("delegated to our app user"))).toBe(true);
|
|
1824
|
+
expect(assessTierMock).toHaveBeenCalled();
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
it("skips when no Linear access token for issue update", async () => {
|
|
1828
|
+
resolveLinearTokenMock.mockReturnValue({ accessToken: null, source: "none" });
|
|
1829
|
+
|
|
1830
|
+
const result = await postWebhook({
|
|
1831
|
+
type: "Issue",
|
|
1832
|
+
action: "update",
|
|
1833
|
+
data: {
|
|
1834
|
+
id: "issue-no-token-upd",
|
|
1835
|
+
identifier: "ENG-NTU",
|
|
1836
|
+
assigneeId: "viewer-1",
|
|
1837
|
+
},
|
|
1838
|
+
updatedFrom: { assigneeId: null },
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
expect(result.status).toBe(200);
|
|
1842
|
+
const errorCalls = (result.api.logger.error as any).mock.calls.map((c: any[]) => c[0]);
|
|
1843
|
+
expect(errorCalls.some((msg: string) => msg.includes("No Linear access token"))).toBe(true);
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
it("deduplicates duplicate Issue.update webhooks", async () => {
|
|
1847
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("viewer-1");
|
|
1848
|
+
|
|
1849
|
+
// First webhook
|
|
1850
|
+
const result1 = await postWebhook({
|
|
1851
|
+
type: "Issue",
|
|
1852
|
+
action: "update",
|
|
1853
|
+
data: {
|
|
1854
|
+
id: "issue-dedup-update",
|
|
1855
|
+
identifier: "ENG-DDU",
|
|
1856
|
+
assigneeId: "viewer-1",
|
|
1857
|
+
},
|
|
1858
|
+
updatedFrom: { assigneeId: null },
|
|
1859
|
+
});
|
|
1860
|
+
expect(result1.status).toBe(200);
|
|
1861
|
+
|
|
1862
|
+
// Second webhook (duplicate) — should be deduped
|
|
1863
|
+
const result2 = await postWebhook({
|
|
1864
|
+
type: "Issue",
|
|
1865
|
+
action: "update",
|
|
1866
|
+
data: {
|
|
1867
|
+
id: "issue-dedup-update",
|
|
1868
|
+
identifier: "ENG-DDU",
|
|
1869
|
+
assigneeId: "viewer-1",
|
|
1870
|
+
},
|
|
1871
|
+
updatedFrom: { assigneeId: null },
|
|
1872
|
+
});
|
|
1873
|
+
expect(result2.status).toBe(200);
|
|
1874
|
+
const infoCalls = (result2.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1875
|
+
expect(infoCalls.some((msg: string) => msg.includes("already processed"))).toBe(true);
|
|
1876
|
+
});
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
// ---------------------------------------------------------------------------
|
|
1880
|
+
// Issue.create — auto-triage
|
|
1881
|
+
// ---------------------------------------------------------------------------
|
|
1882
|
+
|
|
1883
|
+
describe("Issue.create auto-triage", () => {
|
|
1884
|
+
it("responds 200 and logs missing issue data", async () => {
|
|
1885
|
+
const result = await postWebhook({
|
|
1886
|
+
type: "Issue",
|
|
1887
|
+
action: "create",
|
|
1888
|
+
data: null,
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
expect(result.status).toBe(200);
|
|
1892
|
+
const errorCalls = (result.api.logger.error as any).mock.calls.map((c: any[]) => c[0]);
|
|
1893
|
+
expect(errorCalls.some((msg: string) => msg.includes("missing issue data"))).toBe(true);
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
it("deduplicates by issue ID", async () => {
|
|
1897
|
+
_markAsProcessedForTesting("issue-create:issue-create-dup");
|
|
1898
|
+
|
|
1899
|
+
const result = await postWebhook({
|
|
1900
|
+
type: "Issue",
|
|
1901
|
+
action: "create",
|
|
1902
|
+
data: { id: "issue-create-dup", identifier: "ENG-ICD", title: "Dup" },
|
|
1903
|
+
});
|
|
1904
|
+
|
|
1905
|
+
expect(result.status).toBe(200);
|
|
1906
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1907
|
+
expect(infoCalls.some((msg: string) => msg.includes("already processed"))).toBe(true);
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
it("skips when no Linear access token", async () => {
|
|
1911
|
+
resolveLinearTokenMock.mockReturnValue({ accessToken: null, source: "none" });
|
|
1912
|
+
|
|
1913
|
+
const result = await postWebhook({
|
|
1914
|
+
type: "Issue",
|
|
1915
|
+
action: "create",
|
|
1916
|
+
data: { id: "issue-create-nt", identifier: "ENG-ICNT", title: "No Token" },
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1919
|
+
expect(result.status).toBe(200);
|
|
1920
|
+
const errorCalls = (result.api.logger.error as any).mock.calls.map((c: any[]) => c[0]);
|
|
1921
|
+
expect(errorCalls.some((msg: string) => msg.includes("No Linear access token"))).toBe(true);
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
it("skips when active run already exists for the issue", async () => {
|
|
1925
|
+
_addActiveRunForTesting("issue-create-active");
|
|
1926
|
+
|
|
1927
|
+
const result = await postWebhook({
|
|
1928
|
+
type: "Issue",
|
|
1929
|
+
action: "create",
|
|
1930
|
+
data: { id: "issue-create-active", identifier: "ENG-ICA", title: "Active" },
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
expect(result.status).toBe(200);
|
|
1934
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1935
|
+
expect(infoCalls.some((msg: string) => msg.includes("already has active run"))).toBe(true);
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
it("runs triage for valid new issue", async () => {
|
|
1939
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1940
|
+
id: "issue-triage",
|
|
1941
|
+
identifier: "ENG-TR",
|
|
1942
|
+
title: "Triage Test",
|
|
1943
|
+
description: "Needs triage",
|
|
1944
|
+
state: { name: "Backlog", type: "backlog" },
|
|
1945
|
+
team: { id: "team-1", issueEstimationType: "fibonacci" },
|
|
1946
|
+
labels: { nodes: [] },
|
|
1947
|
+
creator: { name: "Dev", email: "dev@example.com" },
|
|
1948
|
+
creatorId: "creator-1",
|
|
1949
|
+
});
|
|
1950
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("viewer-1");
|
|
1951
|
+
mockLinearApiInstance.getTeamLabels.mockResolvedValue([
|
|
1952
|
+
{ id: "label-1", name: "bug" },
|
|
1953
|
+
{ id: "label-2", name: "feature" },
|
|
1954
|
+
]);
|
|
1955
|
+
runAgentMock.mockResolvedValue({
|
|
1956
|
+
success: true,
|
|
1957
|
+
output: '```json\n{"estimate": 3, "labelIds": ["label-1"], "priority": 2, "assessment": "Medium bug fix"}\n```\n\nThis is a medium complexity bug fix.',
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
const result = await postWebhook({
|
|
1961
|
+
type: "Issue",
|
|
1962
|
+
action: "create",
|
|
1963
|
+
data: { id: "issue-triage", identifier: "ENG-TR", title: "Triage Test", creatorId: "creator-1" },
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
expect(result.status).toBe(200);
|
|
1967
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1968
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
1969
|
+
expect(mockLinearApiInstance.updateIssue).toHaveBeenCalled();
|
|
1970
|
+
expect(clearActiveSessionMock).toHaveBeenCalledWith("issue-triage");
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1973
|
+
it("skips triage when issue is created by our bot", async () => {
|
|
1974
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1975
|
+
id: "issue-bot-created",
|
|
1976
|
+
identifier: "ENG-BC",
|
|
1977
|
+
title: "Bot Issue",
|
|
1978
|
+
description: "Created by bot",
|
|
1979
|
+
state: { name: "Backlog", type: "backlog" },
|
|
1980
|
+
team: { id: "team-1" },
|
|
1981
|
+
creatorId: "viewer-1",
|
|
1982
|
+
});
|
|
1983
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("viewer-1");
|
|
1984
|
+
|
|
1985
|
+
const result = await postWebhook({
|
|
1986
|
+
type: "Issue",
|
|
1987
|
+
action: "create",
|
|
1988
|
+
data: { id: "issue-bot-created", identifier: "ENG-BC", title: "Bot Issue", creatorId: "viewer-1" },
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
expect(result.status).toBe(200);
|
|
1992
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1993
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
1994
|
+
expect(infoCalls.some((msg: string) => msg.includes("created by our bot"))).toBe(true);
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
it("skips triage for issues in planning-mode projects", async () => {
|
|
1998
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
1999
|
+
id: "issue-plan-skip",
|
|
2000
|
+
identifier: "ENG-PLS",
|
|
2001
|
+
title: "Planning Skip",
|
|
2002
|
+
description: "desc",
|
|
2003
|
+
state: { name: "Backlog", type: "backlog" },
|
|
2004
|
+
team: { id: "team-1" },
|
|
2005
|
+
project: { id: "proj-plan-skip" },
|
|
2006
|
+
creatorId: "creator-1",
|
|
2007
|
+
});
|
|
2008
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("viewer-1");
|
|
2009
|
+
isInPlanningModeMock.mockReturnValue(true);
|
|
2010
|
+
|
|
2011
|
+
const result = await postWebhook({
|
|
2012
|
+
type: "Issue",
|
|
2013
|
+
action: "create",
|
|
2014
|
+
data: { id: "issue-plan-skip", identifier: "ENG-PLS", title: "Planning Skip", creatorId: "creator-1" },
|
|
2015
|
+
});
|
|
2016
|
+
|
|
2017
|
+
expect(result.status).toBe(200);
|
|
2018
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
2019
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
2020
|
+
expect(infoCalls.some((msg: string) => msg.includes("planning mode"))).toBe(true);
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
it("handles triage agent failure gracefully", async () => {
|
|
2024
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2025
|
+
id: "issue-triage-fail",
|
|
2026
|
+
identifier: "ENG-TF",
|
|
2027
|
+
title: "Triage Fail",
|
|
2028
|
+
description: "desc",
|
|
2029
|
+
state: { name: "Backlog", type: "backlog" },
|
|
2030
|
+
team: { id: "team-1" },
|
|
2031
|
+
labels: { nodes: [] },
|
|
2032
|
+
creatorId: "creator-1",
|
|
2033
|
+
});
|
|
2034
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("viewer-1");
|
|
2035
|
+
runAgentMock.mockResolvedValue({
|
|
2036
|
+
success: false,
|
|
2037
|
+
output: "Agent failed",
|
|
2038
|
+
});
|
|
2039
|
+
|
|
2040
|
+
const result = await postWebhook({
|
|
2041
|
+
type: "Issue",
|
|
2042
|
+
action: "create",
|
|
2043
|
+
data: { id: "issue-triage-fail", identifier: "ENG-TF", title: "Triage Fail", creatorId: "creator-1" },
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2046
|
+
expect(result.status).toBe(200);
|
|
2047
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
2048
|
+
// Should still post a comment about failure
|
|
2049
|
+
const emitCalls = mockLinearApiInstance.emitActivity.mock.calls;
|
|
2050
|
+
const responseCall = emitCalls.find((c: any[]) => c[1]?.type === "response");
|
|
2051
|
+
if (responseCall) {
|
|
2052
|
+
expect(responseCall[1].body).toContain("Something went wrong");
|
|
2053
|
+
}
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
it("handles triage exception and emits error", async () => {
|
|
2057
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2058
|
+
id: "issue-triage-exc",
|
|
2059
|
+
identifier: "ENG-TE",
|
|
2060
|
+
title: "Triage Exception",
|
|
2061
|
+
description: "desc",
|
|
2062
|
+
state: { name: "Backlog", type: "backlog" },
|
|
2063
|
+
team: { id: "team-1" },
|
|
2064
|
+
labels: { nodes: [] },
|
|
2065
|
+
creatorId: "creator-1",
|
|
2066
|
+
});
|
|
2067
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("viewer-1");
|
|
2068
|
+
runAgentMock.mockRejectedValue(new Error("triage exploded"));
|
|
2069
|
+
|
|
2070
|
+
const result = await postWebhook({
|
|
2071
|
+
type: "Issue",
|
|
2072
|
+
action: "create",
|
|
2073
|
+
data: { id: "issue-triage-exc", identifier: "ENG-TE", title: "Triage Exception", creatorId: "creator-1" },
|
|
2074
|
+
});
|
|
2075
|
+
|
|
2076
|
+
expect(result.status).toBe(200);
|
|
2077
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
2078
|
+
const errorCalls = (result.api.logger.error as any).mock.calls.map((c: any[]) => c[0]);
|
|
2079
|
+
expect(errorCalls.some((msg: string) => msg.includes("triage error"))).toBe(true);
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
it("posts via comment when no agentSession is available", async () => {
|
|
2083
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2084
|
+
id: "issue-triage-nosess",
|
|
2085
|
+
identifier: "ENG-TNS",
|
|
2086
|
+
title: "No Session Triage",
|
|
2087
|
+
description: "desc",
|
|
2088
|
+
state: { name: "Backlog", type: "backlog" },
|
|
2089
|
+
team: { id: "team-1" },
|
|
2090
|
+
labels: { nodes: [] },
|
|
2091
|
+
creatorId: "creator-1",
|
|
2092
|
+
});
|
|
2093
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("viewer-1");
|
|
2094
|
+
mockLinearApiInstance.createSessionOnIssue.mockResolvedValue({ sessionId: null });
|
|
2095
|
+
runAgentMock.mockResolvedValue({
|
|
2096
|
+
success: true,
|
|
2097
|
+
output: "Simple triage response without JSON",
|
|
2098
|
+
});
|
|
2099
|
+
|
|
2100
|
+
const result = await postWebhook({
|
|
2101
|
+
type: "Issue",
|
|
2102
|
+
action: "create",
|
|
2103
|
+
data: { id: "issue-triage-nosess", identifier: "ENG-TNS", title: "No Session Triage", creatorId: "creator-1" },
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
expect(result.status).toBe(200);
|
|
2107
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
2108
|
+
// Should fall back to comment since no agentSessionId
|
|
2109
|
+
expect(mockLinearApiInstance.createComment).toHaveBeenCalled();
|
|
2110
|
+
});
|
|
2111
|
+
});
|
|
2112
|
+
|
|
2113
|
+
// ---------------------------------------------------------------------------
|
|
2114
|
+
// Unhandled webhook type — default path
|
|
2115
|
+
// ---------------------------------------------------------------------------
|
|
2116
|
+
|
|
2117
|
+
// ---------------------------------------------------------------------------
|
|
2118
|
+
// dispatchCommentToAgent — fallback paths
|
|
2119
|
+
// ---------------------------------------------------------------------------
|
|
2120
|
+
|
|
2121
|
+
describe("dispatchCommentToAgent via Comment.create intents", () => {
|
|
2122
|
+
it("falls back to comment when emitActivity fails during dispatch", async () => {
|
|
2123
|
+
classifyIntentMock.mockResolvedValue({
|
|
2124
|
+
intent: "request_work",
|
|
2125
|
+
reasoning: "User wants work done",
|
|
2126
|
+
fromFallback: false,
|
|
2127
|
+
});
|
|
2128
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2129
|
+
id: "issue-dispatch-fb",
|
|
2130
|
+
identifier: "ENG-DFB",
|
|
2131
|
+
title: "Dispatch Fallback",
|
|
2132
|
+
description: "desc",
|
|
2133
|
+
state: { name: "In Progress", type: "started" },
|
|
2134
|
+
team: { id: "team-dfb" },
|
|
2135
|
+
comments: { nodes: [] },
|
|
2136
|
+
});
|
|
2137
|
+
// emitActivity fails for response type
|
|
2138
|
+
mockLinearApiInstance.emitActivity.mockImplementation((_sid: string, content: any) => {
|
|
2139
|
+
if (content.type === "response") return Promise.reject(new Error("emit fail"));
|
|
2140
|
+
return Promise.resolve(undefined);
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
const result = await postWebhook({
|
|
2144
|
+
type: "Comment",
|
|
2145
|
+
action: "create",
|
|
2146
|
+
data: {
|
|
2147
|
+
id: "comment-dispatch-fb",
|
|
2148
|
+
body: "Do something",
|
|
2149
|
+
user: { id: "human-dfb", name: "Human" },
|
|
2150
|
+
issue: { id: "issue-dispatch-fb", identifier: "ENG-DFB" },
|
|
2151
|
+
},
|
|
2152
|
+
});
|
|
2153
|
+
|
|
2154
|
+
expect(result.status).toBe(200);
|
|
2155
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
2156
|
+
// Should have fallen back to createComment
|
|
2157
|
+
expect(mockLinearApiInstance.createComment).toHaveBeenCalled();
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
it("dispatches with no agent session when createSessionOnIssue returns null", async () => {
|
|
2161
|
+
classifyIntentMock.mockResolvedValue({
|
|
2162
|
+
intent: "request_work",
|
|
2163
|
+
reasoning: "User wants work done",
|
|
2164
|
+
fromFallback: false,
|
|
2165
|
+
});
|
|
2166
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2167
|
+
id: "issue-no-session",
|
|
2168
|
+
identifier: "ENG-NS",
|
|
2169
|
+
title: "No Session",
|
|
2170
|
+
description: "desc",
|
|
2171
|
+
state: { name: "Backlog", type: "backlog" },
|
|
2172
|
+
team: { id: "team-ns" },
|
|
2173
|
+
comments: { nodes: [] },
|
|
2174
|
+
});
|
|
2175
|
+
mockLinearApiInstance.createSessionOnIssue.mockResolvedValue({ sessionId: null });
|
|
2176
|
+
|
|
2177
|
+
const result = await postWebhook({
|
|
2178
|
+
type: "Comment",
|
|
2179
|
+
action: "create",
|
|
2180
|
+
data: {
|
|
2181
|
+
id: "comment-no-session",
|
|
2182
|
+
body: "Do something",
|
|
2183
|
+
user: { id: "human-ns", name: "Human" },
|
|
2184
|
+
issue: { id: "issue-no-session", identifier: "ENG-NS" },
|
|
2185
|
+
},
|
|
2186
|
+
});
|
|
2187
|
+
|
|
2188
|
+
expect(result.status).toBe(200);
|
|
2189
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
2190
|
+
// Without session, posts via comment
|
|
2191
|
+
expect(mockLinearApiInstance.createComment).toHaveBeenCalled();
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
it("handles error in dispatchCommentToAgent gracefully", async () => {
|
|
2195
|
+
classifyIntentMock.mockResolvedValue({
|
|
2196
|
+
intent: "request_work",
|
|
2197
|
+
reasoning: "User wants work done",
|
|
2198
|
+
fromFallback: false,
|
|
2199
|
+
});
|
|
2200
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2201
|
+
id: "issue-dispatch-err",
|
|
2202
|
+
identifier: "ENG-DE",
|
|
2203
|
+
title: "Dispatch Error",
|
|
2204
|
+
description: "desc",
|
|
2205
|
+
state: { name: "Backlog", type: "backlog" },
|
|
2206
|
+
team: { id: "team-de" },
|
|
2207
|
+
comments: { nodes: [] },
|
|
2208
|
+
});
|
|
2209
|
+
runAgentMock.mockRejectedValue(new Error("agent exploded"));
|
|
2210
|
+
|
|
2211
|
+
const result = await postWebhook({
|
|
2212
|
+
type: "Comment",
|
|
2213
|
+
action: "create",
|
|
2214
|
+
data: {
|
|
2215
|
+
id: "comment-dispatch-err",
|
|
2216
|
+
body: "Do something",
|
|
2217
|
+
user: { id: "human-de", name: "Human" },
|
|
2218
|
+
issue: { id: "issue-dispatch-err", identifier: "ENG-DE" },
|
|
2219
|
+
},
|
|
2220
|
+
});
|
|
2221
|
+
|
|
2222
|
+
expect(result.status).toBe(200);
|
|
2223
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
2224
|
+
const errorCalls = (result.api.logger.error as any).mock.calls.map((c: any[]) => c[0]);
|
|
2225
|
+
expect(errorCalls.some((msg: string) => msg.includes("dispatchCommentToAgent error"))).toBe(true);
|
|
2226
|
+
});
|
|
2227
|
+
|
|
2228
|
+
it("skips dispatch when active run exists in dispatchCommentToAgent", async () => {
|
|
2229
|
+
// The @mention fast path calls dispatchCommentToAgent which has its own
|
|
2230
|
+
// activeRuns check. But we can't easily test that because the first
|
|
2231
|
+
// activeRuns check in the Comment handler runs before intent classification.
|
|
2232
|
+
// Instead, we test the ask_agent path where the agent run gets set up.
|
|
2233
|
+
classifyIntentMock.mockResolvedValue({
|
|
2234
|
+
intent: "ask_agent",
|
|
2235
|
+
agentId: "kaylee",
|
|
2236
|
+
reasoning: "Ask kaylee",
|
|
2237
|
+
fromFallback: false,
|
|
2238
|
+
});
|
|
2239
|
+
// Set activeRuns right before dispatchCommentToAgent checks
|
|
2240
|
+
// This won't work directly, but we can verify the agent runs without issue
|
|
2241
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2242
|
+
id: "issue-agent-run",
|
|
2243
|
+
identifier: "ENG-AR",
|
|
2244
|
+
title: "Agent Run",
|
|
2245
|
+
description: "desc",
|
|
2246
|
+
state: { name: "Backlog", type: "backlog" },
|
|
2247
|
+
team: { id: "team-ar" },
|
|
2248
|
+
comments: { nodes: [{ user: { name: "Dev" }, body: "test" }] },
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
const result = await postWebhook({
|
|
2252
|
+
type: "Comment",
|
|
2253
|
+
action: "create",
|
|
2254
|
+
data: {
|
|
2255
|
+
id: "comment-agent-run",
|
|
2256
|
+
body: "kaylee do this",
|
|
2257
|
+
user: { id: "human-ar", name: "Human" },
|
|
2258
|
+
issue: { id: "issue-agent-run", identifier: "ENG-AR" },
|
|
2259
|
+
},
|
|
2260
|
+
});
|
|
2261
|
+
|
|
2262
|
+
expect(result.status).toBe(200);
|
|
2263
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
2264
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
2265
|
+
expect(clearActiveSessionMock).toHaveBeenCalledWith("issue-agent-run");
|
|
2266
|
+
});
|
|
2267
|
+
});
|
|
2268
|
+
|
|
2269
|
+
// ---------------------------------------------------------------------------
|
|
2270
|
+
// handleCloseIssue — detailed tests
|
|
2271
|
+
// ---------------------------------------------------------------------------
|
|
2272
|
+
|
|
2273
|
+
describe("handleCloseIssue via close_issue intent", () => {
|
|
2274
|
+
it("transitions issue to completed state and posts report", async () => {
|
|
2275
|
+
classifyIntentMock.mockResolvedValue({
|
|
2276
|
+
intent: "close_issue",
|
|
2277
|
+
reasoning: "User wants to close",
|
|
2278
|
+
fromFallback: false,
|
|
2279
|
+
});
|
|
2280
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2281
|
+
id: "issue-close-full",
|
|
2282
|
+
identifier: "ENG-CF",
|
|
2283
|
+
title: "Close Full Test",
|
|
2284
|
+
description: "desc",
|
|
2285
|
+
state: { name: "In Progress", type: "started" },
|
|
2286
|
+
assignee: { name: "User" },
|
|
2287
|
+
team: { id: "team-cf" },
|
|
2288
|
+
comments: { nodes: [{ user: { name: "Dev" }, body: "Implemented this" }] },
|
|
2289
|
+
creator: { name: "Creator" },
|
|
2290
|
+
});
|
|
2291
|
+
mockLinearApiInstance.getTeamStates.mockResolvedValue([
|
|
2292
|
+
{ id: "st-1", name: "Backlog", type: "backlog" },
|
|
2293
|
+
{ id: "st-done", name: "Done", type: "completed" },
|
|
2294
|
+
]);
|
|
2295
|
+
runAgentMock.mockResolvedValue({
|
|
2296
|
+
success: true,
|
|
2297
|
+
output: "## Summary\nFixed the issue.\n## Resolution\nImplemented fix.",
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
const result = await postWebhook({
|
|
2301
|
+
type: "Comment",
|
|
2302
|
+
action: "create",
|
|
2303
|
+
data: {
|
|
2304
|
+
id: "comment-close-full",
|
|
2305
|
+
body: "Close this issue",
|
|
2306
|
+
user: { id: "human-cf", name: "Human" },
|
|
2307
|
+
issue: { id: "issue-close-full", identifier: "ENG-CF" },
|
|
2308
|
+
},
|
|
2309
|
+
});
|
|
2310
|
+
|
|
2311
|
+
expect(result.status).toBe(200);
|
|
2312
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
2313
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
2314
|
+
// Should update issue with completed state
|
|
2315
|
+
expect(mockLinearApiInstance.updateIssue).toHaveBeenCalledWith(
|
|
2316
|
+
"issue-close-full",
|
|
2317
|
+
expect.objectContaining({ stateId: "st-done" }),
|
|
2318
|
+
);
|
|
2319
|
+
});
|
|
2320
|
+
|
|
2321
|
+
it("posts closure report without state change when no completed state found", async () => {
|
|
2322
|
+
classifyIntentMock.mockResolvedValue({
|
|
2323
|
+
intent: "close_issue",
|
|
2324
|
+
reasoning: "User wants to close",
|
|
2325
|
+
fromFallback: false,
|
|
2326
|
+
});
|
|
2327
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2328
|
+
id: "issue-close-nostate",
|
|
2329
|
+
identifier: "ENG-CNS",
|
|
2330
|
+
title: "Close No State",
|
|
2331
|
+
description: "desc",
|
|
2332
|
+
state: { name: "In Progress", type: "started" },
|
|
2333
|
+
team: { id: "team-cns" },
|
|
2334
|
+
comments: { nodes: [] },
|
|
2335
|
+
});
|
|
2336
|
+
mockLinearApiInstance.getTeamStates.mockResolvedValue([
|
|
2337
|
+
{ id: "st-1", name: "Backlog", type: "backlog" },
|
|
2338
|
+
{ id: "st-2", name: "In Progress", type: "started" },
|
|
2339
|
+
// No completed state
|
|
2340
|
+
]);
|
|
2341
|
+
runAgentMock.mockResolvedValue({
|
|
2342
|
+
success: true,
|
|
2343
|
+
output: "Closure report text",
|
|
2344
|
+
});
|
|
2345
|
+
|
|
2346
|
+
const result = await postWebhook({
|
|
2347
|
+
type: "Comment",
|
|
2348
|
+
action: "create",
|
|
2349
|
+
data: {
|
|
2350
|
+
id: "comment-close-nostate",
|
|
2351
|
+
body: "Close this",
|
|
2352
|
+
user: { id: "human-cns", name: "Human" },
|
|
2353
|
+
issue: { id: "issue-close-nostate", identifier: "ENG-CNS" },
|
|
2354
|
+
},
|
|
2355
|
+
});
|
|
2356
|
+
|
|
2357
|
+
expect(result.status).toBe(200);
|
|
2358
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
2359
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
2360
|
+
const warnCalls = (result.api.logger.warn as any).mock.calls.map((c: any[]) => c[0]);
|
|
2361
|
+
expect(warnCalls.some((msg: string) => msg.includes("No completed state found"))).toBe(true);
|
|
2362
|
+
});
|
|
2363
|
+
|
|
2364
|
+
it("handles close agent failure gracefully", async () => {
|
|
2365
|
+
classifyIntentMock.mockResolvedValue({
|
|
2366
|
+
intent: "close_issue",
|
|
2367
|
+
reasoning: "User wants to close",
|
|
2368
|
+
fromFallback: false,
|
|
2369
|
+
});
|
|
2370
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2371
|
+
id: "issue-close-fail",
|
|
2372
|
+
identifier: "ENG-CLF",
|
|
2373
|
+
title: "Close Fail",
|
|
2374
|
+
description: "desc",
|
|
2375
|
+
state: { name: "In Progress", type: "started" },
|
|
2376
|
+
team: { id: "team-clf" },
|
|
2377
|
+
comments: { nodes: [] },
|
|
2378
|
+
});
|
|
2379
|
+
runAgentMock.mockResolvedValue({ success: false, output: "Failed" });
|
|
2380
|
+
|
|
2381
|
+
const result = await postWebhook({
|
|
2382
|
+
type: "Comment",
|
|
2383
|
+
action: "create",
|
|
2384
|
+
data: {
|
|
2385
|
+
id: "comment-close-fail",
|
|
2386
|
+
body: "Close this",
|
|
2387
|
+
user: { id: "human-clf", name: "Human" },
|
|
2388
|
+
issue: { id: "issue-close-fail", identifier: "ENG-CLF" },
|
|
2389
|
+
},
|
|
2390
|
+
});
|
|
2391
|
+
|
|
2392
|
+
expect(result.status).toBe(200);
|
|
2393
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
2394
|
+
// Should still post a closure report (with fallback text)
|
|
2395
|
+
const emitCalls = mockLinearApiInstance.emitActivity.mock.calls;
|
|
2396
|
+
const hasResponse = emitCalls.some((c: any[]) => c[1]?.type === "response");
|
|
2397
|
+
const hasComment = mockLinearApiInstance.createComment.mock.calls.length > 0;
|
|
2398
|
+
expect(hasResponse || hasComment).toBe(true);
|
|
2399
|
+
});
|
|
2400
|
+
});
|
|
2401
|
+
|
|
2402
|
+
// ---------------------------------------------------------------------------
|
|
2403
|
+
// handleDispatch — detailed tests
|
|
2404
|
+
// ---------------------------------------------------------------------------
|
|
2405
|
+
|
|
2406
|
+
describe("handleDispatch via Issue.update assignment", () => {
|
|
2407
|
+
it("registers dispatch and spawns worker pipeline", async () => {
|
|
2408
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("viewer-1");
|
|
2409
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2410
|
+
id: "issue-dispatch-full",
|
|
2411
|
+
identifier: "ENG-DF",
|
|
2412
|
+
title: "Full Dispatch",
|
|
2413
|
+
description: "Implement this feature",
|
|
2414
|
+
state: { name: "In Progress", type: "started" },
|
|
2415
|
+
team: { id: "team-df" },
|
|
2416
|
+
labels: { nodes: [{ id: "l1", name: "feature" }] },
|
|
2417
|
+
comments: { nodes: [{ user: { name: "Dev" }, body: "Please do this" }] },
|
|
2418
|
+
project: null,
|
|
2419
|
+
});
|
|
2420
|
+
|
|
2421
|
+
const result = await postWebhook({
|
|
2422
|
+
type: "Issue",
|
|
2423
|
+
action: "update",
|
|
2424
|
+
data: {
|
|
2425
|
+
id: "issue-dispatch-full",
|
|
2426
|
+
identifier: "ENG-DF",
|
|
2427
|
+
assigneeId: "viewer-1",
|
|
2428
|
+
},
|
|
2429
|
+
updatedFrom: { assigneeId: null },
|
|
2430
|
+
});
|
|
2431
|
+
|
|
2432
|
+
expect(result.status).toBe(200);
|
|
2433
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
2434
|
+
expect(assessTierMock).toHaveBeenCalled();
|
|
2435
|
+
expect(createWorktreeMock).toHaveBeenCalled();
|
|
2436
|
+
expect(prepareWorkspaceMock).toHaveBeenCalled();
|
|
2437
|
+
expect(registerDispatchMock).toHaveBeenCalled();
|
|
2438
|
+
expect(setActiveSessionMock).toHaveBeenCalled();
|
|
2439
|
+
expect(spawnWorkerMock).toHaveBeenCalled();
|
|
2440
|
+
});
|
|
2441
|
+
|
|
2442
|
+
it("handles worktree creation failure", async () => {
|
|
2443
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("viewer-1");
|
|
2444
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2445
|
+
id: "issue-wt-fail",
|
|
2446
|
+
identifier: "ENG-WF",
|
|
2447
|
+
title: "Worktree Fail",
|
|
2448
|
+
description: "desc",
|
|
2449
|
+
state: { name: "In Progress", type: "started" },
|
|
2450
|
+
team: { id: "team-wf" },
|
|
2451
|
+
labels: { nodes: [] },
|
|
2452
|
+
comments: { nodes: [] },
|
|
2453
|
+
});
|
|
2454
|
+
createWorktreeMock.mockImplementation(() => {
|
|
2455
|
+
throw new Error("git worktree add failed");
|
|
2456
|
+
});
|
|
2457
|
+
|
|
2458
|
+
const result = await postWebhook({
|
|
2459
|
+
type: "Issue",
|
|
2460
|
+
action: "update",
|
|
2461
|
+
data: {
|
|
2462
|
+
id: "issue-wt-fail",
|
|
2463
|
+
identifier: "ENG-WF",
|
|
2464
|
+
assigneeId: "viewer-1",
|
|
2465
|
+
},
|
|
2466
|
+
updatedFrom: { assigneeId: null },
|
|
2467
|
+
});
|
|
2468
|
+
|
|
2469
|
+
expect(result.status).toBe(200);
|
|
2470
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
2471
|
+
// Should post failure comment
|
|
2472
|
+
expect(mockLinearApiInstance.createComment).toHaveBeenCalled();
|
|
2473
|
+
const commentArgs = mockLinearApiInstance.createComment.mock.calls[0];
|
|
2474
|
+
expect(commentArgs[1]).toContain("Dispatch failed");
|
|
2475
|
+
});
|
|
2476
|
+
|
|
2477
|
+
it("reclaims stale dispatch and re-dispatches", async () => {
|
|
2478
|
+
mockLinearApiInstance.getViewerId.mockResolvedValue("viewer-1");
|
|
2479
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2480
|
+
id: "issue-stale",
|
|
2481
|
+
identifier: "ENG-STALE",
|
|
2482
|
+
title: "Stale Dispatch",
|
|
2483
|
+
description: "desc",
|
|
2484
|
+
state: { name: "In Progress", type: "started" },
|
|
2485
|
+
team: { id: "team-stale" },
|
|
2486
|
+
labels: { nodes: [] },
|
|
2487
|
+
comments: { nodes: [] },
|
|
2488
|
+
});
|
|
2489
|
+
// Simulate existing stale dispatch
|
|
2490
|
+
getActiveDispatchMock.mockReturnValue({
|
|
2491
|
+
issueId: "issue-stale",
|
|
2492
|
+
status: "working",
|
|
2493
|
+
dispatchedAt: new Date(Date.now() - 60 * 60_000).toISOString(), // 1 hour old
|
|
2494
|
+
tier: "medium",
|
|
2495
|
+
worktreePath: "/tmp/old-worktree",
|
|
2496
|
+
});
|
|
2497
|
+
|
|
2498
|
+
const result = await postWebhook({
|
|
2499
|
+
type: "Issue",
|
|
2500
|
+
action: "update",
|
|
2501
|
+
data: {
|
|
2502
|
+
id: "issue-stale",
|
|
2503
|
+
identifier: "ENG-STALE",
|
|
2504
|
+
assigneeId: "viewer-1",
|
|
2505
|
+
},
|
|
2506
|
+
updatedFrom: { assigneeId: null },
|
|
2507
|
+
});
|
|
2508
|
+
|
|
2509
|
+
expect(result.status).toBe(200);
|
|
2510
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
2511
|
+
// Should have reclaimed the stale dispatch
|
|
2512
|
+
expect(removeActiveDispatchMock).toHaveBeenCalled();
|
|
2513
|
+
// And re-dispatched
|
|
2514
|
+
expect(assessTierMock).toHaveBeenCalled();
|
|
2515
|
+
});
|
|
2516
|
+
});
|
|
2517
|
+
|
|
2518
|
+
// ---------------------------------------------------------------------------
|
|
2519
|
+
// Dedup sweep logic
|
|
2520
|
+
// ---------------------------------------------------------------------------
|
|
2521
|
+
|
|
2522
|
+
describe("dedup sweep logic", () => {
|
|
2523
|
+
it("sweeps expired entries when sweep interval is exceeded", async () => {
|
|
2524
|
+
// Configure very short TTLs for testing
|
|
2525
|
+
_configureDedupTtls({ dedupTtlMs: 10, dedupSweepIntervalMs: 10 });
|
|
2526
|
+
|
|
2527
|
+
// Mark something as processed
|
|
2528
|
+
_markAsProcessedForTesting("session:sweep-test");
|
|
2529
|
+
|
|
2530
|
+
// Wait for TTL + sweep interval to expire
|
|
2531
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
2532
|
+
|
|
2533
|
+
// Now send a webhook that triggers wasRecentlyProcessed check
|
|
2534
|
+
// The sweep should have cleaned up the old entry
|
|
2535
|
+
const result = await postWebhook({
|
|
2536
|
+
type: "AgentSessionEvent",
|
|
2537
|
+
action: "created",
|
|
2538
|
+
agentSession: {
|
|
2539
|
+
id: "sweep-test",
|
|
2540
|
+
issue: { id: "issue-sweep", identifier: "ENG-SW" },
|
|
2541
|
+
},
|
|
2542
|
+
previousComments: [],
|
|
2543
|
+
});
|
|
2544
|
+
|
|
2545
|
+
// Since the entry was swept, it should NOT be deduped
|
|
2546
|
+
expect(result.status).toBe(200);
|
|
2547
|
+
// Should proceed normally (not say "already handled")
|
|
2548
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
2549
|
+
});
|
|
2550
|
+
});
|
|
2551
|
+
|
|
2552
|
+
// ---------------------------------------------------------------------------
|
|
2553
|
+
// Unhandled webhook type — default path
|
|
2554
|
+
// ---------------------------------------------------------------------------
|
|
2555
|
+
|
|
2556
|
+
// ---------------------------------------------------------------------------
|
|
2557
|
+
// resolveAgentId edge cases (via Comment dispatch paths)
|
|
2558
|
+
// ---------------------------------------------------------------------------
|
|
2559
|
+
|
|
2560
|
+
describe("resolveAgentId edge cases", () => {
|
|
2561
|
+
it("uses defaultAgentId from pluginConfig when available", async () => {
|
|
2562
|
+
classifyIntentMock.mockResolvedValue({
|
|
2563
|
+
intent: "request_work",
|
|
2564
|
+
reasoning: "User wants work done",
|
|
2565
|
+
fromFallback: false,
|
|
2566
|
+
});
|
|
2567
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2568
|
+
id: "issue-cfg-agent",
|
|
2569
|
+
identifier: "ENG-CA",
|
|
2570
|
+
title: "Config Agent",
|
|
2571
|
+
description: "desc",
|
|
2572
|
+
state: { name: "Backlog", type: "backlog" },
|
|
2573
|
+
team: { id: "team-ca" },
|
|
2574
|
+
comments: { nodes: [] },
|
|
2575
|
+
});
|
|
2576
|
+
|
|
2577
|
+
// Create API with custom defaultAgentId in pluginConfig
|
|
2578
|
+
const api = createApi();
|
|
2579
|
+
(api as any).pluginConfig = { defaultAgentId: "kaylee" };
|
|
2580
|
+
let status = 0;
|
|
2581
|
+
let body = "";
|
|
2582
|
+
let handlerDone: Promise<void> | undefined;
|
|
2583
|
+
|
|
2584
|
+
await withServer(
|
|
2585
|
+
(req, res) => {
|
|
2586
|
+
handlerDone = (async () => {
|
|
2587
|
+
await handleLinearWebhook(api, req, res);
|
|
2588
|
+
})();
|
|
2589
|
+
},
|
|
2590
|
+
async (baseUrl) => {
|
|
2591
|
+
const response = await fetch(`${baseUrl}/linear/webhook`, {
|
|
2592
|
+
method: "POST",
|
|
2593
|
+
headers: { "content-type": "application/json" },
|
|
2594
|
+
body: JSON.stringify({
|
|
2595
|
+
type: "Comment",
|
|
2596
|
+
action: "create",
|
|
2597
|
+
data: {
|
|
2598
|
+
id: "comment-cfg-agent",
|
|
2599
|
+
body: "Do something",
|
|
2600
|
+
user: { id: "human-ca", name: "Human" },
|
|
2601
|
+
issue: { id: "issue-cfg-agent", identifier: "ENG-CA" },
|
|
2602
|
+
},
|
|
2603
|
+
}),
|
|
2604
|
+
});
|
|
2605
|
+
status = response.status;
|
|
2606
|
+
body = await response.text();
|
|
2607
|
+
if (handlerDone) await handlerDone;
|
|
2608
|
+
},
|
|
2609
|
+
);
|
|
2610
|
+
|
|
2611
|
+
expect(status).toBe(200);
|
|
2612
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
2613
|
+
// Should use kaylee from config
|
|
2614
|
+
const infoCalls = (api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
2615
|
+
expect(infoCalls.some((msg: string) => msg.includes("request_work"))).toBe(true);
|
|
2616
|
+
});
|
|
2617
|
+
|
|
2618
|
+
it("throws when no defaultAgentId and no isDefault profile", async () => {
|
|
2619
|
+
loadAgentProfilesMock.mockReturnValue({
|
|
2620
|
+
mal: { label: "Mal", mission: "captain", mentionAliases: ["mal"] },
|
|
2621
|
+
// No isDefault: true
|
|
2622
|
+
});
|
|
2623
|
+
|
|
2624
|
+
const api = createApi();
|
|
2625
|
+
(api as any).pluginConfig = {}; // no defaultAgentId
|
|
2626
|
+
let status = 0;
|
|
2627
|
+
let handlerDone: Promise<void> | undefined;
|
|
2628
|
+
|
|
2629
|
+
await withServer(
|
|
2630
|
+
(req, res) => {
|
|
2631
|
+
handlerDone = (async () => {
|
|
2632
|
+
await handleLinearWebhook(api, req, res);
|
|
2633
|
+
})();
|
|
2634
|
+
},
|
|
2635
|
+
async (baseUrl) => {
|
|
2636
|
+
const response = await fetch(`${baseUrl}/linear/webhook`, {
|
|
2637
|
+
method: "POST",
|
|
2638
|
+
headers: { "content-type": "application/json" },
|
|
2639
|
+
body: JSON.stringify({
|
|
2640
|
+
type: "Comment",
|
|
2641
|
+
action: "create",
|
|
2642
|
+
data: {
|
|
2643
|
+
id: "comment-no-default",
|
|
2644
|
+
body: "Do something",
|
|
2645
|
+
user: { id: "human-nd", name: "Human" },
|
|
2646
|
+
issue: { id: "issue-no-default", identifier: "ENG-ND" },
|
|
2647
|
+
},
|
|
2648
|
+
}),
|
|
2649
|
+
});
|
|
2650
|
+
status = response.status;
|
|
2651
|
+
await response.text();
|
|
2652
|
+
if (handlerDone) await handlerDone;
|
|
2653
|
+
},
|
|
2654
|
+
);
|
|
2655
|
+
|
|
2656
|
+
// The handler catches the error via .catch path or the intent route
|
|
2657
|
+
expect(status).toBe(200);
|
|
2658
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
2659
|
+
// The error will be logged via the comment dispatch error handler
|
|
2660
|
+
const errorCalls = (api.logger.error as any).mock.calls.map((c: any[]) => c[0]);
|
|
2661
|
+
// The error might be caught in various places depending on the code path
|
|
2662
|
+
expect(errorCalls.length).toBeGreaterThanOrEqual(0);
|
|
2663
|
+
});
|
|
2664
|
+
});
|
|
2665
|
+
|
|
2666
|
+
// ---------------------------------------------------------------------------
|
|
2667
|
+
// postAgentComment coverage (via identity comment failures)
|
|
2668
|
+
// ---------------------------------------------------------------------------
|
|
2669
|
+
|
|
2670
|
+
describe("postAgentComment edge cases", () => {
|
|
2671
|
+
it("falls back to prefix when agent identity comment fails", async () => {
|
|
2672
|
+
// This is tested indirectly when createComment with agentOpts throws
|
|
2673
|
+
// Make createComment fail only when called with opts (identity mode)
|
|
2674
|
+
let callCount = 0;
|
|
2675
|
+
mockLinearApiInstance.createComment.mockImplementation(
|
|
2676
|
+
(_issueId: string, _body: string, opts?: any) => {
|
|
2677
|
+
callCount++;
|
|
2678
|
+
if (opts?.createAsUser) {
|
|
2679
|
+
return Promise.reject(new Error("actor_id scope required"));
|
|
2680
|
+
}
|
|
2681
|
+
return Promise.resolve(`comment-${callCount}`);
|
|
2682
|
+
}
|
|
2683
|
+
);
|
|
2684
|
+
|
|
2685
|
+
classifyIntentMock.mockResolvedValue({
|
|
2686
|
+
intent: "request_work",
|
|
2687
|
+
reasoning: "Work request",
|
|
2688
|
+
fromFallback: false,
|
|
2689
|
+
});
|
|
2690
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
2691
|
+
id: "issue-identity-fail",
|
|
2692
|
+
identifier: "ENG-IF",
|
|
2693
|
+
title: "Identity Fail",
|
|
2694
|
+
description: "desc",
|
|
2695
|
+
state: { name: "Backlog", type: "backlog" },
|
|
2696
|
+
team: { id: "team-if" },
|
|
2697
|
+
comments: { nodes: [] },
|
|
2698
|
+
});
|
|
2699
|
+
// No session, so it falls back to postAgentComment
|
|
2700
|
+
mockLinearApiInstance.createSessionOnIssue.mockResolvedValue({ sessionId: null });
|
|
2701
|
+
|
|
2702
|
+
const result = await postWebhook({
|
|
2703
|
+
type: "Comment",
|
|
2704
|
+
action: "create",
|
|
2705
|
+
data: {
|
|
2706
|
+
id: "comment-identity-fail",
|
|
2707
|
+
body: "Do something",
|
|
2708
|
+
user: { id: "human-if", name: "Human" },
|
|
2709
|
+
issue: { id: "issue-identity-fail", identifier: "ENG-IF" },
|
|
2710
|
+
},
|
|
2711
|
+
});
|
|
2712
|
+
|
|
2713
|
+
expect(result.status).toBe(200);
|
|
2714
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
2715
|
+
// createComment should have been called at least twice:
|
|
2716
|
+
// once with identity (fails), once without (fallback)
|
|
2717
|
+
expect(mockLinearApiInstance.createComment.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
2718
|
+
});
|
|
2719
|
+
});
|
|
2720
|
+
|
|
2721
|
+
describe("Unhandled webhook types", () => {
|
|
2722
|
+
it("logs warning and responds 200 for unknown type+action", async () => {
|
|
2723
|
+
const result = await postWebhook({
|
|
2724
|
+
type: "SomeUnknownType",
|
|
2725
|
+
action: "someAction",
|
|
2726
|
+
data: { id: "test" },
|
|
2727
|
+
});
|
|
2728
|
+
|
|
2729
|
+
expect(result.status).toBe(200);
|
|
2730
|
+
expect(result.body).toBe("ok");
|
|
2731
|
+
const warnCalls = (result.api.logger.warn as any).mock.calls.map((c: any[]) => c[0]);
|
|
2732
|
+
expect(warnCalls.some((msg: string) => msg.includes("Unhandled webhook type=SomeUnknownType"))).toBe(true);
|
|
2733
|
+
});
|
|
2734
|
+
|
|
2735
|
+
it("returns 400 for array payload", async () => {
|
|
2736
|
+
const api = createApi();
|
|
2737
|
+
let status = 0;
|
|
2738
|
+
let body = "";
|
|
2739
|
+
|
|
2740
|
+
await withServer(
|
|
2741
|
+
async (req, res) => {
|
|
2742
|
+
await handleLinearWebhook(api, req, res);
|
|
2743
|
+
},
|
|
2744
|
+
async (baseUrl) => {
|
|
2745
|
+
const response = await fetch(`${baseUrl}/linear/webhook`, {
|
|
2746
|
+
method: "POST",
|
|
2747
|
+
headers: { "content-type": "application/json" },
|
|
2748
|
+
body: JSON.stringify([1, 2, 3]),
|
|
2749
|
+
});
|
|
2750
|
+
status = response.status;
|
|
2751
|
+
body = await response.text();
|
|
2752
|
+
},
|
|
2753
|
+
);
|
|
2754
|
+
|
|
2755
|
+
expect(status).toBe(400);
|
|
2756
|
+
expect(body).toBe("Invalid payload");
|
|
2757
|
+
});
|
|
339
2758
|
});
|