modal.tsx 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import ExternalLink from 'sentry/components/links/externalLink';
  4. import LoadingError from 'sentry/components/loadingError';
  5. import LoadingIndicator from 'sentry/components/loadingIndicator';
  6. import {t, tct} from 'sentry/locale';
  7. import {space} from 'sentry/styles/space';
  8. import type {Event, Frame} from 'sentry/types/event';
  9. import type {TagWithTopValues} from 'sentry/types/group';
  10. import type {Organization} from 'sentry/types/organization';
  11. import type {Project} from 'sentry/types/project';
  12. import {uniq} from 'sentry/utils/array/uniq';
  13. import {useApiQuery} from 'sentry/utils/queryClient';
  14. import {safeURL} from 'sentry/utils/url/safeURL';
  15. import {useUser} from 'sentry/utils/useUser';
  16. import OwnerInput from 'sentry/views/settings/project/projectOwnership/ownerInput';
  17. type IssueOwnershipResponse = {
  18. autoAssignment: boolean;
  19. dateCreated: string;
  20. fallthrough: boolean;
  21. isActive: boolean;
  22. lastUpdated: string;
  23. raw: string;
  24. };
  25. type Props = {
  26. issueId: string;
  27. onCancel: () => void;
  28. organization: Organization;
  29. project: Project;
  30. eventData?: Event;
  31. };
  32. function getFrameSuggestions(eventData?: Event) {
  33. // pull frame data out of exception or the stacktrace
  34. const entry = eventData?.entries?.find(({type}) =>
  35. ['exception', 'stacktrace'].includes(type)
  36. );
  37. let frames: Frame[] = [];
  38. if (entry?.type === 'exception') {
  39. frames = entry?.data?.values?.[0]?.stacktrace?.frames ?? [];
  40. } else if (entry?.type === 'stacktrace') {
  41. frames = entry?.data?.frames ?? [];
  42. }
  43. // Only display in-app frames
  44. frames = frames.filter(frame => frame?.inApp).reverse();
  45. return uniq(frames.map(frame => frame.filename || frame.absPath || ''));
  46. }
  47. /**
  48. * Attempt to remove the origin from a URL
  49. */
  50. function getUrlPath(maybeUrl?: string) {
  51. if (!maybeUrl) {
  52. return '';
  53. }
  54. const parsedURL = safeURL(maybeUrl);
  55. if (!parsedURL) {
  56. return maybeUrl;
  57. }
  58. return `*${parsedURL.pathname}`;
  59. }
  60. function OwnershipSuggestions({
  61. paths,
  62. urls,
  63. eventData,
  64. }: {
  65. paths: string[];
  66. urls: string[];
  67. eventData?: Event;
  68. }) {
  69. const user = useUser();
  70. if (!user.email) {
  71. return null;
  72. }
  73. const pathSuggestion = paths.length ? `path:${paths[0]} ${user.email}` : null;
  74. const urlSuggestion = urls.length ? `url:${getUrlPath(urls[0])} ${user.email}` : null;
  75. const transactionTag = eventData?.tags?.find(({key}) => key === 'transaction');
  76. const transactionSuggestion = transactionTag
  77. ? `tags.transaction:${transactionTag.value} ${user.email}`
  78. : null;
  79. return (
  80. <StyledPre>
  81. # {t('Here’s some suggestions based on this issue')}
  82. <br />
  83. {[pathSuggestion, urlSuggestion, transactionSuggestion]
  84. .filter(x => x)
  85. .map(suggestion => (
  86. <Fragment key={suggestion}>
  87. {suggestion}
  88. <br />
  89. </Fragment>
  90. ))}
  91. </StyledPre>
  92. );
  93. }
  94. function ProjectOwnershipModal({
  95. organization,
  96. project,
  97. issueId,
  98. eventData,
  99. onCancel,
  100. }: Props) {
  101. const {
  102. data: urlTagData,
  103. isPending: isUrlTagDataPending,
  104. isError: isUrlTagDataError,
  105. error,
  106. } = useApiQuery<TagWithTopValues>([`/issues/${issueId}/tags/url/`], {staleTime: 0});
  107. const {
  108. data: ownership,
  109. isPending: isOwnershipPending,
  110. isError: isOwnershipError,
  111. } = useApiQuery<IssueOwnershipResponse>(
  112. [`/projects/${organization.slug}/${project.slug}/ownership/`],
  113. {
  114. staleTime: 0,
  115. }
  116. );
  117. if (isOwnershipPending || isUrlTagDataPending) {
  118. return <LoadingIndicator />;
  119. }
  120. if (isOwnershipError || (isUrlTagDataError && error.status !== 404)) {
  121. return <LoadingError />;
  122. }
  123. if (!ownership) {
  124. return null;
  125. }
  126. const urls = urlTagData
  127. ? urlTagData.topValues
  128. .sort((a, b) => a.count - b.count)
  129. .map(i => i.value)
  130. .slice(0, 5)
  131. : [];
  132. const paths = getFrameSuggestions(eventData);
  133. return (
  134. <Fragment>
  135. <Fragment>
  136. <Description>
  137. {tct(
  138. 'Assign issues based on custom rules. To learn more, [docs:read the docs].',
  139. {
  140. docs: (
  141. <ExternalLink href="https://docs.sentry.io/product/issues/issue-owners/" />
  142. ),
  143. }
  144. )}
  145. </Description>
  146. <OwnershipSuggestions paths={paths} urls={urls} eventData={eventData} />
  147. </Fragment>
  148. <OwnerInput
  149. organization={organization}
  150. project={project}
  151. initialText={ownership?.raw || ''}
  152. urls={urls}
  153. paths={paths}
  154. dateUpdated={ownership.lastUpdated}
  155. onCancel={onCancel}
  156. page="issue_details"
  157. />
  158. </Fragment>
  159. );
  160. }
  161. const Description = styled('p')`
  162. margin-bottom: ${space(1)};
  163. `;
  164. const StyledPre = styled('pre')`
  165. word-break: break-word;
  166. padding: ${space(2)};
  167. line-height: 1.6;
  168. color: ${p => p.theme.subText};
  169. `;
  170. export default ProjectOwnershipModal;