From b6413ea97648c9616a039aac4d55b7a0ec79ee57 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 23 Jul 2026 18:37:16 -0700 Subject: [PATCH 01/12] feat(jira): support structured custom fields and multi-field updates in Jira Update The Jira Update tool only accepted a single customFieldId + string customFieldValue and wrote it raw, so it could not represent Jira's option/user/cascading field shapes or set more than one custom field per call. - Add a `customFields` param: array of { fieldId, type, value } where type is text | number | select | multiselect | userpicker | multiuserpicker | cascading | raw. Serializes each into the Jira REST v3 fields shape. - Add serializeJiraCustomField + buildJiraCustomFields helpers in tools/jira/utils.ts (mirrors toAdf; unit-tested directly). - Merge legacy customFieldId/customFieldValue into the same pipeline as a raw passthrough; customFields wins on fieldId collision. Backward compatible. - Custom fields are applied after simple fields under one PUT `fields` body, so simple + custom fields coexist. ADF description auto-wrap preserved. - Extend jiraUpdateBodySchema contract and JiraUpdateParams; document the new shape in the tool param descriptions. - Add route + utils unit tests asserting exact `fields` payloads. --- .../app/api/tools/jira/update/route.test.ts | 223 ++++++++++++++++++ apps/sim/app/api/tools/jira/update/route.ts | 27 +-- apps/sim/lib/api/contracts/selectors/jira.ts | 24 ++ apps/sim/tools/jira/types.ts | 2 + apps/sim/tools/jira/update.ts | 13 +- apps/sim/tools/jira/utils.test.ts | 168 +++++++++++++ apps/sim/tools/jira/utils.ts | 167 +++++++++++++ 7 files changed, 608 insertions(+), 16 deletions(-) create mode 100644 apps/sim/app/api/tools/jira/update/route.test.ts create mode 100644 apps/sim/tools/jira/utils.test.ts 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..c31c3506938 --- /dev/null +++ b/apps/sim/app/api/tools/jira/update/route.test.ts @@ -0,0 +1,223 @@ +/** + * @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('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..db95b39d38d 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -91,6 +91,29 @@ export const jiraWriteBodySchema = z.object({ fixVersions: z.array(z.string()).optional(), }) +export const jiraCustomFieldTypeSchema = z.enum([ + 'text', + 'number', + 'select', + 'multiselect', + 'userpicker', + 'multiuserpicker', + 'cascading', + 'raw', +]) + +/** + * One structured custom field to write. `value` is intentionally `unknown` — its + * meaning depends on `type` and is serialized into the Jira REST v3 shape by + * `serializeJiraCustomField` in the route. + */ +export const jiraCustomFieldEntrySchema = z.object({ + fieldId: z.string().min(1, 'fieldId is required'), + type: jiraCustomFieldTypeSchema, + value: z.unknown(), + child: z.unknown().optional(), +}) + export const jiraUpdateBodySchema = z.object({ domain: z.string().min(1, 'Domain is required'), accessToken: z.string().min(1, 'Access token is required'), @@ -107,6 +130,7 @@ 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).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..0666932dff1 100644 --- a/apps/sim/tools/jira/update.ts +++ b/apps/sim/tools/jira/update.ts @@ -91,13 +91,21 @@ 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: 'json', + 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.', }, notifyUsers: { type: 'boolean', @@ -136,6 +144,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..45daadb03e9 --- /dev/null +++ b/apps/sim/tools/jira/utils.test.ts @@ -0,0 +1,168 @@ +/** + * @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('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('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('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 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..43b6fa3087b 100644 --- a/apps/sim/tools/jira/utils.ts +++ b/apps/sim/tools/jira/utils.ts @@ -51,6 +51,173 @@ 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: 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 { + const resolved = optionValue(value) + return /^\d+$/.test(resolved) ? { id: resolved } : { value: resolved } +} + +/** + * 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. + */ +function toCascadingOption(entry: JiraCustomFieldEntry): Record { + 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 + } + + 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: String(value) } + case 'multiuserpicker': + return toValueArray(value).map((entryValue) => ({ accountId: String(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 + result[normalizeCustomFieldId(entry.fieldId)] = serializeJiraCustomField(entry) + } + } + + return result +} + /** * Extracts plain text from Atlassian Document Format (ADF) content. * Returns null if content is falsy. From 6d1e7a4fdb40bb4c5d15dbc6ddb651646907ce36 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 23 Jul 2026 23:17:40 -0700 Subject: [PATCH 02/12] fix(jira): respect explicit option/user objects in custom-field serializer Pre-landing review hardening: - select now respects an explicit { value } / { id } object instead of re-applying the numeric-id heuristic to it (bare scalars keep the heuristic). - userpicker / multiuserpicker unwrap a { accountId } object instead of stringifying it to "[object Object]". Adds unit tests for both paths. --- apps/sim/tools/jira/utils.test.ts | 28 ++++++++++++++++++++++++++++ apps/sim/tools/jira/utils.ts | 23 +++++++++++++++++++---- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/sim/tools/jira/utils.test.ts b/apps/sim/tools/jira/utils.test.ts index 45daadb03e9..75c44c4e9cc 100644 --- a/apps/sim/tools/jira/utils.test.ts +++ b/apps/sim/tools/jira/utils.test.ts @@ -23,6 +23,18 @@ describe('serializeJiraCustomField', () => { ).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'] }) @@ -41,6 +53,22 @@ describe('serializeJiraCustomField', () => { ).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({ diff --git a/apps/sim/tools/jira/utils.ts b/apps/sim/tools/jira/utils.ts index 43b6fa3087b..1343c29e7c8 100644 --- a/apps/sim/tools/jira/utils.ts +++ b/apps/sim/tools/jira/utils.ts @@ -109,15 +109,30 @@ function optionValue(value: unknown): string { } /** - * Serializes a select option: a numeric-looking value is treated as an option + * 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 { - const resolved = optionValue(value) + 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 }` @@ -169,9 +184,9 @@ export function serializeJiraCustomField(entry: JiraCustomFieldEntry): unknown { case 'multiselect': return toValueArray(value).map(toSelectOption) case 'userpicker': - return { accountId: String(value) } + return { accountId: toAccountId(value) } case 'multiuserpicker': - return toValueArray(value).map((entryValue) => ({ accountId: String(entryValue) })) + return toValueArray(value).map((entryValue) => ({ accountId: toAccountId(entryValue) })) case 'cascading': return toCascadingOption(entry) default: From 4526a692a8f9c401cbd3e361089fc53efc4c5ed0 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 23 Jul 2026 23:31:06 -0700 Subject: [PATCH 03/12] fix(jira): validate custom-field value shapes and skip unresolved cascading Review round (Greptile P1 + Cursor): - Contract: replace the permissive customFields `value: z.unknown()` with a discriminated union on `type`, so a shape/value mismatch (select as { label }, userpicker as { email }, non-numeric number) is rejected at the boundary instead of serializing into a malformed value that would make Jira reject the whole combined update. - Serializer: a cascading entry with no resolvable parent now returns undefined and is skipped, instead of emitting the literal "undefined" as the parent value. buildJiraCustomFields skips any entry whose serialization is undefined. - Add tests for both plus boundary rejection of mismatched shapes. --- .../app/api/tools/jira/update/route.test.ts | 17 +++++ apps/sim/lib/api/contracts/selectors/jira.ts | 73 ++++++++++++++++--- apps/sim/tools/jira/utils.test.ts | 32 ++++++-- apps/sim/tools/jira/utils.ts | 14 +++- 4 files changed, 118 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/tools/jira/update/route.test.ts b/apps/sim/app/api/tools/jira/update/route.test.ts index c31c3506938..7bbfe2e59f7 100644 --- a/apps/sim/app/api/tools/jira/update/route.test.ts +++ b/apps/sim/app/api/tools/jira/update/route.test.ts @@ -66,6 +66,23 @@ describe('Jira update route custom-field serialization', () => { 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('serializes a multiselect custom field to an array of options', async () => { await update({ customFields: [ diff --git a/apps/sim/lib/api/contracts/selectors/jira.ts b/apps/sim/lib/api/contracts/selectors/jira.ts index db95b39d38d..2603d6b30bf 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -102,17 +102,72 @@ export const jiraCustomFieldTypeSchema = z.enum([ 'raw', ]) +const customFieldIdSchema = z.string().min(1, 'fieldId is required') + +/** A scalar or `{ value }` / `{ id }` option object for a select/multiselect. */ +const optionInputSchema = z.union([ + z.string(), + z.number(), + z.object({ value: z.union([z.string(), z.number()]) }), + z.object({ id: z.union([z.string(), z.number()]) }), +]) + +/** 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'), +]) + +/** Parent scalar, `[parent, child]` array, or `{ parent|value, child }` object. */ +const cascadingInputSchema = z.union([ + z.string(), + z.number(), + z.array(z.union([z.string(), z.number()])).min(1, 'cascading value cannot be empty'), + z.record(z.string(), z.unknown()), +]) + /** - * One structured custom field to write. `value` is intentionally `unknown` — its - * meaning depends on `type` and is serialized into the Jira REST v3 shape by - * `serializeJiraCustomField` in the route. + * 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.object({ - fieldId: z.string().min(1, 'fieldId is required'), - type: jiraCustomFieldTypeSchema, - value: z.unknown(), - child: z.unknown().optional(), -}) +export const jiraCustomFieldEntrySchema = z.discriminatedUnion('type', [ + z.object({ + fieldId: customFieldIdSchema, + type: z.literal('text'), + value: z.union([z.string(), z.number()]), + }), + 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), 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), userInputSchema]), + }), + z.object({ + fieldId: customFieldIdSchema, + type: z.literal('cascading'), + value: cascadingInputSchema, + child: z.unknown().optional(), + }), + z.object({ fieldId: customFieldIdSchema, type: z.literal('raw'), value: z.unknown() }), +]) export const jiraUpdateBodySchema = z.object({ domain: z.string().min(1, 'Domain is required'), diff --git a/apps/sim/tools/jira/utils.test.ts b/apps/sim/tools/jira/utils.test.ts index 75c44c4e9cc..2ddf5f1d2fe 100644 --- a/apps/sim/tools/jira/utils.test.ts +++ b/apps/sim/tools/jira/utils.test.ts @@ -42,15 +42,15 @@ describe('serializeJiraCustomField', () => { }) it('wraps a scalar multiselect value into a single-element array', () => { - expect( - serializeJiraCustomField({ fieldId: 'cf', type: 'multiselect', value: 'Only' }) - ).toEqual([{ value: 'Only' }]) + 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' }) + expect(serializeJiraCustomField({ fieldId: 'cf', type: 'userpicker', value: 'acc-1' })).toEqual( + { accountId: 'acc-1' } + ) }) it('unwraps a { accountId } object for a userpicker', () => { @@ -112,6 +112,15 @@ describe('serializeJiraCustomField', () => { ).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') }) @@ -183,6 +192,17 @@ describe('buildJiraCustomFields', () => { }) }) + 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({ diff --git a/apps/sim/tools/jira/utils.ts b/apps/sim/tools/jira/utils.ts index 1343c29e7c8..0947a909f95 100644 --- a/apps/sim/tools/jira/utils.ts +++ b/apps/sim/tools/jira/utils.ts @@ -136,9 +136,11 @@ function toAccountId(value: unknown): string { /** * 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. + * 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 { +function toCascadingOption(entry: JiraCustomFieldEntry): Record | undefined { let parent: unknown = entry.value let child: unknown = entry.child @@ -151,6 +153,8 @@ function toCascadingOption(entry: JiraCustomFieldEntry): Record 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) } @@ -226,7 +230,11 @@ export function buildJiraCustomFields(args: { 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 - result[normalizeCustomFieldId(entry.fieldId)] = serializeJiraCustomField(entry) + 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 } } From 53ee849b537a60397ec761549dbc873c9ec46c5b Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 23 Jul 2026 23:44:14 -0700 Subject: [PATCH 04/12] fix(jira): tighten cascading custom-field validation at the boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 (Greptile + Cursor): cascading was validated more loosely than select/userpicker — value accepted any record and child was z.unknown(), so an unresolvable shape like { id: '10' } or an object-valued parent/child passed the boundary and was then either silently dropped or serialized to '[object Object]', rejecting the whole combined update. Constrain cascading parent/child to scalars (matching the other option types); an arbitrary record now 400s at the boundary. Adds a route test for the rejection. --- .../app/api/tools/jira/update/route.test.ts | 8 ++++++ apps/sim/lib/api/contracts/selectors/jira.ts | 27 ++++++++++++++----- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/tools/jira/update/route.test.ts b/apps/sim/app/api/tools/jira/update/route.test.ts index 7bbfe2e59f7..4226b4f0758 100644 --- a/apps/sim/app/api/tools/jira/update/route.test.ts +++ b/apps/sim/app/api/tools/jira/update/route.test.ts @@ -83,6 +83,14 @@ describe('Jira update route custom-field serialization', () => { 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('serializes a multiselect custom field to an array of options', async () => { await update({ customFields: [ diff --git a/apps/sim/lib/api/contracts/selectors/jira.ts b/apps/sim/lib/api/contracts/selectors/jira.ts index 2603d6b30bf..04c56004105 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -126,12 +126,27 @@ const numberInputSchema = z.union([ .refine((s) => s.trim() !== '' && Number.isFinite(Number(s)), 'number value must be numeric'), ]) -/** Parent scalar, `[parent, child]` array, or `{ parent|value, child }` object. */ +const cascadingScalar = z.union([z.string(), 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([ - z.string(), - z.number(), - z.array(z.union([z.string(), z.number()])).min(1, 'cascading value cannot be empty'), - z.record(z.string(), z.unknown()), + cascadingScalar, + z.array(cascadingScalar).min(1, 'cascading value cannot be empty'), + 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', + }), ]) /** @@ -164,7 +179,7 @@ export const jiraCustomFieldEntrySchema = z.discriminatedUnion('type', [ fieldId: customFieldIdSchema, type: z.literal('cascading'), value: cascadingInputSchema, - child: z.unknown().optional(), + child: cascadingScalar.optional(), }), z.object({ fieldId: customFieldIdSchema, type: z.literal('raw'), value: z.unknown() }), ]) From 294f602bef2bbae72c6b866df6ddaa12c4cb96b2 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 23 Jul 2026 23:59:36 -0700 Subject: [PATCH 05/12] fix(jira): reject truncated cascading arrays, numeric text, and empty options Round 3 (Greptile + Cursor) boundary hardening: - cascading array values are capped at [parent, child] (.max(2)) so a 3+ element array is rejected instead of silently truncated by the serializer. - text values must be strings; a bare number (common from LLM tool calls) is rejected rather than passed through to a string-typed Jira field. - select/multiselect option values must be non-empty, so { value: '' } / { id: '' } / an empty array element can't serialize into an invalid Jira option. Adds route tests for all three rejections. --- .../app/api/tools/jira/update/route.test.ts | 21 +++++++++++++++++++ apps/sim/lib/api/contracts/selectors/jira.ts | 17 ++++++++------- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/tools/jira/update/route.test.ts b/apps/sim/app/api/tools/jira/update/route.test.ts index 4226b4f0758..89012f0eba3 100644 --- a/apps/sim/app/api/tools/jira/update/route.test.ts +++ b/apps/sim/app/api/tools/jira/update/route.test.ts @@ -91,6 +91,27 @@ describe('Jira update route custom-field serialization', () => { 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 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('serializes a multiselect custom field to an array of options', async () => { await update({ customFields: [ diff --git a/apps/sim/lib/api/contracts/selectors/jira.ts b/apps/sim/lib/api/contracts/selectors/jira.ts index 04c56004105..0b5a044f943 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -104,12 +104,12 @@ export const jiraCustomFieldTypeSchema = z.enum([ const customFieldIdSchema = z.string().min(1, 'fieldId is required') -/** A scalar or `{ value }` / `{ id }` option object for a select/multiselect. */ +/** A non-empty option value/id — as a scalar or a `{ value }` / `{ id }` object. */ +const optionScalar = z.union([z.string().min(1, 'option value cannot be empty'), z.number()]) const optionInputSchema = z.union([ - z.string(), - z.number(), - z.object({ value: z.union([z.string(), z.number()]) }), - z.object({ id: z.union([z.string(), z.number()]) }), + optionScalar, + z.object({ value: optionScalar }), + z.object({ id: optionScalar }), ]) /** An accountId string or a `{ accountId }` object for a user picker. */ @@ -137,7 +137,10 @@ const cascadingScalar = z.union([z.string(), z.number()]) */ const cascadingInputSchema = z.union([ cascadingScalar, - z.array(cascadingScalar).min(1, 'cascading value cannot be empty'), + z + .array(cascadingScalar) + .min(1, 'cascading value cannot be empty') + .max(2, 'cascading value accepts at most [parent, child]'), z .object({ parent: cascadingScalar.optional(), @@ -160,7 +163,7 @@ export const jiraCustomFieldEntrySchema = z.discriminatedUnion('type', [ z.object({ fieldId: customFieldIdSchema, type: z.literal('text'), - value: z.union([z.string(), z.number()]), + value: z.string(), }), z.object({ fieldId: customFieldIdSchema, type: z.literal('number'), value: numberInputSchema }), z.object({ fieldId: customFieldIdSchema, type: z.literal('select'), value: optionInputSchema }), From 8743c54b7d54166a3f1dd8243dcde5288937eb89 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 24 Jul 2026 00:05:40 -0700 Subject: [PATCH 06/12] fix(jira): reject cascading objects that set both parent and value aliases Round 4 (Greptile): a cascading object like { parent: 'A', value: 'B' } passed validation but the serializer always uses parent and silently discarded value. Add a refine rejecting the ambiguous case at the boundary. Adds a route test. --- apps/sim/app/api/tools/jira/update/route.test.ts | 13 +++++++++++++ apps/sim/lib/api/contracts/selectors/jira.ts | 3 +++ 2 files changed, 16 insertions(+) diff --git a/apps/sim/app/api/tools/jira/update/route.test.ts b/apps/sim/app/api/tools/jira/update/route.test.ts index 89012f0eba3..507753c4707 100644 --- a/apps/sim/app/api/tools/jira/update/route.test.ts +++ b/apps/sim/app/api/tools/jira/update/route.test.ts @@ -112,6 +112,19 @@ describe('Jira update route custom-field serialization', () => { expect(response.status).toBe(400) }) + 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('serializes a multiselect custom field to an array of options', async () => { await update({ customFields: [ diff --git a/apps/sim/lib/api/contracts/selectors/jira.ts b/apps/sim/lib/api/contracts/selectors/jira.ts index 0b5a044f943..a8c32400064 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -149,6 +149,9 @@ const cascadingInputSchema = z.union([ }) .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)', }), ]) From 4186a11a5c7b0ee8761cc68705e6d349c8853927 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 24 Jul 2026 00:10:16 -0700 Subject: [PATCH 07/12] fix(jira): reject empty and conflicting-child cascading values Round 5 (Cursor + Greptile): - cascading scalars must be non-empty (min 1), matching option/userpicker, so '', [''], { parent: '' } are rejected at the boundary instead of passing validation and then being silently dropped by the serializer. - reject a cascading entry that sets child both at the top level and inside value (the serializer would keep one and discard the other). Adds route tests. --- .../app/api/tools/jira/update/route.test.ts | 21 +++++ apps/sim/lib/api/contracts/selectors/jira.ts | 79 ++++++++++++------- 2 files changed, 72 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/api/tools/jira/update/route.test.ts b/apps/sim/app/api/tools/jira/update/route.test.ts index 507753c4707..0faa7fb5161 100644 --- a/apps/sim/app/api/tools/jira/update/route.test.ts +++ b/apps/sim/app/api/tools/jira/update/route.test.ts @@ -125,6 +125,27 @@ describe('Jira update route custom-field serialization', () => { 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('serializes a multiselect custom field to an array of options', async () => { await update({ customFields: [ diff --git a/apps/sim/lib/api/contracts/selectors/jira.ts b/apps/sim/lib/api/contracts/selectors/jira.ts index a8c32400064..e744eeb6f15 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -126,7 +126,7 @@ const numberInputSchema = z.union([ .refine((s) => s.trim() !== '' && Number.isFinite(Number(s)), 'number value must be numeric'), ]) -const cascadingScalar = z.union([z.string(), z.number()]) +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. @@ -162,33 +162,56 @@ const cascadingInputSchema = z.union([ * 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(), - }), - 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), 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), 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() }), -]) +export const jiraCustomFieldEntrySchema = z + .discriminatedUnion('type', [ + z.object({ + fieldId: customFieldIdSchema, + type: z.literal('text'), + value: z.string(), + }), + 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), 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), 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 exposes a top-level `child` and an optional `value.child`; if both + // are set the serializer would silently keep one and discard the other, so + // reject the ambiguity at the boundary. + if (entry.type !== 'cascading') return + const value: unknown = entry.value + const nestedChild = + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record).child + : undefined + 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'), From 19cd38e7c025622fabdf5f900238a4ed0267181b Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 24 Jul 2026 00:25:32 -0700 Subject: [PATCH 08/12] fix(jira): also reject cascading child conflict in the array form Completes the round-5 conflicting-child refine, which only checked the object form of value.child. A [parent, child] array plus a top-level child now also rejects at the boundary (nested child is value[1] for the array form). Adds a route test for the array case. --- .../app/api/tools/jira/update/route.test.ts | 14 ++++++++++++++ apps/sim/lib/api/contracts/selectors/jira.ts | 18 +++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/tools/jira/update/route.test.ts b/apps/sim/app/api/tools/jira/update/route.test.ts index 0faa7fb5161..755d3522a65 100644 --- a/apps/sim/app/api/tools/jira/update/route.test.ts +++ b/apps/sim/app/api/tools/jira/update/route.test.ts @@ -146,6 +146,20 @@ describe('Jira update route custom-field serialization', () => { 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: [ diff --git a/apps/sim/lib/api/contracts/selectors/jira.ts b/apps/sim/lib/api/contracts/selectors/jira.ts index e744eeb6f15..9ca7039de37 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -195,15 +195,19 @@ export const jiraCustomFieldEntrySchema = z z.object({ fieldId: customFieldIdSchema, type: z.literal('raw'), value: z.unknown() }), ]) .superRefine((entry, ctx) => { - // Cascading exposes a top-level `child` and an optional `value.child`; if both - // are set the serializer would silently keep one and discard the other, so - // reject the ambiguity at the boundary. + // 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 - const nestedChild = - value !== null && typeof value === 'object' && !Array.isArray(value) - ? (value as Record).child - : undefined + 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', From eed2573a3b5ce13398779fbf75c23827c5be64c7 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 24 Jul 2026 00:48:34 -0700 Subject: [PATCH 09/12] fix(jira): expose customFields as an array to LLMs and reject empty text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 7 (Cursor): - customFields tool param was type: 'json', which the schema builder exposes to LLMs as a JSON Schema object while the contract requires an array — a model emitting an object/map would 400. Switch to type: 'array' with an items schema describing { fieldId, type, value }, matching the array params in jira/write.ts. - text custom-field values must be non-empty (min 1), so '' is rejected at the boundary instead of passing validation and being silently skipped — consistent with select/userpicker/cascading. Adds a route test. --- .../app/api/tools/jira/update/route.test.ts | 7 +++++++ apps/sim/lib/api/contracts/selectors/jira.ts | 2 +- apps/sim/tools/jira/update.ts | 18 +++++++++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/tools/jira/update/route.test.ts b/apps/sim/app/api/tools/jira/update/route.test.ts index 755d3522a65..43b3d7d4d9d 100644 --- a/apps/sim/app/api/tools/jira/update/route.test.ts +++ b/apps/sim/app/api/tools/jira/update/route.test.ts @@ -105,6 +105,13 @@ describe('Jira update route custom-field serialization', () => { 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: '' } }], diff --git a/apps/sim/lib/api/contracts/selectors/jira.ts b/apps/sim/lib/api/contracts/selectors/jira.ts index 9ca7039de37..fb24507dcf5 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -167,7 +167,7 @@ export const jiraCustomFieldEntrySchema = z z.object({ fieldId: customFieldIdSchema, type: z.literal('text'), - value: z.string(), + 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 }), diff --git a/apps/sim/tools/jira/update.ts b/apps/sim/tools/jira/update.ts index 0666932dff1..c920df57575 100644 --- a/apps/sim/tools/jira/update.ts +++ b/apps/sim/tools/jira/update.ts @@ -101,11 +101,27 @@ export const jiraUpdateTool: ToolConfig = description: 'Raw value for the legacy single custom field. Prefer `customFields`.', }, customFields: { - type: 'json', + 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', From 287a56bf32db029ba58f8222ac010e53cd64f016 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 24 Jul 2026 01:01:13 -0700 Subject: [PATCH 10/12] fix(jira): reject select/multiselect option objects that set both value and id Round 8 (Greptile): an option object like { id: '10', value: 'High' } passed the { value } / { id } union, but toSelectOption keeps only one and silently discards the other. Require an option object to set exactly one of value or id (mirrors the cascading parent/value alias rule). Adds a route test. --- apps/sim/app/api/tools/jira/update/route.test.ts | 9 +++++++++ apps/sim/lib/api/contracts/selectors/jira.ts | 14 +++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/tools/jira/update/route.test.ts b/apps/sim/app/api/tools/jira/update/route.test.ts index 43b3d7d4d9d..b4159d3af24 100644 --- a/apps/sim/app/api/tools/jira/update/route.test.ts +++ b/apps/sim/app/api/tools/jira/update/route.test.ts @@ -119,6 +119,15 @@ describe('Jira update route custom-field serialization', () => { 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 a cascading object that sets both parent and value aliases', async () => { const { response } = await update({ customFields: [ diff --git a/apps/sim/lib/api/contracts/selectors/jira.ts b/apps/sim/lib/api/contracts/selectors/jira.ts index fb24507dcf5..e398ff58fcf 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -104,12 +104,20 @@ export const jiraCustomFieldTypeSchema = z.enum([ const customFieldIdSchema = z.string().min(1, 'fieldId is required') -/** A non-empty option value/id — as a scalar or a `{ value }` / `{ id }` object. */ +/** + * 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 }), - z.object({ id: 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. */ From b697b36e2c5ff1101301d2d2c09d530165837a46 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 24 Jul 2026 01:06:44 -0700 Subject: [PATCH 11/12] fix(jira): reject empty multi-value arrays and drop dead type enum Round 9 (Cursor): - multiselect / multiuserpicker no longer accept an empty array; [] would serialize to [] and silently clear the Jira field, inconsistent with the empty-rejection for text/select/cascading. Clearing stays explicit via raw+null. - remove the exported-but-unused jiraCustomFieldTypeSchema enum (the discriminated union rebuilds the literals); dead code that could drift. Adds a route test. --- .../app/api/tools/jira/update/route.test.ts | 7 +++++++ apps/sim/lib/api/contracts/selectors/jira.ts | 21 +++++++------------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/api/tools/jira/update/route.test.ts b/apps/sim/app/api/tools/jira/update/route.test.ts index b4159d3af24..7b3789bc472 100644 --- a/apps/sim/app/api/tools/jira/update/route.test.ts +++ b/apps/sim/app/api/tools/jira/update/route.test.ts @@ -128,6 +128,13 @@ describe('Jira update route custom-field serialization', () => { 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 a cascading object that sets both parent and value aliases', async () => { const { response } = await update({ customFields: [ diff --git a/apps/sim/lib/api/contracts/selectors/jira.ts b/apps/sim/lib/api/contracts/selectors/jira.ts index e398ff58fcf..9ab3f1eb772 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -91,17 +91,6 @@ export const jiraWriteBodySchema = z.object({ fixVersions: z.array(z.string()).optional(), }) -export const jiraCustomFieldTypeSchema = z.enum([ - 'text', - 'number', - 'select', - 'multiselect', - 'userpicker', - 'multiuserpicker', - 'cascading', - 'raw', -]) - const customFieldIdSchema = z.string().min(1, 'fieldId is required') /** @@ -182,7 +171,10 @@ export const jiraCustomFieldEntrySchema = z z.object({ fieldId: customFieldIdSchema, type: z.literal('multiselect'), - value: z.union([z.array(optionInputSchema), optionInputSchema]), + value: z.union([ + z.array(optionInputSchema).min(1, 'multiselect value cannot be empty'), + optionInputSchema, + ]), }), z.object({ fieldId: customFieldIdSchema, @@ -192,7 +184,10 @@ export const jiraCustomFieldEntrySchema = z z.object({ fieldId: customFieldIdSchema, type: z.literal('multiuserpicker'), - value: z.union([z.array(userInputSchema), userInputSchema]), + value: z.union([ + z.array(userInputSchema).min(1, 'multiuserpicker value cannot be empty'), + userInputSchema, + ]), }), z.object({ fieldId: customFieldIdSchema, From 552bc9cafbdfc6fa0999fe869ef45b0d33e4e855 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 24 Jul 2026 01:14:58 -0700 Subject: [PATCH 12/12] fix(jira): reject duplicate custom field ids within customFields Round 10 (Cursor): two customFields entries that normalize to the same customfield_XXXXX (including a lookalike pair like 10001 and customfield_10001) would last-write-win in buildJiraCustomFields and silently apply one value. Add a superRefine on the customFields array rejecting the collision at the boundary, consistent with the other ambiguity rejections. Adds a route test. The legacy customFieldId vs customFields collision stays intentional (customFields wins) and is unaffected. --- .../app/api/tools/jira/update/route.test.ts | 11 ++++++++ apps/sim/lib/api/contracts/selectors/jira.ts | 25 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/tools/jira/update/route.test.ts b/apps/sim/app/api/tools/jira/update/route.test.ts index 7b3789bc472..5f953b9d3b5 100644 --- a/apps/sim/app/api/tools/jira/update/route.test.ts +++ b/apps/sim/app/api/tools/jira/update/route.test.ts @@ -135,6 +135,17 @@ describe('Jira update route custom-field serialization', () => { 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: [ diff --git a/apps/sim/lib/api/contracts/selectors/jira.ts b/apps/sim/lib/api/contracts/selectors/jira.ts index 9ab3f1eb772..c30b3500ae1 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -236,7 +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).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(), })