From da8d53b3bca6855d9d8e228fa825115e18b66689 Mon Sep 17 00:00:00 2001 From: Jason Swartz Date: Tue, 31 Mar 2026 09:09:17 -0700 Subject: [PATCH 01/51] fix(organization) Fix an issue with deleting an older Organization (#111869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the task tries to delete sentry_environment rows, Django's cascade sees that workflow_engine_workflow rows still reference them. It tries to cascade-delete those workflows in Python — but those rows haven't gone through their own proper deletion task yet, and if Workflow has any child relations or outbox requirements, the inline cascade may fail or leave orphans. This is a deletion ordering bug introduced when Workflow was added to the org deletion task. Workflow should be appended before Environment in the relation list, not after. Since it has an FK pointing at Environment, it needs to be cleaned up first. Fixed by moving the Workflow relation earlier in get_child_relations, before Environment. ## Investigation I ran a query for child rows of an organization that was failing to delete, and `Environment` was the top in the list that still had undeleted rows. This indicated a dependent table that was not getting rows related to this Organization removed. table_name | row_count -- | -- sentry_externalissue | 748   sentry_promptsactivity | 183   sentry_environment | 177 workflow_engine_workflow | 9 sentry_dashboard | 9 sentry_discoversavedquery | 7 --- src/sentry/deletions/defaults/organization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/deletions/defaults/organization.py b/src/sentry/deletions/defaults/organization.py index 879f46cbcf6b..96c82b1103e8 100644 --- a/src/sentry/deletions/defaults/organization.py +++ b/src/sentry/deletions/defaults/organization.py @@ -47,6 +47,7 @@ def get_child_relations(self, instance: Organization) -> list[BaseRelation]: AlertRule, Release, Project, + Workflow, Environment, Dashboard, TeamKeyTransaction, @@ -64,7 +65,6 @@ def get_child_relations(self, instance: Organization) -> list[BaseRelation]: task=DiscoverSavedQueryDeletionTask, ) ) - relations.append(ModelRelation(Workflow, {"organization_id": instance.id})) return relations From 0e78176a52933d2f241c9449fda67459b3365231 Mon Sep 17 00:00:00 2001 From: Michal Kuffa Date: Tue, 31 Mar 2026 18:24:39 +0200 Subject: [PATCH 02/51] perf(events): Use cached group lookup on existing grouphash association (#110619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use `get_from_cache` instead of a direct database `get` when looking up a Group by ID during event ingestion in `handle_existing_grouphash()`. This is the hottest query path in event ingestion — every event that matches an existing group (the vast majority) executes `SELECT * FROM sentry_groupedmessage WHERE id = ? LIMIT ?` directly against the database. Switching to `get_from_cache` serves this from Django's cache layer first, only falling back to the database on a miss. Popular groups receiving many events will see a high cache hit rate. --------- Co-authored-by: Claude Opus 4.6 --- src/sentry/event_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/event_manager.py b/src/sentry/event_manager.py index d366f9f37ff5..5f7a3bbf52a8 100644 --- a/src/sentry/event_manager.py +++ b/src/sentry/event_manager.py @@ -1447,7 +1447,7 @@ def handle_existing_grouphash( # this function had races around group creation which made this race # more user visible. For more context, see 84c6f75a and d0e22787, as # well as GH-5085. - group = Group.objects.get(id=existing_grouphash.group_id) + group = Group.objects.get_from_cache(id=existing_grouphash.group_id, use_replica=False) # As far as we know this has never happened, but in theory at least, the error event hashing # algorithm and other event hashing algorithms could come up with the same hash value in the From 13cf85c4c09282c99c53055a0a1e666eb63cfae2 Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Tue, 31 Mar 2026 09:28:23 -0700 Subject: [PATCH 03/51] Remove Add Billing Metric Usage admin UI (#111805) The AdminRecordUsageEndpoint that this UI calls is being removed from getsentry as part of the billing platform migration. --- .../components/addBillingMetricUsage.tsx | 260 ------------------ static/gsAdmin/views/customerDetails.tsx | 13 - 2 files changed, 273 deletions(-) delete mode 100644 static/gsAdmin/components/addBillingMetricUsage.tsx diff --git a/static/gsAdmin/components/addBillingMetricUsage.tsx b/static/gsAdmin/components/addBillingMetricUsage.tsx deleted file mode 100644 index 4fbbb44d0a42..000000000000 --- a/static/gsAdmin/components/addBillingMetricUsage.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import {Fragment, useState} from 'react'; - -import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; -import type {ModalRenderProps} from 'sentry/actionCreators/modal'; -import {openModal} from 'sentry/actionCreators/modal'; -import {InputField} from 'sentry/components/forms/fields/inputField'; -import {NumberField} from 'sentry/components/forms/fields/numberField'; -import {SelectField} from 'sentry/components/forms/fields/selectField'; -import {Form} from 'sentry/components/forms/form'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import type {Organization} from 'sentry/types/organization'; -import type {Project} from 'sentry/types/project'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {useApiQuery} from 'sentry/utils/queryClient'; -import {useApi} from 'sentry/utils/useApi'; - -function getDateString(date: Date): string { - // returns date in YYYY-MM-DD format - return date.toISOString().split('T')[0]!; -} - -type CategoryInfo = { - api_name: string; - billed_category: number; - display_name: string; - name: string; - order: number; - product_name: string; - singular: string; - tally_type: number; -}; - -type BillingConfig = { - category_info: Record; - outcomes: Record; - reason_codes: Record; -}; - -type AdminRecordUsageRequest = { - data_category: number; - // YYYY-MM-DD format - date: string; - outcome: number; - project_id: number; - quantity: number; - reason_code: number; -}; - -type Props = { - onSuccess: () => void; - organization: Organization; -}; - -type ModalProps = Props & ModalRenderProps; - -function AddBillingMetricUsageModal({ - onSuccess, - organization, - closeModal, - Header, - Body, -}: ModalProps) { - const api = useApi(); - const [projectID, setProjectID] = useState(null); - const [dataCategory, setDataCategory] = useState(null); - const [quantity, setQuantity] = useState(0); - // set default outcome to 0 (ACCEPTED) - const [outcome, setOutcome] = useState(0); - // set default reason code to 0 (DEFAULT) - const [reason_code, setReasonCode] = useState(0); - const [date, setDate] = useState(new Date()); - const orgSlug = organization.slug; - - const {data: billingConfig = null, isPending: isLoadingBillingConfig} = - useApiQuery([getApiUrl('/billing-config/')], { - staleTime: Infinity, - }); - - const {data: projects = [], isPending: isLoadingProjects} = useApiQuery( - [ - getApiUrl(`/organizations/$organizationIdOrSlug/projects/`, { - path: {organizationIdOrSlug: orgSlug}, - }), - { - query: {all_projects: '1'}, - }, - ], - { - staleTime: Infinity, - } - ); - - if (isLoadingBillingConfig || isLoadingProjects || !billingConfig) { - return ( - -
Add Billing Metric Usage
- - - -
- ); - } - - const dataCategoryChoices = Object.entries(billingConfig.category_info).map( - ([key, value]) => { - const billingMetric = Number(key); - return [billingMetric, `${value.display_name} (${billingMetric})`] as [ - number, - string, - ]; - } - ); - - const outcomeChoices = Object.entries(billingConfig.outcomes).map(([key, value]) => { - const outcomeID = Number(key); - return [outcomeID, `${value} (${outcomeID})`] as [number, string]; - }); - - const reasonCodeChoices = Object.entries(billingConfig.reason_codes).map( - ([key, value]) => { - const reasonCodeID = Number(key); - return [reasonCodeID, `${value} (${reasonCodeID})`] as [number, string]; - } - ); - - const onSubmit = () => { - if (projectID === null || dataCategory === null || quantity <= 0) { - return; - } - - const data: AdminRecordUsageRequest = { - project_id: projectID, - quantity, - data_category: dataCategory, - outcome, - reason_code, - date: getDateString(date), - }; - - api.request(`/customers/${orgSlug}/record-usage/`, { - method: 'POST', - data, - success: () => { - addSuccessMessage('Created billing metric usage.'); - closeModal(); - onSuccess(); - }, - error: () => { - addErrorMessage('Unable to create billing metric usage.'); - }, - }); - }; - - return ( - -
Add Billing Metric Usage
- -
Create and add mock billing metric usage for testing purposes.
-
-
- { - setProjectID(value); - }} - choices={projects.map(project => [ - Number(project.id), - `${project.slug} - ${project.name}`, - ])} - required - /> - { - setDataCategory(value); - }} - choices={dataCategoryChoices} - required - /> - Enter amount of usage for the chosen data category. - } - name="quantity" - value={quantity} - defaultValue={quantity} - min={0} - onChange={(value: number) => { - setQuantity(value); - }} - required - /> - { - setOutcome(value); - }} - choices={outcomeChoices} - required - /> - { - setReasonCode(value); - }} - choices={reasonCodeChoices} - required - /> - { - setDate(new Date(value)); - }} - /> - - -
- ); -} - -type Options = Pick; - -export const addBillingMetricUsage = (opts: Options) => - openModal(deps => , { - closeEvents: 'escape-key', - }); diff --git a/static/gsAdmin/views/customerDetails.tsx b/static/gsAdmin/views/customerDetails.tsx index 29adb08e5e6a..0396f5bb8a85 100644 --- a/static/gsAdmin/views/customerDetails.tsx +++ b/static/gsAdmin/views/customerDetails.tsx @@ -34,7 +34,6 @@ import {useNavigate} from 'sentry/utils/useNavigate'; import {useParams} from 'sentry/utils/useParams'; import {OrganizationContext} from 'sentry/views/organizationContext'; -import {addBillingMetricUsage} from 'admin/components/addBillingMetricUsage'; import {addGiftBudgetAction} from 'admin/components/addGiftBudgetAction'; import {AddGiftEventsAction} from 'admin/components/addGiftEventsAction'; import {triggerAddToStartupProgramModal} from 'admin/components/addToStartupProgramAction'; @@ -814,18 +813,6 @@ export function CustomerDetails() { }); }, }, - { - key: 'addBillingMetricUsage', - name: 'Add Billing Metric Usage', - help: 'Create and add Billing Metric Usage.', - skipConfirmModal: true, - visible: hasAdminTestFeatures, - onAction: () => - addBillingMetricUsage({ - onSuccess: reloadData, - organization, - }), - }, { key: 'deleteBillingMetricHistory', name: 'Delete Billing Metric History', From dfb32497ab19cdb430a299f7055da82a46df1142 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki <44422760+DominikB2014@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:28:43 -0400 Subject: [PATCH 04/51] fix(dashboards): Render star icon header for is_starred_transaction column (#111885) Show a yellow star icon as the column header for `is_starred_transaction` in dashboard widget tables instead of the raw field name. The icon matches the star used in the insights overview tables. - Render `` in the table widget header when `is_starred_transaction` is present and no field alias overrides it - Right-align the column via `fieldAlignment` - Disable sorting on the column since it's always a forced primary sort Refs LINEAR-DAIN-1437 image Co-authored-by: Claude Opus 4.6 --- static/app/utils/discover/fields.tsx | 5 +++ .../app/views/dashboards/widgetCard/chart.tsx | 5 ++- .../tableWidgetVisualization.spec.tsx | 37 +++++++++++++++++++ .../tableWidget/tableWidgetVisualization.tsx | 27 +++++++++++--- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/static/app/utils/discover/fields.tsx b/static/app/utils/discover/fields.tsx index 06fa349e0d32..470e9cdfeb6e 100644 --- a/static/app/utils/discover/fields.tsx +++ b/static/app/utils/discover/fields.tsx @@ -24,6 +24,7 @@ import { } from 'sentry/views/dashboards/widgetBuilder/releaseWidget/fields'; import {STARFISH_FIELDS} from 'sentry/views/insights/common/utils/constants'; import {STARFISH_AGGREGATION_FIELDS} from 'sentry/views/insights/constants'; +import {SpanFields} from 'sentry/views/insights/types'; import {CONDITIONS_ARGUMENTS, DiscoverDatasets, WEB_VITALS_QUALITY} from './types'; @@ -1404,6 +1405,10 @@ export function fieldAlignment( columnType?: ColumnValueType, metadata?: Record ): Alignments { + if (columnName === SpanFields.IS_STARRED_TRANSACTION) { + return 'right'; + } + let align: Alignments = 'left'; if (columnType) { diff --git a/static/app/views/dashboards/widgetCard/chart.tsx b/static/app/views/dashboards/widgetCard/chart.tsx index 0526a6108f1b..5efdb037e42a 100644 --- a/static/app/views/dashboards/widgetCard/chart.tsx +++ b/static/app/views/dashboards/widgetCard/chart.tsx @@ -96,6 +96,7 @@ import {Thresholds as ThresholdsPlottable} from 'sentry/views/dashboards/widgets import {WheelWidgetVisualization} from 'sentry/views/dashboards/widgets/wheelWidget/wheelWidgetVisualization'; import {Actions} from 'sentry/views/discover/table/cellAction'; import {decodeColumnOrder} from 'sentry/views/discover/utils'; +import {SpanFields} from 'sentry/views/insights/types'; import type {SpanResponse} from 'sentry/views/insights/types'; import {WidgetCardConfidenceFooter} from './confidenceFooter'; @@ -614,7 +615,9 @@ function TableComponent({ tableResults[i]?.meta ).map((column, index) => { let sortable = false; - if (widget.widgetType === WidgetType.RELEASE) { + if (column.key === SpanFields.IS_STARRED_TRANSACTION) { + sortable = false; + } else if (widget.widgetType === WidgetType.RELEASE) { sortable = isAggregateField(column.key); } else if (widget.widgetType !== WidgetType.ISSUE) { sortable = true; diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.spec.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.spec.tsx index ac7e195a34db..63985b776dd2 100644 --- a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.spec.tsx +++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.spec.tsx @@ -63,6 +63,43 @@ describe('TableWidgetVisualization', () => { expect($headers[1]).toHaveTextContent(columns[1]!.key!); }); + it('Renders star icon for is_starred_transaction column without alias', () => { + const starredTableData: TabularData = { + data: [{is_starred_transaction: true, transaction: '/api/foo'}], + meta: { + fields: {is_starred_transaction: 'boolean', transaction: 'string'}, + units: {is_starred_transaction: null, transaction: null}, + }, + }; + + render(); + + const $headers = screen.getAllByRole('columnheader'); + expect($headers[0]).not.toHaveTextContent('is_starred_transaction'); + expect($headers[0]!.querySelector('svg')).toBeInTheDocument(); + }); + + it('Renders alias text instead of star icon when alias is provided', () => { + const starredTableData: TabularData = { + data: [{is_starred_transaction: true, transaction: '/api/foo'}], + meta: { + fields: {is_starred_transaction: 'boolean', transaction: 'string'}, + units: {is_starred_transaction: null, transaction: null}, + }, + }; + + render( + + ); + + const $headers = screen.getAllByRole('columnheader'); + expect($headers[0]).toHaveTextContent('Starred'); + expect($headers[0]!.querySelector('svg')).not.toBeInTheDocument(); + }); + it('Renders unique number fields correctly', async () => { const tableData: TabularData = { data: [{'span.duration': 123, failure_rate: 0.1, epm: 6}], diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx index b4bfc3318b24..03115d07cef5 100644 --- a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx @@ -5,6 +5,7 @@ import {Tooltip} from '@sentry/scraps/tooltip'; import {COL_WIDTH_UNDEFINED, GridEditable} from 'sentry/components/tables/gridEditable'; import {SortLink} from 'sentry/components/tables/gridEditable/sortLink'; +import {IconStar} from 'sentry/icons'; import {defined} from 'sentry/utils'; import {getSortField} from 'sentry/utils/dashboards/issueFieldRenderers'; import type {TableDataRow} from 'sentry/utils/discover/discoverQuery'; @@ -35,6 +36,7 @@ import { CellAction, copyToClipboard, } from 'sentry/views/discover/table/cellAction'; +import {SpanFields} from 'sentry/views/insights/types'; export type FieldRendererGetter = ( field: string, @@ -233,9 +235,16 @@ export function TableWidgetVisualization(props: TableWidgetVisualizationProps) { grid={{ renderHeadCell: (_tableColumn, columnIndex) => { const column = columnOrder[columnIndex]!; + const isStarredColumn = column.key === SpanFields.IS_STARRED_TRANSACTION; + const hasAlias = !!aliases?.[column.key]; const align = fieldAlignment(column.key, column.type as ColumnValueType); - let name = aliases?.[column.key] || column.key; - if (isEquation(column.key)) name = stripEquationPrefix(name); + let name: React.ReactNode = aliases?.[column.key] || column.key; + if (isStarredColumn && !hasAlias) { + name = ; + } else if (isEquation(column.key)) { + name = stripEquationPrefix(name as string); + } + const tooltipTitle = isStarredColumn && !hasAlias ? column.key : name; const sortColumn = getSortField(column.key) ?? column.key; let direction = undefined; @@ -249,7 +258,7 @@ export function TableWidgetVisualization(props: TableWidgetVisualizationProps) { {name}} + title={{name}} onClick={e => { if (!onChangeSort) return; e.preventDefault(); @@ -376,14 +385,22 @@ TableWidgetVisualization.LoadingPlaceholder = function ({ renderHeadCell: (_tableColumn, columnIndex) => { if (!columns) return null; const column = columns[columnIndex]!; + const isStarredColumn = column.key === SpanFields.IS_STARRED_TRANSACTION; + const hasAlias = !!aliases?.[column.key]; const align = fieldAlignment(column.key, column.type as ColumnValueType); - const name = aliases?.[column.key] || column.key; + const displayAsIcon = isStarredColumn && !hasAlias; + const name: React.ReactNode = displayAsIcon ? ( + + ) : ( + aliases?.[column.key] || column.key + ); + const tooltipTitle = displayAsIcon ? column.key : name; return ( {name}} + title={{name}} direction={undefined} generateSortLink={() => undefined} /> From a5f616028858e38d7d4c0d9cd06a874e6c09907d Mon Sep 17 00:00:00 2001 From: Josh Ferge Date: Tue, 31 Mar 2026 12:36:49 -0400 Subject: [PATCH 05/51] fix(copilot): Update models for GitHub Copilot Tasks API field migrations (#111890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Update `GithubCopilotTask`, `GithubCopilotSession`, and related models to match the current live GitHub Copilot Tasks API response shape - `creator_id`/`owner_id` (int) → `creator`/`owner` (object with `id` field) - `last_updated_at` → `updated_at` - `user_collaborators` changed from `list[int]` to `list[Any]` to handle the upcoming migration from int IDs to User objects - Added new fields: `repository`, `html_url`, `url`, `slug` on collaborators - Removed deprecated `status` field from `GithubCopilotTask` — our code only reads `state` - Removed legacy `{"task": {...}}` response envelope handling and `GithubCopilotTaskResponse` model — the API returns task objects directly now **Context:** GitHub is rolling out breaking changes to their Copilot Tasks API. We verified the current API state by testing against a live repo — `last_updated_at`, `creator_id`, `owner_id`, and the `{"task": ...}` envelope are already gone. `status` is still present alongside `state` but deprecated. `user_collaborators` type change (int → object) is coming soon. Our polling flow (`poll_github_copilot_agents`) only reads `task.state` and `task.artifacts`, so it's unaffected. These changes prevent Pydantic validation failures when parsing API responses with the new field shapes. ## Test plan - [x] All existing tests pass (54 tests across `test_client.py` + `test_coding_agent.py`) - [x] Verified against live GitHub Copilot API --- .../integrations/github_copilot/client.py | 7 +-- .../integrations/github_copilot/models.py | 45 ++++++++++++------- .../github_copilot/test_client.py | 26 +---------- 3 files changed, 31 insertions(+), 47 deletions(-) diff --git a/src/sentry/integrations/github_copilot/client.py b/src/sentry/integrations/github_copilot/client.py index 586ec7fe2e52..99b32ddb741b 100644 --- a/src/sentry/integrations/github_copilot/client.py +++ b/src/sentry/integrations/github_copilot/client.py @@ -8,7 +8,6 @@ from sentry.integrations.github_copilot.models import ( GithubCopilotTask, GithubCopilotTaskRequest, - GithubCopilotTaskResponse, GithubPRFromGraphQL, ) from sentry.seer.autofix.utils import CodingAgentProviderType, CodingAgentState, CodingAgentStatus @@ -98,11 +97,7 @@ def launch(self, *, webhook_url: str, request: CodingAgentLaunchRequest) -> Codi }, ) - response_json = api_response.json - if isinstance(response_json, dict) and "task" in response_json: - task = GithubCopilotTaskResponse.validate(response_json).task - else: - task = GithubCopilotTask.validate(response_json) + task = GithubCopilotTask.validate(api_response.json) agent_id = self.encode_agent_id(owner, repo, task.id) diff --git a/src/sentry/integrations/github_copilot/models.py b/src/sentry/integrations/github_copilot/models.py index 8f952b0b29c5..808499d5da19 100644 --- a/src/sentry/integrations/github_copilot/models.py +++ b/src/sentry/integrations/github_copilot/models.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from pydantic import BaseModel @@ -14,6 +16,18 @@ class GithubCopilotTaskRequest(BaseModel): event_type: str = "sentry" +class GithubCopilotUser(BaseModel): + """Lightweight user/owner reference returned by the GitHub Copilot API.""" + + id: int + + +class GithubCopilotRepository(BaseModel): + """Repository reference returned by the GitHub Copilot API.""" + + id: int + + class GithubCopilotArtifactData(BaseModel): """Data for an artifact - structure varies by type""" @@ -39,13 +53,14 @@ class GithubCopilotSession(BaseModel): id: str name: str | None = None state: str | None = None # queued, in_progress, completed, failed, timed_out - user_id: int | None = None + user: GithubCopilotUser | None = None + owner: GithubCopilotUser | None = None agent_id: int | None = None agent_type: str | None = None resource_type: str | None = None resource_id: int | None = None resource_global_id: str | None = None - last_updated_at: str | None = None + updated_at: str | None = None created_at: str | None = None completed_at: str | None = None event_type: str | None = None @@ -60,6 +75,7 @@ class GithubCopilotAgentCollaborator(BaseModel): agent_type: str | None = None agent_id: int | None = None agent_task_id: str | None = None + slug: str | None = None class GithubCopilotTask(BaseModel): @@ -67,28 +83,23 @@ class GithubCopilotTask(BaseModel): id: str name: str | None = None - creator_id: int | None = None - user_collaborators: list[int] | None = None + # creator/owner changed from int IDs to objects: {"id": int} + creator: GithubCopilotUser | None = None + owner: GithubCopilotUser | None = None + # user_collaborators is transitioning from list[int] to list[User objects]. + # Use list[Any] to accept both during the migration. + user_collaborators: list[Any] | None = None agent_collaborators: list[GithubCopilotAgentCollaborator] | None = None - owner_id: int | None = None - repo_id: int | None = None - status: str | None = None + repository: GithubCopilotRepository | None = None state: str | None = None # queued, in_progress, completed, failed, timed_out session_count: int | None = None artifacts: list[GithubCopilotArtifact] | None = None archived_at: str | None = None - last_updated_at: str | None = None + updated_at: str | None = None created_at: str | None = None sessions: list[GithubCopilotSession] | None = None - - -class GithubCopilotTaskResponse(BaseModel): - """ - Response from GitHub Copilot Tasks API. - The API wraps the task object in a {"task": {...}} envelope. - """ - - task: GithubCopilotTask + html_url: str | None = None + url: str | None = None class GithubPRFromGraphQL(BaseModel): diff --git a/tests/sentry/integrations/github_copilot/test_client.py b/tests/sentry/integrations/github_copilot/test_client.py index d6a43bf76d1d..7bb692999e95 100644 --- a/tests/sentry/integrations/github_copilot/test_client.py +++ b/tests/sentry/integrations/github_copilot/test_client.py @@ -121,7 +121,7 @@ def test_launch_with_created_at(self, mock_post: Mock) -> None: mock_response = Mock() mock_response.json = { "id": "task-123", - "status": "in_progress", + "state": "in_progress", "created_at": "2024-06-01T12:00:00Z", } mock_response.status_code = 200 @@ -143,7 +143,7 @@ def test_launch_with_missing_created_at(self, mock_post: Mock) -> None: mock_response = Mock() mock_response.json = { "id": "task-456", - "status": "in_progress", + "state": "in_progress", } mock_response.status_code = 200 mock_post.return_value = mock_response @@ -159,28 +159,6 @@ def test_launch_with_missing_created_at(self, mock_post: Mock) -> None: assert result.status == CodingAgentStatus.RUNNING assert before <= result.started_at <= after - @patch.object(GithubCopilotAgentClient, "post") - def test_launch_with_legacy_task_envelope(self, mock_post: Mock) -> None: - """Test launch handles the legacy {"task": {...}} response envelope""" - mock_response = Mock() - mock_response.json = { - "task": { - "id": "task-789", - "status": "in_progress", - "created_at": "2024-06-01T12:00:00Z", - } - } - mock_response.status_code = 200 - mock_post.return_value = mock_response - - result = self.copilot_client.launch( - webhook_url="https://example.com/webhook", - request=self._make_launch_request(), - ) - - assert result.id == "getsentry:sentry:task-789" - assert result.status == CodingAgentStatus.RUNNING - @patch.object(GithubCopilotAgentClient, "post") def test_get_pr_from_graphql_success(self, mock_post: Mock) -> None: """Test that get_pr_from_graphql correctly fetches PR info""" From 57ae7b6be389a8407c3b049260b93fd02cde7449 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 31 Mar 2026 09:43:39 -0700 Subject: [PATCH 06/51] ref(seer): Move organizationConfigIntegrationsQueryOptions to be co-located with its caller (#111894) This makes OWNERS file cleaner as everything is within the same folder structure. --- .../organizationConfigIntegrationsQueryOptions.ts} | 0 .../scmIntegrationTree/useScmIntegrationTreeData.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename static/app/{endpoints/organizations/organizationsConfigIntegrationsQueryOptions.ts => components/repositories/scmIntegrationTree/organizationConfigIntegrationsQueryOptions.ts} (100%) diff --git a/static/app/endpoints/organizations/organizationsConfigIntegrationsQueryOptions.ts b/static/app/components/repositories/scmIntegrationTree/organizationConfigIntegrationsQueryOptions.ts similarity index 100% rename from static/app/endpoints/organizations/organizationsConfigIntegrationsQueryOptions.ts rename to static/app/components/repositories/scmIntegrationTree/organizationConfigIntegrationsQueryOptions.ts diff --git a/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts b/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts index 0d3f8a947e05..3c77e52eaa99 100644 --- a/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts +++ b/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts @@ -1,7 +1,7 @@ import {useEffect, useMemo} from 'react'; import {organizationRepositoriesInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useOrganizationRepositories'; -import {organizationConfigIntegrationsQueryOptions} from 'sentry/endpoints/organizations/organizationsConfigIntegrationsQueryOptions'; +import {organizationConfigIntegrationsQueryOptions} from 'sentry/components/repositories/scmIntegrationTree/organizationConfigIntegrationsQueryOptions'; import {organizationIntegrationsQueryOptions} from 'sentry/endpoints/organizations/organizationsIntegrationsQueryOptions'; import type { IntegrationProvider, From 1d2b4ca6e3df80ba668d079b5a1b4ba34db82223 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 31 Mar 2026 09:44:48 -0700 Subject: [PATCH 07/51] feat(supergroups): Add feedback component and experimental badge to drawer (#111859) Add a feedback banner at the top of the supergroup drawer asking users if the grouping is accurate, with thumbs up/down buttons that fire a new analytics event. Also add an experimental badge to the breadcrumbs. image --------- Co-authored-by: Claude Opus 4.6 --- .../analytics/workflowAnalyticsEvents.tsx | 6 ++ .../supergroups/supergroupDrawer.tsx | 41 +++++------ .../supergroups/supergroupFeedback.tsx | 70 +++++++++++++++++++ 3 files changed, 94 insertions(+), 23 deletions(-) create mode 100644 static/app/views/issueList/supergroups/supergroupFeedback.tsx diff --git a/static/app/utils/analytics/workflowAnalyticsEvents.tsx b/static/app/utils/analytics/workflowAnalyticsEvents.tsx index 6ae8631903d0..b8f327c56083 100644 --- a/static/app/utils/analytics/workflowAnalyticsEvents.tsx +++ b/static/app/utils/analytics/workflowAnalyticsEvents.tsx @@ -181,6 +181,11 @@ export type TeamInsightsEventParameters = { 'releases_list.click_add_release_health': { project_id: number; }; + 'supergroup.feedback_submitted': { + choice_selected: boolean; + supergroup_id: number; + user_id: string; + }; 'suspect_commit.feedback_submitted': { choice_selected: boolean; group_owner_id: number; @@ -259,5 +264,6 @@ export const workflowEventMap: Record = { 'releases_list.click_add_release_health': 'Releases List: Click Add Release Health', trace_timeline_clicked: 'Trace Timeline Clicked', trace_timeline_more_events_clicked: 'Trace Timeline More Events Clicked', + 'supergroup.feedback_submitted': 'Supergroup Feedback Submitted', 'suspect_commit.feedback_submitted': 'Suspect Commit Feedback Submitted', }; diff --git a/static/app/views/issueList/supergroups/supergroupDrawer.tsx b/static/app/views/issueList/supergroups/supergroupDrawer.tsx index 885bdd952446..66f378c2bbe6 100644 --- a/static/app/views/issueList/supergroups/supergroupDrawer.tsx +++ b/static/app/views/issueList/supergroups/supergroupDrawer.tsx @@ -1,8 +1,8 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; +import {Badge} from '@sentry/scraps/badge'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; -import {Link} from '@sentry/scraps/link'; import {Heading, Text} from '@sentry/scraps/text'; import { @@ -15,12 +15,11 @@ import {GroupList} from 'sentry/components/issues/groupList'; import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants'; import {IconFocus} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {useOrganization} from 'sentry/utils/useOrganization'; import {StyledMarkedText} from 'sentry/views/issueList/pages/supergroups'; +import {SupergroupFeedback} from 'sentry/views/issueList/supergroups/supergroupFeedback'; import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; export function SupergroupDetailDrawer({supergroup}: {supergroup: SupergroupDetail}) { - const organization = useOrganization(); const placeholderRows = Math.min(supergroup.group_ids.length, 10); const issueIdQuery = `issue.id:[${supergroup.group_ids.join(',')}]`; @@ -28,29 +27,25 @@ export function SupergroupDetailDrawer({supergroup}: {supergroup: SupergroupDeta - - {`SG-${supergroup.id}`} - - ), - }, - ]} - /> - - {t('View All Issues')} ({supergroup.group_ids.length}) - + + + {`SG-${supergroup.id}`} + + ), + }, + ]} + /> + {t('Experimental')} + + diff --git a/static/app/views/issueList/supergroups/supergroupFeedback.tsx b/static/app/views/issueList/supergroups/supergroupFeedback.tsx new file mode 100644 index 000000000000..5af7397183ee --- /dev/null +++ b/static/app/views/issueList/supergroups/supergroupFeedback.tsx @@ -0,0 +1,70 @@ +import {useCallback, useState} from 'react'; +import styled from '@emotion/styled'; + +import {Button} from '@sentry/scraps/button'; +import {Flex} from '@sentry/scraps/layout'; + +import {IconThumb} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {trackAnalytics} from 'sentry/utils/analytics'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {useUser} from 'sentry/utils/useUser'; + +interface SupergroupFeedbackProps { + supergroupId: number; +} + +export function SupergroupFeedback({supergroupId}: SupergroupFeedbackProps) { + const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); + const organization = useOrganization(); + const user = useUser(); + + const handleFeedback = useCallback( + (isAccurate: boolean) => { + trackAnalytics('supergroup.feedback_submitted', { + choice_selected: isAccurate, + supergroup_id: supergroupId, + user_id: user.id, + organization, + }); + + setFeedbackSubmitted(true); + }, + [supergroupId, organization, user] + ); + + return ( + + {feedbackSubmitted ? ( + t('Thanks!') + ) : ( + + {t('Help us improve this feature. Is this grouping accurate?')} + + - ); -} - -const InstallButtonHook = HookOrDefault({ - hookName: 'component:scm-multi-org-install-button', - defaultComponent: InstallationButton, -}); - export function GithubInstallationSelect({ installation_info, organization, @@ -193,20 +159,7 @@ export function GithubInstallationSelect({ )} /> - {organization.features.includes('github-multi-org-upsell-modal') ? ( - i.installation_id === installationID) - ?.count === 0 - } - installationID={installationID} - isSaving={isSaving} - handleSubmit={handleSubmit} - /> - ) : ( - renderInstallationButtonOld() - )} + {renderInstallationButtonOld()} diff --git a/static/gsApp/hooks/githubInstallationSelectInstall.spec.tsx b/static/gsApp/hooks/githubInstallationSelectInstall.spec.tsx deleted file mode 100644 index 8b69fedc9427..000000000000 --- a/static/gsApp/hooks/githubInstallationSelectInstall.spec.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; - -import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; - -import {trackAnalytics} from 'sentry/utils/analytics'; - -import {openUpsellModal} from 'getsentry/actionCreators/modal'; - -import GithubInstallationSelectInstallButton from './githubInstallationSelectInstall'; - -// Mock the functions -jest.mock('getsentry/actionCreators/modal', () => ({ - openUpsellModal: jest.fn(), -})); - -jest.mock('sentry/utils/analytics', () => ({ - trackAnalytics: jest.fn(), -})); - -describe('GithubInstallationSelectInstallButton', () => { - const organization = OrganizationFixture(); - const subscription = SubscriptionFixture({ - organization, - }); - - const defaultProps = { - handleSubmit: jest.fn(), - hasSCMMultiOrg: true, - installationID: '-1', - isSaving: false, - subscription, - }; - - beforeEach(() => { - jest.clearAllMocks(); - MockApiClient.clearMockResponses(); - }); - - it('renders Install button when installationID is -1', async () => { - MockApiClient.addMockResponse({ - url: '/customers/org-slug/', - method: 'GET', - body: subscription, - }); - - render(); - expect(await screen.findByText('Install')).toBeInTheDocument(); - }); - - it('renders Upgrade button when has_scm_multi_org is false and installationID is not -1', () => { - render( - - ); - expect(screen.getByText('Upgrade')).toBeInTheDocument(); - }); - - it('renders Install button when has_scm_multi_org is true and installationID is not -1', () => { - render( - - ); - expect(screen.getByText('Install')).toBeInTheDocument(); - }); - - it('calls handleSubmit when clicked', () => { - render(); - screen.getByRole('button').click(); - expect(defaultProps.handleSubmit).toHaveBeenCalled(); - }); - - it('opens upsell modal and tracks analytics when Upgrade button is clicked', async () => { - MockApiClient.addMockResponse({ - url: '/customers/org-slug/', - method: 'GET', - body: subscription, - }); - - render( - - ); - - await userEvent.click(screen.getByRole('button', {name: 'Upgrade'})); - - expect(trackAnalytics).toHaveBeenCalledWith('github.multi_org.upsell', { - organization, - subscriptionTier: subscription.planTier, - }); - - expect(openUpsellModal).toHaveBeenCalledWith({ - source: 'github.multi_org', - organization, - }); - }); -}); diff --git a/static/gsApp/hooks/githubInstallationSelectInstall.tsx b/static/gsApp/hooks/githubInstallationSelectInstall.tsx deleted file mode 100644 index ec2791139dde..000000000000 --- a/static/gsApp/hooks/githubInstallationSelectInstall.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import {Fragment, useCallback, useRef} from 'react'; -import styled from '@emotion/styled'; - -import {Button} from '@sentry/scraps/button'; -import type {SelectKey} from '@sentry/scraps/compactSelect'; - -import {GlobalModal} from 'sentry/components/globalModal'; -import {IconLightning} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import {trackAnalytics} from 'sentry/utils/analytics'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -import {openUpsellModal} from 'getsentry/actionCreators/modal'; -import {withSubscription} from 'getsentry/components/withSubscription'; -import type {Subscription} from 'getsentry/types'; - -type Props = { - handleSubmit: (e: React.MouseEvent) => void; - hasSCMMultiOrg: boolean; - installationID: SelectKey; - isSaving: boolean; - subscription: Subscription; -}; - -function GithubInstallationSelectInstallButton({ - hasSCMMultiOrg, - installationID, - subscription, - handleSubmit, - isSaving, -}: Props) { - const organization = useOrganization(); - const source = 'github.multi_org'; - const mainContainerRef = useRef(null); - const handleModalClose = useCallback(() => { - mainContainerRef.current?.focus?.(); - }, []); - - if (installationID === '-1' || hasSCMMultiOrg) { - return ( - - {t('Install')} - - ); - } - - return ( - - - } - priority="primary" - onClick={() => { - trackAnalytics(`${source}.upsell`, { - organization, - subscriptionTier: subscription.planTier, - }); - openUpsellModal({source, organization}); - }} - disabled={isSaving || !installationID} - > - {t('Upgrade')} - - - ); -} - -const StyledButton = styled(Button)` - margin-left: ${p => p.theme.space.sm}; - &:not(:disabled) { - background-color: #6c5fc7; - color: #fff; - } -`; - -export default withSubscription(GithubInstallationSelectInstallButton); diff --git a/static/gsApp/registerHooks.tsx b/static/gsApp/registerHooks.tsx index 0dc7f12d49fa..d28046b05e5e 100644 --- a/static/gsApp/registerHooks.tsx +++ b/static/gsApp/registerHooks.tsx @@ -52,7 +52,6 @@ import DisabledMemberTooltip from 'getsentry/hooks/disabledMemberTooltip'; import DisabledMemberView from 'getsentry/hooks/disabledMemberView'; import {FirstPartyIntegrationAdditionalCTA} from 'getsentry/hooks/firstPartyIntegrationAdditionalCTA'; import {FirstPartyIntegrationAlertHook} from 'getsentry/hooks/firstPartyIntegrationAlertHook'; -import GithubInstallationSelectInstallButton from 'getsentry/hooks/githubInstallationSelectInstall'; import {handleGuideUpdate} from 'getsentry/hooks/handleGuideUpdate'; import {handleMonitorCreated} from 'getsentry/hooks/handleMonitorCreated'; import {hookIntegrationFeatures} from 'getsentry/hooks/integrationFeatures'; @@ -260,7 +259,6 @@ const GETSENTRY_HOOKS: Partial = { 'component:data-consent-priority-learn-more': () => DataConsentPriorityLearnMore, 'component:data-consent-org-creation-checkbox': () => DataConsentOrgCreationCheckbox, 'component:organization-membership-settings': () => OrganizationMembershipSettingsForm, - 'component:scm-multi-org-install-button': () => GithubInstallationSelectInstallButton, 'component:metric-alert-quota-message': MetricAlertQuotaMessage, 'component:metric-alert-quota-icon': MetricAlertQuotaIcon, From 391f51767828c149b176e9389b62843f775c5bb3 Mon Sep 17 00:00:00 2001 From: Katie Byers Date: Tue, 31 Mar 2026 13:56:11 -0700 Subject: [PATCH 41/51] ref(grouping): Simplify parameterizer code (#111345) This is handful of refactors to the parameterization code, to make it a bit simpler and more testable. - Allow `ParameterizationRegex` objects to be passed directly to parameterizer during initialization rather than always using the default set. (This should make it easier to test, so we don't have to do a bunch of mocking.) If none are passed, the default set will still be used. - Create the final regex directly in `__init__` rather than in a separate method. - When deciding if the experimental parameterizer is actually experimental, look at the regexes themselves rather than at the regex maps. - When deciding whether to skip testing the experimental parameterizer, look directly at it rather than comparing the regex maps. - Use `lastgroup` to get matched key/original value directly rather than iterating over `groupdict`. - Remove the now-unused regex maps. - Rename a number of things. --- src/sentry/grouping/parameterization.py | 90 +++++++++---------- .../sentry/grouping/test_parameterization.py | 5 +- 2 files changed, 44 insertions(+), 51 deletions(-) diff --git a/src/sentry/grouping/parameterization.py b/src/sentry/grouping/parameterization.py index cdf545466343..dca27064f4e0 100644 --- a/src/sentry/grouping/parameterization.py +++ b/src/sentry/grouping/parameterization.py @@ -1,7 +1,7 @@ import dataclasses import re from collections import defaultdict -from collections.abc import Iterable, Sequence +from collections.abc import Sequence from sentry.utils import metrics @@ -279,58 +279,46 @@ def _get_pattern(self, raw_pattern: str) -> str: ] -# Patterns to use for each match type when not in experimental mode. -DEFAULT_PARAMETERIZATION_REGEXES_MAP = {r.name: r.pattern for r in DEFAULT_PARAMETERIZATION_REGEXES} - -# Patterns to use when in experimental mode. If no experimental pattern exists for a given type of -# match, falls back to the default pattern. -EXPERIMENTAL_PARAMETERIZATION_REGEXES_MAP = { - r.name: r.experimental_pattern if r.experimental_pattern else r.pattern - for r in DEFAULT_PARAMETERIZATION_REGEXES -} - - class Parameterizer: def __init__( self, + # List of `ParameterizationRegex` objects defining the regexes to use. If nothing is passed, + # the default set will be used. + regexes: Sequence[ParameterizationRegex] = DEFAULT_PARAMETERIZATION_REGEXES, # List of `ParameterizationRegex.name` values, used to selectively enable pattern types. To # use all available parameterization, omit this argument. - regex_pattern_keys: Sequence[str] | None = None, + regex_keys: Sequence[str] | None = None, # Whether to use experimental patterns, if available. (Pattern types without an experimental # pattern will fall back to the standard pattern.) use_experimental_regexes: bool = False, ): - self._experimental = ( + # Filter regexes by the specified keys, if given + if regex_keys: + regexes = [r for r in regexes if r.name in regex_keys] + + self.is_experimental = ( use_experimental_regexes # Only mark the parameterizer as experimental if there are actually any experiments # running. If there aren't, then both parameterizers use the default regex patterns, so # the "experimental" parameterizer isn't actually experimental. - and EXPERIMENTAL_PARAMETERIZATION_REGEXES_MAP != DEFAULT_PARAMETERIZATION_REGEXES_MAP + and any(r.experimental_pattern is not None for r in regexes) ) - self._parameterization_regex = self._make_regex_from_patterns( - regex_pattern_keys or DEFAULT_PARAMETERIZATION_REGEXES_MAP.keys() - ) - - def _make_regex_from_patterns(self, pattern_keys: Iterable[str]) -> re.Pattern[str]: - """ - Takes list of pattern keys and returns a compiled regex pattern that matches any of them. - @param pattern_keys: A list of keys to match in the _parameterization_regex_components dict. - @returns: A compiled regex pattern that matches any of the given keys. - @raises: KeyError on pattern key not in the _parameterization_regex_components dict + if self.is_experimental: + pattern_strings = [ + r.experimental_pattern if r.experimental_pattern else r.pattern for r in regexes + ] + else: + pattern_strings = [r.pattern for r in regexes] - The `(?x)` tells the regex compiler to ignore comments and unescaped whitespace, - so we can use newlines and indentation for better legibility in patterns above. - """ - - regexes_map = ( - EXPERIMENTAL_PARAMETERIZATION_REGEXES_MAP - if self._experimental - else DEFAULT_PARAMETERIZATION_REGEXES_MAP + # Combine the individual patterns into one giant regex to check against. (This is faster + # than checking each pattern individually because it entails less overhead.) + self.combined_regex = re.compile( + # The `(?x)` tells the regex compiler to ignore comments and unescaped whitespace, so we + # can use newlines and indentation for better legibility when defining regexes + rf"(?x){'|'.join(pattern_strings)}" ) - return re.compile(rf"(?x){'|'.join(regexes_map[k] for k in pattern_keys)}") - def parameterize(self, input_str: str) -> str: """ Replace all regex matches in the input string with placeholder strings, using the regexes @@ -342,24 +330,32 @@ def parameterize(self, input_str: str) -> str: matches_counter: defaultdict[str, int] = defaultdict(int) def _handle_regex_match(match: re.Match[str]) -> str: - # Find the first (should be only) non-None match entry, and sub in the placeholder. For - # example, given the groupdict item `('hex', '0x40000015')`, this returns '' as a - # replacement for the original value in the string. - for key, value in match.groupdict().items(): - if value is not None: - matches_counter[key] += 1 - return f"<{key}>" - return "" + # Since + # a) our regex consists of a bunch of named capturing groups separated by `|`, + # b) no other capturing groups in the regex are named, and + # c) there's nothing else in the regex, + # there should be exactly one named matching group, making the last matching group also + # the only matching group. + matched_key = match.lastgroup + orig_value = match.groupdict().get( + matched_key or "" # Empty string for mypy appeasment + ) + + if not matched_key or not orig_value: # Insurance - shouldn't happen IRL + return "" + + matches_counter[matched_key] += 1 + return f"<{matched_key}>" with metrics.timer( - "grouping.parameterize", tags={"experimental": self._experimental} + "grouping.parameterize", tags={"experimental": self.is_experimental} ) as metric_tags: - parameterized = self._parameterization_regex.sub(_handle_regex_match, input_str) + parameterized = self.combined_regex.sub(_handle_regex_match, input_str) metric_tags["changed"] = parameterized != input_str - for key, value in matches_counter.items(): + for regex_key, count in matches_counter.items(): # Track the kinds of replacements being made - metrics.incr("grouping.value_parameterized", amount=value, tags={"key": key}) + metrics.incr("grouping.value_parameterized", amount=count, tags={"key": regex_key}) return parameterized diff --git a/tests/sentry/grouping/test_parameterization.py b/tests/sentry/grouping/test_parameterization.py index 3b3de3d32e91..43bf6ef0bf51 100644 --- a/tests/sentry/grouping/test_parameterization.py +++ b/tests/sentry/grouping/test_parameterization.py @@ -11,8 +11,6 @@ ) from sentry.grouping.context import GroupingContext from sentry.grouping.parameterization import ( - DEFAULT_PARAMETERIZATION_REGEXES_MAP, - EXPERIMENTAL_PARAMETERIZATION_REGEXES_MAP, experimental_parameterizer, parameterizer, ) @@ -221,8 +219,7 @@ def test_default_parameterizer_misses_experimental_cases( @pytest.mark.skipif( - EXPERIMENTAL_PARAMETERIZATION_REGEXES_MAP == DEFAULT_PARAMETERIZATION_REGEXES_MAP, - reason="no experimental regexes to test", + not experimental_parameterizer.is_experimental, reason="no experimental regexes to test" ) @pytest.mark.parametrize(("name", "input", "expected"), standard_cases + experimental_cases) def test_experimental_parameterization(name: str, input: str, expected: str) -> None: From 7abfaf792678a459a9b8cf94107ce6948e2465e3 Mon Sep 17 00:00:00 2001 From: Lyn Nagara <1779792+lynnagara@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:04:13 -0700 Subject: [PATCH 42/51] ref(cells): Update org creation testutils to pass cell kwarg (#111819) --- .github/CODEOWNERS | 1 + src/sentry/testutils/factories.py | 24 +++++---- src/sentry/testutils/helpers/apigateway.py | 2 +- tests/acceptance/test_proxy.py | 8 +-- .../api/endpoints/test_organization_fork.py | 2 +- ...on_region.py => test_organization_cell.py} | 22 ++++---- .../endpoints/test_organization_details.py | 14 ++--- .../test_cell_organization_provisioning.py | 6 +-- tests/sentry/hybridcloud/test_cell.py | 4 +- .../hybridcloud/test_organizationmapping.py | 16 +++--- .../jira/test_sentry_issue_details.py | 4 +- .../integrations/parsers/test_gitlab.py | 2 +- .../integrations/parsers/test_jira.py | 4 +- tests/sentry/middleware/test_proxy.py | 4 +- .../test_sentry_app_webhook_requests.py | 2 +- .../sentry_apps/models/test_sentryapp.py | 4 +- .../sentry_apps/services/test_app_request.py | 2 +- .../sentry_apps/services/test_hook_service.py | 4 +- .../endpoints/test_org_cell_mappings.py | 22 ++++---- tests/sentry/types/test_cell.py | 12 ++--- .../users/api/endpoints/test_user_regions.py | 52 +++++++++---------- tests/sentry/users/models/test_user.py | 18 +++---- 22 files changed, 116 insertions(+), 113 deletions(-) rename tests/sentry/core/endpoints/{test_organization_region.py => test_organization_cell.py} (92%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 25b0790a64d2..d55a776caa52 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -891,6 +891,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get # Cell architecture /.agents/skills/cell-architecture @getsentry/sre-infrastructure-engineering +tests/sentry/core/endpoints/test_organization_cell.py @getsentry/sre-infrastructure-engineering # End of cell architecture # Foundational Storage diff --git a/src/sentry/testutils/factories.py b/src/sentry/testutils/factories.py index a3b1935bd841..aff0cddbce29 100644 --- a/src/sentry/testutils/factories.py +++ b/src/sentry/testutils/factories.py @@ -378,22 +378,26 @@ def _set_sample_rate_from_error_sampling(normalized_data: MutableMapping[str, An class Factories: @staticmethod @assume_test_silo_mode(SiloMode.CELL) - def create_organization(name=None, owner=None, region: Cell | str | None = None, **kwargs): + def create_organization(name=None, owner=None, cell: Cell | str | None = None, **kwargs): + # TODO(cells): Remove once getsentry passes cell everywhere + if not cell: + cell = kwargs.pop("region", None) + if not name: name = petname.generate(2, " ", letters=10).title() with contextlib.ExitStack() as ctx: - if region is None or SiloMode.get_current_mode() == SiloMode.MONOLITH: - region_name = get_local_cell().name + if cell is None or SiloMode.get_current_mode() == SiloMode.MONOLITH: + cell_name = get_local_cell().name else: - if isinstance(region, Cell): - region_name = region.name + if isinstance(cell, Cell): + cell_name = cell.name else: - region_obj = get_cell_by_name(region) # Verify it exists - region_name = region_obj.name + cell_obj = get_cell_by_name(cell) # Verify it exists + cell_name = cell_obj.name ctx.enter_context( - override_settings(SILO_MODE=SiloMode.CELL, SENTRY_REGION=region_name) + override_settings(SILO_MODE=SiloMode.CELL, SENTRY_REGION=cell_name) ) with outbox_context(flush=False): @@ -403,7 +407,7 @@ def create_organization(name=None, owner=None, region: Cell | str | None = None, # Organization mapping creation relies on having a matching org slug reservation OrganizationSlugReservation( organization_id=org.id, - cell_name=region_name, + cell_name=cell_name, user_id=owner.id if owner else -1, slug=org.slug, ).save(unsafe_write=True) @@ -430,7 +434,7 @@ def create_org_mapping(org=None, **kwds): kwds.setdefault("slug", org.slug) kwds.setdefault("name", org.name) kwds.setdefault("idempotency_key", uuid4().hex) - kwds.setdefault("region_name", "na") + kwds.setdefault("cell_name", "na") return OrganizationMapping.objects.create(**kwds) @staticmethod diff --git a/src/sentry/testutils/helpers/apigateway.py b/src/sentry/testutils/helpers/apigateway.py index bc3cde3f74f5..1c78bdd2c163 100644 --- a/src/sentry/testutils/helpers/apigateway.py +++ b/src/sentry/testutils/helpers/apigateway.py @@ -255,7 +255,7 @@ def setUp(self): headers={"test": "header"}, ) - self.organization = self.create_organization(region=self.CELL) + self.organization = self.create_organization(cell=self.CELL) # Echos the request body and header back for verification def return_request_body(request: httpx.Request): diff --git a/tests/acceptance/test_proxy.py b/tests/acceptance/test_proxy.py index 98c8a39871bd..518fd47dd6ca 100644 --- a/tests/acceptance/test_proxy.py +++ b/tests/acceptance/test_proxy.py @@ -21,7 +21,7 @@ from sentry.testutils.silo import cell_silo_test from sentry.types.cell import Cell from sentry.utils import json -from tests.sentry.middleware.test_proxy import test_region +from tests.sentry.middleware.test_proxy import test_cell @pytest.fixture(scope="function") @@ -31,7 +31,7 @@ def local_live_server(request: pytest.FixtureRequest, live_server: LiveServer) - request.node.live_server = live_server -@cell_silo_test(cells=[test_region]) +@cell_silo_test(cells=[test_cell]) @pytest.mark.usefixtures("local_live_server") class EndToEndAPIProxyTest(TransactionTestCase): live_server: LiveServer @@ -50,11 +50,11 @@ def test_through_api_gateway(self) -> None: return self.client = APIClient() - config = asdict(test_region) + config = asdict(test_cell) config["address"] = self.live_server.url with override_cells([Cell(**config)]): - self.organization = Factories.create_organization(owner=self.user, region="us") + self.organization = Factories.create_organization(owner=self.user, cell="us") self.api_key = Factories.create_api_key( organization=self.organization, scope_list=["org:write", "org:admin", "team:write"] ) diff --git a/tests/sentry/api/endpoints/test_organization_fork.py b/tests/sentry/api/endpoints/test_organization_fork.py index 9953bdd548d4..4115540715e9 100644 --- a/tests/sentry/api/endpoints/test_organization_fork.py +++ b/tests/sentry/api/endpoints/test_organization_fork.py @@ -45,7 +45,7 @@ def setUp(self) -> None: self.existing_org = self.create_organization( name=self.requested_org_slug, owner=self.existing_org_owner, - region=EXPORTING_TEST_REGION, + cell=EXPORTING_TEST_REGION, ) @override_options({"relocation.enabled": True, "relocation.daily-limit.small": 1}) diff --git a/tests/sentry/core/endpoints/test_organization_region.py b/tests/sentry/core/endpoints/test_organization_cell.py similarity index 92% rename from tests/sentry/core/endpoints/test_organization_region.py rename to tests/sentry/core/endpoints/test_organization_cell.py index 9da3ba39c9e8..81d087076527 100644 --- a/tests/sentry/core/endpoints/test_organization_region.py +++ b/tests/sentry/core/endpoints/test_organization_cell.py @@ -13,8 +13,8 @@ class OrganizationRegionTest(APITestCase): def setUp(self) -> None: super().setUp() self.org_owner = self.create_user() - us_region = get_cell_by_name("us") - self.org = self.create_organization(owner=self.org_owner, region=us_region) + us_cell = get_cell_by_name("us") + self.org = self.create_organization(owner=self.org_owner, cell=us_cell) self.test_project = self.create_project(organization=self.org, name="test_project") def create_internal_integration_for_org(self, org, user, scopes: list[str]): @@ -26,8 +26,8 @@ def create_internal_integration_for_org(self, org, user, scopes: list[str]): return (internal_integration, integration_token) - def create_auth_token_for_org(self, org: Organization, region: Cell, scopes: list[str]): - locality = get_global_directory().get_locality_for_cell(region.name) + def create_auth_token_for_org(self, org: Organization, cell: Cell, scopes: list[str]): + locality = get_global_directory().get_locality_for_cell(cell.name) assert locality is not None org_auth_token_str = generate_token(org.slug, locality.to_url("")) self.create_org_auth_token( @@ -64,9 +64,9 @@ def test_non_org_member_has_no_access(self) -> None: assert response.status_code == 403 def test_org_auth_token_access_with_org_read(self) -> None: - us_region = get_cell_by_name("us") + us_cell = get_cell_by_name("us") org_auth_token_str = self.create_auth_token_for_org( - region=us_region, org=self.org, scopes=["org:ci"] + cell=us_cell, org=self.org, scopes=["org:ci"] ) response = self.send_get_request_with_auth(self.org.slug, org_auth_token_str) @@ -76,20 +76,18 @@ def test_org_auth_token_access_with_org_read(self) -> None: assert response.status_code == 200 def test_org_auth_token_access_with_incorrect_scopes(self) -> None: - us_region = get_cell_by_name("us") - org_auth_token_str = self.create_auth_token_for_org( - region=us_region, org=self.org, scopes=[] - ) + us_cell = get_cell_by_name("us") + org_auth_token_str = self.create_auth_token_for_org(cell=us_cell, org=self.org, scopes=[]) response = self.send_get_request_with_auth(self.org.slug, org_auth_token_str) assert response.status_code == 403 def test_org_auth_token_access_for_different_organization(self) -> None: - us_region = get_cell_by_name("us") + us_cell = get_cell_by_name("us") other_user = self.create_user() org_auth_token_str = self.create_auth_token_for_org( - region=us_region, org=self.create_organization(owner=other_user), scopes=["org:ci"] + cell=us_cell, org=self.create_organization(owner=other_user), scopes=["org:ci"] ) response = self.send_get_request_with_auth(self.org.slug, org_auth_token_str) diff --git a/tests/sentry/core/endpoints/test_organization_details.py b/tests/sentry/core/endpoints/test_organization_details.py index c6c29cde2c2a..60679a0f2e90 100644 --- a/tests/sentry/core/endpoints/test_organization_details.py +++ b/tests/sentry/core/endpoints/test_organization_details.py @@ -87,10 +87,10 @@ def has_scope(self, scope): return False -regions = create_test_cells("us", "de") +cells = create_test_cells("us", "de") -@cell_silo_test(cells=regions, include_monolith_run=True) +@cell_silo_test(cells=cells, include_monolith_run=True) class OrganizationDetailsTest(OrganizationDetailsTestBase, BaseMetricsLayerTestCase): @property def now(self): @@ -677,7 +677,7 @@ def test_new_orgs_with_options_do_not_get_onboarding_feature_flag(self) -> None: assert "onboarding" not in response.data["features"] -@cell_silo_test(cells=regions) +@cell_silo_test(cells=cells) class OrganizationUpdateTest(OrganizationDetailsTestBase): method = "put" @@ -1227,13 +1227,13 @@ def test_org_mapping_already_taken(self) -> None: res = self.get_error_response(self.organization.slug, slug="taken", status_code=400) assert res.json()["slug"] == ['The slug "taken" is already in use.'] - def test_org_mapping_already_taken_org_in_other_region(self) -> None: - de_region = regions[1] - assert de_region.name == "de" + def test_org_mapping_already_taken_org_in_other_cell(self) -> None: + de_cell = cells[1] + assert de_cell.name == "de" # Create an org, mapping, and slug reservation. For us to reach the RPC conflict, # we need to not have the org record in our database. - conflict = self.create_organization(slug="taken", region=de_region) + conflict = self.create_organization(slug="taken", cell=de_cell) Organization.objects.filter(id=conflict.id).delete() res = self.get_error_response(self.organization.slug, slug="taken", status_code=400) diff --git a/tests/sentry/hybridcloud/services/test_cell_organization_provisioning.py b/tests/sentry/hybridcloud/services/test_cell_organization_provisioning.py index 371e24ba2f8d..3bfd62f6be0b 100644 --- a/tests/sentry/hybridcloud/services/test_cell_organization_provisioning.py +++ b/tests/sentry/hybridcloud/services/test_cell_organization_provisioning.py @@ -235,7 +235,7 @@ def setUp(self) -> None: name="Santry", slug="santry", owner=self.provisioning_user ) - def create_temporary_slug_res(self, organization: Organization, slug: str, region: str) -> None: + def create_temporary_slug_res(self, organization: Organization, slug: str, cell: str) -> None: with ( assume_test_silo_mode(SiloMode.CONTROL), outbox_context(transaction.atomic(router.db_for_write(OrganizationSlugReservation))), @@ -244,7 +244,7 @@ def create_temporary_slug_res(self, organization: Organization, slug: str, regio reservation_type=OrganizationSlugReservationType.TEMPORARY_RENAME_ALIAS, slug=slug, organization_id=organization.id, - cell_name=region, + cell_name=cell, user_id=-1, ).save(unsafe_write=True) @@ -262,7 +262,7 @@ def test_updates_org_slug_when_no_conflicts(self) -> None: desired_slug = "new-santry" # We have to create a temporary slug reservation in order for org mapping drains to proceed self.create_temporary_slug_res( - organization=self.provisioned_org, region="us", slug=desired_slug + organization=self.provisioned_org, cell="us", slug=desired_slug ) result = ( cell_organization_provisioning_rpc_service.update_organization_slug_from_reservation( diff --git a/tests/sentry/hybridcloud/test_cell.py b/tests/sentry/hybridcloud/test_cell.py index a1ddb07cc44b..6b778d1cbc73 100644 --- a/tests/sentry/hybridcloud/test_cell.py +++ b/tests/sentry/hybridcloud/test_cell.py @@ -25,7 +25,7 @@ class CellResolutionTest(TestCase): def setUp(self) -> None: self.target_cell = _TEST_CELLS[0] - self.organization = self.create_organization(region=self.target_cell) + self.organization = self.create_organization(cell=self.target_cell) def test_by_cell_name(self) -> None: resolver = ByCellName() @@ -75,6 +75,6 @@ def test_require_single_organization(self) -> None: cell_resolution.resolve({}) with override_cells(_TEST_CELLS), override_settings(SENTRY_SINGLE_ORGANIZATION=True): - self.create_organization(region=_TEST_CELLS[1]) + self.create_organization(cell=_TEST_CELLS[1]) with pytest.raises(CellResolutionError): cell_resolution.resolve({}) diff --git a/tests/sentry/hybridcloud/test_organizationmapping.py b/tests/sentry/hybridcloud/test_organizationmapping.py index 346b4309911a..593d2ec1d3cb 100644 --- a/tests/sentry/hybridcloud/test_organizationmapping.py +++ b/tests/sentry/hybridcloud/test_organizationmapping.py @@ -57,7 +57,7 @@ def assert_matching_organization_mapping( @control_silo_test(cells=create_test_cells("us"), include_monolith_run=True) class OrganizationMappingServiceControlProvisioningEnabledTest(TransactionTestCase): def test_upsert__create_if_not_found(self) -> None: - self.organization = self.create_organization(name="test name", slug="foobar", region="us") + self.organization = self.create_organization(name="test name", slug="foobar", cell="us") fixture_org_mapping = OrganizationMapping.objects.get(organization_id=self.organization.id) fixture_org_mapping.delete() @@ -77,7 +77,7 @@ def test_upsert__create_if_not_found(self) -> None: assert_matching_organization_mapping(org=self.organization) def test_upsert__customer_id(self) -> None: - self.organization = self.create_organization(name="test name", slug="foobar", region="us") + self.organization = self.create_organization(name="test name", slug="foobar", cell="us") fixture_org_mapping = OrganizationMapping.objects.get(organization_id=self.organization.id) fixture_org_mapping.delete() @@ -123,7 +123,7 @@ def test_upsert__customer_id(self) -> None: assert_matching_organization_mapping(org=self.organization, customer_id=None) def test_upsert__reject_duplicate_slug(self) -> None: - self.organization = self.create_organization(slug="alreadytaken", region="us") + self.organization = self.create_organization(slug="alreadytaken", cell="us") fake_org_id = 7654321 organization_mapping_service.upsert( @@ -135,7 +135,7 @@ def test_upsert__reject_duplicate_slug(self) -> None: assert not OrganizationMapping.objects.filter(organization_id=fake_org_id).exists() def test_upsert__reject_org_slug_reservation_cell_mismatch(self) -> None: - self.organization = self.create_organization(slug="santry", region="us") + self.organization = self.create_organization(slug="santry", cell="us") organization_mapping_service.upsert( organization_id=self.organization.id, @@ -148,7 +148,7 @@ def test_upsert__reject_org_slug_reservation_cell_mismatch(self) -> None: assert_matching_organization_mapping(org=self.organization) def test_upsert__reject_org_slug_reservation_slug_mismatch(self) -> None: - self.organization = self.create_organization(slug="santry", region="us") + self.organization = self.create_organization(slug="santry", cell="us") organization_mapping_service.upsert( organization_id=self.organization.id, @@ -160,7 +160,7 @@ def test_upsert__reject_org_slug_reservation_slug_mismatch(self) -> None: def test_upsert__update_when_slug_matches_temporary_alias(self) -> None: user = self.create_user() - self.organization = self.create_organization(slug="santry", region="us", owner=user) + self.organization = self.create_organization(slug="santry", cell="us", owner=user) primary_slug_res = OrganizationSlugReservation.objects.get( organization_id=self.organization.id ) @@ -183,7 +183,7 @@ def test_upsert__update_when_slug_matches_temporary_alias(self) -> None: ) def test_upsert__reject_when_no_slug_reservation_found(self) -> None: - self.organization = self.create_organization(slug="santry", region="us") + self.organization = self.create_organization(slug="santry", cell="us") with outbox_context(transaction.atomic(router.db_for_write(OrganizationSlugReservation))): OrganizationSlugReservation.objects.filter( organization_id=self.organization.id @@ -206,7 +206,7 @@ def test_upsert__reject_when_no_slug_reservation_found(self) -> None: @cell_silo_test(cells=create_test_cells("us"), include_monolith_run=True) class OrganizationMappingReplicationTest(TransactionTestCase): def test_replicates_all_flags(self) -> None: - self.organization = self.create_organization(slug="santry", region="us") + self.organization = self.create_organization(slug="santry", cell="us") self.organization.flags = 255 # all flags set organization_mapping_service.upsert( organization_id=self.organization.id, diff --git a/tests/sentry/integrations/jira/test_sentry_issue_details.py b/tests/sentry/integrations/jira/test_sentry_issue_details.py index e8b39c1c9641..dcd1aafa94c1 100644 --- a/tests/sentry/integrations/jira/test_sentry_issue_details.py +++ b/tests/sentry/integrations/jira/test_sentry_issue_details.py @@ -194,7 +194,7 @@ def setUp(self) -> None: ) with assume_test_silo_mode(SiloMode.CELL, cell_name="us"): - self.us_org = self.create_organization(region="us") + self.us_org = self.create_organization(cell="us") self.us_project = Project.objects.create(organization=self.us_org) self.first_release = self.create_release( project=self.us_project, version="v1.0", date_added=self.first_seen @@ -216,7 +216,7 @@ def setUp(self) -> None: key=self.issue_key, ) with assume_test_silo_mode(SiloMode.CELL, cell_name="de"): - self.de_org = self.create_organization(region="de") + self.de_org = self.create_organization(cell="de") self.de_project = Project.objects.create(organization=self.de_org) self.de_group = self.create_group( self.de_project, diff --git a/tests/sentry/middleware/integrations/parsers/test_gitlab.py b/tests/sentry/middleware/integrations/parsers/test_gitlab.py index a82575cf105b..1a9ff986daf1 100644 --- a/tests/sentry/middleware/integrations/parsers/test_gitlab.py +++ b/tests/sentry/middleware/integrations/parsers/test_gitlab.py @@ -33,7 +33,7 @@ def get_response(self, req: HttpRequest) -> HttpResponse: return HttpResponse(status=200, content="passthrough") def get_integration(self) -> Integration: - self.organization = self.create_organization(owner=self.user, region="us") + self.organization = self.create_organization(owner=self.user, cell="us") return self.create_integration( organization=self.organization, provider="gitlab", diff --git a/tests/sentry/middleware/integrations/parsers/test_jira.py b/tests/sentry/middleware/integrations/parsers/test_jira.py index d0a609fc897b..6681c299593a 100644 --- a/tests/sentry/middleware/integrations/parsers/test_jira.py +++ b/tests/sentry/middleware/integrations/parsers/test_jira.py @@ -34,7 +34,7 @@ def get_response(self, req: HttpRequest) -> HttpResponse: return HttpResponse(status=200, content="passthrough") def get_integration(self) -> Integration: - self.organization = self.create_organization(owner=self.user, region="us") + self.organization = self.create_organization(owner=self.user, cell="us") return self.create_integration( organization=self.organization, external_id="jira:1", provider="jira" ) @@ -190,7 +190,7 @@ def test_get_response_multiple_cells(self) -> None: request = self.factory.get(path=f"{self.path_base}/issue/LR-123/") parser = JiraRequestParser(request, self.get_response) - other_org = self.create_organization(owner=self.user, region="eu") + other_org = self.create_organization(owner=self.user, cell="eu") integration = self.get_integration() integration.add_organization(other_org.id) diff --git a/tests/sentry/middleware/test_proxy.py b/tests/sentry/middleware/test_proxy.py index 57406f423b0d..3d1bcb8163f9 100644 --- a/tests/sentry/middleware/test_proxy.py +++ b/tests/sentry/middleware/test_proxy.py @@ -38,7 +38,7 @@ def test_ipv6(self) -> None: assert request.META["REMOTE_ADDR"] == "2001:4860:4860::8888" -test_region = Cell( +test_cell = Cell( "us", 1, "https://test", @@ -46,7 +46,7 @@ def test_ipv6(self) -> None: ) -@control_silo_test(cells=[test_region]) +@control_silo_test(cells=[test_cell]) class FakedAPIProxyTest(APITestCase): endpoint = "sentry-api-0-organization-teams" method = "post" diff --git a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_webhook_requests.py b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_webhook_requests.py index b0c7be05ca6a..f960191ce38e 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_webhook_requests.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_webhook_requests.py @@ -22,7 +22,7 @@ def setUp(self) -> None: self.user = self.create_user(email="user@example.com") self.org = self.create_organization( owner=self.user, - region="us", + cell="us", slug="test-org", ) self.project = self.create_project(organization=self.org) diff --git a/tests/sentry/sentry_apps/models/test_sentryapp.py b/tests/sentry/sentry_apps/models/test_sentryapp.py index 51042c0ec370..0736463bab56 100644 --- a/tests/sentry/sentry_apps/models/test_sentryapp.py +++ b/tests/sentry/sentry_apps/models/test_sentryapp.py @@ -94,13 +94,13 @@ def test_save_outbox_update(self) -> None: assert outboxes[0].cell_name def test_cells_with_installations(self) -> None: - self.us_org = self.create_organization(name="us test name", region="us") + self.us_org = self.create_organization(name="us test name", cell="us") self.create_sentry_app_installation( organization=self.us_org, slug=self.sentry_app.slug, prevent_token_exchange=True ) assert self.sentry_app.cells_with_installations() == {"us"} - self.eu_org = self.create_organization(name="eu test name", region="eu") + self.eu_org = self.create_organization(name="eu test name", cell="eu") self.create_sentry_app_installation( organization=self.eu_org, slug=self.sentry_app.slug, prevent_token_exchange=True ) diff --git a/tests/sentry/sentry_apps/services/test_app_request.py b/tests/sentry/sentry_apps/services/test_app_request.py index dfbbdf930c23..286e8a239411 100644 --- a/tests/sentry/sentry_apps/services/test_app_request.py +++ b/tests/sentry/sentry_apps/services/test_app_request.py @@ -16,7 +16,7 @@ class TestRegionApp(TestCase): def setUp(self) -> None: self.user = Factories.create_user() - self.org = Factories.create_organization(owner=self.user, region="us") + self.org = Factories.create_organization(owner=self.user, cell="us") self.app = Factories.create_sentry_app( name="demo-app", user=self.user, diff --git a/tests/sentry/sentry_apps/services/test_hook_service.py b/tests/sentry/sentry_apps/services/test_hook_service.py index 4d5925399040..1668afa55551 100644 --- a/tests/sentry/sentry_apps/services/test_hook_service.py +++ b/tests/sentry/sentry_apps/services/test_hook_service.py @@ -437,7 +437,7 @@ def test_create_or_update_webhook_and_events_for_installation_delete_duplicates( class TestHookServiceBulkCreate(TestCase): def setUp(self) -> None: self.user = self.create_user() - self.org = self.create_organization(owner=self.user, region="us") + self.org = self.create_organization(owner=self.user, cell="us") self.project = self.create_project(name="foo", organization=self.org) self.sentry_app = self.create_sentry_app( organization_id=self.org.id, events=["issue.created"] @@ -448,7 +448,7 @@ def test_bulk_create_service_hooks_for_app_success(self) -> None: installation1 = self.create_sentry_app_installation( slug=self.sentry_app.slug, organization=self.org, user=self.user ) - org2 = self.create_organization(name="Test Org 2", region="us") + org2 = self.create_organization(name="Test Org 2", cell="us") installation2 = self.create_sentry_app_installation( slug=self.sentry_app.slug, organization=org2, user=self.user ) diff --git a/tests/sentry/synapse/endpoints/test_org_cell_mappings.py b/tests/sentry/synapse/endpoints/test_org_cell_mappings.py index 9bedd55c53a2..53902e4160fb 100644 --- a/tests/sentry/synapse/endpoints/test_org_cell_mappings.py +++ b/tests/sentry/synapse/endpoints/test_org_cell_mappings.py @@ -8,9 +8,9 @@ from sentry.testutils.silo import control_silo_test from sentry.types.cell import Cell, RegionCategory -us_region = Cell("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT) -de_region = Cell("de", 2, "https://de.testserver", RegionCategory.MULTI_TENANT) -region_config = (us_region, de_region) +us_cell = Cell("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT) +de_cell = Cell("de", 2, "https://de.testserver", RegionCategory.MULTI_TENANT) +cell_config = (us_cell, de_cell) @control_silo_test @@ -59,7 +59,7 @@ def test_get_no_data(self) -> None: assert "cell_to_locality" in res.data["metadata"] assert res.data["metadata"]["has_more"] is False - @override_cells(region_config) + @override_cells(cell_config) def test_get_results_no_next(self) -> None: org1 = self.create_organization() org2 = self.create_organization() @@ -75,7 +75,7 @@ def test_get_results_no_next(self) -> None: assert res.data["metadata"]["cell_to_locality"] assert res.data["metadata"]["has_more"] is False - @override_cells(region_config) + @override_cells(cell_config) def test_get_next_page(self) -> None: # newest orgs are in next page (ascending order by date_updated). org1 = self.create_organization() @@ -97,12 +97,12 @@ def test_get_next_page(self) -> None: assert res.data["metadata"]["cell_to_locality"] assert res.data["metadata"]["has_more"] - @override_cells(region_config) + @override_cells(cell_config) def test_get_multiple_pages_multiple_locales(self) -> None: org1 = self.create_organization() org2 = self.create_organization() - org3 = self.create_organization(region=de_region) - org4 = self.create_organization(region=de_region) + org3 = self.create_organization(cell=de_cell) + org4 = self.create_organization(cell=de_cell) url = reverse("sentry-api-0-org-cell-mappings") res = self.client.get( @@ -133,12 +133,12 @@ def test_get_multiple_pages_multiple_locales(self) -> None: assert res.data["metadata"]["cell_to_locality"] assert res.data["metadata"]["has_more"] is False - @override_cells(region_config) + @override_cells(cell_config) def test_get_locale_filter(self) -> None: - # Two orgs in the wrong region to check pagination response data + # Two orgs in the wrong cell to check pagination response data self.create_organization() self.create_organization() - org3 = self.create_organization(region=de_region) + org3 = self.create_organization(cell=de_cell) url = reverse("sentry-api-0-org-cell-mappings") res = self.client.get( diff --git a/tests/sentry/types/test_cell.py b/tests/sentry/types/test_cell.py index f251ddfead15..c7f493bad135 100644 --- a/tests/sentry/types/test_cell.py +++ b/tests/sentry/types/test_cell.py @@ -39,7 +39,7 @@ class CellDirectoryTest(TestCase): CellDirectory, it uses a lot of `override_settings` in ways that most test cases shouldn't. If you are having difficulty with cell setup in other test cases, please don't follow this class as an example, but instead use the - utilities in testutils/silo.py and testutils/region.py. + utilities in testutils/silo.py and testutils/cell.py. """ _INPUTS: list[CellConfig] = [ @@ -167,7 +167,7 @@ def test_find_cells_for_user(self) -> None: with override_settings(SENTRY_MONOLITH_REGION="us"): directory = load_from_config(self._INPUTS, []) with self._in_global_state(directory): - organization = self.create_organization(name="test name", region="us") + organization = self.create_organization(name="test name", cell="us") user = self.create_user() organization_service.add_organization_member( @@ -189,8 +189,8 @@ def test_find_cells_for_sentry_app(self) -> None: with override_settings(SENTRY_MONOLITH_REGION="us"): directory = load_from_config(self._INPUTS, []) with self._in_global_state(directory): - us_org_1 = self.create_organization(name="us test name 1", region="us") - us_org_2 = self.create_organization(name="us test name 2", region="us") + us_org_1 = self.create_organization(name="us test name 1", cell="us") + us_org_2 = self.create_organization(name="us test name 2", cell="us") sentry_app = self.create_sentry_app( organization=self.organization, @@ -204,8 +204,8 @@ def test_find_cells_for_sentry_app(self) -> None: actual_cells = find_cells_for_sentry_app(sentry_app=sentry_app) assert actual_cells == {"us"} - eu_org_1 = self.create_organization(name="eu test name", region="eu") - eu_org_2 = self.create_organization(name="eu test name", region="eu") + eu_org_1 = self.create_organization(name="eu test name", cell="eu") + eu_org_2 = self.create_organization(name="eu test name", cell="eu") self.create_sentry_app_installation(slug=sentry_app.slug, organization=eu_org_1) self.create_sentry_app_installation(slug=sentry_app.slug, organization=eu_org_2) actual_cells = find_cells_for_sentry_app(sentry_app=sentry_app) diff --git a/tests/sentry/users/api/endpoints/test_user_regions.py b/tests/sentry/users/api/endpoints/test_user_regions.py index 196eaa6463d1..33b678a69be8 100644 --- a/tests/sentry/users/api/endpoints/test_user_regions.py +++ b/tests/sentry/users/api/endpoints/test_user_regions.py @@ -6,7 +6,7 @@ us = Cell("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT) de = Cell("de", 2, "https://de.testserver", RegionCategory.MULTI_TENANT) st = Cell("acme", 3, "https://acme.testserver", RegionCategory.SINGLE_TENANT) -region_config = (us, de, st) +cell_config = (us, de, st) us_locality = Locality( name="us", cells=frozenset(["us"]), category=RegionCategory.MULTI_TENANT, new_org_cell="us" @@ -30,12 +30,12 @@ def setUp(self) -> None: super().setUp() self.user = self.create_user() - @override_cells(region_config) + @override_cells(cell_config) def test_get(self) -> None: self.login_as(user=self.user) - self.create_organization(region="us", owner=self.user) - self.create_organization(region="de", owner=self.user) - self.create_organization(region="acme", owner=self.user) + self.create_organization(cell="us", owner=self.user) + self.create_organization(cell="de", owner=self.user) + self.create_organization(cell="acme", owner=self.user) response = self.get_response("me") assert response.status_code == 200 @@ -46,36 +46,36 @@ def test_get(self) -> None: us_locality.api_serialize(), ] - @override_cells(region_config) + @override_cells(cell_config) def test_get_only_memberships(self) -> None: self.login_as(user=self.user) other = self.create_user() - self.create_organization(region="acme", owner=other) - self.create_organization(region="de", owner=self.user) + self.create_organization(cell="acme", owner=other) + self.create_organization(cell="de", owner=self.user) response = self.get_response("me") assert response.status_code == 200 assert "regions" in response.data assert response.data["regions"] == [de_locality.api_serialize()] - @override_cells(region_config) + @override_cells(cell_config) def test_get_other_user_error(self) -> None: self.login_as(user=self.user) other = self.create_user() - self.create_organization(region="acme", owner=other) + self.create_organization(cell="acme", owner=other) response = self.get_response(other.id) assert response.status_code == 403 - @override_cells(region_config) + @override_cells(cell_config) def test_allow_superuser_to_query_all(self) -> None: superuser = self.create_user(is_superuser=True) self.login_as(user=superuser, superuser=True) test_user_1 = self.create_user() - self.create_organization(region="us", owner=test_user_1) - self.create_organization(region="de", owner=test_user_1) - self.create_organization(region="acme", owner=test_user_1) + self.create_organization(cell="us", owner=test_user_1) + self.create_organization(cell="de", owner=test_user_1) + self.create_organization(cell="acme", owner=test_user_1) test_user_2 = self.create_user() response = self.get_response(test_user_1.id) @@ -92,10 +92,10 @@ def test_allow_superuser_to_query_all(self) -> None: assert "regions" in response.data assert response.data["regions"] == [] - @override_cells(region_config) + @override_cells(cell_config) def test_get_for_user_with_auth_token(self) -> None: - self.create_organization(region="us", owner=self.user) - self.create_organization(region="de", owner=self.user) + self.create_organization(cell="us", owner=self.user) + self.create_organization(cell="de", owner=self.user) auth_token = self.create_user_auth_token(user=self.user, scope_list=["org:read"]) response = self.get_success_response( "me", extra_headers={"HTTP_AUTHORIZATION": f"Bearer {auth_token.token}"} @@ -106,11 +106,11 @@ def test_get_for_user_with_auth_token(self) -> None: us_locality.api_serialize(), ] - @override_cells(region_config) + @override_cells(cell_config) def test_get_other_user_with_auth_token_error(self) -> None: other_user = self.create_user() - self.create_organization(region="us", owner=other_user) - self.create_organization(region="de", owner=other_user) + self.create_organization(cell="us", owner=other_user) + self.create_organization(cell="de", owner=other_user) auth_token = self.create_user_auth_token(user=self.user, scope_list=["org:read"]) self.get_error_response( @@ -119,10 +119,10 @@ def test_get_other_user_with_auth_token_error(self) -> None: status_code=403, ) - @override_cells(region_config) + @override_cells(cell_config) def test_get_for_user_with_wrong_scopes_error(self) -> None: - self.create_organization(region="us", owner=self.user) - self.create_organization(region="de", owner=self.user) + self.create_organization(cell="us", owner=self.user) + self.create_organization(cell="de", owner=self.user) auth_token = self.create_user_auth_token(user=self.user, scope_list=["project:read"]) self.get_error_response( @@ -131,10 +131,10 @@ def test_get_for_user_with_wrong_scopes_error(self) -> None: status_code=403, ) - @override_cells(region_config) + @override_cells(cell_config) def test_get_for_user_with_no_auth(self) -> None: - self.create_organization(region="us", owner=self.user) - self.create_organization(region="de", owner=self.user) + self.create_organization(cell="us", owner=self.user) + self.create_organization(cell="de", owner=self.user) self.get_error_response("me", status_code=401) self.get_error_response(self.user.id, status_code=401) diff --git a/tests/sentry/users/models/test_user.py b/tests/sentry/users/models/test_user.py index a34ab72712ae..cd02dcf9a8e4 100644 --- a/tests/sentry/users/models/test_user.py +++ b/tests/sentry/users/models/test_user.py @@ -60,7 +60,7 @@ def setUp(self) -> None: self.user_id = self.user.id # Organization membership determines which cells the deletion will cascade to - self.organization = self.create_organization(region=_TEST_CELLS[0]) + self.organization = self.create_organization(cell=_TEST_CELLS[0]) self.create_member(user=self.user, organization=self.organization) self.create_saved_search( @@ -108,8 +108,8 @@ def test_unrelated_saved_search_is_not_deleted(self) -> None: with assume_test_silo_mode(SiloMode.CELL): assert SavedSearch.objects.filter(owner_id=another_user.id).exists() - def test_cascades_to_multiple_regions(self) -> None: - eu_org = self.create_organization(region=_TEST_CELLS[1]) + def test_cascades_to_multiple_cells(self) -> None: + eu_org = self.create_organization(cell=_TEST_CELLS[1]) self.create_member(user=self.user, organization=eu_org) self.create_saved_search(name="eu-search", owner=self.user, organization=eu_org) @@ -121,7 +121,7 @@ def test_cascades_to_multiple_regions(self) -> None: schedule_hybrid_cloud_foreign_key_jobs() assert self.get_user_saved_search_count() == 0 - def test_deletions_create_tombstones_in_regions_for_user_with_no_orgs(self) -> None: + def test_deletions_create_tombstones_in_cells_for_user_with_no_orgs(self) -> None: # Create a user with no org memberships user_to_delete = self.create_user("foo@example.com") user_id = user_to_delete.id @@ -130,8 +130,8 @@ def test_deletions_create_tombstones_in_regions_for_user_with_no_orgs(self) -> N assert self.user_tombstone_exists(user_id=user_id) - def test_cascades_to_regions_even_if_user_ownership_revoked(self) -> None: - eu_org = self.create_organization(region=_TEST_CELLS[1]) + def test_cascades_to_cells_even_if_user_ownership_revoked(self) -> None: + eu_org = self.create_organization(cell=_TEST_CELLS[1]) self.create_member(user=self.user, organization=eu_org) self.create_saved_search(name="eu-search", owner=self.user, organization=eu_org) assert self.get_user_saved_search_count() == 2 @@ -152,7 +152,7 @@ def test_cascades_to_regions_even_if_user_ownership_revoked(self) -> None: def test_update_purge_cell_cache(self) -> None: user = self.create_user() - na_org = self.create_organization(region=_TEST_CELLS[0]) + na_org = self.create_organization(cell=_TEST_CELLS[0]) self.create_member(user=user, organization=na_org) with patch.object(caching_module, "cell_caching_service") as mock_caching_service: @@ -201,8 +201,8 @@ def verify_model_existence_by_user( for model in sorted(models, key=lambda x: get_model_name(x)): model_relations = dependencies()[get_model_name(model)] user_refs = [k for k, v in model_relations.foreign_keys.items() if v.model == User] - is_region_model = SiloMode.CELL in model_relations.silos - with assume_test_silo_mode(SiloMode.CELL if is_region_model else SiloMode.CONTROL): + is_cell_model = SiloMode.CELL in model_relations.silos + with assume_test_silo_mode(SiloMode.CELL if is_cell_model else SiloMode.CONTROL): for present_user in present: q = Q() for ref in user_refs: From d0a7b18002de4f15999c7af11c6c29d9844da52e Mon Sep 17 00:00:00 2001 From: Giovanni Barillari Date: Tue, 31 Mar 2026 23:06:42 +0200 Subject: [PATCH 43/51] ref(apigateway): async proxy timeouts configuration (#111918) SSIA --- src/sentry/conf/server.py | 6 +++++- src/sentry/hybridcloud/apigateway_async/proxy.py | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 02e2e9f64e88..cbd1bd453e79 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -3016,7 +3016,11 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: SENTRY_PREPROD_ARTIFACT_EVENTS_FUTURES_MAX_LIMIT = 10000 # How long we should wait for a gateway proxy request to return before giving up -GATEWAY_PROXY_TIMEOUT: int | None = None +GATEWAY_PROXY_TIMEOUT: int | None = ( + int(os.environ["SENTRY_APIGW_PROXY_TIMEOUT"]) + if os.environ.get("SENTRY_APIGW_PROXY_TIMEOUT") + else None +) SENTRY_SLICING_LOGICAL_PARTITION_COUNT = 256 # This maps a Sliceable for slicing by name and (lower logical partition, upper physical partition) diff --git a/src/sentry/hybridcloud/apigateway_async/proxy.py b/src/sentry/hybridcloud/apigateway_async/proxy.py index 7fed6928dfae..254ab6bf59df 100644 --- a/src/sentry/hybridcloud/apigateway_async/proxy.py +++ b/src/sentry/hybridcloud/apigateway_async/proxy.py @@ -41,7 +41,7 @@ logger = logging.getLogger(__name__) -proxy_client = httpx.AsyncClient() +proxy_client = httpx.AsyncClient(timeout=httpx.Timeout(5.0, read=60.0)) circuitbreakers = CircuitBreakerManager() # Endpoints that handle uploaded files have higher timeouts configured @@ -189,7 +189,7 @@ async def proxy_cell_request( headers=header_dict, params=dict(query_params) if query_params is not None else None, content=_stream_request(data) if data else None, # type: ignore[arg-type] - timeout=timeout, + timeout=timeout or httpx.USE_CLIENT_DEFAULT, ) resp = await proxy_client.send(req, stream=True, follow_redirects=False) if resp.status_code >= 502: From e260849a385d3df761f1553ed9a833a9368752f4 Mon Sep 17 00:00:00 2001 From: Kyle Consalus Date: Tue, 31 Mar 2026 14:13:52 -0700 Subject: [PATCH 44/51] perf(workflows): Avoid excessive querying in WorkflowEngineRuleSerializer (#111942) The previous logic had us potentially sending 100+ workflow queries, which can add 300ms+ to the request. By only fetching ids, we avoid considerable unnecessary work. --- src/sentry/api/serializers/models/rule.py | 14 ++++++-------- tests/sentry/api/serializers/test_rule.py | 4 ++-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/sentry/api/serializers/models/rule.py b/src/sentry/api/serializers/models/rule.py index 3f62473aaed3..a1ef225e6596 100644 --- a/src/sentry/api/serializers/models/rule.py +++ b/src/sentry/api/serializers/models/rule.py @@ -398,15 +398,13 @@ def _fetch_workflow_users(self, item_list: Sequence[Workflow]) -> dict[int, RpcU ) } - def _fetch_workflow_projects( - self, item_list: Sequence[Workflow] - ) -> dict[Workflow, set[Project]]: - workflow_to_projects: dict[Workflow, set[Project]] = defaultdict(set) + def _fetch_workflow_projects(self, item_list: Sequence[Workflow]) -> dict[int, set[Project]]: + workflow_to_projects: dict[int, set[Project]] = defaultdict(set) detector_workflows = DetectorWorkflow.objects.filter( workflow_id__in=[item.id for item in item_list] - ).prefetch_related("detector__project") - for detector_workflow in detector_workflows: - workflow_to_projects[detector_workflow.workflow].add(detector_workflow.detector.project) + ).select_related("detector__project") + for dw in detector_workflows: + workflow_to_projects[dw.workflow_id].add(dw.detector.project) return workflow_to_projects @@ -583,7 +581,7 @@ def get_attrs(self, item_list: Sequence[Workflow], user, **kwargs): result[workflow]["owner"] = owner result[workflow]["environment"] = workflow.environment - result[workflow]["projects"] = list(workflow_to_projects[workflow]) + result[workflow]["projects"] = list(workflow_to_projects[workflow.id]) result[workflow]["rule_id"] = workflow_rule_ids.get( workflow.id, get_fake_id_from_object_id(workflow.id) ) diff --git a/tests/sentry/api/serializers/test_rule.py b/tests/sentry/api/serializers/test_rule.py index 6feded4c2891..5326fcd96b14 100644 --- a/tests/sentry/api/serializers/test_rule.py +++ b/tests/sentry/api/serializers/test_rule.py @@ -205,8 +205,8 @@ def test_fetch_workflow_projects(self) -> None: [workflow, workflow_2, workflow_3] ) assert workflow_projects == { - workflow_2: {self.project}, - workflow_3: {self.project, project_2}, + workflow_2.id: {self.project}, + workflow_3.id: {self.project, project_2}, } def test_fetch_workflows__prefetch(self) -> None: From 3085867357253035e1f30cfcd5628563952fcd0d Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 31 Mar 2026 14:18:24 -0700 Subject: [PATCH 45/51] feat(seer): Seer Autofix Settings Overview page (#110758) First pass at a Seer Settings Overview page. To help people get oriented while they're getting setup. --- .../useScmIntegrationTreeData.ts | 2 +- static/app/types/organization.tsx | 4 +- static/app/utils/array/procesInChunks.spec.ts | 108 +++ static/app/utils/array/procesInChunks.ts | 19 + .../overview/autofixOverviewSection.spec.tsx | 567 +++++++++++++++ .../autofixOverviewSection.stories.tsx | 80 +++ .../seer/overview/autofixOverviewSection.tsx | 142 +++- .../organizationIntegrationsQueryOptions.ts} | 0 .../settings/seer/seerAgentHooks.spec.tsx | 643 +++++++++++++++++- .../views/settings/seer/seerAgentHooks.tsx | 170 ++++- tests/js/fixtures/organization.ts | 2 +- 11 files changed, 1709 insertions(+), 28 deletions(-) create mode 100644 static/app/utils/array/procesInChunks.spec.ts create mode 100644 static/app/utils/array/procesInChunks.ts create mode 100644 static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx create mode 100644 static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx rename static/app/{endpoints/organizations/organizationsIntegrationsQueryOptions.ts => views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts} (100%) diff --git a/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts b/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts index 3c77e52eaa99..6b45d8736cc2 100644 --- a/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts +++ b/static/app/components/repositories/scmIntegrationTree/useScmIntegrationTreeData.ts @@ -2,7 +2,6 @@ import {useEffect, useMemo} from 'react'; import {organizationRepositoriesInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useOrganizationRepositories'; import {organizationConfigIntegrationsQueryOptions} from 'sentry/components/repositories/scmIntegrationTree/organizationConfigIntegrationsQueryOptions'; -import {organizationIntegrationsQueryOptions} from 'sentry/endpoints/organizations/organizationsIntegrationsQueryOptions'; import type { IntegrationProvider, IntegrationRepository, @@ -12,6 +11,7 @@ import type { import {apiOptions} from 'sentry/utils/api/apiOptions'; import {useInfiniteQuery, useQueries, useQuery} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {organizationIntegrationsQueryOptions} from 'sentry/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions'; type ScmIntegrationTreeData = { connectedIdentifiers: Set; diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx index a3768d08a092..1123f243ba52 100644 --- a/static/app/types/organization.tsx +++ b/static/app/types/organization.tsx @@ -65,8 +65,8 @@ export interface Organization extends OrganizationSummary { dataScrubberDefaults: boolean; debugFilesRole: string; defaultCodeReviewTriggers: CodeReviewTrigger[]; - defaultCodingAgent: string | null | undefined; - defaultCodingAgentIntegrationId: number | null | undefined; + defaultCodingAgent: string | null; + defaultCodingAgentIntegrationId: string | number | null; defaultRole: string; enhancedPrivacy: boolean; eventsMemberAdmin: boolean; diff --git a/static/app/utils/array/procesInChunks.spec.ts b/static/app/utils/array/procesInChunks.spec.ts new file mode 100644 index 000000000000..80a4618ae083 --- /dev/null +++ b/static/app/utils/array/procesInChunks.spec.ts @@ -0,0 +1,108 @@ +import {processInChunks} from 'sentry/utils/array/procesInChunks'; + +describe('processInChunks', () => { + it('returns an empty array for empty input', async () => { + const fn = jest.fn().mockResolvedValue('x'); + const results = await processInChunks({items: [], chunkSize: 3, fn}); + expect(results).toEqual([]); + expect(fn).not.toHaveBeenCalled(); + }); + + it('processes all items when count is less than chunkSize', async () => { + const fn = jest.fn((x: number) => Promise.resolve(x * 2)); + const results = await processInChunks({items: [1, 2], chunkSize: 5, fn}); + expect(results).toEqual([ + {status: 'fulfilled', value: 2}, + {status: 'fulfilled', value: 4}, + ]); + }); + + it('processes all items when count equals chunkSize exactly', async () => { + const fn = jest.fn((x: number) => Promise.resolve(x * 2)); + const results = await processInChunks({items: [1, 2, 3], chunkSize: 3, fn}); + expect(results).toHaveLength(3); + expect(results.every(r => r.status === 'fulfilled')).toBe(true); + }); + + it('processes all items across multiple chunks', async () => { + const fn = jest.fn((x: number) => Promise.resolve(x)); + const results = await processInChunks({ + items: [1, 2, 3, 4, 5], + chunkSize: 2, + fn, + }); + expect(results).toHaveLength(5); + expect(fn).toHaveBeenCalledTimes(5); + expect(results.map(r => (r.status === 'fulfilled' ? r.value : null))).toEqual([ + 1, 2, 3, 4, 5, + ]); + }); + + it('preserves result order matching input order', async () => { + // Simulate varying async latency: later items resolve faster + const fn = jest.fn( + (x: number) => + new Promise(resolve => setTimeout(() => resolve(x), (10 - x) * 10)) + ); + const results = await processInChunks({items: [1, 2, 3, 4, 5], chunkSize: 5, fn}); + expect(results.map(r => (r.status === 'fulfilled' ? r.value : null))).toEqual([ + 1, 2, 3, 4, 5, + ]); + }); + + it('processes chunks sequentially, not all at once', async () => { + const callOrder: number[] = []; + const fn = jest.fn((x: number) => { + callOrder.push(x); + return Promise.resolve(x); + }); + + await processInChunks({items: [1, 2, 3, 4, 5, 6], chunkSize: 2, fn}); + + // Each chunk of 2 must start only after the previous chunk completes. + // Because fn is synchronous here, within each chunk the call order is + // preserved and chunks are processed sequentially. + expect(callOrder).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('marks rejected items as rejected without stopping other items', async () => { + const fn = jest.fn((x: number) => + x === 3 ? Promise.reject(new Error('boom')) : Promise.resolve(x) + ); + const results = await processInChunks({items: [1, 2, 3, 4, 5], chunkSize: 5, fn}); + expect(results).toHaveLength(5); + expect(results[0]).toEqual({status: 'fulfilled', value: 1}); + expect(results[1]).toEqual({status: 'fulfilled', value: 2}); + expect(results[2]).toMatchObject({status: 'rejected', reason: expect.any(Error)}); + expect(results[3]).toEqual({status: 'fulfilled', value: 4}); + expect(results[4]).toEqual({status: 'fulfilled', value: 5}); + }); + + it('continues processing later chunks when an earlier chunk has failures', async () => { + const fn = jest.fn((x: number) => + x === 1 ? Promise.reject(new Error('first chunk error')) : Promise.resolve(x) + ); + // chunk 1: [1] (fails), chunk 2: [2, 3] (succeeds) + const results = await processInChunks({items: [1, 2, 3], chunkSize: 1, fn}); + expect(results).toHaveLength(3); + expect(results[0]).toMatchObject({status: 'rejected'}); + expect(results[1]).toEqual({status: 'fulfilled', value: 2}); + expect(results[2]).toEqual({status: 'fulfilled', value: 3}); + }); + + it('handles chunkSize of 1 by processing items one at a time', async () => { + const fn = jest.fn((x: number) => Promise.resolve(x)); + const results = await processInChunks({items: [10, 20, 30], chunkSize: 1, fn}); + expect(results).toHaveLength(3); + expect(results.map(r => (r.status === 'fulfilled' ? r.value : null))).toEqual([ + 10, 20, 30, + ]); + }); + + it('handles chunkSize larger than item count', async () => { + const fn = jest.fn((x: number) => Promise.resolve(x)); + const results = await processInChunks({items: [1, 2], chunkSize: 100, fn}); + expect(results).toHaveLength(2); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/static/app/utils/array/procesInChunks.ts b/static/app/utils/array/procesInChunks.ts new file mode 100644 index 000000000000..5bb4458b2598 --- /dev/null +++ b/static/app/utils/array/procesInChunks.ts @@ -0,0 +1,19 @@ +interface Props { + chunkSize: number; + fn: (item: Item) => Promise; + items: Item[]; +} + +export async function processInChunks({ + items, + chunkSize, + fn, +}: Props): Promise>> { + const results: Array> = []; + for (let i = 0; i < items.length; i += chunkSize) { + const chunk = items.slice(i, i + chunkSize); + const chunkResults = await Promise.allSettled(chunk.map(fn)); + results.push(...chunkResults); + } + return results; +} diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx new file mode 100644 index 000000000000..9c434d250687 --- /dev/null +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.spec.tsx @@ -0,0 +1,567 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import { + render, + renderHookWithProviders, + screen, + userEvent, + waitFor, +} from 'sentry-test/reactTestingLibrary'; + +import type {AutofixAutomationSettings} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import { + AutofixOverviewSection, + useAutofixOverviewData, +} from 'sentry/views/settings/seer/overview/autofixOverviewSection'; + +function makeSettings( + overrides: Partial = {} +): AutofixAutomationSettings { + return { + projectId: '1', + autofixAutomationTuning: 'medium', + automatedRunStoppingPoint: 'code_changes', + automationHandoff: undefined, + reposCount: 0, + ...overrides, + }; +} + +describe('autofixOverviewSection', () => { + afterEach(() => { + MockApiClient.clearMockResponses(); + ProjectsStore.reset(); + }); + + describe('useAutofixOverviewData', () => { + function setupSettingsMock(settings: AutofixAutomationSettings[]) { + return MockApiClient.addMockResponse({ + url: `/organizations/org-slug/autofix/automation-settings/`, + method: 'GET', + body: settings, + }); + } + + describe('projectsWithRepos', () => { + it('returns empty when there are no settings', async () => { + const organization = OrganizationFixture(); + setupSettingsMock([]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithRepos).toHaveLength(0); + }); + + it('returns projects where reposCount > 0', async () => { + const organization = OrganizationFixture(); + setupSettingsMock([ + makeSettings({projectId: '1', reposCount: 2}), + makeSettings({projectId: '2', reposCount: 0}), + makeSettings({projectId: '3', reposCount: 1}), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithRepos).toHaveLength(2); + }); + + it('returns no projects when all have reposCount === 0', async () => { + const organization = OrganizationFixture(); + setupSettingsMock([ + makeSettings({projectId: '1', reposCount: 0}), + makeSettings({projectId: '2', reposCount: 0}), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithRepos).toHaveLength(0); + }); + }); + + describe('projectsWithPreferredAgent — defaultCodingAgent is seer', () => { + it('returns empty when there are no settings', async () => { + const organization = OrganizationFixture({defaultCodingAgent: 'seer'}); + setupSettingsMock([]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithPreferredAgent).toHaveLength(0); + }); + + it('counts projects without automationHandoff when defaultCodingAgent is seer', async () => { + const organization = OrganizationFixture({defaultCodingAgent: 'seer'}); + setupSettingsMock([ + makeSettings({projectId: '1', automationHandoff: undefined}), + makeSettings({projectId: '2', automationHandoff: undefined}), + makeSettings({ + projectId: '3', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + }, + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithPreferredAgent).toHaveLength(2); + }); + + it('returns none when all projects have automationHandoff', async () => { + const organization = OrganizationFixture({defaultCodingAgent: 'seer'}); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + }, + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithPreferredAgent).toHaveLength(0); + }); + }); + + describe('projectsWithPreferredAgent — defaultCodingAgent is an integration', () => { + it('counts projects where automationHandoff.integration_id matches org setting', async () => { + const organization = OrganizationFixture({ + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: 42, + }); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + }, + }), + makeSettings({ + projectId: '2', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 99, + }, + }), + makeSettings({projectId: '3', automationHandoff: undefined}), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithPreferredAgent).toHaveLength(1); + }); + + it('returns none when no integration_id matches', async () => { + const organization = OrganizationFixture({ + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: 42, + }); + setupSettingsMock([ + makeSettings({projectId: '1', automationHandoff: undefined}), + makeSettings({ + projectId: '2', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 99, + }, + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithPreferredAgent).toHaveLength(0); + }); + + it('matches integration_id numerically despite string/number coercion', async () => { + const organization = OrganizationFixture({ + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: 42, + }); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + }, + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithPreferredAgent).toHaveLength(1); + }); + }); + + describe('projectsWithCreatePr — autoOpenPrs is true', () => { + it('counts projects with automationHandoff === null and open_pr stopping point', async () => { + const organization = OrganizationFixture({autoOpenPrs: true}); + setupSettingsMock([ + // null (not undefined) is what the API actually returns; the source checks === null + makeSettings({ + projectId: '1', + automationHandoff: null as any, + automatedRunStoppingPoint: 'open_pr', + }), + makeSettings({ + projectId: '2', + automationHandoff: null as any, + automatedRunStoppingPoint: 'code_changes', // null but not open_pr — no match + }), + makeSettings({ + projectId: '3', + automationHandoff: undefined, + automatedRunStoppingPoint: 'open_pr', // open_pr but undefined (not null) — no match + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + // Only project 1: automationHandoff is null AND stopping point is open_pr + expect(result.current.data?.projectsWithCreatePr).toHaveLength(1); + }); + + it('counts projects with automationHandoff.auto_create_pr === true', async () => { + const organization = OrganizationFixture({autoOpenPrs: true}); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + auto_create_pr: true, + }, + }), + makeSettings({ + projectId: '2', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + auto_create_pr: false, + }, + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithCreatePr).toHaveLength(1); + }); + + it('counts both null-handoff-with-open_pr and handoff-with-auto_create_pr', async () => { + const organization = OrganizationFixture({autoOpenPrs: true}); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: null as any, // null (not undefined) matches the === null check + automatedRunStoppingPoint: 'open_pr', + }), + makeSettings({ + projectId: '2', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + auto_create_pr: true, + }, + }), + makeSettings({ + projectId: '3', + automationHandoff: undefined, + automatedRunStoppingPoint: 'code_changes', + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithCreatePr).toHaveLength(2); + }); + }); + + describe('projectsWithCreatePr — autoOpenPrs is false', () => { + it('counts projects not configured to create PRs', async () => { + const organization = OrganizationFixture({autoOpenPrs: false}); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: undefined, + automatedRunStoppingPoint: 'code_changes', + }), + makeSettings({ + projectId: '2', + automationHandoff: undefined, + automatedRunStoppingPoint: 'open_pr', + }), + makeSettings({ + projectId: '3', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + auto_create_pr: true, + }, + }), + makeSettings({ + projectId: '4', + automationHandoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent' as any, + integration_id: 42, + auto_create_pr: false, + }, + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + // project 1: no open_pr stopping point AND no auto_create_pr + // project 4: no auto_create_pr (but integration exists) + expect(result.current.data?.projectsWithCreatePr).toHaveLength(2); + }); + + it('returns all projects when none are configured to create PRs', async () => { + const organization = OrganizationFixture({autoOpenPrs: false}); + setupSettingsMock([ + makeSettings({ + projectId: '1', + automationHandoff: undefined, + automatedRunStoppingPoint: 'code_changes', + }), + makeSettings({ + projectId: '2', + automationHandoff: undefined, + automatedRunStoppingPoint: 'solution', + }), + ]); + + const {result} = renderHookWithProviders(useAutofixOverviewData, {organization}); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.projectsWithCreatePr).toHaveLength(2); + }); + }); + }); + + describe('AutofixOverviewSection', () => { + const organization = OrganizationFixture({defaultCodingAgent: 'seer'}); + + function setupIntegrationsMock( + integrations: Array<{id: string; name: string; provider: string}> = [] + ) { + return MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/coding-agents/`, + method: 'GET', + body: {integrations}, + }); + } + + function renderSection( + projectsWithPreferredAgent: AutofixAutomationSettings[], + { + projects = [ProjectFixture()], + org = organization, + projectsWithCreatePr = [] as AutofixAutomationSettings[], + } = {} + ) { + ProjectsStore.loadInitialData(projects); + + const data = { + projectsWithRepos: [], + projectsWithPreferredAgent, + projectsWithCreatePr, + } as any; + + return render( + , + {organization: org} + ); + } + + describe('AgentNameForm labels', () => { + beforeEach(() => { + setupIntegrationsMock(); + }); + + it('shows "No projects found" when there are no projects', async () => { + renderSection([], {projects: []}); + + // Each form section renders this text, so use findAllByText + const messages = await screen.findAllByText('No projects found'); + expect(messages.length).toBeGreaterThanOrEqual(2); + }); + + it('shows "Your existing project uses Seer Agent" when 1 project uses preferred agent', async () => { + renderSection([makeSettings({projectId: '1'})], { + projects: [ProjectFixture({id: '1'})], + }); + + expect( + await screen.findByText('Your existing project uses Seer Agent') + ).toBeInTheDocument(); + }); + + it('shows "Your existing project does not use Seer Agent" when 1 project does not use preferred agent', async () => { + renderSection([], {projects: [ProjectFixture({id: '1'})]}); + + expect( + await screen.findByText('Your existing project does not use Seer Agent') + ).toBeInTheDocument(); + }); + + it('shows "All existing projects use Seer Agent" when all projects match', async () => { + const projects = [ + ProjectFixture({id: '1'}), + ProjectFixture({id: '2'}), + ProjectFixture({id: '3'}), + ]; + renderSection( + [ + makeSettings({projectId: '1'}), + makeSettings({projectId: '2'}), + makeSettings({projectId: '3'}), + ], + {projects} + ); + + expect( + await screen.findByText('All existing projects use Seer Agent') + ).toBeInTheDocument(); + }); + + it('shows "{count} of {total} existing projects use {label}" when some match', async () => { + const projects = [ + ProjectFixture({id: '1'}), + ProjectFixture({id: '2'}), + ProjectFixture({id: '3'}), + ]; + renderSection([makeSettings({projectId: '1'})], {projects}); + + expect( + await screen.findByText('1 of 3 existing projects use Seer Agent') + ).toBeInTheDocument(); + }); + + it('shows correct label when the preferred agent is a named integration', async () => { + setupIntegrationsMock([{id: '42', name: 'Cursor', provider: 'cursor'}]); + const org = OrganizationFixture({ + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: 42, + }); + renderSection([makeSettings({projectId: '1'})], { + projects: [ProjectFixture({id: '1'})], + org, + }); + + expect( + await screen.findByText('Your existing project uses Cursor') + ).toBeInTheDocument(); + }); + }); + + describe('codingAgentMutationOpts', () => { + it('sends PUT with defaultCodingAgent=seer when seer is selected', async () => { + const org = OrganizationFixture({ + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: 42, + }); + setupIntegrationsMock([{id: '42', name: 'Cursor', provider: 'cursor'}]); + + const orgPutRequest = MockApiClient.addMockResponse({ + url: `/organizations/${org.slug}/`, + method: 'PUT', + body: OrganizationFixture(), + }); + + renderSection([], {org}); + + // Open the select dropdown + await userEvent.click( + await screen.findByRole('textbox', {name: /default preferred coding agent/i}) + ); + + // Choose Seer Agent + await userEvent.click(screen.getByRole('menuitemradio', {name: 'Seer Agent'})); + + await waitFor(() => expect(orgPutRequest).toHaveBeenCalledTimes(1)); + expect(orgPutRequest).toHaveBeenCalledWith( + `/organizations/${org.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: { + defaultCodingAgent: 'seer', + defaultCodingAgentIntegrationId: null, + }, + }) + ); + }); + + it('sends PUT with integration provider and id when an integration is selected', async () => { + const org = OrganizationFixture({ + defaultCodingAgent: 'seer', + defaultCodingAgentIntegrationId: null, + }); + setupIntegrationsMock([{id: '42', name: 'Cursor', provider: 'cursor'}]); + + const orgPutRequest = MockApiClient.addMockResponse({ + url: `/organizations/${org.slug}/`, + method: 'PUT', + body: OrganizationFixture(), + }); + + renderSection([], {org}); + + // Open the select dropdown + await userEvent.click( + await screen.findByRole('textbox', {name: /default preferred coding agent/i}) + ); + + // Choose the Cursor integration + await userEvent.click(screen.getByRole('menuitemradio', {name: 'Cursor'})); + + await waitFor(() => expect(orgPutRequest).toHaveBeenCalledTimes(1)); + expect(orgPutRequest).toHaveBeenCalledWith( + `/organizations/${org.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: { + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: '42', + }, + }) + ); + }); + }); + }); +}); diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx new file mode 100644 index 000000000000..3aa8360d482e --- /dev/null +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.stories.tsx @@ -0,0 +1,80 @@ +import {Fragment} from 'react'; + +import type {AutofixAutomationSettings} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; +import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix'; +import * as Storybook from 'sentry/stories'; +import type {Organization} from 'sentry/types/organization'; +import {useQueryClient} from 'sentry/utils/queryClient'; +import {AutofixOverviewSection} from 'sentry/views/settings/seer/overview/autofixOverviewSection'; + +function makeSettings(projectId: string): AutofixAutomationSettings { + return { + projectId, + autofixAutomationTuning: 'medium', + automatedRunStoppingPoint: 'code_changes', + automationHandoff: undefined, + reposCount: 0, + }; +} + +const BASE_ORG = { + slug: 'my-org', + defaultCodingAgent: 'seer', + autoOpenPrs: false, + access: ['org:read', 'org:write', 'org:admin'], +} as Organization; + +function makeProps( + projectsWithPreferredAgent: AutofixAutomationSettings[], + org: Organization = BASE_ORG +) { + return { + canWrite: false, + organization: org, + data: { + projectsWithRepos: [], + projectsWithPreferredAgent, + projectsWithCreatePr: [], + } as any, + isPending: false, + ...({} as any), + }; +} + +export default Storybook.story('AgentNameForm', story => { + story('Overview', () => ( + +

+ The (rendered inside{' '} + ) shows a summary label below + the agent selector. The label reflects how many of the organization's + existing projects are configured to use the preferred coding agent. +

+

+ The label has five variants: no projects, single project (uses / does not use), + all projects use, and a partial count. When the preferred agent is a named + integration (e.g. Cursor), the agent name in the label updates accordingly. +

+
+ )); + + story('0 projects with preferred agent', () => ( + + )); + + story('Named integration (Cursor) as preferred agent', () => { + const cursorOrg = { + ...BASE_ORG, + defaultCodingAgent: 'cursor', + defaultCodingAgentIntegrationId: 42, + } as Organization; + + const queryClient = useQueryClient(); + queryClient.setQueryData(organizationIntegrationsCodingAgents(cursorOrg).queryKey, { + json: {integrations: [{id: '42', name: 'Cursor', provider: 'cursor'}]}, + headers: {Link: undefined, 'X-Hits': undefined, 'X-Max-Hits': undefined}, + }); + + return ; + }); +}); diff --git a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx index d84cd8c4aa05..4ce19c63456e 100644 --- a/static/app/views/settings/seer/overview/autofixOverviewSection.tsx +++ b/static/app/views/settings/seer/overview/autofixOverviewSection.tsx @@ -1,17 +1,23 @@ +import {useState} from 'react'; import {mutationOptions} from '@tanstack/react-query'; import {z} from 'zod'; import {Alert} from '@sentry/scraps/alert'; +import {Button} from '@sentry/scraps/button'; import {AutoSaveForm, FieldGroup} from '@sentry/scraps/form'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {ExternalLink, Link} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; +import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {updateOrganization} from 'sentry/actionCreators/organizations'; -import {bulkAutofixAutomationSettingsInfiniteOptions} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; +import { + bulkAutofixAutomationSettingsInfiniteOptions, + type AutofixAutomationSettings, +} from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix'; import {IconSettings} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; +import {t, tct, tn} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {useFetchAllPages} from 'sentry/utils/api/apiFetch'; @@ -19,7 +25,11 @@ import {fetchMutation, useQuery} from 'sentry/utils/queryClient'; import {useInfiniteQuery} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; -import {useAgentOptions} from 'sentry/views/settings/seer/seerAgentHooks'; +import { + useAgentOptions, + useBulkMutateCreatePr, + useBulkMutateSelectedAgent, +} from 'sentry/views/settings/seer/seerAgentHooks'; export function useAutofixOverviewData() { const organization = useOrganization(); @@ -72,6 +82,9 @@ export function AutofixOverviewSection({canWrite, data, isPending, organization} const {projectsWithPreferredAgent = [], projectsWithCreatePr = []} = data ?? {}; + const [isBulkMutatingAgent, setIsBulkMutatingAgent] = useState(false); + const [isBulkMutatingCreatePr, setIsBulkMutatingCreatePr] = useState(false); + return ( ); @@ -108,15 +127,22 @@ export function AutofixOverviewSection({canWrite, data, isPending, organization} function AgentNameForm({ canWrite, + isPending, + isBulkMutatingAgent, + setIsBulkMutatingAgent, + isBulkMutatingCreatePr, organization, projects, - projectsWithPreferredAgentCount, + projectsWithPreferredAgent, }: { canWrite: boolean; + isBulkMutatingAgent: boolean; + isBulkMutatingCreatePr: boolean; isPending: boolean; organization: Organization; projects: Project[]; - projectsWithPreferredAgentCount: number; + projectsWithPreferredAgent: AutofixAutomationSettings[]; + setIsBulkMutatingAgent: (value: boolean) => void; }) { const {data: integrations} = useQuery( organizationIntegrationsCodingAgents(organization) @@ -161,6 +187,22 @@ function AgentNameForm({ option => option.value === preferredAgentValue )?.label; + const preferredAgentIntegration = + preferredAgentValue === 'seer' + ? 'seer' + : rawAgentOptions + .filter(option => option.value !== 'seer') + .find(option => option.value.id === preferredAgentValue)?.value; + + const preferredAgentProjectIds = new Set( + projectsWithPreferredAgent.map(s => s.projectId) + ); + const projectsToUpdate = projects.filter(p => !preferredAgentProjectIds.has(p.id)); + + const bulkMutateSelectedAgent = useBulkMutateSelectedAgent({ + projects: projectsToUpdate, + }); + return ( + {projects.length === 0 ? t('No projects found') : projects.length === 1 - ? projectsWithPreferredAgentCount === 1 + ? projectsWithPreferredAgent.length === 1 ? t('Your existing project uses %s', preferredAgentLabel) : t('Your existing project does not use %s', preferredAgentLabel) - : projects.length === projectsWithPreferredAgentCount + : projects.length === projectsWithPreferredAgent.length ? t('All existing projects use %s', preferredAgentLabel) : t( '%s of %s existing projects use %s', - projectsWithPreferredAgentCount, + projectsWithPreferredAgent.length, projects.length, preferredAgentLabel )} @@ -212,15 +280,22 @@ function AgentNameForm({ function CreatePrForm({ canWrite, + isPending, + isBulkMutatingCreatePr, + setIsBulkMutatingCreatePr, + isBulkMutatingAgent, organization, projects, - projectsWithCreatePrCount, + projectsWithCreatePr, }: { canWrite: boolean; + isBulkMutatingAgent: boolean; + isBulkMutatingCreatePr: boolean; isPending: boolean; organization: Organization; projects: Project[]; - projectsWithCreatePrCount: number; + projectsWithCreatePr: AutofixAutomationSettings[]; + setIsBulkMutatingCreatePr: (value: boolean) => void; }) { const orgMutationOpts = mutationOptions({ mutationFn: (updateData: Partial) => @@ -232,6 +307,11 @@ function CreatePrForm({ onSuccess: updateOrganization, }); + const projectsWithCreatePrIds = new Set(projectsWithCreatePr.map(s => s.projectId)); + const projectsToUpdate = projects.filter(p => !projectsWithCreatePrIds.has(p.id)); + + const bulkMutateCreatePr = useBulkMutateCreatePr({projects: projectsToUpdate}); + return ( + {projects.length === 0 ? t('No projects found') : projects.length === 1 - ? projectsWithCreatePrCount === 1 + ? projectsWithCreatePr.length === 1 ? t('Your existing project has Create PR enabled') : t('Your existing project does not have Create PR enabled') : field.state.value - ? projects.length === projectsWithCreatePrCount + ? projects.length === projectsWithCreatePr.length ? t('All existing projects have Create PR enabled') : t( '%s of %s existing projects have Create PR enabled', - projectsWithCreatePrCount, + projectsWithCreatePr.length, projects.length ) - : projects.length === projectsWithCreatePrCount + : projects.length === projectsWithCreatePr.length ? t('All existing projects have Create PR disabled') : t( '%s of %s existing projects have Create PR disabled', - projectsWithCreatePrCount, + projectsWithCreatePr.length, projects.length )} diff --git a/static/app/endpoints/organizations/organizationsIntegrationsQueryOptions.ts b/static/app/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts similarity index 100% rename from static/app/endpoints/organizations/organizationsIntegrationsQueryOptions.ts rename to static/app/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions.ts diff --git a/static/app/views/settings/seer/seerAgentHooks.spec.tsx b/static/app/views/settings/seer/seerAgentHooks.spec.tsx index d7a0f5eaedb1..bb72d9c17e76 100644 --- a/static/app/views/settings/seer/seerAgentHooks.spec.tsx +++ b/static/app/views/settings/seer/seerAgentHooks.spec.tsx @@ -14,6 +14,8 @@ import {ProjectsStore} from 'sentry/stores/projectsStore'; import {useQueryClient} from 'sentry/utils/queryClient'; import { useAgentOptions, + useBulkMutateCreatePr, + useBulkMutateSelectedAgent, useMutateCreatePr, useMutateSelectedAgent, useSelectedAgentFromBulkSettings, @@ -35,7 +37,7 @@ describe('seerAgentHooks', () => { }); describe('useAgentOptions', () => { - it('returns Seer, integration options, and No Handoff Selection', () => { + it('returns Seer, integration options', () => { const integrations: CodingAgentIntegration[] = [ {id: '42', name: 'Cursor', provider: 'cursor'}, ]; @@ -616,6 +618,645 @@ describe('seerAgentHooks', () => { }); }); + describe('useBulkMutateSelectedAgent', () => { + const project1 = ProjectFixture({slug: 'project-slug', id: '1'}); + const project2 = ProjectFixture({slug: 'project-slug-2', id: '2'}); + const projects = [project1, project2]; + + const basePreference: ProjectSeerPreferences = { + repositories: [], + automated_run_stopping_point: 'code_changes', + automation_handoff: undefined, + }; + + function setupMocks(preference: ProjectSeerPreferences = basePreference) { + const mocks = projects.map(p => ({ + seerPreferencesGetRequest: MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + method: 'GET', + body: {preference, code_mapping_repos: []} satisfies SeerPreferencesResponse, + }), + projectPutRequest: MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${p.slug}/`, + method: 'PUT', + body: p, + }), + seerPreferencesPostRequest: MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + method: 'POST', + body: {}, + }), + })); + return { + seerPreferencesGetRequests: mocks.map(m => m.seerPreferencesGetRequest), + projectPutRequests: mocks.map(m => m.projectPutRequest), + seerPreferencesPostRequests: mocks.map(m => m.seerPreferencesPostRequest), + }; + } + + function renderBulkMutateSelectedAgent() { + return renderHookWithProviders( + (props: {projects: typeof projects}) => { + const mutate = useBulkMutateSelectedAgent(props); + return {mutate}; + }, + { + initialProps: {projects}, + organization, + } + ); + } + + beforeEach(() => { + ProjectsStore.loadInitialData(projects); + }); + + it('sends correct API requests to all projects when integration is "seer"', async () => { + const {projectPutRequests, seerPreferencesPostRequests} = setupMocks(); + const {result} = renderBulkMutateSelectedAgent(); + + act(() => { + result.current.mutate('seer', {}); + }); + + await waitFor(() => { + expect(projectPutRequests[0]).toHaveBeenCalledTimes(1); + }); + + projects.forEach((p, i) => { + expect(projectPutRequests[i]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${p.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: {autofixAutomationTuning: 'medium'}, + }) + ); + expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + expect.objectContaining({ + method: 'POST', + data: expect.objectContaining({ + repositories: [], + automated_run_stopping_point: 'code_changes', + automation_handoff: undefined, + }), + }) + ); + }); + }); + + it('sends correct API requests to all projects when integration is a CodingAgentIntegration', async () => { + const {projectPutRequests, seerPreferencesPostRequests} = setupMocks(); + const integration: CodingAgentIntegration = { + id: '123', + name: 'Cursor', + provider: 'cursor', + }; + const {result} = renderBulkMutateSelectedAgent(); + + act(() => { + result.current.mutate(integration, {}); + }); + + await waitFor(() => { + expect(projectPutRequests[0]).toHaveBeenCalledTimes(1); + }); + + projects.forEach((p, i) => { + expect(projectPutRequests[i]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${p.slug}/`, + expect.objectContaining({ + method: 'PUT', + data: {autofixAutomationTuning: 'medium'}, + }) + ); + expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + expect.objectContaining({ + method: 'POST', + data: expect.objectContaining({ + automation_handoff: { + handoff_point: 'root_cause', + target: 'cursor_background_agent', + integration_id: 123, + auto_create_pr: false, + }, + }), + }) + ); + }); + }); + + it('sets auto_create_pr true when preference stopping point is open_pr', async () => { + const {seerPreferencesPostRequests} = setupMocks({ + ...basePreference, + automated_run_stopping_point: 'open_pr', + }); + const integration: CodingAgentIntegration = { + id: '456', + name: 'Cursor', + provider: 'cursor', + }; + const {result} = renderBulkMutateSelectedAgent(); + + act(() => { + result.current.mutate(integration, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); + }); + + projects.forEach((_p, i) => { + expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + auto_create_pr: true, + }), + }), + }) + ); + }); + }); + + it('preserves repositories from each project preference', async () => { + const preferenceWithRepos: ProjectSeerPreferences = { + repositories: [ + {external_id: 'repo-1', name: 'my-repo', owner: 'my-org', provider: 'github'}, + ], + automated_run_stopping_point: 'code_changes', + automation_handoff: undefined, + }; + const {seerPreferencesPostRequests} = setupMocks(preferenceWithRepos); + const {result} = renderBulkMutateSelectedAgent(); + + act(() => { + result.current.mutate('seer', {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); + }); + + projects.forEach((_p, i) => { + expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + data: expect.objectContaining({ + repositories: [ + { + external_id: 'repo-1', + name: 'my-repo', + owner: 'my-org', + provider: 'github', + }, + ], + }), + }) + ); + }); + }); + + it('updates ProjectsStore for all projects', async () => { + setupMocks(); + const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); + const {result} = renderBulkMutateSelectedAgent(); + + act(() => { + result.current.mutate('seer', {}); + }); + + await waitFor(() => { + expect(storeSpy).toHaveBeenCalledTimes(2); + }); + + projects.forEach(p => { + expect(storeSpy).toHaveBeenCalledWith({ + id: p.id, + autofixAutomationTuning: 'medium', + }); + }); + }); + + it('updates ProjectsStore with "off" tuning for all projects when integration is "none"', async () => { + setupMocks(); + const storeSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess'); + const {result} = renderBulkMutateSelectedAgent(); + + act(() => { + result.current.mutate('none', {}); + }); + + await waitFor(() => { + expect(storeSpy).toHaveBeenCalledTimes(2); + }); + + projects.forEach(p => { + expect(storeSpy).toHaveBeenCalledWith({ + id: p.id, + autofixAutomationTuning: 'off', + }); + }); + }); + + it('calls onSuccess when all requests succeed', async () => { + setupMocks(); + const onSuccess = jest.fn(); + const {result} = renderBulkMutateSelectedAgent(); + + act(() => { + result.current.mutate('seer', {onSuccess}); + }); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + }); + + it('calls onError when any request fails', async () => { + projects.forEach(p => { + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + method: 'GET', + body: { + preference: basePreference, + code_mapping_repos: [], + } satisfies SeerPreferencesResponse, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${p.slug}/`, + method: 'PUT', + statusCode: 500, + body: {}, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + method: 'POST', + body: {}, + }); + }); + const onError = jest.fn(); + const {result} = renderBulkMutateSelectedAgent(); + + act(() => { + result.current.mutate('seer', {onError}); + }); + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1); + }); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('does nothing when projects list is empty', async () => { + const emptyProjectsMutate = renderHookWithProviders( + () => useBulkMutateSelectedAgent({projects: []}), + {organization} + ); + const onSuccess = jest.fn(); + + act(() => { + emptyProjectsMutate.result.current('seer', {onSuccess}); + }); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('useBulkMutateCreatePr', () => { + const project1 = ProjectFixture({slug: 'project-slug', id: '1'}); + const project2 = ProjectFixture({slug: 'project-slug-2', id: '2'}); + const projects = [project1, project2]; + + const seerPreference: ProjectSeerPreferences = { + repositories: [], + automated_run_stopping_point: 'code_changes', + automation_handoff: undefined, + }; + + const integrationPreference: ProjectSeerPreferences = { + repositories: [], + automated_run_stopping_point: 'code_changes', + automation_handoff: { + handoff_point: 'root_cause', + target: CodingAgentProvider.CURSOR_BACKGROUND_AGENT, + integration_id: 123, + auto_create_pr: false, + }, + }; + + function setupMocks( + p1Preference: ProjectSeerPreferences = seerPreference, + p2Preference: ProjectSeerPreferences = seerPreference + ) { + const perProjectPrefs = [p1Preference, p2Preference]; + const mocks = projects.map((p, i) => ({ + seerPreferencesGetRequest: MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + method: 'GET', + body: { + preference: perProjectPrefs[i], + code_mapping_repos: [], + } satisfies SeerPreferencesResponse, + }), + seerPreferencesPostRequest: MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + method: 'POST', + body: {}, + }), + })); + return { + seerPreferencesGetRequests: mocks.map(m => m.seerPreferencesGetRequest), + seerPreferencesPostRequests: mocks.map(m => m.seerPreferencesPostRequest), + }; + } + + function renderBulkMutateCreatePr() { + return renderHookWithProviders( + (props: {projects: typeof projects}) => { + const mutate = useBulkMutateCreatePr(props); + return {mutate}; + }, + { + initialProps: {projects}, + organization, + } + ); + } + + beforeEach(() => { + ProjectsStore.loadInitialData(projects); + }); + + it('sets automated_run_stopping_point for Seer projects when enabling Create PR', async () => { + const {seerPreferencesPostRequests} = setupMocks(); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(true, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); + }); + + projects.forEach((p, i) => { + expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + expect.objectContaining({ + method: 'POST', + data: expect.objectContaining({ + automated_run_stopping_point: 'open_pr', + automation_handoff: undefined, + }), + }) + ); + }); + }); + + it('sets automated_run_stopping_point for Seer projects when disabling Create PR', async () => { + const {seerPreferencesPostRequests} = setupMocks( + {...seerPreference, automated_run_stopping_point: 'open_pr'}, + {...seerPreference, automated_run_stopping_point: 'open_pr'} + ); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(false, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); + }); + + projects.forEach((p, i) => { + expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automated_run_stopping_point: 'code_changes', + }), + }) + ); + }); + }); + + it('sets auto_create_pr in automation_handoff for external agent projects', async () => { + const {seerPreferencesPostRequests} = setupMocks( + integrationPreference, + integrationPreference + ); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(true, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); + }); + + projects.forEach((p, i) => { + expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + integration_id: 123, + auto_create_pr: true, + }), + }), + }) + ); + }); + }); + + it('disables auto_create_pr in automation_handoff for external agent projects', async () => { + const {seerPreferencesPostRequests} = setupMocks( + { + ...integrationPreference, + automation_handoff: { + ...integrationPreference.automation_handoff!, + auto_create_pr: true, + }, + }, + { + ...integrationPreference, + automation_handoff: { + ...integrationPreference.automation_handoff!, + auto_create_pr: true, + }, + } + ); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(false, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); + }); + + projects.forEach((_p, i) => { + expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + auto_create_pr: false, + }), + }), + }) + ); + }); + }); + + it('handles mixed projects — Seer and external agent — correctly', async () => { + const {seerPreferencesPostRequests} = setupMocks( + seerPreference, + integrationPreference + ); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(true, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); + }); + + // project1 (Seer): uses stopping_point + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project1.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automated_run_stopping_point: 'open_pr', + automation_handoff: undefined, + }), + }) + ); + // project2 (external agent): uses auto_create_pr in handoff + expect(seerPreferencesPostRequests[1]).toHaveBeenCalledWith( + `/projects/${organization.slug}/${project2.slug}/seer/preferences/`, + expect.objectContaining({ + data: expect.objectContaining({ + automation_handoff: expect.objectContaining({ + integration_id: 123, + auto_create_pr: true, + }), + }), + }) + ); + }); + + it('preserves existing repositories and other handoff fields', async () => { + const preferenceWithRepos: ProjectSeerPreferences = { + repositories: [ + {external_id: 'repo-1', name: 'my-repo', owner: 'my-org', provider: 'github'}, + ], + automated_run_stopping_point: 'code_changes', + automation_handoff: undefined, + }; + const {seerPreferencesPostRequests} = setupMocks( + preferenceWithRepos, + preferenceWithRepos + ); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(true, {}); + }); + + await waitFor(() => { + expect(seerPreferencesPostRequests[0]).toHaveBeenCalledTimes(1); + }); + + projects.forEach((_p, i) => { + expect(seerPreferencesPostRequests[i]).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + data: expect.objectContaining({ + repositories: [ + { + external_id: 'repo-1', + name: 'my-repo', + owner: 'my-org', + provider: 'github', + }, + ], + }), + }) + ); + }); + }); + + it('calls onSuccess when all requests succeed', async () => { + setupMocks(); + const onSuccess = jest.fn(); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(true, {onSuccess}); + }); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + }); + + it('calls onError when any request fails', async () => { + projects.forEach(p => { + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + method: 'GET', + body: { + preference: seerPreference, + code_mapping_repos: [], + } satisfies SeerPreferencesResponse, + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${p.slug}/seer/preferences/`, + method: 'POST', + statusCode: 500, + body: {}, + }); + }); + const onError = jest.fn(); + const {result} = renderBulkMutateCreatePr(); + + act(() => { + result.current.mutate(true, {onError}); + }); + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1); + }); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('does nothing when projects list is empty', async () => { + const emptyProjectsMutate = renderHookWithProviders( + () => useBulkMutateCreatePr({projects: []}), + {organization} + ); + const onSuccess = jest.fn(); + + act(() => { + emptyProjectsMutate.result.current(true, {onSuccess}); + }); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + }); + }); + describe('useMutateCreatePr', () => { const basePreference: ProjectSeerPreferences = { repositories: [], diff --git a/static/app/views/settings/seer/seerAgentHooks.tsx b/static/app/views/settings/seer/seerAgentHooks.tsx index 4c19a50cf641..7aac636bce32 100644 --- a/static/app/views/settings/seer/seerAgentHooks.tsx +++ b/static/app/views/settings/seer/seerAgentHooks.tsx @@ -1,9 +1,12 @@ import {useCallback, useMemo} from 'react'; +import {addErrorMessage} from 'sentry/actionCreators/indicator'; import { bulkAutofixAutomationSettingsInfiniteOptions, type AutofixAutomationSettings, } from 'sentry/components/events/autofix/preferences/hooks/useBulkAutofixAutomationSettings'; +import {makeProjectSeerPreferencesQueryKey} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; +import type {SeerPreferencesResponse} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences'; import { useFetchProjectSeerPreferences, useUpdateProjectSeerPreferences, @@ -14,8 +17,10 @@ import {type CodingAgentIntegration} from 'sentry/components/events/autofix/useA import {t} from 'sentry/locale'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {Project} from 'sentry/types/project'; +import {processInChunks} from 'sentry/utils/array/procesInChunks'; import {useUpdateProject} from 'sentry/utils/project/useUpdateProject'; -import {useQueryClient} from 'sentry/utils/queryClient'; +import {fetchDataQuery, fetchMutation, useQueryClient} from 'sentry/utils/queryClient'; +import {RequestError} from 'sentry/utils/requestError/requestError'; import {useOrganization} from 'sentry/utils/useOrganization'; export function useAgentOptions({ @@ -95,11 +100,6 @@ export function useSelectedAgentFromBulkSettings({ ]); } -type MutateOptions = { - onError?: (error: Error) => void; - onSuccess?: () => void; -}; - function useApplyOptimisticUpdate({project}: {project: Project}) { const queryClient = useQueryClient(); const organization = useOrganization(); @@ -140,6 +140,11 @@ function useApplyOptimisticUpdate({project}: {project: Project}) { ); } +type MutateOptions = { + onError?: (error: Error) => void; + onSuccess?: () => void; +}; + export function useMutateSelectedAgent({project}: {project: Project}) { const {mutateAsync: updateProject} = useUpdateProject(project); const {mutateAsync: updateProjectSeerPreferences} = @@ -209,6 +214,159 @@ export function useMutateSelectedAgent({project}: {project: Project}) { ); } +export function useBulkMutateSelectedAgent({projects}: {projects: Project[]}) { + const organization = useOrganization(); + const queryClient = useQueryClient(); + const autofixSettingsQueryOptions = bulkAutofixAutomationSettingsInfiniteOptions({ + organization, + }); + + return useCallback( + async ( + integration: 'seer' | 'none' | CodingAgentIntegration, + {onSuccess, onError}: MutateOptions + ) => { + const results = await processInChunks({ + items: projects, + chunkSize: 15, + fn: async project => { + const [preferencesData] = await queryClient.fetchQuery({ + queryKey: makeProjectSeerPreferencesQueryKey(organization.slug, project.slug), + queryFn: fetchDataQuery, + staleTime: 0, + }); + const preference = preferencesData?.preference; + + const handoff: ProjectSeerPreferences['automation_handoff'] = + integration !== 'seer' && integration !== 'none' && integration + ? { + handoff_point: 'root_cause', + target: PROVIDER_TO_HANDOFF_TARGET[integration.provider]!, + integration_id: Number(integration.id), + auto_create_pr: preference?.automated_run_stopping_point === 'open_pr', + } + : undefined; + + return Promise.all([ + fetchMutation({ + method: 'PUT', + url: `/projects/${organization.slug}/${project.slug}/`, + data: {autofixAutomationTuning: integration === 'none' ? 'off' : 'medium'}, + }), + fetchMutation({ + method: 'POST', + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + data: { + repositories: preference?.repositories ?? [], + automated_run_stopping_point: preference?.automated_run_stopping_point, + automation_handoff: handoff, + }, + }), + ]); + }, + }); + + // Update store only for projects that succeeded + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + ProjectsStore.onUpdateSuccess({ + id: projects[i]!.id, + autofixAutomationTuning: integration === 'none' ? 'off' : 'medium', + }); + } + }); + + // Always invalidate to sync cache with whatever the server actually saved + queryClient.invalidateQueries({ + queryKey: autofixSettingsQueryOptions.queryKey, + }); + + const failures = results.filter(r => r.status === 'rejected'); + if (failures.length === 0) { + onSuccess?.(); + } else { + const has429 = failures.some( + r => r.reason instanceof RequestError && r.reason.status === 429 + ); + if (has429) { + addErrorMessage( + t('Too many requests. Please wait a moment before trying again.') + ); + } else { + onError?.(new Error('Failed to update agent setting')); + } + } + }, + [projects, organization, queryClient, autofixSettingsQueryOptions.queryKey] + ); +} + +export function useBulkMutateCreatePr({projects}: {projects: Project[]}) { + const organization = useOrganization(); + const queryClient = useQueryClient(); + const autofixSettingsQueryOptions = bulkAutofixAutomationSettingsInfiniteOptions({ + organization, + }); + + return useCallback( + async (value: boolean, {onSuccess, onError}: MutateOptions) => { + const results = await processInChunks({ + items: projects, + chunkSize: 10, + fn: async project => { + const [preferencesData] = await queryClient.fetchQuery({ + queryKey: makeProjectSeerPreferencesQueryKey(organization.slug, project.slug), + queryFn: fetchDataQuery, + staleTime: 0, + }); + const preference = preferencesData?.preference; + + return fetchMutation({ + method: 'POST', + url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`, + data: preference?.automation_handoff?.integration_id + ? { + repositories: preference?.repositories ?? [], + automated_run_stopping_point: preference?.automated_run_stopping_point, + automation_handoff: { + ...preference.automation_handoff, + auto_create_pr: value, + }, + } + : { + repositories: preference?.repositories ?? [], + automated_run_stopping_point: value ? 'open_pr' : 'code_changes', + automation_handoff: preference?.automation_handoff, + }, + }); + }, + }); + + // Always invalidate to sync cache with whatever the server actually saved + queryClient.invalidateQueries({ + queryKey: autofixSettingsQueryOptions.queryKey, + }); + + const failures = results.filter(r => r.status === 'rejected'); + if (failures.length === 0) { + onSuccess?.(); + } else { + const has429 = failures.some( + r => r.reason instanceof RequestError && r.reason.status === 429 + ); + if (has429) { + addErrorMessage( + t('Too many requests. Please wait a moment before trying again.') + ); + } else { + onError?.(new Error('Failed to update PR setting')); + } + } + }, + [projects, organization, queryClient, autofixSettingsQueryOptions.queryKey] + ); +} + export function useMutateCreatePr({project}: {project: Project}) { const {mutateAsync: updateProjectSeerPreferences} = useUpdateProjectSeerPreferences(project); diff --git a/tests/js/fixtures/organization.ts b/tests/js/fixtures/organization.ts index 3e386b6b06f4..ba32d2dac427 100644 --- a/tests/js/fixtures/organization.ts +++ b/tests/js/fixtures/organization.ts @@ -55,8 +55,8 @@ export function OrganizationFixture(params: Partial = {}): Organiz dateCreated: new Date().toISOString(), debugFilesRole: '', defaultCodeReviewTriggers: [], + defaultCodingAgentIntegrationId: null, defaultCodingAgent: 'seer', - defaultCodingAgentIntegrationId: undefined, defaultRole: '', enhancedPrivacy: false, eventsMemberAdmin: false, From 5f0a97f2ea64eb0a6cb5e9ea84d877a6090e2b43 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 31 Mar 2026 14:21:43 -0700 Subject: [PATCH 46/51] fix(issues): Avoid supergroup refetches on row removal (#111865) Reuse the previous supergroup request while the visible issue ids only shrink. This keeps archive and resolve actions from issuing a new supergroup lookup and flashing the stream back into a loading state. Co-authored-by: OpenAI Codex --- .../app/utils/supergroup/useSuperGroups.tsx | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/static/app/utils/supergroup/useSuperGroups.tsx b/static/app/utils/supergroup/useSuperGroups.tsx index 664ea33df0a6..8385fb02ac15 100644 --- a/static/app/utils/supergroup/useSuperGroups.tsx +++ b/static/app/utils/supergroup/useSuperGroups.tsx @@ -1,4 +1,4 @@ -import {useMemo} from 'react'; +import {useMemo, useRef} from 'react'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {useApiQuery} from 'sentry/utils/queryClient'; @@ -17,8 +17,24 @@ export function useSuperGroups(groupIds: string[]): { isLoading: boolean; } { const organization = useOrganization(); + const requestedGroupIdsRef = useRef(groupIds); const hasTopIssuesUI = organization.features.includes('top-issues-ui'); - const enabled = hasTopIssuesUI && groupIds.length > 0; + const shouldReuseRequestedGroupIds = useMemo(() => { + const requestedGroupIds = requestedGroupIdsRef.current; + + if (groupIds.length === 0 || requestedGroupIds.length < groupIds.length) { + return false; + } + + const requestedGroupIdSet = new Set(requestedGroupIds); + return groupIds.every(groupId => requestedGroupIdSet.has(groupId)); + }, [groupIds]); + + const requestedGroupIds = shouldReuseRequestedGroupIds + ? requestedGroupIdsRef.current + : groupIds; + requestedGroupIdsRef.current = requestedGroupIds; + const enabled = hasTopIssuesUI && requestedGroupIds.length > 0; const {data: response, isLoading} = useApiQuery<{data: SupergroupDetail[]}>( [ @@ -27,7 +43,7 @@ export function useSuperGroups(groupIds: string[]): { }), { query: { - group_id: groupIds, + group_id: requestedGroupIds, }, }, ], From 8503c7d31bfd5ccd0659dca0afcefa03803c18fa Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 31 Mar 2026 14:22:08 -0700 Subject: [PATCH 47/51] perf(feedback): Collapse stats on feedback group details request (#111947) Adds `collapse=stats` to the feedback group details query key so the group details endpoint skips computing 24h/30d time-series stats. The feedback details page doesn't use the `stats` field from the group response. Matches the pattern from #111156 which did the same for the issue details page. --- static/app/components/feedback/getFeedbackItemQueryKey.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/static/app/components/feedback/getFeedbackItemQueryKey.tsx b/static/app/components/feedback/getFeedbackItemQueryKey.tsx index 6c343a321214..bd33c083436f 100644 --- a/static/app/components/feedback/getFeedbackItemQueryKey.tsx +++ b/static/app/components/feedback/getFeedbackItemQueryKey.tsx @@ -19,7 +19,7 @@ export function getFeedbackItemQueryKey({feedbackId, organization}: Props): { }), { query: { - collapse: ['release', 'tags'], + collapse: ['release', 'tags', 'stats'], }, }, ] @@ -36,6 +36,11 @@ export function getFeedbackItemQueryKey({feedbackId, organization}: Props): { }, } ), + { + query: { + collapse: ['fullRelease'], + }, + }, ] : undefined, }; From f6c8196641693ee37d5e2be1ac9688c3eee32c80 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 31 Mar 2026 17:32:02 -0400 Subject: [PATCH 48/51] styles(autofix): Add more whitespace to autofix cards (#111951) 8px felt too tight, bump it up to 12px --- static/app/components/events/autofix/v3/autofixCards.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/static/app/components/events/autofix/v3/autofixCards.tsx b/static/app/components/events/autofix/v3/autofixCards.tsx index fa4b42347aa0..a13d58f40222 100644 --- a/static/app/components/events/autofix/v3/autofixCards.tsx +++ b/static/app/components/events/autofix/v3/autofixCards.tsx @@ -370,7 +370,7 @@ interface ArtifactCardProps { function ArtifactCard({children, icon, title}: ArtifactCardProps) { return ( - + @@ -379,7 +379,7 @@ function ArtifactCard({children, icon, title}: ArtifactCardProps) { - + {children} @@ -394,7 +394,7 @@ interface ArtifactDetailsProps extends FlexProps { function ArtifactDetails({children, ...flexProps}: ArtifactDetailsProps) { return ( - + {children} ); From 2a005ec631fe14cc19367b8ce551877c4807c087 Mon Sep 17 00:00:00 2001 From: Kyle Consalus Date: Tue, 31 Mar 2026 14:34:58 -0700 Subject: [PATCH 49/51] perf(workflows): Batch Action fetching in WorkflowEngineRuleSerializer (#111945) Fixes SENTRY-5MD5. --- src/sentry/api/serializers/models/rule.py | 31 ++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/sentry/api/serializers/models/rule.py b/src/sentry/api/serializers/models/rule.py index a1ef225e6596..1a518cdfc172 100644 --- a/src/sentry/api/serializers/models/rule.py +++ b/src/sentry/api/serializers/models/rule.py @@ -31,6 +31,7 @@ WorkflowDataConditionGroup, ) from sentry.workflow_engine.models.data_condition import is_slow_condition +from sentry.workflow_engine.models.data_condition_group_action import DataConditionGroupAction from sentry.workflow_engine.models.detector_workflow import DetectorWorkflow from sentry.workflow_engine.processors.workflow_fire_history import get_last_fired_dates from sentry.workflow_engine.registry import condition_handler_registry @@ -454,8 +455,23 @@ def _fetch_workflow_owner(self, workflow: Workflow) -> str | None: return actor.identifier return None - def _fetch_actions(self, condition_group: DataConditionGroup) -> BaseQuerySet[Action]: - return Action.objects.filter(dataconditiongroupaction__condition_group=condition_group) + def _fetch_actions_by_dcg(self, condition_group_ids: Sequence[int]) -> dict[int, list[Action]]: + dcg_actions = ( + DataConditionGroupAction.objects.filter( + condition_group_id__in=condition_group_ids, + ) + .exclude( + action__status__in=[ + ObjectStatus.DELETION_IN_PROGRESS, + ObjectStatus.PENDING_DELETION, + ], + ) + .select_related("action") + ) + result: dict[int, list[Action]] = defaultdict(list) + for dcg_action in dcg_actions: + result[dcg_action.condition_group_id].append(dcg_action.action) + return result def _generate_rule_conditions_filters( self, workflow: Workflow, project: Project, workflow_dcg: WorkflowDataConditionGroup @@ -568,6 +584,15 @@ def get_attrs(self, item_list: Sequence[Workflow], user, **kwargs): # Bulk fetch workflow -> rule ids workflow_rule_ids = self._fetch_workflow_rule_ids(item_list) + # Bulk fetch actions for all condition groups across all workflows + all_dcg_ids: list[int] = [] + for wf in workflows: + all_dcg_ids.extend( + wdcg.condition_group_id + for wdcg in wf.prefetched_wdcgs # type: ignore[attr-defined] + ) + actions_by_dcg = self._fetch_actions_by_dcg(all_dcg_ids) + last_triggered_lookup: dict[int, datetime] = {} if "lastTriggered" in self.expand: last_triggered_lookup = self._fetch_workflow_last_triggered(item_list) @@ -606,7 +631,7 @@ def get_attrs(self, item_list: Sequence[Workflow], user, **kwargs): result[workflow]["filter_match"] = workflow_dcg.condition_group.logic_type # build up actions data - actions = self._fetch_actions(workflow_dcg.condition_group) + actions = actions_by_dcg.get(workflow_dcg.condition_group_id, []) action_to_handler = {} for action in actions: try: From 50cabc08a275abb5811856926e037ed8d579e9c9 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 31 Mar 2026 14:47:26 -0700 Subject: [PATCH 50/51] feat(feedback): Redirect to the feedback details page when given a projectName & eventId (#111931) Test plan: I have a feedback with `groupId: 7377579909` & `eventId: 0e8581c24ade4bb8bb59b3a0f8387b4b` in the `javascript` project. Normally I would need to go to visit `/issues/feedback/?feedbackSlug=javascript:7377579909` to see that feedback properly, using the groupId in the url. Now I've updated the url to not require the the `project` part at all, so the url can be `/issues/feedback/?feedbackSlug=7377579909`, woo! But we can't construct that url from the sdk, we don't know the groupId at that time, only the event it. Turns out we have a redirect in place already, using the url template `/organizations/${orgSlug}/projects/${projectSlug}/events/${eventId}/` we do a redirect to the proper issue. I fixed this redirect to see if the issue is a feedback issue or not, and if it is then we'll use the proper feedback view instead of the generic feedback view! Tested by visiting `/projects/javascript/events/0e8581c24ade4bb8bb59b3a0f8387b4b/` which now takes me to `/issues/feedback/?feedbackSlug=javascript%3A7377579909` I also was able to import nuqs and create a single, simpler, `useFeedbackSlug` hook to fetch the values from the url. Turns out we don't _really_ need projectSlug except to make the alert-creation page have better defaults. Fixes https://linear.app/getsentry/issue/REPLAY-873/direct-url-to-user-feedback-from-capturefeedback Fixes #108213 --- .../feedback/decodeFeedbackSlug.tsx | 13 ---- .../feedbackItem/feedbackItemLoader.tsx | 12 +-- .../feedback/feedbackItem/feedbackShortId.tsx | 5 +- .../feedback/useCurrentFeedbackId.tsx | 12 --- .../feedback/useCurrentFeedbackProject.tsx | 12 --- .../components/feedback/useFeedbackSlug.tsx | 24 ++++++ .../useRedirectToFeedbackFromEvent.tsx | 15 ++-- .../app/views/feedback/feedbackListPage.tsx | 9 ++- .../app/views/projectEventRedirect.spec.tsx | 77 +++++++++++++++++++ static/app/views/projectEventRedirect.tsx | 19 +++++ 10 files changed, 138 insertions(+), 60 deletions(-) delete mode 100644 static/app/components/feedback/decodeFeedbackSlug.tsx delete mode 100644 static/app/components/feedback/useCurrentFeedbackId.tsx delete mode 100644 static/app/components/feedback/useCurrentFeedbackProject.tsx create mode 100644 static/app/components/feedback/useFeedbackSlug.tsx diff --git a/static/app/components/feedback/decodeFeedbackSlug.tsx b/static/app/components/feedback/decodeFeedbackSlug.tsx deleted file mode 100644 index 2f2d9b973e32..000000000000 --- a/static/app/components/feedback/decodeFeedbackSlug.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {decodeScalar} from 'sentry/utils/queryString'; - -// TypeScript thinks the values are strings, but they could be undefined. -// See `noUncheckedIndexedAccess` -interface Return { - feedbackId: string | undefined; - projectSlug: string | undefined; -} - -export function decodeFeedbackSlug(val: string | string[] | null | undefined): Return { - const [projectSlug, feedbackId] = decodeScalar(val, '').split(':'); - return {projectSlug, feedbackId}; -} diff --git a/static/app/components/feedback/feedbackItem/feedbackItemLoader.tsx b/static/app/components/feedback/feedbackItem/feedbackItemLoader.tsx index 07ae5a548697..f55033feecfa 100644 --- a/static/app/components/feedback/feedbackItem/feedbackItemLoader.tsx +++ b/static/app/components/feedback/feedbackItem/feedbackItemLoader.tsx @@ -4,8 +4,7 @@ import {ErrorBoundary} from 'sentry/components/errorBoundary'; import {FeedbackEmptyDetails} from 'sentry/components/feedback/details/feedbackEmptyDetails'; import {FeedbackErrorDetails} from 'sentry/components/feedback/details/feedbackErrorDetails'; import {FeedbackItem} from 'sentry/components/feedback/feedbackItem/feedbackItem'; -import {useCurrentFeedbackId} from 'sentry/components/feedback/useCurrentFeedbackId'; -import {useCurrentFeedbackProject} from 'sentry/components/feedback/useCurrentFeedbackProject'; +import {useFeedbackSlug} from 'sentry/components/feedback/useFeedbackSlug'; import {useFetchFeedbackData} from 'sentry/components/feedback/useFetchFeedbackData'; import {Placeholder} from 'sentry/components/placeholder'; import {t} from 'sentry/locale'; @@ -19,11 +18,12 @@ interface Props { export function FeedbackItemLoader({onBackToList}: Props = {}) { const organization = useOrganization(); - const feedbackId = useCurrentFeedbackId(); - const {issueResult, issueData, eventData} = useFetchFeedbackData({feedbackId}); + const [feedbackSlug] = useFeedbackSlug(); + const feedbackId = feedbackSlug?.feedbackId ?? ''; - const projectSlug = useCurrentFeedbackProject(); - useSentryAppComponentsData({projectId: projectSlug}); + const {issueResult, issueData, eventData} = useFetchFeedbackData({feedbackId}); + const projectId = issueData?.project?.id ?? feedbackSlug?.projectSlug; + useSentryAppComponentsData({projectId}); useEffect(() => { if (issueResult.isError) { diff --git a/static/app/components/feedback/feedbackItem/feedbackShortId.tsx b/static/app/components/feedback/feedbackItem/feedbackShortId.tsx index ec7eef5e1495..012a14a25bc7 100644 --- a/static/app/components/feedback/feedbackItem/feedbackShortId.tsx +++ b/static/app/components/feedback/feedbackItem/feedbackShortId.tsx @@ -6,7 +6,6 @@ import queryString from 'query-string'; import {Flex} from '@sentry/scraps/layout'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; -import {useCurrentFeedbackProject} from 'sentry/components/feedback/useCurrentFeedbackProject'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import {TextOverflow} from 'sentry/components/textOverflow'; import {IconChevron} from 'sentry/icons'; @@ -38,7 +37,7 @@ const hideDropdown = css` export function FeedbackShortId({className, feedbackItem, style}: Props) { const organization = useOrganization(); - const projectSlug = useCurrentFeedbackProject(); + const projectSlug = feedbackItem.project?.slug ?? ''; // we need the stringifyUrl part so that the whole item is a string // for the copy url button below. normalizeUrl can return an object if `query` @@ -52,7 +51,7 @@ export function FeedbackShortId({className, feedbackItem, style}: Props) { queryString.stringifyUrl({ url: '?', query: { - feedbackSlug: `${projectSlug}:${feedbackItem.id}`, + feedbackSlug: projectSlug ? `${projectSlug}:${feedbackItem.id}` : feedbackItem.id, project: feedbackItem.project?.id, }, }); diff --git a/static/app/components/feedback/useCurrentFeedbackId.tsx b/static/app/components/feedback/useCurrentFeedbackId.tsx deleted file mode 100644 index c98e011abab9..000000000000 --- a/static/app/components/feedback/useCurrentFeedbackId.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {decodeFeedbackSlug} from 'sentry/components/feedback/decodeFeedbackSlug'; -import {useLocationQuery} from 'sentry/utils/url/useLocationQuery'; - -// See also: useCurrentFeedbackProject() - -export function useCurrentFeedbackId() { - const {feedbackSlug: feedbackId} = useLocationQuery({ - fields: {feedbackSlug: (val: any) => decodeFeedbackSlug(val).feedbackId ?? ''}, - }); - - return feedbackId; -} diff --git a/static/app/components/feedback/useCurrentFeedbackProject.tsx b/static/app/components/feedback/useCurrentFeedbackProject.tsx deleted file mode 100644 index 3d9113ec35ae..000000000000 --- a/static/app/components/feedback/useCurrentFeedbackProject.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {decodeFeedbackSlug} from 'sentry/components/feedback/decodeFeedbackSlug'; -import {useLocationQuery} from 'sentry/utils/url/useLocationQuery'; - -// See also: useCurrentFeedbackId() - -export function useCurrentFeedbackProject() { - const {feedbackSlug: projectSlug} = useLocationQuery({ - fields: {feedbackSlug: (val: any) => decodeFeedbackSlug(val).projectSlug ?? ''}, - }); - - return projectSlug; -} diff --git a/static/app/components/feedback/useFeedbackSlug.tsx b/static/app/components/feedback/useFeedbackSlug.tsx new file mode 100644 index 000000000000..e339a6c144eb --- /dev/null +++ b/static/app/components/feedback/useFeedbackSlug.tsx @@ -0,0 +1,24 @@ +import {createParser, useQueryState} from 'nuqs'; + +type FeedbackSlug = { + feedbackId: string; + projectSlug: string; +}; + +const parseFeedbackSlug = createParser({ + parse: value => { + if (value.includes(':')) { + const [projectSlug, feedbackId] = value.split(':'); + return {projectSlug: projectSlug!, feedbackId: feedbackId!}; + } + return {feedbackId: value, projectSlug: ''}; + }, + serialize: value => + value + ? value.projectSlug + ? `${value.projectSlug}:${value.feedbackId}` + : value.feedbackId + : '', +}); + +export const useFeedbackSlug = () => useQueryState('feedbackSlug', parseFeedbackSlug); diff --git a/static/app/components/feedback/useRedirectToFeedbackFromEvent.tsx b/static/app/components/feedback/useRedirectToFeedbackFromEvent.tsx index 4c71e02c2ffe..24ac7328cb37 100644 --- a/static/app/components/feedback/useRedirectToFeedbackFromEvent.tsx +++ b/static/app/components/feedback/useRedirectToFeedbackFromEvent.tsx @@ -1,10 +1,9 @@ import {useEffect} from 'react'; +import {parseAsString, useQueryState} from 'nuqs'; import type {Event} from 'sentry/types/event'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {useApiQuery} from 'sentry/utils/queryClient'; -import {decodeScalar} from 'sentry/utils/queryString'; -import {useLocationQuery} from 'sentry/utils/url/useLocationQuery'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; import {makeFeedbackPathname} from 'sentry/views/feedback/pathnames'; @@ -13,20 +12,16 @@ export function useRedirectToFeedbackFromEvent() { const organization = useOrganization(); const navigate = useNavigate(); - const {eventId, projectSlug} = useLocationQuery({ - fields: { - eventId: decodeScalar, - projectSlug: decodeScalar, - }, - }); + const [eventId] = useQueryState('eventId', parseAsString); + const [projectSlug] = useQueryState('projectSlug', parseAsString); const {data: event} = useApiQuery( [ getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/events/$eventId/', { path: { organizationIdOrSlug: organization.slug, - projectIdOrSlug: projectSlug, - eventId, + projectIdOrSlug: projectSlug ?? '', + eventId: eventId ?? '', }, }), ], diff --git a/static/app/views/feedback/feedbackListPage.tsx b/static/app/views/feedback/feedbackListPage.tsx index 7ec776ba920d..666033093978 100644 --- a/static/app/views/feedback/feedbackListPage.tsx +++ b/static/app/views/feedback/feedbackListPage.tsx @@ -13,10 +13,9 @@ import {FeedbackSearch} from 'sentry/components/feedback/feedbackSearch'; import {FeedbackSetupPanel} from 'sentry/components/feedback/feedbackSetupPanel'; import {FeedbackList} from 'sentry/components/feedback/list/feedbackList'; import {FeedbackSummaryCategories} from 'sentry/components/feedback/summaryCategories/feedbackSummaryCategories'; -import {useCurrentFeedbackId} from 'sentry/components/feedback/useCurrentFeedbackId'; -import {useCurrentFeedbackProject} from 'sentry/components/feedback/useCurrentFeedbackProject'; import {useHaveSelectedProjectsSetupFeedback} from 'sentry/components/feedback/useFeedbackOnboarding'; import {FeedbackQueryKeys} from 'sentry/components/feedback/useFeedbackQueryKeys'; +import {useFeedbackSlug} from 'sentry/components/feedback/useFeedbackSlug'; import {useRedirectToFeedbackFromEvent} from 'sentry/components/feedback/useRedirectToFeedbackFromEvent'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import {FullViewport} from 'sentry/components/layouts/fullViewport'; @@ -37,8 +36,9 @@ export default function FeedbackListPage() { const {hasSetupOneFeedback} = useHaveSelectedProjectsSetupFeedback(); const pageFilters = usePageFilters(); - const feedbackId = useCurrentFeedbackId(); - const feedbackProjectSlug = useCurrentFeedbackProject(); + const [feedbackSlug] = useFeedbackSlug(); + const feedbackId = feedbackSlug?.feedbackId ?? ''; + const feedbackProjectSlug = feedbackSlug?.projectSlug ?? ''; const hasSlug = Boolean(feedbackId); const {query: locationQuery} = useLocation(); @@ -175,6 +175,7 @@ export default function FeedbackListPage() { query: { alert_option: 'issues', referrer: 'feedback-list-page', + detectorType: 'metric_issue', ...(feedbackProjectSlug ? {project: feedbackProjectSlug} : {}), }, }} diff --git a/static/app/views/projectEventRedirect.spec.tsx b/static/app/views/projectEventRedirect.spec.tsx index b1dd59f26d74..628438a813e5 100644 --- a/static/app/views/projectEventRedirect.spec.tsx +++ b/static/app/views/projectEventRedirect.spec.tsx @@ -3,6 +3,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; +import {IssueCategory} from 'sentry/types/group'; import {ProjectEventRedirect} from 'sentry/views/projectEventRedirect'; describe('ProjectEventRedirect', () => { @@ -141,6 +142,82 @@ describe('ProjectEventRedirect', () => { expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); }); + it('redirects feedback issues to the feedback page with feedbackSlug', async () => { + const event = EventFixture({ + eventID: 'abc123', + groupID: '456', + projectSlug: 'my-project', + issueCategory: IssueCategory.FEEDBACK, + contexts: { + feedback: {message: 'some feedback'}, + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/my-project:event-id/`, + body: event, + }); + + const {router} = render(, { + organization, + initialRouterConfig: { + location: { + pathname: `/organizations/${organization.slug}/projects/my-project/events/event-id/`, + }, + route: '/organizations/:orgId/projects/:projectId/events/:eventId/', + }, + }); + + await waitFor(() => { + expect(router.location).toEqual( + expect.objectContaining({ + pathname: `/organizations/${organization.slug}/issues/feedback/`, + query: expect.objectContaining({ + feedbackSlug: 'my-project:456', + }), + }) + ); + }); + }); + + it('redirects feedback issues without projectSlug using only groupID as feedbackSlug', async () => { + const event = EventFixture({ + eventID: 'abc123', + groupID: '456', + projectSlug: undefined, + issueCategory: IssueCategory.FEEDBACK, + contexts: { + feedback: {message: 'some feedback'}, + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/my-project:event-id/`, + body: event, + }); + + const {router} = render(, { + organization, + initialRouterConfig: { + location: { + pathname: `/organizations/${organization.slug}/projects/my-project/events/event-id/`, + }, + route: '/organizations/:orgId/projects/:projectId/events/:eventId/', + }, + }); + + await waitFor(() => { + expect(router.location).toEqual( + expect.objectContaining({ + pathname: `/organizations/${organization.slug}/issues/feedback/`, + query: expect.objectContaining({ + feedbackSlug: '456', + }), + }) + ); + }); + }); + it('preserves only relevant query parameters during redirect', async () => { const event = EventFixture({ eventID: 'abc123', diff --git a/static/app/views/projectEventRedirect.tsx b/static/app/views/projectEventRedirect.tsx index f6126ebd0214..34d2ef4f54ea 100644 --- a/static/app/views/projectEventRedirect.tsx +++ b/static/app/views/projectEventRedirect.tsx @@ -16,6 +16,7 @@ import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useParams} from 'sentry/utils/useParams'; +import {makeFeedbackPathname} from 'sentry/views/feedback/pathnames'; import {getTraceDetailsUrl} from 'sentry/views/performance/traceDetails/utils'; /** @@ -64,6 +65,24 @@ export function ProjectEventRedirect() { // If the event has a group ID, navigate to the issue event page if (event.groupID && event.eventID) { + if ('feedback' in event.contexts) { + navigate( + { + pathname: makeFeedbackPathname({ + path: '/', + organization, + }), + query: { + feedbackSlug: event.projectSlug + ? `${event.projectSlug}:${event.groupID}` + : event.groupID, + }, + }, + {replace: true} + ); + return; + } + navigate( { pathname: `/organizations/${organization.slug}/issues/${event.groupID}/events/${event.eventID}/`, From 3f75bac65597ad74721df4b8aa2d50560adc4efd Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 31 Mar 2026 14:55:22 -0700 Subject: [PATCH 51/51] ref(seer): Async load ScmRepoTreeModal where its used (#111959) --- .../app/views/settings/seer/overview/scmOverviewSection.tsx | 6 ++++-- .../seerAutomation/components/repoTable/seerRepoTable.tsx | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/static/app/views/settings/seer/overview/scmOverviewSection.tsx b/static/app/views/settings/seer/overview/scmOverviewSection.tsx index 10047d9e64be..b898ebe03ca4 100644 --- a/static/app/views/settings/seer/overview/scmOverviewSection.tsx +++ b/static/app/views/settings/seer/overview/scmOverviewSection.tsx @@ -25,7 +25,6 @@ import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {RepoProviderIcon} from 'sentry/components/repositories/repoProviderIcon'; import {getProviderConfigUrl} from 'sentry/components/repositories/scmIntegrationTree/providerConfigLink'; import {useScmIntegrationTreeData} from 'sentry/components/repositories/scmIntegrationTree/useScmIntegrationTreeData'; -import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal'; import {IconAdd, IconOpen, IconSettings} from 'sentry/icons'; import {t} from 'sentry/locale'; import type { @@ -178,7 +177,10 @@ function NoIntegrations({refetchIntegrations}: {refetchIntegrations: () => void} priority="primary" size="sm" icon={} - onClick={() => { + onClick={async () => { + const {ScmRepoTreeModal} = + await import('sentry/components/repositories/scmRepoTreeModal'); + openModal( deps => , { diff --git a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx index 87ea64f66ada..f5faa68c1237 100644 --- a/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx +++ b/static/gsApp/views/seerAutomation/components/repoTable/seerRepoTable.tsx @@ -19,7 +19,6 @@ import { import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Panel} from 'sentry/components/panels/panel'; -import {ScmRepoTreeModal} from 'sentry/components/repositories/scmRepoTreeModal'; import {useBulkUpdateRepositorySettings} from 'sentry/components/repositories/useBulkUpdateRepositorySettings'; import {getRepositoryWithSettingsQueryKey} from 'sentry/components/repositories/useRepositoryWithSettings'; import {IconAdd} from 'sentry/icons'; @@ -161,7 +160,10 @@ export function SeerRepoTable() {