@desplega.ai/agent-swarm 1.70.0 → 1.71.0

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.
Files changed (41) hide show
  1. package/openapi.json +184 -1
  2. package/package.json +1 -1
  3. package/src/be/db-queries/oauth.ts +45 -15
  4. package/src/be/db-queries/tracker.ts +109 -0
  5. package/src/be/migrations/043_jira_source.sql +128 -0
  6. package/src/commands/runner.ts +7 -2
  7. package/src/http/core.ts +6 -21
  8. package/src/http/index.ts +9 -1
  9. package/src/http/route-def.ts +19 -0
  10. package/src/http/trackers/index.ts +13 -0
  11. package/src/http/trackers/jira.ts +331 -0
  12. package/src/jira/adf.ts +132 -0
  13. package/src/jira/app.ts +65 -0
  14. package/src/jira/client.ts +82 -0
  15. package/src/jira/index.ts +24 -0
  16. package/src/jira/metadata.ts +104 -0
  17. package/src/jira/oauth.ts +98 -0
  18. package/src/jira/outbound.ts +155 -0
  19. package/src/jira/sync.ts +534 -0
  20. package/src/jira/templates.ts +84 -0
  21. package/src/jira/types.ts +35 -0
  22. package/src/jira/webhook-lifecycle.ts +363 -0
  23. package/src/jira/webhook.ts +159 -0
  24. package/src/oauth/wrapper.ts +11 -1
  25. package/src/tasks/context-key.ts +29 -1
  26. package/src/telemetry.ts +38 -3
  27. package/src/tests/context-key.test.ts +19 -0
  28. package/src/tests/jira-adf.test.ts +239 -0
  29. package/src/tests/jira-metadata.test.ts +147 -0
  30. package/src/tests/jira-oauth.test.ts +167 -0
  31. package/src/tests/jira-outbound-sync.test.ts +334 -0
  32. package/src/tests/jira-sync.test.ts +327 -0
  33. package/src/tests/jira-webhook-lifecycle.test.ts +234 -0
  34. package/src/tests/jira-webhook.test.ts +274 -0
  35. package/src/tests/telemetry-init.test.ts +108 -0
  36. package/src/tools/tracker/tracker-link-task.ts +1 -1
  37. package/src/tools/tracker/tracker-map-agent.ts +1 -1
  38. package/src/tools/tracker/tracker-status.ts +1 -1
  39. package/src/tools/tracker/tracker-sync-status.ts +1 -1
  40. package/src/tracker/types.ts +1 -1
  41. package/src/types.ts +1 -0
