Skip to content

Commit 9eaeb83

Browse files
committed
fix(atlassian): address final review
Make Jira search heavy fields (ADF description, custom fields, attachments, links, subtasks) opt-in through fields, move transformIssueData into jira/utils, keep AQL order by splitting at parenthesis depth 0, let the object-types /flat fallback never add a failure, send next=true with Confluence search cursors, and harden Trello ID lists, Jira parent, and Confluence comment ids for agent calls.
1 parent 4388745 commit 9eaeb83

22 files changed

Lines changed: 742 additions & 269 deletions

‎apps/docs/content/docs/integrations/jira.mdx‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,7 @@ Search for Jira issues using JQL (Jira Query Language)
548548
| `jql` | string | Yes | JQL query string to search for issues \(e.g., "project = PROJ AND status = Open"\) |
549549
| `nextPageToken` | string | No | Cursor token for the next page of results. Omit for the first page. |
550550
| `maxResults` | number | No | Maximum number of results to return per page \(default: 50\) |
551-
| `fields` | array | No | Array of field names to return \(default: all fields\). |
551+
| `fields` | array | No | Array of field names to return \(default: all fields\). When set, each issue also includes descriptionAdf, customFields, attachments, issuelinks, and subtasks. |
552552
| `expand` | string | No | Comma-separated expansions: renderedFields, names, schema, transitions, operations, editmeta, changelog |
553553

554554
#### Output
@@ -562,8 +562,8 @@ Search for Jira issues using JQL (Jira Query Language)
562562
| ↳ `self` | string | REST API URL for this issue |
563563
| ↳ `summary` | string | Issue summary |
564564
| ↳ `description` | string | Issue description text \(extracted from ADF\) |
565-
| ↳ `descriptionAdf` | json | Issue description as the raw Atlassian Document Format \(ADF\) document |
566-
| ↳ `renderedDescription` | string | Issue description rendered as HTML \(requires the renderedFields expansion\) |
565+
| ↳ `descriptionAdf` | json | Issue description as the raw Atlassian Document Format \(ADF\) document. Only included when the fields param is set |
566+
| ↳ `renderedDescription` | string | Issue description rendered as HTML. Only included when expand includes renderedFields |
567567
| ↳ `environment` | string | Environment text \(extracted from ADF\) |
568568
| ↳ `status` | object | Issue status |
569569
| ↳ `id` | string | Status ID |
@@ -644,7 +644,7 @@ Search for Jira issues using JQL (Jira Query Language)
644644
| ↳ `id` | string | Parent issue ID |
645645
| ↳ `key` | string | Parent issue key |
646646
| ↳ `summary` | string | Parent issue summary |
647-
| ↳ `issuelinks` | array | Linked issues |
647+
| ↳ `issuelinks` | array | Linked issues. Only included when the fields param is set |
648648
| ↳ `id` | string | Issue link ID |
649649
| ↳ `type` | object | Link type information |
650650
| ↳ `id` | string | Link type ID |
@@ -661,7 +661,7 @@ Search for Jira issues using JQL (Jira Query Language)
661661
| ↳ `key` | string | Issue key |
662662
| ↳ `statusName` | string | Issue status name |
663663
| ↳ `summary` | string | Issue summary |
664-
| ↳ `subtasks` | array | Subtask issues |
664+
| ↳ `subtasks` | array | Subtask issues. Only included when the fields param is set |
665665
| ↳ `id` | string | Subtask issue ID |
666666
| ↳ `key` | string | Subtask issue key |
667667
| ↳ `summary` | string | Subtask summary |
@@ -673,7 +673,7 @@ Search for Jira issues using JQL (Jira Query Language)
673673
| ↳ `watches` | object | Watch information |
674674
| ↳ `watchCount` | number | Number of watchers |
675675
| ↳ `isWatching` | boolean | Whether the current user is watching |
676-
| ↳ `attachments` | array | Attachment metadata \(no file contents\) |
676+
| ↳ `attachments` | array | Attachment metadata \(no file contents\). Only included when the fields param is set |
677677
| ↳ `id` | string | Attachment ID |
678678
| ↳ `filename` | string | Attachment file name |
679679
| ↳ `mimeType` | string | MIME type of the attachment |
@@ -690,7 +690,7 @@ Search for Jira issues using JQL (Jira Query Language)
690690
| ↳ `timeZone` | string | User timezone |
691691
| ↳ `authorName` | string | Attachment author display name |
692692
| ↳ `created` | string | ISO 8601 timestamp when the attachment was created |
693-
| ↳ `customFields` | json | Every customfield_* value Jira returned, keyed by field ID \(e.g., sprint, story points, team\). Use the names output or Get Fields to map IDs to names. |
693+
| ↳ `customFields` | json | Every customfield_* value Jira returned, keyed by field ID \(e.g., sprint, story points, team\). Only included when the fields param is set; use the names output or Get Fields to map IDs to names. |
694694
| `nextPageToken` | string | Cursor token for the next page. Null when no more results. |
695695
| `isLast` | boolean | Whether this is the last page of results |
696696
| `names` | json | Map of field ID to display name \(e.g., customfield_10020 → "Sprint"\). Only returned when expand includes names. |

