import type {Location, LocationDescriptorObject} from 'history';
import ExternalLink from 'sentry/components/links/externalLink';
import {DEFAULT_QUERY} from 'sentry/constants';
import {t, tct} from 'sentry/locale';
import type {Event} from 'sentry/types/event';
import type {Group, GroupTombstoneHelper} from 'sentry/types/group';
import type {Organization} from 'sentry/types/organization';
export enum Query {
FOR_REVIEW = 'is:unresolved is:for_review assigned_or_suggested:[me, my_teams, none]',
// biome-ignore lint/style/useLiteralEnumMembers: Disable for maintenance cost.
PRIORITIZED = DEFAULT_QUERY,
UNRESOLVED = 'is:unresolved',
IGNORED = 'is:ignored',
NEW = 'is:new',
ARCHIVED = 'is:archived',
ESCALATING = 'is:escalating',
REGRESSED = 'is:regressed',
REPROCESSING = 'is:reprocessing',
}
export const CUSTOM_TAB_VALUE = '__custom__';
type OverviewTab = {
/**
* Emitted analytics event tab name
*/
analyticsName: string;
/**
* Will fetch a count to display on this tab
*/
count: boolean;
/**
* Tabs can be disabled via flag
*/
enabled: boolean;
name: string;
hidden?: boolean;
/**
* Tooltip text to be hoverable when text has links
*/
tooltipHoverable?: boolean;
/**
* Tooltip text for each tab
*/
tooltipTitle?: React.ReactNode;
};
/**
* Get a list of currently active tabs
*/
export function getTabs() {
const tabs: Array<[string, OverviewTab]> = [
[
Query.PRIORITIZED,
{
name: t('Prioritized'),
analyticsName: 'prioritized',
count: true,
enabled: true,
},
],
[
Query.FOR_REVIEW,
{
name: t('For Review'),
analyticsName: 'needs_review',
count: true,
enabled: true,
tooltipTitle: t(
'Issues are marked for review if they are new or escalating, and have not been resolved or archived. Issues are automatically marked reviewed in 7 days.'
),
},
],
[
Query.REGRESSED,
{
name: t('Regressed'),
analyticsName: 'regressed',
count: true,
enabled: true,
},
],
[
Query.ESCALATING,
{
name: t('Escalating'),
analyticsName: 'escalating',
count: true,
enabled: true,
},
],
[
Query.ARCHIVED,
{
name: t('Archived'),
analyticsName: 'archived',
count: true,
enabled: true,
},
],
[
Query.IGNORED,
{
name: t('Ignored'),
analyticsName: 'ignored',
count: true,
enabled: false,
tooltipTitle: t(`Ignored issues don’t trigger alerts. When their ignore
conditions are met they become Unresolved and are flagged for review.`),
},
],
[
Query.REPROCESSING,
{
name: t('Reprocessing'),
analyticsName: 'reprocessing',
count: true,
enabled: true,
tooltipTitle: tct(
`These [link:reprocessing issues] will take some time to complete.
Any new issues that are created during reprocessing will be flagged for review.`,
{
link: (
),
}
),
tooltipHoverable: true,
},
],
[
// Hidden tab to account for custom queries that don't match any of the queries
// above. It's necessary because if Tabs's value doesn't match that of any tab item
// then Tabs will fall back to a default value, causing unexpected behaviors.
CUSTOM_TAB_VALUE,
{
name: t('Custom'),
analyticsName: 'custom',
hidden: true,
count: false,
enabled: true,
},
],
];
return tabs.filter(([_query, tab]) => tab.enabled);
}
/**
* @returns queries that should have counts fetched
*/
export function getTabsWithCounts() {
const tabs = getTabs();
return tabs.filter(([_query, tab]) => tab.count).map(([query]) => query);
}
export function isForReviewQuery(query: string | undefined) {
return !!query && /\bis:for_review\b/.test(query);
}
// the tab counts will look like 99+
export const TAB_MAX_COUNT = 99;
type QueryCount = {
count: number;
hasMore: boolean;
};
export type QueryCounts = Partial>;
export enum IssueSortOptions {
DATE = 'date',
NEW = 'new',
TRENDS = 'trends',
FREQ = 'freq',
USER = 'user',
INBOX = 'inbox',
}
export const DEFAULT_ISSUE_STREAM_SORT = IssueSortOptions.DATE;
export function isDefaultIssueStreamSearch({query, sort}: {query: string; sort: string}) {
return query === DEFAULT_QUERY && sort === DEFAULT_ISSUE_STREAM_SORT;
}
export function getSortLabel(key: string) {
switch (key) {
case IssueSortOptions.NEW:
return t('First Seen');
case IssueSortOptions.TRENDS:
return t('Trends');
case IssueSortOptions.FREQ:
return t('Events');
case IssueSortOptions.USER:
return t('Users');
case IssueSortOptions.INBOX:
return t('Date Added');
case IssueSortOptions.DATE:
default:
return t('Last Seen');
}
}
export const DISCOVER_EXCLUSION_FIELDS: string[] = [
'query',
'status',
'bookmarked_by',
'assigned',
'assigned_to',
'unassigned',
'subscribed_by',
'active_at',
'first_release',
'first_seen',
'is',
'__text',
'issue.priority',
'issue.category',
'issue.type',
];
export const FOR_REVIEW_QUERIES: string[] = [Query.FOR_REVIEW];
export const SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY =
'issue-stream-saved-searches-sidebar-open';
export enum IssueGroup {
ALL = 'all',
ERROR_OUTAGE = 'error_outage',
TREND = 'trend',
CRAFTSMANSHIP = 'craftsmanship',
SECURITY = 'security',
}
const IssueGroupFilter: Record = {
[IssueGroup.ALL]: '',
[IssueGroup.ERROR_OUTAGE]: 'issue.category:[error,cron,uptime]',
[IssueGroup.TREND]:
'issue.type:[profile_function_regression,performance_p95_endpoint_regression,performance_n_plus_one_db_queries]',
[IssueGroup.CRAFTSMANSHIP]:
'issue.category:replay issue.type:[performance_n_plus_one_db_queries,performance_n_plus_one_api_calls,performance_consecutive_db_queries,performance_render_blocking_asset_span,performance_uncompressed_assets,profile_file_io_main_thread,profile_image_decode_main_thread,profile_json_decode_main_thread,profile_regex_main_thread]',
[IssueGroup.SECURITY]: 'event.type:[nel,csp]',
};
function getIssueGroupFilter(group: IssueGroup): string {
if (!Object.hasOwn(IssueGroupFilter, group)) {
throw new Error(`Unknown issue group "${group}"`);
}
return IssueGroupFilter[group];
}
/** Generate a properly encoded `?query=` string for a given issue group */
export function getSearchForIssueGroup(group: IssueGroup): string {
return `?${new URLSearchParams(`query=is:unresolved+${getIssueGroupFilter(group)}`)}`;
}
export function createIssueLink({
organization,
data,
eventId,
referrer,
streamIndex,
location,
query,
}: {
data: Event | Group | GroupTombstoneHelper;
location: Location;
organization: Organization;
eventId?: string;
query?: string;
referrer?: string;
streamIndex?: number;
}): LocationDescriptorObject {
const {id} = data as Group;
const {eventID: latestEventId, groupID} = data as Event;
// If we have passed in a custom event ID, use it; otherwise use default
const finalEventId = eventId ?? latestEventId;
return {
pathname: `/organizations/${organization.slug}/issues/${
latestEventId ? groupID : id
}/${finalEventId ? `events/${finalEventId}/` : ''}`,
query: {
referrer: referrer || 'event-or-group-header',
stream_index: streamIndex,
query,
// This adds sort to the query if one was selected from the
// issues list page
...(location.query.sort !== undefined ? {sort: location.query.sort} : {}),
// This appends _allp to the URL parameters if they have no
// project selected ("all" projects included in results). This is
// so that when we enter the issue details page and lock them to
// a project, we can properly take them back to the issue list
// page with no project selected (and not the locked project
// selected)
...(location.query.project !== undefined ? {} : {_allp: 1}),
},
};
}