group.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. import * as Sentry from '@sentry/react';
  2. import type {Tag} from 'sentry/actionCreators/events';
  3. import type {RequestCallbacks, RequestOptions} from 'sentry/api';
  4. import {Client} from 'sentry/api';
  5. import {getSampleEventQuery} from 'sentry/components/events/eventStatisticalDetector/eventComparison/eventDisplay';
  6. import GroupStore from 'sentry/stores/groupStore';
  7. import type {
  8. Actor,
  9. Group,
  10. Member,
  11. Note,
  12. Tag as GroupTag,
  13. TagValue,
  14. User,
  15. } from 'sentry/types';
  16. import {buildTeamId, buildUserId, defined} from 'sentry/utils';
  17. import {uniqueId} from 'sentry/utils/guid';
  18. import type {ApiQueryKey, UseApiQueryOptions} from 'sentry/utils/queryClient';
  19. import {useApiQuery} from 'sentry/utils/queryClient';
  20. type AssignedBy = 'suggested_assignee' | 'assignee_selector';
  21. type AssignToUserParams = {
  22. assignedBy: AssignedBy;
  23. /**
  24. * Issue id
  25. */
  26. id: string;
  27. orgSlug: string;
  28. user: User | Actor;
  29. member?: Member;
  30. };
  31. export function assignToUser(params: AssignToUserParams) {
  32. const api = new Client();
  33. const endpoint = `/organizations/${params.orgSlug}/issues/${params.id}/`;
  34. const id = uniqueId();
  35. GroupStore.onAssignTo(id, params.id, {
  36. email: (params.member && params.member.email) || '',
  37. });
  38. const request = api.requestPromise(endpoint, {
  39. method: 'PUT',
  40. // Sending an empty value to assignedTo is the same as "clear",
  41. // so if no member exists, that implies that we want to clear the
  42. // current assignee.
  43. data: {
  44. assignedTo: params.user ? buildUserId(params.user.id) : '',
  45. assignedBy: params.assignedBy,
  46. },
  47. });
  48. request
  49. .then(data => {
  50. GroupStore.onAssignToSuccess(id, params.id, data);
  51. })
  52. .catch(data => {
  53. GroupStore.onAssignToError(id, params.id, data);
  54. });
  55. return request;
  56. }
  57. export function clearAssignment(
  58. groupId: string,
  59. orgSlug: string,
  60. assignedBy: AssignedBy
  61. ) {
  62. const api = new Client();
  63. const endpoint = `/organizations/${orgSlug}/issues/${groupId}/`;
  64. const id = uniqueId();
  65. GroupStore.onAssignTo(id, groupId, {
  66. email: '',
  67. });
  68. const request = api.requestPromise(endpoint, {
  69. method: 'PUT',
  70. // Sending an empty value to assignedTo is the same as "clear"
  71. data: {
  72. assignedTo: '',
  73. assignedBy,
  74. },
  75. });
  76. request
  77. .then(data => {
  78. GroupStore.onAssignToSuccess(id, groupId, data);
  79. })
  80. .catch(data => {
  81. GroupStore.onAssignToError(id, groupId, data);
  82. });
  83. return request;
  84. }
  85. type AssignToActorParams = {
  86. actor: Pick<Actor, 'id' | 'type'>;
  87. assignedBy: AssignedBy;
  88. /**
  89. * Issue id
  90. */
  91. id: string;
  92. orgSlug: string;
  93. };
  94. export function assignToActor({id, actor, assignedBy, orgSlug}: AssignToActorParams) {
  95. const api = new Client();
  96. const endpoint = `/organizations/${orgSlug}/issues/${id}/`;
  97. const guid = uniqueId();
  98. let actorId = '';
  99. GroupStore.onAssignTo(guid, id, {email: ''});
  100. switch (actor.type) {
  101. case 'user':
  102. actorId = buildUserId(actor.id);
  103. break;
  104. case 'team':
  105. actorId = buildTeamId(actor.id);
  106. break;
  107. default:
  108. Sentry.withScope(scope => {
  109. scope.setExtra('actor', actor);
  110. Sentry.captureException('Unknown assignee type');
  111. });
  112. }
  113. return api
  114. .requestPromise(endpoint, {
  115. method: 'PUT',
  116. data: {assignedTo: actorId, assignedBy},
  117. })
  118. .then(data => {
  119. GroupStore.onAssignToSuccess(guid, id, data);
  120. })
  121. .catch(data => {
  122. GroupStore.onAssignToSuccess(guid, id, data);
  123. });
  124. }
  125. export function deleteNote(
  126. api: Client,
  127. orgSlug: string,
  128. group: Group,
  129. id: string,
  130. _oldText: string
  131. ) {
  132. const restore = group.activity.find(activity => activity.id === id);
  133. const index = GroupStore.removeActivity(group.id, id);
  134. if (index === -1 || restore === undefined) {
  135. // I dunno, the id wasn't found in the GroupStore
  136. return Promise.reject(new Error('Group was not found in store'));
  137. }
  138. const promise = api.requestPromise(
  139. `/organizations/${orgSlug}/issues/${group.id}/comments/${id}/`,
  140. {
  141. method: 'DELETE',
  142. }
  143. );
  144. promise.catch(() => GroupStore.addActivity(group.id, restore, index));
  145. return promise;
  146. }
  147. export function createNote(api: Client, orgSlug: string, group: Group, note: Note) {
  148. const promise = api.requestPromise(
  149. `/organizations/${orgSlug}/issues/${group.id}/comments/`,
  150. {
  151. method: 'POST',
  152. data: note,
  153. }
  154. );
  155. promise.then(data => GroupStore.addActivity(group.id, data));
  156. return promise;
  157. }
  158. export function updateNote(
  159. api: Client,
  160. orgSlug: string,
  161. group: Group,
  162. note: Note,
  163. id: string,
  164. oldText: string
  165. ) {
  166. GroupStore.updateActivity(group.id, id, {text: note.text});
  167. const promise = api.requestPromise(
  168. `/organizations/${orgSlug}/issues/${group.id}/comments/${id}/`,
  169. {
  170. method: 'PUT',
  171. data: note,
  172. }
  173. );
  174. promise.catch(() => GroupStore.updateActivity(group.id, id, {text: oldText}));
  175. return promise;
  176. }
  177. type ParamsType = {
  178. environment?: string | string[] | null;
  179. itemIds?: string[];
  180. project?: number[] | null;
  181. query?: string;
  182. };
  183. type UpdateParams = ParamsType & {
  184. orgId: string;
  185. projectId?: string;
  186. };
  187. type QueryArgs =
  188. | {
  189. query: string;
  190. environment?: string | Array<string>;
  191. project?: Array<number>;
  192. }
  193. | {
  194. id: Array<number> | Array<string>;
  195. environment?: string | Array<string>;
  196. project?: Array<number>;
  197. }
  198. | {
  199. environment?: string | Array<string>;
  200. project?: Array<number>;
  201. };
  202. /**
  203. * Converts input parameters to API-compatible query arguments
  204. */
  205. export function paramsToQueryArgs(params: ParamsType): QueryArgs {
  206. const p: QueryArgs = params.itemIds
  207. ? {id: params.itemIds} // items matching array of itemids
  208. : params.query
  209. ? {query: params.query} // items matching search query
  210. : {}; // all items
  211. // only include environment if it is not null/undefined
  212. if (params.query && params.environment !== null && params.environment !== undefined) {
  213. p.environment = params.environment;
  214. }
  215. // only include projects if it is not null/undefined/an empty array
  216. if (params.project?.length) {
  217. p.project = params.project;
  218. }
  219. // only include date filters if they are not null/undefined
  220. if (params.query) {
  221. ['start', 'end', 'period', 'utc'].forEach(prop => {
  222. if (params[prop] !== null && params[prop] !== undefined) {
  223. p[prop === 'period' ? 'statsPeriod' : prop] = params[prop];
  224. }
  225. });
  226. }
  227. return p;
  228. }
  229. function getUpdateUrl({projectId, orgId}: UpdateParams) {
  230. return projectId
  231. ? `/projects/${orgId}/${projectId}/issues/`
  232. : `/organizations/${orgId}/issues/`;
  233. }
  234. function chainUtil<Args extends any[]>(
  235. ...funcs: Array<((...args: Args) => any) | undefined>
  236. ) {
  237. const filteredFuncs = funcs.filter(
  238. (f): f is (...args: Args) => any => typeof f === 'function'
  239. );
  240. return (...args: Args): void => {
  241. filteredFuncs.forEach(func => {
  242. func.apply(funcs, args);
  243. });
  244. };
  245. }
  246. function wrapRequest(
  247. api: Client,
  248. path: string,
  249. options: RequestOptions,
  250. extraParams: RequestCallbacks = {}
  251. ) {
  252. options.success = chainUtil(options.success, extraParams.success);
  253. options.error = chainUtil(options.error, extraParams.error);
  254. options.complete = chainUtil(options.complete, extraParams.complete);
  255. return api.request(path, options);
  256. }
  257. type BulkDeleteParams = UpdateParams;
  258. export function bulkDelete(
  259. api: Client,
  260. params: BulkDeleteParams,
  261. options: RequestCallbacks
  262. ) {
  263. const {itemIds} = params;
  264. const path = getUpdateUrl(params);
  265. const query: QueryArgs = paramsToQueryArgs(params);
  266. const id = uniqueId();
  267. GroupStore.onDelete(id, itemIds);
  268. return wrapRequest(
  269. api,
  270. path,
  271. {
  272. query,
  273. method: 'DELETE',
  274. success: response => {
  275. GroupStore.onDeleteSuccess(id, itemIds, response);
  276. },
  277. error: error => {
  278. GroupStore.onDeleteError(id, itemIds, error);
  279. },
  280. },
  281. options
  282. );
  283. }
  284. type BulkUpdateParams = UpdateParams & {
  285. data?: any;
  286. failSilently?: boolean;
  287. };
  288. export function bulkUpdate(
  289. api: Client,
  290. params: BulkUpdateParams,
  291. options: RequestCallbacks
  292. ) {
  293. const {itemIds, failSilently, data} = params;
  294. const path = getUpdateUrl(params);
  295. const query: QueryArgs = paramsToQueryArgs(params);
  296. const id = uniqueId();
  297. GroupStore.onUpdate(id, itemIds, data);
  298. return wrapRequest(
  299. api,
  300. path,
  301. {
  302. query,
  303. method: 'PUT',
  304. data,
  305. success: response => {
  306. GroupStore.onUpdateSuccess(id, itemIds, response);
  307. },
  308. error: () => {
  309. GroupStore.onUpdateError(id, itemIds, !!failSilently);
  310. },
  311. },
  312. options
  313. );
  314. }
  315. type MergeGroupsParams = UpdateParams;
  316. export function mergeGroups(
  317. api: Client,
  318. params: MergeGroupsParams,
  319. options: RequestCallbacks
  320. ) {
  321. const {itemIds} = params;
  322. const path = getUpdateUrl(params);
  323. const query: QueryArgs = paramsToQueryArgs(params);
  324. const id = uniqueId();
  325. GroupStore.onMerge(id, itemIds);
  326. return wrapRequest(
  327. api,
  328. path,
  329. {
  330. query,
  331. method: 'PUT',
  332. data: {merge: 1},
  333. success: response => {
  334. GroupStore.onMergeSuccess(id, itemIds, response);
  335. },
  336. error: error => {
  337. GroupStore.onMergeError(id, itemIds, error);
  338. },
  339. },
  340. options
  341. );
  342. }
  343. export type GroupTagResponseItem = {
  344. key: string;
  345. name: string;
  346. topValues: Array<{
  347. count: number;
  348. firstSeen: string;
  349. lastSeen: string;
  350. name: string;
  351. value: string;
  352. readable?: boolean;
  353. }>;
  354. totalValues: number;
  355. };
  356. export type GroupTagsResponse = GroupTagResponseItem[];
  357. type FetchIssueTagsParameters = {
  358. environment: string[];
  359. orgSlug: string;
  360. groupId?: string;
  361. isStatisticalDetector?: boolean;
  362. limit?: number;
  363. readable?: boolean;
  364. statisticalDetectorParameters?: {
  365. durationBaseline: number;
  366. end: string;
  367. start: string;
  368. transaction: string;
  369. };
  370. };
  371. export const makeFetchIssueTagsQueryKey = ({
  372. groupId,
  373. orgSlug,
  374. environment,
  375. readable,
  376. limit,
  377. }: FetchIssueTagsParameters): ApiQueryKey => [
  378. `/organizations/${orgSlug}/issues/${groupId}/tags/`,
  379. {query: {environment, readable, limit}},
  380. ];
  381. const makeFetchStatisticalDetectorTagsQueryKey = ({
  382. orgSlug,
  383. environment,
  384. statisticalDetectorParameters,
  385. }: FetchIssueTagsParameters): ApiQueryKey => {
  386. const {transaction, durationBaseline, start, end} = statisticalDetectorParameters ?? {
  387. transaction: '',
  388. durationBaseline: 0,
  389. start: undefined,
  390. end: undefined,
  391. };
  392. return [
  393. `/organizations/${orgSlug}/events-facets/`,
  394. {
  395. query: {
  396. environment,
  397. transaction,
  398. includeAll: true,
  399. query: getSampleEventQuery({transaction, durationBaseline, addUpperBound: false}),
  400. start,
  401. end,
  402. },
  403. },
  404. ];
  405. };
  406. export const useFetchIssueTags = (
  407. parameters: FetchIssueTagsParameters,
  408. {
  409. enabled = true,
  410. ...options
  411. }: Partial<UseApiQueryOptions<GroupTagsResponse | Tag[]>> = {}
  412. ) => {
  413. let queryKey = makeFetchIssueTagsQueryKey(parameters);
  414. if (parameters.isStatisticalDetector) {
  415. // Statistical detector issues need to use a Discover query for tags
  416. queryKey = makeFetchStatisticalDetectorTagsQueryKey(parameters);
  417. }
  418. return useApiQuery<GroupTagsResponse | Tag[]>(queryKey, {
  419. staleTime: 30000,
  420. enabled: defined(parameters.groupId) && enabled,
  421. ...options,
  422. });
  423. };
  424. type FetchIssueTagValuesParameters = {
  425. groupId: string;
  426. orgSlug: string;
  427. tagKey: string;
  428. cursor?: string;
  429. environment?: string[];
  430. sort?: string | string[];
  431. };
  432. export const makeFetchIssueTagValuesQueryKey = ({
  433. orgSlug,
  434. groupId,
  435. tagKey,
  436. environment,
  437. sort,
  438. cursor,
  439. }: FetchIssueTagValuesParameters): ApiQueryKey => [
  440. `/organizations/${orgSlug}/issues/${groupId}/tags/${tagKey}/values/`,
  441. {query: {environment, sort, cursor}},
  442. ];
  443. export function useFetchIssueTagValues(
  444. parameters: FetchIssueTagValuesParameters,
  445. options: Partial<UseApiQueryOptions<TagValue[]>> = {}
  446. ) {
  447. return useApiQuery<TagValue[]>(makeFetchIssueTagValuesQueryKey(parameters), {
  448. staleTime: 0,
  449. retry: false,
  450. ...options,
  451. });
  452. }
  453. type FetchIssueTagParameters = {
  454. groupId: string;
  455. orgSlug: string;
  456. tagKey: string;
  457. };
  458. export const makeFetchIssueTagQueryKey = ({
  459. orgSlug,
  460. groupId,
  461. tagKey,
  462. environment,
  463. sort,
  464. }: FetchIssueTagValuesParameters): ApiQueryKey => [
  465. `/organizations/${orgSlug}/issues/${groupId}/tags/${tagKey}/`,
  466. {query: {environment, sort}},
  467. ];
  468. export function useFetchIssueTag(
  469. parameters: FetchIssueTagParameters,
  470. options: Partial<UseApiQueryOptions<GroupTag>> = {}
  471. ) {
  472. return useApiQuery<GroupTag>(makeFetchIssueTagQueryKey(parameters), {
  473. staleTime: 0,
  474. retry: false,
  475. ...options,
  476. });
  477. }