diff --git a/apps/sim/blocks/blocks/jira_service_management.test.ts b/apps/sim/blocks/blocks/jira_service_management.test.ts new file mode 100644 index 00000000000..291644f55d8 --- /dev/null +++ b/apps/sim/blocks/blocks/jira_service_management.test.ts @@ -0,0 +1,170 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { z } from 'zod' +import { + jsmApprovalsBodySchema, + jsmCommentsBodySchema, + jsmCustomersBodySchema, + jsmIssuePaginationBodySchema, + jsmParticipantsBodySchema, + jsmQueuesBodySchema, + jsmRequestsBodySchema, + jsmRequestTypesToolBodySchema, + jsmServiceDeskScopedBodySchema, + jsmServiceDesksBodySchema, +} from '@/lib/api/contracts/selectors/jsm' +import { JiraServiceManagementBlock } from '@/blocks/blocks/jira_service_management' +import { + jsmGetApprovalsTool, + jsmGetCommentsTool, + jsmGetCustomersTool, + jsmGetOrganizationsTool, + jsmGetParticipantsTool, + jsmGetQueuesTool, + jsmGetRequestsTool, + jsmGetRequestTypesTool, + jsmGetServiceDesksTool, + jsmGetSlaTool, + jsmGetTransitionsTool, +} from '@/tools/jsm' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +const DOMAIN = 'example.atlassian.net' +/** Injected by the executor from the OAuth credential before the tool's `body` runs. */ +const ACCESS_TOKEN = 'token-123' + +interface PaginatedCase { + operation: string + toolId: string + /** The tool's own `request.body`, captured at its concrete param type by `paginatedCase`. */ + buildBody: (params: Record) => Record + schema: z.ZodType + extraInputs: Record +} + +/** + * Captures each tool at its own generic so an incompatible tool/contract pairing is still a type + * error at the call site, rather than being erased by a widened `ToolConfig` in the table type. + */ +function paginatedCase( + operation: string, + tool: ToolConfig, + schema: z.ZodType, + extraInputs: Record = {} +): PaginatedCase { + return { + operation, + toolId: tool.id, + buildBody: (params) => { + const bodyFn = tool.request.body + if (!bodyFn) throw new Error(`${tool.id} is missing request.body`) + return bodyFn(params as P) as Record + }, + schema, + extraInputs, + } +} + +/** + * Every paginated JSM operation, wired to the tool it resolves to and the contract its route + * parses the body with. This walks the real chain — block `tools.config.params` → the tool's + * `request.body` → the route contract — which is exactly where `jsm_get_comments` broke: the + * tools declare `start`/`limit` as `type: 'number'` while the contract demanded strings. + */ +const PAGINATED_CASES: PaginatedCase[] = [ + paginatedCase('get_service_desks', jsmGetServiceDesksTool, jsmServiceDesksBodySchema), + paginatedCase('get_request_types', jsmGetRequestTypesTool, jsmRequestTypesToolBodySchema, { + serviceDeskId: '1', + }), + paginatedCase('get_requests', jsmGetRequestsTool, jsmRequestsBodySchema), + paginatedCase('get_comments', jsmGetCommentsTool, jsmCommentsBodySchema, { + issueIdOrKey: 'SD-123', + }), + paginatedCase('get_customers', jsmGetCustomersTool, jsmCustomersBodySchema, { + serviceDeskId: '1', + }), + paginatedCase('get_organizations', jsmGetOrganizationsTool, jsmServiceDeskScopedBodySchema, { + serviceDeskId: '1', + }), + paginatedCase('get_queues', jsmGetQueuesTool, jsmQueuesBodySchema, { serviceDeskId: '1' }), + paginatedCase('get_sla', jsmGetSlaTool, jsmIssuePaginationBodySchema, { issueIdOrKey: 'SD-123' }), + paginatedCase('get_transitions', jsmGetTransitionsTool, jsmIssuePaginationBodySchema, { + issueIdOrKey: 'SD-123', + }), + paginatedCase('get_participants', jsmGetParticipantsTool, jsmParticipantsBodySchema, { + issueIdOrKey: 'SD-123', + }), + paginatedCase('get_approvals', jsmGetApprovalsTool, jsmApprovalsBodySchema, { + issueIdOrKey: 'SD-123', + }), +] + +/** Run a set of block inputs through `tools.config.params`, then through the tool's request body. */ +function buildRequestBody( + { operation, buildBody, extraInputs }: PaginatedCase, + pagination: Record +) { + const paramsFn = JiraServiceManagementBlock.tools.config?.params + if (!paramsFn) throw new Error('Block is missing tools.config.params') + + const toolParams = paramsFn({ + oauthCredential: 'cred-1', + domain: DOMAIN, + operation, + ...extraInputs, + ...pagination, + }) + + return buildBody({ ...toolParams, accessToken: ACCESS_TOKEN, domain: DOMAIN }) +} + +describe.each(PAGINATED_CASES.map((testCase) => [testCase.operation, testCase] as const))( + 'JiraServiceManagementBlock %s', + (_operation, testCase) => { + it('resolves to the expected tool', () => { + const toolFn = JiraServiceManagementBlock.tools.config?.tool + expect(toolFn?.({ operation: testCase.operation })).toBe(testCase.toolId) + expect(JiraServiceManagementBlock.tools.access).toContain(testCase.toolId) + }) + + it('sends a body its route contract accepts when pagination is filled in', () => { + const body = buildRequestBody(testCase, { startIndex: '50', maxResults: '25' }) + + expect(body.start).toBe(50) + expect(body.limit).toBe(25) + expect(testCase.schema.parse(body)).toMatchObject({ start: '50', limit: '25' }) + }) + + it('sends a body its route contract accepts when pagination is left blank', () => { + const body = buildRequestBody(testCase, {}) + + expect(body.start).toBeUndefined() + expect(body.limit).toBeUndefined() + expect(() => testCase.schema.parse(body)).not.toThrow() + }) + + it('drops non-numeric pagination input instead of sending NaN', () => { + const body = buildRequestBody(testCase, { startIndex: 'not-a-number', maxResults: '' }) + + expect(body.start).toBeUndefined() + expect(body.limit).toBeUndefined() + expect(() => testCase.schema.parse(body)).not.toThrow() + }) + } +) + +describe('JiraServiceManagementBlock pagination inputs', () => { + it('exposes Start Index and Max Results on exactly the paginated operations', () => { + const operations = PAGINATED_CASES.map(({ operation }) => operation) + + for (const id of ['startIndex', 'maxResults']) { + const subBlock = JiraServiceManagementBlock.subBlocks.find((sb) => sb.id === id) + expect(subBlock, `${id} subBlock is missing`).toBeDefined() + expect(subBlock?.mode).toBe('advanced') + expect(subBlock?.condition).toEqual({ field: 'operation', value: operations }) + expect(JiraServiceManagementBlock.inputs[id]).toBeDefined() + } + }) +}) diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index a32841ec48c..82b69c1239d 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -5,6 +5,21 @@ import { AuthMode, IntegrationType } from '@/blocks/types' import type { JsmResponse } from '@/tools/jsm/types' import { getTrigger } from '@/triggers' +/** Operations that accept Atlassian's `start`/`limit` pagination query params. */ +const PAGINATED_OPERATIONS = [ + 'get_service_desks', + 'get_request_types', + 'get_requests', + 'get_comments', + 'get_customers', + 'get_organizations', + 'get_queues', + 'get_sla', + 'get_transitions', + 'get_participants', + 'get_approvals', +] as const + /** * Coerce an optional numeric block input into an integer, returning undefined for * empty or non-numeric values so no `NaN` reaches the API query string. @@ -583,27 +598,21 @@ Return ONLY the comment text - no explanations.`, value: () => 'approve', condition: { field: 'operation', value: 'answer_approval' }, }, + { + id: 'startIndex', + title: 'Start Index', + type: 'short-input', + placeholder: 'Pagination start index (default: 0)', + mode: 'advanced', + condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] }, + }, { id: 'maxResults', title: 'Max Results', type: 'short-input', placeholder: 'Maximum results (default: 50)', - condition: { - field: 'operation', - value: [ - 'get_service_desks', - 'get_request_types', - 'get_requests', - 'get_comments', - 'get_customers', - 'get_organizations', - 'get_queues', - 'get_sla', - 'get_transitions', - 'get_participants', - 'get_approvals', - ], - }, + mode: 'advanced', + condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] }, }, { id: 'assetSchemaId', @@ -946,7 +955,8 @@ Return ONLY the comment text - no explanations.`, case 'get_service_desks': return { ...baseParams, - limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined, + start: toOptionalInt(params.startIndex), + limit: toOptionalInt(params.maxResults), } case 'get_request_types': if (!params.serviceDeskId) { @@ -957,7 +967,8 @@ Return ONLY the comment text - no explanations.`, serviceDeskId: params.serviceDeskId, searchQuery: params.searchQuery, groupId: params.groupId, - limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined, + start: toOptionalInt(params.startIndex), + limit: toOptionalInt(params.maxResults), } case 'create_request': if (!params.serviceDeskId) { @@ -1014,7 +1025,8 @@ Return ONLY the comment text - no explanations.`, requestStatus: params.requestStatus, searchTerm: params.searchTerm, expand: params.expand, - limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined, + start: toOptionalInt(params.startIndex), + limit: toOptionalInt(params.maxResults), } case 'add_comment': if (!params.issueIdOrKey) { @@ -1037,7 +1049,8 @@ Return ONLY the comment text - no explanations.`, ...baseParams, issueIdOrKey: params.issueIdOrKey, expand: params.expand, - limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined, + start: toOptionalInt(params.startIndex), + limit: toOptionalInt(params.maxResults), } case 'get_customers': if (!params.serviceDeskId) { @@ -1047,7 +1060,8 @@ Return ONLY the comment text - no explanations.`, ...baseParams, serviceDeskId: params.serviceDeskId, query: params.customerQuery, - limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined, + start: toOptionalInt(params.startIndex), + limit: toOptionalInt(params.maxResults), } case 'add_customer': { if (!params.serviceDeskId) { @@ -1069,7 +1083,8 @@ Return ONLY the comment text - no explanations.`, return { ...baseParams, serviceDeskId: params.serviceDeskId, - limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined, + start: toOptionalInt(params.startIndex), + limit: toOptionalInt(params.maxResults), } case 'get_queues': if (!params.serviceDeskId) { @@ -1079,7 +1094,8 @@ Return ONLY the comment text - no explanations.`, ...baseParams, serviceDeskId: params.serviceDeskId, includeCount: params.includeCount === 'true', - limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined, + start: toOptionalInt(params.startIndex), + limit: toOptionalInt(params.maxResults), } case 'get_sla': if (!params.issueIdOrKey) { @@ -1088,7 +1104,8 @@ Return ONLY the comment text - no explanations.`, return { ...baseParams, issueIdOrKey: params.issueIdOrKey, - limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined, + start: toOptionalInt(params.startIndex), + limit: toOptionalInt(params.maxResults), } case 'get_transitions': if (!params.issueIdOrKey) { @@ -1097,7 +1114,8 @@ Return ONLY the comment text - no explanations.`, return { ...baseParams, issueIdOrKey: params.issueIdOrKey, - limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined, + start: toOptionalInt(params.startIndex), + limit: toOptionalInt(params.maxResults), } case 'transition_request': if (!params.issueIdOrKey) { @@ -1139,7 +1157,8 @@ Return ONLY the comment text - no explanations.`, return { ...baseParams, issueIdOrKey: params.issueIdOrKey, - limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined, + start: toOptionalInt(params.startIndex), + limit: toOptionalInt(params.maxResults), } case 'add_participants': if (!params.issueIdOrKey) { @@ -1160,7 +1179,8 @@ Return ONLY the comment text - no explanations.`, return { ...baseParams, issueIdOrKey: params.issueIdOrKey, - limit: params.maxResults ? Number.parseInt(params.maxResults) : undefined, + start: toOptionalInt(params.startIndex), + limit: toOptionalInt(params.maxResults), } case 'answer_approval': if (!params.issueIdOrKey) { @@ -1466,6 +1486,7 @@ Return ONLY the comment text - no explanations.`, requestStatus: { type: 'string', description: 'Request status filter' }, searchTerm: { type: 'string', description: 'Search term for requests' }, includeCount: { type: 'string', description: 'Include issue count for queues' }, + startIndex: { type: 'string', description: 'Pagination start index' }, maxResults: { type: 'string', description: 'Maximum results to return' }, organizationName: { type: 'string', description: 'Organization name' }, organizationId: { type: 'string', description: 'Organization ID' }, diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index e6cf1687ead..a88d06c0d84 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -83,7 +83,7 @@ const NEW_TRIGGER_URL_VALUE = '__new_trigger_url__' * General). Wide enough to hold a full-length secret key - these are the longest labels the * picker shows, and clipping them is what makes two same-prefixed keys indistinguishable. */ -const MAPPING_TARGET_TRIGGER_CLASS = 'w-[320px] flex-shrink-0' +const MAPPING_TARGET_TRIGGER_CLASS = 'w-[380px] flex-shrink-0' interface DependentBlock { targetBlockId: string diff --git a/apps/sim/lib/api/contracts/selectors/jsm.test.ts b/apps/sim/lib/api/contracts/selectors/jsm.test.ts new file mode 100644 index 00000000000..d241e125564 --- /dev/null +++ b/apps/sim/lib/api/contracts/selectors/jsm.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + jsmApprovalsBodySchema, + jsmCommentsBodySchema, + jsmCustomersBodySchema, + jsmIssuePaginationBodySchema, + jsmParticipantsBodySchema, + jsmQueuesBodySchema, + jsmRequestsBodySchema, + jsmRequestTypesToolBodySchema, + jsmServiceDeskScopedBodySchema, + jsmServiceDesksBodySchema, +} from '@/lib/api/contracts/selectors/jsm' + +const credentials = { + domain: 'example.atlassian.net', + accessToken: 'token-123', +} + +/** + * The JSM tools declare `start`/`limit` as `type: 'number'`, so both the agent tool-call path + * and the block's `toOptionalInt` coercion post numbers to these routes. The contract must + * accept them and hand the route a string for `URLSearchParams`. + */ +const paginatedSchemas = [ + ['jsmServiceDesksBodySchema', jsmServiceDesksBodySchema, {}], + ['jsmServiceDeskScopedBodySchema', jsmServiceDeskScopedBodySchema, { serviceDeskId: '1' }], + ['jsmQueuesBodySchema', jsmQueuesBodySchema, { serviceDeskId: '1' }], + ['jsmRequestTypesToolBodySchema', jsmRequestTypesToolBodySchema, { serviceDeskId: '1' }], + ['jsmRequestsBodySchema', jsmRequestsBodySchema, {}], + ['jsmCommentsBodySchema', jsmCommentsBodySchema, { issueIdOrKey: 'SD-123' }], + ['jsmIssuePaginationBodySchema', jsmIssuePaginationBodySchema, { issueIdOrKey: 'SD-123' }], + ['jsmApprovalsBodySchema', jsmApprovalsBodySchema, { action: 'list', issueIdOrKey: 'SD-123' }], + [ + 'jsmParticipantsBodySchema', + jsmParticipantsBodySchema, + { action: 'list', issueIdOrKey: 'SD-123' }, + ], + ['jsmCustomersBodySchema', jsmCustomersBodySchema, { serviceDeskId: '1' }], +] as const + +describe('JSM contract pagination', () => { + it.each(paginatedSchemas)('%s accepts numeric start/limit', (_name, schema, extra) => { + const parsed = schema.parse({ ...credentials, ...extra, start: 50, limit: 25 }) + + expect(parsed.start).toBe('50') + expect(parsed.limit).toBe('25') + }) + + it.each(paginatedSchemas)('%s still accepts string start/limit', (_name, schema, extra) => { + const parsed = schema.parse({ ...credentials, ...extra, start: '50', limit: '25' }) + + expect(parsed.start).toBe('50') + expect(parsed.limit).toBe('25') + }) + + it('rejects numbers outside the int32 range Atlassian documents', () => { + const body = { ...credentials, issueIdOrKey: 'SD-123' } + + expect(() => jsmCommentsBodySchema.parse({ ...body, limit: 2.5 })).toThrow() + expect(() => jsmCommentsBodySchema.parse({ ...body, start: -1 })).toThrow() + expect(() => jsmCommentsBodySchema.parse({ ...body, limit: Number.NaN })).toThrow() + expect(() => jsmCommentsBodySchema.parse({ ...body, limit: 2147483648 })).toThrow() + expect(jsmCommentsBodySchema.parse({ ...body, limit: 2147483647 }).limit).toBe('2147483647') + expect(jsmCommentsBodySchema.parse({ ...body, start: 0 }).start).toBe('0') + }) + + /** + * The string branch is deliberately unconstrained: the previous schema was a bare + * `z.string()`, so narrowing it would turn bodies that parse today into 400s. + */ + it('still accepts arbitrary strings the previous schema allowed', () => { + const body = { ...credentials, issueIdOrKey: 'SD-123' } + + expect(jsmCommentsBodySchema.parse({ ...body, limit: 'twenty' }).limit).toBe('twenty') + expect(jsmCommentsBodySchema.parse({ ...body, start: '' }).start).toBe('') + expect(jsmCommentsBodySchema.parse({ ...body, start: '-5' }).start).toBe('-5') + }) + + it('leaves omitted pagination undefined', () => { + const parsed = jsmCommentsBodySchema.parse({ ...credentials, issueIdOrKey: 'SD-123' }) + + expect(parsed.start).toBeUndefined() + expect(parsed.limit).toBeUndefined() + }) +}) + +describe('JSM queues includeCount', () => { + /** The tool declares `includeCount` as a boolean and the block always sends one. */ + it.each([ + [true, 'true'], + [false, 'false'], + ])('accepts the boolean %s', (input, expected) => { + const parsed = jsmQueuesBodySchema.parse({ + ...credentials, + serviceDeskId: '1', + includeCount: input, + }) + + expect(parsed.includeCount).toBe(expected) + }) + + it('still accepts the string form sent by hand-authored callers', () => { + const parsed = jsmQueuesBodySchema.parse({ + ...credentials, + serviceDeskId: '1', + includeCount: 'true', + }) + + expect(parsed.includeCount).toBe('true') + }) +}) diff --git a/apps/sim/lib/api/contracts/selectors/jsm.ts b/apps/sim/lib/api/contracts/selectors/jsm.ts index dea80b9344c..7dd2706f3db 100644 --- a/apps/sim/lib/api/contracts/selectors/jsm.ts +++ b/apps/sim/lib/api/contracts/selectors/jsm.ts @@ -25,24 +25,51 @@ const jsmFormIdField = z.string({ error: 'Form ID is required' }).min(1, 'Form I const jsmIdListSchema = z.union([z.string(), z.array(z.string())]).optional() +/** + * JSM pagination values reach this boundary in two shapes: tools declare `start`/`limit` as + * `type: 'number'` (so agent tool-calls and the block's `Number.parseInt` both send numbers), + * while hand-authored callers send strings. Atlassian takes them as int32 query params, and the + * routes stringify them into a `URLSearchParams`, so normalize both shapes to a string here. + * + * The string branch stays unconstrained so every body the previous `z.string()` schema accepted + * still parses; the newly accepted number branch is bounded to the int32 range Atlassian documents. + */ +const jsmPaginationField = z + .union([ + z.string(), + z + .number() + .int('Pagination values must be whole numbers') + .min(0, 'Pagination values must be 0 or greater') + .max(2147483647, 'Pagination values must be within the int32 range'), + ]) + .transform((value) => String(value)) + .optional() + +/** Boolean query flags arrive as booleans from tool params and as `'true'`/`'false'` strings from block dropdowns. */ +const jsmBooleanFlagField = z + .union([z.string(), z.boolean()]) + .transform((value) => String(value)) + .optional() + export const jsmRequestTypesBodySchema = credentialWorkflowDomainBodySchema.extend({ serviceDeskId: z.string().min(1), }) export const jsmServiceDesksBodySchema = jsmBaseBodySchema.extend({ expand: z.string().optional(), - start: z.string().optional(), - limit: z.string().optional(), + start: jsmPaginationField, + limit: jsmPaginationField, }) export const jsmServiceDeskScopedBodySchema = jsmBaseBodySchema.extend({ serviceDeskId: jsmServiceDeskIdField, - start: z.string().optional(), - limit: z.string().optional(), + start: jsmPaginationField, + limit: jsmPaginationField, }) export const jsmQueuesBodySchema = jsmServiceDeskScopedBodySchema.extend({ - includeCount: z.string().optional(), + includeCount: jsmBooleanFlagField, }) export const jsmRequestTypesToolBodySchema = jsmServiceDeskScopedBodySchema.extend({ @@ -65,8 +92,8 @@ export const jsmRequestsBodySchema = jsmBaseBodySchema.extend({ requestTypeId: z.string().optional(), searchTerm: z.string().optional(), expand: z.string().optional(), - start: z.string().optional(), - limit: z.string().optional(), + start: jsmPaginationField, + limit: jsmPaginationField, }) export const jsmRequestBodySchema = jsmBaseBodySchema.extend({ @@ -94,8 +121,8 @@ export const jsmCommentsBodySchema = jsmBaseBodySchema.extend({ isPublic: z.boolean().optional(), internal: z.boolean().optional(), expand: z.string().optional(), - start: z.string().optional(), - limit: z.string().optional(), + start: jsmPaginationField, + limit: jsmPaginationField, }) export const jsmTransitionBodySchema = jsmBaseBodySchema.extend({ @@ -108,8 +135,8 @@ export const jsmTransitionBodySchema = jsmBaseBodySchema.extend({ export const jsmIssuePaginationBodySchema = jsmBaseBodySchema.extend({ issueIdOrKey: jsmIssueIdOrKeyField, - start: z.string().optional(), - limit: z.string().optional(), + start: jsmPaginationField, + limit: jsmPaginationField, }) export const jsmApprovalsBodySchema = jsmBaseBodySchema.extend({ @@ -117,23 +144,23 @@ export const jsmApprovalsBodySchema = jsmBaseBodySchema.extend({ issueIdOrKey: jsmIssueIdOrKeyField, approvalId: z.string().optional(), decision: z.string().optional(), - start: z.string().optional(), - limit: z.string().optional(), + start: jsmPaginationField, + limit: jsmPaginationField, }) export const jsmParticipantsBodySchema = jsmBaseBodySchema.extend({ action: z.string({ error: 'Action is required' }).min(1, 'Action is required'), issueIdOrKey: jsmIssueIdOrKeyField, accountIds: z.union([z.string(), z.array(z.string())]).optional(), - start: z.string().optional(), - limit: z.string().optional(), + start: jsmPaginationField, + limit: jsmPaginationField, }) export const jsmCustomersBodySchema = jsmBaseBodySchema.extend({ serviceDeskId: jsmServiceDeskIdField, query: z.string().optional(), - start: z.string().optional(), - limit: z.string().optional(), + start: jsmPaginationField, + limit: jsmPaginationField, accountIds: jsmIdListSchema, emails: jsmIdListSchema, })