groupActivityItem.tsx 20 KB

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