@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,234 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, getDb, initDb } from "../be/db";
|
|
4
|
+
import { upsertOAuthApp } from "../be/db-queries/oauth";
|
|
5
|
+
import { getJiraMetadata, updateJiraMetadata } from "../jira/metadata";
|
|
6
|
+
|
|
7
|
+
const TEST_DB_PATH = "./test-jira-webhook-lifecycle.sqlite";
|
|
8
|
+
|
|
9
|
+
// Mock the Jira fetch client. Each test installs its own per-call response.
|
|
10
|
+
const jiraFetchMock = mock(
|
|
11
|
+
() =>
|
|
12
|
+
Promise.resolve(
|
|
13
|
+
new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }),
|
|
14
|
+
) as Promise<Response>,
|
|
15
|
+
);
|
|
16
|
+
mock.module("../jira/client", () => ({
|
|
17
|
+
jiraFetch: jiraFetchMock,
|
|
18
|
+
getJiraAccessToken: () => Promise.resolve("access-1"),
|
|
19
|
+
getJiraCloudId: () => "cloud-1",
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
beforeAll(() => {
|
|
23
|
+
initDb(TEST_DB_PATH);
|
|
24
|
+
process.env.JIRA_WEBHOOK_TOKEN = "test-token-32-chars-deadbeef-cafe-99";
|
|
25
|
+
process.env.MCP_BASE_URL = "https://test.example.com";
|
|
26
|
+
|
|
27
|
+
upsertOAuthApp("jira", {
|
|
28
|
+
clientId: "client-id",
|
|
29
|
+
clientSecret: "client-secret",
|
|
30
|
+
authorizeUrl: "https://auth.atlassian.com/authorize",
|
|
31
|
+
tokenUrl: "https://auth.atlassian.com/oauth/token",
|
|
32
|
+
redirectUri: "http://localhost:3013/api/trackers/jira/callback",
|
|
33
|
+
scopes: "read:jira-work,manage:jira-webhook",
|
|
34
|
+
metadata: JSON.stringify({ cloudId: "cloud-1", siteUrl: "https://example.atlassian.net" }),
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
delete process.env.JIRA_WEBHOOK_TOKEN;
|
|
40
|
+
delete process.env.MCP_BASE_URL;
|
|
41
|
+
closeDb();
|
|
42
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
43
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
44
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const { refreshJiraWebhooks, registerJiraWebhook } = await import("../jira/webhook-lifecycle");
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
jiraFetchMock.mockClear();
|
|
51
|
+
// Reset the webhookIds list each test (and clear metadata writebacks).
|
|
52
|
+
getDb()
|
|
53
|
+
.query("UPDATE oauth_apps SET metadata = ? WHERE provider = 'jira'")
|
|
54
|
+
.run(JSON.stringify({ cloudId: "cloud-1", siteUrl: "https://example.atlassian.net" }));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("registerJiraWebhook", () => {
|
|
58
|
+
test("posts the right body shape and persists webhookId into metadata", async () => {
|
|
59
|
+
jiraFetchMock.mockImplementationOnce(
|
|
60
|
+
() =>
|
|
61
|
+
Promise.resolve(
|
|
62
|
+
new Response(
|
|
63
|
+
JSON.stringify({
|
|
64
|
+
webhookRegistrationResult: [{ createdWebhookId: 42 }],
|
|
65
|
+
}),
|
|
66
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
67
|
+
),
|
|
68
|
+
) as Promise<Response>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const result = await registerJiraWebhook("project = KAN");
|
|
72
|
+
|
|
73
|
+
expect(result.webhookId).toBe(42);
|
|
74
|
+
expect(result.jql).toBe("project = KAN");
|
|
75
|
+
expect(result.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO
|
|
76
|
+
|
|
77
|
+
expect(jiraFetchMock).toHaveBeenCalledTimes(1);
|
|
78
|
+
const [path, init] = jiraFetchMock.mock.calls[0] as [string, RequestInit];
|
|
79
|
+
expect(path).toBe("/rest/api/3/webhook");
|
|
80
|
+
expect(init.method).toBe("POST");
|
|
81
|
+
|
|
82
|
+
const parsed = JSON.parse(init.body as string) as {
|
|
83
|
+
url: string;
|
|
84
|
+
webhooks: { events: string[]; jqlFilter: string; fieldIdsFilter: string[] }[];
|
|
85
|
+
};
|
|
86
|
+
expect(parsed.url).toBe(
|
|
87
|
+
"https://test.example.com/api/trackers/jira/webhook/test-token-32-chars-deadbeef-cafe-99",
|
|
88
|
+
);
|
|
89
|
+
expect(parsed.webhooks[0]?.jqlFilter).toBe("project = KAN");
|
|
90
|
+
expect(parsed.webhooks[0]?.events).toEqual([
|
|
91
|
+
"jira:issue_updated",
|
|
92
|
+
"jira:issue_deleted",
|
|
93
|
+
"comment_created",
|
|
94
|
+
"comment_updated",
|
|
95
|
+
]);
|
|
96
|
+
expect(parsed.webhooks[0]?.fieldIdsFilter).toEqual(["assignee"]);
|
|
97
|
+
|
|
98
|
+
// Persisted in metadata
|
|
99
|
+
const meta = getJiraMetadata();
|
|
100
|
+
expect(meta.webhookIds?.length).toBe(1);
|
|
101
|
+
expect(meta.webhookIds?.[0]?.id).toBe(42);
|
|
102
|
+
expect(meta.webhookIds?.[0]?.jql).toBe("project = KAN");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("throws when JIRA_WEBHOOK_TOKEN is unset", async () => {
|
|
106
|
+
const saved = process.env.JIRA_WEBHOOK_TOKEN;
|
|
107
|
+
delete process.env.JIRA_WEBHOOK_TOKEN;
|
|
108
|
+
await expect(registerJiraWebhook("project = X")).rejects.toThrow(
|
|
109
|
+
/JIRA_WEBHOOK_TOKEN is not set/,
|
|
110
|
+
);
|
|
111
|
+
process.env.JIRA_WEBHOOK_TOKEN = saved;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("throws when Atlassian returns no results", async () => {
|
|
115
|
+
jiraFetchMock.mockImplementationOnce(
|
|
116
|
+
() =>
|
|
117
|
+
Promise.resolve(
|
|
118
|
+
new Response(JSON.stringify({ webhookRegistrationResult: [] }), {
|
|
119
|
+
status: 200,
|
|
120
|
+
headers: { "Content-Type": "application/json" },
|
|
121
|
+
}),
|
|
122
|
+
) as Promise<Response>,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await expect(registerJiraWebhook("project = X")).rejects.toThrow(/returned no results/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("throws when entry is malformed (missing createdWebhookId)", async () => {
|
|
129
|
+
jiraFetchMock.mockImplementationOnce(
|
|
130
|
+
() =>
|
|
131
|
+
Promise.resolve(
|
|
132
|
+
new Response(
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
webhookRegistrationResult: [{ errors: ["bad jql"] }],
|
|
135
|
+
}),
|
|
136
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
137
|
+
),
|
|
138
|
+
) as Promise<Response>,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
await expect(registerJiraWebhook("project = X")).rejects.toThrow(/malformed/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("throws when Atlassian responds non-OK", async () => {
|
|
145
|
+
jiraFetchMock.mockImplementationOnce(
|
|
146
|
+
() => Promise.resolve(new Response("forbidden", { status: 403 })) as Promise<Response>,
|
|
147
|
+
);
|
|
148
|
+
await expect(registerJiraWebhook("project = X")).rejects.toThrow(/registration failed \(403\)/);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("refreshJiraWebhooks", () => {
|
|
153
|
+
test("no-op when no webhooks are registered", async () => {
|
|
154
|
+
await refreshJiraWebhooks();
|
|
155
|
+
expect(jiraFetchMock).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("handles 200 + expirationDate — applies new expiry to all webhooks", async () => {
|
|
159
|
+
updateJiraMetadata({
|
|
160
|
+
webhookIds: [
|
|
161
|
+
{ id: 1, expiresAt: "2026-04-01T00:00:00.000Z", jql: "p=A" },
|
|
162
|
+
{ id: 2, expiresAt: "2026-04-15T00:00:00.000Z", jql: "p=B" },
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
jiraFetchMock.mockImplementationOnce(
|
|
167
|
+
() =>
|
|
168
|
+
Promise.resolve(
|
|
169
|
+
new Response(JSON.stringify({ expirationDate: "2026-12-31T00:00:00.000Z" }), {
|
|
170
|
+
status: 200,
|
|
171
|
+
headers: { "Content-Type": "application/json" },
|
|
172
|
+
}),
|
|
173
|
+
) as Promise<Response>,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
await refreshJiraWebhooks();
|
|
177
|
+
|
|
178
|
+
expect(jiraFetchMock).toHaveBeenCalledTimes(1);
|
|
179
|
+
const [path, init] = jiraFetchMock.mock.calls[0] as [string, RequestInit];
|
|
180
|
+
expect(path).toBe("/rest/api/3/webhook/refresh");
|
|
181
|
+
expect(init.method).toBe("PUT");
|
|
182
|
+
const parsed = JSON.parse(init.body as string) as { webhookIds: number[] };
|
|
183
|
+
expect(parsed.webhookIds.sort()).toEqual([1, 2]);
|
|
184
|
+
|
|
185
|
+
const meta = getJiraMetadata();
|
|
186
|
+
const sorted = [...(meta.webhookIds ?? [])].sort((a, b) => a.id - b.id);
|
|
187
|
+
expect(sorted[0]?.expiresAt).toBe("2026-12-31T00:00:00.000Z");
|
|
188
|
+
expect(sorted[1]?.expiresAt).toBe("2026-12-31T00:00:00.000Z");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("handles 204 No Content — leaves local expiries unchanged", async () => {
|
|
192
|
+
updateJiraMetadata({
|
|
193
|
+
webhookIds: [{ id: 7, expiresAt: "2026-04-01T00:00:00.000Z", jql: "p=A" }],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
jiraFetchMock.mockImplementationOnce(
|
|
197
|
+
() => Promise.resolve(new Response(null, { status: 204 })) as Promise<Response>,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
await refreshJiraWebhooks();
|
|
201
|
+
|
|
202
|
+
const meta = getJiraMetadata();
|
|
203
|
+
expect(meta.webhookIds?.[0]?.expiresAt).toBe("2026-04-01T00:00:00.000Z");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("throws on Atlassian non-OK response", async () => {
|
|
207
|
+
updateJiraMetadata({
|
|
208
|
+
webhookIds: [{ id: 8, expiresAt: "2026-04-01T00:00:00.000Z", jql: "p=A" }],
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
jiraFetchMock.mockImplementationOnce(
|
|
212
|
+
() => Promise.resolve(new Response("rate limited", { status: 429 })) as Promise<Response>,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
await expect(refreshJiraWebhooks()).rejects.toThrow(/refresh failed \(429\)/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("malformed JSON response leaves expiries untouched (no crash)", async () => {
|
|
219
|
+
updateJiraMetadata({
|
|
220
|
+
webhookIds: [{ id: 9, expiresAt: "2026-04-01T00:00:00.000Z", jql: "p=A" }],
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
jiraFetchMock.mockImplementationOnce(
|
|
224
|
+
() =>
|
|
225
|
+
Promise.resolve(
|
|
226
|
+
new Response("not-json", { status: 200, headers: { "Content-Type": "text/plain" } }),
|
|
227
|
+
) as Promise<Response>,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
await refreshJiraWebhooks();
|
|
231
|
+
const meta = getJiraMetadata();
|
|
232
|
+
expect(meta.webhookIds?.[0]?.expiresAt).toBe("2026-04-01T00:00:00.000Z");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeAll,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
mock,
|
|
9
|
+
spyOn,
|
|
10
|
+
test,
|
|
11
|
+
} from "bun:test";
|
|
12
|
+
import { unlink } from "node:fs/promises";
|
|
13
|
+
import { closeDb, getDb, initDb } from "../be/db";
|
|
14
|
+
import { upsertOAuthApp } from "../be/db-queries/oauth";
|
|
15
|
+
import {
|
|
16
|
+
createTrackerSync,
|
|
17
|
+
hasTrackerDelivery,
|
|
18
|
+
markTrackerDelivery,
|
|
19
|
+
} from "../be/db-queries/tracker";
|
|
20
|
+
import * as syncModule from "../jira/sync";
|
|
21
|
+
|
|
22
|
+
const TEST_DB_PATH = "./test-jira-webhook.sqlite";
|
|
23
|
+
const TEST_TOKEN = "test-jira-webhook-token-deadbeefcafe1234";
|
|
24
|
+
|
|
25
|
+
// Spy on the sync handlers — using mock.module here would leak globally
|
|
26
|
+
// because bun's mock.module has no documented restore. spyOn is restored by
|
|
27
|
+
// mock.restore() in afterAll, leaving the real module intact for other test
|
|
28
|
+
// files (notably jira-sync.test.ts which exercises the real handlers).
|
|
29
|
+
const issueHandler = spyOn(syncModule, "handleIssueEvent").mockResolvedValue(undefined);
|
|
30
|
+
const commentHandler = spyOn(syncModule, "handleCommentEvent").mockResolvedValue(undefined);
|
|
31
|
+
const issueDeleteHandler = spyOn(syncModule, "handleIssueDeleteEvent").mockResolvedValue(undefined);
|
|
32
|
+
|
|
33
|
+
const { handleJiraWebhook, isDuplicateDelivery, synthesizeDeliveryId, verifyJiraWebhookToken } =
|
|
34
|
+
await import("../jira/webhook");
|
|
35
|
+
|
|
36
|
+
beforeAll(() => {
|
|
37
|
+
initDb(TEST_DB_PATH);
|
|
38
|
+
process.env.JIRA_WEBHOOK_TOKEN = TEST_TOKEN;
|
|
39
|
+
// Seed an oauth app so any nested calls don't blow up.
|
|
40
|
+
upsertOAuthApp("jira", {
|
|
41
|
+
clientId: "client-id",
|
|
42
|
+
clientSecret: "client-secret",
|
|
43
|
+
authorizeUrl: "https://auth.atlassian.com/authorize",
|
|
44
|
+
tokenUrl: "https://auth.atlassian.com/oauth/token",
|
|
45
|
+
redirectUri: "http://localhost:3013/api/trackers/jira/callback",
|
|
46
|
+
scopes: "read:jira-work",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
delete process.env.JIRA_WEBHOOK_TOKEN;
|
|
52
|
+
mock.restore();
|
|
53
|
+
closeDb();
|
|
54
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
55
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
56
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
issueHandler.mockClear();
|
|
61
|
+
commentHandler.mockClear();
|
|
62
|
+
issueDeleteHandler.mockClear();
|
|
63
|
+
// Reset tracker_sync rows
|
|
64
|
+
getDb().query("DELETE FROM tracker_sync").run();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
// Allow any fire-and-forget dispatch to settle so it doesn't bleed into
|
|
69
|
+
// the next test.
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("verifyJiraWebhookToken", () => {
|
|
73
|
+
test("returns true for matching token", () => {
|
|
74
|
+
expect(verifyJiraWebhookToken(TEST_TOKEN, TEST_TOKEN)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns false for empty/missing path token", () => {
|
|
78
|
+
expect(verifyJiraWebhookToken(undefined, TEST_TOKEN)).toBe(false);
|
|
79
|
+
expect(verifyJiraWebhookToken("", TEST_TOKEN)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("returns false for empty expected token", () => {
|
|
83
|
+
expect(verifyJiraWebhookToken(TEST_TOKEN, "")).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("returns false for length mismatch", () => {
|
|
87
|
+
expect(verifyJiraWebhookToken("short", TEST_TOKEN)).toBe(false);
|
|
88
|
+
expect(verifyJiraWebhookToken(`${TEST_TOKEN}extra`, TEST_TOKEN)).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("returns false for byte mismatch at same length", () => {
|
|
92
|
+
const wrong = `${TEST_TOKEN.slice(0, -1)}X`;
|
|
93
|
+
expect(verifyJiraWebhookToken(wrong, TEST_TOKEN)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("synthesizeDeliveryId", () => {
|
|
98
|
+
test("stable across same body+envelope (idempotent retries)", () => {
|
|
99
|
+
const body = {
|
|
100
|
+
webhookEvent: "jira:issue_updated",
|
|
101
|
+
timestamp: 1700000000,
|
|
102
|
+
issue: { id: "10001" },
|
|
103
|
+
};
|
|
104
|
+
const raw = JSON.stringify(body);
|
|
105
|
+
const id1 = synthesizeDeliveryId(body, raw);
|
|
106
|
+
const id2 = synthesizeDeliveryId(body, raw);
|
|
107
|
+
expect(id1).toBe(id2);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("differs across different bodies even at same timestamp/event", () => {
|
|
111
|
+
const a = { webhookEvent: "comment_created", timestamp: 1700000000, issue: { id: "10001" } };
|
|
112
|
+
const b = { webhookEvent: "comment_created", timestamp: 1700000000, issue: { id: "10002" } };
|
|
113
|
+
expect(synthesizeDeliveryId(a, JSON.stringify(a))).not.toBe(
|
|
114
|
+
synthesizeDeliveryId(b, JSON.stringify(b)),
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("DB-persisted dedup (hasTrackerDelivery + markTrackerDelivery)", () => {
|
|
120
|
+
test("round-trip: marked delivery is found, unknown delivery is not", () => {
|
|
121
|
+
createTrackerSync({
|
|
122
|
+
provider: "jira",
|
|
123
|
+
entityType: "task",
|
|
124
|
+
swarmId: "task-1",
|
|
125
|
+
externalId: "10001",
|
|
126
|
+
externalIdentifier: "KAN-1",
|
|
127
|
+
});
|
|
128
|
+
expect(hasTrackerDelivery("jira", "delivery-abc")).toBe(false);
|
|
129
|
+
markTrackerDelivery("jira", "task", "10001", "delivery-abc");
|
|
130
|
+
expect(hasTrackerDelivery("jira", "delivery-abc")).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("hasTrackerDelivery returns false for empty/null deliveryId", () => {
|
|
134
|
+
expect(hasTrackerDelivery("jira", null)).toBe(false);
|
|
135
|
+
expect(hasTrackerDelivery("jira", "")).toBe(false);
|
|
136
|
+
expect(hasTrackerDelivery("jira", undefined)).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("dedup state is durable: marked deliveries survive any number of subsequent reads", () => {
|
|
140
|
+
// We can't fully simulate a process restart in-process (the test harness
|
|
141
|
+
// uses a deserialized in-memory template), but the storage is the
|
|
142
|
+
// tracker_sync table — so as long as the row stays, dedup works. Verify
|
|
143
|
+
// the data persists across many independent reads (the same property a
|
|
144
|
+
// restart would test against the underlying store).
|
|
145
|
+
createTrackerSync({
|
|
146
|
+
provider: "jira",
|
|
147
|
+
entityType: "task",
|
|
148
|
+
swarmId: "task-restart",
|
|
149
|
+
externalId: "10099",
|
|
150
|
+
externalIdentifier: "KAN-99",
|
|
151
|
+
});
|
|
152
|
+
markTrackerDelivery("jira", "task", "10099", "persistent-id");
|
|
153
|
+
for (let i = 0; i < 5; i++) {
|
|
154
|
+
expect(hasTrackerDelivery("jira", "persistent-id")).toBe(true);
|
|
155
|
+
}
|
|
156
|
+
// And the row exists in the underlying SQL store (proves it's not just a
|
|
157
|
+
// process-local Map).
|
|
158
|
+
const row = getDb()
|
|
159
|
+
.query("SELECT lastDeliveryId FROM tracker_sync WHERE externalId = '10099'")
|
|
160
|
+
.get() as { lastDeliveryId: string };
|
|
161
|
+
expect(row.lastDeliveryId).toBe("persistent-id");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("handleJiraWebhook — auth + status codes", () => {
|
|
166
|
+
test("returns 503 when JIRA_WEBHOOK_TOKEN is unset", async () => {
|
|
167
|
+
const saved = process.env.JIRA_WEBHOOK_TOKEN;
|
|
168
|
+
delete process.env.JIRA_WEBHOOK_TOKEN;
|
|
169
|
+
const result = await handleJiraWebhook(TEST_TOKEN, "{}");
|
|
170
|
+
expect(result.status).toBe(503);
|
|
171
|
+
process.env.JIRA_WEBHOOK_TOKEN = saved;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("returns 401 with empty body for missing path token", async () => {
|
|
175
|
+
const result = await handleJiraWebhook(undefined, "{}");
|
|
176
|
+
expect(result.status).toBe(401);
|
|
177
|
+
expect(result.body).toBe("");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("returns 401 with empty body for wrong path token", async () => {
|
|
181
|
+
const result = await handleJiraWebhook("wrong-token-32-chars-long-xxxxxx", "{}");
|
|
182
|
+
expect(result.status).toBe(401);
|
|
183
|
+
expect(result.body).toBe("");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("returns 200 + 'ignored' for invalid JSON body", async () => {
|
|
187
|
+
const result = await handleJiraWebhook(TEST_TOKEN, "{not json");
|
|
188
|
+
expect(result.status).toBe(200);
|
|
189
|
+
expect(result.body).toEqual({ status: "ignored", reason: "invalid-json" });
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("handleJiraWebhook — dispatcher routing", () => {
|
|
194
|
+
async function letDispatchSettle() {
|
|
195
|
+
// Dispatch is fire-and-forget; let microtasks drain.
|
|
196
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
test("issue_updated routes to handleIssueEvent", async () => {
|
|
200
|
+
const body = { webhookEvent: "jira:issue_updated", timestamp: 1, issue: { id: "10001" } };
|
|
201
|
+
const result = await handleJiraWebhook(TEST_TOKEN, JSON.stringify(body));
|
|
202
|
+
expect(result.status).toBe(200);
|
|
203
|
+
expect(result.body).toEqual({ status: "accepted" });
|
|
204
|
+
await letDispatchSettle();
|
|
205
|
+
expect(issueHandler).toHaveBeenCalledTimes(1);
|
|
206
|
+
expect(commentHandler).not.toHaveBeenCalled();
|
|
207
|
+
expect(issueDeleteHandler).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("comment_created routes to handleCommentEvent", async () => {
|
|
211
|
+
const body = {
|
|
212
|
+
webhookEvent: "comment_created",
|
|
213
|
+
timestamp: 2,
|
|
214
|
+
issue: { id: "10002" },
|
|
215
|
+
comment: { id: "c1" },
|
|
216
|
+
};
|
|
217
|
+
await handleJiraWebhook(TEST_TOKEN, JSON.stringify(body));
|
|
218
|
+
await letDispatchSettle();
|
|
219
|
+
expect(commentHandler).toHaveBeenCalledTimes(1);
|
|
220
|
+
expect(issueHandler).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("comment_updated routes to handleCommentEvent", async () => {
|
|
224
|
+
const body = {
|
|
225
|
+
webhookEvent: "comment_updated",
|
|
226
|
+
timestamp: 3,
|
|
227
|
+
issue: { id: "10003" },
|
|
228
|
+
comment: { id: "c2" },
|
|
229
|
+
};
|
|
230
|
+
await handleJiraWebhook(TEST_TOKEN, JSON.stringify(body));
|
|
231
|
+
await letDispatchSettle();
|
|
232
|
+
expect(commentHandler).toHaveBeenCalledTimes(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("issue_deleted routes to handleIssueDeleteEvent", async () => {
|
|
236
|
+
const body = { webhookEvent: "jira:issue_deleted", timestamp: 4, issue: { id: "10004" } };
|
|
237
|
+
await handleJiraWebhook(TEST_TOKEN, JSON.stringify(body));
|
|
238
|
+
await letDispatchSettle();
|
|
239
|
+
expect(issueDeleteHandler).toHaveBeenCalledTimes(1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("unhandled event is ignored — no handler invoked", async () => {
|
|
243
|
+
const body = { webhookEvent: "issue_link_created", timestamp: 5, issue: { id: "10005" } };
|
|
244
|
+
const result = await handleJiraWebhook(TEST_TOKEN, JSON.stringify(body));
|
|
245
|
+
expect(result.status).toBe(200);
|
|
246
|
+
await letDispatchSettle();
|
|
247
|
+
expect(issueHandler).not.toHaveBeenCalled();
|
|
248
|
+
expect(commentHandler).not.toHaveBeenCalled();
|
|
249
|
+
expect(issueDeleteHandler).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("duplicate delivery short-circuits (200 + 'duplicate')", async () => {
|
|
253
|
+
// Pre-stamp the delivery on a tracker_sync row.
|
|
254
|
+
createTrackerSync({
|
|
255
|
+
provider: "jira",
|
|
256
|
+
entityType: "task",
|
|
257
|
+
swarmId: "task-dup",
|
|
258
|
+
externalId: "10006",
|
|
259
|
+
externalIdentifier: "KAN-6",
|
|
260
|
+
});
|
|
261
|
+
const body = { webhookEvent: "jira:issue_updated", timestamp: 6, issue: { id: "10006" } };
|
|
262
|
+
const raw = JSON.stringify(body);
|
|
263
|
+
const did = synthesizeDeliveryId(body, raw);
|
|
264
|
+
markTrackerDelivery("jira", "task", "10006", did);
|
|
265
|
+
|
|
266
|
+
expect(isDuplicateDelivery(did)).toBe(true);
|
|
267
|
+
|
|
268
|
+
const result = await handleJiraWebhook(TEST_TOKEN, raw);
|
|
269
|
+
expect(result.status).toBe(200);
|
|
270
|
+
expect(result.body).toEqual({ status: "duplicate" });
|
|
271
|
+
await letDispatchSettle();
|
|
272
|
+
expect(issueHandler).not.toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
_getInstallationIdForTests,
|
|
4
|
+
_resetTelemetryStateForTests,
|
|
5
|
+
initTelemetry,
|
|
6
|
+
} from "../telemetry";
|
|
7
|
+
|
|
8
|
+
// initTelemetry no-ops when ANONYMIZED_TELEMETRY=false. The CI env or local
|
|
9
|
+
// setup may set this, so force-enable for the duration of this file.
|
|
10
|
+
process.env.ANONYMIZED_TELEMETRY = "true";
|
|
11
|
+
|
|
12
|
+
describe("initTelemetry", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
_resetTelemetryStateForTests();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("without generateIfMissing + missing config → installationId stays null (track no-ops)", async () => {
|
|
18
|
+
const writes: Array<{ key: string; value: string }> = [];
|
|
19
|
+
await initTelemetry(
|
|
20
|
+
"worker",
|
|
21
|
+
async () => undefined,
|
|
22
|
+
async (key, value) => {
|
|
23
|
+
writes.push({ key, value });
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
expect(_getInstallationIdForTests()).toBeNull();
|
|
27
|
+
expect(writes).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("without generateIfMissing + getConfig throws → installationId stays null", async () => {
|
|
31
|
+
const writes: Array<{ key: string; value: string }> = [];
|
|
32
|
+
await initTelemetry(
|
|
33
|
+
"worker",
|
|
34
|
+
async () => {
|
|
35
|
+
throw new Error("network blip");
|
|
36
|
+
},
|
|
37
|
+
async (key, value) => {
|
|
38
|
+
writes.push({ key, value });
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
expect(_getInstallationIdForTests()).toBeNull();
|
|
42
|
+
expect(writes).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("with generateIfMissing + missing config → mints install_<hex> and persists", async () => {
|
|
46
|
+
const writes: Array<{ key: string; value: string }> = [];
|
|
47
|
+
await initTelemetry(
|
|
48
|
+
"api-server",
|
|
49
|
+
async () => undefined,
|
|
50
|
+
async (key, value) => {
|
|
51
|
+
writes.push({ key, value });
|
|
52
|
+
},
|
|
53
|
+
{ generateIfMissing: true },
|
|
54
|
+
);
|
|
55
|
+
const id = _getInstallationIdForTests();
|
|
56
|
+
expect(id).not.toBeNull();
|
|
57
|
+
expect(id).toMatch(/^install_[0-9a-f]{16}$/);
|
|
58
|
+
expect(writes).toEqual([{ key: "telemetry_installation_id", value: id as string }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("with generateIfMissing + getConfig throws → mints ephemeral_<hex>, no persist", async () => {
|
|
62
|
+
const writes: Array<{ key: string; value: string }> = [];
|
|
63
|
+
await initTelemetry(
|
|
64
|
+
"api-server",
|
|
65
|
+
async () => {
|
|
66
|
+
throw new Error("db unavailable");
|
|
67
|
+
},
|
|
68
|
+
async (key, value) => {
|
|
69
|
+
writes.push({ key, value });
|
|
70
|
+
},
|
|
71
|
+
{ generateIfMissing: true },
|
|
72
|
+
);
|
|
73
|
+
const id = _getInstallationIdForTests();
|
|
74
|
+
expect(id).not.toBeNull();
|
|
75
|
+
expect(id).toMatch(/^ephemeral_[0-9a-f]{16}$/);
|
|
76
|
+
expect(writes).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("existing config → reuses regardless of generateIfMissing flag", async () => {
|
|
80
|
+
const existing = "install_deadbeefcafebabe";
|
|
81
|
+
|
|
82
|
+
// Without flag.
|
|
83
|
+
const writesA: Array<{ key: string; value: string }> = [];
|
|
84
|
+
await initTelemetry(
|
|
85
|
+
"worker",
|
|
86
|
+
async () => existing,
|
|
87
|
+
async (key, value) => {
|
|
88
|
+
writesA.push({ key, value });
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
expect(_getInstallationIdForTests()).toBe(existing);
|
|
92
|
+
expect(writesA).toEqual([]);
|
|
93
|
+
|
|
94
|
+
// With flag.
|
|
95
|
+
_resetTelemetryStateForTests();
|
|
96
|
+
const writesB: Array<{ key: string; value: string }> = [];
|
|
97
|
+
await initTelemetry(
|
|
98
|
+
"api-server",
|
|
99
|
+
async () => existing,
|
|
100
|
+
async (key, value) => {
|
|
101
|
+
writesB.push({ key, value });
|
|
102
|
+
},
|
|
103
|
+
{ generateIfMissing: true },
|
|
104
|
+
);
|
|
105
|
+
expect(_getInstallationIdForTests()).toBe(existing);
|
|
106
|
+
expect(writesB).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -12,7 +12,7 @@ export const registerTrackerLinkTaskTool = (server: McpServer) => {
|
|
|
12
12
|
annotations: { destructiveHint: false },
|
|
13
13
|
|
|
14
14
|
inputSchema: z.object({
|
|
15
|
-
provider: z.string().describe("Tracker provider (e.g. 'linear')"),
|
|
15
|
+
provider: z.string().describe("Tracker provider (e.g. 'linear', 'jira')"),
|
|
16
16
|
swarmTaskId: z.string().describe("The swarm task ID to link"),
|
|
17
17
|
externalId: z.string().describe("The external issue ID in the tracker"),
|
|
18
18
|
externalIdentifier: z
|
|
@@ -12,7 +12,7 @@ export const registerTrackerMapAgentTool = (server: McpServer) => {
|
|
|
12
12
|
annotations: { destructiveHint: false },
|
|
13
13
|
|
|
14
14
|
inputSchema: z.object({
|
|
15
|
-
provider: z.string().describe("Tracker provider (e.g. 'linear')"),
|
|
15
|
+
provider: z.string().describe("Tracker provider (e.g. 'linear', 'jira')"),
|
|
16
16
|
agentId: z.string().describe("The swarm agent ID"),
|
|
17
17
|
externalUserId: z.string().describe("The external user ID in the tracker"),
|
|
18
18
|
agentName: z.string().describe("Display name for the agent mapping"),
|
|
@@ -26,7 +26,7 @@ export const registerTrackerStatusTool = (server: McpServer) => {
|
|
|
26
26
|
}),
|
|
27
27
|
},
|
|
28
28
|
async (_requestInfo, _meta) => {
|
|
29
|
-
const providers = ["linear"] as const;
|
|
29
|
+
const providers = ["linear", "jira"] as const;
|
|
30
30
|
const trackers = providers.map((provider) => {
|
|
31
31
|
const app = getOAuthApp(provider);
|
|
32
32
|
const tokens = getOAuthTokens(provider);
|
|
@@ -12,7 +12,7 @@ export const registerTrackerSyncStatusTool = (server: McpServer) => {
|
|
|
12
12
|
annotations: { readOnlyHint: true },
|
|
13
13
|
|
|
14
14
|
inputSchema: z.object({
|
|
15
|
-
provider: z.string().optional().describe("Filter by provider (e.g. 'linear')"),
|
|
15
|
+
provider: z.string().optional().describe("Filter by provider (e.g. 'linear', 'jira')"),
|
|
16
16
|
entityType: z.enum(["task"]).optional().describe("Filter by entity type"),
|
|
17
17
|
}),
|
|
18
18
|
outputSchema: z.object({
|
package/src/tracker/types.ts
CHANGED