@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.
Files changed (45) hide show
  1. package/openapi.json +226 -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 +395 -0
  12. package/src/http/trackers/linear.ts +47 -4
  13. package/src/http/utils.ts +27 -0
  14. package/src/jira/adf.ts +132 -0
  15. package/src/jira/app.ts +83 -0
  16. package/src/jira/client.ts +82 -0
  17. package/src/jira/index.ts +24 -0
  18. package/src/jira/metadata.ts +117 -0
  19. package/src/jira/oauth.ts +98 -0
  20. package/src/jira/outbound.ts +155 -0
  21. package/src/jira/sync.ts +534 -0
  22. package/src/jira/templates.ts +84 -0
  23. package/src/jira/types.ts +35 -0
  24. package/src/jira/webhook-lifecycle.ts +363 -0
  25. package/src/jira/webhook.ts +159 -0
  26. package/src/linear/app.ts +17 -0
  27. package/src/linear/oauth.ts +24 -0
  28. package/src/oauth/wrapper.ts +11 -1
  29. package/src/tasks/context-key.ts +29 -1
  30. package/src/telemetry.ts +38 -3
  31. package/src/tests/context-key.test.ts +19 -0
  32. package/src/tests/jira-adf.test.ts +239 -0
  33. package/src/tests/jira-metadata.test.ts +147 -0
  34. package/src/tests/jira-oauth.test.ts +167 -0
  35. package/src/tests/jira-outbound-sync.test.ts +334 -0
  36. package/src/tests/jira-sync.test.ts +327 -0
  37. package/src/tests/jira-webhook-lifecycle.test.ts +234 -0
  38. package/src/tests/jira-webhook.test.ts +274 -0
  39. package/src/tests/telemetry-init.test.ts +108 -0
  40. package/src/tools/tracker/tracker-link-task.ts +1 -1
  41. package/src/tools/tracker/tracker-map-agent.ts +1 -1
  42. package/src/tools/tracker/tracker-status.ts +1 -1
  43. package/src/tools/tracker/tracker-sync-status.ts +1 -1
  44. package/src/tracker/types.ts +1 -1
  45. package/src/types.ts +1 -0