@@ -0,0 +1,331 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { getOAuthTokens } from "../../be/db-queries/oauth";
4
+ import { isJiraEnabled } from "../../jira/app";
5
+ import { getJiraMetadata } from "../../jira/metadata";
6
+ import { getJiraAuthorizationUrl, handleJiraCallback } from "../../jira/oauth";
7
+ import { handleJiraWebhook } from "../../jira/webhook";
8
+ import { deleteJiraWebhook, registerJiraWebhook } from "../../jira/webhook-lifecycle";
9
+ import { route } from "../route-def";
10
+ import { parseQueryParams } from "../utils";
11
+
12
+ const MANUAL_WEBHOOK_INSTRUCTIONS =
13
+ "See docs-site/.../guides/jira-integration.mdx for manual webhook registration steps.";
14
+
15
+ // ─── Route Definitions ───────────────────────────────────────────────────────
16
+
17
+ const jiraAuthorize = route({
18
+ method: "get",
19
+ path: "/api/trackers/jira/authorize",
20
+ pattern: ["api", "trackers", "jira", "authorize"],
21
+ summary: "Redirect to Atlassian OAuth consent screen",
22
+ tags: ["Trackers"],
23
+ auth: { apiKey: false },
24
+ responses: {
25
+ 302: { description: "Redirect to Atlassian OAuth" },
26
+ 500: { description: "Failed to generate authorization URL" },
27
+ 503: { description: "Jira integration not configured" },
28
+ },
29
+ });
30
+
31
+ const jiraCallback = route({
32
+ method: "get",
33
+ path: "/api/trackers/jira/callback",
34
+ pattern: ["api", "trackers", "jira", "callback"],
35
+ summary: "Handle Jira OAuth callback (resolves cloudId via accessible-resources)",
36
+ tags: ["Trackers"],
37
+ auth: { apiKey: false },
38
+ query: z.object({
39
+ code: z.string(),
40
+ state: z.string(),
41
+ }),
42
+ responses: {
43
+ 200: { description: "OAuth complete" },
44
+ 400: { description: "Invalid state or code" },
45
+ 500: { description: "Token exchange or accessible-resources fetch failed" },
46
+ },
47
+ });
48
+
49
+ const jiraStatus = route({
50
+ method: "get",
51
+ path: "/api/trackers/jira/status",
52
+ pattern: ["api", "trackers", "jira", "status"],
53
+ summary:
54
+ "Jira connection status, cloudId/siteUrl, token expiry, expected webhook URL, scope/token-config flags",
55
+ tags: ["Trackers"],
56
+ responses: {
57
+ 200: { description: "Connection status" },
58
+ 503: { description: "Jira integration not configured" },
59
+ },
60
+ });
61
+
62
+ const jiraWebhook = route({
63
+ method: "post",
64
+ path: "/api/trackers/jira/webhook/{token}",
65
+ pattern: ["api", "trackers", "jira", "webhook", null],
66
+ summary:
67
+ "Receive Jira webhook events (URL-token authenticated). Phase 2 stub — Phase 3 fills in dispatch.",
68
+ tags: ["Trackers"],
69
+ auth: { apiKey: false },
70
+ params: z.object({ token: z.string() }),
71
+ responses: {
72
+ 200: { description: "Event accepted" },
73
+ 401: { description: "Invalid URL token" },
74
+ 503: { description: "Jira webhook handler not configured" },
75
+ },
76
+ });
77
+
78
+ // Admin: register a new dynamic webhook with Atlassian. apiKey is required
79
+ // (route-factory default). The registered URL embeds JIRA_WEBHOOK_TOKEN so
80
+ // inbound deliveries can be authenticated.
81
+ const jiraWebhookRegister = route({
82
+ method: "post",
83
+ path: "/api/trackers/jira/webhook-register",
84
+ pattern: ["api", "trackers", "jira", "webhook-register"],
85
+ summary: "Register a Jira dynamic webhook (admin only)",
86
+ tags: ["Trackers"],
87
+ body: z.object({
88
+ jqlFilter: z.string().min(1),
89
+ }),
90
+ responses: {
91
+ 200: { description: "Webhook registered" },
92
+ 400: { description: "Invalid jqlFilter" },
93
+ 503: { description: "Jira not connected or JIRA_WEBHOOK_TOKEN missing" },
94
+ },
95
+ });
96
+
97
+ // Admin: delete a dynamic webhook from Atlassian and remove from local
98
+ // metadata. apiKey is required (route-factory default).
99
+ const jiraWebhookDelete = route({
100
+ method: "delete",
101
+ path: "/api/trackers/jira/webhook/{id}",
102
+ pattern: ["api", "trackers", "jira", "webhook", null],
103
+ summary: "Delete a Jira dynamic webhook (admin only)",
104
+ tags: ["Trackers"],
105
+ params: z.object({
106
+ id: z.coerce.number().int().positive(),
107
+ }),
108
+ responses: {
109
+ 200: { description: "Webhook deleted" },
110
+ 400: { description: "Invalid webhook id" },
111
+ 503: { description: "Jira not connected" },
112
+ },
113
+ });
114
+
115
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
116
+
117
+ function getWebhookBaseUrl(): string {
118
+ return process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
119
+ }
120
+
121
+ function getWebhookUrl(): string {
122
+ const token = process.env.JIRA_WEBHOOK_TOKEN ?? "<unset>";
123
+ return `${getWebhookBaseUrl()}/api/trackers/jira/webhook/${token}`;
124
+ }
125
+
126
+ // ─── Handler ─────────────────────────────────────────────────────────────────
127
+
128
+ export async function handleJiraTracker(
129
+ req: IncomingMessage,
130
+ res: ServerResponse,
131
+ pathSegments: string[],
132
+ ): Promise<boolean> {
133
+ // GET /api/trackers/jira/authorize — redirect to Atlassian OAuth consent
134
+ if (jiraAuthorize.match(req.method, pathSegments)) {
135
+ if (!isJiraEnabled()) {
136
+ res.writeHead(503, { "Content-Type": "application/json" });
137
+ res.end(JSON.stringify({ error: "Jira integration not configured" }));
138
+ return true;
139
+ }
140
+
141
+ try {
142
+ const url = await getJiraAuthorizationUrl();
143
+ if (!url) {
144
+ res.writeHead(500, { "Content-Type": "application/json" });
145
+ res.end(JSON.stringify({ error: "Failed to generate authorization URL" }));
146
+ return true;
147
+ }
148
+
149
+ res.writeHead(302, { Location: url });
150
+ res.end();
151
+ } catch (err) {
152
+ const message = err instanceof Error ? err.message : String(err);
153
+ console.error("[Jira] Failed to generate authorization URL:", message);
154
+ res.writeHead(500, { "Content-Type": "application/json" });
155
+ res.end(JSON.stringify({ error: "Failed to generate authorization URL" }));
156
+ }
157
+ return true;
158
+ }
159
+
160
+ // GET /api/trackers/jira/callback — handle OAuth callback from Atlassian
161
+ if (jiraCallback.match(req.method, pathSegments)) {
162
+ const queryParams = parseQueryParams(req.url || "");
163
+ const parsed = await jiraCallback.parse(req, res, pathSegments, queryParams);
164
+ if (!parsed) return true; // parse() already sent 400
165
+
166
+ const { code, state } = parsed.query;
167
+
168
+ try {
169
+ await handleJiraCallback(code, state);
170
+ res.writeHead(200, { "Content-Type": "text/html" });
171
+ res.end(`<!DOCTYPE html>
172
+ <html>
173
+ <head><title>Jira Connected</title></head>
174
+ <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
175
+ <div style="text-align: center;">
176
+ <h1>Jira Connected</h1>
177
+ <p>OAuth authorization complete. You can close this window.</p>
178
+ </div>
179
+ </body>
180
+ </html>`);
181
+ } catch (err) {
182
+ const message = err instanceof Error ? err.message : String(err);
183
+ console.error("[Jira] OAuth callback failed:", message);
184
+
185
+ if (message.includes("Invalid or expired OAuth state")) {
186
+ res.writeHead(400, { "Content-Type": "application/json" });
187
+ res.end(JSON.stringify({ error: "Invalid or expired OAuth state" }));
188
+ } else {
189
+ res.writeHead(500, { "Content-Type": "application/json" });
190
+ res.end(JSON.stringify({ error: "OAuth callback failed", details: message }));
191
+ }
192
+ }
193
+ return true;
194
+ }
195
+
196
+ // GET /api/trackers/jira/status — connection status (works even when not connected)
197
+ if (jiraStatus.match(req.method, pathSegments)) {
198
+ if (!isJiraEnabled()) {
199
+ res.writeHead(503, { "Content-Type": "application/json" });
200
+ res.end(JSON.stringify({ error: "Jira integration not configured" }));
201
+ return true;
202
+ }
203
+
204
+ const tokens = getOAuthTokens("jira");
205
+ const meta = getJiraMetadata();
206
+ const scope = tokens?.scope ?? null;
207
+ // Atlassian returns scopes space-separated in the token response.
208
+ const scopeList = scope ? scope.split(/[\s,]+/).filter(Boolean) : [];
209
+
210
+ const hasManageWebhookScope = scopeList.includes("manage:jira-webhook");
211
+
212
+ const status: Record<string, unknown> = {
213
+ provider: "jira",
214
+ connected: !!tokens,
215
+ cloudId: meta.cloudId ?? null,
216
+ siteUrl: meta.siteUrl ?? null,
217
+ tokenExpiresAt: tokens?.expiresAt ?? null,
218
+ scope,
219
+ hasManageWebhookScope,
220
+ webhookTokenConfigured: Boolean(process.env.JIRA_WEBHOOK_TOKEN),
221
+ webhookUrl: getWebhookUrl(),
222
+ webhookIds: meta.webhookIds ?? [],
223
+ };
224
+
225
+ // Phase 5: surface manual-webhook instructions when the OAuth grant
226
+ // doesn't include `manage:jira-webhook` (admin must register webhooks
227
+ // manually in the Atlassian UI).
228
+ if (!hasManageWebhookScope) {
229
+ status.manualWebhookInstructions = MANUAL_WEBHOOK_INSTRUCTIONS;
230
+ }
231
+
232
+ res.writeHead(200, { "Content-Type": "application/json" });
233
+ res.end(JSON.stringify(status));
234
+ return true;
235
+ }
236
+
237
+ // POST /api/trackers/jira/webhook/:token — receive Jira dynamic-webhook events.
238
+ //
239
+ // Atlassian does not HMAC-sign OAuth 3LO dynamic webhooks (errata I8); we
240
+ // authenticate via a URL-path token compared with `JIRA_WEBHOOK_TOKEN`.
241
+ if (jiraWebhook.match(req.method, pathSegments)) {
242
+ // Path token sits at index 4 of the matched segments
243
+ // (["api","trackers","jira","webhook", null]). Use the route parser so
244
+ // we go through the same Zod path-param plumbing the rest of the route
245
+ // file uses.
246
+ const queryParams = parseQueryParams(req.url || "");
247
+ const parsed = await jiraWebhook.parse(req, res, pathSegments, queryParams);
248
+ if (!parsed) return true; // 400 already sent
249
+
250
+ // Read raw body using the same chunk-assembly pattern as
251
+ // src/http/trackers/linear.ts:166-171 — we don't trust the framework to
252
+ // hand us a parsed body for webhook routes.
253
+ const chunks: Buffer[] = [];
254
+ for await (const chunk of req) {
255
+ chunks.push(chunk);
256
+ }
257
+ const rawBody = Buffer.concat(chunks).toString();
258
+
259
+ const result = await handleJiraWebhook(parsed.params.token, rawBody);
260
+
261
+ // 401 with empty body — no info leak about valid-vs-missing token.
262
+ if (result.status === 401) {
263
+ res.writeHead(401);
264
+ res.end();
265
+ return true;
266
+ }
267
+
268
+ res.writeHead(result.status, { "Content-Type": "application/json" });
269
+ res.end(typeof result.body === "string" ? result.body : JSON.stringify(result.body));
270
+ return true;
271
+ }
272
+
273
+ // POST /api/trackers/jira/webhook-register — admin: register a dynamic webhook.
274
+ if (jiraWebhookRegister.match(req.method, pathSegments)) {
275
+ if (!isJiraEnabled()) {
276
+ res.writeHead(503, { "Content-Type": "application/json" });
277
+ res.end(JSON.stringify({ error: "Jira integration not configured" }));
278
+ return true;
279
+ }
280
+ if (!process.env.JIRA_WEBHOOK_TOKEN) {
281
+ res.writeHead(503, { "Content-Type": "application/json" });
282
+ res.end(JSON.stringify({ error: "JIRA_WEBHOOK_TOKEN is not set" }));
283
+ return true;
284
+ }
285
+
286
+ const queryParams = parseQueryParams(req.url || "");
287
+ const parsed = await jiraWebhookRegister.parse(req, res, pathSegments, queryParams);
288
+ if (!parsed) return true;
289
+
290
+ try {
291
+ const result = await registerJiraWebhook(parsed.body.jqlFilter);
292
+ res.writeHead(200, { "Content-Type": "application/json" });
293
+ res.end(JSON.stringify(result));
294
+ } catch (err) {
295
+ const message = err instanceof Error ? err.message : String(err);
296
+ console.error("[Jira] Webhook register failed:", message);
297
+ res.writeHead(500, { "Content-Type": "application/json" });
298
+ res.end(JSON.stringify({ error: "Webhook registration failed", details: message }));
299
+ }
300
+ return true;
301
+ }
302
+
303
+ // DELETE /api/trackers/jira/webhook/:id — admin: delete a dynamic webhook.
304
+ // Note: this pattern overlaps the POST /webhook/:token path; the matcher
305
+ // disambiguates by HTTP method.
306
+ if (jiraWebhookDelete.match(req.method, pathSegments)) {
307
+ if (!isJiraEnabled()) {
308
+ res.writeHead(503, { "Content-Type": "application/json" });
309
+ res.end(JSON.stringify({ error: "Jira integration not configured" }));
310
+ return true;
311
+ }
312
+
313
+ const queryParams = parseQueryParams(req.url || "");
314
+ const parsed = await jiraWebhookDelete.parse(req, res, pathSegments, queryParams);
315
+ if (!parsed) return true;
316
+
317
+ try {
318
+ await deleteJiraWebhook(parsed.params.id);
319
+ res.writeHead(200, { "Content-Type": "application/json" });
320
+ res.end(JSON.stringify({ deleted: true, webhookId: parsed.params.id }));
321
+ } catch (err) {
322
+ const message = err instanceof Error ? err.message : String(err);
323
+ console.error(`[Jira] Webhook delete failed (id=${parsed.params.id}):`, message);
324
+ res.writeHead(500, { "Content-Type": "application/json" });
325
+ res.end(JSON.stringify({ error: "Webhook delete failed", details: message }));
326
+ }
327
+ return true;
328
+ }
329
+
330
+ return false;
331
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Atlassian Document Format (ADF) walker.
3
+ *
4
+ * Jira REST v3 returns rich-text fields (issue descriptions, comment bodies)
5
+ * as ADF JSON trees. We need plaintext for prompt rendering and structured
6
+ * mention extraction for bot-mention detection on inbound webhooks.
7
+ *
8
+ * Reference: https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
9
+ *
10
+ * Supported node types: paragraph, heading, bulletList, orderedList, listItem,
11
+ * text, mention, hardBreak, codeBlock, blockquote. Unknown types descend into
12
+ * `content` if present, else are skipped silently. In non-prod environments a
13
+ * debug line is emitted so unhandled cases surface during dev.
14
+ */
15
+
16
+ type AdfNode = {
17
+ type?: string;
18
+ text?: string;
19
+ content?: unknown[];
20
+ attrs?: Record<string, unknown>;
21
+ };
22
+
23
+ function isNode(value: unknown): value is AdfNode {
24
+ return !!value && typeof value === "object";
25
+ }
26
+
27
+ function asNodeArray(value: unknown): AdfNode[] {
28
+ if (!Array.isArray(value)) return [];
29
+ return value.filter(isNode);
30
+ }
31
+
32
+ /**
33
+ * Recursively concatenate text from an ADF tree. Mentions are inlined as
34
+ * `@<displayName>` (or `@<accountId>` if no displayName is present).
35
+ *
36
+ * Block-level nodes insert a trailing newline so consecutive paragraphs render
37
+ * as separate lines. Hard breaks and list items also produce newlines.
38
+ */
39
+ export function extractText(adf: unknown): string {
40
+ if (!isNode(adf)) return "";
41
+
42
+ const out: string[] = [];
43
+
44
+ const visit = (node: AdfNode): void => {
45
+ const type = typeof node.type === "string" ? node.type : "";
46
+
47
+ switch (type) {
48
+ case "text": {
49
+ if (typeof node.text === "string") out.push(node.text);
50
+ return;
51
+ }
52
+ case "mention": {
53
+ const attrs = node.attrs ?? {};
54
+ const text = typeof attrs.text === "string" ? attrs.text : null;
55
+ const id = typeof attrs.id === "string" ? attrs.id : null;
56
+ const display = text ?? id ?? "";
57
+ // Atlassian mention `text` already starts with "@" sometimes; normalize
58
+ // to a single leading "@".
59
+ out.push(display.startsWith("@") ? display : `@${display}`);
60
+ return;
61
+ }
62
+ case "hardBreak": {
63
+ out.push("\n");
64
+ return;
65
+ }
66
+ case "paragraph":
67
+ case "heading":
68
+ case "blockquote":
69
+ case "codeBlock": {
70
+ for (const child of asNodeArray(node.content)) visit(child);
71
+ out.push("\n");
72
+ return;
73
+ }
74
+ case "bulletList":
75
+ case "orderedList": {
76
+ for (const child of asNodeArray(node.content)) visit(child);
77
+ return;
78
+ }
79
+ case "listItem": {
80
+ out.push("- ");
81
+ for (const child of asNodeArray(node.content)) visit(child);
82
+ // Block children inside a listItem already trail a newline, so we don't
83
+ // double-add. But if the listItem only contained inline content, ensure
84
+ // we end on a newline.
85
+ if (out.length > 0 && !out[out.length - 1]?.endsWith("\n")) {
86
+ out.push("\n");
87
+ }
88
+ return;
89
+ }
90
+ case "doc": {
91
+ for (const child of asNodeArray(node.content)) visit(child);
92
+ return;
93
+ }
94
+ default: {
95
+ // Unknown node — descend into content if present.
96
+ if (process.env.NODE_ENV !== "production" && type) {
97
+ console.log(`[jira.adf] unknown node type: ${type}`);
98
+ }
99
+ for (const child of asNodeArray(node.content)) visit(child);
100
+ return;
101
+ }
102
+ }
103
+ };
104
+
105
+ visit(adf);
106
+
107
+ // Collapse trailing whitespace, preserve internal newlines.
108
+ return out.join("").replace(/\n+$/g, "");
109
+ }
110
+
111
+ /**
112
+ * Collect Atlassian `accountId` values from every `mention` node in the tree.
113
+ * Returns an empty array when no mentions exist or the input is malformed.
114
+ */
115
+ export function extractMentions(adf: unknown): string[] {
116
+ if (!isNode(adf)) return [];
117
+
118
+ const ids: string[] = [];
119
+
120
+ const visit = (node: AdfNode): void => {
121
+ if (node.type === "mention") {
122
+ const id = node.attrs?.id;
123
+ if (typeof id === "string" && id.length > 0) ids.push(id);
124
+ return;
125
+ }
126
+ for (const child of asNodeArray(node.content)) visit(child);
127
+ };
128
+
129
+ visit(adf);
130
+
131
+ return ids;
132
+ }
@@ -0,0 +1,65 @@
1
+ import { upsertOAuthApp } from "../be/db-queries/oauth";
2
+ import { initJiraOutboundSync, teardownJiraOutboundSync } from "./outbound";
3
+ // Side-effect import: registers all Jira event templates in the in-memory
4
+ // registry at module load time (mirrors `src/linear/templates.ts`).
5
+ import "./templates";
6
+ import { resetBotAccountIdCache } from "./sync";
7
+ import { startJiraWebhookKeepalive, stopJiraWebhookKeepalive } from "./webhook-lifecycle";
8
+
9
+ let initialized = false;
10
+
11
+ export function isJiraEnabled(): boolean {
12
+ const disabled = process.env.JIRA_DISABLE;
13
+ if (disabled === "true" || disabled === "1") return false;
14
+ const enabled = process.env.JIRA_ENABLED;
15
+ if (enabled === "false" || enabled === "0") return false;
16
+ return !!process.env.JIRA_CLIENT_ID;
17
+ }
18
+
19
+ export function resetJira(): void {
20
+ // Phase 3: drop the cached bot accountId so a reconnect as a different
21
+ // Atlassian user re-resolves identity on the next inbound webhook.
22
+ resetBotAccountIdCache();
23
+ teardownJiraOutboundSync();
24
+ stopJiraWebhookKeepalive();
25
+ initialized = false;
26
+ }
27
+
28
+ export function initJira(): boolean {
29
+ if (initialized) return isJiraEnabled();
30
+ initialized = true;
31
+
32
+ if (!isJiraEnabled()) {
33
+ console.log("[Jira] Integration disabled or JIRA_CLIENT_ID not set");
34
+ return false;
35
+ }
36
+
37
+ const clientId = process.env.JIRA_CLIENT_ID!;
38
+ const clientSecret = process.env.JIRA_CLIENT_SECRET ?? "";
39
+ const redirectUri =
40
+ process.env.JIRA_REDIRECT_URI ??
41
+ `http://localhost:${process.env.PORT || "3013"}/api/trackers/jira/callback`;
42
+
43
+ upsertOAuthApp("jira", {
44
+ clientId,
45
+ clientSecret,
46
+ authorizeUrl: "https://auth.atlassian.com/authorize",
47
+ tokenUrl: "https://auth.atlassian.com/oauth/token",
48
+ redirectUri,
49
+ // Atlassian uses space-separated scopes (NOT comma-separated like Linear).
50
+ // We persist them as-stored; the OAuth wrapper splits on "," so we keep
51
+ // commas here and the wrapper.ts join(",") will recombine — see oauth.ts
52
+ // where we override scopes from the comma-stored value back to spaces in
53
+ // the authorize URL via the standard `scopes` array path.
54
+ scopes: "read:jira-work,write:jira-work,manage:jira-webhook,offline_access,read:me",
55
+ // Intentionally omit metadata: cloudId/siteUrl/webhookIds are written by
56
+ // the OAuth callback + webhook-register flows. upsertOAuthApp preserves
57
+ // existing metadata on UPDATE when not passed.
58
+ });
59
+
60
+ initJiraOutboundSync();
61
+ startJiraWebhookKeepalive();
62
+
63
+ console.log("[Jira] Integration initialized");
64
+ return true;
65
+ }
@@ -0,0 +1,82 @@
1
+ import { getOAuthTokens } from "../be/db-queries/oauth";
2
+ import { ensureToken } from "../oauth/ensure-token";
3
+ import { getJiraMetadata } from "./metadata";
4
+
5
+ /**
6
+ * Resolve a fresh Jira access token (refreshes if expiring soon).
7
+ *
8
+ * Throws if no tokens are stored — callers should treat this as "not yet
9
+ * connected" rather than a programmer error.
10
+ */
11
+ export async function getJiraAccessToken(): Promise<string> {
12
+ await ensureToken("jira");
13
+ const tokens = getOAuthTokens("jira");
14
+ if (!tokens) {
15
+ throw new Error("Jira not connected — no OAuth tokens stored");
16
+ }
17
+ return tokens.accessToken;
18
+ }
19
+
20
+ /**
21
+ * Resolve the current workspace `cloudId` from `oauth_apps.metadata`.
22
+ *
23
+ * Throws if the OAuth callback hasn't completed yet (no cloudId persisted).
24
+ */
25
+ export function getJiraCloudId(): string {
26
+ const meta = getJiraMetadata();
27
+ if (!meta.cloudId) {
28
+ throw new Error("Jira cloudId not resolved — complete OAuth before calling Jira REST API");
29
+ }
30
+ return meta.cloudId;
31
+ }
32
+
33
+ /**
34
+ * Typed fetch wrapper for the Atlassian Jira REST API.
35
+ *
36
+ * - Prepends `https://api.atlassian.com/ex/jira/{cloudId}` to `path`.
37
+ * - Sets `Authorization: Bearer <token>` and `Accept: application/json`.
38
+ * - Sets `Content-Type: application/json` when a body is provided.
39
+ * - On 401: refreshes the token (forced via `ensureToken("jira", 0)`) and
40
+ * retries once.
41
+ * - On 429: respects `Retry-After` (in seconds) with a single retry.
42
+ *
43
+ * Returns the raw `Response` — callers handle `response.json()`/`response.text()`
44
+ * themselves so we don't impose a parse contract.
45
+ */
46
+ export async function jiraFetch(path: string, init?: RequestInit): Promise<Response> {
47
+ if (!path.startsWith("/")) {
48
+ throw new Error(`jiraFetch path must start with '/' — got: ${path}`);
49
+ }
50
+
51
+ const send = async (token: string): Promise<Response> => {
52
+ const cloudId = getJiraCloudId();
53
+ const url = `https://api.atlassian.com/ex/jira/${cloudId}${path}`;
54
+ const headers = new Headers(init?.headers);
55
+ headers.set("Authorization", `Bearer ${token}`);
56
+ if (!headers.has("Accept")) headers.set("Accept", "application/json");
57
+ if (init?.body && !headers.has("Content-Type")) {
58
+ headers.set("Content-Type", "application/json");
59
+ }
60
+ return fetch(url, { ...init, headers });
61
+ };
62
+
63
+ let token = await getJiraAccessToken();
64
+ let response = await send(token);
65
+
66
+ if (response.status === 401) {
67
+ // Force refresh — bufferMs=0 means "always refresh if any expiry is set"
68
+ await ensureToken("jira", 0);
69
+ token = await getJiraAccessToken();
70
+ response = await send(token);
71
+ }
72
+
73
+ if (response.status === 429) {
74
+ const retryAfterRaw = response.headers.get("Retry-After");
75
+ const retryAfter = retryAfterRaw ? Number.parseInt(retryAfterRaw, 10) : NaN;
76
+ const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 1000;
77
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
78
+ response = await send(token);
79
+ }
80
+
81
+ return response;
82
+ }
@@ -0,0 +1,24 @@
1
+ export { extractMentions, extractText } from "./adf";
2
+ export { initJira, isJiraEnabled, resetJira } from "./app";
3
+ export {
4
+ handleCommentEvent,
5
+ handleIssueDeleteEvent,
6
+ handleIssueEvent,
7
+ resetBotAccountIdCache,
8
+ resolveBotAccountId,
9
+ } from "./sync";
10
+ export {
11
+ handleJiraWebhook,
12
+ isDuplicateDelivery,
13
+ markDelivery,
14
+ synthesizeDeliveryId,
15
+ verifyJiraWebhookToken,
16
+ } from "./webhook";
17
+ export type { RegisterJiraWebhookResult } from "./webhook-lifecycle";
18
+ export {
19
+ deleteJiraWebhook,
20
+ refreshJiraWebhooks,
21
+ registerJiraWebhook,
22
+ startJiraWebhookKeepalive,
23
+ stopJiraWebhookKeepalive,
24
+ } from "./webhook-lifecycle";