@calltelemetry/openclaw-linear 0.8.2 → 0.8.4
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 +37 -4
- package/package.json +1 -1
- package/src/__test__/fixtures/webhook-payloads.ts +93 -0
- package/src/__test__/smoke-linear-api.test.ts +352 -0
- package/src/__test__/webhook-scenarios.test.ts +631 -0
- package/src/agent/agent.ts +69 -5
- package/src/api/linear-api.test.ts +37 -0
- package/src/api/linear-api.ts +96 -5
- package/src/infra/cli.ts +150 -0
- package/src/infra/doctor.test.ts +17 -2
- package/src/infra/doctor.ts +70 -1
- package/src/infra/webhook-provision.test.ts +162 -0
- package/src/infra/webhook-provision.ts +152 -0
- package/src/pipeline/intent-classify.test.ts +43 -0
- package/src/pipeline/intent-classify.ts +10 -0
- package/src/pipeline/webhook-dedup.test.ts +466 -0
- package/src/pipeline/webhook.ts +372 -112
- package/src/tools/tools.test.ts +100 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webhook-scenarios.test.ts — Full handler flow tests using captured payloads.
|
|
3
|
+
*
|
|
4
|
+
* Replays webhook payloads through handleLinearWebhook with mocked API
|
|
5
|
+
* dependencies. Tests the complete async handler behavior for each event
|
|
6
|
+
* type: API calls made, session lifecycle, agent runs, and responses.
|
|
7
|
+
*
|
|
8
|
+
* Unlike webhook-dedup.test.ts (dedup logic) and webhook.test.ts (HTTP basics),
|
|
9
|
+
* these tests verify the full business logic paths end-to-end.
|
|
10
|
+
*
|
|
11
|
+
* Key pattern: handlers prefer emitActivity(response) over createComment
|
|
12
|
+
* when an agent session exists — createComment is only used as a fallback
|
|
13
|
+
* when the session activity emission fails.
|
|
14
|
+
*/
|
|
15
|
+
import type { AddressInfo } from "node:net";
|
|
16
|
+
import { createServer } from "node:http";
|
|
17
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
+
|
|
20
|
+
// ── Hoisted mock references ──────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
mockRunAgent,
|
|
24
|
+
mockGetViewerId,
|
|
25
|
+
mockGetIssueDetails,
|
|
26
|
+
mockCreateComment,
|
|
27
|
+
mockEmitActivity,
|
|
28
|
+
mockUpdateSession,
|
|
29
|
+
mockUpdateIssue,
|
|
30
|
+
mockGetTeamLabels,
|
|
31
|
+
mockGetTeamStates,
|
|
32
|
+
mockCreateSessionOnIssue,
|
|
33
|
+
mockClassifyIntent,
|
|
34
|
+
mockSpawnWorker,
|
|
35
|
+
mockSetActiveSession,
|
|
36
|
+
mockClearActiveSession,
|
|
37
|
+
mockEmitDiagnostic,
|
|
38
|
+
} = vi.hoisted(() => ({
|
|
39
|
+
mockRunAgent: vi.fn(),
|
|
40
|
+
mockGetViewerId: vi.fn(),
|
|
41
|
+
mockGetIssueDetails: vi.fn(),
|
|
42
|
+
mockCreateComment: vi.fn(),
|
|
43
|
+
mockEmitActivity: vi.fn(),
|
|
44
|
+
mockUpdateSession: vi.fn(),
|
|
45
|
+
mockUpdateIssue: vi.fn(),
|
|
46
|
+
mockGetTeamLabels: vi.fn(),
|
|
47
|
+
mockGetTeamStates: vi.fn(),
|
|
48
|
+
mockCreateSessionOnIssue: vi.fn(),
|
|
49
|
+
mockClassifyIntent: vi.fn(),
|
|
50
|
+
mockSpawnWorker: vi.fn(),
|
|
51
|
+
mockSetActiveSession: vi.fn(),
|
|
52
|
+
mockClearActiveSession: vi.fn(),
|
|
53
|
+
mockEmitDiagnostic: vi.fn(),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
// ── Module mocks (must precede all imports of tested code) ───────
|
|
57
|
+
|
|
58
|
+
vi.mock("../agent/agent.js", () => ({
|
|
59
|
+
runAgent: mockRunAgent,
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
vi.mock("../api/linear-api.js", () => ({
|
|
63
|
+
LinearAgentApi: class MockLinearAgentApi {
|
|
64
|
+
emitActivity = mockEmitActivity;
|
|
65
|
+
createComment = mockCreateComment;
|
|
66
|
+
getIssueDetails = mockGetIssueDetails;
|
|
67
|
+
updateSession = mockUpdateSession;
|
|
68
|
+
getViewerId = mockGetViewerId;
|
|
69
|
+
updateIssue = mockUpdateIssue;
|
|
70
|
+
getTeamLabels = mockGetTeamLabels;
|
|
71
|
+
getTeamStates = mockGetTeamStates;
|
|
72
|
+
createSessionOnIssue = mockCreateSessionOnIssue;
|
|
73
|
+
},
|
|
74
|
+
resolveLinearToken: vi.fn().mockReturnValue({
|
|
75
|
+
accessToken: "test-token",
|
|
76
|
+
source: "env",
|
|
77
|
+
}),
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
vi.mock("../pipeline/pipeline.js", () => ({
|
|
81
|
+
spawnWorker: mockSpawnWorker,
|
|
82
|
+
runPlannerStage: vi.fn().mockResolvedValue("mock plan"),
|
|
83
|
+
runFullPipeline: vi.fn().mockResolvedValue(undefined),
|
|
84
|
+
resumePipeline: vi.fn().mockResolvedValue(undefined),
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
vi.mock("../pipeline/active-session.js", () => ({
|
|
88
|
+
setActiveSession: mockSetActiveSession,
|
|
89
|
+
clearActiveSession: mockClearActiveSession,
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
vi.mock("../infra/observability.js", () => ({
|
|
93
|
+
emitDiagnostic: mockEmitDiagnostic,
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
vi.mock("../pipeline/intent-classify.js", () => ({
|
|
97
|
+
classifyIntent: mockClassifyIntent,
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
vi.mock("../pipeline/dispatch-state.js", () => ({
|
|
101
|
+
readDispatchState: vi.fn().mockResolvedValue({ dispatches: { active: {}, completed: {} }, sessionMap: {} }),
|
|
102
|
+
getActiveDispatch: vi.fn().mockReturnValue(null),
|
|
103
|
+
registerDispatch: vi.fn().mockResolvedValue(undefined),
|
|
104
|
+
updateDispatchStatus: vi.fn().mockResolvedValue(undefined),
|
|
105
|
+
completeDispatch: vi.fn().mockResolvedValue(undefined),
|
|
106
|
+
removeActiveDispatch: vi.fn().mockResolvedValue(undefined),
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
vi.mock("../infra/notify.js", () => ({
|
|
110
|
+
createNotifierFromConfig: vi.fn(() => vi.fn().mockResolvedValue(undefined)),
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
vi.mock("../pipeline/tier-assess.js", () => ({
|
|
114
|
+
assessTier: vi.fn().mockResolvedValue({ tier: "standard", model: "test-model", reasoning: "mock assessment" }),
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
vi.mock("../infra/codex-worktree.js", () => ({
|
|
118
|
+
createWorktree: vi.fn().mockReturnValue({ path: "/tmp/mock-worktree", branch: "codex/ENG-123", resumed: false }),
|
|
119
|
+
createMultiWorktree: vi.fn(),
|
|
120
|
+
prepareWorkspace: vi.fn().mockReturnValue({ pulled: false, submodulesInitialized: false, errors: [] }),
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
vi.mock("../infra/multi-repo.js", () => ({
|
|
124
|
+
resolveRepos: vi.fn().mockReturnValue({ repos: [] }),
|
|
125
|
+
isMultiRepo: vi.fn().mockReturnValue(false),
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
vi.mock("../pipeline/artifacts.js", () => ({
|
|
129
|
+
ensureClawDir: vi.fn(),
|
|
130
|
+
writeManifest: vi.fn(),
|
|
131
|
+
writeDispatchMemory: vi.fn(),
|
|
132
|
+
resolveOrchestratorWorkspace: vi.fn().mockReturnValue("/mock/workspace"),
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
vi.mock("../pipeline/planning-state.js", () => ({
|
|
136
|
+
readPlanningState: vi.fn().mockResolvedValue({ sessions: {} }),
|
|
137
|
+
isInPlanningMode: vi.fn().mockReturnValue(false),
|
|
138
|
+
getPlanningSession: vi.fn().mockReturnValue(null),
|
|
139
|
+
endPlanningSession: vi.fn().mockResolvedValue(undefined),
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
vi.mock("../pipeline/planner.js", () => ({
|
|
143
|
+
initiatePlanningSession: vi.fn().mockResolvedValue(undefined),
|
|
144
|
+
handlePlannerTurn: vi.fn().mockResolvedValue(undefined),
|
|
145
|
+
runPlanAudit: vi.fn().mockResolvedValue(undefined),
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
vi.mock("../pipeline/dag-dispatch.js", () => ({
|
|
149
|
+
startProjectDispatch: vi.fn().mockResolvedValue(undefined),
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
// ── Imports (after mocks) ────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
import { handleLinearWebhook, _resetForTesting } from "../pipeline/webhook.js";
|
|
155
|
+
import {
|
|
156
|
+
makeAgentSessionEventCreated,
|
|
157
|
+
makeAgentSessionEventPrompted,
|
|
158
|
+
makeCommentCreate,
|
|
159
|
+
makeCommentCreateFromBot,
|
|
160
|
+
makeIssueCreate,
|
|
161
|
+
makeIssueUpdateWithAssignment,
|
|
162
|
+
makeAppUserNotification,
|
|
163
|
+
} from "./fixtures/webhook-payloads.js";
|
|
164
|
+
import { makeIssueDetails } from "./fixtures/linear-responses.js";
|
|
165
|
+
|
|
166
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
function createApi(): OpenClawPluginApi {
|
|
169
|
+
return {
|
|
170
|
+
logger: {
|
|
171
|
+
info: vi.fn(),
|
|
172
|
+
warn: vi.fn(),
|
|
173
|
+
error: vi.fn(),
|
|
174
|
+
debug: vi.fn(),
|
|
175
|
+
},
|
|
176
|
+
runtime: {},
|
|
177
|
+
pluginConfig: { defaultAgentId: "mal" },
|
|
178
|
+
} as unknown as OpenClawPluginApi;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function withServer(
|
|
182
|
+
handler: Parameters<typeof createServer>[0],
|
|
183
|
+
fn: (baseUrl: string) => Promise<void>,
|
|
184
|
+
) {
|
|
185
|
+
const server = createServer(handler);
|
|
186
|
+
await new Promise<void>((resolve) => {
|
|
187
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
188
|
+
});
|
|
189
|
+
const address = server.address() as AddressInfo | null;
|
|
190
|
+
if (!address) throw new Error("missing server address");
|
|
191
|
+
try {
|
|
192
|
+
await fn(`http://127.0.0.1:${address.port}`);
|
|
193
|
+
} finally {
|
|
194
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function postWebhook(api: OpenClawPluginApi, payload: unknown) {
|
|
199
|
+
let status = 0;
|
|
200
|
+
let body = "";
|
|
201
|
+
await withServer(
|
|
202
|
+
async (req, res) => {
|
|
203
|
+
await handleLinearWebhook(api, req, res);
|
|
204
|
+
},
|
|
205
|
+
async (baseUrl) => {
|
|
206
|
+
const response = await fetch(`${baseUrl}/linear/webhook`, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "content-type": "application/json" },
|
|
209
|
+
body: JSON.stringify(payload),
|
|
210
|
+
});
|
|
211
|
+
status = response.status;
|
|
212
|
+
body = await response.text();
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
return { status, body };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function infoLogs(api: OpenClawPluginApi): string[] {
|
|
219
|
+
return (api.logger.info as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
220
|
+
(c: unknown[]) => String(c[0]),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function errorLogs(api: OpenClawPluginApi): string[] {
|
|
225
|
+
return (api.logger.error as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
226
|
+
(c: unknown[]) => String(c[0]),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Wait for a mock to be called within a timeout.
|
|
232
|
+
* Used for fire-and-forget `void (async () => {...})()` handlers.
|
|
233
|
+
*/
|
|
234
|
+
async function waitForMock(
|
|
235
|
+
mock: ReturnType<typeof vi.fn>,
|
|
236
|
+
opts?: { timeout?: number; times?: number },
|
|
237
|
+
): Promise<void> {
|
|
238
|
+
const timeout = opts?.timeout ?? 2000;
|
|
239
|
+
const times = opts?.times ?? 1;
|
|
240
|
+
await vi.waitFor(
|
|
241
|
+
() => { expect(mock).toHaveBeenCalledTimes(times); },
|
|
242
|
+
{ timeout, interval: 50 },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Extract emitActivity calls that have a specific type (thought, response, error, action). */
|
|
247
|
+
function activityCallsOfType(type: string): unknown[][] {
|
|
248
|
+
return mockEmitActivity.mock.calls.filter(
|
|
249
|
+
(c: unknown[]) => (c[1] as any)?.type === type,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Setup / Teardown ─────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
beforeEach(() => {
|
|
256
|
+
vi.clearAllMocks();
|
|
257
|
+
_resetForTesting();
|
|
258
|
+
|
|
259
|
+
// Default mock behaviors
|
|
260
|
+
mockGetViewerId.mockResolvedValue("viewer-bot-1");
|
|
261
|
+
mockGetIssueDetails.mockResolvedValue(makeIssueDetails());
|
|
262
|
+
mockCreateComment.mockResolvedValue("comment-new-id");
|
|
263
|
+
mockEmitActivity.mockResolvedValue(undefined);
|
|
264
|
+
mockUpdateSession.mockResolvedValue(undefined);
|
|
265
|
+
mockUpdateIssue.mockResolvedValue(true);
|
|
266
|
+
mockCreateSessionOnIssue.mockResolvedValue({ sessionId: "session-mock-1" });
|
|
267
|
+
mockGetTeamLabels.mockResolvedValue([
|
|
268
|
+
{ id: "label-bug", name: "Bug" },
|
|
269
|
+
{ id: "label-feature", name: "Feature" },
|
|
270
|
+
]);
|
|
271
|
+
mockGetTeamStates.mockResolvedValue([
|
|
272
|
+
{ id: "st-backlog", name: "Backlog", type: "backlog" },
|
|
273
|
+
{ id: "st-started", name: "In Progress", type: "started" },
|
|
274
|
+
{ id: "st-done", name: "Done", type: "completed" },
|
|
275
|
+
{ id: "st-canceled", name: "Canceled", type: "canceled" },
|
|
276
|
+
]);
|
|
277
|
+
mockRunAgent.mockResolvedValue({ success: true, output: "Agent response text" });
|
|
278
|
+
mockSpawnWorker.mockResolvedValue(undefined);
|
|
279
|
+
mockClassifyIntent.mockResolvedValue({
|
|
280
|
+
intent: "general",
|
|
281
|
+
reasoning: "test fallback",
|
|
282
|
+
fromFallback: true,
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
afterEach(() => {
|
|
287
|
+
_resetForTesting();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ── Tests ────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
describe("webhook scenario tests — full handler flows", () => {
|
|
293
|
+
describe("AgentSessionEvent", () => {
|
|
294
|
+
it("created: runs agent, delivers response via emitActivity", async () => {
|
|
295
|
+
const api = createApi();
|
|
296
|
+
const payload = makeAgentSessionEventCreated();
|
|
297
|
+
const result = await postWebhook(api, payload);
|
|
298
|
+
expect(result.status).toBe(200);
|
|
299
|
+
|
|
300
|
+
// Wait for the fire-and-forget handler to complete
|
|
301
|
+
await waitForMock(mockClearActiveSession);
|
|
302
|
+
|
|
303
|
+
// Issue enrichment
|
|
304
|
+
expect(mockGetIssueDetails).toHaveBeenCalledWith("issue-1");
|
|
305
|
+
|
|
306
|
+
// Agent invoked with correct session/message
|
|
307
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
308
|
+
const runArgs = mockRunAgent.mock.calls[0][0];
|
|
309
|
+
expect(runArgs.sessionId).toContain("linear-session-sess-event-1");
|
|
310
|
+
expect(runArgs.message).toContain("ENG-123");
|
|
311
|
+
|
|
312
|
+
// emitActivity called with thought and response
|
|
313
|
+
expect(activityCallsOfType("thought").length).toBeGreaterThan(0);
|
|
314
|
+
expect(activityCallsOfType("response").length).toBeGreaterThan(0);
|
|
315
|
+
|
|
316
|
+
// Response delivered via emitActivity (session-first pattern),
|
|
317
|
+
// NOT via createComment — avoids duplicate visible messages.
|
|
318
|
+
expect(mockCreateComment).not.toHaveBeenCalled();
|
|
319
|
+
|
|
320
|
+
// Session lifecycle
|
|
321
|
+
expect(mockSetActiveSession).toHaveBeenCalledWith(
|
|
322
|
+
expect.objectContaining({ issueId: "issue-1", agentSessionId: "sess-event-1" }),
|
|
323
|
+
);
|
|
324
|
+
expect(mockClearActiveSession).toHaveBeenCalledWith("issue-1");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("created: falls back to createComment when emitActivity fails", async () => {
|
|
328
|
+
// Make the response emitActivity fail — comment is the fallback
|
|
329
|
+
let emitCallCount = 0;
|
|
330
|
+
mockEmitActivity.mockImplementation(async (_sessionId: string, content: any) => {
|
|
331
|
+
emitCallCount++;
|
|
332
|
+
// Let the "thought" emission succeed, but fail the "response" emission
|
|
333
|
+
if (content?.type === "response") {
|
|
334
|
+
throw new Error("session expired");
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const api = createApi();
|
|
339
|
+
const payload = makeAgentSessionEventCreated();
|
|
340
|
+
await postWebhook(api, payload);
|
|
341
|
+
|
|
342
|
+
await waitForMock(mockClearActiveSession);
|
|
343
|
+
|
|
344
|
+
// runAgent was called
|
|
345
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
346
|
+
|
|
347
|
+
// emitActivity(response) failed → fell back to createComment
|
|
348
|
+
expect(mockCreateComment).toHaveBeenCalledOnce();
|
|
349
|
+
const commentBody = mockCreateComment.mock.calls[0][1] as string;
|
|
350
|
+
expect(commentBody).toContain("Agent response text");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("prompted: processes follow-up, delivers via emitActivity", async () => {
|
|
354
|
+
const api = createApi();
|
|
355
|
+
const payload = makeAgentSessionEventPrompted();
|
|
356
|
+
const result = await postWebhook(api, payload);
|
|
357
|
+
expect(result.status).toBe(200);
|
|
358
|
+
|
|
359
|
+
await waitForMock(mockClearActiveSession);
|
|
360
|
+
|
|
361
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
362
|
+
const msg = mockRunAgent.mock.calls[0][0].message;
|
|
363
|
+
expect(msg).toContain("Follow-up question here");
|
|
364
|
+
|
|
365
|
+
// Response via emitActivity, not createComment
|
|
366
|
+
expect(activityCallsOfType("response").length).toBeGreaterThan(0);
|
|
367
|
+
expect(mockCreateComment).not.toHaveBeenCalled();
|
|
368
|
+
expect(mockClearActiveSession).toHaveBeenCalledWith("issue-1");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("created with missing data: logs error, no crash", async () => {
|
|
372
|
+
const api = createApi();
|
|
373
|
+
const payload = {
|
|
374
|
+
type: "AgentSessionEvent",
|
|
375
|
+
action: "created",
|
|
376
|
+
agentSession: { id: null, issue: null },
|
|
377
|
+
previousComments: [],
|
|
378
|
+
};
|
|
379
|
+
const result = await postWebhook(api, payload);
|
|
380
|
+
expect(result.status).toBe(200);
|
|
381
|
+
|
|
382
|
+
const errors = errorLogs(api);
|
|
383
|
+
expect(errors.some((e) => e.includes("missing session or issue"))).toBe(true);
|
|
384
|
+
expect(mockRunAgent).not.toHaveBeenCalled();
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("Comment.create", () => {
|
|
389
|
+
it("ask_agent intent: dispatches to named agent", async () => {
|
|
390
|
+
mockClassifyIntent.mockResolvedValue({
|
|
391
|
+
intent: "ask_agent",
|
|
392
|
+
agentId: "mal",
|
|
393
|
+
reasoning: "user asking for work",
|
|
394
|
+
fromFallback: false,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const api = createApi();
|
|
398
|
+
const payload = makeCommentCreate({
|
|
399
|
+
data: {
|
|
400
|
+
id: "comment-intent-1",
|
|
401
|
+
body: "Can someone look at this issue?",
|
|
402
|
+
user: { id: "user-human", name: "Human" },
|
|
403
|
+
issue: {
|
|
404
|
+
id: "issue-intent-1",
|
|
405
|
+
identifier: "ENG-301",
|
|
406
|
+
title: "Intent test",
|
|
407
|
+
team: { id: "team-1" },
|
|
408
|
+
project: null,
|
|
409
|
+
},
|
|
410
|
+
createdAt: new Date().toISOString(),
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
await postWebhook(api, payload);
|
|
414
|
+
|
|
415
|
+
// Wait for the full dispatch to complete
|
|
416
|
+
await waitForMock(mockClearActiveSession);
|
|
417
|
+
|
|
418
|
+
expect(mockClassifyIntent).toHaveBeenCalledOnce();
|
|
419
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
420
|
+
|
|
421
|
+
// Session created → response via emitActivity
|
|
422
|
+
expect(activityCallsOfType("response").length).toBeGreaterThan(0);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("request_work intent: dispatches to default agent", async () => {
|
|
426
|
+
mockClassifyIntent.mockResolvedValue({
|
|
427
|
+
intent: "request_work",
|
|
428
|
+
reasoning: "user wants implementation",
|
|
429
|
+
fromFallback: false,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const api = createApi();
|
|
433
|
+
const payload = makeCommentCreate({
|
|
434
|
+
data: {
|
|
435
|
+
id: "comment-work-1",
|
|
436
|
+
body: "Please implement the login page",
|
|
437
|
+
user: { id: "user-human", name: "Human" },
|
|
438
|
+
issue: {
|
|
439
|
+
id: "issue-work-1",
|
|
440
|
+
identifier: "ENG-350",
|
|
441
|
+
title: "Login implementation",
|
|
442
|
+
team: { id: "team-1" },
|
|
443
|
+
project: null,
|
|
444
|
+
},
|
|
445
|
+
createdAt: new Date().toISOString(),
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
await postWebhook(api, payload);
|
|
449
|
+
|
|
450
|
+
await waitForMock(mockRunAgent);
|
|
451
|
+
|
|
452
|
+
expect(mockClassifyIntent).toHaveBeenCalledOnce();
|
|
453
|
+
|
|
454
|
+
const logs = infoLogs(api);
|
|
455
|
+
expect(logs.some((l) => l.includes("request_work"))).toBe(true);
|
|
456
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("bot's own comment: skips without running agent", async () => {
|
|
460
|
+
const api = createApi();
|
|
461
|
+
const payload = makeCommentCreateFromBot("viewer-bot-1");
|
|
462
|
+
await postWebhook(api, payload);
|
|
463
|
+
// Small wait for the async getViewerId check
|
|
464
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
465
|
+
|
|
466
|
+
const logs = infoLogs(api);
|
|
467
|
+
expect(logs.some((l) => l.includes("skipping our own comment"))).toBe(true);
|
|
468
|
+
expect(mockRunAgent).not.toHaveBeenCalled();
|
|
469
|
+
expect(mockClassifyIntent).not.toHaveBeenCalled();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("general intent: no action taken, no agent run", async () => {
|
|
473
|
+
const api = createApi();
|
|
474
|
+
const payload = makeCommentCreate({
|
|
475
|
+
data: {
|
|
476
|
+
id: "comment-general-1",
|
|
477
|
+
body: "Thanks for the update",
|
|
478
|
+
user: { id: "user-human", name: "Human" },
|
|
479
|
+
issue: {
|
|
480
|
+
id: "issue-general-1",
|
|
481
|
+
identifier: "ENG-302",
|
|
482
|
+
title: "General test",
|
|
483
|
+
team: { id: "team-1" },
|
|
484
|
+
project: null,
|
|
485
|
+
},
|
|
486
|
+
createdAt: new Date().toISOString(),
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
await postWebhook(api, payload);
|
|
490
|
+
await vi.waitFor(
|
|
491
|
+
() => { expect(mockClassifyIntent).toHaveBeenCalledOnce(); },
|
|
492
|
+
{ timeout: 2000, interval: 50 },
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const logs = infoLogs(api);
|
|
496
|
+
expect(logs.some((l) => l.includes("no action taken"))).toBe(true);
|
|
497
|
+
expect(mockRunAgent).not.toHaveBeenCalled();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("close_issue intent: generates closure report, transitions state, posts comment", async () => {
|
|
501
|
+
mockClassifyIntent.mockResolvedValue({
|
|
502
|
+
intent: "close_issue",
|
|
503
|
+
reasoning: "user wants to close the issue",
|
|
504
|
+
fromFallback: false,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
mockRunAgent.mockResolvedValueOnce({
|
|
508
|
+
success: true,
|
|
509
|
+
output: "**Summary**: Fixed the authentication bug.\n**Resolution**: Updated token refresh logic.",
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const api = createApi();
|
|
513
|
+
const payload = makeCommentCreate({
|
|
514
|
+
data: {
|
|
515
|
+
id: "comment-close-1",
|
|
516
|
+
body: "close this issue",
|
|
517
|
+
user: { id: "user-human", name: "Human" },
|
|
518
|
+
issue: {
|
|
519
|
+
id: "issue-close-1",
|
|
520
|
+
identifier: "ENG-400",
|
|
521
|
+
title: "Auth bug fix",
|
|
522
|
+
team: { id: "team-1" },
|
|
523
|
+
project: null,
|
|
524
|
+
},
|
|
525
|
+
createdAt: new Date().toISOString(),
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
await postWebhook(api, payload);
|
|
529
|
+
|
|
530
|
+
await waitForMock(mockClearActiveSession);
|
|
531
|
+
|
|
532
|
+
// Agent ran with readOnly for closure report
|
|
533
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
534
|
+
const runArgs = mockRunAgent.mock.calls[0][0];
|
|
535
|
+
expect(runArgs.readOnly).toBe(true);
|
|
536
|
+
expect(runArgs.message).toContain("closure report");
|
|
537
|
+
|
|
538
|
+
// Issue state transitioned to completed
|
|
539
|
+
expect(mockUpdateIssue).toHaveBeenCalledWith("issue-close-1", { stateId: "st-done" });
|
|
540
|
+
|
|
541
|
+
// Team states fetched to find completed state
|
|
542
|
+
expect(mockGetTeamStates).toHaveBeenCalledWith("team-1");
|
|
543
|
+
|
|
544
|
+
// Closure report posted via emitActivity
|
|
545
|
+
const responseCalls = activityCallsOfType("response");
|
|
546
|
+
expect(responseCalls.length).toBeGreaterThan(0);
|
|
547
|
+
const reportBody = (responseCalls[0][1] as any).body;
|
|
548
|
+
expect(reportBody).toContain("Closure Report");
|
|
549
|
+
expect(reportBody).toContain("authentication bug");
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
describe("Issue.update", () => {
|
|
554
|
+
it("assignment dispatch: triggers handleDispatch pipeline", async () => {
|
|
555
|
+
// Set viewerId to match the fixture's assigneeId
|
|
556
|
+
mockGetViewerId.mockResolvedValue("viewer-1");
|
|
557
|
+
|
|
558
|
+
const api = createApi();
|
|
559
|
+
const payload = makeIssueUpdateWithAssignment();
|
|
560
|
+
await postWebhook(api, payload);
|
|
561
|
+
|
|
562
|
+
await waitForMock(mockSpawnWorker, { timeout: 3000 });
|
|
563
|
+
expect(mockSpawnWorker).toHaveBeenCalledOnce();
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
describe("Issue.create", () => {
|
|
568
|
+
it("auto-triage: applies estimate, labels, priority from agent output", async () => {
|
|
569
|
+
// Mock getIssueDetails to return issue matching the payload
|
|
570
|
+
mockGetIssueDetails.mockResolvedValue(makeIssueDetails({
|
|
571
|
+
id: "issue-new",
|
|
572
|
+
identifier: "ENG-200",
|
|
573
|
+
title: "New issue",
|
|
574
|
+
}));
|
|
575
|
+
|
|
576
|
+
mockRunAgent.mockResolvedValueOnce({
|
|
577
|
+
success: true,
|
|
578
|
+
output:
|
|
579
|
+
'```json\n{"estimate": 3, "labelIds": ["label-bug"], "priority": 3, "assessment": "Medium complexity"}\n```\n\nThis issue needs moderate work.',
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const api = createApi();
|
|
583
|
+
const payload = makeIssueCreate();
|
|
584
|
+
await postWebhook(api, payload);
|
|
585
|
+
|
|
586
|
+
// Wait for the triage handler to complete
|
|
587
|
+
await waitForMock(mockClearActiveSession);
|
|
588
|
+
|
|
589
|
+
// Issue enrichment + team labels
|
|
590
|
+
expect(mockGetIssueDetails).toHaveBeenCalledWith("issue-new");
|
|
591
|
+
expect(mockGetTeamLabels).toHaveBeenCalled();
|
|
592
|
+
|
|
593
|
+
// Session created for triage
|
|
594
|
+
expect(mockCreateSessionOnIssue).toHaveBeenCalledWith("issue-new");
|
|
595
|
+
|
|
596
|
+
// Agent invoked in read-only mode
|
|
597
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
598
|
+
const runArgs = mockRunAgent.mock.calls[0][0];
|
|
599
|
+
expect(runArgs.readOnly).toBe(true);
|
|
600
|
+
expect(runArgs.message).toContain("ENG-200");
|
|
601
|
+
|
|
602
|
+
// Triage JSON applied to issue
|
|
603
|
+
expect(mockUpdateIssue).toHaveBeenCalledWith(
|
|
604
|
+
"issue-new",
|
|
605
|
+
expect.objectContaining({
|
|
606
|
+
estimate: 3,
|
|
607
|
+
priority: 3,
|
|
608
|
+
}),
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
// Response delivered via emitActivity (session exists)
|
|
612
|
+
expect(activityCallsOfType("response").length).toBeGreaterThan(0);
|
|
613
|
+
expect(mockClearActiveSession).toHaveBeenCalledWith("issue-new");
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
describe("AppUserNotification", () => {
|
|
618
|
+
it("ignored: returns 200 with no API calls", async () => {
|
|
619
|
+
const api = createApi();
|
|
620
|
+
const result = await postWebhook(api, makeAppUserNotification());
|
|
621
|
+
expect(result.status).toBe(200);
|
|
622
|
+
|
|
623
|
+
const logs = infoLogs(api);
|
|
624
|
+
expect(logs.some((l) => l.includes("AppUserNotification ignored"))).toBe(true);
|
|
625
|
+
|
|
626
|
+
expect(mockRunAgent).not.toHaveBeenCalled();
|
|
627
|
+
expect(mockCreateComment).not.toHaveBeenCalled();
|
|
628
|
+
expect(mockGetIssueDetails).not.toHaveBeenCalled();
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
});
|