@@ -0,0 +1,363 @@
1
+ import { jiraFetch } from "./client";
2
+ import { getJiraMetadata, updateJiraMetadata } from "./metadata";
3
+
4
+ const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
5
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
6
+ /**
7
+ * Atlassian dynamic webhooks expire 30 days after registration / refresh. We
8
+ * default to that on register and let the keepalive tighten the value on the
9
+ * first refresh round-trip (Atlassian returns the authoritative expiry).
10
+ */
11
+ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
12
+ const SLACK_ALERTS_CHANNEL = process.env.SLACK_ALERTS_CHANNEL || "C08JCRURPBV";
13
+
14
+ const WEBHOOK_EVENTS = [
15
+ "jira:issue_updated",
16
+ "jira:issue_deleted",
17
+ "comment_created",
18
+ "comment_updated",
19
+ ] as const;
20
+
21
+ let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
22
+
23
+ // ─── URL helpers ─────────────────────────────────────────────────────────────
24
+
25
+ function getWebhookBaseUrl(): string {
26
+ return process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
27
+ }
28
+
29
+ function getRegisteredWebhookUrl(): string {
30
+ const token = process.env.JIRA_WEBHOOK_TOKEN;
31
+ if (!token) {
32
+ throw new Error(
33
+ "JIRA_WEBHOOK_TOKEN is not set — webhook registration would produce an unauthenticatable URL.",
34
+ );
35
+ }
36
+ return `${getWebhookBaseUrl()}/api/trackers/jira/webhook/${token}`;
37
+ }
38
+
39
+ // ─── Slack alert (best-effort) ───────────────────────────────────────────────
40
+
41
+ async function notifySlack(text: string): Promise<void> {
42
+ try {
43
+ const { getSlackApp } = await import("../slack/app");
44
+ const app = getSlackApp();
45
+ if (!app) {
46
+ console.warn("[Jira webhook keepalive] Slack not available, cannot send notification");
47
+ return;
48
+ }
49
+ await app.client.chat.postMessage({
50
+ channel: SLACK_ALERTS_CHANNEL,
51
+ text,
52
+ });
53
+ console.log("[Jira webhook keepalive] Slack notification sent");
54
+ } catch (slackErr) {
55
+ console.error(
56
+ "[Jira webhook keepalive] Failed to send Slack notification:",
57
+ slackErr instanceof Error ? slackErr.message : slackErr,
58
+ );
59
+ }
60
+ }
61
+
62
+ // ─── Public API ──────────────────────────────────────────────────────────────
63
+
64
+ export interface RegisterJiraWebhookResult {
65
+ webhookId: number;
66
+ expiresAt: string;
67
+ jql: string;
68
+ }
69
+
70
+ /**
71
+ * Register a dynamic webhook with Atlassian.
72
+ *
73
+ * The registered URL embeds `JIRA_WEBHOOK_TOKEN` in the path so inbound
74
+ * deliveries can be authenticated (Atlassian does not HMAC-sign OAuth 3LO
75
+ * dynamic webhooks — see plan errata I8).
76
+ *
77
+ * Atlassian's response shape:
78
+ * { webhookRegistrationResult: [{ createdWebhookId: number, errors?: string[] }] }
79
+ *
80
+ * The response does NOT include the expiry on registration — we default to
81
+ * `now + 30 days` (the documented webhook lifetime) and let the first refresh
82
+ * tick replace it with Atlassian's authoritative `expirationDate`.
83
+ */
84
+ export async function registerJiraWebhook(jqlFilter: string): Promise<RegisterJiraWebhookResult> {
85
+ const url = getRegisteredWebhookUrl();
86
+
87
+ const response = await jiraFetch("/rest/api/3/webhook", {
88
+ method: "POST",
89
+ body: JSON.stringify({
90
+ url,
91
+ webhooks: [
92
+ {
93
+ events: WEBHOOK_EVENTS,
94
+ jqlFilter,
95
+ fieldIdsFilter: ["assignee"],
96
+ },
97
+ ],
98
+ }),
99
+ });
100
+
101
+ if (!response.ok) {
102
+ const errorText = await response.text().catch(() => "");
103
+ throw new Error(`Jira webhook registration failed (${response.status}): ${errorText}`);
104
+ }
105
+
106
+ const payload = (await response.json()) as {
107
+ webhookRegistrationResult?: Array<{
108
+ createdWebhookId?: number;
109
+ errors?: string[];
110
+ }>;
111
+ };
112
+
113
+ const results = payload.webhookRegistrationResult ?? [];
114
+ if (results.length === 0) {
115
+ throw new Error(
116
+ "Jira webhook registration returned no results — payload may have been rejected upstream",
117
+ );
118
+ }
119
+
120
+ const first = results[0];
121
+ if (!first || typeof first.createdWebhookId !== "number") {
122
+ const errors = first?.errors?.join("; ") ?? "no createdWebhookId";
123
+ throw new Error(`Jira webhook registration entry malformed: ${errors}`);
124
+ }
125
+
126
+ const webhookId = first.createdWebhookId;
127
+ const expiresAt = new Date(Date.now() + THIRTY_DAYS_MS).toISOString();
128
+
129
+ // Persist via the read-modify-write helper so we don't clobber cloudId/siteUrl.
130
+ // updateJiraMetadata's id-keyed merge preserves any other webhookIds rows.
131
+ updateJiraMetadata({
132
+ webhookIds: [{ id: webhookId, expiresAt, jql: jqlFilter }],
133
+ });
134
+
135
+ console.log(
136
+ `[Jira webhook keepalive] Registered webhook id=${webhookId} jql='${jqlFilter}' (default expiry ${expiresAt})`,
137
+ );
138
+
139
+ return { webhookId, expiresAt, jql: jqlFilter };
140
+ }
141
+
142
+ /**
143
+ * Delete a dynamic webhook from Atlassian and remove it from `metadata.webhookIds`.
144
+ *
145
+ * Per Atlassian REST v3: `DELETE /rest/api/3/webhook` with body
146
+ * `{ webhookIds: [<int>, ...] }`. Atlassian silently ignores ids it doesn't
147
+ * recognize, so this is safe to call with stale local entries.
148
+ */
149
+ export async function deleteJiraWebhook(webhookId: number): Promise<void> {
150
+ const response = await jiraFetch("/rest/api/3/webhook", {
151
+ method: "DELETE",
152
+ body: JSON.stringify({ webhookIds: [webhookId] }),
153
+ });
154
+
155
+ if (!response.ok && response.status !== 204) {
156
+ const errorText = await response.text().catch(() => "");
157
+ throw new Error(`Jira webhook delete failed (${response.status}): ${errorText}`);
158
+ }
159
+
160
+ // Remove the id from local metadata regardless of whether Atlassian had it.
161
+ const meta = getJiraMetadata();
162
+ const remaining = (meta.webhookIds ?? []).filter((entry) => entry.id !== webhookId);
163
+
164
+ // updateJiraMetadata's id-keyed merge can't drop entries (it merges by id).
165
+ // For removal we need the read-modify-write to overwrite the array. Bypass
166
+ // the merge by writing an empty `webhookIds` partial isn't supported either,
167
+ // so we manually compose the full list and call updateJiraMetadata once for
168
+ // each remaining entry — but that's quadratic. Instead: mutate via a
169
+ // single UPDATE statement bypassing the helper. Done in a transaction-safe
170
+ // way below.
171
+ await overwriteWebhookIds(remaining);
172
+
173
+ console.log(`[Jira webhook keepalive] Deleted webhook id=${webhookId}`);
174
+ }
175
+
176
+ /**
177
+ * Direct overwrite of the `webhookIds` array — needed when removing entries
178
+ * because `updateJiraMetadata`'s id-keyed merge cannot delete.
179
+ *
180
+ * Reads the current metadata, replaces just the `webhookIds` key, persists.
181
+ * Race-window with concurrent registrations is acceptable: deletions are
182
+ * rare admin actions, and the next register call will re-add anything we
183
+ * accidentally race-stomped.
184
+ */
185
+ async function overwriteWebhookIds(
186
+ next: Array<{ id: number; expiresAt: string; jql: string }>,
187
+ ): Promise<void> {
188
+ const { getDb } = await import("../be/db");
189
+ const db = getDb();
190
+ const txn = db.transaction(() => {
191
+ const row = db.query("SELECT metadata FROM oauth_apps WHERE provider = 'jira'").get() as {
192
+ metadata: string | null;
193
+ } | null;
194
+
195
+ if (!row) {
196
+ throw new Error(
197
+ "[jira.webhook-lifecycle] oauth_apps row missing for provider='jira' — call initJira() first",
198
+ );
199
+ }
200
+
201
+ let current: Record<string, unknown> = {};
202
+ try {
203
+ const parsed = JSON.parse(row.metadata || "{}");
204
+ if (parsed && typeof parsed === "object") {
205
+ current = parsed as Record<string, unknown>;
206
+ }
207
+ } catch {
208
+ // fall through with empty
209
+ }
210
+
211
+ const merged = { ...current, webhookIds: next };
212
+ db.query(
213
+ "UPDATE oauth_apps SET metadata = ?, updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE provider = 'jira'",
214
+ ).run(JSON.stringify(merged));
215
+ });
216
+ txn();
217
+ }
218
+
219
+ /**
220
+ * Refresh known webhooks. Atlassian's `PUT /rest/api/3/webhook/refresh` takes
221
+ * `{ webhookIds: [...] }` and returns either:
222
+ * - 200 + `{ expirationDate: "<ISO-8601>" }` (applies to ALL refreshed webhooks)
223
+ * - 204 No Content (older API variant)
224
+ *
225
+ * Unrecognized ids are silently ignored. On a successful refresh that returns
226
+ * an `expirationDate`, we update every locally-known webhook to that expiry.
227
+ * On 204 (no body), we treat the call as best-effort and log a warning.
228
+ */
229
+ export async function refreshJiraWebhooks(): Promise<void> {
230
+ const meta = getJiraMetadata();
231
+ const ids = (meta.webhookIds ?? []).map((entry) => entry.id);
232
+
233
+ if (ids.length === 0) {
234
+ console.log("[Jira webhook keepalive] No webhooks to refresh");
235
+ return;
236
+ }
237
+
238
+ console.log(`[Jira webhook keepalive] Refreshing ${ids.length} webhook(s): ${ids.join(", ")}`);
239
+
240
+ const response = await jiraFetch("/rest/api/3/webhook/refresh", {
241
+ method: "PUT",
242
+ body: JSON.stringify({ webhookIds: ids }),
243
+ });
244
+
245
+ if (!response.ok) {
246
+ const errorText = await response.text().catch(() => "");
247
+ throw new Error(`Jira webhook refresh failed (${response.status}): ${errorText}`);
248
+ }
249
+
250
+ // 204 No Content → nothing to write back.
251
+ if (response.status === 204) {
252
+ console.warn(
253
+ "[Jira webhook keepalive] Refresh returned 204 (no expirationDate) — local expiries left as-is. Stale entries will surface on next tick.",
254
+ );
255
+ return;
256
+ }
257
+
258
+ let payload: { expirationDate?: string } | null = null;
259
+ try {
260
+ payload = (await response.json()) as { expirationDate?: string };
261
+ } catch {
262
+ payload = null;
263
+ }
264
+
265
+ const expirationDate = payload?.expirationDate;
266
+ if (!expirationDate || typeof expirationDate !== "string") {
267
+ console.warn(
268
+ "[Jira webhook keepalive] Refresh succeeded but response had no expirationDate — local expiries left as-is",
269
+ );
270
+ return;
271
+ }
272
+
273
+ // Apply the new expiry to all known entries (Atlassian docs: the new
274
+ // expiration applies to ALL refreshed webhooks).
275
+ const updated = (meta.webhookIds ?? []).map((entry) => ({
276
+ ...entry,
277
+ expiresAt: expirationDate,
278
+ }));
279
+ updateJiraMetadata({ webhookIds: updated });
280
+
281
+ console.log(
282
+ `[Jira webhook keepalive] Refreshed ${updated.length} webhook(s) → expiresAt=${expirationDate}`,
283
+ );
284
+ }
285
+
286
+ /**
287
+ * Run the keepalive check once: refresh webhooks expiring within 7 days.
288
+ *
289
+ * Errors are caught and reported (Slack-best-effort) so the timer survives
290
+ * transient network / token failures.
291
+ */
292
+ async function runKeepalive(): Promise<void> {
293
+ try {
294
+ const meta = getJiraMetadata();
295
+ const entries = meta.webhookIds ?? [];
296
+ if (entries.length === 0) {
297
+ console.log("[Jira webhook keepalive] No webhooks registered, nothing to refresh");
298
+ return;
299
+ }
300
+
301
+ const now = Date.now();
302
+ const dueSoon = entries.filter((entry) => {
303
+ const expiry = Date.parse(entry.expiresAt);
304
+ if (!Number.isFinite(expiry)) return true; // malformed → refresh defensively
305
+ return expiry - now < SEVEN_DAYS_MS;
306
+ });
307
+
308
+ if (dueSoon.length === 0) {
309
+ console.log(
310
+ `[Jira webhook keepalive] All ${entries.length} webhook(s) have expiries >7 days out — skipping`,
311
+ );
312
+ return;
313
+ }
314
+
315
+ console.log(
316
+ `[Jira webhook keepalive] ${dueSoon.length}/${entries.length} webhook(s) expire within 7 days — refreshing all`,
317
+ );
318
+ await refreshJiraWebhooks();
319
+ } catch (err) {
320
+ const message = err instanceof Error ? err.message : String(err);
321
+ console.error(`[Jira webhook keepalive] Refresh failed: ${message}`);
322
+ await notifySlack(
323
+ `⚠️ *Jira webhook keepalive failed*\nError: ${message}\n\nWebhooks may expire — manual refresh via \`POST /api/trackers/jira/webhook-register\` may be required.`,
324
+ );
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Start the recurring keepalive timer.
330
+ *
331
+ * Per plan errata I5: runs an immediate expiry check on first invocation
332
+ * (a stale webhook surfaces on boot, not after the first 12-hour tick), then
333
+ * every 12 hours.
334
+ */
335
+ export function startJiraWebhookKeepalive(): void {
336
+ if (keepaliveInterval) {
337
+ console.log("[Jira webhook keepalive] Already running, skipping");
338
+ return;
339
+ }
340
+
341
+ console.log("[Jira webhook keepalive] Starting (12h interval, 7-day refresh threshold)");
342
+
343
+ // Immediate-on-boot check (after a short delay so server finishes startup
344
+ // and OAuth tokens are loaded). Mirrors src/oauth/keepalive.ts pattern.
345
+ setTimeout(() => {
346
+ runKeepalive();
347
+ }, 10_000);
348
+
349
+ keepaliveInterval = setInterval(() => {
350
+ runKeepalive();
351
+ }, TWELVE_HOURS_MS);
352
+ }
353
+
354
+ /**
355
+ * Stop the keepalive timer. Idempotent.
356
+ */
357
+ export function stopJiraWebhookKeepalive(): void {
358
+ if (keepaliveInterval) {
359
+ clearInterval(keepaliveInterval);
360
+ keepaliveInterval = null;
361
+ console.log("[Jira webhook keepalive] Stopped");
362
+ }
363
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Jira webhook receiver.
3
+ *
4
+ * Auth model: Atlassian does NOT HMAC-sign OAuth 3LO dynamic webhooks. Instead
5
+ * we embed a high-entropy random token (`JIRA_WEBHOOK_TOKEN`) in the path
6
+ * segment of the registered webhook URL and reject deliveries whose path
7
+ * token doesn't match (timing-safe compare). See plan errata I8.
8
+ *
9
+ * Dedup is DB-persisted (not a process-local Map) so it survives restarts:
10
+ * a synthetic deliveryId derived from `webhookEvent + timestamp + entity id +
11
+ * sha256(rawBody).slice(0,16)` is stored in `tracker_sync.lastDeliveryId`
12
+ * after each successful processing run.
13
+ */
14
+
15
+ import { createHash, timingSafeEqual } from "node:crypto";
16
+ import { hasTrackerDelivery, markTrackerDelivery } from "../be/db-queries/tracker";
17
+ import { handleCommentEvent, handleIssueDeleteEvent, handleIssueEvent } from "./sync";
18
+
19
+ /**
20
+ * Timing-safe compare of two URL-path tokens. Returns `false` for any of:
21
+ * - missing / empty `pathToken` or `expected`
22
+ * - length mismatch (we still call timingSafeEqual on padded buffers to
23
+ * avoid leaking length via early-return timing — both arguments are
24
+ * zero-padded to the longer length first)
25
+ * - byte-for-byte mismatch
26
+ */
27
+ export function verifyJiraWebhookToken(pathToken: string | undefined, expected: string): boolean {
28
+ if (!pathToken || !expected) return false;
29
+
30
+ const a = Buffer.from(pathToken, "utf8");
31
+ const b = Buffer.from(expected, "utf8");
32
+ const len = Math.max(a.length, b.length);
33
+ const padA = Buffer.alloc(len);
34
+ const padB = Buffer.alloc(len);
35
+ a.copy(padA);
36
+ b.copy(padB);
37
+
38
+ // timingSafeEqual on equal-length padded buffers; the length-mismatch path
39
+ // is "real != real after padding" → returns false in constant time.
40
+ const equalBytes = timingSafeEqual(padA, padB);
41
+ return equalBytes && a.length === b.length;
42
+ }
43
+
44
+ /**
45
+ * Build a deliveryId from the raw body and parsed envelope. Stable across
46
+ * retries of the same delivery. The body-hash suffix kills same-millisecond
47
+ * collisions on different events.
48
+ */
49
+ export function synthesizeDeliveryId(body: Record<string, unknown>, rawBody: string): string {
50
+ const event = String(body.webhookEvent ?? "");
51
+ const ts = String(body.timestamp ?? "");
52
+ const issue = body.issue as { id?: unknown } | undefined;
53
+ const comment = body.comment as { id?: unknown } | undefined;
54
+ const entityId = String(issue?.id ?? comment?.id ?? "_");
55
+ const hash = createHash("sha256").update(rawBody).digest("hex").slice(0, 16);
56
+ return `${event}:${ts}:${entityId}:${hash}`;
57
+ }
58
+
59
+ /** Public form so callers (and tests) can poke the same path. */
60
+ export function isDuplicateDelivery(deliveryId: string): boolean {
61
+ return hasTrackerDelivery("jira", deliveryId);
62
+ }
63
+
64
+ /** Mark a delivery as processed for the given Jira entity. */
65
+ export function markDelivery(externalId: string, deliveryId: string): void {
66
+ markTrackerDelivery("jira", "task", externalId, deliveryId);
67
+ }
68
+
69
+ // ─── Top-level dispatcher ──────────────────────────────────────────────────
70
+
71
+ export type WebhookResult = {
72
+ status: number;
73
+ body: unknown;
74
+ };
75
+
76
+ /**
77
+ * Process a raw Jira webhook delivery. Returns the HTTP response shape; the
78
+ * caller owns writing it to the wire.
79
+ *
80
+ * Contract:
81
+ * - 503 when `JIRA_WEBHOOK_TOKEN` is unset.
82
+ * - 401 with empty body on token mismatch (no info leak).
83
+ * - 200 + `{status: "accepted"|"duplicate"|"ignored"}` otherwise.
84
+ *
85
+ * Heavy work is fire-and-forget: we always return 200 once accepted to avoid
86
+ * Atlassian retrying on slow handlers.
87
+ */
88
+ export async function handleJiraWebhook(
89
+ pathToken: string | undefined,
90
+ rawBody: string,
91
+ ): Promise<WebhookResult> {
92
+ const expected = process.env.JIRA_WEBHOOK_TOKEN;
93
+ if (!expected) {
94
+ return { status: 503, body: { error: "Jira webhook handler not configured" } };
95
+ }
96
+
97
+ if (!verifyJiraWebhookToken(pathToken, expected)) {
98
+ // Empty body — no leakage about valid-vs-missing.
99
+ return { status: 401, body: "" };
100
+ }
101
+
102
+ let parsed: Record<string, unknown>;
103
+ try {
104
+ parsed = JSON.parse(rawBody) as Record<string, unknown>;
105
+ } catch (err) {
106
+ const msg = err instanceof Error ? err.message : String(err);
107
+ console.warn(`[Jira Webhook] Malformed JSON body: ${msg}`);
108
+ // Atlassian-side bug shouldn't trigger retries — accept and drop.
109
+ return { status: 200, body: { status: "ignored", reason: "invalid-json" } };
110
+ }
111
+
112
+ const deliveryId = synthesizeDeliveryId(parsed, rawBody);
113
+ if (isDuplicateDelivery(deliveryId)) {
114
+ return { status: 200, body: { status: "duplicate" } };
115
+ }
116
+
117
+ const event = String(parsed.webhookEvent ?? "");
118
+
119
+ // Fire-and-forget heavy work; return 200 immediately.
120
+ void dispatchAndRecord(event, parsed, deliveryId).catch((err) => {
121
+ console.error("[Jira Webhook] Error processing event:", err, {
122
+ event,
123
+ deliveryId,
124
+ });
125
+ });
126
+
127
+ return { status: 200, body: { status: "accepted" } };
128
+ }
129
+
130
+ async function dispatchAndRecord(
131
+ event: string,
132
+ body: Record<string, unknown>,
133
+ deliveryId: string,
134
+ ): Promise<void> {
135
+ const issue = body.issue as { id?: unknown } | undefined;
136
+ const externalId = typeof issue?.id === "string" ? issue.id : null;
137
+
138
+ switch (event) {
139
+ case "jira:issue_updated":
140
+ await handleIssueEvent(body);
141
+ break;
142
+ case "comment_created":
143
+ case "comment_updated":
144
+ await handleCommentEvent(body);
145
+ break;
146
+ case "jira:issue_deleted":
147
+ await handleIssueDeleteEvent(body);
148
+ break;
149
+ default:
150
+ console.log(`[Jira Webhook] Ignoring unhandled event: ${event}`);
151
+ return;
152
+ }
153
+
154
+ // Best-effort: stamp the delivery on whichever sync row exists for this
155
+ // entity. No-op when the handler decided not to create one.
156
+ if (externalId) {
157
+ markDelivery(externalId, deliveryId);
158
+ }
159
+ }
package/src/linear/app.ts CHANGED
@@ -43,6 +43,23 @@ export function initLinear(): boolean {
43
43
 
44
44
  initLinearOutboundSync();
45
45
 
46
+ warnIfMcpBaseUrlLooksLikeAppUrl();
47
+
46
48
  console.log("[Linear] Integration initialized");
47
49
  return true;
48
50
  }
51
+
52
+ /**
53
+ * Soft sanity check for `MCP_BASE_URL`. If it equals `APP_URL` (a common
54
+ * misconfig that surfaces a wrong-looking webhook URL in the dashboard),
55
+ * warn loudly so the operator can fix the env.
56
+ */
57
+ function warnIfMcpBaseUrlLooksLikeAppUrl(): void {
58
+ const mcp = process.env.MCP_BASE_URL?.trim().replace(/\/+$/, "");
59
+ const app = process.env.APP_URL?.trim().replace(/\/+$/, "");
60
+ if (mcp && app && mcp === app) {
61
+ console.warn(
62
+ `[Linear] WARNING: MCP_BASE_URL (${mcp}) equals APP_URL — surfaced webhook URL points at the dashboard host, not the API. Configure Linear with this URL only if the dashboard host also serves /api/*.`,
63
+ );
64
+ }
65
+ }
@@ -33,3 +33,27 @@ export async function handleLinearCallback(
33
33
  if (!config) throw new Error("Linear OAuth not configured");
34
34
  return exchangeCode(config, code, state);
35
35
  }
36
+
37
+ /**
38
+ * Revoke an OAuth access token with Linear. Best-effort — caller should not
39
+ * abort the disconnect flow if this fails. Linear's revocation endpoint is
40
+ * `POST https://api.linear.app/oauth/revoke` with the access token in the
41
+ * Authorization header (per https://developers.linear.app/docs/oauth/authentication).
42
+ *
43
+ * Returns true on a 2xx response, false otherwise.
44
+ */
45
+ export async function revokeLinearToken(accessToken: string): Promise<boolean> {
46
+ try {
47
+ const res = await fetch("https://api.linear.app/oauth/revoke", {
48
+ method: "POST",
49
+ headers: { Authorization: `Bearer ${accessToken}` },
50
+ });
51
+ return res.ok;
52
+ } catch (err) {
53
+ console.warn(
54
+ "[Linear] Token revocation failed (best-effort):",
55
+ err instanceof Error ? err.message : err,
56
+ );
57
+ return false;
58
+ }
59
+ }
@@ -13,6 +13,16 @@ export interface OAuthProviderConfig {
13
13
  scopes: string[];
14
14
  /** Extra query params appended to the authorization URL (e.g. { actor: "app" } for Linear) */
15
15
  extraParams?: Record<string, string>;
16
+ /**
17
+ * How to join `scopes` in the authorization URL.
18
+ *
19
+ * - Linear: `","` (its OAuth implementation requires comma-separated scopes).
20
+ * - Atlassian / RFC 6749 default: `" "` (space-separated).
21
+ *
22
+ * Defaults to `","` for backward compatibility with Linear, the only
23
+ * pre-existing consumer of this wrapper.
24
+ */
25
+ scopeSeparator?: string;
16
26
  }
17
27
 
18
28
  interface PendingState {
@@ -61,7 +71,7 @@ export async function buildAuthorizationUrl(
61
71
  url.searchParams.set("client_id", config.clientId);
62
72
  url.searchParams.set("redirect_uri", config.redirectUri);
63
73
  url.searchParams.set("response_type", "code");
64
- url.searchParams.set("scope", config.scopes.join(","));
74
+ url.searchParams.set("scope", config.scopes.join(config.scopeSeparator ?? ","));
65
75
  url.searchParams.set("state", state);
66
76
  url.searchParams.set("code_challenge", codeChallenge);
67
77
  url.searchParams.set("code_challenge_method", "S256");
@@ -12,6 +12,7 @@
12
12
  * task:trackers:github:{owner}:{repo}:{issue|pr}:{number}
13
13
  * task:trackers:gitlab:{projectId}:{mr|issue}:{iid}
14
14
  * task:trackers:linear:{issueIdentifier} (e.g. DES-42 — case preserved)
15
+ * task:trackers:jira:{issueIdentifier} (e.g. PROJ-123 — case preserved)
15
16
  * task:schedule:{scheduleId}
16
17
  * task:workflow:{workflowRunId}
17
18
  *
@@ -30,7 +31,7 @@ const SEPARATOR = ":";
30
31
 
31
32
  export type ContextKeyFamily = "slack" | "agentmail" | "trackers" | "schedule" | "workflow";
32
33
 
33
- export type TrackerProvider = "github" | "gitlab" | "linear";
34
+ export type TrackerProvider = "github" | "gitlab" | "linear" | "jira";
34
35
 
35
36
  export type ParsedContextKey =
36
37
  | { family: "slack"; parts: { channelId: string; threadTs: string } }
@@ -50,6 +51,11 @@ export type ParsedContextKey =
50
51
  subFamily: "linear";
51
52
  parts: { issueIdentifier: string };
52
53
  }
54
+ | {
55
+ family: "trackers";
56
+ subFamily: "jira";
57
+ parts: { issueIdentifier: string };
58
+ }
53
59
  | { family: "schedule"; parts: { scheduleId: string } }
54
60
  | { family: "workflow"; parts: { workflowRunId: string } };
55
61
 
@@ -129,6 +135,18 @@ export function linearContextKey(input: { issueIdentifier: string }): string {
129
135
  return ["task", "trackers", "linear", issueIdentifier].join(SEPARATOR);
130
136
  }
131
137
 
138
+ /**
139
+ * Build a Jira tracker context key. Plan Phase 1 names this `buildJiraContextKey`
140
+ * (positional `issueIdentifier`) rather than the `<provider>ContextKey({input})`
141
+ * shape used by older builders. New ingress sites should prefer this signature;
142
+ * the existing Linear/GitHub/GitLab builders are kept as-is to avoid touching
143
+ * unrelated call sites.
144
+ */
145
+ export function buildJiraContextKey(issueIdentifier: string): string {
146
+ const id = assertSafePart(issueIdentifier, "issueIdentifier");
147
+ return ["task", "trackers", "jira", id].join(SEPARATOR);
148
+ }
149
+
132
150
  export function scheduleContextKey(input: { scheduleId: string }): string {
133
151
  const scheduleId = assertSafePart(input.scheduleId, "scheduleId");
134
152
  return ["task", "schedule", scheduleId].join(SEPARATOR);
@@ -235,6 +253,16 @@ export function parseContextKey(key: string): ParsedContextKey {
235
253
  parts: { issueIdentifier: parts[3] as string },
236
254
  };
237
255
  }
256
+ if (subFamily === "jira") {
257
+ if (parts.length !== 4) {
258
+ throw new Error(`context-key: malformed jira key: ${JSON.stringify(key)}`);
259
+ }
260
+ return {
261
+ family: "trackers",
262
+ subFamily: "jira",
263
+ parts: { issueIdentifier: parts[3] as string },
264
+ };
265
+ }
238
266
  throw new Error(
239
267
  `context-key: unknown trackers sub-family "${subFamily}": ${JSON.stringify(key)}`,
240
268
  );