‎apps/docs/content/docs/integrations/trello.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,8 +363,8 @@ Update an existing card on Trello
363363
| `start` | string | No | Start date \(ISO 8601 format\) |
364364
| `pos` | string | No | Position of the card in its list \(top, bottom, or positive float\) |
365365
| `idBoard` | string | No | Trello board ID to move the card to \(use with idList\) |
366-
| `labelIds` | array | No | Replace the card labels with these label IDs. An empty list leaves labels unchanged; use Remove Label to take one off |
367-
| `memberIds` | array | No | Replace the card members with these member IDs. An empty list leaves members unchanged; use Remove Member to take one off |
366+
| `labelIds` | array | No | Replace the card labels with these label IDs \(array or comma-separated string\). An empty list leaves labels unchanged; use Remove Label to take one off |
367+
| `memberIds` | array | No | Replace the card members with these member IDs \(array or comma-separated string\). An empty list leaves members unchanged; use Remove Member to take one off |
368368

369369
#### Output
370370

‎apps/sim/blocks/blocks/jira.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1619,7 +1619,7 @@ Return ONLY the comment text - no explanations.`,
16191619
issues: {
16201620
type: 'json',
16211621
description:
1622-
'Array of matching issues with key, summary, description, descriptionAdf, status, assignee, reporter, priority, labels, components, fixVersions, parent, subtasks, issuelinks, attachments, timetracking, customFields, dates',
1622+
'Array of matching issues with key, summary, description, status, assignee, reporter, priority, labels, components, fixVersions, parent, timetracking, dates. descriptionAdf, subtasks, issuelinks, attachments, and customFields are only included when Fields is set; renderedDescription needs the renderedFields expansion',
16231623
},
16241624

16251625
comments: {

‎apps/sim/lib/internal/confluence/operations.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,36 @@ describe('Confluence operations', () => {
200200
)
201201
})
202202

203+
it.each([
204+
[
205+
'search',
206+
() =>
207+
executeConfluenceSearch(
208+
{ ...CONNECTION, query: 'release', limit: 25, cursor: 'cursor-2' },
209+
{ headers: new Headers(), requestId: 'request-1' }
210+
),
211+
],
212+
[
213+
'search in space',
214+
() =>
215+
executeConfluenceSearchInSpace(
216+
{ ...CONNECTION, spaceKey: 'ENG', limit: 25, cursor: 'cursor-2' },
217+
{ headers: new Headers(), requestId: 'request-1' }
218+
),
219+
],
220+
])('sends next=true with the cursor on %s, as Confluence next links do', async (_, run) => {
221+
const fetchMock = vi.fn<typeof fetch>(async () =>
222+
Response.json({ results: [], totalSize: 0, _links: {} })
223+
)
224+
vi.stubGlobal('fetch', fetchMock)
225+
226+
await run()
227+
228+
const params = new URL(String(fetchMock.mock.calls.at(-1)?.[0])).searchParams
229+
expect(params.get('cursor')).toBe('cursor-2')
230+
expect(params.get('next')).toBe('true')
231+
})
232+
203233
it('fails closed when stored-file authorization denies access', async () => {
204234
mockAssertToolFileAccess.mockResolvedValueOnce(Response.json({ error: 'Forbidden' }))
205235

‎apps/sim/lib/internal/confluence/operations.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,16 @@ function escapeCql(value: string): string {
938938
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
939939
}
940940

941+
/**
942+
* Pages the v1 `/search` endpoint forward. Confluence's own `_links.next` URLs carry
943+
* `next=true` alongside the cursor, so a resumed page sends both.
944+
*/
945+
function setSearchCursor(query: URLSearchParams, cursor: string | undefined) {
946+
if (!cursor) return
947+
query.set('next', 'true')
948+
query.set('cursor', cursor)
949+
}
950+
941951
export async function executeConfluenceSearch(
942952
input: ConfluenceSearchBody,
943953
context: ConfluenceOperationContext
@@ -955,7 +965,7 @@ export async function executeConfluenceSearch(
955965
limit: input.limit.toString(),
956966
expand: 'content.space',
957967
})
958-
if (input.cursor) query.set('cursor', input.cursor)
968+
setSearchCursor(query, input.cursor)
959969
const data = await client.json(client.rest(`/search?${query}`), {}, context.signal)
960970
const results = toArray(data.results).map((value) => {
961971
const result = toRecord(value)
@@ -1005,7 +1015,7 @@ export async function executeConfluenceSearchInSpace(
10051015
if (input.contentType) cql += ` AND type = "${escapeCql(input.contentType)}"`
10061016
assertCursor(input.cursor)
10071017
const query = new URLSearchParams({ cql, limit: cappedLimit(input.limit) })
1008-
if (input.cursor) query.set('cursor', input.cursor)
1018+
setSearchCursor(query, input.cursor)
10091019
const data = await client.json(client.rest(`/search?${query}`), {}, context.signal)
10101020
const results = toArray(data.results).map((value) => {
10111021
const result = toRecord(value)

‎apps/sim/lib/internal/jsm/assets.ts‎

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
13
import { toArray, toRecord } from '@sim/utils/object'
24
import type { ContractBody } from '@/lib/api/contracts'
35
import type {
@@ -15,6 +17,8 @@ import { createJsmAssetsClient } from '@/lib/internal/jsm/client'
1517
import { JsmOperationError } from '@/lib/internal/jsm/errors'
1618
import { mapAssetObject } from '@/tools/jsm/utils'
1719

20+
const logger = createLogger('JsmAssets')
21+
1822
type ListObjectSchemasInput = ContractBody<typeof jsmListObjectSchemasContract>
1923
type GetObjectSchemaInput = ContractBody<typeof jsmGetObjectSchemaContract>
2024
type ListObjectTypesInput = ContractBody<typeof jsmListObjectTypesContract>
@@ -85,6 +89,36 @@ function toObjectTypeList(value: unknown): unknown[] {
8589
* documented `/flat` listing, which returns every type in the schema, with
8690
* `excludeAbstract` applied client-side because `/flat` does not accept it.
8791
*/
92+
function isAbort(error: unknown, signal?: AbortSignal): boolean {
93+
if (signal?.aborted) return true
94+
return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')
95+
}
96+
97+
/**
98+
* Reads the flat object-types listing used as a fallback for an empty schema listing.
99+
* A failure other than an abort yields an empty list, so the fallback never fails a
100+
* request the primary listing already answered.
101+
*/
102+
async function listFlatObjectTypes(
103+
client: Awaited<ReturnType<typeof createJsmAssetsClient>>,
104+
schemaPath: string,
105+
schemaId: string,
106+
signal?: AbortSignal
107+
): Promise<unknown[]> {
108+
try {
109+
return toObjectTypeList(
110+
await client.value(client.assets(`${schemaPath}/flat`), {}, signal, true)
111+
)
112+
} catch (error) {
113+
if (isAbort(error, signal)) throw error
114+
logger.warn('Flat object-types fallback failed, returning the empty schema listing', {
115+
schemaId,
116+
error: getErrorMessage(error),
117+
})
118+
return []
119+
}
120+
}
121+
88122
export async function executeJsmListObjectTypes(input: ListObjectTypesInput, signal?: AbortSignal) {
89123
const client = await createJsmAssetsClient(input, signal)
90124
const query = new URLSearchParams()
@@ -101,9 +135,7 @@ export async function executeJsmListObjectTypes(input: ListObjectTypesInput, sig
101135
)
102136
)
103137
if (objectTypes.length === 0) {
104-
const flat = toObjectTypeList(
105-
await client.value(client.assets(`${schemaPath}/flat`), {}, signal, true)
106-
)
138+
const flat = await listFlatObjectTypes(client, schemaPath, input.schemaId, signal)
107139
objectTypes =
108140
String(input.excludeAbstract) === 'true'
109141
? flat.filter((objectType) => toRecord(objectType).abstractObjectType !== true)
@@ -143,24 +175,32 @@ export async function executeJsmGetObjectTypeAttributes(
143175

144176
const ASSETS_NUMERIC_ID = /^\d+$/
145177

146-
const AQL_ORDER_BY = /\s+order\s+by\s/iy
178+
const AQL_ORDER_BY = /order\s+by\s/iy
179+
const AQL_CLAUSE_BOUNDARY = /[\s)"]/
147180

148181
/**
149-
* Splits a trailing `order by` clause off an AQL query, ignoring matches inside
150-
* double-quoted values (including escaped `\\"`), since AQL requires the ordering to
151-
* stay at the end.
182+
* Splits a trailing `order by` clause off an AQL query, since AQL requires the ordering
183+
* to stay at the end. Only a clause at parenthesis depth 0 and outside double-quoted
184+
* values (including escaped `\\"`) counts, so `order by` inside a function argument or
185+
* a string stays in the condition.
152186
*/
153187
function splitAqlOrderBy(qlQuery: string): { condition: string; orderBy: string } {
154188
let inQuotes = false
189+
let depth = 0
155190
let orderByStart = -1
156191
for (let index = 0; index < qlQuery.length; index++) {
157-
if (qlQuery[index] === '"') {
192+
const char = qlQuery[index]
193+
if (char === '"') {
158194
let backslashes = 0
159195
for (let cursor = index - 1; cursor >= 0 && qlQuery[cursor] === '\\'; cursor--) backslashes++
160196
if (backslashes % 2 === 0) inQuotes = !inQuotes
161197
continue
162198
}
163199
if (inQuotes) continue
200+
if (char === '(') depth++
201+
else if (char === ')') depth = Math.max(0, depth - 1)
202+
if (depth !== 0) continue
203+
if (index > 0 && !AQL_CLAUSE_BOUNDARY.test(qlQuery[index - 1] ?? '')) continue
164204
AQL_ORDER_BY.lastIndex = index
165205
if (AQL_ORDER_BY.test(qlQuery)) orderByStart = index
166206
}
@@ -185,7 +225,11 @@ function scopeAqlQuery(input: SearchObjectsAqlInput): string {
185225
].filter((scope): scope is string => scope !== null)
186226
if (scopes.length === 0) return input.qlQuery
187227
const { condition, orderBy } = splitAqlOrderBy(input.qlQuery)
188-
return `(${condition.trim()}) AND ${scopes.join(' AND ')}${orderBy}`
228+
const trimmedCondition = condition.trim()
229+
const scoped = trimmedCondition
230+
? `(${trimmedCondition}) AND ${scopes.join(' AND ')}`
231+
: scopes.join(' AND ')
232+
return orderBy ? `${scoped} ${orderBy}` : scoped
189233
}
190234

191235
export async function executeJsmSearchObjectsAql(

‎apps/sim/lib/internal/jsm/operations.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
12
import { describe, expect, it, vi } from 'vitest'
23

34
const mocks = vi.hoisted(() => {
@@ -189,4 +190,48 @@ describe('JSM operations', () => {
189190
qlQuery: '(Name = "say \\" order by \\" x") AND objectTypeId = 7 order by Name',
190191
})
191192
})
193+
194+
it.each([
195+
['order by Name', 'objectTypeId = 7 order by Name'],
196+
[' ORDER BY "Created Date" desc', 'objectTypeId = 7 ORDER BY "Created Date" desc'],
197+
[
198+
'object having inboundReferences(objectType = Host order by Name)',
199+
'(object having inboundReferences(objectType = Host order by Name)) AND objectTypeId = 7',
200+
],
201+
[
202+
'object having inboundReferences(objectType = Host order by Name) order by Key',
203+
'(object having inboundReferences(objectType = Host order by Name)) AND objectTypeId = 7 order by Key',
204+
],
205+
])('scopes the AQL query %j as %j', async (qlQuery, expected) => {
206+
mocks.client.json.mockResolvedValueOnce({ values: [], total: 0, isLast: 'true' })
207+
await executeJsmSearchObjectsAql({ ...BASE, qlQuery, objectTypeId: '7' })
208+
expect(JSON.parse(String(mocks.client.json.mock.calls.at(-1)?.[1]?.body))).toEqual({
209+
qlQuery: expected,
210+
})
211+
})
212+
213+
it('returns the empty schema listing when the flat fallback fails', async () => {
214+
mocks.client.value.mockReset()
215+
mocks.client.value
216+
.mockResolvedValueOnce([])
217+
.mockRejectedValueOnce(new Error('Jira request failed with status 404'))
218+
const result = await executeJsmListObjectTypes({ ...BASE, schemaId: '6' })
219+
expect(result.output).toMatchObject({ objectTypes: [], total: 0 })
220+
expect(getMockLogger('JsmAssets').warn).toHaveBeenCalledWith(
221+
expect.any(String),
222+
expect.objectContaining({ schemaId: '6' })
223+
)
224+
})
225+
226+
it('still propagates an abort from the flat fallback', async () => {
227+
const controller = new AbortController()
228+
mocks.client.value.mockReset()
229+
mocks.client.value.mockResolvedValueOnce([]).mockImplementationOnce(async () => {
230+
controller.abort()
231+
throw new DOMException('The operation was aborted.', 'AbortError')
232+
})
233+
await expect(
234+
executeJsmListObjectTypes({ ...BASE, schemaId: '6' }, controller.signal)
235+
).rejects.toThrow('aborted')
236+
})
192237
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
type ConfluenceCreateCommentParams,
4+
confluenceCreateCommentTool,
5+
} from '@/tools/confluence/create_comment'
6+
7+
function operationInput(parentCommentId: unknown) {
8+
return confluenceCreateCommentTool.operation.input({
9+
accessToken: 'token',
10+
domain: 'example.atlassian.net',
11+
pageId: '123',
12+
comment: 'Reply',
13+
parentCommentId,
14+
} as ConfluenceCreateCommentParams) as Record<string, unknown>
15+
}
16+
17+
describe('confluence_create_comment operation input', () => {
18+
it.each([
19+
[456, '456'],
20+
[' 456 ', '456'],
21+
['', undefined],
22+
[undefined, undefined],
23+
])('normalizes parentCommentId %j to %j', (parentCommentId, expected) => {
24+
expect(operationInput(parentCommentId).parentCommentId).toBe(expected)
25+
})
26+
})

‎apps/sim/tools/confluence/create_comment.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export const confluenceCreateCommentTool: InternalToolConfig<
8686
cloudId: params.cloudId,
8787
pageId: params.pageId,
8888
comment: params.comment,
89-
parentCommentId: params.parentCommentId?.trim() || undefined,
89+
parentCommentId: String(params.parentCommentId ?? '').trim() || undefined,
9090
}
9191
},
9292
},

‎apps/sim/tools/generated/tool-metadata.ts‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)