groupActivityItem.tsx 12 KB

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