Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import {ActivityFeedFixture} from 'sentry-fixture/activityFeed';
import {UserFixture} from 'sentry-fixture/user';

import {GroupActivityType, PriorityLevel} from 'sentry/types/group';

import {
type ActivityFeedItem,
collapseFlappingStatusActivities,
type DisplayedActivityFeedItem,
} from './activityFeedItem';

type ActivityFixtureParams = NonNullable<Parameters<typeof ActivityFeedFixture>[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);
});

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);
});
});
Original file line number Diff line number Diff line change
@@ -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<Type extends GroupActivityType> = Extract<
GroupActivity,
Expand Down Expand Up @@ -33,6 +34,64 @@ export type ActivityFeedItem =
}
| CollapsedSeerActivity;

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;

const RESOLUTION_ACTIVITY_TYPES = new Set<GroupActivityType>([
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;
Expand Down Expand Up @@ -150,3 +209,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;
Comment on lines +223 to +233

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The collapseSeerActivityPairs function can insert a CollapsedSeerActivity between a regression/resolution pair, breaking the adjacency check in collapseFlappingStatusActivities and preventing flapping detection.
Severity: MEDIUM

Suggested Fix

Modify collapseFlappingStatusActivities to be robust against intervening activities. Instead of checking the immediately adjacent element activities[index+1], the logic should scan forward from a SET_REGRESSION activity to find the next relevant activity, skipping over others like CollapsedSeerActivity, to see if it is a SET_RESOLVED activity.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location:
static/app/views/issueDetails/activitySection/activityLineItem/activityFeedItem.ts#L223-L233

Potential issue: The `collapseFlappingStatusActivities` function assumes that
`SET_REGRESSION` and `SET_RESOLVED` activities are adjacent in the activity feed to
detect a "flapping" issue. However, the `collapseSeerActivityPairs` function, which runs
prior, can create and insert a `CollapsedSeerActivity` object between the regression and
resolution activities. This breaks the adjacency assumption, as the logic specifically
checks `activities[index+1]`. Consequently, if a Seer analysis completes between an
issue regressing and being resolved, the flapping status will not be correctly
identified and displayed in the UI.

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;
}
Loading
Loading