fix(ui): complete grouped Jira dispatch review

This commit is contained in:
alejandrobailo
2026-07-21 13:06:25 +02:00
parent f1d26805d8
commit b6e1dd02c0
26 changed files with 1668 additions and 894 deletions
@@ -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.",
});
});
+6 -98
View File
@@ -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,
);
};
+2 -1
View File
@@ -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");
});
});
@@ -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(
+53 -83
View File
@@ -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<string[]>([]);
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}
/>
<Modal
open={isActionChooserOpen}
onOpenChange={setIsActionChooserOpen}
title="Choose action"
description="Select what to do with the current selection."
>
<div className="flex flex-col gap-3">
<Button
type="button"
variant="outline"
className="justify-start"
onClick={handleMuteClick}
>
<VolumeX className="size-5" />
{muteLabel}
</Button>
{showSendToJira && (
<Tooltip>
<TooltipTrigger asChild>
<span className="relative block">
<Button
type="button"
variant="outline"
className={cn(
"w-full justify-start",
!canSendToJira && "pr-56",
)}
aria-label={sendToJiraLabel}
disabled={!canSendToJira}
onClick={handleJiraClick}
>
<JiraIcon size={20} />
<span className="flex-1 text-left">{sendToJiraLabel}</span>
</Button>
{!canSendToJira && (
<span className="absolute top-1/2 right-2 z-10 -translate-y-1/2">
<CloudFeatureBadgeLink />
</span>
)}
</span>
</TooltipTrigger>
{!canSendToJira && (
<TooltipContent>{jiraDisabledTooltip}</TooltipContent>
)}
</Tooltip>
)}
</div>
</Modal>
{/* Portaled to body: <main> is a layout container (container queries),
which would otherwise capture this fixed button and scroll it away
with the content. */}
{typeof document !== "undefined"
? createPortal(
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 flex gap-2 duration-300">
<Button
onClick={handlePrimaryClick}
disabled={isResolving}
size="lg"
className="shadow-lg"
>
{isResolving ? (
<Spinner className="size-5" />
<div className="shadow-lg">
{showSendToJira ? (
<ActionDropdown
ariaLabel="Open selection actions"
trigger={
<Button disabled={isResolving} size="lg">
{isResolving ? (
<Spinner className="size-5" />
) : (
<Ellipsis className="size-5" />
)}
{label ?? `${selectedCount} selected`}
</Button>
}
>
<ActionDropdownItem
icon={<VolumeX />}
label={muteLabel}
aria-label={muteLabel}
onSelect={() => void handleMuteClick()}
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label={
<span className="flex items-center gap-2">
{sendToJiraLabel}
{!canSendToJira && <CloudFeatureBadgeLink />}
</span>
}
aria-label={sendToJiraLabel}
disabled={!canSendToJira}
disabledTooltip={jiraDisabledTooltip}
onSelect={handleJiraClick}
/>
</ActionDropdown>
) : (
<Ellipsis className="size-5" />
<Button
onClick={() => void handleMuteClick()}
disabled={isResolving}
size="lg"
>
{isResolving ? (
<Spinner className="size-5" />
) : (
<VolumeX className="size-5" />
)}
Mute ({selectedCount})
</Button>
)}
{hasMultipleActions
? (label ?? `${selectedCount} selected`)
: `Mute (${selectedCount})`}
</Button>
</div>
</div>,
document.body,
)
@@ -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) => <button {...props}>{children}</button>,
}));
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.",
});
});
});
@@ -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<void> => {
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<JiraDispatchTaskResult>({
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 ? (
<ToastAction
altText="Retry failed Findings"
onClick={() => retryFailedFindings(task, failedFindingIds)}
>
Retry failed
</ToastAction>
) : 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.",
});
},
};
@@ -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<typeof createJiraBatchSelection>[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">) => (
<button {...props}>{children}</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", () => {
<SendToJiraModal
isOpen
onOpenChange={vi.fn()}
findingId="check-a"
findingTitle="Check A"
targetBatches={[
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
@@ -116,7 +142,7 @@ 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", () => {
<SendToJiraModal
isOpen
onOpenChange={vi.fn()}
findingId="finding-1"
findingTitle="Finding 1"
targetIds={["finding-1", "finding-2"]}
targetType="finding_id"
selection={targetSelection(
["finding-1", "finding-2"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)}
defaultDispatchMode="grouped"
canChooseGroupedDispatch
/>,
@@ -187,9 +214,8 @@ describe("SendToJiraModal", () => {
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingId="check-a"
findingTitle="Check A"
targetBatches={[
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
@@ -199,7 +225,7 @@ 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", () => {
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingId="finding-1"
selection={targetSelection(
["finding-1"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)}
findingTitle="Finding 1"
/>,
);
@@ -294,10 +327,8 @@ describe("SendToJiraModal", () => {
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingId="check-a"
findingTitle="Check A"
targetIds={["check-a"]}
targetType="check_id"
selection={targetSelection(["check-a"], JIRA_DISPATCH_TARGET.CHECK_ID)}
defaultDispatchMode="grouped"
selectedResourceCount={1}
/>,
@@ -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(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingId="check-a"
findingTitle="Check A"
targetIds={["check-a"]}
targetType="check_id"
selection={targetSelection(["check-a"], JIRA_DISPATCH_TARGET.CHECK_ID)}
defaultDispatchMode="grouped"
selectedResourceCount={1}
/>,
@@ -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", () => {
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingId="check-a"
findingTitle="Check A"
targetBatches={[
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: "check_id",
@@ -414,7 +431,7 @@ 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(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingId="finding-1"
selection={targetSelection(
["finding-1"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)}
findingTitle="Finding 1"
/>,
);
@@ -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(
<SendToJiraModal
isOpen
onOpenChange={vi.fn()}
selection={targetSelection(
["finding-1", "finding-2"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)}
/>,
);
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(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingId="check-a"
findingTitle="Check A"
targetBatches={[
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: "check_id",
@@ -521,7 +589,7 @@ describe("SendToJiraModal", () => {
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", () => {
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingId="check-a"
findingTitle="Check A"
targetBatches={[
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: "check_id",
@@ -580,7 +647,7 @@ 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(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingId="check-a"
findingTitle="Check A"
targetBatches={[
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: "check_id",
@@ -643,7 +717,7 @@ describe("SendToJiraModal", () => {
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.",
}),
),
);
});
});
+384 -327
View File
@@ -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<typeof sendToJiraSchema>;
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<string, string[]> | 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<SendToJiraModalProps, "isOpen">) => {
const [integrations, setIntegrations] = useState<IntegrationProps[]>([]);
const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(false);
const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(true);
const [fetchedIssueTypes, setFetchedIssueTypes] = useState<
Record<string, string[]>
>({});
@@ -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<SetStateAction<boolean>> = (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<string, string> =
selectedIntegrationData?.attributes.configuration.projects ??
({} as Record<string, string>);
const projectEntries = Object.entries(projects);
// Get issue types from config (new dict format), falling back to fetched data
const configIssueTypes = selectedIntegrationData?.attributes.configuration
.issue_types as Record<string, string[]> | undefined;
const issueTypesFromConfig =
configIssueTypes &&
typeof configIssueTypes === "object" &&
!Array.isArray(configIssueTypes)
? (configIssueTypes[selectedProject] ?? [])
: [];
const issueTypesForProject =
issueTypesFromConfig.length > 0
? issueTypesFromConfig
: (fetchedIssueTypes[selectedProject] ?? []);
// Fetch issue types from API when project is selected but no types are available
useEffect(() => {
let ignore = false;
if (
selectedIntegration &&
selectedProject &&
issueTypesFromConfig.length === 0 &&
!fetchedIssueTypes[selectedProject]
) {
const fetchIssueTypes = async () => {
setIsFetchingIssueTypes(true);
try {
const result = await getJiraIssueTypes(
selectedIntegration,
selectedProject,
);
if (ignore) return;
if (result.success) {
setFetchedIssueTypes((prev) => ({
...prev,
[selectedProject]: result.issueTypes,
}));
} else {
toast({
variant: "destructive",
title: "Failed to load issue types",
description:
result.error || "Unable to fetch issue types for this project",
});
}
} finally {
if (!ignore) setIsFetchingIssueTypes(false);
}
};
fetchIssueTypes();
}
return () => {
ignore = true;
active = false;
};
}, [
selectedIntegration,
selectedProject,
issueTypesFromConfig.length,
fetchedIssueTypes,
]);
});
const setOpenForFormButtons: Dispatch<SetStateAction<boolean>> = (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<JiraDispatchTaskResult>;
try {
trackedTask = await trackAndPollTask<JiraDispatchTaskResult>({
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 ? (
<ToastAction
altText="Retry failed Jira dispatches"
onClick={async () => {
toast({
title: "Retry started",
description: "Retrying only the Jira dispatches that failed.",
});
await processBatches(retryBatches, settings);
}}
>
Retry failed
</ToastAction>
) : 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 (
<Modal
open={isOpen}
open
onOpenChange={onOpenChange}
title="Send Finding to Jira"
description={
@@ -444,7 +506,6 @@ export const SendToJiraModal = ({
onSubmit={form.handleSubmit(handleSubmit)}
className="flex flex-col gap-4"
>
{/* Loading skeleton for project selector */}
{isFetchingIntegrations && (
<div className="flex flex-col gap-1.5">
<Skeleton className="h-3 w-16" />
@@ -452,7 +513,6 @@ export const SendToJiraModal = ({
</div>
)}
{/* Integration Selection */}
{!isFetchingIntegrations && integrations.length > 1 && (
<FormField
control={form.control}
@@ -469,22 +529,20 @@ export const SendToJiraModal = ({
id="jira-integration-select"
options={integrationOptions}
onValueChange={(values) => {
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
/>
<FormMessage className="text-text-error text-xs" />
</div>
@@ -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
/>
<FormMessage className="text-text-error text-xs" />
</div>
@@ -531,7 +588,6 @@ export const SendToJiraModal = ({
/>
)}
{/* Issue Type Selection */}
{selectedProject && (
<FormField
control={form.control}
@@ -547,23 +603,22 @@ export const SendToJiraModal = ({
<EnhancedMultiSelect
id="jira-issue-type-select"
options={issueTypeOptions}
onValueChange={(values) => {
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
/>
<FormMessage className="text-text-error text-xs" />
</div>
@@ -583,13 +638,11 @@ export const SendToJiraModal = ({
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="gap-3"
>
<label className="border-border-neutral-secondary bg-bg-neutral-secondary flex cursor-pointer gap-3 rounded-md border p-3">
<RadioGroupItem
value={JIRA_DISPATCH_MODE.GROUPED}
aria-label="Create one Jira issue"
/>
<CustomRadio
value={JIRA_DISPATCH_MODE.GROUPED}
ariaLabel="Create one Jira issue"
>
<span className="flex flex-col gap-1">
<span className="text-text-neutral-primary text-sm font-medium">
{jiraDispatchChoiceCopy.groupedTitle}
@@ -598,12 +651,11 @@ export const SendToJiraModal = ({
{jiraDispatchChoiceCopy.groupedHelp}
</span>
</span>
</label>
<label className="border-border-neutral-secondary bg-bg-neutral-secondary flex cursor-pointer gap-3 rounded-md border p-3">
<RadioGroupItem
value={JIRA_DISPATCH_MODE.INDIVIDUAL}
aria-label="Create separate Jira issues"
/>
</CustomRadio>
<CustomRadio
value={JIRA_DISPATCH_MODE.INDIVIDUAL}
ariaLabel="Create separate Jira issues"
>
<span className="flex flex-col gap-1">
<span className="text-text-neutral-primary text-sm font-medium">
Create separate Jira issues
@@ -612,7 +664,7 @@ export const SendToJiraModal = ({
{jiraDispatchChoiceCopy.individualHelp}
</span>
</span>
</label>
</CustomRadio>
</RadioGroup>
<FormMessage className="text-text-error text-xs" />
</div>
@@ -620,7 +672,6 @@ export const SendToJiraModal = ({
/>
)}
{/* No integrations or none connected message */}
{!isFetchingIntegrations &&
(integrations.length === 0 || !hasConnectedIntegration) ? (
<CustomBanner
@@ -652,3 +703,9 @@ export const SendToJiraModal = ({
</Modal>
);
};
export const SendToJiraModal = ({ isOpen, ...props }: SendToJiraModalProps) => {
if (!isOpen) return null;
return <SendToJiraModalContent {...props} />;
};
@@ -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 }) => (
<div
data-testid="jira-modal"
data-finding-id={findingId}
data-open={isOpen ? "true" : "false"}
/>
),
),
SendToJiraModalMock: vi.fn(({ selection, isOpen }: JiraModalMockProps) => (
<div
data-testid="jira-modal"
data-finding-id={selection.targetId}
data-open={isOpen ? "true" : "false"}
/>
)),
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,
}),
@@ -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}
/>
)}
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={resource.findingId}
findingTitle={resource.checkId}
targetIds={displayIds}
targetType={JIRA_DISPATCH_TARGET.FINDING_ID}
defaultDispatchMode={
displayIds.length > 1
? JIRA_DISPATCH_MODE.GROUPED
: JIRA_DISPATCH_MODE.INDIVIDUAL
}
canChooseGroupedDispatch={
displayIds.length > 1 && groupedJiraDispatchEnabled
}
/>
{jiraSelection && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
selection={jiraSelection}
findingTitle={resource.checkId}
defaultDispatchMode={
displayIds.length > 1
? JIRA_DISPATCH_MODE.GROUPED
: JIRA_DISPATCH_MODE.INDIVIDUAL
}
canChooseGroupedDispatch={
displayIds.length > 1 && groupedJiraDispatchEnabled
}
/>
)}
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
@@ -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,
}),
@@ -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<T extends FindingRowData>({
};
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<T extends FindingRowData>({
return (
<>
{(!isGroup || groupedJiraDispatchEnabled) && (
{(!isGroup || groupedJiraDispatchEnabled) && jiraSelection && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={finding.id}
findingTitle={findingTitle}
targetIds={jiraTargetIds}
targetType={
isGroup
? JIRA_DISPATCH_TARGET.CHECK_ID
: JIRA_DISPATCH_TARGET.FINDING_ID
}
selection={jiraSelection}
defaultDispatchMode={jiraDefaultDispatchMode}
canChooseGroupedDispatch={canChooseGroupedJiraDispatch}
selectedResourceCount={selectedJiraResourceCount}
@@ -16,7 +16,7 @@ describe("findings group drill down", () => {
it("routes selected child findings through the Send to Jira modal with issue creation mode", () => {
expect(source).toContain("<SendToJiraModal");
expect(source).toContain('targetType="finding_id"');
expect(source).toContain("JIRA_DISPATCH_TARGET.FINDING_ID");
expect(source).toContain(
"selectedFindingIds.length > 1 && groupedJiraDispatchEnabled",
);
@@ -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 (
<FindingsSelectionContext.Provider
@@ -262,22 +267,22 @@ export function FindingsGroupDrillDown({
/>
)}
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={selectedFindingIds[0] ?? ""}
findingTitle={group.checkTitle}
targetIds={selectedFindingIds}
targetType={JIRA_DISPATCH_TARGET.FINDING_ID}
defaultDispatchMode={
selectedFindingIds.length > 1
? JIRA_DISPATCH_MODE.GROUPED
: JIRA_DISPATCH_MODE.INDIVIDUAL
}
canChooseGroupedDispatch={
selectedFindingIds.length > 1 && groupedJiraDispatchEnabled
}
/>
{jiraSelection && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
selection={jiraSelection}
findingTitle={group.checkTitle}
defaultDispatchMode={
selectedFindingIds.length > 1
? JIRA_DISPATCH_MODE.GROUPED
: JIRA_DISPATCH_MODE.INDIVIDUAL
}
canChooseGroupedDispatch={
selectedFindingIds.length > 1 && groupedJiraDispatchEnabled
}
/>
)}
<ResourceDetailDrawer
open={drawer.isOpen}
@@ -573,18 +573,21 @@ describe("FindingsGroupTable", () => {
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<Record<string, unknown>>;
selection: { batches: Array<Record<string, unknown>> };
};
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,
@@ -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 (
<FindingsGroupTableContent
key={`${requestedExpandedCheckId ?? "manual"}:${initialExpandedCheckId ?? "collapsed"}`}
data={safeData}
metadata={metadata}
resolvedFilters={resolvedFilters}
hasHistoricalData={hasHistoricalData}
initialExpandedCheckId={initialExpandedCheckId}
/>
);
}
interface FindingsGroupTableContentProps {
data: FindingGroupRow[];
metadata?: MetaDataProps;
resolvedFilters: Record<string, string>;
hasHistoricalData: boolean;
initialExpandedCheckId: string | null;
}
const FindingsGroupTableContent = ({
data,
metadata,
resolvedFilters,
hasHistoricalData,
initialExpandedCheckId,
}: FindingsGroupTableContentProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [expandedCheckId, setExpandedCheckId] = useState<string | null>(null);
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
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<string[]>([]);
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const inlineRef = useRef<InlineResourceContainerHandle>(null);
const previousRequestedExpandedCheckIdRef = useRef<string | undefined>(
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) && (
<FloatingMuteButton
selectedCount={selectedCheckIds.length + resourceSelection.length}
selectedFindingIds={[...selectedCheckIds, ...resourceSelection]}
selectedCount={
selectedCheckIds.length + activeResourceSelection.length
}
selectedFindingIds={[...selectedCheckIds, ...activeResourceSelection]}
label={buildSelectionSummary(
selectedCheckIds.length,
resourceSelection.length,
activeResourceSelection.length,
)}
muteLabel={buildMuteActionLabel(
selectedCheckIds.length,
resourceSelection.length,
activeResourceSelection.length,
)}
onBeforeOpen={async () => {
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 && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={jiraTargetIds[0] ?? ""}
findingTitle={jiraTitle}
targetBatches={jiraBatches}
defaultDispatchMode={jiraDispatchMode}
isFindingGroupSelection={
!jiraGroupSelectionTakesPrecedence && Boolean(expandedGroup)
}
canChooseGroupedDispatch={canChooseGroupedJiraDispatch}
selectedResourceCount={selectedJiraResourceCount}
description={jiraDescription}
/>
)}
{canSendToJira && !jiraBatches && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={jiraTargetIds[0] ?? ""}
findingTitle={jiraTitle}
targetIds={jiraTargetIds}
targetType={jiraTargetType}
selection={jiraSelection}
defaultDispatchMode={jiraDispatchMode}
isFindingGroupSelection={
!jiraGroupSelectionTakesPrecedence && Boolean(expandedGroup)
@@ -474,4 +465,4 @@ export function FindingsGroupTable({
)}
</FindingsSelectionContext.Provider>
);
}
};
@@ -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 && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={f.id}
selection={jiraSelection}
findingTitle={checkMeta.checkTitle}
/>
)}
@@ -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({
}}
/>
)}
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={finding.id}
findingTitle={finding.checkTitle}
/>
{jiraSelection && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
selection={jiraSelection}
findingTitle={finding.checkTitle}
/>
)}
<TableRow
className="group cursor-pointer"
onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")}
+7 -2
View File
@@ -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 (
<label
className={cn(
@@ -18,7 +23,7 @@ export const CustomRadio = ({ value, children }: CustomRadioProps) => {
"has-[[data-state=checked]]:border-button-primary",
)}
>
<RadioGroupItem value={value || ""} />
<RadioGroupItem value={value || ""} aria-label={ariaLabel} />
<span>{children}</span>
</label>
);
@@ -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
+141
View File
@@ -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}` };
};
+84
View File
@@ -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,
},
];
};
+46
View File
@@ -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<string, string> => ({
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,
};
};
+64
View File
@@ -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" });
+138 -72
View File
@@ -28,6 +28,9 @@ export interface WatchedTask {
meta: Record<string, string>;
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<R = unknown> {
status: TaskWatcherStatus;
error?: string;
result?: R;
}
export interface TrackAndPollTaskInput {
taskId: string;
kind: string;
meta: Record<string, string>;
/** 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<string, WatchedTask>;
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<TaskWatcherState>()(
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<TaskWatcherState>()(
// 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<string>();
const activePolls = new Map<string, Promise<TaskTrackingResult<unknown>>>();
const suppressedHandlers = new Set<string>();
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<void> => {
const runPollLoop = async <R>(
taskId: string,
): Promise<TaskTrackingResult<R>> => {
for (let round = 0; round < MAX_POLL_ROUNDS; round++) {
const result = await pollTaskUntilSettled(taskId);
const result = await pollTaskUntilSettled<R>(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<R>;
} else {
settleTask(
return settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
`Task ended in state "${result.state}".`,
);
result.result,
) as TaskTrackingResult<R>;
}
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<R>;
}
}
settleTask(
return settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
"The task is taking too long. Try again later.",
);
) as TaskTrackingResult<R>;
};
const pollUntilDone = async (taskId: string): Promise<void> => {
if (activePolls.has(taskId)) return;
activePolls.add(taskId);
const pollUntilDone = <R>(taskId: string): Promise<TaskTrackingResult<R>> => {
const existingPoll = activePolls.get(taskId);
if (existingPoll) return existingPoll as Promise<TaskTrackingResult<R>>;
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<TaskTrackingResult<R>> => {
try {
const runIfPending = async (): Promise<TaskTrackingResult<R>> => {
// 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<R>(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<R>;
} 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 <R = unknown>({
taskId,
kind,
meta,
}: {
taskId: string;
kind: string;
meta: Record<string, string>;
}): Promise<void> => {
notifyHandler = true,
}: TrackAndPollTaskInput): Promise<TaskTrackingResult<R>> => {
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<R>(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<R>(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
+59 -15
View File
@@ -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<string, unknown> | null;
metadata: Record<string, unknown> | 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"]),