groupActivityItem.tsx 22 KB

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