@desplega.ai/agent-swarm 1.70.0 → 1.71.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.json +226 -1
- package/package.json +1 -1
- package/src/be/db-queries/oauth.ts +45 -15
- package/src/be/db-queries/tracker.ts +109 -0
- package/src/be/migrations/043_jira_source.sql +128 -0
- package/src/commands/runner.ts +7 -2
- package/src/http/core.ts +6 -21
- package/src/http/index.ts +9 -1
- package/src/http/route-def.ts +19 -0
- package/src/http/trackers/index.ts +13 -0
- package/src/http/trackers/jira.ts +395 -0
- package/src/http/trackers/linear.ts +47 -4
- package/src/http/utils.ts +27 -0
- package/src/jira/adf.ts +132 -0
- package/src/jira/app.ts +83 -0
- package/src/jira/client.ts +82 -0
- package/src/jira/index.ts +24 -0
- package/src/jira/metadata.ts +117 -0
- package/src/jira/oauth.ts +98 -0
- package/src/jira/outbound.ts +155 -0
- package/src/jira/sync.ts +534 -0
- package/src/jira/templates.ts +84 -0
- package/src/jira/types.ts +35 -0
- package/src/jira/webhook-lifecycle.ts +363 -0
- package/src/jira/webhook.ts +159 -0
- package/src/linear/app.ts +17 -0
- package/src/linear/oauth.ts +24 -0
- package/src/oauth/wrapper.ts +11 -1
- package/src/tasks/context-key.ts +29 -1
- package/src/telemetry.ts +38 -3
- package/src/tests/context-key.test.ts +19 -0
- package/src/tests/jira-adf.test.ts +239 -0
- package/src/tests/jira-metadata.test.ts +147 -0
- package/src/tests/jira-oauth.test.ts +167 -0
- package/src/tests/jira-outbound-sync.test.ts +334 -0
- package/src/tests/jira-sync.test.ts +327 -0
- package/src/tests/jira-webhook-lifecycle.test.ts +234 -0
- package/src/tests/jira-webhook.test.ts +274 -0
- package/src/tests/telemetry-init.test.ts +108 -0
- package/src/tools/tracker/tracker-link-task.ts +1 -1
- package/src/tools/tracker/tracker-map-agent.ts +1 -1
- package/src/tools/tracker/tracker-status.ts +1 -1
- package/src/tools/tracker/tracker-sync-status.ts +1 -1
- package/src/tracker/types.ts +1 -1
- package/src/types.ts +1 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, initDb } from "../be/db";
|
|
4
|
+
import { createTrackerSync, getTrackerSync, updateTrackerSync } from "../be/db-queries/tracker";
|
|
5
|
+
import { initJiraOutboundSync, teardownJiraOutboundSync } from "../jira/outbound";
|
|
6
|
+
import { workflowEventBus } from "../workflows/event-bus";
|
|
7
|
+
|
|
8
|
+
const TEST_DB_PATH = "./test-jira-outbound-sync.sqlite";
|
|
9
|
+
|
|
10
|
+
// Capture every jiraFetch call so we can assert on path/body shape and emoji
|
|
11
|
+
// rendering without hitting the network.
|
|
12
|
+
const mockJiraFetch = mock(
|
|
13
|
+
() =>
|
|
14
|
+
Promise.resolve(
|
|
15
|
+
new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }),
|
|
16
|
+
) as Promise<Response>,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
mock.module("../jira/client", () => ({
|
|
20
|
+
jiraFetch: mockJiraFetch,
|
|
21
|
+
// The outbound module only imports `jiraFetch`; stub the others for
|
|
22
|
+
// robustness in case the module surface grows.
|
|
23
|
+
getJiraAccessToken: () => Promise.resolve("test-token"),
|
|
24
|
+
getJiraCloudId: () => "test-cloud-id",
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
initDb(TEST_DB_PATH);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(async () => {
|
|
32
|
+
closeDb();
|
|
33
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
34
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
35
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("Jira Outbound Sync", () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
mockJiraFetch.mockClear();
|
|
41
|
+
initJiraOutboundSync();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
teardownJiraOutboundSync();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("init/teardown is idempotent — double init/teardown does not double-fire", async () => {
|
|
49
|
+
// Already inited in beforeEach, init again — should be no-op.
|
|
50
|
+
initJiraOutboundSync();
|
|
51
|
+
|
|
52
|
+
createTrackerSync({
|
|
53
|
+
provider: "jira",
|
|
54
|
+
entityType: "task",
|
|
55
|
+
swarmId: "jira-out-idempotent",
|
|
56
|
+
externalId: "10001",
|
|
57
|
+
externalIdentifier: "KAN-1",
|
|
58
|
+
syncDirection: "bidirectional",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
workflowEventBus.emit("task.cancelled", { taskId: "jira-out-idempotent" });
|
|
62
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
63
|
+
|
|
64
|
+
// Should be exactly 1 call, not 2 — proving init didn't re-subscribe.
|
|
65
|
+
expect(mockJiraFetch).toHaveBeenCalledTimes(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("task.created posts plaintext comment with rocket emoji + summary", async () => {
|
|
69
|
+
createTrackerSync({
|
|
70
|
+
provider: "jira",
|
|
71
|
+
entityType: "task",
|
|
72
|
+
swarmId: "jira-out-created",
|
|
73
|
+
externalId: "10002",
|
|
74
|
+
externalIdentifier: "KAN-2",
|
|
75
|
+
syncDirection: "bidirectional",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
workflowEventBus.emit("task.created", {
|
|
79
|
+
taskId: "jira-out-created",
|
|
80
|
+
task: "Investigate flaky test",
|
|
81
|
+
});
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
83
|
+
|
|
84
|
+
expect(mockJiraFetch).toHaveBeenCalledTimes(1);
|
|
85
|
+
const [path, init] = mockJiraFetch.mock.calls[0] as [string, RequestInit];
|
|
86
|
+
expect(path).toBe("/rest/api/2/issue/KAN-2/comment");
|
|
87
|
+
expect(init.method).toBe("POST");
|
|
88
|
+
const parsed = JSON.parse(init.body as string) as { body: string };
|
|
89
|
+
expect(parsed.body).toBe("🚀 Swarm task started: Investigate flaky test");
|
|
90
|
+
// Guard against shortcode regression — Jira REST v2 plaintext does not
|
|
91
|
+
// expand `:rocket:` style.
|
|
92
|
+
expect(parsed.body).not.toContain(":rocket:");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("task.completed truncates output to 4000 chars and ellipsizes", async () => {
|
|
96
|
+
createTrackerSync({
|
|
97
|
+
provider: "jira",
|
|
98
|
+
entityType: "task",
|
|
99
|
+
swarmId: "jira-out-completed-long",
|
|
100
|
+
externalId: "10003",
|
|
101
|
+
externalIdentifier: "KAN-3",
|
|
102
|
+
syncDirection: "bidirectional",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const longOutput = "x".repeat(5000);
|
|
106
|
+
workflowEventBus.emit("task.completed", {
|
|
107
|
+
taskId: "jira-out-completed-long",
|
|
108
|
+
output: longOutput,
|
|
109
|
+
});
|
|
110
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
111
|
+
|
|
112
|
+
expect(mockJiraFetch).toHaveBeenCalledTimes(1);
|
|
113
|
+
const [, init] = mockJiraFetch.mock.calls[0] as [string, RequestInit];
|
|
114
|
+
const parsed = JSON.parse(init.body as string) as { body: string };
|
|
115
|
+
expect(parsed.body.startsWith("✅ Swarm task completed.\n\n")).toBe(true);
|
|
116
|
+
expect(parsed.body).toContain("…"); // ellipsis suffix
|
|
117
|
+
// Body = "✅ Swarm task completed.\n\n" + 4000x + "…" — no shortcodes.
|
|
118
|
+
expect(parsed.body).not.toContain(":white_check_mark:");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("task.completed without output uses bare completion message", async () => {
|
|
122
|
+
createTrackerSync({
|
|
123
|
+
provider: "jira",
|
|
124
|
+
entityType: "task",
|
|
125
|
+
swarmId: "jira-out-completed-empty",
|
|
126
|
+
externalId: "10004",
|
|
127
|
+
externalIdentifier: "KAN-4",
|
|
128
|
+
syncDirection: "bidirectional",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
workflowEventBus.emit("task.completed", { taskId: "jira-out-completed-empty" });
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
133
|
+
|
|
134
|
+
const [, init] = mockJiraFetch.mock.calls[0] as [string, RequestInit];
|
|
135
|
+
const parsed = JSON.parse(init.body as string) as { body: string };
|
|
136
|
+
expect(parsed.body).toBe("✅ Swarm task completed.");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("task.failed renders cross emoji + reason; falls back when reason missing", async () => {
|
|
140
|
+
createTrackerSync({
|
|
141
|
+
provider: "jira",
|
|
142
|
+
entityType: "task",
|
|
143
|
+
swarmId: "jira-out-failed-with-reason",
|
|
144
|
+
externalId: "10005",
|
|
145
|
+
externalIdentifier: "KAN-5",
|
|
146
|
+
syncDirection: "bidirectional",
|
|
147
|
+
});
|
|
148
|
+
createTrackerSync({
|
|
149
|
+
provider: "jira",
|
|
150
|
+
entityType: "task",
|
|
151
|
+
swarmId: "jira-out-failed-no-reason",
|
|
152
|
+
externalId: "10006",
|
|
153
|
+
externalIdentifier: "KAN-6",
|
|
154
|
+
syncDirection: "bidirectional",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
workflowEventBus.emit("task.failed", {
|
|
158
|
+
taskId: "jira-out-failed-with-reason",
|
|
159
|
+
failureReason: "Build broke on line 42",
|
|
160
|
+
});
|
|
161
|
+
workflowEventBus.emit("task.failed", { taskId: "jira-out-failed-no-reason" });
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
163
|
+
|
|
164
|
+
expect(mockJiraFetch).toHaveBeenCalledTimes(2);
|
|
165
|
+
const calls = mockJiraFetch.mock.calls as [string, RequestInit][];
|
|
166
|
+
const bodies = calls.map(([, init]) => JSON.parse(init.body as string).body as string);
|
|
167
|
+
|
|
168
|
+
expect(bodies).toContain("❌ Swarm task failed.\n\nBuild broke on line 42");
|
|
169
|
+
expect(bodies).toContain("❌ Swarm task failed.\n\n(no failure reason recorded)");
|
|
170
|
+
// No shortcode regression.
|
|
171
|
+
for (const b of bodies) {
|
|
172
|
+
expect(b).not.toContain(":x:");
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("task.cancelled posts stop emoji message", async () => {
|
|
177
|
+
createTrackerSync({
|
|
178
|
+
provider: "jira",
|
|
179
|
+
entityType: "task",
|
|
180
|
+
swarmId: "jira-out-cancelled",
|
|
181
|
+
externalId: "10007",
|
|
182
|
+
externalIdentifier: "KAN-7",
|
|
183
|
+
syncDirection: "bidirectional",
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
workflowEventBus.emit("task.cancelled", { taskId: "jira-out-cancelled" });
|
|
187
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
188
|
+
|
|
189
|
+
const [, init] = mockJiraFetch.mock.calls[0] as [string, RequestInit];
|
|
190
|
+
const parsed = JSON.parse(init.body as string) as { body: string };
|
|
191
|
+
expect(parsed.body).toBe("⛔ Swarm task cancelled.");
|
|
192
|
+
expect(parsed.body).not.toContain(":no_entry:");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("flips lastSyncOrigin → 'swarm' on successful post", async () => {
|
|
196
|
+
createTrackerSync({
|
|
197
|
+
provider: "jira",
|
|
198
|
+
entityType: "task",
|
|
199
|
+
swarmId: "jira-out-origin-flip",
|
|
200
|
+
externalId: "10008",
|
|
201
|
+
externalIdentifier: "KAN-8",
|
|
202
|
+
syncDirection: "bidirectional",
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
workflowEventBus.emit("task.completed", {
|
|
206
|
+
taskId: "jira-out-origin-flip",
|
|
207
|
+
output: "ok",
|
|
208
|
+
});
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
210
|
+
|
|
211
|
+
const updated = getTrackerSync("jira", "task", "jira-out-origin-flip");
|
|
212
|
+
expect(updated?.lastSyncOrigin).toBe("swarm");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("no-op when no tracker_sync row exists for the task", async () => {
|
|
216
|
+
workflowEventBus.emit("task.completed", {
|
|
217
|
+
taskId: "jira-out-no-sync-row",
|
|
218
|
+
output: "phantom",
|
|
219
|
+
});
|
|
220
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
221
|
+
|
|
222
|
+
expect(mockJiraFetch).not.toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("loop prevention: skips when lastSyncOrigin='external' within 5s", async () => {
|
|
226
|
+
const sync = createTrackerSync({
|
|
227
|
+
provider: "jira",
|
|
228
|
+
entityType: "task",
|
|
229
|
+
swarmId: "jira-out-loop",
|
|
230
|
+
externalId: "10009",
|
|
231
|
+
externalIdentifier: "KAN-9",
|
|
232
|
+
syncDirection: "bidirectional",
|
|
233
|
+
});
|
|
234
|
+
updateTrackerSync(sync.id, {
|
|
235
|
+
lastSyncOrigin: "external",
|
|
236
|
+
lastSyncedAt: new Date().toISOString(),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
workflowEventBus.emit("task.completed", {
|
|
240
|
+
taskId: "jira-out-loop",
|
|
241
|
+
output: "should-skip",
|
|
242
|
+
});
|
|
243
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
244
|
+
|
|
245
|
+
expect(mockJiraFetch).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("allows sync when lastSyncOrigin='external' but older than 5s", async () => {
|
|
249
|
+
const sync = createTrackerSync({
|
|
250
|
+
provider: "jira",
|
|
251
|
+
entityType: "task",
|
|
252
|
+
swarmId: "jira-out-loop-old",
|
|
253
|
+
externalId: "10010",
|
|
254
|
+
externalIdentifier: "KAN-10",
|
|
255
|
+
syncDirection: "bidirectional",
|
|
256
|
+
});
|
|
257
|
+
updateTrackerSync(sync.id, {
|
|
258
|
+
lastSyncOrigin: "external",
|
|
259
|
+
lastSyncedAt: new Date(Date.now() - 10_000).toISOString(),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
workflowEventBus.emit("task.completed", {
|
|
263
|
+
taskId: "jira-out-loop-old",
|
|
264
|
+
output: "should-fire",
|
|
265
|
+
});
|
|
266
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
267
|
+
|
|
268
|
+
expect(mockJiraFetch).toHaveBeenCalledTimes(1);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("falls back to externalId when externalIdentifier is null", async () => {
|
|
272
|
+
createTrackerSync({
|
|
273
|
+
provider: "jira",
|
|
274
|
+
entityType: "task",
|
|
275
|
+
swarmId: "jira-out-no-key",
|
|
276
|
+
externalId: "10011",
|
|
277
|
+
externalIdentifier: null,
|
|
278
|
+
syncDirection: "bidirectional",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
workflowEventBus.emit("task.cancelled", { taskId: "jira-out-no-key" });
|
|
282
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
283
|
+
|
|
284
|
+
const [path] = mockJiraFetch.mock.calls[0] as [string, RequestInit];
|
|
285
|
+
expect(path).toBe("/rest/api/2/issue/10011/comment");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("teardown removes listeners — events fire no posts after teardown", async () => {
|
|
289
|
+
teardownJiraOutboundSync();
|
|
290
|
+
createTrackerSync({
|
|
291
|
+
provider: "jira",
|
|
292
|
+
entityType: "task",
|
|
293
|
+
swarmId: "jira-out-teardown",
|
|
294
|
+
externalId: "10012",
|
|
295
|
+
externalIdentifier: "KAN-12",
|
|
296
|
+
syncDirection: "bidirectional",
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
workflowEventBus.emit("task.completed", {
|
|
300
|
+
taskId: "jira-out-teardown",
|
|
301
|
+
output: "ignored",
|
|
302
|
+
});
|
|
303
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
304
|
+
|
|
305
|
+
expect(mockJiraFetch).not.toHaveBeenCalled();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("does NOT flip lastSyncOrigin on HTTP error from Jira", async () => {
|
|
309
|
+
const sync = createTrackerSync({
|
|
310
|
+
provider: "jira",
|
|
311
|
+
entityType: "task",
|
|
312
|
+
swarmId: "jira-out-error",
|
|
313
|
+
externalId: "10013",
|
|
314
|
+
externalIdentifier: "KAN-13",
|
|
315
|
+
syncDirection: "bidirectional",
|
|
316
|
+
});
|
|
317
|
+
updateTrackerSync(sync.id, {
|
|
318
|
+
lastSyncOrigin: "external",
|
|
319
|
+
lastSyncedAt: new Date(Date.now() - 10_000).toISOString(), // outside loop window
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
mockJiraFetch.mockImplementationOnce(
|
|
323
|
+
() => Promise.resolve(new Response("forbidden", { status: 403 })) as Promise<Response>,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
workflowEventBus.emit("task.cancelled", { taskId: "jira-out-error" });
|
|
327
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
328
|
+
|
|
329
|
+
expect(mockJiraFetch).toHaveBeenCalledTimes(1);
|
|
330
|
+
const after = getTrackerSync("jira", "task", "jira-out-error");
|
|
331
|
+
// Origin should remain 'external' — we did not write swarm-origin on failed POST.
|
|
332
|
+
expect(after?.lastSyncOrigin).toBe("external");
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, completeTask, createAgent, getDb, getTaskById, initDb } from "../be/db";
|
|
4
|
+
import { upsertOAuthApp } from "../be/db-queries/oauth";
|
|
5
|
+
import {
|
|
6
|
+
createTrackerSync,
|
|
7
|
+
createTrackerSyncIfAbsent,
|
|
8
|
+
getTrackerSyncByExternalId,
|
|
9
|
+
updateTrackerSync,
|
|
10
|
+
} from "../be/db-queries/tracker";
|
|
11
|
+
|
|
12
|
+
const TEST_DB_PATH = "./test-jira-sync.sqlite";
|
|
13
|
+
const BOT_ACCOUNT_ID = "bot-account-12345";
|
|
14
|
+
const SITE_URL = "https://example.atlassian.net";
|
|
15
|
+
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
initDb(TEST_DB_PATH);
|
|
18
|
+
// Seed an oauth_apps row + cloudId/siteUrl so URL helpers don't blow up
|
|
19
|
+
upsertOAuthApp("jira", {
|
|
20
|
+
clientId: "client-id",
|
|
21
|
+
clientSecret: "client-secret",
|
|
22
|
+
authorizeUrl: "https://auth.atlassian.com/authorize",
|
|
23
|
+
tokenUrl: "https://auth.atlassian.com/oauth/token",
|
|
24
|
+
redirectUri: "http://localhost:3013/api/trackers/jira/callback",
|
|
25
|
+
scopes: "read:jira-work,write:jira-work,manage:jira-webhook,offline_access,read:me",
|
|
26
|
+
metadata: JSON.stringify({ cloudId: "cloud-1", siteUrl: SITE_URL }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Seed a lead agent so task creation has a target.
|
|
30
|
+
createAgent({
|
|
31
|
+
name: "lead-1",
|
|
32
|
+
isLead: true,
|
|
33
|
+
status: "idle",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterAll(async () => {
|
|
38
|
+
closeDb();
|
|
39
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
40
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
41
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Import sync handlers AFTER seeding so module-level side effects (template
|
|
45
|
+
// registration) see a healthy DB.
|
|
46
|
+
const { _setBotAccountIdForTesting, handleCommentEvent, handleIssueEvent } = await import(
|
|
47
|
+
"../jira/sync"
|
|
48
|
+
);
|
|
49
|
+
const { getTemplateDefinition } = await import("../prompts/registry");
|
|
50
|
+
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
// Reset tracker_sync rows + tasks each test
|
|
53
|
+
getDb().query("DELETE FROM tracker_sync").run();
|
|
54
|
+
getDb().query("DELETE FROM agent_tasks").run();
|
|
55
|
+
_setBotAccountIdForTesting(BOT_ACCOUNT_ID);
|
|
56
|
+
// Re-register Jira templates if a parallel test file has cleared the registry.
|
|
57
|
+
if (!getTemplateDefinition("jira.issue.assigned")) {
|
|
58
|
+
await import(`../jira/templates?t=${Date.now()}`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function makeIssueAssignedEvent(issueId: string, issueKey: string, summary = "An issue") {
|
|
65
|
+
return {
|
|
66
|
+
webhookEvent: "jira:issue_updated",
|
|
67
|
+
issue: {
|
|
68
|
+
id: issueId,
|
|
69
|
+
key: issueKey,
|
|
70
|
+
fields: {
|
|
71
|
+
summary,
|
|
72
|
+
description: { type: "doc", content: [] },
|
|
73
|
+
reporter: { displayName: "Reporter Name", accountId: "reporter-1" },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
changelog: {
|
|
77
|
+
items: [{ field: "assignee", fieldId: "assignee", from: null, to: BOT_ACCOUNT_ID }],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeCommentEvent(
|
|
83
|
+
issueId: string,
|
|
84
|
+
issueKey: string,
|
|
85
|
+
authorAccountId: string,
|
|
86
|
+
bodyText: string,
|
|
87
|
+
mentionAccountIds: string[] = [],
|
|
88
|
+
) {
|
|
89
|
+
const content: unknown[] = [{ type: "text", text: bodyText }];
|
|
90
|
+
for (const id of mentionAccountIds) {
|
|
91
|
+
content.push({ type: "mention", attrs: { id, text: `@User ${id}` } });
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
webhookEvent: "comment_created",
|
|
95
|
+
issue: {
|
|
96
|
+
id: issueId,
|
|
97
|
+
key: issueKey,
|
|
98
|
+
fields: {
|
|
99
|
+
summary: "Issue with comments",
|
|
100
|
+
description: { type: "doc", content: [] },
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
comment: {
|
|
104
|
+
id: "c-1",
|
|
105
|
+
body: { type: "doc", content: [{ type: "paragraph", content }] },
|
|
106
|
+
author: { accountId: authorAccountId, displayName: "Some User" },
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
describe("handleIssueEvent — assignee→bot", () => {
|
|
114
|
+
test("creates fresh task + tracker_sync when no prior row exists", async () => {
|
|
115
|
+
await handleIssueEvent(makeIssueAssignedEvent("10001", "KAN-1", "Add feature"));
|
|
116
|
+
|
|
117
|
+
const sync = getTrackerSyncByExternalId("jira", "task", "10001");
|
|
118
|
+
expect(sync).not.toBeNull();
|
|
119
|
+
expect(sync?.externalIdentifier).toBe("KAN-1");
|
|
120
|
+
expect(sync?.lastSyncOrigin).toBe("external");
|
|
121
|
+
expect(sync?.swarmId).toBeTruthy();
|
|
122
|
+
|
|
123
|
+
const task = getTaskById(sync?.swarmId ?? "");
|
|
124
|
+
expect(task).not.toBeNull();
|
|
125
|
+
expect(task?.source).toBe("jira");
|
|
126
|
+
expect(task?.taskType).toBe("jira-issue");
|
|
127
|
+
expect(task?.task).toContain("KAN-1");
|
|
128
|
+
expect(task?.task).toContain("Add feature");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("ignores transitions away from bot (FROM=bot, TO=other)", async () => {
|
|
132
|
+
const event = makeIssueAssignedEvent("10002", "KAN-2");
|
|
133
|
+
event.changelog.items[0] = {
|
|
134
|
+
field: "assignee",
|
|
135
|
+
fieldId: "assignee",
|
|
136
|
+
from: BOT_ACCOUNT_ID,
|
|
137
|
+
to: "someone-else",
|
|
138
|
+
};
|
|
139
|
+
await handleIssueEvent(event);
|
|
140
|
+
|
|
141
|
+
expect(getTrackerSyncByExternalId("jira", "task", "10002")).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("UNIQUE-gates concurrent inserts (second call no-ops)", async () => {
|
|
145
|
+
// Prime: create the sync row first as if a previous identical event already won.
|
|
146
|
+
createTrackerSync({
|
|
147
|
+
provider: "jira",
|
|
148
|
+
entityType: "task",
|
|
149
|
+
swarmId: "",
|
|
150
|
+
externalId: "10003",
|
|
151
|
+
externalIdentifier: "KAN-3",
|
|
152
|
+
lastSyncOrigin: "external",
|
|
153
|
+
syncDirection: "inbound",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Now run handler. With prior row + no swarmId, the handler should treat
|
|
157
|
+
// the prior task as orphan/terminal and create a follow-up task.
|
|
158
|
+
await handleIssueEvent(makeIssueAssignedEvent("10003", "KAN-3"));
|
|
159
|
+
|
|
160
|
+
// Should only have ONE sync row (UNIQUE-gated insert).
|
|
161
|
+
const rows = getDb()
|
|
162
|
+
.query("SELECT COUNT(*) AS c FROM tracker_sync WHERE externalId = '10003'")
|
|
163
|
+
.get() as { c: number };
|
|
164
|
+
expect(rows.c).toBe(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("re-assignment with active prior task is ignored (no duplicate task)", async () => {
|
|
168
|
+
// First assignment creates the task.
|
|
169
|
+
await handleIssueEvent(makeIssueAssignedEvent("10004", "KAN-4"));
|
|
170
|
+
const beforeRow = getTrackerSyncByExternalId("jira", "task", "10004");
|
|
171
|
+
expect(beforeRow?.swarmId).toBeTruthy();
|
|
172
|
+
const firstTaskId = beforeRow?.swarmId;
|
|
173
|
+
|
|
174
|
+
// Re-assign while task is still active (not completed/failed/cancelled).
|
|
175
|
+
await handleIssueEvent(makeIssueAssignedEvent("10004", "KAN-4"));
|
|
176
|
+
|
|
177
|
+
const afterRow = getTrackerSyncByExternalId("jira", "task", "10004");
|
|
178
|
+
expect(afterRow?.swarmId).toBe(firstTaskId ?? "");
|
|
179
|
+
// Task count should still be 1
|
|
180
|
+
const taskCount = getDb().query("SELECT COUNT(*) AS c FROM agent_tasks").get() as { c: number };
|
|
181
|
+
expect(taskCount.c).toBe(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("re-assignment after task completion creates follow-up task", async () => {
|
|
185
|
+
await handleIssueEvent(makeIssueAssignedEvent("10005", "KAN-5"));
|
|
186
|
+
const firstSync = getTrackerSyncByExternalId("jira", "task", "10005");
|
|
187
|
+
const firstTaskId = firstSync?.swarmId;
|
|
188
|
+
expect(firstTaskId).toBeTruthy();
|
|
189
|
+
|
|
190
|
+
// Mark first task as completed
|
|
191
|
+
if (firstTaskId) {
|
|
192
|
+
completeTask(firstTaskId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Re-assign — should create a follow-up via jira.issue.followup template
|
|
196
|
+
await handleIssueEvent(makeIssueAssignedEvent("10005", "KAN-5"));
|
|
197
|
+
|
|
198
|
+
// We should now have 2 tasks for this externalId chain. Note tracker_sync's
|
|
199
|
+
// swarmId points at the most-recent task.
|
|
200
|
+
const taskCount = getDb().query("SELECT COUNT(*) AS c FROM agent_tasks").get() as { c: number };
|
|
201
|
+
expect(taskCount.c).toBe(2);
|
|
202
|
+
const afterSync = getTrackerSyncByExternalId("jira", "task", "10005");
|
|
203
|
+
expect(afterSync?.swarmId).not.toBe(firstTaskId ?? "");
|
|
204
|
+
const followupTask = getTaskById(afterSync?.swarmId ?? "");
|
|
205
|
+
expect(followupTask?.task).toContain("Follow-up");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("handleCommentEvent — short-circuits", () => {
|
|
210
|
+
test("self-authored comment is skipped (no task created)", async () => {
|
|
211
|
+
// Prime an empty inbox: no prior sync row.
|
|
212
|
+
await handleCommentEvent(
|
|
213
|
+
makeCommentEvent("10010", "KAN-10", BOT_ACCOUNT_ID, "Ping", [BOT_ACCOUNT_ID]),
|
|
214
|
+
);
|
|
215
|
+
expect(getTrackerSyncByExternalId("jira", "task", "10010")).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("outbound-echo skip: lastSyncOrigin='swarm' within 5s short-circuits", async () => {
|
|
219
|
+
// Pre-create a tracker_sync row marked as swarm-origin with a fresh
|
|
220
|
+
// lastSyncedAt so the 5s echo window is active.
|
|
221
|
+
const sync = createTrackerSync({
|
|
222
|
+
provider: "jira",
|
|
223
|
+
entityType: "task",
|
|
224
|
+
swarmId: "task-existing",
|
|
225
|
+
externalId: "10011",
|
|
226
|
+
externalIdentifier: "KAN-11",
|
|
227
|
+
lastSyncOrigin: "swarm",
|
|
228
|
+
syncDirection: "bidirectional",
|
|
229
|
+
});
|
|
230
|
+
updateTrackerSync(sync.id, {
|
|
231
|
+
lastSyncOrigin: "swarm",
|
|
232
|
+
lastSyncedAt: new Date().toISOString(),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// External user mentions bot → should be skipped because of 5s window.
|
|
236
|
+
await handleCommentEvent(
|
|
237
|
+
makeCommentEvent("10011", "KAN-11", "user-other", "ping", [BOT_ACCOUNT_ID]),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const taskCount = getDb().query("SELECT COUNT(*) AS c FROM agent_tasks").get() as { c: number };
|
|
241
|
+
expect(taskCount.c).toBe(0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("outbound-echo skip ages out after 6s — comment now creates a task", async () => {
|
|
245
|
+
const sync = createTrackerSync({
|
|
246
|
+
provider: "jira",
|
|
247
|
+
entityType: "task",
|
|
248
|
+
swarmId: "", // orphan → followup branch
|
|
249
|
+
externalId: "10012",
|
|
250
|
+
externalIdentifier: "KAN-12",
|
|
251
|
+
lastSyncOrigin: "swarm",
|
|
252
|
+
syncDirection: "bidirectional",
|
|
253
|
+
});
|
|
254
|
+
// 6s ago — outside the 5s window.
|
|
255
|
+
updateTrackerSync(sync.id, {
|
|
256
|
+
lastSyncOrigin: "swarm",
|
|
257
|
+
lastSyncedAt: new Date(Date.now() - 6_000).toISOString(),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await handleCommentEvent(
|
|
261
|
+
makeCommentEvent("10012", "KAN-12", "user-other", "ping", [BOT_ACCOUNT_ID]),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const taskCount = getDb()
|
|
265
|
+
.query("SELECT COUNT(*) AS c FROM agent_tasks WHERE source = 'jira'")
|
|
266
|
+
.get() as { c: number };
|
|
267
|
+
expect(taskCount.c).toBe(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("comment without bot mention is ignored", async () => {
|
|
271
|
+
await handleCommentEvent(
|
|
272
|
+
makeCommentEvent("10013", "KAN-13", "user-other", "no mention here", []),
|
|
273
|
+
);
|
|
274
|
+
expect(getTrackerSyncByExternalId("jira", "task", "10013")).toBeNull();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("bot-mention with no prior sync creates fresh comment-mention task", async () => {
|
|
278
|
+
await handleCommentEvent(
|
|
279
|
+
makeCommentEvent("10014", "KAN-14", "user-other", "Hey ", [BOT_ACCOUNT_ID]),
|
|
280
|
+
);
|
|
281
|
+
const sync = getTrackerSyncByExternalId("jira", "task", "10014");
|
|
282
|
+
expect(sync).not.toBeNull();
|
|
283
|
+
const task = getTaskById(sync?.swarmId ?? "");
|
|
284
|
+
expect(task?.source).toBe("jira");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("bot-mention triggers follow-up on completed prior task", async () => {
|
|
288
|
+
// Establish prior task via assignee path
|
|
289
|
+
await handleIssueEvent(makeIssueAssignedEvent("10015", "KAN-15"));
|
|
290
|
+
const sync = getTrackerSyncByExternalId("jira", "task", "10015");
|
|
291
|
+
const firstTaskId = sync?.swarmId;
|
|
292
|
+
expect(firstTaskId).toBeTruthy();
|
|
293
|
+
if (firstTaskId) completeTask(firstTaskId);
|
|
294
|
+
|
|
295
|
+
await handleCommentEvent(
|
|
296
|
+
makeCommentEvent("10015", "KAN-15", "user-other", "follow-up please", [BOT_ACCOUNT_ID]),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const taskCount = getDb()
|
|
300
|
+
.query("SELECT COUNT(*) AS c FROM agent_tasks WHERE source = 'jira'")
|
|
301
|
+
.get() as { c: number };
|
|
302
|
+
expect(taskCount.c).toBe(2);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("createTrackerSyncIfAbsent — UNIQUE-gated insert", () => {
|
|
307
|
+
test("first call inserts; second call returns existing", () => {
|
|
308
|
+
const first = createTrackerSyncIfAbsent({
|
|
309
|
+
provider: "jira",
|
|
310
|
+
entityType: "task",
|
|
311
|
+
swarmId: "",
|
|
312
|
+
externalId: "10020",
|
|
313
|
+
externalIdentifier: "KAN-20",
|
|
314
|
+
});
|
|
315
|
+
expect(first.inserted).toBe(true);
|
|
316
|
+
|
|
317
|
+
const second = createTrackerSyncIfAbsent({
|
|
318
|
+
provider: "jira",
|
|
319
|
+
entityType: "task",
|
|
320
|
+
swarmId: "",
|
|
321
|
+
externalId: "10020",
|
|
322
|
+
externalIdentifier: "KAN-20",
|
|
323
|
+
});
|
|
324
|
+
expect(second.inserted).toBe(false);
|
|
325
|
+
expect(second.sync.id).toBe(first.sync.id);
|
|
326
|
+
});
|
|
327
|
+
});
|