mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
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:
co-authored by
alejandrobailo
parent
6ac90eb1b5
commit
ab8e83da3f
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user