@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
package/src/telemetry.ts
CHANGED
|
@@ -19,30 +19,51 @@ function isEnabled(): boolean {
|
|
|
19
19
|
return process.env.ANONYMIZED_TELEMETRY !== "false";
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
interface InitTelemetryOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Whether to mint and persist a new install ID when the config read returns
|
|
25
|
+
* nothing (or fails). Only the api-server should set this — it owns the
|
|
26
|
+
* install identity. Workers piggyback on whatever the api-server has
|
|
27
|
+
* persisted; if it's not there yet, the worker silently no-ops telemetry to
|
|
28
|
+
* avoid polluting metrics with ephemeral per-restart IDs.
|
|
29
|
+
*
|
|
30
|
+
* Default: false.
|
|
31
|
+
*/
|
|
32
|
+
generateIfMissing?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
/**
|
|
23
36
|
* Initialize telemetry. Call once at startup.
|
|
24
37
|
* @param sourceId - "api-server" or "worker"
|
|
25
38
|
* @param getConfig - reads a key from swarm_config (global scope)
|
|
26
39
|
* @param setConfig - writes a key to swarm_config (global scope)
|
|
40
|
+
* @param options - see {@link InitTelemetryOptions}
|
|
27
41
|
*/
|
|
28
42
|
export async function initTelemetry(
|
|
29
43
|
sourceId: string,
|
|
30
44
|
getConfig: (key: string) => Promise<string | undefined> | string | undefined,
|
|
31
45
|
setConfig: (key: string, value: string) => Promise<void> | void,
|
|
46
|
+
options: InitTelemetryOptions = {},
|
|
32
47
|
): Promise<void> {
|
|
33
48
|
if (!isEnabled()) return;
|
|
34
49
|
source = sourceId;
|
|
50
|
+
const generateIfMissing = options.generateIfMissing === true;
|
|
35
51
|
try {
|
|
36
52
|
const existing = await getConfig("telemetry_installation_id");
|
|
37
53
|
if (existing) {
|
|
38
54
|
installationId = existing;
|
|
39
|
-
} else {
|
|
55
|
+
} else if (generateIfMissing) {
|
|
40
56
|
installationId = `install_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
41
57
|
await setConfig("telemetry_installation_id", installationId);
|
|
42
58
|
}
|
|
59
|
+
// else: leave installationId = null; track() will no-op
|
|
43
60
|
} catch {
|
|
44
|
-
// Config access failed
|
|
45
|
-
|
|
61
|
+
// Config access failed.
|
|
62
|
+
if (generateIfMissing) {
|
|
63
|
+
// Generate ephemeral ID so telemetry still works this session.
|
|
64
|
+
installationId = `ephemeral_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
65
|
+
}
|
|
66
|
+
// else: leave installationId = null; track() will no-op
|
|
46
67
|
}
|
|
47
68
|
}
|
|
48
69
|
|
|
@@ -82,6 +103,20 @@ export function track(options: TrackOptions): void {
|
|
|
82
103
|
}
|
|
83
104
|
}
|
|
84
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Test-only: reset the module-scoped state so tests can re-init cleanly.
|
|
108
|
+
* Do not call from production code.
|
|
109
|
+
*/
|
|
110
|
+
export function _resetTelemetryStateForTests(): void {
|
|
111
|
+
installationId = null;
|
|
112
|
+
source = "unknown";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Test-only: read the resolved install ID. */
|
|
116
|
+
export function _getInstallationIdForTests(): string | null {
|
|
117
|
+
return installationId;
|
|
118
|
+
}
|
|
119
|
+
|
|
85
120
|
export const telemetry = {
|
|
86
121
|
taskEvent(
|
|
87
122
|
event: string,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
agentmailContextKey,
|
|
4
|
+
buildJiraContextKey,
|
|
4
5
|
githubContextKey,
|
|
5
6
|
gitlabContextKey,
|
|
6
7
|
linearContextKey,
|
|
@@ -37,6 +38,10 @@ describe("context-key builders", () => {
|
|
|
37
38
|
expect(linearContextKey({ issueIdentifier: "DES-42" })).toBe("task:trackers:linear:DES-42");
|
|
38
39
|
});
|
|
39
40
|
|
|
41
|
+
test("buildJiraContextKey preserves identifier case", () => {
|
|
42
|
+
expect(buildJiraContextKey("PROJ-123")).toBe("task:trackers:jira:PROJ-123");
|
|
43
|
+
});
|
|
44
|
+
|
|
40
45
|
test("scheduleContextKey builds expected format", () => {
|
|
41
46
|
expect(scheduleContextKey({ scheduleId: "sched-uuid" })).toBe("task:schedule:sched-uuid");
|
|
42
47
|
});
|
|
@@ -71,10 +76,15 @@ describe("context-key separator safety", () => {
|
|
|
71
76
|
expect(() => linearContextKey({ issueIdentifier: "DES:42" })).toThrow(/must not contain/);
|
|
72
77
|
});
|
|
73
78
|
|
|
79
|
+
test("buildJiraContextKey throws when identifier contains ':'", () => {
|
|
80
|
+
expect(() => buildJiraContextKey("PROJ:123")).toThrow(/must not contain/);
|
|
81
|
+
});
|
|
82
|
+
|
|
74
83
|
test("throws on empty values", () => {
|
|
75
84
|
expect(() => slackContextKey({ channelId: "", threadTs: "1" })).toThrow(/non-empty/);
|
|
76
85
|
expect(() => agentmailContextKey({ threadId: "" })).toThrow(/non-empty/);
|
|
77
86
|
expect(() => linearContextKey({ issueIdentifier: "" })).toThrow(/non-empty/);
|
|
87
|
+
expect(() => buildJiraContextKey("")).toThrow(/non-empty/);
|
|
78
88
|
});
|
|
79
89
|
|
|
80
90
|
test("githubContextKey validates kind", () => {
|
|
@@ -147,6 +157,15 @@ describe("parseContextKey", () => {
|
|
|
147
157
|
});
|
|
148
158
|
});
|
|
149
159
|
|
|
160
|
+
test("round-trips jira keys with case preserved", () => {
|
|
161
|
+
const key = buildJiraContextKey("PROJ-123");
|
|
162
|
+
expect(parseContextKey(key)).toEqual({
|
|
163
|
+
family: "trackers",
|
|
164
|
+
subFamily: "jira",
|
|
165
|
+
parts: { issueIdentifier: "PROJ-123" },
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
150
169
|
test("round-trips schedule keys", () => {
|
|
151
170
|
const key = scheduleContextKey({ scheduleId: "sched-1" });
|
|
152
171
|
expect(parseContextKey(key)).toEqual({
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { extractMentions, extractText } from "../jira/adf";
|
|
3
|
+
|
|
4
|
+
describe("jira/adf — extractText", () => {
|
|
5
|
+
test("returns empty string for non-node input", () => {
|
|
6
|
+
expect(extractText(null)).toBe("");
|
|
7
|
+
expect(extractText(undefined)).toBe("");
|
|
8
|
+
expect(extractText("not a node")).toBe("");
|
|
9
|
+
expect(extractText(42)).toBe("");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("extracts plain text from a single paragraph", () => {
|
|
13
|
+
const adf = {
|
|
14
|
+
type: "doc",
|
|
15
|
+
content: [
|
|
16
|
+
{
|
|
17
|
+
type: "paragraph",
|
|
18
|
+
content: [{ type: "text", text: "Hello world." }],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
expect(extractText(adf)).toBe("Hello world.");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("joins text across multiple paragraphs with newlines", () => {
|
|
26
|
+
const adf = {
|
|
27
|
+
type: "doc",
|
|
28
|
+
content: [
|
|
29
|
+
{ type: "paragraph", content: [{ type: "text", text: "First." }] },
|
|
30
|
+
{ type: "paragraph", content: [{ type: "text", text: "Second." }] },
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
expect(extractText(adf)).toBe("First.\nSecond.");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("inlines mentions as @<displayName>", () => {
|
|
37
|
+
const adf = {
|
|
38
|
+
type: "doc",
|
|
39
|
+
content: [
|
|
40
|
+
{
|
|
41
|
+
type: "paragraph",
|
|
42
|
+
content: [
|
|
43
|
+
{ type: "text", text: "Hi " },
|
|
44
|
+
{
|
|
45
|
+
type: "mention",
|
|
46
|
+
attrs: { id: "557058:abc-123", text: "@Bot User" },
|
|
47
|
+
},
|
|
48
|
+
{ type: "text", text: " please look." },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
// mention text "@Bot User" already has "@", normalizer keeps single "@"
|
|
54
|
+
expect(extractText(adf)).toBe("Hi @Bot User please look.");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("falls back to accountId when mention has no display text", () => {
|
|
58
|
+
const adf = {
|
|
59
|
+
type: "paragraph",
|
|
60
|
+
content: [
|
|
61
|
+
{ type: "text", text: "ping " },
|
|
62
|
+
{ type: "mention", attrs: { id: "557058:xyz" } },
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
expect(extractText(adf)).toBe("ping @557058:xyz");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("handles bullet lists", () => {
|
|
69
|
+
const adf = {
|
|
70
|
+
type: "doc",
|
|
71
|
+
content: [
|
|
72
|
+
{
|
|
73
|
+
type: "bulletList",
|
|
74
|
+
content: [
|
|
75
|
+
{
|
|
76
|
+
type: "listItem",
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
type: "paragraph",
|
|
80
|
+
content: [{ type: "text", text: "alpha" }],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
type: "listItem",
|
|
86
|
+
content: [
|
|
87
|
+
{
|
|
88
|
+
type: "paragraph",
|
|
89
|
+
content: [{ type: "text", text: "beta" }],
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
const out = extractText(adf);
|
|
98
|
+
expect(out).toContain("- alpha");
|
|
99
|
+
expect(out).toContain("- beta");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("handles headings, codeBlock, blockquote, hardBreak", () => {
|
|
103
|
+
const adf = {
|
|
104
|
+
type: "doc",
|
|
105
|
+
content: [
|
|
106
|
+
{
|
|
107
|
+
type: "heading",
|
|
108
|
+
attrs: { level: 1 },
|
|
109
|
+
content: [{ type: "text", text: "Title" }],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
type: "paragraph",
|
|
113
|
+
content: [
|
|
114
|
+
{ type: "text", text: "line1" },
|
|
115
|
+
{ type: "hardBreak" },
|
|
116
|
+
{ type: "text", text: "line2" },
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: "codeBlock",
|
|
121
|
+
content: [{ type: "text", text: "echo hi" }],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
type: "blockquote",
|
|
125
|
+
content: [
|
|
126
|
+
{
|
|
127
|
+
type: "paragraph",
|
|
128
|
+
content: [{ type: "text", text: "quoted" }],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
const out = extractText(adf);
|
|
135
|
+
expect(out).toContain("Title");
|
|
136
|
+
expect(out).toContain("line1\nline2");
|
|
137
|
+
expect(out).toContain("echo hi");
|
|
138
|
+
expect(out).toContain("quoted");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("descends into unknown node content rather than dropping it", () => {
|
|
142
|
+
const adf = {
|
|
143
|
+
type: "doc",
|
|
144
|
+
content: [
|
|
145
|
+
{
|
|
146
|
+
type: "weird-custom-type",
|
|
147
|
+
content: [
|
|
148
|
+
{
|
|
149
|
+
type: "paragraph",
|
|
150
|
+
content: [{ type: "text", text: "still visible" }],
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
expect(extractText(adf)).toContain("still visible");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("jira/adf — extractMentions", () => {
|
|
161
|
+
test("returns empty array for non-node input", () => {
|
|
162
|
+
expect(extractMentions(null)).toEqual([]);
|
|
163
|
+
expect(extractMentions("string")).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("returns empty array when no mentions present", () => {
|
|
167
|
+
const adf = {
|
|
168
|
+
type: "doc",
|
|
169
|
+
content: [
|
|
170
|
+
{
|
|
171
|
+
type: "paragraph",
|
|
172
|
+
content: [{ type: "text", text: "no mentions here" }],
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
expect(extractMentions(adf)).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("collects accountIds from all mention nodes", () => {
|
|
180
|
+
const adf = {
|
|
181
|
+
type: "doc",
|
|
182
|
+
content: [
|
|
183
|
+
{
|
|
184
|
+
type: "paragraph",
|
|
185
|
+
content: [
|
|
186
|
+
{ type: "mention", attrs: { id: "557058:a", text: "@Alice" } },
|
|
187
|
+
{ type: "text", text: " and " },
|
|
188
|
+
{ type: "mention", attrs: { id: "557058:b", text: "@Bob" } },
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
type: "paragraph",
|
|
193
|
+
content: [{ type: "mention", attrs: { id: "557058:c", text: "@Carol" } }],
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
expect(extractMentions(adf)).toEqual(["557058:a", "557058:b", "557058:c"]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("ignores mention nodes without a string id", () => {
|
|
201
|
+
const adf = {
|
|
202
|
+
type: "paragraph",
|
|
203
|
+
content: [
|
|
204
|
+
{ type: "mention", attrs: { id: 123 } }, // wrong type
|
|
205
|
+
{ type: "mention", attrs: {} }, // missing
|
|
206
|
+
{ type: "mention", attrs: { id: "557058:ok", text: "@OK" } },
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
expect(extractMentions(adf)).toEqual(["557058:ok"]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("descends into nested structures (lists, blockquotes)", () => {
|
|
213
|
+
const adf = {
|
|
214
|
+
type: "doc",
|
|
215
|
+
content: [
|
|
216
|
+
{
|
|
217
|
+
type: "bulletList",
|
|
218
|
+
content: [
|
|
219
|
+
{
|
|
220
|
+
type: "listItem",
|
|
221
|
+
content: [
|
|
222
|
+
{
|
|
223
|
+
type: "paragraph",
|
|
224
|
+
content: [
|
|
225
|
+
{
|
|
226
|
+
type: "mention",
|
|
227
|
+
attrs: { id: "557058:nested", text: "@N" },
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
expect(extractMentions(adf)).toEqual(["557058:nested"]);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, 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-metadata.sqlite";
|
|
8
|
+
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
initDb(TEST_DB_PATH);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterAll(async () => {
|
|
14
|
+
closeDb();
|
|
15
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
16
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
17
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
// Reset the oauth_apps row before each test so prior writes can't leak.
|
|
22
|
+
getDb().query("DELETE FROM oauth_apps WHERE provider = 'jira'").run();
|
|
23
|
+
upsertOAuthApp("jira", {
|
|
24
|
+
clientId: "client-id",
|
|
25
|
+
clientSecret: "client-secret",
|
|
26
|
+
authorizeUrl: "https://auth.atlassian.com/authorize",
|
|
27
|
+
tokenUrl: "https://auth.atlassian.com/oauth/token",
|
|
28
|
+
redirectUri: "http://localhost:3013/api/trackers/jira/callback",
|
|
29
|
+
scopes: "read:jira-work,write:jira-work,manage:jira-webhook,offline_access,read:me",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("getJiraMetadata", () => {
|
|
34
|
+
test("returns empty object when oauth_apps row is missing", () => {
|
|
35
|
+
getDb().query("DELETE FROM oauth_apps WHERE provider = 'jira'").run();
|
|
36
|
+
expect(getJiraMetadata()).toEqual({});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns empty object when metadata is malformed JSON", () => {
|
|
40
|
+
getDb()
|
|
41
|
+
.query("UPDATE oauth_apps SET metadata = ? WHERE provider = 'jira'")
|
|
42
|
+
.run("{not valid json");
|
|
43
|
+
expect(getJiraMetadata()).toEqual({});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("returns empty object when metadata is empty string", () => {
|
|
47
|
+
getDb().query("UPDATE oauth_apps SET metadata = ? WHERE provider = 'jira'").run("");
|
|
48
|
+
expect(getJiraMetadata()).toEqual({});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("returns parsed cloudId/siteUrl/webhookIds when present", () => {
|
|
52
|
+
const meta = {
|
|
53
|
+
cloudId: "cloud-1",
|
|
54
|
+
siteUrl: "https://example.atlassian.net",
|
|
55
|
+
webhookIds: [{ id: 42, expiresAt: "2026-12-01T00:00:00.000Z", jql: "project = KAN" }],
|
|
56
|
+
};
|
|
57
|
+
getDb()
|
|
58
|
+
.query("UPDATE oauth_apps SET metadata = ? WHERE provider = 'jira'")
|
|
59
|
+
.run(JSON.stringify(meta));
|
|
60
|
+
|
|
61
|
+
const result = getJiraMetadata();
|
|
62
|
+
expect(result.cloudId).toBe("cloud-1");
|
|
63
|
+
expect(result.siteUrl).toBe("https://example.atlassian.net");
|
|
64
|
+
expect(result.webhookIds).toEqual([
|
|
65
|
+
{ id: 42, expiresAt: "2026-12-01T00:00:00.000Z", jql: "project = KAN" },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("filters malformed webhookIds entries", () => {
|
|
70
|
+
const meta = {
|
|
71
|
+
webhookIds: [
|
|
72
|
+
{ id: 1, expiresAt: "2026-12-01T00:00:00.000Z", jql: "project = A" },
|
|
73
|
+
{ id: "not-a-number", expiresAt: "2026-12-01T00:00:00.000Z", jql: "project = B" },
|
|
74
|
+
{ id: 2, expiresAt: 12345, jql: "project = C" }, // expiresAt wrong type
|
|
75
|
+
null,
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
getDb()
|
|
79
|
+
.query("UPDATE oauth_apps SET metadata = ? WHERE provider = 'jira'")
|
|
80
|
+
.run(JSON.stringify(meta));
|
|
81
|
+
|
|
82
|
+
const result = getJiraMetadata();
|
|
83
|
+
expect(result.webhookIds).toEqual([
|
|
84
|
+
{ id: 1, expiresAt: "2026-12-01T00:00:00.000Z", jql: "project = A" },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("updateJiraMetadata", () => {
|
|
90
|
+
test("scalar keys: shallow merge preserves untouched keys", () => {
|
|
91
|
+
updateJiraMetadata({ cloudId: "cloud-1", siteUrl: "https://example.atlassian.net" });
|
|
92
|
+
updateJiraMetadata({ cloudId: "cloud-2" });
|
|
93
|
+
const meta = getJiraMetadata();
|
|
94
|
+
expect(meta.cloudId).toBe("cloud-2");
|
|
95
|
+
expect(meta.siteUrl).toBe("https://example.atlassian.net");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("webhookIds: id-keyed merge preserves untouched entries and replaces matching ids", () => {
|
|
99
|
+
updateJiraMetadata({
|
|
100
|
+
webhookIds: [
|
|
101
|
+
{ id: 1, expiresAt: "2026-12-01T00:00:00.000Z", jql: "project = A" },
|
|
102
|
+
{ id: 2, expiresAt: "2026-12-01T00:00:00.000Z", jql: "project = B" },
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Update id=2 only — id=1 should be untouched.
|
|
107
|
+
updateJiraMetadata({
|
|
108
|
+
webhookIds: [{ id: 2, expiresAt: "2026-12-31T00:00:00.000Z", jql: "project = B-updated" }],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const meta = getJiraMetadata();
|
|
112
|
+
const sorted = [...(meta.webhookIds ?? [])].sort((a, b) => a.id - b.id);
|
|
113
|
+
expect(sorted).toEqual([
|
|
114
|
+
{ id: 1, expiresAt: "2026-12-01T00:00:00.000Z", jql: "project = A" },
|
|
115
|
+
{ id: 2, expiresAt: "2026-12-31T00:00:00.000Z", jql: "project = B-updated" },
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("concurrent-style updates preserve both writers' keys", () => {
|
|
120
|
+
// Phase 2 OAuth callback writes cloudId+siteUrl, Phase 5 webhook-register
|
|
121
|
+
// writes webhookIds. Both should coexist after both have run.
|
|
122
|
+
updateJiraMetadata({ cloudId: "cloud-x", siteUrl: "https://x.atlassian.net" });
|
|
123
|
+
updateJiraMetadata({
|
|
124
|
+
webhookIds: [{ id: 99, expiresAt: "2026-11-01T00:00:00.000Z", jql: "project = X" }],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const meta = getJiraMetadata();
|
|
128
|
+
expect(meta.cloudId).toBe("cloud-x");
|
|
129
|
+
expect(meta.siteUrl).toBe("https://x.atlassian.net");
|
|
130
|
+
expect(meta.webhookIds).toEqual([
|
|
131
|
+
{ id: 99, expiresAt: "2026-11-01T00:00:00.000Z", jql: "project = X" },
|
|
132
|
+
]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("throws when oauth_apps row for jira is missing", () => {
|
|
136
|
+
getDb().query("DELETE FROM oauth_apps WHERE provider = 'jira'").run();
|
|
137
|
+
expect(() => updateJiraMetadata({ cloudId: "x" })).toThrow(/oauth_apps row missing/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("undefined partial keys do not clobber existing values", () => {
|
|
141
|
+
updateJiraMetadata({ cloudId: "cloud-keep", siteUrl: "https://keep.atlassian.net" });
|
|
142
|
+
updateJiraMetadata({}); // No keys passed
|
|
143
|
+
const meta = getJiraMetadata();
|
|
144
|
+
expect(meta.cloudId).toBe("cloud-keep");
|
|
145
|
+
expect(meta.siteUrl).toBe("https://keep.atlassian.net");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
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 { getJiraMetadata } from "../jira/metadata";
|
|
16
|
+
import * as wrapperModule from "../oauth/wrapper";
|
|
17
|
+
|
|
18
|
+
const TEST_DB_PATH = "./test-jira-oauth.sqlite";
|
|
19
|
+
|
|
20
|
+
// Spy on `exchangeCode` instead of `mock.module(...)` so the real wrapper
|
|
21
|
+
// module remains untouched in the test process. mock.module is process-global
|
|
22
|
+
// and not restorable per bun's docs ("mock.restore() does not reset the value
|
|
23
|
+
// of modules that were overridden with mock.module()") — when this file ran
|
|
24
|
+
// before src/tests/oauth-wrapper.test.ts in CI's order, the wrapper stayed
|
|
25
|
+
// mocked and broke the wrapper's own unit tests.
|
|
26
|
+
const exchangeCodeSpy = spyOn(wrapperModule, "exchangeCode");
|
|
27
|
+
|
|
28
|
+
const originalFetch = globalThis.fetch;
|
|
29
|
+
|
|
30
|
+
beforeAll(() => {
|
|
31
|
+
initDb(TEST_DB_PATH);
|
|
32
|
+
upsertOAuthApp("jira", {
|
|
33
|
+
clientId: "client-id",
|
|
34
|
+
clientSecret: "client-secret",
|
|
35
|
+
authorizeUrl: "https://auth.atlassian.com/authorize",
|
|
36
|
+
tokenUrl: "https://auth.atlassian.com/oauth/token",
|
|
37
|
+
redirectUri: "http://localhost:3013/api/trackers/jira/callback",
|
|
38
|
+
scopes: "read:jira-work",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
globalThis.fetch = originalFetch;
|
|
44
|
+
mock.restore();
|
|
45
|
+
closeDb();
|
|
46
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
47
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
48
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const { handleJiraCallback } = await import("../jira/oauth");
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
exchangeCodeSpy.mockClear();
|
|
55
|
+
exchangeCodeSpy.mockImplementation(() =>
|
|
56
|
+
Promise.resolve({
|
|
57
|
+
accessToken: "access-1",
|
|
58
|
+
refreshToken: "refresh-1",
|
|
59
|
+
expiresIn: 3600,
|
|
60
|
+
scope: "read:jira-work",
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
// Wipe metadata between tests to confirm writes happen.
|
|
64
|
+
getDb().query("UPDATE oauth_apps SET metadata = '{}' WHERE provider = 'jira'").run();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
globalThis.fetch = originalFetch;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("handleJiraCallback", () => {
|
|
72
|
+
test("exchanges code, fetches accessible-resources, persists cloudId+siteUrl", async () => {
|
|
73
|
+
let fetchedUrl: string | undefined;
|
|
74
|
+
let fetchedHeaders: Record<string, string> | undefined;
|
|
75
|
+
globalThis.fetch = mock((url: string, init?: RequestInit) => {
|
|
76
|
+
fetchedUrl = url;
|
|
77
|
+
fetchedHeaders = init?.headers as Record<string, string> | undefined;
|
|
78
|
+
return Promise.resolve(
|
|
79
|
+
new Response(
|
|
80
|
+
JSON.stringify([
|
|
81
|
+
{
|
|
82
|
+
id: "cloud-abc",
|
|
83
|
+
url: "https://example.atlassian.net",
|
|
84
|
+
name: "example",
|
|
85
|
+
scopes: ["read:jira-work"],
|
|
86
|
+
},
|
|
87
|
+
]),
|
|
88
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
}) as unknown as typeof fetch;
|
|
92
|
+
|
|
93
|
+
const result = await handleJiraCallback("code-1", "state-1");
|
|
94
|
+
|
|
95
|
+
// exchangeCode invoked with our config + code/state
|
|
96
|
+
expect(exchangeCodeSpy).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(result.accessToken).toBe("access-1");
|
|
98
|
+
expect(result.cloudId).toBe("cloud-abc");
|
|
99
|
+
expect(result.siteUrl).toBe("https://example.atlassian.net");
|
|
100
|
+
|
|
101
|
+
// accessible-resources URL hit with the right Authorization
|
|
102
|
+
expect(fetchedUrl).toBe("https://api.atlassian.com/oauth/token/accessible-resources");
|
|
103
|
+
expect(fetchedHeaders?.Authorization).toBe("Bearer access-1");
|
|
104
|
+
|
|
105
|
+
// Metadata persisted via updateJiraMetadata
|
|
106
|
+
const meta = getJiraMetadata();
|
|
107
|
+
expect(meta.cloudId).toBe("cloud-abc");
|
|
108
|
+
expect(meta.siteUrl).toBe("https://example.atlassian.net");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("throws cleanly when accessible-resources is empty", async () => {
|
|
112
|
+
globalThis.fetch = mock(() =>
|
|
113
|
+
Promise.resolve(
|
|
114
|
+
new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } }),
|
|
115
|
+
),
|
|
116
|
+
) as unknown as typeof fetch;
|
|
117
|
+
|
|
118
|
+
await expect(handleJiraCallback("code-2", "state-2")).rejects.toThrow(
|
|
119
|
+
/no accessible resources/i,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Metadata not persisted
|
|
123
|
+
const meta = getJiraMetadata();
|
|
124
|
+
expect(meta.cloudId).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("throws cleanly when accessible-resources is non-200", async () => {
|
|
128
|
+
globalThis.fetch = mock(() =>
|
|
129
|
+
Promise.resolve(new Response("oops", { status: 500 })),
|
|
130
|
+
) as unknown as typeof fetch;
|
|
131
|
+
|
|
132
|
+
await expect(handleJiraCallback("code-3", "state-3")).rejects.toThrow(
|
|
133
|
+
/accessible-resources fetch failed/i,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("throws when accessible-resources entry is malformed (missing id/url)", async () => {
|
|
138
|
+
globalThis.fetch = mock(() =>
|
|
139
|
+
Promise.resolve(
|
|
140
|
+
new Response(JSON.stringify([{ name: "no-id" }]), {
|
|
141
|
+
status: 200,
|
|
142
|
+
headers: { "Content-Type": "application/json" },
|
|
143
|
+
}),
|
|
144
|
+
),
|
|
145
|
+
) as unknown as typeof fetch;
|
|
146
|
+
|
|
147
|
+
await expect(handleJiraCallback("code-4", "state-4")).rejects.toThrow(/malformed entry/i);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("picks the FIRST accessible resource (single-workspace v1 contract)", async () => {
|
|
151
|
+
globalThis.fetch = mock(() =>
|
|
152
|
+
Promise.resolve(
|
|
153
|
+
new Response(
|
|
154
|
+
JSON.stringify([
|
|
155
|
+
{ id: "cloud-first", url: "https://first.atlassian.net" },
|
|
156
|
+
{ id: "cloud-second", url: "https://second.atlassian.net" },
|
|
157
|
+
]),
|
|
158
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
) as unknown as typeof fetch;
|
|
162
|
+
|
|
163
|
+
const result = await handleJiraCallback("code-5", "state-5");
|
|
164
|
+
expect(result.cloudId).toBe("cloud-first");
|
|
165
|
+
expect(result.siteUrl).toBe("https://first.atlassian.net");
|
|
166
|
+
});
|
|
167
|
+
});
|