feat(ui): add Jira dispatch choices for finding selections (#12001)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Hugo Pereira Brito
2026-07-22 12:58:12 +01:00
committed by GitHub
parent 3bd13d173d
commit ff45f46047
46 changed files with 4982 additions and 523 deletions
+2
View File
@@ -3,6 +3,8 @@ import { FILTER_FIELD, FilterParam } from "@/types/filters";
/** Findings-only filter fields not shared with other views. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const FINDINGS_EXTRA_FIELD = {
CHECK_ID: "check_id",
CHECK_ID_IN: "check_id__in",
DELTA_IN: "delta__in",
SCAN_EXACT: "scan",
SCAN_ID: "scan_id",
+149 -64
View File
@@ -1,126 +1,167 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { pollTaskUntilSettledMock } = vi.hoisted(() => ({
const { fetchMock, pollTaskUntilSettledMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
pollTaskUntilSettledMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: "Bearer token" }),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: () => ({ error: "An error occurred" }),
}));
vi.mock("@/actions/task/poll", () => ({
pollTaskUntilSettled: pollTaskUntilSettledMock,
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: vi.fn(),
}));
import { pollJiraDispatchTask, sendJiraDispatch } from "./jira-dispatch";
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: vi.fn(),
}));
import { pollJiraDispatchTask } from "./jira-dispatch";
describe("pollJiraDispatchTask", () => {
describe("sendJiraDispatch", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: {
id: "task-1",
type: "tasks",
attributes: { result: null },
},
}),
{ status: 202 },
),
);
});
it("should return the backend error when a completed task has failed Jira dispatches", async () => {
it("should send grouped dispatch mode with multiple finding IDs", async () => {
// Given / When
await sendJiraDispatch({
integrationId: "jira-1",
targetIds: ["finding-1", "finding-2"],
filter: "finding_id",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "grouped",
});
// Then
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.example.com/api/v1/integrations/jira-1/jira/dispatches?filter%5Bfinding_id__in%5D=finding-1%2Cfinding-2",
);
expect(JSON.parse(init.body as string)).toMatchObject({
data: {
attributes: {
dispatch_mode: "grouped",
issue_type: "Task",
project_key: "SEC",
},
},
});
});
it("should send grouped dispatch mode with a finding group check ID", async () => {
// Given / When
await sendJiraDispatch({
integrationId: "jira-1",
targetIds: ["s3_bucket_public_access"],
filter: "check_id",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "grouped",
});
// Then
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.example.com/api/v1/integrations/jira-1/jira/dispatches?filter%5Bcheck_id%5D=s3_bucket_public_access",
);
expect(JSON.parse(init.body as string)).toMatchObject({
data: { attributes: { dispatch_mode: "grouped" } },
});
});
it("should preserve partial success when Jira dispatch has created and failed issues", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 0,
created_count: 2,
failed_count: 1,
error: "Jira project requires custom fields: Team is required",
failed_finding_ids: ["finding-3"],
error: "Jira rejected one Finding.",
},
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Jira project requires custom fields: Team is required",
success: true,
message: "2 Jira issues were created or updated successfully.",
warning:
"Jira rejected one Finding. Jira dispatch completed with 1 failed and 2 created/updated issues.",
failedFindingIds: ["finding-3"],
});
});
it("should return a fallback error when a completed task has failures without an error", async () => {
it("should include updated issues in partial failure summaries", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 0,
failed_count: 1,
},
result: { created_count: 0, updated_count: 2, failed_count: 1 },
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Failed to create Jira issue.",
success: true,
message: "2 Jira issues were created or updated successfully.",
warning:
"Jira dispatch completed with 1 failed and 2 created/updated issues.",
});
});
it("should return a plural fallback error when a completed task has multiple failures without an error", async () => {
it("should fail completed task polling when grouped dispatch reports failed groups", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 0,
failed_count: 3,
},
result: { created_count: 1, failed_groups: [{ check_id: "check-a" }] },
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Failed to create 3 Jira issues.",
success: true,
message: "Finding successfully sent to Jira!",
warning:
"Jira dispatch completed with 1 failed and 1 created/updated issue.",
});
});
it("should surface task failure result errors", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "failed",
result: {
error: "Jira credentials are invalid.",
},
});
// When
const result = await pollJiraDispatchTask("task-123");
// Then
expect(result).toEqual({
success: false,
error: "Jira credentials are invalid.",
});
});
it("should return success when a completed task has no failures", async () => {
it("should succeed completed task polling when grouped dispatch reports no failed groups", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 1,
failed_count: 0,
},
result: { created_count: 1, failed_groups: [] },
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
@@ -129,24 +170,68 @@ describe("pollJiraDispatchTask", () => {
});
});
it("should return a fallback error when no Jira issue was created", async () => {
it("should succeed completed task polling with grouped created and updated issue result shape", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: {
created_count: 0,
created_count: 1,
updated_count: 1,
failed_count: 0,
created_issues: [{ key: "SEC-1" }],
updated_issues: [{ key: "SEC-2" }],
failed_groups: [],
},
});
// When
const result = await pollJiraDispatchTask("task-123");
const result = await pollJiraDispatchTask("task-1");
// Then
expect(pollTaskUntilSettledMock).toHaveBeenCalledWith("task-1", {
maxAttempts: 5,
delayMs: 2000,
});
expect(result).toEqual({
success: true,
message: "2 Jira issues were created or updated successfully.",
});
});
it("should fail completed task polling when Jira dispatch completed as a no-op", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: { created_count: 0, updated_count: 0, failed_count: 0 },
});
// When
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Failed to create Jira issue.",
error: "Jira dispatch completed but did not create or update any issues.",
});
});
it("should fail completed task polling when Jira dispatch has no result payload", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
state: "completed",
result: null,
});
// When
const result = await pollJiraDispatchTask("task-1");
// Then
expect(result).toEqual({
success: false,
error: "Jira dispatch completed but did not create or update any issues.",
});
});
});
+47 -29
View File
@@ -2,12 +2,26 @@
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,
JiraDispatchMode,
JiraDispatchRequest,
JiraDispatchResponse,
JiraDispatchTarget,
JiraDispatchTaskResult,
} from "@/types/integrations";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
interface JiraDispatchInput {
integrationId: string;
targetIds: string[];
filter: JiraDispatchTarget;
projectKey: string;
issueType: string;
dispatchMode?: JiraDispatchMode;
}
export const getJiraIssueTypes = async (
integrationId: string,
@@ -87,14 +101,37 @@ export const sendFindingToJira = async (
): Promise<
| { success: true; taskId: string; message: string }
| { success: false; error: string }
> => {
return sendJiraDispatch({
integrationId,
targetIds: [findingId],
filter: JIRA_DISPATCH_TARGET.FINDING_ID,
projectKey,
issueType,
});
};
export const sendJiraDispatch = async ({
integrationId,
targetIds,
filter,
projectKey,
issueType,
dispatchMode = JIRA_DISPATCH_MODE.INDIVIDUAL,
}: JiraDispatchInput): Promise<
| { success: true; taskId: string; message: string }
| { success: false; error: string }
> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(
`${apiBaseUrl}/integrations/${integrationId}/jira/dispatches`,
);
// Single finding: use direct filter without array notation
url.searchParams.append("filter[finding_id]", findingId);
if (targetIds.length === 1) {
url.searchParams.append(`filter[${filter}]`, targetIds[0]);
} else {
url.searchParams.append(`filter[${filter}__in]`, targetIds.join(","));
}
const payload: JiraDispatchRequest = {
data: {
@@ -102,6 +139,7 @@ export const sendFindingToJira = async (
attributes: {
project_key: projectKey,
issue_type: issueType,
dispatch_mode: dispatchMode,
},
},
};
@@ -145,38 +183,18 @@ export const sendFindingToJira = async (
export const pollJiraDispatchTask = async (
taskId: string,
): Promise<
{ success: true; message: string } | { success: false; error: string }
| { success: true; message: string; warning?: string }
| { success: false; error: string }
> => {
const res = await pollTaskUntilSettled(taskId, {
maxAttempts: 30,
maxAttempts: 5,
delayMs: 2000,
});
if (!res.ok) {
return { success: false, error: res.error };
}
const { state, result } = res;
type JiraTaskResult = JiraDispatchResponse["data"]["attributes"]["result"];
const jiraResult = result as JiraTaskResult | undefined;
if (state === "completed") {
const createdCount = jiraResult?.created_count ?? 0;
const failedCount = jiraResult?.failed_count ?? 0;
if (!jiraResult?.error && failedCount === 0 && createdCount > 0) {
return { success: true, message: "Finding successfully sent to Jira!" };
}
return {
success: false,
error:
jiraResult?.error ||
(failedCount > 1
? `Failed to create ${failedCount} Jira issues.`
: "Failed to create Jira issue."),
};
}
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,
);
};
+13
View File
@@ -44,4 +44,17 @@ describe("findings page", () => {
it("applies the shared default muted filter so muted findings are hidden unless the caller opts in", () => {
expect(source).toContain("applyDefaultMutedFilter");
});
it("loads finding groups as selectable Finding Group filter options", () => {
expect(source).toContain("fetchFindingGroupFilterOptions");
expect(source).toContain("FINDING_GROUP_FILTER_OPTION_PAGE_SIZE");
expect(source).toContain("checkOptions={checkOptions}");
});
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("if (page >= totalPages) break");
expect(source).toContain("page += 1");
});
});
+67
View File
@@ -29,6 +29,60 @@ import { isCloud } from "@/lib/shared/env";
import { ScanEntity, ScanProps } from "@/types";
import { SearchParamsProps } from "@/types/components";
const FINDING_GROUP_FILTER_OPTION_PAGE_SIZE = 100;
const FINDING_GROUP_OWN_FILTER_KEYS = [
"filter[check_id]",
"filter[check_id__in]",
] as const;
type FindingGroupFilterFetcher = typeof getFindingGroups;
function excludeFindingGroupOwnFilters(
filters: Record<string, string | string[] | undefined>,
) {
return Object.fromEntries(
Object.entries(filters).filter(
([key]) =>
!FINDING_GROUP_OWN_FILTER_KEYS.includes(
key as (typeof FINDING_GROUP_OWN_FILTER_KEYS)[number],
),
),
);
}
async function getFindingGroupFilterOptions({
fetchFindingGroups,
filters,
}: {
fetchFindingGroups: FindingGroupFilterFetcher;
filters: Record<string, string | string[] | undefined>;
}) {
const optionFilters = excludeFindingGroupOwnFilters(filters);
const options = new Map<string, { checkId: string; checkTitle: string }>();
let page = 1;
while (true) {
const response = await fetchFindingGroups({
filters: optionFilters,
page,
pageSize: FINDING_GROUP_FILTER_OPTION_PAGE_SIZE,
});
for (const group of adaptFindingGroupsResponse(response)) {
options.set(group.checkId, {
checkId: group.checkId,
checkTitle: group.checkTitle,
});
}
const totalPages = response?.meta?.pagination?.pages ?? page;
if (page >= totalPages) break;
page += 1;
}
return Array.from(options.values());
}
export default async function Findings({
searchParams,
}: {
@@ -68,6 +122,13 @@ export default async function Findings({
metadataInfoData?.data?.attributes?.resource_types || [];
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
const fetchFindingGroupFilterOptions = hasHistoricalData
? getFindingGroups
: getLatestFindingGroups;
const checkOptions = await getFindingGroupFilterOptions({
fetchFindingGroups: fetchFindingGroupFilterOptions,
filters: resolvedFilters,
});
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
@@ -110,6 +171,7 @@ export default async function Findings({
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
checkOptions={checkOptions}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
@@ -145,6 +207,10 @@ const SSRDataTable = async ({
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const expandedCheckIdParam = searchParams.expandedCheckId;
const expandedCheckId = Array.isArray(expandedCheckIdParam)
? expandedCheckIdParam[0]
: expandedCheckIdParam;
const { encodedSort } = extractSortAndKey(searchParams);
const hasHistoricalData = hasDateOrScanFilter(filters);
@@ -178,6 +244,7 @@ const SSRDataTable = async ({
metadata={findingGroupsData?.meta}
resolvedFilters={filters}
hasHistoricalData={hasHistoricalData}
expandedCheckId={expandedCheckId}
/>
</>
);
@@ -0,0 +1 @@
Finding Groups and grouped selections can be sent to Jira in Cloud with deep links, filter chip display, and Jira feedback toasts
+28 -6
View File
@@ -26,7 +26,9 @@ import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
import { ProviderProps } from "@/types/providers";
import {
buildFindingGroupFilterOption,
buildFindingsFilterChips,
type FindingCheckFilterOption,
getFindingsFilterDisplayValue,
} from "./findings-filters.utils";
@@ -42,6 +44,7 @@ interface FindingsFiltersProps {
uniqueResourceTypes: string[];
uniqueCategories: string[];
uniqueGroups: string[];
checkOptions?: FindingCheckFilterOption[];
trailingControls?: ReactNode;
variant?: "default" | "alerts-edit";
}
@@ -71,6 +74,7 @@ const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
const FILTER_CONTROL_COLUMN_CLASS =
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
const FILTER_GRID_ITEM_CLASS = "min-w-0";
const FINDING_GROUP_FILTER_KEYS = ["filter[check_id]", "filter[check_id__in]"];
export const FindingsFilterBatchControls = ({
providers,
@@ -85,6 +89,7 @@ export const FindingsFilterBatchControls = ({
uniqueResourceTypes,
uniqueCategories,
uniqueGroups,
checkOptions = [],
trailingControls,
appliedFilters,
pendingFilters,
@@ -102,6 +107,18 @@ export const FindingsFilterBatchControls = ({
}: FindingsFilterBatchControlsProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const isAlertsEdit = variant === "alerts-edit";
const checkTitles = Object.fromEntries(
checkOptions.map(({ checkId, checkTitle }) => [
checkId,
checkTitle || checkId,
]),
);
const findingGroupFilterOption = buildFindingGroupFilterOption({
checkOptions,
selectedCheckIds: getFilterValue("filter[check_id]"),
selectedCheckIdsIn: getFilterValue("filter[check_id__in]"),
checkTitles,
});
const customFilters = [
...filterFindings
@@ -112,39 +129,41 @@ export const FindingsFilterBatchControls = ({
getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, {
providers,
scans: scanDetails,
checkTitles,
}),
})),
...(findingGroupFilterOption ? [findingGroupFilterOption] : []),
{
key: FILTER_FIELD.REGION,
labelCheckboxGroup: "Regions",
values: uniqueRegions,
index: 3,
index: 4,
},
{
key: FILTER_FIELD.SERVICE,
labelCheckboxGroup: "Services",
values: uniqueServices,
index: 4,
index: 5,
},
{
key: FILTER_FIELD.RESOURCE_TYPE,
labelCheckboxGroup: "Resource Type",
values: uniqueResourceTypes,
index: 8,
index: 9,
},
{
key: FILTER_FIELD.CATEGORY,
labelCheckboxGroup: "Category",
values: uniqueCategories,
labelFormatter: getCategoryLabel,
index: 5,
index: 6,
},
{
key: FILTER_FIELD.RESOURCE_GROUPS,
labelCheckboxGroup: "Resource Group",
values: uniqueGroups,
labelFormatter: getGroupLabel,
index: 6,
index: 7,
},
...(isAlertsEdit
? []
@@ -164,7 +183,7 @@ export const FindingsFilterBatchControls = ({
scans: scanDetails,
},
),
index: 7,
index: 8,
},
]),
];
@@ -177,6 +196,7 @@ export const FindingsFilterBatchControls = ({
providers,
providerGroups,
scans: scanDetails,
checkTitles,
},
);
const pendingFilterChips: FilterChip[] = buildFindingsFilterChips(
@@ -185,6 +205,7 @@ export const FindingsFilterBatchControls = ({
providers,
providerGroups,
scans: scanDetails,
checkTitles,
},
);
const appliedCount = countVisibleFilterKeys(appliedFilters);
@@ -347,6 +368,7 @@ export const FindingsFilters = (props: FindingsFiltersProps) => {
getFilterValue,
} = useFilterBatch({
defaultParams: { "filter[muted]": "false" },
exclusiveFilterGroups: [FINDING_GROUP_FILTER_KEYS],
});
return (
@@ -5,6 +5,7 @@ import { ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import {
buildFindingGroupFilterOption,
buildFindingsFilterChips,
getFindingsFilterDisplayValue,
} from "./findings-filters.utils";
@@ -164,6 +165,30 @@ describe("getFindingsFilterDisplayValue", () => {
);
});
it("uses the finding group title for check_id filters when available", () => {
expect(
getFindingsFilterDisplayValue(
"filter[check_id]",
"teams_external_users_can_join",
{
checkTitles: {
teams_external_users_can_join:
"External Teams users can join meetings",
},
},
),
).toBe("External Teams users can join meetings");
});
it("keeps the check id when no finding group title is available", () => {
expect(
getFindingsFilterDisplayValue(
"filter[check_id]",
"teams_external_users_can_join",
),
).toBe("teams_external_users_can_join");
});
it("uses the provider display name regardless of account alias/uid", () => {
expect(
getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", {
@@ -298,6 +323,45 @@ describe("buildFindingsFilterChips", () => {
expect(chipsPlural[0].displayValues).toEqual(["New", "Changed"]);
});
it("renders filter[check_id] as a first-class Finding Group chip", () => {
// Given - exact deep-link params from the grouped findings page.
const chips = buildFindingsFilterChips(
{
"filter[check_id]": ["teams_external_users_can_join"],
},
{
checkTitles: {
teams_external_users_can_join:
"External Teams users can join meetings",
},
},
);
expect(chips).toEqual([
{
key: "filter[check_id]",
label: "Finding Group",
value: "teams_external_users_can_join",
displayValue: "External Teams users can join meetings",
},
]);
});
it("renders filter[check_id__in] with the Finding Group chip label", () => {
const chips = buildFindingsFilterChips({
"filter[check_id__in]": ["teams_external_users_can_join"],
});
expect(chips).toEqual([
{
key: "filter[check_id__in]",
label: "Finding Group",
value: "teams_external_users_can_join",
displayValue: "teams_external_users_can_join",
},
]);
});
it("skips muted filters because the table toolbar owns that control", () => {
const chips = buildFindingsFilterChips({
"filter[muted]": ["include"],
@@ -324,3 +388,47 @@ describe("buildFindingsFilterChips", () => {
]);
});
});
describe("buildFindingGroupFilterOption", () => {
it("builds a selectable Finding Group filter from fetched options and URL-backed values", () => {
// Given
const filter = buildFindingGroupFilterOption({
checkOptions: [
{
checkId: "teams_external_users_can_join",
checkTitle: "External Teams users can join meetings",
},
],
selectedCheckIds: ["s3_bucket_public_access"],
selectedCheckIdsIn: ["teams_external_users_can_join"],
checkTitles: {
teams_external_users_can_join: "External Teams users can join meetings",
},
});
// Then
expect(filter).toMatchObject({
key: "check_id__in",
labelCheckboxGroup: "Finding Group",
values: ["teams_external_users_can_join", "s3_bucket_public_access"],
index: 3,
});
expect(filter?.labelFormatter?.("teams_external_users_can_join")).toBe(
"External Teams users can join meetings",
);
expect(filter?.labelFormatter?.("s3_bucket_public_access")).toBe(
"s3_bucket_public_access",
);
});
it("omits the Finding Group filter when there are no selectable or URL-backed values", () => {
expect(
buildFindingGroupFilterOption({
checkOptions: [],
selectedCheckIds: [],
selectedCheckIdsIn: [],
checkTitles: {},
}),
).toBeNull();
});
});
@@ -7,14 +7,21 @@ import {
} from "@/lib/helper-filters";
import { FINDING_STATUS_DISPLAY_NAMES } from "@/types";
import { ProviderGroup } from "@/types/components";
import type { FilterOption } from "@/types/filters";
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
export interface FindingCheckFilterOption {
checkId: string;
checkTitle?: string;
}
interface GetFindingsFilterDisplayValueOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
providerGroups?: ProviderGroup[];
checkTitles?: Record<string, string>;
}
const FINDING_DELTA_DISPLAY_NAMES: Record<string, string> = {
@@ -64,6 +71,12 @@ export function getFindingsFilterDisplayValue(
if (filterKey === "filter[scan__in]" || filterKey === "filter[scan]") {
return getScanDisplayValue(value, options.scans || []);
}
if (
filterKey === "filter[check_id]" ||
filterKey === "filter[check_id__in]"
) {
return options.checkTitles?.[value] || value;
}
if (filterKey === "filter[severity__in]") {
return (
SEVERITY_DISPLAY_NAMES[
@@ -100,6 +113,43 @@ export function getFindingsFilterDisplayValue(
return formatLabel(value);
}
function uniqueNonEmptyValues(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)));
}
export function buildFindingGroupFilterOption({
checkOptions,
selectedCheckIds,
selectedCheckIdsIn,
checkTitles,
}: {
checkOptions: FindingCheckFilterOption[];
selectedCheckIds: string[];
selectedCheckIdsIn: string[];
checkTitles: Record<string, string>;
}): FilterOption | null {
const values = uniqueNonEmptyValues([
...checkOptions.map((option) => option.checkId),
...selectedCheckIds,
...selectedCheckIdsIn,
]);
if (values.length === 0) {
return null;
}
return {
key: "check_id__in",
labelCheckboxGroup: "Finding Group",
values,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue("filter[check_id]", value, {
checkTitles,
}),
index: 3,
};
}
/**
* Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels.
* Used to render chips in the FilterSummaryStrip.
@@ -108,6 +158,8 @@ export function getFindingsFilterDisplayValue(
* label is missing.
*/
export const FILTER_KEY_LABELS: Record<FindingsFilterParam, string> = {
"filter[check_id]": "Finding Group",
"filter[check_id__in]": "Finding Group",
"filter[provider_type__in]": "Provider",
"filter[provider_id__in]": "Account",
"filter[provider_groups__in]": "Provider Group",
@@ -134,6 +186,7 @@ interface BuildFindingsFilterChipsOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
providerGroups?: ProviderGroup[];
checkTitles?: Record<string, string>;
includeMuted?: boolean;
}
@@ -1,7 +1,10 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
// ---------------------------------------------------------------------------
// Hoist mocks to avoid deep dependency chains
// ---------------------------------------------------------------------------
@@ -42,6 +45,7 @@ function deferredPromise<T>() {
describe("FloatingMuteButton — onBeforeOpen error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it("should reset isResolving (re-enable button) when onBeforeOpen rejects", async () => {
@@ -199,4 +203,109 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
});
});
});
it("should route Send to Jira through the action chooser without opening mute", async () => {
// Given
const onSendToJira = vi.fn();
const user = userEvent.setup();
render(
<FloatingMuteButton
selectedCount={1}
selectedFindingIds={["finding-1"]}
canSendToJira
onSendToJira={onSendToJira}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "1 selected" }));
await user.click(screen.getByRole("menuitem", { name: "Send to Jira" }));
// Then
expect(onSendToJira).toHaveBeenCalledTimes(1);
const modalCalls = MuteFindingsModalMock.mock.calls as unknown as Array<
[{ isOpen?: boolean }]
>;
expect(modalCalls.some(([props]) => props.isOpen === true)).toBe(false);
});
it("should render custom mixed-selection action labels", async () => {
// Given
const user = userEvent.setup();
render(
<FloatingMuteButton
selectedCount={2}
selectedFindingIds={["group-1", "finding-1"]}
label="1 Group and 1 Finding selected"
muteLabel="Mute 1 Group and 1 Finding"
sendToJiraLabel="Send 1 Group and 1 Finding to Jira"
showSendToJira
/>,
);
// When
await user.click(
screen.getByRole("button", {
name: "1 Group and 1 Finding selected",
}),
);
// Then
expect(
screen.getByRole("menuitem", {
name: "Mute 1 Group and 1 Finding",
}),
).toBeVisible();
expect(
screen.getByRole("menuitem", {
name: "Send 1 Group and 1 Finding to Jira",
}),
).toHaveTextContent("Send 1 Group and 1 Finding to Jira");
});
it("should show the Cloud Jira tooltip and open the upgrade modal", async () => {
// Given
const onSendToJira = vi.fn();
const user = userEvent.setup();
render(
<FloatingMuteButton
selectedCount={1}
selectedFindingIds={["finding-1"]}
showSendToJira
canSendToJira={false}
onSendToJira={onSendToJira}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "1 selected" }));
const jiraAction = screen.getByRole("menuitem", { name: "Send to Jira" });
// Then
expect(jiraAction).toBeVisible();
expect(jiraAction).not.toHaveAttribute("aria-disabled");
expect(
within(jiraAction).queryByText("Available only in Prowler Cloud"),
).not.toBeInTheDocument();
// When
await user.hover(jiraAction);
// Then
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"Available only in Prowler Cloud",
);
// When
await user.click(jiraAction);
// Then
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH,
);
expect(onSendToJira).not.toHaveBeenCalled();
});
});
+82 -14
View File
@@ -1,11 +1,19 @@
"use client";
import { VolumeX } from "lucide-react";
import { Ellipsis, VolumeX } from "lucide-react";
import { useState } from "react";
import { createPortal } from "react-dom";
import { JiraIcon } from "@/components/icons/services/IconServices";
import { Button } from "@/components/shadcn";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown/action-dropdown";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { PROWLER_CLOUD_ONLY_TOOLTIP } from "@/lib/deployment";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { MuteFindingsModal } from "./mute-findings-modal";
@@ -19,6 +27,16 @@ interface FloatingMuteButtonProps {
isBulkOperation?: boolean;
/** Custom button label. Defaults to "Mute ({selectedCount})" */
label?: string;
/** Custom mute action label. Defaults to "Mute". */
muteLabel?: string;
/** Opens the Jira flow for the current selection. */
onSendToJira?: () => void;
/** Whether the Jira action is available for the current selection. */
canSendToJira?: boolean;
/** Whether the Jira action should be displayed in the action menu. */
showSendToJira?: boolean;
/** Custom Jira action label. Defaults to "Send to Jira". */
sendToJiraLabel?: string;
}
export function FloatingMuteButton({
@@ -28,6 +46,11 @@ export function FloatingMuteButton({
onBeforeOpen,
isBulkOperation = false,
label,
muteLabel = "Mute",
onSendToJira,
canSendToJira = false,
showSendToJira = canSendToJira,
sendToJiraLabel = "Send to Jira",
}: FloatingMuteButtonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
@@ -36,6 +59,9 @@ export function FloatingMuteButton({
const [mutePreparationError, setMutePreparationError] = useState<
string | null
>(null);
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
const handleModalOpenChange = (
nextOpen: boolean | ((previousOpen: boolean) => boolean),
@@ -51,7 +77,7 @@ export function FloatingMuteButton({
}
};
const handleClick = async () => {
const handleMuteClick = async () => {
if (onBeforeOpen) {
setResolvedIds([]);
setMutePreparationError(null);
@@ -79,6 +105,15 @@ export function FloatingMuteButton({
}
};
const handleJiraClick = () => {
if (!canSendToJira) {
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
return;
}
onSendToJira?.();
};
const handleComplete = () => {
setResolvedIds([]);
onComplete?.();
@@ -103,20 +138,53 @@ export function FloatingMuteButton({
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 duration-300">
<Button
onClick={handleClick}
disabled={isResolving}
size="lg"
className="shadow-lg"
>
{isResolving ? (
<Spinner className="size-5" />
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 flex gap-2 duration-300">
<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={sendToJiraLabel}
tooltip={
!canSendToJira ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined
}
aria-label={sendToJiraLabel}
onSelect={handleJiraClick}
/>
</ActionDropdown>
) : (
<VolumeX className="size-5" />
<Button
onClick={() => void handleMuteClick()}
disabled={isResolving}
size="lg"
>
{isResolving ? (
<Spinner className="size-5" />
) : (
<VolumeX className="size-5" />
)}
Mute ({selectedCount})
</Button>
)}
{label ?? `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.",
});
},
};
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
buildJiraDispatchChoiceCopy,
JIRA_SELECTION_KIND,
} from "./send-to-jira-modal-copy";
describe("buildJiraDispatchChoiceCopy", () => {
it("uses Finding Group copy for selected Findings grouped Jira choice", () => {
expect(
buildJiraDispatchChoiceCopy({
selectedCount: 2,
isSelectedFindingGroupFlow: true,
}),
).toEqual({
description:
"Create Jira issue(s) for 2 selected Findings from this Finding Group.",
groupedTitle:
"Create one Jira issue for all selected Findings in this Finding Group",
groupedHelp:
"Recommended. The issue will include every selected Finding from this Finding Group.",
individualHelp:
"Use this when each selected Finding should be tracked independently.",
});
});
it("preserves resource copy for resource-based grouped Jira choice", () => {
expect(
buildJiraDispatchChoiceCopy({
selectedCount: 2,
isSelectedFindingGroupFlow: false,
}),
).toEqual({
description:
"Create Jira issue(s) for 2 selected affected failing resources.",
groupedTitle:
"Create one Jira issue for all selected affected failing resources",
groupedHelp:
"Recommended. The issue will include every selected resource from this finding group.",
individualHelp:
"Use this when each selected resource should be tracked independently.",
});
});
it("uses neutral Findings copy outside a single Finding Group", () => {
expect(
buildJiraDispatchChoiceCopy({
selectedCount: 2,
isSelectedFindingGroupFlow: false,
selectionKind: JIRA_SELECTION_KIND.FINDINGS,
}),
).toEqual({
description: "Create Jira issue(s) for 2 selected Findings.",
groupedTitle: "Create one Jira issue for all selected Findings",
groupedHelp:
"Recommended. The issue will include every selected Finding.",
individualHelp:
"Use this when each selected Finding should be tracked independently.",
});
});
});
@@ -0,0 +1,59 @@
export const JIRA_SELECTION_KIND = {
FINDINGS: "findings",
RESOURCES: "resources",
} as const;
type JiraSelectionKind =
(typeof JIRA_SELECTION_KIND)[keyof typeof JIRA_SELECTION_KIND];
interface JiraDispatchChoiceCopyParams {
selectedCount: number;
isSelectedFindingGroupFlow: boolean;
selectionKind?: JiraSelectionKind;
}
interface JiraDispatchChoiceCopy {
description: string;
groupedTitle: string;
groupedHelp: string;
individualHelp: string;
}
export const buildJiraDispatchChoiceCopy = ({
selectedCount,
isSelectedFindingGroupFlow,
selectionKind = JIRA_SELECTION_KIND.RESOURCES,
}: JiraDispatchChoiceCopyParams): JiraDispatchChoiceCopy => {
if (isSelectedFindingGroupFlow) {
return {
description: `Create Jira issue(s) for ${selectedCount} selected Findings from this Finding Group.`,
groupedTitle:
"Create one Jira issue for all selected Findings in this Finding Group",
groupedHelp:
"Recommended. The issue will include every selected Finding from this Finding Group.",
individualHelp:
"Use this when each selected Finding should be tracked independently.",
};
}
if (selectionKind === JIRA_SELECTION_KIND.FINDINGS) {
return {
description: `Create Jira issue(s) for ${selectedCount} selected Findings.`,
groupedTitle: "Create one Jira issue for all selected Findings",
groupedHelp:
"Recommended. The issue will include every selected Finding.",
individualHelp:
"Use this when each selected Finding should be tracked independently.",
};
}
return {
description: `Create Jira issue(s) for ${selectedCount} selected affected failing resources.`,
groupedTitle:
"Create one Jira issue for all selected affected failing resources",
groupedHelp:
"Recommended. The issue will include every selected resource from this finding group.",
individualHelp:
"Use this when each selected resource should be tracked independently.",
};
};
@@ -0,0 +1,748 @@
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 {
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,
sendFindingToJiraMock,
sendJiraDispatchMock,
trackAndPollTaskMock,
toastMock,
} = vi.hoisted(() => ({
getJiraIntegrationsMock: vi.fn(),
getJiraIssueTypesMock: vi.fn(),
sendFindingToJiraMock: vi.fn(),
sendJiraDispatchMock: vi.fn(),
trackAndPollTaskMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("@/actions/integrations/jira-dispatch", () => ({
getJiraIntegrations: getJiraIntegrationsMock,
getJiraIssueTypes: getJiraIssueTypesMock,
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", () => ({
EnhancedMultiSelect: ({
options,
onValueChange,
placeholder,
disabled,
}: {
options: { value: string; label: string }[];
onValueChange: (values: string[]) => void;
placeholder: string;
disabled?: boolean;
}) => (
<button
type="button"
disabled={disabled}
onClick={() => onValueChange([options[0]?.value ?? ""])}
>
{placeholder}
</button>
),
}));
describe("SendToJiraModal", () => {
beforeEach(() => {
vi.clearAllMocks();
getJiraIntegrationsMock.mockResolvedValue({
success: true,
data: [
{
type: "integrations",
id: "jira-1",
attributes: {
inserted_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
enabled: true,
connected: true,
connection_last_checked_at: null,
integration_type: "jira",
configuration: {
domain: "example.atlassian.net",
projects: { SEC: "Security" },
issue_types: { SEC: ["Task"] },
},
},
links: { self: "/integrations/jira-1" },
},
],
});
getJiraIssueTypesMock.mockResolvedValue({ success: true, issueTypes: [] });
sendFindingToJiraMock.mockResolvedValue({
success: true,
taskId: "task-1",
message: "Started",
});
sendJiraDispatchMock.mockResolvedValue({
success: true,
taskId: "task-1",
message: "Started",
});
trackAndPollTaskMock.mockResolvedValue({
status: "ready",
result: { created_count: 1, failed_count: 0 },
});
});
it("shows the grouped-vs-separate choice for a target batch with multiple Findings before dispatching", async () => {
render(
<SendToJiraModal
isOpen
onOpenChange={vi.fn()}
findingTitle="Check A"
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
{
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."
/>,
);
expect(screen.getByText("Jira issue creation mode")).toBeInTheDocument();
expect(
screen.getByText("Create one Jira issue for all selected Findings"),
).toBeInTheDocument();
expect(
screen.queryByText(
"Create one Jira issue for all selected Findings in this Finding Group",
),
).not.toBeInTheDocument();
expect(
screen.getByText("Create Jira issues for 1 Group and 2 Findings."),
).toBeInTheDocument();
expect(screen.getByText("Create separate Jira issues")).toBeInTheDocument();
expect(sendFindingToJiraMock).not.toHaveBeenCalled();
expect(sendJiraDispatchMock).not.toHaveBeenCalled();
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
});
it("uses neutral Findings copy for ordinary multi-Finding selections", async () => {
render(
<SendToJiraModal
isOpen
onOpenChange={vi.fn()}
findingTitle="Finding 1"
selection={targetSelection(
["finding-1", "finding-2"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)}
defaultDispatchMode="grouped"
canChooseGroupedDispatch
/>,
);
expect(screen.getByText("Jira issue creation mode")).toBeInTheDocument();
expect(
screen.getByText("Create one Jira issue for all selected Findings"),
).toBeInTheDocument();
expect(
screen.queryByText(
"Create one Jira issue for all selected Findings in this Finding Group",
),
).not.toBeInTheDocument();
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
});
it("submits mixed Group and Finding batches with the correct dispatch filters and modes", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
sendJiraDispatchMock
.mockResolvedValueOnce({
success: true,
taskId: "group-task",
message: "Group started",
})
.mockResolvedValueOnce({
success: true,
taskId: "finding-task",
message: "Findings started",
});
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
{
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."
/>,
);
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" }),
);
await user.click(
screen.getByRole("radio", { name: "Create separate Jira issues" }),
);
// When
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
// Then
await waitFor(() => expect(sendJiraDispatchMock).toHaveBeenCalledTimes(2));
expect(sendFindingToJiraMock).not.toHaveBeenCalled();
expect(sendJiraDispatchMock).toHaveBeenNthCalledWith(1, {
integrationId: "jira-1",
targetIds: ["check-a"],
filter: "check_id",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "grouped",
});
expect(sendJiraDispatchMock).toHaveBeenNthCalledWith(2, {
integrationId: "jira-1",
targetIds: ["finding-1", "finding-2"],
filter: "finding_id",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "individual",
});
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 () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
selection={targetSelection(
["finding-1"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)}
findingTitle="Finding 1"
/>,
);
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(toastMock).toHaveBeenCalledWith({
title: "Success!",
description: "Finding successfully sent to Jira!",
}),
);
expect(sendFindingToJiraMock).toHaveBeenCalledWith(
"jira-1",
"finding-1",
"SEC",
"Task",
);
});
it("shows a success toast after grouped Finding Group dispatch succeeds", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
sendJiraDispatchMock.mockResolvedValueOnce({
success: true,
taskId: "group-task",
message: "Group started",
});
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={targetSelection(["check-a"], JIRA_DISPATCH_TARGET.CHECK_ID)}
defaultDispatchMode="grouped"
selectedResourceCount={1}
/>,
);
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(toastMock).toHaveBeenCalledWith({
title: "Success!",
description: "Finding successfully sent to Jira!",
}),
);
expect(sendJiraDispatchMock).toHaveBeenCalledWith({
integrationId: "jira-1",
targetIds: ["check-a"],
filter: "check_id",
projectKey: "SEC",
issueType: "Task",
dispatchMode: "grouped",
});
});
it("delegates grouped Finding Group task tracking to the shared watcher", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
sendJiraDispatchMock.mockResolvedValueOnce({
success: true,
taskId: "group-task",
message: "Group started",
});
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={targetSelection(["check-a"], JIRA_DISPATCH_TARGET.CHECK_ID)}
defaultDispatchMode="grouped"
selectedResourceCount={1}
/>,
);
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).toHaveBeenCalledOnce());
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
title: "Success!",
description: "Finding successfully sent to Jira!",
}),
);
});
it("shows one success toast after mixed Group and Finding batches all succeed", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
sendJiraDispatchMock
.mockResolvedValueOnce({
success: true,
taskId: "group-task",
message: "Group started",
})
.mockResolvedValueOnce({
success: true,
taskId: "finding-task",
message: "Findings started",
});
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
},
{
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
])}
defaultDispatchMode="grouped"
selectedResourceCount={1}
/>,
);
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(toastMock).toHaveBeenCalledWith({
title: "Success!",
description: "2 Jira issues were created or updated successfully.",
}),
);
expect(toastMock).toHaveBeenCalledTimes(1);
});
it("shows a partial success toast when a task reports created and failed issues", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
trackAndPollTaskMock.mockResolvedValue({
status: "ready",
result: { created_count: 2, failed_count: 1 },
});
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
selection={targetSelection(
["finding-1"],
JIRA_DISPATCH_TARGET.FINDING_ID,
)}
findingTitle="Finding 1"
/>,
);
await waitFor(() => expect(getJiraIntegrationsMock).toHaveBeenCalled());
// When
await user.click(
screen.getByRole("button", { name: "Select a Jira project" }),
);
await user.click(
screen.getByRole("button", { name: "Select an issue type" }),
);
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
// Then
await waitFor(() =>
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/updated issues.",
}),
);
expect(toastMock).not.toHaveBeenCalledWith(
expect.objectContaining({ title: "Success!" }),
);
});
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();
const onOpenChange = vi.fn();
sendJiraDispatchMock
.mockResolvedValueOnce({
success: true,
taskId: "group-task",
message: "Group started",
})
.mockResolvedValueOnce({
success: true,
taskId: "finding-task",
message: "Findings started",
});
trackAndPollTaskMock
.mockResolvedValueOnce({
status: "ready",
result: { created_count: 1, failed_count: 0 },
})
.mockResolvedValueOnce({
status: "ready",
result: { created_count: 0, failed_count: 1 },
});
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
},
{
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
])}
defaultDispatchMode="grouped"
selectedResourceCount={1}
/>,
);
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(toastMock).toHaveBeenCalledWith({
title: "Partial success",
description:
"Finding successfully sent to Jira! Some Jira dispatches failed: Jira dispatch completed with 1 failed and 0 created/updated issues.",
}),
);
expect(toastMock).not.toHaveBeenCalledWith(
expect.objectContaining({ title: "Success!" }),
);
});
it("polls started tasks when a later mixed dispatch batch fails to launch", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
sendJiraDispatchMock
.mockResolvedValueOnce({
success: true,
taskId: "group-task",
message: "Group started",
})
.mockResolvedValueOnce({
success: false,
error: "Failed to launch Finding batch.",
});
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
},
{
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
])}
defaultDispatchMode="grouped"
selectedResourceCount={1}
/>,
);
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).toHaveBeenCalledWith(
expect.objectContaining({ taskId: "group-task" }),
),
);
await waitFor(() =>
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 () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
sendJiraDispatchMock
.mockResolvedValueOnce({
success: true,
taskId: "group-task",
message: "Group started",
})
.mockResolvedValueOnce({
success: false,
error: "Failed to launch Finding batch.",
});
trackAndPollTaskMock.mockResolvedValueOnce({
status: "error",
error: "Jira dispatch completed with 1 failed issue.",
});
render(
<SendToJiraModal
isOpen
onOpenChange={onOpenChange}
findingTitle="Check A"
selection={batchSelection([
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
},
{
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
])}
defaultDispatchMode="grouped"
selectedResourceCount={1}
/>,
);
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(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
variant: "destructive",
title: "Error",
description:
"Jira dispatch completed with 1 failed issue. Failed to launch Finding batch.",
}),
),
);
});
});
+480 -192
View File
@@ -2,49 +2,138 @@
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 { useToast } from "@/components/shadcn";
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 } from "@/components/shadcn/radio-group/radio-group";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { IntegrationProps } from "@/types/integrations";
import { toast, ToastAction } from "@/components/shadcn/toast";
import { useMountEffect } from "@/hooks/use-mount-effect";
import {
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 JiraDispatchTargetBatch,
type JiraDispatchTaskResult,
type JiraSelection,
} from "@/types/integrations";
interface SendToJiraModalProps {
import {
buildJiraDispatchChoiceCopy,
JIRA_SELECTION_KIND,
} from "./send-to-jira-modal-copy";
export interface SendToJiraModalProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
findingId: string;
selection: JiraSelection;
findingTitle?: string;
defaultDispatchMode?: JiraDispatchMode;
canChooseGroupedDispatch?: boolean;
isFindingGroupSelection?: boolean;
selectedResourceCount?: number;
description?: string;
}
interface JiraDispatchSettings {
integrationId: string;
projectKey: string;
issueType: string;
dispatchMode: JiraDispatchMode;
}
interface StartedJiraTask {
taskId: string;
dispatchMode: JiraDispatchMode;
}
interface JiraTrackedOutcome {
success: boolean;
message?: string;
error?: string;
warning?: string;
retryBatch?: JiraDispatchTargetBatch;
successfulCount?: number;
}
const sendToJiraSchema = z.object({
integration: z.string().min(1, "Please select a Jira integration"),
project: z.string().min(1, "Please select a project"),
issueType: z.string().min(1, "Please select an issue type"),
dispatchMode: z.enum([
JIRA_DISPATCH_MODE.GROUPED,
JIRA_DISPATCH_MODE.INDIVIDUAL,
]),
});
type SendToJiraFormData = z.infer<typeof sendToJiraSchema>;
export const SendToJiraModal = ({
isOpen,
const getConfiguredIssueTypes = (
integration: IntegrationProps | undefined,
projectKey: string,
) => {
const configuredIssueTypes = integration?.attributes.configuration
.issue_types as Record<string, string[]> | undefined;
return configuredIssueTypes &&
typeof configuredIssueTypes === "object" &&
!Array.isArray(configuredIssueTypes)
? (configuredIssueTypes[projectKey] ?? [])
: [];
};
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,
}: SendToJiraModalProps) => {
const { toast } = useToast();
defaultDispatchMode = JIRA_DISPATCH_MODE.INDIVIDUAL,
canChooseGroupedDispatch = false,
isFindingGroupSelection = false,
selectedResourceCount,
description,
}: 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[]>
>({});
@@ -56,191 +145,342 @@ export const SendToJiraModal = ({
integration: "",
project: "",
issueType: "",
dispatchMode: defaultDispatchMode,
},
});
const selectedIntegration = form.watch("integration");
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 || findingTargetCount > 1) &&
(findingTargetCount > 1 || jiraSelectedResourceCount > 1);
const checkIdBatches = jiraTargetBatches.filter(
(batch) => batch.targetType === JIRA_DISPATCH_TARGET.CHECK_ID,
);
const hasOnlySingleFindingGroupBatch =
jiraTargetBatches.length === 1 &&
checkIdBatches.length === 1 &&
checkIdBatches[0].targetIds.length === 1;
const isSelectedFindingGroupFlow =
shouldShowDispatchChoice &&
(isFindingGroupSelection || hasOnlySingleFindingGroupBatch);
const jiraDispatchChoiceCopy = buildJiraDispatchChoiceCopy({
selectedCount:
findingTargetCount > 1 ? findingTargetCount : jiraSelectedResourceCount,
isSelectedFindingGroupFlow,
selectionKind:
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, toast]);
const handleSubmit = async (data: SendToJiraFormData) => {
// Close modal immediately; continue processing in background
onOpenChange(false);
useMountEffect(() => {
let active = true;
void (async () => {
try {
// Send the finding to Jira
const result = await sendFindingToJira(
data.integration,
findingId,
data.project,
data.issueType,
);
const result = await getJiraIntegrations();
if (!active) return;
if (!result.success) {
throw new Error(result.error || "Failed to send to Jira");
throw new Error(result.error || "Unable to fetch Jira integrations");
}
// Poll for task completion and notify once
const taskResult = await pollJiraDispatchTask(result.taskId);
if (!taskResult.success) {
throw new Error(taskResult.error || "Failed to create Jira issue");
setIntegrations(result.data);
if (result.data.length === 1) {
form.setValue("integration", result.data[0].id);
}
toast({
title: "Success!",
description:
taskResult.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,
toast,
]);
});
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}`,
@@ -248,13 +488,17 @@ export const SendToJiraModal = ({
return (
<Modal
open={isOpen}
open
onOpenChange={onOpenChange}
title="Send Finding to Jira"
description={
findingTitle
? `Create a Jira issue for: "${findingTitle}"`
: "Select integration, project and issue type to create a Jira issue"
description
? description
: shouldShowDispatchChoice
? jiraDispatchChoiceCopy.description
: findingTitle
? `Create a Jira issue for: "${findingTitle}"`
: "Select integration, project and issue type to create a Jira issue"
}
>
<Form {...form}>
@@ -262,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" />
@@ -270,7 +513,6 @@ export const SendToJiraModal = ({
</div>
)}
{/* Integration Selection */}
{!isFetchingIntegrations && integrations.length > 1 && (
<FormField
control={form.control}
@@ -287,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>
@@ -310,7 +550,6 @@ export const SendToJiraModal = ({
/>
)}
{/* Project Selection */}
{!isFetchingIntegrations &&
selectedIntegration &&
projectEntries.length > 0 && (
@@ -329,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>
@@ -349,7 +588,6 @@ export const SendToJiraModal = ({
/>
)}
{/* Issue Type Selection */}
{selectedProject && (
<FormField
control={form.control}
@@ -365,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>
@@ -389,7 +626,52 @@ export const SendToJiraModal = ({
/>
)}
{/* No integrations or none connected message */}
{shouldShowDispatchChoice && (
<FormField
control={form.control}
name="dispatchMode"
render={({ field }) => (
<div className="flex flex-col gap-2">
<span className="text-text-neutral-secondary text-xs font-light tracking-tight">
Jira issue creation mode
</span>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
>
<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}
</span>
<span className="text-text-neutral-secondary text-xs">
{jiraDispatchChoiceCopy.groupedHelp}
</span>
</span>
</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
</span>
<span className="text-text-neutral-secondary text-xs">
{jiraDispatchChoiceCopy.individualHelp}
</span>
</span>
</CustomRadio>
</RadioGroup>
<FormMessage className="text-text-error text-xs" />
</div>
)}
/>
)}
{!isFetchingIntegrations &&
(integrations.length === 0 || !hasConnectedIntegration) ? (
<CustomBanner
@@ -421,3 +703,9 @@ export const SendToJiraModal = ({
</Modal>
);
};
export const SendToJiraModal = ({ isOpen, ...props }: SendToJiraModalProps) => {
if (!isOpen) return null;
return <SendToJiraModalContent {...props} />;
};
@@ -5,7 +5,25 @@ import type {
InputHTMLAttributes,
ReactNode,
} from "react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
interface JiraModalMockProps {
selection: { targetId?: string };
isOpen: boolean;
}
const { SendToJiraModalMock, isGroupedJiraDispatchEnabledMock } = vi.hoisted(
() => ({
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),
}),
);
// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env.
vi.mock("@/components/shadcn/custom/custom-link", () => ({
@@ -14,8 +32,7 @@ vi.mock("@/components/shadcn/custom/custom-link", () => ({
),
}));
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
vi.mock("@/components/shadcn", () => ({
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
@@ -42,19 +59,7 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: ({
findingId,
isOpen,
}: {
findingId: string;
isOpen: boolean;
}) => (
<div
data-testid="jira-modal"
data-finding-id={findingId}
data-open={isOpen ? "true" : "false"}
/>
),
SendToJiraModal: SendToJiraModalMock,
}));
vi.mock("@/components/icons/services/IconServices", () => ({
@@ -69,12 +74,14 @@ vi.mock("@/components/shadcn/dropdown", () => ({
label,
onSelect,
disabled,
disabledTooltip,
}: {
label: string;
onSelect?: () => void;
disabled?: boolean;
disabledTooltip?: string;
}) => (
<button disabled={disabled} onClick={onSelect}>
<button disabled={disabled} onClick={onSelect} title={disabledTooltip}>
{label}
</button>
),
@@ -175,6 +182,11 @@ vi.mock("@/lib/date-utils", () => ({
getFailingForLabel: () => "2d",
}));
vi.mock("@/lib/deployment", () => ({
isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
}));
const notificationIndicatorMock = vi.fn((_props: unknown) => null);
vi.mock("./notification-indicator", () => ({
@@ -196,6 +208,7 @@ import {
CLOUD_ONLY_TOOLTIP_COPY,
EDITING_UNAVAILABLE_COPY,
} from "./finding-triage-cells";
import { FindingsSelectionContext } from "./findings-selection-context";
function makeTriageSummary(
overrides?: Partial<FindingTriageSummary>,
@@ -284,6 +297,11 @@ function renderResourceActionsCell({
}
describe("column-finding-resources", () => {
beforeEach(() => {
vi.clearAllMocks();
isGroupedJiraDispatchEnabledMock.mockReturnValue(true);
});
it("should render actions as the last visible column after Triage without Notes", () => {
// Given
const columns = getColumnFindingResources({
@@ -296,6 +314,7 @@ describe("column-finding-resources", () => {
// Then
expect(columnIds.slice(-2)).toEqual(["triage", "actions"]);
expect(columnIds).not.toContain("status");
expect(columnIds).not.toContain("notes");
expect(
(columns.at(-1) as { id?: string; size?: number } | undefined)?.size,
@@ -518,4 +537,107 @@ describe("column-finding-resources", () => {
"true",
);
});
it("should pass selected same-group affected failing resources as grouped Jira targets", async () => {
// Given
const user = userEvent.setup();
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 2,
});
const actionColumn = columns.find(
(col) => (col as { id?: string }).id === "actions",
);
if (!actionColumn?.cell) {
throw new Error("actions column not found");
}
const CellComponent = actionColumn.cell as (props: {
row: { original: FindingResourceRow };
}) => ReactNode;
render(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: ["finding-1", "finding-2"],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
}}
>
{CellComponent({
row: {
original: makeResource({ findingId: "finding-1" }),
},
})}
</FindingsSelectionContext.Provider>,
);
// When
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
// Then
expect(SendToJiraModalMock).toHaveBeenLastCalledWith(
expect.objectContaining({
selection: {
kind: "target-list",
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
defaultDispatchMode: "grouped",
canChooseGroupedDispatch: true,
}),
undefined,
);
});
it("should disable selected multi-finding Jira dispatch outside cloud", async () => {
// Given
isGroupedJiraDispatchEnabledMock.mockReturnValue(false);
const user = userEvent.setup();
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 2,
});
const actionColumn = columns.find(
(col) => (col as { id?: string }).id === "actions",
);
if (!actionColumn?.cell) {
throw new Error("actions column not found");
}
const CellComponent = actionColumn.cell as (props: {
row: { original: FindingResourceRow };
}) => ReactNode;
render(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: ["finding-1", "finding-2"],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
}}
>
{CellComponent({
row: {
original: makeResource({ findingId: "finding-1" }),
},
})}
</FindingsSelectionContext.Provider>,
);
// When
const jiraButton = screen.getByRole("button", { name: "Send to Jira" });
await user.click(jiraButton);
// Then
expect(jiraButton).toBeDisabled();
expect(jiraButton).toHaveAttribute(
"title",
"Available only in Prowler Cloud",
);
expect(SendToJiraModalMock).not.toHaveBeenCalledWith(
expect.objectContaining({ isOpen: true }),
undefined,
);
});
});
@@ -18,16 +18,18 @@ import { InfoField } from "@/components/shadcn/info-field/info-field";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { SeverityBadge } from "@/components/shadcn/table";
import { DataTableColumnHeader } from "@/components/shadcn/table/data-table-column-header";
import {
type FindingStatus,
StatusFindingBadge,
} from "@/components/shadcn/table/status-finding-badge";
import { getFailingForLabel } from "@/lib/date-utils";
import {
isGroupedJiraDispatchEnabled,
PROWLER_CLOUD_ONLY_TOOLTIP,
} from "@/lib/deployment";
import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection";
import { FindingResourceRow } from "@/types";
import type {
FindingTriageLoadedNote,
FindingTriageSummary,
} from "@/types/findings-triage";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import { canMuteFindingResource } from "./finding-resource-selection";
import {
@@ -69,6 +71,7 @@ const ResourceRowActions = ({
const isCurrentSelected = selectedFindingIds.includes(resource.findingId);
const hasMultipleSelected = selectedFindingIds.length > 1;
const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled();
const getDisplayIds = (): string[] => {
if (isCurrentSelected && hasMultipleSelected) {
@@ -83,6 +86,12 @@ const ResourceRowActions = ({
if (ids.length > 1) return `Mute ${ids.length}`;
return "Mute";
};
const displayIds = getDisplayIds();
const canSendToJira = displayIds.length === 1 || groupedJiraDispatchEnabled;
const jiraSelection = createJiraTargetSelection(
displayIds,
JIRA_DISPATCH_TARGET.FINDING_ID,
);
const handleMuteClick = async () => {
const displayIds = getDisplayIds();
@@ -123,12 +132,22 @@ const ResourceRowActions = ({
onComplete={handleMuteComplete}
/>
)}
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={resource.findingId}
findingTitle={resource.checkId}
/>
{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()}
@@ -162,7 +181,11 @@ const ResourceRowActions = ({
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
onSelect={() => setIsJiraModalOpen(true)}
disabled={!canSendToJira}
disabledTooltip={PROWLER_CLOUD_ONLY_TOOLTIP}
onSelect={() => {
if (canSendToJira) setIsJiraModalOpen(true);
}}
/>
</ActionDropdown>
</div>
@@ -243,24 +266,14 @@ export function getColumnFindingResources({
enableSorting: false,
enableHiding: false,
},
// Status
{
id: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
return (
<StatusFindingBadge status={row.original.status as FindingStatus} />
);
},
enableSorting: false,
},
// Resource — name + uid
// Affected failing resource — name + uid
{
id: "resource",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Resource" />
<DataTableColumnHeader
column={column}
title="Affected failing resource"
/>
),
cell: ({ row }) => (
<div className="max-w-[240px]">
@@ -2,8 +2,29 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { MuteFindingsModalMock } = vi.hoisted(() => ({
MuteFindingsModalMock: vi.fn(() => null),
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_STATUS,
type FindingTriageSummary,
} from "@/types/findings-triage";
import {
DataTableRowActions,
type FindingRowData,
} from "./data-table-row-actions";
import { FindingsSelectionContext } from "./findings-selection-context";
const { MuteFindingsModalMock, isGroupedJiraDispatchEnabledMock } = vi.hoisted(
() => ({
MuteFindingsModalMock: vi.fn(() => null),
isGroupedJiraDispatchEnabledMock: vi.fn(() => true),
}),
);
const { SendToJiraModalMock } = vi.hoisted(() => ({
SendToJiraModalMock: vi.fn(() => null),
}));
vi.mock("next/navigation", () => ({
@@ -15,7 +36,12 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
SendToJiraModal: SendToJiraModalMock,
}));
vi.mock("@/lib/deployment", () => ({
isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock,
PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud",
}));
vi.mock("@/components/icons/services/IconServices", () => ({
@@ -30,12 +56,20 @@ vi.mock("@/components/shadcn/dropdown", () => ({
label,
onSelect,
disabled,
disabledTooltip,
tooltip,
}: {
label: string;
onSelect?: () => void;
disabled?: boolean;
disabledTooltip?: string;
tooltip?: string;
}) => (
<button onClick={onSelect} disabled={disabled}>
<button
onClick={onSelect}
disabled={disabled}
title={tooltip ?? disabledTooltip}
>
{label}
</button>
),
@@ -74,18 +108,6 @@ vi.mock("./finding-note-modal", () => ({
) : null,
}));
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_STATUS,
type FindingTriageSummary,
} from "@/types/findings-triage";
import {
DataTableRowActions,
type FindingRowData,
} from "./data-table-row-actions";
import { FindingsSelectionContext } from "./findings-selection-context";
function deferredPromise<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -134,6 +156,8 @@ function makeFindingRow(overrides?: Partial<FindingRowData>) {
describe("DataTableRowActions", () => {
beforeEach(() => {
vi.clearAllMocks();
isGroupedJiraDispatchEnabledMock.mockReturnValue(true);
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it("opens the mute modal immediately in preparing state for finding groups", async () => {
@@ -252,6 +276,292 @@ describe("DataTableRowActions", () => {
).toBeDisabled();
});
it("allows choosing Jira dispatch mode for a group with multiple failing resources", async () => {
// Given
const user = userEvent.setup();
render(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: [],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
resolveMuteIds: vi.fn(),
}}
>
<DataTableRowActions
row={
{
original: {
id: "group-row-1",
rowType: "group",
checkId: "s3_bucket_public_access",
checkTitle: "S3 bucket public access",
mutedCount: 0,
resourcesFail: 2,
resourcesTotal: 2,
},
} as never
}
/>
</FindingsSelectionContext.Provider>,
);
// When
await user.click(
screen.getByRole("button", { name: "Send Finding Group to Jira" }),
);
// Then
expect(SendToJiraModalMock).toHaveBeenLastCalledWith(
expect.objectContaining({
selection: {
kind: "single",
targetId: "s3_bucket_public_access",
targetType: "check_id",
},
defaultDispatchMode: "grouped",
canChooseGroupedDispatch: true,
selectedResourceCount: 2,
}),
undefined,
);
});
it("shows the Cloud tooltip and opens the upgrade modal for groups outside cloud", async () => {
// Given
isGroupedJiraDispatchEnabledMock.mockReturnValue(false);
const user = userEvent.setup();
render(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: [],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
resolveMuteIds: vi.fn(),
}}
>
<DataTableRowActions
row={
{
original: {
id: "group-row-1",
rowType: "group",
checkId: "s3_bucket_public_access",
checkTitle: "S3 bucket public access",
mutedCount: 0,
resourcesFail: 2,
resourcesTotal: 2,
},
} as never
}
/>
</FindingsSelectionContext.Provider>,
);
// When
const jiraButton = screen.getByRole("button", {
name: "Send Finding Group to Jira",
});
await user.click(jiraButton);
// Then
expect(jiraButton).toBeVisible();
expect(jiraButton).toBeEnabled();
expect(jiraButton).toHaveAttribute(
"title",
"Available only in Prowler Cloud",
);
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH,
);
expect(SendToJiraModalMock).not.toHaveBeenCalledWith(
expect.objectContaining({ isOpen: true }),
undefined,
);
});
it("does not offer Jira dispatch mode choice for a group with one failing resource", async () => {
// Given
const user = userEvent.setup();
render(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: [],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
resolveMuteIds: vi.fn(),
}}
>
<DataTableRowActions
row={
{
original: {
id: "group-row-1",
rowType: "group",
checkId: "s3_bucket_public_access",
checkTitle: "S3 bucket public access",
mutedCount: 0,
resourcesFail: 1,
resourcesTotal: 1,
},
} as never
}
/>
</FindingsSelectionContext.Provider>,
);
// When
await user.click(
screen.getByRole("button", { name: "Send Finding Group to Jira" }),
);
// Then
expect(SendToJiraModalMock).toHaveBeenLastCalledWith(
expect.objectContaining({
selection: {
kind: "single",
targetId: "s3_bucket_public_access",
targetType: "check_id",
},
defaultDispatchMode: "grouped",
canChooseGroupedDispatch: false,
selectedResourceCount: 1,
}),
undefined,
);
});
it("uses grouped Jira dispatch for mixed selected finding groups", async () => {
// Given
const user = userEvent.setup();
render(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: ["check-a", "check-b"],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
resolveMuteIds: vi.fn(),
}}
>
<DataTableRowActions
row={
{
original: {
id: "group-row-1",
rowType: "group",
checkId: "check-a",
checkTitle: "Check A",
mutedCount: 0,
resourcesFail: 2,
resourcesTotal: 2,
},
} as never
}
/>
</FindingsSelectionContext.Provider>,
);
// When
await user.click(
screen.getByRole("button", { name: "Send 2 Finding Groups to Jira" }),
);
// Then
expect(SendToJiraModalMock).toHaveBeenLastCalledWith(
expect.objectContaining({
selection: {
kind: "target-list",
targetIds: ["check-a", "check-b"],
targetType: "check_id",
},
defaultDispatchMode: "grouped",
canChooseGroupedDispatch: false,
}),
undefined,
);
});
it("allows choosing Jira dispatch mode for multiple selected findings", async () => {
// Given
const user = userEvent.setup();
render(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: ["finding-1", "finding-2"],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
}}
>
<DataTableRowActions row={makeFindingRow()} />
</FindingsSelectionContext.Provider>,
);
// When
await user.click(
screen.getByRole("button", { name: "Send 2 Findings to Jira" }),
);
// Then
expect(SendToJiraModalMock).toHaveBeenLastCalledWith(
expect.objectContaining({
selection: {
kind: "target-list",
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
},
defaultDispatchMode: "grouped",
canChooseGroupedDispatch: true,
}),
undefined,
);
});
it("keeps single finding Jira dispatch enabled when other rows are selected outside cloud", async () => {
// Given
isGroupedJiraDispatchEnabledMock.mockReturnValue(false);
const user = userEvent.setup();
render(
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: ["finding-2", "finding-3"],
selectedFindings: [],
clearSelection: vi.fn(),
isSelected: vi.fn(),
}}
>
<DataTableRowActions row={makeFindingRow()} />
</FindingsSelectionContext.Provider>,
);
// When
await user.click(
screen.getByRole("button", { name: "Send 1 Finding to Jira" }),
);
// Then
expect(
screen.getByRole("button", { name: "Send 1 Finding to Jira" }),
).toBeEnabled();
expect(SendToJiraModalMock).toHaveBeenLastCalledWith(
expect.objectContaining({
isOpen: true,
selection: {
kind: "single",
targetId: "finding-1",
targetType: "finding_id",
},
defaultDispatchMode: "individual",
canChooseGroupedDispatch: false,
}),
undefined,
);
});
it("shows Add Triage Note for editable findings without a note", () => {
// Given / When
render(
@@ -13,12 +13,20 @@ import {
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import {
isGroupedJiraDispatchEnabled,
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 { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import type {
FindingTriageLoadedNote,
FindingTriageSummary,
} from "@/types/findings-triage";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import type { ProviderType } from "@/types/providers";
import { canMuteFindingGroup } from "./finding-group-selection";
@@ -115,6 +123,9 @@ export function DataTableRowActions<T extends FindingRowData>({
const [mutePreparationError, setMutePreparationError] = useState<
string | null
>(null);
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
const { isMuted, canMute, title: findingTitle } = extractRowInfo(finding);
const resolvedFindingContext = findingContext ?? {
@@ -149,6 +160,7 @@ export function DataTableRowActions<T extends FindingRowData>({
// Otherwise, just mute this single finding
const isCurrentSelected = selectedFindingIds.includes(muteKey);
const hasMultipleSelected = selectedFindingIds.length > 1;
const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled();
const getDisplayIds = (): string[] => {
if (isCurrentSelected && hasMultipleSelected) {
@@ -166,6 +178,45 @@ export function DataTableRowActions<T extends FindingRowData>({
return isGroup ? "Mute Finding Group" : "Mute Finding";
};
const getJiraTargetIds = (): string[] => {
if (isCurrentSelected && hasMultipleSelected) {
return selectedFindingIds;
}
return [muteKey];
};
const getJiraLabel = () => {
const ids = getJiraTargetIds();
if (ids.length > 1) {
return `Send ${ids.length} ${isGroup ? "Finding Groups" : "Findings"} to Jira`;
}
return isGroup ? "Send Finding Group to Jira" : "Send 1 Finding to Jira";
};
const jiraTargetIds = getJiraTargetIds();
const jiraTargetType = isGroup
? JIRA_DISPATCH_TARGET.CHECK_ID
: JIRA_DISPATCH_TARGET.FINDING_ID;
const jiraSelection = createJiraTargetSelection(
jiraTargetIds,
jiraTargetType,
);
const requiresJiraUpgrade =
(isGroup || jiraTargetIds.length > 1) && !groupedJiraDispatchEnabled;
const selectedJiraResourceCount = isGroup
? (finding.resourcesFail ?? 0)
: undefined;
const hasMultipleSelectedFindings = !isGroup && jiraTargetIds.length > 1;
const jiraDefaultDispatchMode =
isGroup || hasMultipleSelectedFindings
? JIRA_DISPATCH_MODE.GROUPED
: JIRA_DISPATCH_MODE.INDIVIDUAL;
const canChooseGroupedJiraDispatch = groupedJiraDispatchEnabled
? isGroup
? jiraTargetIds.length === 1 && (selectedJiraResourceCount ?? 0) > 1
: hasMultipleSelectedFindings
: false;
const handleMuteModalOpenChange = (
nextOpen: boolean | ((previousOpen: boolean) => boolean),
) => {
@@ -226,14 +277,26 @@ export function DataTableRowActions<T extends FindingRowData>({
router.refresh();
};
const handleJiraClick = () => {
if (requiresJiraUpgrade) {
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
return;
}
setIsJiraModalOpen(true);
};
return (
<>
{!isGroup && (
{(!isGroup || groupedJiraDispatchEnabled) && jiraSelection && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={finding.id}
findingTitle={findingTitle}
selection={jiraSelection}
defaultDispatchMode={jiraDefaultDispatchMode}
canChooseGroupedDispatch={canChooseGroupedJiraDispatch}
selectedResourceCount={selectedJiraResourceCount}
/>
)}
@@ -274,13 +337,14 @@ export function DataTableRowActions<T extends FindingRowData>({
disabled={!canMute || isResolving}
onSelect={handleMuteClick}
/>
{!isGroup && (
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
onSelect={() => setIsJiraModalOpen(true)}
/>
)}
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label={getJiraLabel()}
tooltip={
requiresJiraUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined
}
onSelect={handleJiraClick}
/>
</ActionDropdown>
</div>
</>
@@ -13,4 +13,14 @@ describe("findings group drill down", () => {
expect(source).toContain("useFindingGroupResourceState");
expect(source).not.toContain("useInfiniteResources");
});
it("routes selected child findings through the Send to Jira modal with issue creation mode", () => {
expect(source).toContain("<SendToJiraModal");
expect(source).toContain("JIRA_DISPATCH_TARGET.FINDING_ID");
expect(source).toContain(
"selectedFindingIds.length > 1 && groupedJiraDispatchEnabled",
);
expect(source).toContain("canSendSelectedFindingsToJira");
expect(source).toContain("JIRA_DISPATCH_MODE.GROUPED");
});
});
@@ -7,11 +7,13 @@ import {
} from "@tanstack/react-table";
import { ChevronLeft } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import {
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import {
Table,
@@ -25,12 +27,15 @@ import {
} from "@/components/shadcn/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { cn, hasHistoricalFindingFilter } from "@/lib";
import { isGroupedJiraDispatchEnabled } from "@/lib/deployment";
import {
getFilteredFindingGroupDelta,
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";
import { FloatingMuteButton } from "../floating-mute-button";
@@ -51,6 +56,8 @@ export function FindingsGroupDrillDown({
onCollapse,
}: FindingsGroupDrillDownProps) {
const searchParams = useSearchParams();
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled();
// Keep drill-down endpoint selection aligned with the grouped findings page.
const currentParams = Object.fromEntries(searchParams.entries());
@@ -113,6 +120,12 @@ export function FindingsGroupDrillDown({
const impactedCounts = getFindingGroupImpactedCounts(group);
const rows = table.getRowModel().rows;
const canSendSelectedFindingsToJira =
selectedFindingIds.length === 1 || groupedJiraDispatchEnabled;
const jiraSelection = createJiraTargetSelection(
selectedFindingIds,
JIRA_DISPATCH_TARGET.FINDING_ID,
);
return (
<FindingsSelectionContext.Provider
@@ -127,7 +140,7 @@ export function FindingsGroupDrillDown({
>
<div
className={cn(
"minimal-scrollbar border-border-neutral-secondary bg-bg-neutral-secondary rounded-[14px] shadow-sm",
"minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary",
"flex w-full flex-col overflow-auto border",
)}
>
@@ -235,11 +248,37 @@ export function FindingsGroupDrillDown({
<FloatingMuteButton
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
muteLabel={`Mute ${selectedFindingIds.length} ${
selectedFindingIds.length === 1 ? "Finding" : "Findings"
}`}
onBeforeOpen={async () => {
return resolveSelectedFindingIds(selectedFindingIds);
}}
onComplete={handleMuteComplete}
isBulkOperation
isBulkOperation={selectedFindingIds.length > 1}
showSendToJira
canSendToJira={canSendSelectedFindingsToJira}
sendToJiraLabel={`Send ${selectedFindingIds.length} ${
selectedFindingIds.length === 1 ? "Finding" : "Findings"
} to Jira`}
onSendToJira={() => setIsJiraModalOpen(true)}
/>
)}
{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
}
/>
)}
File diff suppressed because it is too large Load Diff
@@ -6,12 +6,19 @@ import { Suspense, useRef, useState } from "react";
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings";
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { DataTable } from "@/components/shadcn/table";
import { isGroupedJiraDispatchEnabled } 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";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import { FloatingMuteButton } from "../floating-mute-button";
@@ -24,18 +31,49 @@ import {
} from "./inline-resource-container";
const exploreFindingsFlow = getFlowById("explore-findings")!;
const EMPTY_FINDING_GROUPS: FindingGroupRow[] = [];
function buildMuteLabel(groupCount: number, resourceCount: number): string {
const parts: string[] = [];
if (groupCount > 0) {
parts.push(`${groupCount} ${groupCount === 1 ? "Group" : "Groups"}`);
}
if (resourceCount > 0) {
parts.push(
`${resourceCount} ${resourceCount === 1 ? "Resource" : "Resources"}`,
);
}
return `Mute ${parts.join(" and ")}`;
function buildSelectionSummary(
groupCount: number,
findingCount: number,
): string {
return `${buildSelectionEntityLabel(groupCount, findingCount)} selected`;
}
function buildMuteActionLabel(
groupCount: number,
findingCount: number,
): string {
return `Mute ${buildSelectionEntityLabel(groupCount, findingCount)}`;
}
function buildJiraActionLabel(
groupCount: number,
findingCount: number,
): string {
return `Send ${buildSelectionEntityLabel(groupCount, findingCount)} to Jira`;
}
function buildSelectionEntityLabel(
groupCount: number,
findingCount: number,
): string {
const parts = [
buildEntityCountLabel(groupCount, "Group", "Groups"),
buildEntityCountLabel(findingCount, "Finding", "Findings"),
].filter(Boolean);
return parts.join(" and ");
}
function buildEntityCountLabel(
count: number,
singular: string,
plural: string,
): string | null {
if (count === 0) return null;
return `${count} ${count === 1 ? singular : plural}`;
}
interface FindingsGroupTableProps {
@@ -43,6 +81,7 @@ interface FindingsGroupTableProps {
metadata?: MetaDataProps;
resolvedFilters: Record<string, string>;
hasHistoricalData: boolean;
expandedCheckId?: string;
}
export function FindingsGroupTable({
@@ -50,23 +89,70 @@ export function FindingsGroupTable({
metadata,
resolvedFilters,
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 safeData = data ?? [];
const hasResourceSelection = resourceSelection.length > 0;
const safeData = data ?? EMPTY_FINDING_GROUPS;
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();
// Exclude expanded group from group-level mutes when it has resource selections.
const selectedCheckIds = Object.keys(rowSelection)
@@ -82,6 +168,76 @@ export function FindingsGroupTable({
.map((idx) => safeData[parseInt(idx)])
.filter(Boolean);
const selectedGroupTitle =
selectedFindings.length === 1 ? selectedFindings[0]?.checkTitle : undefined;
const hasMixedJiraSelection =
selectedCheckIds.length > 0 && hasResourceSelection;
const jiraGroupSelectionTakesPrecedence = selectedCheckIds.length > 0;
const jiraTargetIds = jiraGroupSelectionTakesPrecedence
? selectedCheckIds
: activeResourceSelection;
const jiraTargetType = jiraGroupSelectionTakesPrecedence
? JIRA_DISPATCH_TARGET.CHECK_ID
: JIRA_DISPATCH_TARGET.FINDING_ID;
const singleSelectedGroup =
selectedCheckIds.length === 1
? selectedFindings.find(
(finding) => finding.checkId === selectedCheckIds[0],
)
: undefined;
const selectedJiraResourceCount = jiraGroupSelectionTakesPrecedence
? singleSelectedGroup
? singleSelectedGroup.resourcesFail
: selectedCheckIds.length
: activeResourceSelection.length;
const jiraDispatchMode = jiraGroupSelectionTakesPrecedence
? JIRA_DISPATCH_MODE.GROUPED
: activeResourceSelection.length > 1
? JIRA_DISPATCH_MODE.GROUPED
: JIRA_DISPATCH_MODE.INDIVIDUAL;
const canChooseGroupedJiraDispatch = jiraGroupSelectionTakesPrecedence
? !hasMixedJiraSelection &&
selectedCheckIds.length === 1 &&
selectedJiraResourceCount > 1
: activeResourceSelection.length > 1;
const jiraTitle = hasMixedJiraSelection
? undefined
: jiraGroupSelectionTakesPrecedence
? selectedGroupTitle
: expandedGroup?.checkTitle;
const jiraSelection = hasMixedJiraSelection
? createJiraBatchSelection([
{
targetIds: selectedCheckIds,
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
{
targetIds: activeResourceSelection,
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
...(activeResourceSelection.length > 1
? {}
: { dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL }),
},
])
: createJiraTargetSelection(jiraTargetIds, jiraTargetType);
const jiraDescription = hasMixedJiraSelection
? `Create Jira issues for ${buildSelectionEntityLabel(
selectedCheckIds.length,
activeResourceSelection.length,
)}.`
: undefined;
const hasJiraTargets = jiraTargetIds.length > 0;
const isSingleFindingJiraDispatch =
!jiraGroupSelectionTakesPrecedence && activeResourceSelection.length === 1;
const canSendToJira =
hasJiraTargets &&
(isSingleFindingJiraDispatch || groupedJiraDispatchEnabled);
const sendToJiraLabel = buildJiraActionLabel(
selectedCheckIds.length,
activeResourceSelection.length,
);
const selectableRowCount = safeData.filter((g) =>
canMuteFindingGroup({
resourcesFail: g.resourcesFail,
@@ -146,16 +302,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([]);
@@ -257,27 +411,55 @@ export function FindingsGroupTable({
{(selectedCheckIds.length > 0 || hasResourceSelection) && (
<FloatingMuteButton
selectedCount={selectedCheckIds.length + resourceSelection.length}
selectedFindingIds={[...selectedCheckIds, ...resourceSelection]}
label={buildMuteLabel(
selectedCount={
selectedCheckIds.length + activeResourceSelection.length
}
selectedFindingIds={[...selectedCheckIds, ...activeResourceSelection]}
label={buildSelectionSummary(
selectedCheckIds.length,
resourceSelection.length,
activeResourceSelection.length,
)}
muteLabel={buildMuteActionLabel(
selectedCheckIds.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}
sendToJiraLabel={sendToJiraLabel}
onSendToJira={() => setIsJiraModalOpen(true)}
/>
)}
{canSendToJira && jiraSelection && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingTitle={jiraTitle}
selection={jiraSelection}
defaultDispatchMode={jiraDispatchMode}
isFindingGroupSelection={
!jiraGroupSelectionTakesPrecedence && Boolean(expandedGroup)
}
canChooseGroupedDispatch={canChooseGroupedJiraDispatch}
selectedResourceCount={selectedJiraResourceCount}
description={jiraDescription}
/>
)}
</FindingsSelectionContext.Provider>
);
}
};
@@ -91,11 +91,7 @@ function ResourceSkeletonRow({
<div className="bg-bg-input-primary border-border-input-primary size-5 rounded-sm border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]" />
</div>
</TableCell>
{/* Status */}
<TableCell className={cellClassName}>
<Skeleton className="h-6 w-11 rounded-md" />
</TableCell>
{/* Resource: name + uid */}
{/* Affected failing resource: name + uid */}
<TableCell className={cellClassName}>
<div className="space-y-1.5">
<Skeleton className="h-4 w-32 rounded" />
@@ -73,12 +73,14 @@ import {
import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel";
import { getFailingForLabel, 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")}
@@ -409,6 +409,9 @@ export const ResourceDetailContent = ({
<FloatingMuteButton
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
muteLabel={`Mute ${selectedFindingIds.length} ${
selectedFindingIds.length === 1 ? "Finding" : "Findings"
}`}
onComplete={handleMuteComplete}
/>
)}
+7 -2
View File
@@ -4,12 +4,17 @@ import { RadioGroupItem } from "@/components/shadcn/radio-group/radio-group";
import { cn } from "@/lib/utils";
interface CustomRadioProps {
ariaLabel?: string;
description?: string;
value?: string;
children?: React.ReactNode;
}
export const CustomRadio = ({ value, children }: CustomRadioProps) => {
export const CustomRadio = ({
ariaLabel,
value,
children,
}: CustomRadioProps) => {
return (
<label
className={cn(
@@ -18,7 +23,7 @@ export const CustomRadio = ({ value, children }: CustomRadioProps) => {
"has-[[data-state=checked]]:border-button-primary",
)}
>
<RadioGroupItem value={value || ""} />
<RadioGroupItem value={value || ""} aria-label={ariaLabel} />
<span>{children}</span>
</label>
);
@@ -5,6 +5,8 @@ import { ComponentProps, ReactNode, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip";
import {
DropdownMenu,
DropdownMenuContent,
@@ -104,6 +106,10 @@ interface ActionDropdownItemProps
description?: string;
/** Whether the item is destructive (danger styling) */
destructive?: boolean;
/** Tooltip shown while the item remains interactive. */
tooltip?: string;
/** Tooltip shown when the item is disabled. */
disabledTooltip?: string;
}
export function ActionDropdownItem({
@@ -112,9 +118,13 @@ export function ActionDropdownItem({
description,
destructive = false,
className,
tooltip,
disabledTooltip,
disabled,
onSelect,
...props
}: ActionDropdownItemProps) {
return (
const item = (
<DropdownMenuItem
className={cn(
"hover:bg-bg-neutral-tertiary flex cursor-pointer items-start gap-2 rounded-md transition-colors",
@@ -122,6 +132,16 @@ export function ActionDropdownItem({
"text-text-error-primary focus:text-text-error-primary hover:bg-destructive/10",
className,
)}
aria-disabled={disabled || undefined}
disabled={disabled && !disabledTooltip}
onSelect={(event) => {
if (disabled) {
event.preventDefault();
return;
}
onSelect?.(event);
}}
{...props}
>
{icon && (
@@ -149,6 +169,19 @@ export function ActionDropdownItem({
</div>
</DropdownMenuItem>
);
const tooltipContent = tooltip ?? (disabled ? disabledTooltip : undefined);
if (tooltipContent) {
return (
<Tooltip>
<TooltipTrigger asChild>{item}</TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
);
}
return item;
}
export function ActionDropdownDangerZone({
@@ -116,6 +116,38 @@ describe("CloudUpgradeModal", () => {
);
});
it("renders the contextual Jira dispatch upgrade", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
useCloudUpgradeStore
.getState()
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
// When
render(<CloudUpgradeModal />);
// Then
expect(
await screen.findByRole("dialog", {
name: "Send Findings to Jira at Scale",
}),
).toBeVisible();
expect(
screen.getByRole("link", {
name: "Send Findings to Jira in Prowler Cloud",
}),
).toHaveAttribute(
"href",
"https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=jira-dispatch",
);
expect(
screen.getByRole("link", { name: "View Plans & Pricing" }),
).toHaveAttribute(
"href",
"https://prowler.com/pricing?utm_source=prowler-local-server&utm_content=jira-dispatch",
);
});
it("closes the active upgrade and returns focus to its trigger", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
@@ -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
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import {
toast as shadcnToast,
Toaster as ShadcnToaster,
} from "@/components/shadcn/toast";
import { toast as uiToast, Toaster as UiToaster } from "@/components/ui/toast";
describe("components/ui/toast", () => {
it("uses the mounted shadcn toast store and provider", () => {
expect(uiToast).toBe(shadcnToast);
expect(UiToaster).toBe(ShadcnToaster);
});
});
+61
View File
@@ -98,6 +98,25 @@ describe("useFilterBatch", () => {
"filter[delta]": ["new"],
});
});
it("should parse filter[check_id] from grouped finding deep links", () => {
// Given - URL produced by the grouped finding resources panel deep link.
setSearchParams({
"filter[check_id]": "teams_external_users_can_join",
expandedCheckId: "teams_external_users_can_join",
});
// When
const { result } = renderHook(() => useFilterBatch());
// Then - expandedCheckId is not a filter chip, but check_id is URL-backed.
expect(result.current.pendingFilters).toEqual({
"filter[check_id]": ["teams_external_users_can_join"],
});
expect(result.current.getFilterValue("filter[check_id]")).toEqual([
"teams_external_users_can_join",
]);
});
});
// ── Excluded keys ──────────────────────────────────────────────────────────
@@ -179,6 +198,30 @@ describe("useFilterBatch", () => {
// Then
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([]);
});
it("should replace exclusive legacy filters when the new grouped filter is edited", () => {
// Given - a legacy deep link with the exact check_id filter applied.
setSearchParams({
"filter[check_id]": "teams_external_users_can_join",
});
const { result } = renderHook(() =>
useFilterBatch({
exclusiveFilterGroups: [["filter[check_id]", "filter[check_id__in]"]],
}),
);
// When - the Finding Group multi-select writes the __in filter.
act(() => {
result.current.setPending("filter[check_id__in]", [
"teams_external_users_cannot_join",
]);
});
// Then - the stale exact filter is removed from pending state.
expect(result.current.pendingFilters).toEqual({
"filter[check_id__in]": ["teams_external_users_cannot_join"],
});
});
});
// ── getFilterValue ─────────────────────────────────────────────────────────
@@ -227,6 +270,24 @@ describe("useFilterBatch", () => {
// Then
expect(values).toEqual(["critical"]);
});
it("should expose legacy exclusive filter values through the replacement key", () => {
// Given - a legacy grouped finding deep link uses filter[check_id].
setSearchParams({
"filter[check_id]": "teams_external_users_can_join",
});
const { result } = renderHook(() =>
useFilterBatch({
exclusiveFilterGroups: [["filter[check_id]", "filter[check_id__in]"]],
}),
);
// When - the new Finding Group control asks for filter[check_id__in].
const values = result.current.getFilterValue("filter[check_id__in]");
// Then - the existing exact value appears selected in the control.
expect(values).toEqual(["teams_external_users_can_join"]);
});
});
// ── hasChanges & changeCount ───────────────────────────────────────────────
+50 -9
View File
@@ -151,6 +151,23 @@ export interface UseFilterBatchOptions {
* (e.g. `{ "filter[muted]": "false" }` on the Findings page).
*/
defaultParams?: Record<string, string>;
/**
* Filter keys that represent the same logical control. Updating one removes
* the others so legacy exact params and new multi-select params cannot be
* applied together.
*/
exclusiveFilterGroups?: string[][];
}
function normalizeFilterKey(key: string): string {
return key.startsWith("filter[") ? key : `filter[${key}]`;
}
function getExclusiveFilterGroup(
filterKey: string,
exclusiveFilterGroups: string[][] | undefined,
): string[] | undefined {
return exclusiveFilterGroups?.find((group) => group.includes(filterKey));
}
/**
@@ -187,15 +204,27 @@ export const useFilterBatch = (
}, [searchParams]);
const setPending = (key: string, values: string[]) => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
setPendingFilters((prev) => ({
...prev,
[filterKey]: values,
}));
const filterKey = normalizeFilterKey(key);
const exclusiveGroup = getExclusiveFilterGroup(
filterKey,
options?.exclusiveFilterGroups,
);
setPendingFilters((prev) => {
const next = { ...prev };
exclusiveGroup
?.filter((exclusiveKey) => exclusiveKey !== filterKey)
.forEach((exclusiveKey) => {
delete next[exclusiveKey];
});
next[filterKey] = values;
return next;
});
};
const removePending = (key: string) => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
const filterKey = normalizeFilterKey(key);
setPendingFilters((prev) => {
const next = { ...prev };
delete next[filterKey];
@@ -265,7 +294,7 @@ export const useFilterBatch = (
};
const removeAppliedAndApply = (key: string, value?: string) => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
const filterKey = normalizeFilterKey(key);
const applied = deriveAppliedFromUrl(
new URLSearchParams(searchParams.toString()),
);
@@ -286,8 +315,20 @@ export const useFilterBatch = (
};
const getFilterValue = (key: string): string[] => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
return pendingFilters[filterKey] ?? [];
const filterKey = normalizeFilterKey(key);
const values = pendingFilters[filterKey];
if (values) return values;
const exclusiveGroup = getExclusiveFilterGroup(
filterKey,
options?.exclusiveFilterGroups,
);
const alternateKey = exclusiveGroup?.find(
(exclusiveKey) =>
exclusiveKey !== filterKey && pendingFilters[exclusiveKey],
);
return alternateKey ? pendingFilters[alternateKey] : [];
};
const hasChanges = !areFiltersEqual(pendingFilters, appliedFilters);
+2
View File
@@ -24,6 +24,7 @@ describe("cloud upgrade content", () => {
"Bring CLI Findings into One Cloud View",
"See Compliance Across Every Provider",
"Coordinate Finding Remediation",
"Send Findings to Jira at Scale",
"Use The Agent Cloud Defender",
"Scale Prowler Without Operating It",
"Configure Every Scan Once",
@@ -71,6 +72,7 @@ describe("cloud upgrade URLs", () => {
"cross-provider-compliance",
],
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE, "findings"],
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH, "jira-dispatch"],
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, "lighthouse-ai"],
[CLOUD_UPGRADE_FEATURE.GENERAL, "general"],
[CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION, "scan-configuration"],
+12
View File
@@ -26,6 +26,7 @@ const CLOUD_UPGRADE_UTM_CONTENT = {
[CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE]:
"cross-provider-compliance",
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE]: "findings",
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH]: "jira-dispatch",
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: "lighthouse-ai",
[CLOUD_UPGRADE_FEATURE.GENERAL]: "general",
[CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION]: "scan-configuration",
@@ -98,6 +99,17 @@ export const CLOUD_UPGRADE_CONTENT = {
],
primaryCta: "Triage Findings in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH]: {
title: "Send Findings to Jira at Scale",
description:
"Create Jira issues from selected findings and finding groups without handling each item separately.",
benefits: [
"Send selected findings or finding groups in one action",
"Choose between grouped and individual Jira issues",
"Track dispatch progress and retry failed findings",
],
primaryCta: "Send Findings to Jira in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: {
title: "Use The Agent Cloud Defender",
description:
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const importFresh = async () => {
vi.resetModules();
return import("./deployment");
};
describe("enterprise feature flags", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("should keep grouped Jira dispatch disabled by default", async () => {
// Given / When
const { isGroupedJiraDispatchEnabled } = await importFresh();
// Then
expect(isGroupedJiraDispatchEnabled()).toBe(false);
});
it("should enable grouped Jira dispatch from the enterprise env flag in cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE", "cloud");
vi.stubEnv(
"NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED",
"true",
);
// When
const { isGroupedJiraDispatchEnabled } = await importFresh();
// Then
expect(isGroupedJiraDispatchEnabled()).toBe(true);
});
it("should keep grouped Jira dispatch disabled outside cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE", "onpremise");
vi.stubEnv(
"NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED",
"true",
);
// When
const { isGroupedJiraDispatchEnabled } = await importFresh();
// Then
expect(isGroupedJiraDispatchEnabled()).toBe(false);
});
});
+55
View File
@@ -0,0 +1,55 @@
export const DEPLOYMENT_MODE = {
CLOUD: "cloud",
ON_PREMISE: "onpremise",
} as const;
export const ENTERPRISE_FEATURE_ENV = {
GROUPED_JIRA_DISPATCH_ENABLED:
"NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED",
} as const;
export const PROWLER_CLOUD_ONLY_TOOLTIP = "Available only in Prowler Cloud";
export type DeploymentMode =
(typeof DEPLOYMENT_MODE)[keyof typeof DEPLOYMENT_MODE];
type EnterpriseFeatureEnv =
(typeof ENTERPRISE_FEATURE_ENV)[keyof typeof ENTERPRISE_FEATURE_ENV];
const getEnterpriseFeatureValue = (
envName: EnterpriseFeatureEnv,
): string | undefined => {
if (envName === ENTERPRISE_FEATURE_ENV.GROUPED_JIRA_DISPATCH_ENABLED) {
return process.env
.NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED;
}
return undefined;
};
const getBooleanEnv = (
envName: EnterpriseFeatureEnv,
defaultValue: boolean,
): boolean => {
const value = getEnterpriseFeatureValue(envName);
if (value === undefined || value === "") {
return defaultValue;
}
return value === "true";
};
export const getDeploymentMode = (): DeploymentMode | undefined => {
const mode = process.env.NEXT_PUBLIC_PROWLER_DEPLOYMENT_MODE;
if (mode === DEPLOYMENT_MODE.CLOUD || mode === DEPLOYMENT_MODE.ON_PREMISE) {
return mode;
}
return undefined;
};
export const isGroupedJiraDispatchEnabled = (): boolean =>
getDeploymentMode() === DEPLOYMENT_MODE.CLOUD &&
getBooleanEnv(ENTERPRISE_FEATURE_ENV.GROUPED_JIRA_DISPATCH_ENABLED, false);
+141
View File
@@ -0,0 +1,141 @@
import type { JiraDispatchTaskResult } from "@/types/integrations";
import type { TaskState } from "@/types/tasks";
export interface JiraDispatchSuccessOutcome {
success: true;
message: string;
warning?: string;
failedFindingIds?: string[];
}
export interface JiraDispatchFailureOutcome {
success: false;
error: string;
failedFindingIds?: string[];
}
export type JiraDispatchOutcome =
| JiraDispatchSuccessOutcome
| JiraDispatchFailureOutcome;
const getArrayCount = (value: unknown[] | undefined) =>
Array.isArray(value) ? value.length : 0;
const getFailedCount = (result: JiraDispatchTaskResult | undefined) => {
if (!result) return 0;
return Math.max(
result.failed_count ?? 0,
getArrayCount(result.failed_groups),
getArrayCount(result.failed_batches),
getArrayCount(result.failed_finding_ids),
);
};
export const getJiraDispatchSuccessCount = (
result: JiraDispatchTaskResult | undefined,
) => {
if (!result) return 0;
const createdCount = Math.max(
result.created_count ?? 0,
getArrayCount(result.created_issues),
);
const updatedCount = Math.max(
result.updated_count ?? 0,
getArrayCount(result.updated_issues),
);
return Math.max(
result.successful_count ?? 0,
createdCount + updatedCount,
result.issue_key || result.issue_url ? 1 : 0,
);
};
const ensureSentence = (message: string) =>
/[.!?]$/.test(message.trim()) ? message.trim() : `${message.trim()}.`;
const buildFailureMessage = (
result: JiraDispatchTaskResult | undefined,
failedCount: number,
) => {
const successCount = getJiraDispatchSuccessCount(result);
const summary = `Jira dispatch completed with ${failedCount} failed and ${successCount} created/updated issue${successCount === 1 ? "" : "s"}.`;
return result?.error ? `${ensureSentence(result.error)} ${summary}` : summary;
};
const buildSuccessMessage = (result: JiraDispatchTaskResult | undefined) => {
const successCount = getJiraDispatchSuccessCount(result);
if (successCount > 1) {
return `${successCount} Jira issues were created or updated successfully.`;
}
return "Finding successfully sent to Jira!";
};
const getFailedFindingIds = (result: JiraDispatchTaskResult | undefined) =>
Array.from(new Set(result?.failed_finding_ids?.filter(Boolean) ?? []));
const withFailedFindingIds = (failedFindingIds: string[]) =>
failedFindingIds.length > 0 ? { failedFindingIds } : {};
export const evaluateJiraDispatchTask = (
state: TaskState,
result: JiraDispatchTaskResult | null | undefined,
): JiraDispatchOutcome => {
const jiraResult = result ?? undefined;
const failedFindingIds = getFailedFindingIds(jiraResult);
if (state === "completed") {
const failedCount = getFailedCount(jiraResult);
if (failedCount > 0) {
const successCount = getJiraDispatchSuccessCount(jiraResult);
if (successCount > 0) {
return {
success: true,
message: buildSuccessMessage(jiraResult),
warning: buildFailureMessage(jiraResult, failedCount),
...withFailedFindingIds(failedFindingIds),
};
}
return {
success: false,
error: buildFailureMessage(jiraResult, failedCount),
...withFailedFindingIds(failedFindingIds),
};
}
if (jiraResult?.success === false || jiraResult?.error) {
return {
success: false,
error: jiraResult.error || "Failed to create Jira issue.",
...withFailedFindingIds(failedFindingIds),
};
}
if (!jiraResult || getJiraDispatchSuccessCount(jiraResult) === 0) {
return {
success: false,
error:
"Jira dispatch completed but did not create or update any issues.",
};
}
return {
success: true,
message: buildSuccessMessage(jiraResult),
};
}
if (state === "failed") {
return {
success: false,
error: jiraResult?.error || "Task failed.",
...withFailedFindingIds(failedFindingIds),
};
}
return { success: false, error: `Unknown task state: ${state}` };
};
+84
View File
@@ -0,0 +1,84 @@
import {
JIRA_TARGET_SELECTION_KIND,
type JiraBatchSelection,
type JiraDispatchTarget,
type JiraDispatchTargetBatch,
type JiraSelection,
type NonEmptyStringArray,
} from "@/types/integrations";
export interface JiraDispatchTargetBatchInput {
targetIds: string[];
targetType: JiraDispatchTarget;
dispatchMode?: JiraDispatchTargetBatch["dispatchMode"];
}
export const toNonEmptyStringArray = (
values: string[],
): NonEmptyStringArray | null => {
const [first, ...rest] = values.filter(Boolean);
return first ? [first, ...rest] : null;
};
export const createJiraTargetSelection = (
targetIds: string[],
targetType: JiraDispatchTarget,
): JiraSelection | null => {
const nonEmptyTargetIds = toNonEmptyStringArray(targetIds);
if (!nonEmptyTargetIds) return null;
if (nonEmptyTargetIds.length === 1) {
return {
kind: JIRA_TARGET_SELECTION_KIND.SINGLE,
targetId: nonEmptyTargetIds[0],
targetType,
};
}
return {
kind: JIRA_TARGET_SELECTION_KIND.TARGET_LIST,
targetIds: nonEmptyTargetIds,
targetType,
};
};
export const createJiraBatchSelection = (
batches: JiraDispatchTargetBatchInput[],
): JiraBatchSelection | null => {
const normalizedBatches = batches.flatMap((batch) => {
const targetIds = toNonEmptyStringArray(batch.targetIds);
return targetIds ? [{ ...batch, targetIds }] : [];
});
const [firstBatch, ...remainingBatches] = normalizedBatches;
return firstBatch
? {
kind: JIRA_TARGET_SELECTION_KIND.BATCHES,
batches: [firstBatch, ...remainingBatches],
}
: null;
};
export const getJiraSelectionBatches = (
selection: JiraSelection,
): [JiraDispatchTargetBatch, ...JiraDispatchTargetBatch[]] => {
if (selection.kind === JIRA_TARGET_SELECTION_KIND.BATCHES) {
return selection.batches;
}
if (selection.kind === JIRA_TARGET_SELECTION_KIND.SINGLE) {
return [
{
targetIds: [selection.targetId],
targetType: selection.targetType,
},
];
}
return [
{
targetIds: selection.targetIds,
targetType: selection.targetType,
},
];
};
+46
View File
@@ -0,0 +1,46 @@
import type { WatchedTask } from "@/store/task-watcher/store";
import {
JIRA_DISPATCH_MODE,
type JiraDispatchMode,
} from "@/types/integrations";
export interface JiraDispatchTaskMeta {
integrationId: string;
projectKey: string;
issueType: string;
dispatchMode: JiraDispatchMode;
}
export const buildJiraDispatchTaskMeta = ({
integrationId,
projectKey,
issueType,
dispatchMode,
}: JiraDispatchTaskMeta): Record<string, string> => ({
integrationId,
projectKey,
issueType,
dispatchMode,
});
export const parseJiraDispatchTaskMeta = (
task: WatchedTask,
): JiraDispatchTaskMeta | null => {
const { integrationId, projectKey, issueType, dispatchMode } = task.meta;
if (
!integrationId ||
!projectKey ||
!issueType ||
(dispatchMode !== JIRA_DISPATCH_MODE.GROUPED &&
dispatchMode !== JIRA_DISPATCH_MODE.INDIVIDUAL)
) {
return null;
}
return {
integrationId,
projectKey,
issueType,
dispatchMode,
};
};
+64
View File
@@ -49,6 +49,37 @@ describe("task watcher store", () => {
expect(onError).not.toHaveBeenCalled();
});
it("returns and persists a completed task result while allowing the caller to own notifications", async () => {
// Given
pollMock.mockResolvedValue({
ok: true,
state: "completed",
result: { created_count: 1, failed_count: 1 },
});
// When
const result = await trackAndPollTask<{
created_count: number;
failed_count: number;
}>({
taskId: "jira-task",
kind: "test-kind",
meta: {},
notifyHandler: false,
});
// Then
expect(result).toEqual({
status: TASK_WATCHER_STATUS.READY,
result: { created_count: 1, failed_count: 1 },
});
expect(useTaskWatcherStore.getState().tasks["jira-task"]?.result).toEqual({
created_count: 1,
failed_count: 1,
});
expect(onReady).not.toHaveBeenCalled();
});
it("replaces settled results of the same kind when tracking new work", async () => {
// Given
pollMock.mockResolvedValue({ ok: true, state: "completed" });
@@ -201,6 +232,39 @@ describe("task watcher store", () => {
expect(onReady).toHaveBeenCalledTimes(1);
});
it("passes a persisted task result to its handler after resuming", async () => {
// Given
const taskResult = {
created_count: 1,
failed_count: 1,
failed_finding_ids: ["finding-2"],
};
pollMock.mockResolvedValue({
ok: true,
state: "completed",
result: taskResult,
});
useTaskWatcherStore.setState({
tasks: {
"resumed-jira-task": {
taskId: "resumed-jira-task",
kind: "test-kind",
status: TASK_WATCHER_STATUS.PENDING,
meta: {},
startedAt: Date.now(),
},
},
});
// When
await resumePendingTasks();
// Then
expect(onReady).toHaveBeenCalledWith(
expect.objectContaining({ result: taskResult }),
);
});
it("discards settled tasks before resuming persisted work", async () => {
// Given
pollMock.mockResolvedValue({ ok: true, state: "completed" });
+138 -72
View File
@@ -28,6 +28,9 @@ export interface WatchedTask {
meta: Record<string, string>;
startedAt: number;
error?: string;
/** Serializable task result. Persisted so another tab or a reload can
* finish feature-specific handling without polling the task again. */
result?: unknown;
}
export interface TaskKindHandler {
@@ -35,6 +38,21 @@ export interface TaskKindHandler {
onError: (task: WatchedTask) => void;
}
export interface TaskTrackingResult<R = unknown> {
status: TaskWatcherStatus;
error?: string;
result?: R;
}
export interface TrackAndPollTaskInput {
taskId: string;
kind: string;
meta: Record<string, string>;
/** Let the awaiting caller aggregate notifications. If this tab reloads,
* the persisted task resumes with its registered handler as usual. */
notifyHandler?: boolean;
}
interface TaskWatcherState {
tasks: Record<string, WatchedTask>;
upsertTask: (task: WatchedTask) => void;
@@ -42,6 +60,7 @@ interface TaskWatcherState {
taskId: string,
status: TaskWatcherStatus,
error?: string,
result?: unknown,
) => void;
dismissTask: (taskId: string) => void;
}
@@ -69,12 +88,15 @@ export const useTaskWatcherStore = create<TaskWatcherState>()(
tasks: {},
upsertTask: (task) =>
set((state) => ({ tasks: { ...state.tasks, [task.taskId]: task } })),
resolveTask: (taskId, status, error) =>
resolveTask: (taskId, status, error, result) =>
set((state) => {
const task = state.tasks[taskId];
if (!task) return state;
return {
tasks: { ...state.tasks, [taskId]: { ...task, status, error } },
tasks: {
...state.tasks,
[taskId]: { ...task, status, error, result },
},
};
}),
dismissTask: (taskId) =>
@@ -92,25 +114,35 @@ export const useTaskWatcherStore = create<TaskWatcherState>()(
// In-memory only: poll loops alive in THIS tab. Never persisted, so a reload
// naturally re-enters through resumePendingTasks without double-polling.
const activePolls = new Set<string>();
const activePolls = new Map<string, Promise<TaskTrackingResult<unknown>>>();
const suppressedHandlers = new Set<string>();
const settleTask = (
taskId: string,
status: TaskWatcherStatus,
error?: string,
) => {
result?: unknown,
): TaskTrackingResult => {
const store = useTaskWatcherStore.getState();
const currentTask = store.tasks[taskId];
if (!currentTask || currentTask.status !== TASK_WATCHER_STATUS.PENDING) {
return;
return {
status: currentTask?.status ?? TASK_WATCHER_STATUS.ERROR,
...(currentTask?.error ? { error: currentTask.error } : {}),
...(currentTask?.result !== undefined
? { result: currentTask.result }
: {}),
};
}
store.resolveTask(taskId, status, error);
store.resolveTask(taskId, status, error, result);
const task = useTaskWatcherStore.getState().tasks[taskId];
if (!task) return;
if (!task) {
return { status: TASK_WATCHER_STATUS.ERROR, error: "Task unavailable." };
}
const handler = handlers.get(task.kind);
if (handler) {
if (handler && !suppressedHandlers.has(taskId)) {
if (status === TASK_WATCHER_STATUS.READY) handler.onReady(task);
else handler.onError(task);
}
@@ -120,107 +152,141 @@ const settleTask = (
if (status === TASK_WATCHER_STATUS.ERROR) {
store.dismissTask(taskId);
}
return {
status,
...(error ? { error } : {}),
...(result !== undefined ? { result } : {}),
};
};
const runPollLoop = async (taskId: string): Promise<void> => {
const runPollLoop = async <R>(
taskId: string,
): Promise<TaskTrackingResult<R>> => {
for (let round = 0; round < MAX_POLL_ROUNDS; round++) {
const result = await pollTaskUntilSettled(taskId);
const result = await pollTaskUntilSettled<R>(taskId);
if (result.ok) {
if (result.state === "completed") {
settleTask(taskId, TASK_WATCHER_STATUS.READY);
return settleTask(
taskId,
TASK_WATCHER_STATUS.READY,
undefined,
result.result,
) as TaskTrackingResult<R>;
} else {
settleTask(
return settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
`Task ended in state "${result.state}".`,
);
result.result,
) as TaskTrackingResult<R>;
}
return;
}
// "Task timeout" just means this server round expired while the task
// is still running — keep polling. Real errors settle immediately.
if (result.error !== "Task timeout") {
settleTask(taskId, TASK_WATCHER_STATUS.ERROR, result.error);
return;
return settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
result.error,
result.result,
) as TaskTrackingResult<R>;
}
}
settleTask(
return settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
"The task is taking too long. Try again later.",
);
) as TaskTrackingResult<R>;
};
const pollUntilDone = async (taskId: string): Promise<void> => {
if (activePolls.has(taskId)) return;
activePolls.add(taskId);
const pollUntilDone = <R>(taskId: string): Promise<TaskTrackingResult<R>> => {
const existingPoll = activePolls.get(taskId);
if (existingPoll) return existingPoll as Promise<TaskTrackingResult<R>>;
try {
const runIfPending = async () => {
// A different tab may have completed the task while this one waited for
// the cross-tab lock. Refresh persisted state before polling or notifying.
await useTaskWatcherStore.persist.rehydrate();
const task = useTaskWatcherStore.getState().tasks[taskId];
if (task?.status !== TASK_WATCHER_STATUS.PENDING) return;
await runPollLoop(taskId);
};
const pollPromise = (async (): Promise<TaskTrackingResult<R>> => {
try {
const runIfPending = async (): Promise<TaskTrackingResult<R>> => {
// A different tab may have completed the task while this one waited for
// the cross-tab lock. Refresh persisted state before polling or notifying.
await useTaskWatcherStore.persist.rehydrate();
const task = useTaskWatcherStore.getState().tasks[taskId];
if (task?.status !== TASK_WATCHER_STATUS.PENDING) {
return {
status: task?.status ?? TASK_WATCHER_STATUS.ERROR,
...(task?.error ? { error: task.error } : {}),
...(task?.result !== undefined ? { result: task.result as R } : {}),
};
}
return runPollLoop<R>(taskId);
};
if (typeof navigator !== "undefined" && navigator.locks) {
await navigator.locks.request(`task-watcher:${taskId}`, runIfPending);
} else {
await runIfPending();
if (typeof navigator !== "undefined" && navigator.locks) {
return await navigator.locks.request(
`task-watcher:${taskId}`,
runIfPending,
);
}
return await runIfPending();
} catch {
// A thrown poll (e.g. the server-action RPC failing on a network drop)
// must still settle the task, or it stays PENDING in the persisted
// store and blocks the UI until the staleness ceiling.
return settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
"Tracking the task failed unexpectedly. Try again later.",
) as TaskTrackingResult<R>;
} finally {
activePolls.delete(taskId);
}
} catch {
// A thrown poll (e.g. the server-action RPC failing on a network drop)
// must still settle the task, or it stays PENDING in the persisted
// store and blocks the UI until the staleness ceiling.
settleTask(
taskId,
TASK_WATCHER_STATUS.ERROR,
"Tracking the task failed unexpectedly. Try again later.",
);
} finally {
activePolls.delete(taskId);
}
})();
activePolls.set(taskId, pollPromise);
return pollPromise;
};
/** Track a freshly dispatched backend task and poll it to completion. The
* poll loop lives at module scope (fired from the click handler), so it
* survives client-side navigation without any effect subscriptions. */
export const trackAndPollTask = async ({
export const trackAndPollTask = async <R = unknown>({
taskId,
kind,
meta,
}: {
taskId: string;
kind: string;
meta: Record<string, string>;
}): Promise<void> => {
notifyHandler = true,
}: TrackAndPollTaskInput): Promise<TaskTrackingResult<R>> => {
if (!notifyHandler) suppressedHandlers.add(taskId);
const existing = useTaskWatcherStore.getState().tasks[taskId];
if (existing?.status === TASK_WATCHER_STATUS.PENDING) {
return pollUntilDone(taskId);
try {
if (existing?.status === TASK_WATCHER_STATUS.PENDING) {
return await pollUntilDone<R>(taskId);
}
const store = useTaskWatcherStore.getState();
Object.values(store.tasks)
.filter(
(task) =>
task.kind === kind && task.status !== TASK_WATCHER_STATUS.PENDING,
)
.forEach((task) => store.dismissTask(task.taskId));
store.upsertTask({
taskId,
kind,
status: TASK_WATCHER_STATUS.PENDING,
meta,
startedAt: Date.now(),
});
return await pollUntilDone<R>(taskId);
} finally {
suppressedHandlers.delete(taskId);
}
const store = useTaskWatcherStore.getState();
Object.values(store.tasks)
.filter(
(task) =>
task.kind === kind && task.status !== TASK_WATCHER_STATUS.PENDING,
)
.forEach((task) => store.dismissTask(task.taskId));
store.upsertTask({
taskId,
kind,
status: TASK_WATCHER_STATUS.PENDING,
meta,
startedAt: Date.now(),
});
return pollUntilDone(taskId);
};
/** Resume polling every persisted pending task after a hard reload; tasks
+1
View File
@@ -5,6 +5,7 @@ export const CLOUD_UPGRADE_FEATURE = {
CLI_IMPORT: "cli_import",
CROSS_PROVIDER_COMPLIANCE: "cross_provider_compliance",
FINDING_TRIAGE: "finding_triage",
JIRA_DISPATCH: "jira_dispatch",
LIGHTHOUSE_AI: "lighthouse_ai",
GENERAL: "general",
SCAN_CONFIGURATION: "scan_configuration",
+76 -9
View File
@@ -4,6 +4,63 @@ import type { TaskState } from "@/types/tasks";
export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira";
export const JIRA_DISPATCH_MODE = {
INDIVIDUAL: "individual",
GROUPED: "grouped",
} as const;
export type JiraDispatchMode =
(typeof JIRA_DISPATCH_MODE)[keyof typeof JIRA_DISPATCH_MODE];
export const JIRA_DISPATCH_TARGET = {
CHECK_ID: "check_id",
FINDING_ID: "finding_id",
} as const;
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;
@@ -45,6 +102,7 @@ export interface JiraDispatchRequest {
attributes: {
project_key: string;
issue_type: string;
dispatch_mode?: JiraDispatchMode;
};
};
}
@@ -58,21 +116,30 @@ export interface JiraDispatchResponse {
completed_at: string | null;
name: string;
state: TaskState;
result: {
success?: boolean;
error?: string;
message?: string;
issue_url?: string;
issue_key?: string;
created_count?: number;
failed_count?: number;
} | 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"]),