groupActivityItem.tsx 16 KB

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