groupActivityItem.tsx 19 KB

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