groupActivityItem.tsx 11 KB

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