fix(jira): surface dispatch failures (#11918)

This commit is contained in:
Hugo Pereira Brito
2026-07-09 16:09:12 +01:00
committed by GitHub
parent 01c004a7af
commit be78fe8374
10 changed files with 632 additions and 66 deletions
@@ -0,0 +1 @@
Jira dispatch task results now surface user-facing Jira failure messages
+48 -25
View File
@@ -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
@@ -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")
@@ -0,0 +1 @@
Jira issue creation failures now preserve safe structured response details from Jira
+68 -16
View File
@@ -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
+142 -21
View File
@@ -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
@@ -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.",
});
});
});
+8 -2
View File
@@ -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."),
};
}
@@ -0,0 +1 @@
Jira dispatch polling now reports failed issue creation tasks instead of treating partial failures as successful
+2
View File
@@ -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<string, unknown> | null;
metadata: Record<string, unknown> | null;