@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/jira/adf.ts
ADDED
|
@@ -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
|
+
}
|
package/src/jira/app.ts
ADDED
|
@@ -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
|
+
}
|