mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 04:21:52 +00:00
fix(ui): complete grouped Jira dispatch review
This commit is contained in:
@@ -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.",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+19
-8
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}` };
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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"]),
|
||||
|
||||
Reference in New Issue
Block a user