diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index e143d7886f..7855d26390 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to the **Prowler API** are documented in this file. - Finding groups `check_title__icontains` resolution, `name__icontains` resource filter and `resource_group` field in `/resources` response [(#10486)](https://github.com/prowler-cloud/prowler/pull/10486) - Membership `post_delete` signal using raw FK ids to avoid `DoesNotExist` during cascade deletions [(#10497)](https://github.com/prowler-cloud/prowler/pull/10497) - Finding group resources endpoints returning false 404 when filters match no results, and `sort` parameter being ignored [(#10510)](https://github.com/prowler-cloud/prowler/pull/10510) +- Jira integration failing with `JiraInvalidIssueTypeError` on non-English Jira instances due to hardcoded `"Task"` issue type; now dynamically fetches available issue types per project [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534) ### 🔐 Security diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 485d84ce6b..86da5567da 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -806,11 +806,15 @@ class TestProwlerIntegrationConnectionTest: } integration.configuration = {} - # Mock successful JIRA connection with projects + # Mock successful JIRA connection with projects and issue types mock_connection = MagicMock() mock_connection.is_connected = True mock_connection.error = None mock_connection.projects = {"PROJ1": "Project 1", "PROJ2": "Project 2"} + mock_connection.issue_types = { + "PROJ1": ["Task", "Bug"], + "PROJ2": ["Task", "Story"], + } mock_jira_class.test_connection.return_value = mock_connection # Mock rls_transaction context manager @@ -839,6 +843,12 @@ class TestProwlerIntegrationConnectionTest: "PROJ2": "Project 2", } + # Verify issue types were saved to integration configuration + assert integration.configuration["issue_types"] == { + "PROJ1": ["Task", "Bug"], + "PROJ2": ["Task", "Story"], + } + # Verify integration.save() was called integration.save.assert_called_once() @@ -862,6 +872,7 @@ class TestProwlerIntegrationConnectionTest: mock_connection.is_connected = False mock_connection.error = Exception("Authentication failed: Invalid credentials") mock_connection.projects = {} # Empty projects when connection fails + mock_connection.issue_types = {} # Empty issue types when connection fails mock_jira_class.test_connection.return_value = mock_connection # Mock rls_transaction context manager @@ -887,6 +898,9 @@ class TestProwlerIntegrationConnectionTest: # Verify empty projects dict was saved to integration configuration assert integration.configuration["projects"] == {} + # Verify empty issue types dict was saved to integration configuration + assert integration.configuration["issue_types"] == {} + # Verify integration.save() was called even on connection failure integration.save.assert_called_once() @@ -905,11 +919,11 @@ class TestProwlerIntegrationConnectionTest: "domain": "example.atlassian.net", } integration.configuration = { - "issue_types": ["Task"], # Existing configuration + "issue_types": {"OLD_PROJ": ["Task"]}, # Existing configuration "projects": {"OLD_PROJ": "Old Project"}, # Will be overwritten } - # Mock successful JIRA connection with new projects + # Mock successful JIRA connection with new projects and issue types mock_connection = MagicMock() mock_connection.is_connected = True mock_connection.error = None @@ -917,6 +931,10 @@ class TestProwlerIntegrationConnectionTest: "NEW_PROJ1": "New Project 1", "NEW_PROJ2": "New Project 2", } + mock_connection.issue_types = { + "NEW_PROJ1": ["Task", "Bug"], + "NEW_PROJ2": ["Story"], + } mock_jira_class.test_connection.return_value = mock_connection # Mock rls_transaction context manager @@ -934,8 +952,11 @@ class TestProwlerIntegrationConnectionTest: "NEW_PROJ2": "New Project 2", } - # Verify other configuration fields were preserved - assert integration.configuration["issue_types"] == ["Task"] + # Verify issue types were also updated + assert integration.configuration["issue_types"] == { + "NEW_PROJ1": ["Task", "Bug"], + "NEW_PROJ2": ["Story"], + } # Verify integration.save() was called integration.save.assert_called_once() diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index 8501c5409e..b80d54b08a 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -434,8 +434,12 @@ def prowler_integration_connection_test(integration: Integration) -> Connection: raise_on_exception=False, ) project_keys = jira_connection.projects if jira_connection.is_connected else {} + issue_types = ( + jira_connection.issue_types if jira_connection.is_connected else {} + ) with rls_transaction(str(integration.tenant_id)): integration.configuration["projects"] = project_keys + integration.configuration["issue_types"] = issue_types integration.save() return jira_connection elif integration.integration_type == Integration.IntegrationChoices.SLACK: diff --git a/api/src/backend/api/v1/serializer_utils/integrations.py b/api/src/backend/api/v1/serializer_utils/integrations.py index b389085886..aaa0f4aa31 100644 --- a/api/src/backend/api/v1/serializer_utils/integrations.py +++ b/api/src/backend/api/v1/serializer_utils/integrations.py @@ -69,8 +69,10 @@ class SecurityHubConfigSerializer(BaseValidateSerializer): class JiraConfigSerializer(BaseValidateSerializer): domain = serializers.CharField(read_only=True) - issue_types = serializers.ListField( - read_only=True, child=serializers.CharField(), default=["Task"] + issue_types = serializers.DictField( + read_only=True, + child=serializers.ListField(child=serializers.CharField()), + default={}, ) projects = serializers.DictField(read_only=True) diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 2870ad3d7e..185a8e047a 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -2722,11 +2722,11 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer): ) config_serializer = JiraConfigSerializer # Create non-editable configuration for JIRA integration - default_jira_issue_types = ["Task"] + # issue_types will be populated per project when connection is tested configuration.update( { "projects": {}, - "issue_types": default_jira_issue_types, + "issue_types": {}, "domain": credentials.get("domain"), } ) @@ -2941,13 +2941,25 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer): return representation +class IntegrationJiraIssueTypesSerializer(BaseSerializerV1): + """ + Serializer for Jira issue types response. + """ + + project_key = serializers.CharField(read_only=True) + issue_types = serializers.ListField(child=serializers.CharField(), read_only=True) + + class JSONAPIMeta: + resource_name = "jira-issue-types" + + class IntegrationJiraDispatchSerializer(BaseSerializerV1): """ Serializer for dispatching findings to JIRA integration. """ project_key = serializers.CharField(required=True) - issue_type = serializers.ChoiceField(required=True, choices=["Task"]) + issue_type = serializers.CharField(required=True) class JSONAPIMeta: resource_name = "integrations-jira-dispatches" @@ -2976,6 +2988,23 @@ class IntegrationJiraDispatchSerializer(BaseSerializerV1): } ) + issue_type = attrs.get("issue_type") + available_issue_types = integration_instance.configuration.get( + "issue_types", {} + ) + # Handle old format where issue_types was a flat list (e.g., ["Task"]) + if not isinstance(available_issue_types, dict): + available_issue_types = {} + project_issue_types = available_issue_types.get(project_key, []) + if project_issue_types and issue_type not in project_issue_types: + raise ValidationError( + { + "issue_type": f"The issue type '{issue_type}' is not available for project '{project_key}'. " + f"Available types: {', '.join(project_issue_types)}. " + "Refresh the connection if this is an error." + } + ) + return validated_attrs diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index fc5b1a9245..68ba0abfab 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -207,6 +207,7 @@ from api.rls import Tenant from api.utils import ( CustomOAuth2Client, get_findings_metadata_no_aggregations, + initialize_prowler_integration, initialize_prowler_provider, validate_invitation, ) @@ -235,6 +236,7 @@ from api.v1.serializers import ( FindingsSeverityOverTimeSerializer, IntegrationCreateSerializer, IntegrationJiraDispatchSerializer, + IntegrationJiraIssueTypesSerializer, IntegrationSerializer, IntegrationUpdateSerializer, InvitationAcceptSerializer, @@ -6132,7 +6134,7 @@ class IntegrationViewSet(BaseRLSViewSet): class IntegrationJiraViewSet(BaseRLSViewSet): queryset = Finding.all_objects.all() serializer_class = IntegrationJiraDispatchSerializer - http_method_names = ["post"] + http_method_names = ["get", "post"] filter_backends = [CustomDjangoFilterBackend] filterset_class = IntegrationJiraFindingsFilter # RBAC required permissions @@ -6142,6 +6144,20 @@ class IntegrationJiraViewSet(BaseRLSViewSet): def create(self, request, *args, **kwargs): raise MethodNotAllowed(method="POST") + @extend_schema(exclude=True) + def list(self, request, *args, **kwargs): + raise MethodNotAllowed(method="GET") + + def get_serializer_class(self): + if self.action == "issue_types": + return IntegrationJiraIssueTypesSerializer + return super().get_serializer_class() + + def get_filter_backends(self): + if self.action == "issue_types": + return [] + return super().get_filter_backends() + def get_queryset(self): tenant_id = self.request.tenant_id user_roles = get_role(self.request.user) @@ -6156,6 +6172,65 @@ class IntegrationJiraViewSet(BaseRLSViewSet): return queryset + @extend_schema( + tags=["Integration"], + summary="Get available issue types for a Jira project", + description="Fetch the available issue types from Jira for a given project key and update the integration configuration.", + parameters=[ + OpenApiParameter( + name="project_key", + type=str, + location=OpenApiParameter.QUERY, + required=True, + description="The Jira project key to fetch issue types for.", + ), + ], + ) + @action(detail=False, methods=["get"], url_name="issue-types") + def issue_types(self, request, integration_pk=None): + integration = get_object_or_404(Integration, pk=integration_pk) + + project_key = request.query_params.get("project_key") + if not project_key: + raise ValidationError({"project_key": "This query parameter is required."}) + + projects = integration.configuration.get("projects", {}) + if project_key not in projects: + raise ValidationError( + { + "project_key": "The given project key is not available for this JIRA integration." + } + ) + + try: + jira = initialize_prowler_integration(integration) + fetched_issue_types = jira.get_available_issue_types(project_key) + except Exception as e: + logger.error( + f"Failed to fetch issue types from Jira for integration {integration_pk}, " + f"project {project_key}: {e}" + ) + raise ValidationError( + { + "issue_types": "Failed to fetch issue types from Jira. Please check the integration connection." + } + ) + + # Update the integration configuration with the fetched issue types + issue_types_config = integration.configuration.get("issue_types", {}) + if not isinstance(issue_types_config, dict): + issue_types_config = {} + issue_types_config[project_key] = fetched_issue_types + + with rls_transaction(str(integration.tenant_id), using="default"): + integration.configuration["issue_types"] = issue_types_config + integration.save(using="default") + + serializer = IntegrationJiraIssueTypesSerializer( + {"project_key": project_key, "issue_types": fetched_issue_types} + ) + return Response(data=serializer.data, status=status.HTTP_200_OK) + @action(detail=False, methods=["post"], url_name="dispatches") def dispatches(self, request, integration_pk=None): get_object_or_404(Integration, pk=integration_pk) diff --git a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx index 19a31347c9..01779a6a96 100644 --- a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx +++ b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx @@ -79,6 +79,18 @@ Each Jira integration provides management actions through dedicated buttons: | **Enable/Disable** | Toggle integration status | • Enable or disable integration
| Status change takes effect immediately | | **Delete** | Remove integration permanently | • Permanently delete integration
• Remove all configuration data | ⚠️ **Cannot be undone** - confirm before deleting | +## Known Limitations + +### Issue Types with Required Custom Fields + +Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not currently populate when creating work items. If a selected issue type enforces required fields beyond the standard set (e.g., "Team", "Epic Name"), the work item creation will fail. + +To avoid this, select an issue type that does not require additional custom fields — **Task**, **Bug**, or **Story** typically work without restrictions. If unsure which issue types are available for a project, Prowler automatically fetches and displays them in the "Issue Type" selector when sending a finding. + + +Support for custom field mapping is planned for a future release. + + ## Troubleshooting ### Connection test fails diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 99765e9fce..38a0ac0f34 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🐞 Fixed - `return` statements in `finally` blocks replaced across IAM, Organizations, GCP provider, and custom checks metadata to stop silently swallowing exceptions [(#10102)](https://github.com/prowler-cloud/prowler/pull/10102) +- `JiraConnection` now includes issue types per project fetched during `test_connection`, fixing `JiraInvalidIssueTypeError` on non-English Jira instances [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534) ### 🔐 Security diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 5b21b89153..ed8f7faab0 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -44,9 +44,11 @@ class JiraConnection(Connection): Represents a Jira connection object. Attributes: projects (dict): Dictionary of projects in Jira. + issue_types (dict): Dictionary of issue types per project key. """ projects: dict = None + issue_types: dict = None class MarkdownToADFConverter: @@ -781,7 +783,20 @@ class Jira: ) projects = jira.get_projects() - return JiraConnection(is_connected=True, projects=projects) + issue_types = {} + for project_key in projects: + try: + issue_types[project_key] = jira.get_available_issue_types( + project_key + ) + except Exception as e: + logger.warning( + f"Failed to get issue types for project {project_key}: {e}" + ) + + return JiraConnection( + is_connected=True, projects=projects, issue_types=issue_types + ) except JiraNoProjectsError as no_projects_error: logger.error( f"{no_projects_error.__class__.__name__}[{no_projects_error.__traceback__.tb_lineno}]: {no_projects_error}" diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 5e93d5c7d0..03656891c8 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -345,11 +345,19 @@ class TestJiraIntegration: "get_projects", return_value={"PROJ1": "Project One", "PROJ2": "Project Two"}, ) - def test_test_connection_successful(self, mock_get_projects, mock_get_auth): + @patch.object( + Jira, + "get_available_issue_types", + side_effect=lambda pk: ["Task", "Bug"] if pk == "PROJ1" else ["Story"], + ) + def test_test_connection_successful( + self, mock_get_issue_types, mock_get_projects, mock_get_auth + ): """Test that a successful connection returns an active Connection object with projects.""" # To disable vulture mock_get_projects = mock_get_projects mock_get_auth = mock_get_auth + mock_get_issue_types = mock_get_issue_types connection = Jira.test_connection( redirect_uri=self.redirect_uri, @@ -360,6 +368,10 @@ class TestJiraIntegration: assert connection.is_connected assert connection.error is None assert connection.projects == {"PROJ1": "Project One", "PROJ2": "Project Two"} + assert connection.issue_types == { + "PROJ1": ["Task", "Bug"], + "PROJ2": ["Story"], + } @patch.object(Jira, "get_basic_auth", return_value=None) @patch.object( @@ -367,13 +379,19 @@ class TestJiraIntegration: "get_projects", return_value={"PROJ1": "Project One", "PROJ2": "Project Two"}, ) + @patch.object( + Jira, + "get_available_issue_types", + side_effect=lambda pk: ["Task", "Bug"] if pk == "PROJ1" else ["Story"], + ) def test_test_connection_successful_basic_auth( - self, mock_get_projects, mock_get_basic_auth + self, mock_get_issue_types, mock_get_projects, mock_get_basic_auth ): """Test that a successful connection returns an active Connection object with projects.""" # To disable vulture mock_get_projects = mock_get_projects mock_get_basic_auth = mock_get_basic_auth + mock_get_issue_types = mock_get_issue_types connection = Jira.test_connection( user_mail=self.user_mail, @@ -384,6 +402,10 @@ class TestJiraIntegration: assert connection.is_connected assert connection.error is None assert connection.projects == {"PROJ1": "Project One", "PROJ2": "Project Two"} + assert connection.issue_types == { + "PROJ1": ["Task", "Bug"], + "PROJ2": ["Story"], + } @patch.object( Jira, diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index e2acccc0ce..62764e6b2a 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 Fixed - Clear Filters now resets all filters including muted findings and auto-applies, Clear all in pills only removes pill-visible sub-filters, and the discard icon is now an Undo text button [(#10446)](https://github.com/prowler-cloud/prowler/pull/10446) +- Send to Jira modal now dynamically fetches and displays available issue types per project instead of hardcoding `"Task"`, fixing failures on non-English Jira instances [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534) --- diff --git a/ui/actions/integrations/jira-dispatch.ts b/ui/actions/integrations/jira-dispatch.ts index 715e70c2b5..72bfc1176d 100644 --- a/ui/actions/integrations/jira-dispatch.ts +++ b/ui/actions/integrations/jira-dispatch.ts @@ -9,6 +9,42 @@ import type { JiraDispatchResponse, } from "@/types/integrations"; +export const getJiraIssueTypes = async ( + integrationId: string, + projectKey: string, +): Promise< + { success: true; issueTypes: string[] } | { success: false; error: string } +> => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL( + `${apiBaseUrl}/integrations/${integrationId}/jira/issue_types`, + ); + url.searchParams.append("project_key", projectKey); + + try { + const response = await fetch(url.toString(), { method: "GET", headers }); + + if (response.ok) { + const data: { + data: { type: string; attributes: { issue_types: string[] } }; + } = await response.json(); + return { + success: true, + issueTypes: data.data?.attributes?.issue_types ?? [], + }; + } + + const errorData: unknown = await response.json().catch(() => ({})); + const errorMessage = + (errorData as { errors?: { detail?: string }[] }).errors?.[0]?.detail || + `Unable to fetch issue types: ${response.statusText}`; + return { success: false, error: errorMessage }; + } catch (error) { + const errorResult = handleApiError(error); + return { success: false, error: errorResult.error || "An error occurred" }; + } +}; + export const getJiraIntegrations = async (): Promise< | { success: true; data: IntegrationProps[] } | { success: false; error: string } @@ -47,7 +83,7 @@ export const sendFindingToJira = async ( integrationId: string, findingId: string, projectKey: string, - _issueType: string, + issueType: string, ): Promise< | { success: true; taskId: string; message: string } | { success: false; error: string } @@ -65,8 +101,7 @@ export const sendFindingToJira = async ( type: "integrations-jira-dispatches", attributes: { project_key: projectKey, - // Temporarily hardcode to "Task" regardless of the provided value - issue_type: "Task", + issue_type: issueType, }, }, }; diff --git a/ui/components/findings/send-to-jira-modal.tsx b/ui/components/findings/send-to-jira-modal.tsx index d5ee2d36fc..042b8869c8 100644 --- a/ui/components/findings/send-to-jira-modal.tsx +++ b/ui/components/findings/send-to-jira-modal.tsx @@ -8,6 +8,7 @@ import { z } from "zod"; import { getJiraIntegrations, + getJiraIssueTypes, pollJiraDispatchTask, sendFindingToJira, } from "@/actions/integrations/jira-dispatch"; @@ -34,7 +35,6 @@ const sendToJiraSchema = z.object({ type SendToJiraFormData = z.infer; -// The commented code is related to issue types, which are not required for the first implementation, but will be used in the future export const SendToJiraModal = ({ isOpen, onOpenChange, @@ -44,14 +44,17 @@ export const SendToJiraModal = ({ const { toast } = useToast(); const [integrations, setIntegrations] = useState([]); const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(false); + const [fetchedIssueTypes, setFetchedIssueTypes] = useState< + Record + >({}); + const [isFetchingIssueTypes, setIsFetchingIssueTypes] = useState(false); const form = useForm({ resolver: zodResolver(sendToJiraSchema), defaultValues: { integration: "", project: "", - // Default to Task while issue types are not fetched/required - issueType: "Task", + issueType: "", }, }); @@ -101,8 +104,9 @@ export const SendToJiraModal = ({ fetchJiraIntegrations(); } else { - // Reset form when modal closes + // Reset form and fetched data when modal closes form.reset(); + setFetchedIssueTypes({}); } }, [isOpen, form, toast]); @@ -150,6 +154,8 @@ export const SendToJiraModal = ({ })(); }; + const selectedProject = form.watch("project"); + const selectedIntegrationData = integrations.find( (i) => i.id === selectedIntegration, ); @@ -160,6 +166,75 @@ export const SendToJiraModal = ({ const projectEntries = Object.entries(projects); + // Get issue types from config (new dict format), falling back to fetched data + const configIssueTypes = selectedIntegrationData?.attributes.configuration + .issue_types as Record | undefined; + const issueTypesFromConfig = + configIssueTypes && + typeof configIssueTypes === "object" && + !Array.isArray(configIssueTypes) + ? (configIssueTypes[selectedProject] ?? []) + : []; + const issueTypesForProject = + issueTypesFromConfig.length > 0 + ? issueTypesFromConfig + : (fetchedIssueTypes[selectedProject] ?? []); + + // Fetch issue types from API when project is selected but no types are available + useEffect(() => { + let ignore = false; + + if ( + selectedIntegration && + selectedProject && + issueTypesFromConfig.length === 0 && + !fetchedIssueTypes[selectedProject] + ) { + const fetchIssueTypes = async () => { + setIsFetchingIssueTypes(true); + try { + const result = await getJiraIssueTypes( + selectedIntegration, + selectedProject, + ); + if (ignore) return; + if (result.success) { + setFetchedIssueTypes((prev) => ({ + ...prev, + [selectedProject]: result.issueTypes, + })); + } else { + toast({ + variant: "destructive", + title: "Failed to load issue types", + description: + result.error || "Unable to fetch issue types for this project", + }); + } + } finally { + if (!ignore) setIsFetchingIssueTypes(false); + } + }; + + fetchIssueTypes(); + } + + return () => { + ignore = true; + }; + }, [ + selectedIntegration, + selectedProject, + issueTypesFromConfig.length, + fetchedIssueTypes, + toast, + ]); + + const issueTypeOptions = issueTypesForProject.map((type) => ({ + value: type, + label: type, + })); + const integrationOptions = integrations.map((integration) => ({ value: integration.id, label: integration.attributes.configuration.domain || integration.id, @@ -207,7 +282,8 @@ export const SendToJiraModal = ({ field.onChange(selectedValue); // Reset dependent fields form.setValue("project", ""); - form.setValue("issueType", "Task"); + form.setValue("issueType", ""); + setFetchedIssueTypes({}); }} defaultValue={field.value ? [field.value] : []} placeholder="Select a Jira integration" @@ -244,8 +320,8 @@ export const SendToJiraModal = ({ onValueChange={(values) => { const selectedValue = values.at(-1) ?? ""; field.onChange(selectedValue); - // Keep issue type defaulting to Task when project changes - form.setValue("issueType", "Task"); + // Reset issue type when project changes + form.setValue("issueType", ""); }} defaultValue={field.value ? [field.value] : []} placeholder="Select a Jira project" @@ -262,6 +338,46 @@ export const SendToJiraModal = ({ /> )} + {/* Issue Type Selection */} + {selectedProject && ( + ( +
+ + { + const selectedValue = values.at(-1) ?? ""; + field.onChange(selectedValue); + }} + defaultValue={field.value ? [field.value] : []} + placeholder={ + isFetchingIssueTypes + ? "Loading issue types..." + : "Select an issue type" + } + searchable={true} + emptyIndicator="No issue types found." + disabled={isFetchingIssueTypes} + hideSelectAll={true} + maxCount={1} + closeOnSelect={true} + resetOnDefaultValueChange={true} + /> + +
+ )} + /> + )} + {/* No integrations or none connected message */} {!isFetchingIntegrations && (integrations.length === 0 || !hasConnectedIntegration) ? ( @@ -282,6 +398,7 @@ export const SendToJiraModal = ({ !form.formState.isValid || form.formState.isSubmitting || isFetchingIntegrations || + isFetchingIssueTypes || integrations.length === 0 || !hasConnectedIntegration } diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts index 4f579847e3..63b1e37bff 100644 --- a/ui/types/integrations.ts +++ b/ui/types/integrations.ts @@ -29,7 +29,7 @@ export interface IntegrationProps { // Jira specific configuration domain?: string; projects?: { [key: string]: string }; - issue_types?: string[]; + issue_types?: { [key: string]: string[] }; [key: string]: unknown; }; url?: string;