From 798365fe54b090fd886465ccac6dbff8bbb6d133 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 6 Aug 2026 14:44:23 -0700 Subject: [PATCH 1/2] feat(issues): collapse repetitive issue activity Roll older status flapping and priority event runs into expandable activity rows while keeping the newest resolve and regress cycle visible. Preserve the underlying event count in the sidebar and gate the behavior behind the organization feature. Co-Authored-By: Codex --- .../activityLineItem/activityFeedItem.spec.ts | 180 ++++++++++++++++++ .../activityLineItem/activityFeedItem.ts | 165 ++++++++++++++++ .../collapsedStatusActivityRow.tsx | 72 +++++++ .../deduplicatePullRequestActivities.ts | 69 +++++++ .../activityLineItem/progressMarker/index.tsx | 10 + .../activitySection/index.spec.tsx | 82 ++++++++ .../issueDetails/activitySection/index.tsx | 156 +++++---------- 7 files changed, 630 insertions(+), 104 deletions(-) create mode 100644 static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.spec.ts create mode 100644 static/app/views/issueDetails/activitySection/activityLineItem/collapsedStatusActivityRow.tsx create mode 100644 static/app/views/issueDetails/activitySection/activityLineItem/deduplicatePullRequestActivities.ts diff --git a/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.spec.ts b/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.spec.ts new file mode 100644 index 000000000000..552ec732a33c --- /dev/null +++ b/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.spec.ts @@ -0,0 +1,180 @@ +import {ActivityFeedFixture} from 'sentry-fixture/activityFeed'; +import {UserFixture} from 'sentry-fixture/user'; + +import {GroupActivityType, PriorityLevel} from 'sentry/types/group'; + +import { + type ActivityFeedItem, + collapseFlappingStatusActivities, + countActivityFeedEvents, + type DisplayedActivityFeedItem, +} from './activityFeedItem'; + +type ActivityFixtureParams = NonNullable[0]>; + +function activity(params: ActivityFixtureParams): ActivityFeedItem { + return { + type: 'activity', + activity: {...ActivityFeedFixture(params), user: params.user ?? null}, + }; +} + +function regression(): ActivityFeedItem { + return activity({type: GroupActivityType.SET_REGRESSION, data: {}}); +} + +function resolved(): ActivityFeedItem { + return activity({type: GroupActivityType.SET_RESOLVED, data: {}}); +} + +function ongoing(): ActivityFeedItem { + return activity({ + type: GroupActivityType.AUTO_SET_ONGOING, + data: {after_days: 7}, + }); +} + +function resolvedByAge(): ActivityFeedItem { + return activity({type: GroupActivityType.SET_RESOLVED_BY_AGE, data: {age: 7}}); +} + +function expectCollapsedActivities( + item: DisplayedActivityFeedItem | undefined, + expectedActivities: ActivityFeedItem[] +) { + expect(item).toMatchObject({ + type: 'collapsed_status_activities', + activities: expectedActivities, + }); +} + +describe('collapseFlappingStatusActivities', () => { + it('keeps the newest pair visible and merges adjacent older pairs into one rollup', () => { + const newestRegression = regression(); + const newestResolution = resolved(); + const olderRun = [ + activity({ + type: GroupActivityType.SET_PRIORITY, + data: {priority: PriorityLevel.HIGH, reason: 'issue_platform'}, + }), + regression(), + resolved(), + ongoing(), + regression(), + resolved(), + ]; + const result = collapseFlappingStatusActivities([ + newestRegression, + newestResolution, + ...olderRun, + ]); + + expect(result.slice(0, 2)).toEqual([newestRegression, newestResolution]); + expect(result).toHaveLength(3); + expectCollapsedActivities(result[2], olderRun); + expect(countActivityFeedEvents(result)).toBe(2 + olderRun.length); + }); + + it('keeps the newest pair visible when newer user activity exists', () => { + const manualReopen = activity({ + type: GroupActivityType.SET_UNRESOLVED, + data: {}, + user: UserFixture(), + }); + const newestRun = [ongoing(), regression(), resolvedByAge()]; + const olderRun = [ongoing(), regression(), resolvedByAge()]; + const result = collapseFlappingStatusActivities([ + manualReopen, + ...newestRun, + ...olderRun, + ]); + + expect(result.slice(0, 4)).toEqual([manualReopen, ...newestRun]); + expect(result).toHaveLength(5); + expectCollapsedActivities(result[4], olderRun); + }); + + it('recognizes a resolved status transition', () => { + const newestPair = [regression(), resolved()]; + const flappingPair = [ + regression(), + activity({ + type: GroupActivityType.SET_RESOLVED_IN_RELEASE, + data: {version: '1.0.0'}, + }), + ]; + const result = collapseFlappingStatusActivities([...newestPair, ...flappingPair]); + + expectCollapsedActivities(result[2], flappingPair); + }); + + it('absorbs automatic lifecycle and priority activity into a flapping run', () => { + const automaticRun = [ + resolved(), + activity({type: GroupActivityType.SET_UNRESOLVED, data: {}}), + activity({ + type: GroupActivityType.SET_PRIORITY, + data: {priority: PriorityLevel.HIGH, reason: 'issue_platform'}, + }), + ongoing(), + activity({ + type: GroupActivityType.SET_PRIORITY, + data: {priority: PriorityLevel.MEDIUM, reason: 'ongoing'}, + }), + activity({type: GroupActivityType.SET_ESCALATING, data: {}}), + activity({ + type: GroupActivityType.SET_PRIORITY, + data: {priority: PriorityLevel.HIGH, reason: 'escalating'}, + }), + regression(), + ]; + const result = collapseFlappingStatusActivities([ + activity({ + type: GroupActivityType.SET_UNRESOLVED, + data: {}, + user: UserFixture(), + }), + ...automaticRun, + ]); + + expectCollapsedActivities(result[1], automaticRun); + }); + + it('does not hide an incomplete automatic run', () => { + const activities = [ + resolved(), + ongoing(), + activity({ + type: GroupActivityType.SET_PRIORITY, + data: {priority: PriorityLevel.MEDIUM, reason: 'ongoing'}, + }), + ]; + + expect(collapseFlappingStatusActivities(activities)).toEqual(activities); + }); + + it('keeps other priority activity as a boundary before an older rollup', () => { + const manualReopen = activity({ + type: GroupActivityType.SET_UNRESOLVED, + data: {}, + user: UserFixture(), + }); + const otherPriority = activity({ + type: GroupActivityType.SET_PRIORITY, + data: {priority: PriorityLevel.LOW, reason: 'other'}, + }); + const newerRun = [regression(), resolved()]; + const olderRun = [regression(), resolved()]; + const result = collapseFlappingStatusActivities([ + manualReopen, + ...newerRun, + otherPriority, + ...olderRun, + ]); + + expect(result.slice(0, 3)).toEqual([manualReopen, ...newerRun]); + expect(result).toHaveLength(5); + expect(result[3]).toBe(otherPriority); + expectCollapsedActivities(result[4], olderRun); + }); +}); diff --git a/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.ts b/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.ts index 3c537e5b8ead..59c4d3b7aa2c 100644 --- a/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.ts +++ b/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.ts @@ -1,5 +1,6 @@ import type {GroupActivity} from 'sentry/types/group'; import {GroupActivityType, SEER_ACTIVITY_TYPES} from 'sentry/types/group'; +import {deduplicatePullRequestActivities} from 'sentry/views/issueDetails/activitySection/activityLineItem/deduplicatePullRequestActivities'; type ActivityOfType = Extract< GroupActivity, @@ -33,6 +34,73 @@ export type ActivityFeedItem = } | CollapsedSeerActivity; +export interface CollapsedStatusActivity { + activities: ActivityFeedItem[]; + /** The first rolled-up item provides a stable key for the summary row. */ + activity: GroupActivity; + type: 'collapsed_status_activities'; +} + +export type DisplayedActivityFeedItem = ActivityFeedItem | CollapsedStatusActivity; + +/** Counts each status activity inside a rollup rather than counting the rollup as one row. */ +export function countActivityFeedEvents(items: DisplayedActivityFeedItem[]): number { + return items.reduce( + (count, item) => + count + (item.type === 'collapsed_status_activities' ? item.activities.length : 1), + 0 + ); +} + +const RESOLUTION_ACTIVITY_TYPES = new Set([ + GroupActivityType.SET_RESOLVED, + GroupActivityType.SET_RESOLVED_BY_AGE, + GroupActivityType.SET_RESOLVED_IN_RELEASE, + GroupActivityType.SET_RESOLVED_IN_COMMIT, +]); +// SET_RESOLVED_IN_PULL_REQUEST is intentionally absent: despite its backend name, it records +// that an issue was referenced in a pull request rather than a resolved status transition. + +// These are the complete set of backend reasons for automatic priority changes. Priority +// activities without one of these reasons should remain visible boundaries. +const FLAPPING_PRIORITY_REASONS = new Set(['escalating', 'issue_platform', 'ongoing']); + +function isResolutionActivity(activity: ActivityFeedItem): boolean { + return RESOLUTION_ACTIVITY_TYPES.has(activity.activity.type); +} + +function isFlappingStatusActivity(activity: ActivityFeedItem): boolean { + if (isResolutionActivity(activity)) { + return true; + } + + const groupActivity = activity.activity; + switch (groupActivity.type) { + case GroupActivityType.SET_REGRESSION: + case GroupActivityType.AUTO_SET_ONGOING: + case GroupActivityType.SET_ESCALATING: + return true; + case GroupActivityType.SET_UNRESOLVED: + // A user-authored reopen is meaningful history and splits automatic flapping runs. + return !groupActivity.user; + case GroupActivityType.SET_PRIORITY: + return FLAPPING_PRIORITY_REASONS.has(groupActivity.data.reason); + default: + return false; + } +} + +/** + * A run is only noise once it contains both sides of a resolve/regress flap. This keeps isolated + * automatic lifecycle updates visible. + */ +function isCollapsibleStatusRun(run: ActivityFeedItem[]): boolean { + return ( + run.some(item => item.activity.type === GroupActivityType.SET_REGRESSION) && + run.some(isResolutionActivity) + ); +} + function getSeerRunId(activity: GroupActivity): number | undefined { if (!('run_id' in activity.data)) { return undefined; @@ -150,3 +218,100 @@ export function collapseSeerActivityPairs( return collapsedActivities; } + +/** + * - Keep the newest adjacent regression/resolution pair and everything newer visible. + * - Treat unrelated or user-authored activity as a boundary between runs. + * - Collapse each remaining consecutive run only when it contains both a resolution and a + * regression; keep incomplete runs visible. + * - Match loosely by activity type and feed order because these events have no shared identifier. + */ +export function collapseFlappingStatusActivities( + activities: ActivityFeedItem[] +): DisplayedActivityFeedItem[] { + const latestPairIndex = activities.findIndex((activity, index) => { + const nextActivity = activities[index + 1]; + return ( + activity.activity.type === GroupActivityType.SET_REGRESSION && + nextActivity !== undefined && + isResolutionActivity(nextActivity) + ); + }); + + // Preserve everything through the newest pair; +2 includes both the regression and resolution. + const protectedActivityCount = latestPairIndex === -1 ? 0 : latestPairIndex + 2; + const displayedActivities: DisplayedActivityFeedItem[] = activities.slice( + 0, + protectedActivityCount + ); + + for (let index = protectedActivityCount; index < activities.length;) { + const activity = activities[index]; + if (!activity) { + break; + } + + if (!isFlappingStatusActivity(activity)) { + displayedActivities.push(activity); + index += 1; + continue; + } + + const run: [ActivityFeedItem, ...ActivityFeedItem[]] = [activity]; + index += 1; + while (index < activities.length) { + const runActivity = activities[index]; + if (!runActivity || !isFlappingStatusActivity(runActivity)) { + break; + } + run.push(runActivity); + index += 1; + } + + if (isCollapsibleStatusRun(run)) { + displayedActivities.push({ + type: 'collapsed_status_activities', + activity: run[0].activity, + activities: run, + }); + } else { + displayedActivities.push(...run); + } + } + + return displayedActivities; +} + +interface BuildActivityFeedItemsOptions { + activities: GroupActivity[]; + showSeerActivities: boolean; + showStatusFlappingRollups: boolean; + filterComments?: boolean; +} + +export function buildActivityFeedItems({ + activities, + filterComments, + showSeerActivities, + showStatusFlappingRollups, +}: BuildActivityFeedItemsOptions): DisplayedActivityFeedItem[] { + // Apply Seer visibility first so a hidden Seer PR activity cannot be attached as the actor + // for an otherwise visible pull request activity during deduplication. + const visibleActivities = showSeerActivities + ? activities + : activities.filter(item => !SEER_ACTIVITY_TYPES.has(item.type)); + const {activities: deduplicatedActivities, actorActivityById} = + deduplicatePullRequestActivities(visibleActivities); + const filteredActivities = deduplicatedActivities.filter( + item => !filterComments || item.type === GroupActivityType.NOTE + ); + const activityFeedItems = collapseSeerActivityPairs(filteredActivities).map(item => { + const actorActivity = actorActivityById.get(item.activity.id); + return item.type === 'activity' && actorActivity ? {...item, actorActivity} : item; + }); + + // Collapse status flapping last so expanding a rollup restores the fully processed feed items. + return showStatusFlappingRollups + ? collapseFlappingStatusActivities(activityFeedItems) + : activityFeedItems; +} diff --git a/static/app/views/issueDetails/activitySection/activityLineItem/collapsedStatusActivityRow.tsx b/static/app/views/issueDetails/activitySection/activityLineItem/collapsedStatusActivityRow.tsx new file mode 100644 index 000000000000..6733fd23a510 --- /dev/null +++ b/static/app/views/issueDetails/activitySection/activityLineItem/collapsedStatusActivityRow.tsx @@ -0,0 +1,72 @@ +import {Fragment, type ReactNode, useState} from 'react'; + +import {Button} from '@sentry/scraps/button'; +import {Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {t, tn} from 'sentry/locale'; +import {ActivityLineRow} from 'sentry/views/issueDetails/activitySection/activityLineItem/layout'; +import {ActivityLineDotMarker} from 'sentry/views/issueDetails/activitySection/activityLineItem/progressMarker'; + +interface CollapsedStatusActivityRowProps { + children: ReactNode; + eventCount: number; +} + +interface StatusActivityToggleRowProps { + label: ReactNode; + onClick: () => void; +} + +export function CollapsedStatusActivityRow({ + children, + eventCount, +}: CollapsedStatusActivityRowProps) { + const [isExpanded, setIsExpanded] = useState(false); + + if (!isExpanded) { + return ( + setIsExpanded(true)} + /> + ); + } + + return ( + + {children} + setIsExpanded(false)} + /> + + ); +} + +function StatusActivityToggleRow({label, onClick}: StatusActivityToggleRowProps) { + return ( + + + + + + + ); +} diff --git a/static/app/views/issueDetails/activitySection/activityLineItem/deduplicatePullRequestActivities.ts b/static/app/views/issueDetails/activitySection/activityLineItem/deduplicatePullRequestActivities.ts new file mode 100644 index 000000000000..36f0d0cc6a9a --- /dev/null +++ b/static/app/views/issueDetails/activitySection/activityLineItem/deduplicatePullRequestActivities.ts @@ -0,0 +1,69 @@ +import type {GroupActivity} from 'sentry/types/group'; +import {GroupActivityType} from 'sentry/types/group'; + +function isDuplicatePullRequestActivity( + activity: GroupActivity, + adjacentActivity: GroupActivity | undefined +): boolean { + switch (activity.type) { + case GroupActivityType.REFERENCED_IN_COMMIT: { + if (adjacentActivity?.type !== GroupActivityType.PULL_REQUEST_MERGED) { + return false; + } + + const pullRequest = activity.data.commit?.pullRequest; + const adjacentPullRequest = adjacentActivity.data.pullRequest; + if (!pullRequest || !adjacentPullRequest) { + return false; + } + + return ( + pullRequest.id === adjacentPullRequest.id && + pullRequest.repository.id === adjacentPullRequest.repository.id + ); + } + case GroupActivityType.SEER_PR_CREATED: { + if (adjacentActivity?.type !== GroupActivityType.SET_RESOLVED_IN_PULL_REQUEST) { + return false; + } + + const adjacentPullRequest = adjacentActivity.data.pullRequest; + if (!adjacentPullRequest) { + return false; + } + + return Boolean( + activity.data.pull_requests?.some( + pullRequest => + pullRequest.pull_request.pr_url === adjacentPullRequest.externalUrl + ) + ); + } + default: + return false; + } +} + +/** + * Removes redundant pull request activity while preserving a removed Seer event as the actor + * for the pull request activity that remains. + */ +export function deduplicatePullRequestActivities(activities: GroupActivity[]): { + activities: GroupActivity[]; + actorActivityById: Map; +} { + const actorActivityById = new Map(); + const filteredActivities = activities.filter((activity, index) => { + const duplicateActivity = [activities[index - 1], activities[index + 1]].find( + adjacentActivity => isDuplicatePullRequestActivity(activity, adjacentActivity) + ); + + if (activity.type === GroupActivityType.SEER_PR_CREATED && duplicateActivity) { + actorActivityById.set(duplicateActivity.id, activity); + } + + return !duplicateActivity; + }); + + return {activities: filteredActivities, actorActivityById}; +} diff --git a/static/app/views/issueDetails/activitySection/activityLineItem/progressMarker/index.tsx b/static/app/views/issueDetails/activitySection/activityLineItem/progressMarker/index.tsx index 4ed9dd669d29..50a3cf203cb2 100644 --- a/static/app/views/issueDetails/activitySection/activityLineItem/progressMarker/index.tsx +++ b/static/app/views/issueDetails/activitySection/activityLineItem/progressMarker/index.tsx @@ -35,6 +35,16 @@ export function ActivityLineMarker({ ); } +export function ActivityLineDotMarker() { + return ( + + + + + + ); +} + function ActivityLineDot() { return ; } diff --git a/static/app/views/issueDetails/activitySection/index.spec.tsx b/static/app/views/issueDetails/activitySection/index.spec.tsx index f7425d8cabc2..ec965a41a079 100644 --- a/static/app/views/issueDetails/activitySection/index.spec.tsx +++ b/static/app/views/issueDetails/activitySection/index.spec.tsx @@ -634,6 +634,88 @@ describe('ActivitySection', () => { expect(screen.getByText(/after 2 days of inactivity/)).toBeInTheDocument(); }); + const statusFlappingRollupFeature = 'issue-activity-status-flapping-rollup'; + + function makeFlappingGroup(id: string) { + return GroupFixture({ + id, + activity: [ + { + type: GroupActivityType.SET_REGRESSION, + id: `${id}-regressed-2`, + dateCreated: '2020-01-01T06:00:00Z', + data: {}, + }, + { + type: GroupActivityType.SET_RESOLVED, + id: `${id}-resolved-2`, + dateCreated: '2020-01-01T05:00:00Z', + data: {}, + }, + { + type: GroupActivityType.SET_REGRESSION, + id: `${id}-regressed-1`, + dateCreated: '2020-01-01T04:00:00Z', + data: {}, + }, + { + type: GroupActivityType.SET_RESOLVED, + id: `${id}-resolved-1`, + dateCreated: '2020-01-01T03:00:00Z', + data: {}, + }, + ], + project, + }); + } + + it('expands and collapses a status-flapping rollup when enabled', async () => { + const flappingGroup = makeFlappingGroup('1348'); + + render( + + + , + { + organization: OrganizationFixture({features: [statusFlappingRollupFeature]}), + } + ); + + expect(screen.getAllByText('Regressed')).toHaveLength(1); + expect(screen.getAllByText('Resolved')).toHaveLength(1); + expect(screen.getAllByRole('img', {name: 'Activity update'})).toHaveLength(3); + + await userEvent.click(screen.getByRole('button', {name: 'Show 2 more'})); + + expect(screen.queryByRole('button', {name: 'Show 2 more'})).not.toBeInTheDocument(); + expect(screen.getAllByText('Regressed')).toHaveLength(2); + expect(screen.getAllByText('Resolved')).toHaveLength(2); + + await userEvent.click(screen.getByRole('button', {name: 'Hide 2 events'})); + + expect(screen.getByRole('button', {name: 'Show 2 more'})).toBeInTheDocument(); + expect(screen.getAllByText('Regressed')).toHaveLength(1); + expect(screen.getAllByText('Resolved')).toHaveLength(1); + }); + + it('rolls up status flapping and counts its events in the sidebar', () => { + const flappingGroup = makeFlappingGroup('1350'); + + render( + + + , + { + organization: OrganizationFixture({features: [statusFlappingRollupFeature]}), + } + ); + + expect(screen.getByRole('button', {name: 'Show 2 more'})).toBeInTheDocument(); + expect(screen.getByText('View 2 more')).toBeInTheDocument(); + expect(screen.getAllByText('Regressed')).toHaveLength(1); + expect(screen.getAllByText('Resolved')).toHaveLength(1); + }); + it('renders note and allows for edit', async () => { jest.spyOn(indicators, 'addSuccessMessage'); diff --git a/static/app/views/issueDetails/activitySection/index.tsx b/static/app/views/issueDetails/activitySection/index.tsx index 8c9eb9932452..257e136201d2 100644 --- a/static/app/views/issueDetails/activitySection/index.tsx +++ b/static/app/views/issueDetails/activitySection/index.tsx @@ -9,21 +9,18 @@ import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {TimeSince} from 'sentry/components/timeSince'; import {IconEllipsis} from 'sentry/icons'; import {t} from 'sentry/locale'; -import { - GroupActivityType, - SEER_ACTIVITY_TYPES, - type Group, - type GroupActivity, -} from 'sentry/types/group'; +import type {Group, GroupActivity} from 'sentry/types/group'; import {trackAnalytics} from 'sentry/utils/analytics'; import {uniqueId} from 'sentry/utils/guid'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; import {ActivityLine} from 'sentry/views/issueDetails/activitySection/activityLineItem'; import { - collapseSeerActivityPairs, - type ActivityFeedItem, + buildActivityFeedItems, + countActivityFeedEvents, + type DisplayedActivityFeedItem, } from 'sentry/views/issueDetails/activitySection/activityLineItem/activityFeedItem'; +import {CollapsedStatusActivityRow} from 'sentry/views/issueDetails/activitySection/activityLineItem/collapsedStatusActivityRow'; import {ActivityLineList} from 'sentry/views/issueDetails/activitySection/activityLineItem/layout'; import { ActivityLineNote, @@ -41,7 +38,7 @@ interface ActivityFeedRowProps { group: Group; handleDelete: (item: GroupActivity) => Promise; inputVariant: 'compact' | 'full'; - item: ActivityFeedItem; + item: DisplayedActivityFeedItem; onCommentEdited?: (activity: GroupActivity[]) => void; timestampUnitStyle?: React.ComponentProps['unitStyle']; } @@ -54,6 +51,24 @@ function ActivityFeedRow({ inputVariant, timestampUnitStyle, }: ActivityFeedRowProps) { + if (item.type === 'collapsed_status_activities') { + return ( + + {item.activities.map(activity => ( + + ))} + + ); + } + const {activity} = item; if (!isActivityNote(activity)) { @@ -93,70 +108,6 @@ interface ActivitySectionProps { variant?: 'sidebar' | 'standalone'; } -function isDuplicatePullRequestActivity( - activity: GroupActivity, - adjacentActivity: GroupActivity | undefined -): boolean { - switch (activity.type) { - // REFERENCED_IN_COMMIT should be hidden if there is an adjacent PULL_REQUEST_MERGED activity with the same pull request - case GroupActivityType.REFERENCED_IN_COMMIT: { - if (adjacentActivity?.type !== GroupActivityType.PULL_REQUEST_MERGED) { - return false; - } - - const pullRequest = activity.data.commit?.pullRequest; - const adjacentPullRequest = adjacentActivity.data.pullRequest; - if (!pullRequest || !adjacentPullRequest) { - return false; - } - - return ( - pullRequest.id === adjacentPullRequest.id && - pullRequest.repository.id === adjacentPullRequest.repository.id - ); - } - case GroupActivityType.SEER_PR_CREATED: { - if (adjacentActivity?.type !== GroupActivityType.SET_RESOLVED_IN_PULL_REQUEST) { - return false; - } - - const adjacentPullRequest = adjacentActivity.data.pullRequest; - if (!adjacentPullRequest) { - return false; - } - - return Boolean( - activity.data.pull_requests?.some( - pullRequest => - pullRequest.pull_request.pr_url === adjacentPullRequest.externalUrl - ) - ); - } - default: - return false; - } -} - -function removeAdjacentDuplicatePullRequestActivities(activities: GroupActivity[]): { - activities: GroupActivity[]; - actorActivityById: Map; -} { - const actorActivityById = new Map(); - const filteredActivities = activities.filter((activity, index) => { - const duplicateActivity = [activities[index - 1], activities[index + 1]].find( - adjacentActivity => isDuplicatePullRequestActivity(activity, adjacentActivity) - ); - - if (activity.type === GroupActivityType.SEER_PR_CREATED && duplicateActivity) { - actorActivityById.set(duplicateActivity.id, activity); - } - - return !duplicateActivity; - }); - - return {activities: filteredActivities, actorActivityById}; -} - export function ActivitySection({ group, filterComments, @@ -198,34 +149,21 @@ export function ActivitySection({ [group.activity, mutators, onCommentDeleted, organization] ); - const activityLink = { - pathname: `${baseUrl}${TabPaths[Tab.ACTIVITY]}`, - query: { - ...location.query, - cursor: undefined, - }, - }; - - const showSeerActivities = organization.features.includes( - 'display-seer-actions-as-issue-activities' - ); - const visibleActivities = showSeerActivities - ? group.activity - : group.activity.filter(item => !SEER_ACTIVITY_TYPES.has(item.type)); - - const {activities: deduplicatedActivities, actorActivityById} = - removeAdjacentDuplicatePullRequestActivities(visibleActivities); - const filteredActivities = deduplicatedActivities.filter( - item => !filterComments || item.type === GroupActivityType.NOTE - ); - const displayedActivities = collapseSeerActivityPairs(filteredActivities).map(item => { - const actorActivity = actorActivityById.get(item.activity.id); - return item.type === 'activity' && actorActivity ? {...item, actorActivity} : item; + const isStandalone = variant === 'standalone'; + const displayedActivities = buildActivityFeedItems({ + activities: group.activity, + filterComments, + showSeerActivities: organization.features.includes( + 'display-seer-actions-as-issue-activities' + ), + showStatusFlappingRollups: organization.features.includes( + 'issue-activity-status-flapping-rollup' + ), }); - const inputVariant = variant === 'sidebar' ? 'compact' : 'full'; - const timestampUnitStyle = variant === 'sidebar' ? 'short' : undefined; + const inputVariant = isStandalone ? 'full' : 'compact'; + const timestampUnitStyle = isStandalone ? undefined : 'short'; - const renderActivityItem = (item: ActivityFeedItem) => ( + const renderActivityItem = (item: DisplayedActivityFeedItem) => ( ); - const hiddenActivityCount = - displayedActivities.length >= 5 ? displayedActivities.length - 3 : 0; + const totalActivityCount = countActivityFeedEvents(displayedActivities); const sidebarVisibleActivities = - hiddenActivityCount > 0 ? displayedActivities.slice(0, 3) : displayedActivities; + totalActivityCount >= 5 ? displayedActivities.slice(0, 3) : displayedActivities; + // A status rollup is visible as one row, but its underlying events remain hidden. + const visibleActivityCount = sidebarVisibleActivities.filter( + item => item.type !== 'collapsed_status_activities' + ).length; + const hiddenActivityCount = totalActivityCount - visibleActivityCount; const sidebarActivityItems = ( {sidebarVisibleActivities.map(renderActivityItem)} @@ -269,7 +211,13 @@ export function ActivitySection({ ); - if (variant === 'standalone') { + if (isStandalone) { return ( {noteInput} From 9947f31d031fe4f23e75c3165b3008bb846ed5de Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 7 Aug 2026 15:25:55 -0700 Subject: [PATCH 2/2] fix(issues): Count hidden activity rows correctly The sidebar mixed underlying rollup events with rendered rows, so it could show both "Show 2 more" and "View 2 more" for the same activity. Count only rows omitted from the sidebar and remove the unused rollup exports flagged by knip. Co-Authored-By: OpenAI Codex --- .../activityLineItem/activityFeedItem.spec.ts | 2 -- .../activityLineItem/activityFeedItem.ts | 11 +---------- .../views/issueDetails/activitySection/index.spec.tsx | 5 +++-- .../app/views/issueDetails/activitySection/index.tsx | 11 +++-------- 4 files changed, 7 insertions(+), 22 deletions(-) diff --git a/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.spec.ts b/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.spec.ts index 552ec732a33c..e5e5f0421e98 100644 --- a/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.spec.ts +++ b/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.spec.ts @@ -6,7 +6,6 @@ import {GroupActivityType, PriorityLevel} from 'sentry/types/group'; import { type ActivityFeedItem, collapseFlappingStatusActivities, - countActivityFeedEvents, type DisplayedActivityFeedItem, } from './activityFeedItem'; @@ -72,7 +71,6 @@ describe('collapseFlappingStatusActivities', () => { expect(result.slice(0, 2)).toEqual([newestRegression, newestResolution]); expect(result).toHaveLength(3); expectCollapsedActivities(result[2], olderRun); - expect(countActivityFeedEvents(result)).toBe(2 + olderRun.length); }); it('keeps the newest pair visible when newer user activity exists', () => { diff --git a/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.ts b/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.ts index 59c4d3b7aa2c..5b7ceb769987 100644 --- a/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.ts +++ b/static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.ts @@ -34,7 +34,7 @@ export type ActivityFeedItem = } | CollapsedSeerActivity; -export interface CollapsedStatusActivity { +interface CollapsedStatusActivity { activities: ActivityFeedItem[]; /** The first rolled-up item provides a stable key for the summary row. */ activity: GroupActivity; @@ -43,15 +43,6 @@ export interface CollapsedStatusActivity { export type DisplayedActivityFeedItem = ActivityFeedItem | CollapsedStatusActivity; -/** Counts each status activity inside a rollup rather than counting the rollup as one row. */ -export function countActivityFeedEvents(items: DisplayedActivityFeedItem[]): number { - return items.reduce( - (count, item) => - count + (item.type === 'collapsed_status_activities' ? item.activities.length : 1), - 0 - ); -} - const RESOLUTION_ACTIVITY_TYPES = new Set([ GroupActivityType.SET_RESOLVED, GroupActivityType.SET_RESOLVED_BY_AGE, diff --git a/static/app/views/issueDetails/activitySection/index.spec.tsx b/static/app/views/issueDetails/activitySection/index.spec.tsx index 1aeac5d59c34..85b5f8b3bce8 100644 --- a/static/app/views/issueDetails/activitySection/index.spec.tsx +++ b/static/app/views/issueDetails/activitySection/index.spec.tsx @@ -700,7 +700,7 @@ describe('ActivitySection', () => { expect(screen.getAllByText('Resolved')).toHaveLength(1); }); - it('rolls up status flapping and counts its events in the sidebar', () => { + it('does not count rolled-up events as hidden sidebar rows', () => { const flappingGroup = makeFlappingGroup('1350'); render( @@ -713,7 +713,8 @@ describe('ActivitySection', () => { ); expect(screen.getByRole('button', {name: 'Show 2 more'})).toBeInTheDocument(); - expect(screen.getByText('View 2 more')).toBeInTheDocument(); + expect(screen.getByText('Expand')).toBeInTheDocument(); + expect(screen.queryByText('View 2 more')).not.toBeInTheDocument(); expect(screen.getAllByText('Regressed')).toHaveLength(1); expect(screen.getAllByText('Resolved')).toHaveLength(1); }); diff --git a/static/app/views/issueDetails/activitySection/index.tsx b/static/app/views/issueDetails/activitySection/index.tsx index fd856d5c2a36..bdf772edcaef 100644 --- a/static/app/views/issueDetails/activitySection/index.tsx +++ b/static/app/views/issueDetails/activitySection/index.tsx @@ -17,7 +17,6 @@ import {useOrganization} from 'sentry/utils/useOrganization'; import {ActivityLine} from 'sentry/views/issueDetails/activitySection/activityLineItem'; import { buildActivityFeedItems, - countActivityFeedEvents, type DisplayedActivityFeedItem, } from 'sentry/views/issueDetails/activitySection/activityLineItem/activityFeedItem'; import {CollapsedStatusActivityRow} from 'sentry/views/issueDetails/activitySection/activityLineItem/collapsedStatusActivityRow'; @@ -226,14 +225,10 @@ export function ActivitySection({ )} ); - const totalActivityCount = countActivityFeedEvents(displayedActivities); + const hiddenActivityCount = + displayedActivities.length >= 5 ? displayedActivities.length - 3 : 0; const sidebarVisibleActivities = - totalActivityCount >= 5 ? displayedActivities.slice(0, 3) : displayedActivities; - // A status rollup is visible as one row, but its underlying events remain hidden. - const visibleActivityCount = sidebarVisibleActivities.filter( - item => item.type !== 'collapsed_status_activities' - ).length; - const hiddenActivityCount = totalActivityCount - visibleActivityCount; + hiddenActivityCount > 0 ? displayedActivities.slice(0, 3) : displayedActivities; const sidebarActivityItems = ( {sidebarVisibleActivities.map(item => renderActivityItem(item, true))}