@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,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,83 @@
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
+ warnIfMcpBaseUrlLooksLikeAppUrl();
64
+
65
+ console.log("[Jira] Integration initialized");
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * Soft sanity check for `MCP_BASE_URL`. If it equals `APP_URL` (a common
71
+ * misconfig that points the webhook URL at the dashboard host), warn loudly
72
+ * so the operator can fix the env. We don't fail boot — Atlassian will just
73
+ * 404 webhook deliveries until corrected.
74
+ */
75
+ function warnIfMcpBaseUrlLooksLikeAppUrl(): void {
76
+ const mcp = process.env.MCP_BASE_URL?.trim().replace(/\/+$/, "");
77
+ const app = process.env.APP_URL?.trim().replace(/\/+$/, "");
78
+ if (mcp && app && mcp === app) {
79
+ console.warn(
80
+ `[Jira] WARNING: MCP_BASE_URL (${mcp}) equals APP_URL — registered webhook URLs will hit the dashboard host, not the API. Atlassian will likely 404 webhook deliveries. Point MCP_BASE_URL at the API server.`,
81
+ );
82
+ }
83
+ }
@@ -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";
@@ -0,0 +1,117 @@
1
+ import { getDb } from "../be/db";
2
+ import { getOAuthApp } from "../be/db-queries/oauth";
3
+ import type { JiraOAuthAppMetadata } from "./types";
4
+
5
+ /**
6
+ * Read the typed metadata blob for the `jira` provider.
7
+ *
8
+ * Falls back to an empty object if the provider row doesn't exist or the
9
+ * metadata column is unparseable JSON. We never throw on shape coercion —
10
+ * the keys are all optional and downstream callers already null-check.
11
+ */
12
+ export function getJiraMetadata(): JiraOAuthAppMetadata {
13
+ const app = getOAuthApp("jira");
14
+ if (!app) return {};
15
+
16
+ let parsed: unknown;
17
+ try {
18
+ parsed = JSON.parse(app.metadata || "{}");
19
+ } catch {
20
+ return {};
21
+ }
22
+
23
+ if (!parsed || typeof parsed !== "object") return {};
24
+
25
+ const meta = parsed as Record<string, unknown>;
26
+ const out: JiraOAuthAppMetadata = {};
27
+
28
+ if (typeof meta.cloudId === "string") out.cloudId = meta.cloudId;
29
+ if (typeof meta.siteUrl === "string") out.siteUrl = meta.siteUrl;
30
+ if (Array.isArray(meta.webhookIds)) {
31
+ out.webhookIds = meta.webhookIds.filter(
32
+ (entry): entry is { id: number; expiresAt: string; jql: string } =>
33
+ !!entry &&
34
+ typeof entry === "object" &&
35
+ typeof (entry as { id?: unknown }).id === "number" &&
36
+ typeof (entry as { expiresAt?: unknown }).expiresAt === "string" &&
37
+ typeof (entry as { jql?: unknown }).jql === "string",
38
+ );
39
+ }
40
+
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * Read-modify-write merge of the Jira `oauth_apps.metadata` blob.
46
+ *
47
+ * Wrapped in a single SQLite transaction so concurrent writers can't stomp
48
+ * each other's keys (e.g. Phase 2 cloudId write + Phase 5 webhookIds write).
49
+ *
50
+ * Merge semantics:
51
+ * - Scalar keys (`cloudId`, `siteUrl`): shallow merge — partial overwrites
52
+ * existing value if defined.
53
+ * - `webhookIds`: id-keyed merge — entries in `partial.webhookIds` replace
54
+ * existing entries with the same `id`, untouched ids are preserved.
55
+ *
56
+ * Throws if the `jira` provider row doesn't exist (caller must run `initJira()`
57
+ * before any metadata writes).
58
+ */
59
+ export function updateJiraMetadata(partial: Partial<JiraOAuthAppMetadata>): void {
60
+ const db = getDb();
61
+ const txn = db.transaction(() => {
62
+ const row = db.query("SELECT metadata FROM oauth_apps WHERE provider = 'jira'").get() as {
63
+ metadata: string | null;
64
+ } | null;
65
+
66
+ if (!row) {
67
+ throw new Error(
68
+ "[jira.metadata] oauth_apps row missing for provider='jira' — call initJira() first",
69
+ );
70
+ }
71
+
72
+ let current: JiraOAuthAppMetadata = {};
73
+ try {
74
+ const parsed = JSON.parse(row.metadata || "{}");
75
+ if (parsed && typeof parsed === "object") {
76
+ current = parsed as JiraOAuthAppMetadata;
77
+ }
78
+ } catch {
79
+ // Fall through with empty object
80
+ }
81
+
82
+ const merged: JiraOAuthAppMetadata = { ...current };
83
+
84
+ if (partial.cloudId !== undefined) merged.cloudId = partial.cloudId;
85
+ if (partial.siteUrl !== undefined) merged.siteUrl = partial.siteUrl;
86
+
87
+ if (partial.webhookIds !== undefined) {
88
+ const byId = new Map<number, { id: number; expiresAt: string; jql: string }>();
89
+ for (const existing of current.webhookIds ?? []) {
90
+ byId.set(existing.id, existing);
91
+ }
92
+ for (const incoming of partial.webhookIds) {
93
+ byId.set(incoming.id, incoming);
94
+ }
95
+ merged.webhookIds = [...byId.values()];
96
+ }
97
+
98
+ db.query(
99
+ "UPDATE oauth_apps SET metadata = ?, updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE provider = 'jira'",
100
+ ).run(JSON.stringify(merged));
101
+ });
102
+
103
+ txn();
104
+ }
105
+
106
+ /**
107
+ * Reset the Jira `oauth_apps.metadata` blob to `{}`. Used by the disconnect
108
+ * flow to drop cloudId, siteUrl, and webhookIds in one shot. The row itself
109
+ * stays — `initJira()` requires the `oauth_apps` row to exist.
110
+ */
111
+ export function clearJiraMetadata(): void {
112
+ getDb()
113
+ .query(
114
+ "UPDATE oauth_apps SET metadata = '{}', updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE provider = 'jira'",
115
+ )
116
+ .run();
117
+ }
@@ -0,0 +1,98 @@
1
+ import { getOAuthApp } from "../be/db-queries/oauth";
2
+ import { buildAuthorizationUrl, exchangeCode, type OAuthProviderConfig } from "../oauth/wrapper";
3
+ import { updateJiraMetadata } from "./metadata";
4
+ import type { JiraAccessibleResource } from "./types";
5
+
6
+ const ACCESSIBLE_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources";
7
+
8
+ /**
9
+ * Build the OAuth provider config for the generic wrapper.
10
+ *
11
+ * - `audience=api.atlassian.com` is required by the Atlassian 3LO flow.
12
+ * - We intentionally OMIT `prompt: "consent"`: forcing consent on every reconnect
13
+ * is UX noise. Atlassian's default (skip consent if scopes haven't changed)
14
+ * is what we want. (Plan errata I6.)
15
+ * - `scopeSeparator: " "` is critical — Atlassian wants space-separated scopes
16
+ * per RFC 6749, unlike Linear which requires commas.
17
+ */
18
+ export function getJiraOAuthConfig(): OAuthProviderConfig | null {
19
+ const app = getOAuthApp("jira");
20
+ if (!app) return null;
21
+
22
+ return {
23
+ provider: "jira",
24
+ clientId: app.clientId,
25
+ clientSecret: app.clientSecret,
26
+ authorizeUrl: app.authorizeUrl,
27
+ tokenUrl: app.tokenUrl,
28
+ redirectUri: app.redirectUri,
29
+ scopes: app.scopes.split(","),
30
+ scopeSeparator: " ",
31
+ extraParams: { audience: "api.atlassian.com" },
32
+ };
33
+ }
34
+
35
+ export async function getJiraAuthorizationUrl(): Promise<string | null> {
36
+ const config = getJiraOAuthConfig();
37
+ if (!config) return null;
38
+ const result = await buildAuthorizationUrl(config);
39
+ return result.url;
40
+ }
41
+
42
+ /**
43
+ * Handle the OAuth callback: exchange the authorization code for tokens (which
44
+ * `exchangeCode` persists via storeOAuthTokens), then resolve the workspace
45
+ * `cloudId` via the Atlassian accessible-resources endpoint and persist it
46
+ * into `oauth_apps.metadata`.
47
+ *
48
+ * v1 single-workspace constraint: we always pick the first resource and throw
49
+ * if the list is empty. Multi-workspace is a v2 concern.
50
+ */
51
+ export async function handleJiraCallback(
52
+ code: string,
53
+ state: string,
54
+ ): Promise<{
55
+ accessToken: string;
56
+ refreshToken?: string;
57
+ expiresIn?: number;
58
+ scope?: string;
59
+ cloudId: string;
60
+ siteUrl: string;
61
+ }> {
62
+ const config = getJiraOAuthConfig();
63
+ if (!config) throw new Error("Jira OAuth not configured");
64
+
65
+ const tokens = await exchangeCode(config, code, state);
66
+
67
+ const response = await fetch(ACCESSIBLE_RESOURCES_URL, {
68
+ headers: {
69
+ Authorization: `Bearer ${tokens.accessToken}`,
70
+ Accept: "application/json",
71
+ },
72
+ });
73
+
74
+ if (!response.ok) {
75
+ const errorText = await response.text();
76
+ throw new Error(`Jira accessible-resources fetch failed (${response.status}): ${errorText}`);
77
+ }
78
+
79
+ const resources = (await response.json()) as JiraAccessibleResource[];
80
+ if (!Array.isArray(resources) || resources.length === 0) {
81
+ throw new Error(
82
+ "Jira OAuth completed but no accessible resources returned — does the consenting user have access to any Jira site?",
83
+ );
84
+ }
85
+
86
+ const first = resources[0];
87
+ if (!first || typeof first.id !== "string" || typeof first.url !== "string") {
88
+ throw new Error("Jira accessible-resources returned malformed entry (missing id/url)");
89
+ }
90
+
91
+ updateJiraMetadata({ cloudId: first.id, siteUrl: first.url });
92
+
93
+ return {
94
+ ...tokens,
95
+ cloudId: first.id,
96
+ siteUrl: first.url,
97
+ };
98
+ }
@@ -0,0 +1,155 @@
1
+ import { getTrackerSync, updateTrackerSync } from "../be/db-queries/tracker";
2
+ import { workflowEventBus } from "../workflows/event-bus";
3
+ import { jiraFetch } from "./client";
4
+
5
+ let subscribed = false;
6
+
7
+ const LOOP_PREVENTION_WINDOW_MS = 5_000;
8
+ const OUTPUT_TRUNCATE_CHARS = 4000;
9
+
10
+ /**
11
+ * Subscribe Jira outbound sync to swarm task lifecycle events.
12
+ *
13
+ * For each event, looks up the `tracker_sync` row keyed on
14
+ * `(provider="jira", entityType="task", swarmId=<taskId>)`. If the task
15
+ * originated from a Jira issue, posts a plaintext comment via REST v2 to that
16
+ * issue, then flips `lastSyncOrigin → "swarm"` so the inbound webhook can
17
+ * skip the just-posted comment.
18
+ *
19
+ * Tasks WITHOUT a `tracker_sync` row (most swarm tasks) are silently ignored —
20
+ * the sync row's existence is the gate for "this task came from Jira and should
21
+ * post lifecycle updates back".
22
+ *
23
+ * Idempotent — calling twice is a no-op.
24
+ *
25
+ * Rate-limiting (v1): we rely on `jiraFetch`'s built-in 429 retry-with-
26
+ * `Retry-After` (single retry). If 100+ tasks complete simultaneously across
27
+ * many issues we may exhaust the retry budget and lose comments. Documented
28
+ * as a known v1 limitation; a per-issue debounce / bounded outbound queue
29
+ * could be added in v2 if it becomes a real problem.
30
+ */
31
+ export function initJiraOutboundSync(): void {
32
+ if (subscribed) return;
33
+ subscribed = true;
34
+
35
+ workflowEventBus.on("task.created", handleTaskCreated);
36
+ workflowEventBus.on("task.completed", handleTaskCompleted);
37
+ workflowEventBus.on("task.failed", handleTaskFailed);
38
+ workflowEventBus.on("task.cancelled", handleTaskCancelled);
39
+ console.log("[Jira] Outbound sync subscribed to event bus");
40
+ }
41
+
42
+ export function teardownJiraOutboundSync(): void {
43
+ if (!subscribed) return;
44
+ subscribed = false;
45
+
46
+ workflowEventBus.off("task.created", handleTaskCreated);
47
+ workflowEventBus.off("task.completed", handleTaskCompleted);
48
+ workflowEventBus.off("task.failed", handleTaskFailed);
49
+ workflowEventBus.off("task.cancelled", handleTaskCancelled);
50
+ console.log("[Jira] Outbound sync unsubscribed from event bus");
51
+ }
52
+
53
+ async function handleTaskCreated(data: unknown): Promise<void> {
54
+ const { taskId, task } = data as { taskId?: string; task?: string };
55
+ if (!taskId) return;
56
+
57
+ const summary = (task ?? "").trim();
58
+ // Use Unicode emoji directly — Jira REST v2 plaintext bodies do NOT expand
59
+ // shortcode forms like `:rocket:` (verified in Phase 3 manual testing).
60
+ const body = summary ? `🚀 Swarm task started: ${summary}` : "🚀 Swarm task started.";
61
+
62
+ await postLifecycleComment(taskId, "task.created", body);
63
+ }
64
+
65
+ async function handleTaskCompleted(data: unknown): Promise<void> {
66
+ const { taskId, output } = data as { taskId?: string; output?: string };
67
+ if (!taskId) return;
68
+
69
+ const trimmed = (output ?? "").slice(0, OUTPUT_TRUNCATE_CHARS);
70
+ const ellipsized = output && output.length > OUTPUT_TRUNCATE_CHARS ? `${trimmed}…` : trimmed;
71
+ const body = ellipsized
72
+ ? `✅ Swarm task completed.\n\n${ellipsized}`
73
+ : "✅ Swarm task completed.";
74
+
75
+ await postLifecycleComment(taskId, "task.completed", body);
76
+ }
77
+
78
+ async function handleTaskFailed(data: unknown): Promise<void> {
79
+ const { taskId, failureReason } = data as {
80
+ taskId?: string;
81
+ failureReason?: string;
82
+ };
83
+ if (!taskId) return;
84
+
85
+ const reason = failureReason ?? "(no failure reason recorded)";
86
+ const body = `❌ Swarm task failed.\n\n${reason}`;
87
+
88
+ await postLifecycleComment(taskId, "task.failed", body);
89
+ }
90
+
91
+ async function handleTaskCancelled(data: unknown): Promise<void> {
92
+ const { taskId } = data as { taskId?: string };
93
+ if (!taskId) return;
94
+
95
+ await postLifecycleComment(taskId, "task.cancelled", "⛔ Swarm task cancelled.");
96
+ }
97
+
98
+ async function postLifecycleComment(
99
+ taskId: string,
100
+ eventName: string,
101
+ body: string,
102
+ ): Promise<void> {
103
+ const sync = getTrackerSync("jira", "task", taskId);
104
+ if (!sync) return;
105
+
106
+ if (shouldSkipForLoopPrevention(sync)) {
107
+ console.log(
108
+ `[Jira Outbound] Skipping ${eventName} for task ${taskId} — recent external sync (loop prevention)`,
109
+ );
110
+ return;
111
+ }
112
+
113
+ // Prefer the readable key (e.g. "KAN-1") over the numeric issue id; both
114
+ // work with the REST v2 comment endpoint, but the key is what users see in
115
+ // logs.
116
+ const issueRef = sync.externalIdentifier ?? sync.externalId;
117
+
118
+ try {
119
+ const response = await jiraFetch(`/rest/api/2/issue/${issueRef}/comment`, {
120
+ method: "POST",
121
+ body: JSON.stringify({ body }),
122
+ });
123
+
124
+ if (!response.ok) {
125
+ const text = await response.text().catch(() => "<unreadable body>");
126
+ console.error(
127
+ `[Jira Outbound] Failed to post ${eventName} comment for task ${taskId} → ${issueRef}: HTTP ${response.status} ${text}`,
128
+ );
129
+ // Don't update tracker_sync on failure — leave the prior origin/timestamp
130
+ // intact so a subsequent event isn't loop-suppressed by a partial write.
131
+ return;
132
+ }
133
+
134
+ updateTrackerSync(sync.id, {
135
+ lastSyncOrigin: "swarm",
136
+ lastSyncedAt: new Date().toISOString(),
137
+ });
138
+ console.log(`[Jira Outbound] Posted ${eventName} comment for task ${taskId} → ${issueRef}`);
139
+ } catch (error) {
140
+ console.error(
141
+ `[Jira Outbound] Error posting ${eventName} comment for task ${taskId} → ${issueRef}:`,
142
+ error instanceof Error ? error.message : error,
143
+ );
144
+ }
145
+ }
146
+
147
+ function shouldSkipForLoopPrevention(sync: {
148
+ lastSyncOrigin: string | null;
149
+ lastSyncedAt: string;
150
+ }): boolean {
151
+ if (sync.lastSyncOrigin !== "external") return false;
152
+ const lastSyncTime = new Date(sync.lastSyncedAt).getTime();
153
+ if (Number.isNaN(lastSyncTime)) return false;
154
+ return Date.now() - lastSyncTime < LOOP_PREVENTION_WINDOW_MS;
155
+ }