From b6e1dd02c0be0910e970ed67de3cd0ba83cbfa30 Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Tue, 21 Jul 2026 13:06:25 +0200 Subject: [PATCH] fix(ui): complete grouped Jira dispatch review --- ui/actions/integrations/jira-dispatch.test.ts | 12 +- ui/actions/integrations/jira-dispatch.ts | 104 +-- ui/app/(prowler)/findings/page.test.ts | 3 +- .../findings/floating-mute-button.test.tsx | 16 +- .../findings/floating-mute-button.tsx | 136 ++-- .../jira-dispatch-task-handler.test.tsx | 127 ++++ .../findings/jira-dispatch-task-handler.tsx | 117 +++ .../findings/send-to-jira-modal.test.tsx | 230 ++++-- ui/components/findings/send-to-jira-modal.tsx | 711 ++++++++++-------- .../table/column-finding-resources.test.tsx | 28 +- .../table/column-finding-resources.tsx | 37 +- .../table/data-table-row-actions.test.tsx | 35 +- .../findings/table/data-table-row-actions.tsx | 18 +- .../table/findings-group-drill-down.test.ts | 2 +- .../table/findings-group-drill-down.tsx | 37 +- .../table/findings-group-table.test.tsx | 118 +-- .../findings/table/findings-group-table.tsx | 173 ++--- .../resource-detail-drawer-content.tsx | 27 +- ui/components/shadcn/custom/custom-radio.tsx | 9 +- ui/components/shared/task-polling-watcher.tsx | 3 + ui/lib/jira-dispatch-result.ts | 141 ++++ ui/lib/jira-dispatch-selection.ts | 84 +++ ui/lib/jira-dispatch-task.ts | 46 ++ ui/store/task-watcher/store.test.ts | 64 ++ ui/store/task-watcher/store.ts | 210 ++++-- ui/types/integrations.ts | 74 +- 26 files changed, 1668 insertions(+), 894 deletions(-) create mode 100644 ui/components/findings/jira-dispatch-task-handler.test.tsx create mode 100644 ui/components/findings/jira-dispatch-task-handler.tsx create mode 100644 ui/lib/jira-dispatch-result.ts create mode 100644 ui/lib/jira-dispatch-selection.ts create mode 100644 ui/lib/jira-dispatch-task.ts diff --git a/ui/actions/integrations/jira-dispatch.test.ts b/ui/actions/integrations/jira-dispatch.test.ts index aa54907a5a..c691a04c43 100644 --- a/ui/actions/integrations/jira-dispatch.test.ts +++ b/ui/actions/integrations/jira-dispatch.test.ts @@ -91,7 +91,12 @@ describe("sendJiraDispatch", () => { pollTaskUntilSettledMock.mockResolvedValue({ ok: true, state: "completed", - result: { created_count: 2, failed_count: 1 }, + result: { + created_count: 2, + failed_count: 1, + failed_finding_ids: ["finding-3"], + error: "Jira rejected one Finding.", + }, }); // When @@ -102,7 +107,8 @@ describe("sendJiraDispatch", () => { success: true, message: "2 Jira issues were created or updated successfully.", warning: - "Jira dispatch completed with 1 failed and 2 created/updated issues.", + "Jira rejected one Finding. Jira dispatch completed with 1 failed and 2 created/updated issues.", + failedFindingIds: ["finding-3"], }); }); @@ -189,7 +195,7 @@ describe("sendJiraDispatch", () => { }); expect(result).toEqual({ success: true, - message: "Finding successfully sent to Jira!", + message: "2 Jira issues were created or updated successfully.", }); }); diff --git a/ui/actions/integrations/jira-dispatch.ts b/ui/actions/integrations/jira-dispatch.ts index 7dc72c4c9e..486ef5bb68 100644 --- a/ui/actions/integrations/jira-dispatch.ts +++ b/ui/actions/integrations/jira-dispatch.ts @@ -2,6 +2,7 @@ import { pollTaskUntilSettled } from "@/actions/task/poll"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result"; import { handleApiError } from "@/lib/server-actions-helper"; import type { IntegrationProps, @@ -9,13 +10,10 @@ import type { JiraDispatchRequest, JiraDispatchResponse, JiraDispatchTarget, + JiraDispatchTaskResult, } from "@/types/integrations"; import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; -type JiraTaskResult = NonNullable< - JiraDispatchResponse["data"]["attributes"]["result"] ->; - interface JiraDispatchInput { integrationId: string; targetIds: string[]; @@ -25,53 +23,6 @@ interface JiraDispatchInput { dispatchMode?: JiraDispatchMode; } -const getArrayCount = (value: unknown[] | undefined) => - Array.isArray(value) ? value.length : 0; - -const getJiraDispatchFailedCount = (result: JiraTaskResult | undefined) => { - if (!result) return 0; - return Math.max( - result.failed_count ?? 0, - getArrayCount(result.failed_groups), - getArrayCount(result.failed_batches), - ); -}; - -const getJiraDispatchSuccessCount = (result: JiraTaskResult | undefined) => { - if (!result) return 0; - return Math.max( - result.successful_count ?? 0, - result.created_count ?? 0, - result.updated_count ?? 0, - getArrayCount(result.created_issues), - getArrayCount(result.updated_issues), - result.issue_key || result.issue_url ? 1 : 0, - ); -}; - -const buildJiraDispatchFailureMessage = ( - result: JiraTaskResult | undefined, - failedCount: number, -) => { - if (result?.error) return result.error; - - const createdCount = result?.created_count ?? 0; - const updatedCount = result?.updated_count ?? 0; - const successCount = createdCount + updatedCount; - return `Jira dispatch completed with ${failedCount} failed and ${successCount} created/updated issue${successCount === 1 ? "" : "s"}.`; -}; - -const buildJiraDispatchSuccessMessage = ( - result: JiraTaskResult | undefined, -) => { - const successCount = getJiraDispatchSuccessCount(result); - if (successCount > 1) { - return `${successCount} Jira issues were created or updated successfully.`; - } - - return "Finding successfully sent to Jira!"; -}; - export const getJiraIssueTypes = async ( integrationId: string, projectKey: string, @@ -242,51 +193,8 @@ export const pollJiraDispatchTask = async ( if (!res.ok) { return { success: false, error: res.error }; } - const { state, result } = res; - const jiraResult = (result ?? undefined) as JiraTaskResult | undefined; - - if (state === "completed") { - const failedCount = getJiraDispatchFailedCount(jiraResult); - if (failedCount > 0) { - const successCount = getJiraDispatchSuccessCount(jiraResult); - if (successCount > 0) { - return { - success: true, - message: buildJiraDispatchSuccessMessage(jiraResult), - warning: buildJiraDispatchFailureMessage(jiraResult, failedCount), - }; - } - - return { - success: false, - error: buildJiraDispatchFailureMessage(jiraResult, failedCount), - }; - } - - if (jiraResult?.success === false || jiraResult?.error) { - return { - success: false, - error: jiraResult?.error || "Failed to create Jira issue.", - }; - } - - if (!jiraResult || getJiraDispatchSuccessCount(jiraResult) === 0) { - return { - success: false, - error: - "Jira dispatch completed but did not create or update any issues.", - }; - } - - return { - success: true, - message: buildJiraDispatchSuccessMessage(jiraResult), - }; - } - - if (state === "failed") { - return { success: false, error: jiraResult?.error || "Task failed." }; - } - - return { success: false, error: `Unknown task state: ${state}` }; + return evaluateJiraDispatchTask( + res.state, + res.result as JiraDispatchTaskResult | undefined, + ); }; diff --git a/ui/app/(prowler)/findings/page.test.ts b/ui/app/(prowler)/findings/page.test.ts index 31d3ae76c2..24ddc53b72 100644 --- a/ui/app/(prowler)/findings/page.test.ts +++ b/ui/app/(prowler)/findings/page.test.ts @@ -54,6 +54,7 @@ describe("findings page", () => { it("excludes Finding Group's own filters while loading all option pages", () => { expect(source).toContain("excludeFindingGroupOwnFilters"); expect(source).toContain('"filter[check_id__in]"'); - expect(source).toContain("page <= totalPages"); + expect(source).toContain("if (page >= totalPages) break"); + expect(source).toContain("page += 1"); }); }); diff --git a/ui/components/findings/floating-mute-button.test.tsx b/ui/components/findings/floating-mute-button.test.tsx index 5c26056ef6..fe4d22e03f 100644 --- a/ui/components/findings/floating-mute-button.test.tsx +++ b/ui/components/findings/floating-mute-button.test.tsx @@ -216,7 +216,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => { // When await user.click(screen.getByRole("button", { name: "1 selected" })); - await user.click(screen.getByRole("button", { name: "Send to Jira" })); + await user.click(screen.getByRole("menuitem", { name: "Send to Jira" })); // Then expect(onSendToJira).toHaveBeenCalledTimes(1); @@ -250,12 +250,12 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => { // Then expect( - screen.getByRole("button", { + screen.getByRole("menuitem", { name: "Mute 1 Group and 1 Finding", }), ).toBeVisible(); expect( - screen.getByRole("button", { + screen.getByRole("menuitem", { name: "Send 1 Group and 1 Finding to Jira", }), ).toHaveTextContent("Send 1 Group and 1 Finding to Jira"); @@ -278,11 +278,11 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => { // When await user.click(screen.getByRole("button", { name: "1 selected" })); - const jiraButton = screen.getByRole("button", { name: "Send to Jira" }); + const jiraAction = screen.getByRole("menuitem", { name: "Send to Jira" }); // Then - expect(jiraButton).toBeVisible(); - expect(jiraButton).toBeDisabled(); + expect(jiraAction).toBeVisible(); + expect(jiraAction).toHaveAttribute("aria-disabled", "true"); const cloudBadgeLink = screen.getByRole("link", { name: "Available only in Prowler Cloud", }); @@ -291,11 +291,11 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => { "href", "https://prowler.com/pricing", ); - expect(jiraButton).not.toContainElement(cloudBadgeLink); + expect(jiraAction).toContainElement(cloudBadgeLink); expect(onSendToJira).not.toHaveBeenCalled(); // When - await user.hover(jiraButton.parentElement!); + await user.hover(jiraAction); // Then const tooltipTexts = await screen.findAllByText( diff --git a/ui/components/findings/floating-mute-button.tsx b/ui/components/findings/floating-mute-button.tsx index 1a50e84676..5a675dbf11 100644 --- a/ui/components/findings/floating-mute-button.tsx +++ b/ui/components/findings/floating-mute-button.tsx @@ -7,15 +7,12 @@ import { createPortal } from "react-dom"; import { JiraIcon } from "@/components/icons/services/IconServices"; import { Button } from "@/components/shadcn"; import { Badge } from "@/components/shadcn/badge/badge"; -import { Modal } from "@/components/shadcn/modal"; -import { Spinner } from "@/components/shadcn/spinner/spinner"; import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/shadcn/tooltip"; + ActionDropdown, + ActionDropdownItem, +} from "@/components/shadcn/dropdown/action-dropdown"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; import { PROWLER_CLOUD_ONLY_TOOLTIP } from "@/lib/deployment"; -import { cn } from "@/lib/utils"; import { MuteFindingsModal } from "./mute-findings-modal"; @@ -35,7 +32,7 @@ interface FloatingMuteButtonProps { onSendToJira?: () => void; /** Whether the Jira action is available for the current selection. */ canSendToJira?: boolean; - /** Whether the Jira action should be displayed in the chooser. */ + /** Whether the Jira action should be displayed in the action menu. */ showSendToJira?: boolean; /** Custom Jira action label. Defaults to "Send to Jira". */ sendToJiraLabel?: string; @@ -69,7 +66,6 @@ export function FloatingMuteButton({ sendToJiraLabel = "Send to Jira", jiraDisabledTooltip = PROWLER_CLOUD_ONLY_TOOLTIP, }: FloatingMuteButtonProps) { - const [isActionChooserOpen, setIsActionChooserOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); const [resolvedIds, setResolvedIds] = useState([]); const [isResolving, setIsResolving] = useState(false); @@ -93,8 +89,6 @@ export function FloatingMuteButton({ }; const handleMuteClick = async () => { - setIsActionChooserOpen(false); - if (onBeforeOpen) { setResolvedIds([]); setMutePreparationError(null); @@ -125,7 +119,6 @@ export function FloatingMuteButton({ const handleJiraClick = () => { if (!canSendToJira) return; - setIsActionChooserOpen(false); onSendToJira?.(); }; @@ -135,15 +128,6 @@ export function FloatingMuteButton({ }; const findingIds = onBeforeOpen ? resolvedIds : selectedFindingIds; - const hasMultipleActions = showSendToJira; - const handlePrimaryClick = () => { - if (hasMultipleActions) { - setIsActionChooserOpen(true); - return; - } - - void handleMuteClick(); - }; return ( <> @@ -157,76 +141,62 @@ export function FloatingMuteButton({ preparationError={mutePreparationError} /> - -
- - {showSendToJira && ( - - - - - {!canSendToJira && ( - - - - )} - - - {!canSendToJira && ( - {jiraDisabledTooltip} - )} - - )} -
-
- {/* Portaled to body:
is a layout container (container queries), which would otherwise capture this fixed button and scroll it away with the content. */} {typeof document !== "undefined" ? createPortal(
- + } + > + } + label={muteLabel} + aria-label={muteLabel} + onSelect={() => void handleMuteClick()} + /> + } + label={ + + {sendToJiraLabel} + {!canSendToJira && } + + } + aria-label={sendToJiraLabel} + disabled={!canSendToJira} + disabledTooltip={jiraDisabledTooltip} + onSelect={handleJiraClick} + /> + ) : ( - + )} - {hasMultipleActions - ? (label ?? `${selectedCount} selected`) - : `Mute (${selectedCount})`} - +
, document.body, ) diff --git a/ui/components/findings/jira-dispatch-task-handler.test.tsx b/ui/components/findings/jira-dispatch-task-handler.test.tsx new file mode 100644 index 0000000000..90b5ad2884 --- /dev/null +++ b/ui/components/findings/jira-dispatch-task-handler.test.tsx @@ -0,0 +1,127 @@ +import { type ComponentProps } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { WatchedTask } from "@/store/task-watcher/store"; +import { JIRA_DISPATCH_MODE } from "@/types/integrations"; + +import { jiraDispatchTaskHandler } from "./jira-dispatch-task-handler"; + +interface ToastActionMockProps extends ComponentProps<"button"> { + altText: string; +} + +const { sendJiraDispatchMock, toastMock, trackAndPollTaskMock } = vi.hoisted( + () => ({ + sendJiraDispatchMock: vi.fn(), + toastMock: vi.fn(), + trackAndPollTaskMock: vi.fn(), + }), +); + +vi.mock("@/actions/integrations/jira-dispatch", () => ({ + sendJiraDispatch: sendJiraDispatchMock, +})); + +vi.mock("@/components/shadcn/toast", () => ({ + toast: toastMock, + ToastAction: ({ + altText: _altText, + children, + ...props + }: ToastActionMockProps) => , +})); + +vi.mock("@/store/task-watcher/store", () => ({ + trackAndPollTask: trackAndPollTaskMock, +})); + +const buildTask = (result: unknown): WatchedTask => ({ + taskId: "task-1", + kind: "jira-dispatch", + status: "ready", + startedAt: Date.now(), + meta: { + integrationId: "jira-1", + projectKey: "SEC", + issueType: "Task", + dispatchMode: JIRA_DISPATCH_MODE.GROUPED, + }, + result, +}); + +describe("jiraDispatchTaskHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + sendJiraDispatchMock.mockResolvedValue({ + success: true, + taskId: "retry-task", + message: "Started", + }); + }); + + it("shows the completed Jira result after a persisted task resumes", () => { + // Given + const task = buildTask({ created_count: 2, failed_count: 0 }); + + // When + jiraDispatchTaskHandler.onReady(task); + + // Then + expect(toastMock).toHaveBeenCalledWith({ + title: "Success!", + description: "2 Jira issues were created or updated successfully.", + }); + }); + + it("retries only failed Findings from a resumed partial task", async () => { + // Given + const task = buildTask({ + created_count: 1, + failed_count: 2, + failed_finding_ids: ["finding-2", "finding-3"], + error: "Two Jira issues failed.", + }); + jiraDispatchTaskHandler.onReady(task); + const partialToast = toastMock.mock.calls.at(-1)?.[0]; + + // When + await partialToast.action.props.onClick(); + + // Then + expect(sendJiraDispatchMock).toHaveBeenCalledWith({ + integrationId: "jira-1", + targetIds: ["finding-2", "finding-3"], + filter: "finding_id", + projectKey: "SEC", + issueType: "Task", + dispatchMode: "individual", + }); + expect(trackAndPollTaskMock).toHaveBeenCalledWith({ + taskId: "retry-task", + kind: "jira-dispatch", + meta: { + ...task.meta, + dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, + }, + }); + }); + + it("surfaces task watcher errors without offering an unsafe retry", () => { + // Given + const task = { + ...buildTask(undefined), + status: "error", + error: "Tracking the task failed unexpectedly. Try again later.", + } as WatchedTask; + + // When + jiraDispatchTaskHandler.onError(task); + + // Then + expect(toastMock).toHaveBeenCalledWith({ + variant: "destructive", + title: "Jira dispatch failed", + description: "Tracking the task failed unexpectedly. Try again later.", + }); + }); +}); diff --git a/ui/components/findings/jira-dispatch-task-handler.tsx b/ui/components/findings/jira-dispatch-task-handler.tsx new file mode 100644 index 0000000000..4b42336952 --- /dev/null +++ b/ui/components/findings/jira-dispatch-task-handler.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { sendJiraDispatch } from "@/actions/integrations/jira-dispatch"; +import { toast, ToastAction } from "@/components/shadcn/toast"; +import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result"; +import { + buildJiraDispatchTaskMeta, + parseJiraDispatchTaskMeta, +} from "@/lib/jira-dispatch-task"; +import { + type TaskKindHandler, + trackAndPollTask, + type WatchedTask, +} from "@/store/task-watcher/store"; +import { + JIRA_DISPATCH_MODE, + JIRA_DISPATCH_TARGET, + JIRA_DISPATCH_TASK_KIND, + type JiraDispatchTaskResult, +} from "@/types/integrations"; + +const retryFailedFindings = async ( + task: WatchedTask, + failedFindingIds: string[], +): Promise => { + const meta = parseJiraDispatchTaskMeta(task); + if (!meta) { + toast({ + variant: "destructive", + title: "Jira retry failed", + description: "The original Jira dispatch configuration is unavailable.", + }); + return; + } + + try { + const response = await sendJiraDispatch({ + integrationId: meta.integrationId, + targetIds: failedFindingIds, + filter: JIRA_DISPATCH_TARGET.FINDING_ID, + projectKey: meta.projectKey, + issueType: meta.issueType, + dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, + }); + + if (!response.success) { + toast({ + variant: "destructive", + title: "Jira retry failed", + description: response.error, + }); + return; + } + + toast({ + title: "Retry started", + description: `Retrying ${failedFindingIds.length} failed Finding${failedFindingIds.length === 1 ? "" : "s"}.`, + }); + + await trackAndPollTask({ + taskId: response.taskId, + kind: JIRA_DISPATCH_TASK_KIND, + meta: buildJiraDispatchTaskMeta({ + ...meta, + dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, + }), + }); + } catch { + toast({ + variant: "destructive", + title: "Jira retry failed", + description: "The retry could not be started. Try again later.", + }); + } +}; + +const buildRetryAction = (task: WatchedTask, failedFindingIds?: string[]) => + failedFindingIds?.length ? ( + retryFailedFindings(task, failedFindingIds)} + > + Retry failed + + ) : undefined; + +export const jiraDispatchTaskHandler: TaskKindHandler = { + onReady: (task) => { + const outcome = evaluateJiraDispatchTask( + "completed", + task.result as JiraDispatchTaskResult | undefined, + ); + + if (!outcome.success) { + toast({ + variant: "destructive", + title: "Jira dispatch failed", + description: outcome.error, + action: buildRetryAction(task, outcome.failedFindingIds), + }); + return; + } + + toast({ + title: outcome.warning ? "Jira dispatch partially completed" : "Success!", + description: outcome.warning ?? outcome.message, + action: buildRetryAction(task, outcome.failedFindingIds), + }); + }, + onError: (task) => { + toast({ + variant: "destructive", + title: "Jira dispatch failed", + description: task.error || "The Jira dispatch task failed unexpectedly.", + }); + }, +}; diff --git a/ui/components/findings/send-to-jira-modal.test.tsx b/ui/components/findings/send-to-jira-modal.test.tsx index 835d4a1277..ccad2fee6e 100644 --- a/ui/components/findings/send-to-jira-modal.test.tsx +++ b/ui/components/findings/send-to-jira-modal.test.tsx @@ -1,37 +1,64 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { type ComponentProps } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; +import { + createJiraBatchSelection, + createJiraTargetSelection, +} from "@/lib/jira-dispatch-selection"; +import { + JIRA_DISPATCH_MODE, + JIRA_DISPATCH_TARGET, + type JiraDispatchTarget, +} from "@/types/integrations"; import { SendToJiraModal } from "./send-to-jira-modal"; +const targetSelection = (targetIds: string[], targetType: JiraDispatchTarget) => + createJiraTargetSelection(targetIds, targetType)!; + +const batchSelection = ( + batches: Parameters[0], +) => createJiraBatchSelection(batches)!; + const { getJiraIntegrationsMock, getJiraIssueTypesMock, - pollJiraDispatchTaskMock, sendFindingToJiraMock, sendJiraDispatchMock, + trackAndPollTaskMock, toastMock, } = vi.hoisted(() => ({ getJiraIntegrationsMock: vi.fn(), getJiraIssueTypesMock: vi.fn(), - pollJiraDispatchTaskMock: vi.fn(), sendFindingToJiraMock: vi.fn(), sendJiraDispatchMock: vi.fn(), + trackAndPollTaskMock: vi.fn(), toastMock: vi.fn(), })); vi.mock("@/actions/integrations/jira-dispatch", () => ({ getJiraIntegrations: getJiraIntegrationsMock, getJiraIssueTypes: getJiraIssueTypesMock, - pollJiraDispatchTask: pollJiraDispatchTaskMock, sendFindingToJira: sendFindingToJiraMock, sendJiraDispatch: sendJiraDispatchMock, })); vi.mock("@/components/shadcn/toast", () => ({ toast: toastMock, + ToastAction: ({ children, ...props }: ComponentProps<"button">) => ( + + ), +})); + +vi.mock("@/store/task-watcher/store", () => ({ + TASK_WATCHER_STATUS: { + PENDING: "pending", + READY: "ready", + ERROR: "error", + }, + trackAndPollTask: trackAndPollTaskMock, })); vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({ @@ -93,9 +120,9 @@ describe("SendToJiraModal", () => { taskId: "task-1", message: "Started", }); - pollJiraDispatchTaskMock.mockResolvedValue({ - success: true, - message: "Finding successfully sent to Jira!", + trackAndPollTaskMock.mockResolvedValue({ + status: "ready", + result: { created_count: 1, failed_count: 0 }, }); }); @@ -104,9 +131,8 @@ describe("SendToJiraModal", () => { { targetIds: ["finding-1", "finding-2"], targetType: JIRA_DISPATCH_TARGET.FINDING_ID, }, - ]} + ])} defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED} selectedResourceCount={1} description="Create Jira issues for 1 Group and 2 Findings." @@ -146,10 +172,11 @@ describe("SendToJiraModal", () => { , @@ -187,9 +214,8 @@ describe("SendToJiraModal", () => { { targetIds: ["finding-1", "finding-2"], targetType: JIRA_DISPATCH_TARGET.FINDING_ID, }, - ]} + ])} defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED} selectedResourceCount={1} description="Create Jira issues for 1 Group and 2 Findings." @@ -239,8 +265,12 @@ describe("SendToJiraModal", () => { issueType: "Task", dispatchMode: "individual", }); - expect(pollJiraDispatchTaskMock).toHaveBeenCalledWith("group-task"); - expect(pollJiraDispatchTaskMock).toHaveBeenCalledWith("finding-task"); + expect(trackAndPollTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ taskId: "group-task", notifyHandler: false }), + ); + expect(trackAndPollTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ taskId: "finding-task", notifyHandler: false }), + ); }); it("shows a success toast after individual Finding dispatch succeeds", async () => { @@ -251,7 +281,10 @@ describe("SendToJiraModal", () => { , ); @@ -294,10 +327,8 @@ describe("SendToJiraModal", () => { , @@ -330,7 +361,7 @@ describe("SendToJiraModal", () => { }); }); - it("keeps polling until a slow grouped Finding Group dispatch succeeds", async () => { + it("delegates grouped Finding Group task tracking to the shared watcher", async () => { // Given const user = userEvent.setup(); const onOpenChange = vi.fn(); @@ -339,23 +370,12 @@ describe("SendToJiraModal", () => { taskId: "group-task", message: "Group started", }); - pollJiraDispatchTaskMock - .mockResolvedValueOnce({ - success: false, - error: "Task timeout", - }) - .mockResolvedValueOnce({ - success: true, - message: "Finding successfully sent to Jira!", - }); render( , @@ -372,9 +392,7 @@ describe("SendToJiraModal", () => { await user.click(screen.getByRole("button", { name: "Send to Jira" })); // Then - await waitFor(() => - expect(pollJiraDispatchTaskMock).toHaveBeenCalledTimes(2), - ); + await waitFor(() => expect(trackAndPollTaskMock).toHaveBeenCalledOnce()); await waitFor(() => expect(toastMock).toHaveBeenCalledWith({ title: "Success!", @@ -402,9 +420,8 @@ describe("SendToJiraModal", () => { { targetIds: ["finding-1", "finding-2"], targetType: "finding_id", }, - ]} + ])} defaultDispatchMode="grouped" selectedResourceCount={1} />, @@ -434,7 +451,7 @@ describe("SendToJiraModal", () => { await waitFor(() => expect(toastMock).toHaveBeenCalledWith({ title: "Success!", - description: "Finding successfully sent to Jira!", + description: "2 Jira issues were created or updated successfully.", }), ); expect(toastMock).toHaveBeenCalledTimes(1); @@ -444,16 +461,18 @@ describe("SendToJiraModal", () => { // Given const user = userEvent.setup(); const onOpenChange = vi.fn(); - pollJiraDispatchTaskMock.mockResolvedValue({ - success: true, - message: "2 Jira issues were created or updated successfully.", - warning: "Jira dispatch completed with 1 failed and 2 created issues.", + trackAndPollTaskMock.mockResolvedValue({ + status: "ready", + result: { created_count: 2, failed_count: 1 }, }); render( , ); @@ -473,7 +492,7 @@ describe("SendToJiraModal", () => { expect(toastMock).toHaveBeenCalledWith({ title: "Partial success", description: - "2 Jira issues were created or updated successfully. Some Jira dispatches failed: Jira dispatch completed with 1 failed and 2 created issues.", + "2 Jira issues were created or updated successfully. Some Jira dispatches failed: Jira dispatch completed with 1 failed and 2 created/updated issues.", }), ); expect(toastMock).not.toHaveBeenCalledWith( @@ -481,6 +500,56 @@ describe("SendToJiraModal", () => { ); }); + it("retries only failed Findings after a partial task result", async () => { + // Given + const user = userEvent.setup(); + trackAndPollTaskMock.mockResolvedValueOnce({ + status: "ready", + result: { + created_count: 1, + failed_count: 1, + failed_finding_ids: ["finding-2"], + error: "Jira rejected one Finding.", + }, + }); + render( + , + ); + await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled()); + await user.click( + screen.getByRole("button", { name: "Select a Jira project" }), + ); + await user.click( + screen.getByRole("button", { name: "Select an issue type" }), + ); + + // When + await user.click(screen.getByRole("button", { name: "Send to Jira" })); + + // Then + await waitFor(() => expect(trackAndPollTaskMock).toHaveBeenCalled()); + const partialToast = toastMock.mock.calls.find( + ([toast]) => toast.title === "Partial success", + )?.[0]; + expect(partialToast?.action).toBeDefined(); + + await partialToast.action.props.onClick(); + + expect(sendFindingToJiraMock).toHaveBeenLastCalledWith( + "jira-1", + "finding-2", + "SEC", + "Task", + ); + }); + it("shows a partial success toast when one mixed dispatch batch fails after another succeeds", async () => { // Given const user = userEvent.setup(); @@ -496,22 +565,21 @@ describe("SendToJiraModal", () => { taskId: "finding-task", message: "Findings started", }); - pollJiraDispatchTaskMock + trackAndPollTaskMock .mockResolvedValueOnce({ - success: true, - message: "Finding successfully sent to Jira!", + status: "ready", + result: { created_count: 1, failed_count: 0 }, }) .mockResolvedValueOnce({ - success: false, - error: "Jira dispatch completed with 1 failed and 1 created issue.", + status: "ready", + result: { created_count: 0, failed_count: 1 }, }); render( { targetIds: ["finding-1", "finding-2"], targetType: "finding_id", }, - ]} + ])} defaultDispatchMode="grouped" selectedResourceCount={1} />, @@ -542,7 +610,7 @@ describe("SendToJiraModal", () => { expect(toastMock).toHaveBeenCalledWith({ title: "Partial success", description: - "Finding successfully sent to Jira! Some Jira dispatches failed: Jira dispatch completed with 1 failed and 1 created issue.", + "Finding successfully sent to Jira! Some Jira dispatches failed: Jira dispatch completed with 1 failed and 0 created/updated issues.", }), ); expect(toastMock).not.toHaveBeenCalledWith( @@ -568,9 +636,8 @@ describe("SendToJiraModal", () => { { targetIds: ["finding-1", "finding-2"], targetType: "finding_id", }, - ]} + ])} defaultDispatchMode="grouped" selectedResourceCount={1} />, @@ -598,15 +665,23 @@ describe("SendToJiraModal", () => { // Then await waitFor(() => - expect(pollJiraDispatchTaskMock).toHaveBeenCalledWith("group-task"), + expect(trackAndPollTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ taskId: "group-task" }), + ), ); await waitFor(() => - expect(toastMock).toHaveBeenCalledWith({ - title: "Partial success", - description: - "Finding successfully sent to Jira! Some Jira dispatches failed: Failed to launch Finding batch.", - }), + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Partial success", + description: + "Finding successfully sent to Jira! Some Jira dispatches failed: Failed to launch Finding batch.", + }), + ), ); + const partialToast = toastMock.mock.calls.find( + ([toast]) => toast.title === "Partial success", + )?.[0]; + expect(partialToast?.action).toBeUndefined(); }); it("reports polling and launch failures together for mixed dispatches", async () => { @@ -623,17 +698,16 @@ describe("SendToJiraModal", () => { success: false, error: "Failed to launch Finding batch.", }); - pollJiraDispatchTaskMock.mockResolvedValueOnce({ - success: false, + trackAndPollTaskMock.mockResolvedValueOnce({ + status: "error", error: "Jira dispatch completed with 1 failed issue.", }); render( { targetIds: ["finding-1", "finding-2"], targetType: "finding_id", }, - ]} + ])} defaultDispatchMode="grouped" selectedResourceCount={1} />, @@ -661,12 +735,14 @@ describe("SendToJiraModal", () => { // Then await waitFor(() => - expect(toastMock).toHaveBeenCalledWith({ - variant: "destructive", - title: "Error", - description: - "Jira dispatch completed with 1 failed issue. Failed to launch Finding batch.", - }), + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + title: "Error", + description: + "Jira dispatch completed with 1 failed issue. Failed to launch Finding batch.", + }), + ), ); }); }); diff --git a/ui/components/findings/send-to-jira-modal.tsx b/ui/components/findings/send-to-jira-modal.tsx index 246ddb7cf0..8cd3b1e1ad 100644 --- a/ui/components/findings/send-to-jira-modal.tsx +++ b/ui/components/findings/send-to-jira-modal.tsx @@ -2,34 +2,46 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Send } from "lucide-react"; -import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; +import { type Dispatch, type SetStateAction, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { getJiraIntegrations, getJiraIssueTypes, - pollJiraDispatchTask, sendFindingToJira, sendJiraDispatch, } from "@/actions/integrations/jira-dispatch"; import { CustomBanner } from "@/components/shadcn/custom/custom-banner"; +import { CustomRadio } from "@/components/shadcn/custom/custom-radio"; import { Form, FormField, FormMessage } from "@/components/shadcn/form"; import { FormButtons } from "@/components/shadcn/form/form-buttons"; import { Modal } from "@/components/shadcn/modal"; -import { - RadioGroup, - RadioGroupItem, -} from "@/components/shadcn/radio-group/radio-group"; +import { RadioGroup } from "@/components/shadcn/radio-group/radio-group"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; -import { toast } from "@/components/shadcn/toast"; +import { toast, ToastAction } from "@/components/shadcn/toast"; +import { useMountEffect } from "@/hooks/use-mount-effect"; import { - IntegrationProps, + evaluateJiraDispatchTask, + getJiraDispatchSuccessCount, +} from "@/lib/jira-dispatch-result"; +import { getJiraSelectionBatches } from "@/lib/jira-dispatch-selection"; +import { buildJiraDispatchTaskMeta } from "@/lib/jira-dispatch-task"; +import { + TASK_WATCHER_STATUS, + type TaskTrackingResult, + trackAndPollTask, +} from "@/store/task-watcher/store"; +import { + type IntegrationProps, JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET, + JIRA_DISPATCH_TASK_KIND, type JiraDispatchMode, - type JiraDispatchTarget, + type JiraDispatchTargetBatch, + type JiraDispatchTaskResult, + type JiraSelection, } from "@/types/integrations"; import { @@ -37,16 +49,10 @@ import { JIRA_SELECTION_KIND, } from "./send-to-jira-modal-copy"; -interface JiraDispatchTargetBatch { - targetIds: string[]; - targetType: JiraDispatchTarget; - dispatchMode?: JiraDispatchMode; -} - -interface SendToJiraModalBaseProps { +export interface SendToJiraModalProps { isOpen: boolean; onOpenChange: (open: boolean) => void; - findingId: string; + selection: JiraSelection; findingTitle?: string; defaultDispatchMode?: JiraDispatchMode; canChooseGroupedDispatch?: boolean; @@ -55,29 +61,27 @@ interface SendToJiraModalBaseProps { description?: string; } -interface SendToJiraSingleTargetProps extends SendToJiraModalBaseProps { - targetIds?: never; - targetType?: never; - targetBatches?: never; +interface JiraDispatchSettings { + integrationId: string; + projectKey: string; + issueType: string; + dispatchMode: JiraDispatchMode; } -interface SendToJiraTargetListProps extends SendToJiraModalBaseProps { - targetIds: string[]; - targetType: JiraDispatchTarget; - targetBatches?: never; +interface StartedJiraTask { + taskId: string; + dispatchMode: JiraDispatchMode; } -interface SendToJiraBatchProps extends SendToJiraModalBaseProps { - targetIds?: never; - targetType?: never; - targetBatches: JiraDispatchTargetBatch[]; +interface JiraTrackedOutcome { + success: boolean; + message?: string; + error?: string; + warning?: string; + retryBatch?: JiraDispatchTargetBatch; + successfulCount?: number; } -type SendToJiraModalProps = - | SendToJiraSingleTargetProps - | SendToJiraTargetListProps - | SendToJiraBatchProps; - const sendToJiraSchema = z.object({ integration: z.string().min(1, "Please select a Jira integration"), project: z.string().min(1, "Please select a project"), @@ -90,39 +94,46 @@ const sendToJiraSchema = z.object({ type SendToJiraFormData = z.infer; -const JIRA_TASK_TIMEOUT_ERROR = "Task timeout"; -const JIRA_TASK_POLL_ROUNDS = 15; +const getConfiguredIssueTypes = ( + integration: IntegrationProps | undefined, + projectKey: string, +) => { + const configuredIssueTypes = integration?.attributes.configuration + .issue_types as Record | undefined; -const pollJiraDispatchTaskUntilDone = async (taskId: string) => { - for (let round = 0; round < JIRA_TASK_POLL_ROUNDS; round++) { - const result = await pollJiraDispatchTask(taskId); - if (result.success || result.error !== JIRA_TASK_TIMEOUT_ERROR) { - return result; - } - } - - return { - success: false, - error: "The Jira dispatch task is taking too long. Try again later.", - } as const; + return configuredIssueTypes && + typeof configuredIssueTypes === "object" && + !Array.isArray(configuredIssueTypes) + ? (configuredIssueTypes[projectKey] ?? []) + : []; }; -export const SendToJiraModal = ({ - isOpen, +const getRetryBatch = ( + failedFindingIds: string[] | undefined, +): JiraDispatchTargetBatch | undefined => { + const [firstFindingId, ...remainingFindingIds] = + failedFindingIds?.filter(Boolean) ?? []; + if (!firstFindingId) return undefined; + + return { + targetIds: [firstFindingId, ...remainingFindingIds], + targetType: JIRA_DISPATCH_TARGET.FINDING_ID, + dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL, + }; +}; + +const SendToJiraModalContent = ({ onOpenChange, - findingId, + selection, findingTitle, - targetIds, - targetType = JIRA_DISPATCH_TARGET.FINDING_ID, - targetBatches, defaultDispatchMode = JIRA_DISPATCH_MODE.INDIVIDUAL, canChooseGroupedDispatch = false, isFindingGroupSelection = false, selectedResourceCount, description, -}: SendToJiraModalProps) => { +}: Omit) => { const [integrations, setIntegrations] = useState([]); - const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(false); + const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(true); const [fetchedIssueTypes, setFetchedIssueTypes] = useState< Record >({}); @@ -138,27 +149,14 @@ export const SendToJiraModal = ({ }, }); - const jiraTargetIds = targetIds?.length ? targetIds : [findingId]; - const jiraTargetBatches = targetBatches?.length - ? targetBatches.filter((batch) => batch.targetIds.length > 0) - : [ - { - targetIds: jiraTargetIds, - targetType, - }, - ]; - const multiFindingTargetCount = - jiraTargetBatches.find( - (batch) => - batch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID && - batch.targetIds.length > 1, - )?.targetIds.length ?? 0; - const jiraSelectedResourceCount = - selectedResourceCount ?? jiraTargetIds.length; - const hasMultipleFindingTargets = multiFindingTargetCount > 1; + const jiraTargetBatches = getJiraSelectionBatches(selection); + const findingTargetCount = jiraTargetBatches + .filter((batch) => batch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID) + .reduce((count, batch) => count + batch.targetIds.length, 0); + const jiraSelectedResourceCount = selectedResourceCount ?? findingTargetCount; const shouldShowDispatchChoice = - (canChooseGroupedDispatch || hasMultipleFindingTargets) && - (multiFindingTargetCount > 1 || jiraSelectedResourceCount > 1); + (canChooseGroupedDispatch || findingTargetCount > 1) && + (findingTargetCount > 1 || jiraSelectedResourceCount > 1); const checkIdBatches = jiraTargetBatches.filter( (batch) => batch.targetType === JIRA_DISPATCH_TARGET.CHECK_ID, ); @@ -171,254 +169,318 @@ export const SendToJiraModal = ({ (isFindingGroupSelection || hasOnlySingleFindingGroupBatch); const jiraDispatchChoiceCopy = buildJiraDispatchChoiceCopy({ selectedCount: - multiFindingTargetCount > 1 - ? multiFindingTargetCount - : jiraSelectedResourceCount, + findingTargetCount > 1 ? findingTargetCount : jiraSelectedResourceCount, isSelectedFindingGroupFlow, selectionKind: - multiFindingTargetCount > 1 + findingTargetCount > 1 ? JIRA_SELECTION_KIND.FINDINGS : JIRA_SELECTION_KIND.RESOURCES, }); const selectedIntegration = form.watch("integration"); - + const selectedProject = form.watch("project"); + const selectedIntegrationData = integrations.find( + (integration) => integration.id === selectedIntegration, + ); + const projects = + selectedIntegrationData?.attributes.configuration.projects ?? {}; + const projectEntries = Object.entries(projects); + const configuredIssueTypes = getConfiguredIssueTypes( + selectedIntegrationData, + selectedProject, + ); + const issueTypesForProject = + configuredIssueTypes.length > 0 + ? configuredIssueTypes + : (fetchedIssueTypes[`${selectedIntegration}:${selectedProject}`] ?? []); const hasConnectedIntegration = integrations.some( - (i) => i.attributes.connected === true, + (integration) => integration.attributes.connected === true, ); - const setOpenForFormButtons: Dispatch> = (value) => { - const next = typeof value === "function" ? value(isOpen) : value; - onOpenChange(next); - }; - - // Fetch Jira integrations when modal opens - useEffect(() => { - if (isOpen) { - const fetchJiraIntegrations = async () => { - setIsFetchingIntegrations(true); - - try { - const result = await getJiraIntegrations(); - if (!result.success) { - throw new Error( - result.error || "Unable to fetch Jira integrations", - ); - } - setIntegrations(result.data); - // Auto-select if only one integration - if (result.data.length === 1) { - form.setValue("integration", result.data[0].id); - } - } catch (error) { - const message = - error instanceof Error && error.message - ? error.message - : "Failed to load Jira integrations"; - toast({ - variant: "destructive", - title: "Failed to load integrations", - description: message, - }); - } finally { - setIsFetchingIntegrations(false); - } - }; - - fetchJiraIntegrations(); - } else { - // Reset form and fetched data when modal closes - form.reset(); - setFetchedIssueTypes({}); - } - }, [isOpen, form]); - - useEffect(() => { - if (isOpen) { - form.setValue("dispatchMode", defaultDispatchMode); - } - }, [defaultDispatchMode, form, isOpen]); - - const handleSubmit = async (data: SendToJiraFormData) => { - // Close modal immediately; continue processing in background - onOpenChange(false); + useMountEffect(() => { + let active = true; void (async () => { try { - const taskIds: string[] = []; - const launchErrors: string[] = []; - - for (const batch of jiraTargetBatches) { - const batchDispatchMode = batch.dispatchMode ?? data.dispatchMode; - const result = - batch.targetIds.length === 1 && - batch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID && - batchDispatchMode === JIRA_DISPATCH_MODE.INDIVIDUAL - ? await sendFindingToJira( - data.integration, - batch.targetIds[0], - data.project, - data.issueType, - ) - : await sendJiraDispatch({ - integrationId: data.integration, - targetIds: batch.targetIds, - filter: batch.targetType, - projectKey: data.project, - issueType: data.issueType, - dispatchMode: batchDispatchMode, - }); - - if (!result.success) { - launchErrors.push(result.error || "Failed to send to Jira"); - continue; - } - - taskIds.push(result.taskId); + const result = await getJiraIntegrations(); + if (!active) return; + if (!result.success) { + throw new Error(result.error || "Unable to fetch Jira integrations"); } - if (taskIds.length === 0 && launchErrors.length > 0) { - throw new Error(launchErrors.join(" ")); + setIntegrations(result.data); + if (result.data.length === 1) { + form.setValue("integration", result.data[0].id); } - - // Poll for task completion and notify once - const taskResults = await Promise.all( - taskIds.map((taskId) => pollJiraDispatchTaskUntilDone(taskId)), - ); - const errors = [ - ...taskResults - .filter((taskResult) => !taskResult.success) - .map( - (taskResult) => taskResult.error || "Failed to create Jira issue", - ), - ...launchErrors, - ]; - const warnings = taskResults.flatMap((taskResult) => { - if (!taskResult.success || !taskResult.warning) return []; - return [taskResult.warning]; - }); - - if (errors.length > 0 || warnings.length > 0) { - const successfulTask = taskResults.find( - (taskResult) => taskResult.success, - ); - if (successfulTask) { - const failedMessages = [...warnings, ...errors].join(" "); - toast({ - title: "Partial success", - description: `${successfulTask.message || "Some Jira issues were created successfully."} Some Jira dispatches failed: ${failedMessages}`, - }); - return; - } - - throw new Error(errors.join(" ")); - } - - toast({ - title: "Success!", - description: - taskResults.find((taskResult) => taskResult.success)?.message || - "Finding sent to Jira successfully", - }); } catch (error) { + if (!active) return; const message = error instanceof Error && error.message ? error.message - : "Failed to send finding to Jira"; + : "Failed to load Jira integrations"; toast({ variant: "destructive", - title: "Error", + title: "Failed to load integrations", description: message, }); + } finally { + if (active) setIsFetchingIntegrations(false); } })(); - }; - - const selectedProject = form.watch("project"); - - const selectedIntegrationData = integrations.find( - (i) => i.id === selectedIntegration, - ); - - const projects: Record = - selectedIntegrationData?.attributes.configuration.projects ?? - ({} as Record); - - 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 | 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; + active = false; }; - }, [ - selectedIntegration, - selectedProject, - issueTypesFromConfig.length, - fetchedIssueTypes, - ]); + }); + + const setOpenForFormButtons: Dispatch> = (value) => { + const nextOpen = typeof value === "function" ? value(true) : value; + onOpenChange(nextOpen); + }; + + const loadIssueTypes = async (integrationId: string, projectKey: string) => { + const integration = integrations.find((item) => item.id === integrationId); + if ( + !integrationId || + !projectKey || + getConfiguredIssueTypes(integration, projectKey).length > 0 || + fetchedIssueTypes[`${integrationId}:${projectKey}`] + ) { + return; + } + + setIsFetchingIssueTypes(true); + try { + const result = await getJiraIssueTypes(integrationId, projectKey); + if (result.success) { + setFetchedIssueTypes((current) => ({ + ...current, + [`${integrationId}:${projectKey}`]: result.issueTypes, + })); + return; + } + + toast({ + variant: "destructive", + title: "Failed to load issue types", + description: + result.error || "Unable to fetch issue types for this project", + }); + } catch { + toast({ + variant: "destructive", + title: "Failed to load issue types", + description: "Unable to fetch issue types for this project", + }); + } finally { + setIsFetchingIssueTypes(false); + } + }; + + async function processBatches( + batches: JiraDispatchTargetBatch[], + settings: JiraDispatchSettings, + ) { + const startedTasks: StartedJiraTask[] = []; + const launchErrors: string[] = []; + const unknownLaunchErrors: string[] = []; + + for (const batch of batches) { + const dispatchMode = batch.dispatchMode ?? settings.dispatchMode; + try { + const result = + batch.targetIds.length === 1 && + batch.targetType === JIRA_DISPATCH_TARGET.FINDING_ID && + dispatchMode === JIRA_DISPATCH_MODE.INDIVIDUAL + ? await sendFindingToJira( + settings.integrationId, + batch.targetIds[0], + settings.projectKey, + settings.issueType, + ) + : await sendJiraDispatch({ + integrationId: settings.integrationId, + targetIds: batch.targetIds, + filter: batch.targetType, + projectKey: settings.projectKey, + issueType: settings.issueType, + dispatchMode, + }); + + if (!result.success) { + launchErrors.push(result.error || "Failed to send to Jira"); + continue; + } + + startedTasks.push({ taskId: result.taskId, dispatchMode }); + } catch { + // The request may have reached the server before the RPC failed. Do + // not offer an automatic retry because that could create duplicates. + unknownLaunchErrors.push( + "The Jira dispatch status is unknown after a connection error. Check Jira before retrying.", + ); + } + } + + const trackedOutcomes = await Promise.all( + startedTasks.map(async ({ taskId, dispatchMode }) => { + let trackedTask: TaskTrackingResult; + try { + trackedTask = await trackAndPollTask({ + taskId, + kind: JIRA_DISPATCH_TASK_KIND, + meta: buildJiraDispatchTaskMeta({ + integrationId: settings.integrationId, + projectKey: settings.projectKey, + issueType: settings.issueType, + dispatchMode, + }), + notifyHandler: false, + }); + } catch { + return { + success: false, + error: + "Tracking the Jira dispatch failed unexpectedly. Check Jira before retrying.", + } satisfies JiraTrackedOutcome; + } + + if (trackedTask.status !== TASK_WATCHER_STATUS.READY) { + return { + success: false, + error: trackedTask.error || "Failed to track Jira issue creation.", + } satisfies JiraTrackedOutcome; + } + + const outcome = evaluateJiraDispatchTask( + "completed", + trackedTask.result, + ); + if (outcome.success) { + return { + success: true, + message: outcome.message, + warning: outcome.warning, + retryBatch: getRetryBatch(outcome.failedFindingIds), + successfulCount: getJiraDispatchSuccessCount(trackedTask.result), + } satisfies JiraTrackedOutcome; + } + + return { + success: false, + error: outcome.error, + retryBatch: getRetryBatch(outcome.failedFindingIds), + } satisfies JiraTrackedOutcome; + }), + ); + + const successfulOutcomes = trackedOutcomes.filter( + (outcome) => outcome.success, + ); + const warnings = Array.from( + new Set( + trackedOutcomes.flatMap((outcome) => + outcome.warning ? [outcome.warning] : [], + ), + ), + ); + const successfulIssueCount = successfulOutcomes.reduce( + (count, outcome) => count + (outcome.successfulCount ?? 0), + 0, + ); + const successMessage = + successfulOutcomes.length === 1 + ? successfulOutcomes[0].message + : `${successfulIssueCount} Jira issues were created or updated successfully.`; + const errors = Array.from( + new Set([ + ...trackedOutcomes.flatMap((outcome) => + outcome.error ? [outcome.error] : [], + ), + ...launchErrors, + ...unknownLaunchErrors, + ]), + ); + const retryBatch = getRetryBatch( + Array.from( + new Set( + trackedOutcomes.flatMap( + (outcome) => outcome.retryBatch?.targetIds ?? [], + ), + ), + ), + ); + const retryBatches = retryBatch ? [retryBatch] : []; + const retryAction = + retryBatches.length > 0 ? ( + { + toast({ + title: "Retry started", + description: "Retrying only the Jira dispatches that failed.", + }); + await processBatches(retryBatches, settings); + }} + > + Retry failed + + ) : undefined; + + if (errors.length > 0 || warnings.length > 0) { + if (successfulOutcomes.length > 0) { + toast({ + title: "Partial success", + description: `${successMessage || "Some Jira issues were created successfully."} Some Jira dispatches failed: ${[ + ...warnings, + ...errors, + ].join(" ")}`, + ...(retryAction ? { action: retryAction } : {}), + }); + return; + } + + toast({ + variant: "destructive", + title: "Error", + description: [...warnings, ...errors].join(" "), + ...(retryAction ? { action: retryAction } : {}), + }); + return; + } + + toast({ + title: "Success!", + description: successMessage || "Finding sent to Jira successfully", + }); + } + + const handleSubmit = async (data: SendToJiraFormData) => { + onOpenChange(false); + + void processBatches(jiraTargetBatches, { + integrationId: data.integration, + projectKey: data.project, + issueType: data.issueType, + dispatchMode: data.dispatchMode, + }).catch(() => { + toast({ + variant: "destructive", + title: "Error", + description: + "The Jira dispatch could not be processed. Check Jira before retrying.", + }); + }); + }; const issueTypeOptions = issueTypesForProject.map((type) => ({ value: type, label: type, })); - const integrationOptions = integrations.map((integration) => ({ value: integration.id, label: integration.attributes.configuration.domain || integration.id, })); - const projectOptions = projectEntries.map(([key, name]) => ({ value: key, label: `${key} - ${name}`, @@ -426,7 +488,7 @@ export const SendToJiraModal = ({ return ( - {/* Loading skeleton for project selector */} {isFetchingIntegrations && (
@@ -452,7 +513,6 @@ export const SendToJiraModal = ({
)} - {/* Integration Selection */} {!isFetchingIntegrations && integrations.length > 1 && ( { - const selectedValue = values.at(-1) ?? ""; - field.onChange(selectedValue); - // Reset dependent fields + field.onChange(values.at(-1) ?? ""); form.setValue("project", ""); form.setValue("issueType", ""); setFetchedIssueTypes({}); }} defaultValue={field.value ? [field.value] : []} placeholder="Select a Jira integration" - searchable={true} + searchable emptyIndicator="No integrations found." disabled={isFetchingIntegrations} - hideSelectAll={true} + hideSelectAll maxCount={1} - closeOnSelect={true} - resetOnDefaultValueChange={true} + closeOnSelect + resetOnDefaultValueChange /> @@ -492,7 +550,6 @@ export const SendToJiraModal = ({ /> )} - {/* Project Selection */} {!isFetchingIntegrations && selectedIntegration && projectEntries.length > 0 && ( @@ -511,19 +568,19 @@ export const SendToJiraModal = ({ id="jira-project-select" options={projectOptions} onValueChange={(values) => { - const selectedValue = values.at(-1) ?? ""; - field.onChange(selectedValue); - // Reset issue type when project changes + const projectKey = values.at(-1) ?? ""; + field.onChange(projectKey); form.setValue("issueType", ""); + void loadIssueTypes(selectedIntegration, projectKey); }} defaultValue={field.value ? [field.value] : []} placeholder="Select a Jira project" - searchable={true} + searchable emptyIndicator="No projects found." - hideSelectAll={true} + hideSelectAll maxCount={1} - closeOnSelect={true} - resetOnDefaultValueChange={true} + closeOnSelect + resetOnDefaultValueChange /> @@ -531,7 +588,6 @@ export const SendToJiraModal = ({ /> )} - {/* Issue Type Selection */} {selectedProject && ( { - const selectedValue = values.at(-1) ?? ""; - field.onChange(selectedValue); - }} + onValueChange={(values) => + field.onChange(values.at(-1) ?? "") + } defaultValue={field.value ? [field.value] : []} placeholder={ isFetchingIssueTypes ? "Loading issue types..." : "Select an issue type" } - searchable={true} + searchable emptyIndicator="No issue types found." disabled={isFetchingIssueTypes} - hideSelectAll={true} + hideSelectAll maxCount={1} - closeOnSelect={true} - resetOnDefaultValueChange={true} + closeOnSelect + resetOnDefaultValueChange /> @@ -583,13 +638,11 @@ export const SendToJiraModal = ({ - - + @@ -620,7 +672,6 @@ export const SendToJiraModal = ({ /> )} - {/* No integrations or none connected message */} {!isFetchingIntegrations && (integrations.length === 0 || !hasConnectedIntegration) ? ( ); }; + +export const SendToJiraModal = ({ isOpen, ...props }: SendToJiraModalProps) => { + if (!isOpen) return null; + + return ; +}; diff --git a/ui/components/findings/table/column-finding-resources.test.tsx b/ui/components/findings/table/column-finding-resources.test.tsx index 9cbc1794b1..e66d04ae66 100644 --- a/ui/components/findings/table/column-finding-resources.test.tsx +++ b/ui/components/findings/table/column-finding-resources.test.tsx @@ -7,17 +7,20 @@ import type { } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +interface JiraModalMockProps { + selection: { targetId?: string }; + isOpen: boolean; +} + const { SendToJiraModalMock, isGroupedJiraDispatchEnabledMock } = vi.hoisted( () => ({ - SendToJiraModalMock: vi.fn( - ({ findingId, isOpen }: { findingId: string; isOpen: boolean }) => ( -
- ), - ), + SendToJiraModalMock: vi.fn(({ selection, isOpen }: JiraModalMockProps) => ( +
+ )), isGroupedJiraDispatchEnabledMock: vi.fn(() => true), }), ); @@ -575,8 +578,11 @@ describe("column-finding-resources", () => { // Then expect(SendToJiraModalMock).toHaveBeenLastCalledWith( expect.objectContaining({ - targetIds: ["finding-1", "finding-2"], - targetType: "finding_id", + selection: { + kind: "target-list", + targetIds: ["finding-1", "finding-2"], + targetType: "finding_id", + }, defaultDispatchMode: "grouped", canChooseGroupedDispatch: true, }), diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx index 2abe560386..e83c01e8ef 100644 --- a/ui/components/findings/table/column-finding-resources.tsx +++ b/ui/components/findings/table/column-finding-resources.tsx @@ -23,6 +23,7 @@ import { isGroupedJiraDispatchEnabled, PROWLER_CLOUD_ONLY_TOOLTIP, } from "@/lib/deployment"; +import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection"; import { FindingResourceRow } from "@/types"; import type { FindingTriageLoadedNote, @@ -87,6 +88,10 @@ const ResourceRowActions = ({ }; const displayIds = getDisplayIds(); const canSendToJira = displayIds.length === 1 || groupedJiraDispatchEnabled; + const jiraSelection = createJiraTargetSelection( + displayIds, + JIRA_DISPATCH_TARGET.FINDING_ID, + ); const handleMuteClick = async () => { const displayIds = getDisplayIds(); @@ -127,22 +132,22 @@ const ResourceRowActions = ({ onComplete={handleMuteComplete} /> )} - 1 - ? JIRA_DISPATCH_MODE.GROUPED - : JIRA_DISPATCH_MODE.INDIVIDUAL - } - canChooseGroupedDispatch={ - displayIds.length > 1 && groupedJiraDispatchEnabled - } - /> + {jiraSelection && ( + 1 + ? JIRA_DISPATCH_MODE.GROUPED + : JIRA_DISPATCH_MODE.INDIVIDUAL + } + canChooseGroupedDispatch={ + displayIds.length > 1 && groupedJiraDispatchEnabled + } + /> + )}
e.stopPropagation()} diff --git a/ui/components/findings/table/data-table-row-actions.test.tsx b/ui/components/findings/table/data-table-row-actions.test.tsx index 20798eecc2..7303d6250a 100644 --- a/ui/components/findings/table/data-table-row-actions.test.tsx +++ b/ui/components/findings/table/data-table-row-actions.test.tsx @@ -306,8 +306,11 @@ describe("DataTableRowActions", () => { // Then expect(SendToJiraModalMock).toHaveBeenLastCalledWith( expect.objectContaining({ - targetIds: ["s3_bucket_public_access"], - targetType: "check_id", + selection: { + kind: "single", + targetId: "s3_bucket_public_access", + targetType: "check_id", + }, defaultDispatchMode: "grouped", canChooseGroupedDispatch: true, selectedResourceCount: 2, @@ -406,8 +409,11 @@ describe("DataTableRowActions", () => { // Then expect(SendToJiraModalMock).toHaveBeenLastCalledWith( expect.objectContaining({ - targetIds: ["s3_bucket_public_access"], - targetType: "check_id", + selection: { + kind: "single", + targetId: "s3_bucket_public_access", + targetType: "check_id", + }, defaultDispatchMode: "grouped", canChooseGroupedDispatch: false, selectedResourceCount: 1, @@ -455,8 +461,11 @@ describe("DataTableRowActions", () => { // Then expect(SendToJiraModalMock).toHaveBeenLastCalledWith( expect.objectContaining({ - targetIds: ["check-a", "check-b"], - targetType: "check_id", + selection: { + kind: "target-list", + targetIds: ["check-a", "check-b"], + targetType: "check_id", + }, defaultDispatchMode: "grouped", canChooseGroupedDispatch: false, }), @@ -488,8 +497,11 @@ describe("DataTableRowActions", () => { // Then expect(SendToJiraModalMock).toHaveBeenLastCalledWith( expect.objectContaining({ - targetIds: ["finding-1", "finding-2"], - targetType: "finding_id", + selection: { + kind: "target-list", + targetIds: ["finding-1", "finding-2"], + targetType: "finding_id", + }, defaultDispatchMode: "grouped", canChooseGroupedDispatch: true, }), @@ -526,8 +538,11 @@ describe("DataTableRowActions", () => { expect(SendToJiraModalMock).toHaveBeenLastCalledWith( expect.objectContaining({ isOpen: true, - targetIds: ["finding-1"], - targetType: "finding_id", + selection: { + kind: "single", + targetId: "finding-1", + targetType: "finding_id", + }, defaultDispatchMode: "individual", canChooseGroupedDispatch: false, }), diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx index 55b3c6e7fe..32cfb5650c 100644 --- a/ui/components/findings/table/data-table-row-actions.tsx +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -18,6 +18,7 @@ import { PROWLER_CLOUD_ONLY_TOOLTIP, } from "@/lib/deployment"; import { isFindingGroupMuted } from "@/lib/findings-groups"; +import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection"; import { getOptionalText } from "@/lib/utils"; import type { FindingTriageLoadedNote, @@ -188,6 +189,13 @@ export function DataTableRowActions({ }; const jiraTargetIds = getJiraTargetIds(); + const jiraTargetType = isGroup + ? JIRA_DISPATCH_TARGET.CHECK_ID + : JIRA_DISPATCH_TARGET.FINDING_ID; + const jiraSelection = createJiraTargetSelection( + jiraTargetIds, + jiraTargetType, + ); const isJiraActionDisabled = (isGroup || jiraTargetIds.length > 1) && !groupedJiraDispatchEnabled; const selectedJiraResourceCount = isGroup @@ -272,18 +280,12 @@ export function DataTableRowActions({ return ( <> - {(!isGroup || groupedJiraDispatchEnabled) && ( + {(!isGroup || groupedJiraDispatchEnabled) && jiraSelection && ( { it("routes selected child findings through the Send to Jira modal with issue creation mode", () => { expect(source).toContain(" 1 && groupedJiraDispatchEnabled", ); diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx index f10bb10c30..99458828d1 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -35,6 +35,7 @@ import { getFindingGroupImpactedCounts, isFindingGroupMuted, } from "@/lib/findings-groups"; +import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection"; import { FindingGroupRow } from "@/types"; import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; @@ -122,6 +123,10 @@ export function FindingsGroupDrillDown({ const rows = table.getRowModel().rows; const canSendSelectedFindingsToJira = selectedFindingIds.length === 1 || groupedJiraDispatchEnabled; + const jiraSelection = createJiraTargetSelection( + selectedFindingIds, + JIRA_DISPATCH_TARGET.FINDING_ID, + ); return ( )} - 1 - ? JIRA_DISPATCH_MODE.GROUPED - : JIRA_DISPATCH_MODE.INDIVIDUAL - } - canChooseGroupedDispatch={ - selectedFindingIds.length > 1 && groupedJiraDispatchEnabled - } - /> + {jiraSelection && ( + 1 + ? JIRA_DISPATCH_MODE.GROUPED + : JIRA_DISPATCH_MODE.INDIVIDUAL + } + canChooseGroupedDispatch={ + selectedFindingIds.length > 1 && groupedJiraDispatchEnabled + } + /> + )} { const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; expect(lastCall).toMatchObject({ isOpen: true, - targetBatches: [ - { - targetIds: ["check-a"], - targetType: "check_id", - dispatchMode: "grouped", - }, - { - targetIds: ["finding-1"], - targetType: "finding_id", - dispatchMode: "individual", - }, - ], + selection: { + kind: "batches", + batches: [ + { + targetIds: ["check-a"], + targetType: "check_id", + dispatchMode: "grouped", + }, + { + targetIds: ["finding-1"], + targetType: "finding_id", + dispatchMode: "individual", + }, + ], + }, canChooseGroupedDispatch: false, description: "Create Jira issues for 1 Group and 1 Finding.", }); @@ -639,18 +642,21 @@ describe("FindingsGroupTable", () => { const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; expect(lastCall).toMatchObject({ isOpen: true, - targetBatches: [ - { - targetIds: ["check-b"], - targetType: "check_id", - dispatchMode: "grouped", - }, - { - targetIds: ["finding-1"], - targetType: "finding_id", - dispatchMode: "individual", - }, - ], + selection: { + kind: "batches", + batches: [ + { + targetIds: ["check-b"], + targetType: "check_id", + dispatchMode: "grouped", + }, + { + targetIds: ["finding-1"], + targetType: "finding_id", + dispatchMode: "individual", + }, + ], + }, canChooseGroupedDispatch: false, description: "Create Jira issues for 1 Group and 1 Finding.", }); @@ -703,25 +709,28 @@ describe("FindingsGroupTable", () => { // Then const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0] as { - targetBatches: Array>; + selection: { batches: Array> }; }; expect(lastCall).toMatchObject({ isOpen: true, - targetBatches: [ - { - targetIds: ["check-b"], - targetType: "check_id", - dispatchMode: "grouped", - }, - { - targetIds: ["finding-1", "finding-2"], - targetType: "finding_id", - }, - ], + selection: { + kind: "batches", + batches: [ + { + targetIds: ["check-b"], + targetType: "check_id", + dispatchMode: "grouped", + }, + { + targetIds: ["finding-1", "finding-2"], + targetType: "finding_id", + }, + ], + }, canChooseGroupedDispatch: false, description: "Create Jira issues for 1 Group and 2 Findings.", }); - expect(lastCall.targetBatches[1]).not.toHaveProperty("dispatchMode"); + expect(lastCall.selection.batches[1]).not.toHaveProperty("dispatchMode"); }); it("should route choosing Mute through the existing mute resolver", async () => { @@ -848,8 +857,11 @@ describe("FindingsGroupTable", () => { const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; expect(lastCall).toMatchObject({ isOpen: true, - targetIds: ["finding-1"], - targetType: "finding_id", + selection: { + kind: "single", + targetId: "finding-1", + targetType: "finding_id", + }, defaultDispatchMode: "individual", canChooseGroupedDispatch: false, }); @@ -893,8 +905,11 @@ describe("FindingsGroupTable", () => { const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; expect(lastCall).toMatchObject({ isOpen: true, - targetIds: ["finding-1", "finding-2"], - targetType: "finding_id", + selection: { + kind: "target-list", + targetIds: ["finding-1", "finding-2"], + targetType: "finding_id", + }, defaultDispatchMode: "grouped", canChooseGroupedDispatch: true, }); @@ -1025,8 +1040,11 @@ describe("FindingsGroupTable", () => { const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; expect(lastCall).toMatchObject({ isOpen: true, - targetIds: ["check-a"], - targetType: "check_id", + selection: { + kind: "single", + targetId: "check-a", + targetType: "check_id", + }, defaultDispatchMode: "grouped", canChooseGroupedDispatch: true, selectedResourceCount: 2, @@ -1068,8 +1086,11 @@ describe("FindingsGroupTable", () => { const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; expect(lastCall).toMatchObject({ isOpen: true, - targetIds: ["check-a"], - targetType: "check_id", + selection: { + kind: "single", + targetId: "check-a", + targetType: "check_id", + }, defaultDispatchMode: "grouped", canChooseGroupedDispatch: false, selectedResourceCount: 1, @@ -1118,8 +1139,11 @@ describe("FindingsGroupTable", () => { const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0]; expect(lastCall).toMatchObject({ isOpen: true, - targetIds: ["check-a", "check-b"], - targetType: "check_id", + selection: { + kind: "target-list", + targetIds: ["check-a", "check-b"], + targetType: "check_id", + }, defaultDispatchMode: "grouped", canChooseGroupedDispatch: false, selectedResourceCount: 2, diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx index 71c3105b0b..92cd8e408c 100644 --- a/ui/components/findings/table/findings-group-table.tsx +++ b/ui/components/findings/table/findings-group-table.tsx @@ -2,7 +2,7 @@ import { Row, RowSelectionState } from "@tanstack/react-table"; import { useRouter, useSearchParams } from "next/navigation"; -import { Suspense, useEffect, useRef, useState } from "react"; +import { Suspense, useRef, useState } from "react"; import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource"; import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings"; @@ -14,6 +14,10 @@ import { PROWLER_CLOUD_ONLY_TOOLTIP, } from "@/lib/deployment"; import { canDrillDownFindingGroup } from "@/lib/findings-groups"; +import { + createJiraBatchSelection, + createJiraTargetSelection, +} from "@/lib/jira-dispatch-selection"; import { getFlowById } from "@/lib/onboarding"; import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findings.tour"; import { FindingGroupRow, MetaDataProps } from "@/types"; @@ -89,65 +93,69 @@ export function FindingsGroupTable({ hasHistoricalData, expandedCheckId: requestedExpandedCheckId, }: FindingsGroupTableProps) { + const safeData = data ?? EMPTY_FINDING_GROUPS; + const requestedGroup = requestedExpandedCheckId + ? safeData.find((group) => group.checkId === requestedExpandedCheckId) + : undefined; + const initialExpandedCheckId = + requestedGroup && canDrillDownFindingGroup(requestedGroup) + ? requestedGroup.checkId + : null; + + return ( + + ); +} + +interface FindingsGroupTableContentProps { + data: FindingGroupRow[]; + metadata?: MetaDataProps; + resolvedFilters: Record; + hasHistoricalData: boolean; + initialExpandedCheckId: string | null; +} + +const FindingsGroupTableContent = ({ + data, + metadata, + resolvedFilters, + hasHistoricalData, + initialExpandedCheckId, +}: FindingsGroupTableContentProps) => { const router = useRouter(); const searchParams = useSearchParams(); const [rowSelection, setRowSelection] = useState({}); - const [expandedCheckId, setExpandedCheckId] = useState(null); - const [expandedGroup, setExpandedGroup] = useState( - null, - ); + const [selectedExpandedCheckId, setSelectedExpandedCheckId] = useState< + string | null + >(initialExpandedCheckId); // Separate input (keystroke) from committed search (Enter) to avoid remounting InlineResourceContainer. const [resourceSearchInput, setResourceSearchInput] = useState(""); const [resourceSearch, setResourceSearch] = useState(""); const [resourceSelection, setResourceSelection] = useState([]); const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); const inlineRef = useRef(null); - const previousRequestedExpandedCheckIdRef = useRef( - undefined, - ); const safeData = data ?? EMPTY_FINDING_GROUPS; - const hasResourceSelection = resourceSelection.length > 0; + const expandedGroupCandidate = selectedExpandedCheckId + ? safeData.find((group) => group.checkId === selectedExpandedCheckId) + : undefined; + const expandedGroup = + expandedGroupCandidate && canDrillDownFindingGroup(expandedGroupCandidate) + ? expandedGroupCandidate + : null; + const expandedCheckId = expandedGroup?.checkId ?? null; + const activeResourceSelection = expandedCheckId ? resourceSelection : []; + const hasResourceSelection = activeResourceSelection.length > 0; const filters = resolvedFilters; const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled(); - useEffect(() => { - const previousRequestedExpandedCheckId = - previousRequestedExpandedCheckIdRef.current; - previousRequestedExpandedCheckIdRef.current = requestedExpandedCheckId; - - if (!requestedExpandedCheckId) { - if (previousRequestedExpandedCheckId) { - setExpandedCheckId(null); - setExpandedGroup(null); - setResourceSearchInput(""); - setResourceSearch(""); - setResourceSelection([]); - } - - return; - } - - const requestedGroup = safeData.find( - (group) => group.checkId === requestedExpandedCheckId, - ); - - if (!requestedGroup || !canDrillDownFindingGroup(requestedGroup)) { - setExpandedCheckId(null); - setExpandedGroup(null); - setResourceSearchInput(""); - setResourceSearch(""); - setResourceSelection([]); - return; - } - - setExpandedCheckId(requestedExpandedCheckId); - setExpandedGroup(requestedGroup); - setResourceSearchInput(""); - setResourceSearch(""); - setResourceSelection([]); - }, [requestedExpandedCheckId, safeData]); - // Exclude expanded group from group-level mutes when it has resource selections. const selectedCheckIds = Object.keys(rowSelection) .filter((key) => rowSelection[key]) @@ -169,7 +177,7 @@ export function FindingsGroupTable({ const jiraGroupSelectionTakesPrecedence = selectedCheckIds.length > 0; const jiraTargetIds = jiraGroupSelectionTakesPrecedence ? selectedCheckIds - : resourceSelection; + : activeResourceSelection; const jiraTargetType = jiraGroupSelectionTakesPrecedence ? JIRA_DISPATCH_TARGET.CHECK_ID : JIRA_DISPATCH_TARGET.FINDING_ID; @@ -183,53 +191,53 @@ export function FindingsGroupTable({ ? singleSelectedGroup ? singleSelectedGroup.resourcesFail : selectedCheckIds.length - : resourceSelection.length; + : activeResourceSelection.length; const jiraDispatchMode = jiraGroupSelectionTakesPrecedence ? JIRA_DISPATCH_MODE.GROUPED - : resourceSelection.length > 1 + : activeResourceSelection.length > 1 ? JIRA_DISPATCH_MODE.GROUPED : JIRA_DISPATCH_MODE.INDIVIDUAL; const canChooseGroupedJiraDispatch = jiraGroupSelectionTakesPrecedence ? !hasMixedJiraSelection && selectedCheckIds.length === 1 && selectedJiraResourceCount > 1 - : resourceSelection.length > 1; + : activeResourceSelection.length > 1; const jiraTitle = hasMixedJiraSelection ? undefined : jiraGroupSelectionTakesPrecedence ? selectedGroupTitle : expandedGroup?.checkTitle; - const jiraBatches = hasMixedJiraSelection - ? [ + const jiraSelection = hasMixedJiraSelection + ? createJiraBatchSelection([ { targetIds: selectedCheckIds, targetType: JIRA_DISPATCH_TARGET.CHECK_ID, dispatchMode: JIRA_DISPATCH_MODE.GROUPED, }, { - targetIds: resourceSelection, + targetIds: activeResourceSelection, targetType: JIRA_DISPATCH_TARGET.FINDING_ID, - ...(resourceSelection.length > 1 + ...(activeResourceSelection.length > 1 ? {} : { dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL }), }, - ] - : undefined; + ]) + : createJiraTargetSelection(jiraTargetIds, jiraTargetType); const jiraDescription = hasMixedJiraSelection ? `Create Jira issues for ${buildSelectionEntityLabel( selectedCheckIds.length, - resourceSelection.length, + activeResourceSelection.length, )}.` : undefined; const hasJiraTargets = jiraTargetIds.length > 0; const isSingleFindingJiraDispatch = - !jiraGroupSelectionTakesPrecedence && resourceSelection.length === 1; + !jiraGroupSelectionTakesPrecedence && activeResourceSelection.length === 1; const canSendToJira = hasJiraTargets && (isSingleFindingJiraDispatch || groupedJiraDispatchEnabled); const sendToJiraLabel = buildJiraActionLabel( selectedCheckIds.length, - resourceSelection.length, + activeResourceSelection.length, ); const selectableRowCount = safeData.filter((g) => @@ -296,16 +304,14 @@ export function FindingsGroupTable({ handleCollapse(); return; } - setExpandedCheckId(checkId); - setExpandedGroup(group); + setSelectedExpandedCheckId(checkId); setResourceSearchInput(""); setResourceSearch(""); setResourceSelection([]); }; const handleCollapse = () => { - setExpandedCheckId(null); - setExpandedGroup(null); + setSelectedExpandedCheckId(null); setResourceSearchInput(""); setResourceSearch(""); setResourceSelection([]); @@ -407,28 +413,32 @@ export function FindingsGroupTable({ {(selectedCheckIds.length > 0 || hasResourceSelection) && ( { const [groupIds, resourceIds] = await Promise.all([ selectedCheckIds.length > 0 ? resolveGroupMuteIds(selectedCheckIds) : Promise.resolve([]), - Promise.resolve(hasResourceSelection ? resourceSelection : []), + Promise.resolve( + hasResourceSelection ? activeResourceSelection : [], + ), ]); return [...groupIds, ...resourceIds]; }} onComplete={handleMuteComplete} isBulkOperation={ - selectedCheckIds.length > 0 || resourceSelection.length > 1 + selectedCheckIds.length > 0 || activeResourceSelection.length > 1 } showSendToJira={hasJiraTargets} canSendToJira={canSendToJira} @@ -438,31 +448,12 @@ export function FindingsGroupTable({ /> )} - {canSendToJira && jiraBatches && ( + {canSendToJira && jiraSelection && ( - )} - - {canSendToJira && !jiraBatches && ( - ); -} +}; diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index 1d2716985f..c0d2a37f51 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -74,12 +74,14 @@ import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-pan import { getFailingForLabel } from "@/lib/date-utils"; import { formatDuration } from "@/lib/date-utils"; import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage"; +import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection"; import { buildFindingAnalysisPrompt } from "@/lib/lighthouse/prompts"; import { getRegionFlag } from "@/lib/region-flags"; import { getRecommendationLinkLabel } from "@/lib/vulnerability-references"; import type { ComplianceOverviewData } from "@/types/compliance"; import type { FindingResourceRow } from "@/types/findings-table"; import type { UpdateFindingTriageInput } from "@/types/findings-triage"; +import { JIRA_DISPATCH_TARGET } from "@/types/integrations"; import { Muted } from "../../muted"; import { DeltaIndicator } from "../delta-indicator"; @@ -411,6 +413,9 @@ export function ResourceDetailDrawerContent({ // During carousel navigation we only trust row-backed data until the next // finding payload is fully ready, otherwise stale details flash briefly. const f = isNavigating ? null : currentFinding; + const jiraSelection = f + ? createJiraTargetSelection([f.id], JIRA_DISPATCH_TARGET.FINDING_ID) + : null; const isCheckMetaFresh = !currentResource?.checkId || currentResource.checkId === checkMeta.checkId; const showCheckMetaContent = !isNavigating || isCheckMetaFresh; @@ -545,11 +550,11 @@ export function ResourceDetailDrawerContent({ }} /> )} - {f && ( + {f && jiraSelection && ( )} @@ -1569,6 +1574,10 @@ function OtherFindingRow({ const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); const isMuted = finding.isMuted || isOptimisticallyMuted; + const jiraSelection = createJiraTargetSelection( + [finding.id], + JIRA_DISPATCH_TARGET.FINDING_ID, + ); const findingUrl = `/findings?filter%5Bcheck_id__in%5D=${encodeURIComponent(finding.checkId)}&filter%5Bmuted%5D=include`; @@ -1585,12 +1594,14 @@ function OtherFindingRow({ }} /> )} - + {jiraSelection && ( + + )} window.open(findingUrl, "_blank", "noopener,noreferrer")} diff --git a/ui/components/shadcn/custom/custom-radio.tsx b/ui/components/shadcn/custom/custom-radio.tsx index 53fd0e6bd6..d6bf6dec2c 100644 --- a/ui/components/shadcn/custom/custom-radio.tsx +++ b/ui/components/shadcn/custom/custom-radio.tsx @@ -4,12 +4,17 @@ import { RadioGroupItem } from "@/components/shadcn/radio-group/radio-group"; import { cn } from "@/lib/utils"; interface CustomRadioProps { + ariaLabel?: string; description?: string; value?: string; children?: React.ReactNode; } -export const CustomRadio = ({ value, children }: CustomRadioProps) => { +export const CustomRadio = ({ + ariaLabel, + value, + children, +}: CustomRadioProps) => { return ( ); diff --git a/ui/components/shared/task-polling-watcher.tsx b/ui/components/shared/task-polling-watcher.tsx index 18f9749854..8624f0d9c4 100644 --- a/ui/components/shared/task-polling-watcher.tsx +++ b/ui/components/shared/task-polling-watcher.tsx @@ -4,16 +4,19 @@ import { CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler, } from "@/app/(prowler)/compliance/_lib/cross-provider-pdf"; +import { jiraDispatchTaskHandler } from "@/components/findings/jira-dispatch-task-handler"; import { useMountEffect } from "@/hooks/use-mount-effect"; import { registerTaskKindHandler, resumePendingTasks, } from "@/store/task-watcher/store"; +import { JIRA_DISPATCH_TASK_KIND } from "@/types/integrations"; // Kind registrations happen at module scope, before any task can settle in // this tab. Adding a new watched task kind (integration tests, scan exports, // …) is one line here plus a handler next to the feature that owns it. registerTaskKindHandler(CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler); +registerTaskKindHandler(JIRA_DISPATCH_TASK_KIND, jiraDispatchTaskHandler); /** * Mounted once in the app layout (next to `Toaster`): resumes polling any diff --git a/ui/lib/jira-dispatch-result.ts b/ui/lib/jira-dispatch-result.ts new file mode 100644 index 0000000000..2ee30d9d84 --- /dev/null +++ b/ui/lib/jira-dispatch-result.ts @@ -0,0 +1,141 @@ +import type { JiraDispatchTaskResult } from "@/types/integrations"; +import type { TaskState } from "@/types/tasks"; + +export interface JiraDispatchSuccessOutcome { + success: true; + message: string; + warning?: string; + failedFindingIds?: string[]; +} + +export interface JiraDispatchFailureOutcome { + success: false; + error: string; + failedFindingIds?: string[]; +} + +export type JiraDispatchOutcome = + | JiraDispatchSuccessOutcome + | JiraDispatchFailureOutcome; + +const getArrayCount = (value: unknown[] | undefined) => + Array.isArray(value) ? value.length : 0; + +const getFailedCount = (result: JiraDispatchTaskResult | undefined) => { + if (!result) return 0; + return Math.max( + result.failed_count ?? 0, + getArrayCount(result.failed_groups), + getArrayCount(result.failed_batches), + getArrayCount(result.failed_finding_ids), + ); +}; + +export const getJiraDispatchSuccessCount = ( + result: JiraDispatchTaskResult | undefined, +) => { + if (!result) return 0; + + const createdCount = Math.max( + result.created_count ?? 0, + getArrayCount(result.created_issues), + ); + const updatedCount = Math.max( + result.updated_count ?? 0, + getArrayCount(result.updated_issues), + ); + + return Math.max( + result.successful_count ?? 0, + createdCount + updatedCount, + result.issue_key || result.issue_url ? 1 : 0, + ); +}; + +const ensureSentence = (message: string) => + /[.!?]$/.test(message.trim()) ? message.trim() : `${message.trim()}.`; + +const buildFailureMessage = ( + result: JiraDispatchTaskResult | undefined, + failedCount: number, +) => { + const successCount = getJiraDispatchSuccessCount(result); + const summary = `Jira dispatch completed with ${failedCount} failed and ${successCount} created/updated issue${successCount === 1 ? "" : "s"}.`; + + return result?.error ? `${ensureSentence(result.error)} ${summary}` : summary; +}; + +const buildSuccessMessage = (result: JiraDispatchTaskResult | undefined) => { + const successCount = getJiraDispatchSuccessCount(result); + if (successCount > 1) { + return `${successCount} Jira issues were created or updated successfully.`; + } + + return "Finding successfully sent to Jira!"; +}; + +const getFailedFindingIds = (result: JiraDispatchTaskResult | undefined) => + Array.from(new Set(result?.failed_finding_ids?.filter(Boolean) ?? [])); + +const withFailedFindingIds = (failedFindingIds: string[]) => + failedFindingIds.length > 0 ? { failedFindingIds } : {}; + +export const evaluateJiraDispatchTask = ( + state: TaskState, + result: JiraDispatchTaskResult | null | undefined, +): JiraDispatchOutcome => { + const jiraResult = result ?? undefined; + const failedFindingIds = getFailedFindingIds(jiraResult); + + if (state === "completed") { + const failedCount = getFailedCount(jiraResult); + if (failedCount > 0) { + const successCount = getJiraDispatchSuccessCount(jiraResult); + if (successCount > 0) { + return { + success: true, + message: buildSuccessMessage(jiraResult), + warning: buildFailureMessage(jiraResult, failedCount), + ...withFailedFindingIds(failedFindingIds), + }; + } + + return { + success: false, + error: buildFailureMessage(jiraResult, failedCount), + ...withFailedFindingIds(failedFindingIds), + }; + } + + if (jiraResult?.success === false || jiraResult?.error) { + return { + success: false, + error: jiraResult.error || "Failed to create Jira issue.", + ...withFailedFindingIds(failedFindingIds), + }; + } + + if (!jiraResult || getJiraDispatchSuccessCount(jiraResult) === 0) { + return { + success: false, + error: + "Jira dispatch completed but did not create or update any issues.", + }; + } + + return { + success: true, + message: buildSuccessMessage(jiraResult), + }; + } + + if (state === "failed") { + return { + success: false, + error: jiraResult?.error || "Task failed.", + ...withFailedFindingIds(failedFindingIds), + }; + } + + return { success: false, error: `Unknown task state: ${state}` }; +}; diff --git a/ui/lib/jira-dispatch-selection.ts b/ui/lib/jira-dispatch-selection.ts new file mode 100644 index 0000000000..df561d5bba --- /dev/null +++ b/ui/lib/jira-dispatch-selection.ts @@ -0,0 +1,84 @@ +import { + JIRA_TARGET_SELECTION_KIND, + type JiraBatchSelection, + type JiraDispatchTarget, + type JiraDispatchTargetBatch, + type JiraSelection, + type NonEmptyStringArray, +} from "@/types/integrations"; + +export interface JiraDispatchTargetBatchInput { + targetIds: string[]; + targetType: JiraDispatchTarget; + dispatchMode?: JiraDispatchTargetBatch["dispatchMode"]; +} + +export const toNonEmptyStringArray = ( + values: string[], +): NonEmptyStringArray | null => { + const [first, ...rest] = values.filter(Boolean); + return first ? [first, ...rest] : null; +}; + +export const createJiraTargetSelection = ( + targetIds: string[], + targetType: JiraDispatchTarget, +): JiraSelection | null => { + const nonEmptyTargetIds = toNonEmptyStringArray(targetIds); + if (!nonEmptyTargetIds) return null; + + if (nonEmptyTargetIds.length === 1) { + return { + kind: JIRA_TARGET_SELECTION_KIND.SINGLE, + targetId: nonEmptyTargetIds[0], + targetType, + }; + } + + return { + kind: JIRA_TARGET_SELECTION_KIND.TARGET_LIST, + targetIds: nonEmptyTargetIds, + targetType, + }; +}; + +export const createJiraBatchSelection = ( + batches: JiraDispatchTargetBatchInput[], +): JiraBatchSelection | null => { + const normalizedBatches = batches.flatMap((batch) => { + const targetIds = toNonEmptyStringArray(batch.targetIds); + return targetIds ? [{ ...batch, targetIds }] : []; + }); + const [firstBatch, ...remainingBatches] = normalizedBatches; + + return firstBatch + ? { + kind: JIRA_TARGET_SELECTION_KIND.BATCHES, + batches: [firstBatch, ...remainingBatches], + } + : null; +}; + +export const getJiraSelectionBatches = ( + selection: JiraSelection, +): [JiraDispatchTargetBatch, ...JiraDispatchTargetBatch[]] => { + if (selection.kind === JIRA_TARGET_SELECTION_KIND.BATCHES) { + return selection.batches; + } + + if (selection.kind === JIRA_TARGET_SELECTION_KIND.SINGLE) { + return [ + { + targetIds: [selection.targetId], + targetType: selection.targetType, + }, + ]; + } + + return [ + { + targetIds: selection.targetIds, + targetType: selection.targetType, + }, + ]; +}; diff --git a/ui/lib/jira-dispatch-task.ts b/ui/lib/jira-dispatch-task.ts new file mode 100644 index 0000000000..053455baee --- /dev/null +++ b/ui/lib/jira-dispatch-task.ts @@ -0,0 +1,46 @@ +import type { WatchedTask } from "@/store/task-watcher/store"; +import { + JIRA_DISPATCH_MODE, + type JiraDispatchMode, +} from "@/types/integrations"; + +export interface JiraDispatchTaskMeta { + integrationId: string; + projectKey: string; + issueType: string; + dispatchMode: JiraDispatchMode; +} + +export const buildJiraDispatchTaskMeta = ({ + integrationId, + projectKey, + issueType, + dispatchMode, +}: JiraDispatchTaskMeta): Record => ({ + integrationId, + projectKey, + issueType, + dispatchMode, +}); + +export const parseJiraDispatchTaskMeta = ( + task: WatchedTask, +): JiraDispatchTaskMeta | null => { + const { integrationId, projectKey, issueType, dispatchMode } = task.meta; + if ( + !integrationId || + !projectKey || + !issueType || + (dispatchMode !== JIRA_DISPATCH_MODE.GROUPED && + dispatchMode !== JIRA_DISPATCH_MODE.INDIVIDUAL) + ) { + return null; + } + + return { + integrationId, + projectKey, + issueType, + dispatchMode, + }; +}; diff --git a/ui/store/task-watcher/store.test.ts b/ui/store/task-watcher/store.test.ts index d1fbdb6188..d260e35357 100644 --- a/ui/store/task-watcher/store.test.ts +++ b/ui/store/task-watcher/store.test.ts @@ -49,6 +49,37 @@ describe("task watcher store", () => { expect(onError).not.toHaveBeenCalled(); }); + it("returns and persists a completed task result while allowing the caller to own notifications", async () => { + // Given + pollMock.mockResolvedValue({ + ok: true, + state: "completed", + result: { created_count: 1, failed_count: 1 }, + }); + + // When + const result = await trackAndPollTask<{ + created_count: number; + failed_count: number; + }>({ + taskId: "jira-task", + kind: "test-kind", + meta: {}, + notifyHandler: false, + }); + + // Then + expect(result).toEqual({ + status: TASK_WATCHER_STATUS.READY, + result: { created_count: 1, failed_count: 1 }, + }); + expect(useTaskWatcherStore.getState().tasks["jira-task"]?.result).toEqual({ + created_count: 1, + failed_count: 1, + }); + expect(onReady).not.toHaveBeenCalled(); + }); + it("replaces settled results of the same kind when tracking new work", async () => { // Given pollMock.mockResolvedValue({ ok: true, state: "completed" }); @@ -201,6 +232,39 @@ describe("task watcher store", () => { expect(onReady).toHaveBeenCalledTimes(1); }); + it("passes a persisted task result to its handler after resuming", async () => { + // Given + const taskResult = { + created_count: 1, + failed_count: 1, + failed_finding_ids: ["finding-2"], + }; + pollMock.mockResolvedValue({ + ok: true, + state: "completed", + result: taskResult, + }); + useTaskWatcherStore.setState({ + tasks: { + "resumed-jira-task": { + taskId: "resumed-jira-task", + kind: "test-kind", + status: TASK_WATCHER_STATUS.PENDING, + meta: {}, + startedAt: Date.now(), + }, + }, + }); + + // When + await resumePendingTasks(); + + // Then + expect(onReady).toHaveBeenCalledWith( + expect.objectContaining({ result: taskResult }), + ); + }); + it("discards settled tasks before resuming persisted work", async () => { // Given pollMock.mockResolvedValue({ ok: true, state: "completed" }); diff --git a/ui/store/task-watcher/store.ts b/ui/store/task-watcher/store.ts index b979907456..e95389872e 100644 --- a/ui/store/task-watcher/store.ts +++ b/ui/store/task-watcher/store.ts @@ -28,6 +28,9 @@ export interface WatchedTask { meta: Record; startedAt: number; error?: string; + /** Serializable task result. Persisted so another tab or a reload can + * finish feature-specific handling without polling the task again. */ + result?: unknown; } export interface TaskKindHandler { @@ -35,6 +38,21 @@ export interface TaskKindHandler { onError: (task: WatchedTask) => void; } +export interface TaskTrackingResult { + status: TaskWatcherStatus; + error?: string; + result?: R; +} + +export interface TrackAndPollTaskInput { + taskId: string; + kind: string; + meta: Record; + /** Let the awaiting caller aggregate notifications. If this tab reloads, + * the persisted task resumes with its registered handler as usual. */ + notifyHandler?: boolean; +} + interface TaskWatcherState { tasks: Record; upsertTask: (task: WatchedTask) => void; @@ -42,6 +60,7 @@ interface TaskWatcherState { taskId: string, status: TaskWatcherStatus, error?: string, + result?: unknown, ) => void; dismissTask: (taskId: string) => void; } @@ -69,12 +88,15 @@ export const useTaskWatcherStore = create()( tasks: {}, upsertTask: (task) => set((state) => ({ tasks: { ...state.tasks, [task.taskId]: task } })), - resolveTask: (taskId, status, error) => + resolveTask: (taskId, status, error, result) => set((state) => { const task = state.tasks[taskId]; if (!task) return state; return { - tasks: { ...state.tasks, [taskId]: { ...task, status, error } }, + tasks: { + ...state.tasks, + [taskId]: { ...task, status, error, result }, + }, }; }), dismissTask: (taskId) => @@ -92,25 +114,35 @@ export const useTaskWatcherStore = create()( // In-memory only: poll loops alive in THIS tab. Never persisted, so a reload // naturally re-enters through resumePendingTasks without double-polling. -const activePolls = new Set(); +const activePolls = new Map>>(); +const suppressedHandlers = new Set(); const settleTask = ( taskId: string, status: TaskWatcherStatus, error?: string, -) => { + result?: unknown, +): TaskTrackingResult => { const store = useTaskWatcherStore.getState(); const currentTask = store.tasks[taskId]; if (!currentTask || currentTask.status !== TASK_WATCHER_STATUS.PENDING) { - return; + return { + status: currentTask?.status ?? TASK_WATCHER_STATUS.ERROR, + ...(currentTask?.error ? { error: currentTask.error } : {}), + ...(currentTask?.result !== undefined + ? { result: currentTask.result } + : {}), + }; } - store.resolveTask(taskId, status, error); + store.resolveTask(taskId, status, error, result); const task = useTaskWatcherStore.getState().tasks[taskId]; - if (!task) return; + if (!task) { + return { status: TASK_WATCHER_STATUS.ERROR, error: "Task unavailable." }; + } const handler = handlers.get(task.kind); - if (handler) { + if (handler && !suppressedHandlers.has(taskId)) { if (status === TASK_WATCHER_STATUS.READY) handler.onReady(task); else handler.onError(task); } @@ -120,107 +152,141 @@ const settleTask = ( if (status === TASK_WATCHER_STATUS.ERROR) { store.dismissTask(taskId); } + + return { + status, + ...(error ? { error } : {}), + ...(result !== undefined ? { result } : {}), + }; }; -const runPollLoop = async (taskId: string): Promise => { +const runPollLoop = async ( + taskId: string, +): Promise> => { for (let round = 0; round < MAX_POLL_ROUNDS; round++) { - const result = await pollTaskUntilSettled(taskId); + const result = await pollTaskUntilSettled(taskId); if (result.ok) { if (result.state === "completed") { - settleTask(taskId, TASK_WATCHER_STATUS.READY); + return settleTask( + taskId, + TASK_WATCHER_STATUS.READY, + undefined, + result.result, + ) as TaskTrackingResult; } else { - settleTask( + return settleTask( taskId, TASK_WATCHER_STATUS.ERROR, `Task ended in state "${result.state}".`, - ); + result.result, + ) as TaskTrackingResult; } - return; } // "Task timeout" just means this server round expired while the task // is still running — keep polling. Real errors settle immediately. if (result.error !== "Task timeout") { - settleTask(taskId, TASK_WATCHER_STATUS.ERROR, result.error); - return; + return settleTask( + taskId, + TASK_WATCHER_STATUS.ERROR, + result.error, + result.result, + ) as TaskTrackingResult; } } - settleTask( + return settleTask( taskId, TASK_WATCHER_STATUS.ERROR, "The task is taking too long. Try again later.", - ); + ) as TaskTrackingResult; }; -const pollUntilDone = async (taskId: string): Promise => { - if (activePolls.has(taskId)) return; - activePolls.add(taskId); +const pollUntilDone = (taskId: string): Promise> => { + const existingPoll = activePolls.get(taskId); + if (existingPoll) return existingPoll as Promise>; - try { - const runIfPending = async () => { - // A different tab may have completed the task while this one waited for - // the cross-tab lock. Refresh persisted state before polling or notifying. - await useTaskWatcherStore.persist.rehydrate(); - const task = useTaskWatcherStore.getState().tasks[taskId]; - if (task?.status !== TASK_WATCHER_STATUS.PENDING) return; - await runPollLoop(taskId); - }; + const pollPromise = (async (): Promise> => { + try { + const runIfPending = async (): Promise> => { + // A different tab may have completed the task while this one waited for + // the cross-tab lock. Refresh persisted state before polling or notifying. + await useTaskWatcherStore.persist.rehydrate(); + const task = useTaskWatcherStore.getState().tasks[taskId]; + if (task?.status !== TASK_WATCHER_STATUS.PENDING) { + return { + status: task?.status ?? TASK_WATCHER_STATUS.ERROR, + ...(task?.error ? { error: task.error } : {}), + ...(task?.result !== undefined ? { result: task.result as R } : {}), + }; + } + return runPollLoop(taskId); + }; - if (typeof navigator !== "undefined" && navigator.locks) { - await navigator.locks.request(`task-watcher:${taskId}`, runIfPending); - } else { - await runIfPending(); + if (typeof navigator !== "undefined" && navigator.locks) { + return await navigator.locks.request( + `task-watcher:${taskId}`, + runIfPending, + ); + } + + return await runIfPending(); + } catch { + // A thrown poll (e.g. the server-action RPC failing on a network drop) + // must still settle the task, or it stays PENDING in the persisted + // store and blocks the UI until the staleness ceiling. + return settleTask( + taskId, + TASK_WATCHER_STATUS.ERROR, + "Tracking the task failed unexpectedly. Try again later.", + ) as TaskTrackingResult; + } finally { + activePolls.delete(taskId); } - } catch { - // A thrown poll (e.g. the server-action RPC failing on a network drop) - // must still settle the task, or it stays PENDING in the persisted - // store and blocks the UI until the staleness ceiling. - settleTask( - taskId, - TASK_WATCHER_STATUS.ERROR, - "Tracking the task failed unexpectedly. Try again later.", - ); - } finally { - activePolls.delete(taskId); - } + })(); + + activePolls.set(taskId, pollPromise); + return pollPromise; }; /** Track a freshly dispatched backend task and poll it to completion. The * poll loop lives at module scope (fired from the click handler), so it * survives client-side navigation without any effect subscriptions. */ -export const trackAndPollTask = async ({ +export const trackAndPollTask = async ({ taskId, kind, meta, -}: { - taskId: string; - kind: string; - meta: Record; -}): Promise => { + notifyHandler = true, +}: TrackAndPollTaskInput): Promise> => { + if (!notifyHandler) suppressedHandlers.add(taskId); + const existing = useTaskWatcherStore.getState().tasks[taskId]; - if (existing?.status === TASK_WATCHER_STATUS.PENDING) { - return pollUntilDone(taskId); + try { + if (existing?.status === TASK_WATCHER_STATUS.PENDING) { + return await pollUntilDone(taskId); + } + + const store = useTaskWatcherStore.getState(); + Object.values(store.tasks) + .filter( + (task) => + task.kind === kind && task.status !== TASK_WATCHER_STATUS.PENDING, + ) + .forEach((task) => store.dismissTask(task.taskId)); + + store.upsertTask({ + taskId, + kind, + status: TASK_WATCHER_STATUS.PENDING, + meta, + startedAt: Date.now(), + }); + + return await pollUntilDone(taskId); + } finally { + suppressedHandlers.delete(taskId); } - - const store = useTaskWatcherStore.getState(); - Object.values(store.tasks) - .filter( - (task) => - task.kind === kind && task.status !== TASK_WATCHER_STATUS.PENDING, - ) - .forEach((task) => store.dismissTask(task.taskId)); - - store.upsertTask({ - taskId, - kind, - status: TASK_WATCHER_STATUS.PENDING, - meta, - startedAt: Date.now(), - }); - - return pollUntilDone(taskId); }; /** Resume polling every persisted pending task after a hard reload; tasks diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts index 0f358cf45f..77ee623ad3 100644 --- a/ui/types/integrations.ts +++ b/ui/types/integrations.ts @@ -20,6 +20,47 @@ export const JIRA_DISPATCH_TARGET = { export type JiraDispatchTarget = (typeof JIRA_DISPATCH_TARGET)[keyof typeof JIRA_DISPATCH_TARGET]; +export const JIRA_TARGET_SELECTION_KIND = { + SINGLE: "single", + TARGET_LIST: "target-list", + BATCHES: "batches", +} as const; + +export type JiraTargetSelectionKind = + (typeof JIRA_TARGET_SELECTION_KIND)[keyof typeof JIRA_TARGET_SELECTION_KIND]; + +export type NonEmptyStringArray = [string, ...string[]]; + +export interface JiraDispatchTargetBatch { + targetIds: NonEmptyStringArray; + targetType: JiraDispatchTarget; + dispatchMode?: JiraDispatchMode; +} + +export interface JiraSingleTargetSelection { + kind: typeof JIRA_TARGET_SELECTION_KIND.SINGLE; + targetId: string; + targetType: JiraDispatchTarget; +} + +export interface JiraTargetListSelection { + kind: typeof JIRA_TARGET_SELECTION_KIND.TARGET_LIST; + targetIds: NonEmptyStringArray; + targetType: JiraDispatchTarget; +} + +export interface JiraBatchSelection { + kind: typeof JIRA_TARGET_SELECTION_KIND.BATCHES; + batches: [JiraDispatchTargetBatch, ...JiraDispatchTargetBatch[]]; +} + +export type JiraSelection = + | JiraSingleTargetSelection + | JiraTargetListSelection + | JiraBatchSelection; + +export const JIRA_DISPATCH_TASK_KIND = "jira-dispatch"; + export interface IntegrationProps { type: "integrations"; id: string; @@ -75,27 +116,30 @@ export interface JiraDispatchResponse { completed_at: string | null; name: string; state: TaskState; - result: { - success?: boolean; - error?: string; - message?: string; - successful_count?: number; - created_count?: number; - updated_count?: number; - failed_count?: number; - created_issues?: unknown[]; - updated_issues?: unknown[]; - failed_groups?: unknown[]; - failed_batches?: unknown[]; - issue_url?: string; - issue_key?: string; - } | null; + result: JiraDispatchTaskResult | null; task_args: Record | null; metadata: Record | null; }; }; } +export interface JiraDispatchTaskResult { + success?: boolean; + error?: string; + message?: string; + successful_count?: number; + created_count?: number; + updated_count?: number; + failed_count?: number; + created_issues?: unknown[]; + updated_issues?: unknown[]; + failed_groups?: unknown[]; + failed_batches?: unknown[]; + failed_finding_ids?: string[]; + issue_url?: string; + issue_key?: string; +} + // Shared AWS credential fields schema const awsCredentialFields = { credentials_type: z.enum(["aws-sdk-default", "access-secret-key"]),