fix(ui): address grouped Jira follow-up feedback

This commit is contained in:
Hugo P.Brito
2026-07-17 12:48:56 +01:00
parent 1acf9bf417
commit e6f47a9bd4
15 changed files with 196 additions and 131 deletions
+10 -7
View File
@@ -86,7 +86,7 @@ describe("sendJiraDispatch", () => {
});
});
it("should fail completed task polling when Jira dispatch has partial failures", async () => {
it("should preserve partial success when Jira dispatch has created and failed issues", async () => {
// Given
pollTaskUntilSettledMock.mockResolvedValue({
ok: true,
@@ -99,8 +99,9 @@ describe("sendJiraDispatch", () => {
// Then
expect(result).toEqual({
success: false,
error:
success: true,
message: "2 Jira issues were created or updated successfully.",
warning:
"Jira dispatch completed with 1 failed and 2 created/updated issues.",
});
});
@@ -118,8 +119,9 @@ describe("sendJiraDispatch", () => {
// Then
expect(result).toEqual({
success: false,
error:
success: true,
message: "2 Jira issues were created or updated successfully.",
warning:
"Jira dispatch completed with 1 failed and 2 created/updated issues.",
});
});
@@ -137,8 +139,9 @@ describe("sendJiraDispatch", () => {
// Then
expect(result).toEqual({
success: false,
error:
success: true,
message: "Finding successfully sent to Jira!",
warning:
"Jira dispatch completed with 1 failed and 1 created/updated issue.",
});
});
+26 -2
View File
@@ -61,6 +61,17 @@ const buildJiraDispatchFailureMessage = (
return `Jira dispatch completed with ${failedCount} failed and ${successCount} created/updated issue${successCount === 1 ? "" : "s"}.`;
};
const buildJiraDispatchSuccessMessage = (
result: JiraTaskResult | undefined,
) => {
const successCount = getJiraDispatchSuccessCount(result);
if (successCount > 1) {
return `${successCount} Jira issues were created or updated successfully.`;
}
return "Finding successfully sent to Jira!";
};
export const getJiraIssueTypes = async (
integrationId: string,
projectKey: string,
@@ -221,7 +232,8 @@ export const sendJiraDispatch = 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: 5,
@@ -236,6 +248,15 @@ export const pollJiraDispatchTask = async (
if (state === "completed") {
const failedCount = getJiraDispatchFailedCount(jiraResult);
if (failedCount > 0) {
const successCount = getJiraDispatchSuccessCount(jiraResult);
if (successCount > 0) {
return {
success: true,
message: buildJiraDispatchSuccessMessage(jiraResult),
warning: buildJiraDispatchFailureMessage(jiraResult, failedCount),
};
}
return {
success: false,
error: buildJiraDispatchFailureMessage(jiraResult, failedCount),
@@ -257,7 +278,10 @@ export const pollJiraDispatchTask = async (
};
}
return { success: true, message: "Finding successfully sent to Jira!" };
return {
success: true,
message: buildJiraDispatchSuccessMessage(jiraResult),
};
}
if (state === "failed") {
+4 -4
View File
@@ -60,9 +60,8 @@ async function getFindingGroupFilterOptions({
const optionFilters = excludeFindingGroupOwnFilters(filters);
const options = new Map<string, { checkId: string; checkTitle: string }>();
let page = 1;
let totalPages = 1;
do {
while (true) {
const response = await fetchFindingGroups({
filters: optionFilters,
page,
@@ -76,9 +75,10 @@ async function getFindingGroupFilterOptions({
});
}
totalPages = response?.meta?.pagination?.pages ?? page;
const totalPages = response?.meta?.pagination?.pages ?? page;
if (page >= totalPages) break;
page += 1;
} while (page <= totalPages);
}
return Array.from(options.values());
}
@@ -74,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,
@@ -367,6 +368,7 @@ export const FindingsFilters = (props: FindingsFiltersProps) => {
getFilterValue,
} = useFilterBatch({
defaultParams: { "filter[muted]": "false" },
exclusiveFilterGroups: [FINDING_GROUP_FILTER_KEYS],
});
return (
@@ -2,6 +2,8 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import { SendToJiraModal } from "./send-to-jira-modal";
const {
@@ -104,20 +106,18 @@ describe("SendToJiraModal", () => {
onOpenChange={vi.fn()}
findingId="check-a"
findingTitle="Check A"
targetIds={["check-a"]}
targetType="check_id"
targetBatches={[
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
{
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
},
]}
defaultDispatchMode="grouped"
defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED}
selectedResourceCount={1}
description="Create Jira issues for 1 Group and 2 Findings."
/>,
@@ -189,20 +189,18 @@ describe("SendToJiraModal", () => {
onOpenChange={onOpenChange}
findingId="check-a"
findingTitle="Check A"
targetIds={["check-a"]}
targetType="check_id"
targetBatches={[
{
targetIds: ["check-a"],
targetType: "check_id",
dispatchMode: "grouped",
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
{
targetIds: ["finding-1", "finding-2"],
targetType: "finding_id",
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
},
]}
defaultDispatchMode="grouped"
defaultDispatchMode={JIRA_DISPATCH_MODE.GROUPED}
selectedResourceCount={1}
description="Create Jira issues for 1 Group and 2 Findings."
/>,
@@ -442,13 +440,14 @@ describe("SendToJiraModal", () => {
expect(toastMock).toHaveBeenCalledTimes(1);
});
it("shows an error toast when Jira dispatch polling reports partial failures", async () => {
it("shows a partial success toast when a task reports created and failed issues", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
pollJiraDispatchTaskMock.mockResolvedValue({
success: false,
error: "Jira dispatch completed with 1 failed and 2 created issues.",
success: true,
message: "2 Jira issues were created or updated successfully.",
warning: "Jira dispatch completed with 1 failed and 2 created issues.",
});
render(
<SendToJiraModal
@@ -472,10 +471,9 @@ describe("SendToJiraModal", () => {
// Then
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
variant: "destructive",
title: "Error",
title: "Partial success",
description:
"Jira dispatch completed with 1 failed and 2 created issues.",
"2 Jira issues were created or updated successfully. Some Jira dispatches failed: Jira dispatch completed with 1 failed and 2 created issues.",
}),
);
expect(toastMock).not.toHaveBeenCalledWith(
+12 -7
View File
@@ -62,15 +62,15 @@ interface SendToJiraSingleTargetProps extends SendToJiraModalBaseProps {
}
interface SendToJiraTargetListProps extends SendToJiraModalBaseProps {
targetIds?: string[];
targetType?: JiraDispatchTarget;
targetIds: string[];
targetType: JiraDispatchTarget;
targetBatches?: never;
}
interface SendToJiraBatchProps extends SendToJiraModalBaseProps {
targetIds?: string[];
targetType?: JiraDispatchTarget;
targetBatches?: JiraDispatchTargetBatch[];
targetIds?: never;
targetType?: never;
targetBatches: JiraDispatchTargetBatch[];
}
type SendToJiraModalProps =
@@ -293,15 +293,20 @@ export const SendToJiraModal = ({
),
...launchErrors,
];
const warnings = taskResults.flatMap((taskResult) => {
if (!taskResult.success || !taskResult.warning) return [];
return [taskResult.warning];
});
if (errors.length > 0) {
if (errors.length > 0 || warnings.length > 0) {
const successfulTask = taskResults.find(
(taskResult) => taskResult.success,
);
if (successfulTask) {
const failedMessages = [...warnings, ...errors].join(" ");
toast({
title: "Partial success",
description: `${successfulTask.message || "Some Jira issues were created successfully."} Some Jira dispatches failed: ${errors.join(" ")}`,
description: `${successfulTask.message || "Some Jira issues were created successfully."} Some Jira dispatches failed: ${failedMessages}`,
});
return;
}
@@ -28,7 +28,7 @@ import type {
FindingTriageLoadedNote,
FindingTriageSummary,
} from "@/types/findings-triage";
import { JIRA_DISPATCH_MODE } from "@/types/integrations";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import { canMuteFindingResource } from "./finding-resource-selection";
import {
@@ -133,7 +133,7 @@ const ResourceRowActions = ({
findingId={resource.findingId}
findingTitle={resource.checkId}
targetIds={displayIds}
targetType="finding_id"
targetType={JIRA_DISPATCH_TARGET.FINDING_ID}
defaultDispatchMode={
displayIds.length > 1
? JIRA_DISPATCH_MODE.GROUPED
@@ -23,7 +23,7 @@ import type {
FindingTriageLoadedNote,
FindingTriageSummary,
} from "@/types/findings-triage";
import { JIRA_DISPATCH_MODE } from "@/types/integrations";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import type { ProviderType } from "@/types/providers";
import { canMuteFindingGroup } from "./finding-group-selection";
@@ -279,7 +279,11 @@ export function DataTableRowActions<T extends FindingRowData>({
findingId={finding.id}
findingTitle={findingTitle}
targetIds={jiraTargetIds}
targetType={isGroup ? "check_id" : "finding_id"}
targetType={
isGroup
? JIRA_DISPATCH_TARGET.CHECK_ID
: JIRA_DISPATCH_TARGET.FINDING_ID
}
defaultDispatchMode={jiraDefaultDispatchMode}
canChooseGroupedDispatch={canChooseGroupedJiraDispatch}
selectedResourceCount={selectedJiraResourceCount}
@@ -36,7 +36,7 @@ import {
isFindingGroupMuted,
} from "@/lib/findings-groups";
import { FindingGroupRow } from "@/types";
import { JIRA_DISPATCH_MODE } from "@/types/integrations";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import { FloatingMuteButton } from "../floating-mute-button";
import { getColumnFindingResources } from "./column-finding-resources";
@@ -268,7 +268,7 @@ export function FindingsGroupDrillDown({
findingId={selectedFindingIds[0] ?? ""}
findingTitle={group.checkTitle}
targetIds={selectedFindingIds}
targetType="finding_id"
targetType={JIRA_DISPATCH_TARGET.FINDING_ID}
defaultDispatchMode={
selectedFindingIds.length > 1
? JIRA_DISPATCH_MODE.GROUPED
@@ -573,8 +573,6 @@ describe("FindingsGroupTable", () => {
const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0];
expect(lastCall).toMatchObject({
isOpen: true,
targetIds: ["check-a"],
targetType: "check_id",
targetBatches: [
{
targetIds: ["check-a"],
@@ -641,8 +639,6 @@ describe("FindingsGroupTable", () => {
const lastCall = SendToJiraModalMock.mock.calls.at(-1)?.[0];
expect(lastCall).toMatchObject({
isOpen: true,
targetIds: ["check-b"],
targetType: "check_id",
targetBatches: [
{
targetIds: ["check-b"],
@@ -711,8 +707,6 @@ describe("FindingsGroupTable", () => {
};
expect(lastCall).toMatchObject({
isOpen: true,
targetIds: ["check-b"],
targetType: "check_id",
targetBatches: [
{
targetIds: ["check-b"],
@@ -17,7 +17,7 @@ import { canDrillDownFindingGroup } from "@/lib/findings-groups";
import { getFlowById } from "@/lib/onboarding";
import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findings.tour";
import { FindingGroupRow, MetaDataProps } from "@/types";
import { JIRA_DISPATCH_MODE } from "@/types/integrations";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import { FloatingMuteButton } from "../floating-mute-button";
import { getColumnFindingGroups } from "./column-finding-groups";
@@ -171,8 +171,8 @@ export function FindingsGroupTable({
? selectedCheckIds
: resourceSelection;
const jiraTargetType = jiraGroupSelectionTakesPrecedence
? "check_id"
: "finding_id";
? JIRA_DISPATCH_TARGET.CHECK_ID
: JIRA_DISPATCH_TARGET.FINDING_ID;
const singleSelectedGroup =
selectedCheckIds.length === 1
? selectedFindings.find(
@@ -203,12 +203,12 @@ export function FindingsGroupTable({
? [
{
targetIds: selectedCheckIds,
targetType: "check_id" as const,
targetType: JIRA_DISPATCH_TARGET.CHECK_ID,
dispatchMode: JIRA_DISPATCH_MODE.GROUPED,
},
{
targetIds: resourceSelection,
targetType: "finding_id" as const,
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
...(resourceSelection.length > 1
? {}
: { dispatchMode: JIRA_DISPATCH_MODE.INDIVIDUAL }),
@@ -438,7 +438,24 @@ export function FindingsGroupTable({
/>
)}
{canSendToJira && (
{canSendToJira && jiraBatches && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
findingId={jiraTargetIds[0] ?? ""}
findingTitle={jiraTitle}
targetBatches={jiraBatches}
defaultDispatchMode={jiraDispatchMode}
isFindingGroupSelection={
!jiraGroupSelectionTakesPrecedence && Boolean(expandedGroup)
}
canChooseGroupedDispatch={canChooseGroupedJiraDispatch}
selectedResourceCount={selectedJiraResourceCount}
description={jiraDescription}
/>
)}
{canSendToJira && !jiraBatches && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
@@ -446,7 +463,6 @@ export function FindingsGroupTable({
findingTitle={jiraTitle}
targetIds={jiraTargetIds}
targetType={jiraTargetType}
targetBatches={jiraBatches}
defaultDispatchMode={jiraDispatchMode}
isFindingGroupSelection={
!jiraGroupSelectionTakesPrecedence && Boolean(expandedGroup)
+42
View File
@@ -198,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 ─────────────────────────────────────────────────────────
@@ -246,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);
-48
View File
@@ -10,54 +10,6 @@ describe("enterprise feature flags", () => {
vi.unstubAllEnvs();
});
it("should enable billing and PostHog by default", async () => {
// Given / When
const { isBillingEnabled, isPostHogEnabled } = await importFresh();
// Then
expect(isBillingEnabled()).toBe(true);
expect(isPostHogEnabled()).toBe(true);
});
it("should disable billing independently from PostHog", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_PROWLER_ENTERPRISE_BILLING_ENABLED", "false");
vi.stubEnv("NEXT_PUBLIC_PROWLER_ENTERPRISE_POSTHOG_ENABLED", "true");
// When
const { isBillingEnabled, isPostHogEnabled } = await importFresh();
// Then
expect(isBillingEnabled()).toBe(false);
expect(isPostHogEnabled()).toBe(true);
});
it("should disable PostHog when billing and PostHog are disabled", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_PROWLER_ENTERPRISE_BILLING_ENABLED", "false");
vi.stubEnv("NEXT_PUBLIC_PROWLER_ENTERPRISE_POSTHOG_ENABLED", "false");
// When
const { isBillingEnabled, isPostHogEnabled } = await importFresh();
// Then
expect(isBillingEnabled()).toBe(false);
expect(isPostHogEnabled()).toBe(false);
});
it("should force PostHog on when billing is enabled", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_PROWLER_ENTERPRISE_BILLING_ENABLED", "true");
vi.stubEnv("NEXT_PUBLIC_PROWLER_ENTERPRISE_POSTHOG_ENABLED", "false");
// When
const { isBillingEnabled, isPostHogEnabled } = await importFresh();
// Then
expect(isBillingEnabled()).toBe(true);
expect(isPostHogEnabled()).toBe(true);
});
it("should keep grouped Jira dispatch disabled by default", async () => {
// Given / When
const { isGroupedJiraDispatchEnabled } = await importFresh();
+1 -17
View File
@@ -4,10 +4,8 @@ export const DEPLOYMENT_MODE = {
} as const;
export const ENTERPRISE_FEATURE_ENV = {
BILLING_ENABLED: "NEXT_PUBLIC_PROWLER_ENTERPRISE_BILLING_ENABLED",
GROUPED_JIRA_DISPATCH_ENABLED:
"NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED",
POSTHOG_ENABLED: "NEXT_PUBLIC_PROWLER_ENTERPRISE_POSTHOG_ENABLED",
} as const;
export const PROWLER_CLOUD_ONLY_TOOLTIP = "Available only in Prowler Cloud";
@@ -21,16 +19,12 @@ type EnterpriseFeatureEnv =
const getEnterpriseFeatureValue = (
envName: EnterpriseFeatureEnv,
): string | undefined => {
if (envName === ENTERPRISE_FEATURE_ENV.BILLING_ENABLED) {
return process.env.NEXT_PUBLIC_PROWLER_ENTERPRISE_BILLING_ENABLED;
}
if (envName === ENTERPRISE_FEATURE_ENV.GROUPED_JIRA_DISPATCH_ENABLED) {
return process.env
.NEXT_PUBLIC_PROWLER_ENTERPRISE_GROUPED_JIRA_DISPATCH_ENABLED;
}
return process.env.NEXT_PUBLIC_PROWLER_ENTERPRISE_POSTHOG_ENABLED;
return undefined;
};
const getBooleanEnv = (
@@ -56,16 +50,6 @@ export const getDeploymentMode = (): DeploymentMode | undefined => {
return undefined;
};
export const isOnPremiseDeployment = (): boolean =>
getDeploymentMode() === DEPLOYMENT_MODE.ON_PREMISE;
export const isBillingEnabled = (): boolean =>
getBooleanEnv(ENTERPRISE_FEATURE_ENV.BILLING_ENABLED, true);
export const isPostHogEnabled = (): boolean =>
isBillingEnabled() ||
getBooleanEnv(ENTERPRISE_FEATURE_ENV.POSTHOG_ENABLED, true);
export const isGroupedJiraDispatchEnabled = (): boolean =>
getDeploymentMode() === DEPLOYMENT_MODE.CLOUD &&
getBooleanEnv(ENTERPRISE_FEATURE_ENV.GROUPED_JIRA_DISPATCH_ENABLED, false);