diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index 643ec268..80c324a2 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -294,4 +294,31 @@ export const integrationsDiscoveryRouter = router({ ), ); }), + + createJiraCustomField: protectedProcedure + .input( + jiraCredsInput.extend({ + name: z.string().min(1).max(100), + }), + ) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.createJiraCustomField called', { + orgId: ctx.effectiveOrgId, + name: input.name, + }); + return withResolvedJiraCreds( + input, + ctx.effectiveOrgId, + 'Failed to create JIRA custom field', + (creds) => + withJiraCredentials(creds, () => + jiraClient.createCustomField( + input.name, + 'com.atlassian.jira.plugin.system.customfieldtypes:float', + // exactnumber searcher enables JQL queries like `"Cost" > 100` + 'com.atlassian.jira.plugin.system.customfieldtypes:exactnumber', + ), + ), + ); + }), }); diff --git a/src/jira/client.ts b/src/jira/client.ts index 007cdff0..94c2c57a 100644 --- a/src/jira/client.ts +++ b/src/jira/client.ts @@ -312,4 +312,40 @@ export const jiraClient = { }, }); }, + + async createCustomField( + name: string, + type: string, + searcherKey?: string, + ): Promise<{ id: string; name: string }> { + logger.debug('Creating JIRA custom field', { name, type, searcherKey }); + try { + const result = await getClient().issueFields.createCustomField({ + name, + type, + // searcherKey enables JQL searchability for this field (e.g. `"Cost" > 100`). + // For float fields, 'com.atlassian.jira.plugin.system.customfieldtypes:exactnumber' + // enables exact-value JQL queries while 'numberrange' enables range queries. + // Omitting searcherKey creates a non-searchable field. + ...(searcherKey ? { searcherKey } : {}), + }); + return { + id: (result as { id?: string }).id ?? '', + name: (result as { name?: string }).name ?? '', + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const detail = + error instanceof Object && 'response' in error + ? (error as { response?: { data?: unknown } }).response?.data + : undefined; + const detailStr = detail ? ` — JIRA response: ${JSON.stringify(detail)}` : ''; + + logger.error('JIRA createCustomField failed', { name, type, detail }); + + throw new Error( + `JIRA createCustomField failed (admin permissions may be required): ${message}${detailStr}`, + ); + } + }, }; diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index dbdad52d..40880c02 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -49,6 +49,7 @@ const mockJiraSearchProjects = vi.fn(); const mockJiraGetProjectStatuses = vi.fn(); const mockJiraGetIssueTypesForProject = vi.fn(); const mockJiraGetFields = vi.fn(); +const mockJiraCreateCustomField = vi.fn(); vi.mock('../../../../src/jira/client.js', () => ({ withJiraCredentials: (...args: unknown[]) => { @@ -61,6 +62,7 @@ vi.mock('../../../../src/jira/client.js', () => ({ getProjectStatuses: (...args: unknown[]) => mockJiraGetProjectStatuses(...args), getIssueTypesForProject: (...args: unknown[]) => mockJiraGetIssueTypesForProject(...args), getFields: (...args: unknown[]) => mockJiraGetFields(...args), + createCustomField: (...args: unknown[]) => mockJiraCreateCustomField(...args), }, })); @@ -618,4 +620,93 @@ describe('integrationsDiscoveryRouter', () => { ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); }); }); + + // ── createJiraCustomField ──────────────────────────────────────────── + + describe('createJiraCustomField', () => { + it('returns id and name on success', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'api-token' }, + ]); + mockJiraCreateCustomField.mockResolvedValue({ + id: 'customfield_10001', + name: 'Cost', + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.createJiraCustomField({ + ...jiraCredsInput, + name: 'Cost', + }); + + expect(result).toEqual({ + id: 'customfield_10001', + name: 'Cost', + }); + expect(mockJiraCreateCustomField).toHaveBeenCalledWith( + 'Cost', + 'com.atlassian.jira.plugin.system.customfieldtypes:float', + 'com.atlassian.jira.plugin.system.customfieldtypes:exactnumber', + ); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect( + caller.createJiraCustomField({ + ...jiraCredsInput, + name: 'Cost', + }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('throws NOT_FOUND when credential does not exist', async () => { + mockDbWhere.mockResolvedValueOnce([]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await expect( + caller.createJiraCustomField({ + ...jiraCredsInput, + name: 'Cost', + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('validates name min length of 1', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.createJiraCustomField({ + ...jiraCredsInput, + name: '', + }), + ).rejects.toThrow(); + }); + + it('validates name max length of 100', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.createJiraCustomField({ + ...jiraCredsInput, + name: 'a'.repeat(101), + }), + ).rejects.toThrow(); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'api-token' }, + ]); + mockJiraCreateCustomField.mockRejectedValue(new Error('Admin permission required')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.createJiraCustomField({ + ...jiraCredsInput, + name: 'Cost', + }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + }); }); diff --git a/tests/unit/jira/client.test.ts b/tests/unit/jira/client.test.ts index 88fe2dd9..5e8f9511 100644 --- a/tests/unit/jira/client.test.ts +++ b/tests/unit/jira/client.test.ts @@ -52,6 +52,7 @@ const { }, mockIssueFields: { getFields: vi.fn(), + createCustomField: vi.fn(), }, })); @@ -102,6 +103,7 @@ describe('jiraClient', () => { mockProjects.searchProjects.mockReset(); mockProjects.getAllStatuses.mockReset(); mockIssueFields.getFields.mockReset(); + mockIssueFields.createCustomField.mockReset(); _resetCloudIdCache(); }); @@ -802,4 +804,140 @@ describe('jiraClient', () => { expect(captured).toEqual(creds); }); }); + + describe('createCustomField', () => { + it('calls createCustomField with name and type, returns id and name', async () => { + mockIssueFields.createCustomField.mockResolvedValue({ + id: 'customfield_10001', + name: 'Cost', + }); + + const result = await withJiraCredentials(creds, () => + jiraClient.createCustomField( + 'Cost', + 'com.atlassian.jira.plugin.system.customfieldtypes:float', + ), + ); + + expect(result).toEqual({ + id: 'customfield_10001', + name: 'Cost', + }); + expect(mockIssueFields.createCustomField).toHaveBeenCalledWith({ + name: 'Cost', + type: 'com.atlassian.jira.plugin.system.customfieldtypes:float', + }); + }); + + it('passes searcherKey when provided, enabling JQL searchability', async () => { + mockIssueFields.createCustomField.mockResolvedValue({ + id: 'customfield_10002', + name: 'Budget', + }); + + const result = await withJiraCredentials(creds, () => + jiraClient.createCustomField( + 'Budget', + 'com.atlassian.jira.plugin.system.customfieldtypes:float', + 'com.atlassian.jira.plugin.system.customfieldtypes:exactnumber', + ), + ); + + expect(result).toEqual({ + id: 'customfield_10002', + name: 'Budget', + }); + expect(mockIssueFields.createCustomField).toHaveBeenCalledWith({ + name: 'Budget', + type: 'com.atlassian.jira.plugin.system.customfieldtypes:float', + searcherKey: 'com.atlassian.jira.plugin.system.customfieldtypes:exactnumber', + }); + }); + + it('returns empty id and name when response fields are missing', async () => { + mockIssueFields.createCustomField.mockResolvedValue({}); + + const result = await withJiraCredentials(creds, () => + jiraClient.createCustomField( + 'Cost', + 'com.atlassian.jira.plugin.system.customfieldtypes:float', + ), + ); + + expect(result).toEqual({ + id: '', + name: '', + }); + }); + + it('throws enriched error with admin permission message on API failure', async () => { + const apiError = Object.assign(new Error('Forbidden'), { + response: { + data: { + errorMessages: ['Only administrators can create custom fields'], + }, + }, + }); + mockIssueFields.createCustomField.mockRejectedValue(apiError); + + const { logger } = await import('../../../src/utils/logging.js'); + + await expect( + withJiraCredentials(creds, () => + jiraClient.createCustomField( + 'Cost', + 'com.atlassian.jira.plugin.system.customfieldtypes:float', + ), + ), + ).rejects.toThrow( + /JIRA createCustomField failed \(admin permissions may be required\): Forbidden.*Only administrators/, + ); + + expect(logger.error).toHaveBeenCalledWith( + 'JIRA createCustomField failed', + expect.objectContaining({ + name: 'Cost', + type: 'com.atlassian.jira.plugin.system.customfieldtypes:float', + detail: expect.objectContaining({ + errorMessages: ['Only administrators can create custom fields'], + }), + }), + ); + }); + + it('throws enriched error without detail when error has no response', async () => { + mockIssueFields.createCustomField.mockRejectedValue(new Error('Network error')); + + const { logger } = await import('../../../src/utils/logging.js'); + + await expect( + withJiraCredentials(creds, () => + jiraClient.createCustomField( + 'Cost', + 'com.atlassian.jira.plugin.system.customfieldtypes:float', + ), + ), + ).rejects.toThrow( + 'JIRA createCustomField failed (admin permissions may be required): Network error', + ); + + expect(logger.error).toHaveBeenCalledWith( + 'JIRA createCustomField failed', + expect.objectContaining({ + name: 'Cost', + type: 'com.atlassian.jira.plugin.system.customfieldtypes:float', + detail: undefined, + }), + ); + }); + + it('throws when called outside withJiraCredentials scope', async () => { + await expect( + jiraClient.createCustomField( + 'Cost', + 'com.atlassian.jira.plugin.system.customfieldtypes:float', + ), + ).rejects.toThrow('No JIRA credentials in scope'); + }); + }); });