diff --git a/apps/sim/app/api/tools/jira/update/route.test.ts b/apps/sim/app/api/tools/jira/update/route.test.ts new file mode 100644 index 00000000000..5f953b9d3b5 --- /dev/null +++ b/apps/sim/app/api/tools/jira/update/route.test.ts @@ -0,0 +1,351 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { fetchMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), +})) + +import { PUT } from '@/app/api/tools/jira/update/route' + +const CLOUD_ID = '11111111-1111-1111-1111-111111111111' +const ISSUE_KEY = 'PROJ-123' + +const BASE_BODY = { + domain: 'example.atlassian.net', + accessToken: 'token-123', + cloudId: CLOUD_ID, + issueKey: ISSUE_KEY, +} as const + +function noContent(): Response { + return new Response(null, { status: 204 }) +} + +/** Parses the JSON body sent on the single PUT call to the Jira REST API. */ +function putFields(): Record { + const call = fetchMock.mock.calls[0] + const init = call?.[1] as RequestInit + const parsed = JSON.parse(init.body as string) + return parsed.fields +} + +describe('Jira update route custom-field serialization', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue(noContent()) + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'session', + }) + }) + + async function update(body: Record) { + const request = createMockRequest('PUT', { ...BASE_BODY, ...body }) + const response = await PUT(request) + return { response, data: await response.json() } + } + + it('serializes a select custom field to { value }', async () => { + const { response } = await update({ + customFields: [{ fieldId: 'customfield_10001', type: 'select', value: 'High' }], + }) + expect(response.status).toBe(200) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(putFields()).toEqual({ customfield_10001: { value: 'High' } }) + }) + + it('serializes a select custom field with a numeric option id to { id }', async () => { + await update({ + customFields: [{ fieldId: 'customfield_10001', type: 'select', value: '10023' }], + }) + expect(putFields()).toEqual({ customfield_10001: { id: '10023' } }) + }) + + it('rejects a customFields entry whose value shape mismatches its type', async () => { + const { response } = await update({ + customFields: [{ fieldId: 'customfield_10001', type: 'select', value: { label: 'High' } }], + }) + expect(response.status).toBe(400) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects a userpicker entry passed an { email } object', async () => { + const { response } = await update({ + customFields: [ + { fieldId: 'customfield_10003', type: 'userpicker', value: { email: 'x@y.com' } }, + ], + }) + expect(response.status).toBe(400) + }) + + it('rejects a cascading entry whose value is an unresolvable record', async () => { + const { response } = await update({ + customFields: [{ fieldId: 'customfield_10005', type: 'cascading', value: { id: '10' } }], + }) + expect(response.status).toBe(400) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects a cascading array with more than [parent, child]', async () => { + const { response } = await update({ + customFields: [{ fieldId: 'customfield_10005', type: 'cascading', value: ['A', 'B', 'C'] }], + }) + expect(response.status).toBe(400) + }) + + it('rejects a text entry passed a bare number', async () => { + const { response } = await update({ + customFields: [{ fieldId: 'customfield_10007', type: 'text', value: 42 }], + }) + expect(response.status).toBe(400) + }) + + it('rejects a text entry with an empty value', async () => { + const { response } = await update({ + customFields: [{ fieldId: 'customfield_10007', type: 'text', value: '' }], + }) + expect(response.status).toBe(400) + }) + + it('rejects a select entry with an empty option value', async () => { + const { response } = await update({ + customFields: [{ fieldId: 'customfield_10001', type: 'select', value: { value: '' } }], + }) + expect(response.status).toBe(400) + }) + + it('rejects a select option object that sets both value and id', async () => { + const { response } = await update({ + customFields: [ + { fieldId: 'customfield_10001', type: 'select', value: { value: 'High', id: '10' } }, + ], + }) + expect(response.status).toBe(400) + }) + + it('rejects an empty multiselect array (clearing is not implicit)', async () => { + const { response } = await update({ + customFields: [{ fieldId: 'customfield_10002', type: 'multiselect', value: [] }], + }) + expect(response.status).toBe(400) + }) + + it('rejects duplicate custom field ids after normalization', async () => { + const { response } = await update({ + customFields: [ + { fieldId: '10001', type: 'select', value: 'High' }, + { fieldId: 'customfield_10001', type: 'select', value: 'Low' }, + ], + }) + expect(response.status).toBe(400) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects a cascading object that sets both parent and value aliases', async () => { + const { response } = await update({ + customFields: [ + { + fieldId: 'customfield_10005', + type: 'cascading', + value: { parent: 'A', value: 'B', child: 'C' }, + }, + ], + }) + expect(response.status).toBe(400) + }) + + it('rejects a cascading entry with an empty parent', async () => { + const { response } = await update({ + customFields: [{ fieldId: 'customfield_10005', type: 'cascading', value: { parent: '' } }], + }) + expect(response.status).toBe(400) + }) + + it('rejects a cascading entry that sets child both at top level and inside value', async () => { + const { response } = await update({ + customFields: [ + { + fieldId: 'customfield_10005', + type: 'cascading', + value: { parent: 'Americas', child: 'USA' }, + child: 'Canada', + }, + ], + }) + expect(response.status).toBe(400) + }) + + it('rejects a cascading [parent, child] array plus a conflicting top-level child', async () => { + const { response } = await update({ + customFields: [ + { + fieldId: 'customfield_10005', + type: 'cascading', + value: ['Americas', 'USA'], + child: 'Canada', + }, + ], + }) + expect(response.status).toBe(400) + }) + + it('serializes a multiselect custom field to an array of options', async () => { + await update({ + customFields: [ + { fieldId: 'customfield_10002', type: 'multiselect', value: ['Red', 'Green'] }, + ], + }) + expect(putFields()).toEqual({ customfield_10002: [{ value: 'Red' }, { value: 'Green' }] }) + }) + + it('serializes a userpicker custom field to { accountId }', async () => { + await update({ + customFields: [{ fieldId: 'customfield_10003', type: 'userpicker', value: 'acc-1' }], + }) + expect(putFields()).toEqual({ customfield_10003: { accountId: 'acc-1' } }) + }) + + it('serializes a multiuserpicker custom field to an array of { accountId }', async () => { + await update({ + customFields: [ + { fieldId: 'customfield_10004', type: 'multiuserpicker', value: ['acc-1', 'acc-2'] }, + ], + }) + expect(putFields()).toEqual({ + customfield_10004: [{ accountId: 'acc-1' }, { accountId: 'acc-2' }], + }) + }) + + it('serializes a cascading custom field to { value, child: { value } }', async () => { + await update({ + customFields: [ + { fieldId: 'customfield_10005', type: 'cascading', value: 'Americas', child: 'USA' }, + ], + }) + expect(putFields()).toEqual({ + customfield_10005: { value: 'Americas', child: { value: 'USA' } }, + }) + }) + + it('passes a raw custom field through untouched', async () => { + const raw = { any: ['shape', 1] } + await update({ + customFields: [{ fieldId: 'customfield_10006', type: 'raw', value: raw }], + }) + expect(putFields()).toEqual({ customfield_10006: raw }) + }) + + it('coerces a numeric-string number custom field to a number', async () => { + await update({ + customFields: [{ fieldId: 'customfield_10007', type: 'number', value: '42' }], + }) + expect(putFields()).toEqual({ customfield_10007: 42 }) + }) + + it('supports many custom fields in one call', async () => { + await update({ + customFields: [ + { fieldId: 'customfield_1', type: 'select', value: 'A' }, + { fieldId: 'customfield_2', type: 'userpicker', value: 'acc-1' }, + ], + }) + expect(putFields()).toEqual({ + customfield_1: { value: 'A' }, + customfield_2: { accountId: 'acc-1' }, + }) + }) +}) + +describe('Jira update route backward compatibility', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue(noContent()) + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'session', + }) + }) + + async function update(body: Record) { + const request = createMockRequest('PUT', { ...BASE_BODY, ...body }) + return PUT(request) + } + + it('still writes the legacy single custom field as a raw value', async () => { + await update({ customFieldId: 'customfield_10001', customFieldValue: 'legacy-value' }) + expect(putFields()).toEqual({ customfield_10001: 'legacy-value' }) + }) + + it('prefixes a bare legacy custom field id', async () => { + await update({ customFieldId: '10001', customFieldValue: 'legacy-value' }) + expect(putFields()).toEqual({ customfield_10001: 'legacy-value' }) + }) + + it('lets customFields override a colliding legacy field', async () => { + await update({ + customFieldId: 'customfield_10001', + customFieldValue: 'legacy-value', + customFields: [{ fieldId: 'customfield_10001', type: 'select', value: 'High' }], + }) + expect(putFields()).toEqual({ customfield_10001: { value: 'High' } }) + }) +}) + +describe('Jira update route combined simple + custom fields', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue(noContent()) + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'session', + }) + }) + + it('combines simple fields and custom fields under one fields payload', async () => { + const request = createMockRequest('PUT', { + ...BASE_BODY, + summary: 'Updated summary', + description: 'Plain description', + priority: 'High', + assignee: 'acc-99', + labels: ['alpha', 'beta'], + customFields: [{ fieldId: 'customfield_10001', type: 'select', value: 'Blue' }], + }) + await PUT(request) + + const fields = putFields() + expect(fields.summary).toBe('Updated summary') + expect(fields.priority).toEqual({ name: 'High' }) + expect(fields.assignee).toEqual({ accountId: 'acc-99' }) + expect(fields.labels).toEqual(['alpha', 'beta']) + expect(fields.customfield_10001).toEqual({ value: 'Blue' }) + + const description = fields.description as { type?: string; content?: unknown } + expect(description.type).toBe('doc') + expect(Array.isArray(description.content)).toBe(true) + }) + + it('preserves ADF auto-wrap for a plain-text description', async () => { + const request = createMockRequest('PUT', { + ...BASE_BODY, + description: 'Hello world', + }) + await PUT(request) + + const description = putFields().description as { + type?: string + content?: Array<{ content?: Array<{ text?: string }> }> + } + expect(description.type).toBe('doc') + expect(description.content?.[0]?.content?.[0]?.text).toBe('Hello world') + }) +}) diff --git a/apps/sim/app/api/tools/jira/update/route.ts b/apps/sim/app/api/tools/jira/update/route.ts index f8357b18bd4..bf8c8118748 100644 --- a/apps/sim/app/api/tools/jira/update/route.ts +++ b/apps/sim/app/api/tools/jira/update/route.ts @@ -6,7 +6,12 @@ import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getJiraCloudId, parseAtlassianErrorMessage, toAdf } from '@/tools/jira/utils' +import { + buildJiraCustomFields, + getJiraCloudId, + parseAtlassianErrorMessage, + toAdf, +} from '@/tools/jira/utils' export const dynamic = 'force-dynamic' @@ -38,6 +43,7 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { environment, customFieldId, customFieldValue, + customFields, notifyUsers, cloudId: providedCloudId, } = parsed.data.body @@ -103,19 +109,12 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { fields.environment = toAdf(environment) } - if ( - customFieldId !== undefined && - customFieldId !== null && - customFieldId !== '' && - customFieldValue !== undefined && - customFieldValue !== null && - customFieldValue !== '' - ) { - const fieldId = customFieldId.startsWith('customfield_') - ? customFieldId - : `customfield_${customFieldId}` - fields[fieldId] = customFieldValue - } + const customFieldMap = buildJiraCustomFields({ + customFields, + legacyFieldId: customFieldId, + legacyValue: customFieldValue, + }) + Object.assign(fields, customFieldMap) const requestBody = { fields } diff --git a/apps/sim/lib/api/contracts/selectors/jira.ts b/apps/sim/lib/api/contracts/selectors/jira.ts index c9cf30fd65b..c30b3500ae1 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -91,6 +91,135 @@ export const jiraWriteBodySchema = z.object({ fixVersions: z.array(z.string()).optional(), }) +const customFieldIdSchema = z.string().min(1, 'fieldId is required') + +/** + * A non-empty option value/id — a scalar, or an object that sets exactly one of + * `value` / `id`. Setting both is rejected: `toSelectOption` would keep one and + * silently discard the other, so Jira could store an option the caller didn't + * unambiguously request. + */ +const optionScalar = z.union([z.string().min(1, 'option value cannot be empty'), z.number()]) +const optionInputSchema = z.union([ + optionScalar, + z + .object({ value: optionScalar.optional(), id: optionScalar.optional() }) + .refine((o) => (o.value !== undefined) !== (o.id !== undefined), { + message: 'option object must set exactly one of value or id', + }), +]) + +/** An accountId string or a `{ accountId }` object for a user picker. */ +const userInputSchema = z.union([ + z.string().min(1, 'accountId cannot be empty'), + z.object({ accountId: z.string().min(1, 'accountId cannot be empty') }), +]) + +/** A number or a numeric string for a number field. */ +const numberInputSchema = z.union([ + z.number(), + z + .string() + .refine((s) => s.trim() !== '' && Number.isFinite(Number(s)), 'number value must be numeric'), +]) + +const cascadingScalar = z.union([z.string().min(1, 'cascading value cannot be empty'), z.number()]) + +/** + * Parent scalar, `[parent, child]` array, or `{ parent | value, child }` object. + * Parent and child are constrained to scalars (as tightly as select/userpicker) + * so an arbitrary record like `{ id: '10' }` — which the serializer cannot turn + * into a valid cascading option — is rejected at the boundary instead of being + * silently dropped or serialized to `"[object Object]"`. + */ +const cascadingInputSchema = z.union([ + cascadingScalar, + z + .array(cascadingScalar) + .min(1, 'cascading value cannot be empty') + .max(2, 'cascading value accepts at most [parent, child]'), + z + .object({ + parent: cascadingScalar.optional(), + value: cascadingScalar.optional(), + child: cascadingScalar.optional(), + }) + .refine((o) => o.parent !== undefined || o.value !== undefined, { + message: 'cascading value must include a parent', + }) + .refine((o) => !(o.parent !== undefined && o.value !== undefined), { + message: 'cascading value must not set both parent and value (they are aliases)', + }), +]) + +/** + * One structured custom field to write, validated per `type` so a shape/value + * mismatch (e.g. a select passed as `{ label }`, a user picker as `{ email }`, a + * non-numeric number) is rejected at the boundary instead of serializing into a + * malformed Jira value that would reject the whole combined update. `raw` is the + * escape hatch: its value is passed through untouched. + */ +export const jiraCustomFieldEntrySchema = z + .discriminatedUnion('type', [ + z.object({ + fieldId: customFieldIdSchema, + type: z.literal('text'), + value: z.string().min(1, 'text value cannot be empty'), + }), + z.object({ fieldId: customFieldIdSchema, type: z.literal('number'), value: numberInputSchema }), + z.object({ fieldId: customFieldIdSchema, type: z.literal('select'), value: optionInputSchema }), + z.object({ + fieldId: customFieldIdSchema, + type: z.literal('multiselect'), + value: z.union([ + z.array(optionInputSchema).min(1, 'multiselect value cannot be empty'), + optionInputSchema, + ]), + }), + z.object({ + fieldId: customFieldIdSchema, + type: z.literal('userpicker'), + value: userInputSchema, + }), + z.object({ + fieldId: customFieldIdSchema, + type: z.literal('multiuserpicker'), + value: z.union([ + z.array(userInputSchema).min(1, 'multiuserpicker value cannot be empty'), + userInputSchema, + ]), + }), + z.object({ + fieldId: customFieldIdSchema, + type: z.literal('cascading'), + value: cascadingInputSchema, + child: cascadingScalar.optional(), + }), + z.object({ fieldId: customFieldIdSchema, type: z.literal('raw'), value: z.unknown() }), + ]) + .superRefine((entry, ctx) => { + // Cascading carries a child in two places: the top-level `child`, and one + // embedded in `value` — either `value.child` (object form) or `value[1]` + // (the `[parent, child]` array form). If a child is given both at the top + // level and inside `value`, the serializer would keep one and silently drop + // the other, so reject the ambiguity at the boundary in either form. + if (entry.type !== 'cascading') return + const value: unknown = entry.value + let nestedChild: unknown + if (Array.isArray(value)) { + nestedChild = value[1] + } else if (value !== null && typeof value === 'object') { + nestedChild = (value as Record).child + } + if (entry.child !== undefined && nestedChild !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'cascading child must be set either at the top level or inside value, not both', + path: ['child'], + }) + } + }) + export const jiraUpdateBodySchema = z.object({ domain: z.string().min(1, 'Domain is required'), accessToken: z.string().min(1, 'Access token is required'), @@ -107,6 +236,30 @@ export const jiraUpdateBodySchema = z.object({ environment: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(), customFieldId: z.string().optional(), customFieldValue: z.string().optional(), + customFields: z + .array(jiraCustomFieldEntrySchema) + .max(50) + .superRefine((entries, ctx) => { + // Two entries that normalize to the same `customfield_XXXXX` (including a + // lookalike pair like `10001` and `customfield_10001`) would last-write-win + // in buildJiraCustomFields, silently applying one value. Reject the + // collision at the boundary. + const seen = new Set() + entries.forEach((entry, index) => { + const normalized = entry.fieldId.startsWith('customfield_') + ? entry.fieldId + : `customfield_${entry.fieldId}` + if (seen.has(normalized)) { + ctx.addIssue({ + code: 'custom', + message: `duplicate custom field id: ${entry.fieldId}`, + path: [index, 'fieldId'], + }) + } + seen.add(normalized) + }) + }) + .optional(), notifyUsers: z.boolean().optional(), cloudId: z.string().optional(), }) diff --git a/apps/sim/tools/jira/types.ts b/apps/sim/tools/jira/types.ts index aec32e52270..1064272317b 100644 --- a/apps/sim/tools/jira/types.ts +++ b/apps/sim/tools/jira/types.ts @@ -1,4 +1,5 @@ import type { UserFile } from '@/executor/types' +import type { JiraCustomFieldEntry } from '@/tools/jira/utils' import type { OutputProperty, ToolResponse } from '@/tools/types' /** @@ -986,6 +987,7 @@ export interface JiraUpdateParams { environment?: string customFieldId?: string customFieldValue?: string + customFields?: JiraCustomFieldEntry[] notifyUsers?: boolean cloudId?: string } diff --git a/apps/sim/tools/jira/update.ts b/apps/sim/tools/jira/update.ts index 2494950835d..c920df57575 100644 --- a/apps/sim/tools/jira/update.ts +++ b/apps/sim/tools/jira/update.ts @@ -91,13 +91,37 @@ export const jiraUpdateTool: ToolConfig = type: 'string', required: false, visibility: 'user-or-llm', - description: 'Custom field ID to update (e.g., customfield_10001)', + description: + 'Legacy single custom field ID (e.g., customfield_10001). Sets one field to a raw string value. Prefer `customFields` for structured or multiple fields.', }, customFieldValue: { type: 'string', required: false, visibility: 'user-or-llm', - description: 'Value for the custom field', + description: 'Raw value for the legacy single custom field. Prefer `customFields`.', + }, + customFields: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Structured custom fields to set, as an array of { fieldId, type, value }. type is one of text | number | select | multiselect | userpicker | multiuserpicker | cascading | raw. Serialization: select→{value} (or {id} for numeric option ids); multiselect→[{value}]; userpicker→{accountId}; multiuserpicker→[{accountId}]; cascading→{value, child:{value}} (value = [parent, child] or {parent, child}); text/number→scalar; raw→passed through untouched.', + items: { + type: 'object', + required: ['fieldId', 'type', 'value'], + properties: { + fieldId: { type: 'string', description: 'Custom field id, e.g. customfield_10001' }, + type: { + type: 'string', + description: + 'One of: text, number, select, multiselect, userpicker, multiuserpicker, cascading, raw', + }, + value: { + description: + 'The value to set; its shape depends on type (scalar, option, accountId, array, or cascading object)', + }, + }, + }, }, notifyUsers: { type: 'boolean', @@ -136,6 +160,7 @@ export const jiraUpdateTool: ToolConfig = environment: params.environment, customFieldId: params.customFieldId, customFieldValue: params.customFieldValue, + customFields: params.customFields, notifyUsers: params.notifyUsers, cloudId: params.cloudId, } diff --git a/apps/sim/tools/jira/utils.test.ts b/apps/sim/tools/jira/utils.test.ts new file mode 100644 index 00000000000..2ddf5f1d2fe --- /dev/null +++ b/apps/sim/tools/jira/utils.test.ts @@ -0,0 +1,216 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildJiraCustomFields, serializeJiraCustomField } from '@/tools/jira/utils' + +describe('serializeJiraCustomField', () => { + it('serializes a select with a string value to { value }', () => { + expect(serializeJiraCustomField({ fieldId: 'cf', type: 'select', value: 'High' })).toEqual({ + value: 'High', + }) + }) + + it('serializes a select with a numeric-looking value to { id }', () => { + expect(serializeJiraCustomField({ fieldId: 'cf', type: 'select', value: '10023' })).toEqual({ + id: '10023', + }) + }) + + it('unwraps an option object when serializing a select', () => { + expect( + serializeJiraCustomField({ fieldId: 'cf', type: 'select', value: { value: 'Blue' } }) + ).toEqual({ value: 'Blue' }) + }) + + it('respects an explicit { value } object over the numeric-id heuristic', () => { + expect( + serializeJiraCustomField({ fieldId: 'cf', type: 'select', value: { value: '2024' } }) + ).toEqual({ value: '2024' }) + }) + + it('respects an explicit { id } object when serializing a select', () => { + expect( + serializeJiraCustomField({ fieldId: 'cf', type: 'select', value: { id: '10' } }) + ).toEqual({ id: '10' }) + }) + + it('serializes a multiselect to an array of options', () => { + expect( + serializeJiraCustomField({ fieldId: 'cf', type: 'multiselect', value: ['Red', '42'] }) + ).toEqual([{ value: 'Red' }, { id: '42' }]) + }) + + it('wraps a scalar multiselect value into a single-element array', () => { + expect(serializeJiraCustomField({ fieldId: 'cf', type: 'multiselect', value: 'Only' })).toEqual( + [{ value: 'Only' }] + ) + }) + + it('serializes a userpicker to { accountId }', () => { + expect(serializeJiraCustomField({ fieldId: 'cf', type: 'userpicker', value: 'acc-1' })).toEqual( + { accountId: 'acc-1' } + ) + }) + + it('unwraps a { accountId } object for a userpicker', () => { + expect( + serializeJiraCustomField({ fieldId: 'cf', type: 'userpicker', value: { accountId: 'acc-1' } }) + ).toEqual({ accountId: 'acc-1' }) + }) + + it('unwraps mixed scalar and { accountId } objects for a multiuserpicker', () => { + expect( + serializeJiraCustomField({ + fieldId: 'cf', + type: 'multiuserpicker', + value: [{ accountId: 'acc-1' }, 'acc-2'], + }) + ).toEqual([{ accountId: 'acc-1' }, { accountId: 'acc-2' }]) + }) + + it('serializes a multiuserpicker to an array of { accountId }', () => { + expect( + serializeJiraCustomField({ + fieldId: 'cf', + type: 'multiuserpicker', + value: ['acc-1', 'acc-2'], + }) + ).toEqual([{ accountId: 'acc-1' }, { accountId: 'acc-2' }]) + }) + + it('serializes cascading from an explicit child', () => { + expect( + serializeJiraCustomField({ + fieldId: 'cf', + type: 'cascading', + value: 'Americas', + child: 'USA', + }) + ).toEqual({ value: 'Americas', child: { value: 'USA' } }) + }) + + it('serializes cascading from a { parent, child } object', () => { + expect( + serializeJiraCustomField({ + fieldId: 'cf', + type: 'cascading', + value: { parent: 'Americas', child: 'USA' }, + }) + ).toEqual({ value: 'Americas', child: { value: 'USA' } }) + }) + + it('serializes cascading from a [parent, child] array', () => { + expect( + serializeJiraCustomField({ fieldId: 'cf', type: 'cascading', value: ['Americas', 'USA'] }) + ).toEqual({ value: 'Americas', child: { value: 'USA' } }) + }) + + it('serializes cascading with no child to only { value }', () => { + expect( + serializeJiraCustomField({ fieldId: 'cf', type: 'cascading', value: 'Americas' }) + ).toEqual({ value: 'Americas' }) + }) + + it('returns undefined for a cascading with no resolvable parent', () => { + expect( + serializeJiraCustomField({ fieldId: 'cf', type: 'cascading', value: [] }) + ).toBeUndefined() + expect( + serializeJiraCustomField({ fieldId: 'cf', type: 'cascading', value: { id: '10' } }) + ).toBeUndefined() + }) + + it('passes text through untouched', () => { + expect(serializeJiraCustomField({ fieldId: 'cf', type: 'text', value: 'hello' })).toBe('hello') + }) + + it('coerces a numeric-string number to a number', () => { + expect(serializeJiraCustomField({ fieldId: 'cf', type: 'number', value: '3.5' })).toBe(3.5) + }) + + it('leaves an already-numeric number untouched', () => { + expect(serializeJiraCustomField({ fieldId: 'cf', type: 'number', value: 7 })).toBe(7) + }) + + it('passes a raw value through untouched, including complex shapes', () => { + const raw = { some: ['arbitrary', { nested: true }] } + expect(serializeJiraCustomField({ fieldId: 'cf', type: 'raw', value: raw })).toBe(raw) + }) +}) + +describe('buildJiraCustomFields', () => { + it('normalizes a bare field id with the customfield_ prefix', () => { + expect( + buildJiraCustomFields({ + customFields: [{ fieldId: '10050', type: 'text', value: 'x' }], + }) + ).toEqual({ customfield_10050: 'x' }) + }) + + it('leaves an already-prefixed field id untouched', () => { + expect( + buildJiraCustomFields({ + customFields: [{ fieldId: 'customfield_10050', type: 'text', value: 'x' }], + }) + ).toEqual({ customfield_10050: 'x' }) + }) + + it('serializes a legacy single field as a raw passthrough', () => { + expect( + buildJiraCustomFields({ legacyFieldId: 'customfield_10001', legacyValue: 'legacy-value' }) + ).toEqual({ customfield_10001: 'legacy-value' }) + }) + + it('skips the legacy field when id or value is empty', () => { + expect(buildJiraCustomFields({ legacyFieldId: 'customfield_10001', legacyValue: '' })).toEqual( + {} + ) + expect(buildJiraCustomFields({ legacyFieldId: '', legacyValue: 'x' })).toEqual({}) + }) + + it('lets a customFields entry win over a colliding legacy field', () => { + expect( + buildJiraCustomFields({ + legacyFieldId: 'customfield_10001', + legacyValue: 'legacy-value', + customFields: [{ fieldId: 'customfield_10001', type: 'select', value: 'High' }], + }) + ).toEqual({ customfield_10001: { value: 'High' } }) + }) + + it('merges legacy and non-colliding customFields entries', () => { + expect( + buildJiraCustomFields({ + legacyFieldId: 'customfield_10001', + legacyValue: 'legacy', + customFields: [{ fieldId: 'customfield_10002', type: 'userpicker', value: 'acc-1' }], + }) + ).toEqual({ + customfield_10001: 'legacy', + customfield_10002: { accountId: 'acc-1' }, + }) + }) + + it('skips a cascading entry with no resolvable parent', () => { + expect( + buildJiraCustomFields({ + customFields: [ + { fieldId: 'customfield_1', type: 'cascading', value: [] }, + { fieldId: 'customfield_2', type: 'select', value: 'High' }, + ], + }) + ).toEqual({ customfield_2: { value: 'High' } }) + }) + + it('skips blank non-raw entries but keeps raw passthroughs', () => { + expect( + buildJiraCustomFields({ + customFields: [ + { fieldId: 'customfield_1', type: 'select', value: '' }, + { fieldId: 'customfield_2', type: 'raw', value: null }, + ], + }) + ).toEqual({ customfield_2: null }) + }) +}) diff --git a/apps/sim/tools/jira/utils.ts b/apps/sim/tools/jira/utils.ts index 3ce0db24107..0947a909f95 100644 --- a/apps/sim/tools/jira/utils.ts +++ b/apps/sim/tools/jira/utils.ts @@ -51,6 +51,196 @@ export function toAdf(value: string | Record): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isEmptyScalar(value: unknown): boolean { + return value === undefined || value === null || value === '' +} + +/** + * Coerces an unknown value into an array. Arrays pass through; empty scalars + * become `[]`; any other single value is wrapped in a one-element array. + */ +function toValueArray(value: unknown): unknown[] { + if (Array.isArray(value)) return value + if (isEmptyScalar(value)) return [] + return [value] +} + +/** + * Resolves the string an option is identified by. If the value is an option + * object (`{ value }` / `{ id }`), that inner value is used; otherwise the value + * is stringified. + */ +function optionValue(value: unknown): string { + if (isRecord(value)) { + if (value.value !== undefined) return String(value.value) + if (value.id !== undefined) return String(value.id) + } + return String(value) +} + +/** + * Serializes a select option. An explicit `{ value }` or `{ id }` object is + * respected as-is (the reliable way to force one or the other). A bare scalar + * falls back to the heuristic: a numeric-looking value is treated as an option + * id (`{ id }`), everything else as an option value (`{ value }`). Mirrors the + * priority id-or-name heuristic used elsewhere in the Jira tools. + */ +function toSelectOption(value: unknown): Record { + if (isRecord(value)) { + if (value.id !== undefined) return { id: String(value.id) } + if (value.value !== undefined) return { value: String(value.value) } + } + const resolved = String(value) + return /^\d+$/.test(resolved) ? { id: resolved } : { value: resolved } +} + +/** + * Resolves the accountId a user-picker value refers to. A `{ accountId }` object + * is unwrapped; any other scalar is used directly as the accountId. + */ +function toAccountId(value: unknown): string { + if (isRecord(value) && value.accountId !== undefined) return String(value.accountId) + return String(value) +} + +/** + * Serializes a cascading select into `{ value: , child: { value: } }`. + * The parent/child pair is taken from an explicit `entry.child`, a `{ parent, child }` + * or `{ value, child }` object, or a two-element `[parent, child]` array. Returns + * `undefined` when no parent can be resolved, so a blank cascading entry is skipped + * rather than emitting a literal `"undefined"` value that Jira would reject. + */ +function toCascadingOption(entry: JiraCustomFieldEntry): Record | undefined { + let parent: unknown = entry.value + let child: unknown = entry.child + + if (Array.isArray(entry.value)) { + parent = entry.value[0] + if (child === undefined) child = entry.value[1] + } else if (isRecord(entry.value)) { + const rec = entry.value + parent = rec.parent !== undefined ? rec.parent : rec.value + if (child === undefined) child = rec.child + } + + if (isEmptyScalar(parent)) return undefined + + const result: Record = { value: optionValue(parent) } + if (!isEmptyScalar(child)) { + result.child = { value: optionValue(child) } + } + return result +} + +/** + * Serializes one custom-field entry into the Jira REST v3 value shape for its type: + * - `text` / `raw` → value untouched + * - `number` → numeric-string values coerced to a number, otherwise untouched + * - `select` → `{ value }` (or `{ id }` for numeric option ids) + * - `multiselect` → array of `{ value }` / `{ id }` + * - `userpicker` → `{ accountId }` + * - `multiuserpicker` → array of `{ accountId }` + * - `cascading` → `{ value, child: { value } }` + */ +export function serializeJiraCustomField(entry: JiraCustomFieldEntry): unknown { + const { type, value } = entry + switch (type) { + case 'number': { + if (typeof value === 'number') return value + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return value + } + case 'select': + return toSelectOption(value) + case 'multiselect': + return toValueArray(value).map(toSelectOption) + case 'userpicker': + return { accountId: toAccountId(value) } + case 'multiuserpicker': + return toValueArray(value).map((entryValue) => ({ accountId: toAccountId(entryValue) })) + case 'cascading': + return toCascadingOption(entry) + default: + return value + } +} + +function normalizeCustomFieldId(fieldId: string): string { + return fieldId.startsWith('customfield_') ? fieldId : `customfield_${fieldId}` +} + +/** + * Merges the legacy single `customFieldId` + `customFieldValue` pair with the + * structured `customFields` array into a `customfield_XXXXX` → serialized-value + * map ready to spread onto the Jira `fields` object. The legacy pair is applied + * first as a `raw` passthrough (preserving prior behavior); `customFields` entries + * are applied second so they win on `fieldId` collision. Blank entries are skipped. + */ +export function buildJiraCustomFields(args: { + customFields?: JiraCustomFieldEntry[] + legacyFieldId?: string | null + legacyValue?: unknown +}): Record { + const { customFields, legacyFieldId, legacyValue } = args + const result: Record = {} + + if (typeof legacyFieldId === 'string' && legacyFieldId !== '' && !isEmptyScalar(legacyValue)) { + result[normalizeCustomFieldId(legacyFieldId)] = serializeJiraCustomField({ + fieldId: legacyFieldId, + type: 'raw', + value: legacyValue, + }) + } + + if (Array.isArray(customFields)) { + for (const entry of customFields) { + if (!entry || typeof entry.fieldId !== 'string' || entry.fieldId === '') continue + const valueIsEmpty = !Array.isArray(entry.value) && isEmptyScalar(entry.value) + if (entry.type !== 'raw' && valueIsEmpty && entry.child === undefined) continue + const serialized = serializeJiraCustomField(entry) + // A serializer can decline an entry (e.g. cascading with no resolvable + // parent) by returning undefined — skip rather than emit a bad value. + if (serialized === undefined) continue + result[normalizeCustomFieldId(entry.fieldId)] = serialized + } + } + + return result +} + /** * Extracts plain text from Atlassian Document Format (ADF) content. * Returns null if content is falsy.