@bike4mind/cli 0.2.46-fix-empty-text-content-block.20674 → 0.2.47-docs-clawdbot-comparison-analysis.20779

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.
@@ -1677,6 +1677,83 @@ var CliToolResponseAction = z10.object({
1677
1677
  content: z10.unknown().optional(),
1678
1678
  error: z10.string().optional()
1679
1679
  });
1680
+ var KeepCommandRequestAction = z10.object({
1681
+ action: z10.literal("keep_command_request"),
1682
+ accessToken: z10.string().optional(),
1683
+ commandType: z10.enum(["read_file", "list_directory", "run_tool"]),
1684
+ params: z10.record(z10.string(), z10.unknown()),
1685
+ requestId: z10.string()
1686
+ });
1687
+ var KeepCommandAction = z10.object({
1688
+ action: z10.literal("keep_command"),
1689
+ commandType: z10.enum(["read_file", "list_directory", "run_tool"]),
1690
+ params: z10.record(z10.string(), z10.unknown()),
1691
+ requestId: z10.string(),
1692
+ originConnectionId: z10.string()
1693
+ });
1694
+ var KeepCommandResponseAction = z10.object({
1695
+ action: z10.literal("keep_command_response"),
1696
+ requestId: z10.string(),
1697
+ originConnectionId: z10.string(),
1698
+ success: z10.boolean(),
1699
+ result: z10.unknown().optional(),
1700
+ error: z10.string().optional()
1701
+ });
1702
+ var KeepCommandResultAction = z10.object({
1703
+ action: z10.literal("keep_command_result"),
1704
+ requestId: z10.string(),
1705
+ success: z10.boolean(),
1706
+ result: z10.unknown().optional(),
1707
+ error: z10.string().optional()
1708
+ });
1709
+ var TilePositionSchema = z10.object({ x: z10.number(), y: z10.number() });
1710
+ var SceneCommandSchema = z10.discriminatedUnion("type", [
1711
+ z10.object({
1712
+ type: z10.literal("add_entity"),
1713
+ params: z10.object({
1714
+ id: z10.string(),
1715
+ spriteSheetId: z10.string(),
1716
+ position: TilePositionSchema,
1717
+ facing: z10.enum(["left", "right"]).optional(),
1718
+ animation: z10.string().optional(),
1719
+ visible: z10.boolean().optional(),
1720
+ zIndex: z10.number().optional(),
1721
+ metadata: z10.record(z10.string(), z10.unknown()).optional()
1722
+ })
1723
+ }),
1724
+ z10.object({ type: z10.literal("remove_entity"), id: z10.string() }),
1725
+ z10.object({
1726
+ type: z10.literal("walk_to"),
1727
+ id: z10.string(),
1728
+ target: TilePositionSchema,
1729
+ options: z10.object({ speed: z10.number().optional() }).optional()
1730
+ }),
1731
+ z10.object({ type: z10.literal("teleport"), id: z10.string(), position: TilePositionSchema }),
1732
+ z10.object({ type: z10.literal("play_animation"), id: z10.string(), animation: z10.string(), loop: z10.boolean().optional() }),
1733
+ z10.object({
1734
+ type: z10.literal("show_speech"),
1735
+ id: z10.string(),
1736
+ text: z10.string(),
1737
+ options: z10.object({
1738
+ duration: z10.number().optional(),
1739
+ style: z10.enum(["speech", "thought", "shout"]).optional()
1740
+ }).optional()
1741
+ }),
1742
+ z10.object({ type: z10.literal("clear_speech"), id: z10.string() }),
1743
+ z10.object({ type: z10.literal("set_visible"), id: z10.string(), visible: z10.boolean() }),
1744
+ z10.object({ type: z10.literal("set_facing"), id: z10.string(), facing: z10.enum(["left", "right"]) })
1745
+ ]);
1746
+ var TavernSceneCommandRequestAction = z10.object({
1747
+ action: z10.literal("tavern_scene_command"),
1748
+ accessToken: z10.string().optional(),
1749
+ commands: z10.array(SceneCommandSchema),
1750
+ requestId: z10.string().optional()
1751
+ });
1752
+ var TavernSceneBroadcastAction = z10.object({
1753
+ action: z10.literal("tavern_scene_broadcast"),
1754
+ commands: z10.array(SceneCommandSchema),
1755
+ clientId: z10.string().optional()
1756
+ });
1680
1757
  var SessionCreatedAction = shareableDocumentSchema.extend({
1681
1758
  action: z10.literal("session.created"),
1682
1759
  id: z10.string(),
@@ -1713,7 +1790,10 @@ var MessageDataToServer = z10.discriminatedUnion("action", [
1713
1790
  VoiceSessionSendTranscriptAction,
1714
1791
  VoiceSessionEndedAction,
1715
1792
  CliCompletionRequestAction,
1716
- CliToolRequestAction
1793
+ CliToolRequestAction,
1794
+ KeepCommandRequestAction,
1795
+ KeepCommandResponseAction,
1796
+ TavernSceneCommandRequestAction
1717
1797
  ]);
1718
1798
  var MessageDataToClient = z10.discriminatedUnion("action", [
1719
1799
  DataSubscriptionUpdateAction,
@@ -1743,7 +1823,10 @@ var MessageDataToClient = z10.discriminatedUnion("action", [
1743
1823
  CliCompletionChunkAction,
1744
1824
  CliCompletionDoneAction,
1745
1825
  CliCompletionErrorAction,
1746
- CliToolResponseAction
1826
+ CliToolResponseAction,
1827
+ KeepCommandAction,
1828
+ KeepCommandResultAction,
1829
+ TavernSceneBroadcastAction
1747
1830
  ]);
1748
1831
 
1749
1832
  // ../../b4m-core/packages/common/dist/src/schemas/cliCompletions.js
@@ -2701,6 +2784,7 @@ var SettingKeySchema = z21.enum([
2701
2784
  "EnableHelpChat",
2702
2785
  // B4M PI SETTINGS
2703
2786
  "EnableBmPi",
2787
+ "EnableBmPiJira",
2704
2788
  // QUANTUM CANVASSER SETTINGS
2705
2789
  "EnableQuantumCanvasser",
2706
2790
  // CONTEXT TELEMETRY SETTINGS
@@ -3268,7 +3352,8 @@ var API_SERVICE_GROUPS = {
3268
3352
  { key: "EnableParallelToolExecution", order: 17 },
3269
3353
  { key: "EnableHelpChat", order: 18 },
3270
3354
  { key: "EnableBmPi", order: 19 },
3271
- { key: "EnableQuantumCanvasser", order: 20 }
3355
+ { key: "EnableBmPiJira", order: 20 },
3356
+ { key: "EnableQuantumCanvasser", order: 21 }
3272
3357
  ]
3273
3358
  },
3274
3359
  NOTEBOOK: {
@@ -4479,6 +4564,16 @@ var settingsMap = {
4479
4564
  group: API_SERVICE_GROUPS.EXPERIMENTAL.id,
4480
4565
  order: 18
4481
4566
  }),
4567
+ EnableBmPiJira: makeBooleanSetting({
4568
+ key: "EnableBmPiJira",
4569
+ name: "Enable B4M Pi \u2014 Jira Integration",
4570
+ defaultValue: false,
4571
+ description: 'Show Jira source toggle and Jira views in the B4M Pi dashboard. Requires "Enable B4M Pi" to be on.',
4572
+ category: "Experimental",
4573
+ group: API_SERVICE_GROUPS.EXPERIMENTAL.id,
4574
+ order: 19,
4575
+ dependsOn: "EnableBmPi"
4576
+ }),
4482
4577
  // OptiHashi Settings
4483
4578
  EnableQuantumCanvasser: makeBooleanSetting({
4484
4579
  key: "EnableQuantumCanvasser",
@@ -7624,7 +7719,8 @@ function formatIssueDetails(issue, siteUrl) {
7624
7719
  priority: issue.fields?.priority?.name,
7625
7720
  assignee: issue.fields?.assignee ? {
7626
7721
  accountId: issue.fields.assignee.accountId,
7627
- displayName: issue.fields.assignee.displayName
7722
+ displayName: issue.fields.assignee.displayName,
7723
+ accountType: issue.fields.assignee.accountType
7628
7724
  } : null,
7629
7725
  reporter: issue.fields?.reporter ? {
7630
7726
  accountId: issue.fields.reporter.accountId,
@@ -7648,7 +7744,8 @@ function formatSearchResults2(searchResult, siteUrl) {
7648
7744
  total: searchResult.total || 0,
7649
7745
  startAt: searchResult.startAt || 0,
7650
7746
  maxResults: searchResult.maxResults || 0,
7651
- issues
7747
+ issues,
7748
+ ...searchResult.nextPageToken && { nextPageToken: searchResult.nextPageToken }
7652
7749
  };
7653
7750
  }
7654
7751
  function formatProjectList(response, siteUrl) {
@@ -7703,7 +7800,7 @@ function formatTransitions(transitionsResponse) {
7703
7800
  name: transition.name,
7704
7801
  to: transition.to
7705
7802
  })) : [];
7706
- return transitions;
7803
+ return { transitions };
7707
7804
  }
7708
7805
  function formatUser(user) {
7709
7806
  if (!user || typeof user !== "object" || user.error || user.errors) {
@@ -8056,1449 +8153,1483 @@ var AgileApi = class {
8056
8153
  }
8057
8154
  };
8058
8155
 
8059
- // ../../b4m-core/packages/common/dist/src/jira/webhook/format.js
8060
- function daysUntil(dateString) {
8061
- const expirationDate = new Date(dateString);
8062
- const now = /* @__PURE__ */ new Date();
8063
- const diffMs = expirationDate.getTime() - now.getTime();
8064
- return Math.ceil(diffMs / (1e3 * 60 * 60 * 24));
8065
- }
8066
- function formatWebhook(webhook) {
8067
- const daysUntilExpiry = daysUntil(webhook.expirationDate);
8068
- return {
8069
- id: webhook.id,
8070
- events: webhook.events,
8071
- jqlFilter: webhook.jqlFilter,
8072
- expirationDate: webhook.expirationDate,
8073
- daysUntilExpiry,
8074
- isExpiringSoon: daysUntilExpiry < 7
8075
- };
8156
+ // ../../b4m-core/packages/common/dist/src/jira/api.js
8157
+ var JIRA_MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024;
8158
+ var JIRA_PROJECT_KEY_RE = /^[A-Z][A-Z0-9_]*$/i;
8159
+ function isValidProjectKey(key) {
8160
+ return JIRA_PROJECT_KEY_RE.test(key);
8076
8161
  }
8077
- function formatWebhookList(response) {
8078
- return {
8079
- webhooks: response.values.map(formatWebhook),
8080
- total: response.total,
8081
- hasMore: !response.isLast
8082
- };
8162
+ function isValidIssueKey(key) {
8163
+ return /^[A-Z][A-Z0-9]*-\d+$/.test(key);
8083
8164
  }
8084
- function extractAdfText(body, maxLength = 500) {
8085
- if (!body || typeof body !== "object")
8086
- return "";
8087
- const doc = body;
8088
- if (doc.type !== "doc" || !Array.isArray(doc.content))
8089
- return "";
8090
- const parts = [];
8091
- let totalLength = 0;
8092
- function walk(nodes) {
8093
- for (const node of nodes) {
8094
- if (totalLength >= maxLength)
8095
- return;
8096
- if (!node || typeof node !== "object")
8097
- continue;
8098
- const n = node;
8099
- if (n.type === "text" && typeof n.text === "string") {
8100
- const remaining = maxLength - totalLength;
8101
- const text = n.text.slice(0, remaining);
8102
- parts.push(text);
8103
- totalLength += text.length;
8104
- }
8105
- if (Array.isArray(n.content)) {
8106
- walk(n.content);
8107
- }
8165
+ function wikiMarkupToAdf(text) {
8166
+ const lines = text.split("\n");
8167
+ const content = [];
8168
+ let currentTableRows = [];
8169
+ let inTable = false;
8170
+ for (const line of lines) {
8171
+ const trimmedLine = line.trim();
8172
+ if (trimmedLine.startsWith("||") && trimmedLine.endsWith("||")) {
8173
+ inTable = true;
8174
+ const cells = trimmedLine.slice(2, -2).split("||").map((cell) => cell.trim());
8175
+ const headerRow = {
8176
+ type: "tableRow",
8177
+ content: cells.map((cell) => ({
8178
+ type: "tableHeader",
8179
+ attrs: {},
8180
+ content: [{ type: "paragraph", content: [{ type: "text", text: cell || " " }] }]
8181
+ }))
8182
+ };
8183
+ currentTableRows.push(headerRow);
8184
+ continue;
8185
+ }
8186
+ if (trimmedLine.startsWith("|") && trimmedLine.endsWith("|") && !trimmedLine.startsWith("||")) {
8187
+ inTable = true;
8188
+ const cells = trimmedLine.slice(1, -1).split("|").map((cell) => cell.trim());
8189
+ const dataRow = {
8190
+ type: "tableRow",
8191
+ content: cells.map((cell) => ({
8192
+ type: "tableCell",
8193
+ attrs: {},
8194
+ content: [{ type: "paragraph", content: [{ type: "text", text: cell || " " }] }]
8195
+ }))
8196
+ };
8197
+ currentTableRows.push(dataRow);
8198
+ continue;
8199
+ }
8200
+ if (inTable && currentTableRows.length > 0) {
8201
+ const table = {
8202
+ type: "table",
8203
+ attrs: { isNumberColumnEnabled: false, layout: "default" },
8204
+ content: currentTableRows
8205
+ };
8206
+ content.push(table);
8207
+ currentTableRows = [];
8208
+ inTable = false;
8209
+ }
8210
+ if (trimmedLine) {
8211
+ content.push({
8212
+ type: "paragraph",
8213
+ content: [{ type: "text", text: trimmedLine }]
8214
+ });
8108
8215
  }
8109
8216
  }
8110
- walk(doc.content);
8111
- const result = parts.join("");
8112
- if (totalLength >= maxLength)
8113
- return result + "...";
8114
- return result;
8115
- }
8116
- function escapeSlackMrkdwn(text) {
8117
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\*/g, "\u2217").replace(/_/g, "\uFF3F").replace(/~/g, "\u223C").replace(/`/g, "\u02CB");
8217
+ if (currentTableRows.length > 0) {
8218
+ const table = {
8219
+ type: "table",
8220
+ attrs: { isNumberColumnEnabled: false, layout: "default" },
8221
+ content: currentTableRows
8222
+ };
8223
+ content.push(table);
8224
+ }
8225
+ if (content.length === 0) {
8226
+ content.push({ type: "paragraph", content: [{ type: "text", text: " " }] });
8227
+ }
8228
+ return { type: "doc", version: 1, content };
8118
8229
  }
8119
- var PRIORITY_EMOJI = {
8120
- Highest: ":red_circle:",
8121
- High: ":large_orange_circle:",
8122
- Medium: ":large_yellow_circle:",
8123
- Low: ":large_green_circle:",
8124
- Lowest: ":white_circle:"
8125
- };
8126
- function getPriorityEmoji(priority) {
8127
- if (!priority)
8128
- return "";
8129
- return PRIORITY_EMOJI[priority] || ":blue_circle:";
8230
+ function containsWikiTable(text) {
8231
+ return /^\|\|.+\|\|$/m.test(text) || /^\|(?!\|).+\|$/m.test(text);
8130
8232
  }
8131
- function formatIssueEventForSlack(event, siteUrl) {
8132
- const { issue, webhookEvent, user, changelog } = event;
8133
- const { fields } = issue;
8134
- const priorityEmoji = getPriorityEmoji(fields.priority?.name);
8135
- const issueUrl = `${siteUrl}/browse/${issue.key}`;
8136
- let action;
8137
- let emoji;
8138
- switch (webhookEvent) {
8139
- case "jira:issue_created":
8140
- action = "created";
8141
- emoji = ":new:";
8142
- break;
8143
- case "jira:issue_updated":
8144
- action = "updated";
8145
- emoji = ":pencil2:";
8146
- break;
8147
- case "jira:issue_deleted":
8148
- action = "deleted";
8149
- emoji = ":wastebasket:";
8150
- break;
8151
- default:
8152
- action = "changed";
8153
- emoji = ":bell:";
8233
+ var JiraApi = class {
8234
+ config;
8235
+ constructor(config) {
8236
+ this.config = config;
8154
8237
  }
8155
- let changelogText = "";
8156
- if (changelog?.items && changelog.items.length > 0) {
8157
- const changes = changelog.items.slice(0, 3).map((item) => {
8158
- const from = escapeSlackMrkdwn(item.fromString || "None");
8159
- const to = escapeSlackMrkdwn(item.toString || "None");
8160
- if (item.field === "status") {
8161
- return `Status: ${from} \u2192 ${to}`;
8162
- }
8163
- if (item.field === "assignee") {
8164
- return `Assignee: ${escapeSlackMrkdwn(item.fromString || "Unassigned")} \u2192 ${escapeSlackMrkdwn(item.toString || "Unassigned")}`;
8165
- }
8166
- if (item.field === "priority") {
8167
- return `Priority: ${from} \u2192 ${to}`;
8168
- }
8169
- return `${escapeSlackMrkdwn(item.field)}: ${from} \u2192 ${to}`;
8238
+ /**
8239
+ * Build URL with query parameters
8240
+ */
8241
+ buildUrl(path, query = {}) {
8242
+ const base = `${this.config.apiBaseUrl}${path}`;
8243
+ const url = new URL(base);
8244
+ Object.entries(query).forEach(([key, value]) => {
8245
+ if (value === void 0 || value === "")
8246
+ return;
8247
+ url.searchParams.append(key, String(value));
8170
8248
  });
8171
- if (changes.length > 0) {
8172
- changelogText = "\n" + changes.map((c) => `\u2022 ${c}`).join("\n");
8173
- if (changelog.items.length > 3) {
8174
- changelogText += `
8175
- _...and ${changelog.items.length - 3} more changes_`;
8249
+ return url.toString();
8250
+ }
8251
+ /**
8252
+ * Build web URL for Jira UI links
8253
+ */
8254
+ buildWebUrl(path) {
8255
+ if (!path)
8256
+ return "";
8257
+ const cleanPath = path.startsWith("/") ? path.slice(1) : path;
8258
+ return `${this.config.webBaseUrl}/${cleanPath}`;
8259
+ }
8260
+ /**
8261
+ * Make authenticated HTTP request
8262
+ */
8263
+ async request(method, path, options = {}) {
8264
+ const url = this.buildUrl(path, options.query);
8265
+ const headers = {
8266
+ Authorization: this.config.authHeader,
8267
+ Accept: "application/json",
8268
+ "Content-Type": "application/json"
8269
+ };
8270
+ const response = await fetch(url, {
8271
+ method,
8272
+ headers,
8273
+ body: options.body ? JSON.stringify(options.body) : void 0
8274
+ });
8275
+ const rateLimitInfo = parseRateLimitHeaders(response.headers);
8276
+ if (hasRateLimitInfo(rateLimitInfo)) {
8277
+ const logEntry = buildRateLimitLogEntry("jira", path, rateLimitInfo);
8278
+ console.error(JSON.stringify(logEntry));
8279
+ if (isNearLimit(rateLimitInfo)) {
8280
+ console.error(`[Jira] Rate limit warning: ${rateLimitInfo.usagePercent}% used (${rateLimitInfo.remaining}/${rateLimitInfo.limit} remaining)`);
8176
8281
  }
8177
8282
  }
8178
- }
8179
- const blocks = [
8180
- {
8181
- type: "header",
8182
- text: {
8183
- type: "plain_text",
8184
- text: `${emoji} Issue ${action}: ${escapeSlackMrkdwn(issue.key)}`,
8185
- emoji: true
8186
- }
8187
- },
8188
- {
8189
- type: "section",
8190
- text: {
8191
- type: "mrkdwn",
8192
- text: `*<${issueUrl}|${escapeSlackMrkdwn(issue.key)}>* ${escapeSlackMrkdwn(fields.summary)}`
8193
- }
8194
- },
8195
- {
8196
- type: "section",
8197
- text: {
8198
- type: "mrkdwn",
8199
- text: [
8200
- `${priorityEmoji} *Priority:* ${escapeSlackMrkdwn(fields.priority?.name || "None")}`,
8201
- `*Status:* ${escapeSlackMrkdwn(fields.status.name)}`,
8202
- `*Type:* ${escapeSlackMrkdwn(fields.issuetype.name)}`,
8203
- `*Assignee:* ${escapeSlackMrkdwn(fields.assignee?.displayName || "Unassigned")}`
8204
- ].join(" | ")
8205
- }
8283
+ if (response.status === 429 && (options._retryCount ?? 0) < 1) {
8284
+ const retryAfterMs = rateLimitInfo.retryAfterMs ?? 5e3;
8285
+ const jitterMs = Math.floor(Math.random() * 1e3);
8286
+ const delayMs = Math.min(retryAfterMs + jitterMs, 1e4);
8287
+ const logEntry = buildRateLimitLogEntry("jira", path, rateLimitInfo, true);
8288
+ console.error(JSON.stringify(logEntry));
8289
+ console.error(`[Jira] Rate limited on ${path}, retrying after ${delayMs}ms`);
8290
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
8291
+ return this.request(method, path, {
8292
+ ...options,
8293
+ _retryCount: (options._retryCount ?? 0) + 1
8294
+ });
8206
8295
  }
8207
- ];
8208
- if (changelogText) {
8209
- blocks.push({
8210
- type: "section",
8211
- text: {
8212
- type: "mrkdwn",
8213
- text: `*Changes:*${changelogText}`
8296
+ if (!response.ok) {
8297
+ const errorBody = await response.text();
8298
+ throw new Error(`Jira API error (${response.status}): ${errorBody}`);
8299
+ }
8300
+ if (response.status === 204) {
8301
+ return {};
8302
+ }
8303
+ const data = await response.json();
8304
+ return data;
8305
+ }
8306
+ // ============================================================================
8307
+ // High-Level API Methods
8308
+ // ============================================================================
8309
+ /**
8310
+ * Get issue by key or ID
8311
+ */
8312
+ async getIssue(params) {
8313
+ const { issueKey, expand } = params;
8314
+ const issue = await this.request("GET", `/issue/${issueKey}`, {
8315
+ query: {
8316
+ expand: expand?.join(",")
8214
8317
  }
8215
8318
  });
8319
+ return formatIssueDetails(issue, this.config.siteUrl);
8216
8320
  }
8217
- blocks.push({
8218
- type: "context",
8219
- elements: [
8220
- {
8221
- type: "mrkdwn",
8222
- text: `${escapeSlackMrkdwn(user?.displayName || "Someone")} \u2022 ${escapeSlackMrkdwn(fields.project.name)} (${escapeSlackMrkdwn(fields.project.key)})`
8223
- }
8224
- ]
8225
- });
8226
- blocks.push({
8227
- type: "actions",
8228
- elements: [
8229
- {
8230
- type: "button",
8231
- text: {
8232
- type: "plain_text",
8233
- text: "View in Jira",
8234
- emoji: true
8235
- },
8236
- url: issueUrl,
8237
- style: "primary"
8321
+ /**
8322
+ * Create a new issue
8323
+ */
8324
+ async createIssue(params) {
8325
+ const { projectKey, summary, description, issueTypeName, assignee, labels, parentKey, ...customFields } = params;
8326
+ const fields = {
8327
+ project: { key: projectKey },
8328
+ summary,
8329
+ issuetype: { name: issueTypeName },
8330
+ ...customFields
8331
+ };
8332
+ if (description) {
8333
+ if (containsWikiTable(description)) {
8334
+ console.log("[JIRA-ADF] Converting wiki table to ADF", { length: description.length });
8335
+ fields.description = wikiMarkupToAdf(description);
8336
+ } else {
8337
+ fields.description = {
8338
+ type: "doc",
8339
+ version: 1,
8340
+ content: [
8341
+ {
8342
+ type: "paragraph",
8343
+ content: [{ type: "text", text: description }]
8344
+ }
8345
+ ]
8346
+ };
8238
8347
  }
8239
- ]
8240
- });
8241
- return {
8242
- blocks,
8243
- text: `${emoji} ${escapeSlackMrkdwn(issue.key)} ${action}: ${escapeSlackMrkdwn(fields.summary)}`
8244
- };
8245
- }
8246
- function formatCommentEventForSlack(event, siteUrl) {
8247
- const { issue, comment, webhookEvent } = event;
8248
- const issueUrl = `${siteUrl}/browse/${issue.key}`;
8249
- let action;
8250
- let emoji;
8251
- switch (webhookEvent) {
8252
- case "comment_created":
8253
- action = "commented on";
8254
- emoji = ":speech_balloon:";
8255
- break;
8256
- case "comment_updated":
8257
- action = "updated comment on";
8258
- emoji = ":pencil:";
8259
- break;
8260
- case "comment_deleted":
8261
- action = "deleted comment on";
8262
- emoji = ":x:";
8263
- break;
8264
- default:
8265
- action = "commented on";
8266
- emoji = ":speech_balloon:";
8348
+ }
8349
+ if (assignee) {
8350
+ fields.assignee = { id: assignee };
8351
+ }
8352
+ if (labels && labels.length > 0) {
8353
+ fields.labels = labels;
8354
+ }
8355
+ if (parentKey) {
8356
+ fields.parent = { key: parentKey };
8357
+ }
8358
+ const result = await this.request("POST", "/issue", {
8359
+ body: { fields }
8360
+ });
8361
+ return formatIssueResponse(result, this.config.siteUrl);
8267
8362
  }
8268
- const blocks = [
8269
- {
8270
- type: "header",
8271
- text: {
8272
- type: "plain_text",
8273
- text: `${emoji} ${escapeSlackMrkdwn(comment.author.displayName)} ${action} ${escapeSlackMrkdwn(issue.key)}`,
8274
- emoji: true
8275
- }
8276
- },
8277
- {
8278
- type: "section",
8279
- text: {
8280
- type: "mrkdwn",
8281
- text: `*<${issueUrl}|${escapeSlackMrkdwn(issue.key)}>* ${escapeSlackMrkdwn(issue.fields.summary)}`
8363
+ /**
8364
+ * Update an existing issue
8365
+ */
8366
+ async updateIssue(params) {
8367
+ const { issueKey, summary, description, labels, ...customFields } = params;
8368
+ const fields = { ...customFields };
8369
+ if (summary) {
8370
+ fields.summary = summary;
8371
+ }
8372
+ if (description) {
8373
+ if (containsWikiTable(description)) {
8374
+ console.log("[JIRA-ADF] Converting wiki table to ADF (update)", { length: description.length });
8375
+ fields.description = wikiMarkupToAdf(description);
8376
+ } else {
8377
+ fields.description = {
8378
+ type: "doc",
8379
+ version: 1,
8380
+ content: [
8381
+ {
8382
+ type: "paragraph",
8383
+ content: [{ type: "text", text: description }]
8384
+ }
8385
+ ]
8386
+ };
8282
8387
  }
8283
8388
  }
8284
- ];
8285
- if (webhookEvent !== "comment_deleted") {
8286
- const bodyText = extractAdfText(comment.body);
8287
- if (bodyText) {
8288
- blocks.push({
8289
- type: "section",
8290
- text: {
8291
- type: "mrkdwn",
8292
- text: `> ${escapeSlackMrkdwn(bodyText).replace(/\n/g, "\n> ")}`
8293
- }
8389
+ if (labels && labels.length > 0) {
8390
+ fields.labels = labels;
8391
+ }
8392
+ if (Object.keys(fields).length > 0) {
8393
+ await this.request("PUT", `/issue/${issueKey}`, {
8394
+ body: { fields }
8294
8395
  });
8295
8396
  }
8296
8397
  }
8297
- blocks.push({
8298
- type: "context",
8299
- elements: [
8300
- {
8301
- type: "mrkdwn",
8302
- text: `${escapeSlackMrkdwn(issue.fields.project.name)} \u2022 ${escapeSlackMrkdwn(issue.fields.issuetype.name)}`
8398
+ /**
8399
+ * Search for issues using JQL
8400
+ */
8401
+ async searchIssues(params) {
8402
+ const { jql, startAt = 0, maxResults = 50, fields, expand, nextPageToken } = params;
8403
+ const result = await this.request("GET", "/search/jql", {
8404
+ query: {
8405
+ jql,
8406
+ ...nextPageToken ? { nextPageToken } : { startAt },
8407
+ maxResults,
8408
+ fields: fields?.join(",") || "*all",
8409
+ expand: expand?.join(",")
8303
8410
  }
8304
- ]
8305
- }, {
8306
- type: "actions",
8307
- elements: [
8308
- {
8309
- type: "button",
8310
- text: {
8311
- type: "plain_text",
8312
- text: "View in Jira",
8313
- emoji: true
8314
- },
8315
- url: issueUrl,
8316
- style: "primary"
8317
- }
8318
- ]
8319
- });
8320
- return {
8321
- blocks,
8322
- text: `${emoji} ${escapeSlackMrkdwn(comment.author.displayName)} ${action} ${escapeSlackMrkdwn(issue.key)}: ${escapeSlackMrkdwn(issue.fields.summary)}`
8323
- };
8324
- }
8325
- function formatSprintEventForSlack(event, siteUrl) {
8326
- const { sprint, webhookEvent, user } = event;
8327
- let action;
8328
- let emoji;
8329
- switch (webhookEvent) {
8330
- case "sprint_created":
8331
- action = "created";
8332
- emoji = ":calendar:";
8333
- break;
8334
- case "sprint_started":
8335
- action = "started";
8336
- emoji = ":rocket:";
8337
- break;
8338
- case "sprint_closed":
8339
- action = "completed";
8340
- emoji = ":checkered_flag:";
8341
- break;
8342
- case "sprint_updated":
8343
- action = "updated";
8344
- emoji = ":pencil2:";
8345
- break;
8346
- case "sprint_deleted":
8347
- action = "deleted";
8348
- emoji = ":wastebasket:";
8349
- break;
8350
- default:
8351
- action = "changed";
8352
- emoji = ":bell:";
8353
- }
8354
- const sprintUrl = `${siteUrl}/secure/RapidBoard.jspa?rapidView=${sprint.originBoardId}`;
8355
- const blocks = [
8356
- {
8357
- type: "header",
8358
- text: {
8359
- type: "plain_text",
8360
- text: `${emoji} Sprint ${action}: ${escapeSlackMrkdwn(sprint.name)}`,
8361
- emoji: true
8362
- }
8363
- },
8364
- {
8365
- type: "section",
8366
- text: {
8367
- type: "mrkdwn",
8368
- text: [
8369
- `*State:* ${escapeSlackMrkdwn(sprint.state)}`,
8370
- sprint.startDate ? `*Start:* ${new Date(sprint.startDate).toLocaleDateString()}` : "",
8371
- sprint.endDate ? `*End:* ${new Date(sprint.endDate).toLocaleDateString()}` : ""
8372
- ].filter(Boolean).join(" | ")
8373
- }
8374
- }
8375
- ];
8376
- if (sprint.goal) {
8377
- blocks.push({
8378
- type: "section",
8379
- text: {
8380
- type: "mrkdwn",
8381
- text: `*Goal:* ${escapeSlackMrkdwn(sprint.goal)}`
8382
- }
8383
- });
8384
- }
8385
- blocks.push({
8386
- type: "context",
8387
- elements: [
8388
- {
8389
- type: "mrkdwn",
8390
- text: user?.displayName ? escapeSlackMrkdwn(user.displayName) : "System"
8391
- }
8392
- ]
8393
- });
8394
- blocks.push({
8395
- type: "actions",
8396
- elements: [
8397
- {
8398
- type: "button",
8399
- text: {
8400
- type: "plain_text",
8401
- text: "View Board",
8402
- emoji: true
8403
- },
8404
- url: sprintUrl,
8405
- style: "primary"
8406
- }
8407
- ]
8408
- });
8409
- return {
8410
- blocks,
8411
- text: `${emoji} Sprint ${action}: ${escapeSlackMrkdwn(sprint.name)}`
8412
- };
8413
- }
8414
- function formatGenericEventForSlack(eventType, payload, siteUrl) {
8415
- const issue = payload.issue;
8416
- const user = payload.user;
8417
- const issueLink = payload.issueLink;
8418
- const changelog = payload.changelog;
8419
- const project = payload.project ?? issue?.fields?.project;
8420
- const version = payload.version;
8421
- const worklog = payload.worklog;
8422
- const board = payload.board;
8423
- const readableEvent = eventType.replace(/^jira:/, "").replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
8424
- const blocks = [
8425
- {
8426
- type: "header",
8427
- text: {
8428
- type: "plain_text",
8429
- text: `:bell: ${readableEvent}`,
8430
- emoji: true
8431
- }
8432
- }
8433
- ];
8434
- const details = [];
8435
- if (issue?.key) {
8436
- const issueUrl = `${siteUrl}/browse/${issue.key}`;
8437
- blocks.push({
8438
- type: "section",
8439
- text: {
8440
- type: "mrkdwn",
8441
- text: `*<${issueUrl}|${escapeSlackMrkdwn(issue.key)}>* ${escapeSlackMrkdwn(issue.fields?.summary || "")}`
8411
+ });
8412
+ return formatSearchResults2(result, this.config.siteUrl);
8413
+ }
8414
+ /**
8415
+ * List all accessible projects
8416
+ */
8417
+ async listProjects(params) {
8418
+ const projects = await this.request("GET", "/project/search", {
8419
+ query: {
8420
+ maxResults: params?.maxResults,
8421
+ query: params?.query,
8422
+ expand: params?.expand
8442
8423
  }
8443
8424
  });
8444
- const meta = [];
8445
- if (issue.fields?.status?.name)
8446
- meta.push(`*Status:* ${escapeSlackMrkdwn(issue.fields.status.name)}`);
8447
- if (issue.fields?.issuetype?.name)
8448
- meta.push(`*Type:* ${escapeSlackMrkdwn(issue.fields.issuetype.name)}`);
8449
- if (issue.fields?.priority?.name) {
8450
- const emoji = getPriorityEmoji(issue.fields.priority.name);
8451
- meta.push(`${emoji} *Priority:* ${escapeSlackMrkdwn(issue.fields.priority.name)}`);
8452
- }
8453
- if (issue.fields?.assignee?.displayName)
8454
- meta.push(`*Assignee:* ${escapeSlackMrkdwn(issue.fields.assignee.displayName)}`);
8455
- if (meta.length > 0)
8456
- details.push(meta.join(" | "));
8457
- }
8458
- if (issueLink?.issueLinkType) {
8459
- const linkType = issueLink.issueLinkType;
8460
- const linkDesc = escapeSlackMrkdwn(linkType.outwardName || linkType.name || "linked");
8461
- details.push(`*Link type:* ${linkDesc}`);
8462
- if (issueLink.sourceIssueId)
8463
- details.push(`*Source issue ID:* ${issueLink.sourceIssueId}`);
8464
- if (issueLink.destinationIssueId)
8465
- details.push(`*Destination issue ID:* ${issueLink.destinationIssueId}`);
8466
- }
8467
- if (version?.name) {
8468
- const versionParts = [`*Version:* ${escapeSlackMrkdwn(version.name)}`];
8469
- if (version.description)
8470
- versionParts.push(escapeSlackMrkdwn(version.description));
8471
- if (version.released !== void 0)
8472
- versionParts.push(version.released ? ":white_check_mark: Released" : "Unreleased");
8473
- details.push(versionParts.join(" | "));
8474
- }
8475
- if (worklog) {
8476
- const wlParts = [];
8477
- if (worklog.author?.displayName)
8478
- wlParts.push(`*By:* ${escapeSlackMrkdwn(worklog.author.displayName)}`);
8479
- if (worklog.timeSpent)
8480
- wlParts.push(`*Time:* ${escapeSlackMrkdwn(worklog.timeSpent)}`);
8481
- if (wlParts.length > 0)
8482
- details.push(wlParts.join(" | "));
8483
- }
8484
- if (board?.name) {
8485
- details.push(`*Board:* ${escapeSlackMrkdwn(board.name)}`);
8486
- }
8487
- if (changelog?.items && changelog.items.length > 0) {
8488
- const changes = changelog.items.slice(0, 3).map((item) => `\u2022 ${escapeSlackMrkdwn(item.field || "")}: ${escapeSlackMrkdwn(item.fromString || "None")} \u2192 ${escapeSlackMrkdwn(item.toString || "None")}`);
8489
- if (changelog.items.length > 3) {
8490
- changes.push(`_...and ${changelog.items.length - 3} more_`);
8491
- }
8492
- details.push(`*Changes:*
8493
- ${changes.join("\n")}`);
8425
+ return formatProjectList(projects, this.config.siteUrl);
8494
8426
  }
8495
- if (details.length > 0) {
8496
- blocks.push({
8497
- type: "section",
8498
- text: {
8499
- type: "mrkdwn",
8500
- text: details.join("\n")
8427
+ /**
8428
+ * Get project details
8429
+ */
8430
+ async getProject(params) {
8431
+ const { projectKey, expand } = params;
8432
+ const project = await this.request("GET", `/project/${projectKey}`, {
8433
+ query: {
8434
+ expand: expand?.join(",")
8501
8435
  }
8502
8436
  });
8437
+ return formatProjectResponse(project, this.config.siteUrl);
8503
8438
  }
8504
- const contextParts = [];
8505
- if (user?.displayName)
8506
- contextParts.push(escapeSlackMrkdwn(user.displayName));
8507
- if (project?.name)
8508
- contextParts.push(`${escapeSlackMrkdwn(project.name)}${project.key ? ` (${escapeSlackMrkdwn(project.key)})` : ""}`);
8509
- if (contextParts.length > 0) {
8510
- blocks.push({
8511
- type: "context",
8512
- elements: [{ type: "mrkdwn", text: contextParts.join(" \u2022 ") }]
8513
- });
8439
+ /**
8440
+ * List issue types for a project
8441
+ */
8442
+ async listIssueTypes(params) {
8443
+ const { projectKey } = params;
8444
+ const project = await this.getProject({ projectKey, expand: ["issueTypes"] });
8445
+ return project.issueTypes || [];
8514
8446
  }
8515
- if (issue?.key) {
8516
- const issueUrl = `${siteUrl}/browse/${issue.key}`;
8517
- blocks.push({
8518
- type: "actions",
8519
- elements: [
8520
- {
8521
- type: "button",
8522
- text: { type: "plain_text", text: "View in Jira", emoji: true },
8523
- url: issueUrl,
8524
- style: "primary"
8447
+ /**
8448
+ * Add comment to an issue
8449
+ */
8450
+ async addComment(params) {
8451
+ const { issueKey, body } = params;
8452
+ const comment = await this.request("POST", `/issue/${issueKey}/comment`, {
8453
+ body: {
8454
+ body: {
8455
+ type: "doc",
8456
+ version: 1,
8457
+ content: [
8458
+ {
8459
+ type: "paragraph",
8460
+ content: [{ type: "text", text: body }]
8461
+ }
8462
+ ]
8525
8463
  }
8526
- ]
8464
+ }
8527
8465
  });
8528
- }
8529
- const summary = issue?.key ? `${escapeSlackMrkdwn(issue.key)}: ${escapeSlackMrkdwn(issue.fields?.summary || readableEvent)}` : readableEvent;
8530
- return {
8531
- blocks,
8532
- text: `:bell: ${summary}`
8533
- };
8534
- }
8535
-
8536
- // ../../b4m-core/packages/common/dist/src/jira/webhook/api.js
8537
- var WebhookApi = class {
8538
- config;
8539
- constructor(config) {
8540
- this.config = config;
8466
+ return formatComment(comment);
8541
8467
  }
8542
8468
  /**
8543
- * Build URL for webhook API endpoints.
8544
- *
8545
- * Note: Webhooks use a different base URL than other Jira APIs.
8469
+ * Get all statuses available in a project, grouped by issue type.
8470
+ * Uses GET /project/{projectKey}/statuses which returns every workflow status.
8546
8471
  */
8547
- buildUrl(path, query = {}) {
8548
- const url = new URL(`${this.config.apiBaseUrl}${path}`);
8549
- for (const [key, value] of Object.entries(query)) {
8550
- if (value !== void 0) {
8551
- url.searchParams.append(key, String(value));
8472
+ async getProjectStatuses(params) {
8473
+ const { projectKey } = params;
8474
+ const result = await this.request("GET", `/project/${projectKey}/statuses`);
8475
+ const names = /* @__PURE__ */ new Set();
8476
+ for (const issueType of result) {
8477
+ for (const status of issueType.statuses) {
8478
+ names.add(status.name);
8552
8479
  }
8553
8480
  }
8554
- return url.toString();
8481
+ return Array.from(names).sort();
8555
8482
  }
8556
8483
  /**
8557
- * Make an authenticated request to the Jira Webhook API.
8484
+ * Get available transitions for an issue
8558
8485
  */
8559
- async request(method, path, options = {}) {
8560
- const url = this.buildUrl(path, options.query);
8561
- const headers = {
8562
- Authorization: this.config.authHeader,
8563
- Accept: "application/json",
8564
- "Content-Type": "application/json"
8565
- };
8566
- const response = await fetch(url, {
8567
- method,
8568
- headers,
8569
- body: options.body ? JSON.stringify(options.body) : void 0
8486
+ async getTransitions(params) {
8487
+ const { issueKey } = params;
8488
+ const result = await this.request("GET", `/issue/${issueKey}/transitions`);
8489
+ return formatTransitions(result);
8490
+ }
8491
+ /**
8492
+ * Transition issue to a new status
8493
+ */
8494
+ async transitionIssue(params) {
8495
+ const { issueKey, transitionId } = params;
8496
+ await this.request("POST", `/issue/${issueKey}/transitions`, {
8497
+ body: {
8498
+ transition: { id: transitionId }
8499
+ }
8570
8500
  });
8571
- if (!response.ok) {
8572
- const errorBody = await response.text();
8573
- throw new Error(`Jira Webhook API error (${response.status}): ${errorBody}`);
8574
- }
8575
- if (response.status === 204) {
8576
- return {};
8577
- }
8578
- const data = await response.json();
8579
- return data;
8501
+ const result = { issueKey, transitionId };
8502
+ return formatTransitionResult(result, this.config.siteUrl);
8580
8503
  }
8581
- // ============================================================================
8582
- // Webhook CRUD Operations
8583
- // ============================================================================
8584
8504
  /**
8585
- * List all webhooks registered by this OAuth app.
8586
- *
8587
- * @param startAt - Pagination start index (default: 0)
8588
- * @param maxResults - Max results per page (default: 50, max: 100)
8505
+ * Assign issue to a user
8589
8506
  */
8590
- async listWebhooks(params = {}) {
8591
- const { startAt = 0, maxResults = 50 } = params;
8592
- const response = await this.request("GET", "/webhook", {
8593
- query: { startAt, maxResults }
8507
+ async assignIssue(params) {
8508
+ const { issueKey, accountId } = params;
8509
+ await this.request("PUT", `/issue/${issueKey}/assignee`, {
8510
+ body: { accountId }
8594
8511
  });
8595
- return formatWebhookList(response);
8596
8512
  }
8597
8513
  /**
8598
- * Register a new webhook.
8599
- *
8600
- * @param url - The URL where Jira will POST webhook events
8601
- * @param events - Events to subscribe to
8602
- * @param jqlFilter - Optional JQL filter (only matching issues trigger events)
8603
- * @returns The created webhook ID
8514
+ * Delete an issue
8604
8515
  */
8605
- async registerWebhook(params) {
8606
- const { url, events, jqlFilter } = params;
8607
- const webhook = {
8608
- // Jira webhook API requires jqlFilter but has limited operator support.
8609
- // "IS NOT EMPTY" and empty strings are rejected. Use a universally-true expression.
8610
- jqlFilter: jqlFilter || "project != null",
8611
- events
8612
- };
8613
- const body = {
8614
- url,
8615
- webhooks: [webhook]
8616
- };
8617
- const response = await this.request("POST", "/webhook", {
8618
- body
8516
+ async deleteIssue(params) {
8517
+ const { issueKey } = params;
8518
+ if (!issueKey) {
8519
+ throw new Error("issueKey is required to delete an issue.");
8520
+ }
8521
+ await this.request("DELETE", `/issue/${issueKey}`);
8522
+ }
8523
+ /**
8524
+ * Get current user information
8525
+ */
8526
+ async getCurrentUser() {
8527
+ const user = await this.request("GET", "/myself");
8528
+ return formatUser(user);
8529
+ }
8530
+ /**
8531
+ * Search for users by query string (name, email, username)
8532
+ */
8533
+ async searchUsers(params) {
8534
+ const { query, maxResults = 50 } = params;
8535
+ const users = await this.request("GET", "/user/search", {
8536
+ query: {
8537
+ query,
8538
+ maxResults
8539
+ }
8619
8540
  });
8620
- console.log("[JIRA-WEBHOOK-API] registerWebhook raw response:", JSON.stringify(response, null, 2));
8621
- if (!response.webhookRegistrationResult || response.webhookRegistrationResult.length === 0) {
8622
- throw new Error("Failed to register webhook: No registration result returned");
8541
+ return formatUserList(users);
8542
+ }
8543
+ /**
8544
+ * Find users assignable to issues in a specific project.
8545
+ * Uses GET /user/assignable/search?project=KEY which respects
8546
+ * project permissions and roles.
8547
+ */
8548
+ async findAssignableUsers(params) {
8549
+ const pageSize = 50;
8550
+ const maxTotal = params.maxResults ?? 500;
8551
+ const allUsers = [];
8552
+ let startAt = 0;
8553
+ while (allUsers.length < maxTotal) {
8554
+ const page = await this.request("GET", "/user/assignable/search", {
8555
+ query: { project: params.projectKey, startAt, maxResults: pageSize }
8556
+ });
8557
+ if (!page || page.length === 0)
8558
+ break;
8559
+ for (const u of page) {
8560
+ if (u.active && u.accountType === "atlassian") {
8561
+ allUsers.push(u);
8562
+ }
8563
+ }
8564
+ if (page.length < pageSize)
8565
+ break;
8566
+ startAt += pageSize;
8623
8567
  }
8624
- const result = response.webhookRegistrationResult[0];
8625
- if (result.errors && result.errors.length > 0) {
8626
- throw new Error(`Failed to register webhook: ${result.errors.join(", ")}`);
8568
+ return formatUserList(allUsers);
8569
+ }
8570
+ /**
8571
+ * Bulk create multiple issues in a single API call.
8572
+ * Supports up to 50 issues per request (Jira API limit).
8573
+ * Ideal for creating multiple subtasks under a parent issue.
8574
+ */
8575
+ async bulkCreateIssues(params) {
8576
+ const { issues } = params;
8577
+ if (issues.length === 0) {
8578
+ return { issues: [], errors: [] };
8627
8579
  }
8628
- if (result.createdWebhookId === void 0 || result.createdWebhookId === null) {
8629
- throw new Error(`Failed to register webhook: No webhook ID in response. Full result: ${JSON.stringify(result)}`);
8580
+ if (issues.length > 50) {
8581
+ throw new Error("Bulk create is limited to 50 issues per request. Please split your request.");
8630
8582
  }
8583
+ const issueUpdates = issues.map((issue) => {
8584
+ const { projectKey, summary, description, issueTypeName, assignee, labels, parentKey } = issue;
8585
+ const fields = {
8586
+ project: { key: projectKey },
8587
+ summary,
8588
+ issuetype: { name: issueTypeName }
8589
+ };
8590
+ if (description) {
8591
+ if (containsWikiTable(description)) {
8592
+ console.log("[JIRA-ADF] Converting wiki table to ADF (bulk)", { length: description.length });
8593
+ fields.description = wikiMarkupToAdf(description);
8594
+ } else {
8595
+ fields.description = {
8596
+ type: "doc",
8597
+ version: 1,
8598
+ content: [
8599
+ {
8600
+ type: "paragraph",
8601
+ content: [{ type: "text", text: description }]
8602
+ }
8603
+ ]
8604
+ };
8605
+ }
8606
+ }
8607
+ if (assignee) {
8608
+ fields.assignee = { id: assignee };
8609
+ }
8610
+ if (labels && labels.length > 0) {
8611
+ fields.labels = labels;
8612
+ }
8613
+ if (parentKey) {
8614
+ fields.parent = { key: parentKey };
8615
+ }
8616
+ return { update: {}, fields };
8617
+ });
8618
+ const result = await this.request("POST", "/issue/bulk", {
8619
+ body: { issueUpdates }
8620
+ });
8621
+ const formattedIssues = (result.issues || []).map((issue) => formatIssueResponse(issue, this.config.siteUrl));
8622
+ const formattedErrors = (result.errors || []).map((error) => ({
8623
+ status: error.status,
8624
+ message: Object.values(error.elementErrors?.errors || {}).join(", ") || "Unknown error",
8625
+ failedElementNumber: error.failedElementNumber
8626
+ }));
8631
8627
  return {
8632
- webhookId: result.createdWebhookId
8628
+ issues: formattedIssues,
8629
+ errors: formattedErrors
8633
8630
  };
8634
8631
  }
8635
8632
  /**
8636
- * Refresh webhook expiration dates.
8637
- *
8638
- * Jira webhooks expire after 30 days. Call this to extend expiration.
8633
+ * Bulk transition multiple issues to new statuses in a single API call.
8634
+ * Uses the Jira Cloud Bulk Transition API.
8635
+ * Supports up to 1000 issues per request.
8639
8636
  *
8640
- * @param webhookIds - IDs of webhooks to refresh
8641
- * @returns New expiration date
8637
+ * Note: This is an async operation - returns a task ID for tracking progress.
8642
8638
  */
8643
- async refreshWebhooks(params) {
8644
- const { webhookIds } = params;
8645
- const body = {
8646
- webhookIds
8647
- };
8648
- const response = await this.request("PUT", "/webhook/refresh", {
8649
- body
8639
+ async bulkTransitionIssues(params) {
8640
+ const { issues } = params;
8641
+ console.error(`[Jira] bulkTransitionIssues called with ${issues.length} issue(s)`);
8642
+ if (issues.length === 0) {
8643
+ console.error("[Jira] bulkTransitionIssues: No issues provided, returning early");
8644
+ return { taskId: "", message: "No issues to transition", issueCount: 0 };
8645
+ }
8646
+ if (issues.length > 1e3) {
8647
+ console.error(`[Jira] bulkTransitionIssues: Exceeded 1000 issue limit (${issues.length})`);
8648
+ throw new Error("Bulk transition is limited to 1000 issues per request. Please split your request.");
8649
+ }
8650
+ const transitionGroups = {};
8651
+ for (const issue of issues) {
8652
+ if (!transitionGroups[issue.transitionId]) {
8653
+ transitionGroups[issue.transitionId] = [];
8654
+ }
8655
+ transitionGroups[issue.transitionId].push(issue.issueIdOrKey);
8656
+ }
8657
+ const bulkTransitionInputs = Object.entries(transitionGroups).map(([transitionId, selectedIssueIdsOrKeys]) => ({
8658
+ selectedIssueIdsOrKeys,
8659
+ transitionId
8660
+ }));
8661
+ console.error(`[Jira] bulkTransitionIssues: Grouped into ${bulkTransitionInputs.length} transition group(s)`);
8662
+ const result = await this.request("POST", "/bulk/issues/transition", {
8663
+ body: { bulkTransitionInputs }
8650
8664
  });
8665
+ console.error(`[Jira] bulkTransitionIssues: Task created with ID ${result.taskId}`);
8651
8666
  return {
8652
- expirationDate: response.expirationDate
8667
+ taskId: result.taskId,
8668
+ message: `Bulk transition started for ${issues.length} issue(s).`,
8669
+ issueCount: issues.length
8653
8670
  };
8654
8671
  }
8655
8672
  /**
8656
- * Delete webhooks by ID.
8673
+ * Bulk update labels on multiple issues in a single API call.
8674
+ * Uses the Jira Cloud Bulk Edit API.
8675
+ * Supports up to 1000 issues per request.
8657
8676
  *
8658
- * @param webhookIds - IDs of webhooks to delete
8659
- */
8660
- async deleteWebhooks(params) {
8661
- const { webhookIds } = params;
8662
- const body = {
8663
- webhookIds
8664
- };
8665
- await this.request("DELETE", "/webhook", {
8666
- body
8667
- });
8668
- }
8669
- /**
8670
- * Get a specific webhook by ID.
8677
+ * Note: This is an async operation - returns a task ID for tracking progress.
8671
8678
  *
8672
- * Note: Jira API doesn't have a direct "get by ID" endpoint,
8673
- * so we list and filter.
8679
+ * Why only labels? Jira's Bulk Edit API has significant limitations:
8680
+ * - Summary, description, assignee fields are NOT supported for bulk editing
8681
+ * - Priority and issueType require IDs and have inconsistent behavior
8682
+ * - Labels is the most reliable and commonly used bulk operation
8683
+ * See: https://community.atlassian.com/forums/Jira-questions/Bulk-Change-Description-Field/qaq-p/897762
8674
8684
  */
8675
- async getWebhook(params) {
8676
- const { webhookId } = params;
8677
- const response = await this.request("GET", "/webhook", {
8678
- query: { startAt: 0, maxResults: 100 }
8679
- });
8680
- const webhook = response.values.find((w) => w.id === webhookId);
8681
- return webhook || null;
8682
- }
8683
- };
8684
-
8685
- // ../../b4m-core/packages/common/dist/src/jira/api.js
8686
- var JIRA_MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024;
8687
- function isValidIssueKey(key) {
8688
- return /^[A-Z][A-Z0-9]*-\d+$/.test(key);
8689
- }
8690
- function wikiMarkupToAdf(text) {
8691
- const lines = text.split("\n");
8692
- const content = [];
8693
- let currentTableRows = [];
8694
- let inTable = false;
8695
- for (const line of lines) {
8696
- const trimmedLine = line.trim();
8697
- if (trimmedLine.startsWith("||") && trimmedLine.endsWith("||")) {
8698
- inTable = true;
8699
- const cells = trimmedLine.slice(2, -2).split("||").map((cell) => cell.trim());
8700
- const headerRow = {
8701
- type: "tableRow",
8702
- content: cells.map((cell) => ({
8703
- type: "tableHeader",
8704
- attrs: {},
8705
- content: [{ type: "paragraph", content: [{ type: "text", text: cell || " " }] }]
8706
- }))
8707
- };
8708
- currentTableRows.push(headerRow);
8709
- continue;
8710
- }
8711
- if (trimmedLine.startsWith("|") && trimmedLine.endsWith("|") && !trimmedLine.startsWith("||")) {
8712
- inTable = true;
8713
- const cells = trimmedLine.slice(1, -1).split("|").map((cell) => cell.trim());
8714
- const dataRow = {
8715
- type: "tableRow",
8716
- content: cells.map((cell) => ({
8717
- type: "tableCell",
8718
- attrs: {},
8719
- content: [{ type: "paragraph", content: [{ type: "text", text: cell || " " }] }]
8720
- }))
8721
- };
8722
- currentTableRows.push(dataRow);
8723
- continue;
8724
- }
8725
- if (inTable && currentTableRows.length > 0) {
8726
- const table = {
8727
- type: "table",
8728
- attrs: { isNumberColumnEnabled: false, layout: "default" },
8729
- content: currentTableRows
8730
- };
8731
- content.push(table);
8732
- currentTableRows = [];
8733
- inTable = false;
8685
+ async bulkUpdateIssues(params) {
8686
+ const { issueIdsOrKeys, labels } = params;
8687
+ console.error(`[Jira] bulkUpdateIssues called with ${issueIdsOrKeys.length} issue(s), action: ${labels.action}`);
8688
+ if (issueIdsOrKeys.length === 0) {
8689
+ console.error("[Jira] bulkUpdateIssues: No issues provided, returning early");
8690
+ return { taskId: "", message: "No issues to update", issueCount: 0 };
8734
8691
  }
8735
- if (trimmedLine) {
8736
- content.push({
8737
- type: "paragraph",
8738
- content: [{ type: "text", text: trimmedLine }]
8739
- });
8692
+ if (issueIdsOrKeys.length > 1e3) {
8693
+ console.error(`[Jira] bulkUpdateIssues: Exceeded 1000 issue limit (${issueIdsOrKeys.length})`);
8694
+ throw new Error("Bulk update is limited to 1000 issues per request. Please split your request.");
8740
8695
  }
8741
- }
8742
- if (currentTableRows.length > 0) {
8743
- const table = {
8744
- type: "table",
8745
- attrs: { isNumberColumnEnabled: false, layout: "default" },
8746
- content: currentTableRows
8696
+ const selectedActions = ["labels"];
8697
+ const editedFieldsInput = {
8698
+ labelsFields: [
8699
+ {
8700
+ fieldId: "labels",
8701
+ labels: labels.values.map((name) => ({ name })),
8702
+ bulkEditMultiSelectFieldOption: labels.action
8703
+ }
8704
+ ]
8705
+ };
8706
+ console.error(`[Jira] bulkUpdateIssues: Updating labels [${labels.values.join(", ")}] with action ${labels.action}`);
8707
+ const result = await this.request("POST", "/bulk/issues/fields", {
8708
+ body: {
8709
+ selectedActions,
8710
+ selectedIssueIdsOrKeys: issueIdsOrKeys,
8711
+ editedFieldsInput
8712
+ }
8713
+ });
8714
+ console.error(`[Jira] bulkUpdateIssues: Task created with ID ${result.taskId}`);
8715
+ return {
8716
+ taskId: result.taskId,
8717
+ message: `Bulk label update started for ${issueIdsOrKeys.length} issue(s). Action: ${labels.action}.`,
8718
+ issueCount: issueIdsOrKeys.length
8747
8719
  };
8748
- content.push(table);
8749
- }
8750
- if (content.length === 0) {
8751
- content.push({ type: "paragraph", content: [{ type: "text", text: " " }] });
8752
- }
8753
- return { type: "doc", version: 1, content };
8754
- }
8755
- function containsWikiTable(text) {
8756
- return /^\|\|.+\|\|$/m.test(text) || /^\|(?!\|).+\|$/m.test(text);
8757
- }
8758
- var JiraApi = class {
8759
- config;
8760
- constructor(config) {
8761
- this.config = config;
8762
8720
  }
8763
8721
  /**
8764
- * Build URL with query parameters
8722
+ * Get watchers for an issue
8765
8723
  */
8766
- buildUrl(path, query = {}) {
8767
- const base = `${this.config.apiBaseUrl}${path}`;
8768
- const url = new URL(base);
8769
- Object.entries(query).forEach(([key, value]) => {
8770
- if (value === void 0 || value === "")
8771
- return;
8772
- url.searchParams.append(key, String(value));
8773
- });
8774
- return url.toString();
8724
+ async getWatchers(params) {
8725
+ const { issueKey } = params;
8726
+ const result = await this.request("GET", `/issue/${issueKey}/watchers`);
8727
+ return formatWatchers(result);
8775
8728
  }
8776
8729
  /**
8777
- * Build web URL for Jira UI links
8730
+ * Add a watcher to an issue
8778
8731
  */
8779
- buildWebUrl(path) {
8780
- if (!path)
8781
- return "";
8782
- const cleanPath = path.startsWith("/") ? path.slice(1) : path;
8783
- return `${this.config.webBaseUrl}/${cleanPath}`;
8732
+ async addWatcher(params) {
8733
+ const { issueKey, accountId } = params;
8734
+ await this.request("POST", `/issue/${issueKey}/watchers`, {
8735
+ body: accountId
8736
+ });
8784
8737
  }
8785
8738
  /**
8786
- * Make authenticated HTTP request
8739
+ * Remove a watcher from an issue
8787
8740
  */
8788
- async request(method, path, options = {}) {
8789
- const url = this.buildUrl(path, options.query);
8790
- const headers = {
8791
- Authorization: this.config.authHeader,
8792
- Accept: "application/json",
8793
- "Content-Type": "application/json"
8794
- };
8795
- const response = await fetch(url, {
8796
- method,
8797
- headers,
8798
- body: options.body ? JSON.stringify(options.body) : void 0
8741
+ async removeWatcher(params) {
8742
+ const { issueKey, accountId } = params;
8743
+ await this.request("DELETE", `/issue/${issueKey}/watchers`, {
8744
+ query: { accountId }
8799
8745
  });
8800
- const rateLimitInfo = parseRateLimitHeaders(response.headers);
8801
- if (hasRateLimitInfo(rateLimitInfo)) {
8802
- const logEntry = buildRateLimitLogEntry("jira", path, rateLimitInfo);
8803
- console.error(JSON.stringify(logEntry));
8804
- if (isNearLimit(rateLimitInfo)) {
8805
- console.error(`[Jira] Rate limit warning: ${rateLimitInfo.usagePercent}% used (${rateLimitInfo.remaining}/${rateLimitInfo.limit} remaining)`);
8806
- }
8807
- }
8808
- if (response.status === 429 && (options._retryCount ?? 0) < 1) {
8809
- const retryAfterMs = rateLimitInfo.retryAfterMs ?? 5e3;
8810
- const jitterMs = Math.floor(Math.random() * 1e3);
8811
- const delayMs = Math.min(retryAfterMs + jitterMs, 1e4);
8812
- const logEntry = buildRateLimitLogEntry("jira", path, rateLimitInfo, true);
8813
- console.error(JSON.stringify(logEntry));
8814
- console.error(`[Jira] Rate limited on ${path}, retrying after ${delayMs}ms`);
8815
- await new Promise((resolve) => setTimeout(resolve, delayMs));
8816
- return this.request(method, path, {
8817
- ...options,
8818
- _retryCount: (options._retryCount ?? 0) + 1
8819
- });
8820
- }
8821
- if (!response.ok) {
8822
- const errorBody = await response.text();
8823
- throw new Error(`Jira API error (${response.status}): ${errorBody}`);
8824
- }
8825
- if (response.status === 204) {
8826
- return {};
8827
- }
8828
- const data = await response.json();
8829
- return data;
8830
8746
  }
8831
8747
  // ============================================================================
8832
- // High-Level API Methods
8748
+ // Issue Link Operations
8833
8749
  // ============================================================================
8834
8750
  /**
8835
- * Get issue by key or ID
8751
+ * Get all available issue link types in the Jira instance
8836
8752
  */
8837
- async getIssue(params) {
8838
- const { issueKey, expand } = params;
8839
- const issue = await this.request("GET", `/issue/${issueKey}`, {
8840
- query: {
8841
- expand: expand?.join(",")
8842
- }
8843
- });
8844
- return formatIssueDetails(issue, this.config.siteUrl);
8753
+ async getIssueLinkTypes() {
8754
+ const result = await this.request("GET", "/issueLinkType");
8755
+ return formatIssueLinkTypes(result);
8845
8756
  }
8846
8757
  /**
8847
- * Create a new issue
8758
+ * Get all issue links for a specific issue
8848
8759
  */
8849
- async createIssue(params) {
8850
- const { projectKey, summary, description, issueTypeName, assignee, labels, parentKey, ...customFields } = params;
8851
- const fields = {
8852
- project: { key: projectKey },
8853
- summary,
8854
- issuetype: { name: issueTypeName },
8855
- ...customFields
8856
- };
8857
- if (description) {
8858
- if (containsWikiTable(description)) {
8859
- console.log("[JIRA-ADF] Converting wiki table to ADF", { length: description.length });
8860
- fields.description = wikiMarkupToAdf(description);
8861
- } else {
8862
- fields.description = {
8863
- type: "doc",
8864
- version: 1,
8865
- content: [
8866
- {
8867
- type: "paragraph",
8868
- content: [{ type: "text", text: description }]
8869
- }
8870
- ]
8871
- };
8872
- }
8873
- }
8874
- if (assignee) {
8875
- fields.assignee = { id: assignee };
8876
- }
8877
- if (labels && labels.length > 0) {
8878
- fields.labels = labels;
8879
- }
8880
- if (parentKey) {
8881
- fields.parent = { key: parentKey };
8760
+ async getIssueLinks(params) {
8761
+ const { issueKey } = params;
8762
+ if (!isValidIssueKey(issueKey)) {
8763
+ throw new Error(`Invalid issue key format: ${issueKey}. Expected format: PROJECT-123`);
8882
8764
  }
8883
- const result = await this.request("POST", "/issue", {
8884
- body: { fields }
8765
+ const issue = await this.request("GET", `/issue/${issueKey}`, {
8766
+ query: { fields: "issuelinks" }
8885
8767
  });
8886
- return formatIssueResponse(result, this.config.siteUrl);
8768
+ return formatIssueLinks(issue.fields?.issuelinks || [], this.config.siteUrl);
8887
8769
  }
8888
8770
  /**
8889
- * Update an existing issue
8771
+ * Create a link between two issues.
8772
+ * Uses intuitive sourceIssue/targetIssue terminology:
8773
+ * - sourceIssue: The issue doing the action (e.g., PROJ-1 in "PROJ-1 blocks PROJ-2")
8774
+ * - targetIssue: The issue being acted upon (e.g., PROJ-2 in "PROJ-1 blocks PROJ-2")
8775
+ *
8776
+ * This maps to Jira's API as: sourceIssue → outwardIssue, targetIssue → inwardIssue
8777
+ *
8778
+ * @returns void - Jira returns 201 with no body on success
8890
8779
  */
8891
- async updateIssue(params) {
8892
- const { issueKey, summary, description, labels, ...customFields } = params;
8893
- const fields = { ...customFields };
8894
- if (summary) {
8895
- fields.summary = summary;
8780
+ async createIssueLink(params) {
8781
+ const { linkType, sourceIssue, targetIssue } = params;
8782
+ if (!isValidIssueKey(sourceIssue)) {
8783
+ throw new Error(`Invalid source issue key format: ${sourceIssue}. Expected format: PROJECT-123`);
8896
8784
  }
8897
- if (description) {
8898
- if (containsWikiTable(description)) {
8899
- console.log("[JIRA-ADF] Converting wiki table to ADF (update)", { length: description.length });
8900
- fields.description = wikiMarkupToAdf(description);
8901
- } else {
8902
- fields.description = {
8903
- type: "doc",
8904
- version: 1,
8905
- content: [
8906
- {
8907
- type: "paragraph",
8908
- content: [{ type: "text", text: description }]
8909
- }
8910
- ]
8911
- };
8912
- }
8785
+ if (!isValidIssueKey(targetIssue)) {
8786
+ throw new Error(`Invalid target issue key format: ${targetIssue}. Expected format: PROJECT-123`);
8913
8787
  }
8914
- if (labels) {
8915
- fields.labels = labels;
8788
+ const linkTypes = await this.getIssueLinkTypes();
8789
+ const matchedType = linkTypes.find((t) => t.name.toLowerCase() === linkType.toLowerCase());
8790
+ if (!matchedType) {
8791
+ const availableTypes = linkTypes.map((t) => t.name).join(", ");
8792
+ throw new Error(`Invalid link type: "${linkType}". Available types: ${availableTypes}`);
8916
8793
  }
8917
- await this.request("PUT", `/issue/${issueKey}`, {
8918
- body: { fields }
8794
+ await this.request("POST", "/issueLink", {
8795
+ body: {
8796
+ type: { name: matchedType.name },
8797
+ outwardIssue: { key: sourceIssue },
8798
+ inwardIssue: { key: targetIssue }
8799
+ }
8919
8800
  });
8920
8801
  }
8921
8802
  /**
8922
- * Search for issues using JQL
8803
+ * Delete an issue link by its ID
8923
8804
  */
8924
- async searchIssues(params) {
8925
- const { jql, startAt = 0, maxResults = 50, fields, expand } = params;
8926
- const result = await this.request("GET", "/search/jql", {
8927
- query: {
8928
- jql,
8929
- startAt,
8930
- maxResults,
8931
- fields: fields?.join(",") || "*all",
8932
- expand: expand?.join(",")
8933
- }
8934
- });
8935
- return formatSearchResults2(result, this.config.siteUrl);
8805
+ async deleteIssueLink(params) {
8806
+ const { linkId } = params;
8807
+ await this.request("DELETE", `/issueLink/${linkId}`);
8936
8808
  }
8937
8809
  /**
8938
- * List all accessible projects
8810
+ * Find a specific issue link between two issues by their keys and link type.
8811
+ * Searches bidirectionally - works regardless of which issue key is provided first.
8812
+ *
8813
+ * @returns The link ID if found, null if no matching link exists
8939
8814
  */
8940
- async listProjects(params) {
8941
- const projects = await this.request("GET", "/project/search", {
8942
- query: {
8943
- maxResults: params?.maxResults,
8944
- query: params?.query,
8945
- expand: params?.expand
8815
+ async findIssueLink(params) {
8816
+ const { issueKey, linkedIssueKey, linkType } = params;
8817
+ if (!isValidIssueKey(issueKey)) {
8818
+ throw new Error(`Invalid issue key format: ${issueKey}. Expected format: PROJECT-123`);
8819
+ }
8820
+ if (!isValidIssueKey(linkedIssueKey)) {
8821
+ throw new Error(`Invalid linked issue key format: ${linkedIssueKey}. Expected format: PROJECT-123`);
8822
+ }
8823
+ const links = await this.getIssueLinks({ issueKey });
8824
+ const linkTypeLower = linkType.toLowerCase();
8825
+ for (const link of links) {
8826
+ if (link.type.name.toLowerCase() !== linkTypeLower) {
8827
+ continue;
8946
8828
  }
8947
- });
8948
- return formatProjectList(projects, this.config.siteUrl);
8829
+ const outwardKey = link.outwardIssue?.key;
8830
+ const inwardKey = link.inwardIssue?.key;
8831
+ if (outwardKey === linkedIssueKey || inwardKey === linkedIssueKey) {
8832
+ return link.id;
8833
+ }
8834
+ }
8835
+ return null;
8949
8836
  }
8837
+ // ============================================================================
8838
+ // Project Role & Member Operations
8839
+ // ============================================================================
8950
8840
  /**
8951
- * Get project details
8841
+ * Get all project roles for a project.
8842
+ * Returns a list of roles with their IDs.
8952
8843
  */
8953
- async getProject(params) {
8954
- const { projectKey, expand } = params;
8955
- const project = await this.request("GET", `/project/${projectKey}`, {
8956
- query: {
8957
- expand: expand?.join(",")
8958
- }
8959
- });
8960
- return formatProjectResponse(project, this.config.siteUrl);
8844
+ async getProjectRoles(params) {
8845
+ const { projectKey } = params;
8846
+ const rolesMap = await this.request("GET", `/project/${projectKey}/role`);
8847
+ return formatProjectRoles(rolesMap);
8961
8848
  }
8962
8849
  /**
8963
- * List issue types for a project
8850
+ * Get members (actors) for a specific project role.
8851
+ * Returns the role details with a simplified list of user and group members.
8964
8852
  */
8965
- async listIssueTypes(params) {
8966
- const { projectKey } = params;
8967
- const project = await this.getProject({ projectKey, expand: ["issueTypes"] });
8968
- return project.issueTypes || [];
8853
+ async getProjectRoleMembers(params) {
8854
+ const { projectKey, roleId } = params;
8855
+ const role = await this.request("GET", `/project/${projectKey}/role/${roleId}`);
8856
+ return formatProjectRoleMembers(role);
8969
8857
  }
8970
8858
  /**
8971
- * Add comment to an issue
8859
+ * Get all project members grouped by role.
8860
+ * Convenience method that fetches all roles then all members for each role.
8861
+ * Deduplicates users who appear in multiple roles.
8972
8862
  */
8973
- async addComment(params) {
8974
- const { issueKey, body } = params;
8975
- const comment = await this.request("POST", `/issue/${issueKey}/comment`, {
8976
- body: {
8977
- body: {
8978
- type: "doc",
8979
- version: 1,
8980
- content: [
8981
- {
8982
- type: "paragraph",
8983
- content: [{ type: "text", text: body }]
8984
- }
8985
- ]
8863
+ async getAllProjectMembers(params) {
8864
+ const { projectKey } = params;
8865
+ const roles = await this.getProjectRoles({ projectKey });
8866
+ const roleDetails = await Promise.all(roles.map((role) => this.getProjectRoleMembers({ projectKey, roleId: role.id })));
8867
+ const memberMap = /* @__PURE__ */ new Map();
8868
+ for (const role of roleDetails) {
8869
+ for (const member of role.members) {
8870
+ const key = member.accountId || member.groupName || member.displayName;
8871
+ const existing = memberMap.get(key);
8872
+ if (existing) {
8873
+ existing.roles.push(role.name);
8874
+ } else {
8875
+ memberMap.set(key, { ...member, roles: [role.name] });
8986
8876
  }
8987
8877
  }
8988
- });
8989
- return formatComment(comment);
8878
+ }
8879
+ return {
8880
+ projectKey,
8881
+ roles: roleDetails.map((r) => ({
8882
+ name: r.name,
8883
+ id: r.id,
8884
+ members: r.members
8885
+ })),
8886
+ allMembers: Array.from(memberMap.values())
8887
+ };
8990
8888
  }
8889
+ // ============================================================================
8890
+ // Attachment Operations
8891
+ // ============================================================================
8991
8892
  /**
8992
- * Get available transitions for an issue
8893
+ * List all attachments for an issue
8993
8894
  */
8994
- async getTransitions(params) {
8895
+ async listAttachments(params) {
8995
8896
  const { issueKey } = params;
8996
- const result = await this.request("GET", `/issue/${issueKey}/transitions`);
8997
- return formatTransitions(result);
8897
+ if (!isValidIssueKey(issueKey)) {
8898
+ throw new Error(`Invalid issue key format: ${issueKey}. Expected format: PROJECT-123`);
8899
+ }
8900
+ const issue = await this.request("GET", `/issue/${issueKey}`, {
8901
+ query: { fields: "attachment" }
8902
+ });
8903
+ const attachments = issue.fields?.attachment || [];
8904
+ return attachments.map((att) => ({
8905
+ id: att.id,
8906
+ self: att.self,
8907
+ filename: att.filename,
8908
+ author: att.author,
8909
+ created: att.created,
8910
+ size: att.size,
8911
+ mimeType: att.mimeType,
8912
+ content: att.content,
8913
+ thumbnail: att.thumbnail
8914
+ }));
8998
8915
  }
8999
8916
  /**
9000
- * Transition issue to a new status
8917
+ * Upload an attachment to an issue.
8918
+ * Jira requires multipart/form-data with X-Atlassian-Token: no-check header.
8919
+ *
8920
+ * @param params.issueKey - The issue key (e.g., PROJ-123)
8921
+ * @param params.filename - Name for the uploaded file
8922
+ * @param params.content - Base64-encoded file content
8923
+ * @param params.mimeType - MIME type (auto-detected if omitted)
9001
8924
  */
9002
- async transitionIssue(params) {
9003
- const { issueKey, transitionId } = params;
9004
- await this.request("POST", `/issue/${issueKey}/transitions`, {
9005
- body: {
9006
- transition: { id: transitionId }
9007
- }
8925
+ async uploadAttachment(params) {
8926
+ const { issueKey, filename, content, mimeType } = params;
8927
+ if (!isValidIssueKey(issueKey)) {
8928
+ throw new Error(`Invalid issue key format: ${issueKey}. Expected format: PROJECT-123`);
8929
+ }
8930
+ const binaryContent = Buffer.from(content, "base64");
8931
+ if (binaryContent.length > JIRA_MAX_ATTACHMENT_SIZE) {
8932
+ throw new Error(`File size (${Math.round(binaryContent.length / 1024 / 1024)}MB) exceeds maximum allowed size (${JIRA_MAX_ATTACHMENT_SIZE / 1024 / 1024}MB)`);
8933
+ }
8934
+ const detectedMimeType = mimeType || detectMimeType(filename);
8935
+ const formData = new FormData();
8936
+ const blob = new Blob([binaryContent], { type: detectedMimeType });
8937
+ formData.append("file", blob, filename);
8938
+ const url = `${this.config.apiBaseUrl}/issue/${issueKey}/attachments`;
8939
+ const response = await fetch(url, {
8940
+ method: "POST",
8941
+ headers: {
8942
+ Authorization: this.config.authHeader,
8943
+ Accept: "application/json",
8944
+ "X-Atlassian-Token": "no-check"
8945
+ // Required for attachment uploads
8946
+ },
8947
+ body: formData
9008
8948
  });
9009
- const result = { issueKey, transitionId };
9010
- return formatTransitionResult(result, this.config.siteUrl);
8949
+ if (!response.ok) {
8950
+ const errorBody = await response.text();
8951
+ if (response.status === 413) {
8952
+ throw new Error(`File too large: The attachment exceeds Jira's maximum file size limit.`);
8953
+ }
8954
+ throw new Error(`Jira attachment upload error (${response.status}): ${errorBody}`);
8955
+ }
8956
+ const result = await response.json();
8957
+ return result;
9011
8958
  }
9012
8959
  /**
9013
- * Assign issue to a user
8960
+ * Download an attachment by ID.
8961
+ * Returns the file content as base64-encoded string.
9014
8962
  */
9015
- async assignIssue(params) {
9016
- const { issueKey, accountId } = params;
9017
- await this.request("PUT", `/issue/${issueKey}/assignee`, {
9018
- body: { accountId }
8963
+ async downloadAttachment(params) {
8964
+ const { attachmentId } = params;
8965
+ const metadata = await this.request("GET", `/attachment/${attachmentId}`);
8966
+ if (!metadata.content) {
8967
+ throw new Error(`Attachment ${attachmentId} has no download URL`);
8968
+ }
8969
+ const response = await fetch(metadata.content, {
8970
+ method: "GET",
8971
+ headers: {
8972
+ Authorization: this.config.authHeader
8973
+ }
9019
8974
  });
8975
+ if (!response.ok) {
8976
+ throw new Error(`Failed to download attachment (${response.status}): ${response.statusText}`);
8977
+ }
8978
+ const arrayBuffer = await response.arrayBuffer();
8979
+ const content = Buffer.from(arrayBuffer).toString("base64");
8980
+ return {
8981
+ filename: metadata.filename,
8982
+ mimeType: metadata.mimeType,
8983
+ size: metadata.size,
8984
+ content
8985
+ };
9020
8986
  }
9021
8987
  /**
9022
- * Delete an issue
8988
+ * Delete an attachment by ID.
9023
8989
  */
9024
- async deleteIssue(params) {
9025
- const { issueKey } = params;
9026
- if (!issueKey) {
9027
- throw new Error("issueKey is required to delete an issue.");
9028
- }
9029
- await this.request("DELETE", `/issue/${issueKey}`);
8990
+ async deleteAttachment(params) {
8991
+ const { attachmentId } = params;
8992
+ await this.request("DELETE", `/attachment/${attachmentId}`);
9030
8993
  }
8994
+ // ============================================================================
8995
+ // Agile API Access (Jira Software - Boards, Sprints)
8996
+ // ============================================================================
8997
+ _agileApi = null;
9031
8998
  /**
9032
- * Get current user information
8999
+ * Get the Agile API client for board and sprint operations.
9000
+ * Lazily instantiated on first access.
9033
9001
  */
9034
- async getCurrentUser() {
9035
- const user = await this.request("GET", "/myself");
9036
- return formatUser(user);
9002
+ get agile() {
9003
+ if (!this._agileApi) {
9004
+ this._agileApi = new AgileApi(this.config);
9005
+ }
9006
+ return this._agileApi;
9037
9007
  }
9038
- /**
9039
- * Search for users by query string (name, email, username)
9040
- */
9041
- async searchUsers(params) {
9042
- const { query, maxResults = 50 } = params;
9043
- const users = await this.request("GET", "/user/search", {
9044
- query: {
9045
- query,
9046
- maxResults
9008
+ };
9009
+
9010
+ // ../../b4m-core/packages/common/dist/src/jira/webhook/format.js
9011
+ function daysUntil(dateString) {
9012
+ const expirationDate = new Date(dateString);
9013
+ const now = /* @__PURE__ */ new Date();
9014
+ const diffMs = expirationDate.getTime() - now.getTime();
9015
+ return Math.ceil(diffMs / (1e3 * 60 * 60 * 24));
9016
+ }
9017
+ function formatWebhook(webhook) {
9018
+ const daysUntilExpiry = daysUntil(webhook.expirationDate);
9019
+ return {
9020
+ id: webhook.id,
9021
+ events: webhook.events,
9022
+ jqlFilter: webhook.jqlFilter,
9023
+ expirationDate: webhook.expirationDate,
9024
+ daysUntilExpiry,
9025
+ isExpiringSoon: daysUntilExpiry < 7
9026
+ };
9027
+ }
9028
+ function formatWebhookList(response) {
9029
+ return {
9030
+ webhooks: response.values.map(formatWebhook),
9031
+ total: response.total,
9032
+ hasMore: !response.isLast
9033
+ };
9034
+ }
9035
+ function extractAdfText(body, maxLength = 500) {
9036
+ if (!body || typeof body !== "object")
9037
+ return "";
9038
+ const doc = body;
9039
+ if (doc.type !== "doc" || !Array.isArray(doc.content))
9040
+ return "";
9041
+ const parts = [];
9042
+ let totalLength = 0;
9043
+ function walk(nodes) {
9044
+ for (const node of nodes) {
9045
+ if (totalLength >= maxLength)
9046
+ return;
9047
+ if (!node || typeof node !== "object")
9048
+ continue;
9049
+ const n = node;
9050
+ if (n.type === "text" && typeof n.text === "string") {
9051
+ const remaining = maxLength - totalLength;
9052
+ const text = n.text.slice(0, remaining);
9053
+ parts.push(text);
9054
+ totalLength += text.length;
9055
+ }
9056
+ if (Array.isArray(n.content)) {
9057
+ walk(n.content);
9047
9058
  }
9048
- });
9049
- return formatUserList(users);
9050
- }
9051
- /**
9052
- * Bulk create multiple issues in a single API call.
9053
- * Supports up to 50 issues per request (Jira API limit).
9054
- * Ideal for creating multiple subtasks under a parent issue.
9055
- */
9056
- async bulkCreateIssues(params) {
9057
- const { issues } = params;
9058
- if (issues.length === 0) {
9059
- return { issues: [], errors: [] };
9060
9059
  }
9061
- if (issues.length > 50) {
9062
- throw new Error("Bulk create is limited to 50 issues per request. Please split your request.");
9060
+ }
9061
+ walk(doc.content);
9062
+ const result = parts.join("");
9063
+ if (totalLength >= maxLength)
9064
+ return result + "...";
9065
+ return result;
9066
+ }
9067
+ function escapeSlackMrkdwn(text) {
9068
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\*/g, "\u2217").replace(/_/g, "\uFF3F").replace(/~/g, "\u223C").replace(/`/g, "\u02CB");
9069
+ }
9070
+ var PRIORITY_EMOJI = {
9071
+ Highest: ":red_circle:",
9072
+ High: ":large_orange_circle:",
9073
+ Medium: ":large_yellow_circle:",
9074
+ Low: ":large_green_circle:",
9075
+ Lowest: ":white_circle:"
9076
+ };
9077
+ function getPriorityEmoji(priority) {
9078
+ if (!priority)
9079
+ return "";
9080
+ return PRIORITY_EMOJI[priority] || ":blue_circle:";
9081
+ }
9082
+ function formatIssueEventForSlack(event, siteUrl) {
9083
+ const { issue, webhookEvent, user, changelog } = event;
9084
+ const { fields } = issue;
9085
+ const priorityEmoji = getPriorityEmoji(fields.priority?.name);
9086
+ const issueUrl = `${siteUrl}/browse/${issue.key}`;
9087
+ let action;
9088
+ let emoji;
9089
+ switch (webhookEvent) {
9090
+ case "jira:issue_created":
9091
+ action = "created";
9092
+ emoji = ":new:";
9093
+ break;
9094
+ case "jira:issue_updated":
9095
+ action = "updated";
9096
+ emoji = ":pencil2:";
9097
+ break;
9098
+ case "jira:issue_deleted":
9099
+ action = "deleted";
9100
+ emoji = ":wastebasket:";
9101
+ break;
9102
+ default:
9103
+ action = "changed";
9104
+ emoji = ":bell:";
9105
+ }
9106
+ let changelogText = "";
9107
+ if (changelog?.items && changelog.items.length > 0) {
9108
+ const changes = changelog.items.slice(0, 3).map((item) => {
9109
+ const from = escapeSlackMrkdwn(item.fromString || "None");
9110
+ const to = escapeSlackMrkdwn(item.toString || "None");
9111
+ if (item.field === "status") {
9112
+ return `Status: ${from} \u2192 ${to}`;
9113
+ }
9114
+ if (item.field === "assignee") {
9115
+ return `Assignee: ${escapeSlackMrkdwn(item.fromString || "Unassigned")} \u2192 ${escapeSlackMrkdwn(item.toString || "Unassigned")}`;
9116
+ }
9117
+ if (item.field === "priority") {
9118
+ return `Priority: ${from} \u2192 ${to}`;
9119
+ }
9120
+ return `${escapeSlackMrkdwn(item.field)}: ${from} \u2192 ${to}`;
9121
+ });
9122
+ if (changes.length > 0) {
9123
+ changelogText = "\n" + changes.map((c) => `\u2022 ${c}`).join("\n");
9124
+ if (changelog.items.length > 3) {
9125
+ changelogText += `
9126
+ _...and ${changelog.items.length - 3} more changes_`;
9127
+ }
9063
9128
  }
9064
- const issueUpdates = issues.map((issue) => {
9065
- const { projectKey, summary, description, issueTypeName, assignee, labels, parentKey } = issue;
9066
- const fields = {
9067
- project: { key: projectKey },
9068
- summary,
9069
- issuetype: { name: issueTypeName }
9070
- };
9071
- if (description) {
9072
- if (containsWikiTable(description)) {
9073
- console.log("[JIRA-ADF] Converting wiki table to ADF (bulk)", { length: description.length });
9074
- fields.description = wikiMarkupToAdf(description);
9075
- } else {
9076
- fields.description = {
9077
- type: "doc",
9078
- version: 1,
9079
- content: [
9080
- {
9081
- type: "paragraph",
9082
- content: [{ type: "text", text: description }]
9083
- }
9084
- ]
9085
- };
9086
- }
9129
+ }
9130
+ const blocks = [
9131
+ {
9132
+ type: "header",
9133
+ text: {
9134
+ type: "plain_text",
9135
+ text: `${emoji} Issue ${action}: ${escapeSlackMrkdwn(issue.key)}`,
9136
+ emoji: true
9087
9137
  }
9088
- if (assignee) {
9089
- fields.assignee = { id: assignee };
9138
+ },
9139
+ {
9140
+ type: "section",
9141
+ text: {
9142
+ type: "mrkdwn",
9143
+ text: `*<${issueUrl}|${escapeSlackMrkdwn(issue.key)}>* ${escapeSlackMrkdwn(fields.summary)}`
9090
9144
  }
9091
- if (labels && labels.length > 0) {
9092
- fields.labels = labels;
9145
+ },
9146
+ {
9147
+ type: "section",
9148
+ text: {
9149
+ type: "mrkdwn",
9150
+ text: [
9151
+ `${priorityEmoji} *Priority:* ${escapeSlackMrkdwn(fields.priority?.name || "None")}`,
9152
+ `*Status:* ${escapeSlackMrkdwn(fields.status.name)}`,
9153
+ `*Type:* ${escapeSlackMrkdwn(fields.issuetype.name)}`,
9154
+ `*Assignee:* ${escapeSlackMrkdwn(fields.assignee?.displayName || "Unassigned")}`
9155
+ ].join(" | ")
9093
9156
  }
9094
- if (parentKey) {
9095
- fields.parent = { key: parentKey };
9157
+ }
9158
+ ];
9159
+ if (changelogText) {
9160
+ blocks.push({
9161
+ type: "section",
9162
+ text: {
9163
+ type: "mrkdwn",
9164
+ text: `*Changes:*${changelogText}`
9096
9165
  }
9097
- return { update: {}, fields };
9098
- });
9099
- const result = await this.request("POST", "/issue/bulk", {
9100
- body: { issueUpdates }
9101
9166
  });
9102
- const formattedIssues = (result.issues || []).map((issue) => formatIssueResponse(issue, this.config.siteUrl));
9103
- const formattedErrors = (result.errors || []).map((error) => ({
9104
- status: error.status,
9105
- message: Object.values(error.elementErrors?.errors || {}).join(", ") || "Unknown error",
9106
- failedElementNumber: error.failedElementNumber
9107
- }));
9108
- return {
9109
- issues: formattedIssues,
9110
- errors: formattedErrors
9111
- };
9112
9167
  }
9113
- /**
9114
- * Bulk transition multiple issues to new statuses in a single API call.
9115
- * Uses the Jira Cloud Bulk Transition API.
9116
- * Supports up to 1000 issues per request.
9117
- *
9118
- * Note: This is an async operation - returns a task ID for tracking progress.
9119
- */
9120
- async bulkTransitionIssues(params) {
9121
- const { issues } = params;
9122
- console.error(`[Jira] bulkTransitionIssues called with ${issues.length} issue(s)`);
9123
- if (issues.length === 0) {
9124
- console.error("[Jira] bulkTransitionIssues: No issues provided, returning early");
9125
- return { taskId: "", message: "No issues to transition", issueCount: 0 };
9168
+ blocks.push({
9169
+ type: "context",
9170
+ elements: [
9171
+ {
9172
+ type: "mrkdwn",
9173
+ text: `${escapeSlackMrkdwn(user?.displayName || "Someone")} \u2022 ${escapeSlackMrkdwn(fields.project.name)} (${escapeSlackMrkdwn(fields.project.key)})`
9174
+ }
9175
+ ]
9176
+ });
9177
+ blocks.push({
9178
+ type: "actions",
9179
+ elements: [
9180
+ {
9181
+ type: "button",
9182
+ text: {
9183
+ type: "plain_text",
9184
+ text: "View in Jira",
9185
+ emoji: true
9186
+ },
9187
+ url: issueUrl,
9188
+ style: "primary"
9189
+ }
9190
+ ]
9191
+ });
9192
+ return {
9193
+ blocks,
9194
+ text: `${emoji} ${escapeSlackMrkdwn(issue.key)} ${action}: ${escapeSlackMrkdwn(fields.summary)}`
9195
+ };
9196
+ }
9197
+ function formatCommentEventForSlack(event, siteUrl) {
9198
+ const { issue, comment, webhookEvent } = event;
9199
+ const issueUrl = `${siteUrl}/browse/${issue.key}`;
9200
+ let action;
9201
+ let emoji;
9202
+ switch (webhookEvent) {
9203
+ case "comment_created":
9204
+ action = "commented on";
9205
+ emoji = ":speech_balloon:";
9206
+ break;
9207
+ case "comment_updated":
9208
+ action = "updated comment on";
9209
+ emoji = ":pencil:";
9210
+ break;
9211
+ case "comment_deleted":
9212
+ action = "deleted comment on";
9213
+ emoji = ":x:";
9214
+ break;
9215
+ default:
9216
+ action = "commented on";
9217
+ emoji = ":speech_balloon:";
9218
+ }
9219
+ const blocks = [
9220
+ {
9221
+ type: "header",
9222
+ text: {
9223
+ type: "plain_text",
9224
+ text: `${emoji} ${escapeSlackMrkdwn(comment.author.displayName)} ${action} ${escapeSlackMrkdwn(issue.key)}`,
9225
+ emoji: true
9226
+ }
9227
+ },
9228
+ {
9229
+ type: "section",
9230
+ text: {
9231
+ type: "mrkdwn",
9232
+ text: `*<${issueUrl}|${escapeSlackMrkdwn(issue.key)}>* ${escapeSlackMrkdwn(issue.fields.summary)}`
9233
+ }
9126
9234
  }
9127
- if (issues.length > 1e3) {
9128
- console.error(`[Jira] bulkTransitionIssues: Exceeded 1000 issue limit (${issues.length})`);
9129
- throw new Error("Bulk transition is limited to 1000 issues per request. Please split your request.");
9235
+ ];
9236
+ if (webhookEvent !== "comment_deleted") {
9237
+ const bodyText = extractAdfText(comment.body);
9238
+ if (bodyText) {
9239
+ blocks.push({
9240
+ type: "section",
9241
+ text: {
9242
+ type: "mrkdwn",
9243
+ text: `> ${escapeSlackMrkdwn(bodyText).replace(/\n/g, "\n> ")}`
9244
+ }
9245
+ });
9130
9246
  }
9131
- const transitionGroups = {};
9132
- for (const issue of issues) {
9133
- if (!transitionGroups[issue.transitionId]) {
9134
- transitionGroups[issue.transitionId] = [];
9247
+ }
9248
+ blocks.push({
9249
+ type: "context",
9250
+ elements: [
9251
+ {
9252
+ type: "mrkdwn",
9253
+ text: `${escapeSlackMrkdwn(issue.fields.project.name)} \u2022 ${escapeSlackMrkdwn(issue.fields.issuetype.name)}`
9254
+ }
9255
+ ]
9256
+ }, {
9257
+ type: "actions",
9258
+ elements: [
9259
+ {
9260
+ type: "button",
9261
+ text: {
9262
+ type: "plain_text",
9263
+ text: "View in Jira",
9264
+ emoji: true
9265
+ },
9266
+ url: issueUrl,
9267
+ style: "primary"
9268
+ }
9269
+ ]
9270
+ });
9271
+ return {
9272
+ blocks,
9273
+ text: `${emoji} ${escapeSlackMrkdwn(comment.author.displayName)} ${action} ${escapeSlackMrkdwn(issue.key)}: ${escapeSlackMrkdwn(issue.fields.summary)}`
9274
+ };
9275
+ }
9276
+ function formatSprintEventForSlack(event, siteUrl) {
9277
+ const { sprint, webhookEvent, user } = event;
9278
+ let action;
9279
+ let emoji;
9280
+ switch (webhookEvent) {
9281
+ case "sprint_created":
9282
+ action = "created";
9283
+ emoji = ":calendar:";
9284
+ break;
9285
+ case "sprint_started":
9286
+ action = "started";
9287
+ emoji = ":rocket:";
9288
+ break;
9289
+ case "sprint_closed":
9290
+ action = "completed";
9291
+ emoji = ":checkered_flag:";
9292
+ break;
9293
+ case "sprint_updated":
9294
+ action = "updated";
9295
+ emoji = ":pencil2:";
9296
+ break;
9297
+ case "sprint_deleted":
9298
+ action = "deleted";
9299
+ emoji = ":wastebasket:";
9300
+ break;
9301
+ default:
9302
+ action = "changed";
9303
+ emoji = ":bell:";
9304
+ }
9305
+ const sprintUrl = `${siteUrl}/secure/RapidBoard.jspa?rapidView=${sprint.originBoardId}`;
9306
+ const blocks = [
9307
+ {
9308
+ type: "header",
9309
+ text: {
9310
+ type: "plain_text",
9311
+ text: `${emoji} Sprint ${action}: ${escapeSlackMrkdwn(sprint.name)}`,
9312
+ emoji: true
9313
+ }
9314
+ },
9315
+ {
9316
+ type: "section",
9317
+ text: {
9318
+ type: "mrkdwn",
9319
+ text: [
9320
+ `*State:* ${escapeSlackMrkdwn(sprint.state)}`,
9321
+ sprint.startDate ? `*Start:* ${new Date(sprint.startDate).toLocaleDateString()}` : "",
9322
+ sprint.endDate ? `*End:* ${new Date(sprint.endDate).toLocaleDateString()}` : ""
9323
+ ].filter(Boolean).join(" | ")
9135
9324
  }
9136
- transitionGroups[issue.transitionId].push(issue.issueIdOrKey);
9137
9325
  }
9138
- const bulkTransitionInputs = Object.entries(transitionGroups).map(([transitionId, selectedIssueIdsOrKeys]) => ({
9139
- selectedIssueIdsOrKeys,
9140
- transitionId
9141
- }));
9142
- console.error(`[Jira] bulkTransitionIssues: Grouped into ${bulkTransitionInputs.length} transition group(s)`);
9143
- const result = await this.request("POST", "/bulk/issues/transition", {
9144
- body: { bulkTransitionInputs }
9326
+ ];
9327
+ if (sprint.goal) {
9328
+ blocks.push({
9329
+ type: "section",
9330
+ text: {
9331
+ type: "mrkdwn",
9332
+ text: `*Goal:* ${escapeSlackMrkdwn(sprint.goal)}`
9333
+ }
9145
9334
  });
9146
- console.error(`[Jira] bulkTransitionIssues: Task created with ID ${result.taskId}`);
9147
- return {
9148
- taskId: result.taskId,
9149
- message: `Bulk transition started for ${issues.length} issue(s).`,
9150
- issueCount: issues.length
9151
- };
9152
9335
  }
9153
- /**
9154
- * Bulk update labels on multiple issues in a single API call.
9155
- * Uses the Jira Cloud Bulk Edit API.
9156
- * Supports up to 1000 issues per request.
9157
- *
9158
- * Note: This is an async operation - returns a task ID for tracking progress.
9159
- *
9160
- * Why only labels? Jira's Bulk Edit API has significant limitations:
9161
- * - Summary, description, assignee fields are NOT supported for bulk editing
9162
- * - Priority and issueType require IDs and have inconsistent behavior
9163
- * - Labels is the most reliable and commonly used bulk operation
9164
- * See: https://community.atlassian.com/forums/Jira-questions/Bulk-Change-Description-Field/qaq-p/897762
9165
- */
9166
- async bulkUpdateIssues(params) {
9167
- const { issueIdsOrKeys, labels } = params;
9168
- console.error(`[Jira] bulkUpdateIssues called with ${issueIdsOrKeys.length} issue(s), action: ${labels.action}`);
9169
- if (issueIdsOrKeys.length === 0) {
9170
- console.error("[Jira] bulkUpdateIssues: No issues provided, returning early");
9171
- return { taskId: "", message: "No issues to update", issueCount: 0 };
9172
- }
9173
- if (issueIdsOrKeys.length > 1e3) {
9174
- console.error(`[Jira] bulkUpdateIssues: Exceeded 1000 issue limit (${issueIdsOrKeys.length})`);
9175
- throw new Error("Bulk update is limited to 1000 issues per request. Please split your request.");
9336
+ blocks.push({
9337
+ type: "context",
9338
+ elements: [
9339
+ {
9340
+ type: "mrkdwn",
9341
+ text: user?.displayName ? escapeSlackMrkdwn(user.displayName) : "System"
9342
+ }
9343
+ ]
9344
+ });
9345
+ blocks.push({
9346
+ type: "actions",
9347
+ elements: [
9348
+ {
9349
+ type: "button",
9350
+ text: {
9351
+ type: "plain_text",
9352
+ text: "View Board",
9353
+ emoji: true
9354
+ },
9355
+ url: sprintUrl,
9356
+ style: "primary"
9357
+ }
9358
+ ]
9359
+ });
9360
+ return {
9361
+ blocks,
9362
+ text: `${emoji} Sprint ${action}: ${escapeSlackMrkdwn(sprint.name)}`
9363
+ };
9364
+ }
9365
+ function formatGenericEventForSlack(eventType, payload, siteUrl) {
9366
+ const issue = payload.issue;
9367
+ const user = payload.user;
9368
+ const issueLink = payload.issueLink;
9369
+ const changelog = payload.changelog;
9370
+ const project = payload.project ?? issue?.fields?.project;
9371
+ const version = payload.version;
9372
+ const worklog = payload.worklog;
9373
+ const board = payload.board;
9374
+ const readableEvent = eventType.replace(/^jira:/, "").replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
9375
+ const blocks = [
9376
+ {
9377
+ type: "header",
9378
+ text: {
9379
+ type: "plain_text",
9380
+ text: `:bell: ${readableEvent}`,
9381
+ emoji: true
9382
+ }
9176
9383
  }
9177
- const selectedActions = ["labels"];
9178
- const editedFieldsInput = {
9179
- labelsFields: [
9180
- {
9181
- fieldId: "labels",
9182
- labels: labels.values.map((name) => ({ name })),
9183
- bulkEditMultiSelectFieldOption: labels.action
9184
- }
9185
- ]
9186
- };
9187
- console.error(`[Jira] bulkUpdateIssues: Updating labels [${labels.values.join(", ")}] with action ${labels.action}`);
9188
- const result = await this.request("POST", "/bulk/issues/fields", {
9189
- body: {
9190
- selectedActions,
9191
- selectedIssueIdsOrKeys: issueIdsOrKeys,
9192
- editedFieldsInput
9384
+ ];
9385
+ const details = [];
9386
+ if (issue?.key) {
9387
+ const issueUrl = `${siteUrl}/browse/${issue.key}`;
9388
+ blocks.push({
9389
+ type: "section",
9390
+ text: {
9391
+ type: "mrkdwn",
9392
+ text: `*<${issueUrl}|${escapeSlackMrkdwn(issue.key)}>* ${escapeSlackMrkdwn(issue.fields?.summary || "")}`
9193
9393
  }
9194
9394
  });
9195
- console.error(`[Jira] bulkUpdateIssues: Task created with ID ${result.taskId}`);
9196
- return {
9197
- taskId: result.taskId,
9198
- message: `Bulk label update started for ${issueIdsOrKeys.length} issue(s). Action: ${labels.action}.`,
9199
- issueCount: issueIdsOrKeys.length
9200
- };
9395
+ const meta = [];
9396
+ if (issue.fields?.status?.name)
9397
+ meta.push(`*Status:* ${escapeSlackMrkdwn(issue.fields.status.name)}`);
9398
+ if (issue.fields?.issuetype?.name)
9399
+ meta.push(`*Type:* ${escapeSlackMrkdwn(issue.fields.issuetype.name)}`);
9400
+ if (issue.fields?.priority?.name) {
9401
+ const emoji = getPriorityEmoji(issue.fields.priority.name);
9402
+ meta.push(`${emoji} *Priority:* ${escapeSlackMrkdwn(issue.fields.priority.name)}`);
9403
+ }
9404
+ if (issue.fields?.assignee?.displayName)
9405
+ meta.push(`*Assignee:* ${escapeSlackMrkdwn(issue.fields.assignee.displayName)}`);
9406
+ if (meta.length > 0)
9407
+ details.push(meta.join(" | "));
9201
9408
  }
9202
- /**
9203
- * Get watchers for an issue
9204
- */
9205
- async getWatchers(params) {
9206
- const { issueKey } = params;
9207
- const result = await this.request("GET", `/issue/${issueKey}/watchers`);
9208
- return formatWatchers(result);
9409
+ if (issueLink?.issueLinkType) {
9410
+ const linkType = issueLink.issueLinkType;
9411
+ const linkDesc = escapeSlackMrkdwn(linkType.outwardName || linkType.name || "linked");
9412
+ details.push(`*Link type:* ${linkDesc}`);
9413
+ if (issueLink.sourceIssueId)
9414
+ details.push(`*Source issue ID:* ${issueLink.sourceIssueId}`);
9415
+ if (issueLink.destinationIssueId)
9416
+ details.push(`*Destination issue ID:* ${issueLink.destinationIssueId}`);
9209
9417
  }
9210
- /**
9211
- * Add a watcher to an issue
9212
- */
9213
- async addWatcher(params) {
9214
- const { issueKey, accountId } = params;
9215
- await this.request("POST", `/issue/${issueKey}/watchers`, {
9216
- body: accountId
9217
- });
9418
+ if (version?.name) {
9419
+ const versionParts = [`*Version:* ${escapeSlackMrkdwn(version.name)}`];
9420
+ if (version.description)
9421
+ versionParts.push(escapeSlackMrkdwn(version.description));
9422
+ if (version.released !== void 0)
9423
+ versionParts.push(version.released ? ":white_check_mark: Released" : "Unreleased");
9424
+ details.push(versionParts.join(" | "));
9218
9425
  }
9219
- /**
9220
- * Remove a watcher from an issue
9221
- */
9222
- async removeWatcher(params) {
9223
- const { issueKey, accountId } = params;
9224
- await this.request("DELETE", `/issue/${issueKey}/watchers`, {
9225
- query: { accountId }
9226
- });
9426
+ if (worklog) {
9427
+ const wlParts = [];
9428
+ if (worklog.author?.displayName)
9429
+ wlParts.push(`*By:* ${escapeSlackMrkdwn(worklog.author.displayName)}`);
9430
+ if (worklog.timeSpent)
9431
+ wlParts.push(`*Time:* ${escapeSlackMrkdwn(worklog.timeSpent)}`);
9432
+ if (wlParts.length > 0)
9433
+ details.push(wlParts.join(" | "));
9227
9434
  }
9228
- // ============================================================================
9229
- // Issue Link Operations
9230
- // ============================================================================
9231
- /**
9232
- * Get all available issue link types in the Jira instance
9233
- */
9234
- async getIssueLinkTypes() {
9235
- const result = await this.request("GET", "/issueLinkType");
9236
- return formatIssueLinkTypes(result);
9435
+ if (board?.name) {
9436
+ details.push(`*Board:* ${escapeSlackMrkdwn(board.name)}`);
9237
9437
  }
9238
- /**
9239
- * Get all issue links for a specific issue
9240
- */
9241
- async getIssueLinks(params) {
9242
- const { issueKey } = params;
9243
- if (!isValidIssueKey(issueKey)) {
9244
- throw new Error(`Invalid issue key format: ${issueKey}. Expected format: PROJECT-123`);
9438
+ if (changelog?.items && changelog.items.length > 0) {
9439
+ const changes = changelog.items.slice(0, 3).map((item) => `\u2022 ${escapeSlackMrkdwn(item.field || "")}: ${escapeSlackMrkdwn(item.fromString || "None")} \u2192 ${escapeSlackMrkdwn(item.toString || "None")}`);
9440
+ if (changelog.items.length > 3) {
9441
+ changes.push(`_...and ${changelog.items.length - 3} more_`);
9245
9442
  }
9246
- const issue = await this.request("GET", `/issue/${issueKey}`, {
9247
- query: { fields: "issuelinks" }
9248
- });
9249
- return formatIssueLinks(issue.fields?.issuelinks || [], this.config.siteUrl);
9443
+ details.push(`*Changes:*
9444
+ ${changes.join("\n")}`);
9250
9445
  }
9251
- /**
9252
- * Create a link between two issues.
9253
- * Uses intuitive sourceIssue/targetIssue terminology:
9254
- * - sourceIssue: The issue doing the action (e.g., PROJ-1 in "PROJ-1 blocks PROJ-2")
9255
- * - targetIssue: The issue being acted upon (e.g., PROJ-2 in "PROJ-1 blocks PROJ-2")
9256
- *
9257
- * This maps to Jira's API as: sourceIssue → outwardIssue, targetIssue → inwardIssue
9258
- *
9259
- * @returns void - Jira returns 201 with no body on success
9260
- */
9261
- async createIssueLink(params) {
9262
- const { linkType, sourceIssue, targetIssue } = params;
9263
- if (!isValidIssueKey(sourceIssue)) {
9264
- throw new Error(`Invalid source issue key format: ${sourceIssue}. Expected format: PROJECT-123`);
9265
- }
9266
- if (!isValidIssueKey(targetIssue)) {
9267
- throw new Error(`Invalid target issue key format: ${targetIssue}. Expected format: PROJECT-123`);
9268
- }
9269
- const linkTypes = await this.getIssueLinkTypes();
9270
- const matchedType = linkTypes.find((t) => t.name.toLowerCase() === linkType.toLowerCase());
9271
- if (!matchedType) {
9272
- const availableTypes = linkTypes.map((t) => t.name).join(", ");
9273
- throw new Error(`Invalid link type: "${linkType}". Available types: ${availableTypes}`);
9274
- }
9275
- await this.request("POST", "/issueLink", {
9276
- body: {
9277
- type: { name: matchedType.name },
9278
- outwardIssue: { key: sourceIssue },
9279
- inwardIssue: { key: targetIssue }
9446
+ if (details.length > 0) {
9447
+ blocks.push({
9448
+ type: "section",
9449
+ text: {
9450
+ type: "mrkdwn",
9451
+ text: details.join("\n")
9280
9452
  }
9281
9453
  });
9282
9454
  }
9283
- /**
9284
- * Delete an issue link by its ID
9285
- */
9286
- async deleteIssueLink(params) {
9287
- const { linkId } = params;
9288
- await this.request("DELETE", `/issueLink/${linkId}`);
9455
+ const contextParts = [];
9456
+ if (user?.displayName)
9457
+ contextParts.push(escapeSlackMrkdwn(user.displayName));
9458
+ if (project?.name)
9459
+ contextParts.push(`${escapeSlackMrkdwn(project.name)}${project.key ? ` (${escapeSlackMrkdwn(project.key)})` : ""}`);
9460
+ if (contextParts.length > 0) {
9461
+ blocks.push({
9462
+ type: "context",
9463
+ elements: [{ type: "mrkdwn", text: contextParts.join(" \u2022 ") }]
9464
+ });
9289
9465
  }
9290
- /**
9291
- * Find a specific issue link between two issues by their keys and link type.
9292
- * Searches bidirectionally - works regardless of which issue key is provided first.
9293
- *
9294
- * @returns The link ID if found, null if no matching link exists
9295
- */
9296
- async findIssueLink(params) {
9297
- const { issueKey, linkedIssueKey, linkType } = params;
9298
- if (!isValidIssueKey(issueKey)) {
9299
- throw new Error(`Invalid issue key format: ${issueKey}. Expected format: PROJECT-123`);
9300
- }
9301
- if (!isValidIssueKey(linkedIssueKey)) {
9302
- throw new Error(`Invalid linked issue key format: ${linkedIssueKey}. Expected format: PROJECT-123`);
9303
- }
9304
- const links = await this.getIssueLinks({ issueKey });
9305
- const linkTypeLower = linkType.toLowerCase();
9306
- for (const link of links) {
9307
- if (link.type.name.toLowerCase() !== linkTypeLower) {
9308
- continue;
9309
- }
9310
- const outwardKey = link.outwardIssue?.key;
9311
- const inwardKey = link.inwardIssue?.key;
9312
- if (outwardKey === linkedIssueKey || inwardKey === linkedIssueKey) {
9313
- return link.id;
9314
- }
9315
- }
9316
- return null;
9466
+ if (issue?.key) {
9467
+ const issueUrl = `${siteUrl}/browse/${issue.key}`;
9468
+ blocks.push({
9469
+ type: "actions",
9470
+ elements: [
9471
+ {
9472
+ type: "button",
9473
+ text: { type: "plain_text", text: "View in Jira", emoji: true },
9474
+ url: issueUrl,
9475
+ style: "primary"
9476
+ }
9477
+ ]
9478
+ });
9317
9479
  }
9318
- // ============================================================================
9319
- // Project Role & Member Operations
9320
- // ============================================================================
9321
- /**
9322
- * Get all project roles for a project.
9323
- * Returns a list of roles with their IDs.
9324
- */
9325
- async getProjectRoles(params) {
9326
- const { projectKey } = params;
9327
- const rolesMap = await this.request("GET", `/project/${projectKey}/role`);
9328
- return formatProjectRoles(rolesMap);
9480
+ const summary = issue?.key ? `${escapeSlackMrkdwn(issue.key)}: ${escapeSlackMrkdwn(issue.fields?.summary || readableEvent)}` : readableEvent;
9481
+ return {
9482
+ blocks,
9483
+ text: `:bell: ${summary}`
9484
+ };
9485
+ }
9486
+
9487
+ // ../../b4m-core/packages/common/dist/src/jira/webhook/api.js
9488
+ var WebhookApi = class {
9489
+ config;
9490
+ constructor(config) {
9491
+ this.config = config;
9329
9492
  }
9330
9493
  /**
9331
- * Get members (actors) for a specific project role.
9332
- * Returns the role details with a simplified list of user and group members.
9494
+ * Build URL for webhook API endpoints.
9495
+ *
9496
+ * Note: Webhooks use a different base URL than other Jira APIs.
9333
9497
  */
9334
- async getProjectRoleMembers(params) {
9335
- const { projectKey, roleId } = params;
9336
- const role = await this.request("GET", `/project/${projectKey}/role/${roleId}`);
9337
- return formatProjectRoleMembers(role);
9498
+ buildUrl(path, query = {}) {
9499
+ const url = new URL(`${this.config.apiBaseUrl}${path}`);
9500
+ for (const [key, value] of Object.entries(query)) {
9501
+ if (value !== void 0) {
9502
+ url.searchParams.append(key, String(value));
9503
+ }
9504
+ }
9505
+ return url.toString();
9338
9506
  }
9339
9507
  /**
9340
- * Get all project members grouped by role.
9341
- * Convenience method that fetches all roles then all members for each role.
9342
- * Deduplicates users who appear in multiple roles.
9508
+ * Make an authenticated request to the Jira Webhook API.
9343
9509
  */
9344
- async getAllProjectMembers(params) {
9345
- const { projectKey } = params;
9346
- const roles = await this.getProjectRoles({ projectKey });
9347
- const roleDetails = await Promise.all(roles.map((role) => this.getProjectRoleMembers({ projectKey, roleId: role.id })));
9348
- const memberMap = /* @__PURE__ */ new Map();
9349
- for (const role of roleDetails) {
9350
- for (const member of role.members) {
9351
- const key = member.accountId || member.groupName || member.displayName;
9352
- const existing = memberMap.get(key);
9353
- if (existing) {
9354
- existing.roles.push(role.name);
9355
- } else {
9356
- memberMap.set(key, { ...member, roles: [role.name] });
9357
- }
9358
- }
9359
- }
9360
- return {
9361
- projectKey,
9362
- roles: roleDetails.map((r) => ({
9363
- name: r.name,
9364
- id: r.id,
9365
- members: r.members
9366
- })),
9367
- allMembers: Array.from(memberMap.values())
9510
+ async request(method, path, options = {}) {
9511
+ const url = this.buildUrl(path, options.query);
9512
+ const headers = {
9513
+ Authorization: this.config.authHeader,
9514
+ Accept: "application/json",
9515
+ "Content-Type": "application/json"
9368
9516
  };
9517
+ const response = await fetch(url, {
9518
+ method,
9519
+ headers,
9520
+ body: options.body ? JSON.stringify(options.body) : void 0
9521
+ });
9522
+ if (!response.ok) {
9523
+ const errorBody = await response.text();
9524
+ throw new Error(`Jira Webhook API error (${response.status}): ${errorBody}`);
9525
+ }
9526
+ if (response.status === 204) {
9527
+ return {};
9528
+ }
9529
+ const data = await response.json();
9530
+ return data;
9369
9531
  }
9370
9532
  // ============================================================================
9371
- // Attachment Operations
9533
+ // Webhook CRUD Operations
9372
9534
  // ============================================================================
9373
9535
  /**
9374
- * List all attachments for an issue
9536
+ * List all webhooks registered by this OAuth app.
9537
+ *
9538
+ * @param startAt - Pagination start index (default: 0)
9539
+ * @param maxResults - Max results per page (default: 50, max: 100)
9375
9540
  */
9376
- async listAttachments(params) {
9377
- const { issueKey } = params;
9378
- if (!isValidIssueKey(issueKey)) {
9379
- throw new Error(`Invalid issue key format: ${issueKey}. Expected format: PROJECT-123`);
9380
- }
9381
- const issue = await this.request("GET", `/issue/${issueKey}`, {
9382
- query: { fields: "attachment" }
9541
+ async listWebhooks(params = {}) {
9542
+ const { startAt = 0, maxResults = 50 } = params;
9543
+ const response = await this.request("GET", "/webhook", {
9544
+ query: { startAt, maxResults }
9383
9545
  });
9384
- const attachments = issue.fields?.attachment || [];
9385
- return attachments.map((att) => ({
9386
- id: att.id,
9387
- self: att.self,
9388
- filename: att.filename,
9389
- author: att.author,
9390
- created: att.created,
9391
- size: att.size,
9392
- mimeType: att.mimeType,
9393
- content: att.content,
9394
- thumbnail: att.thumbnail
9395
- }));
9546
+ return formatWebhookList(response);
9396
9547
  }
9397
9548
  /**
9398
- * Upload an attachment to an issue.
9399
- * Jira requires multipart/form-data with X-Atlassian-Token: no-check header.
9549
+ * Register a new webhook.
9400
9550
  *
9401
- * @param params.issueKey - The issue key (e.g., PROJ-123)
9402
- * @param params.filename - Name for the uploaded file
9403
- * @param params.content - Base64-encoded file content
9404
- * @param params.mimeType - MIME type (auto-detected if omitted)
9551
+ * @param url - The URL where Jira will POST webhook events
9552
+ * @param events - Events to subscribe to
9553
+ * @param jqlFilter - Optional JQL filter (only matching issues trigger events)
9554
+ * @returns The created webhook ID
9405
9555
  */
9406
- async uploadAttachment(params) {
9407
- const { issueKey, filename, content, mimeType } = params;
9408
- if (!isValidIssueKey(issueKey)) {
9409
- throw new Error(`Invalid issue key format: ${issueKey}. Expected format: PROJECT-123`);
9410
- }
9411
- const binaryContent = Buffer.from(content, "base64");
9412
- if (binaryContent.length > JIRA_MAX_ATTACHMENT_SIZE) {
9413
- throw new Error(`File size (${Math.round(binaryContent.length / 1024 / 1024)}MB) exceeds maximum allowed size (${JIRA_MAX_ATTACHMENT_SIZE / 1024 / 1024}MB)`);
9414
- }
9415
- const detectedMimeType = mimeType || detectMimeType(filename);
9416
- const formData = new FormData();
9417
- const blob = new Blob([binaryContent], { type: detectedMimeType });
9418
- formData.append("file", blob, filename);
9419
- const url = `${this.config.apiBaseUrl}/issue/${issueKey}/attachments`;
9420
- const response = await fetch(url, {
9421
- method: "POST",
9422
- headers: {
9423
- Authorization: this.config.authHeader,
9424
- Accept: "application/json",
9425
- "X-Atlassian-Token": "no-check"
9426
- // Required for attachment uploads
9427
- },
9428
- body: formData
9556
+ async registerWebhook(params) {
9557
+ const { url, events, jqlFilter } = params;
9558
+ const webhook = {
9559
+ // Jira webhook API requires jqlFilter but has limited operator support.
9560
+ // "IS NOT EMPTY" and empty strings are rejected. Use a universally-true expression.
9561
+ jqlFilter: jqlFilter || "project != null",
9562
+ events
9563
+ };
9564
+ const body = {
9565
+ url,
9566
+ webhooks: [webhook]
9567
+ };
9568
+ const response = await this.request("POST", "/webhook", {
9569
+ body
9429
9570
  });
9430
- if (!response.ok) {
9431
- const errorBody = await response.text();
9432
- if (response.status === 413) {
9433
- throw new Error(`File too large: The attachment exceeds Jira's maximum file size limit.`);
9434
- }
9435
- throw new Error(`Jira attachment upload error (${response.status}): ${errorBody}`);
9571
+ console.log("[JIRA-WEBHOOK-API] registerWebhook raw response:", JSON.stringify(response, null, 2));
9572
+ if (!response.webhookRegistrationResult || response.webhookRegistrationResult.length === 0) {
9573
+ throw new Error("Failed to register webhook: No registration result returned");
9436
9574
  }
9437
- const result = await response.json();
9438
- return result;
9439
- }
9440
- /**
9441
- * Download an attachment by ID.
9442
- * Returns the file content as base64-encoded string.
9443
- */
9444
- async downloadAttachment(params) {
9445
- const { attachmentId } = params;
9446
- const metadata = await this.request("GET", `/attachment/${attachmentId}`);
9447
- if (!metadata.content) {
9448
- throw new Error(`Attachment ${attachmentId} has no download URL`);
9575
+ const result = response.webhookRegistrationResult[0];
9576
+ if (result.errors && result.errors.length > 0) {
9577
+ throw new Error(`Failed to register webhook: ${result.errors.join(", ")}`);
9449
9578
  }
9450
- const response = await fetch(metadata.content, {
9451
- method: "GET",
9452
- headers: {
9453
- Authorization: this.config.authHeader
9454
- }
9455
- });
9456
- if (!response.ok) {
9457
- throw new Error(`Failed to download attachment (${response.status}): ${response.statusText}`);
9579
+ if (result.createdWebhookId === void 0 || result.createdWebhookId === null) {
9580
+ throw new Error(`Failed to register webhook: No webhook ID in response. Full result: ${JSON.stringify(result)}`);
9458
9581
  }
9459
- const arrayBuffer = await response.arrayBuffer();
9460
- const content = Buffer.from(arrayBuffer).toString("base64");
9461
9582
  return {
9462
- filename: metadata.filename,
9463
- mimeType: metadata.mimeType,
9464
- size: metadata.size,
9465
- content
9583
+ webhookId: result.createdWebhookId
9466
9584
  };
9467
9585
  }
9468
9586
  /**
9469
- * Delete an attachment by ID.
9587
+ * Refresh webhook expiration dates.
9588
+ *
9589
+ * Jira webhooks expire after 30 days. Call this to extend expiration.
9590
+ *
9591
+ * @param webhookIds - IDs of webhooks to refresh
9592
+ * @returns New expiration date
9470
9593
  */
9471
- async deleteAttachment(params) {
9472
- const { attachmentId } = params;
9473
- await this.request("DELETE", `/attachment/${attachmentId}`);
9594
+ async refreshWebhooks(params) {
9595
+ const { webhookIds } = params;
9596
+ const body = {
9597
+ webhookIds
9598
+ };
9599
+ const response = await this.request("PUT", "/webhook/refresh", {
9600
+ body
9601
+ });
9602
+ return {
9603
+ expirationDate: response.expirationDate
9604
+ };
9474
9605
  }
9475
- // ============================================================================
9476
- // Agile API Access (Jira Software - Boards, Sprints)
9477
- // ============================================================================
9478
- _agileApi = null;
9479
9606
  /**
9480
- * Get the Agile API client for board and sprint operations.
9481
- * Lazily instantiated on first access.
9607
+ * Delete webhooks by ID.
9608
+ *
9609
+ * @param webhookIds - IDs of webhooks to delete
9482
9610
  */
9483
- get agile() {
9484
- if (!this._agileApi) {
9485
- this._agileApi = new AgileApi(this.config);
9486
- }
9487
- return this._agileApi;
9611
+ async deleteWebhooks(params) {
9612
+ const { webhookIds } = params;
9613
+ const body = {
9614
+ webhookIds
9615
+ };
9616
+ await this.request("DELETE", "/webhook", {
9617
+ body
9618
+ });
9488
9619
  }
9489
- // ============================================================================
9490
- // Webhook API Access (Webhook Management)
9491
- // ============================================================================
9492
- _webhookApi = null;
9493
9620
  /**
9494
- * Get the Webhook API client for webhook management operations.
9495
- * Lazily instantiated on first access.
9621
+ * Get a specific webhook by ID.
9622
+ *
9623
+ * Note: Jira API doesn't have a direct "get by ID" endpoint,
9624
+ * so we list and filter.
9496
9625
  */
9497
- get webhook() {
9498
- if (!this._webhookApi) {
9499
- this._webhookApi = new WebhookApi(this.config);
9500
- }
9501
- return this._webhookApi;
9626
+ async getWebhook(params) {
9627
+ const { webhookId } = params;
9628
+ const response = await this.request("GET", "/webhook", {
9629
+ query: { startAt: 0, maxResults: 100 }
9630
+ });
9631
+ const webhook = response.values.find((w) => w.id === webhookId);
9632
+ return webhook || null;
9502
9633
  }
9503
9634
  };
9504
9635
 
@@ -11399,6 +11530,12 @@ export {
11399
11530
  CliCompletionDoneAction,
11400
11531
  CliCompletionErrorAction,
11401
11532
  CliToolResponseAction,
11533
+ KeepCommandRequestAction,
11534
+ KeepCommandAction,
11535
+ KeepCommandResponseAction,
11536
+ KeepCommandResultAction,
11537
+ TavernSceneCommandRequestAction,
11538
+ TavernSceneBroadcastAction,
11402
11539
  SessionCreatedAction,
11403
11540
  MessageDataToServer,
11404
11541
  MessageDataToClient,
@@ -11646,6 +11783,13 @@ export {
11646
11783
  formatProjectRoles,
11647
11784
  formatProjectRoleMembers,
11648
11785
  AgileApi,
11786
+ JIRA_MAX_ATTACHMENT_SIZE,
11787
+ JIRA_PROJECT_KEY_RE,
11788
+ isValidProjectKey,
11789
+ isValidIssueKey,
11790
+ wikiMarkupToAdf,
11791
+ containsWikiTable,
11792
+ JiraApi,
11649
11793
  formatWebhook,
11650
11794
  formatWebhookList,
11651
11795
  extractAdfText,
@@ -11655,11 +11799,6 @@ export {
11655
11799
  formatSprintEventForSlack,
11656
11800
  formatGenericEventForSlack,
11657
11801
  WebhookApi,
11658
- JIRA_MAX_ATTACHMENT_SIZE,
11659
- isValidIssueKey,
11660
- wikiMarkupToAdf,
11661
- containsWikiTable,
11662
- JiraApi,
11663
11802
  isIssueWebhookEvent,
11664
11803
  isCommentWebhookEvent,
11665
11804
  isSprintWebhookEvent,