diff --git a/api/changelog.d/jira-dispatch-errors.fixed.md b/api/changelog.d/jira-dispatch-errors.fixed.md new file mode 100644 index 0000000000..8aa080e8e9 --- /dev/null +++ b/api/changelog.d/jira-dispatch-errors.fixed.md @@ -0,0 +1 @@ +Jira dispatch task results now surface user-facing Jira failure messages diff --git a/api/src/backend/tasks/jobs/integrations.py b/api/src/backend/tasks/jobs/integrations.py index 25722686cc..c77ba22b7f 100644 --- a/api/src/backend/tasks/jobs/integrations.py +++ b/api/src/backend/tasks/jobs/integrations.py @@ -14,6 +14,7 @@ from prowler.lib.outputs.compliance.generic.generic import GenericCompliance from prowler.lib.outputs.csv.csv import CSV from prowler.lib.outputs.finding import Finding as FindingOutput from prowler.lib.outputs.html.html import HTML +from prowler.lib.outputs.jira.exceptions.exceptions import JiraBaseException from prowler.lib.outputs.ocsf.ocsf import OCSF from prowler.providers.aws.aws_provider import AwsProvider from prowler.providers.aws.lib.s3.s3 import S3 @@ -26,6 +27,8 @@ from tasks.utils import batched logger = get_task_logger(__name__) +JIRA_GENERIC_SEND_ERROR = "Failed to create Jira issue." + def get_s3_client_from_integration( integration: Integration, @@ -483,6 +486,7 @@ def send_findings_to_jira( jira_integration = initialize_prowler_integration(integration) num_tickets_created = 0 + error_messages = [] for finding_id in finding_ids: with rls_transaction(tenant_id): finding_instance = ( @@ -512,35 +516,54 @@ def send_findings_to_jira( recommendation = remediation.get("recommendation", {}) remediation_code = remediation.get("code", {}) - # Send the individual finding to Jira - result = jira_integration.send_finding( - check_id=finding_instance.check_id, - check_title=check_metadata.get("checktitle", ""), - severity=finding_instance.severity, - status=finding_instance.status, - status_extended=finding_instance.status_extended or "", - provider=finding_instance.scan.provider.provider, - region=region, - resource_uid=resource_uid, - resource_name=resource_name, - risk=check_metadata.get("risk", ""), - recommendation_text=recommendation.get("text", ""), - recommendation_url=recommendation.get("url", ""), - remediation_code_native_iac=remediation_code.get("nativeiac", ""), - remediation_code_terraform=remediation_code.get("terraform", ""), - remediation_code_cli=remediation_code.get("cli", ""), - remediation_code_other=remediation_code.get("other", ""), - resource_tags=resource_tags, - compliance=finding_instance.compliance or {}, - project_key=project_key, - issue_type=issue_type, - ) + try: + # Send the individual finding to Jira + result = jira_integration.send_finding( + check_id=finding_instance.check_id, + check_title=check_metadata.get("checktitle", ""), + severity=finding_instance.severity, + status=finding_instance.status, + status_extended=finding_instance.status_extended or "", + provider=finding_instance.scan.provider.provider, + region=region, + resource_uid=resource_uid, + resource_name=resource_name, + risk=check_metadata.get("risk", ""), + recommendation_text=recommendation.get("text", ""), + recommendation_url=recommendation.get("url", ""), + remediation_code_native_iac=remediation_code.get("nativeiac", ""), + remediation_code_terraform=remediation_code.get("terraform", ""), + remediation_code_cli=remediation_code.get("cli", ""), + remediation_code_other=remediation_code.get("other", ""), + resource_tags=resource_tags, + compliance=finding_instance.compliance or {}, + project_key=project_key, + issue_type=issue_type, + ) + except JiraBaseException as error: + error_message = error.message or JIRA_GENERIC_SEND_ERROR + logger.exception( + "Failed to send finding %s to Jira: %s", finding_id, error_message + ) + error_messages.append(error_message) + continue + except Exception: + logger.exception("Failed to send finding %s to Jira", finding_id) + error_messages.append(JIRA_GENERIC_SEND_ERROR) + continue + if result: num_tickets_created += 1 else: - logger.error(f"Failed to send finding {finding_id} to Jira") + error_message = JIRA_GENERIC_SEND_ERROR + logger.error(error_message) + error_messages.append(error_message) - return { + result = { "created_count": num_tickets_created, "failed_count": len(finding_ids) - num_tickets_created, } + if error_messages: + result["error"] = "; ".join(dict.fromkeys(error_messages)) + + return result diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py index 9cb727e8d0..bebb813feb 100644 --- a/api/src/backend/tasks/tests/test_integrations.py +++ b/api/src/backend/tasks/tests/test_integrations.py @@ -5,6 +5,10 @@ from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.models import Integration from api.utils import prowler_integration_connection_test from django.db import OperationalError +from prowler.lib.outputs.jira.exceptions.exceptions import ( + JiraRefreshTokenError, + JiraRequiredCustomFieldsError, +) from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection from prowler.providers.common.models import Connection from tasks.jobs.integrations import ( @@ -1830,10 +1834,213 @@ class TestJiraIntegration: ) # Assertions - assert result == {"created_count": 2, "failed_count": 1} + assert result == { + "created_count": 2, + "failed_count": 1, + "error": "Failed to create Jira issue.", + } # Verify error was logged for the failed finding - mock_logger.error.assert_called_with("Failed to send finding finding-2 to Jira") + mock_logger.error.assert_called_with("Failed to create Jira issue.") + + @patch("tasks.jobs.integrations.rls_transaction") + @patch("tasks.jobs.integrations.Finding") + @patch("tasks.jobs.integrations.Integration") + @patch("tasks.jobs.integrations.initialize_prowler_integration") + @patch("tasks.jobs.integrations.logger") + def test_send_findings_to_jira_preserves_exception_message( + self, + mock_logger, + mock_initialize_integration, + mock_integration_model, + mock_finding_model, + mock_rls_transaction, + ): + """Test Jira send exceptions are returned for UI polling.""" + tenant_id = "tenant-123" + integration_id = "integration-456" + project_key = "PROJ" + issue_type = "Task" + finding_ids = ["finding-1"] + error_message = "Jira project requires custom fields: Team is required" + + mock_rls_transaction.return_value.__enter__ = MagicMock() + mock_rls_transaction.return_value.__exit__ = MagicMock() + + integration = MagicMock() + mock_integration_model.objects.get.return_value = integration + + mock_jira_integration = MagicMock() + + mock_jira_integration.send_finding.side_effect = JiraRequiredCustomFieldsError( + message=error_message + ) + mock_initialize_integration.return_value = mock_jira_integration + + finding = MagicMock() + finding.id = "finding-1" + finding.check_id = "check_001" + finding.severity = "high" + finding.status = "FAIL" + finding.status_extended = "Resource is not compliant" + finding.compliance = {} + finding.resources.exists.return_value = False + finding.resources.first.return_value = None + finding.scan.provider.provider = "aws" + finding.check_metadata = { + "checktitle": "Check Title", + "risk": "High risk", + "remediation": {"recommendation": {}, "code": {}}, + } + mock_select_related = mock_finding_model.all_objects.select_related.return_value + mock_finding_query = mock_select_related.prefetch_related.return_value + mock_finding_query.get.return_value = finding + + result = send_findings_to_jira( + tenant_id, integration_id, project_key, issue_type, finding_ids + ) + + assert result == { + "created_count": 0, + "failed_count": 1, + "error": error_message, + } + mock_logger.exception.assert_called_with( + "Failed to send finding %s to Jira: %s", + "finding-1", + error_message, + ) + + @patch("tasks.jobs.integrations.rls_transaction") + @patch("tasks.jobs.integrations.Finding") + @patch("tasks.jobs.integrations.Integration") + @patch("tasks.jobs.integrations.initialize_prowler_integration") + @patch("tasks.jobs.integrations.logger") + def test_send_findings_to_jira_preserves_refresh_token_error_message( + self, + mock_logger, + mock_initialize_integration, + mock_integration_model, + mock_finding_model, + mock_rls_transaction, + ): + """Test Jira refresh token exceptions return their UI-friendly message.""" + tenant_id = "tenant-123" + integration_id = "integration-456" + project_key = "PROJ" + issue_type = "Task" + finding_ids = ["finding-1"] + error_message = "Failed to refresh the access token" + + mock_rls_transaction.return_value.__enter__ = MagicMock() + mock_rls_transaction.return_value.__exit__ = MagicMock() + + integration = MagicMock() + mock_integration_model.objects.get.return_value = integration + + mock_jira_integration = MagicMock() + + mock_jira_integration.send_finding.side_effect = JiraRefreshTokenError( + message=error_message + ) + mock_initialize_integration.return_value = mock_jira_integration + + finding = MagicMock() + finding.id = "finding-1" + finding.check_id = "check_001" + finding.severity = "high" + finding.status = "FAIL" + finding.status_extended = "Resource is not compliant" + finding.compliance = {} + finding.resources.exists.return_value = False + finding.resources.first.return_value = None + finding.scan.provider.provider = "aws" + finding.check_metadata = { + "checktitle": "Check Title", + "risk": "High risk", + "remediation": {"recommendation": {}, "code": {}}, + } + mock_select_related = mock_finding_model.all_objects.select_related.return_value + mock_finding_query = mock_select_related.prefetch_related.return_value + mock_finding_query.get.return_value = finding + + result = send_findings_to_jira( + tenant_id, integration_id, project_key, issue_type, finding_ids + ) + + assert result == { + "created_count": 0, + "failed_count": 1, + "error": error_message, + } + mock_logger.exception.assert_called_with( + "Failed to send finding %s to Jira: %s", + "finding-1", + error_message, + ) + + @patch("tasks.jobs.integrations.rls_transaction") + @patch("tasks.jobs.integrations.Finding") + @patch("tasks.jobs.integrations.Integration") + @patch("tasks.jobs.integrations.initialize_prowler_integration") + @patch("tasks.jobs.integrations.logger") + def test_send_findings_to_jira_sanitizes_unexpected_exception_message( + self, + mock_logger, + mock_initialize_integration, + mock_integration_model, + mock_finding_model, + mock_rls_transaction, + ): + """Test unexpected Jira send exceptions do not leak raw details to UI.""" + tenant_id = "tenant-123" + integration_id = "integration-456" + project_key = "PROJ" + issue_type = "Task" + finding_ids = ["finding-1"] + + mock_rls_transaction.return_value.__enter__ = MagicMock() + mock_rls_transaction.return_value.__exit__ = MagicMock() + + integration = MagicMock() + mock_integration_model.objects.get.return_value = integration + + mock_jira_integration = MagicMock() + mock_jira_integration.send_finding.side_effect = Exception("token=secret-value") + mock_initialize_integration.return_value = mock_jira_integration + + finding = MagicMock() + finding.id = "finding-1" + finding.check_id = "check_001" + finding.severity = "high" + finding.status = "FAIL" + finding.status_extended = "Resource is not compliant" + finding.compliance = {} + finding.resources.exists.return_value = False + finding.resources.first.return_value = None + finding.scan.provider.provider = "aws" + finding.check_metadata = { + "checktitle": "Check Title", + "risk": "High risk", + "remediation": {"recommendation": {}, "code": {}}, + } + mock_select_related = mock_finding_model.all_objects.select_related.return_value + mock_finding_query = mock_select_related.prefetch_related.return_value + mock_finding_query.get.return_value = finding + + result = send_findings_to_jira( + tenant_id, integration_id, project_key, issue_type, finding_ids + ) + + assert result == { + "created_count": 0, + "failed_count": 1, + "error": "Failed to create Jira issue.", + } + assert "secret-value" not in result["error"] + mock_logger.exception.assert_called_with( + "Failed to send finding %s to Jira", "finding-1" + ) @patch("tasks.jobs.integrations.rls_transaction") @patch("tasks.jobs.integrations.Finding") diff --git a/prowler/changelog.d/jira-dispatch-errors.fixed.md b/prowler/changelog.d/jira-dispatch-errors.fixed.md new file mode 100644 index 0000000000..c626872ecd --- /dev/null +++ b/prowler/changelog.d/jira-dispatch-errors.fixed.md @@ -0,0 +1 @@ +Jira issue creation failures now preserve safe structured response details from Jira diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 9005b5274e..59b4d015aa 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -51,6 +51,39 @@ class JiraConnection(Connection): issue_types: dict = None +def _format_jira_issue_creation_error(response_json: object, status_code: int) -> str: + """Build a safe Jira issue creation error message from structured fields. + + Args: + response_json: Parsed Jira response body. + status_code: HTTP status code returned by Jira. + + Returns: + Safe issue creation error message for user-facing propagation. + """ + message_parts = [] + + if not isinstance(response_json, dict): + return f"Failed to create Jira issue: Jira returned status code {status_code}." + + errors = response_json.get("errors") + if isinstance(errors, dict): + message_parts.extend( + f"'{field}': '{message}'" for field, message in errors.items() if message + ) + + error_messages = response_json.get("errorMessages") + if isinstance(error_messages, list): + message_parts.extend(str(message) for message in error_messages if message) + elif isinstance(error_messages, str) and error_messages: + message_parts.append(error_messages) + + if message_parts: + return f"Failed to create Jira issue: {'; '.join(message_parts)}" + + return f"Failed to create Jira issue: Jira returned status code {status_code}." + + class MarkdownToADFConverter: """Helper to convert Markdown strings into Atlassian Document Format blocks.""" @@ -2060,6 +2093,7 @@ class Jira: Raises: - JiraRefreshTokenError: Failed to refresh the access token - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200 + - JiraNoTokenError: Failed to get an access token - JiraCreateIssueError: Failed to create an issue in Jira - JiraSendFindingsResponseError: Failed to send the finding to Jira - JiraRequiredCustomFieldsError: Jira project requires custom fields that are not supported @@ -2155,31 +2189,45 @@ class Jira: try: response_json = response.json() except (ValueError, requests.exceptions.JSONDecodeError): - response_error = f"Failed to send finding: {response.status_code} - {response.text}" + response_error = _format_jira_issue_creation_error( + {}, response.status_code + ) logger.error(response_error) - return False + raise JiraSendFindingsResponseError( + message=response_error, file=os.path.basename(__file__) + ) # Check if the error is due to required custom fields - if response.status_code == 400 and "errors" in response_json: + if ( + response.status_code == 400 + and isinstance(response_json, dict) + and "errors" in response_json + ): errors = response_json.get("errors", {}) # Look for custom field errors (fields starting with "customfield_") - custom_field_errors = { - k: v for k, v in errors.items() if k.startswith("customfield_") - } + custom_field_errors = {} + if isinstance(errors, dict): + custom_field_errors = { + k: v + for k, v in errors.items() + if k.startswith("customfield_") + } if custom_field_errors: custom_fields_formatted = ", ".join( [f"'{k}': '{v}'" for k, v in custom_field_errors.items()] ) - logger.error( - f"Jira project requires custom fields that are not supported: {custom_fields_formatted}" + raise JiraRequiredCustomFieldsError( + message=f"Jira project requires custom fields that are not supported: {custom_fields_formatted}", + file=os.path.basename(__file__), ) - return False - response_error = ( - f"Failed to send finding: {response.status_code} - {response_json}" + response_error = _format_jira_issue_creation_error( + response_json, response.status_code ) logger.error(response_error) - return False + raise JiraSendFindingsResponseError( + message=response_error, file=os.path.basename(__file__) + ) else: try: response_json = response.json() @@ -2191,13 +2239,17 @@ class Jira: return True except JiraRequiredCustomFieldsError as custom_fields_error: logger.error(f"Custom fields error: {custom_fields_error}") - return False + raise custom_fields_error + except JiraSendFindingsResponseError as response_error: + logger.error(f"Jira response error: {response_error}") + raise response_error except JiraRefreshTokenError as refresh_error: logger.error(f"Token refresh error: {refresh_error}") - return False + raise refresh_error except JiraRefreshTokenResponseError as response_error: - logger.error(f"Token response error: {response_error}") - return False + raise response_error + except JiraNoTokenError as no_token_error: + raise no_token_error except Exception as e: logger.error(f"Failed to send finding: {e}") return False diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 76352cb297..b71a58a570 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -16,8 +16,11 @@ from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraGetProjectsError, JiraGetProjectsResponseError, JiraNoProjectsError, + JiraNoTokenError, JiraRefreshTokenError, + JiraRefreshTokenResponseError, JiraRequiredCustomFieldsError, + JiraSendFindingsResponseError, JiraTestConnectionError, ) from prowler.lib.outputs.jira.jira import Jira @@ -1692,7 +1695,7 @@ class TestJiraIntegration: mock_cloud_id, mock_get_access_token, ): - """Test that send_finding returns False when the request fails.""" + """Test that send_finding raises with Jira JSON error details.""" # To disable vulture mock_cloud_id = mock_cloud_id mock_get_access_token = mock_get_access_token @@ -1702,19 +1705,66 @@ class TestJiraIntegration: # Mock failed response mock_response = MagicMock() mock_response.status_code = 400 - mock_response.json.return_value = {"errors": {"summary": "Required field"}} + mock_response.json.return_value = { + "errors": {"Team": "Team is required."}, + "errorMessages": ["Field 'Team' cannot be set."], + } mock_post.return_value = mock_response - result = self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) + with pytest.raises(JiraSendFindingsResponseError) as error: + self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) - assert result is False + assert "Failed to create Jira issue" in str(error.value) + assert "'Team': 'Team is required.'" in str(error.value) + assert "Field 'Team' cannot be set." in str(error.value) + mock_post.assert_called_once() + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" + ) + @patch.object(Jira, "get_projects", return_value={"TEST": {"name": "Test Project"}}) + @patch.object(Jira, "get_available_issue_types", return_value=["Bug"]) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_send_finding_response_error_without_json_body( + self, + mock_post, + mock_get_issue_types, + mock_get_projects, + mock_cloud_id, + mock_get_access_token, + ): + """Test send_finding raises with status-code context for non-JSON errors.""" + # To disable vulture + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + mock_get_projects = mock_get_projects + mock_get_issue_types = mock_get_issue_types + + mock_response = MagicMock() + mock_response.status_code = 502 + mock_response.json.side_effect = ValueError("No JSON body") + mock_post.return_value = mock_response + + with pytest.raises(JiraSendFindingsResponseError) as error: + self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) + + assert "Failed to create Jira issue" in str(error.value) + assert "Jira returned status code 502" in str(error.value) mock_post.assert_called_once() @patch.object(Jira, "get_access_token", return_value="valid_access_token") @@ -1732,7 +1782,7 @@ class TestJiraIntegration: mock_cloud_id, mock_get_access_token, ): - """Test that send_finding returns False when custom fields cause an error.""" + """Test that send_finding raises when custom fields cause an error.""" # To disable vulture mock_cloud_id = mock_cloud_id mock_get_access_token = mock_get_access_token @@ -1750,18 +1800,89 @@ class TestJiraIntegration: } mock_post.return_value = mock_response - result = self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) + with pytest.raises(JiraRequiredCustomFieldsError) as error: + self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) - assert result is False + assert "Jira project requires custom fields" in str(error.value) + assert "customfield_10001" in str(error.value) mock_post.assert_called_once() + @patch.object( + Jira, + "get_access_token", + side_effect=JiraRefreshTokenError(message="Failed to refresh the access token"), + ) + def test_send_finding_reraises_refresh_token_error(self, mock_get_access_token): + """Test send_finding re-raises refresh token errors for API propagation.""" + # To disable vulture + mock_get_access_token = mock_get_access_token + + with pytest.raises(JiraRefreshTokenError) as error: + self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) + + assert error.value.message == "Failed to refresh the access token" + + @patch.object(Jira, "get_access_token", return_value=None) + def test_send_finding_reraises_no_token_error(self, mock_get_access_token): + """Test send_finding re-raises missing token errors for API propagation.""" + # To disable vulture + mock_get_access_token = mock_get_access_token + + with pytest.raises(JiraNoTokenError) as error: + self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) + + assert error.value.message == "No token was found" + + @patch.object( + Jira, + "get_access_token", + side_effect=JiraRefreshTokenResponseError( + message="Failed to refresh the access token, response code did not match 200" + ), + ) + def test_send_finding_reraises_refresh_token_response_error( + self, mock_get_access_token + ): + """Test send_finding re-raises refresh token response errors for API propagation.""" + # To disable vulture + mock_get_access_token = mock_get_access_token + + with pytest.raises(JiraRefreshTokenResponseError) as error: + self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) + + assert ( + error.value.message + == "Failed to refresh the access token, response code did not match 200" + ) + def test_get_headers_oauth_with_access_token(self): """Test get_headers returns correct OAuth headers with access token.""" self.jira_integration._using_basic_auth = False diff --git a/ui/actions/integrations/jira-dispatch.test.ts b/ui/actions/integrations/jira-dispatch.test.ts new file mode 100644 index 0000000000..05c3188e47 --- /dev/null +++ b/ui/actions/integrations/jira-dispatch.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { pollTaskUntilSettledMock } = vi.hoisted(() => ({ + pollTaskUntilSettledMock: vi.fn(), +})); + +vi.mock("@/actions/task/poll", () => ({ + pollTaskUntilSettled: pollTaskUntilSettledMock, +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: vi.fn(), +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: vi.fn(), +})); + +import { pollJiraDispatchTask } from "./jira-dispatch"; + +describe("pollJiraDispatchTask", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return the backend error when a completed task has failed Jira dispatches", async () => { + // Given + pollTaskUntilSettledMock.mockResolvedValue({ + ok: true, + state: "completed", + result: { + created_count: 0, + failed_count: 1, + error: "Jira project requires custom fields: Team is required", + }, + }); + + // When + const result = await pollJiraDispatchTask("task-123"); + + // Then + expect(result).toEqual({ + success: false, + error: "Jira project requires custom fields: Team is required", + }); + }); + + it("should return a fallback error when a completed task has failures without an error", async () => { + // Given + pollTaskUntilSettledMock.mockResolvedValue({ + ok: true, + state: "completed", + result: { + created_count: 0, + failed_count: 1, + }, + }); + + // When + const result = await pollJiraDispatchTask("task-123"); + + // Then + expect(result).toEqual({ + success: false, + error: "Failed to create Jira issue.", + }); + }); + + it("should return a plural fallback error when a completed task has multiple failures without an error", async () => { + // Given + pollTaskUntilSettledMock.mockResolvedValue({ + ok: true, + state: "completed", + result: { + created_count: 0, + failed_count: 3, + }, + }); + + // When + const result = await pollJiraDispatchTask("task-123"); + + // Then + expect(result).toEqual({ + success: false, + error: "Failed to create 3 Jira issues.", + }); + }); + + it("should surface task failure result errors", async () => { + // Given + pollTaskUntilSettledMock.mockResolvedValue({ + ok: true, + state: "failed", + result: { + error: "Jira credentials are invalid.", + }, + }); + + // When + const result = await pollJiraDispatchTask("task-123"); + + // Then + expect(result).toEqual({ + success: false, + error: "Jira credentials are invalid.", + }); + }); + + it("should return success when a completed task has no failures", async () => { + // Given + pollTaskUntilSettledMock.mockResolvedValue({ + ok: true, + state: "completed", + result: { + created_count: 1, + failed_count: 0, + }, + }); + + // When + const result = await pollJiraDispatchTask("task-123"); + + // Then + expect(result).toEqual({ + success: true, + message: "Finding successfully sent to Jira!", + }); + }); + + it("should return a fallback error when no Jira issue was created", async () => { + // Given + pollTaskUntilSettledMock.mockResolvedValue({ + ok: true, + state: "completed", + result: { + created_count: 0, + failed_count: 0, + }, + }); + + // When + const result = await pollJiraDispatchTask("task-123"); + + // Then + expect(result).toEqual({ + success: false, + error: "Failed to create Jira issue.", + }); + }); +}); diff --git a/ui/actions/integrations/jira-dispatch.ts b/ui/actions/integrations/jira-dispatch.ts index 9e3b25679f..b6d3215e13 100644 --- a/ui/actions/integrations/jira-dispatch.ts +++ b/ui/actions/integrations/jira-dispatch.ts @@ -159,12 +159,18 @@ export const pollJiraDispatchTask = async ( const jiraResult = result as JiraTaskResult | undefined; if (state === "completed") { - if (!jiraResult?.error) { + const createdCount = jiraResult?.created_count ?? 0; + const failedCount = jiraResult?.failed_count ?? 0; + if (!jiraResult?.error && failedCount === 0 && createdCount > 0) { return { success: true, message: "Finding successfully sent to Jira!" }; } return { success: false, - error: jiraResult?.error || "Failed to create Jira issue.", + error: + jiraResult?.error || + (failedCount > 1 + ? `Failed to create ${failedCount} Jira issues.` + : "Failed to create Jira issue."), }; } diff --git a/ui/changelog.d/jira-dispatch-errors.fixed.md b/ui/changelog.d/jira-dispatch-errors.fixed.md new file mode 100644 index 0000000000..3d9afe9918 --- /dev/null +++ b/ui/changelog.d/jira-dispatch-errors.fixed.md @@ -0,0 +1 @@ +Jira dispatch polling now reports failed issue creation tasks instead of treating partial failures as successful diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts index 63b1e37bff..3ff525964e 100644 --- a/ui/types/integrations.ts +++ b/ui/types/integrations.ts @@ -64,6 +64,8 @@ export interface JiraDispatchResponse { message?: string; issue_url?: string; issue_key?: string; + created_count?: number; + failed_count?: number; } | null; task_args: Record | null; metadata: Record | null;