import {Fragment} from 'react'; import styled from '@emotion/styled'; import moment from 'moment'; import CommitLink from 'sentry/components/commitLink'; import DateTime from 'sentry/components/dateTime'; import Duration from 'sentry/components/duration'; import ExternalLink from 'sentry/components/links/externalLink'; import Link from 'sentry/components/links/link'; import PullRequestLink from 'sentry/components/pullRequestLink'; import Version from 'sentry/components/version'; import {t, tct, tn} from 'sentry/locale'; import type { Group, GroupActivity, GroupActivityAssigned, GroupActivitySetEscalating, GroupActivitySetIgnored, Organization, Project, User, } from 'sentry/types'; import {GroupActivityType} from 'sentry/types'; import {isSemverRelease} from 'sentry/utils/formatters'; import {useTeamsById} from 'sentry/utils/useTeamsById'; interface AssignedMessageProps { activity: GroupActivityAssigned; author: React.ReactNode; issueType: string; } function AssignedMessage({activity, author, issueType}: AssignedMessageProps) { const {data} = activity; let assignee: string | User | undefined = undefined; const {teams} = useTeamsById( data.assigneeType === 'team' ? {ids: [data.assignee]} : undefined ); if (data.assigneeType === 'team') { const team = teams.find(({id}) => id === data.assignee); // TODO: could show a loading indicator if the team is loading assignee = team ? `#${team.slug}` : ''; } else if (activity.user && data.assignee === activity.user.id) { assignee = t('themselves'); } else if (data.assigneeType === 'user' && data.assigneeEmail) { assignee = data.assigneeEmail; } else { assignee = t('an unknown user'); } const isAutoAssigned = ['projectOwnership', 'codeowners', 'suspectCommitter'].includes( data.integration as string ); const integrationName: Record< NonNullable, string > = { msteams: t('Microsoft Teams'), slack: t('Slack'), projectOwnership: t('Ownership Rule'), codeowners: t('Codeowners Rule'), suspectCommitter: t('Suspect Commit'), }; return (
{tct('[author] [action] this [issueType] to [assignee]', { action: isAutoAssigned ? t('auto-assigned') : t('assigned'), author, assignee, issueType, })}
{data.integration && integrationName[data.integration] && ( {t('Assigned via %s', integrationName[data.integration])} {data.rule && ( : {data.rule} )} )}
); } interface GroupActivityItemProps { activity: GroupActivity; author: React.ReactNode; group: Group; organization: Organization; projectId: Project['id']; } function GroupActivityItem({ activity, organization, projectId, author, group, }: GroupActivityItemProps) { const issuesLink = `/organizations/${organization.slug}/issues/`; const isFeedback = (group.issueCategory as string) === 'feedback'; const issueType = isFeedback ? t('feedback') : t('issue'); function getIgnoredMessage(data: GroupActivitySetIgnored['data']) { const archived = t('archived'); if (data.ignoreDuration) { return tct('[author] [action] this issue for [duration]', { author, action: archived, duration: , }); } if (data.ignoreCount && data.ignoreWindow) { return tct( '[author] [action] this issue until it happens [count] time(s) in [duration]', { author, action: archived, count: data.ignoreCount, duration: , } ); } if (data.ignoreCount) { return tct('[author] [action] this issue until it happens [count] time(s)', { author, action: archived, count: data.ignoreCount, }); } if (data.ignoreUserCount && data.ignoreUserWindow) { return tct( '[author] [action] this issue until it affects [count] user(s) in [duration]', { author, action: archived, count: data.ignoreUserCount, duration: , } ); } if (data.ignoreUserCount) { return tct('[author] [action] this issue until it affects [count] user(s)', { author, action: archived, count: data.ignoreUserCount, }); } if (data.ignoreUntil) { return tct('[author] [action] this issue until [date]', { author, action: archived, date: , }); } if (data.ignoreUntilEscalating) { return tct('[author] archived this issue until it escalates', { author, }); } return isFeedback ? tct('[author] marked this feedback as spam', { author, }) : tct('[author] [action] this issue forever', { author, action: archived, }); } function getEscalatingMessage(data: GroupActivitySetEscalating['data']) { if (data.forecast) { return tct( '[author] flagged this issue as escalating because over [forecast] [event] happened in an hour', { author, forecast: data.forecast, event: data.forecast === 1 ? 'event' : 'events', } ); } if (data.expired_snooze) { if (data.expired_snooze.count && data.expired_snooze.window) { return tct( '[author] flagged this issue as escalating because [count] [event] happened in [duration]', { author, count: data.expired_snooze.count, event: data.expired_snooze.count === 1 ? 'event' : 'events', duration: , } ); } if (data.expired_snooze.count) { return tct( '[author] flagged this issue as escalating because [count] [event] happened', { author, count: data.expired_snooze.count, event: data.expired_snooze.count === 1 ? 'event' : 'events', } ); } if (data.expired_snooze.user_count && data.expired_snooze.user_window) { return tct( '[author] flagged this issue as escalating because [count] [user] affected in [duration]', { author, count: data.expired_snooze.user_count, user: data.expired_snooze.user_count === 1 ? 'user was' : 'users were', duration: , } ); } if (data.expired_snooze.user_count) { return tct( '[author] flagged this issue as escalating because [count] [user] affected', { author, count: data.expired_snooze.user_count, user: data.expired_snooze.user_count === 1 ? 'user was' : 'users were', } ); } if (data.expired_snooze.until) { return tct('[author] flagged this issue as escalating because [date] passed', { author, date: , }); } } return tct('[author] flagged this issue as escalating', {author}); // should not reach this } function renderContent() { switch (activity.type) { case GroupActivityType.NOTE: return tct('[author] left a comment', {author}); case GroupActivityType.SET_RESOLVED: if ('integration_id' in activity.data && activity.data.integration_id) { return tct('[author] marked this [issueType] as resolved via [integration]', { integration: ( {activity.data.provider} ), author, issueType, }); } return tct('[author] marked this [issueType] as resolved', {author, issueType}); case GroupActivityType.SET_RESOLVED_BY_AGE: return tct('[author] marked this issue as resolved due to inactivity', { author, }); case GroupActivityType.SET_RESOLVED_IN_RELEASE: // Resolved in the next release if ('current_release_version' in activity.data) { const currentVersion = activity.data.current_release_version; return tct( '[author] marked this issue as resolved in releases greater than [version] [semver]', { author, version: ( ), semver: isSemverRelease(currentVersion) ? t('(semver)') : t('(non-semver)'), } ); } const version = activity.data.version; return version ? tct('[author] marked this issue as resolved in [version] [semver]', { author, version: ( ), semver: isSemverRelease(version) ? t('(semver)') : t('(non-semver)'), }) : tct('[author] marked this issue as resolved in the upcoming release', { author, }); case GroupActivityType.SET_RESOLVED_IN_COMMIT: const deployedReleases = (activity.data.commit?.releases || []) .filter(r => r.dateReleased !== null) .sort( (a, b) => moment(a.dateReleased).valueOf() - moment(b.dateReleased).valueOf() ); if (deployedReleases.length === 1 && activity.data.commit) { return tct( '[author] marked this issue as resolved in [version] [break]This commit was released in [release]', { author, version: ( ), break:
, release: ( ), } ); } if (deployedReleases.length > 1 && activity.data.commit) { return tct( '[author] marked this issue as resolved in [version] [break]This commit was released in [release] and [otherCount] others', { author, otherCount: deployedReleases.length - 1, version: ( ), break:
, release: ( ), } ); } if (activity.data.commit) { return tct('[author] marked this issue as resolved in [commit]', { author, commit: ( ), }); } return tct('[author] marked this issue as resolved in a commit', {author}); case GroupActivityType.SET_RESOLVED_IN_PULL_REQUEST: { const {data} = activity; const {pullRequest} = data; return tct('[author] has created a PR for this issue: [pullRequest]', { author, pullRequest: pullRequest ? ( ) : ( t('PR not available') ), }); } case GroupActivityType.SET_UNRESOLVED: { // TODO(nisanthan): Remove after migrating records to SET_ESCALATING const {data} = activity; if ('forecast' in data && data.forecast) { return tct( '[author] flagged this issue as escalating because over [forecast] [event] happened in an hour', { author, forecast: data.forecast, event: data.forecast === 1 ? 'event' : 'events', } ); } if ('integration_id' in data && data.integration_id) { return tct('[author] marked this [issueType] as unresolved via [integration]', { integration: ( {data.provider} ), author, issueType, }); } return tct('[author] marked this [issueType] as unresolved', {author, issueType}); } case GroupActivityType.SET_IGNORED: { const {data} = activity; return getIgnoredMessage(data); } case GroupActivityType.SET_PUBLIC: return tct('[author] made this issue public', {author}); case GroupActivityType.SET_PRIVATE: return tct('[author] made this issue private', {author}); case GroupActivityType.SET_REGRESSION: { const {data} = activity; let subtext: React.ReactNode = null; if (data.version && data.resolved_in_version && 'follows_semver' in data) { subtext = ( {tct( '[regressionVersion] is greater than or equal to [resolvedVersion] compared via [comparison]', { regressionVersion: ( ), resolvedVersion: ( ), comparison: data.follows_semver ? t('semver') : t('release date'), } )} ); } return data.version ? ( {tct('[author] marked this issue as a regression in [version]', { author, version: ( ), })} {subtext} ) : ( {tct('[author] marked this issue as a regression', { author, })} {subtext} ); } case GroupActivityType.CREATE_ISSUE: { const {data} = activity; return tct('[author] created an issue on [provider] titled [title]', { author, provider: data.provider, title: {data.title}, }); } case GroupActivityType.UNMERGE_SOURCE: { const {data} = activity; const {destination, fingerprints} = data; return tn( '%2$s migrated %1$s fingerprint to %3$s', '%2$s migrated %1$s fingerprints to %3$s', fingerprints.length, author, destination ? ( {destination.shortId} ) : ( t('a group') ) ); } case GroupActivityType.UNMERGE_DESTINATION: { const {data} = activity; const {source, fingerprints} = data; return tn( '%2$s migrated %1$s fingerprint from %3$s', '%2$s migrated %1$s fingerprints from %3$s', fingerprints.length, author, source ? ( {source.shortId} ) : ( t('a group') ) ); } case GroupActivityType.FIRST_SEEN: if ( organization.features.includes('issue-priority-ui') && activity.data.priority ) { return tct( '[author] first saw this issue and marked it as [priority] priority', {author, priority: activity.data.priority} ); } return tct('[author] first saw this issue', {author}); case GroupActivityType.ASSIGNED: { return ( ); } case GroupActivityType.UNASSIGNED: return tct('[author] unassigned this [issueType]', {author, issueType}); case GroupActivityType.MERGE: return tn( '%2$s merged %1$s issue into this issue', '%2$s merged %1$s issues into this issue', activity.data.issues.length, author ); case GroupActivityType.REPROCESS: { const {data} = activity; const {oldGroupId, eventCount} = data; return tct('[author] reprocessed the events in this issue. [new-events]', { author, ['new-events']: ( {tn('See %s new event', 'See %s new events', eventCount)} ), }); } case GroupActivityType.MARK_REVIEWED: { return tct('[author] marked this issue as reviewed', { author, }); } case GroupActivityType.AUTO_SET_ONGOING: { return activity.data?.afterDays ? tct( '[author] automatically marked this issue as ongoing after [afterDays] days', {author, afterDays: activity.data.afterDays} ) : tct('[author] automatically marked this issue as ongoing', { author, }); } case GroupActivityType.SET_ESCALATING: { return getEscalatingMessage(activity.data); } case GroupActivityType.SET_PRIORITY: { const {data} = activity; switch (data.reason) { case 'escalating': return tct( '[author] updated the priority value of this issue to be [priority] after it escalated', {author, priority: data.priority} ); case 'ongoing': return tct( '[author] updated the priority value of this issue to be [priority] after it was marked as ongoing', {author, priority: data.priority} ); default: return tct( '[author] updated the priority value of this issue to be [priority]', {author, priority: data.priority} ); } } default: return ''; // should never hit (?) } } return {renderContent()}; } export default GroupActivityItem; const Subtext = styled('div')` font-size: ${p => p.theme.fontSizeSmall}; `; const CodeWrapper = styled('div')` overflow-wrap: anywhere; font-size: ${p => p.theme.fontSizeSmall}; `; const StyledRuleSpan = styled('span')` font-family: ${p => p.theme.text.familyMono}; `;