fix(api,ui): dynamically fetch Jira issue types instead of hardcoding "Task" (#10534)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Adrián Peña
2026-04-01 14:37:49 +02:00
committed by GitHub
parent 6ac90eb1b5
commit ab8e83da3f
14 changed files with 360 additions and 25 deletions
+26 -5
View File
@@ -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()
+4
View File
@@ -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:
@@ -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)
+32 -3
View File
@@ -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
+76 -1
View File
@@ -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)