groupActivityItem.tsx 20 KB

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