groupActivityItem.tsx 14 KB

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