From 7a1d73dd521d60b11b3f471cb86fe6e579c6d932 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sun, 16 Aug 2026 10:02:02 -0700 Subject: [PATCH] fix(jira): gate the project selector to operations that use it `projectId`/`manualProjectId` had no `condition`, so Select Project rendered on all 30 operations, including the 9 whose Jira endpoint accepts no project and whose visible fields do not depend on one. Both members now share one positive condition composed from two hoisted lists: PROJECT_PARAM_OPERATIONS (write, read-bulk, get_project, whose tools declare a projectId param) and ISSUE_KEY_OPERATIONS (the 18 single-issue operations, where the project scopes the issue picker). 21 shown, 9 hidden. ISSUE_KEY_OPERATIONS also replaces four verbatim copies of the same 18-element array previously pasted across issueKey/manualIssueKey's condition and required, which is why those lists drifted apart in the first place. Relabels Read Issue to Get Issue and Read Bulk Issues to Get Bulk Issues to match the sibling Get X convention. Operation ids and tool ids are unchanged, so stored workflows are unaffected; integrations.json is regenerated. --- apps/sim/blocks/blocks/jira.test.ts | 258 ++++++++++++++++++++ apps/sim/blocks/blocks/jira.ts | 150 ++++-------- apps/sim/lib/integrations/integrations.json | 4 +- 3 files changed, 312 insertions(+), 100 deletions(-) create mode 100644 apps/sim/blocks/blocks/jira.test.ts diff --git a/apps/sim/blocks/blocks/jira.test.ts b/apps/sim/blocks/blocks/jira.test.ts new file mode 100644 index 00000000000..891dbb7106c --- /dev/null +++ b/apps/sim/blocks/blocks/jira.test.ts @@ -0,0 +1,258 @@ +/** + * @vitest-environment node + * + * Schema gating for the Jira block's resource selectors. + * + * The expectations are derived from the tool contracts rather than hand-copied + * operation lists, so adding an operation that consumes a project fails here + * until the block's `condition` is widened to match. + */ +import { describe, expect, it } from 'vitest' +import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' +import { JiraBlock } from '@/blocks/blocks/jira' +import type { SubBlockConfig } from '@/blocks/types' +import { + jiraAddAttachmentTool, + jiraAddCommentTool, + jiraAddWatcherTool, + jiraAddWorklogTool, + jiraAssignIssueTool, + jiraBulkRetrieveTool, + jiraCreateIssueLinkTool, + jiraDeleteAttachmentTool, + jiraDeleteCommentTool, + jiraDeleteIssueLinkTool, + jiraDeleteIssueTool, + jiraDeleteWorklogTool, + jiraGetAttachmentsTool, + jiraGetCommentsTool, + jiraGetFieldsTool, + jiraGetProjectTool, + jiraGetTransitionsTool, + jiraGetUsersTool, + jiraGetWorklogsTool, + jiraListIssueTypesTool, + jiraListProjectsTool, + jiraRemoveWatcherTool, + jiraRetrieveTool, + jiraSearchIssuesTool, + jiraSearchUsersTool, + jiraTransitionIssueTool, + jiraUpdateCommentTool, + jiraUpdateTool, + jiraUpdateWorklogTool, + jiraWriteTool, +} from '@/tools/jira' +import type { ToolConfig } from '@/tools/types' + +/** + * The block's tools, keyed by id. Imported directly rather than through + * `@/tools/registry`, which `vitest.setup.ts` mocks to `{}` for import cost. + * Going through the registry would make every contract assertion below pass + * vacuously. + */ +const TOOLS_BY_ID = new Map( + ( + [ + jiraAddAttachmentTool, + jiraAddCommentTool, + jiraAddWatcherTool, + jiraAddWorklogTool, + jiraAssignIssueTool, + jiraBulkRetrieveTool, + jiraCreateIssueLinkTool, + jiraDeleteAttachmentTool, + jiraDeleteCommentTool, + jiraDeleteIssueLinkTool, + jiraDeleteIssueTool, + jiraDeleteWorklogTool, + jiraGetAttachmentsTool, + jiraGetCommentsTool, + jiraGetFieldsTool, + jiraGetProjectTool, + jiraGetTransitionsTool, + jiraGetUsersTool, + jiraGetWorklogsTool, + jiraListIssueTypesTool, + jiraListProjectsTool, + jiraRemoveWatcherTool, + jiraRetrieveTool, + jiraSearchIssuesTool, + jiraSearchUsersTool, + jiraTransitionIssueTool, + jiraUpdateCommentTool, + jiraUpdateTool, + jiraUpdateWorklogTool, + jiraWriteTool, + ] as ToolConfig[] + ).map((tool) => [tool.id, tool]) +) + +/** + * Operations where the project selector has no role: the endpoint takes no + * project (issue/user search, site-wide lookups, issue links, attachment + * delete) and no visible subblock depends on it. + */ +const PROJECT_HIDDEN_OPERATIONS = [ + 'search', + 'search_users', + 'get_users', + 'list_projects', + 'get_fields', + 'create_link', + 'delete_link', + 'delete_attachment', + 'list_issue_types', +] as const + +const operationSubBlock = JiraBlock.subBlocks.find((sb) => sb.id === 'operation') +const operationIds = (operationSubBlock?.options as Array<{ id: string }>).map((o) => o.id) + +const subBlockById = (id: string): SubBlockConfig => { + const found = JiraBlock.subBlocks.find((sb) => sb.id === id) + if (!found) throw new Error(`Jira block has no subblock "${id}"`) + return found +} + +/** Whether a subblock renders for `operation`, using the app's own evaluator. */ +const isVisibleFor = (sb: SubBlockConfig, operation: string): boolean => + evaluateSubBlockCondition(sb.condition, { operation }) + +/** Whether a subblock is marked required for `operation`. */ +const isRequiredFor = (sb: SubBlockConfig, operation: string): boolean => { + if (typeof sb.required === 'boolean') return sb.required + if (!sb.required) return false + return evaluateSubBlockCondition(sb.required, { operation }) +} + +/** The tool an operation dispatches to, via the block's own switch. */ +const toolIdFor = (operation: string): string => + (JiraBlock.tools.config?.tool as (params: Record) => string)({ operation }) + +/** Whether that tool declares `paramId` as a required input. */ +const toolRequiresParam = (operation: string, paramId: string): boolean => { + const toolId = toolIdFor(operation) + const tool = TOOLS_BY_ID.get(toolId) + if (!tool) throw new Error(`No imported tool for "${toolId}" (operation "${operation}")`) + return Boolean(tool.params?.[paramId]?.required) +} + +const PROJECT_MEMBERS = ['projectId', 'manualProjectId'] as const +const ISSUE_MEMBERS = ['issueKey', 'manualIssueKey'] as const + +describe('Jira block operation gating', () => { + it('exposes every operation exactly once', () => { + expect(new Set(operationIds).size).toBe(operationIds.length) + expect(operationIds).toContain('read') + expect(operationIds).toContain('read-bulk') + }) + + it('resolves every operation to an imported tool', () => { + // Guards the contract assertions below from passing vacuously: if an + // operation dispatches to a tool this file does not import, fail loudly + // rather than treat its params as absent. + const unresolved = operationIds.filter((operation) => !TOOLS_BY_ID.has(toolIdFor(operation))) + expect(unresolved).toEqual([]) + }) + + describe('project selector matches the tool contract', () => { + it.each(operationIds)( + 'shows the project selector for %s whenever its tool requires projectId', + (operation) => { + if (!toolRequiresParam(operation, 'projectId')) return + for (const id of PROJECT_MEMBERS) { + expect(isVisibleFor(subBlockById(id), operation)).toBe(true) + } + } + ) + + it.each(PROJECT_HIDDEN_OPERATIONS)('hides the project selector for %s', (operation) => { + expect(toolRequiresParam(operation, 'projectId')).toBe(false) + for (const id of PROJECT_MEMBERS) { + expect(isVisibleFor(subBlockById(id), operation)).toBe(false) + } + }) + + it('keeps the project selector on read-bulk, whose tool requires it', () => { + expect(toolRequiresParam('read-bulk', 'projectId')).toBe(true) + for (const id of PROJECT_MEMBERS) { + expect(isVisibleFor(subBlockById(id), 'read-bulk')).toBe(true) + expect(isRequiredFor(subBlockById(id), 'read-bulk')).toBe(true) + } + }) + + it('keeps the project selector wherever the issue picker depends on it', () => { + for (const operation of operationIds) { + const issuePickerVisible = isVisibleFor(subBlockById('issueKey'), operation) + if (!issuePickerVisible) continue + expect(subBlockById('issueKey').dependsOn).toContain('projectId') + expect(isVisibleFor(subBlockById('projectId'), operation)).toBe(true) + } + }) + }) + + describe('condition is a superset of required', () => { + it.each([...PROJECT_MEMBERS, ...ISSUE_MEMBERS])( + '%s is visible on every operation where it is required', + (id) => { + const sb = subBlockById(id) + for (const operation of operationIds) { + if (!isRequiredFor(sb, operation)) continue + expect(isVisibleFor(sb, operation)).toBe(true) + } + } + ) + }) + + describe('basic and advanced twins agree', () => { + it.each([PROJECT_MEMBERS, ISSUE_MEMBERS])('%s render identically', (basicId, advancedId) => { + const basic = subBlockById(basicId) + const advanced = subBlockById(advancedId) + expect(basic.canonicalParamId).toBe(advanced.canonicalParamId) + expect(advanced.dependsOn).toEqual(basic.dependsOn) + for (const operation of operationIds) { + expect(isVisibleFor(advanced, operation)).toBe(isVisibleFor(basic, operation)) + expect(isRequiredFor(advanced, operation)).toBe(isRequiredFor(basic, operation)) + } + }) + }) + + describe('no visible subblock depends on a hidden one', () => { + it.each(operationIds)('%s leaves every rendered dependency satisfiable', (operation) => { + const visible = JiraBlock.subBlocks.filter((sb) => isVisibleFor(sb, operation)) + const visibleIds = new Set(visible.map((sb) => sb.id)) + for (const sb of visible) { + const deps = Array.isArray(sb.dependsOn) ? sb.dependsOn : [] + for (const dep of deps) { + // A dependency satisfied by a canonical twin counts as present. + const depConfigs = JiraBlock.subBlocks.filter( + (candidate) => candidate.id === dep || candidate.canonicalParamId === dep + ) + const anyVisible = depConfigs.some((candidate) => visibleIds.has(candidate.id)) + expect( + anyVisible, + `"${sb.id}" renders for "${operation}" but its dependency "${dep}" does not` + ).toBe(true) + } + } + }) + }) + + describe('operation labels', () => { + const labelFor = (id: string) => + (operationSubBlock?.options as Array<{ id: string; label: string }>).find((o) => o.id === id) + ?.label + + it('uses the Get convention for retrieval operations', () => { + expect(labelFor('read')).toBe('Get Issue') + expect(labelFor('read-bulk')).toBe('Get Bulk Issues') + }) + + it('leaves the persisted operation ids untouched', () => { + expect(operationIds).toContain('read') + expect(operationIds).toContain('read-bulk') + expect(toolIdFor('read')).toBe('jira_retrieve') + expect(toolIdFor('read-bulk')).toBe('jira_bulk_read') + }) + }) +}) diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index 44127da9104..615d78670bf 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -13,6 +13,46 @@ const PROJECT_FIELD = ['projectId', 'manualProjectId'] as const /** Canonical `files` pair: upload (basic) and file reference (advanced). */ const ATTACHMENT_FIELD = ['attachmentFiles', 'files'] as const +/** + * Operations whose Jira endpoint consumes a project: `jira_write` posts to it, + * `jira_bulk_read` resolves it into a `project = "KEY"` JQL clause, and + * `jira_get_project` addresses it directly. + */ +const PROJECT_PARAM_OPERATIONS = ['write', 'read-bulk', 'get_project'] as const + +/** + * Operations that address a single issue. The endpoint takes only the issue key, + * but the project still scopes the issue picker, so both must stay visible. + */ +const ISSUE_KEY_OPERATIONS = [ + 'read', + 'update', + 'delete', + 'assign', + 'transition', + 'add_comment', + 'get_comments', + 'update_comment', + 'delete_comment', + 'get_attachments', + 'add_attachment', + 'add_worklog', + 'get_worklogs', + 'update_worklog', + 'delete_worklog', + 'add_watcher', + 'remove_watcher', + 'get_transitions', +] as const + +/** + * Every operation that shows the project selector: those that send a project and + * those whose issue picker it scopes. The rest (issue/user search, site-wide + * lookups, issue links, attachment delete) accept no project parameter and have + * no dependent selector, so the field would only invite a hardcoded project id. + */ +const PROJECT_SCOPED_OPERATIONS = [...PROJECT_PARAM_OPERATIONS, ...ISSUE_KEY_OPERATIONS] as const + export const JiraBlock: BlockConfig = { type: 'jira', name: 'Jira', @@ -38,8 +78,8 @@ export const JiraBlock: BlockConfig = { }, sentences: { byOperation: { - read: [{ text: 'Read issue', field: ISSUE_FIELD, core: true }], - 'read-bulk': [{ text: 'Read all issues in', field: PROJECT_FIELD, core: true }], + read: [{ text: 'Get issue', field: ISSUE_FIELD, core: true }], + 'read-bulk': [{ text: 'Get all issues in', field: PROJECT_FIELD, core: true }], update: [ { text: 'Update issue', field: ISSUE_FIELD, core: true }, { text: ', setting summary to', field: 'summary' }, @@ -140,8 +180,8 @@ export const JiraBlock: BlockConfig = { title: 'Operation', type: 'dropdown', options: [ - { label: 'Read Issue', id: 'read' }, - { label: 'Read Bulk Issues', id: 'read-bulk' }, + { label: 'Get Issue', id: 'read' }, + { label: 'Get Bulk Issues', id: 'read-bulk' }, { label: 'Update Issue', id: 'update' }, { label: 'Write Issue', id: 'write' }, { label: 'Delete Issue', id: 'delete' }, @@ -211,7 +251,8 @@ export const JiraBlock: BlockConfig = { placeholder: 'Select Jira project', dependsOn: ['credential', 'domain'], mode: 'basic', - required: { field: 'operation', value: ['write', 'read-bulk', 'get_project'] }, + condition: { field: 'operation', value: [...PROJECT_SCOPED_OPERATIONS] }, + required: { field: 'operation', value: [...PROJECT_PARAM_OPERATIONS] }, }, // Manual project ID input (advanced mode) { @@ -222,7 +263,8 @@ export const JiraBlock: BlockConfig = { placeholder: 'Enter Jira project ID', dependsOn: ['credential', 'domain'], mode: 'advanced', - required: { field: 'operation', value: ['write', 'read-bulk', 'get_project'] }, + condition: { field: 'operation', value: [...PROJECT_SCOPED_OPERATIONS] }, + required: { field: 'operation', value: [...PROJECT_PARAM_OPERATIONS] }, }, // Issue selector (basic mode) { @@ -234,52 +276,8 @@ export const JiraBlock: BlockConfig = { selectorKey: 'jira.issues', placeholder: 'Select Jira issue', dependsOn: ['credential', 'domain', 'projectId'], - condition: { - field: 'operation', - value: [ - 'read', - 'update', - 'delete', - 'assign', - 'transition', - 'add_comment', - 'get_comments', - 'update_comment', - 'delete_comment', - 'get_attachments', - 'add_attachment', - 'add_worklog', - 'get_worklogs', - 'update_worklog', - 'delete_worklog', - 'add_watcher', - 'remove_watcher', - 'get_transitions', - ], - }, - required: { - field: 'operation', - value: [ - 'read', - 'update', - 'delete', - 'assign', - 'transition', - 'add_comment', - 'get_comments', - 'update_comment', - 'delete_comment', - 'get_attachments', - 'add_attachment', - 'add_worklog', - 'get_worklogs', - 'update_worklog', - 'delete_worklog', - 'add_watcher', - 'remove_watcher', - 'get_transitions', - ], - }, + condition: { field: 'operation', value: [...ISSUE_KEY_OPERATIONS] }, + required: { field: 'operation', value: [...ISSUE_KEY_OPERATIONS] }, mode: 'basic', }, // Manual issue key input (advanced mode) @@ -290,52 +288,8 @@ export const JiraBlock: BlockConfig = { canonicalParamId: 'issueKey', placeholder: 'Enter Jira issue key', dependsOn: ['credential', 'domain', 'projectId'], - condition: { - field: 'operation', - value: [ - 'read', - 'update', - 'delete', - 'assign', - 'transition', - 'add_comment', - 'get_comments', - 'update_comment', - 'delete_comment', - 'get_attachments', - 'add_attachment', - 'add_worklog', - 'get_worklogs', - 'update_worklog', - 'delete_worklog', - 'add_watcher', - 'remove_watcher', - 'get_transitions', - ], - }, - required: { - field: 'operation', - value: [ - 'read', - 'update', - 'delete', - 'assign', - 'transition', - 'add_comment', - 'get_comments', - 'update_comment', - 'delete_comment', - 'get_attachments', - 'add_attachment', - 'add_worklog', - 'get_worklogs', - 'update_worklog', - 'delete_worklog', - 'add_watcher', - 'remove_watcher', - 'get_transitions', - ], - }, + condition: { field: 'operation', value: [...ISSUE_KEY_OPERATIONS] }, + required: { field: 'operation', value: [...ISSUE_KEY_OPERATIONS] }, mode: 'advanced', }, { diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index df98e42145b..c7027a88df5 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -10988,11 +10988,11 @@ "docsUrl": "https://docs.sim.ai/integrations/jira", "operations": [ { - "name": "Read Issue", + "name": "Get Issue", "description": "Retrieve detailed information about a specific Jira issue" }, { - "name": "Read Bulk Issues", + "name": "Get Bulk Issues", "description": "Retrieve multiple Jira issues from a project in bulk" }, {