groupActivityItem.tsx 21 KB

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