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
co-authored by alejandrobailo
parent 6ac90eb1b5
commit ab8e83da3f
14 changed files with 360 additions and 25 deletions
+1
View File
@@ -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
+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)
@@ -79,6 +79,18 @@ Each Jira integration provides management actions through dedicated buttons:
| **Enable/Disable** | Toggle integration status | • Enable or disable integration<br/>| Status change takes effect immediately |
| **Delete** | Remove integration permanently | • Permanently delete integration<br/>• 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.
<Note>
Support for custom field mapping is planned for a future release.
</Note>
## Troubleshooting
### Connection test fails
+1
View File
@@ -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
+16 -1
View File
@@ -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}"
+24 -2
View File
@@ -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,
+1
View File
@@ -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)
---
+38 -3
View File
@@ -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,
},
},
};
+124 -7
View File
@@ -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<typeof sendToJiraSchema>;
// 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<IntegrationProps[]>([]);
const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(false);
const [fetchedIssueTypes, setFetchedIssueTypes] = useState<
Record<string, string[]>
>({});
const [isFetchingIssueTypes, setIsFetchingIssueTypes] = useState(false);
const form = useForm<SendToJiraFormData>({
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<string, string[]> | 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 && (
<FormField
control={form.control}
name="issueType"
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-issue-type-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Issue Type
</label>
<EnhancedMultiSelect
id="jira-issue-type-select"
options={issueTypeOptions}
onValueChange={(values) => {
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}
/>
<FormMessage className="text-text-error text-xs" />
</div>
)}
/>
)}
{/* 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
}
+1 -1
View File
@@ -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;