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 (
-
-
-
-
-
-
- );
- }
-
- 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 (
-
-
-
- Create and add mock billing metric usage for testing purposes.
-
-
- }
- 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
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.
---------
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?')}
+
+ }
+ onClick={() => handleFeedback(true)}
+ aria-label={t('Yes, this grouping is accurate')}
+ />
+ }
+ onClick={() => handleFeedback(false)}
+ aria-label={t('No, this grouping is not accurate')}
+ />
+
+
+ )}
+
+ );
+}
+
+const FeedbackContainer = styled('div')`
+ display: flex;
+ align-items: center;
+ gap: ${p => p.theme.space.md};
+ padding: ${p => p.theme.space.md} ${p => p.theme.space['2xl']};
+ border-bottom: 1px solid ${p => p.theme.tokens.border.primary};
+ background: ${p => p.theme.tokens.background.transparent.promotion.muted};
+`;
From d947df8b91699a3063b365f4aa562d0e55bf39df Mon Sep 17 00:00:00 2001
From: Jonas
Date: Tue, 31 Mar 2026 09:49:31 -0700
Subject: [PATCH 08/51] ref(nav) realign indicators (#111816)
This broke with our change to the new indicators, so I'm updating each
element's location and the button overflow accordingly
---
.../views/navigation/primary/components.tsx | 41 +++++++++++++++----
1 file changed, 33 insertions(+), 8 deletions(-)
diff --git a/static/app/views/navigation/primary/components.tsx b/static/app/views/navigation/primary/components.tsx
index 98ea4b689ac3..4164b5acbf2c 100644
--- a/static/app/views/navigation/primary/components.tsx
+++ b/static/app/views/navigation/primary/components.tsx
@@ -10,7 +10,13 @@ import type {DistributedOmit} from 'type-fest';
import {FeatureBadge, type FeatureBadgeProps} from '@sentry/scraps/badge';
import type {ButtonBarProps, ButtonProps} from '@sentry/scraps/button';
import {Button, ButtonBar} from '@sentry/scraps/button';
-import {Container, Flex, Stack, type FlexProps} from '@sentry/scraps/layout';
+import {
+ Container,
+ Flex,
+ Stack,
+ type FlexProps,
+ type ContainerProps,
+} from '@sentry/scraps/layout';
import {Link, type LinkProps} from '@sentry/scraps/link';
import {SizeProvider, useSizeContext} from '@sentry/scraps/sizeContext';
import {StatusIndicator} from '@sentry/scraps/statusIndicator';
@@ -310,13 +316,20 @@ function PrimaryNavigationUnreadIndicator({
}: PrimaryNavigationUnreadIndicatorProps) {
const theme = useTheme();
const {layout} = usePrimaryNavigation();
+ const hasPageFrame = useHasPageFrameFeature();
+ const indicatorPosition: Pick<
+ ContainerProps<'div'>,
+ 'top' | 'right' | 'left'
+ > = hasPageFrame
+ ? layout === 'mobile'
+ ? {top: '0', right: '0'}
+ : {top: '0', right: '0'}
+ : layout === 'mobile'
+ ? {left: '11px', top: `-${theme.space['2xs']}`}
+ : {top: '0', right: '0'};
+
return (
-
+
{p => (
) {
justify={layout === 'mobile' && !hasPageFrame ? 'start' : 'center'}
>
{p => (
- ) {
);
}
+/**
+ * @TODO(JonasBadalic) Scraps buttons have been setting overflow hidden onto the inner surface wrapper ever since
+ * we inherited that component, and we need to override that to ensure that the indicator is visible as it will
+ * otherwise clip the indicator and StatusIndicator animation. We need to unwind this and remove the overflow
+ * from buttons from ever being set.
+ */
+const ButtonWithOverflowVisible = styled(Button)`
+ > span:last-child {
+ overflow: initial;
+ }
+`;
+
function PrimaryNavigationButtonBar(props: ButtonBarProps) {
return ;
}
From e24356d3290a4c4811b159b5fae28e96754cba5f Mon Sep 17 00:00:00 2001
From: getsentry-bot
Date: Tue, 31 Mar 2026 17:07:40 +0000
Subject: [PATCH 09/51] Revert "feat(seer): Update default triggers for Code
Review (#111829)"
This reverts commit 128112a7c3803c2808938db14cd13eeccbcb39a1.
Co-authored-by: ryan953 <187460+ryan953@users.noreply.github.com>
---
src/sentry/constants.py | 1 +
static/app/types/integrations.tsx | 5 ++++-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/sentry/constants.py b/src/sentry/constants.py
index fda38cfaf076..f2aff76cadff 100644
--- a/src/sentry/constants.py
+++ b/src/sentry/constants.py
@@ -727,6 +727,7 @@ class InsightModules(Enum):
# Seer Org level default for code review triggers
DEFAULT_CODE_REVIEW_TRIGGERS: list[str] = [
"on_ready_for_review",
+ "on_new_commit",
]
SEER_DEFAULT_CODING_AGENT_DEFAULT = "seer"
SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT = "code_changes"
diff --git a/static/app/types/integrations.tsx b/static/app/types/integrations.tsx
index 643cfe07f052..6cf1358bd2d2 100644
--- a/static/app/types/integrations.tsx
+++ b/static/app/types/integrations.tsx
@@ -99,7 +99,10 @@ export interface RepositoryWithSettings extends Repository {
};
}
-export const DEFAULT_CODE_REVIEW_TRIGGERS: CodeReviewTrigger[] = ['on_ready_for_review'];
+export const DEFAULT_CODE_REVIEW_TRIGGERS: CodeReviewTrigger[] = [
+ 'on_ready_for_review',
+ 'on_new_commit',
+];
/**
* Integration Repositories from OrganizationIntegrationReposEndpoint
From be610d1045bd1543b5fe3f1a5022a4db99bd4e2a Mon Sep 17 00:00:00 2001
From: Charlie Luo
Date: Tue, 31 Mar 2026 13:19:23 -0400
Subject: [PATCH 10/51] ref(grouping): remove `useReranking` from similar
issues UI (#111867)
Related: https://github.com/getsentry/sentry/pull/111866,
https://github.com/getsentry/seer/pull/5554
Reranking has been on for ~2 years, we don't need to check it in the
frontend.
Co-authored-by: Claude
---
.../issueDetails/groupSimilarIssues/similarIssues.spec.tsx | 4 ++--
.../groupSimilarIssues/similarStackTrace/index.tsx | 7 -------
2 files changed, 2 insertions(+), 9 deletions(-)
diff --git a/static/app/views/issueDetails/groupSimilarIssues/similarIssues.spec.tsx b/static/app/views/issueDetails/groupSimilarIssues/similarIssues.spec.tsx
index 7edc2f5687c9..23874bd43af7 100644
--- a/static/app/views/issueDetails/groupSimilarIssues/similarIssues.spec.tsx
+++ b/static/app/views/issueDetails/groupSimilarIssues/similarIssues.spec.tsx
@@ -216,7 +216,7 @@ describe('Issues Similar Embeddings View', () => {
beforeEach(() => {
mock = MockApiClient.addMockResponse({
- url: `/organizations/org-slug/issues/${group.id}/similar-issues-embeddings/?k=10&threshold=0.01&useReranking=true`,
+ url: `/organizations/org-slug/issues/${group.id}/similar-issues-embeddings/?k=10&threshold=0.01`,
body: mockData.similarEmbeddings,
});
MockApiClient.addMockResponse({
@@ -330,7 +330,7 @@ describe('Issues Similar Embeddings View', () => {
it('shows empty message', async () => {
mock = MockApiClient.addMockResponse({
- url: `/organizations/org-slug/issues/${group.id}/similar-issues-embeddings/?k=10&threshold=0.01&useReranking=true`,
+ url: `/organizations/org-slug/issues/${group.id}/similar-issues-embeddings/?k=10&threshold=0.01`,
body: [],
});
diff --git a/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/index.tsx b/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/index.tsx
index f48a2bc068a6..8de2cdb88da9 100644
--- a/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/index.tsx
+++ b/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/index.tsx
@@ -58,11 +58,6 @@ export function SimilarStackTrace({project}: Props) {
const hasSimilarityEmbeddingsFeature =
projectData?.features.includes('similarity-embeddings') ||
location.query.similarityEmbeddings === '1';
- // Use reranking by default (assuming the `seer.similarity.similar_issues.use_reranking`
- // backend option is using its default value of `True`). This is just so we can turn it off
- // on demand to see if/how that changes the results.
- const useReranking = String(location.query.useReranking !== '0');
-
const fetchData = useCallback(() => {
if (isPending) {
return;
@@ -77,7 +72,6 @@ export function SimilarStackTrace({project}: Props) {
{
k: 10,
threshold: 0.01,
- useReranking,
}
)}`,
dataKey: 'similar',
@@ -101,7 +95,6 @@ export function SimilarStackTrace({project}: Props) {
organization.slug,
hasSimilarityFeature,
hasSimilarityEmbeddingsFeature,
- useReranking,
isPending,
]);
From 0cd47ace6611330a92d3748b0b5eeb90e2d958a4 Mon Sep 17 00:00:00 2001
From: Charlie Luo
Date: Tue, 31 Mar 2026 13:19:39 -0400
Subject: [PATCH 11/51] ref(grouping): always use reranking for similarity
(#111866)
Removing from seer here: https://github.com/getsentry/seer/pull/5554
We've always been using the reranking path for ~2 years. We can get rid
of the non-reranking path.
Co-authored-by: Claude
---
src/sentry/grouping/ingest/seer.py | 3 ---
.../group_similar_issues_embeddings.py | 7 +------
src/sentry/options/defaults.py | 19 -------------------
src/sentry/seer/similarity/similar_issues.py | 3 +--
src/sentry/seer/similarity/types.py | 1 -
.../test_get_seer_similar_issues.py | 3 ---
.../grouping/seer_similarity/test_seer.py | 2 --
.../test_group_similar_issues_embeddings.py | 16 ----------------
8 files changed, 2 insertions(+), 52 deletions(-)
diff --git a/src/sentry/grouping/ingest/seer.py b/src/sentry/grouping/ingest/seer.py
index 3fdcc2420ee2..ec9a4147c06a 100644
--- a/src/sentry/grouping/ingest/seer.py
+++ b/src/sentry/grouping/ingest/seer.py
@@ -316,7 +316,6 @@ def _build_seer_request(
"exception_type": filter_null_from_string(exception_type) if exception_type else None,
"k": options.get("seer.similarity.ingest.num_matches_to_request"),
"referrer": "ingest",
- "use_reranking": options.get("seer.similarity.ingest.use_reranking"),
"model": model_version,
"training_mode": training_mode,
"platform": event.platform or "unknown",
@@ -413,8 +412,6 @@ def get_seer_similar_issues(
# By asking Seer to find zero matches, we can trick it into thinking there aren't
# any, thereby forcing it to create the record
"k": 0,
- # Turn off re-ranking to speed up the process of finding nothing
- "use_reranking": False,
}
# TODO: Temporary log to prove things are working as they should. This should come in a pair
diff --git a/src/sentry/issues/endpoints/group_similar_issues_embeddings.py b/src/sentry/issues/endpoints/group_similar_issues_embeddings.py
index f3d4921e7e43..ce678207fc0a 100644
--- a/src/sentry/issues/endpoints/group_similar_issues_embeddings.py
+++ b/src/sentry/issues/endpoints/group_similar_issues_embeddings.py
@@ -6,7 +6,7 @@
from rest_framework.request import Request
from rest_framework.response import Response
-from sentry import analytics, options
+from sentry import analytics
from sentry.api.analytics import GroupSimilarIssuesEmbeddingsCountEvent
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
@@ -123,7 +123,6 @@ def get(self, request: Request, group: Group) -> Response:
"exception_type": get_path(latest_event.data, "exception", "values", -1, "type"),
"read_only": True,
"referrer": "similar_issues",
- "use_reranking": options.get("seer.similarity.similar_issues.use_reranking"),
"model": model_version,
"training_mode": False,
"platform": latest_event.platform or "unknown",
@@ -134,10 +133,6 @@ def get(self, request: Request, group: Group) -> Response:
if request.GET.get("threshold"):
similar_issues_params["threshold"] = float(request.GET["threshold"])
- # Override `use_reranking` value if necessary
- if request.GET.get("useReranking"):
- similar_issues_params["use_reranking"] = request.GET["useReranking"] == "true"
-
logger.info("Similar issues embeddings parameters", extra=similar_issues_params)
viewer_context = SeerViewerContext(
diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py
index a3f6df779157..f9c468f901d7 100644
--- a/src/sentry/options/defaults.py
+++ b/src/sentry/options/defaults.py
@@ -1247,20 +1247,6 @@
flags=FLAG_AUTOMATOR_MODIFIABLE,
)
-register(
- "seer.similarity.ingest.use_reranking",
- type=Bool,
- default=True,
- flags=FLAG_AUTOMATOR_MODIFIABLE,
-)
-
-register(
- "seer.similarity.similar_issues.use_reranking",
- type=Bool,
- default=True,
- flags=FLAG_AUTOMATOR_MODIFIABLE,
-)
-
register(
"seer.similarity.ingest.num_matches_to_request",
type=Int,
@@ -3488,11 +3474,6 @@
default=False,
flags=FLAG_AUTOMATOR_MODIFIABLE,
)
-register(
- "similarity.backfill_use_reranking",
- default=False,
- flags=FLAG_AUTOMATOR_MODIFIABLE,
-)
register(
"delayed_processing.batch_size",
default=10000,
diff --git a/src/sentry/seer/similarity/similar_issues.py b/src/sentry/seer/similarity/similar_issues.py
index 6e60d115f965..51dcae30f709 100644
--- a/src/sentry/seer/similarity/similar_issues.py
+++ b/src/sentry/seer/similarity/similar_issues.py
@@ -76,8 +76,7 @@ def get_similarity_data_from_seer(
logger_extra = {
k: v
for k, v in similar_issues_request.items()
- if k
- in {"event_id", "project_id", "hash", "referrer", "use_reranking", "model", "training_mode"}
+ if k in {"event_id", "project_id", "hash", "referrer", "model", "training_mode"}
}
logger.info(
"get_seer_similar_issues.request",
diff --git a/src/sentry/seer/similarity/types.py b/src/sentry/seer/similarity/types.py
index 89030321d3b5..f6fd15ddfc61 100644
--- a/src/sentry/seer/similarity/types.py
+++ b/src/sentry/seer/similarity/types.py
@@ -38,7 +38,6 @@ class SimilarIssuesEmbeddingsRequest(TypedDict):
read_only: NotRequired[bool]
event_id: NotRequired[str]
referrer: NotRequired[str]
- use_reranking: NotRequired[bool]
model: NotRequired[GroupingVersion] # Model version, defaults to V1 for backward compatibility
training_mode: NotRequired[bool] # whether to just insert embedding without querying
platform: NotRequired[str]
diff --git a/tests/sentry/grouping/seer_similarity/test_get_seer_similar_issues.py b/tests/sentry/grouping/seer_similarity/test_get_seer_similar_issues.py
index 1494a7f383e6..205b64541a4b 100644
--- a/tests/sentry/grouping/seer_similarity/test_get_seer_similar_issues.py
+++ b/tests/sentry/grouping/seer_similarity/test_get_seer_similar_issues.py
@@ -107,7 +107,6 @@ def test_sends_expected_data_to_seer(self, mock_get_similarity_data: MagicMock)
"exception_type": "FailedToFetchError",
"k": options.get("seer.similarity.ingest.num_matches_to_request"),
"referrer": "ingest",
- "use_reranking": True,
"model": GroupingVersion.V1,
"training_mode": False,
"platform": "python",
@@ -177,7 +176,6 @@ def test_sends_second_seer_request_when_seer_matches_are_unusable(
**base_request_params,
"k": options.get("seer.similarity.ingest.num_matches_to_request"),
"referrer": "ingest",
- "use_reranking": True,
},
{
"platform": "python",
@@ -194,7 +192,6 @@ def test_sends_second_seer_request_when_seer_matches_are_unusable(
**base_request_params,
"k": 0,
"referrer": "ingest_follow_up",
- "use_reranking": False,
},
{
"platform": "python",
diff --git a/tests/sentry/grouping/seer_similarity/test_seer.py b/tests/sentry/grouping/seer_similarity/test_seer.py
index eb533ec84388..e07dfb270db1 100644
--- a/tests/sentry/grouping/seer_similarity/test_seer.py
+++ b/tests/sentry/grouping/seer_similarity/test_seer.py
@@ -59,7 +59,6 @@ def test_simple(self, mock_get_similarity_data: MagicMock) -> None:
"exception_type": "FailedToFetchError",
"k": 1,
"referrer": "ingest",
- "use_reranking": True,
"model": GroupingVersion.V1,
"training_mode": False,
"platform": "python",
@@ -198,7 +197,6 @@ def test_bypassed_platform_calls_seer_regardless_of_length(
"exception_type": "FailedToFetchError",
"k": 1,
"referrer": "ingest",
- "use_reranking": True,
"model": GroupingVersion.V1,
"training_mode": False,
"platform": "python",
diff --git a/tests/sentry/issues/endpoints/test_group_similar_issues_embeddings.py b/tests/sentry/issues/endpoints/test_group_similar_issues_embeddings.py
index f9f33eb75917..f22e5e90fa3e 100644
--- a/tests/sentry/issues/endpoints/test_group_similar_issues_embeddings.py
+++ b/tests/sentry/issues/endpoints/test_group_similar_issues_embeddings.py
@@ -228,7 +228,6 @@ def test_simple(
"exception_type": "ZeroDivisionError",
"read_only": True,
"referrer": "similar_issues",
- "use_reranking": True,
"model": "v1",
"training_mode": False,
"platform": "python",
@@ -402,7 +401,6 @@ def test_incomplete_return_data(
"exception_type": "ZeroDivisionError",
"read_only": True,
"referrer": "similar_issues",
- "use_reranking": True,
"model": "v1",
"training_mode": False,
"platform": "python",
@@ -687,7 +685,6 @@ def test_no_optional_params(self, mock_seer_request: mock.MagicMock) -> None:
"exception_type": "ZeroDivisionError",
"read_only": True,
"referrer": "similar_issues",
- "use_reranking": True,
"model": "v1",
"training_mode": False,
"platform": "python",
@@ -718,7 +715,6 @@ def test_no_optional_params(self, mock_seer_request: mock.MagicMock) -> None:
"exception_type": "ZeroDivisionError",
"read_only": True,
"referrer": "similar_issues",
- "use_reranking": True,
"model": "v1",
"training_mode": False,
"platform": "python",
@@ -752,7 +748,6 @@ def test_no_optional_params(self, mock_seer_request: mock.MagicMock) -> None:
"exception_type": "ZeroDivisionError",
"read_only": True,
"referrer": "similar_issues",
- "use_reranking": True,
"model": "v1",
"training_mode": False,
"platform": "python",
@@ -761,17 +756,6 @@ def test_no_optional_params(self, mock_seer_request: mock.MagicMock) -> None:
headers={"content-type": "application/json;charset=utf-8"},
)
- @mock.patch("sentry.seer.similarity.similar_issues.seer_grouping_connection_pool.urlopen")
- def test_obeys_useReranking_query_param(self, mock_seer_request: mock.MagicMock) -> None:
- for incoming_value, outgoing_value in [("true", True), ("false", False)]:
- self.client.get(self.path, data={"useReranking": incoming_value})
-
- assert mock_seer_request.call_count == 1
- request_params = orjson.loads(mock_seer_request.call_args.kwargs["body"])
- assert request_params["use_reranking"] == outgoing_value
-
- mock_seer_request.reset_mock()
-
def test_too_many_frames(self) -> None:
error_type = "FailedToFetchError"
error_value = "Charlie didn't bring the ball back"
From 615e4b5f6c81f2a7b4901b0fea981c16e34dbbc0 Mon Sep 17 00:00:00 2001
From: Evan Purkhiser
Date: Tue, 31 Mar 2026 13:20:23 -0400
Subject: [PATCH 12/51] feat(pipeline): Detect API-driven pipelines in existing
callback URL (#111455)
Integration providers register callback URLs with external services
(e.g. GitHub OAuth redirect). These URLs point to PipelineAdvancerView,
which traditionally drives the pipeline server-side by calling
pipeline.current_step() on each callback.
For the new API-driven pipeline mode, we cannot change the callback URLs
already registered with production integrations. Instead, this view now
detects when a pipeline was initiated in API mode (api_mode flag in
session state) and renders a lightweight trampoline page. The trampoline
relays the callback URL query parameters (code, state, installation_id,
etc.) back to the opener window via postMessage and closes itself. The
frontend pipeline system then continues driving the pipeline via API
endpoints.
Fixes [VDY-36](https://linear.app/getsentry/issue/VDY-36)
---
src/sentry/web/frontend/pipeline_advancer.py | 74 +++++++++++++++++++-
1 file changed, 72 insertions(+), 2 deletions(-)
diff --git a/src/sentry/web/frontend/pipeline_advancer.py b/src/sentry/web/frontend/pipeline_advancer.py
index 08113e4c3f45..ffbdef10bebe 100644
--- a/src/sentry/web/frontend/pipeline_advancer.py
+++ b/src/sentry/web/frontend/pipeline_advancer.py
@@ -1,14 +1,18 @@
+from urllib.parse import parse_qs
+
from django.contrib import messages
-from django.http import HttpRequest, HttpResponseRedirect
+from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.http.response import HttpResponseBase
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
+from sentry import features
from sentry.identity.pipeline import IdentityPipeline
from sentry.integrations.pipeline import IntegrationPipeline
from sentry.integrations.types import IntegrationProviderSlug
from sentry.organizations.absolute_url import generate_organization_url
from sentry.utils.http import absolute_uri, create_redirect_url
+from sentry.utils.json import dumps_htmlsafe
from sentry.web.frontend.base import BaseView, all_silo_view
# The request doesn't contain the pipeline type (pipeline information is stored
@@ -16,10 +20,70 @@
# and use whichever one works.
PIPELINE_CLASSES = (IntegrationPipeline, IdentityPipeline)
+TRAMPOLINE_HTML = """\
+
+
+
+
+
+
+
Unable to continue. Please restart the flow.
+
+
+"""
+
+
+def _render_trampoline(request: HttpRequest, pipeline: object) -> HttpResponse:
+ """Render a minimal page that posts callback params back to the opener."""
+ params: dict[str, str] = {"source": "sentry-pipeline"}
+ for key, values in parse_qs(request.META.get("QUERY_STRING", "")).items():
+ if values:
+ params[key] = values[0]
+
+ data_json = str(dumps_htmlsafe(params))
+
+ # In multi-region the opener may be on a different origin (e.g.
+ # org-slug.sentry.io) than the trampoline (sentry.io/extensions/...),
+ # so we need the org-specific URL. In single-region document.origin works.
+ if features.has("system:multi-region"):
+ org = getattr(pipeline, "organization", None)
+ origin = str(dumps_htmlsafe(generate_organization_url(org.slug if org else "")))
+ else:
+ origin = "document.origin"
+
+ return HttpResponse(
+ TRAMPOLINE_HTML.format(data_json=data_json, origin=origin),
+ content_type="text/html",
+ )
+
@all_silo_view
class PipelineAdvancerView(BaseView):
- """Gets the current pipeline from the request and executes the current step."""
+ """
+ Gets the current pipeline from the request and executes the current step.
+
+ External services (e.g. GitHub OAuth) redirect back to this view after the
+ user completes an action. For legacy template-driven pipelines this view
+ processes the callback server-side via pipeline.current_step().
+
+ For API-driven pipelines (is_api_mode) this view does NOT process the
+ callback. Instead it renders a lightweight trampoline page that relays the
+ callback URL query params (code, state, installation_id, etc.) back to the
+ opener window via postMessage and closes itself. The frontend is
+ responsible for POSTing those params to the pipeline API endpoint to
+ advance the pipeline.
+ """
auth_required = False
@@ -50,6 +114,12 @@ def handle(self, request: HttpRequest, provider_id: str) -> HttpResponseBase:
messages.add_message(request, messages.ERROR, _("Invalid request."))
return self.redirect("/")
+ # If the pipeline was initiated via the API, render a trampoline page
+ # that relays the callback params back to the opener window via
+ # postMessage instead of processing the callback server-side.
+ if pipeline.is_api_mode:
+ return _render_trampoline(request, pipeline)
+
subdomain = pipeline.fetch_state("subdomain")
if subdomain is not None and request.subdomain != subdomain:
url_prefix = generate_organization_url(subdomain)
From 823f751e64a07c4ef442d72256baf33c8e034dfd Mon Sep 17 00:00:00 2001
From: Colton Allen
Date: Tue, 31 Mar 2026 12:23:48 -0500
Subject: [PATCH 13/51] feat(seer): Add RPC interface for retrieving the
installation_id (#111893)
Adds an RPC interface for fetching a GitHub integration's
installation_id.
- Adds a new get_repo_installation_id function to the Seer RPC interface
that resolves a repository to its GitHub App installation ID
- GitHub stores the installation ID as integration.external_id, while
GitHub Enterprise stores it in integration.metadata["installation_id"]
- Registered in seer_method_registry so Seer can call it via RPC
- Only supports GitHub and GitHub Enterprise providers; returns an error
for unsupported providers
---
src/sentry/seer/endpoints/seer_rpc.py | 56 +++++++
tests/sentry/seer/endpoints/test_seer_rpc.py | 162 +++++++++++++++++++
2 files changed, 218 insertions(+)
diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py
index 2795c05760c5..f6c034d673e2 100644
--- a/src/sentry/seer/endpoints/seer_rpc.py
+++ b/src/sentry/seer/endpoints/seer_rpc.py
@@ -663,6 +663,61 @@ def validate_repo(
return {"valid": True, "integration_id": repo.integration_id}
+def get_repo_installation_id(
+ *,
+ organization_id: int,
+ provider: str,
+ external_id: str,
+ owner: str,
+ name: str,
+) -> dict[str, Any]:
+ """
+ Look up a repository and return the external_id of its associated integration (the installation ID).
+
+ Args:
+ organization_id: The Sentry organization ID
+ provider: The SCM provider (e.g., "github", "github_enterprise")
+ external_id: The repository's external ID in the provider's system
+ owner: The repository owner (e.g., "getsentry")
+ name: The repository name (e.g., "sentry")
+
+ Returns:
+ {"installation_id": } if found
+ {"error": } if not found or unsupported
+ """
+ repo = filter_repo_by_provider(organization_id, provider, external_id, owner, name).first()
+
+ if not repo:
+ return {"error": "repository_not_found"}
+
+ if repo.provider not in SEER_SUPPORTED_SCM_PROVIDERS:
+ return {"error": "unsupported_provider"}
+
+ if repo.integration_id is None:
+ return {"error": "no_integration"}
+
+ integration = integration_service.get_integration(integration_id=repo.integration_id)
+ if integration is None:
+ return {"error": "integration_not_found"}
+
+ # GitHub stores the installation ID as the integration's external_id,
+ # while GitHub Enterprise stores it in metadata["installation_id"].
+ if integration.provider == IntegrationProviderSlug.GITHUB_ENTERPRISE.value:
+ installation_id = integration.metadata.get("installation_id")
+ elif integration.provider == IntegrationProviderSlug.GITHUB.value:
+ installation_id = integration.external_id
+ else:
+ return {"error": "unsupported_provider"}
+
+ if not installation_id:
+ return {"error": "installation_id_not_found"}
+
+ return {
+ "installation_id": installation_id,
+ "permissions": integration.metadata.get("permissions"),
+ }
+
+
def check_repository_integrations_status(*, repository_integrations: list[dict[str, Any]]) -> dict:
"""
Check whether repository integrations exist and are active.
@@ -760,6 +815,7 @@ def check_repository_integrations_status(*, repository_integrations: list[dict[s
"get_organization_project_ids": get_organization_project_ids,
"check_repository_integrations_status": check_repository_integrations_status,
"validate_repo": validate_repo,
+ "get_repo_installation_id": get_repo_installation_id,
#
# Autofix
"get_organization_slug": get_organization_slug,
diff --git a/tests/sentry/seer/endpoints/test_seer_rpc.py b/tests/sentry/seer/endpoints/test_seer_rpc.py
index 0610b39ed643..f7b133775e5a 100644
--- a/tests/sentry/seer/endpoints/test_seer_rpc.py
+++ b/tests/sentry/seer/endpoints/test_seer_rpc.py
@@ -20,6 +20,7 @@
generate_request_signature,
get_attributes_for_span,
get_github_enterprise_integration_config,
+ get_repo_installation_id,
has_repo_code_mappings,
validate_repo,
)
@@ -1317,3 +1318,164 @@ def test_validate_repo_github_enterprise(self) -> None:
)
assert result == {"valid": True, "integration_id": integration.id}
+
+ def test_get_repo_installation_id_github(self) -> None:
+ """Test returns external_id as installation_id for GitHub repos"""
+ integration = self.create_integration(
+ organization=self.organization, provider="github", external_id="12345"
+ )
+
+ Repository.objects.create(
+ name="getsentry/sentry",
+ organization_id=self.organization.id,
+ provider="integrations:github",
+ external_id="123456",
+ status=ObjectStatus.ACTIVE,
+ integration_id=integration.id,
+ )
+
+ result = get_repo_installation_id(
+ organization_id=self.organization.id,
+ provider="github",
+ external_id="123456",
+ owner="getsentry",
+ name="sentry",
+ )
+
+ assert result == {"installation_id": "12345", "permissions": None}
+
+ def test_get_repo_installation_id_github_with_permissions(self) -> None:
+ """Test returns permissions from integration metadata"""
+ permissions = {"contents": "read", "issues": "write", "pull_requests": "read"}
+ integration = self.create_integration(
+ organization=self.organization,
+ provider="github",
+ external_id="12345",
+ metadata={"permissions": permissions},
+ )
+
+ Repository.objects.create(
+ name="getsentry/sentry",
+ organization_id=self.organization.id,
+ provider="integrations:github",
+ external_id="123456",
+ status=ObjectStatus.ACTIVE,
+ integration_id=integration.id,
+ )
+
+ result = get_repo_installation_id(
+ organization_id=self.organization.id,
+ provider="github",
+ external_id="123456",
+ owner="getsentry",
+ name="sentry",
+ )
+
+ assert result == {"installation_id": "12345", "permissions": permissions}
+
+ def test_get_repo_installation_id_github_enterprise(self) -> None:
+ """Test returns metadata installation_id for GitHub Enterprise repos"""
+ integration = self.create_integration(
+ organization=self.organization,
+ provider="github_enterprise",
+ external_id="ghe:1",
+ metadata={"installation_id": "99999"},
+ )
+
+ Repository.objects.create(
+ name="mycompany/internal-repo",
+ organization_id=self.organization.id,
+ provider="integrations:github_enterprise",
+ external_id="789",
+ status=ObjectStatus.ACTIVE,
+ integration_id=integration.id,
+ )
+
+ result = get_repo_installation_id(
+ organization_id=self.organization.id,
+ provider="github_enterprise",
+ external_id="789",
+ owner="mycompany",
+ name="internal-repo",
+ )
+
+ assert result == {"installation_id": "99999", "permissions": None}
+
+ def test_get_repo_installation_id_not_found(self) -> None:
+ """Test returns error when repository does not exist"""
+ result = get_repo_installation_id(
+ organization_id=self.organization.id,
+ provider="github",
+ external_id="nonexistent",
+ owner="getsentry",
+ name="sentry",
+ )
+
+ assert result == {"error": "repository_not_found"}
+
+ def test_get_repo_installation_id_unsupported_provider(self) -> None:
+ """Test returns error for unsupported provider"""
+ integration = self.create_integration(
+ organization=self.organization, provider="gitlab", external_id="gitlab:1"
+ )
+
+ Repository.objects.create(
+ name="getsentry/sentry",
+ organization_id=self.organization.id,
+ provider="gitlab",
+ external_id="123456",
+ status=ObjectStatus.ACTIVE,
+ integration_id=integration.id,
+ )
+
+ result = get_repo_installation_id(
+ organization_id=self.organization.id,
+ provider="gitlab",
+ external_id="123456",
+ owner="getsentry",
+ name="sentry",
+ )
+
+ assert result == {"error": "unsupported_provider"}
+
+ def test_get_repo_installation_id_no_integration(self) -> None:
+ """Test returns error when repo has no integration_id"""
+ Repository.objects.create(
+ name="getsentry/sentry",
+ organization_id=self.organization.id,
+ provider="integrations:github",
+ external_id="123456",
+ status=ObjectStatus.ACTIVE,
+ integration_id=None,
+ )
+
+ result = get_repo_installation_id(
+ organization_id=self.organization.id,
+ provider="github",
+ external_id="123456",
+ owner="getsentry",
+ name="sentry",
+ )
+
+ assert result == {"error": "no_integration"}
+
+ def test_get_repo_installation_id_integration_not_found(self) -> None:
+ """Test returns error when integration record doesn't exist"""
+ Repository.objects.create(
+ name="getsentry/sentry",
+ organization_id=self.organization.id,
+ provider="integrations:github",
+ external_id="123456",
+ status=ObjectStatus.ACTIVE,
+ integration_id=999999,
+ )
+
+ result = get_repo_installation_id(
+ organization_id=self.organization.id,
+ provider="github",
+ external_id="123456",
+ owner="getsentry",
+ name="sentry",
+ )
+
+ assert result == {"error": "integration_not_found"}
From 6a04d3078e66925b9c029e9f0e14244a63df7d3d Mon Sep 17 00:00:00 2001
From: Raj Joshi
Date: Tue, 31 Mar 2026 10:25:34 -0700
Subject: [PATCH 14/51] =?UTF-8?q?=E2=9C=A8=20feat(gitlab):=20add=20inbound?=
=?UTF-8?q?=20status=20sync=20support=20(#107139)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
fixtures/gitlab.py | 150 ++++++++++++++++++
src/sentry/integrations/gitlab/integration.py | 24 +++
src/sentry/integrations/gitlab/issue_sync.py | 13 ++
src/sentry/integrations/gitlab/types.py | 11 +-
src/sentry/integrations/gitlab/webhooks.py | 45 +++++-
.../integrations/gitlab/test_integration.py | 2 +
.../integrations/gitlab/test_webhook.py | 68 ++++++++
7 files changed, 301 insertions(+), 12 deletions(-)
diff --git a/fixtures/gitlab.py b/fixtures/gitlab.py
index 0b21efdc8ae7..4d1c8fd7622e 100644
--- a/fixtures/gitlab.py
+++ b/fixtures/gitlab.py
@@ -426,6 +426,156 @@ def create_gitlab_repo(
}
"""
+ISSUE_CLOSED_EVENT = b"""{
+ "object_kind": "issue",
+ "event_type": "issue",
+ "user": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "avatar_url": "http://www.gravatar.com/avatar/avatar.jpg",
+ "email": "admin@example.com"
+ },
+ "project": {
+ "id": 15,
+ "name": "Sentry",
+ "description": "",
+ "web_url": "http://example.com/cool-group/sentry",
+ "avatar_url": null,
+ "git_ssh_url": "git@example.com:cool-group/sentry.git",
+ "git_http_url": "http://example.com/cool-group/sentry.git",
+ "namespace": "cool-group",
+ "visibility_level": 0,
+ "path_with_namespace": "cool-group/sentry",
+ "default_branch": "master",
+ "homepage": "http://example.com/cool-group/sentry",
+ "url": "git@example.com:cool-group/sentry.git",
+ "ssh_url": "git@example.com:cool-group/sentry.git",
+ "http_url": "http://example.com/cool-group/sentry.git"
+ },
+ "object_attributes": {
+ "id": 301,
+ "title": "Test issue",
+ "assignee_ids": [],
+ "assignee_id": null,
+ "author_id": 1,
+ "project_id": 15,
+ "created_at": "2023-01-01 00:00:00 UTC",
+ "updated_at": "2023-01-01 00:00:00 UTC",
+ "position": 0,
+ "branch_name": null,
+ "description": "Test issue description",
+ "milestone_id": null,
+ "state": "closed",
+ "iid": 23,
+ "url": "http://example.com/cool-group/sentry/issues/23",
+ "action": "close"
+ },
+ "assignees": [],
+ "labels": []
+}
+"""
+
+ISSUE_REOPENED_EVENT = b"""{
+ "object_kind": "issue",
+ "event_type": "issue",
+ "user": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "avatar_url": "http://www.gravatar.com/avatar/avatar.jpg",
+ "email": "admin@example.com"
+ },
+ "project": {
+ "id": 15,
+ "name": "Sentry",
+ "description": "",
+ "web_url": "http://example.com/cool-group/sentry",
+ "avatar_url": null,
+ "git_ssh_url": "git@example.com:cool-group/sentry.git",
+ "git_http_url": "http://example.com/cool-group/sentry.git",
+ "namespace": "cool-group",
+ "visibility_level": 0,
+ "path_with_namespace": "cool-group/sentry",
+ "default_branch": "master",
+ "homepage": "http://example.com/cool-group/sentry",
+ "url": "git@example.com:cool-group/sentry.git",
+ "ssh_url": "git@example.com:cool-group/sentry.git",
+ "http_url": "http://example.com/cool-group/sentry.git"
+ },
+ "object_attributes": {
+ "id": 301,
+ "title": "Test issue",
+ "assignee_ids": [],
+ "assignee_id": null,
+ "author_id": 1,
+ "project_id": 15,
+ "created_at": "2023-01-01 00:00:00 UTC",
+ "updated_at": "2023-01-01 00:00:00 UTC",
+ "position": 0,
+ "branch_name": null,
+ "description": "Test issue description",
+ "milestone_id": null,
+ "state": "opened",
+ "iid": 23,
+ "url": "http://example.com/cool-group/sentry/issues/23",
+ "action": "reopen"
+ },
+ "assignees": [],
+ "labels": []
+}
+"""
+
+ISSUE_OPENED_EVENT = b"""{
+ "object_kind": "issue",
+ "event_type": "issue",
+ "user": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "avatar_url": "http://www.gravatar.com/avatar/avatar.jpg",
+ "email": "admin@example.com"
+ },
+ "project": {
+ "id": 15,
+ "name": "Sentry",
+ "description": "",
+ "web_url": "http://example.com/cool-group/sentry",
+ "avatar_url": null,
+ "git_ssh_url": "git@example.com:cool-group/sentry.git",
+ "git_http_url": "http://example.com/cool-group/sentry.git",
+ "namespace": "cool-group",
+ "visibility_level": 0,
+ "path_with_namespace": "cool-group/sentry",
+ "default_branch": "master",
+ "homepage": "http://example.com/cool-group/sentry",
+ "url": "git@example.com:cool-group/sentry.git",
+ "ssh_url": "git@example.com:cool-group/sentry.git",
+ "http_url": "http://example.com/cool-group/sentry.git"
+ },
+ "object_attributes": {
+ "id": 301,
+ "title": "Test issue",
+ "assignee_ids": [],
+ "assignee_id": null,
+ "author_id": 1,
+ "project_id": 15,
+ "created_at": "2023-01-01 00:00:00 UTC",
+ "updated_at": "2023-01-01 00:00:00 UTC",
+ "position": 0,
+ "branch_name": null,
+ "description": "Test issue description",
+ "milestone_id": null,
+ "state": "opened",
+ "iid": 23,
+ "url": "http://example.com/cool-group/sentry/issues/23",
+ "action": "open"
+ },
+ "assignees": [],
+ "labels": []
+}
+"""
+
COMPARE_RESPONSE = r"""
{
"commit": {
diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py
index ae90f2dedd83..292670e79e5b 100644
--- a/src/sentry/integrations/gitlab/integration.py
+++ b/src/sentry/integrations/gitlab/integration.py
@@ -228,6 +228,30 @@ def _get_organization_config_default_values(self) -> list[dict[str, Any]]:
"label": _("Sync Sentry Comments to GitLab"),
"help": _("Post comments from Sentry issues to linked GitLab issues"),
},
+ {
+ "name": self.inbound_status_key,
+ "type": "boolean",
+ "label": _("Sync GitLab Status to Sentry"),
+ "help": _(
+ "When a GitLab issue is marked closed, resolve its linked issue in Sentry. "
+ "When a GitLab issue is reopened, unresolve its linked Sentry issue."
+ ),
+ "default": False,
+ },
+ {
+ "name": self.resolution_strategy_key,
+ "label": "Resolve",
+ "type": "select",
+ "placeholder": "Resolve",
+ "choices": [
+ ("resolve", "Resolve"),
+ ("resolve_current_release", "Resolve in Current Release"),
+ ("resolve_next_release", "Resolve in Next Release"),
+ ],
+ "help": _(
+ "Select what action to take on Sentry Issue when GitLab ticket is marked Closed."
+ ),
+ },
]
)
diff --git a/src/sentry/integrations/gitlab/issue_sync.py b/src/sentry/integrations/gitlab/issue_sync.py
index 9f70220c8c62..6012b00ff163 100644
--- a/src/sentry/integrations/gitlab/issue_sync.py
+++ b/src/sentry/integrations/gitlab/issue_sync.py
@@ -6,6 +6,7 @@
from urllib.parse import quote
from sentry import features
+from sentry.integrations.gitlab.types import GitLabIssueAction
from sentry.integrations.mixins.issues import IssueSyncIntegration, ResolveSyncAction
from sentry.integrations.models.external_actor import ExternalActor
from sentry.integrations.models.external_issue import ExternalIssue
@@ -22,6 +23,8 @@ class GitlabIssueSyncSpec(IssueSyncIntegration):
comment_key = "sync_comments"
outbound_assignee_key = "sync_forward_assignment"
inbound_assignee_key = "sync_reverse_assignment"
+ inbound_status_key = "sync_status_reverse"
+ resolution_strategy_key = "resolution_strategy"
def check_feature_flag(self) -> bool:
"""
@@ -169,6 +172,16 @@ def get_resolve_sync_action(self, data: Mapping[str, Any]) -> ResolveSyncAction:
Given webhook data, check whether the GitLab issue status changed.
GitLab issues have opened/closed state.
"""
+ if not self.check_feature_flag():
+ return ResolveSyncAction.NOOP
+
+ action = data.get("action")
+
+ if action == GitLabIssueAction.CLOSE:
+ return ResolveSyncAction.RESOLVE
+ elif action == GitLabIssueAction.REOPEN:
+ return ResolveSyncAction.UNRESOLVE
+
return ResolveSyncAction.NOOP
def get_config_data(self):
diff --git a/src/sentry/integrations/gitlab/types.py b/src/sentry/integrations/gitlab/types.py
index 3182bef30501..34935f78d697 100644
--- a/src/sentry/integrations/gitlab/types.py
+++ b/src/sentry/integrations/gitlab/types.py
@@ -1,20 +1,11 @@
from enum import StrEnum
-class GitLabIssueStatus(StrEnum):
- OPENED = "opened"
- CLOSED = "closed"
-
- @classmethod
- def get_choices(cls):
- """Return choices formatted for dropdown selectors"""
- return [(status.value, status.value.capitalize()) for status in cls]
-
-
class GitLabIssueAction(StrEnum):
UPDATE = "update"
OPEN = "open"
REOPEN = "reopen"
+ CLOSE = "close"
@classmethod
def values(cls):
diff --git a/src/sentry/integrations/gitlab/webhooks.py b/src/sentry/integrations/gitlab/webhooks.py
index 76edeea09b0b..39781c8f2ee3 100644
--- a/src/sentry/integrations/gitlab/webhooks.py
+++ b/src/sentry/integrations/gitlab/webhooks.py
@@ -17,8 +17,10 @@
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, cell_silo_endpoint
+from sentry.constants import ObjectStatus
from sentry.integrations.base import IntegrationDomain
from sentry.integrations.gitlab.types import GitLabIssueAction
+from sentry.integrations.mixins.issues import IssueSyncIntegration
from sentry.integrations.services.integration import integration_service
from sentry.integrations.services.integration.model import RpcIntegration
from sentry.integrations.source_code_management.webhook import SCMWebhook
@@ -129,6 +131,7 @@ def event_type(self) -> IntegrationWebhookEventType:
def __call__(self, event: Mapping[str, Any], **kwargs):
if not (integration := kwargs.get("integration")):
raise ValueError("Integration must be provided")
+ organization: RpcOrganization | None = kwargs.get("organization")
external_issue_key = self._extract_issue_key(event, integration)
if not external_issue_key:
@@ -144,10 +147,14 @@ def __call__(self, event: Mapping[str, Any], **kwargs):
object_attributes = event.get("object_attributes", {})
action = object_attributes.get("action")
- # Handle assignment changes
- if action in GitLabIssueAction.values():
+ # Handle assignment changes — CLOSE does not affect assignment
+ if action in GitLabIssueAction.values() and action != GitLabIssueAction.CLOSE:
self._handle_assignment(integration, event, external_issue_key)
+ # Handle status changes (CLOSE and REOPEN)
+ if action in [GitLabIssueAction.CLOSE, GitLabIssueAction.REOPEN] and organization:
+ self._handle_status_change(integration, external_issue_key, action, organization.id)
+
def _handle_assignment(
self,
integration: RpcIntegration,
@@ -216,6 +223,40 @@ def _handle_assignment(
},
)
+ def _handle_status_change(
+ self,
+ integration: RpcIntegration,
+ external_issue_key: str,
+ action: str,
+ organization_id: int,
+ ) -> None:
+ """
+ Handle issue status changes (close/reopen).
+
+ Triggers the sync_status_inbound task to update linked Sentry issues.
+ """
+ org_integrations = integration_service.get_organization_integrations(
+ integration_id=integration.id,
+ organization_id=organization_id,
+ providers=[integration.provider],
+ status=ObjectStatus.ACTIVE,
+ )
+ for org_integration in org_integrations:
+ installation = integration.get_installation(org_integration.organization_id)
+ if isinstance(installation, IssueSyncIntegration):
+ installation.sync_status_inbound(
+ external_issue_key,
+ {"action": action},
+ )
+ logger.info(
+ "gitlab.webhook.status.synced",
+ extra={
+ "integration_id": integration.id,
+ "external_issue_key": external_issue_key,
+ "action": action,
+ },
+ )
+
def _extract_issue_key(
self, event: Mapping[str, Any], integration: RpcIntegration
) -> str | None:
diff --git a/tests/sentry/integrations/gitlab/test_integration.py b/tests/sentry/integrations/gitlab/test_integration.py
index 0e208a5a222d..c1cae71ad3b7 100644
--- a/tests/sentry/integrations/gitlab/test_integration.py
+++ b/tests/sentry/integrations/gitlab/test_integration.py
@@ -841,6 +841,8 @@ def test_get_organization_config(self) -> None:
"sync_reverse_assignment",
"sync_forward_assignment",
"sync_comments",
+ "sync_status_reverse",
+ "resolution_strategy",
]
@responses.activate
diff --git a/tests/sentry/integrations/gitlab/test_webhook.py b/tests/sentry/integrations/gitlab/test_webhook.py
index c782bb51c404..993d8759f1d3 100644
--- a/tests/sentry/integrations/gitlab/test_webhook.py
+++ b/tests/sentry/integrations/gitlab/test_webhook.py
@@ -5,6 +5,9 @@
from fixtures.gitlab import (
EXTERNAL_ID,
ISSUE_ASSIGNED_EVENT,
+ ISSUE_CLOSED_EVENT,
+ ISSUE_OPENED_EVENT,
+ ISSUE_REOPENED_EVENT,
ISSUE_UNASSIGNED_EVENT,
MERGE_REQUEST_OPENED_EVENT,
PUSH_EVENT,
@@ -490,3 +493,68 @@ def test_issue_unassigned(self, mock_sync: MagicMock) -> None:
call_args[1]["external_issue_key"] == "example.gitlab.com/group-x:cool-group/sentry#23"
)
assert call_args[1]["assign"] is False
+
+
+class TestIssuesEventWebhookStatusSync(GitLabTestCase):
+ url = "/extensions/gitlab/webhook/"
+
+ @patch("sentry.integrations.gitlab.integration.GitlabIntegration.sync_status_inbound")
+ def test_close_event_triggers_sync(self, mock_sync_status: MagicMock) -> None:
+ response = self.client.post(
+ self.url,
+ data=ISSUE_CLOSED_EVENT,
+ content_type="application/json",
+ HTTP_X_GITLAB_TOKEN=WEBHOOK_TOKEN,
+ HTTP_X_GITLAB_EVENT="Issue Hook",
+ )
+ assert response.status_code == 204
+
+ assert mock_sync_status.called
+ call_args = mock_sync_status.call_args
+ assert call_args[0][0] == "example.gitlab.com/group-x:cool-group/sentry#23"
+ assert call_args[0][1] == {"action": "close"}
+
+ @patch("sentry.integrations.gitlab.integration.GitlabIntegration.sync_status_inbound")
+ def test_reopen_event_triggers_sync(self, mock_sync_status: MagicMock) -> None:
+ response = self.client.post(
+ self.url,
+ data=ISSUE_REOPENED_EVENT,
+ content_type="application/json",
+ HTTP_X_GITLAB_TOKEN=WEBHOOK_TOKEN,
+ HTTP_X_GITLAB_EVENT="Issue Hook",
+ )
+ assert response.status_code == 204
+
+ assert mock_sync_status.called
+ call_args = mock_sync_status.call_args
+ assert call_args[0][0] == "example.gitlab.com/group-x:cool-group/sentry#23"
+ assert call_args[0][1] == {"action": "reopen"}
+
+ @patch("sentry.integrations.gitlab.integration.GitlabIntegration.sync_status_inbound")
+ def test_open_event_does_not_trigger_sync(self, mock_sync_status: MagicMock) -> None:
+ response = self.client.post(
+ self.url,
+ data=ISSUE_OPENED_EVENT,
+ content_type="application/json",
+ HTTP_X_GITLAB_TOKEN=WEBHOOK_TOKEN,
+ HTTP_X_GITLAB_EVENT="Issue Hook",
+ )
+ assert response.status_code == 204
+
+ assert not mock_sync_status.called
+
+ @patch("sentry.integrations.gitlab.integration.GitlabIntegration.sync_status_inbound")
+ def test_sync_called_with_correct_params(self, mock_sync_status: MagicMock) -> None:
+ response = self.client.post(
+ self.url,
+ data=ISSUE_CLOSED_EVENT,
+ content_type="application/json",
+ HTTP_X_GITLAB_TOKEN=WEBHOOK_TOKEN,
+ HTTP_X_GITLAB_EVENT="Issue Hook",
+ )
+ assert response.status_code == 204
+
+ assert mock_sync_status.called
+ call_args = mock_sync_status.call_args
+ assert call_args[0][0] == "example.gitlab.com/group-x:cool-group/sentry#23"
+ assert call_args[0][1]["action"] == "close"
From f2fc5213241010f50cdcc4174d46c2a31ee5b529 Mon Sep 17 00:00:00 2001
From: Charles Paul
Date: Tue, 31 Mar 2026 10:29:52 -0700
Subject: [PATCH 15/51] ref(errors): Explicitly throw on empty group_id snuba
query (#110923)
This breaks clickhouse. Let's raise a SnubaError that spells out the
problem more clearly instead of letting things get that far.
---
src/sentry/utils/snuba.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/sentry/utils/snuba.py b/src/sentry/utils/snuba.py
index 219b7eff37e2..8021ed233921 100644
--- a/src/sentry/utils/snuba.py
+++ b/src/sentry/utils/snuba.py
@@ -974,7 +974,13 @@ def _preprocess_group_id_redirects(self):
# just subtract the NOT IN groups from the IN groups.
if in_groups is not None:
in_groups.difference_update(out_groups)
- triple = ["group_id", "IN", get_all_merged_group_ids(in_groups)]
+
+ # An "group_id IN ()" clause breaks clickhouse.
+ # Better to make the exception (& expectations) clear.
+ if len(in_groups) > 0:
+ triple = ["group_id", "IN", get_all_merged_group_ids(in_groups)]
+ else:
+ raise SnubaError("Found empty intersection of group_ids")
elif len(out_groups) > 0:
triple = ["group_id", "NOT IN", out_groups]
From 0d24b838ef60a3aa7b43dca7eb7128bbbf3df1d9 Mon Sep 17 00:00:00 2001
From: Ryan Albrecht
Date: Tue, 31 Mar 2026 10:38:53 -0700
Subject: [PATCH 16/51] feat(seer): Update default triggers for Code Review
(#111911)
This is a repeat of https://github.com/getsentry/sentry/pull/111829
That #111829 was reverted because of a failing test: the selective test
runner didn't find the test so i missed it.
---
src/sentry/constants.py | 1 -
static/app/types/integrations.tsx | 5 +----
.../api/endpoints/test_organization_repository_details.py | 1 -
3 files changed, 1 insertion(+), 6 deletions(-)
diff --git a/src/sentry/constants.py b/src/sentry/constants.py
index f2aff76cadff..fda38cfaf076 100644
--- a/src/sentry/constants.py
+++ b/src/sentry/constants.py
@@ -727,7 +727,6 @@ class InsightModules(Enum):
# Seer Org level default for code review triggers
DEFAULT_CODE_REVIEW_TRIGGERS: list[str] = [
"on_ready_for_review",
- "on_new_commit",
]
SEER_DEFAULT_CODING_AGENT_DEFAULT = "seer"
SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT = "code_changes"
diff --git a/static/app/types/integrations.tsx b/static/app/types/integrations.tsx
index 6cf1358bd2d2..643cfe07f052 100644
--- a/static/app/types/integrations.tsx
+++ b/static/app/types/integrations.tsx
@@ -99,10 +99,7 @@ export interface RepositoryWithSettings extends Repository {
};
}
-export const DEFAULT_CODE_REVIEW_TRIGGERS: CodeReviewTrigger[] = [
- 'on_ready_for_review',
- 'on_new_commit',
-];
+export const DEFAULT_CODE_REVIEW_TRIGGERS: CodeReviewTrigger[] = ['on_ready_for_review'];
/**
* Integration Repositories from OrganizationIntegrationReposEndpoint
diff --git a/tests/sentry/integrations/api/endpoints/test_organization_repository_details.py b/tests/sentry/integrations/api/endpoints/test_organization_repository_details.py
index 2724c264dadf..e11527b5fc95 100644
--- a/tests/sentry/integrations/api/endpoints/test_organization_repository_details.py
+++ b/tests/sentry/integrations/api/endpoints/test_organization_repository_details.py
@@ -90,7 +90,6 @@ def test_get_repository_expand_settings_no_settings_exist(self) -> None:
assert response.data["settings"]["enabledCodeReview"] is False
assert response.data["settings"]["codeReviewTriggers"] == [
"on_ready_for_review",
- "on_new_commit",
]
From b266553afe1fde4f077aaf4dd0e6a01e6c404f8f Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 31 Mar 2026 13:42:16 -0400
Subject: [PATCH 17/51] chore(deps): bump pyjwt from 2.10.1 to 2.12.0 (#110969)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.10.1 to 2.12.0.
Release notes
Sourced from pyjwt's
releases .
2.12.0
Security
What's Changed
New Contributors
Full Changelog : https://github.com/jpadilla/pyjwt/compare/2.11.0...2.12.0
2.11.0
What's Changed
... (truncated)
Changelog
Sourced from pyjwt's
changelog .
v2.12.0
<https://github.com/jpadilla/pyjwt/compare/2.11.0...2.12.0>__
Fixed
- Annotate PyJWKSet.keys for pyright by @tamird in
`[#1134](https://github.com/jpadilla/pyjwt/issues/1134)
<https://github.com/jpadilla/pyjwt/pull/1134>`__
- Close ``HTTPError`` response to prevent ``ResourceWarning`` on Python
3.14 by @veeceey in
`[#1133](https://github.com/jpadilla/pyjwt/issues/1133)
<https://github.com/jpadilla/pyjwt/pull/1133>`__
- Do not keep ``algorithms`` dict in PyJWK instances by @akx in
`[#1143](https://github.com/jpadilla/pyjwt/issues/1143)
<https://github.com/jpadilla/pyjwt/pull/1143>`__
- Validate the crit (Critical) Header Parameter defined in RFC 7515
§4.1.11. by @dmbs335 in `GHSA-752w-5fwx-jx9f
<https://github.com/jpadilla/pyjwt/security/advisories/GHSA-752w-5fwx-jx9f>`__
- Use PyJWK algorithm when encoding without explicit algorithm in
`[#1148](https://github.com/jpadilla/pyjwt/issues/1148)
<https://github.com/jpadilla/pyjwt/pull/1148>`__
Added
Docs: Add PyJWKClient API reference and document the
two-tier caching system (JWK Set cache and signing key LRU cache).
v2.11.0
<https://github.com/jpadilla/pyjwt/compare/2.10.1...2.11.0>__
Fixed
- Enforce ECDSA curve validation per RFC 7518 Section 3.4.
- Fix build system warnings by @kurtmckee in
`[#1105](https://github.com/jpadilla/pyjwt/issues/1105)
<https://github.com/jpadilla/pyjwt/pull/1105>`__
- Validate key against allowed types for Algorithm family in
`[#964](https://github.com/jpadilla/pyjwt/issues/964)
<https://github.com/jpadilla/pyjwt/pull/964>`__
- Add iterator for JWKSet in
`[#1041](https://github.com/jpadilla/pyjwt/issues/1041)
<https://github.com/jpadilla/pyjwt/pull/1041>`__
- Validate `iss` claim is a string during encoding and decoding by
@pachewise in `[#1040](https://github.com/jpadilla/pyjwt/issues/1040)
<https://github.com/jpadilla/pyjwt/pull/1040>`__
- Improve typing/logic for `options` in decode, decode_complete by
@pachewise in `[#1045](https://github.com/jpadilla/pyjwt/issues/1045)
<https://github.com/jpadilla/pyjwt/pull/1045>`__
- Declare float supported type for lifespan and timeout by
@nikitagashkov in
`[#1068](https://github.com/jpadilla/pyjwt/issues/1068)
<https://github.com/jpadilla/pyjwt/pull/1068>`__
- Fix ``SyntaxWarning``\s/``DeprecationWarning``\s caused by invalid
escape sequences by @kurtmckee in
`[#1103](https://github.com/jpadilla/pyjwt/issues/1103)
<https://github.com/jpadilla/pyjwt/pull/1103>`__
- Development: Build a shared wheel once to speed up test suite setup
times by @kurtmckee in
`[#1114](https://github.com/jpadilla/pyjwt/issues/1114)
<https://github.com/jpadilla/pyjwt/pull/1114>`__
- Development: Test type annotations across all supported Python
versions,
increase the strictness of the type checking, and remove the mypy
pre-commit hook
by @kurtmckee in `[#1112](https://github.com/jpadilla/pyjwt/issues/1112)
<https://github.com/jpadilla/pyjwt/pull/1112>`__
Added
Support Python 3.14, and test against PyPy 3.10 and 3.11 by @kurtmckee in
[#1104](https://github.com/jpadilla/pyjwt/issues/1104)
<https://github.com/jpadilla/pyjwt/pull/1104>__
Development: Migrate to build to test package building
in CI by @kurtmckee in
[#1108](https://github.com/jpadilla/pyjwt/issues/1108)
<https://github.com/jpadilla/pyjwt/pull/1108>__
Development: Improve coverage config and eliminate unused test suite
code by @kurtmckee in
[#1115](https://github.com/jpadilla/pyjwt/issues/1115)
<https://github.com/jpadilla/pyjwt/pull/1115>__
Docs: Standardize CHANGELOG links to PRs by @kurtmckee in
[#1110](https://github.com/jpadilla/pyjwt/issues/1110)
<https://github.com/jpadilla/pyjwt/pull/1110>__
Docs: Fix Read the Docs builds by @kurtmckee in
[#1111](https://github.com/jpadilla/pyjwt/issues/1111)
<https://github.com/jpadilla/pyjwt/pull/1111>__
Docs: Add example of using leeway with nbf by @djw8605 in
[#1034](https://github.com/jpadilla/pyjwt/issues/1034)
<https://github.com/jpadilla/pyjwt/pull/1034>__
Docs: Refactored docs with autodoc; added
PyJWS and jwt.algorithms docs by @pachewise in
[#1045](https://github.com/jpadilla/pyjwt/issues/1045)
<https://github.com/jpadilla/pyjwt/pull/1045>__
Docs: Documentation improvements for "sub" and
"jti" claims by @cleder in
[#1088](https://github.com/jpadilla/pyjwt/issues/1088)
<https://github.com/jpadilla/pyjwt/pull/1088>__
Development: Add pyupgrade as a pre-commit hook by @kurtmckee in
[#1109](https://github.com/jpadilla/pyjwt/issues/1109)
<https://github.com/jpadilla/pyjwt/pull/1109>__
Add minimum key length validation for HMAC and RSA keys (CWE-326).
Warns by default via InsecureKeyLengthWarning when keys are
below
... (truncated)
Commits
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/getsentry/sentry/network/alerts).
---------
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alexander Tarasov
Co-authored-by: Michelle Tran
Co-authored-by: Claude
---
pyproject.toml | 4 ++++
src/sentry/integrations/github/utils.py | 2 +-
src/sentry/utils/jwt.py | 3 ++-
src/sentry_plugins/github/client.py | 2 +-
uv.lock | 4 ++--
5 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 60264b09ad04..79c2b035d3be 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -300,6 +300,10 @@ filterwarnings = [
# a deprecated constant. But, still report it as a warning so that we're
# informed and can evaluate.
"default:ATTRIBUTE_NAMES\\..* is deprecated.*:DeprecationWarning",
+
+ # PyJWT 2.12.0 introduced InsecureKeyLengthWarning for HMAC keys shorter than
+ # 32 bytes. Tests use short secrets intentionally; production secrets are long enough.
+ "ignore:.*HMAC key.*below the minimum recommended length.*:jwt.warnings.InsecureKeyLengthWarning",
]
looponfailroots = ["src", "tests"]
diff --git a/src/sentry/integrations/github/utils.py b/src/sentry/integrations/github/utils.py
index 6f4198481e24..a75284daff64 100644
--- a/src/sentry/integrations/github/utils.py
+++ b/src/sentry/integrations/github/utils.py
@@ -16,7 +16,7 @@
def get_jwt(github_id: str | None = None, github_private_key: str | None = None) -> str:
if github_id is None:
- github_id = options.get("github-app.id")
+ github_id = str(options.get("github-app.id"))
if github_private_key is None:
github_private_key = options.get("github-app.private-key")
exp_ = datetime.datetime.utcnow() + datetime.timedelta(minutes=10)
diff --git a/src/sentry/utils/jwt.py b/src/sentry/utils/jwt.py
index 462e1dee48a3..f35585193140 100644
--- a/src/sentry/utils/jwt.py
+++ b/src/sentry/utils/jwt.py
@@ -18,6 +18,7 @@
PublicFormat,
)
from jwt import DecodeError
+from jwt.types import Options
__all__ = ["peek_claims", "decode", "encode", "authorization_header", "DecodeError"]
@@ -65,7 +66,7 @@ def decode(
"""
# TODO: We do not currently have type-safety for keys suitable for decoding *and*
# encoding vs those only suitable for decoding.
- options = {}
+ options: Options = {}
kwargs: dict[str, Any] = {}
if audience is False:
options["verify_aud"] = False
diff --git a/src/sentry_plugins/github/client.py b/src/sentry_plugins/github/client.py
index e12576f27db1..6bcc067ec069 100644
--- a/src/sentry_plugins/github/client.py
+++ b/src/sentry_plugins/github/client.py
@@ -99,7 +99,7 @@ def get_jwt(self) -> str:
# JWT expiration time (10 minute maximum)
"exp": exp,
# Integration's GitHub identifier
- "iss": options.get("github.integration-app-id"),
+ "iss": str(options.get("github.integration-app-id")),
}
return jwt.encode(payload, options.get("github.integration-private-key"), algorithm="RS256")
diff --git a/uv.lock b/uv.lock
index 041cffa6e8c2..c5e6b2985d25 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1701,10 +1701,10 @@ wheels = [
[[package]]
name = "pyjwt"
-version = "2.10.1"
+version = "2.12.0"
source = { registry = "https://pypi.devinfra.sentry.io/simple" }
wheels = [
- { url = "https://pypi.devinfra.sentry.io/wheels/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb" },
+ { url = "https://pypi.devinfra.sentry.io/wheels/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e" },
]
[package.optional-dependencies]
From 7dd85b0bad708ec7eb5a447d9e97f281796085b5 Mon Sep 17 00:00:00 2001
From: Nick
Date: Tue, 31 Mar 2026 13:51:59 -0400
Subject: [PATCH 18/51] feat(core-ui): Add ClearButton to CompositeSelect
(#111706)
The current `CompositeSelect` component doesn't seem to have an easy way
to hook up clearing selections, which is understandable since there are
multi-selection possibilities so to tackle this I'd like to add in a
button that is styled to match that of the `CompactSelect`'s clear
button.
---------
Co-authored-by: Claude Sonnet 4.6
Co-authored-by: Claude Sonnet 4
---
.../core/compactSelect/composite.mdx | 97 +++++++++++++++++++
.../core/compactSelect/composite.tsx | 14 ++-
.../components/core/compactSelect/control.tsx | 2 +-
.../metricToolbar/aggregateDropdown.tsx | 18 +++-
4 files changed, 124 insertions(+), 7 deletions(-)
diff --git a/static/app/components/core/compactSelect/composite.mdx b/static/app/components/core/compactSelect/composite.mdx
index 55f9b2a11264..94c4a9045cdb 100644
--- a/static/app/components/core/compactSelect/composite.mdx
+++ b/static/app/components/core/compactSelect/composite.mdx
@@ -53,6 +53,7 @@ Use `` when you need a dropdown with multiple independent selec
- **``**: The wrapper component that manages the dropdown
- **``**: Individual selection sections within the dropdown
+- **``**: A "Clear" button for use in `menuHeaderTrailingItems` that calls your `onClick` to reset all regions. Re-exported from ``'s internal clear button for visual consistency
Each region acts like an independent ``, requiring its own `value`, `onChange`, and `options`.
@@ -124,6 +125,102 @@ const [day, setDay] = useState('1');
;
```
+## Clear Button
+
+Use `` in `menuHeaderTrailingItems` to add a "Clear" button to the menu header. When clicked, it calls your `onClick` handler where you reset each region's value. The menu stays open, matching the behavior of the built-in clear button in ``. The component is the same styled button used internally by ``, ensuring visual consistency.
+
+Only render the button when there is an active selection to clear.
+
+export function ClearButtonDemo() {
+ const monthOptions = [
+ {value: 'jan', label: 'January'},
+ {value: 'feb', label: 'February'},
+ {value: 'mar', label: 'March'},
+ ];
+ const tagOptions = [
+ {value: 'cool', label: 'cool'},
+ {value: 'funny', label: 'funny'},
+ {value: 'awesome', label: 'awesome'},
+ ];
+ const [month, setMonth] = useState('');
+ const [tags, setTags] = useState(tagOptions.slice(0, 0).map(o => o.value));
+ const hasSelection = month !== '' || tags.length > 0;
+ return (
+ {
+ setMonth('');
+ setTags([]);
+ }}
+ />
+ ) : null
+ }
+ trigger={props => (
+ } {...props}>
+ Filters
+
+ )}
+ >
+ setMonth(selection.value)}
+ options={monthOptions}
+ />
+ setTags(selection.map(s => s.value))}
+ options={tagOptions}
+ />
+
+ );
+}
+
+
+
+
+
+```jsx
+const [month, setMonth] = useState(null);
+const [tags, setTags] = useState([]);
+const hasSelection = month !== null || tags.length > 0;
+
+ {
+ setMonth(null);
+ setTags([]);
+ }}
+ />
+ ) : null
+ }
+ trigger={props => Filters }
+>
+ setMonth(selection.value)}
+ options={monthOptions}
+ />
+ setTags(selection.map(s => s.value))}
+ options={tagOptions}
+ />
+ ;
+```
+
## Multi-Select Regions
Individual regions can enable multi-select by setting the `multiple` prop. This allows mixing single and multi-select behavior within the same dropdown.
diff --git a/static/app/components/core/compactSelect/composite.tsx b/static/app/components/core/compactSelect/composite.tsx
index 7d67012bf994..34ea6b5ce98a 100644
--- a/static/app/components/core/compactSelect/composite.tsx
+++ b/static/app/components/core/compactSelect/composite.tsx
@@ -4,10 +4,12 @@ import {FocusScope} from '@react-aria/focus';
import {Item} from '@react-stately/collections';
import type {DistributedOmit} from 'type-fest';
+import {type ButtonProps} from '@sentry/scraps/button';
+
import {t} from 'sentry/locale';
+import {ClearButton, Control} from './control';
import type {ControlProps} from './control';
-import {Control} from './control';
import type {MultipleListProps, SingleListProps} from './list';
import {List} from './list';
import {EmptyMessage} from './styles';
@@ -118,6 +120,16 @@ CompositeSelect.Region = function (
return null;
};
+CompositeSelect.ClearButton = function CompositeSelectClearButton(
+ props: DistributedOmit
+) {
+ return (
+
+ {t('Clear')}
+
+ );
+};
+
export {CompositeSelect};
type RegionProps = CompositeSelectRegion & {
diff --git a/static/app/components/core/compactSelect/control.tsx b/static/app/components/core/compactSelect/control.tsx
index 493efbef4a61..bc99eecc9b20 100644
--- a/static/app/components/core/compactSelect/control.tsx
+++ b/static/app/components/core/compactSelect/control.tsx
@@ -643,7 +643,7 @@ const StyledLoadingIndicator = styled(LoadingIndicator)`
}
`;
-const ClearButton = styled(Button)`
+export const ClearButton = styled(Button)`
font-size: inherit; /* Inherit font size from MenuHeader */
font-weight: ${p => p.theme.font.weight.sans.regular};
color: ${p => p.theme.tokens.content.secondary};
diff --git a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx
index d308eea86c3b..ab1dc14f95dd 100644
--- a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx
+++ b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx
@@ -48,10 +48,18 @@ export function AggregateDropdown({traceMetric}: {traceMetric: TraceMetric}) {
}
const selectedList = [...selectedNames].filter(Boolean);
+ const defaultValue = DEFAULT_YAXIS_BY_TYPE[traceMetric.type];
+ const isDefaultSelection =
+ selectedList.length === 1 && selectedList[0] === defaultValue;
return (
handleChange([])} />
+ }
style={{width: '100%'}}
trigger={triggerProps => (
>) => handleChange(opts)}
+ onChange={handleChange}
/>
);
}
@@ -100,10 +108,10 @@ export function AggregateDropdown({traceMetric}: {traceMetric: TraceMetric}) {
return (
) => handleChange([opt])}
+ value={activeValues[0]}
+ onChange={opt => handleChange([opt])}
/>
);
})}
From a09a4b12fe3af906757a19d45d12a27c80ddef00 Mon Sep 17 00:00:00 2001
From: Charlie Luo
Date: Tue, 31 Mar 2026 13:52:07 -0400
Subject: [PATCH 19/51] chore(grouping): Remove unused seer backfill options
(#111896)
Remove 9 options that were deleted in
https://github.com/getsentry/sentry-options-automator/pull/7037. These
controlled seer similarity backfill tuning and are no longer needed.
Co-authored-by: Claude
---
src/sentry/options/defaults.py | 45 ----------------------------------
1 file changed, 45 deletions(-)
diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py
index f9c468f901d7..4de89276d3fc 100644
--- a/src/sentry/options/defaults.py
+++ b/src/sentry/options/defaults.py
@@ -1321,14 +1321,6 @@
flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE,
)
-# seer embeddings backfill batch size
-register(
- "embeddings-grouping.seer.backfill-batch-size",
- type=Int,
- default=10,
- flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE,
-)
-
register(
"embeddings-grouping.seer.delete-record-batch-size",
type=Int,
@@ -3427,48 +3419,11 @@
flags=FLAG_AUTOMATOR_MODIFIABLE,
)
-register(
- "similarity.backfill_nodestore_use_multithread",
- default=False,
- flags=FLAG_AUTOMATOR_MODIFIABLE,
-)
-
-register(
- "similarity.backfill_nodestore_chunk_size",
- default=5,
- flags=FLAG_AUTOMATOR_MODIFIABLE,
-)
-
-register(
- "similarity.backfill_nodestore_threads",
- default=6,
- flags=FLAG_AUTOMATOR_MODIFIABLE,
-)
-register(
- "similarity.backfill_snuba_concurrent_requests",
- default=20,
- flags=FLAG_AUTOMATOR_MODIFIABLE,
-)
-register(
- "similarity.backfill_seer_chunk_size",
- default=30,
- flags=FLAG_AUTOMATOR_MODIFIABLE,
-)
-register(
- "similarity.backfill_seer_threads",
- default=1,
- flags=FLAG_AUTOMATOR_MODIFIABLE,
-)
register(
"similarity.backfill_project_cohort_size",
default=1000,
flags=FLAG_AUTOMATOR_MODIFIABLE,
)
-register(
- "similarity.backfill_total_worker_count",
- default=6,
- flags=FLAG_AUTOMATOR_MODIFIABLE,
-)
register(
"similarity.new_project_seer_grouping.enabled",
default=False,
From db61be2b2f4014c833d9ee2fbf80e30bd66f0885 Mon Sep 17 00:00:00 2001
From: Alex Sohn <44201357+alexsohn1126@users.noreply.github.com>
Date: Tue, 31 Mar 2026 13:58:00 -0400
Subject: [PATCH 20/51] feat(slack): Implement process_mention_for_slack task
for Explorer (#109733)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- Implement the full `process_mention_for_slack` task body (replacing
the previous TODO stub): fetch org, check explorer access, construct
`SlackExplorerEntrypoint`, resolve the Sentry user from Slack identity,
extract the user prompt, build thread context, and trigger an Explorer
run via `SeerExplorerOperator`
- Add `_resolve_user()` helper that maps a Slack user ID to an `RpcUser`
through the linked identity provider, `_send_link_identity_prompt()`
that sends an ephemeral Slack message prompting the user to link their
identity when no link exists, and `_send_not_org_member_message()` that
sends an ephemeral error when the user is not a member of the
organization
- Extract `bot_user_id` from the Slack `authorizations` payload in
`on_app_mention` so the task can strip the bot mention from the user's
prompt via `extract_prompt()`
- Set assistant thread status ("Thinking..." with loading messages)
immediately on mention for user feedback before dispatching the async
task; clear thread status on early exits (identity not linked, not an
org member)
- Fix `thread_ts` resolution in `on_app_mention` to correctly
distinguish `ts` (message timestamp) from `thread_ts` (parent thread
timestamp, None for top-level messages), and pass both to the task
- Move `return self.respond()` inside the lifecycle context manager so
halts/failures are properly recorded
- Simplify `SlackExplorerEntrypoint`: remove `message_ts` parameter
(caller now resolves `thread_ts`), make `thread_ts` required, raise
`EntrypointSetupError` instead of `ValueError`, and store
`self.integration` for use by identity-linking helpers
- Add Block Kit text extraction utilities (`_extract_block_text`,
`_extract_text_from_blocks`, `_extract_rich_text_element_text`) for
building thread context from rich_text, section, context, header, and
markdown blocks — with defensive `isinstance` checks for external Slack
API data
- Update `build_thread_context` to prefer block-based text extraction
over plain `text` fallback, preserving URLs and mentions from rich text
blocks
- Fix `SlackActionRequest.get_action_list` to use `.get("value", "")`
instead of `["value"]` to avoid `KeyError` on actions without a value
field
- Rename `MISSING_CHANNEL_OR_TEXT` to `MISSING_EVENT_DATA` in
`AppMentionHaltReason` to reflect broader validation
- Add `SlackEntrypointInteractionType.PROCESS_MENTION` and
`ProcessMentionHaltReason`/`ProcessMentionFailureReason` enums for
structured observability
- Change `SeerExplorerOperator.execute` to pass `summary=None` instead
of a hardcoded fallback string when no assistant content is found in
Explorer results
- Add `loading_messages` parameter to
`SlackIntegration.set_thread_status` for rotating status messages
- Add comprehensive tests for the task (`test_tasks.py`) covering happy
path, org-not-found, no-access, integration-not-found,
identity-not-linked, user-not-org-member, and thread-context scenarios;
add Block Kit extraction tests; update webhook and entrypoint tests
accordingly
Refs ISWF-2023
---
src/sentry/integrations/messaging/metrics.py | 2 +-
src/sentry/integrations/slack/integration.py | 6 +-
.../integrations/slack/webhooks/action.py | 2 +-
.../integrations/slack/webhooks/event.py | 73 ++++--
src/sentry/seer/entrypoints/metrics.py | 1 +
src/sentry/seer/entrypoints/operator.py | 2 +-
.../seer/entrypoints/slack/entrypoint.py | 19 +-
src/sentry/seer/entrypoints/slack/mention.py | 78 +++++-
src/sentry/seer/entrypoints/slack/metrics.py | 13 +
src/sentry/seer/entrypoints/slack/tasks.py | 214 +++++++++++++++-
.../slack/webhooks/events/test_app_mention.py | 64 ++++-
.../seer/entrypoints/slack/test_mention.py | 242 ++++++++++++++++++
.../seer/entrypoints/slack/test_slack.py | 23 +-
.../seer/entrypoints/slack/test_tasks.py | 204 +++++++++++++++
.../sentry/seer/entrypoints/test_operator.py | 4 +-
15 files changed, 877 insertions(+), 70 deletions(-)
create mode 100644 src/sentry/seer/entrypoints/slack/metrics.py
create mode 100644 tests/sentry/seer/entrypoints/slack/test_tasks.py
diff --git a/src/sentry/integrations/messaging/metrics.py b/src/sentry/integrations/messaging/metrics.py
index d0568446953a..b8bf47f8d87a 100644
--- a/src/sentry/integrations/messaging/metrics.py
+++ b/src/sentry/integrations/messaging/metrics.py
@@ -118,4 +118,4 @@ class AppMentionHaltReason(StrEnum):
ORGANIZATION_NOT_FOUND = "organization_not_found"
ORGANIZATION_NOT_ACTIVE = "organization_not_active"
FEATURE_NOT_ENABLED = "feature_not_enabled"
- MISSING_CHANNEL_OR_TEXT = "missing_channel_or_text"
+ MISSING_EVENT_DATA = "missing_event_data"
diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py
index 0533c404892d..81a1106abc23 100644
--- a/src/sentry/integrations/slack/integration.py
+++ b/src/sentry/integrations/slack/integration.py
@@ -3,7 +3,7 @@
import logging
from collections import namedtuple
from collections.abc import Mapping, Sequence
-from typing import Any
+from typing import Any, Optional
from django.utils.translation import gettext_lazy as _
from slack_sdk import WebClient
@@ -251,10 +251,13 @@ def set_thread_status(
channel_id: str,
thread_ts: str,
status: str,
+ loading_messages: Optional[list[str]] = None,
) -> None:
"""
Set a status indicator in a Slack assistant thread (e.g. "Thinking...").
The status auto-clears when the bot sends a reply, or after 2 minutes.
+
+ Sending an empty status message will clear the status indicator.
"""
client = self.get_client()
try:
@@ -262,6 +265,7 @@ def set_thread_status(
channel_id=channel_id,
thread_ts=thread_ts,
status=status,
+ loading_messages=loading_messages,
)
except SlackApiError:
_logger.warning(
diff --git a/src/sentry/integrations/slack/webhooks/action.py b/src/sentry/integrations/slack/webhooks/action.py
index 7c3090b1cb1b..259759504f51 100644
--- a/src/sentry/integrations/slack/webhooks/action.py
+++ b/src/sentry/integrations/slack/webhooks/action.py
@@ -660,7 +660,7 @@ def get_action_list(cls, slack_request: SlackActionRequest) -> list[BlockKitMess
name=action_name,
label=action_data["text"]["text"],
type=action_data["type"],
- value=action_data["value"],
+ value=action_data.get("value", ""),
action_id=action_data["action_id"],
block_id=action_data["block_id"],
)
diff --git a/src/sentry/integrations/slack/webhooks/event.py b/src/sentry/integrations/slack/webhooks/event.py
index f02495e44212..b6b347df46b8 100644
--- a/src/sentry/integrations/slack/webhooks/event.py
+++ b/src/sentry/integrations/slack/webhooks/event.py
@@ -7,6 +7,7 @@
import orjson
import sentry_sdk
+from rest_framework.exceptions import NotFound
from rest_framework.request import Request
from rest_framework.response import Response
from slack_sdk.errors import SlackApiError
@@ -23,6 +24,7 @@
)
from sentry.integrations.services.integration import integration_service
from sentry.integrations.slack.analytics import SlackIntegrationChartUnfurl
+from sentry.integrations.slack.integration import SlackIntegration
from sentry.integrations.slack.message_builder.help import SlackHelpMessageBuilder
from sentry.integrations.slack.message_builder.prompt import SlackPromptLinkMessageBuilder
from sentry.integrations.slack.requests.base import SlackDMRequest, SlackRequestError
@@ -334,47 +336,86 @@ def on_app_mention(self, slack_request: SlackDMRequest) -> Response:
organization_id = ois[0].organization_id
lifecycle.add_extra("organization_id", organization_id)
- organization_context = organization_service.get_organization_by_id(
- id=organization_id,
- user_id=None,
- include_projects=False,
- include_teams=False,
+ installation = slack_request.integration.get_installation(
+ organization_id=organization_id
)
- if not organization_context:
+ assert isinstance(installation, SlackIntegration)
+ try:
+ organization = installation.organization
+ except NotFound:
lifecycle.record_halt(AppMentionHaltReason.ORGANIZATION_NOT_FOUND)
return self.respond()
- if organization_context.organization.status != OrganizationStatus.ACTIVE:
- lifecycle.add_extra("status", organization_context.organization.status)
+ if organization.status != OrganizationStatus.ACTIVE:
+ lifecycle.add_extra("status", organization.status)
lifecycle.record_halt(AppMentionHaltReason.ORGANIZATION_NOT_ACTIVE)
return self.respond()
- if not features.has(
- "organizations:seer-slack-explorer", organization_context.organization
- ):
+ if not features.has("organizations:seer-slack-explorer", organization):
lifecycle.record_halt(AppMentionHaltReason.FEATURE_NOT_ENABLED)
return self.respond()
channel_id = data.get("channel")
text = data.get("text")
- thread_ts = data.get("ts")
+ ts = data.get("ts")
+ thread_ts = data.get("thread_ts") # None for top-level messages
- if not channel_id or not text or not thread_ts:
- lifecycle.record_halt(AppMentionHaltReason.MISSING_CHANNEL_OR_TEXT)
+ lifecycle.add_extras(
+ {
+ "channel_id": channel_id,
+ "text": text,
+ "ts": ts,
+ "thread_ts": thread_ts,
+ "user_id": slack_request.user_id,
+ }
+ )
+
+ if not channel_id or not text or not ts or not slack_request.user_id:
+ lifecycle.record_halt(AppMentionHaltReason.MISSING_EVENT_DATA)
return self.respond()
+ try:
+ installation.set_thread_status(
+ channel_id=channel_id,
+ thread_ts=thread_ts or ts,
+ status="Thinking...",
+ loading_messages=[
+ "Digging through your errors...",
+ "Sifting through stack traces...",
+ "Blaming the right code...",
+ "Following the breadcrumbs...",
+ "Asking the stack trace nicely...",
+ "Reading between the stack frames...",
+ "Hold on, I've seen this one before...",
+ "It worked on my machine...",
+ ],
+ )
+ except Exception:
+ _logger.exception(
+ "slack.assistant_threads_setStatus.failed",
+ extra={
+ "integration_id": slack_request.integration.id,
+ "channel_id": channel_id,
+ "thread_ts": thread_ts or ts,
+ },
+ )
+
+ authorizations = slack_request.data.get("authorizations") or []
+ bot_user_id = authorizations[0].get("user_id", "") if authorizations else ""
+
process_mention_for_slack.apply_async(
kwargs={
"integration_id": slack_request.integration.id,
"organization_id": organization_id,
"channel_id": channel_id,
+ "ts": ts,
"thread_ts": thread_ts,
"text": text,
"slack_user_id": slack_request.user_id,
+ "bot_user_id": bot_user_id,
}
)
-
- return self.respond()
+ return self.respond()
# TODO(dcramer): implement app_uninstalled and tokens_revoked
def post(self, request: Request) -> Response:
diff --git a/src/sentry/seer/entrypoints/metrics.py b/src/sentry/seer/entrypoints/metrics.py
index abe75ca3b8c3..f71d6a058ccb 100644
--- a/src/sentry/seer/entrypoints/metrics.py
+++ b/src/sentry/seer/entrypoints/metrics.py
@@ -49,6 +49,7 @@ class SlackEntrypointInteractionType(StrEnum):
PROCESS_THREAD_UPDATE = "process_thread_update"
SCHEDULE_ALL_THREAD_UPDATES = "schedule_all_thread_updates"
UPDATE_EXISTING_MESSAGE = "update_existing_message"
+ PROCESS_MENTION = "process_mention"
@dataclass
diff --git a/src/sentry/seer/entrypoints/operator.py b/src/sentry/seer/entrypoints/operator.py
index 0d1e3062633d..0bcf26d79213 100644
--- a/src/sentry/seer/entrypoints/operator.py
+++ b/src/sentry/seer/entrypoints/operator.py
@@ -785,7 +785,7 @@ def execute(cls, organization: Organization, run_id: int) -> None:
}
)
- summary = "Explorer result could not be fetched. Please try again."
+ summary: str | None = None
try:
state = fetch_run_status(run_id, organization)
for block in reversed(state.blocks):
diff --git a/src/sentry/seer/entrypoints/slack/entrypoint.py b/src/sentry/seer/entrypoints/slack/entrypoint.py
index 348cb08a8cc9..f51dabaa6746 100644
--- a/src/sentry/seer/entrypoints/slack/entrypoint.py
+++ b/src/sentry/seer/entrypoints/slack/entrypoint.py
@@ -42,6 +42,12 @@
from sentry.models.group import Group
+class EntrypointSetupError(Exception):
+ """Raised when entrypoint construction fails during mention processing."""
+
+ pass
+
+
class SlackThreadDetails(TypedDict):
thread_ts: str
channel_id: str
@@ -60,7 +66,6 @@ class SlackExplorerCachePayload(TypedDict):
organization_id: int
integration_id: int
thread: SlackThreadDetails
- message_ts: str
class SlackAutofixEntrypoint(
@@ -339,8 +344,7 @@ def __init__(
integration_id: int,
organization_id: int,
channel_id: str,
- message_ts: str,
- thread_ts: str | None,
+ thread_ts: str,
slack_user_id: str,
):
from sentry.integrations.services.integration import integration_service
@@ -354,7 +358,7 @@ def __init__(
status=ObjectStatus.ACTIVE,
)
if not integration:
- raise ValueError(f"Slack integration {integration_id} not found")
+ raise EntrypointSetupError(f"Slack integration {integration_id} not found")
ois = integration_service.get_organization_integrations(
integration_id=integration_id,
@@ -363,15 +367,15 @@ def __init__(
limit=1,
)
if not ois:
- raise ValueError(
+ raise EntrypointSetupError(
f"Slack integration {integration_id} is not active for org {organization_id}"
)
self.channel_id = channel_id
- self.message_ts = message_ts
- self.thread_ts = thread_ts or message_ts
+ self.thread_ts = thread_ts
self.thread = SlackThreadDetails(thread_ts=self.thread_ts, channel_id=channel_id)
self.organization_id = organization_id
+ self.integration = integration
self.install = SlackIntegration(model=integration, organization_id=organization_id)
self.slack_user_id = slack_user_id
@@ -399,7 +403,6 @@ def create_explorer_cache_payload(self) -> SlackExplorerCachePayload:
thread=self.thread,
organization_id=self.organization_id,
integration_id=self.install.model.id,
- message_ts=self.message_ts,
)
@staticmethod
diff --git a/src/sentry/seer/entrypoints/slack/mention.py b/src/sentry/seer/entrypoints/slack/mention.py
index 8531c2c5e8bf..0531283b14a2 100644
--- a/src/sentry/seer/entrypoints/slack/mention.py
+++ b/src/sentry/seer/entrypoints/slack/mention.py
@@ -48,6 +48,74 @@ def extract_issue_links(text: str) -> list[IssueLink]:
return results
+def _extract_rich_text_element_text(element: Mapping[str, Any]) -> str:
+ """Extract text from a single rich_text sub-element (text, link, user, channel, etc.)."""
+ elem_type = element.get("type")
+ if elem_type == "text":
+ return element.get("text", "")
+ if elem_type == "link":
+ url = element.get("url", "")
+ label = element.get("text", "")
+ return f"<{url}|{label}>" if label else f"<{url}>"
+ if elem_type == "user":
+ return f"<@{element.get('user_id', '')}>"
+ if elem_type == "channel":
+ return f"<#{element.get('channel_id', '')}>"
+ if elem_type == "emoji":
+ return f":{element.get('name', '')}:"
+ if elem_type == "broadcast":
+ return f"@{element.get('range', 'here')}"
+ return ""
+
+
+def _extract_block_text(block: Mapping[str, Any]) -> str:
+ """Extract readable text from a single Slack Block Kit block."""
+ block_type = block.get("type")
+
+ if block_type == "rich_text":
+ parts: list[str] = []
+ for container in block.get("elements", []):
+ if not isinstance(container, dict):
+ continue
+ container_parts = [
+ _extract_rich_text_element_text(el) for el in container.get("elements", [])
+ ]
+ parts.append("".join(container_parts))
+ return "\n".join(parts)
+
+ if block_type == "section":
+ section_parts: list[str] = []
+ text_obj = block.get("text")
+ if isinstance(text_obj, dict):
+ section_parts.append(text_obj.get("text", ""))
+ for field in block.get("fields", []):
+ if isinstance(field, dict):
+ section_parts.append(field.get("text", ""))
+ return "\n".join(part for part in section_parts if part)
+
+ if block_type == "context":
+ return " ".join(
+ el.get("text", "")
+ for el in block.get("elements", [])
+ if isinstance(el, dict) and el.get("type") in ("mrkdwn", "plain_text")
+ )
+
+ if block_type in ("header", "markdown"):
+ text_obj = block.get("text")
+ if isinstance(text_obj, dict):
+ return text_obj.get("text", "")
+ if isinstance(text_obj, str):
+ return text_obj
+
+ return ""
+
+
+def _extract_text_from_blocks(blocks: Sequence[Mapping[str, Any]]) -> str:
+ """Extract readable text from a list of Slack Block Kit blocks."""
+ parts = [_extract_block_text(block) for block in blocks]
+ return "\n".join(part for part in parts if part)
+
+
def build_thread_context(messages: Sequence[Mapping[str, Any]]) -> str:
"""Build a context string from thread history for Seer Explorer."""
if not messages:
@@ -56,7 +124,15 @@ def build_thread_context(messages: Sequence[Mapping[str, Any]]) -> str:
parts: list[str] = []
for msg in messages:
user = msg.get("user", "unknown")
- text = msg.get("text", "")
+
+ text = ""
+ blocks = msg.get("blocks")
+ if blocks:
+ text = _extract_text_from_blocks(blocks)
+
+ if not text:
+ text = msg.get("text", "")
+
if not text:
continue
parts.append(f"<@{user}>: {text}")
diff --git a/src/sentry/seer/entrypoints/slack/metrics.py b/src/sentry/seer/entrypoints/slack/metrics.py
new file mode 100644
index 000000000000..464306f4bc11
--- /dev/null
+++ b/src/sentry/seer/entrypoints/slack/metrics.py
@@ -0,0 +1,13 @@
+from __future__ import annotations
+
+from enum import StrEnum
+
+
+class ProcessMentionHaltReason(StrEnum):
+ IDENTITY_NOT_LINKED = "identity_not_linked"
+ USER_NOT_ORG_MEMBER = "user_not_org_member"
+
+
+class ProcessMentionFailureReason(StrEnum):
+ ORG_NOT_FOUND = "org_not_found"
+ NO_EXPLORER_ACCESS = "no_explorer_access"
diff --git a/src/sentry/seer/entrypoints/slack/tasks.py b/src/sentry/seer/entrypoints/slack/tasks.py
index 28af4faa1033..9151568e9fc6 100644
--- a/src/sentry/seer/entrypoints/slack/tasks.py
+++ b/src/sentry/seer/entrypoints/slack/tasks.py
@@ -2,10 +2,29 @@
import logging
+from slack_sdk.models.blocks import ActionsBlock, ButtonElement, LinkButtonElement, MarkdownBlock
from taskbroker_client.retry import Retry
+from sentry.identity.services.identity import identity_service
+from sentry.integrations.slack.views.link_identity import build_linking_url
+from sentry.integrations.types import IntegrationProviderSlug
+from sentry.models.organization import Organization
+from sentry.notifications.platform.slack.provider import SlackRenderable
+from sentry.seer.entrypoints.metrics import (
+ SlackEntrypointEventLifecycleMetric,
+ SlackEntrypointInteractionType,
+)
+from sentry.seer.entrypoints.operator import SeerExplorerOperator
+from sentry.seer.entrypoints.slack.entrypoint import EntrypointSetupError, SlackExplorerEntrypoint
+from sentry.seer.entrypoints.slack.mention import build_thread_context, extract_prompt
+from sentry.seer.entrypoints.slack.metrics import (
+ ProcessMentionFailureReason,
+ ProcessMentionHaltReason,
+)
from sentry.tasks.base import instrumented_task
from sentry.taskworker.namespaces import integrations_tasks
+from sentry.users.services.user import RpcUser
+from sentry.users.services.user.service import user_service
logger = logging.getLogger(__name__)
@@ -21,25 +40,194 @@ def process_mention_for_slack(
integration_id: int,
organization_id: int,
channel_id: str,
- thread_ts: str,
+ ts: str,
+ thread_ts: str | None = None,
text: str,
slack_user_id: str,
+ bot_user_id: str,
) -> None:
"""
Process a Slack @mention for Seer Explorer.
- Parses the mention, extracts issue links and thread context,
- and triggers an Explorer run.
+ Parses the mention, extracts thread context,
+ and triggers an Explorer run via SeerExplorerOperator.
- TODO(ISWF-2023): Implement full processing logic.
+ ``ts`` is the message's own timestamp (always present).
+ ``thread_ts`` is the parent thread's timestamp (None for top-level messages).
+
+ Authorization: Access is gated by the org-level ``seer-slack-workflows``
+ feature flag and ``has_explorer_access()``. The incoming webhook is
+ verified by ``SlackDMRequest.validate()``. The Slack user must have a
+ linked Sentry identity; if not, an ephemeral prompt to link is sent.
"""
- logger.info(
- "seer.explorer.slack.process_mention",
- extra={
- "integration_id": integration_id,
- "organization_id": organization_id,
- "channel_id": channel_id,
- "thread_ts": thread_ts,
- "slack_user_id": slack_user_id,
- },
+
+ with SlackEntrypointEventLifecycleMetric(
+ interaction_type=SlackEntrypointInteractionType.PROCESS_MENTION,
+ integration_id=integration_id,
+ organization_id=organization_id,
+ ).capture() as lifecycle:
+ lifecycle.add_extras(
+ {
+ "channel_id": channel_id,
+ "ts": ts,
+ "thread_ts": thread_ts,
+ "slack_user_id": slack_user_id,
+ },
+ )
+
+ try:
+ organization = Organization.objects.get_from_cache(id=organization_id)
+ except Organization.DoesNotExist:
+ lifecycle.record_failure(failure_reason=ProcessMentionFailureReason.ORG_NOT_FOUND)
+ return
+
+ if not SlackExplorerEntrypoint.has_access(organization):
+ lifecycle.record_failure(failure_reason=ProcessMentionFailureReason.NO_EXPLORER_ACCESS)
+ return
+
+ try:
+ entrypoint = SlackExplorerEntrypoint(
+ integration_id=integration_id,
+ organization_id=organization_id,
+ channel_id=channel_id,
+ thread_ts=thread_ts or ts,
+ slack_user_id=slack_user_id,
+ )
+ except (ValueError, EntrypointSetupError) as e:
+ lifecycle.record_failure(failure_reason=e)
+ return
+
+ user = _resolve_user(
+ integration_external_id=entrypoint.integration.external_id,
+ slack_user_id=slack_user_id,
+ )
+ if not user:
+ lifecycle.record_halt(ProcessMentionHaltReason.IDENTITY_NOT_LINKED)
+ # In a thread, show the prompt in the thread; top-level, show in the channel.
+ _send_link_identity_prompt(
+ entrypoint=entrypoint,
+ thread_ts=thread_ts if thread_ts else "",
+ )
+ entrypoint.install.set_thread_status(
+ channel_id=channel_id,
+ thread_ts=thread_ts or ts,
+ status="",
+ )
+ return
+
+ if not organization.has_access(user):
+ lifecycle.record_halt(ProcessMentionHaltReason.USER_NOT_ORG_MEMBER)
+ _send_not_org_member_message(
+ entrypoint=entrypoint,
+ thread_ts=thread_ts if thread_ts else "",
+ org_name=organization.name,
+ )
+ entrypoint.install.set_thread_status(
+ channel_id=channel_id,
+ thread_ts=thread_ts or ts,
+ status="",
+ )
+ return
+
+ prompt = extract_prompt(text, bot_user_id)
+
+ # Only fetch thread context when actually in a thread.
+ thread_context: str | None = None
+ if thread_ts:
+ messages = entrypoint.install.get_thread_history(
+ channel_id=channel_id, thread_ts=thread_ts
+ )
+ thread_context = build_thread_context(messages) or None
+
+ operator = SeerExplorerOperator(entrypoint=entrypoint)
+ operator.trigger_explorer(
+ organization=organization,
+ user=user,
+ prompt=prompt,
+ on_page_context=thread_context,
+ category_key="slack_thread",
+ category_value=f"{channel_id}:{entrypoint.thread_ts}",
+ )
+
+
+def _resolve_user(
+ *,
+ integration_external_id: str,
+ slack_user_id: str,
+) -> RpcUser | None:
+ """Resolve the Sentry user from a Slack user ID via linked identity."""
+ provider = identity_service.get_provider(
+ provider_type=IntegrationProviderSlug.SLACK.value,
+ provider_ext_id=integration_external_id,
+ )
+ if not provider:
+ return None
+
+ identity = identity_service.get_identity(
+ filter={
+ "provider_id": provider.id,
+ "identity_ext_id": slack_user_id,
+ }
+ )
+ if not identity:
+ return None
+
+ return user_service.get_user(identity.user_id)
+
+
+def _send_link_identity_prompt(
+ *,
+ entrypoint: SlackExplorerEntrypoint,
+ thread_ts: str,
+) -> None:
+ """Send an ephemeral message prompting the user to link their Slack identity to Sentry."""
+ associate_url = build_linking_url(
+ integration=entrypoint.integration,
+ slack_id=entrypoint.slack_user_id,
+ channel_id=entrypoint.channel_id,
+ response_url=None,
+ )
+ renderable = _build_link_identity_renderable(associate_url)
+ entrypoint.install.send_threaded_ephemeral_message(
+ slack_user_id=entrypoint.slack_user_id,
+ channel_id=entrypoint.channel_id,
+ renderable=renderable,
+ thread_ts=thread_ts,
+ )
+
+
+def _build_link_identity_renderable(associate_url: str) -> SlackRenderable:
+ """Build a SlackRenderable prompting the user to link their Slack identity to Sentry."""
+ message = "Link your Slack identity to Sentry to use Seer Explorer in Slack."
+ return SlackRenderable(
+ blocks=[
+ MarkdownBlock(text=message),
+ ActionsBlock(
+ elements=[
+ LinkButtonElement(text="Link", url=associate_url),
+ ButtonElement(text="Cancel", value="ignore"),
+ ]
+ ),
+ ],
+ text=message,
+ )
+
+
+def _send_not_org_member_message(
+ *,
+ entrypoint: SlackExplorerEntrypoint,
+ thread_ts: str,
+ org_name: str,
+) -> None:
+ """Send an ephemeral message informing the user they are not a member of the organization."""
+ message = f"You must be a member of the *{org_name}* Sentry organization to use Seer Explorer in Slack."
+ renderable = SlackRenderable(
+ blocks=[MarkdownBlock(text=message)],
+ text=message,
+ )
+ entrypoint.install.send_threaded_ephemeral_message(
+ slack_user_id=entrypoint.slack_user_id,
+ channel_id=entrypoint.channel_id,
+ renderable=renderable,
+ thread_ts=thread_ts,
)
diff --git a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py
index 751a974fc48f..a04cdc769cea 100644
--- a/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py
+++ b/tests/sentry/integrations/slack/webhooks/events/test_app_mention.py
@@ -1,5 +1,8 @@
from unittest.mock import patch
+from sentry.integrations.messaging.metrics import AppMentionHaltReason
+from sentry.testutils.asserts import assert_halt_metric
+
from . import BaseEventTest
APP_MENTION_EVENT = {
@@ -11,6 +14,10 @@
"event_ts": "1234567890.123456",
}
+AUTHORIZATIONS_DATA = {
+ "authorizations": [{"user_id": "U0BOT", "is_bot": True}],
+}
+
THREADED_APP_MENTION_EVENT = {
**APP_MENTION_EVENT,
"thread_ts": "1234567890.000001",
@@ -21,7 +28,9 @@ class AppMentionEventTest(BaseEventTest):
@patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async")
def test_app_mention_dispatches_task(self, mock_apply_async):
with self.feature("organizations:seer-slack-explorer"):
- resp = self.post_webhook(event_data=THREADED_APP_MENTION_EVENT)
+ resp = self.post_webhook(
+ event_data=THREADED_APP_MENTION_EVENT, data=AUTHORIZATIONS_DATA
+ )
assert resp.status_code == 200
mock_apply_async.assert_called_once()
@@ -29,29 +38,57 @@ def test_app_mention_dispatches_task(self, mock_apply_async):
assert kwargs["integration_id"] == self.integration.id
assert kwargs["organization_id"] == self.organization.id
assert kwargs["channel_id"] == "C1234567890"
- assert kwargs["thread_ts"] == "1234567890.123456"
+ assert kwargs["ts"] == "1234567890.123456"
+ assert kwargs["thread_ts"] == "1234567890.000001"
assert kwargs["text"] == THREADED_APP_MENTION_EVENT["text"]
assert kwargs["slack_user_id"] == "U1234567890"
- assert "message_ts" not in kwargs
+ assert kwargs["bot_user_id"] == "U0BOT"
+
+ @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async")
+ def test_app_mention_dispatches_task_no_authorizations(self, mock_apply_async):
+ with self.feature("organizations:seer-slack-explorer"):
+ resp = self.post_webhook(event_data=THREADED_APP_MENTION_EVENT)
+
+ assert resp.status_code == 200
+ mock_apply_async.assert_called_once()
+ kwargs = mock_apply_async.call_args[1]["kwargs"]
+ assert kwargs["bot_user_id"] == ""
+
+ @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async")
+ def test_app_mention_non_threaded_dispatches_task(self, mock_apply_async):
+ """Non-threaded mentions dispatch with ts set and thread_ts as None."""
+ with self.feature("organizations:seer-slack-explorer"):
+ resp = self.post_webhook(event_data=APP_MENTION_EVENT)
+ assert resp.status_code == 200
+ mock_apply_async.assert_called_once()
+ kwargs = mock_apply_async.call_args[1]["kwargs"]
+ assert kwargs["ts"] == APP_MENTION_EVENT["ts"]
+ assert kwargs["thread_ts"] is None
+
+ @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
@patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async")
- def test_app_mention_feature_flag_disabled(self, mock_apply_async):
+ def test_app_mention_feature_flag_disabled(self, mock_apply_async, mock_record):
resp = self.post_webhook(event_data=APP_MENTION_EVENT)
assert resp.status_code == 200
mock_apply_async.assert_not_called()
+ assert_halt_metric(mock_record, AppMentionHaltReason.FEATURE_NOT_ENABLED)
+ @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
@patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async")
- def test_app_mention_empty_text(self, mock_apply_async):
+ def test_app_mention_empty_text(self, mock_apply_async, mock_record):
event_data = {**APP_MENTION_EVENT, "text": ""}
with self.feature("organizations:seer-slack-explorer"):
resp = self.post_webhook(event_data=event_data)
assert resp.status_code == 200
mock_apply_async.assert_not_called()
+ assert_halt_metric(mock_record, AppMentionHaltReason.MISSING_EVENT_DATA)
+ @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
@patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async")
- def test_app_mention_no_organization(self, mock_apply_async):
+ def test_app_mention_no_organization(self, mock_apply_async, mock_record):
"""When the integration has no org integrations, we should not dispatch."""
with patch(
"sentry.integrations.slack.webhooks.event.integration_service.get_organization_integrations",
@@ -62,3 +99,18 @@ def test_app_mention_no_organization(self, mock_apply_async):
assert resp.status_code == 200
mock_apply_async.assert_not_called()
+ assert_halt_metric(mock_record, AppMentionHaltReason.NO_ORGANIZATION)
+
+ @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
+ @patch("sentry.seer.entrypoints.slack.tasks.process_mention_for_slack.apply_async")
+ def test_app_mention_org_not_found(self, mock_apply_async, mock_record):
+ with patch(
+ "sentry.organizations.services.organization.impl.DatabaseBackedOrganizationService.get",
+ return_value=None,
+ ):
+ with self.feature("organizations:seer-slack-explorer"):
+ resp = self.post_webhook(event_data=APP_MENTION_EVENT)
+
+ assert resp.status_code == 200
+ mock_apply_async.assert_not_called()
+ assert_halt_metric(mock_record, AppMentionHaltReason.ORGANIZATION_NOT_FOUND)
diff --git a/tests/sentry/seer/entrypoints/slack/test_mention.py b/tests/sentry/seer/entrypoints/slack/test_mention.py
index 65633912ab3e..cae4dbb94282 100644
--- a/tests/sentry/seer/entrypoints/slack/test_mention.py
+++ b/tests/sentry/seer/entrypoints/slack/test_mention.py
@@ -1,5 +1,20 @@
+from collections.abc import Mapping
+from typing import Any
+
+from slack_sdk.models.blocks import (
+ ContextBlock,
+ DividerBlock,
+ HeaderBlock,
+ MarkdownBlock,
+ RichTextBlock,
+ SectionBlock,
+)
+from slack_sdk.models.blocks.block_elements import RichTextElementParts, RichTextSectionElement
+
from sentry.seer.entrypoints.slack.mention import (
IssueLink,
+ _extract_block_text,
+ _extract_text_from_blocks,
build_thread_context,
extract_issue_links,
extract_prompt,
@@ -98,6 +113,140 @@ def test_ignores_metric_alert_urls(self) -> None:
assert extract_issue_links(text) == []
+class ExtractBlockTextTest(TestCase):
+ def test_section_block(self):
+ block = SectionBlock(text="hello *world*").to_dict()
+ assert _extract_block_text(block) == "hello *world*"
+
+ def test_section_block_with_fields(self):
+ block = SectionBlock(
+ fields=[
+ {"type": "mrkdwn", "text": "*Priority*"},
+ {"type": "plain_text", "text": "High"},
+ ]
+ ).to_dict()
+ result = _extract_block_text(block)
+ assert "*Priority*" in result
+ assert "High" in result
+
+ def test_context_block(self):
+ block = ContextBlock(
+ elements=[{"type": "mrkdwn", "text": "Project: test-project"}]
+ ).to_dict()
+ assert _extract_block_text(block) == "Project: test-project"
+
+ def test_header_block(self):
+ block = HeaderBlock(text="My Header").to_dict()
+ assert _extract_block_text(block) == "My Header"
+
+ def test_markdown_block(self):
+ block = MarkdownBlock(text="markdown content").to_dict()
+ assert _extract_block_text(block) == "markdown content"
+
+ def test_rich_text_with_text_and_link(self):
+ block = RichTextBlock(
+ elements=[
+ RichTextSectionElement(
+ elements=[
+ RichTextElementParts.Text(text="Check "),
+ RichTextElementParts.Link(
+ url="https://sentry.io/organizations/test-org/issues/123/",
+ text="ISSUE-123",
+ ),
+ ]
+ )
+ ]
+ ).to_dict()
+ result = _extract_block_text(block)
+ assert result == "Check "
+
+ def test_rich_text_link_without_label(self):
+ block = RichTextBlock(
+ elements=[
+ RichTextSectionElement(
+ elements=[
+ RichTextElementParts.Link(url="https://example.com"),
+ ]
+ )
+ ]
+ ).to_dict()
+ assert _extract_block_text(block) == ""
+
+ def test_rich_text_with_user_and_channel(self):
+ block = RichTextBlock(
+ elements=[
+ RichTextSectionElement(
+ elements=[
+ RichTextElementParts.Text(text="Hey "),
+ RichTextElementParts.User(user_id="U123"),
+ RichTextElementParts.Text(text=" check "),
+ RichTextElementParts.Channel(channel_id="C456"),
+ ]
+ )
+ ]
+ ).to_dict()
+ assert _extract_block_text(block) == "Hey <@U123> check <#C456>"
+
+ def test_rich_text_with_emoji(self):
+ block = RichTextBlock(
+ elements=[
+ RichTextSectionElement(
+ elements=[
+ RichTextElementParts.Emoji(name="wave"),
+ RichTextElementParts.Text(text=" hello"),
+ ]
+ )
+ ]
+ ).to_dict()
+ assert _extract_block_text(block) == ":wave: hello"
+
+ def test_divider_returns_empty(self):
+ assert _extract_block_text(DividerBlock().to_dict()) == ""
+
+ def test_actions_block_returns_empty(self):
+ block = {
+ "type": "actions",
+ "elements": [{"type": "button", "text": {"type": "plain_text", "text": "Click"}}],
+ }
+ assert _extract_block_text(block) == ""
+
+
+class ExtractTextFromBlocksTest(TestCase):
+ def test_multiple_blocks(self):
+ blocks = [
+ HeaderBlock(text="Alert Title").to_dict(),
+ SectionBlock(text="Something broke").to_dict(),
+ DividerBlock().to_dict(),
+ ContextBlock(elements=[{"type": "mrkdwn", "text": "Project: my-project"}]).to_dict(),
+ ]
+ result = _extract_text_from_blocks(blocks)
+ assert result == "Alert Title\nSomething broke\nProject: my-project"
+
+ def test_sentry_alert_like_blocks(self):
+ blocks = [
+ SectionBlock(
+ text=":red_circle: "
+ ).to_dict(),
+ ContextBlock(
+ elements=[{"type": "mrkdwn", "text": "my_module.views in handle_request"}]
+ ).to_dict(),
+ SectionBlock(text="```invalid literal for int()```").to_dict(),
+ ContextBlock(
+ elements=[
+ {
+ "type": "mrkdwn",
+ "text": "Project: Alert: My Alert Short ID: BACKEND-123",
+ }
+ ]
+ ).to_dict(),
+ ]
+ result = _extract_text_from_blocks(blocks)
+ assert "https://sentry.io/organizations/test-org/issues/456/" in result
+ assert "ValueError: invalid input" in result
+ assert "invalid literal for int()" in result
+ assert "Project:" in result
+
+
class BuildThreadContextTest(TestCase):
def test_single_message(self) -> None:
messages = [{"user": "U123", "text": "hello world", "ts": "1234567890.000001"}]
@@ -139,3 +288,96 @@ def test_preserves_urls_in_text(self) -> None:
]
result = build_thread_context(messages)
assert "" in result
+
+ def test_prefers_blocks_over_text(self):
+ messages = [
+ {
+ "user": "U123",
+ "text": "fallback text",
+ "blocks": [SectionBlock(text="rich block content").to_dict()],
+ "ts": "1234567890.000001",
+ }
+ ]
+ result = build_thread_context(messages)
+ assert result == "<@U123>: rich block content"
+ assert "fallback text" not in result
+
+ def test_falls_back_to_text_when_blocks_empty(self):
+ messages = [
+ {
+ "user": "U123",
+ "text": "fallback text",
+ "blocks": [],
+ "ts": "1234567890.000001",
+ }
+ ]
+ result = build_thread_context(messages)
+ assert result == "<@U123>: fallback text"
+
+ def test_falls_back_to_text_when_blocks_have_no_text(self):
+ messages = [
+ {
+ "user": "U123",
+ "text": "fallback text",
+ "blocks": [DividerBlock().to_dict()],
+ "ts": "1234567890.000001",
+ }
+ ]
+ result = build_thread_context(messages)
+ assert result == "<@U123>: fallback text"
+
+ def test_extracts_links_from_rich_text_blocks(self):
+ messages = [
+ {
+ "user": "U123",
+ "text": "fallback with no links",
+ "blocks": [
+ RichTextBlock(
+ elements=[
+ RichTextSectionElement(
+ elements=[
+ RichTextElementParts.Text(text="Check "),
+ RichTextElementParts.Link(
+ url="https://sentry.io/organizations/test-org/issues/999/",
+ text="this issue",
+ ),
+ ]
+ )
+ ]
+ ).to_dict()
+ ],
+ "ts": "1234567890.000001",
+ }
+ ]
+ result = build_thread_context(messages)
+ assert "https://sentry.io/organizations/test-org/issues/999/" in result
+ assert "this issue" in result
+
+ def test_mixed_block_and_text_messages(self):
+ messages: list[Mapping[str, Any]] = [
+ {
+ "user": "U123",
+ "text": "alert fallback",
+ "blocks": [SectionBlock(text="alert from blocks").to_dict()],
+ "ts": "1234567890.000001",
+ },
+ {
+ "user": "U456",
+ "text": "plain text reply",
+ "ts": "1234567890.000002",
+ },
+ ]
+ result = build_thread_context(messages)
+ assert result == "<@U123>: alert from blocks\n<@U456>: plain text reply"
+
+ def test_markdown_block_in_seer_response(self):
+ messages = [
+ {
+ "user": "UBOT",
+ "text": "fallback",
+ "blocks": [MarkdownBlock(text="Here is the Seer analysis...").to_dict()],
+ "ts": "1234567890.000001",
+ }
+ ]
+ result = build_thread_context(messages)
+ assert result == "<@UBOT>: Here is the Seer analysis..."
diff --git a/tests/sentry/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py
index 97179fea76a9..3df290364832 100644
--- a/tests/sentry/seer/entrypoints/slack/test_slack.py
+++ b/tests/sentry/seer/entrypoints/slack/test_slack.py
@@ -10,6 +10,7 @@
from sentry.notifications.utils.actions import BlockKitMessageAction
from sentry.seer.autofix.utils import AutofixStoppingPoint
from sentry.seer.entrypoints.slack.entrypoint import (
+ EntrypointSetupError,
SlackAutofixCachePayload,
SlackAutofixEntrypoint,
SlackExplorerCachePayload,
@@ -489,7 +490,6 @@ class SlackExplorerEntrypointTest(TestCase):
def setUp(self) -> None:
self.slack_user_id = "UXXXXXXXXX2"
self.channel_id = "CXXXXXXXXX2"
- self.message_ts = "1712345678.111111"
self.thread_ts = "1712345678.222222"
self.integration = self.create_integration(
organization=self.organization,
@@ -502,7 +502,6 @@ def _get_entrypoint(self, thread_ts: str | None = None) -> SlackExplorerEntrypoi
integration_id=self.integration.id,
organization_id=self.organization.id,
channel_id=self.channel_id,
- message_ts=self.message_ts,
thread_ts=thread_ts if thread_ts is not None else self.thread_ts,
slack_user_id=self.slack_user_id,
)
@@ -510,32 +509,18 @@ def _get_entrypoint(self, thread_ts: str | None = None) -> SlackExplorerEntrypoi
def test_init_success(self) -> None:
ep = self._get_entrypoint()
assert ep.channel_id == self.channel_id
- assert ep.message_ts == self.message_ts
assert ep.thread_ts == self.thread_ts
assert ep.organization_id == self.organization.id
assert ep.slack_user_id == self.slack_user_id
assert ep.install.model.id == self.integration.id
assert ep.thread == SlackThreadDetails(thread_ts=self.thread_ts, channel_id=self.channel_id)
- def test_init_defaults_thread_ts_to_message_ts_when_none(self) -> None:
- ep = SlackExplorerEntrypoint(
- integration_id=self.integration.id,
- organization_id=self.organization.id,
- channel_id=self.channel_id,
- message_ts=self.message_ts,
- thread_ts=None,
- slack_user_id=self.slack_user_id,
- )
- assert ep.thread_ts == self.message_ts
- assert ep.thread["thread_ts"] == self.message_ts
-
def test_init_raises_if_integration_not_found(self) -> None:
- with pytest.raises(ValueError):
+ with pytest.raises(EntrypointSetupError):
SlackExplorerEntrypoint(
integration_id=99999,
organization_id=self.organization.id,
channel_id=self.channel_id,
- message_ts=self.message_ts,
thread_ts=self.thread_ts,
slack_user_id=self.slack_user_id,
)
@@ -545,12 +530,11 @@ def test_init_raises_if_integration_not_found(self) -> None:
return_value=[],
)
def test_init_raises_if_no_org_integration(self, mock_get_ois):
- with pytest.raises(ValueError):
+ with pytest.raises(EntrypointSetupError):
SlackExplorerEntrypoint(
integration_id=self.integration.id,
organization_id=self.organization.id,
channel_id=self.channel_id,
- message_ts=self.message_ts,
thread_ts=self.thread_ts,
slack_user_id=self.slack_user_id,
)
@@ -591,7 +575,6 @@ def test_create_explorer_cache_payload(self) -> None:
SlackExplorerCachePayload(**payload) # validates TypedDict structure
assert payload["organization_id"] == self.organization.id
assert payload["integration_id"] == self.integration.id
- assert payload["message_ts"] == self.message_ts
assert payload["thread"]["thread_ts"] == self.thread_ts
assert payload["thread"]["channel_id"] == self.channel_id
diff --git a/tests/sentry/seer/entrypoints/slack/test_tasks.py b/tests/sentry/seer/entrypoints/slack/test_tasks.py
new file mode 100644
index 000000000000..edd0a0b070d8
--- /dev/null
+++ b/tests/sentry/seer/entrypoints/slack/test_tasks.py
@@ -0,0 +1,204 @@
+from unittest.mock import MagicMock, patch
+
+from sentry.seer.entrypoints.slack.entrypoint import EntrypointSetupError
+from sentry.seer.entrypoints.slack.metrics import (
+ ProcessMentionFailureReason,
+ ProcessMentionHaltReason,
+)
+from sentry.seer.entrypoints.slack.tasks import process_mention_for_slack
+from sentry.testutils.asserts import assert_failure_metric, assert_halt_metric
+from sentry.testutils.cases import TestCase
+
+TASK_KWARGS = {
+ "integration_id": 123,
+ "channel_id": "C1234567890",
+ "ts": "1234567890.654321",
+ "thread_ts": "1234567890.123456",
+ "text": "<@U0BOT> What is causing this issue?",
+ "slack_user_id": "U1234567890",
+ "bot_user_id": "U0BOT",
+}
+
+
+class ProcessMentionForSlackTest(TestCase):
+ def _run_task(self, **overrides):
+ kwargs = {
+ **TASK_KWARGS,
+ "organization_id": self.organization.id,
+ **overrides,
+ }
+ process_mention_for_slack(**kwargs)
+
+ @patch("sentry.seer.entrypoints.slack.tasks.SeerExplorerOperator")
+ @patch("sentry.seer.entrypoints.slack.tasks.SlackExplorerEntrypoint")
+ @patch("sentry.seer.entrypoints.slack.tasks._resolve_user")
+ def test_happy_path(self, mock_resolve_user, mock_explorer_cls, mock_operator_cls):
+ mock_user = MagicMock(id=self.user.id)
+ mock_resolve_user.return_value = mock_user
+
+ mock_explorer_cls.has_access.return_value = True
+ mock_entrypoint = MagicMock()
+ mock_entrypoint.thread_ts = "1234567890.123456"
+ mock_explorer_cls.return_value = mock_entrypoint
+
+ mock_operator = MagicMock()
+ mock_operator_cls.return_value = mock_operator
+
+ self._run_task()
+
+ mock_operator.trigger_explorer.assert_called_once()
+ call_kwargs = mock_operator.trigger_explorer.call_args[1]
+ assert call_kwargs["organization"] == self.organization
+ assert call_kwargs["user"] is mock_user
+ assert call_kwargs["prompt"] == "What is causing this issue?"
+ assert call_kwargs["on_page_context"] is None
+ assert call_kwargs["category_key"] == "slack_thread"
+ assert call_kwargs["category_value"] == "C1234567890:1234567890.123456"
+
+ @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
+ @patch("sentry.seer.entrypoints.slack.tasks.SeerExplorerOperator")
+ @patch("sentry.seer.entrypoints.slack.tasks.SlackExplorerEntrypoint")
+ def test_org_not_found(self, mock_explorer_cls, mock_operator_cls, mock_record):
+ self._run_task(organization_id=999999999)
+
+ mock_explorer_cls.has_access.assert_not_called()
+ mock_explorer_cls.assert_not_called()
+ mock_operator_cls.assert_not_called()
+ assert_failure_metric(mock_record, ProcessMentionFailureReason.ORG_NOT_FOUND)
+
+ @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
+ @patch("sentry.seer.entrypoints.slack.tasks.SeerExplorerOperator")
+ @patch("sentry.seer.entrypoints.slack.tasks.SlackExplorerEntrypoint")
+ def test_no_explorer_access(self, mock_explorer_cls, mock_operator_cls, mock_record):
+ mock_explorer_cls.has_access.return_value = False
+
+ self._run_task()
+
+ mock_explorer_cls.assert_not_called()
+ mock_operator_cls.assert_not_called()
+ assert_failure_metric(mock_record, ProcessMentionFailureReason.NO_EXPLORER_ACCESS)
+
+ @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
+ @patch("sentry.seer.entrypoints.slack.tasks.SeerExplorerOperator")
+ @patch("sentry.seer.entrypoints.slack.tasks.SlackExplorerEntrypoint")
+ def test_integration_not_found(self, mock_explorer_cls, mock_operator_cls, mock_record):
+ mock_explorer_cls.has_access.return_value = True
+ mock_explorer_cls.side_effect = EntrypointSetupError("not found")
+
+ self._run_task()
+
+ mock_operator_cls.assert_not_called()
+ assert_failure_metric(mock_record, EntrypointSetupError("not found"))
+
+ @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
+ @patch("sentry.seer.entrypoints.slack.tasks._send_link_identity_prompt")
+ @patch("sentry.seer.entrypoints.slack.tasks.SeerExplorerOperator")
+ @patch("sentry.seer.entrypoints.slack.tasks.SlackExplorerEntrypoint")
+ @patch("sentry.seer.entrypoints.slack.tasks._resolve_user")
+ def test_identity_not_linked(
+ self,
+ mock_resolve_user,
+ mock_explorer_cls,
+ mock_operator_cls,
+ mock_send_link,
+ mock_record,
+ ):
+ mock_resolve_user.return_value = None
+
+ mock_explorer_cls.has_access.return_value = True
+ mock_entrypoint = MagicMock()
+ mock_entrypoint.thread_ts = "1234567890.123456"
+ mock_explorer_cls.return_value = mock_entrypoint
+
+ self._run_task()
+
+ mock_send_link.assert_called_once_with(
+ entrypoint=mock_entrypoint, thread_ts="1234567890.123456"
+ )
+ mock_operator_cls.assert_not_called()
+ assert_halt_metric(mock_record, ProcessMentionHaltReason.IDENTITY_NOT_LINKED)
+
+ @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
+ @patch("sentry.seer.entrypoints.slack.tasks._send_not_org_member_message")
+ @patch("sentry.seer.entrypoints.slack.tasks.SeerExplorerOperator")
+ @patch("sentry.seer.entrypoints.slack.tasks.SlackExplorerEntrypoint")
+ @patch("sentry.seer.entrypoints.slack.tasks._resolve_user")
+ def test_user_not_org_member(
+ self,
+ mock_resolve_user,
+ mock_explorer_cls,
+ mock_operator_cls,
+ mock_send_not_member,
+ mock_record,
+ ):
+ other_org = self.create_organization(name="Other Org")
+ mock_user = MagicMock(id=self.create_user().id)
+ mock_resolve_user.return_value = mock_user
+
+ mock_explorer_cls.has_access.return_value = True
+ mock_entrypoint = MagicMock()
+ mock_explorer_cls.return_value = mock_entrypoint
+
+ self._run_task(organization_id=other_org.id)
+
+ mock_operator_cls.assert_not_called()
+ mock_send_not_member.assert_called_once_with(
+ entrypoint=mock_entrypoint,
+ thread_ts="1234567890.123456",
+ org_name="Other Org",
+ )
+ mock_entrypoint.install.set_thread_status.assert_called_once_with(
+ channel_id="C1234567890",
+ thread_ts="1234567890.123456",
+ status="",
+ )
+ assert_halt_metric(mock_record, ProcessMentionHaltReason.USER_NOT_ORG_MEMBER)
+
+ @patch("sentry.seer.entrypoints.slack.tasks.SeerExplorerOperator")
+ @patch("sentry.seer.entrypoints.slack.tasks.SlackExplorerEntrypoint")
+ @patch("sentry.seer.entrypoints.slack.tasks._resolve_user")
+ def test_with_thread_context(self, mock_resolve_user, mock_explorer_cls, mock_operator_cls):
+ mock_resolve_user.return_value = MagicMock(id=self.user.id)
+
+ mock_explorer_cls.has_access.return_value = True
+ mock_entrypoint = MagicMock()
+ mock_entrypoint.thread_ts = "1234567890.000001"
+ mock_entrypoint.install.get_thread_history.return_value = [
+ {"user": "U111", "text": "help me debug this"},
+ {"user": "U222", "text": "sure, what's the error?"},
+ ]
+ mock_explorer_cls.return_value = mock_entrypoint
+
+ mock_operator = MagicMock()
+ mock_operator_cls.return_value = mock_operator
+
+ self._run_task(thread_ts="1234567890.000001")
+
+ mock_entrypoint.install.get_thread_history.assert_called_once_with(
+ channel_id="C1234567890",
+ thread_ts="1234567890.000001",
+ )
+ call_kwargs = mock_operator.trigger_explorer.call_args[1]
+ assert call_kwargs["on_page_context"] is not None
+ assert "<@U111>: help me debug this" in call_kwargs["on_page_context"]
+ assert "<@U222>: sure, what's the error?" in call_kwargs["on_page_context"]
+
+ @patch("sentry.seer.entrypoints.slack.tasks.SeerExplorerOperator")
+ @patch("sentry.seer.entrypoints.slack.tasks.SlackExplorerEntrypoint")
+ @patch("sentry.seer.entrypoints.slack.tasks._resolve_user")
+ def test_without_thread_context(self, mock_resolve_user, mock_explorer_cls, mock_operator_cls):
+ mock_resolve_user.return_value = MagicMock(id=self.user.id)
+
+ mock_explorer_cls.has_access.return_value = True
+ mock_entrypoint = MagicMock()
+ mock_entrypoint.thread_ts = "1234567890.123456"
+ mock_explorer_cls.return_value = mock_entrypoint
+
+ mock_operator = MagicMock()
+ mock_operator_cls.return_value = mock_operator
+
+ self._run_task(thread_ts=None)
+
+ mock_entrypoint.install.get_thread_history.assert_not_called()
+ call_kwargs = mock_operator.trigger_explorer.call_args[1]
+ assert call_kwargs["on_page_context"] is None
diff --git a/tests/sentry/seer/entrypoints/test_operator.py b/tests/sentry/seer/entrypoints/test_operator.py
index 61b03937c288..e69185b1e86a 100644
--- a/tests/sentry/seer/entrypoints/test_operator.py
+++ b/tests/sentry/seer/entrypoints/test_operator.py
@@ -741,7 +741,7 @@ def test_execute_uses_default_summary_when_no_assistant_content(self, mock_fetch
mock_entrypoint_cls.on_explorer_update.assert_called_once_with(
cache_payload={"thread_id": "abc", "organization_id": self.organization.id},
- summary="Explorer result could not be fetched. Please try again.",
+ summary=None,
run_id=MOCK_RUN_ID,
)
@@ -846,6 +846,6 @@ def test_execute_with_empty_blocks(self, mock_fetch):
mock_entrypoint_cls.on_explorer_update.assert_called_once_with(
cache_payload={"thread_id": "abc", "organization_id": self.organization.id},
- summary="Explorer result could not be fetched. Please try again.",
+ summary=None,
run_id=MOCK_RUN_ID,
)
From 8a8d0cf1df1b36e0c290e56875e3203e875249eb Mon Sep 17 00:00:00 2001
From: Malachi Willey
Date: Tue, 31 Mar 2026 11:02:27 -0700
Subject: [PATCH 21/51] ref(aci): Use Section component for form sections in
Alerts builder (#111832)
Using `Section` like we use in the detector form for consistency
---
.../automations/components/automationForm.tsx | 43 ++++++++-----------
1 file changed, 18 insertions(+), 25 deletions(-)
diff --git a/static/app/views/automations/components/automationForm.tsx b/static/app/views/automations/components/automationForm.tsx
index 7db57e6b38ed..7d3a175d0d29 100644
--- a/static/app/views/automations/components/automationForm.tsx
+++ b/static/app/views/automations/components/automationForm.tsx
@@ -1,12 +1,12 @@
import {useCallback} from 'react';
import {Flex} from '@sentry/scraps/layout';
-import {Heading, Text} from '@sentry/scraps/text';
import type {FormModel} from 'sentry/components/forms/model';
import {EnvironmentSelector} from 'sentry/components/workflowEngine/form/environmentSelector';
import {useFormField} from 'sentry/components/workflowEngine/form/useFormField';
import {Card} from 'sentry/components/workflowEngine/ui/card';
+import {Section} from 'sentry/components/workflowEngine/ui/section';
import {t} from 'sentry/locale';
import type {Automation} from 'sentry/types/workflowEngine/automations';
import {AutomationBuilder} from 'sentry/views/automations/components/automationBuilder';
@@ -32,34 +32,27 @@ export function AutomationForm({model}: {model: FormModel}) {
setConnectedIds={setConnectedIds}
/>
-
-
- {t('Choose Environment')}
-
-
- {t(
- 'If you select environments different than your monitors then the automation will not fire.'
- )}
-
-
-
+
-
- {t('Alert Builder')}
-
-
+
-
-
- {t('Action Interval')}
-
-
- {t('Perform the actions above this often for an issue.')}
-
-
-
+
);
From 506d7d95da53eb6dbf730b39341895ae890e0ccb Mon Sep 17 00:00:00 2001
From: Lyn Nagara <1779792+lynnagara@users.noreply.github.com>
Date: Tue, 31 Mar 2026 11:14:19 -0700
Subject: [PATCH 22/51] ref(cells): Update assert_webhook_payloads_for_mailbox
(#111856)
more region to cell renaming
---
src/sentry/testutils/outbox.py | 23 +++---
.../integrations/parsers/test_bitbucket.py | 30 +++----
.../parsers/test_bitbucket_server.py | 16 ++--
.../integrations/parsers/test_github.py | 80 +++++++++----------
.../parsers/test_github_enterprise.py | 20 ++---
.../integrations/parsers/test_gitlab.py | 34 ++++----
.../integrations/parsers/test_jira.py | 38 ++++-----
.../integrations/parsers/test_jira_server.py | 26 +++---
.../integrations/parsers/test_msteams.py | 8 +-
.../integrations/parsers/test_plugin.py | 6 +-
.../integrations/parsers/test_vsts.py | 8 +-
11 files changed, 146 insertions(+), 143 deletions(-)
diff --git a/src/sentry/testutils/outbox.py b/src/sentry/testutils/outbox.py
index 98da1a967b66..7f38bb13d2c8 100644
--- a/src/sentry/testutils/outbox.py
+++ b/src/sentry/testutils/outbox.py
@@ -64,7 +64,10 @@ def assert_no_webhook_payloads() -> None:
def assert_webhook_payloads_for_mailbox(
request: WSGIRequest,
mailbox_name: str,
- region_names: list[str],
+ # TODO(cells): make required once getsentry passes cell everywhere
+ cell_names: list[str] | None = None,
+ # TODO(cells): remove once getsentry passes cell everywhere
+ region_names: list[str] | None = None,
destination_types: dict[DestinationType, int] | None = None,
) -> None:
"""
@@ -73,16 +76,16 @@ def assert_webhook_payloads_for_mailbox(
:param request:
:param mailbox_name: The mailbox name that messages should be found in.
- :param region_names: Optional list of regions each messages should be queued for
+ :param cell_names: List of cells each messages should be queued for
:param destination_types: Optional Mapping of destination types to the number of messages that should be found for that destination type
"""
expected_payload = WebhookPayload.get_attributes_from_request(request=request)
- region_names_set = set(region_names)
+ cell_names_set = set(cell_names or region_names or [])
messages = WebhookPayload.objects.filter(mailbox_name=mailbox_name)
- messages_with_region_count = messages.filter(cell_name__isnull=False).count()
- if messages_with_region_count != len(region_names_set):
+ messages_with_cell_count = messages.filter(cell_name__isnull=False).count()
+ if messages_with_cell_count != len(cell_names_set):
raise Exception(
- f"Mismatch: Found {messages_with_region_count} WebhookPayload but {len(region_names_set)} region_names"
+ f"Mismatch: Found {messages_with_cell_count} WebhookPayload but {len(cell_names_set)} cell_names"
)
for message in messages:
assert message.request_method == expected_payload["request_method"]
@@ -104,13 +107,13 @@ def assert_webhook_payloads_for_mailbox(
else:
assert message.cell_name is not None
try:
- region_names_set.remove(message.cell_name)
+ cell_names_set.remove(message.cell_name)
except KeyError:
raise Exception(
- f"Found ControlOutbox for '{message.cell_name}', which was not in region_names: {str(region_names_set)}"
+ f"Found ControlOutbox for '{message.cell_name}', which was not in cell_names: {str(cell_names_set)}"
)
- if len(region_names_set) != 0:
- raise Exception(f"WebhookPayload not found for some region_names: {str(region_names_set)}")
+ if len(cell_names_set) != 0:
+ raise Exception(f"WebhookPayload not found for some cell_names: {str(cell_names_set)}")
if destination_types and len(destination_types) != 0:
exc_strs = [
diff --git a/tests/sentry/middleware/integrations/parsers/test_bitbucket.py b/tests/sentry/middleware/integrations/parsers/test_bitbucket.py
index 07fa1b9856a2..2ab0797f1214 100644
--- a/tests/sentry/middleware/integrations/parsers/test_bitbucket.py
+++ b/tests/sentry/middleware/integrations/parsers/test_bitbucket.py
@@ -12,8 +12,8 @@
from sentry.testutils.silo import control_silo_test
from sentry.types.cell import Cell, RegionCategory
-region = Cell("us", 1, "http://us.testserver", RegionCategory.MULTI_TENANT)
-region_config = (region,)
+cell = Cell("us", 1, "http://us.testserver", RegionCategory.MULTI_TENANT)
+cell_config = (cell,)
@control_silo_test
@@ -22,8 +22,8 @@ def get_response(self, req: HttpRequest) -> HttpResponse:
return HttpResponse(status=200, content="passthrough")
factory = RequestFactory()
- region = Cell("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT)
- region_config = (region,)
+ cell = Cell("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT)
+ cell_config = (cell,)
def get_integration(self) -> Integration:
return self.create_integration(
@@ -31,7 +31,7 @@ def get_integration(self) -> Integration:
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_routing_endpoints(self) -> None:
self.get_integration()
control_routes = [
@@ -57,15 +57,15 @@ def test_routing_endpoints(self) -> None:
assert_no_webhook_payloads()
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
- def test_routing_webhook_no_regions(self) -> None:
- region_route = reverse(
+ @override_cells(cell_config)
+ def test_routing_webhook_no_cells(self) -> None:
+ cell_route = reverse(
"sentry-extensions-bitbucket-webhook", kwargs={"organization_id": self.organization.id}
)
- request = self.factory.post(region_route)
+ request = self.factory.post(cell_route)
parser = BitbucketRequestParser(request=request, response_handler=self.get_response)
- # Missing region
+ # Missing cell
OrganizationMapping.objects.get(organization_id=self.organization.id).update(cell_name="eu")
response = parser.get_response()
@@ -75,13 +75,13 @@ def test_routing_webhook_no_regions(self) -> None:
assert_no_webhook_payloads()
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
- def test_routing_webhook_with_regions(self) -> None:
+ @override_cells(cell_config)
+ def test_routing_webhook_with_cells(self) -> None:
self.get_integration()
- region_route = reverse(
+ cell_route = reverse(
"sentry-extensions-bitbucket-webhook", kwargs={"organization_id": self.organization.id}
)
- request = self.factory.post(region_route)
+ request = self.factory.post(cell_route)
parser = BitbucketRequestParser(request=request, response_handler=self.get_response)
response = parser.get_response()
@@ -91,5 +91,5 @@ def test_routing_webhook_with_regions(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"bitbucket:{self.organization.id}",
- region_names=[self.region.name],
+ cell_names=[self.cell.name],
)
diff --git a/tests/sentry/middleware/integrations/parsers/test_bitbucket_server.py b/tests/sentry/middleware/integrations/parsers/test_bitbucket_server.py
index 4664a8cf0782..c07006f84b67 100644
--- a/tests/sentry/middleware/integrations/parsers/test_bitbucket_server.py
+++ b/tests/sentry/middleware/integrations/parsers/test_bitbucket_server.py
@@ -19,21 +19,21 @@
class BitbucketServerRequestParserTest(TestCase):
get_response = MagicMock(return_value=HttpResponse(content=b"no-error", status=200))
factory = RequestFactory()
- region = Cell("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT)
- region_config = (region,)
+ cell = Cell("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT)
+ cell_config = (cell,)
- @override_cells(region_config)
+ @override_cells(cell_config)
@override_settings(SILO_MODE=SiloMode.CONTROL)
def test_routing_webhook(self) -> None:
- region_route = reverse(
+ cell_route = reverse(
"sentry-extensions-bitbucketserver-webhook",
kwargs={"organization_id": self.organization.id, "integration_id": self.integration.id},
)
with outbox_runner():
- request = self.factory.post(region_route)
+ request = self.factory.post(cell_route)
parser = BitbucketServerRequestParser(request=request, response_handler=self.get_response)
- # Missing region
+ # Missing cell
OrganizationMapping.objects.get(organization_id=self.organization.id).update(cell_name="eu")
with mock.patch.object(
parser, "get_response_from_control_silo"
@@ -41,7 +41,7 @@ def test_routing_webhook(self) -> None:
parser.get_response()
assert get_response_from_control_silo.called
- # Valid region
+ # Valid cell
OrganizationMapping.objects.get(organization_id=self.organization.id).update(cell_name="us")
response = parser.get_response()
assert isinstance(response, HttpResponse)
@@ -51,5 +51,5 @@ def test_routing_webhook(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"bitbucket_server:{self.organization.id}",
- region_names=[self.region.name],
+ cell_names=[self.cell.name],
)
diff --git a/tests/sentry/middleware/integrations/parsers/test_github.py b/tests/sentry/middleware/integrations/parsers/test_github.py
index ed497fdb80ae..a86de294b8f9 100644
--- a/tests/sentry/middleware/integrations/parsers/test_github.py
+++ b/tests/sentry/middleware/integrations/parsers/test_github.py
@@ -22,8 +22,8 @@
from sentry.testutils.silo import control_silo_test
from sentry.types.cell import Cell, RegionCategory
-region = Cell("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT)
-region_config = (region,)
+cell = Cell("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT)
+cell_config = (cell,)
@control_silo_test
@@ -42,7 +42,7 @@ def get_integration(self) -> Integration:
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_invalid_webhook(self) -> None:
if SiloMode.get_current_mode() != SiloMode.CONTROL:
return
@@ -59,7 +59,7 @@ def test_invalid_webhook(self) -> None:
assert response.status_code == status.HTTP_400_BAD_REQUEST
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_routing_no_organization_integration_found(self) -> None:
integration = self.get_integration()
@@ -82,7 +82,7 @@ def test_routing_no_organization_integration_found(self) -> None:
assert_no_webhook_payloads()
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_routing_no_integration_found(self) -> None:
self.get_integration()
@@ -101,7 +101,7 @@ def test_routing_no_integration_found(self) -> None:
assert_no_webhook_payloads()
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_routing_search_properly(self) -> None:
path = reverse(
@@ -126,7 +126,7 @@ def test_routing_search_properly(self) -> None:
assert_no_webhook_payloads()
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_get_integration_from_request(self) -> None:
integration = self.get_integration()
request = self.factory.post(
@@ -140,7 +140,7 @@ def test_get_integration_from_request(self) -> None:
assert result == integration
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_webhook_outbox_creation(self) -> None:
integration = self.get_integration()
request = self.factory.post(
@@ -158,7 +158,7 @@ def test_webhook_outbox_creation(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"github:{integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
)
@override_settings(SILO_MODE=SiloMode.CONTROL, CODECOV_API_BASE_URL="https://api.codecov.io")
@@ -169,7 +169,7 @@ def test_webhook_outbox_creation(self) -> None:
"codecov.forward-webhooks.disabled": False,
}
)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_webhook_for_codecov(self) -> None:
integration = self.get_integration()
request = self.factory.post(
@@ -187,13 +187,13 @@ def test_webhook_for_codecov(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"github:{integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
destination_types={DestinationType.SENTRY_REGION: 1},
)
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name="github:codecov:1",
- region_names=[],
+ cell_names=[],
destination_types={DestinationType.CODECOV: 1},
)
@@ -205,8 +205,8 @@ def test_webhook_for_codecov(self) -> None:
"codecov.forward-webhooks.disabled": False,
}
)
- @override_cells(region_config)
- def test_webhook_for_codecov_no_regions(self) -> None:
+ @override_cells(cell_config)
+ def test_webhook_for_codecov_no_cells(self) -> None:
integration = self.get_integration()
request = self.factory.post(
self.path,
@@ -223,7 +223,7 @@ def test_webhook_for_codecov_no_regions(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"github:{integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
destination_types={DestinationType.SENTRY_REGION: 1},
)
with pytest.raises(
@@ -233,7 +233,7 @@ def test_webhook_for_codecov_no_regions(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name="github:codecov:1",
- region_names=[],
+ cell_names=[],
destination_types={DestinationType.CODECOV: 1},
)
@@ -245,9 +245,9 @@ def test_webhook_for_codecov_no_regions(self) -> None:
"codecov.forward-webhooks.disabled": True,
}
)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_webhook_no_codecov_payload_when_forwarding_disabled(self) -> None:
- """When codecov.forward-webhooks.disabled is True, only region payload is created."""
+ """When codecov.forward-webhooks.disabled is True, only cell payload is created."""
integration = self.get_integration()
request = self.factory.post(
self.path,
@@ -264,7 +264,7 @@ def test_webhook_no_codecov_payload_when_forwarding_disabled(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"github:{integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
destination_types={DestinationType.SENTRY_REGION: 1},
)
with pytest.raises(
@@ -274,12 +274,12 @@ def test_webhook_no_codecov_payload_when_forwarding_disabled(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name="github:codecov:1",
- region_names=[],
+ cell_names=[],
destination_types={DestinationType.CODECOV: 1},
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_installation_created_routing(self) -> None:
self.get_integration()
@@ -315,7 +315,7 @@ def test_installation_deleted_routing(self) -> None:
assert_no_webhook_payloads()
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_issue_deleted_routing(self) -> None:
integration = self.get_integration()
@@ -340,7 +340,7 @@ def test_issue_deleted_routing(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"github:{integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
destination_types={DestinationType.SENTRY_REGION: 1},
)
@@ -394,7 +394,7 @@ def test_mailbox_bucket_id_handles_malformed_payload(self) -> None:
assert parser.mailbox_bucket_id({}) is None
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_webhook_outbox_creation_with_bucketing(self) -> None:
integration = self.get_integration()
request = self.factory.post(
@@ -414,11 +414,11 @@ def test_webhook_outbox_creation_with_bucketing(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"github:{integration.id}:77:push",
- region_names=[region.name],
+ cell_names=[cell.name],
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_webhook_outbox_creation_with_bucketing_isolates_event_types(self) -> None:
"""Different event types for the same repo land in different mailboxes."""
integration = self.get_integration()
@@ -447,7 +447,7 @@ def test_webhook_outbox_creation_with_bucketing_isolates_event_types(self) -> No
) != check_run_parser.get_mailbox_identifier(integration, {})
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_webhook_outbox_creation_with_bucketing_no_event_type_header(self) -> None:
"""Falls back gracefully when X-GitHub-Event header is absent."""
integration = self.get_integration()
@@ -468,11 +468,11 @@ def test_webhook_outbox_creation_with_bucketing_no_event_type_header(self) -> No
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"github:{integration.id}:77",
- region_names=[region.name],
+ cell_names=[cell.name],
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_webhook_outbox_creation_without_bucketing(self) -> None:
integration = self.get_integration()
request = self.factory.post(
@@ -489,11 +489,11 @@ def test_webhook_outbox_creation_without_bucketing(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"github:{integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_webhook_without_repository_uses_event_type_only(self) -> None:
"""No repository ID means no repo bucket, but event type still provides isolation."""
integration = self.get_integration()
@@ -513,13 +513,13 @@ def test_webhook_without_repository_uses_event_type_only(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"github:{integration.id}:issues",
- region_names=[region.name],
+ cell_names=[cell.name],
)
@control_silo_test
class GithubRequestParserDropUnprocessedEventsTest(TestCase):
- """Tests for dropping GitHub webhook events that the region does not process."""
+ """Tests for dropping GitHub webhook events that the cell does not process."""
factory = RequestFactory()
path = reverse("sentry-integration-github-webhook")
@@ -535,7 +535,7 @@ def get_integration(self) -> Integration:
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
@patch("sentry.middleware.integrations.parsers.github.metrics")
def test_drops_unprocessed_event(self, mock_metrics: Mock) -> None:
@@ -559,7 +559,7 @@ def test_drops_unprocessed_event(self, mock_metrics: Mock) -> None:
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_supported_event_never_dropped(self) -> None:
"""Supported event (push) is never dropped."""
@@ -578,14 +578,14 @@ def test_supported_event_never_dropped(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"github:{integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
- def test_missing_x_github_event_forwards_to_region(self) -> None:
- """Missing X-GitHub-Event is forwarded to region so it can return 400."""
+ def test_missing_x_github_event_forwards_to_cell(self) -> None:
+ """Missing X-GitHub-Event is forwarded to cell so it can return 400."""
integration = self.get_integration()
request = self.factory.post(
self.path,
@@ -601,7 +601,7 @@ def test_missing_x_github_event_forwards_to_region(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"github:{integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
)
diff --git a/tests/sentry/middleware/integrations/parsers/test_github_enterprise.py b/tests/sentry/middleware/integrations/parsers/test_github_enterprise.py
index d44aefff5a65..8743230f937d 100644
--- a/tests/sentry/middleware/integrations/parsers/test_github_enterprise.py
+++ b/tests/sentry/middleware/integrations/parsers/test_github_enterprise.py
@@ -15,8 +15,8 @@
from sentry.testutils.silo import control_silo_test
from sentry.types.cell import Cell, RegionCategory
-region = Cell("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT)
-region_config = (region,)
+cell = Cell("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT)
+cell_config = (cell,)
@control_silo_test
@@ -38,7 +38,7 @@ def get_integration(self) -> Integration:
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_invalid_webhook(self) -> None:
self.get_integration()
request = self.factory.post(
@@ -49,7 +49,7 @@ def test_invalid_webhook(self) -> None:
assert response.status_code == 400
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_routing_no_organization_integrations_found(self) -> None:
integration = self.get_integration()
@@ -72,7 +72,7 @@ def test_routing_no_organization_integrations_found(self) -> None:
assert_no_webhook_payloads()
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_routing_no_integrations_found(self) -> None:
self.get_integration()
@@ -86,7 +86,7 @@ def test_routing_no_integrations_found(self) -> None:
assert_no_webhook_payloads()
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_get_integration_from_request_no_host(self) -> None:
# No host header
request = self.factory.post(
@@ -100,7 +100,7 @@ def test_get_integration_from_request_no_host(self) -> None:
assert result is None
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_get_integration_from_request_with_host(self) -> None:
# With host header
request = self.factory.post(
@@ -115,7 +115,7 @@ def test_get_integration_from_request_with_host(self) -> None:
assert result == integration
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_installation_hook_handled_in_control(self) -> None:
self.get_integration()
@@ -137,7 +137,7 @@ def test_installation_hook_handled_in_control(self) -> None:
assert_no_webhook_payloads()
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_webhook_outbox_creation(self) -> None:
integration = self.get_integration()
@@ -156,5 +156,5 @@ def test_webhook_outbox_creation(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"github_enterprise:{integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
)
diff --git a/tests/sentry/middleware/integrations/parsers/test_gitlab.py b/tests/sentry/middleware/integrations/parsers/test_gitlab.py
index 53d50f28d2ea..a82575cf105b 100644
--- a/tests/sentry/middleware/integrations/parsers/test_gitlab.py
+++ b/tests/sentry/middleware/integrations/parsers/test_gitlab.py
@@ -20,8 +20,8 @@
from sentry.testutils.silo import control_silo_test
from sentry.types.cell import Cell, RegionCategory
-region = Cell("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT)
-region_config = (region,)
+cell = Cell("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT)
+cell_config = (cell,)
@control_silo_test
@@ -54,7 +54,7 @@ def run_parser(self, request):
return parser.get_response()
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_missing_x_gitlab_token(self) -> None:
self.get_integration()
request = self.factory.post(
@@ -70,7 +70,7 @@ def test_missing_x_gitlab_token(self) -> None:
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_invalid_token(self) -> None:
self.get_integration()
request = self.factory.post(
@@ -86,9 +86,9 @@ def test_invalid_token(self) -> None:
assert_no_webhook_payloads()
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
- def test_routing_webhook_properly_no_regions(self) -> None:
+ def test_routing_webhook_properly_no_cells(self) -> None:
request = self.factory.post(
self.path,
data=PUSH_EVENT,
@@ -111,9 +111,9 @@ def test_routing_webhook_properly_no_regions(self) -> None:
assert_no_webhook_payloads()
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
- def test_routing_webhook_properly_with_regions(self) -> None:
+ def test_routing_webhook_properly_with_cells(self) -> None:
integration = self.get_integration()
request = self.factory.post(
self.path,
@@ -132,11 +132,11 @@ def test_routing_webhook_properly_with_regions(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"gitlab:{integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_routing_webhook_properly_with_multiple_orgs(self) -> None:
integration = self.get_integration()
@@ -160,10 +160,10 @@ def test_routing_webhook_properly_with_multiple_orgs(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"gitlab:{integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
)
- @override_cells(region_config)
+ @override_cells(cell_config)
@override_settings(SILO_MODE=SiloMode.CONTROL)
@responses.activate
def test_routing_webhook_with_mailbox_buckets(self) -> None:
@@ -189,11 +189,11 @@ def test_routing_webhook_with_mailbox_buckets(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"gitlab:{integration.id}:15",
- region_names=[region.name],
+ cell_names=[cell.name],
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_routing_search_properly(self) -> None:
self.get_integration()
@@ -215,7 +215,7 @@ def test_routing_search_properly(self) -> None:
assert_no_webhook_payloads()
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_get_integration_from_request(self) -> None:
integration = self.get_integration()
request = self.factory.post(
@@ -231,7 +231,7 @@ def test_get_integration_from_request(self) -> None:
assert result.id == integration.id
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_webhook_outbox_creation(self) -> None:
request = self.factory.post(
@@ -253,5 +253,5 @@ def test_webhook_outbox_creation(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"gitlab:{integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
)
diff --git a/tests/sentry/middleware/integrations/parsers/test_jira.py b/tests/sentry/middleware/integrations/parsers/test_jira.py
index 7caf2d098cf1..d0a609fc897b 100644
--- a/tests/sentry/middleware/integrations/parsers/test_jira.py
+++ b/tests/sentry/middleware/integrations/parsers/test_jira.py
@@ -17,12 +17,12 @@
from sentry.testutils.silo import control_silo_test
from sentry.types.cell import Cell, Locality, RegionCategory
-region = Cell("us", 1, "http://us.testserver", RegionCategory.MULTI_TENANT)
-eu_region = Cell("eu", 2, "http://eu.testserver", RegionCategory.MULTI_TENANT)
+cell = Cell("us", 1, "http://us.testserver", RegionCategory.MULTI_TENANT)
+eu_cell = Cell("eu", 2, "http://eu.testserver", RegionCategory.MULTI_TENANT)
locality = Locality("us", frozenset(["us"]), RegionCategory.MULTI_TENANT, new_org_cell="us")
eu_locality = Locality("eu", frozenset(["eu"]), RegionCategory.MULTI_TENANT, new_org_cell="eu")
-region_config = (region, eu_region)
+cell_config = (cell, eu_cell)
@control_silo_test
@@ -40,7 +40,7 @@ def get_integration(self) -> Integration:
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_get_integration_from_request(self) -> None:
request = self.factory.post(path=f"{self.path_base}/issue-updated/")
parser = JiraRequestParser(request, self.get_response)
@@ -54,7 +54,7 @@ def test_get_integration_from_request(self) -> None:
assert parser.get_integration_from_request() == integration
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_get_response_routing_to_control(self) -> None:
paths = [
"/ui-hook/",
@@ -76,12 +76,12 @@ def test_get_response_routing_to_control(self) -> None:
@responses.activate
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
- def test_get_response_routing_to_region_sync(self) -> None:
+ @override_cells(cell_config)
+ def test_get_response_routing_to_cell_sync(self) -> None:
responses.add(
responses.POST,
locality.to_url("/extensions/jira/issue/LR-123/"),
- body="region response",
+ body="cell response",
status=200,
)
request = self.factory.post(path=f"{self.path_base}/issue/LR-123/")
@@ -93,17 +93,17 @@ def test_get_response_routing_to_region_sync(self) -> None:
assert isinstance(response, HttpResponse)
assert response.status_code == status.HTTP_200_OK
- assert response.content == b"region response"
+ assert response.content == b"cell response"
assert_no_webhook_payloads()
@responses.activate
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
- def test_get_response_routing_to_region_sync_retry_errors(self) -> None:
+ @override_cells(cell_config)
+ def test_get_response_routing_to_cell_sync_retry_errors(self) -> None:
responses.add(
responses.POST,
locality.to_url("/extensions/jira/issue/LR-123/"),
- body="region response",
+ body="cell response",
status=503,
)
request = self.factory.post(path=f"{self.path_base}/issue/LR-123/")
@@ -122,8 +122,8 @@ def test_get_response_routing_to_region_sync_retry_errors(self) -> None:
@responses.activate
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
- def test_get_response_routing_to_region_async(self) -> None:
+ @override_cells(cell_config)
+ def test_get_response_routing_to_cell_async(self) -> None:
request = self.factory.post(path=f"{self.path_base}/issue-updated/")
parser = JiraRequestParser(request, self.get_response)
@@ -139,12 +139,12 @@ def test_get_response_routing_to_region_async(self) -> None:
assert len(responses.calls) == 0
assert_webhook_payloads_for_mailbox(
- mailbox_name=f"jira:{integration.id}", region_names=[region.name], request=request
+ mailbox_name=f"jira:{integration.id}", cell_names=[cell.name], request=request
)
@responses.activate
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
def test_get_response_missing_org_integration(self) -> None:
request = self.factory.post(path=f"{self.path_base}/issue-updated/")
parser = JiraRequestParser(request, self.get_response)
@@ -165,7 +165,7 @@ def test_get_response_missing_org_integration(self) -> None:
assert len(responses.calls) == 0
assert_no_webhook_payloads()
- @override_cells(region_config)
+ @override_cells(cell_config)
@override_settings(SILO_MODE=SiloMode.CONTROL)
@responses.activate
def test_get_response_invalid_path(self) -> None:
@@ -183,9 +183,9 @@ def test_get_response_invalid_path(self) -> None:
assert len(responses.calls) == 0
assert_no_webhook_payloads()
- @override_cells(region_config)
+ @override_cells(cell_config)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- def test_get_response_multiple_regions(self) -> None:
+ def test_get_response_multiple_cells(self) -> None:
# Use GET — the view only handles GET, and Jira sends GET for issue hooks.
request = self.factory.get(path=f"{self.path_base}/issue/LR-123/")
parser = JiraRequestParser(request, self.get_response)
diff --git a/tests/sentry/middleware/integrations/parsers/test_jira_server.py b/tests/sentry/middleware/integrations/parsers/test_jira_server.py
index 058ef837a977..aef9a8b38e0f 100644
--- a/tests/sentry/middleware/integrations/parsers/test_jira_server.py
+++ b/tests/sentry/middleware/integrations/parsers/test_jira_server.py
@@ -17,9 +17,9 @@
from sentry.testutils.silo import control_silo_test
from sentry.types.cell import Cell, RegionCategory
-region = Cell("us", 1, "http://us.testserver", RegionCategory.MULTI_TENANT)
+cell = Cell("us", 1, "http://us.testserver", RegionCategory.MULTI_TENANT)
-region_config = (region,)
+cell_config = (cell,)
issue_updated_payload = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json")
no_changelog: dict[str, Any] = {}
@@ -32,7 +32,7 @@ class JiraServerRequestParserTest(TestCase):
def get_response(self, req: HttpRequest) -> HttpResponse:
return HttpResponse(status=status.HTTP_200_OK, content="passthrough")
- @override_cells(region_config)
+ @override_cells(cell_config)
def setUp(self) -> None:
super().setUp()
self.integration = self.create_integration(
@@ -57,7 +57,7 @@ def test_routing_endpoint_no_integration(self) -> None:
assert len(responses.calls) == 0
assert_no_webhook_payloads()
- @override_cells(region_config)
+ @override_cells(cell_config)
@override_settings(SILO_MODE=SiloMode.CONTROL)
@responses.activate
def test_routing_endpoint_with_integration(self) -> None:
@@ -80,10 +80,10 @@ def test_routing_endpoint_with_integration(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"jira_server:{self.integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
)
- @override_cells(region_config)
+ @override_cells(cell_config)
@override_settings(SILO_MODE=SiloMode.CONTROL)
@responses.activate
def test_routing_endpoint_with_integration_no_organization_integration(self) -> None:
@@ -109,7 +109,7 @@ def test_routing_endpoint_with_integration_no_organization_integration(self) ->
assert response.content == b""
assert len(responses.calls) == 0
- @override_cells(region_config)
+ @override_cells(cell_config)
@override_settings(SILO_MODE=SiloMode.CONTROL)
@responses.activate
def test_routing_webhook_with_mailbox_buckets_low_volume(self) -> None:
@@ -132,10 +132,10 @@ def test_routing_webhook_with_mailbox_buckets_low_volume(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"jira_server:{self.integration.id}",
- region_names=[region.name],
+ cell_names=[cell.name],
)
- @override_cells(region_config)
+ @override_cells(cell_config)
@override_settings(SILO_MODE=SiloMode.CONTROL)
@responses.activate
def test_routing_webhook_with_mailbox_buckets_high_volume(self) -> None:
@@ -165,10 +165,10 @@ def test_routing_webhook_with_mailbox_buckets_high_volume(self) -> None:
request=request,
# Mailbox name should have an extra segment
mailbox_name=f"jira_server:{self.integration.id}:1",
- region_names=[region.name],
+ cell_names=[cell.name],
)
- @override_cells(region_config)
+ @override_cells(cell_config)
@override_settings(SILO_MODE=SiloMode.CONTROL)
@responses.activate
def test_routing_webhook_with_mailbox_bucket_mode_active(self) -> None:
@@ -197,11 +197,11 @@ def test_routing_webhook_with_mailbox_bucket_mode_active(self) -> None:
request=request,
# Mailbox name should have an extra segment
mailbox_name=f"jira_server:{self.integration.id}:1",
- region_names=[region.name],
+ cell_names=[cell.name],
)
@override_settings(SILO_MODE=SiloMode.CONTROL)
- @override_cells(region_config)
+ @override_cells(cell_config)
@responses.activate
def test_drop_request_without_changelog(self) -> None:
route = reverse("sentry-extensions-jiraserver-issue-updated", kwargs={"token": "TOKEN"})
diff --git a/tests/sentry/middleware/integrations/parsers/test_msteams.py b/tests/sentry/middleware/integrations/parsers/test_msteams.py
index 9e64bb77e3f0..988849e991fe 100644
--- a/tests/sentry/middleware/integrations/parsers/test_msteams.py
+++ b/tests/sentry/middleware/integrations/parsers/test_msteams.py
@@ -64,7 +64,7 @@ def generate_card_response(self, integration_id: int) -> dict[str, Any]:
@responses.activate
def test_routing_events(self) -> None:
- # No regions identified
+ # No cells identified
request = self.factory.post(
self.path,
data=GENERIC_EVENT,
@@ -95,7 +95,7 @@ def test_routing_events(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"msteams:{self.integration.id}",
- region_names=["us"],
+ cell_names=["us"],
)
def test_routing_events_no_org_integration(self) -> None:
@@ -147,7 +147,7 @@ def test_routing_control_paths(self) -> None:
def test_get_integration_from_request(self) -> None:
CARD_ACTION_RESPONSE = self.generate_card_response(self.integration.id)
- region_silo_payloads = [
+ cell_silo_payloads = [
# Integration inferred from channelData.team.id
EXAMPLE_TEAM_MEMBER_REMOVED,
EXAMPLE_TEAM_MEMBER_ADDED,
@@ -156,7 +156,7 @@ def test_get_integration_from_request(self) -> None:
CARD_ACTION_RESPONSE,
]
- for payload in region_silo_payloads:
+ for payload in cell_silo_payloads:
request = self.factory.post(
self.path,
data=payload,
diff --git a/tests/sentry/middleware/integrations/parsers/test_plugin.py b/tests/sentry/middleware/integrations/parsers/test_plugin.py
index 3554cfb1aa23..b9040f0a717f 100644
--- a/tests/sentry/middleware/integrations/parsers/test_plugin.py
+++ b/tests/sentry/middleware/integrations/parsers/test_plugin.py
@@ -19,7 +19,7 @@ def get_response(self, request: HttpRequest) -> HttpResponse:
return HttpResponse(status=200, content="passthrough")
@responses.activate
- def test_routing_webhooks_no_region(self) -> None:
+ def test_routing_webhooks_no_cell(self) -> None:
routes = [
reverse("sentry-plugins-github-webhook", args=[self.organization.id]),
reverse("sentry-plugins-bitbucket-webhook", args=[self.organization.id]),
@@ -37,7 +37,7 @@ def test_routing_webhooks_no_region(self) -> None:
assert len(responses.calls) == 0
assert_no_webhook_payloads()
- def test_routing_webhooks_with_region(self) -> None:
+ def test_routing_webhooks_with_cell(self) -> None:
routes = [
reverse("sentry-plugins-github-webhook", args=[self.organization.id]),
reverse("sentry-plugins-bitbucket-webhook", args=[self.organization.id]),
@@ -50,7 +50,7 @@ def test_routing_webhooks_with_region(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"plugins:{self.organization.id}",
- region_names=["us"],
+ cell_names=["us"],
)
# Purge outboxes after checking each route
WebhookPayload.objects.all().delete()
diff --git a/tests/sentry/middleware/integrations/parsers/test_vsts.py b/tests/sentry/middleware/integrations/parsers/test_vsts.py
index 3b830a66bc91..3a08dda5466e 100644
--- a/tests/sentry/middleware/integrations/parsers/test_vsts.py
+++ b/tests/sentry/middleware/integrations/parsers/test_vsts.py
@@ -71,7 +71,7 @@ def test_routing_work_item_webhook(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"vsts:{self.integration.id}",
- region_names=["us"],
+ cell_names=["us"],
)
@responses.activate
@@ -101,9 +101,9 @@ def test_routing_control_paths(self) -> None:
assert_no_webhook_payloads()
def test_get_integration_from_request(self) -> None:
- region_silo_payloads = [WORK_ITEM_UNASSIGNED, WORK_ITEM_UPDATED, WORK_ITEM_UPDATED_STATUS]
+ cell_silo_payloads = [WORK_ITEM_UNASSIGNED, WORK_ITEM_UPDATED, WORK_ITEM_UPDATED_STATUS]
- for payload in region_silo_payloads:
+ for payload in cell_silo_payloads:
request = self.factory.post(
self.path,
HTTP_SHARED_SECRET=self.shared_secret,
@@ -139,5 +139,5 @@ def test_webhook_outbox_creation(self) -> None:
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"vsts:{self.integration.id}",
- region_names=["us"],
+ cell_names=["us"],
)
From 748eea61ba10b325b5b4f6860e0be296a07e7936 Mon Sep 17 00:00:00 2001
From: Malachi Willey
Date: Tue, 31 Mar 2026 11:15:57 -0700
Subject: [PATCH 23/51] chore(aci): All create monitor buttons should link to
type selection (#111801)
"Create Monitor" buttons should always link to the initial type
selection page. We made this link to the actual form on the
type-specific pages after user feedback, but we now believe that the
original experience was more consistent and leads to less confusion
overall.
---
.../app/views/detectors/list/allMonitors.tsx | 2 +-
.../list/common/detectorListActions.tsx | 36 ++++---------------
static/app/views/detectors/list/cron.tsx | 2 +-
static/app/views/detectors/list/error.tsx | 2 +-
static/app/views/detectors/list/metric.tsx | 2 +-
.../app/views/detectors/list/mobileBuild.tsx | 2 +-
.../app/views/detectors/list/myMonitors.tsx | 2 +-
static/app/views/detectors/list/uptime.tsx | 2 +-
static/app/views/detectors/pathnames.tsx | 4 ---
9 files changed, 13 insertions(+), 41 deletions(-)
diff --git a/static/app/views/detectors/list/allMonitors.tsx b/static/app/views/detectors/list/allMonitors.tsx
index 58d95c75695d..e9b38236c925 100644
--- a/static/app/views/detectors/list/allMonitors.tsx
+++ b/static/app/views/detectors/list/allMonitors.tsx
@@ -19,7 +19,7 @@ export default function AllMonitors() {
return (
}
+ actions={ }
title={TITLE}
description={DESCRIPTION}
docsUrl={DOCS_URL}
diff --git a/static/app/views/detectors/list/common/detectorListActions.tsx b/static/app/views/detectors/list/common/detectorListActions.tsx
index db3724911f49..0a14dccd1cdd 100644
--- a/static/app/views/detectors/list/common/detectorListActions.tsx
+++ b/static/app/views/detectors/list/common/detectorListActions.tsx
@@ -5,42 +5,22 @@ import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants';
import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters';
import {IconAdd} from 'sentry/icons';
import {t} from 'sentry/locale';
-import type {DetectorType} from 'sentry/types/workflowEngine/detectors';
import {useOrganization} from 'sentry/utils/useOrganization';
import {MonitorFeedbackButton} from 'sentry/views/detectors/components/monitorFeedbackButton';
-import {
- makeMonitorCreatePathname,
- makeMonitorCreateSettingsPathname,
-} from 'sentry/views/detectors/pathnames';
-import {detectorTypeIsUserCreateable} from 'sentry/views/detectors/utils/detectorTypeConfig';
+import {makeMonitorCreatePathname} from 'sentry/views/detectors/pathnames';
import {getNoPermissionToCreateMonitorsTooltip} from 'sentry/views/detectors/utils/monitorAccessMessages';
import {useCanCreateDetector} from 'sentry/views/detectors/utils/useCanCreateDetector';
interface DetectorListActionsProps {
- detectorType: DetectorType | null;
children?: React.ReactNode;
}
-function getPermissionTooltipText({detectorType}: {detectorType: DetectorType | null}) {
- const noPermissionText = getNoPermissionToCreateMonitorsTooltip();
-
- if (!detectorType || detectorTypeIsUserCreateable(detectorType)) {
- return noPermissionText;
- }
-
- return t('This monitor type is managed by Sentry.');
-}
-
-export function DetectorListActions({detectorType, children}: DetectorListActionsProps) {
+export function DetectorListActions({children}: DetectorListActionsProps) {
const organization = useOrganization();
const {selection} = usePageFilters();
- const createPath = detectorType
- ? makeMonitorCreateSettingsPathname(organization.slug)
- : makeMonitorCreatePathname(organization.slug);
const project = selection.projects.find(pid => pid !== ALL_ACCESS_PROJECTS);
- const createQuery = detectorType ? {project, detectorType} : {project};
- const canCreateDetector = useCanCreateDetector(detectorType);
+ const canCreateDetector = useCanCreateDetector(null);
return (
@@ -48,19 +28,15 @@ export function DetectorListActions({detectorType, children}: DetectorListAction
}
size="sm"
disabled={!canCreateDetector}
tooltipProps={{
- title: canCreateDetector
- ? undefined
- : getPermissionTooltipText({
- detectorType,
- }),
+ title: canCreateDetector ? undefined : getNoPermissionToCreateMonitorsTooltip(),
}}
>
{t('Create Monitor')}
diff --git a/static/app/views/detectors/list/cron.tsx b/static/app/views/detectors/list/cron.tsx
index ac861c9fa7b3..d291cfe7f475 100644
--- a/static/app/views/detectors/list/cron.tsx
+++ b/static/app/views/detectors/list/cron.tsx
@@ -244,7 +244,7 @@ export default function CronDetectorsList() {
}
+ actions={ }
title={TITLE}
description={DESCRIPTION}
docsUrl={DOCS_URL}
diff --git a/static/app/views/detectors/list/error.tsx b/static/app/views/detectors/list/error.tsx
index d2ed71479687..cc23e6a3360d 100644
--- a/static/app/views/detectors/list/error.tsx
+++ b/static/app/views/detectors/list/error.tsx
@@ -21,7 +21,7 @@ export default function ErrorDetectorsList() {
return (
}
+ actions={ }
title={TITLE}
description={DESCRIPTION}
docsUrl={DOCS_URL}
diff --git a/static/app/views/detectors/list/metric.tsx b/static/app/views/detectors/list/metric.tsx
index 063e6bfcf0d5..c0de7b94a409 100644
--- a/static/app/views/detectors/list/metric.tsx
+++ b/static/app/views/detectors/list/metric.tsx
@@ -21,7 +21,7 @@ export default function MetricDetectorsList() {
return (
}
+ actions={ }
title={TITLE}
description={DESCRIPTION}
docsUrl={DOCS_URL}
diff --git a/static/app/views/detectors/list/mobileBuild.tsx b/static/app/views/detectors/list/mobileBuild.tsx
index daea09353a6e..fe6914cf811e 100644
--- a/static/app/views/detectors/list/mobileBuild.tsx
+++ b/static/app/views/detectors/list/mobileBuild.tsx
@@ -22,7 +22,7 @@ export default function MobileBuildDetectorsList() {
}
+ actions={ }
title={TITLE}
description={DESCRIPTION}
docsUrl={DOCS_URL}
diff --git a/static/app/views/detectors/list/myMonitors.tsx b/static/app/views/detectors/list/myMonitors.tsx
index 2d81d8172bf5..6c91043f495f 100644
--- a/static/app/views/detectors/list/myMonitors.tsx
+++ b/static/app/views/detectors/list/myMonitors.tsx
@@ -18,7 +18,7 @@ export default function MyMonitorsList() {
return (
}
+ actions={ }
title={TITLE}
description={DESCRIPTION}
docsUrl={DOCS_URL}
diff --git a/static/app/views/detectors/list/uptime.tsx b/static/app/views/detectors/list/uptime.tsx
index fa385dbfc422..dbbacafdcd32 100644
--- a/static/app/views/detectors/list/uptime.tsx
+++ b/static/app/views/detectors/list/uptime.tsx
@@ -105,7 +105,7 @@ export default function UptimeDetectorsList() {
}
+ actions={ }
title={TITLE}
description={DESCRIPTION}
docsUrl={DOCS_URL}
diff --git a/static/app/views/detectors/pathnames.tsx b/static/app/views/detectors/pathnames.tsx
index c89431355cbe..33b54650b84f 100644
--- a/static/app/views/detectors/pathnames.tsx
+++ b/static/app/views/detectors/pathnames.tsx
@@ -23,10 +23,6 @@ export const makeMonitorCreatePathname = (orgSlug: string) => {
return normalizeUrl(`${makeMonitorBasePathname(orgSlug)}new/`);
};
-export const makeMonitorCreateSettingsPathname = (orgSlug: string) => {
- return normalizeUrl(`${makeMonitorBasePathname(orgSlug)}new/settings/`);
-};
-
export const makeMonitorEditPathname = (orgSlug: string, monitorId: string) => {
return normalizeUrl(`${makeMonitorBasePathname(orgSlug)}${monitorId}/edit/`);
};
From 48a6b9ac811695f7fed65461c98c111f22d86889 Mon Sep 17 00:00:00 2001
From: Lyn Nagara <1779792+lynnagara@users.noreply.github.com>
Date: Tue, 31 Mar 2026 11:17:02 -0700
Subject: [PATCH 24/51] ref(cells): Update slack and discord tasks to accept
cell_name (#111858)
Update the convert_to_async_slack_response and
convert_to_async_discord_response tasks to also accept cell_name.
Once deployed everywhere, will update callers to pass cell_name
---
src/sentry/middleware/integrations/tasks.py | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/src/sentry/middleware/integrations/tasks.py b/src/sentry/middleware/integrations/tasks.py
index 65cec4735c81..416dff03ec66 100644
--- a/src/sentry/middleware/integrations/tasks.py
+++ b/src/sentry/middleware/integrations/tasks.py
@@ -121,11 +121,12 @@ def unpack_payload(self, response: Response) -> Any:
silo_mode=SiloMode.CONTROL,
)
def convert_to_async_slack_response(
- region_names: list[str],
payload: dict[str, Any],
response_url: str,
+ cell_names: list[str] | None = None, # TODO(cells): make required once region_names is removed
+ region_names: list[str] | None = None, # TODO(cells): remove after queue drains
) -> None:
- _AsyncSlackDispatcher(payload, response_url).dispatch(region_names)
+ _AsyncSlackDispatcher(payload, response_url).dispatch(cell_names or region_names or [])
class _AsyncDiscordDispatcher(_AsyncCellDispatcher):
@@ -148,9 +149,10 @@ def unpack_payload(self, response: Response) -> Any:
silo_mode=SiloMode.CONTROL,
)
def convert_to_async_discord_response(
- region_names: list[str],
payload: dict[str, Any],
response_url: str,
+ cell_names: list[str] | None = None, # TODO(cells): make required once region_names is removed
+ region_names: list[str] | None = None, # TODO(cells): remove after queue drains
) -> None:
"""
This task asks relevant cell silos for response data to send asynchronously to Discord. It
@@ -159,7 +161,9 @@ def convert_to_async_discord_response(
In the event this task finishes prior to returning the above type, the outbound post will fail.
"""
- response = _AsyncDiscordDispatcher(payload, response_url).dispatch(region_names)
+ response = _AsyncDiscordDispatcher(payload, response_url).dispatch(
+ cell_names or region_names or []
+ )
if response is not None and response.status_code == status.HTTP_404_NOT_FOUND:
raise Exception("Discord hook is not ready.")
From 9b4d18d1b77f785f6229db1546b40e7f11040788 Mon Sep 17 00:00:00 2001
From: Nick
Date: Tue, 31 Mar 2026 14:24:18 -0400
Subject: [PATCH 25/51] feat(search): Switch filter operator from contains to
is on dropdown selection (#111668)
Tweaking the logic around selecting default operators. When a user
selects a value from the dropdown it's likely they're looking for an
exact matching value, rather than it containing that value.
This PR modifies the query builder logic so that if a user selects a
value from the value combobox it'll change it from `contains` to `is`.
However, if a user already has a valid contains filter, we do not change
the operator if selecting a value afterwards.
---------
Co-authored-by: Claude Opus 4.6
---
.../hooks/useQueryBuilderState.tsx | 64 +++++++++++++------
.../searchQueryBuilder/index.spec.tsx | 31 +++++++--
.../tokens/filter/valueCombobox.tsx | 17 ++++-
3 files changed, 87 insertions(+), 25 deletions(-)
diff --git a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx
index 1169b5a0b472..470d81647607 100644
--- a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx
+++ b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx
@@ -185,6 +185,7 @@ type UpdateTokenValueAction = {
token: TokenResult;
type: 'UPDATE_TOKEN_VALUE';
value: string;
+ op?: TermOperator;
};
type MultiSelectFilterValueAction = {
@@ -280,6 +281,32 @@ function deleteQueryTokens(
};
}
+function termOperatorToInternal(op: TermOperator): {
+ internalOp: TermOperator;
+ negated: boolean;
+} {
+ const negated =
+ op === TermOperator.NOT_EQUAL ||
+ op === TermOperator.DOES_NOT_CONTAIN ||
+ op === TermOperator.DOES_NOT_START_WITH ||
+ op === TermOperator.DOES_NOT_END_WITH;
+
+ let internalOp: TermOperator;
+ if (op === TermOperator.DOES_NOT_CONTAIN) {
+ internalOp = TermOperator.CONTAINS;
+ } else if (op === TermOperator.DOES_NOT_START_WITH) {
+ internalOp = TermOperator.STARTS_WITH;
+ } else if (op === TermOperator.DOES_NOT_END_WITH) {
+ internalOp = TermOperator.ENDS_WITH;
+ } else if (op === TermOperator.NOT_EQUAL) {
+ internalOp = TermOperator.DEFAULT;
+ } else {
+ internalOp = op;
+ }
+
+ return {negated, internalOp};
+}
+
export function modifyFilterOperatorQuery(
query: string,
token: TokenResult,
@@ -289,23 +316,10 @@ export function modifyFilterOperatorQuery(
return modifyFilterOperatorDate(query, token, newOperator);
}
+ const {negated, internalOp} = termOperatorToInternal(newOperator);
const newToken: TokenResult = {...token};
- newToken.negated =
- newOperator === TermOperator.NOT_EQUAL ||
- newOperator === TermOperator.DOES_NOT_CONTAIN ||
- newOperator === TermOperator.DOES_NOT_START_WITH ||
- newOperator === TermOperator.DOES_NOT_END_WITH;
-
- if (newOperator === TermOperator.DOES_NOT_CONTAIN) {
- newToken.operator = TermOperator.CONTAINS;
- } else if (newOperator === TermOperator.DOES_NOT_START_WITH) {
- newToken.operator = TermOperator.STARTS_WITH;
- } else if (newOperator === TermOperator.DOES_NOT_END_WITH) {
- newToken.operator = TermOperator.ENDS_WITH;
- } else {
- newToken.operator =
- newOperator === TermOperator.NOT_EQUAL ? TermOperator.DEFAULT : newOperator;
- }
+ newToken.negated = negated;
+ newToken.operator = internalOp;
return replaceQueryToken(query, token, stringifyToken(newToken));
}
@@ -605,7 +619,8 @@ function replaceTokensWithText(
export function modifyFilterValue(
query: string,
token: TokenResult,
- newValue: string
+ newValue: string,
+ newOp?: TermOperator
): string {
if (isDateToken(token)) {
return modifyFilterValueDate(query, token, newValue);
@@ -614,7 +629,18 @@ export function modifyFilterValue(
// stop the user from entering multiple wildcards by themselves
newValue = newValue.replace(/\*\*+/g, '*');
- return replaceQueryToken(query, token.value, newValue);
+ // No operator change — just replace the value (existing behavior)
+ if (newOp === undefined) {
+ return replaceQueryToken(query, token.value, newValue);
+ }
+
+ // Operator change — replace the entire filter token atomically
+ const {negated, internalOp} = termOperatorToInternal(newOp);
+
+ const prefix = negated ? '!' : '';
+ const keyStr = stringifyToken(token.key);
+ const replacement = `${prefix}${keyStr}:${internalOp}${newValue}`;
+ return replaceQueryToken(query, token, replacement);
}
function updateFilterMultipleValues(
@@ -1049,7 +1075,7 @@ export function useQueryBuilderState({
case 'UPDATE_TOKEN_VALUE':
return {
...state,
- query: modifyFilterValue(state.query, action.token, action.value),
+ query: modifyFilterValue(state.query, action.token, action.value, action.op),
};
case 'UPDATE_LOGIC_OPERATOR':
return updateLogicOperator(state, action);
diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx
index 8cd8cf77dded..4e82b8c027c7 100644
--- a/static/app/components/searchQueryBuilder/index.spec.tsx
+++ b/static/app/components/searchQueryBuilder/index.spec.tsx
@@ -1089,21 +1089,43 @@ describe('SearchQueryBuilder', () => {
await userEvent.click(await screen.findByRole('option', {name: 'Firefox'}));
- // New token should have a value
+ // New token should have a value, and selecting from dropdown switches operator to "is"
expect(
screen.getByRole('row', {
- name: `browser.name:${WildcardOperators.CONTAINS}Firefox`,
+ name: 'browser.name:Firefox',
})
).toBeInTheDocument();
// Now we call onChange
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange).toHaveBeenCalledWith(
- `browser.name:${WildcardOperators.CONTAINS}Firefox`,
+ 'browser.name:Firefox',
expect.anything()
);
});
+ it('does not switch operator to "is" when filter already has a value', async () => {
+ render(
+ ,
+ {organization: {features: ['search-query-builder-input-flow-changes']}}
+ );
+
+ await userEvent.click(
+ screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
+ );
+ await userEvent.click(await screen.findByRole('option', {name: 'Chrome'}));
+
+ // Operator should remain "contains" since there was already a value
+ expect(
+ await screen.findByRole('row', {
+ name: `browser.name:${WildcardOperators.CONTAINS}[firefox,Chrome]`,
+ })
+ ).toBeInTheDocument();
+ });
+
it('can add free text by typing', async () => {
const mockOnSearch = jest.fn();
render( );
@@ -1266,9 +1288,10 @@ describe('SearchQueryBuilder', () => {
await userEvent.keyboard('{enter}');
await userEvent.click(screen.getByRole('option', {name: '[Filtered]'}));
+ // Selecting from dropdown switches operator from contains to "is"
expect(
await screen.findByRole('row', {
- name: `message:${WildcardOperators.CONTAINS}"[Filtered]"`,
+ name: 'message:"[Filtered]"',
})
).toBeInTheDocument();
});
diff --git a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx
index ca22790f1d13..ab8cdef9324b 100644
--- a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx
+++ b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx
@@ -749,7 +749,7 @@ export function SearchQueryBuilderValueCombobox({
);
const updateFilterValue = useCallback(
- (value: string) => {
+ (value: string, op?: TermOperator) => {
if (token.filter === FilterType.HAS) {
const suggested = getSuggestedFilterKey(value);
if (suggested) {
@@ -793,6 +793,7 @@ export function SearchQueryBuilderValueCombobox({
type: 'UPDATE_TOKEN_VALUE',
token,
value: newValue,
+ op,
});
if (newValue && newValue !== '""' && !ctrlKeyPressed) {
@@ -809,6 +810,7 @@ export function SearchQueryBuilderValueCombobox({
getFilterValueType(token, fieldDefinition),
replaceCommaSeparatedValue(inputValue, selectionIndex, escapeTagValue(value))
),
+ op,
});
if (!ctrlKeyPressed) {
@@ -819,6 +821,7 @@ export function SearchQueryBuilderValueCombobox({
type: 'UPDATE_TOKEN_VALUE',
token,
value: cleanedValue,
+ op,
});
onCommit();
}
@@ -860,7 +863,17 @@ export function SearchQueryBuilderValueCombobox({
return;
}
- updateFilterValue(value);
+ // When selecting from dropdown with no existing value, switch from "contains" to "is"
+ let newOp: TermOperator | undefined;
+ if (
+ token.operator === TermOperator.CONTAINS &&
+ token.value.type === Token.VALUE_TEXT &&
+ !token.value.value
+ ) {
+ newOp = token.negated ? TermOperator.NOT_EQUAL : TermOperator.DEFAULT;
+ }
+
+ updateFilterValue(value, newOp);
trackAnalytics('search.value_autocompleted', {
...analyticsData,
filter_value: value,
From bb1eb15d023640dce85220bcce78967f0eb62a7d Mon Sep 17 00:00:00 2001
From: Christinarlong <60594860+Christinarlong@users.noreply.github.com>
Date: Tue, 31 Mar 2026 11:25:07 -0700
Subject: [PATCH 26/51] feat(sentry apps): Add circuit breaker into webhook
code (#111723)
---
.github/CODEOWNERS | 1 +
src/sentry/features/temporary.py | 2 +
src/sentry/options/defaults.py | 23 +++
src/sentry/sentry_apps/metrics.py | 1 +
.../exceptions/__init__.py | 2 +-
.../utils/sentry_apps/circuit_breaker.py | 39 ++++
src/sentry/utils/sentry_apps/webhooks.py | 123 +++++++++---
.../sentry_apps/tasks/test_sentry_apps.py | 2 +-
tests/sentry/utils/sentry_apps/__init__.py | 0
.../utils/sentry_apps/test_webhook_timeout.py | 74 +++++++-
.../sentry/utils/sentry_apps/test_webhooks.py | 179 ++++++++++++++++++
11 files changed, 412 insertions(+), 34 deletions(-)
create mode 100644 src/sentry/utils/sentry_apps/circuit_breaker.py
create mode 100644 tests/sentry/utils/sentry_apps/__init__.py
create mode 100644 tests/sentry/utils/sentry_apps/test_webhooks.py
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index fd5d107b0a33..25b0790a64d2 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -448,6 +448,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get
/src/sentry/sentry_apps/ @getsentry/product-owners-settings-integrations @getsentry/ecosystem
/tests/sentry/sentry_apps/ @getsentry/product-owners-settings-integrations @getsentry/ecosystem
/src/sentry/utils/sentry_apps/ @getsentry/ecosystem
+/tests/sentry/utils/sentry_apps/ @getsentry/ecosystem
/src/sentry/middleware/integrations/ @getsentry/ecosystem
/src/sentry/api/endpoints/project_rule*.py @getsentry/alerts-notifications
/src/sentry/api/serializers/models/rule.py @getsentry/alerts-notifications
diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py
index 7d43ad0f34b8..3b4b3ff9827a 100644
--- a/src/sentry/features/temporary.py
+++ b/src/sentry/features/temporary.py
@@ -492,6 +492,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:conduit-demo", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable hard timeout alarm for webhooks
manager.add("organizations:sentry-app-webhook-hard-timeout", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
+ # Enable circuit breaker for webhook endpoint failure detection
+ manager.add("organizations:sentry-app-webhook-circuit-breaker", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enables organization access to the new notification platform
manager.add("organizations:notification-platform.internal-testing", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py
index 4de89276d3fc..12942114b0c1 100644
--- a/src/sentry/options/defaults.py
+++ b/src/sentry/options/defaults.py
@@ -2597,6 +2597,29 @@
flags=FLAG_AUTOMATOR_MODIFIABLE,
)
+# Circuit breaker configuration for webhook endpoint failure detection.
+# Keys match RateBasedTripStrategyConfig + CircuitBreakerConfig
+register(
+ "sentry-apps.webhook.circuit-breaker.config",
+ type=Dict,
+ default={
+ "error_limit_window": 600, # 10 minutes
+ "broken_state_duration": 300, # 5 minutes
+ "threshold": 0.5, # 50% error rate
+ "floor": 500, # 500 errors before error rate check applies
+ "metrics_key": "sentry-app.webhook", # to avoid high cardinality slug tag
+ },
+ flags=FLAG_AUTOMATOR_MODIFIABLE,
+)
+
+# When True, the circuit breaker tracks state and emits metrics but does not block requests.
+register(
+ "sentry-apps.webhook.circuit-breaker.dry-run",
+ type=Bool,
+ default=False,
+ flags=FLAG_AUTOMATOR_MODIFIABLE,
+)
+
# Enables statistical detectors for a project
register(
"statistical_detectors.enable",
diff --git a/src/sentry/sentry_apps/metrics.py b/src/sentry/sentry_apps/metrics.py
index 4d0e060beb8b..72eb5fea395f 100644
--- a/src/sentry/sentry_apps/metrics.py
+++ b/src/sentry/sentry_apps/metrics.py
@@ -71,6 +71,7 @@ class SentryAppWebhookHaltReason(StrEnum):
RESTRICTED_IP = "restricted_ip"
CONNECTION_RESET = "connection_reset"
HARD_TIMEOUT = "hard_timeout"
+ CIRCUIT_BROKEN = "circuit_broken"
class SentryAppExternalRequestFailureReason(StrEnum):
diff --git a/src/sentry/shared_integrations/exceptions/__init__.py b/src/sentry/shared_integrations/exceptions/__init__.py
index b48955b19ec3..f65164182b65 100644
--- a/src/sentry/shared_integrations/exceptions/__init__.py
+++ b/src/sentry/shared_integrations/exceptions/__init__.py
@@ -224,6 +224,6 @@ def __init__(self, field_errors: Mapping[str, Any] | None = None) -> None:
class ClientError(RequestException):
"""4xx Error Occurred"""
- def __init__(self, status_code: str, url: str, response: Response | None = None) -> None:
+ def __init__(self, status_code: str | int, url: str, response: Response | None = None) -> None:
http_error_msg = f"{status_code} Client Error: for url: {url}"
super().__init__(http_error_msg, response=response)
diff --git a/src/sentry/utils/sentry_apps/circuit_breaker.py b/src/sentry/utils/sentry_apps/circuit_breaker.py
new file mode 100644
index 000000000000..34dbd8591a47
--- /dev/null
+++ b/src/sentry/utils/sentry_apps/circuit_breaker.py
@@ -0,0 +1,39 @@
+import logging
+from collections.abc import Generator
+from contextlib import contextmanager
+
+from sentry.utils.circuit_breaker2 import CircuitBreaker
+
+logger = logging.getLogger("sentry.sentry_apps.circuit_breaker")
+
+
+@contextmanager
+def circuit_breaker_tracking(
+ breaker: CircuitBreaker | None,
+) -> Generator[None]:
+ """Track request outcome: record_error on WebhookTimeoutError, record_success on normal exit.
+
+ Handles the None case as a no-op so callers don't need nullcontext().
+ """
+ from sentry.utils.sentry_apps.webhooks import WebhookTimeoutError
+
+ if breaker is None:
+ yield
+ return
+ try:
+ yield
+
+ # Currently we only count WebhookTimeoutError as an error in the circuit breaker as those operations are the ones that are taking too long
+ # If an app returns a say 500, in a reasonable time that's okay
+ except WebhookTimeoutError:
+ # This is gross but we don't want to propagate a redis or circuit breaker error to the webhook code
+ try:
+ breaker.record_error()
+ except Exception:
+ logger.exception("sentry_apps.circuit_breaker.record_error.failure")
+ raise
+ else:
+ try:
+ breaker.record_success()
+ except Exception:
+ logger.exception("sentry_apps.circuit_breaker.record_success.failure")
diff --git a/src/sentry/utils/sentry_apps/webhooks.py b/src/sentry/utils/sentry_apps/webhooks.py
index 7e84de7d41e6..7c5380f75f42 100644
--- a/src/sentry/utils/sentry_apps/webhooks.py
+++ b/src/sentry/utils/sentry_apps/webhooks.py
@@ -4,6 +4,7 @@
from collections.abc import Callable, Mapping
from types import FrameType
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar
+from urllib.parse import urlparse
import sentry_sdk
from requests import RequestException, Response
@@ -13,6 +14,8 @@
from sentry import features, options
from sentry.exceptions import RestrictedIPAddress
from sentry.http import safe_urlopen
+from sentry.integrations.utils.metrics import EventLifecycle
+from sentry.organizations.services.organization.model import RpcUserOrganizationContext
from sentry.organizations.services.organization.service import organization_service
from sentry.sentry_apps.metrics import (
SentryAppEventType,
@@ -23,7 +26,10 @@
from sentry.sentry_apps.utils.errors import SentryAppSentryError
from sentry.shared_integrations.exceptions import ApiHostError, ApiTimeoutError, ClientError
from sentry.taskworker.timeout import timeout_alarm
+from sentry.utils import metrics
+from sentry.utils.circuit_breaker2 import CircuitBreaker, RateBasedTripStrategy
from sentry.utils.sentry_apps import SentryAppWebhookRequestsBuffer
+from sentry.utils.sentry_apps.circuit_breaker import circuit_breaker_tracking
if TYPE_CHECKING:
from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent
@@ -51,7 +57,6 @@ def _handle_webhook_timeout(signum: int, frame: FrameType | None) -> None:
"""Handler for when a webhook request exceeds the hard timeout deadline.
- This is a workaround for safe_create_connection sockets hanging when the given url
cannot be reached or resolved.
- - TODO(christinarlong): Add sentry app disabling logic here
"""
raise WebhookTimeoutError("Webhook request exceeded hard timeout deadline")
@@ -73,6 +78,79 @@ def wrapper(
return wrapper
+def _create_circuit_breaker(
+ sentry_app: SentryApp | RpcSentryApp,
+ organization_context: RpcUserOrganizationContext | None,
+) -> CircuitBreaker | None:
+ if organization_context is None or not features.has(
+ "organizations:sentry-app-webhook-circuit-breaker",
+ organization_context.organization,
+ ):
+ return None
+ config = options.get("sentry-apps.webhook.circuit-breaker.config")
+ return CircuitBreaker(
+ key=f"sentry-app.webhook.{sentry_app.slug}",
+ config=config,
+ trip_strategy=RateBasedTripStrategy.from_config(config),
+ )
+
+
+def _circuit_breaker_allows_request(
+ circuit_breaker: CircuitBreaker | None,
+ sentry_app: SentryApp | RpcSentryApp,
+ org_id: int,
+ lifecycle: EventLifecycle,
+) -> bool:
+ if circuit_breaker is None or circuit_breaker.should_allow_request():
+ return True
+
+ dry_run = options.get("sentry-apps.webhook.circuit-breaker.dry-run")
+ if dry_run:
+ metrics.incr(
+ "sentry_app.webhook.circuit_breaker.would_block",
+ tags={"slug": sentry_app.slug},
+ )
+ logger.warning(
+ "sentry_app.webhook.circuit_breaker.would_block",
+ extra={"slug": sentry_app.slug, "org_id": org_id},
+ )
+ return True
+
+ lifecycle.record_halt(
+ halt_reason=f"send_and_save_webhook_request.{SentryAppWebhookHaltReason.CIRCUIT_BROKEN}"
+ )
+ return False
+
+
+def _send_webhook_request(
+ url: str,
+ app_platform_event: AppPlatformEvent[T],
+ organization_context: RpcUserOrganizationContext | None,
+) -> Response:
+ if organization_context is not None and features.has(
+ "organizations:sentry-app-webhook-hard-timeout",
+ organization_context.organization,
+ ):
+ # We're using a signal based timeout here because we need to interrupt the blocking
+ # socket.connect() operation. See SENTRY-5HA6 for more context. Here we're hanging at
+ # the socket.connect() call and the timeout we set in safe_urlopen is not being respected.
+ timeout_seconds = options.get("sentry-apps.webhook.hard-timeout.sec")
+ with timeout_alarm(timeout_seconds, _handle_webhook_timeout):
+ return safe_urlopen(
+ url=url,
+ data=app_platform_event.body,
+ headers=app_platform_event.headers,
+ timeout=options.get("sentry-apps.webhook.timeout.sec"),
+ )
+
+ return safe_urlopen(
+ url=url,
+ data=app_platform_event.body,
+ headers=app_platform_event.headers,
+ timeout=options.get("sentry-apps.webhook.timeout.sec"),
+ )
+
+
@sentry_sdk.trace(name="send_and_save_webhook_request")
@ignore_unpublished_app_errors
def send_and_save_webhook_request(
@@ -124,28 +202,12 @@ def send_and_save_webhook_request(
include_projects=False,
include_teams=False,
)
- if organization_context is not None and features.has(
- "organizations:sentry-app-webhook-hard-timeout",
- organization_context.organization,
- ):
- # We're using a signal based timeout here because we need to interrupt the blocking socket.connect() opeartion.
- # See SENTRY-5HA6 for more context. Here we're hanging at the socket.connect() call and the timeout we set
- # in safe_urlopen is not being respected.
- timeout_seconds = options.get("sentry-apps.webhook.hard-timeout.sec")
- with timeout_alarm(timeout_seconds, _handle_webhook_timeout):
- response = safe_urlopen(
- url=url,
- data=app_platform_event.body,
- headers=app_platform_event.headers,
- timeout=options.get("sentry-apps.webhook.timeout.sec"),
- )
- else:
- response = safe_urlopen(
- url=url,
- data=app_platform_event.body,
- headers=app_platform_event.headers,
- timeout=options.get("sentry-apps.webhook.timeout.sec"),
- )
+ circuit_breaker = _create_circuit_breaker(sentry_app, organization_context)
+ if not _circuit_breaker_allows_request(circuit_breaker, sentry_app, org_id, lifecycle):
+ return Response()
+
+ with circuit_breaker_tracking(circuit_breaker):
+ response = _send_webhook_request(url, app_platform_event, organization_context)
except WebhookTimeoutError:
lifecycle.record_halt(
@@ -186,13 +248,19 @@ def send_and_save_webhook_request(
raise
track_response_code(response.status_code, slug, event)
+
+ project_id = (
+ int(p_id)
+ if (p_id := response.headers.get("Sentry-Hook-Project")) and p_id.isdigit()
+ else None
+ )
buffer.add_request(
response_code=response.status_code,
org_id=org_id,
event=event,
url=url,
error_id=response.headers.get("Sentry-Hook-Error"),
- project_id=response.headers.get("Sentry-Hook-Project"),
+ project_id=project_id,
response=response,
headers=app_platform_event.headers,
)
@@ -223,13 +291,15 @@ def send_and_save_webhook_request(
lifecycle.record_halt(
halt_reason=f"send_and_save_webhook_request.{SentryAppWebhookHaltReason.INTEGRATOR_ERROR}"
)
- raise ApiHostError.from_request(response.request)
+ raise ApiHostError(f"Unable to reach host: {urlparse(url).netloc}", url=url)
elif response.status_code == status.HTTP_504_GATEWAY_TIMEOUT:
lifecycle.record_halt(
halt_reason=f"send_and_save_webhook_request.{SentryAppWebhookHaltReason.INTEGRATOR_ERROR}"
)
- raise ApiTimeoutError.from_request(response.request)
+ raise ApiTimeoutError(
+ f"Timed out attempting to reach host: {urlparse(url).netloc}", url=url
+ )
elif 400 <= response.status_code < 500:
lifecycle.record_halt(
@@ -243,4 +313,5 @@ def send_and_save_webhook_request(
except RequestException as e:
lifecycle.record_halt(e)
raise
+
return response
diff --git a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py
index 5cc1080ac3f4..141cc6f7f250 100644
--- a/tests/sentry/sentry_apps/tasks/test_sentry_apps.py
+++ b/tests/sentry/sentry_apps/tasks/test_sentry_apps.py
@@ -1713,7 +1713,7 @@ def test_saves_error_event_id_if_in_header(self, safe_urlopen: MagicMock) -> Non
assert first_request["event_type"] == "issue.assigned"
assert first_request["organization_id"] == self.install.organization_id
assert first_request["error_id"] == "d5111da2c28645c5889d072017e3445d"
- assert first_request["project_id"] == "1"
+ assert first_request["project_id"] == 1
@patch("sentry.utils.sentry_apps.webhooks.safe_urlopen", return_value=MockResponseInstance)
diff --git a/tests/sentry/utils/sentry_apps/__init__.py b/tests/sentry/utils/sentry_apps/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/sentry/utils/sentry_apps/test_webhook_timeout.py b/tests/sentry/utils/sentry_apps/test_webhook_timeout.py
index 755805366713..a67386acf813 100644
--- a/tests/sentry/utils/sentry_apps/test_webhook_timeout.py
+++ b/tests/sentry/utils/sentry_apps/test_webhook_timeout.py
@@ -1,5 +1,6 @@
+import signal
import time
-from unittest.mock import ANY, Mock, patch
+from unittest.mock import Mock, patch
import pytest
from requests import Response
@@ -91,6 +92,33 @@ def slow_urlopen(*args, **kwargs):
with pytest.raises(WebhookTimeoutError, match="Webhook request exceeded hard timeout"):
send_and_save_webhook_request(self.sentry_app, app_platform_event)
+ @with_feature("organizations:sentry-app-webhook-hard-timeout")
+ @override_options(
+ {
+ "sentry-apps.webhook.hard-timeout.sec": 1.0,
+ "sentry-apps.webhook.timeout.sec": 10.0,
+ "sentry-apps.webhook.restricted-webhook-sending": [],
+ }
+ )
+ @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
+ def test_timeout_exception_propagates(self, mock_safe_urlopen):
+ # Make safe_urlopen sleep
+ def slow_urlopen(*args, **kwargs):
+ time.sleep(2.0)
+ return Mock(spec=Response)
+
+ mock_safe_urlopen.side_effect = slow_urlopen
+
+ app_platform_event = AppPlatformEvent(
+ resource=SentryAppResourceType.ISSUE,
+ action=IssueActionType.CREATED,
+ install=self.install,
+ data={"test": "data"},
+ )
+
+ with pytest.raises(WebhookTimeoutError):
+ send_and_save_webhook_request(self.sentry_app, app_platform_event)
+
@with_feature("organizations:sentry-app-webhook-hard-timeout")
@override_options(
{
@@ -134,14 +162,46 @@ def slow_urlopen(*args, **kwargs):
}
)
@patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
- @patch("sentry.utils.sentry_apps.webhooks.timeout_alarm")
- def test_timeout_alarm_is_used(self, mock_timeout_alarm, mock_safe_urlopen):
+ def test_timeout_alarm_restores_signal_handler(self, mock_safe_urlopen):
+ # Get original handler
+ original_handler = signal.signal(signal.SIGALRM, signal.SIG_DFL)
+ signal.signal(signal.SIGALRM, original_handler)
+
+ # Mock quick response
+ mock_response = Mock(spec=Response)
+ mock_response.status_code = 200
+ mock_response.headers = {}
+ mock_safe_urlopen.return_value = mock_response
+
+ app_platform_event = AppPlatformEvent(
+ resource=SentryAppResourceType.ISSUE,
+ action=IssueActionType.CREATED,
+ install=self.install,
+ data={"test": "data"},
+ )
+
+ send_and_save_webhook_request(self.sentry_app, app_platform_event)
+
+ # Verify signal handler was restored
+ current_handler = signal.signal(signal.SIGALRM, signal.SIG_DFL)
+ signal.signal(signal.SIGALRM, current_handler)
+ assert current_handler == original_handler
+
+ @with_feature("organizations:sentry-app-webhook-hard-timeout")
+ @override_options(
+ {
+ "sentry-apps.webhook.hard-timeout.sec": 5.0,
+ "sentry-apps.webhook.timeout.sec": 1.0,
+ "sentry-apps.webhook.restricted-webhook-sending": [],
+ "sentry-apps.webhook-logging.enabled": {"installation_uuid": [], "sentry_app_slug": []},
+ }
+ )
+ @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
+ def test_alarm_cancelled_after_successful_webhook(self, mock_safe_urlopen):
mock_response = Mock(spec=Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_safe_urlopen.return_value = mock_response
- mock_timeout_alarm.return_value.__enter__ = Mock(return_value=None)
- mock_timeout_alarm.return_value.__exit__ = Mock(return_value=False)
app_platform_event = AppPlatformEvent(
resource=SentryAppResourceType.ISSUE,
@@ -152,4 +212,6 @@ def test_timeout_alarm_is_used(self, mock_timeout_alarm, mock_safe_urlopen):
send_and_save_webhook_request(self.sentry_app, app_platform_event)
- mock_timeout_alarm.assert_called_once_with(5.0, ANY)
+ # Verify no alarm is pending
+ remaining = signal.alarm(0)
+ assert remaining == 0 # No alarm was pending
diff --git a/tests/sentry/utils/sentry_apps/test_webhooks.py b/tests/sentry/utils/sentry_apps/test_webhooks.py
new file mode 100644
index 000000000000..5e5cf54b47e7
--- /dev/null
+++ b/tests/sentry/utils/sentry_apps/test_webhooks.py
@@ -0,0 +1,179 @@
+from collections import namedtuple
+from unittest.mock import Mock, patch
+
+import pytest
+from requests import Response
+from requests.exceptions import Timeout
+
+from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent
+from sentry.sentry_apps.utils.webhooks import IssueActionType, SentryAppResourceType
+from sentry.shared_integrations.exceptions import ApiHostError
+from sentry.testutils.cases import TestCase
+from sentry.testutils.helpers.features import with_feature
+from sentry.testutils.helpers.options import override_options
+from sentry.testutils.silo import cell_silo_test
+from sentry.utils.circuit_breaker2 import CircuitBreaker
+from sentry.utils.sentry_apps.webhooks import WebhookTimeoutError, send_and_save_webhook_request
+
+
+def _raise_status_false() -> bool:
+ return False
+
+
+_MockResponse = namedtuple(
+ "_MockResponse",
+ ["headers", "content", "text", "ok", "status_code", "raise_for_status", "request"],
+)
+
+CIRCUIT_BREAKER_OPTIONS = {
+ "sentry-apps.webhook.circuit-breaker.config": {
+ "error_limit_window": 600,
+ "broken_state_duration": 300,
+ "threshold": 0.5,
+ "floor": 5, # low floor for testing
+ },
+ "sentry-apps.webhook.circuit-breaker.dry-run": False,
+ "sentry-apps.webhook.timeout.sec": 1.0,
+ "sentry-apps.webhook.restricted-webhook-sending": [],
+}
+
+
+@cell_silo_test
+class WebhookCircuitBreakerTest(TestCase):
+ def setUp(self):
+ self.organization = self.create_organization()
+ self.sentry_app = self.create_sentry_app(
+ name="TestApp",
+ organization=self.organization,
+ webhook_url="https://example.com/webhook",
+ published=True,
+ )
+ self.install = self.create_sentry_app_installation(
+ organization=self.organization, slug=self.sentry_app.slug
+ )
+
+ def _make_event(self):
+ return AppPlatformEvent(
+ resource=SentryAppResourceType.ISSUE,
+ action=IssueActionType.CREATED,
+ install=self.install,
+ data={"test": "data"},
+ )
+
+ @override_options(CIRCUIT_BREAKER_OPTIONS)
+ @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
+ def test_no_circuit_breaker_without_feature_flag(self, mock_safe_urlopen):
+ """Without feature flag, no circuit breaker is instantiated."""
+ mock_response = Mock(spec=Response)
+ mock_response.status_code = 200
+ mock_response.headers = {}
+ mock_safe_urlopen.return_value = mock_response
+
+ response = send_and_save_webhook_request(self.sentry_app, self._make_event())
+ assert response is not None
+ assert response.status_code == 200
+
+ @with_feature("organizations:sentry-app-webhook-circuit-breaker")
+ @override_options(
+ {**CIRCUIT_BREAKER_OPTIONS, "sentry-apps.webhook.circuit-breaker.dry-run": True}
+ )
+ @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
+ @patch("sentry.utils.sentry_apps.webhooks.CircuitBreaker")
+ def test_dry_run_emits_metric_but_sends_webhook(self, MockBreaker, mock_safe_urlopen):
+ """In dry-run mode, a broken circuit emits would_block but still sends."""
+ mock_breaker_instance = MockBreaker.return_value
+ mock_breaker_instance.should_allow_request.return_value = False
+
+ mock_response = Mock(spec=Response)
+ mock_response.status_code = 200
+ mock_response.headers = {}
+ mock_safe_urlopen.return_value = mock_response
+
+ response = send_and_save_webhook_request(self.sentry_app, self._make_event())
+ # In dry-run, webhook is still sent
+ assert response is not None
+ assert response.status_code == 200
+ mock_safe_urlopen.assert_called_once()
+
+ @with_feature("organizations:sentry-app-webhook-circuit-breaker")
+ @override_options(CIRCUIT_BREAKER_OPTIONS)
+ @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
+ @patch("sentry.utils.sentry_apps.webhooks.CircuitBreaker")
+ def test_blocking_mode_returns_empty_response(self, MockBreaker, mock_safe_urlopen):
+ """With dry-run OFF, a broken circuit blocks the webhook."""
+ mock_breaker_instance = MockBreaker.return_value
+ mock_breaker_instance.should_allow_request.return_value = False
+
+ send_and_save_webhook_request(self.sentry_app, self._make_event())
+ # Webhook is blocked — no HTTP call made
+ mock_safe_urlopen.assert_not_called()
+
+ @with_feature("organizations:sentry-app-webhook-circuit-breaker")
+ @override_options(CIRCUIT_BREAKER_OPTIONS)
+ @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
+ @patch("sentry.utils.sentry_apps.webhooks.CircuitBreaker")
+ def test_hard_timeout_calls_record_error(self, MockBreaker, mock_safe_urlopen):
+ """WebhookTimeoutError (hard timeout) should call record_error() on the circuit breaker."""
+ mock_breaker_instance = MockBreaker.return_value
+ mock_breaker_instance.should_allow_request.return_value = True
+ mock_safe_urlopen.side_effect = WebhookTimeoutError()
+
+ with pytest.raises(WebhookTimeoutError):
+ send_and_save_webhook_request(self.sentry_app, self._make_event())
+
+ mock_breaker_instance.record_error.assert_called_once()
+ mock_breaker_instance.record_success.assert_not_called()
+
+ @with_feature("organizations:sentry-app-webhook-circuit-breaker")
+ @override_options(CIRCUIT_BREAKER_OPTIONS)
+ @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
+ @patch("sentry.utils.sentry_apps.webhooks.CircuitBreaker")
+ def test_timeout_does_not_record_error(self, MockBreaker, mock_safe_urlopen):
+ """Regular Timeout exceptions are not recorded as circuit breaker errors — only
+ WebhookTimeoutError (hard timeout) is. A fast network timeout still counts as
+ a completed attempt from the breaker's perspective."""
+ mock_breaker_instance = MockBreaker.return_value
+ mock_breaker_instance.should_allow_request.return_value = True
+ mock_safe_urlopen.side_effect = Timeout()
+
+ with pytest.raises(Timeout):
+ send_and_save_webhook_request(self.sentry_app, self._make_event())
+
+ mock_breaker_instance.record_error.assert_not_called()
+ mock_breaker_instance.record_success.assert_not_called()
+
+ @with_feature("organizations:sentry-app-webhook-circuit-breaker")
+ @override_options(CIRCUIT_BREAKER_OPTIONS)
+ @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
+ @patch("sentry.utils.sentry_apps.webhooks.CircuitBreaker")
+ def test_success_calls_record_success(self, MockBreaker, mock_safe_urlopen):
+ """Successful responses should call record_success()."""
+ mock_breaker_instance = MockBreaker.return_value
+ mock_breaker_instance.should_allow_request.return_value = True
+
+ mock_response = Mock(spec=Response)
+ mock_response.status_code = 200
+ mock_response.headers = {}
+ mock_safe_urlopen.return_value = mock_response
+
+ send_and_save_webhook_request(self.sentry_app, self._make_event())
+ mock_breaker_instance.record_success.assert_called_once()
+
+ @with_feature("organizations:sentry-app-webhook-circuit-breaker")
+ @override_options(CIRCUIT_BREAKER_OPTIONS)
+ @patch.object(CircuitBreaker, "record_success")
+ @patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
+ def test_http_error_response_records_success_and_raises(
+ self, mock_safe_urlopen, mock_record_success
+ ):
+ """When the circuit breaker allows a request but the response is an HTTP error,
+ the breaker records success (the connection completed) and the normal error
+ handling still raises the appropriate exception."""
+ mock_safe_urlopen.return_value = _MockResponse(
+ {}, '{"error": "service unavailable"}', "", False, 503, _raise_status_false, None
+ )
+
+ with pytest.raises(ApiHostError):
+ send_and_save_webhook_request(self.sentry_app, self._make_event())
+
+ mock_record_success.assert_called_once()
From ba1591e45e238e81a4a87732b7de33aa8416e5df Mon Sep 17 00:00:00 2001
From: joshuarli
Date: Tue, 31 Mar 2026 11:35:54 -0700
Subject: [PATCH 27/51] fix(st): add src/sentry/constants.py to
full_suite_triggers (#111922)
tests failed on master here:
https://github.com/getsentry/sentry/actions/runs/23808567753/job/69388944325
https://github.com/getsentry/sentry/actions/runs/23767928687/job/69252070904
```
gh api "repos/getsentry/sentry/actions/workflows/backend.yml/runs?head
_sha=b8bd5add647a14a7c80afae86cf5cafe1ec26ba5&per_page=10" --jq
'.workflow_runs[] | {id, run_number, created_at, run_attempt, event,
status}'
```
and the newer stuff (prints how many test contexts changed files are
linked to):
https://github.com/getsentry/sentry/actions/runs/23767928704/job/69252070278#step:7:32
src/sentry/constants.py has plenty of coverage apparently but not enough
---
.github/workflows/scripts/compute-sentry-selected-tests.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/scripts/compute-sentry-selected-tests.py b/.github/workflows/scripts/compute-sentry-selected-tests.py
index 7a9f2f02a463..65bbcd1a6ba0 100644
--- a/.github/workflows/scripts/compute-sentry-selected-tests.py
+++ b/.github/workflows/scripts/compute-sentry-selected-tests.py
@@ -43,6 +43,7 @@
FULL_SUITE_TRIGGERS: list[str | re.Pattern[str]] = [
"src/sentry/testutils/pytest/sentry.py",
+ "src/sentry/constants.py",
"pyproject.toml",
"src/sentry/conf/server.py",
"src/sentry/web/urls.py",
From e8a2643f2c77db295a5e031acef28e39e004c6f7 Mon Sep 17 00:00:00 2001
From: Dominik Buszowiecki <44422760+DominikB2014@users.noreply.github.com>
Date: Tue, 31 Mar 2026 14:40:23 -0400
Subject: [PATCH 28/51] feat(features): Add data browsing widget unfurl feature
flag (#111897)
## Summary
- Registers a new FlagPole feature flag
`organizations:data-browsing-widget-unfurl` to gate URL unfurling in the
data browsing widget.
- The flag is API-exposed so the frontend can check it.
## Test plan
- [ ] Verify the feature flag is registered correctly by checking
`features.has("organizations:data-browsing-widget-unfurl", ...)` in a
shell
- [ ] Confirm the flag appears in the organization features API response
when enabled
DAIN-1439
---
src/sentry/features/temporary.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py
index 3b4b3ff9827a..497ef5b09c6b 100644
--- a/src/sentry/features/temporary.py
+++ b/src/sentry/features/temporary.py
@@ -356,6 +356,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:insights-modules-use-eap", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable access to insights metrics alerts
manager.add("organizations:insights-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
+ # Enable data browsing widget unfurl
+ manager.add("organizations:data-browsing-widget-unfurl", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable dual-write of Seer project preferences to Sentry DB and Seer API
manager.add("organizations:seer-project-settings-dual-write", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable public RPC endpoint for local seer development
From e818053d16336275ec9f1be0641d2417626e9da6 Mon Sep 17 00:00:00 2001
From: Shashank Jarmale
Date: Tue, 31 Mar 2026 11:42:48 -0700
Subject: [PATCH 29/51] feat(occurrences on eap): Implement EAP query for
tagstore groups user counts (errors) (#111861)
Implements double reads of occurrences from EAP for
`get_groups_user_counts` in `src/sentry/tagstore/snuba/backend.py`.
---
src/sentry/tagstore/snuba/backend.py | 106 ++++++++++-
tests/snuba/tagstore/test_tagstore_backend.py | 175 +++++++++++++++++-
2 files changed, 279 insertions(+), 2 deletions(-)
diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py
index e49468820198..e455fbf5ef40 100644
--- a/src/sentry/tagstore/snuba/backend.py
+++ b/src/sentry/tagstore/snuba/backend.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import functools
+import logging
import os
import re
from collections import defaultdict
@@ -30,6 +31,7 @@
from sentry.api.utils import default_start_end_dates, handle_query_errors
from sentry.eventstream.item_helpers import format_attr_key
from sentry.issues.grouptype import GroupCategory
+from sentry.models.environment import Environment
from sentry.models.group import Group
from sentry.models.organization import Organization
from sentry.models.project import Project
@@ -56,6 +58,7 @@
from sentry.search.events.types import SnubaParams
from sentry.services.eventstore.query_preprocessing import translate_environment_ids_to_names
from sentry.snuba.dataset import Dataset
+from sentry.snuba.occurrences_rpc import OccurrenceCategory, Occurrences
from sentry.snuba.referrer import Referrer
from sentry.tagstore.base import TOP_VALUES_DEFAULT_LIMIT, TagKeyStatus, TagStorage
from sentry.tagstore.exceptions import GroupTagKeyNotFound, TagKeyNotFound
@@ -70,6 +73,8 @@
raw_snql_query,
)
+logger = logging.getLogger(__name__)
+
_max_unsampled_projects = 50
if os.environ.get("SENTRY_SINGLE_TENANT"):
# This is a patch we used to have in single-tenant, but moving here
@@ -133,6 +138,12 @@ def _translate_filter_keys(
return forward(filter_keys)
+def _reasonable_user_counts_match(control: dict[int, int], experimental: dict[int, int]) -> bool:
+ if not set(experimental.keys()).issubset(set(control.keys())):
+ return False
+ return all(experimental[group_id] <= control[group_id] for group_id in experimental)
+
+
class _OptimizeKwargs(TypedDict, total=False):
turbo: bool
sample: int
@@ -1166,7 +1177,7 @@ def get_groups_user_counts(
tenant_ids: dict[str, str | int] | None = None,
referrer: str = "tagstore.get_groups_user_counts",
) -> dict[int, int]:
- return self.__get_groups_user_counts(
+ snuba_result = self.__get_groups_user_counts(
project_ids,
group_ids,
environment_ids,
@@ -1177,6 +1188,99 @@ def get_groups_user_counts(
referrer,
tenant_ids=tenant_ids,
)
+ result = snuba_result
+
+ callsite = "SnubaTagStorage::get_groups_user_counts"
+ if EAPOccurrencesComparator.should_check_experiment(callsite):
+ eap_result = self._eap_get_groups_user_counts(
+ project_ids, group_ids, environment_ids, start, end, referrer
+ )
+ result = EAPOccurrencesComparator.check_and_choose(
+ control_data=snuba_result,
+ experimental_data=eap_result,
+ callsite=callsite,
+ is_experimental_data_a_null_result=len(eap_result) == 0,
+ reasonable_match_comparator=_reasonable_user_counts_match,
+ debug_context={
+ "project_ids": list(project_ids),
+ "group_ids": list(group_ids),
+ "environment_ids": list(environment_ids) if environment_ids else None,
+ "start": start.isoformat() if start else None,
+ "end": end.isoformat() if end else None,
+ },
+ )
+
+ return result
+
+ def _eap_get_groups_user_counts(
+ self,
+ project_ids: Sequence[int],
+ group_ids: Sequence[int],
+ environment_ids: Sequence[int] | None,
+ start: datetime | None,
+ end: datetime | None,
+ referrer: str,
+ ) -> dict[int, int]:
+ organization_id = get_organization_id_from_project_ids(project_ids)
+
+ now = datetime.now(tz=timezone.utc)
+ resolved_start = start if start else now - timedelta(days=90)
+ resolved_end = end if end else now
+
+ try:
+ organization = Organization.objects.get_from_cache(id=organization_id)
+ except Organization.DoesNotExist:
+ return defaultdict(int)
+
+ projects = list(Project.objects.filter(id__in=project_ids))
+ if not projects:
+ return defaultdict(int)
+
+ environments = (
+ list(Environment.objects.filter(id__in=environment_ids)) if environment_ids else []
+ )
+
+ query_string = f"group_id:[{','.join(str(gid) for gid in group_ids)}]"
+
+ snuba_params = SnubaParams(
+ start=resolved_start,
+ end=resolved_end,
+ organization=organization,
+ projects=projects,
+ environments=environments,
+ )
+
+ try:
+ result = Occurrences.run_table_query(
+ params=snuba_params,
+ query_string=query_string,
+ selected_columns=["group_id", "count_unique(user)"],
+ orderby=["-count_unique(user)"],
+ offset=0,
+ limit=len(group_ids),
+ referrer=referrer,
+ config=SearchResolverConfig(),
+ occurrence_category=OccurrenceCategory.ERROR,
+ )
+
+ return defaultdict(
+ int,
+ {
+ int(row["group_id"]): int(row["count_unique(user)"])
+ for row in result.get("data", [])
+ if row.get("group_id") is not None and row.get("count_unique(user)") is not None
+ },
+ )
+ except Exception:
+ logger.exception(
+ "EAP get_groups_user_counts query failed",
+ extra={
+ "organization_id": organization_id,
+ "project_ids": list(project_ids),
+ "group_ids": list(group_ids),
+ },
+ )
+ return defaultdict(int)
def get_generic_groups_user_counts(
self,
diff --git a/tests/snuba/tagstore/test_tagstore_backend.py b/tests/snuba/tagstore/test_tagstore_backend.py
index a2dae2909bdb..2532ca806743 100644
--- a/tests/snuba/tagstore/test_tagstore_backend.py
+++ b/tests/snuba/tagstore/test_tagstore_backend.py
@@ -21,7 +21,7 @@
from sentry.tagstore.types import GroupTagValue, TagValue
from sentry.testutils.abstract import Abstract
from sentry.testutils.cases import PerformanceIssueTestCase, SnubaTestCase, TestCase
-from sentry.testutils.helpers.datetime import before_now
+from sentry.testutils.helpers.datetime import before_now, freeze_time
from sentry.utils.samples import load_data
from tests.sentry.issues.test_utils import SearchIssueTestMixin
@@ -1559,3 +1559,176 @@ def test_semver_package(self) -> None:
self.run_test("1", ["124"], self.environment)
self.run_test("4", ["456", "457a"])
self.run_test("4", ["456"], env_2)
+
+
+class TestEAPGetGroupsUserCounts(TestCase, SnubaTestCase):
+ FROZEN_TIME = before_now(hours=24).replace(hour=6, minute=0, second=0)
+
+ def setUp(self) -> None:
+ super().setUp()
+ self.ts = SnubaTagStorage()
+
+ def _store_event_with_user(
+ self, fingerprint: str, user_id: str, timestamp: float, environment: str | None = None
+ ):
+ extra: dict = {
+ "user": {"id": user_id},
+ "tags": {"sentry:user": f"id:{user_id}"},
+ }
+ if environment is not None:
+ extra["environment"] = environment
+ return self.store_events_to_snuba_and_eap(
+ fingerprint,
+ count=1,
+ timestamp=timestamp,
+ extra_event_data=extra,
+ )[0]
+
+ @freeze_time(FROZEN_TIME)
+ def test_eap_and_snuba_user_counts_match_multiple_groups(self) -> None:
+ ts = (self.FROZEN_TIME - timedelta(minutes=5)).timestamp()
+
+ # Group A: 3 events from 2 unique users (user1 appears twice)
+ self._store_event_with_user("group-a", "user1", ts)
+ self._store_event_with_user("group-a", "user1", ts)
+ event_a = self._store_event_with_user("group-a", "user2", ts)
+ group_a = event_a.group
+ assert group_a is not None
+
+ # Group B: 2 events from 2 unique users
+ self._store_event_with_user("group-b", "user3", ts)
+ event_b = self._store_event_with_user("group-b", "user4", ts)
+ group_b = event_b.group
+ assert group_b is not None
+
+ # Group C: 1 event from 1 unique user
+ event_c = self._store_event_with_user("group-c", "user5", ts)
+ group_c = event_c.group
+ assert group_c is not None
+
+ group_ids = [group_a.id, group_b.id, group_c.id]
+ start = self.FROZEN_TIME - timedelta(hours=1)
+ end = self.FROZEN_TIME + timedelta(hours=1)
+
+ snuba_result = self.ts.get_groups_user_counts(
+ project_ids=[self.project.id],
+ group_ids=group_ids,
+ environment_ids=None,
+ start=start,
+ end=end,
+ tenant_ids={"referrer": "r", "organization_id": self.project.organization_id},
+ )
+
+ eap_result = self.ts._eap_get_groups_user_counts(
+ project_ids=[self.project.id],
+ group_ids=group_ids,
+ environment_ids=None,
+ start=start,
+ end=end,
+ referrer="tagstore.get_groups_user_counts",
+ )
+
+ assert snuba_result == {group_a.id: 2, group_b.id: 2, group_c.id: 1}
+ assert eap_result == snuba_result
+
+ @freeze_time(FROZEN_TIME)
+ def test_eap_and_snuba_user_counts_match_with_environment_filter(self) -> None:
+ ts = (self.FROZEN_TIME - timedelta(minutes=5)).timestamp()
+ env = self.create_environment(project=self.project, name="production")
+
+ # 2 events in "production" env from 2 unique users
+ self._store_event_with_user("group-a", "user1", ts, environment=env.name)
+ self._store_event_with_user("group-a", "user2", ts, environment=env.name)
+
+ # 1 event in a different env (should be excluded by env filter)
+ event = self._store_event_with_user("group-a", "user3", ts, environment="staging")
+ group_a = event.group
+ assert group_a is not None
+
+ group_ids = [group_a.id]
+ start = self.FROZEN_TIME - timedelta(hours=1)
+ end = self.FROZEN_TIME + timedelta(hours=1)
+
+ # With environment filter: should only count user1, user2
+ snuba_with_env = self.ts.get_groups_user_counts(
+ project_ids=[self.project.id],
+ group_ids=group_ids,
+ environment_ids=[env.id],
+ start=start,
+ end=end,
+ tenant_ids={"referrer": "r", "organization_id": self.project.organization_id},
+ )
+
+ eap_with_env = self.ts._eap_get_groups_user_counts(
+ project_ids=[self.project.id],
+ group_ids=group_ids,
+ environment_ids=[env.id],
+ start=start,
+ end=end,
+ referrer="tagstore.get_groups_user_counts",
+ )
+
+ assert snuba_with_env == {group_a.id: 2}
+ assert eap_with_env == snuba_with_env
+
+ # Without environment filter: should count all 3 users
+ snuba_no_env = self.ts.get_groups_user_counts(
+ project_ids=[self.project.id],
+ group_ids=group_ids,
+ environment_ids=None,
+ start=start,
+ end=end,
+ tenant_ids={"referrer": "r", "organization_id": self.project.organization_id},
+ )
+
+ eap_no_env = self.ts._eap_get_groups_user_counts(
+ project_ids=[self.project.id],
+ group_ids=group_ids,
+ environment_ids=None,
+ start=start,
+ end=end,
+ referrer="tagstore.get_groups_user_counts",
+ )
+
+ assert snuba_no_env == {group_a.id: 3}
+ assert eap_no_env == snuba_no_env
+
+ @freeze_time(FROZEN_TIME)
+ def test_eap_and_snuba_user_counts_match_with_time_range_filter(self) -> None:
+ old_ts = (self.FROZEN_TIME - timedelta(hours=5)).timestamp()
+ recent_ts = (self.FROZEN_TIME - timedelta(minutes=5)).timestamp()
+
+ # Old events outside the query window
+ self._store_event_with_user("group-a", "user1", old_ts)
+ self._store_event_with_user("group-a", "user2", old_ts)
+
+ # Recent event inside the query window
+ event = self._store_event_with_user("group-a", "user3", recent_ts)
+ group_a = event.group
+ assert group_a is not None
+
+ group_ids = [group_a.id]
+ # Query window that only includes the recent event
+ start = self.FROZEN_TIME - timedelta(hours=1)
+ end = self.FROZEN_TIME + timedelta(hours=1)
+
+ snuba_result = self.ts.get_groups_user_counts(
+ project_ids=[self.project.id],
+ group_ids=group_ids,
+ environment_ids=None,
+ start=start,
+ end=end,
+ tenant_ids={"referrer": "r", "organization_id": self.project.organization_id},
+ )
+
+ eap_result = self.ts._eap_get_groups_user_counts(
+ project_ids=[self.project.id],
+ group_ids=group_ids,
+ environment_ids=None,
+ start=start,
+ end=end,
+ referrer="tagstore.get_groups_user_counts",
+ )
+
+ assert snuba_result == {group_a.id: 1}
+ assert eap_result == snuba_result
From 181221d6279631a4633f709f526b48d7b5f25f4e Mon Sep 17 00:00:00 2001
From: Sentry Bot
Date: Tue, 31 Mar 2026 12:01:36 -0700
Subject: [PATCH 30/51] ref: bump sentry-arroyo to 2.38.5 (#111789)
Co-Authored-By: bmckerry <110857332+bmckerry@users.noreply.github.com>
---------
Co-authored-by: getsentry-bot <10587625+getsentry-bot@users.noreply.github.com>
Co-authored-by: bmckerry <110857332+bmckerry@users.noreply.github.com>
---
pyproject.toml | 2 +-
src/sentry/monitors/tasks/clock_pulse.py | 5 ++++-
.../consumers/indexer/multiprocess.py | 2 +-
.../consumers/indexer/routing_producer.py | 16 +++++++---------
src/sentry/testutils/pytest/kafka.py | 5 +++--
src/sentry/utils/batching_kafka_consumer.py | 10 +++++++---
.../sentry/monitors/tasks/test_clock_pulse.py | 2 +-
uv.lock | 18 ++++++++++--------
8 files changed, 34 insertions(+), 26 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 79c2b035d3be..c7538a2aceca 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -82,7 +82,7 @@ dependencies = [
"rfc3339-validator>=0.1.2",
"rfc3986-validator>=0.1.1",
# [end] jsonschema format validators
- "sentry-arroyo>=2.38.1",
+ "sentry-arroyo>=2.38.5",
"sentry-conventions>=0.3.0",
"sentry-forked-email-reply-parser>=0.5.12.post1",
"sentry-kafka-schemas>=2.1.27",
diff --git a/src/sentry/monitors/tasks/clock_pulse.py b/src/sentry/monitors/tasks/clock_pulse.py
index 9281eed4b0e6..dc42bdcfb4eb 100644
--- a/src/sentry/monitors/tasks/clock_pulse.py
+++ b/src/sentry/monitors/tasks/clock_pulse.py
@@ -8,7 +8,10 @@
from arroyo import Partition
from arroyo import Topic as ArroyoTopic
from arroyo.backends.kafka import KafkaPayload
-from confluent_kafka.admin import AdminClient, PartitionMetadata
+from confluent_kafka.admin import ( # type: ignore[attr-defined]
+ AdminClient,
+ PartitionMetadata,
+)
from django.conf import settings
from sentry_kafka_schemas.codecs import Codec
from sentry_kafka_schemas.schema_types.ingest_monitors_v1 import ClockPulse, IngestMonitorMessage
diff --git a/src/sentry/sentry_metrics/consumers/indexer/multiprocess.py b/src/sentry/sentry_metrics/consumers/indexer/multiprocess.py
index 307fdfd50ed4..5b1042bd4664 100644
--- a/src/sentry/sentry_metrics/consumers/indexer/multiprocess.py
+++ b/src/sentry/sentry_metrics/consumers/indexer/multiprocess.py
@@ -93,7 +93,7 @@ def submit(self, message: Message[KafkaPayload | FilteredPayload]) -> None:
on_delivery=partial(
self.callback, committable=message.committable, timestamp=message.timestamp
),
- headers=message.payload.headers,
+ headers=message.payload.headers, # type: ignore[arg-type]
)
def callback(
diff --git a/src/sentry/sentry_metrics/consumers/indexer/routing_producer.py b/src/sentry/sentry_metrics/consumers/indexer/routing_producer.py
index 9690a9233312..57c2061cbec2 100644
--- a/src/sentry/sentry_metrics/consumers/indexer/routing_producer.py
+++ b/src/sentry/sentry_metrics/consumers/indexer/routing_producer.py
@@ -118,7 +118,7 @@ def poll(self) -> None:
def __delivery_callback(
self,
future: Future[str],
- error: KafkaError,
+ error: KafkaError | None,
message: ConfluentMessage,
) -> None:
if error is not None:
@@ -142,14 +142,12 @@ def submit(self, message: Message[RoutingPayload]) -> None:
future: Future[str] = Future()
future.set_running_or_notify_cancel()
- (
- producer.produce(
- topic=topic.name,
- value=output_message.payload.value,
- key=output_message.payload.key,
- headers=output_message.payload.headers,
- on_delivery=partial(self.__delivery_callback, future),
- ),
+ producer.produce(
+ topic=topic.name,
+ value=output_message.payload.value,
+ key=output_message.payload.key,
+ headers=output_message.payload.headers, # type: ignore[arg-type]
+ on_delivery=partial(self.__delivery_callback, future),
)
self.__queue.append((output_message.committable, future))
diff --git a/src/sentry/testutils/pytest/kafka.py b/src/sentry/testutils/pytest/kafka.py
index 80475030cc87..f0cd95a6179d 100644
--- a/src/sentry/testutils/pytest/kafka.py
+++ b/src/sentry/testutils/pytest/kafka.py
@@ -4,7 +4,8 @@
from collections.abc import MutableMapping
import pytest
-from confluent_kafka import Consumer, Producer
+from arroyo.processing import StreamProcessor
+from confluent_kafka import Producer
from confluent_kafka.admin import AdminClient
from sentry.testutils.pytest import xdist
@@ -73,7 +74,7 @@ def scope_consumers():
be created once per test session).
"""
- all_consumers: MutableMapping[str, Consumer | None] = {
+ all_consumers: MutableMapping[str, StreamProcessor | None] = {
xdist.get_kafka_topic("ingest-events"): None,
xdist.get_kafka_topic("outcomes"): None,
}
diff --git a/src/sentry/utils/batching_kafka_consumer.py b/src/sentry/utils/batching_kafka_consumer.py
index 47f0530e32b4..9442975cdb2b 100644
--- a/src/sentry/utils/batching_kafka_consumer.py
+++ b/src/sentry/utils/batching_kafka_consumer.py
@@ -15,7 +15,7 @@ def wait_for_topics(admin_client: AdminClient, topics: list[str], timeout: int =
"""
for topic in topics:
start = time.time()
- last_error = None
+ last_error: str | KafkaError | None = None
while True:
if time.time() > start + timeout:
@@ -25,10 +25,14 @@ def wait_for_topics(admin_client: AdminClient, topics: list[str], timeout: int =
result = admin_client.list_topics(topic=topic, timeout=timeout)
topic_metadata = result.topics.get(topic)
- if topic_metadata and topic_metadata.partitions and not topic_metadata.error:
+ if topic_metadata is None:
+ last_error = "Topic metadata not found"
+ time.sleep(0.1)
+ continue
+ if topic_metadata.partitions and not topic_metadata.error:
logger.debug("Topic '%s' is ready", topic)
break
- elif topic_metadata.error in {
+ elif topic_metadata.error is not None and topic_metadata.error.code() in {
KafkaError.UNKNOWN_TOPIC_OR_PART,
KafkaError.LEADER_NOT_AVAILABLE,
}:
diff --git a/tests/sentry/monitors/tasks/test_clock_pulse.py b/tests/sentry/monitors/tasks/test_clock_pulse.py
index 9c216c9f897c..4ee9c363f5ec 100644
--- a/tests/sentry/monitors/tasks/test_clock_pulse.py
+++ b/tests/sentry/monitors/tasks/test_clock_pulse.py
@@ -3,7 +3,7 @@
from arroyo import Partition, Topic
from arroyo.backends.kafka import KafkaPayload
-from confluent_kafka.admin import PartitionMetadata
+from confluent_kafka.admin import PartitionMetadata # type: ignore[attr-defined]
from django.test import override_settings
from sentry.monitors.tasks.clock_pulse import MONITOR_CODEC, clock_pulse
diff --git a/uv.lock b/uv.lock
index c5e6b2985d25..9e5160cffe4f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -205,13 +205,15 @@ wheels = [
[[package]]
name = "confluent-kafka"
-version = "2.8.0"
+version = "2.13.2"
source = { registry = "https://pypi.devinfra.sentry.io/simple" }
wheels = [
- { url = "https://pypi.devinfra.sentry.io/wheels/confluent_kafka-2.8.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:dd3bc67d589dd486d128a159e918ecf3765f8154474edf9f6f701f701de735a1" },
- { url = "https://pypi.devinfra.sentry.io/wheels/confluent_kafka-2.8.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:4c0e655df9faef450654700db3fda163ddbc4b68f5bf5c7633cf1bf9d932d892" },
- { url = "https://pypi.devinfra.sentry.io/wheels/confluent_kafka-2.8.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:abff7c4853e2d118563229329ca0a1f148ee5004cbcb9a8dad9dc8e796fcc477" },
- { url = "https://pypi.devinfra.sentry.io/wheels/confluent_kafka-2.8.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e75230b51456de5cfaefe94c35f3de5101864d8c21518f114d5cd9dd1d7d43b1" },
+ { url = "https://pypi.devinfra.sentry.io/wheels/confluent_kafka-2.13.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:02702808dd3cfd91f117fbf17181da2a95392967e9f946b1cbdc5589b36e39d1" },
+ { url = "https://pypi.devinfra.sentry.io/wheels/confluent_kafka-2.13.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f3e6d010ad38447a48e0f9fab81edd4d2fd0b5f5a79ab475c30347689e35c6e6" },
+ { url = "https://pypi.devinfra.sentry.io/wheels/confluent_kafka-2.13.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9161865d8246eb77d1c30233a315bdad96145af783981877664532fa212f56be" },
+ { url = "https://pypi.devinfra.sentry.io/wheels/confluent_kafka-2.13.2-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:9cb0d6820107deca1823d68b96831bd982d0a11c4e6bcf0a12e8040192c48a8f" },
+ { url = "https://pypi.devinfra.sentry.io/wheels/confluent_kafka-2.13.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f09adb42fb898a0b3a88b02e77bee472e93f758258945386c77864016b4e4efc" },
+ { url = "https://pypi.devinfra.sentry.io/wheels/confluent_kafka-2.13.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:fa3be1fe231e06b2c7501fa3641b30ea90ea17be79ca89806eef22ff34ed106c" },
]
[[package]]
@@ -2365,7 +2367,7 @@ requires-dist = [
{ name = "requests-oauthlib", specifier = ">=1.2.0" },
{ name = "rfc3339-validator", specifier = ">=0.1.2" },
{ name = "rfc3986-validator", specifier = ">=0.1.1" },
- { name = "sentry-arroyo", specifier = ">=2.38.1" },
+ { name = "sentry-arroyo", specifier = ">=2.38.5" },
{ name = "sentry-conventions", specifier = ">=0.3.0" },
{ name = "sentry-forked-email-reply-parser", specifier = ">=0.5.12.post1" },
{ name = "sentry-kafka-schemas", specifier = ">=2.1.27" },
@@ -2456,13 +2458,13 @@ dev = [
[[package]]
name = "sentry-arroyo"
-version = "2.38.1"
+version = "2.38.5"
source = { registry = "https://pypi.devinfra.sentry.io/simple" }
dependencies = [
{ name = "confluent-kafka", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
wheels = [
- { url = "https://pypi.devinfra.sentry.io/wheels/sentry_arroyo-2.38.1-py3-none-any.whl", hash = "sha256:e86e02127bcfc884e94d5c36c4e6c97e3e39df033effe96a4eca4346852db1b4" },
+ { url = "https://pypi.devinfra.sentry.io/wheels/sentry_arroyo-2.38.5-py3-none-any.whl", hash = "sha256:add69f320b7065d675aa9f8caae65e03d35d1241534871caeff70de54ed4f33e" },
]
[[package]]
From 9b16dfeb41ff816478eeb73d9ed174bbc8a23bd5 Mon Sep 17 00:00:00 2001
From: Jay
Date: Tue, 31 Mar 2026 14:10:50 -0500
Subject: [PATCH 31/51] fix(onboarding): Wrap connected tag in Container to
constrain width (#111936)
The "Connected to {provider} org {name}" tag was expanding to 100% width
of its parent flex container. Wrapping it in a Container constrains it
to its natural content width.
---
static/app/views/onboarding/scmConnect.tsx | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/static/app/views/onboarding/scmConnect.tsx b/static/app/views/onboarding/scmConnect.tsx
index c6fb122c47c9..d859ec987332 100644
--- a/static/app/views/onboarding/scmConnect.tsx
+++ b/static/app/views/onboarding/scmConnect.tsx
@@ -3,7 +3,7 @@ import {AnimatePresence, LayoutGroup, motion} from 'framer-motion';
import {Tag} from '@sentry/scraps/badge';
import {Button} from '@sentry/scraps/button';
-import {Flex, Stack} from '@sentry/scraps/layout';
+import {Container, Flex, Stack} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
@@ -86,13 +86,15 @@ export function ScmConnect({onComplete}: StepProps) {
width="100%"
maxWidth={SCM_STEP_CONTENT_WIDTH}
>
- }>
- {t(
- 'Connected to %s org %s',
- effectiveIntegration.provider.name,
- effectiveIntegration.name
- )}
-
+
+ }>
+ {t(
+ 'Connected to %s org %s',
+ effectiveIntegration.provider.name,
+ effectiveIntegration.name
+ )}
+
+
{selectedRepository ? (
From c196e71f49886952df1b75d6d56dabb3d05b380f Mon Sep 17 00:00:00 2001
From: Rahul Chhabria
Date: Tue, 31 Mar 2026 12:38:09 -0700
Subject: [PATCH 32/51] feat(admin): Replace startup program notes field with
program dropdown (#111701)
Replace the free-text notes field in the "Add to Startup Program" admin
modal with a dropdown of predefined program options:
- Y Combinator
- Sentry for Startups (default)
- a16z
- Accelatoms
- Accelfam
- Renderstack
- Finpack
- Betaworks
- Alchemist
- Antler
- Other (reveals a free-text input)
When "Other" is selected, a custom notes text field appears for
free-form input. The submitted `notes` value is the selected program
key, or the custom text when "Other" is chosen.
---------
Co-authored-by: Claude
---
.../addToStartupProgramAction.spec.tsx | 78 +++++++++++++++++--
.../components/addToStartupProgramAction.tsx | 44 ++++++++++-
2 files changed, 111 insertions(+), 11 deletions(-)
diff --git a/static/gsAdmin/components/addToStartupProgramAction.spec.tsx b/static/gsAdmin/components/addToStartupProgramAction.spec.tsx
index 8295bc9cd9c8..df0908a33b60 100644
--- a/static/gsAdmin/components/addToStartupProgramAction.spec.tsx
+++ b/static/gsAdmin/components/addToStartupProgramAction.spec.tsx
@@ -64,7 +64,7 @@ describe('AddToStartupProgramAction', () => {
expect(await screen.findByRole('spinbutton', {name: 'Credit Amount'})).toHaveValue(
5000
);
- expect(screen.getByRole('textbox', {name: 'Notes'})).toHaveValue('sentryforstartups');
+ expect(screen.getByText('sentryforstartups')).toBeInTheDocument();
});
it('can submit with default values', async () => {
@@ -97,7 +97,69 @@ describe('AddToStartupProgramAction', () => {
expect(onSuccess).toHaveBeenCalled();
});
- it('can submit with custom values', async () => {
+ it('can submit with a different option selected', async () => {
+ const updateMock = MockApiClient.addMockResponse({
+ url: `/_admin/customers/${organization.slug}/balance-changes/`,
+ method: 'POST',
+ body: {},
+ });
+
+ triggerAddToStartupProgramModal(modalProps);
+
+ const {waitForModalToHide} = renderGlobalModal();
+
+ await userEvent.click(await screen.findByText('sentryforstartups'));
+ await userEvent.click(screen.getByText('ycombinator'));
+
+ await userEvent.click(screen.getByRole('button', {name: 'Submit'}));
+
+ await waitForModalToHide();
+
+ await waitFor(() => {
+ expect(updateMock).toHaveBeenCalledWith(
+ `/_admin/customers/${organization.slug}/balance-changes/`,
+ expect.objectContaining({
+ method: 'POST',
+ data: {
+ creditAmount: 500000,
+ ticketUrl: '',
+ notes: 'ycombinator',
+ },
+ })
+ );
+ });
+ });
+
+ it('shows custom notes field when "Enter custom notes" is selected', async () => {
+ triggerAddToStartupProgramModal(modalProps);
+
+ renderGlobalModal();
+
+ expect(screen.queryByRole('textbox', {name: 'Custom Notes'})).not.toBeInTheDocument();
+
+ await userEvent.click(await screen.findByText('sentryforstartups'));
+ await userEvent.click(screen.getByText('Enter custom notes'));
+
+ expect(screen.getByRole('textbox', {name: 'Custom Notes'})).toBeInTheDocument();
+ });
+
+ it('hides custom notes field when switching back to a preset option', async () => {
+ triggerAddToStartupProgramModal(modalProps);
+
+ renderGlobalModal();
+
+ // Select "Enter custom notes"
+ await userEvent.click(await screen.findByText('sentryforstartups'));
+ await userEvent.click(screen.getByText('Enter custom notes'));
+ expect(screen.getByRole('textbox', {name: 'Custom Notes'})).toBeInTheDocument();
+
+ // Switch back to a preset option
+ await userEvent.click(screen.getByText('Enter custom notes'));
+ await userEvent.click(screen.getByText('a16z'));
+ expect(screen.queryByRole('textbox', {name: 'Custom Notes'})).not.toBeInTheDocument();
+ });
+
+ it('can submit with custom notes', async () => {
const updateMock = MockApiClient.addMockResponse({
url: `/_admin/customers/${organization.slug}/balance-changes/`,
method: 'POST',
@@ -115,9 +177,13 @@ describe('AddToStartupProgramAction', () => {
await userEvent.type(screen.getByRole('textbox', {name: 'Ticket URL'}), url);
- const notesInput = screen.getByRole('textbox', {name: 'Notes'});
- await userEvent.clear(notesInput);
- await userEvent.type(notesInput, 'custom note');
+ await userEvent.click(screen.getByText('sentryforstartups'));
+ await userEvent.click(screen.getByText('Enter custom notes'));
+
+ await userEvent.type(
+ screen.getByRole('textbox', {name: 'Custom Notes'}),
+ 'custom note'
+ );
await userEvent.click(screen.getByRole('button', {name: 'Submit'}));
@@ -156,7 +222,6 @@ describe('AddToStartupProgramAction', () => {
expect(submitButton).toBeDisabled();
expect(screen.getByRole('spinbutton', {name: 'Credit Amount'})).toBeDisabled();
expect(screen.getByRole('textbox', {name: 'Ticket URL'})).toBeDisabled();
- expect(screen.getByRole('textbox', {name: 'Notes'})).toBeDisabled();
await waitForModalToHide();
});
@@ -205,7 +270,6 @@ describe('AddToStartupProgramAction', () => {
{timeout: 5_000}
);
expect(screen.getByRole('textbox', {name: 'Ticket URL'})).toBeEnabled();
- expect(screen.getByRole('textbox', {name: 'Notes'})).toBeEnabled();
expect(screen.getByRole('button', {name: /submit/i})).toBeEnabled();
}, 25_000);
diff --git a/static/gsAdmin/components/addToStartupProgramAction.tsx b/static/gsAdmin/components/addToStartupProgramAction.tsx
index 86b083cbb1d8..f6ac174bf1e4 100644
--- a/static/gsAdmin/components/addToStartupProgramAction.tsx
+++ b/static/gsAdmin/components/addToStartupProgramAction.tsx
@@ -1,4 +1,4 @@
-import {Fragment} from 'react';
+import {Fragment, useState} from 'react';
import {Flex} from '@sentry/scraps/layout';
import {Heading, Text} from '@sentry/scraps/text';
@@ -8,6 +8,7 @@ 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 {TextField} from 'sentry/components/forms/fields/textField';
import {Form, type FormProps} from 'sentry/components/forms/form';
import {fetchMutation, useMutation} from 'sentry/utils/queryClient';
@@ -16,6 +17,20 @@ import type {RequestError} from 'sentry/utils/requestError/requestError';
import type {Subscription} from 'getsentry/types';
import {formatBalance} from 'getsentry/utils/billing';
+const STARTUP_PROGRAM_OPTIONS = [
+ {value: 'ycombinator', label: 'ycombinator'},
+ {value: 'sentryforstartups', label: 'sentryforstartups'},
+ {value: 'a16z', label: 'a16z'},
+ {value: 'accelatoms', label: 'accelatoms'},
+ {value: 'accelfam', label: 'accelfam'},
+ {value: 'renderstack', label: 'renderstack'},
+ {value: 'finpack', label: 'finpack'},
+ {value: 'betaworks', label: 'betaworks'},
+ {value: 'alchemist', label: 'alchemist'},
+ {value: 'antler', label: 'antler'},
+ {value: 'other', label: 'Enter custom notes'},
+];
+
function coerceValue(value: number) {
if (isNaN(value)) {
return undefined;
@@ -46,6 +61,8 @@ function AddToStartupProgramModal({
Header,
Body,
}: AddToStartupProgramModalProps) {
+ const [showCustomNotes, setShowCustomNotes] = useState(false);
+
const {mutate, isPending} = useMutation<
Record,
RequestError,
@@ -82,7 +99,13 @@ function AddToStartupProgramModal({
const creditAmountInput = Number(data.creditAmount);
const creditAmount = coerceValue(creditAmountInput);
const ticketUrl = typeof data.ticketUrl === 'string' ? data.ticketUrl : '';
- const notes = typeof data.notes === 'string' ? data.notes : '';
+ const rawNotes = typeof data.notes === 'string' ? data.notes : '';
+ const notes =
+ rawNotes === 'other'
+ ? typeof data.customNotes === 'string'
+ ? data.customNotes
+ : ''
+ : rawNotes;
if (!creditAmount || isPending) {
return;
@@ -139,14 +162,27 @@ function AddToStartupProgramModal({
stacked
disabled={isPending}
/>
- {
+ setShowCustomNotes(value === 'other');
+ }}
/>
+ {showCustomNotes && (
+
+ )}
From 9e883abbc6eab76bb592ae88643632884f836145 Mon Sep 17 00:00:00 2001
From: Nico Hinderling
Date: Tue, 31 Mar 2026 12:56:08 -0700
Subject: [PATCH 33/51] feat(preprod): Add shadow taskbroker dispatch for
launchpad integration (#110602)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add external namespace registration for launchpad tasks and shadow
dispatch
of artifact processing to the taskbroker alongside the existing Kafka
pipeline.
This is the Sentry-side integration for the launchpad taskbroker
migration.
When enabled, after the existing Kafka dispatch succeeds, a shadow
dispatch
sends the same artifact processing request to the launchpad application
via
the taskbroker. This allows us to validate the taskbroker path in
production
without affecting the primary Kafka pipeline.
**Safety gates:**
- Feature flag `launchpad-taskbroker-rollout` — org-level rollout
control
- Shadow dispatch failures are caught and logged, never affecting the
primary path
**Changes:**
- Register external `launchpad_tasks` namespace via `taskbroker-client`
`ExternalNamespace`
- Extract `_dispatch_kafka` helper from `assemble_preprod_artifact` for
clarity
- Add `_dispatch_taskbroker_shadow` with feature-based quota/size checks
and `process_artifact.apply_async()` dispatch
- Bump `taskbroker-client` to 0.1.5 (adds `ExternalNamespace` support)
- Bump `sentry-cli` to 3.1.0
---------
Co-authored-by: Claude Opus 4.6
Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
---
pyproject.toml | 2 +-
src/sentry/preprod/tasks.py | 138 ++++++++++++++++++++--------
src/sentry/taskworker/namespaces.py | 5 +
tests/sentry/preprod/test_tasks.py | 94 +++++++++++++++++++
uv.lock | 11 +--
5 files changed, 205 insertions(+), 45 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index c7538a2aceca..4d323c498368 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -172,7 +172,7 @@ dev = [
"responses>=0.23.1",
"ruff>=0.14.0",
"selenium>=4.16.0",
- "sentry-cli>=2.16.0",
+ "sentry-cli>=3.3.0",
"sentry-covdefaults-disable-branch-coverage>=1.0.2",
"sentry-devenv>=1.28.0",
"django-stubs>=5.2.9",
diff --git a/src/sentry/preprod/tasks.py b/src/sentry/preprod/tasks.py
index ca25645895dd..493a03ddac08 100644
--- a/src/sentry/preprod/tasks.py
+++ b/src/sentry/preprod/tasks.py
@@ -11,6 +11,7 @@
from django.utils import timezone
from taskbroker_client.retry import Retry
+from sentry import features
from sentry.constants import DataCategory
from sentry.models.commitcomparison import CommitComparison
from sentry.models.organization import Organization
@@ -28,7 +29,10 @@
PreprodBuildConfiguration,
)
from sentry.preprod.producer import PreprodFeature, produce_preprod_artifact_to_kafka
-from sentry.preprod.quotas import has_installable_quota, has_size_quota
+from sentry.preprod.quotas import (
+ has_installable_quota,
+ has_size_quota,
+)
from sentry.preprod.size_analysis.models import SizeAnalysisResults
from sentry.preprod.size_analysis.tasks import (
compare_preprod_artifact_size_analysis,
@@ -47,7 +51,7 @@
set_assemble_status,
)
from sentry.tasks.base import instrumented_task
-from sentry.taskworker.namespaces import preprod_tasks
+from sentry.taskworker.namespaces import launchpad_tasks, preprod_tasks
from sentry.utils import metrics
from sentry.utils.outcomes import Outcome, track_outcome
from sentry.utils.sdk import bind_organization_context
@@ -55,6 +59,17 @@
logger = logging.getLogger(__name__)
+# Typed RPC stub — the body is never called. The decorator registers the task signature
+# with the external namespace; dispatch happens via process_artifact.apply_async().
+@launchpad_tasks.register(
+ name="process_artifact",
+ retry=Retry(times=3),
+ processing_deadline_duration=60 * 12,
+)
+def process_artifact(artifact_id: str, project_id: str, organization_id: str) -> None:
+ pass
+
+
@instrumented_task(
name="sentry.preprod.tasks.assemble_preprod_artifact",
retry=Retry(times=3),
@@ -83,6 +98,7 @@ def assemble_preprod_artifact(
},
)
+ assemble_result = None
try:
organization = Organization.objects.get_from_cache(pk=org_id)
project = Project.objects.get(id=project_id, organization=organization)
@@ -142,43 +158,18 @@ def assemble_preprod_artifact(
)
return
+ finally:
+ try:
+ if assemble_result is not None:
+ assemble_result.bundle_temp_file.close()
+ except Exception:
+ pass
- try:
- # Note: requested_features is no longer used for filtering - all features are
- # requested here, and the actual quota/filter checks happen in the update endpoint
- # (project_preprod_artifact_update.py) after preprocessing completes.
- produce_preprod_artifact_to_kafka(
- project_id=project_id,
- organization_id=org_id,
- artifact_id=artifact_id,
- requested_features=[
- PreprodFeature.SIZE_ANALYSIS,
- PreprodFeature.BUILD_DISTRIBUTION,
- ],
- )
- except Exception as e:
- user_friendly_error_message = "Failed to dispatch preprod artifact event for analysis"
- sentry_sdk.capture_exception(e)
- logger.exception(
- user_friendly_error_message,
- extra={
- "project_id": project_id,
- "organization_id": org_id,
- "checksum": checksum,
- "preprod_artifact_id": artifact_id,
- },
- )
- PreprodArtifact.objects.filter(id=artifact_id).update(
- state=PreprodArtifact.ArtifactState.FAILED,
- error_code=PreprodArtifact.ErrorCode.ARTIFACT_PROCESSING_ERROR,
- error_message=user_friendly_error_message,
- )
- create_preprod_status_check_task.apply_async(
- kwargs={
- "preprod_artifact_id": artifact_id,
- "caller": "assemble_dispatch_error",
- }
- )
+ if features.has("organizations:launchpad-taskbroker-rollout", organization):
+ _dispatch_taskbroker_shadow(project_id, org_id, artifact_id)
+
+ kafka_dispatched = _dispatch_kafka(project_id, org_id, artifact_id, checksum)
+ if not kafka_dispatched:
return
logger.info(
@@ -979,3 +970,74 @@ def detect_expired_preprod_artifacts() -> None:
+ expired_size_comparisons_count,
},
)
+
+
+def _dispatch_kafka(project_id: int, org_id: int, artifact_id: int, checksum: str) -> bool:
+ # Note: requested_features is no longer used for filtering - all features are
+ # requested here, and the actual quota/filter checks happen in the update endpoint
+ # (project_preprod_artifact_update.py) after preprocessing completes.
+ try:
+ produce_preprod_artifact_to_kafka(
+ project_id=project_id,
+ organization_id=org_id,
+ artifact_id=artifact_id,
+ requested_features=[
+ PreprodFeature.SIZE_ANALYSIS,
+ PreprodFeature.BUILD_DISTRIBUTION,
+ ],
+ )
+ return True
+ except Exception as e:
+ user_friendly_error_message = "Failed to dispatch preprod artifact event for analysis"
+ sentry_sdk.capture_exception(e)
+ logger.exception(
+ user_friendly_error_message,
+ extra={
+ "project_id": project_id,
+ "organization_id": org_id,
+ "checksum": checksum,
+ "preprod_artifact_id": artifact_id,
+ },
+ )
+ PreprodArtifact.objects.filter(id=artifact_id).update(
+ state=PreprodArtifact.ArtifactState.FAILED,
+ error_code=PreprodArtifact.ErrorCode.ARTIFACT_PROCESSING_ERROR,
+ error_message=user_friendly_error_message,
+ )
+ create_preprod_status_check_task.apply_async(
+ kwargs={
+ "preprod_artifact_id": artifact_id,
+ "caller": "assemble_dispatch_error",
+ }
+ )
+ return False
+
+
+def _dispatch_taskbroker_shadow(project_id: int, org_id: int, artifact_id: int) -> None:
+ # TODO: When taskbroker becomes the primary path, add PreprodArtifactSizeMetrics
+ # state management here (mirroring project_preprod_artifact_update.py). Currently
+ # omitted to avoid racing with the primary Kafka consumer path.
+ try:
+ logger.info(
+ "preprod.dispatch_taskbroker_shadow",
+ extra={
+ "project_id": project_id,
+ "organization_id": org_id,
+ "preprod_artifact_id": artifact_id,
+ },
+ )
+
+ process_artifact.delay(
+ artifact_id=str(artifact_id),
+ project_id=str(project_id),
+ organization_id=str(org_id),
+ )
+ except Exception:
+ logger.exception(
+ "Failed to dispatch shadow taskbroker event",
+ extra={
+ "project_id": project_id,
+ "organization_id": org_id,
+ "preprod_artifact_id": artifact_id,
+ },
+ )
diff --git a/src/sentry/taskworker/namespaces.py b/src/sentry/taskworker/namespaces.py
index 2daca183a58e..31502074bcae 100644
--- a/src/sentry/taskworker/namespaces.py
+++ b/src/sentry/taskworker/namespaces.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
from sentry.taskworker.runtime import app
# Namespaces for taskworker tasks
@@ -261,6 +263,9 @@
)
+# External namespaces for tasks belonging to other applications
+launchpad_tasks = app.create_external_namespace(name="default", application="launchpad")
+
# Namespaces for testing taskworker tasks
exampletasks = app.taskregistry.create_namespace(name="examples")
test_tasks = app.taskregistry.create_namespace(name="test")
diff --git a/tests/sentry/preprod/test_tasks.py b/tests/sentry/preprod/test_tasks.py
index d923a3056d3d..436e6c759b55 100644
--- a/tests/sentry/preprod/test_tasks.py
+++ b/tests/sentry/preprod/test_tasks.py
@@ -467,6 +467,100 @@ def test_assemble_preprod_artifact_includes_feature_on_invalid_query(
call_kwargs = mock_produce_to_kafka.call_args[1]
assert PreprodFeature.SIZE_ANALYSIS in call_kwargs["requested_features"]
+ @patch("sentry.preprod.tasks._dispatch_taskbroker_shadow")
+ @patch("sentry.preprod.tasks.produce_preprod_artifact_to_kafka")
+ def test_shadow_taskbroker_dispatched_when_flag_enabled(
+ self, mock_produce_to_kafka, mock_shadow
+ ) -> None:
+ content = b"test shadow taskbroker dispatch"
+ fileobj = ContentFile(content)
+ total_checksum = sha1(content).hexdigest()
+
+ blob = FileBlob.from_file_with_organization(fileobj, self.organization)
+
+ artifact = create_preprod_artifact(
+ org_id=self.organization.id,
+ project_id=self.project.id,
+ checksum=total_checksum,
+ build_configuration_name="release",
+ )
+ assert artifact is not None
+
+ with self.feature("organizations:launchpad-taskbroker-rollout"):
+ assemble_preprod_artifact(
+ org_id=self.organization.id,
+ project_id=self.project.id,
+ checksum=total_checksum,
+ chunks=[blob.checksum],
+ artifact_id=artifact.id,
+ )
+
+ mock_produce_to_kafka.assert_called_once()
+ mock_shadow.assert_called_once_with(self.project.id, self.organization.id, artifact.id)
+
+ @patch("sentry.preprod.tasks._dispatch_taskbroker_shadow")
+ @patch("sentry.preprod.tasks.produce_preprod_artifact_to_kafka")
+ def test_shadow_taskbroker_not_dispatched_when_flag_disabled(
+ self, mock_produce_to_kafka, mock_shadow
+ ) -> None:
+ content = b"test shadow taskbroker not dispatched"
+ fileobj = ContentFile(content)
+ total_checksum = sha1(content).hexdigest()
+
+ blob = FileBlob.from_file_with_organization(fileobj, self.organization)
+
+ artifact = create_preprod_artifact(
+ org_id=self.organization.id,
+ project_id=self.project.id,
+ checksum=total_checksum,
+ build_configuration_name="release",
+ )
+ assert artifact is not None
+
+ assemble_preprod_artifact(
+ org_id=self.organization.id,
+ project_id=self.project.id,
+ checksum=total_checksum,
+ chunks=[blob.checksum],
+ artifact_id=artifact.id,
+ )
+
+ mock_produce_to_kafka.assert_called_once()
+ mock_shadow.assert_not_called()
+
+ @patch("sentry.preprod.tasks._dispatch_taskbroker_shadow")
+ @patch("sentry.preprod.tasks.produce_preprod_artifact_to_kafka")
+ def test_shadow_taskbroker_dispatched_after_kafka(
+ self, mock_produce_to_kafka, mock_shadow
+ ) -> None:
+ content = b"test shadow taskbroker dispatch ordering"
+ fileobj = ContentFile(content)
+ total_checksum = sha1(content).hexdigest()
+
+ blob = FileBlob.from_file_with_organization(fileobj, self.organization)
+
+ artifact = create_preprod_artifact(
+ org_id=self.organization.id,
+ project_id=self.project.id,
+ checksum=total_checksum,
+ build_configuration_name="release",
+ )
+ assert artifact is not None
+
+ with self.feature("organizations:launchpad-taskbroker-rollout"):
+ assemble_preprod_artifact(
+ org_id=self.organization.id,
+ project_id=self.project.id,
+ checksum=total_checksum,
+ chunks=[blob.checksum],
+ artifact_id=artifact.id,
+ )
+
+ mock_produce_to_kafka.assert_called_once()
+ mock_shadow.assert_called_once()
+ artifact.refresh_from_db()
+ assert artifact.state != PreprodArtifact.ArtifactState.FAILED
+
class CreatePreprodArtifactTest(TestCase):
def test_create_preprod_artifact_with_all_vcs_params_succeeds(self) -> None:
diff --git a/uv.lock b/uv.lock
index 9e5160cffe4f..cb70c3108333 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2432,7 +2432,7 @@ dev = [
{ name = "responses", specifier = ">=0.23.1" },
{ name = "ruff", specifier = ">=0.14.0" },
{ name = "selenium", specifier = ">=4.16.0" },
- { name = "sentry-cli", specifier = ">=2.16.0" },
+ { name = "sentry-cli", specifier = ">=3.3.0" },
{ name = "sentry-covdefaults-disable-branch-coverage", specifier = ">=1.0.2" },
{ name = "sentry-devenv", specifier = ">=1.28.0" },
{ name = "time-machine", specifier = ">=2.16.0" },
@@ -2469,13 +2469,12 @@ wheels = [
[[package]]
name = "sentry-cli"
-version = "2.16.0"
+version = "3.3.3"
source = { registry = "https://pypi.devinfra.sentry.io/simple" }
wheels = [
- { url = "https://pypi.devinfra.sentry.io/wheels/sentry_cli-2.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:db01ed6951a6f65faaf97b34f8dbe66c42c59f922f8be0f383f032309a4dd0b6" },
- { url = "https://pypi.devinfra.sentry.io/wheels/sentry_cli-2.16.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:c2f51b3b79113ec4080908af7a0a8a43a3bf30873a801ed8f095ba5cf7b73e9e" },
- { url = "https://pypi.devinfra.sentry.io/wheels/sentry_cli-2.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_2_aarch64.whl", hash = "sha256:872462805c749cc974ba27709052194f080ff44762a47b98d7f520e34e64a260" },
- { url = "https://pypi.devinfra.sentry.io/wheels/sentry_cli-2.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.musllinux_1_2_x86_64.whl", hash = "sha256:9d0541a3cbe96697f354549f2464c24c3250aa189e58d690ca632f434c34e6e8" },
+ { url = "https://pypi.devinfra.sentry.io/wheels/sentry_cli-3.3.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37d235bf0b2bb5ce2526928d6584441879aa0bcf023e0bc4f43a52c043e996d8" },
+ { url = "https://pypi.devinfra.sentry.io/wheels/sentry_cli-3.3.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_2_aarch64.whl", hash = "sha256:9ba4b3199bcfefe40f9e9e7e6ee42f7ecd7b1702af743b2263a78fe8f363bbfd" },
+ { url = "https://pypi.devinfra.sentry.io/wheels/sentry_cli-3.3.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.musllinux_1_2_x86_64.whl", hash = "sha256:49f1d757778db87c40abb0d0b692938019c13a6d67e14976bd87b66f4ea9820b" },
]
[[package]]
From d28c826d560768f0fe5fc676b52718398ed1fa76 Mon Sep 17 00:00:00 2001
From: Evan Purkhiser
Date: Tue, 31 Mar 2026 16:03:29 -0400
Subject: [PATCH 34/51] feat(github): Add API-driven GitHub integration setup
(#111728)
Adds backend support for completing the GitHub integration setup flow
via API endpoints instead of server-rendered Django views.
Extracts shared logic from the existing template-driven pipeline views
into reusable functions (exchange_github_oauth,
validate_github_installation,
validate_org_installation_choice, _build_installation_info_with_counts)
so
both the legacy views and new API steps can use them.
Adds OAuthLoginApiStep and GithubOrganizationSelectionApiStep which
implement the same flow as the existing views but return structured data
for the frontend to render. Adds api_finish_pipeline to
IntegrationPipeline
for completing the pipeline without server-side redirects.
Refactors _finish_pipeline to separate model operations
(_execute_finish_pipeline) from HTTP response handling, and extracts
initialize_integration_pipeline from OrganizationIntegrationSetupView
for
reuse by the API endpoint.
Refs VDY-38
---
src/sentry/integrations/github/integration.py | 716 ++++++++++++------
.../integrations/github/test_integration.py | 403 +++++++++-
2 files changed, 879 insertions(+), 240 deletions(-)
diff --git a/src/sentry/integrations/github/integration.py b/src/sentry/integrations/github/integration.py
index 04f0d4693bf3..86cdfaf18935 100644
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -3,19 +3,21 @@
import logging
import re
from collections.abc import Callable, Mapping, MutableMapping, Sequence
+from dataclasses import dataclass
from enum import StrEnum
-from typing import Any, TypedDict
+from typing import Any, NotRequired, TypedDict
from urllib.parse import parse_qsl
from django.db.models import Count
-from django.http import HttpResponse
from django.http.request import HttpRequest
from django.http.response import HttpResponseBase, HttpResponseRedirect
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
+from rest_framework.serializers import CharField
from sentry import features, options
+from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer
from sentry.constants import ObjectStatus
from sentry.http import safe_urlopen, safe_urlread
from sentry.identity.github.provider import GitHubIdentityProvider
@@ -62,7 +64,12 @@
RpcOrganization,
RpcUserOrganizationContext,
)
-from sentry.pipeline.views.base import PipelineView, render_react_view
+from sentry.pipeline.types import PipelineStepResult
+from sentry.pipeline.views.base import (
+ ApiPipelineSteps,
+ PipelineView,
+ render_react_view,
+)
from sentry.shared_integrations.constants import ERR_INTERNAL, ERR_UNAUTHORIZED
from sentry.shared_integrations.exceptions import ApiError, ApiInvalidRequestError, IntegrationError
from sentry.snuba.referrer import Referrer
@@ -177,6 +184,7 @@ class GithubInstallationInfo(TypedDict):
installation_id: str
github_account: str
avatar_url: str
+ count: NotRequired[int]
def build_repository_query(metadata: Mapping[str, Any], name: str, query: str) -> bytes:
@@ -789,6 +797,12 @@ def get_pipeline_views(
]:
return [OAuthLoginView(), GithubOrganizationSelection(), GitHubInstallation()]
+ def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]:
+ return [
+ OAuthLoginApiStep(),
+ GithubOrganizationSelectionApiStep(),
+ ]
+
def get_installation_info(self, installation_id: str) -> Mapping[str, Any]:
resp: Mapping[str, Any] = self.client.get_installation_info(installation_id=installation_id)
return resp
@@ -844,133 +858,491 @@ class GitHubInstallationError(StrEnum):
USER_MISMATCH = "Authenticated user is not the same as who installed the app."
MISSING_INTEGRATION = "Integration does not exist."
INVALID_INSTALLATION = "User does not have access to given installation."
+ MISSING_OWNER_PRIVILEGES = (
+ "Your GitHub account does not have owner privileges for the chosen organization."
+ )
FEATURE_NOT_AVAILABLE = "Your organization does not have access to this feature."
MISSING_ORGANIZATION = "You must be logged into an organization to access this feature."
+def get_install_app_url() -> str:
+ name = options.get("github-app.name")
+ return f"https://github.com/apps/{slugify(name)}"
+
+
def record_event(event: IntegrationPipelineViewType):
return IntegrationPipelineViewEvent(
event, IntegrationDomain.SOURCE_CODE_MANAGEMENT, GitHubIntegrationProvider.key
)
-class OAuthLoginView:
- client: GithubSetupApiClient
+class GitHubPipelineError(Exception):
+ """Raised when a GitHub pipeline step fails validation."""
+
+ def __init__(self, message: GitHubInstallationError) -> None:
+ self.message = message
+ super().__init__(str(message))
+
+
+@dataclass
+class GitHubOAuthLoginResult:
+ authenticated_user: str
+ installation_info: list[GithubInstallationInfo]
+
+
+def _get_owner_github_organizations(client: GithubSetupApiClient) -> list[str]:
+ user_org_membership_details = client.get_organization_memberships_for_user()
+ return [
+ gh_org.get("organization", {}).get("login")
+ for gh_org in user_org_membership_details
+ if (
+ gh_org.get("role", "").lower() == "admin"
+ and gh_org.get("state", "").lower() == "active"
+ )
+ ]
+
+
+def _get_eligible_multi_org_installations(
+ client: GithubSetupApiClient, owner_orgs: list[str]
+) -> list[GithubInstallationInfo]:
+ installed_orgs = client.get_user_info_installations()
+ return [
+ {
+ "installation_id": str(installation.get("id")),
+ "github_account": installation.get("account").get("login"),
+ "avatar_url": installation.get("account").get("avatar_url"),
+ }
+ for installation in installed_orgs["installations"]
+ if (
+ installation.get("account").get("login") in owner_orgs
+ or installation.get("target_type") == "User"
+ )
+ ]
+
+
+def _build_github_oauth_url(pipeline: IntegrationPipeline) -> str:
+ ghip = GitHubIdentityProvider()
+ redirect_uri = absolute_uri(
+ reverse(
+ "sentry-extension-setup",
+ kwargs={"provider_id": IntegrationProviderSlug.GITHUB.value},
+ )
+ )
+ return (
+ f"{ghip.get_oauth_authorize_url()}"
+ f"?client_id={ghip.get_oauth_client_id()}"
+ f"&state={pipeline.signature}"
+ f"&redirect_uri={redirect_uri}"
+ )
+
+
+def exchange_github_oauth(
+ code: str,
+ fetch_installations: bool = True,
+) -> GitHubOAuthLoginResult:
+ """
+ Exchange an OAuth code for a GitHub access token, fetch the authenticated
+ user's info, and optionally determine eligible installations.
+
+ Raises GitHubPipelineError on failure.
+ """
+ ghip = GitHubIdentityProvider()
+ data = {
+ "code": code,
+ "client_id": ghip.get_oauth_client_id(),
+ "client_secret": ghip.get_oauth_client_secret(),
+ }
+
+ req = safe_urlopen(url=ghip.get_oauth_access_token_url(), data=data)
+ try:
+ body = safe_urlread(req).decode("utf-8")
+ payload = dict(parse_qsl(body))
+ except Exception:
+ payload = {}
+
+ if "access_token" not in payload:
+ raise GitHubPipelineError(GitHubInstallationError.MISSING_TOKEN)
+
+ client = GithubSetupApiClient(access_token=payload["access_token"])
+ authenticated_user_info = client.get_user_info()
+
+ installation_info: list[GithubInstallationInfo] = []
+ if fetch_installations:
+ owner_orgs = _get_owner_github_organizations(client)
+ installation_info = _get_eligible_multi_org_installations(client, owner_orgs)
+
+ if "login" not in authenticated_user_info:
+ raise GitHubPipelineError(GitHubInstallationError.MISSING_LOGIN)
+
+ return GitHubOAuthLoginResult(
+ authenticated_user=authenticated_user_info["login"],
+ installation_info=installation_info,
+ )
+
+
+def _build_installation_info_with_counts(
+ installation_info: list[GithubInstallationInfo],
+) -> list[GithubInstallationInfo]:
+ """
+ Enriches an installation info list with Sentry org install counts and appends
+ the 'install on new GitHub org' sentinel option.
+ """
+ installation_ids = [i["installation_id"] for i in installation_info]
+ counts_qs = (
+ OrganizationIntegration.objects.filter(
+ integration__provider=GitHubIntegrationProvider.key,
+ integration__external_id__in=installation_ids,
+ )
+ .values("integration__external_id")
+ .annotate(count=Count("id"))
+ )
+ counts_dict = {item["integration__external_id"]: item["count"] for item in counts_qs}
+
+ result: list[GithubInstallationInfo] = []
+ for installation in installation_info:
+ result.append(
+ {
+ **installation,
+ "count": counts_dict.get(installation["installation_id"], 0),
+ }
+ )
+ result.append(
+ {
+ "installation_id": "-1",
+ "github_account": "Integrate with a new GitHub organization",
+ "avatar_url": "",
+ "count": 0,
+ }
+ )
+ return result
+
+
+def validate_org_installation_choice(
+ chosen_installation_id: str,
+ pipeline: IntegrationPipeline,
+) -> None:
+ """
+ Validates a chosen GitHub installation can be linked to this Sentry org.
+ Checks multi-org feature flag, install count, org consistency, and that the
+ installation belongs to the user. Binds chosen_installation to pipeline state.
+
+ Raises GitHubPipelineError on validation failure.
+ """
+ installation_info: list[GithubInstallationInfo] = (
+ pipeline.fetch_state("existing_installation_info") or []
+ )
+
+ has_scm_multi_org = features.has(
+ "organizations:integrations-scm-multi-org",
+ organization=pipeline.organization,
+ )
+ install_count = OrganizationIntegration.objects.filter(
+ integration__provider=GitHubIntegrationProvider.key,
+ integration__external_id=chosen_installation_id,
+ ).count()
+ if not ((install_count == 0) or has_scm_multi_org):
+ raise GitHubPipelineError(GitHubInstallationError.FEATURE_NOT_AVAILABLE)
+
+ installing_org_slug = pipeline.fetch_state("installing_organization_slug")
+ if not (installing_org_slug is not None and installing_org_slug == pipeline.organization.slug):
+ raise GitHubPipelineError(GitHubInstallationError.FEATURE_NOT_AVAILABLE)
+
+ valid_ids = [i["installation_id"] for i in installation_info]
+ if chosen_installation_id not in valid_ids:
+ raise GitHubPipelineError(GitHubInstallationError.MISSING_OWNER_PRIVILEGES)
+
+ pipeline.bind_state("chosen_installation", chosen_installation_id)
+
+
+def validate_github_installation(
+ pipeline: IntegrationPipeline,
+) -> str | None:
+ """
+ Resolves the installation_id from pipeline state (handling chosen_installation),
+ checks for pending deletion and user mismatch against existing integrations.
+
+ Returns the resolved installation_id, or None if the user needs to install the
+ GitHub App first (caller should redirect).
+
+ Raises GitHubPipelineError on validation failure.
+ """
+ chosen_installation_id = pipeline.fetch_state("chosen_installation")
+ if chosen_installation_id is not None:
+ pipeline.bind_state("installation_id", chosen_installation_id)
+
+ installation_id = pipeline.fetch_state("installation_id")
+ if installation_id is None:
+ return None
+
+ pending_deletion = OrganizationIntegration.objects.filter(
+ integration__provider=GitHubIntegrationProvider.key,
+ organization_id=pipeline.organization.id,
+ status=ObjectStatus.PENDING_DELETION,
+ ).exists()
+ if pending_deletion:
+ raise GitHubPipelineError(GitHubInstallationError.PENDING_DELETION)
+
+ try:
+ integration = Integration.objects.get(
+ external_id=installation_id, status=ObjectStatus.ACTIVE
+ )
+ except Integration.DoesNotExist:
+ # The installation.created webhook from GitHub normally creates the
+ # Integration record (with sender metadata) before the user reaches
+ # this point. If it doesn't exist yet, the webhook likely hasn't
+ # been processed. We proceed without the sender validation that the
+ # existing-integration path provides below. In theory an attacker
+ # could race the webhook to link an installation they don't own,
+ # but the window is extremely narrow (they'd need to predict the
+ # installation_id before the webhook arrives).
+ return installation_id
+
+ if (
+ chosen_installation_id is None
+ and pipeline.fetch_state("github_authenticated_user")
+ != integration.metadata["sender"]["login"]
+ ):
+ raise GitHubPipelineError(GitHubInstallationError.USER_MISMATCH)
+
+ return installation_id
+
+
+class OAuthLoginStepData(TypedDict):
+ oauthUrl: str
+
+
+class OAuthLoginValidatedData(TypedDict):
+ code: str
+ state: str
+ installation_id: NotRequired[str]
+
+
+class OAuthLoginSerializer(CamelSnakeSerializer):
+ code = CharField(required=True)
+ state = CharField(required=True)
+ # GitHub includes installation_id in the OAuth callback URL when the user
+ # installs the app from GitHub's side rather than from Sentry.
+ installation_id = CharField(required=False)
+
+
+class OAuthLoginApiStep:
+ """
+ Initiates and completes the GitHub OAuth authorization flow.
+
+ On GET (via get_step_data): returns the GitHub OAuth authorize URL. The
+ frontend should open this in a popup/redirect so the user can authorize.
+
+ On POST (via handle_post): receives the OAuth callback params (code, state,
+ and optionally installation_id), exchanges the code for an access token,
+ verifies the authenticated GitHub user, and discovers existing GitHub App
+ installations the user has access to.
+
+ If installation_id is present in the callback, it is bound to pipeline
+ state early. This handles the case where the user started from GitHub's
+ side (installing the app directly on GitHub), which redirects back with
+ installation_id before OAuth has happened. The installation_id persists
+ in pipeline state across the OAuth redirect, so the later installation
+ validation step can find it without requiring user action.
+ """
+
+ step_name = "oauth_login"
+
+ def get_step_data(
+ self, pipeline: IntegrationPipeline, request: HttpRequest
+ ) -> OAuthLoginStepData:
+ return {"oauthUrl": _build_github_oauth_url(pipeline)}
+
+ def get_serializer_cls(self) -> type:
+ return OAuthLoginSerializer
+
+ def handle_post(
+ self,
+ validated_data: OAuthLoginValidatedData,
+ pipeline: IntegrationPipeline,
+ request: HttpRequest,
+ ) -> PipelineStepResult:
+ with record_event(IntegrationPipelineViewType.OAUTH_LOGIN).capture() as lifecycle:
+ lifecycle.add_extra("organization_id", pipeline.organization.id)
+
+ if installation_id := validated_data.get("installation_id"):
+ pipeline.bind_state("installation_id", installation_id)
+
+ if validated_data["state"] != pipeline.signature:
+ lifecycle.record_failure(GitHubInstallationError.INVALID_STATE)
+ return PipelineStepResult.error(GitHubInstallationError.INVALID_STATE)
+
+ try:
+ result = exchange_github_oauth(code=validated_data["code"])
+ except GitHubPipelineError as e:
+ lifecycle.record_failure(e.message)
+ return PipelineStepResult.error(e.message)
+
+ if result.installation_info:
+ pipeline.bind_state("existing_installation_info", result.installation_info)
+ pipeline.bind_state("github_authenticated_user", result.authenticated_user)
+ return PipelineStepResult.advance()
+
+
+class GithubInstallationApiInfo(TypedDict):
+ installationId: str
+ githubAccount: str
+ avatarUrl: str
+ count: NotRequired[int | None]
+
+
+class OrgSelectionStepData(TypedDict):
+ installAppUrl: str
+ installationInfo: list[GithubInstallationApiInfo]
+
+
+class OrgSelectionValidatedData(TypedDict):
+ chosen_installation_id: NotRequired[str]
+ installation_id: NotRequired[str]
+
+
+class OrgSelectionSerializer(CamelSnakeSerializer):
+ chosen_installation_id = CharField(required=False, allow_blank=True)
+ installation_id = CharField(required=False)
+
+
+class GithubOrganizationSelectionApiStep:
+ """
+ Connects a GitHub App installation to the user's Sentry org.
+
+ On GET: returns the list of GitHub orgs/accounts where the authenticated
+ user has the app installed, enriched with how many Sentry orgs already use
+ each installation.
+
+ On POST: handles three cases:
+ 1. User selected an existing installation (chosen_installation_id) —
+ validates multi-org eligibility, org consistency, and ownership.
+ 2. User completed the GitHub App install popup and the frontend posted
+ back the new installation_id.
+ 3. The installation_id is already in pipeline state from the user
+ clicking "Install" on github.com and being redirected into this flow.
+
+ Cases 2 and 3 both go through validate_github_installation. If no
+ installation_id is available yet, returns a stay result prompting the
+ user to install the app.
+ """
+
+ step_name = "org_selection"
+
+ def get_step_data(
+ self, pipeline: IntegrationPipeline, request: HttpRequest
+ ) -> OrgSelectionStepData:
+ install_app_url = get_install_app_url()
+
+ installation_info: list[GithubInstallationInfo] = list(
+ pipeline.fetch_state("existing_installation_info") or []
+ )
+ if not installation_info:
+ return {"installationInfo": [], "installAppUrl": install_app_url}
+
+ # Bind the installing org slug so we can validate it hasn't changed when
+ # the user submits their choice (same logic as the template flow).
+ pipeline.bind_state("installing_organization_slug", pipeline.organization.slug)
+
+ enriched = _build_installation_info_with_counts(installation_info)
+ return {
+ "installAppUrl": install_app_url,
+ "installationInfo": [
+ {
+ "installationId": info["installation_id"],
+ "githubAccount": info["github_account"],
+ "avatarUrl": info.get("avatar_url", ""),
+ "count": info.get("count"),
+ }
+ for info in enriched
+ ],
+ }
+
+ def get_serializer_cls(self) -> type | None:
+ return OrgSelectionSerializer
+
+ def handle_post(
+ self,
+ validated_data: OrgSelectionValidatedData,
+ pipeline: IntegrationPipeline,
+ request: HttpRequest,
+ ) -> PipelineStepResult:
+ with record_event(
+ IntegrationPipelineViewType.ORGANIZATION_SELECTION
+ ).capture() as lifecycle:
+ lifecycle.add_extra("organization_id", pipeline.organization.id)
+
+ # If the user completed the GitHub App install popup, bind the
+ # installation_id and validate it
+ if post_installation_id := validated_data.get("installation_id"):
+ pipeline.bind_state("installation_id", post_installation_id)
+ # If the user selected a GitHub app installation from the list
+ # validate their choice and bind the installation_id.
+ chosen_installation_id = validated_data.get("chosen_installation_id")
+ if chosen_installation_id:
+ try:
+ validate_org_installation_choice(chosen_installation_id, pipeline)
+ except GitHubPipelineError as e:
+ lifecycle.record_failure(e.message)
+ return PipelineStepResult.error(e.message)
+ pipeline.bind_state("installation_id", chosen_installation_id)
+
+ # Validate whatever installation_id is in pipeline state — handles
+ # pending deletion check and user mismatch against existing integrations.
+ try:
+ installation_id = validate_github_installation(pipeline)
+ except GitHubPipelineError as e:
+ lifecycle.record_failure(e.message)
+ return PipelineStepResult.error(e.message)
+
+ if installation_id is None:
+ return PipelineStepResult.stay(data={"installAppUrl": get_install_app_url()})
+
+ return PipelineStepResult.advance()
+
+
+class OAuthLoginView:
def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpResponseBase:
with record_event(IntegrationPipelineViewType.OAUTH_LOGIN).capture() as lifecycle:
- self.active_user_organization = determine_active_organization(request)
+ active_user_organization = determine_active_organization(request)
lifecycle.add_extra(
"organization_id",
- (
- self.active_user_organization.organization.id
- if self.active_user_organization
- else None
- ),
+ (active_user_organization.organization.id if active_user_organization else None),
)
- ghip = GitHubIdentityProvider()
- github_client_id = ghip.get_oauth_client_id()
- github_client_secret = ghip.get_oauth_client_secret()
-
installation_id = request.GET.get("installation_id")
if installation_id:
pipeline.bind_state("installation_id", installation_id)
if not request.GET.get("state"):
- state = pipeline.signature
+ return HttpResponseRedirect(_build_github_oauth_url(pipeline))
- redirect_uri = absolute_uri(
- reverse(
- "sentry-extension-setup",
- kwargs={"provider_id": IntegrationProviderSlug.GITHUB.value},
- )
- )
- return HttpResponseRedirect(
- f"{ghip.get_oauth_authorize_url()}?client_id={github_client_id}&state={state}&redirect_uri={redirect_uri}"
- )
-
- # At this point, we are past the GitHub "authorize" step
if request.GET.get("state") != pipeline.signature:
lifecycle.record_failure(GitHubInstallationError.INVALID_STATE)
return error(
request,
- self.active_user_organization,
+ active_user_organization,
error_short=GitHubInstallationError.INVALID_STATE,
)
- # similar to OAuth2CallbackView.get_token_params
- data = {
- "code": request.GET.get("code"),
- "client_id": github_client_id,
- "client_secret": github_client_secret,
- }
-
- # similar to OAuth2CallbackView.exchange_token
- req = safe_urlopen(url=ghip.get_oauth_access_token_url(), data=data)
try:
- body = safe_urlread(req).decode("utf-8")
- payload = dict(parse_qsl(body))
- except Exception:
- payload = {}
-
- if "access_token" not in payload:
- lifecycle.record_failure(GitHubInstallationError.MISSING_TOKEN)
- return error(
- request,
- self.active_user_organization,
- error_short=GitHubInstallationError.MISSING_TOKEN,
- )
- self.client = GithubSetupApiClient(access_token=payload["access_token"])
- authenticated_user_info = self.client.get_user_info()
-
- if self.active_user_organization is not None:
- owner_orgs = self._get_owner_github_organizations()
-
- installation_info = self._get_eligible_multi_org_installations(
- owner_orgs=owner_orgs
+ result = exchange_github_oauth(
+ code=request.GET.get("code", ""),
+ fetch_installations=active_user_organization is not None,
)
- pipeline.bind_state("existing_installation_info", installation_info)
-
- if "login" not in authenticated_user_info:
- lifecycle.record_failure(GitHubInstallationError.MISSING_LOGIN)
+ except GitHubPipelineError as e:
+ lifecycle.record_failure(e.message)
return error(
request,
- self.active_user_organization,
- error_short=GitHubInstallationError.MISSING_LOGIN,
+ active_user_organization,
+ error_short=e.message,
+ error_long=e.message,
)
- pipeline.bind_state("github_authenticated_user", authenticated_user_info["login"])
- return pipeline.next_step()
-
- def _get_owner_github_organizations(self) -> list[str]:
- user_org_membership_details = self.client.get_organization_memberships_for_user()
-
- return [
- gh_org.get("organization", {}).get("login")
- for gh_org in user_org_membership_details
- if (
- gh_org.get("role", "").lower() == "admin"
- and gh_org.get("state", "").lower() == "active"
- )
- ]
- def _get_eligible_multi_org_installations(
- self, owner_orgs: list[str]
- ) -> list[GithubInstallationInfo]:
- installed_orgs = self.client.get_user_info_installations()
-
- return [
- {
- "installation_id": str(installation.get("id")),
- "github_account": installation.get("account").get("login"),
- "avatar_url": installation.get("account").get("avatar_url"),
- }
- for installation in installed_orgs["installations"]
- if (
- installation.get("account").get("login") in owner_orgs
- or installation.get("target_type") == "User"
- )
- ]
+ if result.installation_info:
+ pipeline.bind_state("existing_installation_info", result.installation_info)
+ pipeline.bind_state("github_authenticated_user", result.authenticated_user)
+ return pipeline.next_step()
class GithubOrganizationSelection:
@@ -997,79 +1369,23 @@ def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpR
if len(installation_info) == 0:
return pipeline.next_step()
- # add information about number of org integrations per installation
- installation_ids = [
- installation["installation_id"] for installation in installation_info
- ]
- integration_install_counts = (
- OrganizationIntegration.objects.filter(
- integration__provider=GitHubIntegrationProvider.key,
- integration__external_id__in=installation_ids,
- )
- .values("integration__external_id")
- .annotate(count=Count("id"))
- )
- integration_install_counts_dict = {
- item["integration__external_id"]: item["count"]
- for item in integration_install_counts
- }
- for installation in installation_info:
- installation["count"] = integration_install_counts_dict.get(
- installation["installation_id"], 0
- )
-
- # add an option for users to install on a new GH organization
- installation_info.append(
- {
- "installation_id": "-1",
- "github_account": "Integrate with a new GitHub organization",
- "avatar_url": "",
- "count": 0,
- }
- )
+ installation_info = _build_installation_info_with_counts(installation_info)
if chosen_installation_id := request.GET.get("chosen_installation_id"):
if chosen_installation_id == "-1":
return pipeline.next_step()
- # NOTE: there may still be a race condition here where multiple orgs read the same count (0)
- # the org integration creation logic is in finish_pipeline
- can_install_chosen_installation = (
- integration_install_counts_dict.get(chosen_installation_id, 0) == 0
- ) or has_scm_multi_org
-
- # Validate the same org is installing and that they have the multi org feature
- installing_organization_slug = pipeline.fetch_state("installing_organization_slug")
- is_same_installing_org = (
- (installing_organization_slug is not None)
- and installing_organization_slug
- == self.active_user_organization.organization.slug
- )
-
- if not can_install_chosen_installation or not is_same_installing_org:
- lifecycle.record_failure(GitHubInstallationError.FEATURE_NOT_AVAILABLE)
- return error(
- request,
- self.active_user_organization,
- error_short=GitHubInstallationError.FEATURE_NOT_AVAILABLE,
- )
-
- # Verify that the given GH installation belongs to the person installing the pipeline
- installation_ids = [
- installation["installation_id"] for installation in installation_info
- ]
- if chosen_installation_id not in installation_ids:
- lifecycle.record_failure(
- failure_reason=GitHubInstallationError.INVALID_INSTALLATION
- )
+ try:
+ validate_org_installation_choice(chosen_installation_id, pipeline)
+ except GitHubPipelineError as e:
+ lifecycle.record_failure(e.message)
return error(
request,
self.active_user_organization,
- error_short=GitHubInstallationError.INVALID_INSTALLATION,
- error_long=ERR_INTEGRATION_INVALID_INSTALLATION,
+ error_short=e.message,
+ error_long=e.message,
)
- pipeline.bind_state("chosen_installation", chosen_installation_id)
return pipeline.next_step()
pipeline.bind_state(
"installing_organization_slug", self.active_user_organization.organization.slug
@@ -1093,85 +1409,39 @@ def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpR
class GitHubInstallation:
- def get_app_url(self) -> str:
- name = options.get("github-app.name")
- return f"https://github.com/apps/{slugify(name)}"
-
def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpResponseBase:
with record_event(IntegrationPipelineViewType.GITHUB_INSTALLATION).capture() as lifecycle:
- self.active_user_organization = determine_active_organization(request)
-
- chosen_installation_id = pipeline.fetch_state("chosen_installation")
- if chosen_installation_id is not None:
- pipeline.bind_state("installation_id", chosen_installation_id)
-
- installation_id = pipeline.fetch_state("installation_id") or request.GET.get(
- "installation_id", None
- )
- if installation_id is None:
- return HttpResponseRedirect(self.get_app_url())
-
- pipeline.bind_state("installation_id", installation_id)
-
+ active_user_organization = determine_active_organization(request)
lifecycle.add_extra(
"organization_id",
(
- self.active_user_organization.organization.id
- if self.active_user_organization is not None
+ active_user_organization.organization.id
+ if active_user_organization is not None
else None
),
)
- error_page = self.check_pending_integration_deletion(request=request)
- if error_page is not None:
- lifecycle.record_failure(GitHubInstallationError.PENDING_DELETION)
- return error_page
-
- if self.active_user_organization is not None:
- try:
- integration = Integration.objects.get(
- external_id=installation_id, status=ObjectStatus.ACTIVE
- )
- except Integration.DoesNotExist:
- return pipeline.next_step()
-
- # Check that the authenticated GitHub user is the same as who installed the app.
- if (
- chosen_installation_id is None
- and pipeline.fetch_state("github_authenticated_user")
- != integration.metadata["sender"]["login"]
- ):
- lifecycle.record_failure(GitHubInstallationError.USER_MISMATCH)
+ if active_user_organization is None:
return error(
request,
- self.active_user_organization,
- error_short=GitHubInstallationError.USER_MISMATCH,
+ None,
+ error_short=GitHubInstallationError.MISSING_ORGANIZATION,
+ error_long=ERR_INTEGRATION_MISSING_ORGANIZATION,
)
- return pipeline.next_step()
+ # The template flow also picks up installation_id from query params
+ if installation_id := request.GET.get("installation_id"):
+ pipeline.bind_state("installation_id", installation_id)
- def check_pending_integration_deletion(self, request: HttpRequest) -> HttpResponse | None:
- if self.active_user_organization is None:
- return error(
- request,
- None,
- error_short=GitHubInstallationError.MISSING_ORGANIZATION,
- error_long=ERR_INTEGRATION_MISSING_ORGANIZATION,
- )
+ try:
+ resolved_id = validate_github_installation(pipeline)
+ except GitHubPipelineError as e:
+ lifecycle.record_failure(e.message)
+ return error(
+ request, active_user_organization, error_short=e.message, error_long=e.message
+ )
- # We want to wait until the scheduled deletions finish or else the
- # post install to migrate repos do not work.
- integration_pending_deletion_exists = OrganizationIntegration.objects.filter(
- integration__provider=GitHubIntegrationProvider.key,
- organization_id=self.active_user_organization.organization.id,
- status=ObjectStatus.PENDING_DELETION,
- ).exists()
+ if resolved_id is None:
+ return HttpResponseRedirect(get_install_app_url())
- if integration_pending_deletion_exists:
- return error(
- request,
- self.active_user_organization,
- error_short=GitHubInstallationError.PENDING_DELETION,
- error_long=ERR_INTEGRATION_PENDING_DELETION,
- )
- return None
+ return pipeline.next_step()
diff --git a/tests/sentry/integrations/github/test_integration.py b/tests/sentry/integrations/github/test_integration.py
index b72af03bb438..2a97645ea9b6 100644
--- a/tests/sentry/integrations/github/test_integration.py
+++ b/tests/sentry/integrations/github/test_integration.py
@@ -28,10 +28,12 @@
GitHubInstallationError,
GitHubIntegration,
GitHubIntegrationProvider,
- OAuthLoginView,
+ _get_eligible_multi_org_installations,
+ _get_owner_github_organizations,
)
from sentry.integrations.models.integration import Integration
from sentry.integrations.models.organization_integration import OrganizationIntegration
+from sentry.integrations.pipeline import IntegrationPipeline
from sentry.integrations.source_code_management.commit_context import (
CommitInfo,
FileBlameInfo,
@@ -52,7 +54,7 @@
assert_failure_metric,
assert_success_metric,
)
-from sentry.testutils.cases import IntegrationTestCase
+from sentry.testutils.cases import APITestCase, IntegrationTestCase
from sentry.testutils.helpers import with_feature
from sentry.testutils.helpers.integrations import get_installation_of_type
from sentry.testutils.helpers.options import override_options
@@ -1468,11 +1470,7 @@ def test_github_installation_fails_on_invalid_installation(
self.assertTemplateUsed(resp, "sentry/integrations/github-integration-failed.html")
assert (
- b'{"success":false,"data":{"error":"User does not have access to given installation."}'
- in resp.content
- )
- assert (
- b"Your GitHub account does not have owner privileges for the chosen organization."
+ b'{"success":false,"data":{"error":"Your GitHub account does not have owner privileges for the chosen organization."}'
in resp.content
)
assert b'window.opener.postMessage({"success":false' in resp.content
@@ -1493,7 +1491,7 @@ def test_github_installation_fails_on_invalid_installation(
mock_record=mock_record, outcome=EventLifecycleOutcome.FAILURE, outcome_count=1
)
- assert_failure_metric(mock_record, GitHubInstallationError.INVALID_INSTALLATION)
+ assert_failure_metric(mock_record, GitHubInstallationError.MISSING_OWNER_PRIVILEGES)
@with_feature({"organizations:integrations-scm-multi-org": False})
@responses.activate
@@ -1658,10 +1656,9 @@ def test_github_installation_skips_chosen_installation(self, mock_record: MagicM
@responses.activate
def test_github_installation_gets_owner_orgs(self) -> None:
self._setup_with_existing_installations()
- pipeline_view = OAuthLoginView()
- pipeline_view.client = GithubSetupApiClient(self.access_token)
+ client = GithubSetupApiClient(self.access_token)
- owner_orgs = pipeline_view._get_owner_github_organizations()
+ owner_orgs = _get_owner_github_organizations(client)
assert owner_orgs == ["santry"]
@@ -1669,15 +1666,12 @@ def test_github_installation_gets_owner_orgs(self) -> None:
@responses.activate
def test_github_installation_filters_valid_installations(self) -> None:
self._setup_with_existing_installations()
- pipeline_view = OAuthLoginView()
- pipeline_view.client = GithubSetupApiClient(self.access_token)
+ client = GithubSetupApiClient(self.access_token)
- owner_orgs = pipeline_view._get_owner_github_organizations()
+ owner_orgs = _get_owner_github_organizations(client)
assert owner_orgs == ["santry"]
- installation_info = pipeline_view._get_eligible_multi_org_installations(
- owner_orgs=owner_orgs
- )
+ installation_info = _get_eligible_multi_org_installations(client, owner_orgs)
assert installation_info == [
{
@@ -2266,3 +2260,378 @@ def test_get_debug_metadata(self) -> None:
"domain_name": "github.com/Test-Organization",
"permissions": None,
}
+
+
+@control_silo_test
+class GitHubIntegrationApiPipelineTest(APITestCase):
+ endpoint = "sentry-api-0-organization-pipeline"
+ method = "post"
+
+ base_url = "https://api.github.com"
+
+ def setUp(self) -> None:
+ super().setUp()
+ self.installation_id = "install_1"
+ self.user_id = "user_1"
+ self.app_id = "app_1"
+ self.access_token = "xxxxx-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx"
+ self.expires_at = "3000-01-01T00:00:00Z"
+ self.login_as(self.user)
+ self._stub_github()
+
+ def tearDown(self) -> None:
+ responses.reset()
+ super().tearDown()
+
+ @pytest.fixture(autouse=True)
+ def stub_get_jwt(self):
+ with mock.patch.object(client, "get_jwt", return_value="jwt_token_1"):
+ yield
+
+ @pytest.fixture(autouse=True)
+ def stub_get_jwt_function(self):
+ with mock.patch("sentry.integrations.github.utils.get_jwt", return_value="jwt_token_1"):
+ yield
+
+ def _stub_github(self) -> None:
+ """Stubs GitHub API responses needed for the integration pipeline."""
+ self.gh_org = "Test-Organization"
+
+ responses.add(
+ responses.POST,
+ "https://github.com/login/oauth/access_token",
+ body=f"access_token={self.access_token}",
+ )
+ responses.add(responses.GET, self.base_url + "/user", json={"login": "octocat"})
+ responses.add(
+ responses.POST,
+ self.base_url + f"/app/installations/{self.installation_id}/access_tokens",
+ json={
+ "token": self.access_token,
+ "expires_at": self.expires_at,
+ "permissions": {
+ "administration": "read",
+ "contents": "read",
+ "issues": "write",
+ "metadata": "read",
+ "pull_requests": "read",
+ },
+ "repository_selection": "all",
+ },
+ )
+
+ repositories: dict[str, Any] = {
+ "xyz": {
+ "name": "xyz",
+ "full_name": "Test-Organization/xyz",
+ "default_branch": "master",
+ },
+ "foo": {
+ "id": 1296269,
+ "name": "foo",
+ "full_name": "Test-Organization/foo",
+ "default_branch": "master",
+ },
+ }
+
+ responses.add(
+ responses.GET,
+ url=self.base_url + "/installation/repositories",
+ json={
+ "total_count": len(repositories),
+ "repositories": list(repositories.values()),
+ },
+ )
+
+ responses.add(
+ responses.GET,
+ self.base_url + f"/app/installations/{self.installation_id}",
+ json={
+ "id": self.installation_id,
+ "app_id": self.app_id,
+ "account": {
+ "id": 60591805,
+ "login": "Test Organization",
+ "avatar_url": "http://example.com/avatar.png",
+ "html_url": "https://github.com/Test-Organization",
+ "type": "Organization",
+ },
+ },
+ )
+
+ responses.add(responses.GET, self.base_url + "/repos/Test-Organization/foo/hooks", json=[])
+
+ responses.add(
+ responses.GET,
+ f"{self.base_url}/user/memberships/orgs",
+ json=[
+ {
+ "state": "active",
+ "role": "admin",
+ "organization": {
+ "login": "santry",
+ "id": 1,
+ "avatar_url": "https://example.com/santry.png",
+ },
+ },
+ ],
+ )
+
+ def _get_pipeline_url(self) -> str:
+ return reverse(
+ self.endpoint,
+ args=[self.organization.slug, IntegrationPipeline.pipeline_name],
+ )
+
+ def _initialize_pipeline(self) -> Any:
+ """POST action=initialize to start the pipeline, return the response."""
+ return self.client.post(
+ self._get_pipeline_url(),
+ data={"action": "initialize", "provider": "github"},
+ format="json",
+ )
+
+ def _get_step_info(self) -> Any:
+ """GET current step info."""
+ return self.client.get(self._get_pipeline_url())
+
+ def _advance_step(self, data: dict[str, Any]) -> Any:
+ """POST to advance the pipeline with step-specific data."""
+ return self.client.post(self._get_pipeline_url(), data=data, format="json")
+
+ def _get_pipeline_signature(self, init_resp: Any) -> str:
+ """Extract the pipeline signature (OAuth state) from an initialize response."""
+ return init_resp.data["data"]["oauthUrl"].split("state=")[1].split("&")[0]
+
+ def _stub_user_installations(self, installations: list[dict[str, Any]] | None = None) -> None:
+ """Stub the GitHub /user/installations endpoint."""
+ if installations is None:
+ installations = [
+ {
+ "id": self.installation_id,
+ "target_type": "Organization",
+ "account": {
+ "login": "santry",
+ "avatar_url": "https://example.com/santry.png",
+ },
+ }
+ ]
+ responses.add(
+ responses.GET,
+ f"{self.base_url}/user/installations",
+ json={"installations": installations},
+ )
+
+ def _complete_oauth_step(self, pipeline_signature: str, **extra: Any) -> Any:
+ """Submit the OAuth callback data to advance past the OAuth step."""
+ return self._advance_step(
+ {
+ "code": "12345678901234567890",
+ "state": pipeline_signature,
+ **extra,
+ }
+ )
+
+ def _advance_to_org_selection(self) -> str:
+ """Initialize pipeline and complete OAuth, returning the pipeline signature."""
+ resp = self._initialize_pipeline()
+ pipeline_signature = self._get_pipeline_signature(resp)
+ self._stub_user_installations()
+
+ resp = self._complete_oauth_step(pipeline_signature)
+ assert resp.data["status"] == "advance"
+ assert resp.data["step"] == "org_selection"
+ return pipeline_signature
+
+ @responses.activate
+ def test_initialize_pipeline(self) -> None:
+ resp = self._initialize_pipeline()
+ assert resp.status_code == 200
+ assert resp.data["step"] == "oauth_login"
+ assert resp.data["stepIndex"] == 0
+ assert resp.data["totalSteps"] == 2
+ assert resp.data["provider"] == "github"
+ assert "oauthUrl" in resp.data["data"]
+
+ @responses.activate
+ def test_get_oauth_step_info(self) -> None:
+ self._initialize_pipeline()
+ resp = self._get_step_info()
+ assert resp.status_code == 200
+ assert resp.data["step"] == "oauth_login"
+ assert "oauthUrl" in resp.data["data"]
+ oauth_url = resp.data["data"]["oauthUrl"]
+ assert "github.com/login/oauth/authorize" in oauth_url
+ assert "client_id=" in oauth_url
+
+ @responses.activate
+ def test_oauth_step_advance(self) -> None:
+ resp = self._initialize_pipeline()
+ pipeline_signature = self._get_pipeline_signature(resp)
+ self._stub_user_installations()
+
+ resp = self._complete_oauth_step(pipeline_signature)
+ assert resp.status_code == 200
+ assert resp.data["status"] == "advance"
+ assert resp.data["step"] == "org_selection"
+ assert resp.data["stepIndex"] == 1
+
+ @responses.activate
+ def test_oauth_step_invalid_state(self) -> None:
+ self._initialize_pipeline()
+ resp = self._advance_step(
+ {
+ "code": "12345678901234567890",
+ "state": "invalid_state_value",
+ }
+ )
+ assert resp.status_code == 400
+ assert resp.data["status"] == "error"
+ assert GitHubInstallationError.INVALID_STATE in resp.data["data"]["detail"]
+
+ @responses.activate
+ def test_oauth_step_missing_fields(self) -> None:
+ self._initialize_pipeline()
+ resp = self._advance_step({})
+ assert resp.status_code == 400
+
+ @responses.activate
+ def test_org_selection_get_with_installations(self) -> None:
+ self._advance_to_org_selection()
+ resp = self._get_step_info()
+ assert resp.status_code == 200
+ assert resp.data["step"] == "org_selection"
+ data = resp.data["data"]
+ assert "installAppUrl" in data
+ assert "installationInfo" in data
+ assert len(data["installationInfo"]) > 0
+
+ @responses.activate
+ @with_feature("organizations:integrations-scm-multi-org")
+ def test_org_selection_choose_existing_installation(self) -> None:
+ self._advance_to_org_selection()
+
+ resp = self._advance_step(
+ {
+ "chosen_installation_id": self.installation_id,
+ }
+ )
+ assert resp.status_code == 200
+ assert resp.data["status"] == "complete"
+
+ @responses.activate
+ @with_feature("organizations:integrations-scm-multi-org")
+ def test_org_selection_chosen_installation_blocked_by_pending_deletion(self) -> None:
+ """Choosing an existing installation should fail if there's a pending deletion."""
+ self._advance_to_org_selection()
+
+ integration = self.create_integration(
+ organization=self.organization,
+ provider="github",
+ external_id=self.installation_id,
+ metadata={"sender": {"login": "octocat"}},
+ )
+ oi = OrganizationIntegration.objects.get(
+ integration=integration, organization_id=self.organization.id
+ )
+ oi.status = ObjectStatus.PENDING_DELETION
+ oi.save()
+
+ resp = self._advance_step(
+ {
+ "chosen_installation_id": self.installation_id,
+ }
+ )
+ assert resp.status_code == 400
+ assert resp.data["status"] == "error"
+ assert GitHubInstallationError.PENDING_DELETION in resp.data["data"]["detail"]
+
+ @responses.activate
+ def test_org_selection_new_installation_from_popup(self) -> None:
+ """When user installs the app via the GitHub popup, installation_id comes in the POST."""
+ self._advance_to_org_selection()
+
+ resp = self._advance_step(
+ {
+ "installation_id": self.installation_id,
+ }
+ )
+ assert resp.status_code == 200
+ assert resp.data["status"] == "complete"
+
+ @responses.activate
+ def test_full_api_pipeline_flow_new_installation(self) -> None:
+ """End-to-end: initialize -> OAuth -> skip org selection -> complete."""
+ resp = self._initialize_pipeline()
+ assert resp.status_code == 200
+ assert resp.data["step"] == "oauth_login"
+ pipeline_signature = self._get_pipeline_signature(resp)
+
+ self._stub_user_installations(installations=[])
+
+ resp = self._complete_oauth_step(pipeline_signature, installation_id=self.installation_id)
+ assert resp.status_code == 200
+ assert resp.data["status"] == "advance"
+ assert resp.data["step"] == "org_selection"
+
+ resp = self._advance_step({"installation_id": self.installation_id})
+ assert resp.status_code == 200
+ assert resp.data["status"] == "complete"
+ assert "data" in resp.data
+
+ integration = Integration.objects.get(provider="github")
+ assert integration.external_id == self.installation_id
+ assert integration.name == "Test Organization"
+
+ assert OrganizationIntegration.objects.filter(
+ organization_id=self.organization.id,
+ integration=integration,
+ ).exists()
+
+ @responses.activate
+ @with_feature("organizations:integrations-scm-multi-org")
+ def test_full_api_pipeline_flow_existing_installation(self) -> None:
+ """End-to-end: initialize -> OAuth -> choose existing installation -> complete."""
+ resp = self._initialize_pipeline()
+ pipeline_signature = self._get_pipeline_signature(resp)
+ self._stub_user_installations()
+
+ resp = self._complete_oauth_step(pipeline_signature)
+ assert resp.data["status"] == "advance"
+ assert resp.data["step"] == "org_selection"
+
+ resp = self._advance_step({"chosen_installation_id": self.installation_id})
+ assert resp.status_code == 200
+ assert resp.data["status"] == "complete"
+
+ integration = Integration.objects.get(provider="github")
+ assert integration.external_id == self.installation_id
+
+ @responses.activate
+ def test_oauth_exchange_failure(self) -> None:
+ """OAuth code exchange fails when GitHub returns no access_token."""
+ resp = self._initialize_pipeline()
+ pipeline_signature = self._get_pipeline_signature(resp)
+
+ responses.replace(
+ responses.POST,
+ "https://github.com/login/oauth/access_token",
+ body="error=bad_verification_code",
+ )
+
+ resp = self._complete_oauth_step(pipeline_signature)
+ assert resp.status_code == 400
+ assert resp.data["status"] == "error"
+
+ @responses.activate
+ def test_org_selection_invalid_installation(self) -> None:
+ """Choosing an installation not in the user's list fails validation."""
+ self._advance_to_org_selection()
+
+ resp = self._advance_step(
+ {
+ "chosen_installation_id": "99999",
+ }
+ )
+ assert resp.status_code == 400
+ assert resp.data["status"] == "error"
From 8ecaf1cb429e3af6f98e5ea085e50454ff8d7378 Mon Sep 17 00:00:00 2001
From: Katie Byers
Date: Tue, 31 Mar 2026 13:09:36 -0700
Subject: [PATCH 35/51] fix(grouping): Fix int parameterization bugs (#111870)
This fixes two bugs with our integer parameterization:
- Only count a dash as a minus sign if it doesn't come in between two sets of alphanumeric characters (so `1121-1231` comes out as `-` rather than `` and `maisey-908` comes out as `maisey-` rather than `maisey`, but `difference:-4` and `difference: -4` both are correctly parameterized as `difference:` and `difference: `, respectively).
- Fix the fact that positive integers count as hex values if they have at least 8 digits, but negative integers still count as integers (no matter how long they are) by restricting the int pattern to 7 characters.
Note that the combination of these fixes (making sure that dashes count as minus signs for negative hex values if they're not part of a dashed string) will require changes to the hex pattern, and so isn't included here. (It will be addressed in an upcoming PR making other hex changes.)
---
src/sentry/grouping/parameterization.py | 18 +++++++++++-
.../newstyle@2023_01_11/java_chained.pysnap | 6 ++--
.../newstyle@2026_01_20/java_chained.pysnap | 6 ++--
.../newstyle@2023_01_11/java_chained.pysnap | 6 ++--
.../newstyle@2026_01_20/java_chained.pysnap | 6 ++--
.../sentry/grouping/test_parameterization.py | 28 ++++++-------------
6 files changed, 38 insertions(+), 32 deletions(-)
diff --git a/src/sentry/grouping/parameterization.py b/src/sentry/grouping/parameterization.py
index e0a1031b0adf..cdf545466343 100644
--- a/src/sentry/grouping/parameterization.py
+++ b/src/sentry/grouping/parameterization.py
@@ -238,7 +238,23 @@ def _get_pattern(self, raw_pattern: str) -> str:
""",
),
ParameterizationRegex(name="float", raw_pattern=r"""-\d+\.\d+\b | \b\d+\.\d+\b"""),
- ParameterizationRegex(name="int", raw_pattern=r"""-\d+\b | \b\d+\b"""),
+ ParameterizationRegex(
+ name="int",
+ raw_pattern=r"""
+ (
+ # Regular word boundary for positive ints
+ \b
+ |
+ # Alphanumeric negative lookbehind for negative ints to ensure a dash is only
+ # considered a minus sign if it doesn't connect two alphanumeric strings. (No word
+ # boundary here because the dash serves as the word boundary, since it's not a word
+ # character.)
+ (?]]"
+ "Failed to start component [Connector[HTTP/-]]"
]
},
{
@@ -1963,7 +1963,7 @@ source: tests/sentry/grouping/test_grouping_info.py::test_grouping_info
"id": "message",
"name": "message",
"values": [
- "Failed to start connector [Connector[HTTP/]]"
+ "Failed to start connector [Connector[HTTP/-]]"
]
}
]
@@ -3407,7 +3407,7 @@ source: tests/sentry/grouping/test_grouping_info.py::test_grouping_info
"id": "value",
"name": null,
"values": [
- "Failed to start component [Connector[HTTP/]]"
+ "Failed to start component [Connector[HTTP/-]]"
]
},
{
diff --git a/tests/sentry/grouping/snapshots/grouping_info/test_grouping_info/newstyle@2026_01_20/java_chained.pysnap b/tests/sentry/grouping/snapshots/grouping_info/test_grouping_info/newstyle@2026_01_20/java_chained.pysnap
index 7c8105a52551..9b46fa7be581 100644
--- a/tests/sentry/grouping/snapshots/grouping_info/test_grouping_info/newstyle@2026_01_20/java_chained.pysnap
+++ b/tests/sentry/grouping/snapshots/grouping_info/test_grouping_info/newstyle@2026_01_20/java_chained.pysnap
@@ -1436,7 +1436,7 @@ source: tests/sentry/grouping/test_grouping_info.py::test_grouping_info
"id": "value",
"name": null,
"values": [
- "Failed to start component [Connector[HTTP/]]"
+ "Failed to start component [Connector[HTTP/-]]"
]
},
{
@@ -1963,7 +1963,7 @@ source: tests/sentry/grouping/test_grouping_info.py::test_grouping_info
"id": "message",
"name": "message",
"values": [
- "Failed to start connector [Connector[HTTP/]]"
+ "Failed to start connector [Connector[HTTP/-]]"
]
}
]
@@ -3407,7 +3407,7 @@ source: tests/sentry/grouping/test_grouping_info.py::test_grouping_info
"id": "value",
"name": null,
"values": [
- "Failed to start component [Connector[HTTP/]]"
+ "Failed to start component [Connector[HTTP/-]]"
]
},
{
diff --git a/tests/sentry/grouping/snapshots/variants/test_variants/newstyle@2023_01_11/java_chained.pysnap b/tests/sentry/grouping/snapshots/variants/test_variants/newstyle@2023_01_11/java_chained.pysnap
index 22c500cae37e..c262494dbc87 100644
--- a/tests/sentry/grouping/snapshots/variants/test_variants/newstyle@2023_01_11/java_chained.pysnap
+++ b/tests/sentry/grouping/snapshots/variants/test_variants/newstyle@2023_01_11/java_chained.pysnap
@@ -389,7 +389,7 @@ app:
type*
"LifecycleException"
value* (stripped event-specific values)
- "Failed to start component [Connector[HTTP/]]"
+ "Failed to start component [Connector[HTTP/-]]"
--------------------------------------------------------------------------
default:
hash: null
@@ -398,7 +398,7 @@ default:
root_component:
default (ignored because system exception takes precedence)
message (ignored because system exception takes precedence)
- "Failed to start connector [Connector[HTTP/]]"
+ "Failed to start connector [Connector[HTTP/-]]"
--------------------------------------------------------------------------
system:
hash: "1959b227a7cf6acf7f3fd401b5d9f09b"
@@ -788,4 +788,4 @@ system:
type*
"LifecycleException"
value (ignored because stacktrace takes precedence)
- "Failed to start component [Connector[HTTP/]]"
+ "Failed to start component [Connector[HTTP/-]]"
diff --git a/tests/sentry/grouping/snapshots/variants/test_variants/newstyle@2026_01_20/java_chained.pysnap b/tests/sentry/grouping/snapshots/variants/test_variants/newstyle@2026_01_20/java_chained.pysnap
index 0dd1d2fe5efc..06001771cd9b 100644
--- a/tests/sentry/grouping/snapshots/variants/test_variants/newstyle@2026_01_20/java_chained.pysnap
+++ b/tests/sentry/grouping/snapshots/variants/test_variants/newstyle@2026_01_20/java_chained.pysnap
@@ -290,7 +290,7 @@ app:
type*
"LifecycleException"
value* (stripped event-specific values)
- "Failed to start component [Connector[HTTP/]]"
+ "Failed to start component [Connector[HTTP/-]]"
stacktrace (ignored because it contains no in-app frames)
frame (marked out of app by built-in stacktrace rule (category:framework -app))
module*
@@ -398,7 +398,7 @@ default:
root_component:
default (ignored because system exception takes precedence)
message (ignored because system exception takes precedence)
- "Failed to start connector [Connector[HTTP/]]"
+ "Failed to start connector [Connector[HTTP/-]]"
--------------------------------------------------------------------------
system:
hash: "db7a5bf0e72c22eb770a3be8940782c6"
@@ -689,7 +689,7 @@ system:
type*
"LifecycleException"
value (ignored because stacktrace takes precedence)
- "Failed to start component [Connector[HTTP/]]"
+ "Failed to start component [Connector[HTTP/-]]"
stacktrace*
frame (ignored by built-in stacktrace rule (module:io.sentry.* -group))
module*
diff --git a/tests/sentry/grouping/test_parameterization.py b/tests/sentry/grouping/test_parameterization.py
index 4133dbea7ae4..3b3de3d32e91 100644
--- a/tests/sentry/grouping/test_parameterization.py
+++ b/tests/sentry/grouping/test_parameterization.py
@@ -49,7 +49,7 @@
(
"traceparent - aws, but not word boundary",
"abc1-67891233-abcdef012345678912345678",
- "abc1-",
+ "abc1--",
),
("uuid", "7c1811ed-e98f-4c9c-a9f9-58c757ff494f", ""),
(
@@ -171,11 +171,13 @@
("int - separator", "0:17502", ":"),
("int - separator negative no space", "value:-17502", "value:"),
("int - separator negative with space", "value: -17502", "value: "),
+ ("int - in dashed string with numbers", "415-908", "-"),
+ ("int - in dashed string with letters", "maisey-908", "maisey-"),
("int - parens", '{"msg" => "(#239323)', '{"msg" => "(#)'),
- ("int - date - invalid day", "2006-01-40", ""),
- ("int - date - invalid month", "2006-20-02", ""),
- ("int - date - invalid year", "10000-01-02", ""),
- ("int - date - missing day", "2006-01", ""),
+ ("int - date - invalid day", "2006-01-40", "--"),
+ ("int - date - invalid month", "2006-20-02", "--"),
+ ("int - date - invalid year", "10000-01-02", "--"),
+ ("int - date - missing day", "2006-01", "-"),
("int - quoted_str whitespace", "b = '1'", "b = ''"),
("int - quoted_str whitespace", 'b = "1"', 'b = ""'),
("quoted_str - single", "b='1'", "b="),
@@ -245,25 +247,13 @@ def test_experimental_parameterization(name: str, input: str, expected: str) ->
"hex without prefix - no letters, 8+ digits, negative",
"-12345678",
"",
- "",
- ),
- (
- "int - dashed string with numbers",
- "415-908",
- "-",
- "",
- ),
- (
- "int - dashed string with letters",
- "maisey-908",
- "maisey-",
- "maisey",
+ "-",
),
(
"int - number in word",
"Encoding: utf-8",
"Encoding: utf-8",
- "Encoding: utf",
+ "Encoding: utf-",
),
(
"int - with commas",
From ec94c1d3b6bb8f067d3a82eb62492ce9b4655590 Mon Sep 17 00:00:00 2001
From: Shashank Jarmale
Date: Tue, 31 Mar 2026 13:11:19 -0700
Subject: [PATCH 36/51] feat(occurrences on eap): Implement EAP query for
tagstore groups user counts (issue platform) (#111863)
Implements double reads of occurrences from EAP for
`get_generic_groups_user_counts` in
`src/sentry/tagstore/snuba/backend.py`.
---
src/sentry/tagstore/snuba/backend.py | 48 ++++-
tests/snuba/tagstore/test_tagstore_backend.py | 165 ++++++++++++++++--
2 files changed, 191 insertions(+), 22 deletions(-)
diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py
index e455fbf5ef40..6d57731e4926 100644
--- a/src/sentry/tagstore/snuba/backend.py
+++ b/src/sentry/tagstore/snuba/backend.py
@@ -1193,7 +1193,13 @@ def get_groups_user_counts(
callsite = "SnubaTagStorage::get_groups_user_counts"
if EAPOccurrencesComparator.should_check_experiment(callsite):
eap_result = self._eap_get_groups_user_counts(
- project_ids, group_ids, environment_ids, start, end, referrer
+ project_ids,
+ group_ids,
+ environment_ids,
+ start,
+ end,
+ referrer,
+ occurrence_category=OccurrenceCategory.ERROR,
)
result = EAPOccurrencesComparator.check_and_choose(
control_data=snuba_result,
@@ -1220,7 +1226,8 @@ def _eap_get_groups_user_counts(
start: datetime | None,
end: datetime | None,
referrer: str,
- ) -> dict[int, int]:
+ occurrence_category: OccurrenceCategory,
+ ) -> defaultdict[int, int]:
organization_id = get_organization_id_from_project_ids(project_ids)
now = datetime.now(tz=timezone.utc)
@@ -1260,7 +1267,7 @@ def _eap_get_groups_user_counts(
limit=len(group_ids),
referrer=referrer,
config=SearchResolverConfig(),
- occurrence_category=OccurrenceCategory.ERROR,
+ occurrence_category=occurrence_category,
)
return defaultdict(
@@ -1273,11 +1280,12 @@ def _eap_get_groups_user_counts(
)
except Exception:
logger.exception(
- "EAP get_groups_user_counts query failed",
+ "EAP groups user counts query failed",
extra={
"organization_id": organization_id,
"project_ids": list(project_ids),
"group_ids": list(group_ids),
+ "occurrence_category": occurrence_category.value,
},
)
return defaultdict(int)
@@ -1329,9 +1337,37 @@ def get_generic_groups_user_counts(
result_snql = raw_snql_query(snuba_request, referrer=referrer, use_cache=True)
- result = nest_groups(result_snql["data"], ["group_id"], ["count"])
+ nested = nest_groups(result_snql["data"], ["group_id"], ["count"])
+ snuba_result = defaultdict(int, {k: v for k, v in nested.items() if v})
+ result = snuba_result
- return defaultdict(int, {k: v for k, v in result.items() if v})
+ callsite = "SnubaTagStorage::get_generic_groups_user_counts"
+ if EAPOccurrencesComparator.should_check_experiment(callsite):
+ eap_result = self._eap_get_groups_user_counts(
+ project_ids,
+ group_ids,
+ environment_ids,
+ start,
+ end,
+ referrer,
+ occurrence_category=OccurrenceCategory.ISSUE_PLATFORM,
+ )
+ result = EAPOccurrencesComparator.check_and_choose(
+ control_data=snuba_result,
+ experimental_data=eap_result,
+ callsite=callsite,
+ is_experimental_data_a_null_result=len(eap_result) == 0,
+ reasonable_match_comparator=_reasonable_user_counts_match,
+ debug_context={
+ "project_ids": list(project_ids),
+ "group_ids": list(group_ids),
+ "environment_ids": list(environment_ids) if environment_ids else None,
+ "start": start.isoformat() if start else None,
+ "end": end.isoformat() if end else None,
+ },
+ )
+
+ return result
def get_tag_value_paginator(
self,
diff --git a/tests/snuba/tagstore/test_tagstore_backend.py b/tests/snuba/tagstore/test_tagstore_backend.py
index 2532ca806743..f50bcb3a0c96 100644
--- a/tests/snuba/tagstore/test_tagstore_backend.py
+++ b/tests/snuba/tagstore/test_tagstore_backend.py
@@ -1,6 +1,7 @@
-from datetime import timedelta
+from datetime import datetime, timedelta
from functools import cached_property
from unittest import mock
+from uuid import uuid4
import pytest
from django.utils import timezone
@@ -16,11 +17,17 @@
SEMVER_BUILD_ALIAS,
SEMVER_PACKAGE_ALIAS,
)
+from sentry.snuba.occurrences_rpc import OccurrenceCategory
from sentry.tagstore.exceptions import GroupTagKeyNotFound, TagKeyNotFound
from sentry.tagstore.snuba.backend import SnubaTagStorage
from sentry.tagstore.types import GroupTagValue, TagValue
from sentry.testutils.abstract import Abstract
-from sentry.testutils.cases import PerformanceIssueTestCase, SnubaTestCase, TestCase
+from sentry.testutils.cases import (
+ OccurrenceTestCase,
+ PerformanceIssueTestCase,
+ SnubaTestCase,
+ TestCase,
+)
from sentry.testutils.helpers.datetime import before_now, freeze_time
from sentry.utils.samples import load_data
from tests.sentry.issues.test_utils import SearchIssueTestMixin
@@ -1561,14 +1568,14 @@ def test_semver_package(self) -> None:
self.run_test("4", ["456"], env_2)
-class TestEAPGetGroupsUserCounts(TestCase, SnubaTestCase):
+class TestEAPGetGroupsUserCounts(TestCase, SnubaTestCase, OccurrenceTestCase):
FROZEN_TIME = before_now(hours=24).replace(hour=6, minute=0, second=0)
def setUp(self) -> None:
super().setUp()
self.ts = SnubaTagStorage()
- def _store_event_with_user(
+ def _store_error_event_with_user(
self, fingerprint: str, user_id: str, timestamp: float, environment: str | None = None
):
extra: dict = {
@@ -1584,25 +1591,37 @@ def _store_event_with_user(
extra_event_data=extra,
)[0]
+ def _store_issue_platform_event_with_user(
+ self, group, user_id: str, timestamp: datetime, environment: str | None = None
+ ):
+ eap_item = self.create_eap_occurrence(
+ group_id=group.id,
+ timestamp=timestamp,
+ environment=environment,
+ issue_occurrence_id=uuid4().hex,
+ tags={"sentry:user": f"id:{user_id}"},
+ )
+ self.store_eap_items([eap_item])
+
@freeze_time(FROZEN_TIME)
def test_eap_and_snuba_user_counts_match_multiple_groups(self) -> None:
ts = (self.FROZEN_TIME - timedelta(minutes=5)).timestamp()
# Group A: 3 events from 2 unique users (user1 appears twice)
- self._store_event_with_user("group-a", "user1", ts)
- self._store_event_with_user("group-a", "user1", ts)
- event_a = self._store_event_with_user("group-a", "user2", ts)
+ self._store_error_event_with_user("group-a", "user1", ts)
+ self._store_error_event_with_user("group-a", "user1", ts)
+ event_a = self._store_error_event_with_user("group-a", "user2", ts)
group_a = event_a.group
assert group_a is not None
# Group B: 2 events from 2 unique users
- self._store_event_with_user("group-b", "user3", ts)
- event_b = self._store_event_with_user("group-b", "user4", ts)
+ self._store_error_event_with_user("group-b", "user3", ts)
+ event_b = self._store_error_event_with_user("group-b", "user4", ts)
group_b = event_b.group
assert group_b is not None
# Group C: 1 event from 1 unique user
- event_c = self._store_event_with_user("group-c", "user5", ts)
+ event_c = self._store_error_event_with_user("group-c", "user5", ts)
group_c = event_c.group
assert group_c is not None
@@ -1626,6 +1645,7 @@ def test_eap_and_snuba_user_counts_match_multiple_groups(self) -> None:
start=start,
end=end,
referrer="tagstore.get_groups_user_counts",
+ occurrence_category=OccurrenceCategory.ERROR,
)
assert snuba_result == {group_a.id: 2, group_b.id: 2, group_c.id: 1}
@@ -1637,11 +1657,11 @@ def test_eap_and_snuba_user_counts_match_with_environment_filter(self) -> None:
env = self.create_environment(project=self.project, name="production")
# 2 events in "production" env from 2 unique users
- self._store_event_with_user("group-a", "user1", ts, environment=env.name)
- self._store_event_with_user("group-a", "user2", ts, environment=env.name)
+ self._store_error_event_with_user("group-a", "user1", ts, environment=env.name)
+ self._store_error_event_with_user("group-a", "user2", ts, environment=env.name)
# 1 event in a different env (should be excluded by env filter)
- event = self._store_event_with_user("group-a", "user3", ts, environment="staging")
+ event = self._store_error_event_with_user("group-a", "user3", ts, environment="staging")
group_a = event.group
assert group_a is not None
@@ -1666,6 +1686,7 @@ def test_eap_and_snuba_user_counts_match_with_environment_filter(self) -> None:
start=start,
end=end,
referrer="tagstore.get_groups_user_counts",
+ occurrence_category=OccurrenceCategory.ERROR,
)
assert snuba_with_env == {group_a.id: 2}
@@ -1688,6 +1709,7 @@ def test_eap_and_snuba_user_counts_match_with_environment_filter(self) -> None:
start=start,
end=end,
referrer="tagstore.get_groups_user_counts",
+ occurrence_category=OccurrenceCategory.ERROR,
)
assert snuba_no_env == {group_a.id: 3}
@@ -1699,11 +1721,11 @@ def test_eap_and_snuba_user_counts_match_with_time_range_filter(self) -> None:
recent_ts = (self.FROZEN_TIME - timedelta(minutes=5)).timestamp()
# Old events outside the query window
- self._store_event_with_user("group-a", "user1", old_ts)
- self._store_event_with_user("group-a", "user2", old_ts)
+ self._store_error_event_with_user("group-a", "user1", old_ts)
+ self._store_error_event_with_user("group-a", "user2", old_ts)
# Recent event inside the query window
- event = self._store_event_with_user("group-a", "user3", recent_ts)
+ event = self._store_error_event_with_user("group-a", "user3", recent_ts)
group_a = event.group
assert group_a is not None
@@ -1728,7 +1750,118 @@ def test_eap_and_snuba_user_counts_match_with_time_range_filter(self) -> None:
start=start,
end=end,
referrer="tagstore.get_groups_user_counts",
+ occurrence_category=OccurrenceCategory.ERROR,
)
assert snuba_result == {group_a.id: 1}
assert eap_result == snuba_result
+
+ @freeze_time(FROZEN_TIME)
+ def test_eap_issue_platform_user_counts_multiple_groups(self) -> None:
+ ts = self.FROZEN_TIME - timedelta(minutes=5)
+
+ group_a = self.create_group(project=self.project)
+ group_b = self.create_group(project=self.project)
+ group_c = self.create_group(project=self.project)
+
+ # Group A: 3 items from 2 unique users (user1 appears twice)
+ self._store_issue_platform_event_with_user(group_a, "user1", ts)
+ self._store_issue_platform_event_with_user(group_a, "user1", ts)
+ self._store_issue_platform_event_with_user(group_a, "user2", ts)
+
+ # Group B: 2 items from 2 unique users
+ self._store_issue_platform_event_with_user(group_b, "user3", ts)
+ self._store_issue_platform_event_with_user(group_b, "user4", ts)
+
+ # Group C: 1 item from 1 unique user
+ self._store_issue_platform_event_with_user(group_c, "user5", ts)
+
+ group_ids = [group_a.id, group_b.id, group_c.id]
+ start = self.FROZEN_TIME - timedelta(hours=1)
+ end = self.FROZEN_TIME + timedelta(hours=1)
+
+ eap_result = self.ts._eap_get_groups_user_counts(
+ project_ids=[self.project.id],
+ group_ids=group_ids,
+ environment_ids=None,
+ start=start,
+ end=end,
+ referrer="tagstore.get_generic_groups_user_counts",
+ occurrence_category=OccurrenceCategory.ISSUE_PLATFORM,
+ )
+
+ assert eap_result == {group_a.id: 2, group_b.id: 2, group_c.id: 1}
+
+ @freeze_time(FROZEN_TIME)
+ def test_eap_issue_platform_user_counts_with_environment_filter(self) -> None:
+ ts = self.FROZEN_TIME - timedelta(minutes=5)
+ env = self.create_environment(project=self.project, name="prod")
+
+ group_a = self.create_group(project=self.project)
+
+ # 2 items in "prod" env from 2 unique users
+ self._store_issue_platform_event_with_user(group_a, "user1", ts, environment=env.name)
+ self._store_issue_platform_event_with_user(group_a, "user2", ts, environment=env.name)
+
+ # 1 item in a different env
+ self._store_issue_platform_event_with_user(group_a, "user3", ts, environment="staging")
+
+ group_ids = [group_a.id]
+ start = self.FROZEN_TIME - timedelta(hours=1)
+ end = self.FROZEN_TIME + timedelta(hours=1)
+
+ # With environment filter: should only count user1, user2
+ eap_with_env = self.ts._eap_get_groups_user_counts(
+ project_ids=[self.project.id],
+ group_ids=group_ids,
+ environment_ids=[env.id],
+ start=start,
+ end=end,
+ referrer="tagstore.get_generic_groups_user_counts",
+ occurrence_category=OccurrenceCategory.ISSUE_PLATFORM,
+ )
+
+ assert eap_with_env == {group_a.id: 2}
+
+ # Without environment filter: should count all 3 users
+ eap_no_env = self.ts._eap_get_groups_user_counts(
+ project_ids=[self.project.id],
+ group_ids=group_ids,
+ environment_ids=None,
+ start=start,
+ end=end,
+ referrer="tagstore.get_generic_groups_user_counts",
+ occurrence_category=OccurrenceCategory.ISSUE_PLATFORM,
+ )
+
+ assert eap_no_env == {group_a.id: 3}
+
+ @freeze_time(FROZEN_TIME)
+ def test_eap_issue_platform_user_counts_with_time_range_filter(self) -> None:
+ old_ts = self.FROZEN_TIME - timedelta(hours=5)
+ recent_ts = self.FROZEN_TIME - timedelta(minutes=5)
+
+ group_a = self.create_group(project=self.project)
+
+ # Old items outside the query window
+ self._store_issue_platform_event_with_user(group_a, "user1", old_ts)
+ self._store_issue_platform_event_with_user(group_a, "user2", old_ts)
+
+ # Recent item inside the query window
+ self._store_issue_platform_event_with_user(group_a, "user3", recent_ts)
+
+ group_ids = [group_a.id]
+ start = self.FROZEN_TIME - timedelta(hours=1)
+ end = self.FROZEN_TIME + timedelta(hours=1)
+
+ eap_result = self.ts._eap_get_groups_user_counts(
+ project_ids=[self.project.id],
+ group_ids=group_ids,
+ environment_ids=None,
+ start=start,
+ end=end,
+ referrer="tagstore.get_generic_groups_user_counts",
+ occurrence_category=OccurrenceCategory.ISSUE_PLATFORM,
+ )
+
+ assert eap_result == {group_a.id: 1}
From f49ecc753970ad5055f9ca28c3d864f6f19fd951 Mon Sep 17 00:00:00 2001
From: Shashank Jarmale
Date: Tue, 31 Mar 2026 13:13:22 -0700
Subject: [PATCH 37/51] feat(occurrences on eap): Implement EAP query for
tagstore group tag value count (#111868)
Implements double reads of occurrences from EAP for
`get_group_tag_value_count` in `src/sentry/tagstore/snuba/backend.py`.
---
src/sentry/snuba/referrer.py | 1 +
src/sentry/tagstore/snuba/backend.py | 53 ++++++++++++++++++++++++----
2 files changed, 48 insertions(+), 6 deletions(-)
diff --git a/src/sentry/snuba/referrer.py b/src/sentry/snuba/referrer.py
index bc785e83708f..8996e68a9748 100644
--- a/src/sentry/snuba/referrer.py
+++ b/src/sentry/snuba/referrer.py
@@ -836,6 +836,7 @@ class Referrer(StrEnum):
)
TAGSTORE__GET_TAG_KEYS = "tagstore.__get_tag_keys"
TAGSTORE_GET_GROUP_LIST_TAG_VALUE = "tagstore.get_group_list_tag_value"
+ TAGSTORE_GET_GROUP_TAG_VALUE_COUNT = "tagstore.get_group_tag_value_count"
TAGSTORE_GET_GROUP_TAG_VALUE_ITER = "tagstore.get_group_tag_value_iter"
TAGSTORE_GET_GROUPS_USER_COUNTS = "tagstore.get_groups_user_counts"
TAGSTORE_GET_GROUPS_USER_COUNTS_GROUP_SNOOZE = "tagstore.get_groups_user_counts.groupsnooze"
diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py
index 6d57731e4926..1df6578b8626 100644
--- a/src/sentry/tagstore/snuba/backend.py
+++ b/src/sentry/tagstore/snuba/backend.py
@@ -40,6 +40,7 @@
from sentry.models.releaseprojectenvironment import ReleaseProjectEnvironment
from sentry.models.releases.release_project import ReleaseProject
from sentry.replays.query import query_replays_dataset_tagkey_values
+from sentry.search.eap.occurrences.common_queries import count_occurrences
from sentry.search.eap.occurrences.definitions import OCCURRENCE_DEFINITIONS
from sentry.search.eap.occurrences.rollout_utils import EAPOccurrencesComparator
from sentry.search.eap.resolver import SearchResolver
@@ -887,24 +888,64 @@ def apply_group_filters(self, group: Group | None, filters: MutableMapping[str,
def get_group_tag_value_count(
self,
- group,
- environment_id,
+ group: Group,
+ environment_id: int | None,
key: str,
- tenant_ids=None,
- ):
+ tenant_ids: dict[str, str | int] | None = None,
+ ) -> int:
filters: dict[str, Sequence[Any]] = {"project_id": get_project_list(group.project_id)}
if environment_id:
filters["environment"] = [environment_id]
aggregations = [["count()", "", "count"]]
dataset, filters = self.apply_group_filters(group, filters)
- return snuba.query(
+ snuba_result = snuba.query(
dataset=dataset,
filter_keys=filters,
aggregations=aggregations,
- referrer="tagstore.get_group_tag_value_count",
+ referrer=Referrer.TAGSTORE_GET_GROUP_TAG_VALUE_COUNT.value,
tenant_ids=tenant_ids,
)
+ result = snuba_result
+
+ callsite = "SnubaTagStorage::get_group_tag_value_count"
+ if EAPOccurrencesComparator.should_check_experiment(callsite):
+ occurrence_category = (
+ OccurrenceCategory.ERROR
+ if group.issue_category == GroupCategory.ERROR
+ else OccurrenceCategory.ISSUE_PLATFORM
+ )
+
+ now = datetime.now(tz=timezone.utc)
+ environments = (
+ list(Environment.objects.filter(id=environment_id)) if environment_id else None
+ )
+
+ eap_result = count_occurrences(
+ organization=Organization.objects.get_from_cache(id=group.project.organization_id),
+ projects=[group.project],
+ start=now - timedelta(days=90),
+ end=now,
+ referrer=Referrer.TAGSTORE_GET_GROUP_TAG_VALUE_COUNT.value,
+ group_id=group.id,
+ environments=environments,
+ occurrence_category=occurrence_category,
+ )
+ result = EAPOccurrencesComparator.check_and_choose(
+ control_data=snuba_result,
+ experimental_data=eap_result,
+ callsite=callsite,
+ is_experimental_data_a_null_result=eap_result == 0,
+ reasonable_match_comparator=lambda control, experimental: experimental <= control,
+ debug_context={
+ "group_id": group.id,
+ "project_id": group.project_id,
+ "environment_id": environment_id,
+ "occurrence_category": occurrence_category.value,
+ },
+ )
+
+ return result
def get_top_group_tag_values(
self,
From 2ec994054940dfb090778a6b5f79dea0346743ca Mon Sep 17 00:00:00 2001
From: Evan Purkhiser
Date: Tue, 31 Mar 2026 16:23:20 -0400
Subject: [PATCH 38/51] refs(github): Remove github-multi-org-upsell-modal flag
(#111940)
---
src/sentry/features/temporary.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py
index 497ef5b09c6b..0bf28bcf3fc8 100644
--- a/src/sentry/features/temporary.py
+++ b/src/sentry/features/temporary.py
@@ -480,8 +480,6 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:cache-detectors-by-data-source", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable single trace summary
manager.add("organizations:single-trace-summary", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
- # Enable seeing upsell modal when clicking upgrade for multi-org
- manager.add("organizations:github-multi-org-upsell-modal", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enables CMD+K supercharged (omni search)
manager.add("organizations:cmd-k-supercharged", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enables DSN lookup in CMD+K palette
From 5800193020a2e9465b394f24f5f3967dabc34049 Mon Sep 17 00:00:00 2001
From: Max Topolsky <30879163+mtopo27@users.noreply.github.com>
Date: Tue, 31 Mar 2026 16:29:47 -0400
Subject: [PATCH 39/51] feat(preprod): Add value, conditions, and config to
size analysis evidence_data (#111923)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add `value`, `conditions`, and `config` to the `evidence_data` dict in
`PreprodSizeAnalysisDetectorHandler.create_occurrence()`. This snapshots
the evaluated value, the condition(s) that fired, and the detector
config
at trigger time — following the same pattern as
`StatefulDetectorHandler._build_workflow_engine_evidence_data`.
This is the backend half of EME-981 (triggered condition display for
size
analysis issues). The frontend PR will read these new fields from
`event.occurrence.evidenceData` to render a triggered condition section.
Refs EME-981
Co-authored-by: Claude Opus 4.6
---
src/sentry/preprod/size_analysis/grouptype.py | 5 ++
.../preprod/size_analysis/test_grouptype.py | 56 ++++++++++++++++++-
2 files changed, 60 insertions(+), 1 deletion(-)
diff --git a/src/sentry/preprod/size_analysis/grouptype.py b/src/sentry/preprod/size_analysis/grouptype.py
index ce89fd2b1886..8e0b30126029 100644
--- a/src/sentry/preprod/size_analysis/grouptype.py
+++ b/src/sentry/preprod/size_analysis/grouptype.py
@@ -295,6 +295,11 @@ def create_occurrence(
evidence_data: dict[str, Any] = {
"detector_id": self.detector.id,
+ "value": self.extract_value(data_packet),
+ "conditions": [
+ result.condition.get_snapshot() for result in evaluation_result.condition_results
+ ],
+ "config": self.detector.config,
}
if metadata:
evidence_data["head_artifact_id"] = metadata["head_artifact_id"]
diff --git a/tests/sentry/preprod/size_analysis/test_grouptype.py b/tests/sentry/preprod/size_analysis/test_grouptype.py
index 95c50b0d1b31..fe2ae8ed8bd8 100644
--- a/tests/sentry/preprod/size_analysis/test_grouptype.py
+++ b/tests/sentry/preprod/size_analysis/test_grouptype.py
@@ -683,6 +683,14 @@ def test_create_occurrence_install_size_with_metadata(self) -> None:
assert occurrence.evidence_data["base_artifact_id"] == base_artifact.id
assert occurrence.evidence_data["head_size_metric_id"] == 100
assert occurrence.evidence_data["base_size_metric_id"] == 200
+ assert occurrence.evidence_data["value"] == 5000000
+ assert len(occurrence.evidence_data["conditions"]) == 1
+ assert occurrence.evidence_data["conditions"][0]["type"] == "gt"
+ assert occurrence.evidence_data["conditions"][0]["comparison"] == 1000000
+ assert occurrence.evidence_data["config"] == {
+ "threshold_type": "absolute",
+ "measurement": "install_size",
+ }
def test_create_occurrence_download_size_with_metadata(self) -> None:
commit_comparison = self.create_commit_comparison(
@@ -801,7 +809,53 @@ def test_create_occurrence_without_metadata(self) -> None:
assert occurrence.issue_title == "Install size regression"
assert event_data["platform"] == "unknown"
assert event_data["tags"] == {}
- assert occurrence.evidence_data == {"detector_id": detector.id}
+ assert occurrence.evidence_data["detector_id"] == detector.id
+ assert occurrence.evidence_data["value"] == 5000000
+ assert len(occurrence.evidence_data["conditions"]) == 1
+ assert occurrence.evidence_data["conditions"][0]["type"] == "gt"
+ assert occurrence.evidence_data["conditions"][0]["comparison"] == 1000000
+ assert occurrence.evidence_data["config"] == {
+ "threshold_type": "absolute",
+ "measurement": "install_size",
+ }
+
+ def test_create_occurrence_relative_diff_value(self) -> None:
+ condition_group = self.create_data_condition_group(
+ organization=self.project.organization,
+ )
+ self.create_data_condition(
+ condition_group=condition_group,
+ type=Condition.GREATER,
+ comparison=10,
+ condition_result=DetectorPriorityLevel.HIGH,
+ )
+ detector = self.create_detector(
+ name="test-detector",
+ type=PreprodSizeAnalysisGroupType.slug,
+ project=self.project,
+ config={"threshold_type": "relative_diff", "measurement": "install_size"},
+ workflow_condition_group=condition_group,
+ )
+ data_packet: SizeAnalysisDataPacket = DataPacket(
+ source_id="test-source",
+ packet={
+ "head_install_size_bytes": 1500000,
+ "head_download_size_bytes": 1500000,
+ "base_install_size_bytes": 1000000,
+ "base_download_size_bytes": 1000000,
+ },
+ )
+ handler = PreprodSizeAnalysisDetectorHandler(detector)
+ result = handler.evaluate(data_packet)
+
+ assert None in result
+ occurrence = result[None].result
+ assert isinstance(occurrence, IssueOccurrence)
+
+ # relative_diff: ((1500000 - 1000000) / 1000000) * 100 = 50.0
+ assert occurrence.evidence_data["value"] == 50.0
+ assert occurrence.evidence_data["config"]["threshold_type"] == "relative_diff"
+ assert occurrence.evidence_data["conditions"][0]["comparison"] == 10
@cell_silo_test
From a5b35d9cb4ef63a9659b09bf861a7af74d182ca6 Mon Sep 17 00:00:00 2001
From: Evan Purkhiser
Date: Tue, 31 Mar 2026 16:39:06 -0400
Subject: [PATCH 40/51] Remove github-multi-org-upsell-modal feature flag from
frontend (#111941)
Always use the InstallButtonHook path instead of branching on the
flag. Removes the old renderInstallationButtonOld code path and
associated dead code (doesntRequireUpgrade, isSelfHosted, etc).
---
static/app/types/hooks.tsx | 8 --
.../githubInstallationSelect.tsx | 49 +-------
.../githubInstallationSelectInstall.spec.tsx | 106 ------------------
.../hooks/githubInstallationSelectInstall.tsx | 76 -------------
static/gsApp/registerHooks.tsx | 2 -
5 files changed, 1 insertion(+), 240 deletions(-)
delete mode 100644 static/gsApp/hooks/githubInstallationSelectInstall.spec.tsx
delete mode 100644 static/gsApp/hooks/githubInstallationSelectInstall.tsx
diff --git a/static/app/types/hooks.tsx b/static/app/types/hooks.tsx
index 0f7246ac3a8a..3f75c04634cc 100644
--- a/static/app/types/hooks.tsx
+++ b/static/app/types/hooks.tsx
@@ -1,5 +1,4 @@
import type {ButtonProps} from '@sentry/scraps/button';
-import type {SelectKey} from '@sentry/scraps/compactSelect';
import type {ChildrenRenderFn} from 'sentry/components/acl/feature';
import type {Guide} from 'sentry/components/assistant/types';
@@ -168,12 +167,6 @@ export type MembershipSettingsProps = {
onSave: (previous: Organization, updated: Organization) => void;
organization: Organization;
};
-export type GithubInstallationInstallButtonProps = {
- handleSubmit: (e: React.MouseEvent) => void;
- hasSCMMultiOrg: boolean;
- installationID: SelectKey;
- isSaving: boolean;
-};
type DashboardLimitProviderProps = {
children:
@@ -230,7 +223,6 @@ type ComponentHooks = {
'component:replay-onboarding-alert': () => React.ComponentType;
'component:replay-onboarding-cta': () => React.ComponentType;
'component:replay-settings-alert': () => React.ComponentType | null;
- 'component:scm-multi-org-install-button': () => React.ComponentType;
'component:seer-beta-closing-alert': () => React.ComponentType;
'component:superuser-access-category': React.ComponentType;
'component:superuser-warning': React.ComponentType;
diff --git a/static/app/views/integrationPipeline/githubInstallationSelect.tsx b/static/app/views/integrationPipeline/githubInstallationSelect.tsx
index 17ff73150435..6bb85ad57665 100644
--- a/static/app/views/integrationPipeline/githubInstallationSelect.tsx
+++ b/static/app/views/integrationPipeline/githubInstallationSelect.tsx
@@ -11,11 +11,9 @@ import {OverlayTrigger} from '@sentry/scraps/overlayTrigger';
import {addLoadingMessage} from 'sentry/actionCreators/indicator';
import {FeatureDisabled} from 'sentry/components/acl/featureDisabled';
-import {HookOrDefault} from 'sentry/components/hookOrDefault';
import {IconAdd, IconLightning} from 'sentry/icons';
import {t} from 'sentry/locale';
import {ConfigStore} from 'sentry/stores/configStore';
-import type {GithubInstallationInstallButtonProps} from 'sentry/types/hooks';
import type {Organization} from 'sentry/types/organization';
import {testableWindowLocation} from 'sentry/utils/testableWindowLocation';
@@ -31,38 +29,6 @@ type GithubInstallationProps = {
organization: Organization;
};
-function InstallationButton({
- handleSubmit,
- isSaving,
- installationID,
- hasSCMMultiOrg,
-}: GithubInstallationInstallButtonProps) {
- if (installationID !== '-1' && !hasSCMMultiOrg) {
- return (
-
- );
- }
-
- return (
-
- {t('Install')}
-
- );
-}
-
-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 (
+ {
+ if (preferredAgentIntegration) {
+ setIsBulkMutatingAgent(true);
+ await bulkMutateSelectedAgent(preferredAgentIntegration, {});
+ setIsBulkMutatingAgent(false);
+ } else {
+ addErrorMessage(t('No coding agent integration found'));
+ }
+ }}
+ >
+ {tn(
+ 'Set for the existing project',
+ 'Set for all existing projects',
+ projectsWithPreferredAgent.length
+ )}
+
{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 (
+ {
+ setIsBulkMutatingCreatePr(true);
+ await bulkMutateCreatePr(field.state.value, {});
+ setIsBulkMutatingCreatePr(false);
+ }}
+ >
+ {field.state.value
+ ? tn(
+ 'Enable for the existing project',
+ 'Enable for all existing projects',
+ projectsWithCreatePr.length
+ )
+ : tn(
+ 'Disable for the existing project',
+ 'Disable for all existing projects',
+ projectsWithCreatePr.length
+ )}
+
{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() {
}
- onClick={() => {
+ onClick={async () => {
+ const {ScmRepoTreeModal} =
+ await import('sentry/components/repositories/scmRepoTreeModal');
+
openModal(
deps => ,
{