groupActivityItem.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import moment from 'moment';
  4. import CommitLink from 'sentry/components/commitLink';
  5. import DateTime from 'sentry/components/dateTime';
  6. import Duration from 'sentry/components/duration';
  7. import ExternalLink from 'sentry/components/links/externalLink';
  8. import Link from 'sentry/components/links/link';
  9. import PullRequestLink from 'sentry/components/pullRequestLink';
  10. import Version from 'sentry/components/version';
  11. import {t, tct, tn} from 'sentry/locale';
  12. import {
  13. GroupActivity,
  14. GroupActivityAssigned,
  15. GroupActivitySetEscalating,
  16. GroupActivitySetIgnored,
  17. GroupActivityType,
  18. Organization,
  19. Project,
  20. User,
  21. } from 'sentry/types';
  22. import {isSemverRelease} from 'sentry/utils/formatters';
  23. import {useTeamsById} from 'sentry/utils/useTeamsById';
  24. interface AssignedMessageProps {
  25. activity: GroupActivityAssigned;
  26. author: React.ReactNode;
  27. }
  28. function AssignedMessage({activity, author}: AssignedMessageProps) {
  29. const {data} = activity;
  30. let assignee: string | User | undefined = undefined;
  31. const {teams} = useTeamsById(
  32. data.assigneeType === 'team' ? {ids: [data.assignee]} : undefined
  33. );
  34. if (data.assigneeType === 'team') {
  35. const team = teams.find(({id}) => id === data.assignee);
  36. // TODO: could show a loading indicator if the team is loading
  37. assignee = team ? `#${team.slug}` : '<unknown-team>';
  38. } else if (activity.user && data.assignee === activity.user.id) {
  39. assignee = t('themselves');
  40. } else if (data.assigneeType === 'user' && data.assigneeEmail) {
  41. assignee = data.assigneeEmail;
  42. } else {
  43. assignee = t('an unknown user');
  44. }
  45. const isAutoAssigned = ['projectOwnership', 'codeowners'].includes(
  46. data.integration as string
  47. );
  48. const integrationName: Record<
  49. NonNullable<GroupActivityAssigned['data']['integration']>,
  50. string
  51. > = {
  52. msteams: t('Microsoft Teams'),
  53. slack: t('Slack'),
  54. projectOwnership: t('Ownership Rule'),
  55. codeowners: t('Codeowners Rule'),
  56. };
  57. return (
  58. <Fragment>
  59. <div>
  60. {tct('[author] [action] this issue to [assignee]', {
  61. action: isAutoAssigned ? t('auto-assigned') : t('assigned'),
  62. author,
  63. assignee,
  64. })}
  65. </div>
  66. {data.integration && (
  67. <CodeWrapper>
  68. {t('Assigned via %s', integrationName[data.integration])}
  69. {data.rule && (
  70. <Fragment>
  71. : <StyledRuleSpan>{data.rule}</StyledRuleSpan>
  72. </Fragment>
  73. )}
  74. </CodeWrapper>
  75. )}
  76. </Fragment>
  77. );
  78. }
  79. interface GroupActivityItemProps {
  80. activity: GroupActivity;
  81. author: React.ReactNode;
  82. organization: Organization;
  83. projectId: Project['id'];
  84. }
  85. function GroupActivityItem({
  86. activity,
  87. organization,
  88. projectId,
  89. author,
  90. }: GroupActivityItemProps) {
  91. const issuesLink = `/organizations/${organization.slug}/issues/`;
  92. const hasEscalatingIssuesUi = organization.features.includes('escalating-issues');
  93. function getIgnoredMessage(data: GroupActivitySetIgnored['data']) {
  94. const ignoredOrArchived = hasEscalatingIssuesUi ? t('archived') : t('ignored');
  95. if (data.ignoreDuration) {
  96. return tct('[author] [action] this issue for [duration]', {
  97. author,
  98. action: ignoredOrArchived,
  99. duration: <Duration seconds={data.ignoreDuration * 60} />,
  100. });
  101. }
  102. if (data.ignoreCount && data.ignoreWindow) {
  103. return tct(
  104. '[author] [action] this issue until it happens [count] time(s) in [duration]',
  105. {
  106. author,
  107. action: ignoredOrArchived,
  108. count: data.ignoreCount,
  109. duration: <Duration seconds={data.ignoreWindow * 60} />,
  110. }
  111. );
  112. }
  113. if (data.ignoreCount) {
  114. return tct('[author] [action] this issue until it happens [count] time(s)', {
  115. author,
  116. action: ignoredOrArchived,
  117. count: data.ignoreCount,
  118. });
  119. }
  120. if (data.ignoreUserCount && data.ignoreUserWindow) {
  121. return tct(
  122. '[author] [action] this issue until it affects [count] user(s) in [duration]',
  123. {
  124. author,
  125. action: ignoredOrArchived,
  126. count: data.ignoreUserCount,
  127. duration: <Duration seconds={data.ignoreUserWindow * 60} />,
  128. }
  129. );
  130. }
  131. if (data.ignoreUserCount) {
  132. return tct('[author] [action] this issue until it affects [count] user(s)', {
  133. author,
  134. action: ignoredOrArchived,
  135. count: data.ignoreUserCount,
  136. });
  137. }
  138. if (data.ignoreUntil) {
  139. return tct('[author] [action] this issue until [date]', {
  140. author,
  141. action: ignoredOrArchived,
  142. date: <DateTime date={data.ignoreUntil} />,
  143. });
  144. }
  145. if (hasEscalatingIssuesUi && data.ignoreUntilEscalating) {
  146. return tct('[author] archived this issue until it escalates', {
  147. author,
  148. });
  149. }
  150. return tct('[author] [action] this issue forever', {
  151. author,
  152. action: ignoredOrArchived,
  153. });
  154. }
  155. function getEscalatingMessage(data: GroupActivitySetEscalating['data']) {
  156. if (data.forecast) {
  157. return tct(
  158. '[author] flagged this issue as escalating because over [forecast] [event] happened in an hour',
  159. {
  160. author,
  161. forecast: data.forecast,
  162. event: data.forecast === 1 ? 'event' : 'events',
  163. }
  164. );
  165. }
  166. if (data.expired_snooze) {
  167. if (data.expired_snooze.count && data.expired_snooze.window) {
  168. return tct(
  169. '[author] flagged this issue as escalating because [count] [event] happened in [duration]',
  170. {
  171. author,
  172. count: data.expired_snooze.count,
  173. event: data.expired_snooze.count === 1 ? 'event' : 'events',
  174. duration: <Duration seconds={data.expired_snooze.window * 60} />,
  175. }
  176. );
  177. }
  178. if (data.expired_snooze.count) {
  179. return tct(
  180. '[author] flagged this issue as escalating because [count] [event] happened',
  181. {
  182. author,
  183. count: data.expired_snooze.count,
  184. event: data.expired_snooze.count === 1 ? 'event' : 'events',
  185. }
  186. );
  187. }
  188. if (data.expired_snooze.user_count && data.expired_snooze.user_window) {
  189. return tct(
  190. '[author] flagged this issue as escalating because [count] [user] affected in [duration]',
  191. {
  192. author,
  193. count: data.expired_snooze.user_count,
  194. user: data.expired_snooze.user_count === 1 ? 'user was' : 'users were',
  195. duration: <Duration seconds={data.expired_snooze.user_window * 60} />,
  196. }
  197. );
  198. }
  199. if (data.expired_snooze.user_count) {
  200. return tct(
  201. '[author] flagged this issue as escalating because [count] [user] affected',
  202. {
  203. author,
  204. count: data.expired_snooze.user_count,
  205. user: data.expired_snooze.user_count === 1 ? 'user was' : 'users were',
  206. }
  207. );
  208. }
  209. if (data.expired_snooze.until) {
  210. return tct('[author] flagged this issue as escalating because [date] passed', {
  211. author,
  212. date: <DateTime date={data.expired_snooze.until} />,
  213. });
  214. }
  215. }
  216. return tct('[author] flagged this issue as escalating', {author}); // should not reach this
  217. }
  218. function renderContent() {
  219. switch (activity.type) {
  220. case GroupActivityType.NOTE:
  221. return tct('[author] left a comment', {author});
  222. case GroupActivityType.SET_RESOLVED:
  223. if ('integration_id' in activity.data && activity.data.integration_id) {
  224. return tct('[author] marked this issue as resolved via [integration]', {
  225. integration: (
  226. <Link
  227. to={`/settings/${organization.slug}/integrations/${activity.data.provider_key}/${activity.data.integration_id}/`}
  228. >
  229. {activity.data.provider}
  230. </Link>
  231. ),
  232. author,
  233. });
  234. }
  235. return tct('[author] marked this issue as resolved', {author});
  236. case GroupActivityType.SET_RESOLVED_BY_AGE:
  237. return tct('[author] marked this issue as resolved due to inactivity', {
  238. author,
  239. });
  240. case GroupActivityType.SET_RESOLVED_IN_RELEASE:
  241. // Resolved in the next release
  242. if ('current_release_version' in activity.data) {
  243. const currentVersion = activity.data.current_release_version;
  244. return tct(
  245. '[author] marked this issue as resolved in releases greater than [version] [semver]',
  246. {
  247. author,
  248. version: (
  249. <Version
  250. version={currentVersion}
  251. projectId={projectId}
  252. tooltipRawVersion
  253. />
  254. ),
  255. semver: isSemverRelease(currentVersion) ? t('(semver)') : t('(non-semver)'),
  256. }
  257. );
  258. }
  259. const version = activity.data.version;
  260. return version
  261. ? tct('[author] marked this issue as resolved in [version] [semver]', {
  262. author,
  263. version: (
  264. <Version version={version} projectId={projectId} tooltipRawVersion />
  265. ),
  266. semver: isSemverRelease(version) ? t('(semver)') : t('(non-semver)'),
  267. })
  268. : tct('[author] marked this issue as resolved in the upcoming release', {
  269. author,
  270. });
  271. case GroupActivityType.SET_RESOLVED_IN_COMMIT:
  272. const deployedReleases = (activity.data.commit?.releases || [])
  273. .filter(r => r.dateReleased !== null)
  274. .sort(
  275. (a, b) => moment(a.dateReleased).valueOf() - moment(b.dateReleased).valueOf()
  276. );
  277. if (deployedReleases.length === 1 && activity.data.commit) {
  278. return tct(
  279. '[author] marked this issue as resolved in [version] [break]This commit was released in [release]',
  280. {
  281. author,
  282. version: (
  283. <CommitLink
  284. inline
  285. commitId={activity.data.commit.id}
  286. repository={activity.data.commit.repository}
  287. />
  288. ),
  289. break: <br />,
  290. release: (
  291. <Version
  292. version={deployedReleases[0].version}
  293. projectId={projectId}
  294. tooltipRawVersion
  295. />
  296. ),
  297. }
  298. );
  299. }
  300. if (deployedReleases.length > 1 && activity.data.commit) {
  301. return tct(
  302. '[author] marked this issue as resolved in [version] [break]This commit was released in [release] and [otherCount] others',
  303. {
  304. author,
  305. otherCount: deployedReleases.length - 1,
  306. version: (
  307. <CommitLink
  308. inline
  309. commitId={activity.data.commit.id}
  310. repository={activity.data.commit.repository}
  311. />
  312. ),
  313. break: <br />,
  314. release: (
  315. <Version
  316. version={deployedReleases[0].version}
  317. projectId={projectId}
  318. tooltipRawVersion
  319. />
  320. ),
  321. }
  322. );
  323. }
  324. if (activity.data.commit) {
  325. return tct('[author] marked this issue as resolved in [commit]', {
  326. author,
  327. commit: (
  328. <CommitLink
  329. inline
  330. commitId={activity.data.commit.id}
  331. repository={activity.data.commit.repository}
  332. />
  333. ),
  334. });
  335. }
  336. return tct('[author] marked this issue as resolved in a commit', {author});
  337. case GroupActivityType.SET_RESOLVED_IN_PULL_REQUEST: {
  338. const {data} = activity;
  339. const {pullRequest} = data;
  340. return tct('[author] has created a PR for this issue: [pullRequest]', {
  341. author,
  342. pullRequest: pullRequest ? (
  343. <PullRequestLink
  344. inline
  345. pullRequest={pullRequest}
  346. repository={pullRequest.repository}
  347. />
  348. ) : (
  349. t('PR not available')
  350. ),
  351. });
  352. }
  353. case GroupActivityType.SET_UNRESOLVED: {
  354. // TODO(nisanthan): Remove after migrating records to SET_ESCALATING
  355. const {data} = activity;
  356. if ('forecast' in data && data.forecast) {
  357. return tct(
  358. '[author] flagged this issue as escalating because over [forecast] [event] happened in an hour',
  359. {
  360. author,
  361. forecast: data.forecast,
  362. event: data.forecast === 1 ? 'event' : 'events',
  363. }
  364. );
  365. }
  366. if ('integration_id' in data && data.integration_id) {
  367. return tct('[author] marked this issue as unresolved via [integration]', {
  368. integration: (
  369. <Link
  370. to={`/settings/${organization.slug}/integrations/${data.provider_key}/${data.integration_id}/`}
  371. >
  372. {data.provider}
  373. </Link>
  374. ),
  375. author,
  376. });
  377. }
  378. return tct('[author] marked this issue as unresolved', {author});
  379. }
  380. case GroupActivityType.SET_IGNORED: {
  381. const {data} = activity;
  382. return getIgnoredMessage(data);
  383. }
  384. case GroupActivityType.SET_PUBLIC:
  385. return tct('[author] made this issue public', {author});
  386. case GroupActivityType.SET_PRIVATE:
  387. return tct('[author] made this issue private', {author});
  388. case GroupActivityType.SET_REGRESSION: {
  389. const {data} = activity;
  390. let subtext: React.ReactNode = null;
  391. if (data.version && data.resolved_in_version && 'follows_semver' in data) {
  392. subtext = (
  393. <Subtext>
  394. {tct(
  395. '[regressionVersion] is greater than or equal to [resolvedVersion] compared via [comparison]',
  396. {
  397. regressionVersion: (
  398. <Version
  399. version={data.version}
  400. projectId={projectId}
  401. tooltipRawVersion
  402. />
  403. ),
  404. resolvedVersion: (
  405. <Version
  406. version={data.resolved_in_version}
  407. projectId={projectId}
  408. tooltipRawVersion
  409. />
  410. ),
  411. comparison: data.follows_semver ? t('semver') : t('release date'),
  412. }
  413. )}
  414. </Subtext>
  415. );
  416. }
  417. return data.version ? (
  418. <Fragment>
  419. {tct('[author] marked this issue as a regression in [version]', {
  420. author,
  421. version: (
  422. <Version version={data.version} projectId={projectId} tooltipRawVersion />
  423. ),
  424. })}
  425. {subtext}
  426. </Fragment>
  427. ) : (
  428. <Fragment>
  429. {tct('[author] marked this issue as a regression', {
  430. author,
  431. })}
  432. {subtext}
  433. </Fragment>
  434. );
  435. }
  436. case GroupActivityType.CREATE_ISSUE: {
  437. const {data} = activity;
  438. return tct('[author] created an issue on [provider] titled [title]', {
  439. author,
  440. provider: data.provider,
  441. title: <ExternalLink href={data.location}>{data.title}</ExternalLink>,
  442. });
  443. }
  444. case GroupActivityType.UNMERGE_SOURCE: {
  445. const {data} = activity;
  446. const {destination, fingerprints} = data;
  447. return tn(
  448. '%2$s migrated %1$s fingerprint to %3$s',
  449. '%2$s migrated %1$s fingerprints to %3$s',
  450. fingerprints.length,
  451. author,
  452. destination ? (
  453. <Link
  454. to={`${issuesLink}${destination.id}?referrer=group-activity-unmerged-source`}
  455. >
  456. {destination.shortId}
  457. </Link>
  458. ) : (
  459. t('a group')
  460. )
  461. );
  462. }
  463. case GroupActivityType.UNMERGE_DESTINATION: {
  464. const {data} = activity;
  465. const {source, fingerprints} = data;
  466. return tn(
  467. '%2$s migrated %1$s fingerprint from %3$s',
  468. '%2$s migrated %1$s fingerprints from %3$s',
  469. fingerprints.length,
  470. author,
  471. source ? (
  472. <Link
  473. to={`${issuesLink}${source.id}?referrer=group-activity-unmerged-destination`}
  474. >
  475. {source.shortId}
  476. </Link>
  477. ) : (
  478. t('a group')
  479. )
  480. );
  481. }
  482. case GroupActivityType.FIRST_SEEN:
  483. return tct('[author] first saw this issue', {author});
  484. case GroupActivityType.ASSIGNED: {
  485. return <AssignedMessage activity={activity} author={author} />;
  486. }
  487. case GroupActivityType.UNASSIGNED:
  488. return tct('[author] unassigned this issue', {author});
  489. case GroupActivityType.MERGE:
  490. return tn(
  491. '%2$s merged %1$s issue into this issue',
  492. '%2$s merged %1$s issues into this issue',
  493. activity.data.issues.length,
  494. author
  495. );
  496. case GroupActivityType.REPROCESS: {
  497. const {data} = activity;
  498. const {oldGroupId, eventCount} = data;
  499. return tct('[author] reprocessed the events in this issue. [new-events]', {
  500. author,
  501. ['new-events']: (
  502. <Link
  503. to={`/organizations/${organization.slug}/issues/?query=reprocessing.original_issue_id:${oldGroupId}&referrer=group-activity-reprocesses`}
  504. >
  505. {tn('See %s new event', 'See %s new events', eventCount)}
  506. </Link>
  507. ),
  508. });
  509. }
  510. case GroupActivityType.MARK_REVIEWED: {
  511. return tct('[author] marked this issue as reviewed', {
  512. author,
  513. });
  514. }
  515. case GroupActivityType.AUTO_SET_ONGOING: {
  516. return activity.data?.afterDays
  517. ? tct(
  518. '[author] automatically marked this issue as ongoing after [afterDays] days',
  519. {author, afterDays: activity.data.afterDays}
  520. )
  521. : tct('[author] automatically marked this issue as ongoing', {
  522. author,
  523. });
  524. }
  525. case GroupActivityType.SET_ESCALATING: {
  526. return getEscalatingMessage(activity.data);
  527. }
  528. default:
  529. return ''; // should never hit (?)
  530. }
  531. }
  532. return <Fragment>{renderContent()}</Fragment>;
  533. }
  534. export default GroupActivityItem;
  535. const Subtext = styled('div')`
  536. font-size: ${p => p.theme.fontSizeSmall};
  537. `;
  538. const CodeWrapper = styled('div')`
  539. overflow-wrap: anywhere;
  540. font-size: ${p => p.theme.fontSizeSmall};
  541. `;
  542. const StyledRuleSpan = styled('span')`
  543. font-family: ${p => p.theme.text.familyMono};
  544. `;