import {Fragment} from 'react';
import styled from '@emotion/styled';
import moment from 'moment-timezone';
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 VersionHoverCard from 'sentry/components/versionHoverCard';
import {t, tct, tn} from 'sentry/locale';
import type {
GroupActivity,
GroupActivityAssigned,
GroupActivitySetEscalating,
GroupActivitySetIgnored,
} from 'sentry/types/group';
import {GroupActivityType} from 'sentry/types/group';
import type {Organization, Team} from 'sentry/types/organization';
import type {Project} from 'sentry/types/project';
import type {User} from 'sentry/types/user';
import useOrganization from 'sentry/utils/useOrganization';
import {isSemverRelease} from 'sentry/utils/versions/isSemverRelease';
export default function getGroupActivityItem(
activity: GroupActivity,
organization: Organization,
project: Project,
author: React.ReactNode,
teams: Team[]
) {
const issuesLink = `/organizations/${organization.slug}/issues/`;
function getIgnoredMessage(data: GroupActivitySetIgnored['data']): {
message: JSX.Element | string | null;
title: JSX.Element | string;
} {
if (data.ignoreDuration) {
return {
title: t('Archived'),
message: tct('by [author] for [duration]', {
author,
duration: ,
}),
};
}
if (data.ignoreCount && data.ignoreWindow) {
return {
title: t('Archived'),
message: tct('by [author] until it happens [count] time(s) in [duration]', {
author,
count: data.ignoreCount,
duration: ,
}),
};
}
if (data.ignoreCount) {
return {
title: t('Archived'),
message: tct('by [author] until it happens [count] time(s)', {
author,
count: data.ignoreCount,
}),
};
}
if (data.ignoreUserCount && data.ignoreUserWindow) {
return {
title: t('Archived'),
message: tct('by [author] until it affects [count] user(s) in [duration]', {
author,
count: data.ignoreUserCount,
duration: ,
}),
};
}
if (data.ignoreUserCount) {
return {
title: t('Archived'),
message: tct('by [author] until it affects [count] user(s)', {
author,
count: data.ignoreUserCount,
}),
};
}
if (data.ignoreUntil) {
return {
title: t('Archived'),
message: tct('by [author] until [date]', {
author,
date: ,
}),
};
}
if (data.ignoreUntilEscalating) {
return {
title: t('Archived'),
message: tct('by [author] until it escalates', {
author,
}),
};
}
return {
title: t('Archived'),
message: tct('by [author] forever', {
author,
}),
};
}
function getAssignedMessage(assignedActivity: GroupActivityAssigned) {
const {data} = assignedActivity;
let assignee: string | User | undefined = 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 {
title: isAutoAssigned ? t('Auto-Assigned') : t('Assigned'),
message: tct('by [author] to [assignee]. [assignedReason]', {
author,
assignee,
assignedReason: data.integration && integrationName[data.integration] && (
{t('Assigned via %s', integrationName[data.integration])}
{data.rule && (
: {data.rule}
)}
),
}),
};
}
function getEscalatingMessage(data: GroupActivitySetEscalating['data']): {
message: JSX.Element | string | null;
title: JSX.Element | string;
} {
if (data.forecast) {
return {
title: t('Escalated'),
message: tct('by [author] 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 {
title: t('Escalated'),
message: tct('by [author] 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 {
title: t('Escalated'),
message: tct('by [author] 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 {
title: t('Escalated'),
message: tct('by [author] 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 {
title: t('Escalated'),
message: tct('by [author] 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 {
title: t('Escalated'),
message: tct('by [author] because [date] passed', {
author,
date: ,
}),
};
}
}
return {
title: t('Escalated'),
message: tct('by [author]', {author}),
}; // should not reach this
}
function renderContent(): {
message: JSX.Element | string | null;
title: JSX.Element | string;
} {
switch (activity.type) {
case GroupActivityType.NOTE:
return {
title: tct('[author]', {author}),
message: activity.data.text,
};
case GroupActivityType.SET_RESOLVED:
let resolvedMessage: JSX.Element;
if ('integration_id' in activity.data && activity.data.integration_id) {
resolvedMessage = tct('by [author] via [integration]', {
integration: (
{activity.data.provider}
),
author,
});
} else {
resolvedMessage = tct('by [author]', {author});
}
return {
title: t('Resolved'),
message: resolvedMessage,
};
case GroupActivityType.SET_RESOLVED_BY_AGE:
return {
title: t('Resolved'),
message: tct('by [author] 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 {
title: t('Resolved'),
message: tct('by [author] in releases greater than [version] [semver]', {
author,
version: ,
semver: isSemverRelease(currentVersion) ? t('(semver)') : t('(non-semver)'),
}),
};
}
const version = activity.data.version;
return {
title: t('Resolved'),
message: version
? tct('by [author] in [version] [semver]', {
author,
version: ,
semver: isSemverRelease(version) ? t('(semver)') : t('(non-semver)'),
})
: tct('by [author] 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 {
title: t('Resolved'),
message: tct(
'by [author] in [version]. This commit was released in [release]',
{
author,
version: (
),
release: (
),
}
),
};
}
if (deployedReleases.length > 1 && activity.data.commit) {
return {
title: t('Resolved'),
message: tct(
'by [author] in [version]. This commit was released in [release] and [otherCount] others',
{
author,
otherCount: deployedReleases.length - 1,
version: (
),
release: (
),
}
),
};
}
if (activity.data.commit) {
return {
title: t('Resolved'),
message: tct('by [author] in [commit]', {
author,
commit: (
),
}),
};
}
return {
title: t('Resolved'),
message: tct('by [author] in a commit', {author}),
};
case GroupActivityType.SET_RESOLVED_IN_PULL_REQUEST: {
const {data} = activity;
const {pullRequest} = data;
return {
title: t('Pull Request Created'),
message: tct(' by [author]: [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 {
title: t('Escalated'),
message: tct(
' by [author] 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 {
title: t('Unresolved'),
message: tct('by [author] via [integration]', {
integration: (
{data.provider}
),
author,
}),
};
}
return {
title: t('Unresolved'),
message: tct('by [author]', {author}),
};
}
case GroupActivityType.SET_IGNORED: {
const {data} = activity;
return getIgnoredMessage(data);
}
case GroupActivityType.SET_PUBLIC:
return {
title: t('Made Public'),
message: tct('by [author]', {author}),
};
case GroupActivityType.SET_PRIVATE:
return {
title: t('Made Private'),
message: tct('by [author]', {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 {
title: t('Regressed'),
message: data.version
? tct('by [author] in [version]. [subtext]', {
author,
version: ,
subtext,
})
: tct('by [author]', {
author,
subtext,
}),
};
}
case GroupActivityType.CREATE_ISSUE: {
const {data} = activity;
return {
title: t('Created Issue'),
message: tct('by [author] on [provider] titled [title]', {
author,
provider: data.provider,
title: {data.title},
}),
};
}
case GroupActivityType.MERGE:
return {
title: t('Merged'),
message: tn(
'%1$s issue into this issue by %2$s',
'%1$s issues into this issue by %2$s',
activity.data.issues.length,
author
),
};
case GroupActivityType.UNMERGE_SOURCE: {
const {data} = activity;
const {destination, fingerprints} = data;
return {
title: t('Unmerged'),
message: tn(
'%1$s fingerprint to %3$s by %2$s',
'%1$s fingerprints to %3$s by %2$s',
fingerprints.length,
author,
destination ? (
{destination.shortId}
) : (
t('a group')
)
),
};
}
case GroupActivityType.UNMERGE_DESTINATION: {
const {data} = activity;
const {source, fingerprints} = data;
return {
title: t('Unmerged'),
message: tn(
'%1$s fingerprint to %3$s by %2$s',
'%1$s fingerprints to %3$s by %2$s',
fingerprints.length,
author,
source ? (
{source.shortId}
) : (
t('a group')
)
),
};
}
case GroupActivityType.FIRST_SEEN:
if (activity.data.priority) {
return {
title: t('First Seen'),
message: tct('Marked as [priority] priority', {
priority: activity.data.priority,
}),
};
}
return {
title: t('First Seen'),
message: null,
};
case GroupActivityType.ASSIGNED: {
return getAssignedMessage(activity);
}
case GroupActivityType.UNASSIGNED:
return {
title: t('Unassigned'),
message: tct('by [author]', {author}),
};
case GroupActivityType.REPROCESS: {
const {data} = activity;
const {oldGroupId, eventCount} = data;
return {
title: t('Resprocessed Events'),
message: tct('by [author]. [new-events]', {
author,
['new-events']: (
{tn('See %s new event', 'See %s new events', eventCount)}
),
}),
};
}
case GroupActivityType.MARK_REVIEWED: {
return {
title: t('Reviewed'),
message: tct('by [author]', {
author,
}),
};
}
case GroupActivityType.AUTO_SET_ONGOING: {
return {
title: t('Marked as Ongoing'),
message: activity.data?.afterDays
? tct('automatically by [author] after [afterDays] days', {
author,
afterDays: activity.data.afterDays,
})
: tct('automatically by [author]', {
author,
}),
};
}
case GroupActivityType.SET_ESCALATING: {
return getEscalatingMessage(activity.data);
}
case GroupActivityType.SET_PRIORITY: {
const {data} = activity;
switch (data.reason) {
case 'escalating':
return {
title: t('Priority Updated'),
message: tct('by [author] to be [priority] after it escalated', {
author,
priority: data.priority,
}),
};
case 'ongoing':
return {
title: t('Priority Updated'),
message: tct(
'by [author] to be [priority] after it was marked as ongoing',
{author, priority: data.priority}
),
};
default:
return {
title: t('Priority Updated'),
message: tct('by [author] to be [priority]', {
author,
priority: data.priority,
}),
};
}
}
case GroupActivityType.DELETED_ATTACHMENT:
return {
title: t('Attachment Deleted'),
message: tct('by [author]', {author}),
};
default:
return {title: '', message: ''}; // should never hit (?)
}
}
return renderContent();
}
function ActivityRelease({project, version}: {project: Project; version: string}) {
const organization = useOrganization();
return (
);
}
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};
`;
const ReleaseVersion = styled(Version)`
color: ${p => p.theme.gray300};
text-decoration: underline;
`;