From 32def599908d126857d1931b134491c5208961fd Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 29 May 2026 09:07:35 -0700 Subject: [PATCH 01/42] feat(cmdk): Add search keywords to reduce no-result queries (#116431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add synonyms and alternative search terms to command palette actions based on analysis of top no-result queries from Amplitude data. Many users search for features using terms that don't match any existing action label, details, or keyword — resulting in zero results. Since the command palette uses fzf subsequence matching, the query must appear as a subsequence within a single candidate string (label, details, or keyword). Longer queries can't match shorter candidates, and space-separated terms won't match their concatenated forms. Each addition was verified against the fzf matching algorithm to confirm it's not already reachable via existing candidates: | Action | Keywords Added | Top Queries Covered | |--------|---------------|---------------------| | Create Alert | `alert rules`, `issue alert` | "alert rules", "issue alert" (~15) | | Monitors > Alerts | `alert rules`, `issue alert` | same, for workflow-engine-ui users | | DSN | `sentry dsn` | "Sentry DSN", "sentry d" (~8) | | Traces | `spans`, `trace explorer` | "spans", "Trace Explorer" (~5) | | Replays | `rum`, `session replay` | "RUM", "Session Replay" (~6) | | Releases | `release health` | "Release Health" (2) | | Profiles | `profiling` | "Profiling" (3) | | Organization Tokens | `user auth tokens` | "user auth", "User Auth Tokens" (~26) | | Personal Tokens | `user auth tokens` | same | | Close Account | `delete account` | "delete account", "delete" (~5) | | Integrations | `linear` | "linear", "Linear" (~5) | | Issue Grouping | `fingerprinting`, `fingerprint rules` | "fingerprint" (~14) | | Client Keys (DSN) | `allowed domains` | "allow", "allowed dom" (~14) | | Ownership Rules | `code owners` | "code owner" (~5) | Co-authored-by: Claude Opus 4.6 --- .../ui/commandPaletteGlobalActions.tsx | 17 ++++++++++++++--- .../userOrgNavigationConfiguration.tsx | 4 ++++ .../project/navigationConfiguration.tsx | 18 ++++++++++++++++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 7dd26ef26803..1bbf8d0c6229 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -345,7 +345,11 @@ export function GlobalCommandPaletteActions() { }} limit={4}> - + {organization.features.includes('ourlogs-enabled') && ( )} @@ -365,17 +369,20 @@ export function GlobalCommandPaletteActions() { {organization.features.includes('profiling') && ( )} {organization.features.includes('session-replay-ui') && ( )} {organization.features.includes('gen-ai-conversations') && ( @@ -529,6 +536,7 @@ export function GlobalCommandPaletteActions() { )} @@ -766,7 +774,7 @@ export function GlobalCommandPaletteActions() { /> }} - keywords={[t('add alert')]} + keywords={[t('add alert'), t('alert rules'), t('issue alert')]} to={`${prefix}/issues/alerts/wizard/`} /> - + Date: Fri, 29 May 2026 09:07:42 -0700 Subject: [PATCH 02/42] ref(issues): Minor cleanup of boolean logic in escalating issue algorithm (#116453) No change in behavior, just doing some minor cleanup in the logic here. --- src/sentry/issues/escalating/escalating.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/sentry/issues/escalating/escalating.py b/src/sentry/issues/escalating/escalating.py index c0f7d3aa31b0..96bafb9cad12 100644 --- a/src/sentry/issues/escalating/escalating.py +++ b/src/sentry/issues/escalating/escalating.py @@ -517,13 +517,13 @@ def manage_issue_states( snooze_details: InboxReasonDetails | None = None, activity_data: Mapping[str, Any] | None = None, ) -> None: - from sentry.integrations.tasks.kick_off_status_syncs import kick_off_status_syncs - """ Handles the downstream changes to the status/substatus of GroupInbox and Group for each GroupInboxReason `activity_data`: Additional activity data, such as escalating forecast """ + from sentry.integrations.tasks.kick_off_status_syncs import kick_off_status_syncs + data: dict[str, str | Mapping[str, Any]] | None = ( {"event_id": event.event_id} if event else None ) @@ -547,15 +547,13 @@ def manage_issue_states( kwargs={"project_id": group.project_id, "group_id": group.id} ) - has_forecast = ( - True if data and activity_data and "forecast" in activity_data.keys() else False - ) + has_forecast = bool(data and activity_data and "forecast" in activity_data) issue_escalating.send_robust( project=group.project, group=group, event=event, sender=manage_issue_states, - was_until_escalating=True if has_forecast else False, + was_until_escalating=has_forecast, new_substatus=GroupSubStatus.ESCALATING, ) if data and activity_data and has_forecast: # Redundant checks needed for typing From 4f6e18a14591721512259026a3de61609bd87b59 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Fri, 29 May 2026 09:09:11 -0700 Subject: [PATCH 03/42] ref(issues): Rename feature flag to be specific to displaying Seer actions as issue details activities (#116425) https://linear.app/getsentry/project/add-seer-actions-to-issue-activityaction-log-0e641e1f5dac/overview Follow-up to https://github.com/getsentry/sentry/pull/116424. Renames the `seer-activity-timeline` feature flag to `display-seer-actions-as-issue-activities` to clarify that this flag only gates the *display* of Seer activities in the issue details timeline. The *recording* of these activities is controlled by the `issues.record-seer-actions-as-activities` option added in the above PR. --- src/sentry/features/temporary.py | 5 +++-- .../app/views/issueDetails/activitySection/index.spec.tsx | 8 ++++++-- static/app/views/issueDetails/activitySection/index.tsx | 4 +++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index cb94a3b9a5f7..94cdccedb169 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -296,8 +296,9 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-slack-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Show Seer run ID in Slack notification footers manager.add("organizations:seer-run-id-in-slack", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) - # Enable Seer activity events in the issue activity timeline - manager.add("organizations:seer-activity-timeline", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Gate display of Seer action events in the issue activity timeline + # https://linear.app/getsentry/project/add-seer-actions-to-issue-activityaction-log-0e641e1f5dac/overview + manager.add("organizations:display-seer-actions-as-issue-activities", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Gate outbox-based mirroring of SeerRun records to Seer manager.add("organizations:seer-run-mirror", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Gate outbox-based mirroring for autofix writes diff --git a/static/app/views/issueDetails/activitySection/index.spec.tsx b/static/app/views/issueDetails/activitySection/index.spec.tsx index 2fabb552aea5..d0b6a3fc6067 100644 --- a/static/app/views/issueDetails/activitySection/index.spec.tsx +++ b/static/app/views/issueDetails/activitySection/index.spec.tsx @@ -594,7 +594,9 @@ describe('ActivitySection', () => { project, }); - const org = OrganizationFixture({features: ['seer-activity-timeline']}); + const org = OrganizationFixture({ + features: ['display-seer-actions-as-issue-activities'], + }); render(, {organization: org}); expect(await screen.findByText('Root Cause Analysis')).toBeInTheDocument(); @@ -650,7 +652,9 @@ describe('ActivitySection', () => { project, }); - const org = OrganizationFixture({features: ['seer-activity-timeline']}); + const org = OrganizationFixture({ + features: ['display-seer-actions-as-issue-activities'], + }); render(, {organization: org}); expect(screen.queryByText('Pull Request Created')).not.toBeInTheDocument(); diff --git a/static/app/views/issueDetails/activitySection/index.tsx b/static/app/views/issueDetails/activitySection/index.tsx index 1386643232aa..02261d34c1aa 100644 --- a/static/app/views/issueDetails/activitySection/index.tsx +++ b/static/app/views/issueDetails/activitySection/index.tsx @@ -368,7 +368,9 @@ export function ActivitySection({ }, }; - const showSeerActivities = organization.features.includes('seer-activity-timeline'); + const showSeerActivities = organization.features.includes( + 'display-seer-actions-as-issue-activities' + ); const visibleActivities = showSeerActivities ? group.activity.filter(item => item.type !== GroupActivityType.SEER_PR_CREATED) : group.activity.filter(item => !SEER_ACTIVITY_TYPES.has(item.type)); From 04519884fe5372b4b686d0884d9423ccea13e93c Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Fri, 29 May 2026 09:10:10 -0700 Subject: [PATCH 04/42] fix(forms): Surface backend error messages in AutoSaveForm (#116448) When `AutoSaveForm` gets back an error response that doesn't contain field-level errors (e.g. `{detail: "Cannot override environment variable EXAMPLE"}` instead of `{project_mappings: ["error"]}`), it currently falls back to a generic "Failed to save" message. This PR attempts to extract any `detail` errors before falling back. --- .../core/form/autoSaveForm.spec.tsx | 35 +++++++++++++++++++ .../app/components/core/form/autoSaveForm.tsx | 10 ++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/static/app/components/core/form/autoSaveForm.spec.tsx b/static/app/components/core/form/autoSaveForm.spec.tsx index 992f9f856062..4354b31f134d 100644 --- a/static/app/components/core/form/autoSaveForm.spec.tsx +++ b/static/app/components/core/form/autoSaveForm.spec.tsx @@ -236,6 +236,41 @@ describe('AutoSaveForm', () => { expect(await screen.findByText('Failed to save')).toBeInTheDocument(); }); + it('shows detail message from RequestError', async () => { + function TestComponent() { + return ( + { + const error = new RequestError('POST', '/test/', new Error('test')); + error.responseJSON = {detail: 'Organization is suspended'}; + throw error; + }, + }} + > + {field => ( + + + + + )} + + ); + } + + render(); + + const input = screen.getByRole('textbox', {name: 'Name'}); + await userEvent.clear(input); + await userEvent.type(input, 'new value'); + await userEvent.tab(); + + expect(await screen.findByText('Organization is suspended')).toBeInTheDocument(); + }); + it('shows generic error when RequestError has no responseJSON', async () => { function TestComponent() { return ( diff --git a/static/app/components/core/form/autoSaveForm.tsx b/static/app/components/core/form/autoSaveForm.tsx index 89828d7fbf9d..5c39a01551fb 100644 --- a/static/app/components/core/form/autoSaveForm.tsx +++ b/static/app/components/core/form/autoSaveForm.tsx @@ -13,6 +13,7 @@ import { import {openConfirmModal} from 'sentry/components/confirm'; import {t} from 'sentry/locale'; +import {getRequestErrorUserMessage} from 'sentry/utils/requestError/getRequestErrorUserMessage'; import {RequestError} from 'sentry/utils/requestError/requestError'; /** @@ -202,11 +203,14 @@ export function AutoSaveForm< if (resetOnErrorRef.current) { formApi.reset(); } - const hasBackendErrors = - error instanceof RequestError ? setFieldErrors(formApi, error) : false; + const isRequestError = error instanceof RequestError; + const hasBackendErrors = isRequestError ? setFieldErrors(formApi, error) : false; if (!hasBackendErrors) { + const message = isRequestError + ? getRequestErrorUserMessage(error, t('Failed to save')) + : t('Failed to save'); setFieldErrors(formApi, { - [name]: {message: t('Failed to save')}, + [name]: {message}, } as never); } }; From a3c4b0369316f0ba17851acdeace6083f7462dfa Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Fri, 29 May 2026 12:15:31 -0400 Subject: [PATCH 05/42] ref: Type utils.signing.unsign return as Any (#116486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unsign` deserializes an arbitrary signed JSON payload, so narrowing its return to `dict[str, Any]` forced callers that know the concrete shape to cast the result. This types the return as `Any` (like `json.loads`) so callers can annotate the deserialized shape directly — e.g. a DRF serializer's `validate()` returning a `TypedDict` — without a cast. --- src/sentry/utils/signing.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/sentry/utils/signing.py b/src/sentry/utils/signing.py index ae6667991585..4a776cedc169 100644 --- a/src/sentry/utils/signing.py +++ b/src/sentry/utils/signing.py @@ -24,9 +24,12 @@ def sign(*, salt: str = SALT, **kwargs: object) -> str: ) -def unsign(data: str | bytes, salt: str = SALT, max_age: int = 60 * 60 * 24 * 2) -> dict[str, Any]: +def unsign(data: str | bytes, salt: str = SALT, max_age: int = 60 * 60 * 24 * 2) -> Any: """ - Unsign a signed base64 string. Accepts the base64 value as a string or bytes + Unsign a signed base64 string. Accepts the base64 value as a string or bytes. + + Returns the decoded payload. Typed as ``Any`` (like ``json.loads``) so + callers can annotate the concrete shape they expect without casting. """ return loads( TimestampSigner(salt=salt).unsign(urlsafe_b64decode(data).decode(), max_age=max_age) From 1791e4f4b8c41cd2bd13987ff33ee27b93925cd1 Mon Sep 17 00:00:00 2001 From: Giovanni M Guidini <99758426+giovanni-guidini@users.noreply.github.com> Date: Fri, 29 May 2026 18:54:04 +0200 Subject: [PATCH 06/42] ref(seer): use get_group_list helper in supergroups-by-group endpoint (#116474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `OrganizationSupergroupsByGroupEndpoint` was using inline `Group.objects.filter` calls to validate and filter group IDs. This refactors it to use the existing `get_group_list` helper from `sentry.api.helpers.group_index`, which is the canonical pattern for this kind of scoped group lookup (see `api/helpers/group_index/update.py` line 275 for the existing usage). `get_group_list` previously lived in `group_index/update.py` — a large mutation-focused module with heavy dependencies. Since the function is a pure read with minimal dependencies (`Group`, `Project`, `Sequence`), it has been extracted into a new `group_index/lookup.py`. This avoids a circular-import risk (`update.py` ← `index.py` ← `__init__.py` ← `update.py`) and gives the function a proper home as a first-class export of the package via `__init__.__all__`. `update.py` now imports `get_group_list` from `.lookup`; no behaviour change for existing callers. Test coverage for the endpoint has been expanded and `@cell_silo_test` has been added to the test class to match the endpoint's `@cell_silo_endpoint` decorator. Refs AIML-2879 --- .../api/helpers/group_index/__init__.py | 2 + src/sentry/api/helpers/group_index/lookup.py | 44 ++++++++++++++ src/sentry/api/helpers/group_index/update.py | 39 +----------- .../organization_supergroups_by_group.py | 23 +++----- tests/sentry/api/helpers/test_group_index.py | 7 ++- .../test_organization_supergroups_by_group.py | 59 +++++++++++++++++++ 6 files changed, 120 insertions(+), 54 deletions(-) create mode 100644 src/sentry/api/helpers/group_index/lookup.py diff --git a/src/sentry/api/helpers/group_index/__init__.py b/src/sentry/api/helpers/group_index/__init__.py index cf19a6371cac..9d60fd94e74f 100644 --- a/src/sentry/api/helpers/group_index/__init__.py +++ b/src/sentry/api/helpers/group_index/__init__.py @@ -24,11 +24,13 @@ "BULK_MUTATION_LIMIT", "SEARCH_MAX_HITS", "delete_group_list", + "get_group_list", "update_groups", ) from .delete import * # NOQA from .delete import delete_group_list from .index import * # NOQA +from .lookup import get_group_list from .update import * # NOQA from .update import update_groups diff --git a/src/sentry/api/helpers/group_index/lookup.py b/src/sentry/api/helpers/group_index/lookup.py new file mode 100644 index 000000000000..e7111fc2b588 --- /dev/null +++ b/src/sentry/api/helpers/group_index/lookup.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from sentry.models.group import Group +from sentry.models.project import Project + + +def get_group_list( + organization_id: int, + projects: Sequence[Project], + group_ids: Sequence[int | str], +) -> list[Group]: + """ + Gets group list based on provided filters. + + Args: + organization_id: ID of the organization + projects: Sequence of projects to filter groups by + group_ids: Sequence of specific group IDs to fetch + + Returns: List of Group objects filtered to only valid groups in the org/projects + """ + groups: list[Group] = [] + # Convert all group IDs to integers and filter out any non-integer values + group_ids_int = [int(gid) for gid in group_ids if str(gid).isdigit()] + if group_ids_int: + return list( + Group.objects.filter( + project__organization_id=organization_id, project__in=projects, id__in=group_ids_int + ).select_related("project") + ) + else: + project_ids = {p.id for p in projects} + for group_id in group_ids: + if isinstance(group_id, str): + try: + group = Group.objects.by_qualified_short_id(organization_id, group_id) + except Group.DoesNotExist: + continue + if group.project_id in project_ids: + groups.append(group) + + return groups diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 9d559a150da8..2b2c0787d4ae 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -61,6 +61,7 @@ from sentry.utils import metrics from . import ACTIVITIES_COUNT, BULK_MUTATION_LIMIT, SearchFunction, delete_group_list +from .lookup import get_group_list from .validators import GroupValidator, ValidationError logger = logging.getLogger(__name__) @@ -325,44 +326,6 @@ def validate_request( return serializer -def get_group_list( - organization_id: int, - projects: Sequence[Project], - group_ids: Sequence[int | str], -) -> list[Group]: - """ - Gets group list based on provided filters. - - Args: - organization_id: ID of the organization - projects: Sequence of projects to filter groups by - group_ids: Sequence of specific group IDs to fetch - - Returns: List of Group objects filtered to only valid groups in the org/projects - """ - groups: list[Group] = [] - # Convert all group IDs to integers and filter out any non-integer values - group_ids_int = [int(gid) for gid in group_ids if str(gid).isdigit()] - if group_ids_int: - return list( - Group.objects.filter( - project__organization_id=organization_id, project__in=projects, id__in=group_ids_int - ).select_related("project") - ) - else: - project_ids = {p.id for p in projects} - for group_id in group_ids: - if isinstance(group_id, str): - try: - group = Group.objects.by_qualified_short_id(organization_id, group_id) - except Group.DoesNotExist: - continue - if group.project_id in project_ids: - groups.append(group) - - return groups - - def handle_resolve_in_release( status: str, status_details: Mapping[str, Any], diff --git a/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py b/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py index 4f490296f4cc..566bd5a5f4bd 100644 --- a/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py +++ b/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py @@ -12,9 +12,10 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.helpers.group_index import get_group_list from sentry.api.serializers import serialize from sentry.api.serializers.models.actor import ActorSerializer, ActorSerializerResponse -from sentry.models.group import STATUS_QUERY_CHOICES, Group +from sentry.models.group import STATUS_QUERY_CHOICES from sentry.models.groupassignee import GroupAssignee from sentry.models.organization import Organization from sentry.models.team import Team @@ -72,12 +73,8 @@ def get(self, request: Request, organization: Organization) -> Response: status=status_codes.HTTP_400_BAD_REQUEST, ) - valid_group_ids = set( - Group.objects.filter( - id__in=group_ids, - project__organization=organization, - ).values_list("id", flat=True) - ) + projects = self.get_projects(request, organization) + valid_group_ids = {g.id for g in get_group_list(organization.id, projects, group_ids)} group_ids = [gid for gid in group_ids if gid in valid_group_ids] if not group_ids: @@ -98,13 +95,11 @@ def get(self, request: Request, organization: Organization) -> Response: return Response({"data": data["data"], "meta": {"estimated": True}}) if status_param: - matching_ids = set( - Group.objects.filter( - id__in=all_response_group_ids, - project__organization=organization, - status=STATUS_QUERY_CHOICES[status_param], - ).values_list("id", flat=True) - ) + matching_ids = { + g.id + for g in get_group_list(organization.id, projects, list(all_response_group_ids)) + if g.status == STATUS_QUERY_CHOICES[status_param] + } for sg in data["data"]: sg["group_ids"] = [gid for gid in sg["group_ids"] if gid in matching_ids] diff --git a/tests/sentry/api/helpers/test_group_index.py b/tests/sentry/api/helpers/test_group_index.py index 35cfc1ed13a8..f55bda0b41d1 100644 --- a/tests/sentry/api/helpers/test_group_index.py +++ b/tests/sentry/api/helpers/test_group_index.py @@ -9,10 +9,13 @@ from sentry.analytics.events.advanced_search_feature_gated import AdvancedSearchFeatureGateEvent from sentry.analytics.events.manual_issue_assignment import ManualIssueAssignment -from sentry.api.helpers.group_index import update_groups, validate_search_filter_permissions +from sentry.api.helpers.group_index import ( + get_group_list, + update_groups, + validate_search_filter_permissions, +) from sentry.api.helpers.group_index.delete import schedule_tasks_to_delete_groups from sentry.api.helpers.group_index.update import ( - get_group_list, get_semver_releases, greatest_semver_release, handle_assigned_to, diff --git a/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py index 9a23a9272fe3..142c130fb0ec 100644 --- a/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py +++ b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py @@ -9,6 +9,7 @@ from sentry.models.groupassignee import GroupAssignee from sentry.seer.supergroups.endpoints import organization_supergroups_by_group from sentry.testutils.cases import APITestCase +from sentry.testutils.silo import cell_silo_test def mock_seer_response(data: dict[str, Any]) -> MagicMock: @@ -18,6 +19,7 @@ def mock_seer_response(data: dict[str, Any]) -> MagicMock: return response +@cell_silo_test class OrganizationSupergroupsByGroupEndpointTest(APITestCase): endpoint = "sentry-api-0-organization-supergroups-by-group" @@ -189,6 +191,63 @@ def test_skips_fanout_over_threshold(self, mock_seer): assert "assignees" not in sg assert self.resolved_group.id in sg["group_ids"] + def test_returns_404_when_all_groups_are_in_inaccessible_projects(self): + self.organization.flags.allow_joinleave = False + self.organization.save() + + member_user = self.create_user() + self.create_member(organization=self.organization, user=member_user, role="member") + + other_project = self.create_project(organization=self.organization) + inaccessible_group = self.create_group(project=other_project) + + self.login_as(member_user) + + with self.feature("organizations:top-issues-ui"): + self.get_error_response( + self.organization.slug, + group_id=[inaccessible_group.id], + status_code=404, + ) + + @patch("sentry.seer.supergroups.by_group.make_supergroups_get_by_group_ids_request") + def test_filters_out_groups_from_inaccessible_projects(self, mock_seer): + self.organization.flags.allow_joinleave = False + self.organization.save() + + member_user = self.create_user() + member_team = self.create_team(organization=self.organization) + self.create_member( + organization=self.organization, + user=member_user, + role="member", + teams=[member_team], + ) + self.project.add_team(member_team) + + accessible_group = self.create_group(project=self.project) + + other_project = self.create_project(organization=self.organization) + inaccessible_group = self.create_group(project=other_project) + + mock_seer.return_value = mock_seer_response( + {"data": [{"id": 1, "group_ids": [accessible_group.id], "title": "sg"}]} + ) + + self.login_as(member_user) + + with self.feature("organizations:top-issues-ui"): + response = self.get_success_response( + self.organization.slug, + group_id=[accessible_group.id, inaccessible_group.id], + ) + + seer_call_body = mock_seer.call_args[0][0] + assert accessible_group.id in seer_call_body["group_ids"] + assert inaccessible_group.id not in seer_call_body["group_ids"] + + assert len(response.data["data"]) == 1 + @patch("sentry.seer.supergroups.by_group.make_supergroups_get_by_group_ids_request") def test_assignee_summary_tolerates_missing_actor(self, mock_seer): # GroupAssignee row references a user_id that `user_service.get_many_by_id` no longer returns From 6f8a95523682b2739ba6b45f2b5488b1e071ea48 Mon Sep 17 00:00:00 2001 From: Hector Dearman Date: Fri, 29 May 2026 17:54:44 +0100 Subject: [PATCH 07/42] ref(night-shift): Use default autofix model for night-shift runs (#116469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Night-shift was the only autofix invoker that forwarded an explicit `intelligence_level` and `reasoning_effort` (both `"high"`) to `trigger_autofix_agent`. Every other invoker — the group autofix endpoint, the issue-summary auto-trigger, the on-completion pipeline, and Slack — relies on the defaults: `intelligence_level="medium"` and the per-step `reasoning_effort` from the step config. This drops the explicit arguments from the night-shift autofix call so its runs use the same model as all other invokers. The night-shift triage step still honors the configured `intelligence_level` and `reasoning_effort` options; only the autofix invocation changes. Agent transcript: https://claudescope.sentry.dev/share/p8XAfGYOrDo-lObjRdup6LquAGyi4CNn3M0hGLD_HDo --- src/sentry/tasks/seer/night_shift/cron.py | 4 ---- tests/sentry/tasks/seer/test_night_shift.py | 15 --------------- 2 files changed, 19 deletions(-) diff --git a/src/sentry/tasks/seer/night_shift/cron.py b/src/sentry/tasks/seer/night_shift/cron.py index d7a7636749ba..4e822ef8e04e 100644 --- a/src/sentry/tasks/seer/night_shift/cron.py +++ b/src/sentry/tasks/seer/night_shift/cron.py @@ -343,7 +343,6 @@ def run_night_shift_execution( results = _run_autofix_for_candidates( run=run, candidates=candidates, - options=resolved_options, stopping_point_by_project_id=stopping_point_by_project_id, log_extra=log_extra, ) @@ -493,7 +492,6 @@ def _get_eligible_projects( def _run_autofix_for_candidates( run: SeerNightShiftRun, candidates: Sequence[TriageResult], - options: SeerNightShiftRunOptions, stopping_point_by_project_id: Mapping[int, AutofixStoppingPoint], log_extra: dict[str, object], ) -> list[SeerNightShiftRunResult]: @@ -534,8 +532,6 @@ def _run_autofix_for_candidates( step=AutofixStep.ROOT_CAUSE, referrer=referrer, stopping_point=stopping_point, - intelligence_level=options["intelligence_level"], - reasoning_effort=options["reasoning_effort"], user_context=user_context, ) except Exception: diff --git a/tests/sentry/tasks/seer/test_night_shift.py b/tests/sentry/tasks/seer/test_night_shift.py index 212566d2f225..a1efa32ef7a2 100644 --- a/tests/sentry/tasks/seer/test_night_shift.py +++ b/tests/sentry/tasks/seer/test_night_shift.py @@ -458,21 +458,6 @@ def test_autofix_stopping_point_honors_project_preference(self) -> None: assert mock_trigger.call_args.kwargs["stopping_point"] == AutofixStoppingPoint.SOLUTION - def test_forwards_reasoning_effort_to_trigger(self) -> None: - org = self.create_organization() - project = self.create_project(organization=org) - self._make_eligible(project) - - group = self._store_event_and_update_group( - project, "fixable", seer_fixability_score=0.9, times_seen=5 - ) - - with self._patched_night_shift([(group.id, "autofix")]) as (mock_trigger, _): - run_night_shift_for_org(org.id, options={"reasoning_effort": "low"}) - - mock_trigger.assert_called_once() - assert mock_trigger.call_args.kwargs["reasoning_effort"] == "low" - def test_dry_run_skips_autofix(self) -> None: org = self.create_organization() project = self.create_project(organization=org) From 6ee756907d01862b917df66aacc80a59773a3ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 29 May 2026 18:56:42 +0200 Subject: [PATCH 08/42] ref(onboarding): update project creation URL to /organizations/{org}/projects/ (#116388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend PR #116333 promoted `POST /organizations/{org}/experimental/projects/` to `POST /organizations/{org}/projects/` (PUBLIC). That PR keeps the old `/experimental/` path alive as a backward-compat alias so the frontend doesn't break between deploys. This PR drops the `/experimental/` path from the two hooks that call it and updates the test mocks and generated URL registry to match. **Changed hooks:** - `useCreateProject` — used by the main onboarding flow and the create-project settings page - `useCreateProjectFromWizard` — used by the setup wizard **Also adds** a unit test for `useCreateProject` that explicitly asserts the URL routing logic (no team slug → org endpoint, team slug present → teams endpoint). That coverage didn't exist before. Depends on #116333. ### Legal Boilerplate Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms. --- .../onboarding/useCreateProject.spec.tsx | 70 +++++++++++++++++++ .../components/onboarding/useCreateProject.ts | 2 +- .../utils/api/knownSentryApiUrls.generated.ts | 1 - .../app/views/onboarding/onboarding.spec.tsx | 2 +- .../projectInstall/createProject.spec.tsx | 8 +-- .../utils/useCreateProjectFromWizard.tsx | 2 +- 6 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 static/app/components/onboarding/useCreateProject.spec.tsx diff --git a/static/app/components/onboarding/useCreateProject.spec.tsx b/static/app/components/onboarding/useCreateProject.spec.tsx new file mode 100644 index 000000000000..3f094c676cf2 --- /dev/null +++ b/static/app/components/onboarding/useCreateProject.spec.tsx @@ -0,0 +1,70 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {useCreateProject} from 'sentry/components/onboarding/useCreateProject'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; + +const platform: OnboardingSelectedSDK = { + key: 'javascript-nextjs', + language: 'javascript', + link: 'https://docs.sentry.io/platforms/javascript/guides/nextjs/', + name: 'Next.js', + type: 'framework', + category: 'browser', +}; + +describe('useCreateProject', () => { + const organization = OrganizationFixture(); + const project = ProjectFixture({slug: 'my-project'}); + + beforeEach(() => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/`, + body: organization, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + body: [], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/teams/`, + body: [], + }); + jest.spyOn(ProjectsStore, 'onCreateSuccess'); + }); + + it('POSTs to /organizations/{org}/projects/ when no team slug is given', async () => { + const mockCreate = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + method: 'POST', + body: project, + }); + + const {result} = renderHookWithProviders(() => useCreateProject(), {organization}); + + result.current.mutate({platform, name: 'my-project'}); + + await waitFor(() => expect(mockCreate).toHaveBeenCalled()); + expect(ProjectsStore.onCreateSuccess).toHaveBeenCalledWith( + project, + organization.slug + ); + }); + + it('POSTs to /teams/{org}/{team}/projects/ when a team slug is provided', async () => { + const mockCreate = MockApiClient.addMockResponse({ + url: `/teams/${organization.slug}/my-team/projects/`, + method: 'POST', + body: project, + }); + + const {result} = renderHookWithProviders(() => useCreateProject(), {organization}); + + result.current.mutate({platform, name: 'my-project', firstTeamSlug: 'my-team'}); + + await waitFor(() => expect(mockCreate).toHaveBeenCalled()); + }); +}); diff --git a/static/app/components/onboarding/useCreateProject.ts b/static/app/components/onboarding/useCreateProject.ts index b396a3100668..95c91c127e0f 100644 --- a/static/app/components/onboarding/useCreateProject.ts +++ b/static/app/components/onboarding/useCreateProject.ts @@ -22,7 +22,7 @@ export function useCreateProject() { return api.requestPromise( firstTeamSlug ? `/teams/${organization.slug}/${firstTeamSlug}/projects/` - : `/organizations/${organization.slug}/experimental/projects/`, + : `/organizations/${organization.slug}/projects/`, { method: 'POST', data: { diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index f80f30e39d2d..e29eb3401a0f 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -263,7 +263,6 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/events/' | '/organizations/$organizationIdOrSlug/events/$projectIdOrSlug:$eventId/' | '/organizations/$organizationIdOrSlug/events/anomalies/' - | '/organizations/$organizationIdOrSlug/experimental/projects/' | '/organizations/$organizationIdOrSlug/explore/saved/' | '/organizations/$organizationIdOrSlug/explore/saved/$id/' | '/organizations/$organizationIdOrSlug/explore/saved/$id/starred/' diff --git a/static/app/views/onboarding/onboarding.spec.tsx b/static/app/views/onboarding/onboarding.spec.tsx index d9c74af620b7..48650ecb050a 100644 --- a/static/app/views/onboarding/onboarding.spec.tsx +++ b/static/app/views/onboarding/onboarding.spec.tsx @@ -898,7 +898,7 @@ describe('Onboarding', () => { body: createdProject, }); const createRequest = MockApiClient.addMockResponse({ - url: `/organizations/${controlOrganization.slug}/experimental/projects/`, + url: `/organizations/${controlOrganization.slug}/projects/`, method: 'POST', body: createdProject, }); diff --git a/static/app/views/projectInstall/createProject.spec.tsx b/static/app/views/projectInstall/createProject.spec.tsx index 1e8baba2b6dd..137a8e0c5688 100644 --- a/static/app/views/projectInstall/createProject.spec.tsx +++ b/static/app/views/projectInstall/createProject.spec.tsx @@ -60,13 +60,13 @@ function renderFrameworkModalMockRequests({ body: {slug: 'testProj'}, }); - const experimentalprojectCreationMockRequest = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/experimental/projects/`, + const orgProjectCreationMockRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, method: 'POST', body: {slug: 'testProj', team: {slug: 'testTeam'}}, }); - return {projectCreationMockRequest, experimentalprojectCreationMockRequest}; + return {projectCreationMockRequest, orgProjectCreationMockRequest}; } describe('CreateProject', () => { @@ -483,7 +483,7 @@ describe('CreateProject', () => { await userEvent.click(screen.getByRole('button', {name: 'Create Project'})); expect( - frameWorkModalMockRequests.experimentalprojectCreationMockRequest + frameWorkModalMockRequests.orgProjectCreationMockRequest ).toHaveBeenCalledTimes(1); expect(addSuccessMessage).toHaveBeenCalledWith( 'Created testProj under new team #testTeam' diff --git a/static/app/views/setupWizard/utils/useCreateProjectFromWizard.tsx b/static/app/views/setupWizard/utils/useCreateProjectFromWizard.tsx index 3df094b5cf60..483df87828a4 100644 --- a/static/app/views/setupWizard/utils/useCreateProjectFromWizard.tsx +++ b/static/app/views/setupWizard/utils/useCreateProjectFromWizard.tsx @@ -16,7 +16,7 @@ export function useCreateProjectFromWizard() { return api.requestPromise( params.team ? `/teams/${params.organization.slug}/${params.team}/projects/` - : `/organizations/${params.organization.slug}/experimental/projects/`, + : `/organizations/${params.organization.slug}/projects/`, { method: 'POST', host: params.organization.region.url, From 99c950709ec8cf4b9a022a00a60fff29ef2de34e Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Fri, 29 May 2026 12:58:11 -0400 Subject: [PATCH 09/42] feat(msteams): Support installing through the API pipeline modal (#116490) [VDY-101: Microsoft Teams: API-driven integration setup](https://linear.app/getsentry/issue/VDY-101/microsoft-teams-api-driven-integration-setup) Adds the API-mode pipeline machinery for Microsoft Teams alongside the existing server-rendered configure flow, without changing the entry point yet. This is the first of three deploy-safe steps: the legacy `MsTeamsExtensionConfigurationView` and the `/extensions/msteams/configure/` URL are left untouched, so nothing changes for users until the frontend can drive the modal and the configure URL is later swapped to a redirect. - `MsTeamsInitialDataSerializer` unsigns the bot's `signed_params` blob and binds each field to top-level pipeline state. - `MsTeamsApiStep` has no interactive UI; it signals the frontend to auto-advance, which runs `build_integration` on the bound state. - `build_integration` now reads top-level state, falling back to the nested `state["msteams"]` the legacy view binds, so both flows work during the transition. Also adds a `can_add_externally` marker to `IntegrationProvider` for integrations whose install is initiated from the third party's app directory or marketplace and completed through the pipeline modal. MS Teams sets `can_add = False` to hide the in-app install button, so the pipeline endpoint needs this opt-in to allow the externally-initiated install. The other already-external providers (Discord, GitHub, and GitHub Enterprise via subclassing) are marked for consistency; it's a no-op for them since they're `can_add = True`. Note: mypy will be red until #116486 (typing `utils.signing.unsign` as `Any`) merges, since the serializer's `validate()` returns a `TypedDict` from `unsign()`. The frontend follow-up is #116488. --- src/sentry/integrations/base.py | 11 ++ .../integrations/discord/integration.py | 1 + src/sentry/integrations/github/integration.py | 1 + .../integrations/msteams/integration.py | 93 +++++++++++- src/sentry/integrations/pipeline.py | 2 +- .../integrations/msteams/test_integration.py | 137 +++++++++++++++++- 6 files changed, 240 insertions(+), 5 deletions(-) diff --git a/src/sentry/integrations/base.py b/src/sentry/integrations/base.py index e29e45d21d67..44fb19f0d608 100644 --- a/src/sentry/integrations/base.py +++ b/src/sentry/integrations/base.py @@ -228,6 +228,17 @@ class is just a descriptor for how that object functions, and what behavior can_add = True """whether or not the integration installation be initiated from Sentry""" + can_add_externally = False + """ + Marks providers whose install is initiated from the third party's app + directory or marketplace (e.g. Discord's App Directory, the GitHub App + listing, the Teams Marketplace) and completed through the pipeline modal. + + For providers that also set `can_add = False`, hiding the in-app install + button because the install can only start from the third party, this is + what lets the pipeline endpoint accept the externally-initiated install. + """ + allow_multiple = True """whether multiple installations of this integration are allowed per organization""" diff --git a/src/sentry/integrations/discord/integration.py b/src/sentry/integrations/discord/integration.py index 0acb6959223f..defaa6fae970 100644 --- a/src/sentry/integrations/discord/integration.py +++ b/src/sentry/integrations/discord/integration.py @@ -227,6 +227,7 @@ def handle_post( class DiscordIntegrationProvider(IntegrationProvider): key = IntegrationProviderSlug.DISCORD.value name = "Discord" + can_add_externally = True metadata = metadata integration_cls = DiscordIntegration features = frozenset([IntegrationFeatures.CHAT_UNFURL, IntegrationFeatures.ALERT_RULE]) diff --git a/src/sentry/integrations/github/integration.py b/src/sentry/integrations/github/integration.py index 621ad648e761..8a72da4e5f2b 100644 --- a/src/sentry/integrations/github/integration.py +++ b/src/sentry/integrations/github/integration.py @@ -693,6 +693,7 @@ def process_api_error(e: ApiError) -> list[dict[str, Any]] | None: class GitHubIntegrationProvider(IntegrationProvider): key = IntegrationProviderSlug.GITHUB.value name = "GitHub" + can_add_externally = True metadata = metadata integration_cls: type[IntegrationInstallation] = GitHubIntegration features = frozenset( diff --git a/src/sentry/integrations/msteams/integration.py b/src/sentry/integrations/msteams/integration.py index 756e5111882f..f43bff9aada1 100644 --- a/src/sentry/integrations/msteams/integration.py +++ b/src/sentry/integrations/msteams/integration.py @@ -2,13 +2,17 @@ import logging from collections.abc import Mapping, Sequence -from typing import Any +from typing import Any, TypedDict +from django.core.signing import BadSignature, SignatureExpired from django.http.request import HttpRequest from django.http.response import HttpResponseBase from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers +from rest_framework.fields import CharField from sentry import options +from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer from sentry.integrations.base import ( FeatureDescription, IntegrationData, @@ -19,6 +23,7 @@ ) from sentry.integrations.models.integration import Integration from sentry.integrations.msteams.card_builder.block import AdaptiveCard +from sentry.integrations.msteams.constants import SALT from sentry.integrations.msteams.metrics import translate_msteams_api_error from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.types import IntegrationProviderSlug @@ -28,8 +33,10 @@ ) from sentry.notifications.platform.target import IntegrationNotificationTarget from sentry.organizations.services.organization.model import RpcOrganization -from sentry.pipeline.views.base import PipelineView +from sentry.pipeline.types import PipelineStepResult +from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView from sentry.shared_integrations.exceptions import ApiError +from sentry.utils.signing import unsign from .card_builder.installation import ( build_personal_installation_confirmation_message, @@ -37,6 +44,9 @@ ) from .client import MsTeamsClient, get_token_data +# 24 hours to finish installation +INSTALL_EXPIRATION_TIME = 60 * 60 * 24 + logger = logging.getLogger("sentry.integrations.msteams") DESCRIPTION = ( @@ -105,10 +115,76 @@ def send_notification_with_threading( raise NotImplementedError("Threading is not supported for Microsoft Teams") +class MsTeamsInstallParams(TypedDict): + """The payload the Sentry-Teams bot signs into `signed_params`.""" + + external_id: str + external_name: str + service_url: str + user_id: str + conversation_id: str + tenant_id: str + installation_type: str + + +class MsTeamsInitialDataSerializer(CamelSnakeSerializer): + """Initial pipeline data for Microsoft Teams installs. + + The Sentry bot in Teams renders a card with a single `signed_params` blob + (see MsTeamsInstallParams). We unsign it here so each field is bound to + top-level pipeline state individually. + """ + + signed_params = CharField(required=True) + + def validate(self, attrs: dict[str, Any]) -> MsTeamsInstallParams: + try: + return unsign(attrs["signed_params"], max_age=INSTALL_EXPIRATION_TIME, salt=SALT) + except SignatureExpired: + raise serializers.ValidationError("Installation link expired") + except BadSignature: + raise serializers.ValidationError("Invalid installation link") + + +class MsTeamsAdvanceSerializer(CamelSnakeSerializer): + state = CharField(required=True) + + +class MsTeamsApiStep: + """Install step for Microsoft Teams. + + All install data arrives bound to pipeline state via initialData, so this + step has no UI of its own. It signals the frontend to auto-advance, which + triggers `build_integration` to run on the already-bound state. + """ + + step_name = "msteams_install" + + def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]: + return { + "appDirectoryInstall": True, + "state": pipeline.signature, + } + + def get_serializer_cls(self) -> type: + return MsTeamsAdvanceSerializer + + def handle_post( + self, + validated_data: dict[str, str], + pipeline: IntegrationPipeline, + request: HttpRequest, + ) -> PipelineStepResult: + if validated_data["state"] != pipeline.signature: + return PipelineStepResult.error("An error occurred while validating your request.") + return PipelineStepResult.advance() + + class MsTeamsIntegrationProvider(IntegrationProvider): key = IntegrationProviderSlug.MSTEAMS.value name = "Microsoft Teams" can_add = False + can_add_externally = True metadata = metadata integration_cls = MsTeamsIntegration features = frozenset([IntegrationFeatures.CHAT_UNFURL, IntegrationFeatures.ALERT_RULE]) @@ -116,8 +192,19 @@ class MsTeamsIntegrationProvider(IntegrationProvider): def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]: return [MsTeamsPipelineView()] + def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: + return [MsTeamsApiStep()] + + def get_initial_data_serializer_cls(self) -> type[MsTeamsInitialDataSerializer]: + return MsTeamsInitialDataSerializer + def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: - data = state[self.key] + # Legacy installs (server-rendered configure view) bind everything under + # `state["msteams"]`; the API pipeline binds each field top-level. Read + # the nested blob if present, else fall back to top-level state. + # TODO: drop the `state[self.key]` fallback once the legacy configure + # view is removed. + data = state.get(self.key) or state external_id = data["external_id"] external_name = data["external_name"] service_url = data["service_url"] diff --git a/src/sentry/integrations/pipeline.py b/src/sentry/integrations/pipeline.py index 3bf3b955bb49..71dda7a648ff 100644 --- a/src/sentry/integrations/pipeline.py +++ b/src/sentry/integrations/pipeline.py @@ -96,7 +96,7 @@ def initialize_integration_pipeline( % "\n".join(is_feature_enabled) ) - if not pipeline.provider.can_add: + if not pipeline.provider.can_add and not pipeline.provider.can_add_externally: raise IntegrationPipelineError("Integration cannot be added.", not_found=True) pipeline.initialize() diff --git a/tests/sentry/integrations/msteams/test_integration.py b/tests/sentry/integrations/msteams/test_integration.py index fc67907e495f..1e1dd8f0a0e9 100644 --- a/tests/sentry/integrations/msteams/test_integration.py +++ b/tests/sentry/integrations/msteams/test_integration.py @@ -1,19 +1,23 @@ +from typing import Any from unittest.mock import MagicMock, patch from urllib.parse import urlencode import pytest import responses +from django.urls import reverse from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.integrations.msteams.constants import SALT from sentry.integrations.msteams.integration import MsTeamsIntegration, MsTeamsIntegrationProvider +from sentry.integrations.pipeline import IntegrationPipeline from sentry.notifications.platform.target import IntegrationNotificationTarget from sentry.notifications.platform.types import ( NotificationProviderKey, NotificationTargetResourceType, ) from sentry.shared_integrations.exceptions import ApiError, IntegrationConfigurationError -from sentry.testutils.cases import IntegrationTestCase, TestCase +from sentry.testutils.cases import APITestCase, IntegrationTestCase, TestCase from sentry.testutils.silo import control_silo_test from sentry.utils import json from sentry.utils.signing import sign @@ -120,6 +124,137 @@ def test_personal_installation(self) -> None: self.assert_setup_flow(installation_type="tenant") +@control_silo_test +class MsTeamsApiPipelineTest(APITestCase): + endpoint = "sentry-api-0-organization-pipeline" + method = "post" + provider = MsTeamsIntegrationProvider + + def setUp(self) -> None: + super().setUp() + self.login_as(self.user) + self.start_time = 1594768808 + self.install_params = { + "external_id": team_id, + "external_name": "my_team", + "service_url": "https://smba.trafficmanager.net/amer/", + "user_id": user_id, + "conversation_id": team_id, + "tenant_id": tenant_id, + } + + def tearDown(self) -> None: + responses.reset() + super().tearDown() + + def _get_pipeline_url(self) -> str: + return reverse( + self.endpoint, + args=[self.organization.slug, IntegrationPipeline.pipeline_name], + ) + + def _initialize_pipeline(self, initial_data: dict[str, Any] | None = None) -> Any: + payload: dict[str, Any] = {"action": "initialize", "provider": self.provider.key} + if initial_data is not None: + payload["initialData"] = initial_data + return self.client.post(self._get_pipeline_url(), data=payload, format="json") + + def _advance_step(self, data: dict[str, Any]) -> Any: + return self.client.post(self._get_pipeline_url(), data=data, format="json") + + def _signed_params(self, installation_type: str) -> str: + return sign(salt=SALT, **{**self.install_params, "installation_type": installation_type}) + + @responses.activate + def test_initialize_returns_auto_advance_data(self) -> None: + resp = self._initialize_pipeline(initial_data={"signedParams": self._signed_params("team")}) + assert resp.status_code == 200 + assert resp.data["step"] == "msteams_install" + data = resp.data["data"] + assert data["appDirectoryInstall"] is True + assert "state" in data + + @responses.activate + def test_initialize_expired_signature(self) -> None: + with patch("sentry.integrations.msteams.integration.INSTALL_EXPIRATION_TIME", -1): + resp = self._initialize_pipeline( + initial_data={"signedParams": self._signed_params("team")} + ) + assert resp.status_code == 400 + + @responses.activate + def test_initialize_tampered_signature(self) -> None: + # Signed with a different salt, so unsigning with SALT raises + # BadSignature rather than SignatureExpired. + tampered = sign(salt="not-the-msteams-salt", **self.install_params) + resp = self._initialize_pipeline(initial_data={"signedParams": tampered}) + assert resp.status_code == 400 + + def _complete_install(self, installation_type: str) -> str: + """Run the full API pipeline and return the post-install card body.""" + responses.add( + responses.POST, + "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token", + json={"expires_in": 86399, "access_token": "my_token"}, + ) + responses.add( + responses.POST, + "https://smba.trafficmanager.net/amer/v3/conversations/%s/activities" % team_id, + json={}, + ) + + with patch("time.time") as mock_time: + mock_time.return_value = self.start_time + + resp = self._initialize_pipeline( + initial_data={"signedParams": self._signed_params(installation_type)} + ) + pipeline_signature = resp.data["data"]["state"] + + resp = self._advance_step({"state": pipeline_signature}) + + assert resp.status_code == 200 + assert resp.data["status"] == "complete" + + token_body = responses.calls[0].request.body + assert token_body == urlencode( + { + "client_id": "msteams-client-id", + "client_secret": "msteams-client-secret", + "grant_type": "client_credentials", + "scope": "https://api.botframework.com/.default", + } + ) + + integration = Integration.objects.get(provider=self.provider.key) + assert integration.external_id == team_id + assert integration.name == "my_team" + assert integration.metadata == { + "access_token": "my_token", + "service_url": "https://smba.trafficmanager.net/amer/", + "expires_at": self.start_time + 86399 - 60 * 5, + "installation_type": installation_type, + "tenant_id": tenant_id, + } + assert OrganizationIntegration.objects.filter( + integration=integration, organization_id=self.organization.id + ).exists() + + return responses.calls[1].request.body.decode("utf-8") + + @responses.activate + def test_team_installation(self) -> None: + post_install_body = self._complete_install(installation_type="team") + assert f"organizations/{self.organization.slug}/alerts/rules/" in post_install_body + assert self.organization.name in post_install_body + + @responses.activate + def test_personal_installation(self) -> None: + post_install_body = self._complete_install(installation_type="tenant") + assert "Personal installation successful" in post_install_body + assert "/settings/account/notifications" in post_install_body + + @control_silo_test class MsTeamsIntegrationSendNotificationTest(TestCase): def setUp(self) -> None: From e44e20b6e3a1e85d56c993e39cf8cedfad784de6 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 29 May 2026 14:05:16 -0300 Subject: [PATCH 10/42] fix(replays): Stop page reloads on initial tab change (#116494) Seems to be an issue with nuqs on the initial tab change doing a fairly hefty page reload. To resolve this issue, we need to make the change "shallow". Closes JAVASCRIPT-3A1R --------- Co-authored-by: Codex --- .../replays/hooks/useActiveReplayTab.spec.tsx | 38 +++++++++++++++++++ .../replays/hooks/useActiveReplayTab.tsx | 4 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx b/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx index 954b5d0161c8..246917830bc7 100644 --- a/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx +++ b/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx @@ -1,6 +1,8 @@ +import type {OnUrlUpdateFunction} from 'nuqs/adapters/testing'; import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture'; import {OrganizationFixture} from 'sentry-fixture/organization'; +import {SentryNuqsTestingAdapter} from 'sentry-test/nuqsTestingAdapter'; import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; import {setWindowLocation} from 'sentry-test/utils'; @@ -150,4 +152,40 @@ describe('useActiveReplayTab', () => { }); }); }); + + it('should update the tab query parameter shallowly', async () => { + const onUrlUpdate = jest.fn< + ReturnType, + Parameters + >(); + + const {result, router} = renderHookWithProviders(useActiveReplayTab, { + initialProps: {}, + initialRouterConfig: { + location: {pathname: '/mock-pathname/', query: {}}, + }, + organization: OrganizationFixture({features: []}), + additionalWrapper: ({children}) => ( + + {children} + + ), + }); + + act(() => result.current.setActiveTab('network')); + + await waitFor(() => { + expect(router.location.query.t_main).toBe('network'); + }); + + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: '?t_main=network', + options: expect.objectContaining({shallow: true}), + }) + ); + }); }); diff --git a/static/app/utils/replays/hooks/useActiveReplayTab.tsx b/static/app/utils/replays/hooks/useActiveReplayTab.tsx index 0379b28ebc12..1919a073328e 100644 --- a/static/app/utils/replays/hooks/useActiveReplayTab.tsx +++ b/static/app/utils/replays/hooks/useActiveReplayTab.tsx @@ -58,7 +58,9 @@ export function useActiveReplayTab({isVideoReplay = false}: {isVideoReplay?: boo const [tabParam, setTabParam] = useQueryState( 't_main', - tabKeyParser.withDefault(defaultTab).withOptions({clearOnDefault: false}) + tabKeyParser + .withDefault(defaultTab) + .withOptions({clearOnDefault: false, shallow: true}) ); return { From c8dc13c8b5cf9a76d55f5dc6d8c0826d1d61a89f Mon Sep 17 00:00:00 2001 From: Sofia Rest <68917129+srest2021@users.noreply.github.com> Date: Fri, 29 May 2026 10:05:22 -0700 Subject: [PATCH 11/42] ref(seer): Unify Seer project settings update helper and add tuning and auto_create_pr fields (#116352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate `update_seer_project_settings` and `bulk_update_seer_project_settings` into a single function that bulk deletes/creates project options across 1+ project ids. Also, strip seat-based business logic (stopping point → auto_create_pr syncing, tuning validation) out of the update helper and add tuning and auto_create_pr as update fields, so it becomes a pure write layer and can be used by other callsites. Followups: https://github.com/getsentry/sentry/pull/116356, https://github.com/getsentry/sentry/pull/115962 #### Why: By moving business logic out, other callsites can do updates without hidden side effects. - Independent auto_create_pr and tuning updates are needed by legacy seer and will be used when we add legacy seer to the project settings endpoints: https://github.com/getsentry/sentry/pull/115962 - https://github.com/getsentry/sentry/pull/116356 reroutes existing callsites that modify seer project settings (`_write_preferences_to_sentry_db`, `configure_seer_for_existing_org`, `set_default_project_seer_preferences`) to the update helper to ensure that handoff options are either cleared or added atomically AND to skip unnecessary connected repo operations. It also removes `Project` row locks and simplifies `clear_preference_automation_handoff`. #### Other cleanups: - `SeerProjectSettingsUpdate` uses snake_case keys via `CamelSnakeSerializer`. - Add tuning to the project settings endpoints serializers (as opposed to consolidating tuning="off" into stopping_point="off"), again to make it easier to support legacy seer. - Endpoint returns `autoCreatePr` and `automationTuning` in response. - Stopping point syncs `auto_create_pr` automatically. --- src/sentry/seer/autofix/utils.py | 97 ++--- .../seer/endpoints/project_seer_settings.py | 73 ++-- .../sentry/seer/autofix/test_autofix_utils.py | 356 ++++++------------ .../endpoints/test_project_seer_settings.py | 171 +++++++-- 4 files changed, 319 insertions(+), 378 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 34a7443dd3b3..7dc97c642147 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Iterable, Mapping from datetime import UTC, datetime from enum import StrEnum -from typing import Any, Literal, NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict import orjson import sentry_sdk @@ -728,16 +728,19 @@ def _get_project_option(key: str) -> Any: class SeerProjectSettingsUpdate(TypedDict, total=False): agent: AutomationCodingAgent - integrationId: int - stoppingPoint: AutofixStoppingPoint | Literal["off"] - scannerAutomation: bool + integration_id: int + stopping_point: str + automation_tuning: str + scanner_automation: bool + auto_create_pr: bool -def _get_seer_project_options_to_update( - data: SeerProjectSettingsUpdate, -) -> tuple[dict[str, Any], list[str]]: - """Return (options_to_set, options_to_clear) for the given Seer project settings update. - Clear the option if it's the default; otherwise, set it.""" +def update_seer_project_settings(project_ids: list[int], data: SeerProjectSettingsUpdate) -> None: + """Apply Seer project settings to one or more projects. + For any ProjectOptions, delete the row if we're setting that field to its default.""" + if not project_ids or not data: + return + options_to_set: dict[str, Any] = {} options_to_clear: list[str] = [] @@ -756,84 +759,44 @@ def _set_or_clear(key: str, value: Any, default: Any) -> None: "sentry:seer_automation_handoff_integration_id", ] else: - integration_id = data.get("integrationId") + integration_id = data.get("integration_id") if integration_id is None: raise ValueError("integrationId is required for external coding agents") options_to_set["sentry:seer_automation_handoff_point"] = AutofixHandoffPoint.ROOT_CAUSE options_to_set["sentry:seer_automation_handoff_target"] = agent options_to_set["sentry:seer_automation_handoff_integration_id"] = integration_id - if "scannerAutomation" in data: - _set_or_clear("sentry:seer_scanner_automation", data["scannerAutomation"], default=True) + if "scanner_automation" in data: + _set_or_clear("sentry:seer_scanner_automation", data["scanner_automation"], default=True) - if "stoppingPoint" not in data: - return options_to_set, options_to_clear - elif data["stoppingPoint"] == "off": - # Disable automation and leave stopping point and handoff_auto_create_pr unchanged - # so that reenabling restores the prior state. - _set_or_clear( - "sentry:autofix_automation_tuning", - AutofixAutomationTuningSettings.OFF, - default=AUTOFIX_AUTOMATION_TUNING_DEFAULT, - ) - else: - # Enable automation and set the stopping point. - _set_or_clear( - "sentry:autofix_automation_tuning", - AutofixAutomationTuningSettings.MEDIUM, - default=AUTOFIX_AUTOMATION_TUNING_DEFAULT, - ) + if "stopping_point" in data: _set_or_clear( "sentry:seer_automated_run_stopping_point", - data["stoppingPoint"], + data["stopping_point"], default=SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, ) - if data["stoppingPoint"] == AutofixStoppingPoint.OPEN_PR: - # Safe to set even if no external handoff is configured - # since we'll only read it if the other handoff options are all non-null. - options_to_set["sentry:seer_automation_handoff_auto_create_pr"] = True - else: - options_to_clear.append("sentry:seer_automation_handoff_auto_create_pr") - return options_to_set, options_to_clear - - -def update_seer_project_settings(project: Project, data: SeerProjectSettingsUpdate) -> None: - """Apply high-level Seer settings to a single project.""" - options_to_set, options_to_delete = _get_seer_project_options_to_update(data) - - with transaction.atomic(using=router.db_for_write(ProjectOption)): - # Lock project rows to serialize concurrent writes. - Project.objects.select_for_update().filter(id=project.id).first() - - for key in options_to_delete: - project.delete_option(key) - for key, value in options_to_set.items(): - project.update_option(key, value) - + if "auto_create_pr" in data: + _set_or_clear( + "sentry:seer_automation_handoff_auto_create_pr", data["auto_create_pr"], default=False + ) -def bulk_update_seer_project_settings( - projects: list[Project], data: SeerProjectSettingsUpdate -) -> None: - """Apply high-level Seer settings to multiple projects in bulk.""" - if not projects: - return + if "automation_tuning" in data: + _set_or_clear( + "sentry:autofix_automation_tuning", + data["automation_tuning"], + default=AUTOFIX_AUTOMATION_TUNING_DEFAULT, + ) - options_to_set, options_to_delete = _get_seer_project_options_to_update(data) - if not options_to_set and not options_to_delete: + if not options_to_set and not options_to_clear: return - project_ids = [p.id for p in projects] - with transaction.atomic(using=router.db_for_write(ProjectOption)): - # Lock project rows to serialize concurrent writes. - list(Project.objects.select_for_update().filter(id__in=project_ids).order_by("id")) - - if options_to_delete: + if options_to_clear: # Use _raw_delete to skip per-row post_delete signals that each trigger reload_cache. # For efficiency, we reload once per project after the transaction instead. ProjectOption.objects.filter( - project_id__in=project_ids, key__in=options_to_delete + project_id__in=project_ids, key__in=options_to_clear )._raw_delete(using=router.db_for_write(ProjectOption)) if options_to_set: diff --git a/src/sentry/seer/endpoints/project_seer_settings.py b/src/sentry/seer/endpoints/project_seer_settings.py index 9b13fdb3e370..8249b26f73cd 100644 --- a/src/sentry/seer/endpoints/project_seer_settings.py +++ b/src/sentry/seer/endpoints/project_seer_settings.py @@ -19,6 +19,7 @@ from sentry.api.event_search import QueryToken, SearchConfig, SearchFilter from sentry.api.event_search import parse_search_query as base_parse_search_query from sentry.api.paginator import OffsetPaginator +from sentry.api.serializers.rest_framework import CamelSnakeSerializer from sentry.constants import ( AUTOFIX_AUTOMATION_TUNING_DEFAULT, SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, @@ -36,7 +37,6 @@ from sentry.seer.autofix.utils import ( AutofixStoppingPoint, AutomationCodingAgent, - bulk_update_seer_project_settings, get_automation_handoff, get_valid_automated_run_stopping_points, update_seer_project_settings, @@ -80,6 +80,8 @@ class SeerProjectSettingsResponse(TypedDict): agent: str integrationId: str | None stoppingPoint: str + autoCreatePr: bool | None + automationTuning: str scannerAutomation: bool reposCount: int @@ -154,9 +156,11 @@ def _serialize(project: Project, settings: SeerProjectSettings) -> SeerProjectSe # No configured external handoff means use Seer agent. agent: str = "seer" integration_id: str | None = None + auto_create_pr: bool | None = None else: agent = handoff.target integration_id = str(handoff.integration_id) + auto_create_pr = handoff.auto_create_pr return SeerProjectSettingsResponse( projectId=str(project.id), @@ -164,6 +168,8 @@ def _serialize(project: Project, settings: SeerProjectSettings) -> SeerProjectSe agent=agent, integrationId=integration_id, stoppingPoint=stopping_point, + autoCreatePr=auto_create_pr, + automationTuning=settings["automation_tuning"], scannerAutomation=settings["scanner_automation"], reposCount=settings["repos_count"], ) @@ -307,22 +313,22 @@ def _apply_search_filters(queryset, filters: Sequence[QueryToken]): return queryset -class ProjectSettingsUpdateSerializer(serializers.Serializer): +class ProjectSettingsUpdateSerializer(CamelSnakeSerializer): agent = serializers.ChoiceField(choices=[*AutomationCodingAgent], required=False) - integrationId = serializers.IntegerField(required=False) - stoppingPoint = serializers.ChoiceField(choices=["off", *AutofixStoppingPoint], required=False) - scannerAutomation = serializers.BooleanField(required=False) - - def validate_stoppingPoint(self, value: str) -> str: - if value == "off": - return value + integration_id = serializers.IntegerField(required=False) + stopping_point = serializers.ChoiceField(choices=[*AutofixStoppingPoint], required=False) + scanner_automation = serializers.BooleanField(required=False) + automation_tuning = serializers.ChoiceField( + choices=[AutofixAutomationTuningSettings.OFF, AutofixAutomationTuningSettings.MEDIUM], + required=False, + ) - organization = self.context["organization"] - if value not in get_valid_automated_run_stopping_points(organization): + def validate_stopping_point(self, value: str) -> str: + if value not in get_valid_automated_run_stopping_points(self.context["organization"]): raise serializers.ValidationError(f'"{value}" is not a valid choice.') return value - def validate_integrationId(self, value: int) -> int: + def validate_integration_id(self, value: int) -> int: organization = self.context["organization"] org_integrations = integration_service.get_organization_integrations( organization_id=organization.id, integration_id=value @@ -332,25 +338,31 @@ def validate_integrationId(self, value: int) -> int: return value def validate(self, data): - if "agent" in data and data["agent"] != "seer" and "integrationId" not in data: + if "agent" in data and data["agent"] != "seer" and "integration_id" not in data: raise serializers.ValidationError( - {"integrationId": "Required when agent is an external coding agent."} + {"integration_id": "Required when agent is an external coding agent."} ) - if "integrationId" in data: + if "integration_id" in data: if "agent" not in data: raise serializers.ValidationError( - {"agent": "Required when integrationId is provided."} + {"agent": "Required when integration_id is provided."} ) elif data["agent"] == "seer": raise serializers.ValidationError( - {"agent": "Must be an external coding agent when integrationId is provided."} + {"agent": "Must be an external coding agent when integration_id is provided."} ) - has_update = any(k in data for k in ("agent", "stoppingPoint", "scannerAutomation")) - if not has_update: + if not any( + k in data + for k in ("agent", "stopping_point", "scanner_automation", "automation_tuning") + ): raise serializers.ValidationError("At least one update field must be provided.") + # Keep stopping point in sync with handoff auto_create_pr. + if "stopping_point" in data and "auto_create_pr" not in data: + data["auto_create_pr"] = data["stopping_point"] == AutofixStoppingPoint.OPEN_PR + return data @@ -373,20 +385,15 @@ def put(self, request: Request, project: Project) -> Response: if not serializer.is_valid(): return Response(serializer.errors, status=400) - update_seer_project_settings(project, serializer.validated_data) + data = serializer.validated_data + update_seer_project_settings([project.id], data) self.create_audit_entry( request=request, organization=project.organization, target_object=project.id, event=audit_log.get_event_id("AUTOFIX_SETTINGS_EDIT"), - data={ - "project_id": project.id, - "agent": serializer.validated_data.get("agent"), - "integration_id": serializer.validated_data.get("integrationId"), - "stopping_point": serializer.validated_data.get("stoppingPoint"), - "scanner_automation": serializer.validated_data.get("scannerAutomation"), - }, + data={"project_id": project.id, **data}, ) return Response(serialize_project(project)) @@ -455,21 +462,15 @@ def put(self, request: Request, organization: Organization) -> Response: return Response({"detail": "Invalid search query"}, status=400) projects = list(queryset) - bulk_update_seer_project_settings(projects, data) + if projects: + update_seer_project_settings([p.id for p in projects], data) self.create_audit_entry( request=request, organization=organization, target_object=organization.id, event=audit_log.get_event_id("AUTOFIX_SETTINGS_EDIT"), - data={ - "project_count": len(projects), - "project_ids": [p.id for p in projects], - "agent": data.get("agent"), - "integration_id": data.get("integrationId"), - "stopping_point": data.get("stoppingPoint"), - "scanner_automation": data.get("scannerAutomation"), - }, + data={"project_count": len(projects), "project_ids": [p.id for p in projects], **data}, ) return Response(status=204) diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 0c1796a3c1be..dbf59c6bf630 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -25,7 +25,6 @@ CodingAgentStatus, add_seer_project_repos, bulk_read_preferences_from_sentry_db, - bulk_update_seer_project_settings, bulk_write_preferences_to_sentry_db, clear_preference_automation_handoff, deduplicate_repositories, @@ -1609,188 +1608,134 @@ def test_returns_none_when_message_is_not_a_string(self) -> None: class TestUpdateSeerProjectSettings(TestCase): def setUp(self) -> None: super().setUp() - self.project = self.create_project(organization=self.organization) + self.project1 = self.create_project(organization=self.organization) + self.project2 = self.create_project(organization=self.organization) + + def test_updates_settings(self) -> None: + """All fields should be written to the correct project options.""" + update_seer_project_settings( + [self.project1.id], + { + "agent": AutomationCodingAgent.SEER, + "stopping_point": AutofixStoppingPoint.CODE_CHANGES, + "automation_tuning": AutofixAutomationTuningSettings.MEDIUM, + "scanner_automation": False, + }, + ) + + assert ( + self.project1.get_option("sentry:seer_automated_run_stopping_point") + == AutofixStoppingPoint.CODE_CHANGES + ) + assert ( + self.project1.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.MEDIUM + ) + assert self.project1.get_option("sentry:seer_scanner_automation") is False + assert self.project1.get_option("sentry:seer_automation_handoff_target") is None + + def test_mixed_sets_and_clears_settings(self) -> None: + """New and existing fields are upserted. Fields set to their defaults are cleared.""" + self.project1.update_option( + "sentry:seer_automation_handoff_target", + CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, + ) + self.project1.update_option( + "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE + ) + self.project1.update_option("sentry:seer_automation_handoff_integration_id", 42) + + update_seer_project_settings( + [self.project1.id], + {"agent": AutomationCodingAgent.SEER, "scanner_automation": False}, + ) + + assert self.project1.get_option("sentry:seer_automation_handoff_target") is None + assert self.project1.get_option("sentry:seer_automation_handoff_point") is None + assert self.project1.get_option("sentry:seer_automation_handoff_integration_id") is None + assert self.project1.get_option("sentry:seer_scanner_automation") is False + + assert not ProjectOption.objects.filter( + project=self.project1, key="sentry:seer_automation_handoff_target" + ).exists() def test_agent_seer_clears_handoff_options(self) -> None: """Setting agent=seer should delete all handoff-related project options.""" - self.project.update_option( + self.project1.update_option( "sentry:seer_automation_handoff_target", CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, ) - self.project.update_option( + self.project1.update_option( "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE ) - self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + self.project1.update_option("sentry:seer_automation_handoff_integration_id", 42) - update_seer_project_settings(self.project, {"agent": AutomationCodingAgent.SEER}) + update_seer_project_settings([self.project1.id], {"agent": AutomationCodingAgent.SEER}) - assert self.project.get_option("sentry:seer_automation_handoff_target") is None - assert self.project.get_option("sentry:seer_automation_handoff_point") is None - assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None + assert self.project1.get_option("sentry:seer_automation_handoff_target") is None + assert self.project1.get_option("sentry:seer_automation_handoff_point") is None + assert self.project1.get_option("sentry:seer_automation_handoff_integration_id") is None def test_agent_external_sets_handoff_options(self) -> None: - """Setting agent=cursor with integrationId should set handoff target, point, and integration ID.""" + """Setting agent=cursor with integration_id should set handoff target, point, and integration ID.""" update_seer_project_settings( - self.project, {"agent": AutomationCodingAgent.CURSOR, "integrationId": 99} + [self.project1.id], + {"agent": AutomationCodingAgent.CURSOR, "integration_id": 99}, ) assert ( - self.project.get_option("sentry:seer_automation_handoff_target") + self.project1.get_option("sentry:seer_automation_handoff_target") == CodingAgentProviderType.CURSOR_BACKGROUND_AGENT ) assert ( - self.project.get_option("sentry:seer_automation_handoff_point") + self.project1.get_option("sentry:seer_automation_handoff_point") == AutofixHandoffPoint.ROOT_CAUSE ) - assert self.project.get_option("sentry:seer_automation_handoff_integration_id") == 99 + assert self.project1.get_option("sentry:seer_automation_handoff_integration_id") == 99 def test_agent_external_requires_integration_id(self) -> None: - """Setting an external agent without integrationId should raise ValueError.""" + """Setting an external agent without integration_id should raise ValueError.""" with pytest.raises(ValueError): - update_seer_project_settings(self.project, {"agent": AutomationCodingAgent.CURSOR}) - - def test_agent_external_with_open_pr_sets_auto_create_pr(self) -> None: - """External agent + stoppingPoint=open_pr should set auto_create_pr=True.""" - update_seer_project_settings( - self.project, - { - "agent": AutomationCodingAgent.CURSOR, - "integrationId": 99, - "stoppingPoint": AutofixStoppingPoint.OPEN_PR, - }, - ) - - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - - def test_agent_external_with_non_open_pr_does_not_set_auto_create_pr(self) -> None: - """External agent + stoppingPoint!=open_pr should not set auto_create_pr.""" - update_seer_project_settings( - self.project, - { - "agent": AutomationCodingAgent.CURSOR, - "integrationId": 99, - "stoppingPoint": AutofixStoppingPoint.CODE_CHANGES, - }, - ) - - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False - - def test_stopping_point_off_sets_tuning_off(self) -> None: - """stoppingPoint=off should set tuning to OFF and preserve stopping point and auto_create_pr.""" - self.project.update_option( - "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM - ) - self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") - self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - - update_seer_project_settings(self.project, {"stoppingPoint": "off"}) + update_seer_project_settings( + [self.project1.id], {"agent": AutomationCodingAgent.CURSOR} + ) - assert ( - self.project.get_option("sentry:autofix_automation_tuning") - == AutofixAutomationTuningSettings.OFF - ) - assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True + def test_stopping_point_omitted_preserves_existing(self) -> None: + """Omitting stopping_point should leave stopping point and auto_create_pr unchanged.""" + self.project1.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + self.project1.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - def test_stopping_point_sets_tuning_medium_and_stores_value(self) -> None: - """A non-off stoppingPoint should set tuning to MEDIUM and store the value.""" - update_seer_project_settings( - self.project, {"stoppingPoint": AutofixStoppingPoint.ROOT_CAUSE} - ) + update_seer_project_settings([self.project1.id], {"scanner_automation": False}) - assert ( - self.project.get_option("sentry:autofix_automation_tuning") - == AutofixAutomationTuningSettings.MEDIUM - ) - assert ( - self.project.get_option("sentry:seer_automated_run_stopping_point") - == AutofixStoppingPoint.ROOT_CAUSE - ) + assert self.project1.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + assert self.project1.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - def test_stopping_point_omitted_preserves_existing_options(self) -> None: - """Omitting stoppingPoint from data should leave tuning, stopping point, and auto_create_pr unchanged.""" - self.project.update_option( + def test_automation_tuning_omitted_preserves_existing(self) -> None: + """Omitting automation_tuning should leave the existing value unchanged.""" + self.project1.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM ) - self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") - self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - update_seer_project_settings(self.project, {"scannerAutomation": False}) + update_seer_project_settings([self.project1.id], {"scanner_automation": False}) assert ( - self.project.get_option("sentry:autofix_automation_tuning") + self.project1.get_option("sentry:autofix_automation_tuning") == AutofixAutomationTuningSettings.MEDIUM ) - assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - - def test_stopping_point_non_open_pr_clears_auto_create_pr(self) -> None: - """Changing stoppingPoint away from open_pr should clear auto_create_pr.""" - self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - self.project.update_option( - "sentry:seer_automation_handoff_target", - CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, - ) + def test_bulk_updates_settings(self) -> None: + """The provided settings fields should be applied to every project.""" update_seer_project_settings( - self.project, {"stoppingPoint": AutofixStoppingPoint.CODE_CHANGES} - ) - - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False - assert not ProjectOption.objects.filter( - project=self.project, key="sentry:seer_automation_handoff_auto_create_pr" - ).exists() - - def test_stopping_point_open_pr_sets_auto_create_pr(self) -> None: - """stoppingPoint=open_pr should set auto_create_pr, even if no handoff is configured.""" - update_seer_project_settings(self.project, {"stoppingPoint": AutofixStoppingPoint.OPEN_PR}) - - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - - def test_scanner_automation_false(self) -> None: - """scannerAutomation=false should update the project option.""" - update_seer_project_settings(self.project, {"scannerAutomation": False}) - - assert self.project.get_option("sentry:seer_scanner_automation") is False - - def test_deletes_option_when_value_is_default(self) -> None: - """Setting a value equal to its registered default should delete the ProjectOption row.""" - self.project.update_option("sentry:seer_scanner_automation", False) - assert ProjectOption.objects.filter( - project=self.project, key="sentry:seer_scanner_automation" - ).exists() - - update_seer_project_settings(self.project, {"scannerAutomation": True}) - - assert not ProjectOption.objects.filter( - project=self.project, key="sentry:seer_scanner_automation" - ).exists() - - -class TestBulkUpdateSeerProjectSettings(TestCase): - def setUp(self) -> None: - super().setUp() - self.project_a = self.create_project(organization=self.organization) - self.project_b = self.create_project(organization=self.organization) - self.projects = [self.project_a, self.project_b] - - def test_empty_projects(self) -> None: - """Empty project list should be a no-op without errors.""" - bulk_update_seer_project_settings([], {"scannerAutomation": False}) - - def test_sets_options(self) -> None: - """All provided settings fields should be applied to every project.""" - bulk_update_seer_project_settings( - self.projects, + [self.project1.id, self.project2.id], { "agent": AutomationCodingAgent.CURSOR, - "integrationId": 99, - "stoppingPoint": AutofixStoppingPoint.OPEN_PR, - "scannerAutomation": False, + "integration_id": 99, + "stopping_point": AutofixStoppingPoint.OPEN_PR, + "scanner_automation": False, }, ) - for project in self.projects: + for project in [self.project1, self.project2]: assert ( project.get_option("sentry:seer_automation_handoff_target") == AutomationCodingAgent.CURSOR @@ -1800,113 +1745,45 @@ def test_sets_options(self) -> None: == AutofixHandoffPoint.ROOT_CAUSE ) assert project.get_option("sentry:seer_automation_handoff_integration_id") == 99 - assert ( - project.get_option("sentry:autofix_automation_tuning") - == AutofixAutomationTuningSettings.MEDIUM - ) assert ( project.get_option("sentry:seer_automated_run_stopping_point") == AutofixStoppingPoint.OPEN_PR ) - assert project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True assert project.get_option("sentry:seer_scanner_automation") is False - def test_agent_seer_clears_handoff_options(self) -> None: - """Switching to seer agent should delete handoff options across all projects.""" - for project in self.projects: - project.update_option( - "sentry:seer_automation_handoff_target", - CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, - ) - project.update_option( - "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE - ) - project.update_option("sentry:seer_automation_handoff_integration_id", 42) - - bulk_update_seer_project_settings(self.projects, {"agent": AutomationCodingAgent.SEER}) - - for project in self.projects: - assert project.get_option("sentry:seer_automation_handoff_target") is None - assert project.get_option("sentry:seer_automation_handoff_point") is None - assert project.get_option("sentry:seer_automation_handoff_integration_id") is None - - def test_upserts_existing_options(self) -> None: - """Existing options should be overwritten, not duplicated.""" - for project in self.projects: - project.update_option("sentry:seer_scanner_automation", True) - - bulk_update_seer_project_settings(self.projects, {"scannerAutomation": False}) - - for project in self.projects: - assert project.get_option("sentry:seer_scanner_automation") is False - assert ( - ProjectOption.objects.filter( - project=project, key="sentry:seer_scanner_automation" - ).count() - == 1 - ) - - def test_clears_option_when_value_is_default(self) -> None: - """Setting a value equal to its registered default should delete the ProjectOption row.""" - for project in self.projects: - project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + def test_empty_projects(self) -> None: + """Empty project list should be a no-op without errors.""" + update_seer_project_settings([], {"scanner_automation": False}) - bulk_update_seer_project_settings( - self.projects, - {"stoppingPoint": AutofixStoppingPoint(SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT)}, + def test_does_not_modify_excluded_projects(self) -> None: + """Projects not included in the update list should be completely unaffected.""" + self.project1.update_option( + "sentry:seer_automated_run_stopping_point", AutofixStoppingPoint.OPEN_PR + ) + self.project2.update_option( + "sentry:seer_automated_run_stopping_point", AutofixStoppingPoint.OPEN_PR ) - for project in self.projects: - assert ( - project.get_option("sentry:seer_automated_run_stopping_point") - == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT - ) - assert not ProjectOption.objects.filter( - project=project, key="sentry:seer_automated_run_stopping_point" - ).exists() - - def test_stopping_point_off_sets_tuning_off(self) -> None: - """stoppingPoint='off' should set tuning to OFF and preserve existing stopping point.""" - for project in self.projects: - project.update_option( - "sentry:seer_automated_run_stopping_point", AutofixStoppingPoint.OPEN_PR - ) - project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - - bulk_update_seer_project_settings(self.projects, {"stoppingPoint": "off"}) - - for project in self.projects: - assert ( - project.get_option("sentry:autofix_automation_tuning") - == AutofixAutomationTuningSettings.OFF - ) - - def test_mixed_sets_and_clears_options(self) -> None: - """Test that sets new options and deletes existing ones.""" - for project in self.projects: - project.update_option( - "sentry:seer_automation_handoff_target", - CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, - ) - project.update_option( - "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE - ) - project.update_option("sentry:seer_automation_handoff_integration_id", 42) - - bulk_update_seer_project_settings( - self.projects, - {"agent": AutomationCodingAgent.SEER, "scannerAutomation": False}, + update_seer_project_settings( + [self.project1.id], + { + "stopping_point": AutofixStoppingPoint.CODE_CHANGES, + "agent": AutomationCodingAgent.SEER, + }, ) - for project in self.projects: - assert project.get_option("sentry:seer_automation_handoff_target") is None - assert project.get_option("sentry:seer_automation_handoff_point") is None - assert project.get_option("sentry:seer_automation_handoff_integration_id") is None - assert project.get_option("sentry:seer_scanner_automation") is False + assert ( + self.project1.get_option("sentry:seer_automated_run_stopping_point") + == AutofixStoppingPoint.CODE_CHANGES + ) + assert ( + self.project2.get_option("sentry:seer_automated_run_stopping_point") + == AutofixStoppingPoint.OPEN_PR + ) - def test_omitted_fields_preserve_existing_options(self) -> None: - """Updating one field should not clobber unrelated existing options.""" - for project in self.projects: + def test_bulk_omitted_fields_preserve_existing_options(self) -> None: + """Updating one field should not clobber unrelated existing options across multiple projects.""" + for project in [self.project1, self.project2]: project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM ) @@ -1915,9 +1792,12 @@ def test_omitted_fields_preserve_existing_options(self) -> None: ) project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - bulk_update_seer_project_settings(self.projects, {"scannerAutomation": False}) + update_seer_project_settings( + [self.project1.id, self.project2.id], + {"scanner_automation": False}, + ) - for project in self.projects: + for project in [self.project1, self.project2]: assert ( project.get_option("sentry:autofix_automation_tuning") == AutofixAutomationTuningSettings.MEDIUM diff --git a/tests/sentry/seer/endpoints/test_project_seer_settings.py b/tests/sentry/seer/endpoints/test_project_seer_settings.py index ffc0634513cd..ae1c2bcc8495 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_settings.py +++ b/tests/sentry/seer/endpoints/test_project_seer_settings.py @@ -32,6 +32,8 @@ def test_get_returns_defaults(self) -> None: "agent": "seer", "integrationId": None, "stoppingPoint": "off", + "autoCreatePr": None, + "automationTuning": "off", "scannerAutomation": True, "reposCount": 0, } @@ -48,10 +50,13 @@ def test_get_returns_configured_project_options(self) -> None: assert response.status_code == 200 assert response.data["stoppingPoint"] == "open_pr" + assert response.data["autoCreatePr"] is None + assert response.data["automationTuning"] == "medium" assert response.data["scannerAutomation"] is False - def test_get_returns_external_agent_with_integration_id(self) -> None: - """A project with an external handoff should return the agent alias and integration ID.""" + def test_get_external_agent_with_integration_id(self) -> None: + """A project with an external handoff should return the agent, integration ID, + and autoCreatePr from the handoff config.""" self.project.update_option( "sentry:seer_automation_handoff_target", "cursor_background_agent" ) @@ -65,6 +70,23 @@ def test_get_returns_external_agent_with_integration_id(self) -> None: assert response.status_code == 200 assert response.data["agent"] == "cursor_background_agent" assert response.data["integrationId"] == "42" + assert response.data["autoCreatePr"] is False + + def test_get_external_agent_with_auto_create_pr(self) -> None: + """autoCreatePr should reflect the handoff config value.""" + self.project.update_option( + "sentry:seer_automation_handoff_target", "cursor_background_agent" + ) + self.project.update_option( + "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE + ) + self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) + + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data["autoCreatePr"] is True def test_get_stopping_point_off_when_tuning_off(self) -> None: """stoppingPoint should be 'off' when tuning is OFF.""" @@ -77,6 +99,7 @@ def test_get_stopping_point_off_when_tuning_off(self) -> None: assert response.status_code == 200 assert response.data["stoppingPoint"] == "off" + assert response.data["automationTuning"] == "off" def test_get_stopping_point_when_tuning_on(self) -> None: """When tuning is not OFF, stoppingPoint should reflect the stored value.""" @@ -89,6 +112,7 @@ def test_get_stopping_point_when_tuning_on(self) -> None: assert response.status_code == 200 assert response.data["stoppingPoint"] == "root_cause" + assert response.data["automationTuning"] == "medium" def test_get_repos_count(self) -> None: """reposCount should reflect active SeerProjectRepository rows.""" @@ -119,7 +143,9 @@ def test_get_repos_count_excludes_inactive_repos(self) -> None: def test_put_returns_updated_settings(self) -> None: """PUT response should contain the full updated settings object.""" response = self.client.put( - self.url, data={"agent": "seer", "stoppingPoint": "code_changes"}, format="json" + self.url, + data={"agent": "seer", "stoppingPoint": "code_changes", "automationTuning": "medium"}, + format="json", ) assert response.status_code == 200 @@ -152,17 +178,50 @@ def test_put_scanner_automation(self) -> None: assert response.status_code == 200 assert response.data["scannerAutomation"] is False - def test_put_stopping_point_off(self) -> None: - """PUT stoppingPoint=off should disable automation.""" - self.project.update_option( - "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM - ) - self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + def test_put_stopping_point(self) -> None: + response = self.client.put(self.url, data={"stoppingPoint": "open_pr"}, format="json") + + assert response.status_code == 200 + assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" - response = self.client.put(self.url, data={"stoppingPoint": "off"}, format="json") + def test_put_stopping_point_open_pr_syncs_auto_create_pr(self) -> None: + """Setting stoppingPoint to open_pr should also set auto_create_pr to True.""" + response = self.client.put(self.url, data={"stoppingPoint": "open_pr"}, format="json") assert response.status_code == 200 - assert response.data["stoppingPoint"] == "off" + assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True + + def test_put_stopping_point_non_open_pr_clears_auto_create_pr(self) -> None: + """Setting stoppingPoint to non-open_pr should clear auto_create_pr.""" + self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) + + response = self.client.put(self.url, data={"stoppingPoint": "code_changes"}, format="json") + + assert response.status_code == 200 + assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "code_changes" + assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False + + def test_put_automation_tuning(self) -> None: + """automationTuning accepts off and medium.""" + response = self.client.put(self.url, data={"automationTuning": "off"}, format="json") + assert response.status_code == 200 + assert ( + self.project.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.OFF + ) + + response = self.client.put(self.url, data={"automationTuning": "medium"}, format="json") + assert response.status_code == 200 + assert ( + self.project.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.MEDIUM + ) + + def test_put_automation_tuning_rejects_granular(self) -> None: + """Granular tuning values like 'high' should be rejected.""" + response = self.client.put(self.url, data={"automationTuning": "high"}, format="json") + assert response.status_code == 400 def test_put_requires_at_least_one_update_field(self) -> None: """Sending no update fields should return 400.""" @@ -270,6 +329,8 @@ def test_get_returns_defaults(self) -> None: "agent": "seer", "integrationId": None, "stoppingPoint": "off", + "autoCreatePr": None, + "automationTuning": "off", "scannerAutomation": True, "reposCount": 0, } @@ -286,11 +347,13 @@ def test_get_returns_configured_project_options(self) -> None: assert response.status_code == 200 assert response.data[0]["stoppingPoint"] == "open_pr" + assert response.data[0]["autoCreatePr"] is None + assert response.data[0]["automationTuning"] == "medium" assert response.data[0]["scannerAutomation"] is False - def test_get_returns_external_agent_with_integration_id(self) -> None: - """A project configured with an external handoff target should return - the alias and integration ID.""" + def test_get_external_agent_with_integration_id(self) -> None: + """A project configured with an external handoff should return the agent, + integration ID, and autoCreatePr from the handoff config.""" self.project.update_option( "sentry:seer_automation_handoff_target", "cursor_background_agent" ) @@ -304,6 +367,23 @@ def test_get_returns_external_agent_with_integration_id(self) -> None: assert response.status_code == 200 assert response.data[0]["agent"] == "cursor_background_agent" assert response.data[0]["integrationId"] == "42" + assert response.data[0]["autoCreatePr"] is False + + def test_get_external_agent_with_auto_create_pr(self) -> None: + """autoCreatePr should reflect the handoff config value.""" + self.project.update_option( + "sentry:seer_automation_handoff_target", "cursor_background_agent" + ) + self.project.update_option( + "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE + ) + self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) + + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data[0]["autoCreatePr"] is True def test_get_stopping_point_off_when_tuning_off(self) -> None: """When tuning is OFF, stoppingPoint should be 'off' regardless of the @@ -317,6 +397,7 @@ def test_get_stopping_point_off_when_tuning_off(self) -> None: assert response.status_code == 200 assert response.data[0]["stoppingPoint"] == "off" + assert response.data[0]["automationTuning"] == "off" def test_get_stopping_point_when_tuning_on(self) -> None: """When tuning is not OFF, stoppingPoint should reflect the stored value.""" @@ -329,6 +410,7 @@ def test_get_stopping_point_when_tuning_on(self) -> None: assert response.status_code == 200 assert response.data[0]["stoppingPoint"] == "root_cause" + assert response.data[0]["automationTuning"] == "medium" def test_get_repos_count(self) -> None: """reposCount should reflect the number of active SeerProjectRepository rows.""" @@ -635,41 +717,56 @@ def test_put_applies_to_filtered_projects_only(self) -> None: assert project2.get_option("sentry:seer_scanner_automation") is False assert self.project.get_option("sentry:seer_scanner_automation") is True - def test_put_requires_at_least_one_update_field(self) -> None: - """Sending only query with no update fields should return 400.""" - response = self.client.put(self.url, data={"query": ""}, format="json") - assert response.status_code == 400 + def test_put_updates_settings(self) -> None: + """Bulk update with multiple seer agent fields should apply all of them.""" + project2 = self.create_project(organization=self.organization) - def test_put_requires_integration_id_for_external_agent(self) -> None: - """External agent without integrationId should return 400.""" response = self.client.put( - self.url, data={"agent": "cursor_background_agent"}, format="json" + self.url, + data={ + "agent": "seer", + "stoppingPoint": "code_changes", + "automationTuning": "medium", + "scannerAutomation": False, + }, + format="json", ) - assert response.status_code == 400 - def test_put_rejects_invalid_agent(self) -> None: - """An unrecognized agent value should return 400.""" - response = self.client.put(self.url, data={"agent": "invalid"}, format="json") - assert response.status_code == 400 - - def test_put_rejects_invalid_stopping_point(self) -> None: - """An unrecognized stoppingPoint value should return 400.""" - response = self.client.put(self.url, data={"stoppingPoint": "invalid"}, format="json") - assert response.status_code == 400 + assert response.status_code == 204 + for p in (self.project, project2): + assert p.get_option("sentry:seer_automated_run_stopping_point") == "code_changes" + assert ( + p.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.MEDIUM + ) + assert p.get_option("sentry:seer_scanner_automation") is False - def test_put_rejects_integration_id_from_other_org(self) -> None: - """An integration ID that doesn't belong to this org should return 400.""" - other_org = self.create_organization() + def test_put_updates_settings_with_external_agent(self) -> None: + """Bulk update with external agent fields should set agent, integration, and stopping point.""" + project2 = self.create_project(organization=self.organization) integration = self.create_integration( - organization=other_org, external_id="other", provider="github" + organization=self.organization, external_id="ext", provider="github" ) response = self.client.put( self.url, - data={"agent": "cursor_background_agent", "integrationId": integration.id}, + data={ + "agent": "cursor_background_agent", + "integrationId": integration.id, + "stoppingPoint": "open_pr", + "scannerAutomation": True, + }, format="json", ) - assert response.status_code == 400 + + assert response.status_code == 204 + for p in (self.project, project2): + assert ( + p.get_option("sentry:seer_automation_handoff_target") == "cursor_background_agent" + ) + assert p.get_option("sentry:seer_automation_handoff_integration_id") == integration.id + assert p.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + assert p.get_option("sentry:seer_scanner_automation") is True def test_put_invalid_search_query_returns_400(self) -> None: """A malformed query value should return 400.""" From d58a6dfca539a80c22ba83639b52f86f900e63f6 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki <44422760+DominikB2014@users.noreply.github.com> Date: Fri, 29 May 2026 13:18:05 -0400 Subject: [PATCH 12/42] fix(dashboards): Move global filter loading spinner to dropdown footer (#116342) ## Summary Move the loading indicator from dropdown trigger to the dropdown list. This is exactly how the other search bars handle it (for example searching in the explore search bar). This resolves two issues: 1. Having the spinner in the trigger causes the trigger to change widths when it goes from not-loading -> loading, this results in some annoying shifting (See [DAIN-1636](https://linear.app/getsentry/issue/DAIN-1636) for video) 2. Having the trigger on the dropdown list more accurately reflects the state, the list of options are loading. Before it looked like the selected item is being changed. ### Before image ### After image Refs DAIN-1636 --------- Co-authored-by: Claude --- .../app/views/dashboards/filtersBar.spec.tsx | 19 +++------ .../globalFilter/filterSelector.spec.tsx | 12 +++--- .../globalFilter/filterSelector.tsx | 42 +++++++++++++------ .../globalFilter/filterSelectorTrigger.tsx | 39 +++++------------ 4 files changed, 52 insertions(+), 60 deletions(-) diff --git a/static/app/views/dashboards/filtersBar.spec.tsx b/static/app/views/dashboards/filtersBar.spec.tsx index 653c3ca5d9f7..6d32ab62e01a 100644 --- a/static/app/views/dashboards/filtersBar.spec.tsx +++ b/static/app/views/dashboards/filtersBar.spec.tsx @@ -5,12 +5,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {ReleaseFixture} from 'sentry-fixture/release'; import {TagsFixture} from 'sentry-fixture/tags'; -import { - render, - screen, - waitFor, - waitForElementToBeRemoved, -} from 'sentry-test/reactTestingLibrary'; +import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; import type {Organization} from 'sentry/types/organization'; import {FieldKind} from 'sentry/utils/fields'; @@ -63,9 +58,8 @@ describe('FiltersBar', () => { }, }); renderFilterBar({location: newLocation}); - await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); expect( - screen.getByRole('button', {name: /browser\.name.*Chrome/i}) + await screen.findByRole('button', {name: /browser\.name.*Chrome/i}) ).toBeInTheDocument(); }); @@ -80,8 +74,7 @@ describe('FiltersBar', () => { }, }); renderFilterBar({location: newLocation, hasUnsavedChanges: true}); - await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); - expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument(); + expect(await screen.findByRole('button', {name: 'Save'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument(); }); @@ -98,9 +91,8 @@ describe('FiltersBar', () => { }); renderFilterBar({location: newLocation}); - await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); expect( - screen.getByRole('button', {name: /browser\.name.*Chrome/i}) + await screen.findByRole('button', {name: /browser\.name.*Chrome/i}) ).toBeInTheDocument(); expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument(); expect(screen.queryByRole('button', {name: 'Cancel'})).not.toBeInTheDocument(); @@ -215,9 +207,8 @@ describe('FiltersBar', () => { hasUnsavedChanges: true, prebuiltDashboardId: PrebuiltDashboardId.FRONTEND_SESSION_HEALTH, }); - await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); expect( - screen.getByRole('button', {name: /browser\.name.*Chrome/i}) + await screen.findByRole('button', {name: /browser\.name.*Chrome/i}) ).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Save for Everyone'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument(); diff --git a/static/app/views/dashboards/globalFilter/filterSelector.spec.tsx b/static/app/views/dashboards/globalFilter/filterSelector.spec.tsx index 448259dfd665..2f4c5be2dcf9 100644 --- a/static/app/views/dashboards/globalFilter/filterSelector.spec.tsx +++ b/static/app/views/dashboards/globalFilter/filterSelector.spec.tsx @@ -38,7 +38,7 @@ describe('FilterSelector', () => { ); const button = screen.getByRole('button', { - name: `${mockGlobalFilter.tag.key} contains`, + name: `${mockGlobalFilter.tag.key} contains All`, }); await userEvent.click(button); @@ -58,7 +58,7 @@ describe('FilterSelector', () => { ); const button = screen.getByRole('button', { - name: `${mockGlobalFilter.tag.key} contains`, + name: `${mockGlobalFilter.tag.key} contains All`, }); await userEvent.click(button); @@ -90,7 +90,7 @@ describe('FilterSelector', () => { /> ); - const button = screen.getByRole('button', {name: mockGlobalFilter.tag.key + ' :'}); + const button = screen.getByRole('button', {name: /^browser :/}); await userEvent.click(button); expect(screen.getByRole('checkbox', {name: 'Select firefox'})).toBeChecked(); @@ -108,7 +108,7 @@ describe('FilterSelector', () => { ); const button = screen.getByRole('button', { - name: `${mockGlobalFilter.tag.key} contains`, + name: `${mockGlobalFilter.tag.key} contains All`, }); await userEvent.click(button); await userEvent.click(screen.getByRole('button', {name: 'Remove Filter'})); @@ -209,7 +209,7 @@ describe('FilterSelector', () => { ); const button = screen.getByRole('button', { - name: `${SpanFields.USER_GEO_SUBREGION} contains`, + name: `${SpanFields.USER_GEO_SUBREGION} contains All`, }); await userEvent.click(button); @@ -240,7 +240,7 @@ describe('FilterSelector', () => { ); const button = screen.getByRole('button', { - name: `${mockGlobalFilter.tag.key} contains`, + name: `${mockGlobalFilter.tag.key} contains All`, }); await userEvent.click(button); diff --git a/static/app/views/dashboards/globalFilter/filterSelector.tsx b/static/app/views/dashboards/globalFilter/filterSelector.tsx index ccf727c10e19..0bf9a24589d4 100644 --- a/static/app/views/dashboards/globalFilter/filterSelector.tsx +++ b/static/app/views/dashboards/globalFilter/filterSelector.tsx @@ -16,6 +16,7 @@ import {Flex} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {useStagedCompactSelect} from 'sentry/components/pageFilters/useStagedCompactSelect'; import { @@ -342,10 +343,15 @@ export function FilterSelector({ activeFilterValues={filterValues} operator={stagedOperator} options={translatedOptions} - queryResult={queryResult} /> ); + const loadingFooter = isFetching ? ( + + + + ) : null; + if (!canSelectMultipleValues) { return ( { setStagedFilterValues([]); }} + menuFooter={loadingFooter} menuTitle={ {t('%s Filter', getDatasetLabel(globalFilter.dataset))} @@ -421,17 +428,22 @@ export function FilterSelector({ isFetching ? t('Loading filter values...') : t('No filter values found') } menuFooter={ - hasStagedChanges ? ( - - dispatch({type: 'remove staged'})} - /> - { - dispatch({type: 'remove staged'}); - handleChange(stagedSelect.value); - }} - /> + hasStagedChanges || isFetching ? ( + + {loadingFooter} + {hasStagedChanges && ( + + dispatch({type: 'remove staged'})} + /> + { + dispatch({type: 'remove staged'}); + handleChange(stagedSelect.value); + }} + /> + + )} ) : null } @@ -517,6 +529,12 @@ export const MenuTitleWrapper = styled('span')` padding-bottom: ${p => p.theme.space.xs}; `; +const FooterLoadingIndicator = styled(LoadingIndicator)` + && { + margin: 0; + } +`; + const OperatorFlex = styled(Flex)` margin-left: -${p => p.theme.space.sm}; `; diff --git a/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx b/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx index e53dab57a1b7..32960f5711cf 100644 --- a/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx +++ b/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx @@ -1,12 +1,8 @@ -import styled from '@emotion/styled'; -import type {UseQueryResult} from '@tanstack/react-query'; - import {Badge} from '@sentry/scraps/badge'; import type {SelectOption} from '@sentry/scraps/compactSelect'; import {Container, Flex} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {OP_LABELS} from 'sentry/components/searchQueryBuilder/tokens/filter/utils'; import {TermOperator} from 'sentry/components/searchSyntax/parser'; import {t} from 'sentry/locale'; @@ -19,7 +15,6 @@ type FilterSelectorTriggerProps = { globalFilter: GlobalFilter; operator: TermOperator; options: Array>; - queryResult: UseQueryResult; }; export function FilterSelectorTrigger({ @@ -27,12 +22,10 @@ export function FilterSelectorTrigger({ activeFilterValues, operator, options, - queryResult, }: FilterSelectorTriggerProps) { - const {isFetching} = queryResult; const {tag} = globalFilter; - const shouldShowBadge = !isFetching && activeFilterValues.length > 1; + const shouldShowBadge = activeFilterValues.length > 1; // "All" means no filter is applied (empty selection). We intentionally avoid // comparing against options.length because when tag values fail to load, @@ -64,20 +57,17 @@ export function FilterSelectorTrigger({ {opLabel} - {!isFetching && - (isAllSelected ? ( - - {t('All')} + {isAllSelected ? ( + + {t('All')} + + ) : ( + + + {label} - ) : ( - - - {label} - - - ))} - - {isFetching && } + + )} {shouldShowBadge && ( @@ -87,10 +77,3 @@ export function FilterSelectorTrigger({ ); } - -const InlineLoadingIndicator = styled(LoadingIndicator)` - && { - margin: 0; - margin-left: ${p => p.theme.space.xs}; - } -`; From 2068dffba218c884549b6a3a7b458e6bf791689d Mon Sep 17 00:00:00 2001 From: Hector Dearman Date: Fri, 29 May 2026 18:19:31 +0100 Subject: [PATCH 13/42] feat(night-shift): be more conservative about which issues get autofixed (#116476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make Night Shift more conservative about which issues it autofixes. The triage agent assigns each candidate issue one of three verdicts: - **`autofix`** → runs a full Seer autofix to the project's stopping point (can open a code-change PR, with no human consulted before the fix is written) - **`root_cause_only`** → root-cause analysis only; a human decides what to do - **`skip`** → nothing This PR tightens the `autofix` decision so we only ship code changes for issues we're convinced can be fixed correctly without human involvement. Anything less now falls back to `root_cause_only` — investigation is still valuable, but a human stays in the loop. ## Changes **Stricter triage prompt** (`agentic_triage.py`) - Frames the verdicts as a "ladder of increasing caution" — default to the least aggressive verdict; when torn, pick the more conservative one. - Gates `autofix` behind a hard ALL-of checklist: exact root cause confirmed by reading code, exactly one clearly-correct fix, small/localized change, can't change intended behavior or need human judgment, not in a high-blast-radius area (auth, billing, security, concurrency, migrations…), and shippable without a human reviewing the approach. - Makes `root_cause_only` the explicit default for anything fixable that doesn't clear the bar. - Notes that a high fixability score alone is **not** a reason to autofix. Agent transcript: https://claudescope.sentry.dev/share/yiuFYRbvXKCU5YRlGYCutt_oRvbpmMBZoVfko81-1Xo --- .../tasks/seer/night_shift/agentic_triage.py | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/src/sentry/tasks/seer/night_shift/agentic_triage.py b/src/sentry/tasks/seer/night_shift/agentic_triage.py index 0930721e73af..be2d71411210 100644 --- a/src/sentry/tasks/seer/night_shift/agentic_triage.py +++ b/src/sentry/tasks/seer/night_shift/agentic_triage.py @@ -314,18 +314,46 @@ def _build_triage_prompt( When evaluating each issue, consider whether an AI coding agent with full codebase access could fix the ROOT CAUSE of the issue — not just add try/except or defensive - checks around it. Use these criteria: - - Clearly fixable in code (-> autofix): - - The bug is a clear mistake in application logic (wrong key, off-by-one, - missing None check on app data) - - Root cause is visible in application code within a connected repository - - Straightforward change to business logic - - Worth investigating but not auto-fixable (-> root_cause_only): - - Likely fixable but requires non-trivial investigation or cross-cutting changes - - Error originates in third-party libraries, vendor code, or framework internals - - Root cause is outside the code (filesystem, external services, environment) + checks around it. + + The verdicts form a ladder of increasing caution. Default to the LEAST + aggressive verdict that fits, and only step up to `autofix` when you have + cleared a high bar. When you are torn between two verdicts, always pick + the more conservative one (`root_cause_only` over `autofix`, `skip` over + `root_cause_only`). + + Autofix actually opens a code change with no human in the loop before it + is written, so reserve it for issues you are CONVINCED can be fixed + correctly and automatically. Choose `autofix` ONLY when ALL of the + following hold: + - You have pinpointed the exact root cause in application code (specific + file and function), confirmed by reading the code — not a hypothesis. + - There is exactly ONE clearly-correct fix. If several plausible fixes + exist and choosing between them depends on product intent or domain + knowledge you don't have, it is NOT an autofix. + - The fix is small and localized. It does not require a redesign, a new + API or abstraction, a schema/data migration, or coordinated changes + across many files or systems. + - Applying the fix cannot plausibly change intended behavior or make + things worse, and needs no human judgment to confirm it is correct. + - The change is not in a high-blast-radius area — authentication, + permissions/access control, billing or payments, security, money + math, concurrency/locking, or data deletion/migration — unless the fix + is truly trivial and obviously correct. + - You would be comfortable shipping this fix without a human reviewing + the approach first. + If you cannot honestly check every box, do NOT autofix. + + Worth investigating but not safe to auto-fix (-> root_cause_only): + - Likely fixable, but you are not fully confident the fix is correct, or + it needs human judgment, or it touches a high-blast-radius area. + - The fix requires non-trivial investigation, design decisions, or + cross-cutting changes. + - Error originates in third-party libraries, vendor code, or framework internals. + - Root cause is outside the code (filesystem, external services, environment). + This is the default home for anything fixable that doesn't clear the + autofix bar — investigating the root cause is still valuable, and a human + decides what to do with it. Not worth processing (-> skip): - The issue is vague with no actionable stacktrace @@ -337,7 +365,8 @@ def _build_triage_prompt( the issue is to be fixable (0.0 = not fixable, 1.0 = very fixable). Issues marked "not scored" have not been evaluated yet — treat them neutrally rather than assuming they are unfixable. Use the score as a signal but verify with - your own investigation. + your own investigation. A high fixability score is NOT on its own a reason + to autofix — it only means investigation is likely worthwhile. For each verdict, fill the `reason` field. For `autofix` and `root_cause_only` verdicts, the `reason` is handed off as context to the downstream autofix agent From 01b7ff97121740b286e19654ca583612e178f7c5 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Fri, 29 May 2026 13:22:48 -0400 Subject: [PATCH 14/42] ref(bitbucket-server): Remove legacy pipeline setup views (#116489) Now that the API-driven pipeline is in place, drop the legacy `InstallationConfigView`, `OAuthLoginView`, and `OAuthCallbackView` classes (and the `InstallationForm` they used), the associated Django template, and the legacy-flow tests. `get_pipeline_views()` now returns an empty list; `get_pipeline_api_steps()` is the only setup path. Fixes [VDY-103](https://linear.app/getsentry/issue/VDY-103/remove-legacy-bitbucket-server-integration-setup-views) --- .../bitbucket_server/integration.py | 164 +-------- .../integrations/bitbucket-server-config.html | 57 --- .../bitbucket_server/test_integration.py | 332 ------------------ 3 files changed, 1 insertion(+), 552 deletions(-) delete mode 100644 src/sentry/templates/sentry/integrations/bitbucket-server-config.html diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 807c0be881c0..76a50c95a3e8 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -6,14 +6,8 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import load_pem_private_key -from django import forms -from django.core.validators import URLValidator -from django.http import HttpResponseRedirect from django.http.request import HttpRequest -from django.http.response import HttpResponseBase -from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ -from django.views.decorators.csrf import csrf_exempt from rest_framework import serializers from rest_framework.fields import BooleanField, CharField, URLField @@ -47,7 +41,6 @@ from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView from sentry.shared_integrations.exceptions import ApiError, IntegrationError from sentry.users.models.identity import Identity -from sentry.web.helpers import render_to_response from .client import BitbucketServerClient, BitbucketServerSetupClient from .repository import BitbucketServerRepositoryProvider @@ -106,161 +99,6 @@ ) -class InstallationForm(forms.Form): - url = forms.CharField( - label=_("Bitbucket URL"), - help_text=_( - "The base URL for your Bitbucket Server instance, including the host and protocol." - ), - widget=forms.TextInput(attrs={"placeholder": "https://bitbucket.example.com"}), - validators=[URLValidator()], - ) - verify_ssl = forms.BooleanField( - label=_("Verify SSL"), - help_text=_( - "By default, we verify SSL certificates " - "when making requests to your Bitbucket instance." - ), - widget=forms.CheckboxInput(), - required=False, - initial=True, - ) - consumer_key = forms.CharField( - label=_("Bitbucket Consumer Key"), - widget=forms.TextInput(attrs={"placeholder": "sentry-consumer-key"}), - ) - private_key = forms.CharField( - label=_("Bitbucket Consumer Private Key"), - widget=forms.Textarea( - attrs={ - "placeholder": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" - } - ), - ) - - def clean_url(self): - """Strip off trailing / as they cause invalid URLs downstream""" - return self.cleaned_data["url"].rstrip("/") - - def clean_private_key(self): - data = self.cleaned_data["private_key"] - - try: - load_pem_private_key(data.encode("utf-8"), None, default_backend()) - except Exception: - raise forms.ValidationError( - "Private key must be a valid SSH private key encoded in a PEM format." - ) - return data - - def clean_consumer_key(self): - data = self.cleaned_data["consumer_key"] - if len(data) > 200: - raise forms.ValidationError("Consumer key is limited to 200 characters.") - return data - - -class InstallationConfigView: - """ - Collect the OAuth client credentials from the user. - """ - - def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpResponseBase: - if request.method == "POST": - form = InstallationForm(request.POST) - if form.is_valid(): - form_data = form.cleaned_data - - pipeline.bind_state("installation_data", form_data) - return pipeline.next_step() - else: - form = InstallationForm() - - return render_to_response( - template="sentry/integrations/bitbucket-server-config.html", - context={"form": form}, - request=request, - ) - - -class OAuthLoginView: - """ - Start the OAuth dance by creating a request token - and redirecting the user to approve it. - """ - - @method_decorator(csrf_exempt) - def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpResponseBase: - with IntegrationPipelineViewEvent( - IntegrationPipelineViewType.OAUTH_LOGIN, - IntegrationDomain.SOURCE_CODE_MANAGEMENT, - BitbucketServerIntegrationProvider.key, - ).capture() as lifecycle: - if "oauth_token" in request.GET: - return pipeline.next_step() - - config = pipeline.fetch_state("installation_data") - assert config is not None - client = BitbucketServerSetupClient( - config.get("url"), - config.get("consumer_key"), - config.get("private_key"), - config.get("verify_ssl"), - ) - - try: - request_token = client.get_request_token() - except ApiError as error: - lifecycle.record_failure(str(error), extra={"url": config.get("url")}) - return pipeline.error(f"Could not fetch a request token from Bitbucket. {error}") - - pipeline.bind_state("request_token", request_token) - if not request_token.get("oauth_token"): - lifecycle.record_failure("missing oauth_token", extra={"url": config.get("url")}) - return pipeline.error("Missing oauth_token") - - authorize_url = client.get_authorize_url(request_token) - - return HttpResponseRedirect(authorize_url) - - -class OAuthCallbackView: - """ - Complete the OAuth dance by exchanging our request token - into an access token. - """ - - @method_decorator(csrf_exempt) - def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpResponseBase: - with IntegrationPipelineViewEvent( - IntegrationPipelineViewType.OAUTH_CALLBACK, - IntegrationDomain.SOURCE_CODE_MANAGEMENT, - BitbucketServerIntegrationProvider.key, - ).capture() as lifecycle: - config = pipeline.fetch_state("installation_data") - assert config is not None - client = BitbucketServerSetupClient( - config.get("url"), - config.get("consumer_key"), - config.get("private_key"), - config.get("verify_ssl"), - ) - - try: - access_token = client.get_access_token( - pipeline.fetch_state("request_token"), request.GET["oauth_token"] - ) - - pipeline.bind_state("access_token", access_token) - - return pipeline.next_step() - except ApiError as error: - lifecycle.record_failure(str(error)) - return pipeline.error( - f"Could not fetch an access token from Bitbucket. {str(error)}" - ) - - class InstallationConfigData(TypedDict): url: str consumer_key: str @@ -544,7 +382,7 @@ class BitbucketServerIntegrationProvider(IntegrationProvider): setup_dialog_config = {"width": 1030, "height": 1000} def get_pipeline_views(self) -> list[PipelineView[IntegrationPipeline]]: - return [InstallationConfigView(), OAuthLoginView(), OAuthCallbackView()] + return [] def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: return [InstallationConfigApiStep(), OAuthApiStep()] diff --git a/src/sentry/templates/sentry/integrations/bitbucket-server-config.html b/src/sentry/templates/sentry/integrations/bitbucket-server-config.html deleted file mode 100644 index d2e8936089f5..000000000000 --- a/src/sentry/templates/sentry/integrations/bitbucket-server-config.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "sentry/bases/modal.html" %} -{% load crispy_forms_tags %} -{% load sentry_assets %} -{% load i18n %} - -{% block css %} - -{% endblock %} - -{% block wrapperclass %} narrow auth {% endblock %} -{% block modal_header_signout %} {% endblock %} - -{% block title %} {% trans "Bitbucket-Server Setup" %} | {{ block.super }} {% endblock %} - -{% block main %} -

{% trans "Connect Sentry with your App" %}

-

{% trans "Add your Bitbucket Server App credentials to Sentry." %}

-

- - - {% blocktrans %} - You must complete the required steps - - in Bitbucket Server before attempting to connect with Sentry. - {% endblocktrans %} - -

-
- {% csrf_token %} - - - {{ form|as_crispy_errors }} - - {% for field in form %} - {{ field|as_crispy_field }} - {% endfor %} - -
-
- -
-
-
-{% endblock %} diff --git a/tests/sentry/integrations/bitbucket_server/test_integration.py b/tests/sentry/integrations/bitbucket_server/test_integration.py index 8f998c61f7e6..12ce633a5073 100644 --- a/tests/sentry/integrations/bitbucket_server/test_integration.py +++ b/tests/sentry/integrations/bitbucket_server/test_integration.py @@ -38,338 +38,6 @@ def integration(self): integration.add_organization(self.organization, self.user) return integration - def test_config_view(self) -> None: - resp = self.client.get(self.init_path) - assert resp.status_code == 200 - - resp = self.client.get(self.setup_path) - assert resp.status_code == 200 - self.assertContains(resp, "Connect Sentry") - self.assertContains(resp, "Submit") - - @responses.activate - def test_validate_url(self) -> None: - # Start pipeline and go to setup page. - self.client.get(self.setup_path) - - # Submit credentials - data = { - "url": "bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 200 - self.assertContains(resp, "Enter a valid URL") - - @responses.activate - def test_validate_private_key(self) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=503, - ) - - # Start pipeline and go to setup page. - self.client.get(self.setup_path) - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": "hot-garbage", - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 200 - self.assertContains( - resp, "Private key must be a valid SSH private key encoded in a PEM format." - ) - - @responses.activate - def test_validate_consumer_key_length(self) -> None: - # Start pipeline and go to setup page. - self.client.get(self.setup_path) - - # Submit credentials - data = { - "url": "bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "x" * 201, - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 200 - self.assertContains(resp, "Consumer key is limited to 200") - - @responses.activate - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_authentication_request_token_timeout(self, mock_record: MagicMock) -> None: - timeout = ReadTimeout("Read timed out. (read timeout=30)") - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - body=timeout, - ) - - # Start pipeline and go to setup page. - self.client.get(self.setup_path) - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 200 - self.assertContains(resp, "Setup Error") - self.assertContains(resp, "request token from Bitbucket") - self.assertContains(resp, "Timed out") - - assert_failure_metric( - mock_record, "Timed out attempting to reach host: bitbucket.example.com" - ) - - @responses.activate - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_authentication_request_token_fails(self, mock_record: MagicMock) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=503, - ) - - # Start pipeline and go to setup page. - self.client.get(self.setup_path) - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 200 - self.assertContains(resp, "Setup Error") - self.assertContains(resp, "request token from Bitbucket") - - assert_failure_metric(mock_record, "") - - @responses.activate - def test_authentication_request_token_redirect(self) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=200, - content_type="text/plain", - body="oauth_token=abc123&oauth_token_secret=def456", - ) - - # Start pipeline - self.client.get(self.init_path) - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 302 - redirect = ( - "https://bitbucket.example.com/plugins/servlet/oauth/authorize?oauth_token=abc123" - ) - assert redirect == resp["Location"] - - @responses.activate - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_authentication_access_token_failure(self, mock_record: MagicMock) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=200, - content_type="text/plain", - body="oauth_token=abc123&oauth_token_secret=def456", - ) - error_msg = "it broke" - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/access-token", - status=500, - content_type="text/plain", - body=error_msg, - ) - - # Get config page - resp = self.client.get(self.init_path) - assert resp.status_code == 200 - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 302 - assert resp["Location"] - - resp = self.client.get(self.setup_path + "?oauth_token=xyz789") - assert resp.status_code == 200 - self.assertContains(resp, "Setup Error") - self.assertContains(resp, "access token from Bitbucket") - - assert_failure_metric(mock_record, error_msg) - - def install_integration(self): - # Get config page - resp = self.client.get(self.setup_path) - assert resp.status_code == 200 - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "sentry-bot", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 302 - assert resp["Location"] - - resp = self.client.get(self.setup_path + "?oauth_token=xyz789") - assert resp.status_code == 200 - - return resp - - @responses.activate - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - def test_authentication_verifier_expired(self, mock_record: MagicMock) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=200, - content_type="text/plain", - body="oauth_token=abc123&oauth_token_secret=def456", - ) - error_msg = "oauth_error=token+expired" - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/access-token", - status=404, - content_type="text/plain", - body=error_msg, - ) - - # Try getting the token but it has expired for some reason, - # perhaps a stale reload/history navigate. - resp = self.install_integration() - - self.assertContains(resp, "Setup Error") - self.assertContains(resp, "access token from Bitbucket") - - assert_failure_metric(mock_record, error_msg) - - @responses.activate - def test_authentication_success(self) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=200, - content_type="text/plain", - body="oauth_token=abc123&oauth_token_secret=def456", - ) - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/access-token", - status=200, - content_type="text/plain", - body="oauth_token=valid-token&oauth_token_secret=valid-secret", - ) - responses.add( - responses.POST, - "https://bitbucket.example.com/rest/webhooks/1.0/webhook", - status=204, - body="", - ) - - self.install_integration() - - integration = Integration.objects.get() - assert integration.name == "sentry-bot" - assert integration.metadata["domain_name"] == "bitbucket.example.com" - assert integration.metadata["base_url"] == "https://bitbucket.example.com" - assert integration.metadata["verify_ssl"] is False - - org_integration = OrganizationIntegration.objects.get( - integration=integration, organization_id=self.organization.id - ) - assert org_integration.config == {} - - idp = IdentityProvider.objects.get(type="bitbucket_server") - identity = Identity.objects.get( - idp=idp, user=self.user, external_id="bitbucket.example.com:sentry-bot" - ) - assert identity.data["consumer_key"] == "sentry-bot" - assert identity.data["access_token"] == "valid-token" - assert identity.data["access_token_secret"] == "valid-secret" - assert identity.data["private_key"] == EXAMPLE_PRIVATE_KEY - - @responses.activate - def test_setup_external_id_length(self) -> None: - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/request-token", - status=200, - content_type="text/plain", - body="oauth_token=abc123&oauth_token_secret=def456", - ) - responses.add( - responses.POST, - "https://bitbucket.example.com/plugins/servlet/oauth/access-token", - status=200, - content_type="text/plain", - body="oauth_token=valid-token&oauth_token_secret=valid-secret", - ) - responses.add( - responses.POST, - "https://bitbucket.example.com/rest/webhooks/1.0/webhook", - status=204, - body="", - ) - - # Start pipeline and go to setup page. - self.client.get(self.setup_path) - - # Submit credentials - data = { - "url": "https://bitbucket.example.com/", - "verify_ssl": False, - "consumer_key": "a-very-long-consumer-key-that-when-combined-with-host-would-overflow", - "private_key": EXAMPLE_PRIVATE_KEY, - } - resp = self.client.post(self.setup_path, data=data) - assert resp.status_code == 302 - redirect = ( - "https://bitbucket.example.com/plugins/servlet/oauth/authorize?oauth_token=abc123" - ) - assert redirect == resp["Location"] - - resp = self.client.get(self.setup_path + "?oauth_token=xyz789") - assert resp.status_code == 200 - - integration = Integration.objects.get(provider="bitbucket_server") - assert ( - integration.external_id - == "bitbucket.example.com:a-very-long-consumer-key-that-when-combine" - ) - def test_source_url_matches(self) -> None: installation = self.integration.get_installation(self.organization.id) From 3e7c3913992f7fe6c7976fd03e3abde4d4fb32e4 Mon Sep 17 00:00:00 2001 From: Ken Jiang Date: Fri, 29 May 2026 10:33:48 -0700 Subject: [PATCH 15/42] chore(ci): Skip broken trace item detail tests (#116497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Skip 3 tests in `test_project_trace_item_details.py` that are broken as of 05-29-2026 - `test_convert_rpc_attribute_to_json_serializes_known_string_array_without_array_flag` - `test_convert_rpc_attribute_to_json_exposes_array_with_array_flag` - `TestReplacementAttributeFiltering::test_replacement_array_shown_when_no_deprecated_source` ## Test plan - CI should pass with these tests skipped Will continue root causing and follow up with the relevant parties to ensure the real cause is addressed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) --- tests/sentry/api/endpoints/test_project_trace_item_details.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/sentry/api/endpoints/test_project_trace_item_details.py b/tests/sentry/api/endpoints/test_project_trace_item_details.py index d3f3218f7e81..fb4a47e6ae27 100644 --- a/tests/sentry/api/endpoints/test_project_trace_item_details.py +++ b/tests/sentry/api/endpoints/test_project_trace_item_details.py @@ -4,6 +4,7 @@ from sentry.search.eap.types import SupportedTraceItemType +@pytest.mark.skip(reason="Skipping due to broken CI 05-29-2026") def test_convert_rpc_attribute_to_json_serializes_known_string_array_without_array_flag() -> None: result = convert_rpc_attribute_to_json( [ @@ -52,6 +53,7 @@ def test_convert_rpc_attribute_to_json_hides_non_replacement_array_without_array assert result == [] +@pytest.mark.skip(reason="Skipping due to broken CI 05-29-2026") def test_convert_rpc_attribute_to_json_exposes_array_with_array_flag() -> None: result = convert_rpc_attribute_to_json( [ @@ -109,6 +111,7 @@ def test_replacement_attribute_hidden_when_deprecated_source_present(self) -> No assert "gen_ai.usage.prompt_tokens" in names assert "gen_ai.usage.input_tokens" not in names + @pytest.mark.skip(reason="Skipping due to broken CI 05-29-2026") def test_replacement_array_shown_when_no_deprecated_source(self) -> None: result = convert_rpc_attribute_to_json( [ From ba70c889ecf71a50a34416c69347fd37ab9ed0c0 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Fri, 29 May 2026 11:03:50 -0700 Subject: [PATCH 16/42] feat(apidocs): Support union Response[T] annotations in structural linter (#116496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the structural linter at `src/sentry/apidocs/_check_response_annotation_matches_schema.py` to support union return annotations and compare against decorator response entries at every status code (not just 2xx). This unblocks endpoints that want to type both their success and error response bodies — `-> Response[FooResponse] | Response[ErrorBody]` with corresponding decorator entries — and removes the artificial 2xx restriction from before. ## What changed | Aspect | Before | After | |---|---|---| | Annotation forms | `Response[T]` only | `Response[T]` and `Response[T_a] \| Response[T_b] \| ...` | | Decorator status range | 2xx entries only | All status codes | | Decorator shape recognized | `inline_sentry_response_serializer("Name", T)` | unchanged | | Comparison | T-name equality (single) | Set equality on raw T names | | Name handling | Verbatim | Verbatim (no normalization, no convention pairing) | ## What is intentionally still skipped (no diagnostic, no false positive) - Plain `-> Response` annotations (the unmigrated state). - Methods with no `@extend_schema` decorator. - Decorator entries that aren't `inline_sentry_response_serializer(...)`. This includes: - Direct serializer-class references like `MonitorSerializer` — these carry a typed output by sentry convention but no statically-resolvable link to a `TypedDict`. The right path for these is either migrating the entry to `inline_sentry_response_serializer(...)`, or waiting for a generic-`Serializer[T]` refactor where the linter can read the parameter directly from the class. - `OpenApiResponse(...)` wrappers. - `RESPONSE_*` canned constants (error documentation, no body type). - `None`, raw `dict`, etc. - Union arms that aren't `Response[T]` (e.g. `Response[T] | HttpResponse`). In all those cases the linter exits clean — it neither false-positives nor gives a misleading guess. ## Status-code-to-T linkage is intentionally not enforced mypy can't model the link (the status code is runtime data), and broad `except APIException` patterns would lose it anyway. Endpoints needing strict status↔T guarantees should `raise (...)` for error paths so the success-path return is uniquely typed. ## Worked examples **Single T (unchanged from prior behavior):** ```python @extend_schema(responses={200: inline_sentry_response_serializer("Foo", FooResponse)}) def get(...) -> Response[FooResponse]: ... # pass: decorator set {FooResponse} == annotation set {FooResponse} ``` **Union with success + typed error:** ```python @extend_schema( responses={ 200: inline_sentry_response_serializer("Foo", FooResponse), 400: inline_sentry_response_serializer("Err", ErrorBody), }, ) def get(...) -> Response[FooResponse] | Response[ErrorBody]: ... # pass: sets {FooResponse, ErrorBody} match ``` **Drift — annotation missing a decorator-declared T:** ```python @extend_schema( responses={ 200: inline_sentry_response_serializer("Foo", FooResponse), 400: inline_sentry_response_serializer("Err", ErrorBody), }, ) def get(...) -> Response[FooResponse]: ... # fail: decorator {FooResponse, ErrorBody} != annotation {FooResponse} ``` ## Tests 16 cases in `tests/sentry/apidocs/test_check_response_annotation_matches_schema.py`: - Single-T match / mismatch - Union annotation matching multi-status decorator (both arms typed) - Annotation missing a decorator-declared T (set inequality) - Annotation containing extra T not in decorator (set inequality) - Multiple 2xx schemas (200 + 201) matched by union - Unmigrated `-> Response` skipped - Method without `@extend_schema` skipped - `RESPONSE_*` canned constant skipped - Direct serializer-class reference skipped (waiting on generic Serializer[T]) - `OpenApiResponse(...)` wrapper skipped - Union arm that isn't `Response[T]` skipped - Async methods - Dotted annotation form (`rest_framework.response.Response[T]`) - `main()` exit codes (zero on clean, nonzero on mismatch) Full src/sentry run: clean. The 43 endpoints already opted into `Response[T]` (#116335, #116433) continue to pass. ## Follow-up paths (out of scope for this PR) A survey of all 862 `responses={N: ...}` entries in `src/sentry` showed that ~97 use direct serializer-class references (about as common as `inline_sentry_response_serializer`). The linter doesn't help these endpoints today. Two ways to bring them into coverage later: - **Migrate** each to `inline_sentry_response_serializer("Name", FooResponse)` with an explicit paired TypedDict. - **Make sentry's `Serializer` generic** in its output type (`Serializer[T]`), so the linter can extract T directly from the class's bases. This eliminates the need for any naming convention. Either path gives the linter a real handle. This PR doesn't attempt to bridge it with name heuristics — those were considered and rejected as brittle. Co-authored-by: Claude --- ...heck_response_annotation_matches_schema.py | 181 +++++++++++------ ...heck_response_annotation_matches_schema.py | 183 ++++++++++++++++-- 2 files changed, 284 insertions(+), 80 deletions(-) diff --git a/src/sentry/apidocs/_check_response_annotation_matches_schema.py b/src/sentry/apidocs/_check_response_annotation_matches_schema.py index 0fe4bb93ae9c..b4d0c74e121e 100644 --- a/src/sentry/apidocs/_check_response_annotation_matches_schema.py +++ b/src/sentry/apidocs/_check_response_annotation_matches_schema.py @@ -1,15 +1,31 @@ -"""Lint: assert decorator-T equals annotation-T for typed endpoint responses. +"""Lint: assert decorator's declared `T`s match the method's `Response[T]` annotation. For each method decorated with - @extend_schema(responses={N: inline_sentry_response_serializer("Name", T_decl)}) + @extend_schema(responses={N: inline_sentry_response_serializer("Name", T_decl), ...}) and annotated -> Response[T_annot] -this linter asserts that `T_decl == T_annot` (AST-level name equality) for the -2xx-status entry. mypy enforces body-vs-annotation; this linter enforces -decorator-vs-annotation. Together they close the schema/runtime drift gap. +or, when the endpoint exposes multiple typed response shapes, + -> Response[T_success] | Response[T_error_400] | ... -Plain `-> Response` annotations are skipped (unmigrated endpoints). Decorator -entries that are canned constants (e.g. `RESPONSE_BAD_REQUEST`) are skipped. +this linter asserts that the *set* of `T`s declared by +`inline_sentry_response_serializer(...)` entries in the decorator equals the set of +`T`s in the annotation's union arms. Names are compared verbatim — no +normalization, no convention-based pairing. mypy enforces body-vs-annotation; +this linter enforces decorator-vs-annotation. + +The status-code-to-`T` linkage is intentionally not enforced — mypy can't model +it, and broad `except APIException` catches would lose the linkage anyway. + +Skipped silently (no diagnostic): + - Plain `-> Response` annotations (the unmigrated state). + - Methods with no `@extend_schema` decorator. + - Decorator entries that aren't `inline_sentry_response_serializer(...)` — + direct serializer-class references (`MonitorSerializer`), + `OpenApiResponse(...)` wrappers, `RESPONSE_*` canned constants, `None`, + raw `dict`, etc. These don't carry a statically-resolvable `T` for the + linter to compare; either migrate them to + `inline_sentry_response_serializer(...)` or wait for the generic + `Serializer[T]` refactor. Invoke as: python -m sentry.apidocs._check_response_annotation_matches_schema [paths...] @@ -38,8 +54,6 @@ "src/sentry/uptime/endpoints", ) -SUCCESS_STATUSES = frozenset(range(200, 300)) - @dataclass(frozen=True) class Mismatch: @@ -47,14 +61,15 @@ class Mismatch: line: int cls: str method: str - status: int - decl: str - annot: str + decl: frozenset[str] + annot: frozenset[str] def __str__(self) -> str: + decl = ", ".join(sorted(self.decl)) or "" + annot = ", ".join(sorted(self.annot)) or "" return ( - f"{self.path}:{self.line} {self.cls}.{self.method} status={self.status}: " - f"decorator declares `{self.decl}`, annotation declares `{self.annot}`" + f"{self.path}:{self.line} {self.cls}.{self.method}: " + f"decorator declares {{{decl}}}, annotation declares {{{annot}}}" ) @@ -69,28 +84,51 @@ def _name_of(node: ast.expr) -> str: return ast.unparse(node) -def _extract_decorator_responses( - decorator: ast.expr, -) -> dict[int, ast.expr]: - """For `@extend_schema(responses={N: inline_sentry_response_serializer("X", T)})`, - return {N: T_expr} for entries that use `inline_sentry_response_serializer`. - Skips canned constants and non-2xx entries that don't carry a body schema. +def _is_response_subscript(node: ast.expr) -> ast.expr | None: + """If `node` is `Response[T]` (or `rest_framework.response.Response[T]`), + return the `T` expression. Otherwise return None.""" + if not isinstance(node, ast.Subscript): + return None + val = node.value + if isinstance(val, ast.Name) and val.id == "Response": + return node.slice + if isinstance(val, ast.Attribute) and val.attr == "Response": + return node.slice + return None + + +def _extract_decorator_response_Ts(decorator: ast.expr) -> list[ast.expr]: + """From `@extend_schema(responses={N: inline_sentry_response_serializer("X", T), ...})`, + return every `T` expression. Only this explicit form is recognized — it's the + one shape where the linter has a real handle on what the typed schema is. + + Skipped silently (no extractable T): + - Direct serializer-class references (e.g. `MonitorSerializer`). These + carry a typed output by sentry convention but no statically-resolvable + link to a TypedDict. Either migrate the entry to + `inline_sentry_response_serializer(...)`, or wait for the generic- + `Serializer[T]` refactor to land — both give the linter a real handle. + - Canned `RESPONSE_*` constants (no T — error responses, untyped body). + - `OpenApiResponse(...)` wrappers. + - `None`, raw `dict`, etc. + + Status code is intentionally ignored — see module docstring. """ if not isinstance(decorator, ast.Call): - return {} + return [] func = decorator.func - if not (isinstance(func, ast.Name) and func.id == "extend_schema"): - if not (isinstance(func, ast.Attribute) and func.attr == "extend_schema"): - return {} + if not ( + (isinstance(func, ast.Name) and func.id == "extend_schema") + or (isinstance(func, ast.Attribute) and func.attr == "extend_schema") + ): + return [] responses_kw = next((kw for kw in decorator.keywords if kw.arg == "responses"), None) if responses_kw is None or not isinstance(responses_kw.value, ast.Dict): - return {} - out: dict[int, ast.expr] = {} + return [] + out: list[ast.expr] = [] for key, val in zip(responses_kw.value.keys, responses_kw.value.values): if not isinstance(key, ast.Constant) or not isinstance(key.value, int): continue - if key.value not in SUCCESS_STATUSES: - continue if not isinstance(val, ast.Call): continue func_v = val.func @@ -99,25 +137,44 @@ def _extract_decorator_responses( ) or ( isinstance(func_v, ast.Attribute) and func_v.attr == "inline_sentry_response_serializer" ) - if not is_inline or len(val.args) < 2: - continue - out[key.value] = val.args[1] + if is_inline and len(val.args) >= 2: + out.append(val.args[1]) return out -def _extract_response_annotation_T(returns: ast.expr | None) -> ast.expr | None: - """If the return annotation is `Response[T]`, return the T expr. Else None - (which means: skip this method — it's either unmigrated or non-Response). +def _extract_response_annotation_Ts(returns: ast.expr | None) -> list[ast.expr] | None: + """Parse the return annotation and return the list of `T` expressions that appear + inside `Response[...]` (handling both single `Response[T]` and union + `Response[T_a] | Response[T_b]` forms). + + Returns: + - `None` if the annotation is not a `Response[T]` (or union thereof) — that's + the unmigrated state; the method is skipped. + - A non-empty list of `T` AST expressions otherwise. """ if returns is None: return None - if isinstance(returns, ast.Subscript): - val = returns.value - if isinstance(val, ast.Name) and val.id == "Response": - return returns.slice - if isinstance(val, ast.Attribute) and val.attr == "Response": - return returns.slice - return None + + # Collect every leaf of a union, then check each is `Response[T]`. + arms: list[ast.expr] = [] + pending: list[ast.expr] = [returns] + while pending: + node = pending.pop() + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + pending.append(node.left) + pending.append(node.right) + else: + arms.append(node) + + extracted: list[ast.expr] = [] + for arm in arms: + T = _is_response_subscript(arm) + if T is None: + # If any arm is `Response` (bare, unparameterized) or some other type, + # we can't meaningfully compare — treat this as unmigrated. + return None + extracted.append(T) + return extracted or None def _iter_methods(tree: ast.Module) -> Iterator[tuple[str, ast.FunctionDef | ast.AsyncFunctionDef]]: @@ -141,32 +198,30 @@ def check_file(path: Path) -> list[Mismatch]: mismatches: list[Mismatch] = [] for cls_name, method in _iter_methods(tree): - annot_T = _extract_response_annotation_T(method.returns) - if annot_T is None: + annot_Ts = _extract_response_annotation_Ts(method.returns) + if annot_Ts is None: continue - annot_name = _name_of(annot_T) - decl_by_status: dict[int, ast.expr] = {} + decl_Ts: list[ast.expr] = [] for dec in method.decorator_list: - decl_by_status.update(_extract_decorator_responses(dec)) + decl_Ts.extend(_extract_decorator_response_Ts(dec)) - if not decl_by_status: + if not decl_Ts: continue - for status, decl_expr in decl_by_status.items(): - decl_name = _name_of(decl_expr) - if decl_name != annot_name: - mismatches.append( - Mismatch( - path=path, - line=method.lineno, - cls=cls_name, - method=method.name, - status=status, - decl=decl_name, - annot=annot_name, - ) + decl_set = frozenset(_name_of(t) for t in decl_Ts) + annot_set = frozenset(_name_of(t) for t in annot_Ts) + if decl_set != annot_set: + mismatches.append( + Mismatch( + path=path, + line=method.lineno, + cls=cls_name, + method=method.name, + decl=decl_set, + annot=annot_set, ) + ) return mismatches @@ -189,9 +244,9 @@ def main(argv: list[str]) -> int: sys.stdout.write(f"{m}\n") if all_mismatches: sys.stderr.write( - f"\n{len(all_mismatches)} mismatch(es) — decorator's " - "`inline_sentry_response_serializer(name, T)` must match the " - "method's `-> Response[T]` annotation.\n", + f"\n{len(all_mismatches)} mismatch(es) — the set of `T`s declared by " + "`inline_sentry_response_serializer(...)` in `@extend_schema` must " + "equal the set of `T`s in the `Response[T]` (or union) annotation.\n", ) return 1 return 0 diff --git a/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py b/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py index 7bc5d9a55120..43a222d7b692 100644 --- a/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py +++ b/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py @@ -67,9 +67,8 @@ def get(self) -> Response[BarResponse]: m = mismatches[0] assert m.cls == "FooEndpoint" assert m.method == "get" - assert m.status == 200 - assert m.decl == "FooResponse" - assert m.annot == "BarResponse" + assert m.decl == frozenset({"FooResponse"}) + assert m.annot == frozenset({"BarResponse"}) def test_unmigrated_endpoint_skipped() -> None: @@ -109,9 +108,8 @@ def get(self) -> Response[FooResponse]: def test_canned_response_constant_skipped() -> None: - """Non-inline_sentry_response_serializer entries (e.g. RESPONSE_BAD_REQUEST) - are not comparable and must not produce false positives. - """ + """Non-`inline_sentry_response_serializer` entries (e.g. RESPONSE_BAD_REQUEST) + are not comparable and must not produce false positives.""" source = """ from typing import TypedDict from drf_spectacular.utils import extend_schema @@ -135,10 +133,36 @@ def get(self) -> Response[FooResponse]: assert _run(source) == [] -def test_only_2xx_entries_compared() -> None: - """Even if a 4xx/5xx entry used inline_sentry_response_serializer (unusual), - it should not be compared to the success-path annotation. - """ +def test_union_annotation_matches_multi_status_decorator() -> None: + """Union return type matching a decorator with multiple typed responses.""" + source = """ +from typing import TypedDict +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response +from sentry.apidocs.utils import inline_sentry_response_serializer + +class FooResponse(TypedDict): + x: int + +class ErrorBody(TypedDict): + detail: str + +class FooEndpoint: + @extend_schema( + responses={ + 200: inline_sentry_response_serializer("Foo", FooResponse), + 400: inline_sentry_response_serializer("Err", ErrorBody), + }, + ) + def get(self) -> Response[FooResponse] | Response[ErrorBody]: + return Response({"x": 1}) +""" + assert _run(source) == [] + + +def test_union_annotation_missing_decorator_T_fires() -> None: + """If the decorator declares two typed responses but the annotation only + covers one, the set-equality check must fail.""" source = """ from typing import TypedDict from drf_spectacular.utils import extend_schema @@ -160,6 +184,87 @@ class FooEndpoint: ) def get(self) -> Response[FooResponse]: return Response({"x": 1}) +""" + mismatches = _run(source) + assert len(mismatches) == 1 + assert mismatches[0].decl == frozenset({"FooResponse", "ErrorBody"}) + assert mismatches[0].annot == frozenset({"FooResponse"}) + + +def test_annotation_has_extra_T_not_in_decorator_fires() -> None: + """If the annotation union declares a `T` that the decorator doesn't, fail.""" + source = """ +from typing import TypedDict +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response +from sentry.apidocs.utils import inline_sentry_response_serializer + +class FooResponse(TypedDict): + x: int + +class GhostResponse(TypedDict): + y: int + +class FooEndpoint: + @extend_schema( + responses={200: inline_sentry_response_serializer("Foo", FooResponse)}, + ) + def get(self) -> Response[FooResponse] | Response[GhostResponse]: + return Response({"x": 1}) +""" + mismatches = _run(source) + assert len(mismatches) == 1 + assert mismatches[0].decl == frozenset({"FooResponse"}) + assert mismatches[0].annot == frozenset({"FooResponse", "GhostResponse"}) + + +def test_multi_2xx_decorator_with_union_annotation() -> None: + """Multiple 2xx schemas (e.g. 200 + 201) with a union annotation covering both.""" + source = """ +from typing import TypedDict +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response +from sentry.apidocs.utils import inline_sentry_response_serializer + +class GetResponse(TypedDict): + x: int + +class CreatedResponse(TypedDict): + id: str + +class FooEndpoint: + @extend_schema( + responses={ + 200: inline_sentry_response_serializer("Foo", GetResponse), + 201: inline_sentry_response_serializer("FooCreated", CreatedResponse), + }, + ) + def post(self) -> Response[GetResponse] | Response[CreatedResponse]: + return Response({"x": 1}) +""" + assert _run(source) == [] + + +def test_union_with_non_response_arm_skipped() -> None: + """If the union contains an arm that isn't `Response[T]` (e.g. a bare + `HttpResponse`), the method is treated as unmigrated and skipped — there's + no clean comparison to make.""" + source = """ +from typing import TypedDict +from drf_spectacular.utils import extend_schema +from django.http import HttpResponse +from rest_framework.response import Response +from sentry.apidocs.utils import inline_sentry_response_serializer + +class FooResponse(TypedDict): + x: int + +class FooEndpoint: + @extend_schema( + responses={200: inline_sentry_response_serializer("Foo", FooResponse)}, + ) + def get(self) -> Response[FooResponse] | HttpResponse: + return Response({"x": 1}) """ assert _run(source) == [] @@ -186,14 +291,12 @@ async def get(self) -> Response[BarResponse]: """ mismatches = _run(source) assert len(mismatches) == 1 - assert mismatches[0].decl == "FooResponse" - assert mismatches[0].annot == "BarResponse" + assert mismatches[0].decl == frozenset({"FooResponse"}) + assert mismatches[0].annot == frozenset({"BarResponse"}) def test_dotted_response_annotation_handled() -> None: - """Some files write the annotation as `rest_framework.response.Response[T]`. - The linter must extract T from both `Name` and `Attribute` forms. - """ + """Some files write the annotation as `rest_framework.response.Response[T]`.""" source = """ from typing import TypedDict import rest_framework.response @@ -215,8 +318,8 @@ def get(self) -> rest_framework.response.Response[BarResponse]: """ mismatches = _run(source) assert len(mismatches) == 1 - assert mismatches[0].decl == "FooResponse" - assert mismatches[0].annot == "BarResponse" + assert mismatches[0].decl == frozenset({"FooResponse"}) + assert mismatches[0].annot == frozenset({"BarResponse"}) def test_main_returns_zero_on_clean(tmp_path: Path) -> None: @@ -268,3 +371,49 @@ def get(self) -> Response[BarResponse]: assert "FooResponse" in captured.out assert "BarResponse" in captured.out assert "mismatch" in captured.err + + +def test_direct_serializer_class_reference_skipped() -> None: + """Decorator entries that are bare class references (e.g. `MonitorSerializer`) + carry a typed output by sentry convention but no statically-resolvable link + to a TypedDict. The linter skips them silently — neither false-positives nor + false-negatives. Resolving these waits on the generic-`Serializer[T]` + refactor, or on migrating the entry to `inline_sentry_response_serializer`. + """ + source = """ +from typing import TypedDict +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response + +class MonitorSerializer: ... + +class MonitorSerializerResponse(TypedDict): + id: str + +class MonitorEndpoint: + @extend_schema(responses={200: MonitorSerializer}) + def get(self) -> Response[MonitorSerializerResponse]: + return Response({"id": "x"}) +""" + assert _run(source) == [] + + +def test_openapi_response_wrapper_skipped() -> None: + """`OpenApiResponse(...)` and similar wrappers don't carry a comparable T — + skip silently.""" + source = """ +from typing import TypedDict +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.response import Response + +class FooResponse(TypedDict): + x: int + +class FooEndpoint: + @extend_schema( + responses={200: OpenApiResponse(description="ok")}, + ) + def get(self) -> Response[FooResponse]: + return Response({"x": 1}) +""" + assert _run(source) == [] From 7bc46aba91a5d82ea1b52ad14b47e54ef0c375b0 Mon Sep 17 00:00:00 2001 From: Calvin Date: Fri, 29 May 2026 11:11:14 -0700 Subject: [PATCH 17/42] feat: Remove code coverage feature (#116240) Remove codecov-integration feature from the Settings page --- .../core/avatar/__stories__/fixtures.tsx | 1 - .../banners/stacktraceBanners.spec.tsx | 2 +- .../components/keyValueData/index.stories.tsx | 4 +- static/app/icons/iconCodecov.tsx | 10 --- static/app/icons/icons.stories.tsx | 7 -- static/app/icons/index.tsx | 1 - static/app/types/organization.tsx | 1 - static/app/types/overrides.tsx | 6 -- .../analytics/ecosystemAnalyticsEvents.tsx | 6 -- .../analytics/settingsAnalyticsEvents.tsx | 3 - static/app/utils/api/knownGetsentryApiUrls.ts | 1 - static/app/utils/integrationUtil.tsx | 5 -- .../index.spec.tsx | 33 -------- .../organizationGeneralSettings/index.tsx | 8 -- .../organizationSettingsForm.spec.tsx | 34 --------- .../organizationSettingsForm.tsx | 76 ------------------- .../gsApp/components/codecovSettingsLink.tsx | 21 ----- static/gsApp/registerOverrides.tsx | 11 --- static/gsApp/utils/useCodecovJwt.tsx | 44 ----------- tests/js/fixtures/organization.ts | 1 - tests/js/getsentry-test/fixtures/am1Plans.ts | 1 - tests/js/getsentry-test/fixtures/am2Plans.ts | 1 - tests/js/getsentry-test/fixtures/am3Plans.ts | 1 - 23 files changed, 3 insertions(+), 275 deletions(-) delete mode 100644 static/app/icons/iconCodecov.tsx delete mode 100644 static/gsApp/components/codecovSettingsLink.tsx delete mode 100644 static/gsApp/utils/useCodecovJwt.tsx diff --git a/static/app/components/core/avatar/__stories__/fixtures.tsx b/static/app/components/core/avatar/__stories__/fixtures.tsx index 145c36612744..15de4dbbb8f5 100644 --- a/static/app/components/core/avatar/__stories__/fixtures.tsx +++ b/static/app/components/core/avatar/__stories__/fixtures.tsx @@ -23,7 +23,6 @@ export const ORGANIZATION: OrganizationSummary = { avatarUrl: 'https://sentry.io/avatar/2d641b5d-8c74-44de-9cb6-fbd54701b35e/', avatarUuid: '2d641b5d-8c74-44de-9cb6-fbd54701b35e', }, - codecovAccess: false, dateCreated: '2021-01-01', hideAiFeatures: false, isEarlyAdopter: false, diff --git a/static/app/components/events/interfaces/crashContent/exception/banners/stacktraceBanners.spec.tsx b/static/app/components/events/interfaces/crashContent/exception/banners/stacktraceBanners.spec.tsx index b0b7e9a9512a..889c55884ced 100644 --- a/static/app/components/events/interfaces/crashContent/exception/banners/stacktraceBanners.spec.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/banners/stacktraceBanners.spec.tsx @@ -14,7 +14,7 @@ import {StacktraceBanners} from './stacktraceBanners'; describe('StacktraceBanners', () => { const org = OrganizationFixture({ - features: ['codecov-integration'], + features: ['dashboards-basic'], }); const project = ProjectFixture(); diff --git a/static/app/components/keyValueData/index.stories.tsx b/static/app/components/keyValueData/index.stories.tsx index fdbbe0aa2310..f3403a75033a 100644 --- a/static/app/components/keyValueData/index.stories.tsx +++ b/static/app/components/keyValueData/index.stories.tsx @@ -9,7 +9,7 @@ import { KeyValueData, type KeyValueDataContentProps, } from 'sentry/components/keyValueData'; -import {IconCodecov, IconEdit, IconSentry, IconSettings} from 'sentry/icons'; +import {IconEdit, IconSentry, IconSettings} from 'sentry/icons'; import * as Storybook from 'sentry/stories'; export default Storybook.story('KeyValueData', story => { @@ -268,7 +268,7 @@ function generateContentItems(theme: Theme): KeyValueDataContentProps[] { ), value: ( - Custom Value Node + Custom Value Node ), }, diff --git a/static/app/icons/iconCodecov.tsx b/static/app/icons/iconCodecov.tsx deleted file mode 100644 index 6c8976429bd5..000000000000 --- a/static/app/icons/iconCodecov.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type {SVGIconProps} from './svgIcon'; -import {SvgIcon} from './svgIcon'; - -export function IconCodecov(props: SVGIconProps) { - return ( - - - - ); -} diff --git a/static/app/icons/icons.stories.tsx b/static/app/icons/icons.stories.tsx index 773b28396f55..9f09104a82e6 100644 --- a/static/app/icons/icons.stories.tsx +++ b/static/app/icons/icons.stories.tsx @@ -245,13 +245,6 @@ const SECTIONS: TSection[] = [ name: 'SentryPrideLogo', defaultProps: {}, }, - { - id: 'codecov', - groups: ['logo'], - keywords: ['coverage', 'testing', 'code'], - name: 'Codecov', - defaultProps: {}, - }, { id: 'bitbucket', groups: ['logo'], diff --git a/static/app/icons/index.tsx b/static/app/icons/index.tsx index 775b725c1142..6fdeb971a257 100644 --- a/static/app/icons/index.tsx +++ b/static/app/icons/index.tsx @@ -22,7 +22,6 @@ export {IconCircleFill} from './iconCircleFill'; export {IconClock} from './iconClock'; export {IconClose} from './iconClose'; export {IconCode} from './iconCode'; -export {IconCodecov} from './iconCodecov'; export {IconCommand} from './iconCommand'; export {IconCommit} from './iconCommit'; export {IconCompass} from './iconCompass'; diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx index f9d1d8c2666f..021736144b3c 100644 --- a/static/app/types/organization.tsx +++ b/static/app/types/organization.tsx @@ -21,7 +21,6 @@ import type {User} from './user'; */ export interface OrganizationSummary { avatar: Avatar; - codecovAccess: boolean; dateCreated: string; hideAiFeatures: boolean; id: string; diff --git a/static/app/types/overrides.tsx b/static/app/types/overrides.tsx index 8ec0ad31da6c..050a6936cafd 100644 --- a/static/app/types/overrides.tsx +++ b/static/app/types/overrides.tsx @@ -147,10 +147,6 @@ type AttemptCloseAttemptProps = { organizationSlugs: string[]; }; -type CodecovLinkProps = { - organization: Organization; -}; - type GuideUpdateCallback = (nextGuide: Guide | null, opts: {dismissed?: boolean}) => void; type MonitorCreatedCallback = (organization: Organization) => void; @@ -187,7 +183,6 @@ type DashboardLimitProviderProps = { type ComponentOverrides = { 'component:ai-configure-seer-quota-sidebar': () => React.ComponentType; 'component:ai-setup-data-consent': () => React.ComponentType | null; - 'component:codecov-integration-settings-link': () => React.ComponentType; 'component:confirm-account-close': () => React.ComponentType; 'component:continuous-profiling-billing-requirement-banner': () => React.ComponentType; 'component:crons-list-page-header': () => React.ComponentType; @@ -259,7 +254,6 @@ type AnalyticsOverrides = { export type FeatureDisabledOverrides = { 'feature-disabled:alert-wizard-performance': FeatureDisabledOverride; 'feature-disabled:alerts-page': FeatureDisabledOverride; - 'feature-disabled:codecov-integration-setting': FeatureDisabledOverride; 'feature-disabled:custom-inbound-filters': FeatureDisabledOverride; 'feature-disabled:dashboards-edit': FeatureDisabledOverride; 'feature-disabled:dashboards-page': FeatureDisabledOverride; diff --git a/static/app/utils/analytics/ecosystemAnalyticsEvents.tsx b/static/app/utils/analytics/ecosystemAnalyticsEvents.tsx index f9ee4faed521..67576e796e5f 100644 --- a/static/app/utils/analytics/ecosystemAnalyticsEvents.tsx +++ b/static/app/utils/analytics/ecosystemAnalyticsEvents.tsx @@ -37,10 +37,6 @@ export type EcosystemEventParameters = { provider: string; view: StackTraceView; } & BaseEventAnalyticsParams; - 'integrations.stacktrace_codecov_link_clicked': { - group_id: number; - view: StackTraceView; - } & BaseEventAnalyticsParams; 'integrations.stacktrace_complete_setup': { provider: string; setup_type: SetupType; @@ -100,8 +96,6 @@ export const ecosystemEventMap: Record = { 'Integrations: Stacktrace Manual Option Clicked', 'integrations.stacktrace_start_setup': 'Integrations: Stacktrace Start Setup', 'integrations.stacktrace_submit_config': 'Integrations: Stacktrace Submit Config', - 'integrations.stacktrace_codecov_link_clicked': - 'Integrations: Stacktrace Codecov Link Clicked', 'integrations.non_inapp_stacktrace_link_clicked': 'Integrations: Non-InApp Stacktrace Link Clicked', }; diff --git a/static/app/utils/analytics/settingsAnalyticsEvents.tsx b/static/app/utils/analytics/settingsAnalyticsEvents.tsx index fcc53d95e99e..08c6d4135c9f 100644 --- a/static/app/utils/analytics/settingsAnalyticsEvents.tsx +++ b/static/app/utils/analytics/settingsAnalyticsEvents.tsx @@ -7,7 +7,6 @@ export type SettingsEventParameters = { notification_type: string; tuning_field_type: string; }; - 'organization_settings.codecov_access_updated': {has_access: boolean}; 'sidebar.item_clicked': { dest: string; project_id?: string; @@ -22,7 +21,5 @@ export const settingsEventMap: Record = { 'notification_settings.tuning_page_viewed': 'Notification Settings: Tuning Page Viewed', 'notification_settings.updated_tuning_setting': 'Notification Settings: Updated Tuning Setting', - 'organization_settings.codecov_access_updated': - 'Organization Settings: Codecov Access Updated', 'sidebar.item_clicked': 'Sidebar: Item Clicked', }; diff --git a/static/app/utils/api/knownGetsentryApiUrls.ts b/static/app/utils/api/knownGetsentryApiUrls.ts index 0edc21392b90..6c257abd172d 100644 --- a/static/app/utils/api/knownGetsentryApiUrls.ts +++ b/static/app/utils/api/knownGetsentryApiUrls.ts @@ -59,7 +59,6 @@ export type KnownGetsentryApiUrls = | '/invoices/$invoiceId/close/' | '/invoices/$invoiceId/effective-at/' | '/invoices/$invoiceId/retry-payment/' - | '/organizations/$organizationIdOrSlug/codecov-jwt/' | '/organizations/$organizationIdOrSlug/console-sdk-invites/' | '/organizations/$organizationIdOrSlug/data-consent/' | '/organizations/$organizationIdOrSlug/issues/force-auto-assignment/' diff --git a/static/app/utils/integrationUtil.tsx b/static/app/utils/integrationUtil.tsx index 53bd81d92040..abb6d95ad06c 100644 --- a/static/app/utils/integrationUtil.tsx +++ b/static/app/utils/integrationUtil.tsx @@ -3,7 +3,6 @@ import * as qs from 'query-string'; import { IconAsana, IconBitbucket, - IconCodecov, IconGeneric, IconGithub, IconGitlab, @@ -230,8 +229,6 @@ export const getIntegrationIcon = ( return ; case 'vsts': return ; - case 'codecov': - return ; default: return ; } @@ -257,8 +254,6 @@ export const getIntegrationDisplayName = (integrationType?: string) => { return 'Perforce'; case 'vsts': return 'Azure DevOps'; - case 'codecov': - return 'Codeov'; default: return ''; } diff --git a/static/app/views/settings/organizationGeneralSettings/index.spec.tsx b/static/app/views/settings/organizationGeneralSettings/index.spec.tsx index a05731faa1c8..903cb1c64b1a 100644 --- a/static/app/views/settings/organizationGeneralSettings/index.spec.tsx +++ b/static/app/views/settings/organizationGeneralSettings/index.spec.tsx @@ -17,12 +17,9 @@ import {OrganizationsStore} from 'sentry/stores/organizationsStore'; import {OrganizationStore} from 'sentry/stores/organizationStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {Config} from 'sentry/types/system'; -import {trackAnalytics} from 'sentry/utils/analytics'; import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; import OrganizationGeneralSettings from 'sentry/views/settings/organizationGeneralSettings'; -jest.mock('sentry/utils/analytics'); - describe('OrganizationGeneralSettings', () => { const ENDPOINT = '/organizations/org-slug/'; const organization = OrganizationFixture(); @@ -70,36 +67,6 @@ describe('OrganizationGeneralSettings', () => { }); }); - it('can enable "codecov access"', async () => { - const organizationWithCodecovFeature = OrganizationFixture({ - features: ['codecov-integration'], - codecovAccess: false, - }); - OrganizationStore.onUpdate(organizationWithCodecovFeature, {replace: true}); - render(, { - organization: organizationWithCodecovFeature, - }); - const mock = MockApiClient.addMockResponse({ - url: ENDPOINT, - method: 'PUT', - }); - - await userEvent.click( - screen.getByRole('checkbox', {name: /Enable Code Coverage Insights/i}) - ); - - await waitFor(() => { - expect(mock).toHaveBeenCalledWith( - ENDPOINT, - expect.objectContaining({ - data: {codecovAccess: true}, - }) - ); - }); - - expect(trackAnalytics).toHaveBeenCalled(); - }); - it('changes org slug and redirects to new slug', async () => { const {router} = render(, { organization, diff --git a/static/app/views/settings/organizationGeneralSettings/index.tsx b/static/app/views/settings/organizationGeneralSettings/index.tsx index 6d71b5b847be..2d6a5849c73e 100644 --- a/static/app/views/settings/organizationGeneralSettings/index.tsx +++ b/static/app/views/settings/organizationGeneralSettings/index.tsx @@ -17,7 +17,6 @@ import {PanelHeader} from 'sentry/components/panels/panelHeader'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t, tct} from 'sentry/locale'; import {ConfigStore} from 'sentry/stores/configStore'; -import {trackAnalytics} from 'sentry/utils/analytics'; import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; import {useApi} from 'sentry/utils/useApi'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -74,13 +73,6 @@ export default function OrganizationGeneralSettings() { navigate(`/settings/${updated.slug}/`, {replace: true}); } } else { - if (prevData.codecovAccess !== updated.codecovAccess) { - trackAnalytics('organization_settings.codecov_access_updated', { - organization: updated, - has_access: updated.codecovAccess, - }); - } - // This will update OrganizationStore (as well as OrganizationsStore // which is slightly incorrect because it has summaries vs a detailed org) updateOrganization(updated); diff --git a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx index 769955ea8687..835c69f39850 100644 --- a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx +++ b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx @@ -183,40 +183,6 @@ describe('OrganizationSettingsForm', () => { ).toBeInTheDocument(); }); - it('can enable codecov', async () => { - putMock = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/`, - method: 'PUT', - body: {...organization, codecovAccess: true}, - }); - - render( - , - { - organization: { - ...organization, - features: ['codecov-integration'], - }, - } - ); - - await userEvent.click( - screen.getByRole('checkbox', {name: /Enable Code Coverage Insights/}) - ); - - expect(putMock).toHaveBeenCalledWith( - '/organizations/org-slug/', - expect.objectContaining({ - data: { - codecovAccess: true, - }, - }) - ); - }); - it('can enable "Show Generative AI Features"', async () => { // initialData.hideAiFeatures = false (default) → switch starts OFF render( diff --git a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.tsx b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.tsx index fb9f9b778de7..adb4b59773e0 100644 --- a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.tsx +++ b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.tsx @@ -1,11 +1,9 @@ import {Fragment, useMemo} from 'react'; -import styled from '@emotion/styled'; import {mutationOptions, useQuery} from '@tanstack/react-query'; import {useMutation} from '@tanstack/react-query'; import {z} from 'zod'; import {Alert} from '@sentry/scraps/alert'; -import {Tag} from '@sentry/scraps/badge'; import { AutoSaveForm, defaultFormOptions, @@ -20,12 +18,8 @@ import {Text} from '@sentry/scraps/text'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {updateOrganization} from 'sentry/actionCreators/organizations'; -import Feature from 'sentry/components/acl/feature'; -import {FeatureDisabled} from 'sentry/components/acl/featureDisabled'; import {AvatarChooser} from 'sentry/components/avatarChooser'; -import {Hovercard} from 'sentry/components/hovercard'; import {OverrideOrDefault} from 'sentry/components/overrideOrDefault'; -import {IconCodecov, IconLock} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {ConfigStore} from 'sentry/stores/configStore'; import type {Organization} from 'sentry/types/organization'; @@ -39,10 +33,6 @@ import {slugify} from 'sentry/utils/slugify'; import {useOrganization} from 'sentry/utils/useOrganization'; import {DATA_STORAGE_DOCS_LINK} from 'sentry/views/organizationCreate'; -const OverriddenCodecovSettingsLink = OverrideOrDefault({ - overrideName: 'component:codecov-integration-settings-link', -}); - const OverriddenOrganizationMembershipSettings = OverrideOrDefault({ overrideName: 'component:organization-membership-settings', defaultComponent: OrganizationMembershipSettingsBase, @@ -62,7 +52,6 @@ const generalSchema = z.object({ organizationId: z.string(), isEarlyAdopter: z.boolean(), hideAiFeatures: z.boolean(), - codecovAccess: z.boolean(), slug: z.string().min(1, t('Organization slug is required')), }); @@ -641,60 +630,6 @@ export function OrganizationSettingsForm({initialData, onSave}: Props) { )} - - {/* Enable Code Coverage Insights */} - - {field => ( - - {t('Enable Code Coverage Insights')}{' '} - ( - - } - > - }> - {t('disabled')} - - - )} - features="organizations:codecov-integration" - > - {() => null} - - - } - hintText={ - - {t('powered by')} Codecov{' '} - - - } - > - - - )} - @@ -714,14 +649,3 @@ export function OrganizationSettingsForm({initialData, onSave}: Props) { ); } - -const PoweredByCodecov = styled('div')` - display: flex; - align-items: center; - gap: ${p => p.theme.space.xs}; - - & > span { - display: flex; - align-items: center; - } -`; diff --git a/static/gsApp/components/codecovSettingsLink.tsx b/static/gsApp/components/codecovSettingsLink.tsx deleted file mode 100644 index d85f716d029a..000000000000 --- a/static/gsApp/components/codecovSettingsLink.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import {ExternalLink} from '@sentry/scraps/link'; - -import {t} from 'sentry/locale'; -import type {Organization} from 'sentry/types/organization'; - -import {getCodecovJwtLink, useCodecovJwt} from 'getsentry/utils/useCodecovJwt'; - -export function CodecovSettingsLink({organization}: {organization: Organization}) { - const {data: jwtData, isError} = useCodecovJwt(organization.slug); - - if (isError) { - return null; - } - - const codecovLink = getCodecovJwtLink('sentry-app-stacktracelink', jwtData); - return ( - - {t('Learn More')} - - ); -} diff --git a/static/gsApp/registerOverrides.tsx b/static/gsApp/registerOverrides.tsx index e746420eac73..850b04848cb6 100644 --- a/static/gsApp/registerOverrides.tsx +++ b/static/gsApp/registerOverrides.tsx @@ -1,7 +1,6 @@ import {lazy} from 'react'; import {LazyLoad} from 'sentry/components/lazyLoad'; -import {IconBusiness} from 'sentry/icons'; import {registerOverride} from 'sentry/overrideRegistry'; import type {Overrides} from 'sentry/types/overrides'; import type {OrganizationStatsProps} from 'sentry/views/organizationStats'; @@ -78,7 +77,6 @@ import {useScmFeatureMeta} from 'getsentry/overrides/useScmFeatureMeta'; import {rawTrackAnalyticsEvent} from 'getsentry/utils/rawTrackAnalyticsEvent'; import {trackMetric} from 'getsentry/utils/trackMetric'; -import {CodecovSettingsLink} from './components/codecovSettingsLink'; import {GsBillingCommandPaletteActions} from './components/gsBillingCommandPaletteActions'; import {PrimaryNavigationQuotaExceeded} from './components/navBillingStatus'; import {OpenInDiscoverBtn} from './components/openInDiscoverBtn'; @@ -236,7 +234,6 @@ const GETSENTRY_OVERRIDES: Partial = { InsightsDateRangeQueryLimitFooter, 'component:ai-configure-seer-quota-sidebar': () => AiConfigureSeerQuotaSidebar, 'component:ai-setup-data-consent': () => AiSetupDataConsent, - 'component:codecov-integration-settings-link': () => CodecovSettingsLink, 'component:continuous-profiling-billing-requirement-banner': () => ContinuousProfilingBillingRequirementBanner, 'component:header-date-page-filter-upsell-footer': () => DateRangeQueryLimitFooter, @@ -335,14 +332,6 @@ const GETSENTRY_OVERRIDES: Partial = { {typeof p.children === 'function' ? p.children(p) : p.children} ), - 'feature-disabled:codecov-integration-setting': () => ( - - - - ), 'feature-disabled:project-performance-score-card': p => ( {typeof p.children === 'function' ? p.children(p) : p.children} diff --git a/static/gsApp/utils/useCodecovJwt.tsx b/static/gsApp/utils/useCodecovJwt.tsx deleted file mode 100644 index 08d6e551f1c8..000000000000 --- a/static/gsApp/utils/useCodecovJwt.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import type {UseApiQueryOptions} from 'sentry/utils/queryClient'; -import {useApiQuery} from 'sentry/utils/queryClient'; - -interface CodecovJWTResponse { - token: string; -} - -export function useCodecovJwt( - orgSlug: string, - options: Partial> = {} -) { - return useApiQuery( - [ - getApiUrl('/organizations/$organizationIdOrSlug/codecov-jwt/', { - path: {organizationIdOrSlug: orgSlug}, - }), - ], - { - staleTime: Infinity, - retry: false, - refetchOnWindowFocus: false, - ...options, - } - ); -} - -export function getCodecovJwtLink( - source: string, - jwtData?: CodecovJWTResponse -): string | undefined { - if (!jwtData?.token) { - return undefined; - } - - const params = new URLSearchParams({ - state: jwtData.token, - utm_medium: 'referral', - utm_source: source, - utm_campaign: 'sentry-codecov', - utm_department: 'marketing', - }); - return `https://app.codecov.io/login/?${params.toString()}`; -} diff --git a/tests/js/fixtures/organization.ts b/tests/js/fixtures/organization.ts index cbd76dac6eed..5114098c68e4 100644 --- a/tests/js/fixtures/organization.ts +++ b/tests/js/fixtures/organization.ts @@ -50,7 +50,6 @@ export function OrganizationFixture(params: Partial = {}): Organiz avatarUuid: null, avatarUrl: null, }, - codecovAccess: false, dataScrubber: false, dataScrubberDefaults: false, dateCreated: new Date().toISOString(), diff --git a/tests/js/getsentry-test/fixtures/am1Plans.ts b/tests/js/getsentry-test/fixtures/am1Plans.ts index 5608f9b69155..1472d08262b9 100644 --- a/tests/js/getsentry-test/fixtures/am1Plans.ts +++ b/tests/js/getsentry-test/fixtures/am1Plans.ts @@ -66,7 +66,6 @@ const AM1_FREE_FEATURES = [ const AM1_TEAM_FEATURES = [ ...AM1_FREE_FEATURES, - 'codecov-integration', 'crash-rate-alerts', 'discover-basic', 'incidents', diff --git a/tests/js/getsentry-test/fixtures/am2Plans.ts b/tests/js/getsentry-test/fixtures/am2Plans.ts index 26d25f1dea4e..cab6bce2ed44 100644 --- a/tests/js/getsentry-test/fixtures/am2Plans.ts +++ b/tests/js/getsentry-test/fixtures/am2Plans.ts @@ -76,7 +76,6 @@ const AM2_FREE_FEATURES = [ const AM2_TEAM_FEATURES = [ ...AM2_FREE_FEATURES, - 'codecov-integration', 'crash-rate-alerts', 'discover-basic', 'incidents', diff --git a/tests/js/getsentry-test/fixtures/am3Plans.ts b/tests/js/getsentry-test/fixtures/am3Plans.ts index 0ed889ad3c42..9dbfb61dcd8a 100644 --- a/tests/js/getsentry-test/fixtures/am3Plans.ts +++ b/tests/js/getsentry-test/fixtures/am3Plans.ts @@ -97,7 +97,6 @@ const AM3_FREE_FEATURES = [ const AM3_TEAM_FEATURES = [ ...AM3_FREE_FEATURES, - 'codecov-integration', 'crash-rate-alerts', 'discover-basic', 'incidents', From 51326ad0a64de23c0ef9376a6db6966ba3163ecf Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Fri, 29 May 2026 14:12:40 -0400 Subject: [PATCH 18/42] ref(feature-flags): Remove `organizations:insights-ai-and-mcp-dashboard-migration` (#116450) Remove `organizations:insights-ai-and-mcp-dashboard-migration`. This flag has 0 usages across code, frontend, and tests (dead for 78 days, first seen dead 2026-03-12). Not present in sentry-options-automator. --- _Generated by [Claude Code](https://claude.ai/code/session_01RNki2RgdHF3ZuroLVpupeQ)_ Co-authored-by: Claude --- src/sentry/features/temporary.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 94cdccedb169..1d286ade2a59 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -192,8 +192,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:performance-discover-get-custom-measurements-reduced-range", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Detect performance issues in the new standalone spans pipeline instead of on transactions manager.add("organizations:performance-issues-spans", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=False, api_expose=False) - # Enable AI and MCP module dashboards on dashboards platform - manager.add("organizations:insights-ai-and-mcp-dashboard-migration", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable all registered prebuilt dashboards to be synced to the database manager.add("organizations:dashboards-sync-all-registered-prebuilt-dashboards", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable Seer Suggestions for Web Vitals Module From 297a20eaa5c960b6cc53f0291296c56a29432861 Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Fri, 29 May 2026 14:27:15 -0400 Subject: [PATCH 19/42] fix(segment-enrichment): Propagate conventional user attributes (#116492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently v1 (transaction-based) spans send user attributes in `sentry.user.*` attributes, while v2 (streaming) spans send user attributes in conventions-defined `user.*` attributes. Only the v1 attributes are currently propagated, meaning that child spans from span streaming SDKs are missing user info. We can use our conventions' deprecation mechanism (https://github.com/getsentry/sentry-conventions/pull/406) to make queries backwards compatible, but that doesn't affect segment enrichment which reads data directly from spans. For now, propagate both the old v1 user attributes and the new v2 user attributes to children. Once that's done, EAP's attribute coalescing (via above deprecation PR) will take care of the rest. Next steps will be to clean up our use of the v1 `sentry.user.*` attributes in Relay and Sentry, at which point we can stop propagating them. (That work is tracked in BROWSE-535). Fixes BROWSE-532. --- 🤖: Claude Code (Opus 4.6) used to generate the tests. I made the code changes and reviewed the tests. Words my own. --- .../consumers/process_segments/enrichment.py | 32 ++++++--- .../process_segments/test_enrichment.py | 72 +++++++++++++++++++ 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/src/sentry/spans/consumers/process_segments/enrichment.py b/src/sentry/spans/consumers/process_segments/enrichment.py index 56569e7781fa..d41f20cdd761 100644 --- a/src/sentry/spans/consumers/process_segments/enrichment.py +++ b/src/sentry/spans/consumers/process_segments/enrichment.py @@ -17,16 +17,6 @@ # is taken from `extract_shared_tags` in Relay. SHARED_SENTRY_ATTRIBUTES = ( ATTRIBUTE_NAMES.SENTRY_RELEASE, - "sentry.user", - "sentry.user.id", - "sentry.user.ip", - "sentry.user.username", - "sentry.user.email", - "sentry.user.geo.city", - "sentry.user.geo.country_code", - "sentry.user.geo.region", - "sentry.user.geo.subdivision", - "sentry.user.geo.subregion", ATTRIBUTE_NAMES.SENTRY_ENVIRONMENT, ATTRIBUTE_NAMES.SENTRY_TRANSACTION, "sentry.transaction.method", @@ -42,6 +32,28 @@ ATTRIBUTE_NAMES.SENTRY_PLATFORM, "sentry.thread.id", "sentry.thread.name", + # Current user attributes + ATTRIBUTE_NAMES.USER_EMAIL, + ATTRIBUTE_NAMES.USER_GEO_CITY, + ATTRIBUTE_NAMES.USER_GEO_COUNTRY_CODE, + ATTRIBUTE_NAMES.USER_GEO_REGION, + ATTRIBUTE_NAMES.USER_GEO_SUBDIVISION, + ATTRIBUTE_NAMES.USER_ID, + ATTRIBUTE_NAMES.USER_IP_ADDRESS, + ATTRIBUTE_NAMES.USER_NAME, + # Legacy user attributes, taken from sentry_tags. + # TODO(mjq): Remove these once everything is switched over to the new + # conventional attribute names. See BROWSE-535. + "sentry.user", + "sentry.user.id", + "sentry.user.ip", + "sentry.user.username", + "sentry.user.email", + "sentry.user.geo.city", + "sentry.user.geo.country_code", + "sentry.user.geo.region", + "sentry.user.geo.subdivision", + "sentry.user.geo.subregion", ) # The name of the main thread used to infer the `main_thread` flag in spans from diff --git a/tests/sentry/spans/consumers/process_segments/test_enrichment.py b/tests/sentry/spans/consumers/process_segments/test_enrichment.py index a7d07795ed84..ef8fe6cbdc25 100644 --- a/tests/sentry/spans/consumers/process_segments/test_enrichment.py +++ b/tests/sentry/spans/consumers/process_segments/test_enrichment.py @@ -1,5 +1,6 @@ from typing import cast +from sentry_conventions.attributes import ATTRIBUTE_NAMES from sentry_kafka_schemas.schema_types.ingest_spans_v1 import SpanEvent from sentry.spans.consumers.process_segments.enrichment import ( @@ -514,6 +515,77 @@ def _mock_performance_issue_span(is_segment, attributes, **fields) -> SpanEvent: ) +def test_conventional_user_attributes_propagated_to_child_spans() -> None: + """New conventional user attributes (user.email, user.id, etc.) are propagated + from the segment span to child spans that don't already have them.""" + user_attrs = { + ATTRIBUTE_NAMES.USER_EMAIL: {"type": "string", "value": "user@example.com"}, + ATTRIBUTE_NAMES.USER_ID: {"type": "string", "value": "12345"}, + ATTRIBUTE_NAMES.USER_IP_ADDRESS: {"type": "string", "value": "203.0.113.1"}, + ATTRIBUTE_NAMES.USER_NAME: {"type": "string", "value": "testuser"}, + ATTRIBUTE_NAMES.USER_GEO_CITY: {"type": "string", "value": "San Francisco"}, + ATTRIBUTE_NAMES.USER_GEO_COUNTRY_CODE: {"type": "string", "value": "US"}, + ATTRIBUTE_NAMES.USER_GEO_REGION: {"type": "string", "value": "CA"}, + ATTRIBUTE_NAMES.USER_GEO_SUBDIVISION: {"type": "string", "value": "San Francisco"}, + } + + segment = build_mock_span( + project_id=1, + is_segment=True, + span_id="aaaaaaaaaaaaaaaa", + start_timestamp=1609455600.0, + end_timestamp=1609455605.0, + attributes=user_attrs, + ) + child = build_mock_span( + project_id=1, + span_id="bbbbbbbbbbbbbbbb", + parent_span_id="aaaaaaaaaaaaaaaa", + start_timestamp=1609455601.0, + end_timestamp=1609455602.0, + ) + + _, enriched = TreeEnricher.enrich_spans([segment, child]) + + enriched_child = enriched[1] + for attr_name, attr in user_attrs.items(): + assert attribute_value(enriched_child, attr_name) == attr["value"], ( + f"{attr_name} not propagated to child" + ) + + +def test_conventional_user_attributes_not_overwritten_on_child() -> None: + """If a child span already has a conventional user attribute, the segment + value must not overwrite it.""" + segment = build_mock_span( + project_id=1, + is_segment=True, + span_id="aaaaaaaaaaaaaaaa", + start_timestamp=1609455600.0, + end_timestamp=1609455605.0, + attributes={ + ATTRIBUTE_NAMES.USER_EMAIL: {"type": "string", "value": "segment@example.com"}, + ATTRIBUTE_NAMES.USER_ID: {"type": "string", "value": "111"}, + }, + ) + child = build_mock_span( + project_id=1, + span_id="bbbbbbbbbbbbbbbb", + parent_span_id="aaaaaaaaaaaaaaaa", + start_timestamp=1609455601.0, + end_timestamp=1609455602.0, + attributes={ + ATTRIBUTE_NAMES.USER_EMAIL: {"type": "string", "value": "child@example.com"}, + }, + ) + + _, enriched = TreeEnricher.enrich_spans([segment, child]) + + enriched_child = enriched[1] + assert attribute_value(enriched_child, ATTRIBUTE_NAMES.USER_EMAIL) == "child@example.com" + assert attribute_value(enriched_child, ATTRIBUTE_NAMES.USER_ID) == "111" + + def test_enrich_gen_ai_agent_name_from_immediate_parent() -> None: """Test that gen_ai.agent.name is inherited from the immediate parent with gen_ai.invoke_agent operation.""" parent_span = build_mock_span( From 7eb9deafec4cbbd29a3ad4d7294ece7687082985 Mon Sep 17 00:00:00 2001 From: Sofia Rest <68917129+srest2021@users.noreply.github.com> Date: Fri, 29 May 2026 11:40:04 -0700 Subject: [PATCH 20/42] feat(settings): Support legacy usage-based Seer in project settings endpoint (#115962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Depends on https://github.com/getsentry/sentry/pull/116352 and https://github.com/getsentry/sentry/pull/116356. Add legacy usage-based Seer support to the project settings endpoints by branching on `is_seer_seat_based_tier_enabled`. **Serializer split:** - Extract `_BaseProjectSettingsUpdateSerializer` with shared validation (agent, integration_id, cross-field checks) - `ProjectSettingsUpdateSerializer` (seat-based): restricted tuning choices (off/medium), stopping_point validation via `get_valid_automated_run_stopping_points`, auto_create_pr synced from stopping_point - `LegacyProjectSettingsUpdateSerializer` (usage-based): accepts all tuning values, direct `auto_create_pr` field, no stopping_point restrictions ### Example payloads **Set tuning to a granular value:** ```json PUT /api/0/projects/{org}/{project}/seer/settings/ {"automationTuning": "high"} ``` **Set stopping point (frontend sends agent=seer to clear any external handoff):** ```json PUT /api/0/projects/{org}/{project}/seer/settings/ {"stoppingPoint": "code_changes", "agent": "seer"} ``` This clears handoff keys (target, point, integration_id) but preserves `auto_create_pr` so switching back to an external agent restores the previous toggle value. **Switch to external agent with auto_create_pr:** ```json PUT /api/0/projects/{org}/{project}/seer/settings/ {"agent": "cursor_background_agent", "integrationId": 1, "autoCreatePr": true} ``` **Toggle auto_create_pr on an existing external agent:** ```json PUT /api/0/projects/{org}/{project}/seer/settings/ {"autoCreatePr": false} ``` **Turn off automation:** ```json PUT /api/0/projects/{org}/{project}/seer/settings/ {"automationTuning": "off"} ``` GET response will show `stoppingPoint: "off"`, but the stored stopping point is unchanged — re-enabling restores it. --- .../seer/endpoints/project_seer_settings.py | 63 ++++-- .../endpoints/test_project_seer_settings.py | 202 +++++++++++++----- 2 files changed, 192 insertions(+), 73 deletions(-) diff --git a/src/sentry/seer/endpoints/project_seer_settings.py b/src/sentry/seer/endpoints/project_seer_settings.py index 8249b26f73cd..8b0370fca778 100644 --- a/src/sentry/seer/endpoints/project_seer_settings.py +++ b/src/sentry/seer/endpoints/project_seer_settings.py @@ -39,6 +39,7 @@ AutomationCodingAgent, get_automation_handoff, get_valid_automated_run_stopping_points, + is_seer_seat_based_tier_enabled, update_seer_project_settings, ) from sentry.seer.models.project_repository import SeerProjectRepository @@ -313,20 +314,17 @@ def _apply_search_filters(queryset, filters: Sequence[QueryToken]): return queryset -class ProjectSettingsUpdateSerializer(CamelSnakeSerializer): +class _BaseProjectSettingsUpdateSerializer(CamelSnakeSerializer): agent = serializers.ChoiceField(choices=[*AutomationCodingAgent], required=False) integration_id = serializers.IntegerField(required=False) stopping_point = serializers.ChoiceField(choices=[*AutofixStoppingPoint], required=False) scanner_automation = serializers.BooleanField(required=False) automation_tuning = serializers.ChoiceField( - choices=[AutofixAutomationTuningSettings.OFF, AutofixAutomationTuningSettings.MEDIUM], - required=False, + choices=[*AutofixAutomationTuningSettings], required=False ) - def validate_stopping_point(self, value: str) -> str: - if value not in get_valid_automated_run_stopping_points(self.context["organization"]): - raise serializers.ValidationError(f'"{value}" is not a valid choice.') - return value + def _update_fields(self) -> set[str]: + return {"agent", "stopping_point", "scanner_automation", "automation_tuning"} def validate_integration_id(self, value: int) -> int: organization = self.context["organization"] @@ -353,12 +351,28 @@ def validate(self, data): {"agent": "Must be an external coding agent when integration_id is provided."} ) - if not any( - k in data - for k in ("agent", "stopping_point", "scanner_automation", "automation_tuning") - ): + if not any(k in data for k in self._update_fields()): raise serializers.ValidationError("At least one update field must be provided.") + return data + + +class ProjectSettingsUpdateSerializer(_BaseProjectSettingsUpdateSerializer): + """Seat-based (new) Seer: restricted tuning choices, stopping point sync.""" + + automation_tuning = serializers.ChoiceField( + choices=[AutofixAutomationTuningSettings.OFF, AutofixAutomationTuningSettings.MEDIUM], + required=False, + ) + + def validate_stopping_point(self, value: str) -> str: + if value not in get_valid_automated_run_stopping_points(self.context["organization"]): + raise serializers.ValidationError(f'"{value}" is not a valid choice.') + return value + + def validate(self, data): + data = super().validate(data) + # Keep stopping point in sync with handoff auto_create_pr. if "stopping_point" in data and "auto_create_pr" not in data: data["auto_create_pr"] = data["stopping_point"] == AutofixStoppingPoint.OPEN_PR @@ -366,6 +380,15 @@ def validate(self, data): return data +class LegacyProjectSettingsUpdateSerializer(_BaseProjectSettingsUpdateSerializer): + """Legacy Seer: accepts auto_create_pr and all tuning/stopping point values.""" + + auto_create_pr = serializers.BooleanField(required=False) + + def _update_fields(self) -> set[str]: + return super()._update_fields() | {"auto_create_pr"} + + @cell_silo_endpoint class ProjectSeerSettingsEndpoint(ProjectEndpoint): owner = ApiOwner.ML_AI @@ -379,7 +402,12 @@ def get(self, request: Request, project: Project) -> Response: return Response(serialize_project(project)) def put(self, request: Request, project: Project) -> Response: - serializer = ProjectSettingsUpdateSerializer( + serializer_cls = ( + ProjectSettingsUpdateSerializer + if is_seer_seat_based_tier_enabled(project.organization) + else LegacyProjectSettingsUpdateSerializer + ) + serializer = serializer_cls( data=request.data, context={"organization": project.organization} ) if not serializer.is_valid(): @@ -403,6 +431,10 @@ class BulkProjectSettingsUpdateSerializer(ProjectSettingsUpdateSerializer): query = serializers.CharField(required=False, default="") +class LegacyBulkProjectSettingsUpdateSerializer(LegacyProjectSettingsUpdateSerializer): + query = serializers.CharField(required=False, default="") + + @cell_silo_endpoint class OrganizationSeerProjectSettingsEndpoint(OrganizationEndpoint): owner = ApiOwner.ML_AI @@ -440,9 +472,12 @@ def get(self, request: Request, organization: Organization) -> Response: ) def put(self, request: Request, organization: Organization) -> Response: - serializer = BulkProjectSettingsUpdateSerializer( - data=request.data, context={"organization": organization} + serializer_cls = ( + BulkProjectSettingsUpdateSerializer + if is_seer_seat_based_tier_enabled(organization) + else LegacyBulkProjectSettingsUpdateSerializer ) + serializer = serializer_cls(data=request.data, context={"organization": organization}) if not serializer.is_valid(): return Response(serializer.errors, status=400) diff --git a/tests/sentry/seer/endpoints/test_project_seer_settings.py b/tests/sentry/seer/endpoints/test_project_seer_settings.py index ae1c2bcc8495..565e466839e8 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_settings.py +++ b/tests/sentry/seer/endpoints/test_project_seer_settings.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.urls import reverse from sentry.constants import ObjectStatus @@ -6,6 +8,10 @@ from sentry.testutils.cases import APITestCase +@patch( + "sentry.seer.endpoints.project_seer_settings.is_seer_seat_based_tier_enabled", + return_value=True, +) class ProjectSeerSettingsEndpointTest(APITestCase): endpoint = "sentry-api-0-project-seer-settings" @@ -21,7 +27,7 @@ def setUp(self) -> None: }, ) - def test_get_returns_defaults(self) -> None: + def test_get_returns_defaults(self, mock_is_seat_based) -> None: """A project with no options set should return defaults.""" response = self.client.get(self.url) @@ -38,7 +44,7 @@ def test_get_returns_defaults(self) -> None: "reposCount": 0, } - def test_get_returns_configured_project_options(self) -> None: + def test_get_returns_configured_project_options(self, mock_is_seat_based) -> None: """A project with explicit options should reflect them in the response.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM @@ -54,7 +60,7 @@ def test_get_returns_configured_project_options(self) -> None: assert response.data["automationTuning"] == "medium" assert response.data["scannerAutomation"] is False - def test_get_external_agent_with_integration_id(self) -> None: + def test_get_external_agent_with_integration_id(self, mock_is_seat_based) -> None: """A project with an external handoff should return the agent, integration ID, and autoCreatePr from the handoff config.""" self.project.update_option( @@ -72,7 +78,7 @@ def test_get_external_agent_with_integration_id(self) -> None: assert response.data["integrationId"] == "42" assert response.data["autoCreatePr"] is False - def test_get_external_agent_with_auto_create_pr(self) -> None: + def test_get_external_agent_with_auto_create_pr(self, mock_is_seat_based) -> None: """autoCreatePr should reflect the handoff config value.""" self.project.update_option( "sentry:seer_automation_handoff_target", "cursor_background_agent" @@ -88,7 +94,7 @@ def test_get_external_agent_with_auto_create_pr(self) -> None: assert response.status_code == 200 assert response.data["autoCreatePr"] is True - def test_get_stopping_point_off_when_tuning_off(self) -> None: + def test_get_stopping_point_off_when_tuning_off(self, mock_is_seat_based) -> None: """stoppingPoint should be 'off' when tuning is OFF.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.OFF @@ -101,7 +107,7 @@ def test_get_stopping_point_off_when_tuning_off(self) -> None: assert response.data["stoppingPoint"] == "off" assert response.data["automationTuning"] == "off" - def test_get_stopping_point_when_tuning_on(self) -> None: + def test_get_stopping_point_when_tuning_on(self, mock_is_seat_based) -> None: """When tuning is not OFF, stoppingPoint should reflect the stored value.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM @@ -114,7 +120,7 @@ def test_get_stopping_point_when_tuning_on(self) -> None: assert response.data["stoppingPoint"] == "root_cause" assert response.data["automationTuning"] == "medium" - def test_get_repos_count(self) -> None: + def test_get_repos_count(self, mock_is_seat_based) -> None: """reposCount should reflect active SeerProjectRepository rows.""" repo1 = self.create_repo(project=self.project, name="owner/repo-1") repo2 = self.create_repo(project=self.project, name="owner/repo-2") @@ -126,7 +132,7 @@ def test_get_repos_count(self) -> None: assert response.status_code == 200 assert response.data["reposCount"] == 2 - def test_get_repos_count_excludes_inactive_repos(self) -> None: + def test_get_repos_count_excludes_inactive_repos(self, mock_is_seat_based) -> None: """Repos with non-active status should not be counted.""" active_repo = self.create_repo(project=self.project, name="owner/active") disabled_repo = self.create_repo(project=self.project, name="owner/deleted") @@ -140,7 +146,7 @@ def test_get_repos_count_excludes_inactive_repos(self) -> None: assert response.status_code == 200 assert response.data["reposCount"] == 1 - def test_put_returns_updated_settings(self) -> None: + def test_put_returns_updated_settings(self, mock_is_seat_based) -> None: """PUT response should contain the full updated settings object.""" response = self.client.put( self.url, @@ -156,7 +162,7 @@ def test_put_returns_updated_settings(self) -> None: assert "scannerAutomation" in response.data assert "reposCount" in response.data - def test_put_external_agent_with_valid_integration(self) -> None: + def test_put_external_agent_with_valid_integration(self, mock_is_seat_based) -> None: """Valid external agent + integrationId should succeed and reflect in response.""" integration = self.create_integration( organization=self.organization, external_id="ext", provider="github" @@ -171,20 +177,20 @@ def test_put_external_agent_with_valid_integration(self) -> None: assert response.data["agent"] == "cursor_background_agent" assert response.data["integrationId"] == str(integration.id) - def test_put_scanner_automation(self) -> None: + def test_put_scanner_automation(self, mock_is_seat_based) -> None: """PUT scannerAutomation should update and return the new value.""" response = self.client.put(self.url, data={"scannerAutomation": False}, format="json") assert response.status_code == 200 assert response.data["scannerAutomation"] is False - def test_put_stopping_point(self) -> None: + def test_put_stopping_point(self, mock_is_seat_based) -> None: response = self.client.put(self.url, data={"stoppingPoint": "open_pr"}, format="json") assert response.status_code == 200 assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" - def test_put_stopping_point_open_pr_syncs_auto_create_pr(self) -> None: + def test_put_stopping_point_open_pr_syncs_auto_create_pr(self, mock_is_seat_based) -> None: """Setting stoppingPoint to open_pr should also set auto_create_pr to True.""" response = self.client.put(self.url, data={"stoppingPoint": "open_pr"}, format="json") @@ -192,7 +198,7 @@ def test_put_stopping_point_open_pr_syncs_auto_create_pr(self) -> None: assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - def test_put_stopping_point_non_open_pr_clears_auto_create_pr(self) -> None: + def test_put_stopping_point_non_open_pr_clears_auto_create_pr(self, mock_is_seat_based) -> None: """Setting stoppingPoint to non-open_pr should clear auto_create_pr.""" self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) @@ -202,8 +208,40 @@ def test_put_stopping_point_non_open_pr_clears_auto_create_pr(self) -> None: assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "code_changes" assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False - def test_put_automation_tuning(self) -> None: - """automationTuning accepts off and medium.""" + def test_put_legacy_stopping_point_does_not_sync_auto_create_pr( + self, mock_is_seat_based + ) -> None: + """Legacy: changing stoppingPoint should not touch auto_create_pr.""" + mock_is_seat_based.return_value = False + + response = self.client.put(self.url, data={"stoppingPoint": "open_pr"}, format="json") + + assert response.status_code == 200 + assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False + + def test_put_legacy_auto_create_pr(self, mock_is_seat_based) -> None: + """autoCreatePr should update the handoff option directly.""" + mock_is_seat_based.return_value = False + + response = self.client.put(self.url, data={"autoCreatePr": True}, format="json") + + assert response.status_code == 200 + assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True + + def test_put_legacy_auto_create_pr_preserves_stopping_point(self, mock_is_seat_based) -> None: + """autoCreatePr should not change the stored stopping_point.""" + mock_is_seat_based.return_value = False + + self.project.update_option("sentry:seer_automated_run_stopping_point", "root_cause") + + response = self.client.put(self.url, data={"autoCreatePr": True}, format="json") + + assert response.status_code == 200 + assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "root_cause" + + def test_put_automation_tuning(self, mock_is_seat_based) -> None: + """Seat-based: automationTuning accepts off and medium.""" response = self.client.put(self.url, data={"automationTuning": "off"}, format="json") assert response.status_code == 200 assert ( @@ -218,24 +256,35 @@ def test_put_automation_tuning(self) -> None: == AutofixAutomationTuningSettings.MEDIUM ) - def test_put_automation_tuning_rejects_granular(self) -> None: - """Granular tuning values like 'high' should be rejected.""" + def test_put_automation_tuning_rejects_granular(self, mock_is_seat_based) -> None: + """Seat-based: granular tuning values like 'high' should be rejected.""" response = self.client.put(self.url, data={"automationTuning": "high"}, format="json") assert response.status_code == 400 - def test_put_requires_at_least_one_update_field(self) -> None: + def test_put_legacy_automation_tuning_allows_granular(self, mock_is_seat_based) -> None: + """Legacy: automationTuning should set tuning directly.""" + mock_is_seat_based.return_value = False + response = self.client.put(self.url, data={"automationTuning": "high"}, format="json") + + assert response.status_code == 200 + assert ( + self.project.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.HIGH + ) + + def test_put_requires_at_least_one_update_field(self, mock_is_seat_based) -> None: """Sending no update fields should return 400.""" response = self.client.put(self.url, data={}, format="json") assert response.status_code == 400 - def test_put_requires_integration_id_for_external_agent(self) -> None: + def test_put_requires_integration_id_for_external_agent(self, mock_is_seat_based) -> None: """External agent without integrationId should return 400.""" response = self.client.put( self.url, data={"agent": "cursor_background_agent"}, format="json" ) assert response.status_code == 400 - def test_put_rejects_integration_id_without_agent(self) -> None: + def test_put_rejects_integration_id_without_agent(self, mock_is_seat_based) -> None: """integrationId without agent should return 400.""" integration = self.create_integration( organization=self.organization, external_id="valid", provider="github" @@ -243,7 +292,7 @@ def test_put_rejects_integration_id_without_agent(self) -> None: response = self.client.put(self.url, data={"integrationId": integration.id}, format="json") assert response.status_code == 400 - def test_put_rejects_integration_id_with_seer_agent(self) -> None: + def test_put_rejects_integration_id_with_seer_agent(self, mock_is_seat_based) -> None: """integrationId with agent=seer should return 400.""" integration = self.create_integration( organization=self.organization, external_id="valid", provider="github" @@ -253,7 +302,7 @@ def test_put_rejects_integration_id_with_seer_agent(self) -> None: ) assert response.status_code == 400 - def test_put_rejects_integration_id_from_other_org(self) -> None: + def test_put_rejects_integration_id_from_other_org(self, mock_is_seat_based) -> None: """An integration ID that doesn't belong to this org should return 400.""" other_org = self.create_organization() integration = self.create_integration( @@ -267,22 +316,22 @@ def test_put_rejects_integration_id_from_other_org(self) -> None: ) assert response.status_code == 400 - def test_put_seer_agent_does_not_require_integration_id(self) -> None: + def test_put_seer_agent_does_not_require_integration_id(self, mock_is_seat_based) -> None: """agent=seer should not require integrationId.""" response = self.client.put(self.url, data={"agent": "seer"}, format="json") assert response.status_code == 200 - def test_put_rejects_invalid_agent(self) -> None: + def test_put_rejects_invalid_agent(self, mock_is_seat_based) -> None: """An unrecognized agent value should return 400.""" response = self.client.put(self.url, data={"agent": "invalid"}, format="json") assert response.status_code == 400 - def test_put_rejects_invalid_stopping_point(self) -> None: + def test_put_rejects_invalid_stopping_point(self, mock_is_seat_based) -> None: """An unrecognized stoppingPoint value should return 400.""" response = self.client.put(self.url, data={"stoppingPoint": "invalid"}, format="json") assert response.status_code == 400 - def test_put_creates_audit_log_entry(self) -> None: + def test_put_creates_audit_log_entry(self, mock_is_seat_based) -> None: """PUT should create an audit log entry with the project ID.""" from sentry.models.auditlogentry import AuditLogEntry from sentry.silo.base import SiloMode @@ -305,6 +354,10 @@ def test_put_creates_audit_log_entry(self) -> None: assert entry.data["project_id"] == self.project.id +@patch( + "sentry.seer.endpoints.project_seer_settings.is_seer_seat_based_tier_enabled", + return_value=True, +) class OrganizationSeerProjectSettingsEndpointTest(APITestCase): endpoint = "sentry-api-0-organization-seer-project-settings" @@ -317,7 +370,7 @@ def setUp(self) -> None: kwargs={"organization_id_or_slug": self.organization.slug}, ) - def test_get_returns_defaults(self) -> None: + def test_get_returns_defaults(self, mock_is_seat_based) -> None: """Projects with no options set should return default values.""" response = self.client.get(self.url) @@ -335,7 +388,7 @@ def test_get_returns_defaults(self) -> None: "reposCount": 0, } - def test_get_returns_configured_project_options(self) -> None: + def test_get_returns_configured_project_options(self, mock_is_seat_based) -> None: """Projects with explicit option values should reflect those in the response.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM @@ -351,7 +404,7 @@ def test_get_returns_configured_project_options(self) -> None: assert response.data[0]["automationTuning"] == "medium" assert response.data[0]["scannerAutomation"] is False - def test_get_external_agent_with_integration_id(self) -> None: + def test_get_external_agent_with_integration_id(self, mock_is_seat_based) -> None: """A project configured with an external handoff should return the agent, integration ID, and autoCreatePr from the handoff config.""" self.project.update_option( @@ -369,7 +422,7 @@ def test_get_external_agent_with_integration_id(self) -> None: assert response.data[0]["integrationId"] == "42" assert response.data[0]["autoCreatePr"] is False - def test_get_external_agent_with_auto_create_pr(self) -> None: + def test_get_external_agent_with_auto_create_pr(self, mock_is_seat_based) -> None: """autoCreatePr should reflect the handoff config value.""" self.project.update_option( "sentry:seer_automation_handoff_target", "cursor_background_agent" @@ -385,7 +438,7 @@ def test_get_external_agent_with_auto_create_pr(self) -> None: assert response.status_code == 200 assert response.data[0]["autoCreatePr"] is True - def test_get_stopping_point_off_when_tuning_off(self) -> None: + def test_get_stopping_point_off_when_tuning_off(self, mock_is_seat_based) -> None: """When tuning is OFF, stoppingPoint should be 'off' regardless of the stored seer_automated_run_stopping_point value.""" self.project.update_option( @@ -399,7 +452,7 @@ def test_get_stopping_point_off_when_tuning_off(self) -> None: assert response.data[0]["stoppingPoint"] == "off" assert response.data[0]["automationTuning"] == "off" - def test_get_stopping_point_when_tuning_on(self) -> None: + def test_get_stopping_point_when_tuning_on(self, mock_is_seat_based) -> None: """When tuning is not OFF, stoppingPoint should reflect the stored value.""" self.project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM @@ -412,7 +465,7 @@ def test_get_stopping_point_when_tuning_on(self) -> None: assert response.data[0]["stoppingPoint"] == "root_cause" assert response.data[0]["automationTuning"] == "medium" - def test_get_repos_count(self) -> None: + def test_get_repos_count(self, mock_is_seat_based) -> None: """reposCount should reflect the number of active SeerProjectRepository rows.""" repo1 = self.create_repo(project=self.project, name="owner/repo-1") repo2 = self.create_repo(project=self.project, name="owner/repo-2") @@ -424,7 +477,7 @@ def test_get_repos_count(self) -> None: assert response.status_code == 200 assert response.data[0]["reposCount"] == 2 - def test_get_repos_count_excludes_inactive_repos(self) -> None: + def test_get_repos_count_excludes_inactive_repos(self, mock_is_seat_based) -> None: """Repos with non-active status should not be counted.""" active_repo = self.create_repo(project=self.project, name="owner/active") disabled_repo = self.create_repo(project=self.project, name="owner/deleted") @@ -438,7 +491,7 @@ def test_get_repos_count_excludes_inactive_repos(self) -> None: assert response.status_code == 200 assert response.data[0]["reposCount"] == 1 - def test_get_only_returns_accessible_projects(self) -> None: + def test_get_only_returns_accessible_projects(self, mock_is_seat_based) -> None: """Response should only include projects the user has access to.""" self.organization.flags.allow_joinleave = False self.organization.save() @@ -458,7 +511,7 @@ def test_get_only_returns_accessible_projects(self) -> None: assert len(project_ids) == 1 assert str(inaccessible_project.id) not in project_ids - def test_get_paginates_results(self) -> None: + def test_get_paginates_results(self, mock_is_seat_based) -> None: """Results should be paginated with Link headers indicating next/previous.""" for i in range(5): self.create_project(organization=self.organization, slug=f"paginate-{i}") @@ -473,7 +526,7 @@ def test_get_paginates_results(self) -> None: assert 'rel="previous"; results="true"' in response2.headers["Link"] assert 'rel="next"; results="false"' in response2.headers["Link"] - def test_get_sort_by_name(self) -> None: + def test_get_sort_by_name(self, mock_is_seat_based) -> None: """sortBy=name should order by project slug.""" project_b = self.create_project(organization=self.organization, slug="banana") project_a = self.create_project(organization=self.organization, slug="apple") @@ -484,7 +537,7 @@ def test_get_sort_by_name(self) -> None: slugs = [r["projectSlug"] for r in response.data] assert slugs.index(project_a.slug) < slugs.index(project_b.slug) - def test_get_sort_by_repos_count(self) -> None: + def test_get_sort_by_repos_count(self, mock_is_seat_based) -> None: """sortBy=reposCount should order by SeerProjectRepository count.""" project1 = self.create_project(organization=self.organization) for i in range(2): @@ -498,7 +551,7 @@ def test_get_sort_by_repos_count(self) -> None: ids = [r["projectId"] for r in response.data] assert ids.index(str(project2.id)) < ids.index(str(project1.id)) - def test_get_sort_by_agent(self) -> None: + def test_get_sort_by_agent(self, mock_is_seat_based) -> None: """sortBy=agent should order alphabetically by agent alias.""" project_seer = self.create_project(organization=self.organization) @@ -517,7 +570,7 @@ def test_get_sort_by_agent(self) -> None: assert ids.index(str(project_claude.id)) < ids.index(str(project_cursor.id)) assert ids.index(str(project_cursor.id)) < ids.index(str(project_seer.id)) - def test_get_sort_by_stopping_point(self) -> None: + def test_get_sort_by_stopping_point(self, mock_is_seat_based) -> None: """sortBy=stoppingPoint should order by hierarchy rank (off < root_cause < code_changes < open_pr).""" project_open_pr = self.create_project(organization=self.organization) project_open_pr.update_option( @@ -540,19 +593,19 @@ def test_get_sort_by_stopping_point(self) -> None: assert ids.index(str(self.project.id)) < ids.index(str(project_root_cause.id)) assert ids.index(str(project_root_cause.id)) < ids.index(str(project_open_pr.id)) - def test_get_sort_by_invalid_field_returns_400(self) -> None: + def test_get_sort_by_invalid_field_returns_400(self, mock_is_seat_based) -> None: """An unrecognized sortBy value should return 400.""" response = self.client.get(self.url, {"sortBy": "invalid"}) assert response.status_code == 400 - def test_get_filter_empty_results(self) -> None: + def test_get_filter_empty_results(self, mock_is_seat_based) -> None: """A filter that matches nothing should return an empty list.""" response = self.client.get(self.url, {"query": "id:999999999"}) assert response.status_code == 200 assert response.data == [] - def test_get_filter_by_free_text_name(self) -> None: + def test_get_filter_by_free_text_name(self, mock_is_seat_based) -> None: """Free text query should match against both name and slug.""" project1 = self.create_project( organization=self.organization, name="", slug="matching-slug" @@ -571,7 +624,7 @@ def test_get_filter_by_free_text_name(self) -> None: assert str(project2.id) in ids assert str(project3.id) not in ids - def test_get_filter_by_id(self) -> None: + def test_get_filter_by_id(self, mock_is_seat_based) -> None: """id:N should return only the project with that ID.""" self.create_project(organization=self.organization) project = self.create_project(organization=self.organization) @@ -582,7 +635,7 @@ def test_get_filter_by_id(self) -> None: ids = [r["projectId"] for r in response.data] assert ids == [str(project.id)] - def test_get_filter_by_id_list(self) -> None: + def test_get_filter_by_id_list(self, mock_is_seat_based) -> None: """id:[N,M] should return only the projects with those IDs.""" project1 = self.create_project(organization=self.organization) project2 = self.create_project(organization=self.organization) @@ -594,7 +647,7 @@ def test_get_filter_by_id_list(self) -> None: ids = [r["projectId"] for r in response.data] assert sorted(ids) == sorted([str(project1.id), str(project2.id)]) - def test_get_filter_by_repos_count(self) -> None: + def test_get_filter_by_repos_count(self, mock_is_seat_based) -> None: """reposCount with numeric operators.""" project1 = self.create_project(organization=self.organization) for i in range(2): @@ -613,7 +666,7 @@ def test_get_filter_by_repos_count(self) -> None: assert str(project2.id) in ids assert str(project1.id) not in ids - def test_get_filter_by_stopping_point(self) -> None: + def test_get_filter_by_stopping_point(self, mock_is_seat_based) -> None: """stoppingPoint filter should account for tuning state.""" project1 = self.create_project(organization=self.organization) project1.update_option( @@ -632,7 +685,7 @@ def test_get_filter_by_stopping_point(self) -> None: assert str(project1.id) in ids assert str(self.project.id) not in ids - def test_get_filter_by_agent_seer(self) -> None: + def test_get_filter_by_agent_seer(self, mock_is_seat_based) -> None: """agent:seer should return projects with no handoff target (NULL).""" project1 = self.create_project(organization=self.organization) project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -644,7 +697,7 @@ def test_get_filter_by_agent_seer(self) -> None: assert str(self.project.id) in ids assert str(project1.id) not in ids - def test_get_filter_by_agent_external(self) -> None: + def test_get_filter_by_agent_external(self, mock_is_seat_based) -> None: """agent:cursor_background_agent should return projects with cursor handoff target.""" project1 = self.create_project(organization=self.organization) project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -656,7 +709,7 @@ def test_get_filter_by_agent_external(self) -> None: assert str(project1.id) in ids assert str(self.project.id) not in ids - def test_get_filter_negation(self) -> None: + def test_get_filter_negation(self, mock_is_seat_based) -> None: """!agent:seer should exclude projects with no handoff target.""" project1 = self.create_project(organization=self.organization) project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -668,7 +721,7 @@ def test_get_filter_negation(self) -> None: assert str(project1.id) in ids assert str(self.project.id) not in ids - def test_get_multiple_filters(self) -> None: + def test_get_multiple_filters(self, mock_is_seat_based) -> None: """Combining multiple filters should intersect the results.""" project1 = self.create_project(organization=self.organization) project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -686,13 +739,13 @@ def test_get_multiple_filters(self) -> None: ids = [r["projectId"] for r in response.data] assert ids == [str(project1.id)] - def test_get_invalid_search_query_returns_400(self) -> None: + def test_get_invalid_search_query_returns_400(self, mock_is_seat_based) -> None: """A malformed search query should return 400 with detail.""" response = self.client.get(self.url, {"query": "bogusKey:value"}) assert response.status_code == 400 assert "detail" in response.data - def test_put_updates_all_projects(self) -> None: + def test_put_updates_all_projects(self, mock_is_seat_based) -> None: """Empty query should update all accessible projects.""" project2 = self.create_project(organization=self.organization) @@ -702,7 +755,7 @@ def test_put_updates_all_projects(self) -> None: assert self.project.get_option("sentry:seer_scanner_automation") is False assert project2.get_option("sentry:seer_scanner_automation") is False - def test_put_applies_to_filtered_projects_only(self) -> None: + def test_put_applies_to_filtered_projects_only(self, mock_is_seat_based) -> None: """The query parameter should scope which projects get updated.""" project2 = self.create_project(organization=self.organization) project2.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") @@ -717,7 +770,7 @@ def test_put_applies_to_filtered_projects_only(self) -> None: assert project2.get_option("sentry:seer_scanner_automation") is False assert self.project.get_option("sentry:seer_scanner_automation") is True - def test_put_updates_settings(self) -> None: + def test_put_updates_settings(self, mock_is_seat_based) -> None: """Bulk update with multiple seer agent fields should apply all of them.""" project2 = self.create_project(organization=self.organization) @@ -741,7 +794,7 @@ def test_put_updates_settings(self) -> None: ) assert p.get_option("sentry:seer_scanner_automation") is False - def test_put_updates_settings_with_external_agent(self) -> None: + def test_put_updates_settings_with_external_agent(self, mock_is_seat_based) -> None: """Bulk update with external agent fields should set agent, integration, and stopping point.""" project2 = self.create_project(organization=self.organization) integration = self.create_integration( @@ -768,14 +821,45 @@ def test_put_updates_settings_with_external_agent(self) -> None: assert p.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" assert p.get_option("sentry:seer_scanner_automation") is True - def test_put_invalid_search_query_returns_400(self) -> None: + def test_put_legacy_updates_settings(self, mock_is_seat_based) -> None: + """Legacy: bulk update with granular tuning, auto_create_pr, and external agent.""" + mock_is_seat_based.return_value = False + project2 = self.create_project(organization=self.organization) + integration = self.create_integration( + organization=self.organization, external_id="ext", provider="github" + ) + + response = self.client.put( + self.url, + data={ + "automationTuning": "high", + "autoCreatePr": True, + "agent": "cursor_background_agent", + "integrationId": integration.id, + }, + format="json", + ) + + assert response.status_code == 204 + for p in (self.project, project2): + assert ( + p.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.HIGH + ) + assert p.get_option("sentry:seer_automation_handoff_auto_create_pr") is True + assert ( + p.get_option("sentry:seer_automation_handoff_target") == "cursor_background_agent" + ) + assert p.get_option("sentry:seer_automation_handoff_integration_id") == integration.id + + def test_put_invalid_search_query_returns_400(self, mock_is_seat_based) -> None: """A malformed query value should return 400.""" response = self.client.put( self.url, data={"query": "invalidKey:value", "scannerAutomation": False}, format="json" ) assert response.status_code == 400 - def test_put_creates_audit_log_entry(self) -> None: + def test_put_creates_audit_log_entry(self, mock_is_seat_based) -> None: """Bulk update should create an audit log entry with project count and IDs.""" from sentry.models.auditlogentry import AuditLogEntry from sentry.silo.base import SiloMode From 8c0f8f852012bfa9aba71aa3b223905ac034c71b Mon Sep 17 00:00:00 2001 From: joshuarli Date: Fri, 29 May 2026 11:42:35 -0700 Subject: [PATCH 21/42] ref: delete unused options (#116409) this removes 93 unused options identified via tracking read options via logging in #115610 (which this PR also reverts as I'm done collecting data) cross referenced with static analysis in https://github.com/getsentry/sentry-options/pull/123 the details are in https://github.com/getsentry/sentry-options/pull/123 - `scripts/migration/safe.txt` is essentially the set intersection of statically unreferenced options and unseen/unread options over 120 hours of GCP production logs. the unseen list was determined by subtracting options.seen unique events from all known options (options.all()) and I observed no newly seen options beyond the ~100 hour mark (`scripts/migration/collect-seen-options-out.txt`). getsentry options that were unused are removed here too: https://github.com/getsentry/getsentry/pull/20449 --- src/sentry/options/defaults.py | 489 ----------------------------- src/sentry/options/manager.py | 16 - tests/sentry/tasks/test_options.py | 30 +- 3 files changed, 4 insertions(+), 531 deletions(-) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index c5cdf8635650..99d7d4b2666f 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -46,7 +46,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) register("system.secret-key", flags=FLAG_CREDENTIAL | FLAG_NOSTORE) -register("system.root-api-key", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE) register("system.logging-format", default=LoggingFormat.HUMAN, flags=FLAG_NOSTORE) # This is used for the chunk upload endpoint register("system.upload-url-prefix", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE) @@ -91,12 +90,6 @@ ) # The region that this instance is currently running in. register("system.region", flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_NOSTORE) -# Enable date-util parsing for timestamps -register( - "system.use-date-util-timestamps", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # Organization register( @@ -127,7 +120,6 @@ default={"default": {"hosts": {0: {"host": "127.0.0.1", "port": 6379}}}}, flags=FLAG_NOSTORE | FLAG_IMMUTABLE, ) -register("redis.options", type=Dict, flags=FLAG_NOSTORE) # Processing worker caches register( @@ -142,12 +134,6 @@ default="/tmp/sentry-releasefile-cache", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "releasefile.cache-limit", - type=Int, - default=10 * 1024 * 1024, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) # Mail @@ -322,27 +308,6 @@ flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, ) -# API Tokens -register( - "apitoken.auto-add-last-chars", - default=True, - type=Bool, - flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "apitoken.save-hash-on-create", - default=True, - type=Bool, - flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - -# Controls the rate of using the hashed value of User API tokens for lookups when logging in -# and also updates tokens which are not hashed -register( - "apitoken.use-and-update-hash-rate", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "api.rate-limit.org-create", @@ -408,10 +373,6 @@ register("filestore.control.backend", default="", flags=FLAG_NOSTORE) register("filestore.control.options", default={}, flags=FLAG_NOSTORE) -# Whether to use a redis lock on fileblob uploads and deletes -register("fileblob.upload.use_lock", default=True, flags=FLAG_AUTOMATOR_MODIFIABLE) -# Whether to use redis to cache `FileBlob.id` lookups -register("fileblob.upload.use_blobid_cache", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) # New `objectstore` service configuration. Additional supported options and # their defaults: @@ -499,18 +460,6 @@ flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -# Configuration Options -register( - "configurations.storage.backend", - default=None, - flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "configurations.storage.options", - type=Dict, - default=None, - flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) # Flag Options register( @@ -568,13 +517,6 @@ default=0.0, flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -# Enables profiling for replay recording ingestion. -register( - "replay.consumer.recording.profiling.enabled", - type=Bool, - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # Enable new msgspec-based recording parser. register( "replay.consumer.msgspec_recording_parser", @@ -631,15 +573,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# Ingest only a random fraction of logs sent to relay. Used to roll out ourlogs ingestion. -# -# NOTE: Any value below 1.0 will cause customer data to not appear and can break the product. Do not override in production. -register( - "relay.ourlogs-ingestion.sample-rate", - default=1.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - # Allow the Relay to skip normalization of spans for certain hosts. register( @@ -755,7 +688,6 @@ # Codecov Integration register("codecov.client-secret", flags=FLAG_CREDENTIAL | FLAG_PRIORITIZE_DISK) -register("codecov.base-url", default="https://api.codecov.io") register("codecov.api-bridge-signing-secret", flags=FLAG_CREDENTIAL | FLAG_PRIORITIZE_DISK) # GitHub Integration @@ -838,16 +770,6 @@ flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) register("github-login.organization", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE) -register( - "github-extension.enabled", - default=False, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "github-extension.enabled-orgs", - default=[], - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) # VSTS Integration register("vsts.client-id", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE) @@ -930,16 +852,6 @@ default=False, flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "snuba.search.pre-snuba-candidates-percentage", - default=0.2, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "snuba.search.project-group-count-cache-time", - default=24 * 60 * 60, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "snuba.search.min-pre-snuba-candidates", default=500, @@ -989,7 +901,6 @@ default={7001: 0.15}, flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, ) -register("snuba.track-outcomes-sample-rate", default=0.0, flags=FLAG_AUTOMATOR_MODIFIABLE) # The percentage of tagkeys that we want to cache. Set to 1.0 in order to cache everything, <=0.0 to stop caching register( @@ -1006,27 +917,7 @@ type=Int, ) -# Kafka Publisher -register( - "kafka-publisher.raw-event-sample-rate", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# Enable multiple topics for eventstream. It allows specific event types to be sent -# to specific topic. -register( - "store.eventstream-per-type-topic", - default=False, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - -# Query and supply Bundle Indexes to Symbolicator SourceMap processing -register( - "symbolicator.sourcemaps-bundle-index-sample-rate", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # Refresh Bundle Indexes reported as used by symbolicator register( "symbolicator.sourcemaps-bundle-index-refresh-sample-rate", @@ -1042,18 +933,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# Enables setting a sampling rate when producing the tag facet. -register( - "discover2.tags_facet_enable_sampling", - default=True, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - -# Enable use of symbolic-sourcemapcache for JavaScript Source Maps processing. -# Set this value of the fraction of projects that you want to use it for. -register( - "processing.sourcemapcache-processor", default=0.0, flags=FLAG_AUTOMATOR_MODIFIABLE -) # unused # Killswitch for sending internal errors to the internal project or # `SENTRY_SDK_CONFIG.relay_dsn`. Set to `0` to only send to @@ -1068,23 +947,10 @@ # Sample rate for transaction/span data sent to S4S upstream (1.0 = keep all, 0.05 = keep 5%) register("store.s4s-transaction-sample-rate", default=1.0, flags=FLAG_AUTOMATOR_MODIFIABLE) -# Mock out integrations and services for tests -register("mocks.jira", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) - -# Record statistics about event payloads and their compressibility -register( - "store.nodestore-stats-sample-rate", default=0.0, flags=FLAG_AUTOMATOR_MODIFIABLE -) # unused # Killswitch to stop storing any reprocessing payloads. register("store.reprocessing-force-disable", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) -# Enable calling the severity modeling API on group creation -register( - "processing.calculate-severity-on-group-creation", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # Enable sending the flag to the microservice to tell it to purposefully take longer than our # timeout, to see the effect on the overall error event processing backlog @@ -1102,12 +968,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "issues.severity.first-event-severity-calculation-projects-allowlist", - type=Sequence, - default=[], - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) register( "issues.severity.seer-project-rate-limit", @@ -1137,20 +997,6 @@ flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "issues.priority.projects-allowlist", - type=Sequence, - default=[], - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) - -# Killswitch for issue priority -register( - "issues.priority.enabled", - default=False, - type=Bool, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) # Killswitch for batched nodestore fetches in group events endpoint. # When disabled, falls back to lazy per-event nodestore fetches. @@ -1189,12 +1035,6 @@ type=Bool, flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "seer.similarity-embeddings-grouping-killswitch.enabled", - default=False, - type=Bool, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) register( "seer.similarity-embeddings-delete-by-hash-killswitch.enabled", default=False, @@ -1219,30 +1059,6 @@ default=1, flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "seer.severity-killswitch.enabled", - default=False, - type=Bool, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "seer.breakpoint-detection-killswitch.enabled", - default=False, - type=Bool, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "seer.autofix-killswitch.enabled", - default=False, - type=Bool, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "seer.anomaly-detection-killswitch.enabled", - default=False, - type=Bool, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) # Agent context engine indexing options register( @@ -1339,37 +1155,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# seer nearest neighbour endpoint timeout -register( - "embeddings-grouping.seer.nearest-neighbour-timeout", - type=Float, - default=0.1, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) - -# seer embeddings record update endpoint timeout -register( - "embeddings-grouping.seer.embeddings-record-update-timeout", - type=Float, - default=0.05, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) - -# seer embeddings record delete endpoint timeout -register( - "embeddings-grouping.seer.embeddings-record-delete-timeout", - type=Float, - default=0.1, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) - -# seer embeddings ratelimit in percentage that is allowed -register( - "embeddings-grouping.seer.ratelimit", - type=Int, - default=0, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) register( "embeddings-grouping.seer.delete-record-batch-size", @@ -1486,12 +1271,6 @@ default=[], flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "store.save-event-highcpu-platforms", - type=Sequence, - default=[], - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "post_process.get-autoassign-owners", type=Sequence, @@ -1533,9 +1312,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# Minimum number of files in an archive. Archives with fewer files are extracted and have their -# contents stored as separate release files. -register("processing.release-archive-min-files", default=10, flags=FLAG_AUTOMATOR_MODIFIABLE) # All Relay options (statically authenticated Relays can be registered here) register("relay.static_auth", default={}, flags=FLAG_NOSTORE) @@ -1582,15 +1358,6 @@ # Subscription queries sampling rate register("subscriptions-query.sample-rate", default=0.01, flags=FLAG_AUTOMATOR_MODIFIABLE) -# The ratio of symbolication requests for which metrics will be submitted to redis. -# -# This is to allow gradual rollout of metrics collection for symbolication requests and can be -# removed once it is fully rolled out. -register( - "symbolicate-event.low-priority.metrics.submission-rate", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # The ratio of events for which we emit verbose apple symbol stats. # @@ -1733,13 +1500,6 @@ flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "organization-abuse-quota.custom-metric-bucket-limit", - type=Int, - default=0, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - for mabq in build_metric_abuse_quotas(): register( @@ -1992,11 +1752,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "sentry-metrics.synchronize-kafka-rebalances", - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "sentry-metrics.synchronized-rebalance-delay", @@ -2233,60 +1988,12 @@ default=False, flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "performance.traces.trace-explorer-buffer-hours", - type=Float, - default=1.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "performance.traces.trace-explorer-max-trace-ids-per-chunk", - type=Int, - default=2500, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "performance.traces.trace-explorer-skip-floating-spans", - type=Bool, - default=True, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "performance.traces.trace-explorer-scan-max-block-size-hours", - type=Int, - default=8, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "performance.traces.trace-explorer-scan-max-batches", - type=Int, - default=7, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "performance.traces.trace-explorer-scan-max-execution-seconds", - type=Int, - default=30, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "performance.traces.trace-explorer-scan-max-parallel-queries", - type=Int, - default=3, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "performance.traces.trace-explorer-skip-recent-seconds", type=Int, default=0, flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "performance.traces.span_query_minimum_spans", - type=Int, - default=10000, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "performance.traces.check_span_extraction_date", type=Bool, @@ -2312,24 +2019,12 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "performance.spans-tags-key.sample-rate", - type=Float, - default=1.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "performance.spans-tags-key.max", type=Int, default=1000, flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "performance.spans-tags-value.sample-rate", - type=Float, - default=1.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "performance.spans-tags-values.max", type=Int, @@ -2349,14 +2044,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# In Single Tenant with 100% DS, we may need to reverse the UI change made by dynamic-sampling -# if metrics extraction isn't ready. -register( - "performance.hide-metrics-ui", - type=Bool, - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # Used for the z-score when calculating the margin of error in performance register( @@ -2466,8 +2153,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# Flagpole Configuration (used in getsentry) -register("flagpole.debounce_reporting_seconds", default=0, flags=FLAG_AUTOMATOR_MODIFIABLE) # Feature flagging error capture rate. # When feature flagging has faults, it can become very high volume and we can overwhelm sentry. @@ -2477,7 +2162,6 @@ register("hybridcloud.regionsiloclient.retries", default=5, flags=FLAG_AUTOMATOR_MODIFIABLE) register("hybridcloud.rpc.retries", default=5, flags=FLAG_AUTOMATOR_MODIFIABLE) register("hybridcloud.integrationproxy.retries", default=5, flags=FLAG_AUTOMATOR_MODIFIABLE) -register("hybridcloud.endpoint_flag_logging", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) register( "hybridcloud.rpc.method_retry_overrides", default={}, @@ -2706,18 +2390,6 @@ default=False, flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "statistical_detectors.enable.projects.performance", - type=Sequence, - default=[], - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "statistical_detectors.enable.projects.profiling", - type=Sequence, - default=[], - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) register( "statistical_detectors.query.batch_size", type=Int, @@ -2804,11 +2476,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "metric_extraction.max_span_attribute_specs", - default=100, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "metric_alerts.extended_max_subscriptions", @@ -2947,12 +2614,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# The flag disables the file io on main thread detector -register( - "performance_issues.file_io_main_thread.disabled", - default=False, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) # Enables on-demand metric extraction for Dashboard Widgets. register( @@ -3134,33 +2795,6 @@ ) -# Switch to read assemble status from Redis instead of memcache -register("assemble.read_from_redis", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) - -# Sampling rates for testing Rust-based grouping enhancers - -# Rate at which to run the Rust implementation of `assemble_stacktrace_component` -# and compare the results -register( - "grouping.rust_enhancers.compare_components", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# Rate at which to prefer the Rust implementation of `assemble_stacktrace_component`. -register( - "grouping.rust_enhancers.prefer_rust_components", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - -register( - "metrics.sample-list.sample-rate", - type=Float, - default=100_000.0, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - - # TODO: For now, only a small number of projects are going through a grouping config transition at # any given time, so we're sampling at 100% in order to be able to get good signal. Once we've fully # transitioned to the optimized logic, and before the next config change, we probably either want to @@ -3194,20 +2828,6 @@ ) -# Sample rate for double writing to experimental dsn -register( - "store.experimental-dsn-double-write.sample-rate", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - -# temporary option for logging canonical key fallback stacktraces -register( - "canonical-fallback.send-error-to-sentry", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - # SPAN BUFFER # Span buffer killswitch register( @@ -3402,30 +3022,6 @@ flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "indexed-spans.agg-span-waterfall.enable", - default=False, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - -register( - "traces.sample-list.sample-rate", - type=Float, - default=0.0, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - -register( - "discover.saved-query-dataset-split.enable", - default=False, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) -register( - "discover.saved-query-dataset-split.organization-id-allowlist", - type=Sequence, - default=[], - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) register( "feedback.filter_garbage_messages", @@ -3479,38 +3075,6 @@ ) # Notification Options - End -# List of organizations with increased rate limits for organization_events API -register( - "api.organization_events.rate-limit-increased.orgs", - type=Sequence, - default=[], - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) -# Increased rate limits for organization_events API for the orgs above -register( - "api.organization_events.rate-limit-increased.limits", - type=Dict, - default={"limit": 50, "window": 1, "concurrent_limit": 50}, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) -# Reduced rate limits for organization_events API for the orgs in LA/EA/GA rollout -# Once GA'd, this will be the default rate limit for all orgs not on the increase list -register( - "api.organization_events.rate-limit-reduced.limits", - type=Dict, - default={"limit": 1000, "window": 300, "concurrent_limit": 15}, - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) - - -# TODO: remove once removed from options -register( - "issue_platform.use_kafka_partition_key", - type=Bool, - default=False, - flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, -) - register( "sentry.save-event-attachments.project-per-5-minute-limit", @@ -3540,21 +3104,7 @@ default=250, flags=FLAG_AUTOMATOR_MODIFIABLE, ) -# Limits the total duration of profile chunks to aggregate in flamegraphs -register( - "profiling.continuous-profiling.flamegraph.max-seconds", - type=Int, - default=10 * 60, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) -# Enable orjson in the occurrence_consumer.process_[message|batch] -register( - "issues.occurrence_consumer.use_orjson", - type=Bool, - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # Controls the rate of using the sentry api shared secret for communicating to sentry. # DEPRECATED: will be removed after the shared secret is confirmed to always be set. @@ -3564,11 +3114,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "similarity.backfill_project_cohort_size", - default=1000, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "similarity.new_project_seer_grouping.enabled", default=False, @@ -3579,12 +3124,6 @@ default=10000, flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "delayed_processing.emit_logs", - type=Bool, - default=False, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) register( "delayed_workflow.rollout", type=Bool, @@ -3697,17 +3236,6 @@ flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, ) -# Disables specific uptime checker regions. This is a list of region slugs -# which must match regions available in the settings.UPTIME_REGIONS list. -# -# Useful to remove a region from check rotation if there is some kind of -# problem with the region. -register( - "uptime.disabled-checker-regions", - type=Sequence, - default=[], - flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, -) register( "uptime.checker-regions-mode-override", type=Dict, @@ -3952,17 +3480,6 @@ register("objectstore.enable_for.attachments", default=0.0, flags=FLAG_AUTOMATOR_MODIFIABLE) -# option used to enable/disable tracking -# rate of potential functions metrics to -# be written into EAP -register( - "profiling.track_functions_metrics_write_rate.eap.enabled", - default=False, - type=Bool, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) - - register( "sentry.send_onboarding_task_metrics", type=Bool, @@ -4145,12 +3662,6 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "seer.scanner_no_consent.rollout_rate", - type=Float, - default=0.0, - flags=FLAG_MODIFIABLE_RATE | FLAG_AUTOMATOR_MODIFIABLE, -) # Enabled Prebuilt Dashboard IDs register( diff --git a/src/sentry/options/manager.py b/src/sentry/options/manager.py index 8ca58acbc051..554eaca43467 100644 --- a/src/sentry/options/manager.py +++ b/src/sentry/options/manager.py @@ -183,7 +183,6 @@ class OptionsManager: def __init__(self, store: OptionsStore): self.store = store self.registry: dict[str, Key] = {} - self._seen: set[str] = set() def set(self, key: str, value, coerce=True, channel: UpdateChannel = UpdateChannel.UNKNOWN): """ @@ -284,15 +283,6 @@ def is_set_on_disk(self, key: str) -> bool: """ return key in settings.SENTRY_OPTIONS - def _record_seen(self, key: str) -> None: - """Emit one log line per key per process lifetime so reads can be - audited in GCP. Logs before adding to _seen so a logging failure - doesn't permanently suppress the event. In debug mode, mark keys as - seen without logging to keep local tooling output clean.""" - if not settings.DEBUG: - logger.info("option.seen", extra={"option_key": key}) - self._seen.add(key) - def get(self, key: str, silent=False): """ Get the value of an option, falling back to the local configuration. @@ -316,12 +306,6 @@ def get(self, key: str, silent=False): sample_rate=0.01, ) as tags: opt = self.lookup_key(key) - if key not in self._seen: - try: - self._record_seen(key) - except Exception: - # Tracking is best-effort. Never let it affect option reads. - pass # First check if the option should exist on disk, and if it actually # has a value set, let's use that one instead without even attempting diff --git a/tests/sentry/tasks/test_options.py b/tests/sentry/tasks/test_options.py index 53844e8b7185..80d444a5400b 100644 --- a/tests/sentry/tasks/test_options.py +++ b/tests/sentry/tasks/test_options.py @@ -11,15 +11,12 @@ class SyncOptionsTest(TestCase): _TEST_KEY = "foo" - _SEEN_TEST_KEY = "test.option-seen" - def tearDown(self) -> None: super().tearDown() - for key in (self._TEST_KEY, self._SEEN_TEST_KEY): - try: - default_manager.unregister(key) - except UnknownOption: - pass + try: + default_manager.unregister(self._TEST_KEY) + except UnknownOption: + pass def test_task_persistent_name(self) -> None: assert sync_options.name == "sentry.tasks.options.sync_options" @@ -38,22 +35,3 @@ def test_simple(self, mock_set_cache: MagicMock) -> None: sync_options(cutoff=60) assert not mock_set_cache.called - - def test_option_seen_logs_first_access_and_short_circuits(self) -> None: - default_manager.register(self._SEEN_TEST_KEY, default="x") - default_manager._seen.discard(self._SEEN_TEST_KEY) - - # First read: must emit exactly one log record with the key in extra. - with self.assertLogs("sentry", level="INFO") as cm: - default_manager.get(self._SEEN_TEST_KEY) - - assert any( - r.getMessage() == "option.seen" - and getattr(r, "option_key", None) == self._SEEN_TEST_KEY - for r in cm.records - ) - assert self._SEEN_TEST_KEY in default_manager._seen - - # Second read: short-circuit — _record_seen must not be called again. - with self.assertNoLogs("sentry", level="INFO"): - default_manager.get(self._SEEN_TEST_KEY) From 196b72d67fdb167e14679b63e2d6c64a0d196ca6 Mon Sep 17 00:00:00 2001 From: Christinarlong <60594860+Christinarlong@users.noreply.github.com> Date: Fri, 29 May 2026 11:48:23 -0700 Subject: [PATCH 22/42] ref(webhooks): Add legacy_webhook to the Plugin ActionType (#116454) --- .../action_handler_registry/plugin_handler.py | 29 +++- src/sentry/rules/actions/notify_event.py | 7 + .../test_plugin_handler.py | 134 ++++++++++++++++++ .../sentry/rules/actions/test_notify_event.py | 24 ++++ 4 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 tests/sentry/notifications/notification_action/action_handler_registry/test_plugin_handler.py diff --git a/src/sentry/notifications/notification_action/action_handler_registry/plugin_handler.py b/src/sentry/notifications/notification_action/action_handler_registry/plugin_handler.py index dcd00cdf3094..4e2bf77c6f89 100644 --- a/src/sentry/notifications/notification_action/action_handler_registry/plugin_handler.py +++ b/src/sentry/notifications/notification_action/action_handler_registry/plugin_handler.py @@ -1,10 +1,16 @@ +import logging from typing import override +from sentry import features from sentry.notifications.notification_action.utils import execute_via_group_type_registry +from sentry.sentry_apps.services.legacy_webhook.service import send_legacy_webhooks_for_invocation +from sentry.services.eventstore.models import GroupEvent from sentry.workflow_engine.models import Action from sentry.workflow_engine.registry import action_handler_registry from sentry.workflow_engine.types import ActionHandler, ActionInvocation, ConfigTransformer +logger = logging.getLogger(__name__) + @action_handler_registry.register(Action.Type.PLUGIN) class PluginActionHandler(ActionHandler): @@ -36,4 +42,25 @@ def get_config_transformer() -> ConfigTransformer | None: @staticmethod @override def execute(invocation: ActionInvocation) -> None: - execute_via_group_type_registry(invocation) + if not isinstance(invocation.event_data.event, GroupEvent): + return + + organization = invocation.detector.project.organization + new_path = features.has("organizations:legacy-webhook-new-path", organization) + + try: + execute_via_group_type_registry(invocation) + except Exception: + logger.exception( + "plugin_action_handler.old_path_error", + extra={"invocation": invocation}, + ) + + if new_path: + try: + send_legacy_webhooks_for_invocation(invocation) + except Exception: + logger.exception( + "plugin_action_handler.new_path_error", + extra={"invocation": invocation}, + ) diff --git a/src/sentry/rules/actions/notify_event.py b/src/sentry/rules/actions/notify_event.py index 0a73015ea997..d6698b0d9c9e 100644 --- a/src/sentry/rules/actions/notify_event.py +++ b/src/sentry/rules/actions/notify_event.py @@ -1,5 +1,6 @@ from collections.abc import Generator, Sequence +from sentry import features from sentry.plugins.base import plugins from sentry.rules.actions.base import EventAction from sentry.rules.actions.services import LegacyPluginService @@ -19,10 +20,16 @@ class NotifyEventAction(EventAction): def get_plugins(self) -> Sequence[LegacyPluginService]: from sentry.plugins.bases.notify import NotificationPlugin + skip_webhooks = features.has( + "organizations:legacy-webhook-disable-old-path", self.project.organization + ) + results = [] for plugin in plugins.for_project(self.project, version=1): if not isinstance(plugin, NotificationPlugin): continue + if skip_webhooks and plugin.slug == "webhooks": + continue results.append(LegacyPluginService(plugin)) return results diff --git a/tests/sentry/notifications/notification_action/action_handler_registry/test_plugin_handler.py b/tests/sentry/notifications/notification_action/action_handler_registry/test_plugin_handler.py new file mode 100644 index 000000000000..36e78b52612a --- /dev/null +++ b/tests/sentry/notifications/notification_action/action_handler_registry/test_plugin_handler.py @@ -0,0 +1,134 @@ +import uuid +from unittest import mock + +import responses + +from sentry.models.activity import Activity +from sentry.models.options.project_option import ProjectOption +from sentry.notifications.notification_action.action_handler_registry.plugin_handler import ( + PluginActionHandler, +) +from sentry.plugins.base import plugins +from sentry.testutils.helpers.features import with_feature +from sentry.types.activity import ActivityType +from sentry.utils import json +from sentry.workflow_engine.models import Action +from sentry.workflow_engine.types import ActionInvocation, WorkflowEventData +from tests.sentry.workflow_engine.test_base import BaseWorkflowTest + + +class TestPluginActionHandlerExecute(BaseWorkflowTest): + def setUp(self) -> None: + super().setUp() + self.detector = self.create_detector(project=self.project) + self.workflow = self.create_workflow(environment=self.environment) + self.action = self.create_action( + type=Action.Type.PLUGIN, + ) + self.group, self.event, self.group_event = self.create_group_event() + self.event_data = WorkflowEventData( + event=self.group_event, workflow_env=self.environment, group=self.group + ) + self.invocation = ActionInvocation( + event_data=self.event_data, + action=self.action, + detector=self.detector, + notification_uuid=str(uuid.uuid4()), + workflow_id=self.workflow.id, + ) + ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://example.com/hook") + webhook_plugin = plugins.get("webhooks") + webhook_plugin.set_option("enabled", True, self.project) + + @responses.activate + def test_default_no_flags_fires_old_path_only(self) -> None: + responses.add(responses.POST, "http://example.com/hook") + + with self.tasks(): + PluginActionHandler.execute(self.invocation) + + assert len(responses.calls) == 1 + + @responses.activate + @with_feature( + { + "organizations:legacy-webhook-new-path": True, + "organizations:legacy-webhook-disable-old-path": True, + } + ) + def test_new_path_skips_webhooks_in_old_path(self) -> None: + """When new path is on and old path disabled, webhooks are sent via the + new task-based service and skipped in the old path.""" + responses.add(responses.POST, "http://example.com/hook") + + with self.tasks(): + PluginActionHandler.execute(self.invocation) + + assert len(responses.calls) == 1 + body = json.loads(responses.calls[0].request.body) + assert body["id"] == str(self.group.id) + + @with_feature("organizations:legacy-webhook-new-path") + @mock.patch( + "sentry.notifications.notification_action.action_handler_registry.plugin_handler.send_legacy_webhooks_for_invocation" + ) + @mock.patch( + "sentry.notifications.notification_action.action_handler_registry.plugin_handler.execute_via_group_type_registry" + ) + def test_old_path_always_runs( + self, mock_old_path: mock.MagicMock, mock_new_path: mock.MagicMock + ) -> None: + """Old path runs regardless of flags to keep non-webhook plugins firing.""" + PluginActionHandler.execute(self.invocation) + + mock_old_path.assert_called_once_with(self.invocation) + mock_new_path.assert_called_once_with(self.invocation) + + @with_feature("organizations:legacy-webhook-new-path") + @mock.patch( + "sentry.notifications.notification_action.action_handler_registry.plugin_handler.send_legacy_webhooks_for_invocation" + ) + @mock.patch( + "sentry.notifications.notification_action.action_handler_registry.plugin_handler.execute_via_group_type_registry" + ) + def test_non_group_event_skips_both_paths( + self, mock_old_path: mock.MagicMock, mock_new_path: mock.MagicMock + ) -> None: + activity = Activity.objects.create( + project=self.project, + group=self.group, + type=ActivityType.SET_RESOLVED.value, + ) + invocation = ActionInvocation( + event_data=WorkflowEventData( + event=activity, workflow_env=self.environment, group=self.group + ), + action=self.action, + detector=self.detector, + notification_uuid=str(uuid.uuid4()), + workflow_id=self.workflow.id, + ) + + PluginActionHandler.execute(invocation) + + mock_old_path.assert_not_called() + mock_new_path.assert_not_called() + + @responses.activate + @with_feature("organizations:legacy-webhook-new-path") + @mock.patch( + "sentry.notifications.notification_action.action_handler_registry.plugin_handler.execute_via_group_type_registry", + side_effect=Exception("legacy path error"), + ) + def test_old_path_exception_does_not_block_new_path( + self, mock_old_path: mock.MagicMock + ) -> None: + responses.add(responses.POST, "http://example.com/hook") + + with self.tasks(): + PluginActionHandler.execute(self.invocation) + + mock_old_path.assert_called_once() + assert len(responses.calls) == 1 + body = json.loads(responses.calls[0].request.body) + assert body["id"] == str(self.group.id) diff --git a/tests/sentry/rules/actions/test_notify_event.py b/tests/sentry/rules/actions/test_notify_event.py index 2403c2de7e5a..6c558c1b7888 100644 --- a/tests/sentry/rules/actions/test_notify_event.py +++ b/tests/sentry/rules/actions/test_notify_event.py @@ -1,8 +1,11 @@ from unittest.mock import MagicMock +from sentry.models.options.project_option import ProjectOption +from sentry.plugins.base import plugins from sentry.rules.actions.notify_event import NotifyEventAction from sentry.rules.actions.services import LegacyPluginService from sentry.testutils.cases import RuleTestCase +from sentry.testutils.helpers.features import with_feature from sentry.testutils.skips import requires_snuba pytestmark = [requires_snuba] @@ -23,3 +26,24 @@ def test_applies_correctly(self) -> None: assert len(results) == 1 assert plugin.should_notify.call_count == 1 assert results[0].callback is plugin.rule_notify + + def test_get_plugins_includes_webhooks_by_default(self) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://example.com/hook") + webhook_plugin = plugins.get("webhooks") + webhook_plugin.set_option("enabled", True, self.project) + + rule = self.get_rule() + result_slugs = [p.service.slug for p in rule.get_plugins()] + + assert "webhooks" in result_slugs + + @with_feature("organizations:legacy-webhook-disable-old-path") + def test_get_plugins_skips_webhooks_when_old_path_disabled(self) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://example.com/hook") + webhook_plugin = plugins.get("webhooks") + webhook_plugin.set_option("enabled", True, self.project) + + rule = self.get_rule() + result_slugs = [p.service.slug for p in rule.get_plugins()] + + assert "webhooks" not in result_slugs From eb7a68aaeba0640a188ecbbb2304819a89de531c Mon Sep 17 00:00:00 2001 From: Christinarlong <60594860+Christinarlong@users.noreply.github.com> Date: Fri, 29 May 2026 11:49:06 -0700 Subject: [PATCH 23/42] fix(webhooks): Check webhooks:enabled in new webhook path (#116459) --- .../services/legacy_webhook/service.py | 4 +++ .../test_webhook_handler.py | 17 ++++++++++++ .../services/legacy_webhook/test_service.py | 26 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/src/sentry/sentry_apps/services/legacy_webhook/service.py b/src/sentry/sentry_apps/services/legacy_webhook/service.py index c551d5ed182c..f83bd77d55be 100644 --- a/src/sentry/sentry_apps/services/legacy_webhook/service.py +++ b/src/sentry/sentry_apps/services/legacy_webhook/service.py @@ -135,6 +135,10 @@ def send_legacy_webhooks_for_invocation(invocation: ActionInvocation) -> None: from sentry.sentry_apps.services.legacy_webhook.tasks import send_legacy_webhook_task project = invocation.detector.project + enabled = ProjectOption.objects.get_value(project, "webhooks:enabled", default=False) + if not enabled: + return + urls_raw = ProjectOption.objects.get_value(project, "webhooks:urls", default="") urls = split_urls(urls_raw) if not urls: diff --git a/tests/sentry/notifications/notification_action/action_handler_registry/test_webhook_handler.py b/tests/sentry/notifications/notification_action/action_handler_registry/test_webhook_handler.py index e8cf060017ea..3229c0293b41 100644 --- a/tests/sentry/notifications/notification_action/action_handler_registry/test_webhook_handler.py +++ b/tests/sentry/notifications/notification_action/action_handler_registry/test_webhook_handler.py @@ -226,6 +226,23 @@ def test_new_path_webhooks_action_routes_to_legacy_webhook( mock_legacy.assert_called_once_with(self.invocation) mock_sentry_app.assert_not_called() + @responses.activate + @with_feature( + { + "organizations:legacy-webhook-new-path": True, + "organizations:legacy-webhook-disable-old-path": True, + } + ) + def test_new_path_disabled_webhooks_does_not_send(self) -> None: + responses.add(responses.POST, "http://example.com/hook") + webhook_plugin = plugins.get("webhooks") + webhook_plugin.set_option("enabled", False, self.project) + + with self.tasks(): + WebhookActionHandler.execute(self.invocation) + + assert len(responses.calls) == 0 + @responses.activate @mock.patch( "sentry.notifications.notification_action.action_handler_registry.webhook_handler.execute_via_group_type_registry", diff --git a/tests/sentry/sentry_apps/services/legacy_webhook/test_service.py b/tests/sentry/sentry_apps/services/legacy_webhook/test_service.py index 8e556deb0aa7..2f5abd4512d3 100644 --- a/tests/sentry/sentry_apps/services/legacy_webhook/test_service.py +++ b/tests/sentry/sentry_apps/services/legacy_webhook/test_service.py @@ -97,6 +97,7 @@ def setUp(self) -> None: "sentry.sentry_apps.services.legacy_webhook.tasks.send_legacy_webhook_task", ) def test_dispatches_task_per_url(self, mock_task: mock.MagicMock) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:enabled", True) ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://a.com\nhttp://b.com") send_legacy_webhooks_for_invocation(self.invocation) @@ -109,6 +110,29 @@ def test_dispatches_task_per_url(self, mock_task: mock.MagicMock) -> None: "sentry.sentry_apps.services.legacy_webhook.tasks.send_legacy_webhook_task", ) def test_no_urls_configured_is_noop(self, mock_task: mock.MagicMock) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:enabled", True) + + send_legacy_webhooks_for_invocation(self.invocation) + + assert mock_task.delay.call_count == 0 + + @mock.patch( + "sentry.sentry_apps.services.legacy_webhook.tasks.send_legacy_webhook_task", + ) + def test_webhooks_disabled_is_noop(self, mock_task: mock.MagicMock) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://a.com") + + send_legacy_webhooks_for_invocation(self.invocation) + + assert mock_task.delay.call_count == 0 + + @mock.patch( + "sentry.sentry_apps.services.legacy_webhook.tasks.send_legacy_webhook_task", + ) + def test_webhooks_explicitly_disabled_is_noop(self, mock_task: mock.MagicMock) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:enabled", False) + ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://a.com") + send_legacy_webhooks_for_invocation(self.invocation) assert mock_task.delay.call_count == 0 @@ -117,6 +141,7 @@ def test_no_urls_configured_is_noop(self, mock_task: mock.MagicMock) -> None: "sentry.sentry_apps.services.legacy_webhook.tasks.send_legacy_webhook_task", ) def test_triggering_rules_uses_workflow_name(self, mock_task: mock.MagicMock) -> None: + ProjectOption.objects.set_value(self.project, "webhooks:enabled", True) ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://a.com") send_legacy_webhooks_for_invocation(self.invocation) @@ -133,6 +158,7 @@ def test_triggering_rules_prefers_legacy_rule_label(self, mock_task: mock.MagicM rule.save() self.create_alert_rule_workflow(rule_id=rule.id, workflow=self.workflow) + ProjectOption.objects.set_value(self.project, "webhooks:enabled", True) ProjectOption.objects.set_value(self.project, "webhooks:urls", "http://a.com") send_legacy_webhooks_for_invocation(self.invocation) From 35f5dbcec89d68ef3a319c992eb638272d8b9083 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 29 May 2026 11:50:55 -0700 Subject: [PATCH 24/42] fix(discover): Link issue event ids directly (#116507) Discover was still routing issue event id clicks through the project-event redirect, even when the row already had `issue.id`. That made some issue-event urls messy and left the redirect to clean things up later. This requests `issue.id` with the hidden link context and builds the issue event url directly. Keeps the old project redirect fallback for rows that do not have issue context. --- static/app/views/discover/table/index.tsx | 1 + .../views/discover/table/tableView.spec.tsx | 33 +++- static/app/views/discover/table/tableView.tsx | 175 +++++++++++------- 3 files changed, 143 insertions(+), 66 deletions(-) diff --git a/static/app/views/discover/table/index.tsx b/static/app/views/discover/table/index.tsx index 91d25606734a..b90407efaad1 100644 --- a/static/app/views/discover/table/index.tsx +++ b/static/app/views/discover/table/index.tsx @@ -161,6 +161,7 @@ class Table extends PureComponent { // Note: Event ID or 'id' is added to the fields in the API payload response by default for all non-aggregate queries. if (!eventView.hasAggregateField() || apiPayload.field.includes('id')) { apiPayload.field.push('trace'); + apiPayload.field.push('issue.id'); // We need to include the event.type field because we want to // route to issue details for error and default event types. diff --git a/static/app/views/discover/table/tableView.spec.tsx b/static/app/views/discover/table/tableView.spec.tsx index bf93622896bd..374b3241d5aa 100644 --- a/static/app/views/discover/table/tableView.spec.tsx +++ b/static/app/views/discover/table/tableView.spec.tsx @@ -54,7 +54,11 @@ describe('TableView > CellActions', () => { const eventView = EventView.fromLocation(location); - function renderComponent(tableData: TableData, view: EventView) { + function renderComponent( + tableData: TableData, + view: EventView, + queryDataset = SavedQueryDatasets.TRANSACTIONS + ) { return render( CellActions', () => { measurementKeys={null} showTags={false} title="" - queryDataset={SavedQueryDatasets.TRANSACTIONS} + queryDataset={queryDataset} />, { organization, @@ -490,6 +494,31 @@ describe('TableView > CellActions', () => { ); }); + it('renders issue event id links directly to the issue event', () => { + const view = EventView.fromLocation( + LocationFixture({ + query: {...locationQuery, field: ['id', 'title']}, + }) + ); + rows.meta = {...rows.meta, id: 'string', 'issue.id': 'integer'}; + rows.data[0] = { + ...rows.data[0]!, + id: 'deadbeef', + 'issue.id': 123, + 'project.name': 'project-slug', + }; + + renderComponent(rows, view, SavedQueryDatasets.ERRORS); + + const firstRow = screen.getAllByRole('row')[1]!; + const link = within(firstRow).getByTestId('view-event'); + + expect(link).toHaveAttribute( + 'href', + '/organizations/org-slug/issues/123/events/deadbeef/?referrer=discover-events-table' + ); + }); + it('handles go to release', async () => { const {router} = renderComponent(rows, eventView); await openContextMenu(5); diff --git a/static/app/views/discover/table/tableView.tsx b/static/app/views/discover/table/tableView.tsx index d7eabdd8306a..f0c0157b76fc 100644 --- a/static/app/views/discover/table/tableView.tsx +++ b/static/app/views/discover/table/tableView.tsx @@ -3,7 +3,7 @@ import {useMatches} from 'react-router-dom'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; -import type {Location, LocationDescriptorObject} from 'history'; +import type {Location, LocationDescriptor, LocationDescriptorObject} from 'history'; import {Link} from '@sentry/scraps/link'; import {useModal} from '@sentry/scraps/modal'; @@ -184,37 +184,17 @@ export function TableView(props: TableViewProps) { value = fieldRenderer(dataRow, {navigate, organization, location, theme}); } - let target: any; - - if (dataRow['event.type'] !== 'transaction' && !isTransactionsDataset) { - const project = dataRow.project || dataRow['project.name']; - target = { - // Redirects to the issue group event page via ProjectEventRedirect - pathname: normalizeUrl( - `/${organization.slug}/${project}/events/${dataRow.id}/` - ), - query: {...location.query, referrer: 'discover-events-table'}, - }; - } else { - if (!dataRow.trace) { - throw new Error( - 'Transaction event should always have a trace associated with it.' - ); - } - - target = generateLinkToEventInTraceView({ - traceSlug: dataRow.trace, - eventId: dataRow.id, - timestamp: dataRow.timestamp, - organization, - location, - eventView, - source: TraceViewSources.DISCOVER, - }); - } - const eventIdLink = ( - + {value} ); @@ -319,38 +299,17 @@ export function TableView(props: TableViewProps) { queryDataset === SavedQueryDatasets.TRANSACTIONS; if (columnKey === 'id') { - let target: any; - - if (dataRow['event.type'] !== 'transaction' && !isTransactionsDataset) { - const project = dataRow.project || dataRow['project.name']; - - target = { - // Redirects to the issue group event page via ProjectEventRedirect - pathname: normalizeUrl( - `/${organization.slug}/${project}/events/${dataRow.id}/` - ), - query: {...location.query, referrer: 'discover-events-table'}, - }; - } else { - if (!dataRow.trace) { - throw new Error( - 'Transaction event should always have a trace associated with it.' - ); - } - - target = generateLinkToEventInTraceView({ - traceSlug: dataRow.trace?.toString(), - eventId: dataRow.id, - timestamp: dataRow.timestamp!, - organization, - location, - eventView, - source: TraceViewSources.DISCOVER, - }); - } - const idLink = ( - + {cell} ); @@ -386,10 +345,11 @@ export function TableView(props: TableViewProps) { dataRow['max(timestamp)'] ?? dataRow.timestamp ); const dateSelection = eventView.normalizeDateSelection(location); - if (dataRow.trace) { + const traceSlug = dataRow.trace; + if (typeof traceSlug === 'string' && traceSlug) { const target = getTraceDetailsUrl({ organization, - traceSlug: String(dataRow.trace), + traceSlug, dateSelection, timestamp, location, @@ -704,6 +664,93 @@ export function TableView(props: TableViewProps) { ); } +type EventTargetOptions = { + dataRow: TableDataRow; + eventView: EventView; + isTransactionsDataset: boolean; + location: Location; + organization: Organization; +}; + +type TraceEventDataRow = TableDataRow & { + timestamp: string | number; + trace: string; +}; + +type IssueEventDataRow = TableDataRow & { + 'issue.id': string | number; +}; + +function getEventTarget({ + dataRow, + eventView, + isTransactionsDataset, + location, + organization, +}: EventTargetOptions): LocationDescriptor { + if (dataRow['event.type'] !== 'transaction' && !isTransactionsDataset) { + if (isIssueEventDataRow(dataRow)) { + return getIssueEventTarget(dataRow, organization); + } + + return getProjectEventRedirectTarget(dataRow, organization, location); + } + + if (!isTraceEventDataRow(dataRow)) { + return getProjectEventRedirectTarget(dataRow, organization, location); + } + + return generateLinkToEventInTraceView({ + traceSlug: dataRow.trace, + eventId: dataRow.id, + timestamp: dataRow.timestamp, + organization, + location, + eventView, + source: TraceViewSources.DISCOVER, + }); +} + +function isIssueEventDataRow(dataRow: TableDataRow): dataRow is IssueEventDataRow { + const issueId = dataRow['issue.id']; + // Discover coalesces missing issue IDs to 0, so treat 0 as no issue. + return issueId !== undefined && issueId !== null && issueId !== 0 && issueId !== '0'; +} + +function isTraceEventDataRow(dataRow: TableDataRow): dataRow is TraceEventDataRow { + return ( + typeof dataRow.trace === 'string' && + dataRow.trace !== '' && + dataRow.timestamp !== undefined + ); +} + +function getIssueEventTarget( + dataRow: IssueEventDataRow, + organization: Organization +): LocationDescriptor { + return normalizeUrl({ + pathname: `/organizations/${organization.slug}/issues/${dataRow['issue.id']}/events/${dataRow.id}/`, + query: { + referrer: 'discover-events-table', + }, + }); +} + +function getProjectEventRedirectTarget( + dataRow: TableDataRow, + organization: Organization, + location: Location +): LocationDescriptor { + const project = dataRow.project || dataRow['project.name']; + + return { + // Redirects to the issue group event page via ProjectEventRedirect + pathname: normalizeUrl(`/${organization.slug}/${project}/events/${dataRow.id}/`), + query: {...location.query, referrer: 'discover-events-table'}, + }; +} + const PrependHeader = styled('span')` color: ${p => p.theme.tokens.content.secondary}; `; From c5b68dfde839be1c7a4e27af634da5bafe74297c Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Fri, 29 May 2026 13:52:28 -0500 Subject: [PATCH 25/42] feat(snapshots): Add all_image_file_names_as_regex (new regex dep) (#116427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `all_image_file_names_as_regex` field to the snapshot upload endpoint. It works like the existing `all_image_file_names`, except each entry is a regex pattern (matched with `fullmatch`) rather than a literal file name. This lets callers declare the head build's complete image set with a handful of patterns instead of an exhaustive list — useful for selective uploads, where only changed images are sent but the full set is needed downstream to tell `removed` images apart from `skipped` ones. The two fields are mutually exclusive and both require `selective`. Every uploaded image name must be covered by the declared set (appear in the list, or match at least one pattern), preserving the existing invariant. `fullmatch` (not `search`) is used because the literal field uses exact full-string equality, and full-match is its faithful regex analog. Renames still work: rename detection runs after the removed/skipped partitioning by matching content hashes across `added`/`removed`/`skipped`, and that shared logic is unchanged. ### New dependency: `google-re2` Patterns are **client-supplied**, so they cannot be matched with a backtracking engine (stdlib `re` or the `regex` lib) without exposing a ReDoS vector — a pathological pattern can hang a worker. This PR matches them with **RE2** ([`google-re2`](https://github.com/google/re2)), a linear-time, finite-automaton engine: - **Catastrophic backtracking is impossible by construction** — no time budget or timeout needed. - **Unsupported constructs (backreferences, lookaround) are rejected at compile time** (`re2.error`), so the engine enforces the safe subset. These make no sense for filename patterns anyway. Compiling a pattern *is* the validation (invalid/unsupported → 400), and the same compiled object does the matching. Pattern length (500 chars) and count (100) caps remain as sanity bounds. Reviewed with security; RE2's linear-time guarantee is the agreed approach over a wall-clock-timeout mitigation. `google-re2` was added to the internal PyPI mirror in getsentry/pypi#2192 (merged). It ships prebuilt wheels for all our targets and has no new transitive dependencies. ### Refactor included To avoid a third parallel code path, the literal and regex modes are unified behind a single `SnapshotManifest.head_image_name_matcher()` consumed by `categorize_image_diff`, and the endpoint's coverage checks are consolidated into one helper (`make_image_name_matcher`). Behavior-preserving. A `root_validator` on `SnapshotManifest` enforces the mutual-exclusion invariant at the model level. Co-authored-by: Claude --- pyproject.toml | 4 + .../snapshots/preprod_artifact_snapshot.py | 76 +++++++++++++++---- src/sentry/preprod/snapshots/manifest.py | 57 +++++++++++++- src/sentry/preprod/snapshots/tasks.py | 10 +-- .../test_preprod_artifact_snapshot.py | 49 ++++++++++++ .../sentry/preprod/snapshots/test_manifest.py | 44 ++++++++++- tests/sentry/preprod/snapshots/test_tasks.py | 71 +++++++++++++++++ uv.lock | 15 ++++ 8 files changed, 303 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d134a0e40b7e..12615a4ca449 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,9 @@ dependencies = [ "google-cloud-storage>=2.18.0", "google-cloud-storage-transfer>=1.17.0", "google-crc32c>=1.6.0", + # Linear-time regex engine (RE2). Used for client-supplied snapshot image-name + # patterns: no catastrophic backtracking (ReDoS-safe), no backreferences/lookaround. + "google-re2>=1.1.20251105", "googleapis-common-protos>=1.63.2", "granian[pname,reload,uvloop]>=2.7", "grpc-google-iam-v1>=0.13.1", @@ -376,6 +379,7 @@ module = [ "pymemcache.*", "P4", "rb.*", + "re2.*", "statsd.*", "tokenizers.*", "u2flib_server.model.*", diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py index 0e189ebf12f2..079a20397552 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from collections.abc import Collection from typing import Any import jsonschema @@ -62,8 +63,10 @@ from sentry.preprod.snapshots.manifest import ( ComparisonManifest, ImageMetadata, + InvalidImageNamePattern, SnapshotManifest, image_metadata_extras, + make_image_name_matcher, ) from sentry.preprod.snapshots.models import ( PreprodSnapshotComparison, @@ -99,6 +102,13 @@ "items": {"type": "string"}, "maxItems": 50000, }, + # Sanity bounds for client-supplied patterns; ReDoS-safety comes from RE2's + # linear-time matching (see make_image_name_matcher in manifest.py). + "all_image_file_names_as_regex": { + "type": "array", + "items": {"type": "string", "maxLength": 500}, + "maxItems": 100, + }, **VCS_SCHEMA_PROPERTIES, }, "required": ["app_id", "images"], @@ -110,6 +120,7 @@ "images": "The images field is required and must be an object mapping image names to image metadata.", "selective": "The selective field must be a boolean.", "all_image_file_names": "The all_image_file_names field must be an array of strings with at most 50000 entries.", + "all_image_file_names_as_regex": "The all_image_file_names_as_regex field must be an array of regex pattern strings (each at most 500 characters) with at most 100 entries.", **VCS_ERROR_MESSAGES, } @@ -174,6 +185,35 @@ def _format_validation_error(e: jsonschema.ValidationError) -> str: return e.message +def _validate_image_name_coverage( + image_names: Collection[str], + all_image_file_names: list[str] | None, + all_image_file_names_as_regex: list[str] | None, +) -> str | None: + """ + Ensure every uploaded image name is covered by the head build's declared set + of image names (a literal name list or a list of regex patterns). Returns an + error detail string, or None when valid. + """ + if all_image_file_names is not None: + if not all_image_file_names: + return "all_image_file_names must not be empty." + if set(image_names) - set(all_image_file_names): + return "Every image name must appear in all_image_file_names." + + if all_image_file_names_as_regex is not None: + if not all_image_file_names_as_regex: + return "all_image_file_names_as_regex must not be empty." + try: + matches = make_image_name_matcher(all_image_file_names_as_regex) + except InvalidImageNamePattern as e: + return f"all_image_file_names_as_regex contains an invalid regex pattern: {e.pattern}" + if any(not matches(name) for name in image_names): + return "Every image name must match a pattern in all_image_file_names_as_regex." + + return None + + def _format_pydantic_error(e: pydantic.ValidationError) -> str: err = e.errors()[0] loc = err.get("loc", ()) @@ -673,10 +713,23 @@ def post(self, request: Request, project: Project) -> Response: selective = data.get("selective", False) all_image_file_names = data.get("all_image_file_names") + all_image_file_names_as_regex = data.get("all_image_file_names_as_regex") - if all_image_file_names is not None and not selective: + if all_image_file_names is not None and all_image_file_names_as_regex is not None: return Response( - {"detail": "all_image_file_names requires selective to be true."}, + { + "detail": "all_image_file_names and all_image_file_names_as_regex are mutually exclusive." + }, + status=400, + ) + + if ( + all_image_file_names is not None or all_image_file_names_as_regex is not None + ) and not selective: + return Response( + { + "detail": "all_image_file_names and all_image_file_names_as_regex require selective to be true." + }, status=400, ) @@ -686,19 +739,11 @@ def post(self, request: Request, project: Project) -> Response: status=400, ) - if all_image_file_names is not None: - if not all_image_file_names: - return Response( - {"detail": "all_image_file_names must not be empty."}, - status=400, - ) - all_image_file_names_set = set(all_image_file_names) - missing = set(images.keys()) - all_image_file_names_set - if missing: - return Response( - {"detail": "Every image name must appear in all_image_file_names."}, - status=400, - ) + coverage_error = _validate_image_name_coverage( + images.keys(), all_image_file_names, all_image_file_names_as_regex + ) + if coverage_error: + return Response({"detail": coverage_error}, status=400) # Validate before entering the transaction so invalid data never creates # orphaned DB records. @@ -708,6 +753,7 @@ def post(self, request: Request, project: Project) -> Response: diff_threshold=diff_threshold, selective=selective, all_image_file_names=all_image_file_names, + all_image_file_names_as_regex=all_image_file_names_as_regex, ) except pydantic.ValidationError as e: return Response( diff --git a/src/sentry/preprod/snapshots/manifest.py b/src/sentry/preprod/snapshots/manifest.py index b7eb4680d29d..75cb699119ab 100644 --- a/src/sentry/preprod/snapshots/manifest.py +++ b/src/sentry/preprod/snapshots/manifest.py @@ -1,9 +1,38 @@ from __future__ import annotations -from collections.abc import Set +from collections.abc import Callable, Sequence, Set from typing import Any, Literal -from pydantic import BaseModel, Field, validator +import re2 +from pydantic import BaseModel, Field, root_validator, validator + +# Invalid patterns are client input we surface as a 400, not a server error worth logging. +_RE2_OPTIONS = re2.Options() +_RE2_OPTIONS.log_errors = False + + +class InvalidImageNamePattern(ValueError): + def __init__(self, pattern: str) -> None: + super().__init__(pattern) + self.pattern = pattern + + +def make_image_name_matcher(patterns: Sequence[str]) -> Callable[[str], bool]: + """ + Build a predicate testing whether a name fully matches any of `patterns`. + + Patterns compile with RE2, a linear-time engine: matching cannot catastrophically + backtrack, so no time budget is needed. RE2 rejects unsupported constructs + (backreferences, lookaround) at compile time; we raise InvalidImageNamePattern + carrying the offending pattern. + """ + compiled: list[Any] = [] + for pattern in patterns: + try: + compiled.append(re2.compile(pattern, _RE2_OPTIONS)) + except re2.error as e: + raise InvalidImageNamePattern(pattern) from e + return lambda name: any(compiled_pattern.fullmatch(name) for compiled_pattern in compiled) class ImageMetadata(BaseModel): @@ -57,6 +86,30 @@ class SnapshotManifest(BaseModel): diff_threshold: float | None = Field(default=None, ge=0.0, lt=1.0) selective: bool = False all_image_file_names: list[str] | None = None + all_image_file_names_as_regex: list[str] | None = None + + @root_validator(skip_on_failure=True) + def _all_image_file_names_mutually_exclusive(cls, values: dict[str, Any]) -> dict[str, Any]: + if ( + values.get("all_image_file_names") is not None + and values.get("all_image_file_names_as_regex") is not None + ): + raise ValueError( + "all_image_file_names and all_image_file_names_as_regex are mutually exclusive" + ) + return values + + def head_image_name_matcher(self) -> Callable[[str], bool] | None: + """ + Return a check for whether a name is in the head's declared image set, or + None if the head didn't declare one. Distinguishes removed images (not in the + set) from skipped ones (in the set but not re-uploaded). + """ + if self.all_image_file_names is not None: + return set(self.all_image_file_names).__contains__ + if self.all_image_file_names_as_regex is not None: + return make_image_name_matcher(self.all_image_file_names_as_regex) + return None class ComparisonImageResult(BaseModel): diff --git a/src/sentry/preprod/snapshots/tasks.py b/src/sentry/preprod/snapshots/tasks.py index cc9d17a56c25..52e3d3d88861 100644 --- a/src/sentry/preprod/snapshots/tasks.py +++ b/src/sentry/preprod/snapshots/tasks.py @@ -85,15 +85,15 @@ def categorize_image_diff( head_by_name = {key: meta.content_hash for key, meta in head_manifest.images.items()} base_by_name = {key: meta.content_hash for key, meta in base_manifest.images.items()} - all_image_file_names = head_manifest.all_image_file_names + matches_head_image = head_manifest.head_image_name_matcher() matched = head_by_name.keys() & base_by_name.keys() added = head_by_name.keys() - base_by_name.keys() - if all_image_file_names is not None: - all_names_set = set(all_image_file_names) - removed = base_by_name.keys() - all_names_set - skipped = (all_names_set - head_by_name.keys()) & base_by_name.keys() + if matches_head_image is not None: + base_in_head = {name for name in base_by_name.keys() if matches_head_image(name)} + removed = base_by_name.keys() - base_in_head + skipped = base_in_head - head_by_name.keys() elif head_manifest.selective: removed = set() skipped = base_by_name.keys() - head_by_name.keys() diff --git a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py index f3fcda47a71e..1c59242b727d 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py @@ -332,6 +332,13 @@ def _post_selective(self, **overrides): self._get_create_url(), self._selective_data(**overrides), format="json" ) + def _post_regex(self, patterns, **overrides): + data = self._selective_data(**overrides) + del data["all_image_file_names"] + data["all_image_file_names_as_regex"] = patterns + with self.feature("organizations:preprod-snapshots"): + return self.client.post(self._get_create_url(), data, format="json") + def test_all_image_file_names_rejects_empty_list(self): response = self._post_selective(images={}, all_image_file_names=[]) assert response.status_code == 400 @@ -363,6 +370,48 @@ def test_selective_with_all_image_file_names_accepted(self): response = self._post_selective() assert response.status_code == 200 + def test_all_image_file_names_as_regex_accepted(self): + response = self._post_regex([r".*\.png"]) + assert response.status_code == 200 + + def test_all_image_file_names_as_regex_mutually_exclusive(self): + response = self._post_selective(all_image_file_names_as_regex=[r".*\.png"]) + assert response.status_code == 400 + assert "mutually exclusive" in response.data["detail"] + + def test_all_image_file_names_as_regex_requires_selective(self): + response = self._post_regex([r".*\.png"], selective=False) + assert response.status_code == 400 + assert "selective" in response.data["detail"] + + def test_all_image_file_names_as_regex_rejects_empty_list(self): + response = self._post_regex([]) + assert response.status_code == 400 + assert "empty" in response.data["detail"] + + def test_all_image_file_names_as_regex_rejects_invalid_pattern(self): + response = self._post_regex(["["]) + assert response.status_code == 400 + assert "all_image_file_names_as_regex" in response.data["detail"] + assert "invalid regex pattern" in response.data["detail"] + assert "[" in response.data["detail"] + + def test_all_image_file_names_as_regex_rejects_unsupported_construct(self): + response = self._post_regex([r"screen(?=\.png)"]) + assert response.status_code == 400 + assert "invalid regex pattern" in response.data["detail"] + assert r"screen(?=\.png)" in response.data["detail"] + + def test_all_image_file_names_as_regex_must_match_all_images(self): + response = self._post_regex([r"other\.png"]) + assert response.status_code == 400 + assert "must match a pattern" in response.data["detail"] + + def test_all_image_file_names_as_regex_rejects_overlong_pattern(self): + response = self._post_regex(["a" * 501]) + assert response.status_code == 400 + assert "all_image_file_names_as_regex" in response.data["detail"] + @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.get_preprod_session") @patch("sentry.preprod.api.endpoints.snapshots.preprod_artifact_snapshot.compare_snapshots") def test_base_upload_triggers_comparison_for_waiting_head( diff --git a/tests/sentry/preprod/snapshots/test_manifest.py b/tests/sentry/preprod/snapshots/test_manifest.py index fda2da6202b4..528a46e4762e 100644 --- a/tests/sentry/preprod/snapshots/test_manifest.py +++ b/tests/sentry/preprod/snapshots/test_manifest.py @@ -1,6 +1,13 @@ import jsonschema +import pydantic +import pytest -from sentry.preprod.snapshots.manifest import ImageMetadata, SnapshotManifest +from sentry.preprod.snapshots.manifest import ( + ImageMetadata, + InvalidImageNamePattern, + SnapshotManifest, + make_image_name_matcher, +) def _meta(**kwargs: object) -> dict: @@ -9,6 +16,41 @@ def _meta(**kwargs: object) -> dict: return defaults +class TestSnapshotManifestHeadImageNameMatcher: + def test_no_declared_set_returns_none(self) -> None: + manifest = SnapshotManifest(images={}) + assert manifest.head_image_name_matcher() is None + + def test_literal_names_matcher(self) -> None: + manifest = SnapshotManifest(images={}, all_image_file_names=["a.png", "b.png"]) + matches_head_image = manifest.head_image_name_matcher() + assert matches_head_image is not None + assert matches_head_image("a.png") + assert not matches_head_image("c.png") + + def test_regex_matcher_uses_full_match(self) -> None: + manifest = SnapshotManifest(images={}, all_image_file_names_as_regex=[r"login\.png"]) + matches_head_image = manifest.head_image_name_matcher() + assert matches_head_image is not None + assert matches_head_image("login.png") + assert not matches_head_image("screens/login.png") + + def test_both_fields_set_is_rejected(self) -> None: + with pytest.raises(pydantic.ValidationError): + SnapshotManifest( + images={}, + all_image_file_names=["a.png"], + all_image_file_names_as_regex=[r"a\.png"], + ) + + def test_make_image_name_matcher_rejects_unsupported_construct(self) -> None: + with pytest.raises(InvalidImageNamePattern) as exc: + make_image_name_matcher([r"a(?=b)"]) + assert exc.value.pattern == r"a(?=b)" + with pytest.raises(InvalidImageNamePattern): + make_image_name_matcher([r"(a)\1"]) + + class TestImageMetadataTagsCoercion: def test_tags_none(self) -> None: meta = ImageMetadata(**_meta()) diff --git a/tests/sentry/preprod/snapshots/test_tasks.py b/tests/sentry/preprod/snapshots/test_tasks.py index dd71c6698e27..47a9c10b6db4 100644 --- a/tests/sentry/preprod/snapshots/test_tasks.py +++ b/tests/sentry/preprod/snapshots/test_tasks.py @@ -242,3 +242,74 @@ def test_selective_without_names_all_missing_are_skipped(self) -> None: assert result.skipped == {"b.png", "c.png"} assert result.removed == set() assert result.matched == {"a.png"} + + +class TestCategorizeImageDiffSelectiveRegex: + def test_regex_all_categories(self) -> None: + head = SnapshotManifest( + images={"new.png": _meta("h_new"), "matched.png": _meta("h1")}, + selective=True, + all_image_file_names_as_regex=[r".*\.png"], + ) + base = SnapshotManifest( + images={ + "matched.png": _meta("h1"), + "skipped.png": _meta("h2"), + "deleted.txt": _meta("h3"), + } + ) + + result = categorize_image_diff(head, base) + + assert result.added == {"new.png"} + assert result.matched == {"matched.png"} + assert result.skipped == {"skipped.png"} + assert result.removed == {"deleted.txt"} + + def test_regex_multiple_patterns(self) -> None: + head = SnapshotManifest( + images={"dark/a.png": _meta("h1")}, + selective=True, + all_image_file_names_as_regex=[r"dark/.*", r"light/.*"], + ) + base = SnapshotManifest( + images={ + "dark/a.png": _meta("h1"), + "light/b.png": _meta("h2"), + "other/c.png": _meta("h3"), + } + ) + + result = categorize_image_diff(head, base) + + assert result.matched == {"dark/a.png"} + assert result.skipped == {"light/b.png"} + assert result.removed == {"other/c.png"} + + def test_regex_rename_old_name_not_matching(self) -> None: + head = SnapshotManifest( + images={"new.png": _meta("shared")}, + selective=True, + all_image_file_names_as_regex=[r"new\.png"], + ) + base = SnapshotManifest(images={"old.png": _meta("shared")}) + + result = categorize_image_diff(head, base) + + assert result.renamed_pairs == [("new.png", "old.png")] + assert result.removed == set() + + def test_regex_rename_old_name_matching_is_detected_from_skipped(self) -> None: + head = SnapshotManifest( + images={"screens/new.png": _meta("shared")}, + selective=True, + all_image_file_names_as_regex=[r"screens/.*"], + ) + base = SnapshotManifest(images={"screens/old.png": _meta("shared")}) + + result = categorize_image_diff(head, base) + + assert result.renamed_pairs == [("screens/new.png", "screens/old.png")] + assert result.skipped == set() + assert result.added == set() + assert result.removed == set() diff --git a/uv.lock b/uv.lock index 31e2079b6e90..20698dafcdad 100644 --- a/uv.lock +++ b/uv.lock @@ -809,6 +809,19 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/google_crc32c-1.6.0-cp314-cp314-manylinux_2_31_x86_64.whl", hash = "sha256:db3b57e16252dfd8606edcf0b2ec652dde01b910a8890c030ca8edcde97f7978" }, ] +[[package]] +name = "google-re2" +version = "1.1.20251105" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/google_re2-1.1.20251105-1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:83292e23963aa1b219d5f64a65365b0880448a6a060276027b55270bc5b18c7e" }, + { url = "https://pypi.devinfra.sentry.io/wheels/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b1458d9ca588124cd61aa1bf5388a216e1247e7d474f8e5e1530498044f5c87" }, + { url = "https://pypi.devinfra.sentry.io/wheels/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a52cb204e49d20cdbb66faf394d57f476e96c39c23a328442ab0194fc6bd1a2b" }, + { url = "https://pypi.devinfra.sentry.io/wheels/google_re2-1.1.20251105-1-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:79ce664038194a31bbcf422137f9607ae3d9946a5cff98cf0efbeb7f9411e64b" }, + { url = "https://pypi.devinfra.sentry.io/wheels/google_re2-1.1.20251105-1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85feec3161ffdc12f6b144e37a2f91f80b771c72ffadde60191e89a49f6d7e81" }, + { url = "https://pypi.devinfra.sentry.io/wheels/google_re2-1.1.20251105-1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7bfaa2cf55daf0c5c650e68526bb20b61e37d7f3ae53f6893013acc1c91c116" }, +] + [[package]] name = "google-resumable-media" version = "2.7.0" @@ -2188,6 +2201,7 @@ dependencies = [ { name = "google-cloud-storage", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "google-cloud-storage-transfer", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "google-crc32c", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "google-re2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "granian", extra = ["pname", "reload", "uvloop"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "grpc-google-iam-v1", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2362,6 +2376,7 @@ requires-dist = [ { name = "google-cloud-storage", specifier = ">=2.18.0" }, { name = "google-cloud-storage-transfer", specifier = ">=1.17.0" }, { name = "google-crc32c", specifier = ">=1.6.0" }, + { name = "google-re2", specifier = ">=1.1.20251105" }, { name = "googleapis-common-protos", specifier = ">=1.63.2" }, { name = "granian", extras = ["pname", "reload", "uvloop"], specifier = ">=2.7" }, { name = "grpc-google-iam-v1", specifier = ">=0.13.1" }, From fd08d690cc7c02d645a51552c181a751b1267277 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Fri, 29 May 2026 14:18:50 -0500 Subject: [PATCH 26/42] ref(seer): Add GitLab code-review web hooks (#116317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire GitLab merge requests into Seer code review. This uses the old web hook handler format rather than the new event listeners format. I struggled too much with the listeners. I'd rather ship the old system than deal with it. It can be ported later if/when the new system is at parity. # Slop What changed: - Processor architecture on GitlabWebhook — adds a `WEBHOOK_EVENT_PROCESSORS` tuple + shared `_handle` loop (with `EVENT_TYPE` enforcement) so multiple handlers can run per event. PR persistence stays inline in `MergeEventWebhook.__call__`; only the code-review handler runs in the (error-isolated) processor loop. - New handle_merge_request_event handler (seer/code_review/webhooks/merge_request.py) — runs preflight checks, maps GitLab actions to triggers, and enqueues the review: - open / un-drafted update → ON_READY_FOR_REVIEW; update with new commits → ON_NEW_COMMIT; close / merge → PR-closed. Everything else (metadata-only edits, drafts unsupported actions) is filtered. - GitLab has no ready_for_review action, so un-drafting is detected from the changes payload. - De-dupes redeliveries (and the per-org dispatch fan-out) via a short Redis TTL key. - Routes to the provider-agnostic /v1/scm_code_review/{review-request,pr-closed} Seer endpoints (GitLab isn't supported by the PyGithub-based /v1/code_review/* routes). - Provider-aware repo definitions (seer/code_review/utils.py) — split GitHub/GitLab builders. GitLab derives owner/name from config["path"] (not the display name) and is_private from project.visibility_level. - Metrics generalized to accept string event names so the funnel metrics work for merge_request. Known limitation Code review won't fire in production until GitLab OrganizationContributors are seeded — the billing preflight currently filters every MR with ORG_CONTRIBUTOR_NOT_FOUND (GitHub seeds these via track_contributor_seat; GitLab doesn't yet). Documented in the module docstring. --- src/sentry/features/temporary.py | 2 + src/sentry/integrations/gitlab/webhooks.py | 96 ++- src/sentry/seer/code_review/metrics.py | 23 +- src/sentry/seer/code_review/utils.py | 78 +- .../code_review/webhooks/merge_request.py | 385 +++++++++ .../integrations/gitlab/test_webhook.py | 55 ++ tests/sentry/seer/code_review/test_utils.py | 31 + .../webhooks/test_merge_request.py | 778 ++++++++++++++++++ 8 files changed, 1414 insertions(+), 34 deletions(-) create mode 100644 src/sentry/seer/code_review/webhooks/merge_request.py create mode 100644 tests/sentry/seer/code_review/webhooks/test_merge_request.py diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 1d286ade2a59..f9a110dfb22b 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -248,6 +248,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:sdk-crash-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable Seer PR code review for GitHub Enterprise Server organizations manager.add("organizations:seer-code-review-github-enterprise", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Enable Seer MR code review for GitLab organizations + manager.add("organizations:seer-code-review-gitlab", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable the Seer Config Reminder in the primary nav manager.add("organizations:seer-config-reminder", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable Seer Agent panel for AI-powered data exploration diff --git a/src/sentry/integrations/gitlab/webhooks.py b/src/sentry/integrations/gitlab/webhooks.py index 34135b50902f..ef34e32f00b6 100644 --- a/src/sentry/integrations/gitlab/webhooks.py +++ b/src/sentry/integrations/gitlab/webhooks.py @@ -1,12 +1,14 @@ from __future__ import annotations +import inspect import logging from abc import ABC from collections.abc import Mapping from datetime import timezone -from typing import Any +from typing import Any, Protocol import orjson +import sentry_sdk from dateutil.parser import parse as parse_date from django.db import IntegrityError, router, transaction from django.http import Http404, HttpRequest, HttpResponse @@ -36,11 +38,25 @@ from sentry.organizations.services.organization import organization_service from sentry.organizations.services.organization.model import RpcOrganization from sentry.plugins.providers import IntegrationRepositoryProvider +from sentry.seer.code_review.webhooks.merge_request import handle_merge_request_event +from sentry.utils import metrics logger = logging.getLogger("sentry.webhooks") PROVIDER_NAME = "integrations:gitlab" -GITHUB_WEBHOOK_SECRET_INVALID_ERROR = """Gitlab's webhook secret does not match. Refresh token (or re-install the integration) by following this https://docs.sentry.io/organization/integrations/integration-platform/public-integration/#refreshing-tokens.""" +GITLAB_WEBHOOK_SECRET_INVALID_ERROR = """Gitlab's webhook secret does not match. Refresh token (or re-install the integration) by following this https://docs.sentry.io/organization/integrations/integration-platform/public-integration/#refreshing-tokens.""" + + +class WebhookProcessor(Protocol): + def __call__( + self, + *, + event: Mapping[str, Any], + organization: RpcOrganization, + repo: Repository, + integration: RpcIntegration | None = None, + **kwargs: Any, + ) -> None: ... def get_gitlab_external_id(request, extra) -> tuple[str, str] | HttpResponse: @@ -69,10 +85,48 @@ def get_gitlab_external_id(request, extra) -> tuple[str, str] | HttpResponse: class GitlabWebhook(SCMWebhook, ABC): + EVENT_TYPE: IntegrationWebhookEventType + WEBHOOK_EVENT_PROCESSORS: tuple[WebhookProcessor, ...] = () + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + if not inspect.isabstract(cls) and not hasattr(cls, "EVENT_TYPE"): + raise TypeError(f"{cls.__name__} must define EVENT_TYPE class attribute") + + @property + def event_type(self) -> IntegrationWebhookEventType: + return self.EVENT_TYPE + @property def provider(self) -> str: return IntegrationProviderSlug.GITLAB.value + def _handle( + self, + integration: RpcIntegration, + event: Mapping[str, Any], + organization: RpcOrganization, + repo: Repository, + **kwargs: Any, + ) -> None: + for processor in self.WEBHOOK_EVENT_PROCESSORS: + try: + processor( + event=event, + integration=integration, + organization=organization, + repo=repo, + **kwargs, + ) + except Exception as e: + sentry_sdk.capture_exception(e) + metrics.incr( + "gitlab.webhook.processor.error", + tags={"event_type": self.event_type.value}, + sample_rate=1.0, + ) + continue + def get_repo( self, integration: RpcIntegration, organization: RpcOrganization, event: Mapping[str, Any] ): @@ -125,9 +179,7 @@ class IssuesEventWebhook(GitlabWebhook): See https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#issue-events """ - @property - def event_type(self) -> IntegrationWebhookEventType: - return IntegrationWebhookEventType.INBOUND_SYNC + EVENT_TYPE = IntegrationWebhookEventType.INBOUND_SYNC def __call__(self, event: Mapping[str, Any], **kwargs): if not (integration := kwargs.get("integration")): @@ -292,9 +344,8 @@ class MergeEventWebhook(GitlabWebhook): See https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#merge-request-events """ - @property - def event_type(self) -> IntegrationWebhookEventType: - return IntegrationWebhookEventType.MERGE_REQUEST + EVENT_TYPE = IntegrationWebhookEventType.MERGE_REQUEST + WEBHOOK_EVENT_PROCESSORS = (handle_merge_request_event,) def __call__(self, event: Mapping[str, Any], **kwargs): if not ( @@ -326,9 +377,11 @@ def __call__(self, event: Mapping[str, Any], **kwargs): except KeyError as e: logger.warning( "gitlab.webhook.invalid-merge-data", - extra={"integration_id": integration.id, "error": str(e)}, + extra={ + "integration_id": integration.id if integration else None, + "error": str(e), + }, ) - # TODO(mgaeta): This try/catch is full of reportUnboundVariable errors. return if not author_email: @@ -352,10 +405,16 @@ def __call__(self, event: Mapping[str, Any], **kwargs): "date_added": parse_date(created_at).astimezone(timezone.utc), }, ) - except IntegrityError: pass + self._handle( + integration=integration, + event=event, + organization=organization, + repo=repo, + ) + class PushEventWebhook(GitlabWebhook): """ @@ -364,9 +423,7 @@ class PushEventWebhook(GitlabWebhook): See https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#push-events """ - @property - def event_type(self) -> IntegrationWebhookEventType: - return IntegrationWebhookEventType.PUSH + EVENT_TYPE = IntegrationWebhookEventType.PUSH def __call__(self, event: Mapping[str, Any], **kwargs): if not ( @@ -420,6 +477,13 @@ def __call__(self, event: Mapping[str, Any], **kwargs): except IntegrityError: pass + self._handle( + integration=integration, + event=event, + organization=organization, + repo=repo, + ) + @cell_silo_endpoint class GitlabWebhookEndpoint(Endpoint): @@ -487,9 +551,9 @@ def post(self, request: HttpRequest) -> HttpResponse: if not constant_time_compare(secret, integration.metadata["webhook_secret"]): # Summary and potential workaround mentioned here: # https://github.com/getsentry/sentry/issues/34903#issuecomment-1262754478 - extra["reason"] = GITHUB_WEBHOOK_SECRET_INVALID_ERROR + extra["reason"] = GITLAB_WEBHOOK_SECRET_INVALID_ERROR logger.info("gitlab.webhook.invalid-token-secret", extra=extra) - return HttpResponse(status=409, reason=GITHUB_WEBHOOK_SECRET_INVALID_ERROR) + return HttpResponse(status=409, reason=GITLAB_WEBHOOK_SECRET_INVALID_ERROR) try: event = orjson.loads(request.body) diff --git a/src/sentry/seer/code_review/metrics.py b/src/sentry/seer/code_review/metrics.py index f36c220d923a..41504fc43c98 100644 --- a/src/sentry/seer/code_review/metrics.py +++ b/src/sentry/seer/code_review/metrics.py @@ -48,13 +48,16 @@ class CodeReviewErrorType(StrEnum): def _build_webhook_tags( - github_event: GithubWebhookType, github_event_action: str + github_event: GithubWebhookType | str, github_event_action: str ) -> dict[str, str]: - return {"github_event": github_event.value, "github_event_action": github_event_action} + event_value = ( + github_event.value if isinstance(github_event, GithubWebhookType) else github_event + ) + return {"github_event": event_value, "github_event_action": github_event_action} def record_webhook_received( - github_event: GithubWebhookType, + github_event: GithubWebhookType | str, github_event_action: str, ) -> None: """ @@ -63,7 +66,7 @@ def record_webhook_received( This is the entry point metric for the processing funnel. Args: - github_event: The GitHub webhook event type (e.g., check_run, issue_comment) + github_event: The webhook event type (e.g., check_run, issue_comment, merge_request) github_event_action: The webhook action (e.g., created, rerequested, synchronize) """ metrics.incr( @@ -74,7 +77,7 @@ def record_webhook_received( def record_webhook_filtered( - github_event: GithubWebhookType, + github_event: GithubWebhookType | str, github_event_action: str, reason: CodeReviewFilteredReason, ) -> None: @@ -85,7 +88,7 @@ def record_webhook_filtered( not enabled, not a review command, wrong action type). Args: - github_event: The GitHub webhook event type + github_event: The webhook event type github_event_action: The webhook action (e.g., created, rerequested, synchronize) reason: Why the webhook was filtered """ @@ -97,7 +100,7 @@ def record_webhook_filtered( def record_webhook_enqueued( - github_event: GithubWebhookType, + github_event: GithubWebhookType | str, github_event_action: str, ) -> None: """ @@ -107,7 +110,7 @@ def record_webhook_enqueued( was created to process it. Args: - github_event: The GitHub webhook event type + github_event: The webhook event type github_event_action: The webhook action (e.g., created, rerequested, synchronize) """ metrics.incr( @@ -118,7 +121,7 @@ def record_webhook_enqueued( def record_webhook_handler_error( - github_event: GithubWebhookType, + github_event: GithubWebhookType | str, github_event_action: str, error_type: CodeReviewErrorType, ) -> None: @@ -126,7 +129,7 @@ def record_webhook_handler_error( Record an error in the webhook handler stage. Args: - github_event: The GitHub webhook event type + github_event: The webhook event type github_event_action: The webhook action (e.g., created, rerequested, synchronize) error_type: Specific error identifier from CodeReviewErrorType enum """ diff --git a/src/sentry/seer/code_review/utils.py b/src/sentry/seer/code_review/utils.py index 6e05ce10c958..22520ca217d7 100644 --- a/src/sentry/seer/code_review/utils.py +++ b/src/sentry/seer/code_review/utils.py @@ -16,6 +16,7 @@ from sentry.integrations.github.utils import is_github_rate_limit_sensitive from sentry.integrations.github.webhook_types import GithubWebhookType from sentry.integrations.services.integration.model import RpcIntegration +from sentry.integrations.types import IntegrationProviderSlug from sentry.models.organization import Organization from sentry.models.repository import Repository from sentry.net.http import connection_from_url @@ -75,6 +76,11 @@ class SeerEndpoint(StrEnum): PR_REVIEW_RERUN = "/v1/code_review/check/rerun" CODE_REVIEW_REVIEW_REQUEST = "/v1/code_review/review-request" CODE_REVIEW_PR_CLOSED = "/v1/code_review/pr-closed" + # The scm_code_review endpoints route every SCM operation through the + # scm-platform RPC instead of direct PyGithub calls, so they are the + # provider-agnostic path required for non-GitHub providers like GitLab. + SCM_CODE_REVIEW_REVIEW_REQUEST = "/v1/scm_code_review/review-request" + SCM_CODE_REVIEW_PR_CLOSED = "/v1/scm_code_review/pr-closed" REPOSITORY_OFFBOARD = "/v1/offboarding/repository" @@ -354,22 +360,42 @@ def _build_repo_definition( ) -> dict[str, Any]: """ Build the repository definition for code review requests. - """ - # Extract owner and repo name from full repository name (format: "owner/repo") - repo_name_sections = repo.name.split("/") - if len(repo_name_sections) < 2: - raise ValueError(f"Invalid repository name format: {repo.name}") + GitHub and GitLab expose repo identity and visibility differently, so each + provider has its own builder. Anything unrecognized falls back to GitHub. + """ # repo.provider uses the "integrations:" format; Seer expects the bare slug provider = repo.provider.removeprefix("integrations:") if repo.provider else "github" + + if provider == IntegrationProviderSlug.GITLAB.value: + return _build_gitlab_repo_definition(repo, provider, target_commit_sha, event_payload) + return _build_github_repo_definition(repo, provider, target_commit_sha, event_payload) + + +def _split_full_name(full_name: str) -> tuple[str, str]: + """Split a "owner/repo" (or "group/subgroup/repo") slug into (owner, name).""" + sections = full_name.split("/") + if len(sections) < 2: + raise ValueError(f"Invalid repository name format: {full_name}") + return sections[0], "/".join(sections[1:]) + + +def _base_repo_definition( + repo: Repository, + provider: str, + owner: str, + name: str, + target_commit_sha: str, + is_private: bool | None, +) -> dict[str, Any]: repo_definition = { "provider": provider, - "owner": repo_name_sections[0], - "name": "/".join(repo_name_sections[1:]), + "owner": owner, + "name": name, "external_id": repo.external_id, "base_commit_sha": target_commit_sha, "organization_id": repo.organization_id, - "is_private": event_payload.get("repository", {}).get("private"), + "is_private": is_private, } # add integration_id which is used in pr_closed_step for product metrics dashboarding only @@ -379,6 +405,42 @@ def _build_repo_definition( return repo_definition +def _build_github_repo_definition( + repo: Repository, + provider: str, + target_commit_sha: str, + event_payload: Mapping[str, Any], +) -> dict[str, Any]: + # GitHub stores Repository.name as the "owner/repo" slug. + owner, name = _split_full_name(repo.name) + is_private = event_payload.get("repository", {}).get("private") + return _base_repo_definition(repo, provider, owner, name, target_commit_sha, is_private) + + +def _build_gitlab_repo_definition( + repo: Repository, + provider: str, + target_commit_sha: str, + event_payload: Mapping[str, Any], +) -> dict[str, Any]: + # GitLab stores Repository.name as the display "name_with_namespace" + # (e.g. "Cool Group / Sentry"), which is not a valid URL slug. The slug + # ("cool-group/sentry") lives in config["path"], kept current by the webhook's + # update_repo_data(), and is the only valid source for owner/name. Falling back + # to repo.name would silently produce slugs with spaces, so require the path. + path = repo.config.get("path") + if not path: + raise ValueError(f"GitLab repository {repo.id} is missing config['path']") + owner, name = _split_full_name(path) + + # GitLab has no repository.private; visibility lives in project.visibility_level + # (0 = private, 10 = internal, 20 = public). Leave as None when absent. + visibility_level = event_payload.get("project", {}).get("visibility_level") + is_private = visibility_level != 20 if visibility_level is not None else None + + return _base_repo_definition(repo, provider, owner, name, target_commit_sha, is_private) + + def get_pr_author_id(event: Mapping[str, Any]) -> str | None: """ Extract the PR author's GitHub user ID from the webhook payload. diff --git a/src/sentry/seer/code_review/webhooks/merge_request.py b/src/sentry/seer/code_review/webhooks/merge_request.py new file mode 100644 index 000000000000..58f79200be4f --- /dev/null +++ b/src/sentry/seer/code_review/webhooks/merge_request.py @@ -0,0 +1,385 @@ +""" +Handler for GitLab merge_request webhook events. +https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#merge-request-events + +Known limitations +----------------- + +Code review does not fire in production yet: GitLab contributors are never seeded. +``handle_merge_request_event`` runs ``CodeReviewPreflightService``, whose +``_check_billing`` looks up ``OrganizationContributors`` by +``(organization_id, integration_id, external_identifier=str(author_id))`` and +returns ``ORG_CONTRIBUTOR_NOT_FOUND`` (before the beta exemption) when the row is +missing. GitHub creates that row via ``track_contributor_seat`` in +``PullRequestEventWebhook._handle`` on PR creation; the GitLab merge-request path +(PR persistence inline in ``MergeEventWebhook.__call__``) does not, and nothing +else seeds GitLab contributors. Until contributor seeding is added, every GitLab MR +is filtered with ``ORG_CONTRIBUTOR_NOT_FOUND``. The handler tests pass only because +they seed the row manually. + +The code-review tests seed OrganizationContributors manually; consider a test that +omits it to lock in the intended production behavior (related to Issue 1). + +GitLab has no dedicated "ready_for_review" action: un-drafting an MR arrives as an +"update" whose top-level ``changes`` flips draft/work_in_progress to false, which is +treated as an ON_READY_FOR_REVIEW trigger (see ``_resolve_review_trigger``). +""" + +from __future__ import annotations + +import enum +import logging +from collections.abc import Mapping +from datetime import datetime, timezone +from typing import Any + +from pydantic import ValidationError + +from sentry import features +from sentry.integrations.services.integration.model import RpcIntegration +from sentry.models.organization import Organization +from sentry.models.repository import Repository +from sentry.models.repositorysettings import CodeReviewTrigger +from sentry.organizations.services.organization.model import RpcOrganization +from sentry.seer.code_review.models import ( + SeerCodeReviewTaskRequestForPrClosed, + SeerCodeReviewTaskRequestForPrReview, + SeerCodeReviewTrigger, +) +from sentry.utils import json +from sentry.utils.redis import redis_clusters + +from ..metrics import ( + WebhookFilteredReason, + record_webhook_enqueued, + record_webhook_filtered, + record_webhook_received, +) +from ..preflight import CodeReviewPreflightService +from ..utils import SeerEndpoint, _common_codegen_request_payload +from .task import process_github_webhook_event + +logger = logging.getLogger(__name__) + +GITLAB_WEBHOOK_EVENT = "merge_request" + +# GitLab redelivers webhooks (e.g. when our response times out), and the endpoint +# dispatches the same payload once per installed organization. Either can enqueue +# duplicate Seer review requests, so we skip a delivery already seen within this +# window. The key is scoped per organization/repo to keep distinct installs isolated. +WEBHOOK_SEEN_TTL_SECONDS = 20 +WEBHOOK_SEEN_KEY_PREFIX = "webhook:gitlab:merge_request:" + + +def _is_duplicate_delivery(seen_key: str) -> bool: + """ + Return True if this delivery was already processed within the TTL window. + + On Redis errors we return False (process anyway) since processing twice is + preferable to never processing. + """ + try: + cluster = redis_clusters.get("default") + is_first_time_seen = cluster.set(seen_key, "1", ex=WEBHOOK_SEEN_TTL_SECONDS, nx=True) + except Exception: + logger.warning("gitlab.webhook.merge_request.mark_seen_failed") + return False + return not is_first_time_seen + + +class MergeRequestAction(enum.StrEnum): + """ + GitLab merge request webhook actions. + https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#merge-request-events + """ + + OPEN = "open" + CLOSE = "close" + REOPEN = "reopen" + UPDATE = "update" + MERGE = "merge" + APPROVED = "approved" + UNAPPROVED = "unapproved" + + +WHITELISTED_ACTIONS = { + MergeRequestAction.CLOSE, + MergeRequestAction.MERGE, + MergeRequestAction.OPEN, + MergeRequestAction.UPDATE, +} + +CLOSE_ACTIONS = {MergeRequestAction.CLOSE, MergeRequestAction.MERGE} + +# Map the repo trigger that gated a review to the trigger value reported to Seer. +CODE_REVIEW_TO_SEER_TRIGGER: dict[CodeReviewTrigger, SeerCodeReviewTrigger] = { + CodeReviewTrigger.ON_READY_FOR_REVIEW: SeerCodeReviewTrigger.ON_READY_FOR_REVIEW, + CodeReviewTrigger.ON_NEW_COMMIT: SeerCodeReviewTrigger.ON_NEW_COMMIT, +} + + +def _is_undraft_update(changes: Mapping[str, Any]) -> bool: + """ + True when an "update" event marks a draft MR ready for review. + + GitLab has no dedicated "ready_for_review" action (unlike GitHub); un-drafting + arrives as an "update" whose ``changes`` shows draft/work_in_progress flipping + from true to false. ``changes`` is a top-level payload field, not part of + ``object_attributes``. + """ + for field in ("draft", "work_in_progress"): + change = changes.get(field) or {} + if change.get("previous") is True and change.get("current") is False: + return True + return False + + +def _resolve_review_trigger( + action: MergeRequestAction, event: Mapping[str, Any] +) -> CodeReviewTrigger | None: + """ + Map a non-close MR action to the repo trigger that gates a review, or None when + the event should not start one. + + "open" is a ready-for-review trigger unless the MR is opened as a draft. "update" + is ambiguous because GitLab fires it for any edit, so it triggers a review only + when it brings new commits (ON_NEW_COMMIT) or marks the MR ready for review + (ON_READY_FOR_REVIEW). + """ + if action == MergeRequestAction.OPEN: + # An MR opened as a draft is not ready for review. GitLab sets + # object_attributes.draft (legacy: work_in_progress) from the "Draft:" + # title prefix; un-drafting later arrives as an "update" (_is_undraft_update). + object_attributes = event.get("object_attributes") or {} + if ( + object_attributes.get("draft") is True + or object_attributes.get("work_in_progress") is True + ): + return None + return CodeReviewTrigger.ON_READY_FOR_REVIEW + if action == MergeRequestAction.UPDATE: + # GitLab puts "changes" at the top level of the payload, while "oldrev" + # (present only when commits were pushed) lives in "object_attributes". + if _is_undraft_update(event.get("changes") or {}): + return CodeReviewTrigger.ON_READY_FOR_REVIEW + if "oldrev" in (event.get("object_attributes") or {}): + return CodeReviewTrigger.ON_NEW_COMMIT + return None + + +def handle_merge_request_event( + *, + event: Mapping[str, Any], + organization: RpcOrganization, + repo: Repository, + integration: RpcIntegration | None = None, + **kwargs: Any, +) -> None: + """Handle GitLab merge request webhook events for code review.""" + if integration is None: + return + + if not features.has("organizations:seer-code-review-gitlab", organization): + return + + object_attributes = event.get("object_attributes", {}) + action_value = object_attributes.get("action") + if not action_value or not isinstance(action_value, str): + return + + record_webhook_received(GITLAB_WEBHOOK_EVENT, action_value) + + try: + action = MergeRequestAction(action_value) + except ValueError: + record_webhook_filtered( + GITLAB_WEBHOOK_EVENT, action_value, WebhookFilteredReason.UNSUPPORTED_ACTION + ) + return + + if action not in WHITELISTED_ACTIONS: + record_webhook_filtered( + GITLAB_WEBHOOK_EVENT, action_value, WebhookFilteredReason.UNSUPPORTED_ACTION + ) + return + + # GitLab fires "update" for any MR edit (title, labels, assignee, etc.), so a + # non-close action only starts a review when it maps to a repo trigger: a new + # commit (ON_NEW_COMMIT) or the MR being opened / marked ready (ON_READY_FOR_REVIEW). + review_trigger: CodeReviewTrigger | None = None + if action not in CLOSE_ACTIONS: + review_trigger = _resolve_review_trigger(action, event) + if review_trigger is None: + record_webhook_filtered( + GITLAB_WEBHOOK_EVENT, action_value, WebhookFilteredReason.UNSUPPORTED_ACTION + ) + return + + try: + org = Organization.objects.get_from_cache(id=organization.id) + except Organization.DoesNotExist: + return + + author_id = object_attributes.get("author_id") + preflight = CodeReviewPreflightService( + organization=org, + repo=repo, + integration_id=integration.id, + pr_author_external_id=str(author_id) if author_id else None, + ).check() + + if not preflight.allowed: + if preflight.denial_reason: + record_webhook_filtered(GITLAB_WEBHOOK_EVENT, action_value, preflight.denial_reason) + return + + org_code_review_settings = preflight.settings + + if review_trigger is not None and ( + org_code_review_settings is None or review_trigger not in org_code_review_settings.triggers + ): + record_webhook_filtered( + GITLAB_WEBHOOK_EVENT, action_value, WebhookFilteredReason.TRIGGER_DISABLED + ) + return + + if action in CLOSE_ACTIONS and ( + org_code_review_settings is None or not org_code_review_settings.triggers + ): + record_webhook_filtered( + GITLAB_WEBHOOK_EVENT, action_value, WebhookFilteredReason.TRIGGER_DISABLED + ) + return + + if action not in CLOSE_ACTIONS: + if ( + object_attributes.get("draft") is True + or object_attributes.get("work_in_progress") is True + ): + return + + last_commit = object_attributes.get("last_commit") or {} + target_commit_sha = last_commit.get("id") + if not target_commit_sha: + return + + seen_key = ( + f"{WEBHOOK_SEEN_KEY_PREFIX}{org.id}:{repo.id}:" + f"{object_attributes.get('iid')}:{action_value}:{target_commit_sha}" + ) + if _is_duplicate_delivery(seen_key): + logger.warning("gitlab.webhook.merge_request.duplicate_delivery_skipped") + return + + _schedule_task( + action=action, + action_value=action_value, + event=event, + organization=org, + repo=repo, + target_commit_sha=target_commit_sha, + review_trigger=review_trigger, + ) + + +def _get_trigger_metadata(event: Mapping[str, Any]) -> dict[str, Any]: + user = event.get("user", {}) + object_attributes = event.get("object_attributes", {}) + trigger_at = ( + object_attributes.get("updated_at") + or object_attributes.get("created_at") + or datetime.now(timezone.utc).isoformat() + ) + return { + "trigger_user": user.get("username"), + "trigger_user_id": user.get("id"), + "trigger_comment_id": None, + "trigger_comment_type": None, + "trigger_at": trigger_at, + } + + +def _build_payload( + action: MergeRequestAction, + event: Mapping[str, Any], + organization: Organization, + repo: Repository, + target_commit_sha: str, + review_trigger: CodeReviewTrigger | None, +) -> dict[str, Any]: + is_close = action in CLOSE_ACTIONS + payload = _common_codegen_request_payload( + add_experiment_enabled=not is_close, + repo=repo, + target_commit_sha=target_commit_sha, + organization=organization, + event_payload=event, + ) + + object_attributes = event.get("object_attributes", {}) + payload["data"]["pr_id"] = object_attributes.get("iid") + + config = payload["data"]["config"] + trigger_metadata = _get_trigger_metadata(event) + seer_trigger = ( + CODE_REVIEW_TO_SEER_TRIGGER[review_trigger] + if review_trigger is not None + else SeerCodeReviewTrigger.UNKNOWN + ) + config["trigger"] = seer_trigger.value + config["trigger_user"] = trigger_metadata["trigger_user"] + config["trigger_user_id"] = trigger_metadata["trigger_user_id"] + config["trigger_comment_id"] = trigger_metadata["trigger_comment_id"] + config["trigger_comment_type"] = trigger_metadata["trigger_comment_type"] + config["trigger_at"] = trigger_metadata["trigger_at"] + config["sentry_received_trigger_at"] = datetime.now(timezone.utc).isoformat() + + return payload + + +def _schedule_task( + *, + action: MergeRequestAction, + action_value: str, + event: Mapping[str, Any], + organization: Organization, + repo: Repository, + target_commit_sha: str, + review_trigger: CodeReviewTrigger | None, +) -> None: + payload = _build_payload(action, event, organization, repo, target_commit_sha, review_trigger) + + # GitLab is not supported by the direct-PyGithub /v1/code_review/* endpoints; + # it must use the scm-platform RPC counterparts at /v1/scm_code_review/*. + is_closed = action in CLOSE_ACTIONS + seer_path = ( + SeerEndpoint.SCM_CODE_REVIEW_PR_CLOSED.value + if is_closed + else SeerEndpoint.SCM_CODE_REVIEW_REVIEW_REQUEST.value + ) + + try: + validated: SeerCodeReviewTaskRequestForPrClosed | SeerCodeReviewTaskRequestForPrReview + if is_closed: + validated = SeerCodeReviewTaskRequestForPrClosed.parse_obj(payload) + else: + validated = SeerCodeReviewTaskRequestForPrReview.parse_obj(payload) + serialized_payload = json.loads(validated.json()) + except ValidationError: + logger.warning("gitlab.webhook.merge_request.validation_failed") + record_webhook_filtered( + GITLAB_WEBHOOK_EVENT, action_value, WebhookFilteredReason.INVALID_PAYLOAD + ) + return + + process_github_webhook_event.delay( + seer_path=seer_path, + event_payload=serialized_payload, + tags={ + "sentry_organization_id": str(organization.id), + "sentry_organization_slug": organization.slug, + "sentry_integration_id": str(repo.integration_id) if repo.integration_id else "", + "scm_provider": "gitlab", + }, + ) + record_webhook_enqueued(GITLAB_WEBHOOK_EVENT, action_value) diff --git a/tests/sentry/integrations/gitlab/test_webhook.py b/tests/sentry/integrations/gitlab/test_webhook.py index 993d8759f1d3..a8a28dc58757 100644 --- a/tests/sentry/integrations/gitlab/test_webhook.py +++ b/tests/sentry/integrations/gitlab/test_webhook.py @@ -15,11 +15,13 @@ WEBHOOK_TOKEN, GitLabTestCase, ) +from sentry.integrations.gitlab.webhooks import MergeEventWebhook from sentry.integrations.models.integration import Integration from sentry.models.commit import Commit from sentry.models.commitauthor import CommitAuthor from sentry.models.grouplink import GroupLink from sentry.models.pullrequest import PullRequest +from sentry.seer.code_review.webhooks.merge_request import handle_merge_request_event from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_failure_metric, assert_success_metric from sentry.testutils.silo import assume_test_silo_mode, assume_test_silo_mode_of @@ -342,6 +344,59 @@ def test_merge_event_no_last_commit(self, mock_record: MagicMock) -> None: assert_success_metric(mock_record) + @patch("sentry.integrations.gitlab.webhooks.metrics.incr") + def test_merge_event_no_author_email_does_not_error(self, mock_incr: MagicMock) -> None: + # A repo exists (so the processor runs), but the MR has no commit author + # email. The PR processor must stop cleanly rather than raising, which + # _handle would otherwise catch and mislabel as a processor error. + self.create_gitlab_repo("getsentry/sentry") + payload = orjson.loads(MERGE_REQUEST_OPENED_EVENT) + del payload["object_attributes"]["last_commit"] + + response = self.client.post( + self.url, + data=orjson.dumps(payload), + content_type="application/json", + HTTP_X_GITLAB_TOKEN=WEBHOOK_TOKEN, + HTTP_X_GITLAB_EVENT="Merge Request Hook", + ) + assert response.status_code == 204 + assert 0 == PullRequest.objects.count() + + error_metrics = [ + c + for c in mock_incr.call_args_list + if c.args and c.args[0] == "gitlab.webhook.processor.error" + ] + assert error_metrics == [] + + def test_merge_event_invokes_code_review_handler(self) -> None: + # The code-review handler is wired into the endpoint via the processor + # tuple. Confirm both that it is registered and that an inbound + # merge_request event is dispatched into it with the expected context. + assert handle_merge_request_event in MergeEventWebhook.WEBHOOK_EVENT_PROCESSORS + + self.create_gitlab_repo("getsentry/sentry") + + # wraps the real handler so it still runs while we record the invocation + spy = MagicMock(wraps=handle_merge_request_event) + with patch.object(MergeEventWebhook, "WEBHOOK_EVENT_PROCESSORS", (spy,)): + response = self.client.post( + self.url, + data=MERGE_REQUEST_OPENED_EVENT, + content_type="application/json", + HTTP_X_GITLAB_TOKEN=WEBHOOK_TOKEN, + HTTP_X_GITLAB_EVENT="Merge Request Hook", + ) + + assert response.status_code == 204 + spy.assert_called_once() + call_kwargs = spy.call_args.kwargs + assert call_kwargs["event"]["object_attributes"]["action"] == "open" + assert call_kwargs["integration"].id == self.integration.id + assert call_kwargs["organization"].id == self.organization.id + assert call_kwargs["repo"].name == "getsentry/sentry" + def test_merge_event_create_pull_request(self) -> None: self.create_gitlab_repo("getsentry/sentry") group = self.create_group(project=self.project, short_id=9) diff --git a/tests/sentry/seer/code_review/test_utils.py b/tests/sentry/seer/code_review/test_utils.py index c7c53af43fc7..34d9081abf1a 100644 --- a/tests/sentry/seer/code_review/test_utils.py +++ b/tests/sentry/seer/code_review/test_utils.py @@ -922,3 +922,34 @@ def test_provider_is_github_enterprise_for_ghe_integration(self) -> None: event_payload={"repository": {"private": False}}, ) assert result["provider"] == "github_enterprise" + + def test_gitlab_uses_config_path_not_display_name(self) -> None: + from sentry.seer.code_review.utils import _build_repo_definition + + repo = self._make_repo("integrations:gitlab") + # Repository.name is the display "name_with_namespace"; it must not be used. + repo.name = "Cool Group / Sentry" + repo.config = {"path": "cool-group/sentry"} + + result = _build_repo_definition( + repo=repo, + target_commit_sha="abc123", + event_payload={"project": {"visibility_level": 0}}, + ) + assert result["provider"] == "gitlab" + assert result["owner"] == "cool-group" + assert result["name"] == "sentry" + + def test_gitlab_missing_config_path_raises_rather_than_using_display_name(self) -> None: + from sentry.seer.code_review.utils import _build_repo_definition + + repo = self._make_repo("integrations:gitlab") + repo.name = "Cool Group / Sentry" + repo.config = {} + + with pytest.raises(ValueError): + _build_repo_definition( + repo=repo, + target_commit_sha="abc123", + event_payload={"project": {}}, + ) diff --git a/tests/sentry/seer/code_review/webhooks/test_merge_request.py b/tests/sentry/seer/code_review/webhooks/test_merge_request.py new file mode 100644 index 000000000000..26ebdfd1f6c8 --- /dev/null +++ b/tests/sentry/seer/code_review/webhooks/test_merge_request.py @@ -0,0 +1,778 @@ +from collections.abc import Generator +from typing import Any +from unittest.mock import patch + +import orjson +import pytest + +from fixtures.gitlab import MERGE_REQUEST_OPENED_EVENT, GitLabTestCase +from sentry.models.organization import Organization +from sentry.models.organizationcontributors import OrganizationContributors +from sentry.models.repositorysettings import CodeReviewTrigger +from sentry.organizations.services.organization.model import RpcOrganization +from sentry.seer.code_review.webhooks.merge_request import ( + WEBHOOK_SEEN_KEY_PREFIX, + handle_merge_request_event, +) +from sentry.testutils.helpers.features import with_feature +from sentry.utils.redis import redis_clusters + + +def _make_event(action: str = "open", **overrides: object) -> dict[str, Any]: + event = orjson.loads(MERGE_REQUEST_OPENED_EVENT) + event["object_attributes"]["action"] = action + # GitLab sends "changes" as a top-level payload field; everything else here + # (oldrev, draft, work_in_progress, last_commit, ...) lives in object_attributes. + if "changes" in overrides: + event["changes"] = overrides.pop("changes") + for key, value in overrides.items(): + event["object_attributes"][key] = value + return event + + +def _rpc_org(org: Organization) -> RpcOrganization: + return RpcOrganization( + id=org.id, + slug=org.slug, + name=org.name, + ) + + +class MergeRequestEventWebhookTest(GitLabTestCase): + CODE_REVIEW_FEATURES = { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + + @pytest.fixture(autouse=True) + def mock_seer_request(self) -> Generator[None]: + with patch("sentry.seer.code_review.webhooks.task.make_seer_request") as mock_seer: + self.mock_seer = mock_seer + yield + + def _setup_code_review( + self, + triggers: list[CodeReviewTrigger] | None = None, + name: str = "Cool Group / Sentry", + path: str = "cool-group/sentry", + ) -> None: + if triggers is None: + triggers = [ + CodeReviewTrigger.ON_NEW_COMMIT, + CodeReviewTrigger.ON_READY_FOR_REVIEW, + ] + + # GitLab stores Repository.name as the display "name_with_namespace"; the + # URL slug used to address the project lives in config["path"]. + repo = self.create_gitlab_repo(name=name) + repo.config["path"] = path + repo.save() + + trigger_values = [t.value for t in triggers] + self.create_repository_settings( + repository=repo, + enabled_code_review=True, + code_review_triggers=trigger_values, + ) + + OrganizationContributors.objects.get_or_create( + organization_id=self.organization.id, + integration_id=self.integration.id, + external_identifier="51", + defaults={"alias": "root"}, + ) + + self.repo = repo + + def _call_handler(self, event: dict[str, Any]) -> None: + handle_merge_request_event( + event=event, + organization=_rpc_org(self.organization), + repo=self.repo, + integration=self.integration, + ) + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_open_uses_review_request_endpoint(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + assert call_kwargs["path"] == "/v1/scm_code_review/review-request" + + @with_feature({"organizations:gen-ai-features", "organizations:code-review-beta"}) + def test_skips_when_gitlab_flag_disabled(self) -> None: + # The GitLab MR handler is gated on organizations:seer-code-review-gitlab, + # independent of the other code-review flags. + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_close_uses_pr_closed_endpoint(self) -> None: + self._setup_code_review() + event = _make_event("close") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + assert call_kwargs["path"] == "/v1/scm_code_review/pr-closed" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_merge_uses_pr_closed_endpoint(self) -> None: + self._setup_code_review() + event = _make_event("merge") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + assert call_kwargs["path"] == "/v1/scm_code_review/pr-closed" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_update_uses_review_request_endpoint(self) -> None: + self._setup_code_review() + event = _make_event("update", oldrev="0" * 40) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + assert call_kwargs["path"] == "/v1/scm_code_review/review-request" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_update_without_oldrev_is_skipped(self) -> None: + self._setup_code_review() + event = _make_event("update") + assert "oldrev" not in event["object_attributes"] + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_update_with_unrelated_changes_is_skipped(self) -> None: + # An "update" that only edits metadata (no new commit, no un-draft) must not + # trigger a review. + self._setup_code_review() + event = _make_event("update", changes={"title": {"previous": "a", "current": "b"}}) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_undraft_update_uses_review_request_endpoint(self) -> None: + # GitLab has no "ready_for_review" action; un-drafting arrives as an "update" + # whose changes flip draft -> false, and must be treated as ready-for-review. + self._setup_code_review() + event = _make_event("update", changes={"draft": {"previous": True, "current": False}}) + assert "oldrev" not in event["object_attributes"] + # GitLab delivers "changes" at the top level, not under object_attributes. + assert "changes" in event and "changes" not in event["object_attributes"] + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + assert self.mock_seer.call_args[1]["path"] == "/v1/scm_code_review/review-request" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_undraft_update_via_work_in_progress_uses_review_request_endpoint(self) -> None: + self._setup_code_review() + event = _make_event( + "update", changes={"work_in_progress": {"previous": True, "current": False}} + ) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + assert self.mock_seer.call_args[1]["path"] == "/v1/scm_code_review/review-request" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_undraft_update_trigger_is_ready_for_review(self) -> None: + self._setup_code_review() + event = _make_event("update", changes={"draft": {"previous": True, "current": False}}) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + payload = self.mock_seer.call_args[1]["payload"] + assert payload["data"]["config"]["trigger"] == "on_ready_for_review" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_undraft_update_filtered_when_ready_trigger_disabled(self) -> None: + # An un-draft maps to ON_READY_FOR_REVIEW, so a repo that only enabled + # ON_NEW_COMMIT must not get a review for it. + self._setup_code_review(triggers=[CodeReviewTrigger.ON_NEW_COMMIT]) + event = _make_event("update", changes={"draft": {"previous": True, "current": False}}) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_draft_mr(self) -> None: + self._setup_code_review() + event = _make_event("open", draft=True) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_work_in_progress_mr(self) -> None: + self._setup_code_review() + event = _make_event("open", work_in_progress=True) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_close_still_sends_for_draft_mr(self) -> None: + self._setup_code_review() + event = _make_event("close", draft=True) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_unsupported_action(self) -> None: + self._setup_code_review() + event = _make_event("approved") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_unknown_action(self) -> None: + self._setup_code_review() + event = _make_event("future_action_not_in_enum") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_missing_action(self) -> None: + self._setup_code_review() + event = _make_event("open") + del event["object_attributes"]["action"] + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_when_integration_is_none(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + handle_merge_request_event( + event=event, + organization=_rpc_org(self.organization), + repo=self.repo, + integration=None, + ) + + self.mock_seer.assert_not_called() + + def test_skips_when_code_review_not_enabled(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_skips_missing_last_commit(self) -> None: + self._setup_code_review() + event = _make_event("open") + del event["object_attributes"]["last_commit"] + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_open_filtered_when_trigger_disabled(self) -> None: + self._setup_code_review(triggers=[CodeReviewTrigger.ON_NEW_COMMIT]) + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_update_filtered_when_trigger_disabled(self) -> None: + self._setup_code_review(triggers=[CodeReviewTrigger.ON_READY_FOR_REVIEW]) + event = _make_event("update", oldrev="0" * 40) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_close_filtered_when_no_triggers_configured(self) -> None: + self._setup_code_review(triggers=[]) + event = _make_event("close") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_not_called() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_close_sends_when_triggers_configured(self) -> None: + self._setup_code_review(triggers=[CodeReviewTrigger.ON_READY_FOR_REVIEW]) + event = _make_event("close") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_contains_correct_pr_id(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + payload = call_kwargs["payload"] + assert payload["data"]["pr_id"] == 1 + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_contains_gitlab_provider(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + payload = call_kwargs["payload"] + assert payload["data"]["repo"]["provider"] == "gitlab" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_owner_and_name_use_path_not_display_name(self) -> None: + # Repository.name is the display "Cool Group / Sentry"; Seer must receive + # the URL slugs derived from config["path"] ("cool-group/sentry"). + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + repo = self.mock_seer.call_args[1]["payload"]["data"]["repo"] + assert repo["owner"] == "cool-group" + assert repo["name"] == "sentry" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_owner_and_name_handle_subgroups(self) -> None: + self._setup_code_review(name="Group / Subgroup / Project", path="group/subgroup/project") + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + repo = self.mock_seer.call_args[1]["payload"]["data"]["repo"] + assert repo["owner"] == "group" + assert repo["name"] == "subgroup/project" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_is_private_true_for_private_project(self) -> None: + self._setup_code_review() + event = _make_event("open") + event["project"]["visibility_level"] = 0 + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + repo = self.mock_seer.call_args[1]["payload"]["data"]["repo"] + assert repo["is_private"] is True + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_is_private_true_for_internal_project(self) -> None: + self._setup_code_review() + event = _make_event("open") + event["project"]["visibility_level"] = 10 + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + repo = self.mock_seer.call_args[1]["payload"]["data"]["repo"] + assert repo["is_private"] is True + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_is_private_false_for_public_project(self) -> None: + self._setup_code_review() + event = _make_event("open") + event["project"]["visibility_level"] = 20 + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + repo = self.mock_seer.call_args[1]["payload"]["data"]["repo"] + assert repo["is_private"] is False + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_is_private_none_when_visibility_absent(self) -> None: + self._setup_code_review() + event = _make_event("open") + del event["project"]["visibility_level"] + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + repo = self.mock_seer.call_args[1]["payload"]["data"]["repo"] + assert repo["is_private"] is None + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_trigger_on_ready_for_review_for_open(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + payload = call_kwargs["payload"] + assert payload["data"]["config"]["trigger"] == "on_ready_for_review" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_trigger_on_new_commit_for_update(self) -> None: + self._setup_code_review() + event = _make_event("update", oldrev="0" * 40) + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + payload = call_kwargs["payload"] + assert payload["data"]["config"]["trigger"] == "on_new_commit" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_payload_contains_trigger_user_from_event(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + + self.mock_seer.assert_called_once() + call_kwargs = self.mock_seer.call_args[1] + payload = call_kwargs["payload"] + assert payload["data"]["config"]["trigger_user"] == "root" + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_duplicate_delivery_within_window_skipped(self) -> None: + self._setup_code_review() + event = _make_event("open") + + with self.tasks(): + self._call_handler(event) + self._call_handler(event) + + self.mock_seer.assert_called_once() + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_duplicate_delivery_after_ttl_processes_again(self) -> None: + self._setup_code_review() + event = _make_event("open") + commit_sha = event["object_attributes"]["last_commit"]["id"] + iid = event["object_attributes"]["iid"] + + with self.tasks(): + self._call_handler(event) + assert self.mock_seer.call_count == 1 + + # Simulate TTL expiry so the same delivery can be processed again. + seen_key = ( + f"{WEBHOOK_SEEN_KEY_PREFIX}{self.organization.id}:{self.repo.id}:" + f"{iid}:open:{commit_sha}" + ) + redis_clusters.get("default").delete(seen_key) + + with self.tasks(): + self._call_handler(event) + assert self.mock_seer.call_count == 2 + + @with_feature( + { + "organizations:gen-ai-features", + "organizations:code-review-beta", + "organizations:seer-code-review-gitlab", + } + ) + def test_distinct_commits_are_not_deduped(self) -> None: + # Two new-commit pushes have different last_commit ids, so they are distinct + # operations and both must reach Seer despite sharing the same MR and action. + self._setup_code_review() + first = _make_event("update", oldrev="0" * 40) + second = _make_event("update", oldrev="0" * 40) + second["object_attributes"]["last_commit"] = { + **second["object_attributes"]["last_commit"], + "id": "f" * 40, + } + + with self.tasks(): + self._call_handler(first) + self._call_handler(second) + + assert self.mock_seer.call_count == 2 From fa46defa3bfdbd2e6b97c793c4e7c74bd6d4bb32 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Fri, 29 May 2026 15:21:33 -0400 Subject: [PATCH 27/42] feat(api-docs): publish project debug files list endpoint (#116444) Update the dsyms endpoint to use the modern DRF-spec system. --- api-docs/openapi.json | 3 - api-docs/paths/projects/dsyms.json | 165 ------------------ src/sentry/api/endpoints/debug_files.py | 76 ++++++-- .../apidocs/endpoints/projects/test_dsyms.py | 31 ---- 4 files changed, 63 insertions(+), 212 deletions(-) delete mode 100644 api-docs/paths/projects/dsyms.json diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 7fa535924882..c1eeb7126c0a 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -97,9 +97,6 @@ "/api/0/organizations/{organization_id_or_slug}/repos/": { "$ref": "paths/organizations/repos.json" }, - "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/files/dsyms/": { - "$ref": "paths/projects/dsyms.json" - }, "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/users/": { "$ref": "paths/projects/users.json" }, diff --git a/api-docs/paths/projects/dsyms.json b/api-docs/paths/projects/dsyms.json deleted file mode 100644 index 8d3bd158b564..000000000000 --- a/api-docs/paths/projects/dsyms.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "get": { - "tags": ["Projects"], - "description": "Retrieve a list of debug information files for a given project.", - "operationId": "List a Project's Debug Information Files", - "parameters": [ - { - "name": "organization_id_or_slug", - "in": "path", - "description": "The ID or slug of the organization the file belongs to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "project_id_or_slug", - "in": "path", - "description": "The ID or slug of the project to list the DIFs of.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": {} - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "The requested resource does not exist" - } - }, - "security": [ - { - "auth_token": ["project:read"] - } - ] - }, - "post": { - "tags": ["Projects"], - "description": "Upload a new debug information file for the given release.\n\nUnlike other API requests, files must be uploaded using the\ntraditional multipart/form-data content-type.\n\nRequests to this endpoint should use the region-specific domain eg. `us.sentry.io` or `de.sentry.io`.\n\nThe file uploaded is a zip archive of an Apple .dSYM folder which\ncontains the individual debug images. Uploading through this endpoint\nwill create different files for the contained images.", - "operationId": "Upload a New File", - "parameters": [ - { - "name": "organization_id_or_slug", - "in": "path", - "description": "The ID or slug of the organization the project belongs to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "project_id_or_slug", - "in": "path", - "description": "The ID or slug of the project to upload a file to.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "required": ["file"], - "type": "object", - "properties": { - "file": { - "type": "string", - "format": "binary", - "description": "The multipart encoded file." - } - } - }, - "example": { - "file": "debug.zip" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "Success", - "content": { - "application/json": {} - } - }, - "400": { - "description": "Bad Input" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "The requested resource does not exist" - } - }, - "security": [ - { - "auth_token": ["project:write"] - } - ], - "servers": [{ "url": "https://{region}.sentry.io" }] - }, - "delete": { - "tags": ["Projects"], - "description": "Delete a debug information file for a given project.", - "operationId": "Delete a Specific Project's Debug Information File", - "parameters": [ - { - "name": "organization_id_or_slug", - "in": "path", - "description": "The ID or slug of the organization the file belongs to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "project_id_or_slug", - "in": "path", - "description": "The ID or slug of the project to delete the DIF.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "query", - "description": "The ID of the DIF to delete.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Success" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "The requested resource does not exist" - } - }, - "security": [ - { - "auth_token": ["project:admin", "project:releases"] - } - ] - } -} diff --git a/src/sentry/api/endpoints/debug_files.py b/src/sentry/api/endpoints/debug_files.py index bc923eac46bd..a452e6fddbfa 100644 --- a/src/sentry/api/endpoints/debug_files.py +++ b/src/sentry/api/endpoints/debug_files.py @@ -10,6 +10,7 @@ from django.db import IntegrityError, router from django.db.models import Case, Exists, F, IntegerField, Q, QuerySet, Value, When from django.http import Http404, HttpResponse, StreamingHttpResponse +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -27,7 +28,12 @@ from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import serialize +from sentry.api.serializers.models.debug_file import DebugFileSerializerResponse from sentry.api.utils import to_valid_int_id +from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.examples.dsym_examples import DebugFileExamples +from sentry.apidocs.parameters import CursorQueryParam, GlobalParams +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.access import Access from sentry.auth.superuser import is_active_superuser from sentry.auth.system import is_system_auth @@ -198,12 +204,47 @@ def get(self, request: Request, project: Project) -> Response: return Response({"releases": releases}) +DSYM_QUERY_PARAM = OpenApiParameter( + name="query", + location="query", + required=False, + type=str, + description="Substring filter matched against object name, debug ID, code ID, CPU name, and file headers.", +) + +DSYM_DEBUG_ID_PARAM = OpenApiParameter( + name="debug_id", + location="query", + required=False, + type=str, + description="Filter results to debug information files matching the given debug ID.", +) + +DSYM_CODE_ID_PARAM = OpenApiParameter( + name="code_id", + location="query", + required=False, + type=str, + description="Filter results to debug information files matching the given code ID.", +) + +DSYM_FILE_FORMATS_PARAM = OpenApiParameter( + name="file_formats", + location="query", + required=False, + many=True, + type=str, + description="Restrict results to one or more file formats.", +) + + +@extend_schema(tags=["Projects"]) @cell_silo_endpoint class DebugFilesEndpoint(ProjectEndpoint): owner = ApiOwner.OWNERS_INGEST publish_status = { "DELETE": ApiPublishStatus.PRIVATE, - "GET": ApiPublishStatus.PRIVATE, + "GET": ApiPublishStatus.PUBLIC, "POST": ApiPublishStatus.PRIVATE, } permission_classes = (ProjectReleasePermission,) @@ -241,21 +282,30 @@ def download(self, debug_file_id, project: Project): except OSError: raise Http404 + @extend_schema( + operation_id="List a Project's Debug Information Files", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + GlobalParams.PROJECT_ID_OR_SLUG, + DSYM_QUERY_PARAM, + DSYM_DEBUG_ID_PARAM, + DSYM_CODE_ID_PARAM, + DSYM_FILE_FORMATS_PARAM, + CursorQueryParam, + ], + responses={ + 200: inline_sentry_response_serializer( + "ListProjectDebugFilesResponse", list[DebugFileSerializerResponse] + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=DebugFileExamples.LIST_PROJECT_DEBUG_FILES, + ) def get(self, request: Request, project: Project) -> Response: """ - List a Project's Debug Information Files - ```````````````````````````````````````` - Retrieve a list of debug information files for a given project. - - :pparam string organization_id_or_slug: the id or slug of the organization the - file belongs to. - :pparam string project_id_or_slug: the id or slug of the project to list the - DIFs of. - :qparam string query: If set, this parameter is used to locate DIFs with. - :qparam string id: If set, the specified DIF will be sent in the response. - :qparam string file_formats: If set, only DIFs with these formats will be returned. - :auth: required """ download_requested = request.GET.get("id") is not None if download_requested and has_download_permission(request, project): diff --git a/tests/apidocs/endpoints/projects/test_dsyms.py b/tests/apidocs/endpoints/projects/test_dsyms.py index 1a47eab57b72..524d2ba1a56c 100644 --- a/tests/apidocs/endpoints/projects/test_dsyms.py +++ b/tests/apidocs/endpoints/projects/test_dsyms.py @@ -1,7 +1,3 @@ -import zipfile -from io import BytesIO - -from django.core.files.uploadedfile import SimpleUploadedFile from django.test.client import RequestFactory from django.urls import reverse @@ -26,30 +22,3 @@ def test_get(self) -> None: request = RequestFactory().get(self.url) self.validate_schema(request, response) - - def test_post(self) -> None: - PROGUARD_UUID = "6dc7fdb0-d2fb-4c8e-9d6b-bb1aa98929b1" - PROGUARD_SOURCE = b"""\ - org.slf4j.helpers.Util$ClassContextSecurityManager -> org.a.b.g$a: - 65:65:void () -> - 67:67:java.lang.Class[] getClassContext() -> getClassContext - 65:65:void (org.slf4j.helpers.Util$1) -> - """ - out = BytesIO() - f = zipfile.ZipFile(out, "w") - f.writestr("proguard/%s.txt" % PROGUARD_UUID, PROGUARD_SOURCE) - f.close() - data = { - "file": SimpleUploadedFile( - "symbols.zip", out.getvalue(), content_type="application/zip" - ), - } - - response = self.client.post( - self.url, - data, - format="multipart", - ) - request = RequestFactory().post(self.url, data, SERVER_NAME="de.sentry.io", secure=True) - - self.validate_schema(request, response) From be5e81ee108fb1cf0edbdcd561d8e8be48181419 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Fri, 29 May 2026 14:37:04 -0500 Subject: [PATCH 28/42] deps(scm): Upgrade sentry-scm to 0.20.0 (#116499) --- pyproject.toml | 2 +- uv.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 12615a4ca449..babc20189d11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,7 +99,7 @@ dependencies = [ "sentry-protos>=0.17.0", "sentry-redis-tools>=0.5.0", "sentry-relay>=0.9.27", - "sentry-scm==0.16.0", + "sentry-scm==0.20.0", "sentry-sdk[http2]>=2.59.0", "sentry-usage-accountant>=0.0.10", # remove once there are no unmarked transitive dependencies on setuptools diff --git a/uv.lock b/uv.lock index 20698dafcdad..0a27e6e19a11 100644 --- a/uv.lock +++ b/uv.lock @@ -2430,7 +2430,7 @@ requires-dist = [ { name = "sentry-protos", specifier = ">=0.17.0" }, { name = "sentry-redis-tools", specifier = ">=0.5.0" }, { name = "sentry-relay", specifier = ">=0.9.27" }, - { name = "sentry-scm", specifier = "==0.16.0" }, + { name = "sentry-scm", specifier = "==0.20.0" }, { name = "sentry-sdk", extras = ["http2"], specifier = ">=2.59.0" }, { name = "sentry-usage-accountant", specifier = ">=0.0.10" }, { name = "setuptools", specifier = ">=70.0.0" }, @@ -2648,14 +2648,14 @@ wheels = [ [[package]] name = "sentry-scm" -version = "0.16.0" +version = "0.20.0" source = { registry = "https://pypi.devinfra.sentry.io/simple" } dependencies = [ { name = "msgspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] wheels = [ - { url = "https://pypi.devinfra.sentry.io/wheels/sentry_scm-0.16.0-py3-none-any.whl", hash = "sha256:506e544b320e155c5808aca588ed2579cf66fb08d7fb0148893dbd328cdb6fe4" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_scm-0.20.0-py3-none-any.whl", hash = "sha256:9e26a9bbf62730974e3748a27462fe814ac6cfe9fe8286be9b881475be7aa72d" }, ] [[package]] From 29fc97b1abc823aeb1e62961ac43690fb7ba0f82 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki <44422760+DominikB2014@users.noreply.github.com> Date: Fri, 29 May 2026 15:39:48 -0400 Subject: [PATCH 29/42] feat(dashboards): Track dashboard generation validation attempts (#116502) --- src/sentry/dashboards/on_completion_hook.py | 80 ++++++++++++++++----- 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/src/sentry/dashboards/on_completion_hook.py b/src/sentry/dashboards/on_completion_hook.py index 44841881bd73..3343b2bc1d08 100644 --- a/src/sentry/dashboards/on_completion_hook.py +++ b/src/sentry/dashboards/on_completion_hook.py @@ -4,6 +4,7 @@ from types import SimpleNamespace from typing import Any +import sentry_sdk from pydantic import ValidationError from sentry.api.serializers.rest_framework import DashboardSerializer @@ -87,6 +88,8 @@ def execute(cls, organization: Organization, run_id: int) -> None: if state.status != "completed": return + retry_count = cls._count_retries(state) + try: artifact = state.get_artifact("dashboard", GeneratedDashboard) except ValidationError as validation_error: @@ -98,8 +101,23 @@ def execute(cls, organization: Organization, run_id: int) -> None: }, ) - if cls._within_retry_budget(state, organization, run_id): + if retry_count < MAX_VALIDATION_RETRIES: cls._request_fix(organization, run_id, str(validation_error)) + else: + logger.info( + "dashboards.on_completion_hook.max_retries_reached", + extra={ + "organization_id": organization.id, + "run_id": run_id, + "retry_count": retry_count, + }, + ) + cls._emit_generation_attempts_metric( + status="fail", + result="max_retries", + retry_count=retry_count, + last_layer="pydantic", + ) return if artifact is None: @@ -107,6 +125,11 @@ def execute(cls, organization: Organization, run_id: int) -> None: "dashboards.on_completion_hook.no_artifact", extra={"run_id": run_id, "organization_id": organization.id}, ) + cls._emit_generation_attempts_metric( + status="fail", + result="no_artifact", + retry_count=retry_count, + ) return serializer_errors = _validate_with_serializer(artifact, organization) @@ -120,17 +143,37 @@ def execute(cls, organization: Organization, run_id: int) -> None: }, ) - if cls._within_retry_budget(state, organization, run_id): + if retry_count < MAX_VALIDATION_RETRIES: cls._request_fix(organization, run_id, str(serializer_errors)) + else: + logger.info( + "dashboards.on_completion_hook.max_retries_reached", + extra={ + "organization_id": organization.id, + "run_id": run_id, + "retry_count": retry_count, + }, + ) + cls._emit_generation_attempts_metric( + status="fail", + result="max_retries", + retry_count=retry_count, + last_layer="serializer", + ) return logger.info( "dashboards.on_completion_hook.validation_passed", extra={"run_id": run_id, "organization_id": organization.id}, ) + cls._emit_generation_attempts_metric( + status="pass", + result="pass", + retry_count=retry_count, + ) - @classmethod - def _within_retry_budget(cls, state: Any, organization: Organization, run_id: int) -> bool: + @staticmethod + def _count_retries(state: Any) -> int: """ Count consecutive fix requests in the current failure chain by scanning blocks in reverse. A non-fix user message (i.e. the user @@ -147,18 +190,23 @@ def _within_retry_budget(cls, state: Any, organization: Organization, run_id: in retry_count += 1 elif block.message.role == "user": break - - if retry_count >= MAX_VALIDATION_RETRIES: - logger.info( - "dashboards.on_completion_hook.max_retries_reached", - extra={ - "organization_id": organization.id, - "run_id": run_id, - "retry_count": retry_count, - }, - ) - return False - return True + return retry_count + + @staticmethod + def _emit_generation_attempts_metric( + status: str, + result: str, + retry_count: int, + last_layer: str | None = None, + ) -> None: + attributes = {"status": status, "result": result} + if last_layer is not None: + attributes["last_layer"] = last_layer + sentry_sdk.metrics.distribution( + "dashboards.on_completion_hook.generation_attempts", + retry_count + 1, + attributes=attributes, + ) @classmethod def _request_fix(cls, organization: Organization, run_id: int, error: str) -> None: From 5ea7cc0fe97e107c2faeb8d10ef05cd23e4d9f10 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 29 May 2026 12:59:10 -0700 Subject: [PATCH 30/42] chore(issues): Add fallback event components codeowner (#116505) for components that are not specifically owned by a team further down in the codeowners, fallback to the issue-workflow team. --- .github/CODEOWNERS | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a86c9a1af6ce..990895d92664 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -214,6 +214,9 @@ pnpm-lock.yaml @getsentry/owners-js-de /src/sentry/tasks/post_process.py @getsentry/issue-detection-backend ## Issue Detection Lower Priority +## Event components fallback so more specific rules can take precedence +/static/app/components/events/ @getsentry/issue-workflow + ## Hybrid Cloud /src/sentry/silo/ @getsentry/hybrid-cloud /src/sentry/hybridcloud/ @getsentry/hybrid-cloud @@ -347,7 +350,6 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/views/performance/ @getsentry/data-browsing /static/app/components/performance/ @getsentry/data-browsing /static/app/utils/performance/ @getsentry/data-browsing -/static/app/components/events/groupingInfo @getsentry/data-browsing /static/app/components/events/interfaces/spans/ @getsentry/data-browsing /static/app/components/events/viewHierarchy/* @getsentry/data-browsing /static/app/components/searchQueryBuilder/ @getsentry/data-browsing @@ -439,7 +441,6 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/components/analyticsArea.spec.tsx @getsentry/app-frontend /static/app/components/analyticsArea.tsx @getsentry/app-frontend /static/app/components/loading/ @getsentry/app-frontend -/static/app/components/events/interfaces/ @getsentry/app-frontend /static/app/components/forms/ @getsentry/app-frontend /static/app/components/featureShowcase.mdx @getsentry/app-frontend /static/app/components/featureShowcase.spec.tsx @getsentry/app-frontend @@ -691,9 +692,6 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /src/sentry/tasks/unmerge.py @getsentry/issue-detection-backend /src/sentry/tasks/weekly_escalating_forecast.py @getsentry/issue-detection-backend /src/sentry/tasks/llm_issue_detection/ @getsentry/issue-detection-backend -/static/app/components/events/contexts/ @getsentry/issue-workflow -/static/app/components/events/eventTags/ @getsentry/issue-workflow -/static/app/components/events/highlights/ @getsentry/issue-workflow /static/app/components/issues/ @getsentry/issue-workflow /static/app/components/stackTrace/ @getsentry/issue-workflow /static/app/components/stream/supergroups/ @getsentry/issue-detection-frontend @@ -704,8 +702,6 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/views/issueDetails/ @getsentry/issue-workflow /static/app/views/nav/secondary/sections/issues/ @getsentry/issue-workflow /static/app/views/sharedGroupDetails/ @getsentry/issue-workflow -/static/app/components/events/interfaces/performance/spanEvidenceKeyValueList.tsx @getsentry/issue-detection-frontend -/static/app/components/events/interfaces/crashContent/exception/actionableItems.tsx @getsentry/issue-workflow /tests/sentry/deletions/test_group.py @getsentry/issue-detection-backend /tests/sentry/event_manager/ @getsentry/issue-detection-backend /tests/sentry/grouping/ @getsentry/issue-detection-backend From 201dd5adae647dbbbee9970cfbf472f7b6ee60b3 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Fri, 29 May 2026 16:09:16 -0400 Subject: [PATCH 31/42] feat(msteams): Wire Teams Marketplace installs through the API pipeline modal (#116488) [VDY-101: Microsoft Teams: API-driven integration setup](https://linear.app/getsentry/issue/VDY-101/microsoft-teams-api-driven-integration-setup) When a user installs the Sentry app from the Microsoft Teams Marketplace, the Sentry bot posts a card linking to `/extensions/msteams/configure/` with a signed `signed_params` blob. That forwards to `/extensions/msteams/link/` (the org picker), and from there the install needs to drive the API pipeline modal. This wires up the frontend side: - Adds `msTeamsParams` in the org-link view, returning `{signedParams}` when `signed_params` is present in the URL query, and routes `handleInstallClick` through it via the existing `gitHubAppListingParams ?? discordAppDirectoryParams ?? msTeamsParams` chain. - Adds the `integrationMsTeams` pipeline definition. Its single step has no interactive UI; all install data arrives already bound to pipeline state, so it auto-advances when the backend returns `appDirectoryInstall`, rendering a brief "Finishing up..." message (guarded by a ref against React strict-mode double-fire). - Registers the pipeline and adds `msteams` to `UNCONDITIONAL_API_PIPELINE_PROVIDERS`. Depends on the backend support (separate PR) and must not deploy before it: the modal calls the API pipeline `initialize` endpoint for `msteams`, which only works once the backend serializer / step / `can_add_externally` changes are live. The `/extensions/msteams/configure/` URL still routes through the legacy server-rendered view until a follow-up swaps it to a redirect. --- .../integrationMsTeams/index.spec.tsx | 36 +++++++++++++ .../pipeline/integrationMsTeams/index.tsx | 52 +++++++++++++++++++ static/app/components/pipeline/registry.tsx | 2 + .../utils/integrations/useAddIntegration.tsx | 1 + .../integrationOrganizationLink/index.tsx | 27 ++++++++-- 5 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 static/app/components/pipeline/integrationMsTeams/index.spec.tsx create mode 100644 static/app/components/pipeline/integrationMsTeams/index.tsx diff --git a/static/app/components/pipeline/integrationMsTeams/index.spec.tsx b/static/app/components/pipeline/integrationMsTeams/index.spec.tsx new file mode 100644 index 000000000000..6802911298be --- /dev/null +++ b/static/app/components/pipeline/integrationMsTeams/index.spec.tsx @@ -0,0 +1,36 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {createMakeStepProps} from 'sentry/components/pipeline/testUtils'; + +import {msTeamsIntegrationPipeline} from '.'; + +const MsTeamsInstallStep = msTeamsIntegrationPipeline.steps[0].component; + +const makeStepProps = createMakeStepProps({totalSteps: 1}); + +describe('MsTeamsInstallStep', () => { + it('auto-advances with the pipeline state and shows a finishing message', () => { + const advance = jest.fn(); + render( + + ); + + expect(advance).toHaveBeenCalledWith({state: 'pipeline-sig'}); + expect(advance).toHaveBeenCalledTimes(1); + expect( + screen.getByText('Finishing up Microsoft Teams integration installation...') + ).toBeInTheDocument(); + }); + + it('does not advance until stepData is available', () => { + const advance = jest.fn(); + render(); + + expect(advance).not.toHaveBeenCalled(); + }); +}); diff --git a/static/app/components/pipeline/integrationMsTeams/index.tsx b/static/app/components/pipeline/integrationMsTeams/index.tsx new file mode 100644 index 000000000000..c1d19427e101 --- /dev/null +++ b/static/app/components/pipeline/integrationMsTeams/index.tsx @@ -0,0 +1,52 @@ +import {useEffect, useRef} from 'react'; + +import {Text} from '@sentry/scraps/text'; + +import type { + PipelineDefinition, + PipelineStepProps, +} from 'sentry/components/pipeline/types'; +import {pipelineComplete} from 'sentry/components/pipeline/types'; +import {t} from 'sentry/locale'; +import type {IntegrationWithConfig} from 'sentry/types/integrations'; + +type MsTeamsStepData = { + appDirectoryInstall: true; + state: string; +}; + +function MsTeamsInstallStep({ + stepData, + advance, +}: PipelineStepProps) { + // MS Teams installs are initiated from the Teams Marketplace, so by the time + // the pipeline modal opens all the install data is already bound to pipeline + // state. The backend signals this with `appDirectoryInstall` and we advance + // immediately with no user interaction. The ref guards against React strict + // mode double-firing the effect. + const hasAutoAdvanced = useRef(false); + useEffect(() => { + if (!stepData?.appDirectoryInstall || hasAutoAdvanced.current) { + return; + } + hasAutoAdvanced.current = true; + advance({state: stepData.state}); + }, [stepData, advance]); + + return {t('Finishing up Microsoft Teams integration installation...')}; +} + +export const msTeamsIntegrationPipeline = { + type: 'integration', + provider: 'msteams', + actionTitle: t('Installing Microsoft Teams Integration'), + getCompletionData: pipelineComplete, + completionView: null, + steps: [ + { + stepId: 'msteams_install', + shortDescription: t('Finishing installation'), + component: MsTeamsInstallStep, + }, + ], +} as const satisfies PipelineDefinition; diff --git a/static/app/components/pipeline/registry.tsx b/static/app/components/pipeline/registry.tsx index f6f1691abacd..ed074af1384c 100644 --- a/static/app/components/pipeline/registry.tsx +++ b/static/app/components/pipeline/registry.tsx @@ -8,6 +8,7 @@ import {discordIntegrationPipeline} from './integrationDiscord'; import {githubIntegrationPipeline} from './integrationGitHub'; import {githubEnterpriseIntegrationPipeline} from './integrationGitHubEnterprise'; import {gitlabIntegrationPipeline} from './integrationGitLab'; +import {msTeamsIntegrationPipeline} from './integrationMsTeams'; import {opsgenieIntegrationPipeline} from './integrationOpsgenie'; import {pagerDutyIntegrationPipeline} from './integrationPagerDuty'; import {perforceIntegrationPipeline} from './integrationPerforce'; @@ -32,6 +33,7 @@ export const PIPELINE_REGISTRY = [ githubIntegrationPipeline, githubEnterpriseIntegrationPipeline, gitlabIntegrationPipeline, + msTeamsIntegrationPipeline, opsgenieIntegrationPipeline, pagerDutyIntegrationPipeline, perforceIntegrationPipeline, diff --git a/static/app/utils/integrations/useAddIntegration.tsx b/static/app/utils/integrations/useAddIntegration.tsx index 2f1c207575d6..a7477841eaf1 100644 --- a/static/app/utils/integrations/useAddIntegration.tsx +++ b/static/app/utils/integrations/useAddIntegration.tsx @@ -53,6 +53,7 @@ const UNCONDITIONAL_API_PIPELINE_PROVIDERS = [ 'github', 'github_enterprise', 'gitlab', + 'msteams', 'opsgenie', 'pagerduty', 'perforce', diff --git a/static/app/views/integrationOrganizationLink/index.tsx b/static/app/views/integrationOrganizationLink/index.tsx index c59d42a41388..4b0991ab2ef4 100644 --- a/static/app/views/integrationOrganizationLink/index.tsx +++ b/static/app/views/integrationOrganizationLink/index.tsx @@ -77,15 +77,20 @@ function trackExternalAnalytics({ * Provider-initiated entry points handled here: * * - GitHub - * `/extensions/github/link/?installationId=…` (redirected from + * `/extensions/github/link/?installationId=...` (redirected from * `/extensions/external-install/github/:installationId`). Drives the * pipeline with `gitHubAppListingParams`. * * - Discord - * `/extensions/discord/link/?code=…&guild_id=…` (redirected from + * `/extensions/discord/link/?code=...&guild_id=...` (redirected from * `/extensions/discord/configure/`). Drives the pipeline with * `discordAppDirectoryParams`. * + * - Microsoft Teams + * `/extensions/msteams/link/?signed_params=...` (redirected from + * `/extensions/msteams/configure/`). Drives the pipeline with + * `msTeamsParams`. + * * - Anything else * falls through to {@link finishLegacyInstallation}, which bounces to the * legacy `/extensions//configure/` backend endpoint. @@ -218,6 +223,20 @@ export default function IntegrationOrganizationLink() { return {code, guild_id: guildId, use_configure: '1'}; }, [integrationSlug, location.query]); + // Microsoft Teams installs arrive here with `signed_params` in the URL query + // (forwarded from `/extensions/msteams/configure/`). The install button uses + // it as `initialData` for the pipeline modal. + const msTeamsParams = useMemo | null>(() => { + if (integrationSlug !== 'msteams') { + return null; + } + const signedParams = location.query.signed_params; + if (typeof signedParams !== 'string') { + return null; + } + return {signedParams}; + }, [integrationSlug, location.query]); + // Legacy install path. Redirects to `/extensions//configure/`, which // runs the Django-rendered `IntegrationExtensionConfigurationView` to drive // the install server-side via the legacy pipeline. Used by every provider @@ -246,7 +265,8 @@ export default function IntegrationOrganizationLink() { // Each provider-initiated entry point contributes its own params bag. // Whichever one is non-null routes through the API pipeline modal; // otherwise we fall back to the legacy server-driven install flow. - const urlParams = gitHubAppListingParams ?? discordAppDirectoryParams; + const urlParams = + gitHubAppListingParams ?? discordAppDirectoryParams ?? msTeamsParams; if (urlParams) { startFlow({provider, organization, onInstall, urlParams}); return; @@ -258,6 +278,7 @@ export default function IntegrationOrganizationLink() { organization, gitHubAppListingParams, discordAppDirectoryParams, + msTeamsParams, startFlow, onInstall, finishLegacyInstallation, From 268270d8569407a95f251c675d08e6eaed784172 Mon Sep 17 00:00:00 2001 From: Richard Roggenkemper <46740234+roggenkemper@users.noreply.github.com> Date: Fri, 29 May 2026 16:33:42 -0400 Subject: [PATCH 32/42] fix(eventstream): Guard against None entries in exception values list (#116511) Guard against `None` entries in `exception.values` when extracting exception data for the eventstream/EAP pipeline. Some events have `None` entries in their `exception.values` list. When `_extract_exception` iterates over this list, it calls `.get("type")` on `None`, causing `AttributeError: 'NoneType' object has no attribute 'get'`. The fix filters `None` entries out of the exceptions list before iteration rather than skipping inside the loop. This keeps `exception_count`, `stack_level` indices, and the parallel `stack_*` arrays consistent. Also adds `or []` to handle the case where `"values"` is explicitly `None`. Fixes SENTRY-FOR-SENTRY-2T8 --- src/sentry/eventstream/item_helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sentry/eventstream/item_helpers.py b/src/sentry/eventstream/item_helpers.py index 3264ab736a40..dc9b4b6999e6 100644 --- a/src/sentry/eventstream/item_helpers.py +++ b/src/sentry/eventstream/item_helpers.py @@ -347,7 +347,9 @@ def _extract_exception( ) -> Mapping[str, int | list[str | int | bool | None]]: out: dict[str, int | list[Any]] = {} - exceptions = event_data.get("exception", {}).get("values", []) + exceptions = [ + exc for exc in event_data.get("exception", {}).get("values", []) or [] if exc is not None + ] # So, logically, each exception here is basically a mapping of data. # Most notable in that mapping is frames, which is itself a mapping of frame data. # NOW! EAP currently doesn't support mappings. So what we do here is instead build From 154893f19cced9ef61bd77f03de2d6167eb14b6d Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Fri, 29 May 2026 16:38:54 -0400 Subject: [PATCH 33/42] feat(autofix): Link linear ticket in autofix PR (#116510) When creating autofix PRs, make sure to link the linear ticket so it'll be closed too. --- .../events/resolution_attribution.py | 0 src/sentry/seer/autofix/autofix_agent.py | 34 ++++++++++- .../sentry/seer/autofix/test_autofix_agent.py | 60 +++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 src/sentry/analytics/events/resolution_attribution.py diff --git a/src/sentry/analytics/events/resolution_attribution.py b/src/sentry/analytics/events/resolution_attribution.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 7a9d7d2f49da..285d0ca4eb6e 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import re from collections.abc import Callable from enum import StrEnum from typing import TYPE_CHECKING, Any, Literal @@ -43,6 +44,7 @@ from sentry.seer.models import SeerRepoDefinition from sentry.seer.models.seer_api_models import SeerPermissionError from sentry.sentry_apps.metrics import SentryAppEventType +from sentry.sentry_apps.models.platformexternalissue import PlatformExternalIssue from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization from sentry.sentry_apps.utils.webhooks import SeerActionType from sentry.utils import metrics @@ -634,9 +636,7 @@ def trigger_push_changes( client.push_changes( run_id, repo_name=repo_name, - pr_description_suffix=( - f"Fixes {group.qualified_short_id}" if group.qualified_short_id else None - ), + pr_description_suffix=build_pr_description_suffix(group), blocking=False, ) @@ -644,3 +644,31 @@ def trigger_push_changes( "autofix.explorer.trigger", tags={"step": "open_pr", "referrer": referrer.value}, ) + + +def build_pr_description_suffix(group: Group) -> str | None: + lines = [] + + if group.qualified_short_id: + lines.append(f"Fixes {group.qualified_short_id}") + + for external_issue in PlatformExternalIssue.objects.filter(group_id=group.id): + if external_issue.service_type == "linear": + is_valid = bool(re.match(r"^[A-Z0-9]+#\d+$", external_issue.display_name)) + if not is_valid: + logger.warning( + "autofix.linear.unknown-id", + extra={ + "group": group.id, + "project": group.project_id, + "linear_id": external_issue.display_name, + }, + ) + continue + linear_id = external_issue.display_name.replace("#", "-") + lines.append(f"Fixes [{linear_id}]({external_issue.web_url})") + + if lines: + return "\n".join(lines) + + return None diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index 54139cd7914b..59b83087607c 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -1059,3 +1059,63 @@ def test_passes_correct_pr_description_suffix(self, mock_post): body = mock_post.call_args[0][0] assert body["payload"]["pr_description_suffix"] == f"Fixes {self.group.qualified_short_id}" + + @patch("sentry.seer.agent.client.make_agent_update_request") + def test_pr_description_suffix_includes_linear_issue(self, mock_post): + mock_post.return_value = MagicMock(status=200) + self.create_platform_external_issue( + group=self.group, + service_type="linear", + display_name="PROJ#123", + web_url="https://linear.app/proj/issue/PROJ-123", + ) + state = SeerRunState( + run_id=123, + blocks=[], + status="completed", + updated_at="2024-01-01T00:00:00Z", + repo_pr_states={}, + metadata={"group_id": self.group.id}, + ) + + with self.feature("organizations:gen-ai-features"): + trigger_push_changes( + group=self.group, + run_id=123, + referrer=AutofixReferrer.UNKNOWN, + state=state, + ) + + body = mock_post.call_args[0][0] + expected = f"Fixes {self.group.qualified_short_id}\nFixes [PROJ-123](https://linear.app/proj/issue/PROJ-123)" + assert body["payload"]["pr_description_suffix"] == expected + + @patch("sentry.seer.agent.client.make_agent_update_request") + def test_pr_description_suffix_linear_alphanumeric_prefix(self, mock_post): + mock_post.return_value = MagicMock(status=200) + self.create_platform_external_issue( + group=self.group, + service_type="linear", + display_name="PROJ2#456", + web_url="https://linear.app/team/issue/PROJ2-456", + ) + state = SeerRunState( + run_id=123, + blocks=[], + status="completed", + updated_at="2024-01-01T00:00:00Z", + repo_pr_states={}, + metadata={"group_id": self.group.id}, + ) + + with self.feature("organizations:gen-ai-features"): + trigger_push_changes( + group=self.group, + run_id=123, + referrer=AutofixReferrer.UNKNOWN, + state=state, + ) + + body = mock_post.call_args[0][0] + expected = f"Fixes {self.group.qualified_short_id}\nFixes [PROJ2-456](https://linear.app/team/issue/PROJ2-456)" + assert body["payload"]["pr_description_suffix"] == expected From a15501fe7cf9662e226c6324cce5b80326e3552f Mon Sep 17 00:00:00 2001 From: Mark Story Date: Fri, 29 May 2026 16:58:24 -0400 Subject: [PATCH 34/42] fix(typing) Remove sentry.db.postgres.base from ignore list (#116493) Fixes ENG-6446 --- pyproject.toml | 1 - src/sentry/db/postgres/base.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index babc20189d11..dad881b74677 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -394,7 +394,6 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = [ "sentry.api.endpoints.organization_releases", - "sentry.db.postgres.base", "sentry.release_health.metrics_sessions_v2", "sentry.search.events.builder.errors", "sentry.search.events.builder.metrics", diff --git a/src/sentry/db/postgres/base.py b/src/sentry/db/postgres/base.py index ddd9f52c9fe0..8b340f8075a3 100644 --- a/src/sentry/db/postgres/base.py +++ b/src/sentry/db/postgres/base.py @@ -101,7 +101,7 @@ def executemany(self, sql, paramlist=()): class DatabaseWrapper(DjangoDatabaseWrapper): - SchemaEditorClass = DatabaseSchemaEditorProxy + SchemaEditorClass = DatabaseSchemaEditorProxy # type: ignore[assignment] queries_limit = 15000 def __init__(self, *args, **kwargs): @@ -111,7 +111,7 @@ def __init__(self, *args, **kwargs): @auto_reconnect_connection def _cursor(self, *args, **kwargs): - return super()._cursor() + return super()._cursor() # type: ignore[misc] # We're overriding this internal method that's present in Django 1.11+, because # things were shuffled around since 1.10 resulting in not constructing a django CursorWrapper @@ -119,7 +119,7 @@ def _cursor(self, *args, **kwargs): # not the other way around since then we'll lose things like __enter__ due to the way this # wrapper is working (getattr on self.cursor). def _prepare_cursor(self, cursor): - cursor = super()._prepare_cursor(CursorWrapper(self, cursor)) + cursor = super()._prepare_cursor(CursorWrapper(self, cursor)) # type: ignore[misc] return cursor def close(self, reconnect=False): From d14ccb51a8f76a97f8aa87c130704f6763ec6890 Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Fri, 29 May 2026 16:59:01 -0400 Subject: [PATCH 35/42] fix(eap): Recognize `normalize` deprecations in attribute mapping (#116509) --- src/sentry/search/eap/spans/attributes.py | 2 +- .../test_project_trace_item_details.py | 3 - tests/sentry/search/eap/test_spans.py | 55 ++++++++++++++++++- ...test_organization_trace_item_attributes.py | 5 +- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/sentry/search/eap/spans/attributes.py b/src/sentry/search/eap/spans/attributes.py index 3e18eedffb96..1d2fe0072a50 100644 --- a/src/sentry/search/eap/spans/attributes.py +++ b/src/sentry/search/eap/spans/attributes.py @@ -533,7 +533,7 @@ def _update_attribute_definitions_with_deprecations( if ( deprecation is None or deprecation.replacement is None - or deprecation.status != DeprecationStatus.BACKFILL + or deprecation.status not in (DeprecationStatus.BACKFILL, DeprecationStatus.NORMALIZE) ): continue diff --git a/tests/sentry/api/endpoints/test_project_trace_item_details.py b/tests/sentry/api/endpoints/test_project_trace_item_details.py index fb4a47e6ae27..d3f3218f7e81 100644 --- a/tests/sentry/api/endpoints/test_project_trace_item_details.py +++ b/tests/sentry/api/endpoints/test_project_trace_item_details.py @@ -4,7 +4,6 @@ from sentry.search.eap.types import SupportedTraceItemType -@pytest.mark.skip(reason="Skipping due to broken CI 05-29-2026") def test_convert_rpc_attribute_to_json_serializes_known_string_array_without_array_flag() -> None: result = convert_rpc_attribute_to_json( [ @@ -53,7 +52,6 @@ def test_convert_rpc_attribute_to_json_hides_non_replacement_array_without_array assert result == [] -@pytest.mark.skip(reason="Skipping due to broken CI 05-29-2026") def test_convert_rpc_attribute_to_json_exposes_array_with_array_flag() -> None: result = convert_rpc_attribute_to_json( [ @@ -111,7 +109,6 @@ def test_replacement_attribute_hidden_when_deprecated_source_present(self) -> No assert "gen_ai.usage.prompt_tokens" in names assert "gen_ai.usage.input_tokens" not in names - @pytest.mark.skip(reason="Skipping due to broken CI 05-29-2026") def test_replacement_array_shown_when_no_deprecated_source(self) -> None: result = convert_rpc_attribute_to_json( [ diff --git a/tests/sentry/search/eap/test_spans.py b/tests/sentry/search/eap/test_spans.py index df530fe6d273..9196687e3623 100644 --- a/tests/sentry/search/eap/test_spans.py +++ b/tests/sentry/search/eap/test_spans.py @@ -1036,14 +1036,18 @@ def test_resolver_cache_function(self) -> None: assert (resolved_column, virtual_context) == (p95_column, p95_context) -def _make_deprecated_metadata(attr_type: AttributeType, replacement: str) -> AttributeMetadata: +def _make_deprecated_metadata( + attr_type: AttributeType, + replacement: str, + status: DeprecationStatus = DeprecationStatus.BACKFILL, +) -> AttributeMetadata: return AttributeMetadata( brief="", type=attr_type, pii=PiiInfo(isPii=IsPii.FALSE), is_in_otel=False, visibility=Visibility.PUBLIC, - deprecation=DeprecationInfo(replacement=replacement, status=DeprecationStatus.BACKFILL), + deprecation=DeprecationInfo(replacement=replacement, status=status), ) @@ -1249,3 +1253,50 @@ def test_deprecated_attribute_normalizes_supported_convention_attribute_types() assert attribute_definitions["old_double"].search_type == "number" assert attribute_definitions["new_double"].search_type == "number" + + +def test_normalize_deprecated_attributes_resolve_to_replacement() -> None: + attribute_definitions: dict[str, ResolvedAttribute] = {} + + _update_attribute_definitions_with_deprecations( + attribute_definitions, + { + "old_attr": _make_deprecated_metadata( + AttributeType.STRING, "new_attr", status=DeprecationStatus.NORMALIZE + ), + }, + ) + + assert "old_attr" in attribute_definitions + assert attribute_definitions["old_attr"].replacement == "new_attr" + assert attribute_definitions["old_attr"].deprecation_status == "normalize" + assert "new_attr" in attribute_definitions + assert attribute_definitions["new_attr"].search_type == "string" + + +def test_normalize_deprecated_attribute_preserves_existing_definition() -> None: + attribute_definitions = { + "gen_ai.request.messages": ResolvedAttribute( + public_alias="gen_ai.request.messages", + internal_name="gen_ai.request.messages", + search_type="string", + ), + } + + _update_attribute_definitions_with_deprecations( + attribute_definitions, + { + "gen_ai.request.messages": _make_deprecated_metadata( + AttributeType.STRING, "gen_ai.input.messages", status=DeprecationStatus.NORMALIZE + ), + }, + ) + + deprecated_attr = attribute_definitions["gen_ai.request.messages"] + replacement_attr = attribute_definitions["gen_ai.input.messages"] + + assert deprecated_attr.replacement == "gen_ai.input.messages" + assert deprecated_attr.deprecation_status == "normalize" + assert replacement_attr.public_alias == "gen_ai.input.messages" + assert replacement_attr.internal_name == "gen_ai.input.messages" + assert replacement_attr.search_type == "string" diff --git a/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py b/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py index 93aa51c50606..38086c5dac2a 100644 --- a/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py +++ b/tests/snuba/api/endpoints/test_organization_trace_item_attributes.py @@ -898,9 +898,12 @@ def test_aliased_attribute(self) -> None: assert response.status_code == 200, response.content keys = {item["key"] for item in response.data} - assert len(keys) == 2 + assert len(keys) == 4 assert "transaction.op" in keys assert "span.op" in keys + # These two are unrelated, but happen to match. + assert "db.operation" in keys + assert "db.operation.name" in keys response = self.do_request(query={"attributeType": "string", "substringMatch": "sentry.op"}) assert response.status_code == 200, response.content From f8e642ec6e75cbbdf4e910c95aadb4c8cd88417e Mon Sep 17 00:00:00 2001 From: Sentry Bot Date: Fri, 29 May 2026 14:03:10 -0700 Subject: [PATCH 36/42] ref: bump taskbroker-client to 0.17.0 (#116526) Co-Authored-By: george-sentry <249834052+george-sentry@users.noreply.github.com> Co-authored-by: getsentry-bot <10587625+getsentry-bot@users.noreply.github.com> Co-authored-by: george-sentry <249834052+george-sentry@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dad881b74677..dcd98a19c7b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ dependencies = [ "statsd>=3.3.0", "structlog>=22.1.0", "symbolic>=12.14.1", - "taskbroker-client>=0.16.0,<1", + "taskbroker-client>=0.17.0", "tiktoken>=0.8.0", "tokenizers>=0.22.0", "tldextract>=5.1.2", diff --git a/uv.lock b/uv.lock index 0a27e6e19a11..199e6fae844c 100644 --- a/uv.lock +++ b/uv.lock @@ -916,6 +916,18 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/grpcio-1.75.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:07a554fa31c668cf0e7a188678ceeca3cb8fead29bbe455352e712ec33ca701c" }, ] +[[package]] +name = "grpcio-health-checking" +version = "1.67.1" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +dependencies = [ + { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/grpcio_health_checking-1.67.1-py3-none-any.whl", hash = "sha256:93753da5062152660aef2286c9b261e07dd87124a65e4dc9fbd47d1ce966b39d" }, +] + [[package]] name = "grpcio-status" version = "1.67.0" @@ -2442,7 +2454,7 @@ requires-dist = [ { name = "stripe", specifier = ">=6.7.0" }, { name = "structlog", specifier = ">=22.1.0" }, { name = "symbolic", specifier = ">=12.14.1" }, - { name = "taskbroker-client", specifier = ">=0.16.0,<1" }, + { name = "taskbroker-client", specifier = ">=0.17.0" }, { name = "tiktoken", specifier = ">=0.8.0" }, { name = "tldextract", specifier = ">=5.1.2" }, { name = "tokenizers", specifier = ">=0.22.0" }, @@ -2836,12 +2848,13 @@ wheels = [ [[package]] name = "taskbroker-client" -version = "0.16.0" +version = "0.17.0" source = { registry = "https://pypi.devinfra.sentry.io/simple" } dependencies = [ { name = "confluent-kafka", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "cronsim", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "grpcio-health-checking", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "msgpack", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2853,7 +2866,7 @@ dependencies = [ { name = "zstandard", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] wheels = [ - { url = "https://pypi.devinfra.sentry.io/wheels/taskbroker_client-0.16.0-py3-none-any.whl", hash = "sha256:10322e6bb51a70a77ba18498d38f1b751aab4f862983381d4d218ac3b4a6d54c" }, + { url = "https://pypi.devinfra.sentry.io/wheels/taskbroker_client-0.17.0-py3-none-any.whl", hash = "sha256:a52f5ff5914a50dbea50f07777a3c46080b467e04c7ec06895e7d3dceea16dc3" }, ] [[package]] From a9393017f6be00ac7c919d8ce99e3080b4a6013e Mon Sep 17 00:00:00 2001 From: Sofia Rest <68917129+srest2021@users.noreply.github.com> Date: Fri, 29 May 2026 14:10:36 -0700 Subject: [PATCH 37/42] ref(seer): Call project settings update helper in callsites that don't need to update the full Seer project preference (#116356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Followup to https://github.com/getsentry/sentry/pull/116352. Reroute existing callsites that modify seer project settings (`_write_preferences_to_sentry_db`, `configure_seer_for_existing_org`, `set_default_project_seer_preferences`) to the unified `update_seer_project_settings` helper. This ensures handoff options are cleared/added atomically and skips unnecessary connected repo operations. Also remove `Project` row locks from `_write_preferences_to_sentry_db` and `clear_preference_automation_handoff` — no longer needed since handoff options are all either cleared or added atomically, and the other update fields may be updated independently of each other. --- src/sentry/seer/autofix/utils.py | 66 +++++++------------ src/sentry/seer/similarity/utils.py | 25 +++---- src/sentry/tasks/seer/autofix.py | 38 +++++------ .../test_autofix_on_completion_hook.py | 1 - .../sentry/seer/autofix/test_autofix_utils.py | 15 ++++- .../seer/autofix/test_on_completion_hook.py | 1 - .../test_project_seer_preferences.py | 1 - tests/sentry/seer/endpoints/test_seer_rpc.py | 1 - tests/sentry/tasks/seer/test_autofix.py | 20 ------ 9 files changed, 68 insertions(+), 100 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 7dc97c642147..801b0c882f54 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -419,33 +419,6 @@ def deduplicate_repositories( return deduplicated -def _write_preference_project_options(project: Project, preference: SeerProjectPreference) -> None: - stopping_point = preference.automated_run_stopping_point - if stopping_point and stopping_point != SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT: - project.update_option("sentry:seer_automated_run_stopping_point", stopping_point) - else: - project.delete_option("sentry:seer_automated_run_stopping_point") - - handoff = preference.automation_handoff - if handoff is not None: - project.update_option("sentry:seer_automation_handoff_point", handoff.handoff_point) - project.update_option("sentry:seer_automation_handoff_target", handoff.target) - project.update_option( - "sentry:seer_automation_handoff_integration_id", handoff.integration_id - ) - if handoff.auto_create_pr: - project.update_option( - "sentry:seer_automation_handoff_auto_create_pr", handoff.auto_create_pr - ) - else: - project.delete_option("sentry:seer_automation_handoff_auto_create_pr") - else: - project.delete_option("sentry:seer_automation_handoff_point") - project.delete_option("sentry:seer_automation_handoff_target") - project.delete_option("sentry:seer_automation_handoff_integration_id") - project.delete_option("sentry:seer_automation_handoff_auto_create_pr") - - def _write_preferences_to_sentry_db( project_preferences: list[tuple[Project, SeerProjectPreference]], ) -> None: @@ -459,9 +432,6 @@ def _write_preferences_to_sentry_db( with transaction.atomic(using=router.db_for_write(SeerProjectRepository)): project_ids = {project.id for project, _ in project_preferences} - # Lock project rows to serialize concurrent preference writes. - list(Project.objects.select_for_update().filter(id__in=project_ids).order_by("id")) - # Only delete SeerProjectRepository for active repos. SeerProjectRepository.objects.filter( project_repository__project_id__in=project_ids, @@ -549,10 +519,22 @@ def _write_preferences_to_sentry_db( ) SeerProjectRepositoryBranchOverride.objects.bulk_create(overrides_to_create) - # Write ProjectOptions last so cache updates only happen after all DB writes succeed + # Write ProjectOptions last so cache updates happen after repo DB writes succeed # (cache cannot be rolled back by the transaction). for project, pref in project_preferences: - _write_preference_project_options(project, pref) + update = SeerProjectSettingsUpdate() + + if pref.automated_run_stopping_point is not None: + update["stopping_point"] = pref.automated_run_stopping_point + + if pref.automation_handoff is not None: + update["agent"] = AutomationCodingAgent(pref.automation_handoff.target) + update["integration_id"] = pref.automation_handoff.integration_id + update["auto_create_pr"] = pref.automation_handoff.auto_create_pr + else: + update["agent"] = AutomationCodingAgent.SEER + + update_seer_project_settings([project.id], update) def write_preference_to_sentry_db(project: Project, preference: SeerProjectPreference) -> None: @@ -584,15 +566,15 @@ def bulk_write_preferences_to_sentry_db( def clear_preference_automation_handoff(project: Project) -> None: - """Atomically clear automation_handoff from a project's Seer preferences in Sentry DB.""" - with transaction.atomic(using=router.db_for_write(ProjectOption)): - # Lock project rows to serialize concurrent preference writes. - list(Project.objects.select_for_update().filter(id=project.id)) - - project.delete_option("sentry:seer_automation_handoff_point") - project.delete_option("sentry:seer_automation_handoff_target") - project.delete_option("sentry:seer_automation_handoff_integration_id") - project.delete_option("sentry:seer_automation_handoff_auto_create_pr") + """Atomically clear a project's automation handoff settings.""" + ProjectOption.objects.filter( + project=project, + key__in=[ + "sentry:seer_automation_handoff_point", + "sentry:seer_automation_handoff_target", + "sentry:seer_automation_handoff_integration_id", + ], + ).delete() def build_repo_definition_from_project_repo( @@ -764,7 +746,7 @@ def _set_or_clear(key: str, value: Any, default: Any) -> None: raise ValueError("integrationId is required for external coding agents") options_to_set["sentry:seer_automation_handoff_point"] = AutofixHandoffPoint.ROOT_CAUSE options_to_set["sentry:seer_automation_handoff_target"] = agent - options_to_set["sentry:seer_automation_handoff_integration_id"] = integration_id + options_to_set["sentry:seer_automation_handoff_integration_id"] = data["integration_id"] if "scanner_automation" in data: _set_or_clear("sentry:seer_scanner_automation", data["scanner_automation"], default=True) diff --git a/src/sentry/seer/similarity/utils.py b/src/sentry/seer/similarity/utils.py index 40a02faeff9f..72e1171b14f9 100644 --- a/src/sentry/seer/similarity/utils.py +++ b/src/sentry/seer/similarity/utils.py @@ -20,12 +20,12 @@ from sentry.models.project import Project from sentry.seer.autofix.constants import AutofixAutomationTuningSettings from sentry.seer.autofix.utils import ( + AutofixStoppingPoint, + AutomationCodingAgent, + SeerProjectSettingsUpdate, get_org_default_seer_automation_handoff, is_seer_seat_based_tier_enabled, - write_preference_to_sentry_db, -) -from sentry.seer.models import ( - SeerProjectPreference, + update_seer_project_settings, ) from sentry.seer.similarity.types import GroupingVersion from sentry.services.eventstore.models import Event, GroupEvent @@ -575,16 +575,17 @@ def set_default_project_seer_preferences(organization: Organization, project: Pr stopping_point, automation_handoff = get_org_default_seer_automation_handoff(organization) - preference = SeerProjectPreference( - organization_id=organization.id, - project_id=project.id, - repositories=[], - automated_run_stopping_point=stopping_point, - automation_handoff=automation_handoff, - ) + update = SeerProjectSettingsUpdate(stopping_point=stopping_point) + if automation_handoff is not None: + update["agent"] = AutomationCodingAgent(automation_handoff.target) + update["integration_id"] = automation_handoff.integration_id + update["auto_create_pr"] = automation_handoff.auto_create_pr + else: + update["agent"] = AutomationCodingAgent.SEER + update["auto_create_pr"] = stopping_point == AutofixStoppingPoint.OPEN_PR try: - write_preference_to_sentry_db(project, preference) + update_seer_project_settings([project.id], update) except Exception as e: sentry_sdk.capture_exception(e) diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py index 508047eaeeca..2f658906c24f 100644 --- a/src/sentry/tasks/seer/autofix.py +++ b/src/sentry/tasks/seer/autofix.py @@ -20,14 +20,16 @@ SeerAutomationSource, ) from sentry.seer.autofix.utils import ( + AutofixStoppingPoint, + AutomationCodingAgent, + SeerProjectSettingsUpdate, bulk_read_preferences_from_sentry_db, - bulk_write_preferences_to_sentry_db, get_autofix_state, get_org_default_seer_automation_handoff, get_seer_seat_based_tier_cache_key, get_valid_automated_run_stopping_points, + update_seer_project_settings, ) -from sentry.seer.models import SeerProjectPreference, SeerRepoDefinition from sentry.tasks.base import instrumented_task from sentry.taskworker.namespaces import ingest_errors_tasks, issues_tasks from sentry.utils import metrics @@ -246,16 +248,13 @@ def configure_seer_for_existing_org(organization_id: int) -> None: preferences = bulk_read_preferences_from_sentry_db(organization_id, project_ids) # Determine which projects need updates - preferences_to_set: list[SeerProjectPreference] = [] - for project_id in project_ids: + preferences_set = 0 + for project in projects: stopping_point = default_stopping_point handoff = default_handoff - repositories: list[SeerRepoDefinition] = [] - existing_pref = preferences.get(project_id) + existing_pref = preferences.get(project.id) if existing_pref: - repositories = existing_pref.repositories - existing_stopping_point = existing_pref.automated_run_stopping_point existing_handoff = existing_pref.automation_handoff @@ -271,18 +270,17 @@ def configure_seer_for_existing_org(organization_id: int) -> None: if existing_handoff: handoff = existing_handoff - preferences_to_set.append( - SeerProjectPreference( - organization_id=organization_id, - project_id=project_id, - repositories=repositories, - automated_run_stopping_point=stopping_point, - automation_handoff=handoff, - ) - ) + update = SeerProjectSettingsUpdate(stopping_point=stopping_point) + if handoff is not None: + update["agent"] = AutomationCodingAgent(handoff.target) + update["integration_id"] = handoff.integration_id + update["auto_create_pr"] = handoff.auto_create_pr + else: + update["agent"] = AutomationCodingAgent.SEER + update["auto_create_pr"] = stopping_point == AutofixStoppingPoint.OPEN_PR - if len(preferences_to_set) > 0: - bulk_write_preferences_to_sentry_db(projects, preferences_to_set) + update_seer_project_settings([project.id], update) + preferences_set += 1 # Invalidate existing cache entry and set cache to True to prevent race conditions where another # request re-caches False before the billing flag has fully propagated @@ -294,6 +292,6 @@ def configure_seer_for_existing_org(organization_id: int) -> None: "org_id": organization.id, "org_slug": organization.slug, "projects_configured": len(project_ids), - "preferences_set": len(preferences_to_set), + "preferences_set": preferences_set, }, ) diff --git a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py index b9b7f885e1ac..f64ede07004a 100644 --- a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py @@ -546,7 +546,6 @@ def test_trigger_coding_agent_handoff_clears_preference_on_not_found(self, mock_ assert self.project.get_option("sentry:seer_automation_handoff_point") is None assert self.project.get_option("sentry:seer_automation_handoff_target") is None assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") def test_trigger_coding_agent_handoff_calls_function(self, mock_trigger): diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index dbf59c6bf630..1583c3bd2b02 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -1039,9 +1039,19 @@ def _assert_handoff_options_cleared(self) -> None: assert self.project.get_option("sentry:seer_automation_handoff_point") is None assert self.project.get_option("sentry:seer_automation_handoff_target") is None assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False - def test_clears_all_four_handoff_options(self) -> None: + def test_clears_handoff_options(self) -> None: + self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") + self.project.update_option( + "sentry:seer_automation_handoff_target", "cursor_background_agent" + ) + self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + + clear_preference_automation_handoff(self.project) + + self._assert_handoff_options_cleared() + + def test_preserves_auto_create_pr(self) -> None: self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") self.project.update_option( "sentry:seer_automation_handoff_target", "cursor_background_agent" @@ -1052,6 +1062,7 @@ def test_clears_all_four_handoff_options(self) -> None: clear_preference_automation_handoff(self.project) self._assert_handoff_options_cleared() + assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True def test_preserves_unrelated_preference_fields(self) -> None: self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") diff --git a/tests/sentry/seer/autofix/test_on_completion_hook.py b/tests/sentry/seer/autofix/test_on_completion_hook.py index 7ebcfeae65a9..5e84e847788d 100644 --- a/tests/sentry/seer/autofix/test_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_on_completion_hook.py @@ -41,4 +41,3 @@ def test_not_found_clears_automation_handoff(self, mock_trigger) -> None: assert self.project.get_option("sentry:seer_automation_handoff_point") is None assert self.project.get_option("sentry:seer_automation_handoff_target") is None assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False diff --git a/tests/sentry/seer/endpoints/test_project_seer_preferences.py b/tests/sentry/seer/endpoints/test_project_seer_preferences.py index 808d362a7224..a61d723fb0dc 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_preferences.py +++ b/tests/sentry/seer/endpoints/test_project_seer_preferences.py @@ -218,7 +218,6 @@ def test_post_with_null_automation_handoff(self) -> None: assert self.project.get_option("sentry:seer_automation_handoff_point") is None assert self.project.get_option("sentry:seer_automation_handoff_target") is None assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False def test_post_with_invalid_automation_handoff_target(self) -> None: """Test that POST request fails with invalid target value""" diff --git a/tests/sentry/seer/endpoints/test_seer_rpc.py b/tests/sentry/seer/endpoints/test_seer_rpc.py index f1ca1dc498a2..dbf59ebe2476 100644 --- a/tests/sentry/seer/endpoints/test_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_seer_rpc.py @@ -1752,7 +1752,6 @@ def test_integration_not_found_clears_handoff_project_options(self, mock_launch) assert self.project.get_option("sentry:seer_automation_handoff_point") is None assert self.project.get_option("sentry:seer_automation_handoff_target") is None assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False @patch("sentry.seer.endpoints.seer_rpc.launch_coding_agents_for_run") def test_integration_not_found_skips_clear_when_project_outside_org(self, mock_launch): diff --git a/tests/sentry/tasks/seer/test_autofix.py b/tests/sentry/tasks/seer/test_autofix.py index f193cf0c7e38..d254bd6aa49d 100644 --- a/tests/sentry/tasks/seer/test_autofix.py +++ b/tests/sentry/tasks/seer/test_autofix.py @@ -10,7 +10,6 @@ SummarizeIssueResponse, SummarizeIssueScores, ) -from sentry.seer.models.project_repository import SeerProjectRepository from sentry.tasks.seer.autofix import ( check_autofix_status, configure_seer_for_existing_org, @@ -293,22 +292,3 @@ def test_sets_seat_based_tier_cache_to_true(self) -> None: # Cache should be set to True to prevent race conditions assert cache.get(cache_key) is True - - def test_preserves_existing_repositories(self) -> None: - """Test that existing repositories are preserved when preferences are set.""" - project = self.create_project(organization=self.organization) - repo = self.create_repo( - project=project, - provider="integrations:github", - external_id="ext123", - name="existing-org/existing-repo", - ) - self.create_seer_project_repository(project=project, repository=repo) - # Force the update path by using an invalid stopping point. - project.update_option("sentry:seer_automated_run_stopping_point", "root_cause") - - configure_seer_for_existing_org(organization_id=self.organization.id) - - seer_repos = list(SeerProjectRepository.objects.filter(project_repository__project=project)) - assert len(seer_repos) == 1 - assert seer_repos[0].project_repository.repository_id == repo.id From 61968fc904c89f7987dff301635f817ea7193822 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Fri, 29 May 2026 17:37:47 -0400 Subject: [PATCH 38/42] fix(releases): combine duplicate Author type (#116358) We had duplicate definitions of 'Author' across releases. Consolidate them to the type `UserSerializerResponse | NonMappableUser`. Co-authored-by: Claude --- src/sentry/api/serializers/models/commit.py | 3 ++- .../api/serializers/models/organization.py | 2 +- src/sentry/api/serializers/models/project.py | 2 +- .../api/serializers/models/pullrequest.py | 3 ++- src/sentry/api/serializers/models/release.py | 12 ++------- src/sentry/api/serializers/models/team.py | 2 +- .../api/serializers/release_details_types.py | 26 +++++-------------- src/sentry/api/serializers/types.py | 6 ----- .../apidocs/examples/organization_examples.py | 2 +- src/sentry/users/api/serializers/user.py | 7 ++++- src/sentry/utils/committers.py | 2 +- 11 files changed, 23 insertions(+), 44 deletions(-) diff --git a/src/sentry/api/serializers/models/commit.py b/src/sentry/api/serializers/models/commit.py index 40b869a893fa..e54d754188a3 100644 --- a/src/sentry/api/serializers/models/commit.py +++ b/src/sentry/api/serializers/models/commit.py @@ -5,8 +5,9 @@ from sentry.api.serializers import Serializer, register, serialize from sentry.api.serializers.models.pullrequest import PullRequestSerializerResponse -from sentry.api.serializers.models.release import Author, get_users_for_authors +from sentry.api.serializers.models.release import get_users_for_authors from sentry.api.serializers.models.repository import RepositorySerializerResponse +from sentry.api.serializers.release_details_types import Author from sentry.models.commit import Commit from sentry.models.commitauthor import CommitAuthor from sentry.models.pullrequest import PullRequest diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index d5c28e1df77b..2c495a180090 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -24,7 +24,6 @@ TeamRoleSerializerResponse, ) from sentry.api.serializers.models.team import TeamSerializerResponse -from sentry.api.serializers.types import SerializedAvatarFields from sentry.api.utils import generate_locality_url from sentry.auth.access import Access from sentry.auth.services.auth import RpcOrganizationAuthConfig, auth_service @@ -86,6 +85,7 @@ from sentry.replays.models import OrganizationMemberReplayAccess from sentry.seer.autofix.utils import get_valid_automated_run_stopping_points from sentry.types.cell import get_locality_name_for_cell +from sentry.users.api.serializers.user import SerializedAvatarFields from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service diff --git a/src/sentry/api/serializers/models/project.py b/src/sentry/api/serializers/models/project.py index 926c7f386925..24d9d1a20b3d 100644 --- a/src/sentry/api/serializers/models/project.py +++ b/src/sentry/api/serializers/models/project.py @@ -17,7 +17,6 @@ from sentry.api.serializers import Serializer, register, serialize from sentry.api.serializers.models.plugin import PluginSerializer from sentry.api.serializers.models.team import get_org_roles -from sentry.api.serializers.types import SerializedAvatarFields from sentry.app import env from sentry.auth.access import Access from sentry.auth.superuser import is_active_superuser @@ -49,6 +48,7 @@ from sentry.services.eventstore.models import DEFAULT_SUBJECT_TEMPLATE from sentry.snuba import discover from sentry.tempest.utils import has_tempest_access +from sentry.users.api.serializers.user import SerializedAvatarFields from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser diff --git a/src/sentry/api/serializers/models/pullrequest.py b/src/sentry/api/serializers/models/pullrequest.py index 0af9f0ef6163..a4d9ed0c78d2 100644 --- a/src/sentry/api/serializers/models/pullrequest.py +++ b/src/sentry/api/serializers/models/pullrequest.py @@ -2,8 +2,9 @@ from typing import TypedDict from sentry.api.serializers import Serializer, register, serialize -from sentry.api.serializers.models.release import Author, get_users_for_authors +from sentry.api.serializers.models.release import get_users_for_authors from sentry.api.serializers.models.repository import RepositorySerializerResponse +from sentry.api.serializers.release_details_types import Author from sentry.models.commitauthor import CommitAuthor from sentry.models.pullrequest import PullRequest from sentry.models.repository import Repository diff --git a/src/sentry/api/serializers/models/release.py b/src/sentry/api/serializers/models/release.py index 9cded35fc887..13687a0277a5 100644 --- a/src/sentry/api/serializers/models/release.py +++ b/src/sentry/api/serializers/models/release.py @@ -3,7 +3,7 @@ import datetime from collections import defaultdict from collections.abc import Mapping, Sequence -from typing import Any, NotRequired, TypedDict, Union +from typing import Any, NotRequired, TypedDict from django.contrib.auth.models import AnonymousUser from django.core.cache import cache @@ -11,7 +11,7 @@ from sentry import release_health, tagstore from sentry.api.serializers import Serializer, register, serialize -from sentry.api.serializers.release_details_types import VersionInfo +from sentry.api.serializers.release_details_types import Author, NonMappableUser, VersionInfo from sentry.api.serializers.types import ( GroupEventReleaseSerializerResponse, ReleaseSerializerResponse, @@ -226,14 +226,6 @@ def _user_to_author_cache_key(organization_id: int, author: CommitAuthor) -> str return f"get_users_for_authors:{organization_id}:{author_hash}" -class NonMappableUser(TypedDict): - name: str | None - email: str - - -Author = Union[UserSerializerResponse, NonMappableUser] - - def get_author_users_by_external_actors( authors: list[CommitAuthor], organization_id: int ) -> tuple[dict[CommitAuthor, str], list[CommitAuthor]]: diff --git a/src/sentry/api/serializers/models/team.py b/src/sentry/api/serializers/models/team.py index 50125537f5f6..a2903f96f830 100644 --- a/src/sentry/api/serializers/models/team.py +++ b/src/sentry/api/serializers/models/team.py @@ -11,7 +11,6 @@ from sentry import roles from sentry.api.serializers import Serializer, register, serialize -from sentry.api.serializers.types import SerializedAvatarFields from sentry.app import env from sentry.auth.access import ( Access, @@ -28,6 +27,7 @@ from sentry.models.projectteam import ProjectTeam from sentry.models.team import Team from sentry.roles import organization_roles, team_roles +from sentry.users.api.serializers.user import SerializedAvatarFields from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser from sentry.utils.query import RangeQuerySetWrapper diff --git a/src/sentry/api/serializers/release_details_types.py b/src/sentry/api/serializers/release_details_types.py index fd1350f647c5..a69531b92a8d 100644 --- a/src/sentry/api/serializers/release_details_types.py +++ b/src/sentry/api/serializers/release_details_types.py @@ -1,6 +1,8 @@ from datetime import datetime from typing import Any, TypedDict +from sentry.users.api.serializers.user import UserSerializerResponse + class VersionInfoOptional(TypedDict, total=False): description: str @@ -24,28 +26,12 @@ class LastDeploy(LastDeployOptional): name: str -class AuthorOptional(TypedDict, total=False): - lastLogin: str - has2fa: bool - lastActive: str - isSuperuser: bool - isStaff: bool - experiments: dict[str, str | int | float | bool | None] - emails: list[dict[str, int | str | bool]] - avatar: dict[str, str | None] +class NonMappableUser(TypedDict): + name: str | None + email: str -class Author(AuthorOptional): - id: int - name: str - username: str - email: str - avatarUrl: str - isActive: bool - isSuspended: bool - hasPasswordAuth: bool - isManaged: bool - dateJoined: str +Author = UserSerializerResponse | NonMappableUser class HealthDataOptional(TypedDict, total=False): diff --git a/src/sentry/api/serializers/types.py b/src/sentry/api/serializers/types.py index 32b2274e70bf..d8bd2fc70c2a 100644 --- a/src/sentry/api/serializers/types.py +++ b/src/sentry/api/serializers/types.py @@ -4,12 +4,6 @@ from sentry.api.serializers.release_details_types import Author, LastDeploy, Project, VersionInfo -class SerializedAvatarFields(TypedDict, total=False): - avatarType: str - avatarUuid: str | None - avatarUrl: str | None - - # Reponse type for OrganizationReleaseDetailsEndpoint class ReleaseSerializerResponseOptional(TypedDict, total=False): ref: str | None diff --git a/src/sentry/apidocs/examples/organization_examples.py b/src/sentry/apidocs/examples/organization_examples.py index ee5ccafc9137..f4e58286b2ae 100644 --- a/src/sentry/apidocs/examples/organization_examples.py +++ b/src/sentry/apidocs/examples/organization_examples.py @@ -772,7 +772,7 @@ class OrganizationExamples: }, "authors": [ { - "id": 2837091, + "id": "2837091", "name": "Raj's Raspberries", "username": "rajraspberry", "email": "raj@raspberries", diff --git a/src/sentry/users/api/serializers/user.py b/src/sentry/users/api/serializers/user.py index 6bbb5c2553dc..36bf5f5d2d11 100644 --- a/src/sentry/users/api/serializers/user.py +++ b/src/sentry/users/api/serializers/user.py @@ -12,7 +12,6 @@ from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register -from sentry.api.serializers.types import SerializedAvatarFields from sentry.app import env from sentry.auth.elevated_mode import has_elevated_mode from sentry.hybridcloud.services.organization_mapping import organization_mapping_service @@ -34,6 +33,12 @@ from sentry.utils.serializers import manytoone_to_dict +class SerializedAvatarFields(TypedDict, total=False): + avatarType: str + avatarUuid: str | None + avatarUrl: str | None + + class _UserEmails(TypedDict): id: str email: str diff --git a/src/sentry/utils/committers.py b/src/sentry/utils/committers.py index 7ac7b3d8b4c6..97e45cf7bdf7 100644 --- a/src/sentry/utils/committers.py +++ b/src/sentry/utils/committers.py @@ -12,7 +12,7 @@ from sentry.api.serializers import serialize from sentry.api.serializers.models.commit import CommitSerializer, get_users_for_commits -from sentry.api.serializers.models.release import Author, NonMappableUser +from sentry.api.serializers.release_details_types import Author, NonMappableUser from sentry.models.commit import Commit from sentry.models.commitfilechange import CommitFileChange from sentry.models.group import Group From 4f7cc95f3d67ad4534c6a4850ed2152f00ab3ff9 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Fri, 29 May 2026 17:38:07 -0400 Subject: [PATCH 39/42] ref(seer): Remove `organizations:seer-issue-view` (#116528) Remove `organizations:seer-issue-view`. This flag has zero usage and is not registered in sentry-options-automator. --- _Generated by [Claude Code](https://claude.ai/code/session_01KC1dc6Scf5gtgkusF5v6Ui)_ --- src/sentry/features/temporary.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index f9a110dfb22b..18618f44f19b 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -286,8 +286,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:root-cause-stopping-point", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable the Seer Wizard and related prompts/links/banners manager.add("organizations:seer-wizard", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable the Seer issues view - manager.add("organizations:seer-issue-view", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable autofix introspection for early stopping of autofix runs manager.add("organizations:seer-autofix-introspection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable Seer Workflows in Slack (released, kept until overrides are removed) From f46cb722f66deccf59794990740103529b68130d Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Fri, 29 May 2026 14:43:45 -0700 Subject: [PATCH 40/42] feat(activity): Add (project, type) index on sentry_activity (#116524) We're planning to add search filters which will allow users to query issues by the activity types contained within them (e.g. an upcoming `issue.agent:pr_created` filter which filters to issues with the activity `SEER_PR_CREATED`). Those queries filter `sentry_activity` on `project_id` plus `type`, which results in a very slow and inefficient query without this index. This is a huge table (>1B rows), so it is a post deployment migration. In addition to the new index, this also removes an unused one (`sentry_activity_weekly_report_jtcunning`) to clear some room. --- migrations_lockfile.txt | 2 +- .../1102_activity_project_type_index.py | 41 +++++++++++++++++++ src/sentry/models/activity.py | 5 ++- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 src/sentry/migrations/1102_activity_project_type_index.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index d02269d4e3e0..4ad31dbc6ebb 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -31,7 +31,7 @@ replays: 0007_organizationmember_replay_access seer: 0017_drop_old_fk_columns -sentry: 1101_remove_email_model_pending +sentry: 1102_activity_project_type_index social_auth: 0003_social_auth_json_field diff --git a/src/sentry/migrations/1102_activity_project_type_index.py b/src/sentry/migrations/1102_activity_project_type_index.py new file mode 100644 index 000000000000..2b28408afe5d --- /dev/null +++ b/src/sentry/migrations/1102_activity_project_type_index.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.14 on 2026-05-29 19:53 + +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration +from sentry.new_migrations.monkey.special import SafeRunSQL + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = True + + dependencies = [ + ("sentry", "1101_remove_email_model_pending"), + ] + + operations = [ + # Drop a legacy, unused index that was created manually outside of Django's + # migration state, so there is no corresponding model/state change. + SafeRunSQL( + sql="DROP INDEX CONCURRENTLY IF EXISTS sentry_activity_weekly_report_jtcunning;", + reverse_sql=migrations.RunSQL.noop, + hints={"tables": ["sentry_activity"]}, + use_statement_timeout=False, + ), + migrations.AddIndex( + model_name="activity", + index=models.Index(fields=["project", "type"], name="sentry_acti_project_4b71f8_idx"), + ), + ] diff --git a/src/sentry/models/activity.py b/src/sentry/models/activity.py index 58359960145d..b3d73066444c 100644 --- a/src/sentry/models/activity.py +++ b/src/sentry/models/activity.py @@ -125,7 +125,10 @@ class Activity(Model): class Meta: app_label = "sentry" db_table = "sentry_activity" - indexes = (models.Index(fields=("project", "datetime")),) + indexes = ( + models.Index(fields=("project", "datetime")), + models.Index(fields=("project", "type")), + ) __repr__ = sane_repr("project_id", "group_id", "event_id", "user_id", "type", "ident") From 35ffd12e96ad6c22c9994402696b4a05748f9a01 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Fri, 29 May 2026 17:46:40 -0400 Subject: [PATCH 41/42] ref(api-docs): add `EventAttachmentSerializerResponse` type and example (#116515) Add types and example for the event attachments endpoint, in preparation for publishing it. Co-authored-by: Claude --- .../api/serializers/models/eventattachment.py | 16 ++++++- .../examples/event_attachment_examples.py | 48 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/sentry/apidocs/examples/event_attachment_examples.py diff --git a/src/sentry/api/serializers/models/eventattachment.py b/src/sentry/api/serializers/models/eventattachment.py index eccfb68e8f49..8756285997a1 100644 --- a/src/sentry/api/serializers/models/eventattachment.py +++ b/src/sentry/api/serializers/models/eventattachment.py @@ -1,13 +1,27 @@ import mimetypes +from datetime import datetime +from typing import TypedDict from sentry.api.serializers import Serializer, register from sentry.models.eventattachment import EventAttachment from sentry.models.files.file import File +class EventAttachmentSerializerResponse(TypedDict): + id: str + event_id: str + type: str + name: str + mimetype: str | None + dateCreated: datetime + size: int + headers: dict[str, str | None] + sha1: str | None + + @register(EventAttachment) class EventAttachmentSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): + def serialize(self, obj, attrs, user, **kwargs) -> EventAttachmentSerializerResponse: content_type = obj.content_type size = obj.size or 0 sha1 = obj.sha1 diff --git a/src/sentry/apidocs/examples/event_attachment_examples.py b/src/sentry/apidocs/examples/event_attachment_examples.py new file mode 100644 index 000000000000..95a2a0d64966 --- /dev/null +++ b/src/sentry/apidocs/examples/event_attachment_examples.py @@ -0,0 +1,48 @@ +from datetime import datetime + +from drf_spectacular.utils import OpenApiExample + +from sentry.api.serializers.models.eventattachment import EventAttachmentSerializerResponse + +SCREENSHOT_ATTACHMENT: EventAttachmentSerializerResponse = { + "id": "1234", + "event_id": "9b29bbe17e9d4ee3a6d0fe9b2e8a3b1c", + "type": "event.attachment", + "name": "screenshot.png", + "mimetype": "image/png", + "dateCreated": datetime.fromisoformat("2026-04-15T18:22:31.000000Z"), + "size": 248137, + "headers": {"Content-Type": "image/png"}, + "sha1": "d3f299af02d6abbe92dd8368bab781824a9702ed", +} + +VIEW_HIERARCHY_ATTACHMENT: EventAttachmentSerializerResponse = { + "id": "1235", + "event_id": "9b29bbe17e9d4ee3a6d0fe9b2e8a3b1c", + "type": "event.view_hierarchy", + "name": "view-hierarchy.json", + "mimetype": "application/json", + "dateCreated": datetime.fromisoformat("2026-04-15T18:22:31.000000Z"), + "size": 8421, + "headers": {"Content-Type": "application/json"}, + "sha1": "fa0a5fad9e64129f6b5f60cca3a5b8c9b8a1a3a0", +} + + +class EventAttachmentExamples: + LIST_EVENT_ATTACHMENTS = [ + OpenApiExample( + "Return a list of attachments for an event", + value=[SCREENSHOT_ATTACHMENT, VIEW_HIERARCHY_ATTACHMENT], + response_only=True, + status_codes=["200"], + ) + ] + EVENT_ATTACHMENT_DETAILS = [ + OpenApiExample( + "Return a single event attachment", + value=SCREENSHOT_ATTACHMENT, + response_only=True, + status_codes=["200"], + ) + ] From 3d2d19e21729650c957d970f32a9a849fe148b85 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 29 May 2026 14:53:53 -0700 Subject: [PATCH 42/42] feat(issues): Restore issue details tour, remove guide (#116355) The old issue details guide was still wired into anchors that no longer exist, while the current tour had no launcher when the backend marked it unseen. Removes the stale guide and anchor wrappers, keeps the existing tour, and lazy-loads the start tour modal when we should show it. Brings back the tour, can be manually triggered `#issue-details-tour` on the issue details page image --- .../components/assistant/getGuidesContent.tsx | 61 --------- .../components/assistant/guideAnchor.spec.tsx | 38 +++--- .../events/eventTagsAndScreenshot/tags.tsx | 7 +- static/app/stores/guideStore.spec.tsx | 27 ++-- .../issueDetails/eventNavigation/index.tsx | 4 +- .../app/views/issueDetails/groupDetails.tsx | 2 + .../groupEventDetails.spec.tsx | 2 +- .../groupEventDetailsContent.tsx | 124 ++++++++---------- .../views/issueDetails/issueDetailsTour.tsx | 108 ++++++++++++++- 9 files changed, 197 insertions(+), 176 deletions(-) diff --git a/static/app/components/assistant/getGuidesContent.tsx b/static/app/components/assistant/getGuidesContent.tsx index 39b7e0ff26af..b69bfc63a7d9 100644 --- a/static/app/components/assistant/getGuidesContent.tsx +++ b/static/app/components/assistant/getGuidesContent.tsx @@ -10,67 +10,6 @@ export function getGuidesContent(): GuidesContent { return getDemoModeGuides(); } return [ - { - guide: 'issue', - requiredTargets: ['issue_header_stats', 'breadcrumbs', 'issue_sidebar_owners'], - steps: [ - { - title: t('How bad is it?'), - target: 'issue_header_stats', - description: t( - `You have Issues and that's fine. - Understand impact at a glance by viewing total issue frequency and affected users.` - ), - }, - { - title: t('Find problematic releases'), - target: 'issue_sidebar_releases', - description: t( - 'See which release introduced the issue and which release it last appeared in.' - ), - }, - { - title: t('Not your typical stack trace'), - target: 'stacktrace', - description: t( - `Sentry can show your source code in the stack trace. - See the exact sequence of function calls leading to the error in question.` - ), - }, - { - // TODO(streamline-ui): Remove from guides on GA, tag sidebar is gone - title: t('Pinpoint hotspots'), - target: 'issue_sidebar_tags', - description: t( - 'Tags are key/value string pairs that are automatically indexed and searchable in Sentry.' - ), - }, - { - title: t('Retrace Your Steps'), - target: 'breadcrumbs', - description: t( - `Not sure how you got here? Sentry automatically captures breadcrumbs for - events your user and app took that led to the error.` - ), - }, - { - title: t('Annoy the Right People'), - target: 'issue_sidebar_owners', - description: t( - `Automatically assign issues to the person who introduced the commit, - notify them over notification tools like Slack, - and triage through issue management tools like Jira. ` - ), - }, - { - title: t('Onboarding'), - target: 'onboarding_sidebar', - description: t( - 'Walk through this guide to get the most out of Sentry right away.' - ), - }, - ], - }, { guide: 'issue_stream', requiredTargets: ['issue_stream'], diff --git a/static/app/components/assistant/guideAnchor.spec.tsx b/static/app/components/assistant/guideAnchor.spec.tsx index c6fa62504ec1..893ba4681e0d 100644 --- a/static/app/components/assistant/guideAnchor.spec.tsx +++ b/static/app/components/assistant/guideAnchor.spec.tsx @@ -10,11 +10,11 @@ import {GuideStore} from 'sentry/stores/guideStore'; describe('GuideAnchor', () => { const serverGuide = [ { - guide: 'issue', + guide: 'trace_view', seen: false, }, ]; - const firstGuideHeader = 'How bad is it?'; + const firstGuideHeader = 'Event Breakdown'; beforeEach(() => { ConfigStore.loadInitialData( @@ -30,29 +30,25 @@ describe('GuideAnchor', () => { it('renders, async advances, async and finishes', async () => { render(
- - - + + +
); act(() => GuideStore.fetchSucceeded(serverGuide)); expect(await screen.findByText(firstGuideHeader)).toBeInTheDocument(); - // XXX(epurkhiser): Skip pointer event checks due to a bug with how Popper - // renders the hovercard with pointer-events: none. See [0] - // - // [0]: https://github.com/testing-library/user-event/issues/639 - // - // NOTE(epurkhiser): We may be able to remove the skipPointerEventsCheck - // when we're on popper >= 1. await userEvent.click(screen.getByLabelText('Next')); - expect(await screen.findByText('Retrace Your Steps')).toBeInTheDocument(); + expect(await screen.findByText('Events')).toBeInTheDocument(); expect(screen.queryByText(firstGuideHeader)).not.toBeInTheDocument(); await userEvent.click(screen.getByLabelText('Next')); + expect(await screen.findByText('Event Details')).toBeInTheDocument(); + expect(screen.queryByText('Events')).not.toBeInTheDocument(); + // Clicking on the button in the last step should finish the guide. const finishMock = MockApiClient.addMockResponse({ method: 'PUT', @@ -66,7 +62,7 @@ describe('GuideAnchor', () => { expect.objectContaining({ method: 'PUT', data: { - guide: 'issue', + guide: 'trace_view', status: 'viewed', }, }) @@ -76,9 +72,9 @@ describe('GuideAnchor', () => { it('dismisses', async () => { render(
- - - + + +
); @@ -97,7 +93,7 @@ describe('GuideAnchor', () => { expect.objectContaining({ method: 'PUT', data: { - guide: 'issue', + guide: 'trace_view', status: 'dismissed', }, }) @@ -131,9 +127,9 @@ describe('GuideAnchor', () => { it('if forceHide is true, async do not render guide', async () => { render(
- - - + + +
); diff --git a/static/app/components/events/eventTagsAndScreenshot/tags.tsx b/static/app/components/events/eventTagsAndScreenshot/tags.tsx index f5a8fdd7ed1f..733428033fce 100644 --- a/static/app/components/events/eventTagsAndScreenshot/tags.tsx +++ b/static/app/components/events/eventTagsAndScreenshot/tags.tsx @@ -3,7 +3,6 @@ import {useMemo, useState} from 'react'; import {Grid} from '@sentry/scraps/layout'; import {SegmentedControl} from '@sentry/scraps/segmentedControl'; -import {GuideAnchor} from 'sentry/components/assistant/guideAnchor'; import {EventTags} from 'sentry/components/events/eventTags'; import { associateTagsWithMeta, @@ -83,11 +82,7 @@ export function EventTagsDataSection({ return ( - {t('Tags')} - - } + title={t('Tags')} actions={actions} sectionKey={SectionKey.TAGS} ref={ref} diff --git a/static/app/stores/guideStore.spec.tsx b/static/app/stores/guideStore.spec.tsx index c76eef35e3c6..d06a3d82741e 100644 --- a/static/app/stores/guideStore.spec.tsx +++ b/static/app/stores/guideStore.spec.tsx @@ -25,15 +25,14 @@ describe('GuideStore', () => { GuideStore.init(); data = [ { - guide: 'issue', + guide: 'trace_view', seen: false, }, {guide: 'issue_stream', seen: true}, ]; - GuideStore.registerAnchor('issue_header_stats'); - GuideStore.registerAnchor('issue_sidebar_owners'); - GuideStore.registerAnchor('breadcrumbs'); GuideStore.registerAnchor('issue_stream'); + GuideStore.registerAnchor('trace_view_guide_row'); + GuideStore.registerAnchor('trace_view_guide_row_details'); }); afterEach(() => { @@ -44,14 +43,12 @@ describe('GuideStore', () => { GuideStore.fetchSucceeded(data); // Should pick the first non-seen guide in alphabetic order. expect(GuideStore.getState().currentStep).toBe(0); - expect(GuideStore.getState().currentGuide?.guide).toBe('issue'); + expect(GuideStore.getState().currentGuide?.guide).toBe('trace_view'); // Should prune steps that don't have anchors. - expect(GuideStore.getState().currentGuide?.steps).toHaveLength(3); + expect(GuideStore.getState().currentGuide?.steps).toHaveLength(2); GuideStore.nextStep(); expect(GuideStore.getState().currentStep).toBe(1); - GuideStore.nextStep(); - expect(GuideStore.getState().currentStep).toBe(2); GuideStore.closeGuide(); expect(GuideStore.getState().currentGuide).toBeNull(); }); @@ -59,18 +56,18 @@ describe('GuideStore', () => { it('should force show a guide with #assistant', () => { data = [ { - guide: 'issue', + guide: 'issue_stream', seen: true, }, - {guide: 'issue_stream', seen: false}, + {guide: 'trace_view', seen: false}, ]; GuideStore.fetchSucceeded(data); window.location.hash = '#assistant'; window.dispatchEvent(new Event('load')); - expect(GuideStore.getState().currentGuide?.guide).toBe('issue'); - GuideStore.closeGuide(); expect(GuideStore.getState().currentGuide?.guide).toBe('issue_stream'); + GuideStore.closeGuide(); + expect(GuideStore.getState().currentGuide?.guide).toBe('trace_view'); window.location.hash = ''; }); @@ -85,10 +82,10 @@ describe('GuideStore', () => { it('should record analytics events when guide is cued', () => { const spy = jest.spyOn(GuideStore, 'recordCue'); GuideStore.fetchSucceeded(data); - expect(spy).toHaveBeenCalledWith('issue'); + expect(spy).toHaveBeenCalledWith('trace_view'); expect(trackAnalytics).toHaveBeenCalledWith('assistant.guide_cued', { - guide: 'issue', + guide: 'trace_view', organization: null, }); @@ -105,7 +102,7 @@ describe('GuideStore', () => { it('only shows guides with server data and content', () => { data = [ { - guide: 'issue', + guide: 'issue_stream', seen: true, }, { diff --git a/static/app/views/issueDetails/eventNavigation/index.tsx b/static/app/views/issueDetails/eventNavigation/index.tsx index a3dfed994407..dd07dd97283f 100644 --- a/static/app/views/issueDetails/eventNavigation/index.tsx +++ b/static/app/views/issueDetails/eventNavigation/index.tsx @@ -246,9 +246,9 @@ export function IssueEventNavigation({event, group}: IssueEventNavigationProps) tourContext={IssueDetailsTourContext} id={IssueDetailsTour.NAVIGATION} - title={t('Compare events')} + title={t('Compare and copy events')} description={t( - 'Review the events associated with an issue. Compare the first, latest, or recommended event to see what changed.' + 'Review the events associated with an issue. Compare the first, latest, or recommended event to see what changed, or use Copy as to copy the issue details as Markdown.' )} > {tourProps => ( diff --git a/static/app/views/issueDetails/groupDetails.tsx b/static/app/views/issueDetails/groupDetails.tsx index 7ffa24cec83f..85dfc61939d9 100644 --- a/static/app/views/issueDetails/groupDetails.tsx +++ b/static/app/views/issueDetails/groupDetails.tsx @@ -61,6 +61,7 @@ import {useMergedIssuesDrawer} from 'sentry/views/issueDetails/hooks/useMergedIs import {useSimilarIssuesDrawer} from 'sentry/views/issueDetails/hooks/useSimilarIssuesDrawer'; import { ISSUE_DETAILS_TOUR_GUIDE_KEY, + IssueDetailsTourModal, IssueDetailsTourContext, ORDERED_ISSUE_DETAILS_TOUR, type IssueDetailsTour, @@ -836,6 +837,7 @@ function GroupDetailsPageContent(props: GroupDetailsPageContentProps) { orderedStepIds={ORDERED_ISSUE_DETAILS_TOUR} TourContext={IssueDetailsTourContext} > + { initialRouterConfig, }); - expect(await screen.findByRole('region', {name: 'tags'})).toBeInTheDocument(); + expect(await screen.findByRole('region', {name: 'Tags'})).toBeInTheDocument(); const highlights = screen.getByRole('region', {name: 'Highlights'}); expect(within(highlights).getByRole('button', {name: 'Edit'})).toBeInTheDocument(); diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx index fc2133f4b9cd..e22daa23a0f0 100644 --- a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx +++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx @@ -1,8 +1,6 @@ import {Fragment, useMemo, useRef} from 'react'; -import {ClassNames} from '@emotion/react'; import Feature from 'sentry/components/acl/feature'; -import {GuideAnchor} from 'sentry/components/assistant/guideAnchor'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; import {BreadcrumbsDataSection} from 'sentry/components/events/breadcrumbs/breadcrumbsDataSection'; import {EventContexts} from 'sentry/components/events/contexts'; @@ -173,79 +171,67 @@ export function EventDetailsContent({ {isMetricKitHang ? ( ) : ( - /* Wrapping all stacktrace components since multiple could appear */ - - {({css}) => ( - - {shouldShowTombstonesBanner(event) && !isSampleError && ( - - + {shouldShowTombstonesBanner(event) && !isSampleError && ( + + + + )} + {defined(eventEntries[EntryType.EXCEPTION]) && ( + + {shouldUseNewStackTrace ? ( + + ) : ( + + )} + + )} + {issueTypeConfig.stacktrace.enabled && + defined(eventEntries[EntryType.STACKTRACE]) && ( + + {shouldUseNewStackTrace ? ( + - - )} - {defined(eventEntries[EntryType.EXCEPTION]) && ( - - {shouldUseNewStackTrace ? ( - - ) : ( - - )} - - )} - {issueTypeConfig.stacktrace.enabled && - defined(eventEntries[EntryType.STACKTRACE]) && ( - - {shouldUseNewStackTrace ? ( - - ) : ( - - )} - - )} - {defined(eventEntries[EntryType.THREADS]) && ( - - - - )} - + )} + + )} + {defined(eventEntries[EntryType.THREADS]) && ( + + + )} - + )} {isANR && ( diff --git a/static/app/views/issueDetails/issueDetailsTour.tsx b/static/app/views/issueDetails/issueDetailsTour.tsx index 8a26ed4764ee..d8145fb09346 100644 --- a/static/app/views/issueDetails/issueDetailsTour.tsx +++ b/static/app/views/issueDetails/issueDetailsTour.tsx @@ -1,6 +1,14 @@ -import {createContext} from 'react'; +import {createContext, useContext, useEffect, useRef} from 'react'; + +import issueDetailsPreview from 'sentry-images/issue_details/issue-details-preview.png'; + +import {useModal} from '@sentry/scraps/modal'; import type {TourContextType} from 'sentry/components/tours/tourContext'; +import {useAssistant, useMutateAssistant} from 'sentry/components/tours/useAssistant'; +import {t} from 'sentry/locale'; +import {trackAnalytics} from 'sentry/utils/analytics'; +import {useOrganization} from 'sentry/utils/useOrganization'; export const enum IssueDetailsTour { /** Trends and aggregates, the graph, and tag distributions */ @@ -27,6 +35,104 @@ export const ORDERED_ISSUE_DETAILS_TOUR = [ ]; export const ISSUE_DETAILS_TOUR_GUIDE_KEY = 'tour.issue_details'; +const ISSUE_DETAILS_TOUR_FORCE_HASH = '#issue-details-tour'; export const IssueDetailsTourContext = createContext | null>(null); + +function useIssueDetailsTourModal() { + const {openModal} = useModal(); + const organization = useOrganization(); + const hasOpenedTourModal = useRef(false); + const {isRegistered, currentStepId, startTour, endTour} = useContext( + IssueDetailsTourContext + )!; + const {data: assistantData} = useAssistant({ + notifyOnChangeProps: ['data'], + }); + const {mutate: mutateAssistant} = useMutateAssistant(); + const forceShowTourModal = window.location.hash === ISSUE_DETAILS_TOUR_FORCE_HASH; + const hasUnseenIssueDetailsTour = + assistantData?.find(item => item.guide === ISSUE_DETAILS_TOUR_GUIDE_KEY)?.seen === + false; + + const shouldShowTourModal = + !process.env.IS_ACCEPTANCE_TEST && + currentStepId === null && + (forceShowTourModal || hasUnseenIssueDetailsTour); + + useEffect(() => { + if (!isRegistered || !shouldShowTourModal || hasOpenedTourModal.current) { + return; + } + + let cancelled = false; + const dismissTour = () => { + mutateAssistant({ + guide: ISSUE_DETAILS_TOUR_GUIDE_KEY, + status: 'dismissed', + }); + endTour(); + trackAnalytics('issue_details.tour.skipped', {organization}); + }; + + hasOpenedTourModal.current = true; + void import('sentry/components/tours/startTour').then( + ({StartTourModal, startTourModalCss}) => { + if (cancelled) { + return; + } + + openModal( + props => ( + { + startTour(); + trackAnalytics('issue_details.tour.started', { + organization, + method: 'modal', + }); + }} + /> + ), + { + modalCss: startTourModalCss, + onClose: reason => { + if (reason) { + dismissTour(); + } + }, + } + ); + } + ); + + return () => { + cancelled = true; + }; + }, [ + endTour, + forceShowTourModal, + isRegistered, + mutateAssistant, + openModal, + organization, + shouldShowTourModal, + startTour, + ]); +} + +export function IssueDetailsTourModal() { + useIssueDetailsTourModal(); + return null; +}