groupActivityItem.tsx 18 KB

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