diff --git a/.agents/skills/cell-architecture/SKILL.md b/.agents/skills/cell-architecture/SKILL.md index 0febba93b8a9..47b774956caa 100644 --- a/.agents/skills/cell-architecture/SKILL.md +++ b/.agents/skills/cell-architecture/SKILL.md @@ -199,6 +199,7 @@ This applies to: - DB columns: new columns must be nullable or have defaults; don't drop a column in the same deploy that stops writing it; when **renaming a Python field**, set `db_column="old_name"` to avoid a schema migration entirely — the DB column stays unchanged and is safe across rolling deploys - API response shape changes - Any data written to outboxes, queues, or caches that may be read by older code +- **Taskworker/Celery task names and kwargs** — the `name=` string is serialized into Kafka/the broker; in-flight tasks carry the old name and old kwarg names. Keep the old name registered during the transition (via `alias=` on `@instrumented_task`, or by keeping the old task as a shim that calls the new function directly). Remove it in a follow-up deploy once the queue has drained. #### region -> cell Rename diff --git a/.agents/skills/hybrid-cloud-outboxes/references/signal-receivers.md b/.agents/skills/hybrid-cloud-outboxes/references/signal-receivers.md index e72a270371a7..a24729b6fcfb 100644 --- a/.agents/skills/hybrid-cloud-outboxes/references/signal-receivers.md +++ b/.agents/skills/hybrid-cloud-outboxes/references/signal-receivers.md @@ -79,13 +79,13 @@ def process_my_category(object_identifier: int, payload: Any, **kwds: Any) -> No ## Control Outbox Receivers -Control outbox signals include an additional `region_name` argument: +Control outbox signals include an additional `cell_name` argument: - `sender`: `OutboxCategory` enum value - `payload`: `dict | None` - `object_identifier`: `int` - `shard_identifier`: `int` -- `region_name`: `str` — the target region +- `cell_name`: `str` — the target cell - `shard_scope`: `int` - `date_added`: `datetime` - `scheduled_for`: `datetime` @@ -100,13 +100,13 @@ from sentry.receivers.outbox import maybe_process_tombstone @receiver(process_control_outbox, sender=OutboxCategory.MY_CATEGORY) -def process_my_category(object_identifier: int, region_name: str, **kwds: Any) -> None: +def process_my_category(object_identifier: int, cell_name: str, **kwds: Any) -> None: if (instance := maybe_process_tombstone( - MyModel, object_identifier, cell_name=region_name + MyModel, object_identifier, cell_name=cell_name )) is None: return # Replicate to the specific cell - my_cell_service.sync(cell_name=region_name, data=serialize(instance)) + my_cell_service.sync(cell_name=cell_name, data=serialize(instance)) ``` ### Template: Control Pure-RPC Receiver @@ -118,7 +118,7 @@ For categories where the receiver makes an RPC call without looking up a model: def process_my_category( payload: Mapping[str, Any], shard_identifier: int, **kwds: Any ) -> None: - my_region_service.do_something( + my_cell_service.do_something( organization_id=shard_identifier, data=payload["data"], ) @@ -144,4 +144,4 @@ The tombstone system drives `HybridCloudForeignKey` cascade deletes across silos **When to use**: Any receiver that needs to distinguish between "object was created/updated" and "object was deleted". Not needed for payload-only categories (audit logs, IP events) where the payload carries all necessary data. -**`region_name` parameter**: Pass `region_name` for control outbox receivers (tombstone goes to the cell). Omit for cell outbox receivers (tombstone goes to control). +**`cell_name` parameter**: Pass `cell_name` for control outbox receivers (tombstone goes to the cell). Omit for cell outbox receivers (tombstone goes to control). diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 75e586ab5d82..71f2d511d69d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -294,6 +294,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /src/sentry/search/events/ @getsentry/data-browsing /src/sentry/search/eap/ @getsentry/data-browsing +/src/sentry/search/exceptions.py @getsentry/data-browsing /src/sentry/issue_detection/ @getsentry/issue-detection-backend /tests/sentry/issue_detection/ @getsentry/issue-detection-backend @@ -778,6 +779,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get # Span buffer + process-segments are co-owned by streaming platform and vis for now. /src/sentry/spans/ @getsentry/data-browsing @getsentry/streaming-platform +/src/sentry/scripts/spans/ @getsentry/data-browsing @getsentry/streaming-platform /tests/sentry/spans/ @getsentry/data-browsing @getsentry/streaming-platform # Streaming platform diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index 1f0cc453e14f..35a7bad47fde 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -13,12 +13,15 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 90 steps: - - name: Getsentry Token - id: getsentry - uses: getsentry/action-github-app-token@5c1e90706fe007857338ac1bfbd7a4177db2f789 # v4.0.0 + - name: github app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: - app_id: ${{ vars.SENTRY_INTERNAL_APP_ID }} - private_key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }} + app-id: ${{ vars.SENTRY_API_SCHEMA_UPDATER_APP_CLIENT_ID }} + private-key: ${{ secrets.SENTRY_API_SCHEMA_UPDATER_PRIVATE_KEY }} + repositories: | + sentry + sentry-api-schema - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 @@ -42,7 +45,9 @@ jobs: ref: 'main' repository: getsentry/sentry-api-schema path: sentry-api-schema - token: ${{ steps.getsentry.outputs.token }} + # using app token so that `Git Commit & Push` step has the right permissions + # https://github.com/stefanzweifel/git-auto-commit-action/tree/master?tab=readme-ov-file#push-to-protected-branches + token: ${{ steps.app-token.outputs.token }} - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 if: steps.changes.outputs.api_docs == 'true' @@ -75,5 +80,5 @@ jobs: repository: sentry-api-schema branch: main commit_message: Generated - commit_user_email: bot@getsentry.com - commit_user_name: openapi-getsentry-bot + commit_user_email: 271575301+sentry-api-schema-updater[bot]@users.noreply.github.com + commit_user_name: sentry-api-schema-updater[bot] diff --git a/src/sentry/api/endpoints/organization_pipeline.py b/src/sentry/api/endpoints/organization_pipeline.py new file mode 100644 index 000000000000..4a3931a4b78c --- /dev/null +++ b/src/sentry/api/endpoints/organization_pipeline.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import logging + +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import control_silo_endpoint +from sentry.api.bases.organization import ( + ControlSiloOrganizationEndpoint, + OrganizationPermission, +) +from sentry.exceptions import NotRegistered +from sentry.identity.pipeline import IdentityPipeline +from sentry.integrations.pipeline import ( + IntegrationPipeline, + IntegrationPipelineError, + initialize_integration_pipeline, +) +from sentry.organizations.services.organization.model import RpcOrganization +from sentry.pipeline.base import Pipeline +from sentry.pipeline.types import PipelineStepAction + +logger = logging.getLogger(__name__) + +# All pipeline classes that can be driven via the API. The endpoint tries each +# in order and uses whichever one has a valid session for the request. +PIPELINE_CLASSES = (IntegrationPipeline, IdentityPipeline) + + +class PipelinePermission(OrganizationPermission): + scope_map = { + "GET": ["org:read", "org:write", "org:admin", "org:integrations"], + "POST": ["org:write", "org:admin", "org:integrations"], + } + + +def _get_api_pipeline( + request: Request, organization: RpcOrganization, pipeline_name: str +) -> Response | Pipeline: + """Look up an active API-ready pipeline from the session, or return an error Response.""" + pipelines = {cls.pipeline_name: cls for cls in PIPELINE_CLASSES} + if pipeline_name not in pipelines: + return Response({"detail": "Invalid pipeline type"}, status=404) + + pipeline = pipelines[pipeline_name].get_for_request(request._request) + if not pipeline or not pipeline.organization: + return Response({"detail": "No active pipeline session."}, status=404) + + if not pipeline.is_valid() or pipeline.organization.id != organization.id: + return Response({"detail": "Invalid pipeline state."}, status=404) + + if not pipeline.is_api_ready(): + return Response({"detail": "Pipeline does not support API mode."}, status=400) + + return pipeline + + +@control_silo_endpoint +class OrganizationPipelineEndpoint(ControlSiloOrganizationEndpoint): + owner = ApiOwner.ENTERPRISE + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + "POST": ApiPublishStatus.EXPERIMENTAL, + } + permission_classes = (PipelinePermission,) + + def get( + self, request: Request, organization: RpcOrganization, pipeline_name: str, **kwargs: object + ) -> Response: + result = _get_api_pipeline(request, organization, pipeline_name) + if isinstance(result, Response): + return result + return Response(result.get_current_step_info()) + + def post( + self, request: Request, organization: RpcOrganization, pipeline_name: str, **kwargs: object + ) -> Response: + if request.data.get("action") == "initialize": + return self._initialize_pipeline(request, organization, pipeline_name) + + result = _get_api_pipeline(request, organization, pipeline_name) + if isinstance(result, Response): + return result + pipeline = result + + step_result = pipeline.api_advance(request._request, request.data) + + response_data = step_result.serialize() + if step_result.action == PipelineStepAction.ADVANCE: + response_data.update(pipeline.get_current_step_info()) + + if step_result.action == PipelineStepAction.ERROR: + return Response(response_data, status=400) + + return Response(response_data) + + def _initialize_pipeline( + self, request: Request, organization: RpcOrganization, pipeline_name: str + ) -> Response: + if pipeline_name != IntegrationPipeline.pipeline_name: + return Response( + {"detail": "Initialization not supported for this pipeline."}, status=400 + ) + + provider_id = request.data.get("provider") + if not provider_id: + return Response({"detail": "provider is required."}, status=400) + + try: + pipeline = initialize_integration_pipeline(request._request, organization, provider_id) + except NotRegistered: + return Response({"detail": f"Unknown provider: {provider_id}"}, status=404) + except IntegrationPipelineError as e: + return Response({"detail": str(e)}, status=404 if e.not_found else 400) + + if not pipeline.is_api_ready(): + return Response({"detail": "Pipeline does not support API mode."}, status=400) + + pipeline.set_api_mode() + + return Response(pipeline.get_current_step_info()) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index f74e5f1b33ac..0515bc29aa45 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -19,6 +19,7 @@ from sentry.api.endpoints.organization_insights_tree import OrganizationInsightsTreeEndpoint from sentry.api.endpoints.organization_intercom_jwt import OrganizationIntercomJwtEndpoint from sentry.api.endpoints.organization_missing_org_members import OrganizationMissingMembersEndpoint +from sentry.api.endpoints.organization_pipeline import OrganizationPipelineEndpoint from sentry.api.endpoints.organization_plugin_deprecation_info import ( OrganizationPluginDeprecationInfoEndpoint, ) @@ -2038,6 +2039,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: ExternalUserDetailsEndpoint.as_view(), name="sentry-api-0-organization-external-user-details", ), + re_path( + r"^(?P[^/]+)/pipeline/(?P[^/]+)/$", + OrganizationPipelineEndpoint.as_view(), + name="sentry-api-0-organization-pipeline", + ), re_path( r"^(?P[^/]+)/integration-requests/$", OrganizationIntegrationRequestEndpoint.as_view(), diff --git a/src/sentry/apidocs/parameters.py b/src/sentry/apidocs/parameters.py index 2cbb90a209ba..5da3ec91d758 100644 --- a/src/sentry/apidocs/parameters.py +++ b/src/sentry/apidocs/parameters.py @@ -431,7 +431,13 @@ class DetectorParams: location="query", required=False, type=str, - description="An optional search query for filtering monitors.", + description="""An optional search query for filtering monitors. + +Available fields are: +- `name` +- `type`: e.g. `error`, `metric_issue`, `issue_stream` +- `assignee`: email, username, #team, me, none + """, ) SORT = OpenApiParameter( diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 23afbdbfc2c5..7f448f1cc9ed 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -752,18 +752,22 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: # An enum is better because there shouldn't be multiple "modes". SENTRY_MODE = SentryMode.SELF_HOSTED -# If this instance is a region silo, which region is it running in? -SENTRY_REGION = os.environ.get("SENTRY_REGION", None) +# If this instance is a cell silo, which cell is it running in? +SENTRY_LOCAL_CELL = os.environ.get("SENTRY_REGION", None) # Returns the customer single tenant ID. CUSTOMER_ID = os.environ.get("CUSTOMER_ID", None) # List of the available cells (e.g. "us1", "us2", "de1") -SENTRY_REGION_CONFIG: list[CellConfig] = [] +SENTRY_CELLS: list[CellConfig] = [] # Mapping of localities (e.g. "us", "de") to their constituent cells (e.g. "us1", "us2") SENTRY_LOCALITIES: list[LocalityConfig] = [] +# TODO(cells): Superceded by SENTRY_LOCAL_CELL and SENTRY_CELLS. Remove once migration is complete. +SENTRY_REGION = os.environ.get("SENTRY_REGION", None) +SENTRY_REGION_CONFIG: list[CellConfig] = [] + # Shared secret used to sign cross-region RPC requests. RPC_SHARED_SECRET: list[str] | None = None diff --git a/src/sentry/hybridcloud/models/outbox.py b/src/sentry/hybridcloud/models/outbox.py index 48af722b4bb8..be03c109ef43 100644 --- a/src/sentry/hybridcloud/models/outbox.py +++ b/src/sentry/hybridcloud/models/outbox.py @@ -474,7 +474,7 @@ def send_signal(self) -> None: process_control_outbox.send( sender=OutboxCategory(self.category), payload=self.payload, - region_name=self.cell_name, + cell_name=self.cell_name, object_identifier=self.object_identifier, shard_identifier=self.shard_identifier, shard_scope=self.shard_scope, diff --git a/src/sentry/hybridcloud/outbox/category.py b/src/sentry/hybridcloud/outbox/category.py index 132ed383a9cf..8bc9baf0d144 100644 --- a/src/sentry/hybridcloud/outbox/category.py +++ b/src/sentry/hybridcloud/outbox/category.py @@ -98,25 +98,25 @@ def receiver( object_identifier: int, payload: Mapping[str, Any] | None, shard_identifier: int, - region_name: str, + cell_name: str, *args: Any, **kwds: Any, ) -> None: from sentry.receivers.outbox import maybe_process_tombstone maybe_instance: HasControlReplicationHandlers | None = maybe_process_tombstone( - cast(Any, model), object_identifier, cell_name=region_name + cast(Any, model), object_identifier, cell_name=cell_name ) if maybe_instance is None: model.handle_async_deletion( identifier=object_identifier, - cell_name=region_name, + cell_name=cell_name, shard_identifier=shard_identifier, payload=payload, ) else: maybe_instance.handle_async_replication( - shard_identifier=shard_identifier, cell_name=region_name + shard_identifier=shard_identifier, cell_name=cell_name ) process_control_outbox.connect(receiver, weak=False, sender=self) diff --git a/src/sentry/hybridcloud/outbox/signals.py b/src/sentry/hybridcloud/outbox/signals.py index db3c4ee838e5..16bdd84959ae 100644 --- a/src/sentry/hybridcloud/outbox/signals.py +++ b/src/sentry/hybridcloud/outbox/signals.py @@ -1,4 +1,4 @@ from django.dispatch import Signal process_cell_outbox = Signal() # ["payload", "object_identifier"] -process_control_outbox = Signal() # ["payload", "region_name", "object_identifier"] +process_control_outbox = Signal() # ["payload", "cell_name", "object_identifier"] diff --git a/src/sentry/identity/oauth2.py b/src/sentry/identity/oauth2.py index 6c1f6f0815b9..fa36cc207a09 100644 --- a/src/sentry/identity/oauth2.py +++ b/src/sentry/identity/oauth2.py @@ -15,7 +15,9 @@ from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from requests import Response -from requests.exceptions import HTTPError, SSLError +from requests.exceptions import ConnectionError, HTTPError, SSLError +from rest_framework.fields import CharField +from rest_framework.serializers import Serializer from sentry.auth.exceptions import IdentityNotValid from sentry.exceptions import NotRegistered @@ -30,6 +32,7 @@ IntegrationPipelineViewEvent, IntegrationPipelineViewType, ) +from sentry.pipeline.types import PipelineStepResult from sentry.pipeline.views.base import PipelineView from sentry.shared_integrations.exceptions import ApiError, ApiInvalidRequestError, ApiUnauthorized from sentry.users.models.identity import Identity @@ -37,13 +40,19 @@ from .base import Provider -__all__ = ["OAuth2Provider", "OAuth2CallbackView", "OAuth2LoginView"] +__all__ = ["OAuth2Provider", "OAuth2CallbackView", "OAuth2LoginView", "OAuth2ApiStep"] logger = logging.getLogger(__name__) ERR_INVALID_STATE = "An error occurred while validating your request." ERR_TOKEN_RETRIEVAL = "Failed to retrieve token from the upstream service." +class OAuth2ApiStepError(Exception): + """Raised when the OAuth2 API step encounters an error during token exchange.""" + + pass + + def _redirect_url(pipeline: IdentityPipeline) -> str: associate_url = reverse( "sentry-extension-setup", @@ -137,6 +146,23 @@ def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: ), ] + def get_pipeline_api_steps(self) -> list[OAuth2ApiStep]: + redirect_url = self.config.get( + "redirect_url", + reverse("sentry-extension-setup", kwargs={"provider_id": "default"}), + ) + return [ + OAuth2ApiStep( + authorize_url=self.get_oauth_authorize_url(), + client_id=self.get_oauth_client_id(), + client_secret=self.get_oauth_client_secret(), + access_token_url=self.get_oauth_access_token_url(), + scope=" ".join(self.get_oauth_scopes()), + redirect_url=redirect_url, + verify_ssl=self.config.get("verify_ssl", True), + ), + ] + def get_refresh_token_params( self, refresh_token: str, identity: Identity | RpcIdentity, **kwargs: Any ) -> dict[str, str | None]: @@ -214,6 +240,124 @@ def record_event(event: IntegrationPipelineViewType, provider: str): ) +class OAuth2ApiSerializer(Serializer): + code = CharField(required=True) + state = CharField(required=True) + + +class OAuth2ApiStep: + """ + Generic API-mode step for OAuth2 identity authentication. + + Handles the full OAuth2 authorization code flow in a single API step: + + - GET (get_step_data): returns the OAuth authorize URL for the frontend to + open in a popup. + - POST (handle_post): receives the callback params (code, state) relayed by + the trampoline via postMessage, validates state, exchanges the code for an + access token, and binds the token data to pipeline state. + """ + + step_name = "oauth_login" + + def __init__( + self, + authorize_url: str, + client_id: str, + client_secret: str, + access_token_url: str, + scope: str, + redirect_url: str, + verify_ssl: bool = True, + bind_key: str = "data", + extra_authorize_params: dict[str, str] | None = None, + ) -> None: + self.authorize_url = authorize_url + self.client_id = client_id + self.client_secret = client_secret + self.access_token_url = access_token_url + self.scope = scope + self.redirect_url = redirect_url + self.verify_ssl = verify_ssl + self.bind_key = bind_key + self.extra_authorize_params = extra_authorize_params or {} + + def get_step_data(self, pipeline: Any, request: HttpRequest) -> dict[str, str]: + params = urlencode( + { + "client_id": self.client_id, + "response_type": "code", + "scope": self.scope, + "state": pipeline.signature, + "redirect_uri": absolute_uri(self.redirect_url), + **self.extra_authorize_params, + } + ) + return {"oauthUrl": f"{self.authorize_url}?{params}"} + + def get_serializer_cls(self) -> type: + return OAuth2ApiSerializer + + def handle_post( + self, + validated_data: dict[str, str], + pipeline: Any, + request: HttpRequest, + ) -> PipelineStepResult: + code = validated_data["code"] + state = validated_data["state"] + + if state != pipeline.signature: + return PipelineStepResult.error(ERR_INVALID_STATE) + + try: + data = self._exchange_token(code) + except OAuth2ApiStepError as e: + logger.info("identity.token-exchange-error", extra={"error": str(e)}) + return PipelineStepResult.error(str(e)) + + pipeline.bind_state(self.bind_key, data) + return PipelineStepResult.advance() + + def _exchange_token(self, code: str) -> dict[str, Any]: + """Exchange an authorization code for an access token. + + Raises OAuth2ApiStepError on failure. + """ + token_params = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": absolute_uri(self.redirect_url), + "client_id": self.client_id, + "client_secret": self.client_secret, + } + try: + req = safe_urlopen(self.access_token_url, data=token_params, verify_ssl=self.verify_ssl) + req.raise_for_status() + except HTTPError as e: + error_resp = e.response + exc = ApiError.from_response(error_resp, url=self.access_token_url) + sentry_sdk.capture_exception(exc) + raise OAuth2ApiStepError( + f"Could not retrieve access token. Received {exc.code}: {exc.text}" + ) from e + except SSLError as e: + raise OAuth2ApiStepError( + f"Could not verify SSL certificate for {self.access_token_url}" + ) from e + except ConnectionError as e: + raise OAuth2ApiStepError(f"Could not connect to {self.access_token_url}") from e + + try: + body = safe_urlread(req) + content_type = req.headers.get("Content-Type", "").lower() + if content_type.startswith("application/x-www-form-urlencoded"): + return dict(parse_qsl(body.decode("utf-8"))) + return orjson.loads(body) + except orjson.JSONDecodeError as e: + raise OAuth2ApiStepError("Could not decode a JSON response, please try again.") from e + + class OAuth2LoginView: authorize_url: str | None = None client_id: str | None = None @@ -334,7 +478,7 @@ def exchange_token( body = safe_urlread(req) content_type = req.headers.get("Content-Type", "").lower() if content_type.startswith("application/x-www-form-urlencoded"): - return dict(parse_qsl(body)) + return dict(parse_qsl(body.decode("utf-8"))) return orjson.loads(body) except orjson.JSONDecodeError: lifecycle.record_failure( diff --git a/src/sentry/incidents/endpoints/organization_alert_rule_index.py b/src/sentry/incidents/endpoints/organization_alert_rule_index.py index 0122c3c0741d..ca0662b1ed06 100644 --- a/src/sentry/incidents/endpoints/organization_alert_rule_index.py +++ b/src/sentry/incidents/endpoints/organization_alert_rule_index.py @@ -3,6 +3,7 @@ from copy import deepcopy from datetime import UTC, datetime +from django.db import connections, router, transaction from django.db.models import ( Case, DateTimeField, @@ -43,6 +44,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import ObjectStatus from sentry.db.models.manager.base_query_set import BaseQuerySet +from sentry.db.postgres.transactions import in_test_hide_transaction_boundary from sentry.exceptions import InvalidParams from sentry.incidents.endpoints.bases import OrganizationAlertRuleBaseEndpoint from sentry.incidents.endpoints.serializers.alert_rule import ( @@ -112,6 +114,7 @@ logger = logging.getLogger(__name__) + # Sentinel values for incident_status annotation when sorting combined rules # Used to ensure proper sort order for rules without active incidents INCIDENT_STATUS_NONE = -1 # Metric alerts with no active incident @@ -515,33 +518,48 @@ def _get_workflow_engine( ), ) - # Build intermediaries for pagination - intermediaries: list[CombinedQuerysetIntermediary] = [] - def has_type(rule_type: str) -> bool: return not type_filter or rule_type in type_filter - if has_type("alert_rule"): - intermediaries.append(CombinedQuerysetIntermediary(metric_detectors, sort_key)) - if has_type("rule"): - intermediaries.append(CombinedQuerysetIntermediary(issue_workflows, sort_key)) - if has_type("uptime"): - intermediaries.append(CombinedQuerysetIntermediary(uptime_rules, sort_key)) - if has_type("monitor"): - intermediaries.append(CombinedQuerysetIntermediary(crons_rules, sort_key)) + # Disable JIT on the Detector/DetectorGroup database for the combined paginator queries. + # The planner thinks our metric detector query is going to be very slow because DetectorGroup + # in general has many Groups per Detector, even though for metrics detectors (our case here) it's effectively + # one-to-one. + # It decides to spend ~400ms JITing the query, thinking it is justified due to the bulk of the data, but it is + # wrong. What's worse, we send this query twice, and pay for the JIT twice. + # Disabling it makes this endpoint considerably faster. + # The risk of other regression here should be low; our API endpoint isn't generally doing the sort of bulk + # work that benefits from JIT. + # in_test_hide_transaction_boundary is safe here: this transaction is only + # used to scope SET LOCAL, not to guard data mutations. No writes happen + # inside this block, so there's no cross-db atomicity concern to enforce. + db = router.db_for_write(Detector) + with in_test_hide_transaction_boundary(), transaction.atomic(using=db): + with connections[db].cursor() as cursor: + cursor.execute("SET LOCAL jit = off") + + intermediaries: list[CombinedQuerysetIntermediary] = [] + if has_type("alert_rule"): + intermediaries.append(CombinedQuerysetIntermediary(metric_detectors, sort_key)) + if has_type("rule"): + intermediaries.append(CombinedQuerysetIntermediary(issue_workflows, sort_key)) + if has_type("uptime"): + intermediaries.append(CombinedQuerysetIntermediary(uptime_rules, sort_key)) + if has_type("monitor"): + intermediaries.append(CombinedQuerysetIntermediary(crons_rules, sort_key)) - response = self.paginate( - request, - paginator_cls=CombinedQuerysetPaginator, - on_results=lambda x: serialize( - x, request.user, WorkflowEngineCombinedRuleSerializer(expand=expand) - ), - default_per_page=25, - intermediaries=intermediaries, - desc=not is_asc, - cursor_cls=StringCursor if case_insensitive else Cursor, - case_insensitive=case_insensitive, - ) + response = self.paginate( + request, + paginator_cls=CombinedQuerysetPaginator, + on_results=lambda x: serialize( + x, request.user, WorkflowEngineCombinedRuleSerializer(expand=expand) + ), + default_per_page=25, + intermediaries=intermediaries, + desc=not is_asc, + cursor_cls=StringCursor if case_insensitive else Cursor, + case_insensitive=case_insensitive, + ) response[MAX_QUERY_SUBSCRIPTIONS_HEADER] = get_max_metric_alert_subscriptions(organization) return response diff --git a/src/sentry/incidents/utils/process_update_helpers.py b/src/sentry/incidents/utils/process_update_helpers.py index 1b3cd69449b5..9d0cd395f59f 100644 --- a/src/sentry/incidents/utils/process_update_helpers.py +++ b/src/sentry/incidents/utils/process_update_helpers.py @@ -5,7 +5,7 @@ from sentry.incidents.utils.types import QuerySubscriptionUpdate from sentry.search.eap.utils import add_start_end_conditions -from sentry.search.events.datasets.discover import InvalidIssueSearchQuery +from sentry.search.exceptions import InvalidIssueSearchQuery from sentry.snuba.dataset import Dataset from sentry.snuba.entity_subscription import ( ENTITY_TIME_COLUMNS, diff --git a/src/sentry/integrations/api/endpoints/organization_repository_details.py b/src/sentry/integrations/api/endpoints/organization_repository_details.py index c843b7c2ae76..25df9ba13509 100644 --- a/src/sentry/integrations/api/endpoints/organization_repository_details.py +++ b/src/sentry/integrations/api/endpoints/organization_repository_details.py @@ -12,13 +12,10 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationIntegrationsPermission from sentry.api.exceptions import ResourceDoesNotExist -from sentry.api.fields.empty_integer import EmptyIntegerField from sentry.api.serializers import serialize from sentry.api.serializers.models.repository import RepositorySerializer as RepositoryApiSerializer from sentry.constants import ObjectStatus from sentry.deletions.models.scheduleddeletion import CellScheduledDeletion -from sentry.hybridcloud.rpc import coerce_id_from -from sentry.integrations.services.integration import integration_service from sentry.models.commit import Commit from sentry.models.organization import Organization from sentry.models.repository import Repository @@ -37,7 +34,6 @@ class RepositorySerializer(serializers.Serializer): ) name = serializers.CharField(required=False) url = serializers.URLField(required=False, allow_blank=True) - integrationId = EmptyIntegerField(required=False, allow_null=True) @cell_silo_endpoint @@ -71,6 +67,11 @@ def put(self, request: Request, organization: Organization, repo_id) -> Response if repo.status == ObjectStatus.DELETION_IN_PROGRESS: return Response(status=400) + if "integrationId" in request.data: + return Response( + {"detail": "Changing the repository provider is not allowed"}, status=400 + ) + serializer = RepositorySerializer(data=request.data, partial=True) if not serializer.is_valid(): @@ -85,18 +86,6 @@ def put(self, request: Request, organization: Organization, repo_id) -> Response update_kwargs["status"] = ObjectStatus.HIDDEN else: raise NotImplementedError - if result.get("integrationId"): - integration = integration_service.get_integration( - integration_id=result["integrationId"], - organization_id=coerce_id_from(organization), - status=ObjectStatus.ACTIVE, - ) - if integration is None: - return Response({"detail": "Invalid integration id"}, status=400) - - update_kwargs["integration_id"] = integration.id - update_kwargs["provider"] = f"integrations:{integration.provider}" - if update_kwargs: old_status = repo.status with transaction.atomic(router.db_for_write(Repository)): diff --git a/src/sentry/integrations/base.py b/src/sentry/integrations/base.py index 1d27bb59e7d6..4098320f242e 100644 --- a/src/sentry/integrations/base.py +++ b/src/sentry/integrations/base.py @@ -312,7 +312,7 @@ def get_pipeline_views( """ raise NotImplementedError - def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: + def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline] | None: """ Return API step objects for this provider's pipeline, or None if API mode is not supported. Override to enable the pipeline API for this diff --git a/src/sentry/integrations/pipeline.py b/src/sentry/integrations/pipeline.py index 6a752ba3624f..7fd4cdc32b76 100644 --- a/src/sentry/integrations/pipeline.py +++ b/src/sentry/integrations/pipeline.py @@ -172,7 +172,7 @@ def get_pipeline_views( ]: return self.provider.get_pipeline_views() - def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: + def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline] | None: return self.provider.get_pipeline_api_steps() def get_analytics_event(self) -> analytics.Event | None: diff --git a/src/sentry/objectstore/__init__.py b/src/sentry/objectstore/__init__.py index 4d93198bcb8d..844037177258 100644 --- a/src/sentry/objectstore/__init__.py +++ b/src/sentry/objectstore/__init__.py @@ -9,6 +9,7 @@ MetricsBackend, Session, TimeToLive, + TokenGenerator, Usecase, parse_accept_encoding, ) @@ -56,6 +57,16 @@ def create_client() -> Client: from sentry import options as options_store options = options_store.get("objectstore.config") + + # Initialize the `TokenGenerator` if key parameters are found. + token_generator = None + if signing_key_options := options.get("token_generator"): + # We require the `kid` and `secret_key` keys be set, other options are optional + if signing_key_options.get("kid") and signing_key_options.get("secret_key"): + token_generator = TokenGenerator( + **signing_key_options, + ) + return Client( options["base_url"], metrics_backend=SentryMetricsBackend(), @@ -67,6 +78,7 @@ def create_client() -> Client: # Workaround for 0.0.14's default read timeout. Can be removed with 0.0.15 {"timeout": urllib3.Timeout(connect=0.1)}, ), + token=token_generator, ) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index c079014870a6..a010f970b05e 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -405,6 +405,7 @@ # - retries: int | None = None, # - timeout_ms: float | None = None, # - connection_kwargs: Mapping[str, Any] | None = None, +# - token_generator: Mapping[str, Any] | None = None, # # For an always up-to-date list, see: # https://getsentry.github.io/objectstore/python/objectstore_client.html#objectstore_client.Client diff --git a/src/sentry/pipeline/base.py b/src/sentry/pipeline/base.py index ef2ff667d89b..be056a7d7392 100644 --- a/src/sentry/pipeline/base.py +++ b/src/sentry/pipeline/base.py @@ -260,7 +260,7 @@ def fetch_state(self, key: str | None = None) -> Any | None: return nested_pipeline.fetch_state(key) return self._fetch_state(key) - def get_pipeline_api_steps(self) -> ApiPipelineSteps[Self]: + def get_pipeline_api_steps(self) -> ApiPipelineSteps[Self] | None: """ Return API step objects for this pipeline, or None if API mode is not supported. Steps may be callables for late binding (resolved when the @@ -275,6 +275,14 @@ def is_api_ready(self) -> bool: """Returns True if this pipeline supports API mode.""" return self.get_pipeline_api_steps() is not None + @property + def is_api_mode(self) -> bool: + """Returns True if this pipeline session was initiated via the API.""" + return bool(self._fetch_state("api_mode")) + + def set_api_mode(self, enabled: bool = True) -> None: + self.bind_state("api_mode", enabled) + def _assert_user_authorization(self) -> None: assert not (self.state.uid is not None and self.state.uid != self.request.user.id), ( ERR_MISMATCHED_USER diff --git a/src/sentry/pipeline/provider.py b/src/sentry/pipeline/provider.py index 0a8644071c14..8fb23e761217 100644 --- a/src/sentry/pipeline/provider.py +++ b/src/sentry/pipeline/provider.py @@ -36,7 +36,7 @@ def get_pipeline_views(self) -> Sequence[PipelineView[P] | Callable[[], Pipeline >>> return [OAuthInitView(), OAuthCallbackView()] """ - def get_pipeline_api_steps(self) -> ApiPipelineSteps[P]: + def get_pipeline_api_steps(self) -> ApiPipelineSteps[P] | None: """ Return API step objects for this provider's pipeline, or None if API mode is not supported. Override to enable the pipeline API. diff --git a/src/sentry/pipeline/views/base.py b/src/sentry/pipeline/views/base.py index 06e27e7075a5..4abfbfefff53 100644 --- a/src/sentry/pipeline/views/base.py +++ b/src/sentry/pipeline/views/base.py @@ -58,7 +58,7 @@ def handle_post( type ApiPipelineStep[P] = ApiPipelineEndpoint[P] | Callable[[], ApiPipelineEndpoint[P]] -type ApiPipelineSteps[P] = Sequence[ApiPipelineStep[P]] | None +type ApiPipelineSteps[P] = Sequence[ApiPipelineStep[P]] def render_react_view( diff --git a/src/sentry/preprod/size_analysis/grouptype.py b/src/sentry/preprod/size_analysis/grouptype.py index 6d03b72e55f6..ce89fd2b1886 100644 --- a/src/sentry/preprod/size_analysis/grouptype.py +++ b/src/sentry/preprod/size_analysis/grouptype.py @@ -8,7 +8,7 @@ from uuid import uuid4 from sentry.exceptions import InvalidSearchQuery -from sentry.issues.grouptype import GroupCategory, GroupType +from sentry.issues.grouptype import GroupCategory, GroupType, NotificationConfig from sentry.issues.issue_occurrence import IssueEvidence from sentry.preprod.artifact_search import artifact_matches_query from sentry.types.group import PriorityLevel @@ -61,6 +61,82 @@ def _artifact_to_tags(artifact: PreprodArtifact) -> dict[str, str]: return tags +_THRESHOLD_TYPE_LABELS: dict[str, str] = { + "absolute_diff": "Absolute Diff", + "relative_diff": "Relative Diff", + "absolute": "Absolute Size", +} + + +def _get_measurement_label(measurement: str, platform: str) -> str: + """Get platform-aware display label for a measurement type. + + On Android, install_size is called "Uncompressed Size". + On iOS/apple, install_size is called "Install Size". + """ + if measurement == "install_size": + return "Uncompressed Size" if platform == "android" else "Install Size" + if measurement == "download_size": + return "Download Size" + return measurement.replace("_", " ").title() + + +def _build_evidence_text( + detector_config: dict[str, Any], + evaluation_result: ProcessedDataConditionGroup, + data_packet: SizeAnalysisDataPacket, + platform: str, +) -> str: + """Build a single-line evidence string for Slack/Jira notifications. + + Format: {measurement}, {threshold_type} > {threshold_value} ({actual_value}) + Example: Install Size, Absolute Diff > 1.0 MB (+1.0 MB) + """ + from sentry.preprod.utils import format_bytes_base10 + + measurement = detector_config["measurement"] + threshold_type = detector_config["threshold_type"] + measurement_label = _get_measurement_label(measurement, platform) + + # Threshold: type > value + threshold_part = "" + if evaluation_result.condition_results: + condition = evaluation_result.condition_results[0].condition + threshold_label = _THRESHOLD_TYPE_LABELS.get(threshold_type, threshold_type) + + if threshold_type == "relative_diff": + formatted_threshold = f"{condition.comparison}%" + else: + formatted_threshold = format_bytes_base10(int(condition.comparison)) + + threshold_part = f", {threshold_label} > {formatted_threshold}" + + # Actual value + match measurement: + case "install_size": + head_bytes = data_packet.packet["head_install_size_bytes"] + base_bytes = data_packet.packet.get("base_install_size_bytes") + case "download_size": + head_bytes = data_packet.packet["head_download_size_bytes"] + base_bytes = data_packet.packet.get("base_download_size_bytes") + case _: + head_bytes = 0 + base_bytes = None + + if threshold_type == "absolute" or base_bytes is None: + actual_value = format_bytes_base10(head_bytes) + elif threshold_type == "relative_diff": + pct = ((head_bytes - base_bytes) / base_bytes) * 100 if base_bytes else 0 + actual_value = f"+{pct:.1f}%" + else: + delta = head_bytes - base_bytes + delta_formatted = format_bytes_base10(abs(delta)) + sign = "+" if delta >= 0 else "-" + actual_value = f"{sign}{delta_formatted}" + + return f"{measurement_label}{threshold_part} ({actual_value})" + + class SizeAnalysisMetadata(TypedDict): """Metadata about the artifacts being compared, used for occurrence creation.""" @@ -252,14 +328,18 @@ def create_occurrence( if commit_comparison.pr_number is not None: tags["git.pr_number"] = str(commit_comparison.pr_number) + evidence_text = _build_evidence_text( + self.detector.config, evaluation_result, data_packet, platform + ) + occurrence = DetectorOccurrence( issue_title=issue_title, subtitle="A preprod static analysis issue was detected", evidence_data=evidence_data, evidence_display=[ IssueEvidence( - name="Source", - value=data_packet.source_id, + name="Size Analysis", + value=evidence_text, important=True, ) ], @@ -299,6 +379,10 @@ class PreprodSizeAnalysisGroupType(GroupType): released = False enable_auto_resolve = True enable_escalation_detection = False + notification_config = NotificationConfig( + context=[], + text_code_formatted=False, + ) detector_settings = DetectorSettings( handler=PreprodSizeAnalysisDetectorHandler, validator=PreprodSizeAnalysisDetectorValidator, diff --git a/src/sentry/preprod/utils.py b/src/sentry/preprod/utils.py index a8c8bfe3470c..b93f59423919 100644 --- a/src/sentry/preprod/utils.py +++ b/src/sentry/preprod/utils.py @@ -33,3 +33,21 @@ def parse_release_version(release_version: str) -> ParsedReleaseVersion | None: return ParsedReleaseVersion(app_id=app_id, build_version=build_version) return None + + +def format_bytes_base10(size_bytes: int) -> str: + """Format file size using decimal (base-10) units. Matches the frontend implementation of formatBytesBase10.""" + units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] + threshold = 1000 + + if size_bytes < threshold: + return f"{size_bytes} {units[0]}" + + u = 0 + number = float(size_bytes) + max_unit = len(units) - 1 + while number >= threshold and u < max_unit: + number /= threshold + u += 1 + + return f"{number:.1f} {units[u]}" diff --git a/src/sentry/preprod/vcs/status_checks/size/templates.py b/src/sentry/preprod/vcs/status_checks/size/templates.py index 1c15360909ce..f25498f5e385 100644 --- a/src/sentry/preprod/vcs/status_checks/size/templates.py +++ b/src/sentry/preprod/vcs/status_checks/size/templates.py @@ -568,24 +568,8 @@ def _calculate_size_change(head_size: int | None, base_size: int | None) -> str: def _format_file_size(size_bytes: int | None) -> str: """Format file size with null handling for display in templates.""" + from sentry.preprod.utils import format_bytes_base10 + if size_bytes is None: return "Unknown" - return _format_bytes_base10(size_bytes) - - -def _format_bytes_base10(size_bytes: int) -> str: - """Format file size using decimal (base-10) units. Matches the frontend implementation of formatBytesBase10.""" - units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] - threshold = 1000 - - if size_bytes < threshold: - return f"{size_bytes} {units[0]}" - - u = 0 - number = float(size_bytes) - max_unit = len(units) - 1 - while number >= threshold and u < max_unit: - number /= threshold - u += 1 - - return f"{number:.1f} {units[u]}" + return format_bytes_base10(size_bytes) diff --git a/src/sentry/receivers/outbox/control.py b/src/sentry/receivers/outbox/control.py index badbf3a88dc2..5264a502ab98 100644 --- a/src/sentry/receivers/outbox/control.py +++ b/src/sentry/receivers/outbox/control.py @@ -33,51 +33,50 @@ @receiver(process_control_outbox, sender=OutboxCategory.INTEGRATION_UPDATE) -def process_integration_updates(object_identifier: int, region_name: str, **kwds: Any): +def process_integration_updates(object_identifier: int, cell_name: str, **kwds: Any): if ( - integration := maybe_process_tombstone( - Integration, object_identifier, cell_name=region_name - ) + integration := maybe_process_tombstone(Integration, object_identifier, cell_name=cell_name) ) is None: return integration # Currently we do not sync any other integration changes, but if we did, you can use this variable. @receiver(process_control_outbox, sender=OutboxCategory.IDENTITY_UPDATE) -def process_identity_updates(object_identifier: int, region_name: str, **kwds: Any): - maybe_process_tombstone(Identity, object_identifier, cell_name=region_name) +def process_identity_updates(object_identifier: int, cell_name: str, **kwds: Any): + maybe_process_tombstone(Identity, object_identifier, cell_name=cell_name) @receiver(process_control_outbox, sender=OutboxCategory.SENTRY_APP_UPDATE) -def process_sentry_app_updates(object_identifier: int, region_name: str, **kwds: Any): +def process_sentry_app_updates(object_identifier: int, cell_name: str, **kwds: Any): if ( sentry_app := maybe_process_tombstone( - model=SentryApp, object_identifier=object_identifier, cell_name=region_name + model=SentryApp, object_identifier=object_identifier, cell_name=cell_name ) ) is None: return # Spawn a task to clear caches, as there can be 1000+ installations # for a sentry app. - clear_region_cache.delay(sentry_app_id=sentry_app.id, region_name=region_name) + # TODO(cells): switch to clear_cell_cache once deployed on all pods + clear_region_cache.delay(sentry_app_id=sentry_app.id, region_name=cell_name) @receiver(process_control_outbox, sender=OutboxCategory.SENTRY_APP_DELETE) def process_sentry_app_deletes( shard_identifier: int, object_identifier: int, - region_name: str, + cell_name: str, payload: Mapping[str, Any], **kwds: Any, ): action_service.update_action_status_for_sentry_app_via_sentry_app_id( - cell_name=region_name, + cell_name=cell_name, status=ObjectStatus.DISABLED, sentry_app_id=object_identifier, ) if slug := payload.get("slug"): action_service.update_action_status_for_webhook_via_sentry_app_slug( - cell_name=region_name, + cell_name=cell_name, status=ObjectStatus.DISABLED, sentry_app_slug=slug, ) @@ -87,12 +86,12 @@ def process_sentry_app_deletes( def process_sentry_app_installation_deletes( shard_identifier: int, object_identifier: int, - region_name: str, + cell_name: str, payload: Mapping[str, Any], **kwds: Any, ): action_service.update_action_status_for_sentry_app_installation( - cell_name=region_name, + cell_name=cell_name, status=ObjectStatus.DISABLED, sentry_app_id=payload["sentry_app_id"], organization_id=payload["organization_id"], @@ -100,10 +99,10 @@ def process_sentry_app_installation_deletes( @receiver(process_control_outbox, sender=OutboxCategory.API_APPLICATION_UPDATE) -def process_api_application_updates(object_identifier: int, region_name: str, **kwds: Any): +def process_api_application_updates(object_identifier: int, cell_name: str, **kwds: Any): if ( api_application := maybe_process_tombstone( - ApiApplication, object_identifier, cell_name=region_name + ApiApplication, object_identifier, cell_name=cell_name ) ) is None: return @@ -111,7 +110,7 @@ def process_api_application_updates(object_identifier: int, region_name: str, ** @receiver(process_control_outbox, sender=OutboxCategory.SERVICE_HOOK_UPDATE) -def process_service_hook_updates(object_identifier: int, region_name: str, **kwds: Any): +def process_service_hook_updates(object_identifier: int, cell_name: str, **kwds: Any): try: installation = SentryAppInstallation.objects.select_related("sentry_app").get( id=object_identifier diff --git a/src/sentry/scm/private/helpers.py b/src/sentry/scm/private/helpers.py index 1ca94c1a9f9e..152824d79a06 100644 --- a/src/sentry/scm/private/helpers.py +++ b/src/sentry/scm/private/helpers.py @@ -112,15 +112,16 @@ def exec_provider_fn[P: Provider, T]( if provider.is_rate_limited(referrer): raise SCMCodedError(provider, referrer, code="rate_limit_exceeded") + provider_name = provider.__class__.__name__ + try: result = provider_fn() - record_count( - "sentry.scm.actions.success_by_provider", 1, {"provider": provider.__class__.__name__} - ) + record_count("sentry.scm.actions.success_by_provider", 1, {"provider": provider_name}) record_count("sentry.scm.actions.success_by_referrer", 1, {"referrer": referrer}) return result except SCMError: raise except Exception as e: - record_count("sentry.scm.actions.failed", 1, {}) + record_count("sentry.scm.actions.failed_by_provider", 1, {"provider": provider_name}) + record_count("sentry.scm.actions.failed_by_referrer", 1, {"referrer": referrer}) raise SCMUnhandledException from e diff --git a/src/sentry/scm/private/providers/github.py b/src/sentry/scm/private/providers/github.py index 77380ee34645..9d0f12372ce2 100644 --- a/src/sentry/scm/private/providers/github.py +++ b/src/sentry/scm/private/providers/github.py @@ -712,7 +712,7 @@ def get_pull_request_diff( return { "data": response.text, "type": "github", - "raw": response.text, + "raw": {"data": response.text, "headers": dict(response.headers)}, "meta": _extract_response_meta(response), } @@ -933,7 +933,7 @@ def get_archive_link( return { "data": ArchiveLink(url=response.headers["Location"], headers={}), "type": "github", - "raw": response.headers["Location"], + "raw": {"data": response.headers["Location"], "headers": dict(response.headers)}, "meta": _extract_response_meta(response), } @@ -1125,7 +1125,7 @@ def map_action[T]( return { "data": fn(raw), "type": "github", - "raw": raw, + "raw": {"data": raw, "headers": dict(response.headers)}, "meta": _extract_response_meta(response), } @@ -1143,6 +1143,6 @@ def map_paginated_action[T]( return { "data": fn(raw), "type": "github", - "raw": raw, + "raw": {"data": raw, "headers": dict(response.headers)}, "meta": meta, } diff --git a/src/sentry/scm/private/providers/gitlab.py b/src/sentry/scm/private/providers/gitlab.py index 8cafc47eeb07..4049057177ea 100644 --- a/src/sentry/scm/private/providers/gitlab.py +++ b/src/sentry/scm/private/providers/gitlab.py @@ -354,7 +354,7 @@ def get_tree( truncated=False, ), type="gitlab", - raw=raw, + raw={"data": raw, "headers": None}, meta={}, ) @@ -390,7 +390,7 @@ def get_archive_link( return ActionResult( data=data, type="gitlab", - raw=url, + raw={"data": url, "headers": None}, meta={}, ) @@ -597,7 +597,7 @@ def make_paginated_result[T]( return PaginatedActionResult( data=[map_item(item) for item in raw_items], type="gitlab", - raw=raw, + raw={"data": raw, "headers": None}, # No actual pagination for now meta=PaginatedResponseMeta(next_cursor=None), ) @@ -615,7 +615,7 @@ def make_result[T]( return ActionResult( data=map_item(raw_item), type="gitlab", - raw=raw, + raw={"data": raw, "headers": None}, meta={}, ) diff --git a/src/sentry/scm/types.py b/src/sentry/scm/types.py index d16c3b899b52..3cf9c60df664 100644 --- a/src/sentry/scm/types.py +++ b/src/sentry/scm/types.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from datetime import datetime -from typing import Any, Literal, Protocol, Required, TypedDict, runtime_checkable +from typing import Any, Literal, MutableMapping, Protocol, Required, TypedDict, runtime_checkable type Action = Literal["check_run", "comment", "pull_request"] type EventType = "CheckRunEvent" | "CommentEvent" | "PullRequestEvent" @@ -217,6 +217,11 @@ class PullRequest(TypedDict): base: PullRequestBranch +class RawResult(TypedDict): + headers: MutableMapping[str, str] | None + data: Any + + class ActionResult[T](TypedDict): """Wraps a provider response with metadata and the original API payload. @@ -230,7 +235,7 @@ class ActionResult[T](TypedDict): data: T type: ProviderName - raw: Any + raw: RawResult meta: ResponseMeta @@ -244,7 +249,7 @@ class PaginatedActionResult[T](TypedDict): data: list[T] type: ProviderName - raw: Any + raw: RawResult meta: PaginatedResponseMeta diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index 819f23925878..53822371291a 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -64,9 +64,9 @@ from sentry.search.events import constants as qb_constants from sentry.search.events import fields from sentry.search.events import filter as event_filter -from sentry.search.events.datasets.discover import InvalidIssueSearchQuery from sentry.search.events.filter import to_list from sentry.search.events.types import SAMPLING_MODES, SnubaParams +from sentry.search.exceptions import InvalidIssueSearchQuery def collect_issue_short_ids_from_parsed_terms(terms: Sequence[object]) -> set[str]: diff --git a/src/sentry/search/events/datasets/discover.py b/src/sentry/search/events/datasets/discover.py index 5601278c015c..ca37706038a3 100644 --- a/src/sentry/search/events/datasets/discover.py +++ b/src/sentry/search/events/datasets/discover.py @@ -95,23 +95,13 @@ ) from sentry.search.events.filter import to_list from sentry.search.events.types import SelectType, WhereType +from sentry.search.exceptions import InvalidIssueSearchQuery from sentry.search.utils import DEVICE_CLASS from sentry.snuba.dataset import Dataset from sentry.snuba.referrer import Referrer from sentry.utils.numbers import format_grouped_length -class InvalidIssueSearchQuery(InvalidSearchQuery): - """Raised when an issue filter references non-existent issue IDs.""" - - def __init__(self, invalid_ids: list[str]): - self.invalid_ids = invalid_ids - super().__init__(f"Issue IDs do not exist: {invalid_ids}") - - def __str__(self) -> str: - return f"Issue IDs do not exist: {self.invalid_ids}" - - class DiscoverDatasetConfig(DatasetConfig): custom_threshold_columns = { "apdex()", diff --git a/src/sentry/search/exceptions.py b/src/sentry/search/exceptions.py new file mode 100644 index 000000000000..6924f9ae808f --- /dev/null +++ b/src/sentry/search/exceptions.py @@ -0,0 +1,12 @@ +from sentry.exceptions import InvalidSearchQuery + + +class InvalidIssueSearchQuery(InvalidSearchQuery): + """Raised when an issue filter references non-existent issue IDs.""" + + def __init__(self, invalid_ids: list[str]): + self.invalid_ids = invalid_ids + super().__init__(f"Issue IDs do not exist: {invalid_ids}") + + def __str__(self) -> str: + return f"Issue IDs do not exist: {self.invalid_ids}" diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 860cc17e880c..fba4ae85ed74 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -31,9 +31,11 @@ from sentry.seer.explorer.client import SeerExplorerClient from sentry.seer.explorer.client_models import SeerRunState from sentry.seer.models import SeerRepoDefinition +from sentry.seer.models.seer_api_models import SeerPermissionError from sentry.sentry_apps.metrics import SentryAppEventType from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization from sentry.sentry_apps.utils.webhooks import SeerActionType +from sentry.utils import metrics if TYPE_CHECKING: from sentry.models.group import Group @@ -293,6 +295,8 @@ def trigger_autofix_explorer( }, ) + metrics.incr("autofix.explorer.trigger", tags={"step": step.value, "referrer": referrer.value}) + return run_id @@ -406,6 +410,7 @@ def _get_relevant_repo( def trigger_coding_agent_handoff( group: Group, run_id: int, + referrer: AutofixReferrer, integration_id: int | None = None, provider: str | None = None, user_id: int | None = None, @@ -451,12 +456,7 @@ def trigger_coding_agent_handoff( "failures": [{"error_message": "No repositories configured in project preferences"}], } - client = SeerExplorerClient( - organization=group.organization, - user=None, - category_key="autofix", - category_value=str(group.id), - ) + client = get_autofix_explorer_client(group) state = client.get_run(run_id) repo = _get_relevant_repo(state, repo_definitions, run_id, group) @@ -486,7 +486,7 @@ def trigger_coding_agent_handoff( prompt = generate_autofix_handoff_prompt(state, short_id=short_id) - return client.launch_coding_agents( + coding_agents = client.launch_coding_agents( run_id=run_id, integration_id=integration_id, provider=provider, @@ -496,3 +496,36 @@ def trigger_coding_agent_handoff( branch_name_base=group.title or "seer", auto_create_pr=auto_create_pr, ) + + metrics.incr( + "autofix.explorer.trigger", + tags={"step": "coding_agent_handoff", "referrer": referrer.value}, + ) + + return coding_agents + + +def trigger_push_changes( + group: Group, + run_id: int, + referrer: AutofixReferrer, + state: SeerRunState | None = None, +): + client = get_autofix_explorer_client(group) + + if state is None: + try: + state = client.get_run(run_id) + except ValueError: + raise SeerPermissionError("Unknown run id for group") + + group_id = state.metadata.get("group_id") if state.metadata else None + if group_id != group.id: + raise SeerPermissionError("Unknown run id for group") + + client.push_changes(run_id, blocking=False) + + metrics.incr( + "autofix.explorer.trigger", + tags={"step": "open_pr", "referrer": referrer.value}, + ) diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 6c10e988fa58..e82ae9412bf2 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -10,11 +10,11 @@ AutofixStep, trigger_autofix_explorer, trigger_coding_agent_handoff, + trigger_push_changes, ) from sentry.seer.autofix.constants import AutofixReferrer from sentry.seer.autofix.utils import AutofixStoppingPoint, get_project_seer_preferences from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates -from sentry.seer.explorer.client import SeerExplorerClient from sentry.seer.explorer.client_models import Artifact from sentry.seer.explorer.client_utils import fetch_run_status from sentry.seer.explorer.on_completion_hook import ExplorerOnCompletionHook @@ -316,6 +316,20 @@ def _maybe_continue_pipeline( ) return + # Get the group + try: + group = Group.objects.get(id=group_id, project__organization=organization) + except Group.DoesNotExist: + logger.warning( + "autofix.on_completion_hook.group_not_found", + extra={ + "run_id": run_id, + "organization_id": organization.id, + "group_id": group_id, + }, + ) + return + if current_step is None: logger.warning( "autofix.on_completion_hook.no_current_step", @@ -330,11 +344,9 @@ def _maybe_continue_pipeline( return # Check if we should trigger coding agent handoff instead of continuing - handoff_config = cls._get_handoff_config_if_applicable( - stopping_point, current_step, group_id - ) + handoff_config = cls._get_handoff_config_if_applicable(stopping_point, current_step, group) if handoff_config: - cls._trigger_coding_agent_handoff(organization, run_id, group_id, handoff_config) + cls._trigger_coding_agent_handoff(organization, run_id, group, handoff_config) return # Special case: if stopping_point is open_pr and we just finished code_changes, push changes @@ -342,7 +354,7 @@ def _maybe_continue_pipeline( stopping_point == AutofixStoppingPoint.OPEN_PR and current_step == AutofixStep.CODE_CHANGES ): - cls._push_changes(organization, run_id, state) + cls._push_changes(group, run_id, state) return # Get the next step @@ -363,20 +375,6 @@ def _maybe_continue_pipeline( ) return - # Get the group - try: - group = Group.objects.get(id=group_id, project__organization=organization) - except Group.DoesNotExist: - logger.warning( - "autofix.on_completion_hook.group_not_found", - extra={ - "run_id": run_id, - "organization_id": organization.id, - "group_id": group_id, - }, - ) - return - # Trigger the next step logger.info( "autofix.on_completion_hook.continuing_pipeline", @@ -396,7 +394,7 @@ def _maybe_continue_pipeline( ) @classmethod - def _push_changes(cls, organization: Organization, run_id: int, state: SeerRunState) -> None: + def _push_changes(cls, group: Group, run_id: int, state: SeerRunState) -> None: """Push code changes to create PRs.""" # Check if there are code changes to push has_changes, is_synced = state.has_code_changes() @@ -405,7 +403,7 @@ def _push_changes(cls, organization: Organization, run_id: int, state: SeerRunSt "autofix.on_completion_hook.no_changes_to_push", extra={ "run_id": run_id, - "organization_id": organization.id, + "organization_id": group.organization.id, "has_changes": has_changes, "is_synced": is_synced, }, @@ -414,16 +412,20 @@ def _push_changes(cls, organization: Organization, run_id: int, state: SeerRunSt logger.info( "autofix.on_completion_hook.pushing_changes", - extra={"run_id": run_id, "organization_id": organization.id}, + extra={"run_id": run_id, "organization_id": group.organization.id}, ) try: - client = SeerExplorerClient(organization=organization, user=None) - client.push_changes(run_id, blocking=False) + trigger_push_changes( + group, + run_id, + referrer=AutofixReferrer.ON_COMPLETION_HOOK, + state=state, + ) except Exception: logger.exception( "autofix.on_completion_hook.push_changes_failed", - extra={"run_id": run_id, "organization_id": organization.id}, + extra={"run_id": run_id, "organization_id": group.organization.id}, ) @classmethod @@ -431,7 +433,7 @@ def _get_handoff_config_if_applicable( cls, stopping_point: AutofixStoppingPoint, current_step: AutofixStep | None, - group_id: int, + group: Group, ) -> SeerAutomationHandoffConfiguration | None: """ Read project preferences and return handoff config if applicable. @@ -454,13 +456,12 @@ def _get_handoff_config_if_applicable( return None # Check project preferences - group = Group.objects.get(id=group_id) try: preference_response = get_project_seer_preferences(group.project_id) except (SeerApiError, SeerApiResponseValidationError): logger.exception( "autofix.on_completion_hook.get_preferences_failed", - extra={"group_id": group_id, "project_id": group.project_id}, + extra={"group_id": group.id, "project_id": group.project_id}, ) return None if not preference_response or not preference_response.preference: @@ -476,7 +477,7 @@ def _trigger_coding_agent_handoff( cls, organization: Organization, run_id: int, - group_id: int, + group: Group, handoff_config: SeerAutomationHandoffConfiguration, ) -> None: """Trigger coding agent handoff using the configured integration.""" @@ -485,17 +486,17 @@ def _trigger_coding_agent_handoff( extra={ "run_id": run_id, "organization_id": organization.id, - "group_id": group_id, + "group_id": group.id, "integration_id": handoff_config.integration_id, "target": handoff_config.target, }, ) try: - group = Group.objects.get(id=group_id) result = trigger_coding_agent_handoff( group=group, run_id=run_id, + referrer=AutofixReferrer.ON_COMPLETION_HOOK, integration_id=handoff_config.integration_id, ) logger.info( @@ -507,15 +508,6 @@ def _trigger_coding_agent_handoff( "failures": len(result.get("failures", [])), }, ) - except Group.DoesNotExist: - logger.exception( - "autofix.on_completion_hook.coding_agent_handoff_group_not_found", - extra={ - "run_id": run_id, - "organization_id": organization.id, - "group_id": group_id, - }, - ) except Exception: logger.exception( "autofix.on_completion_hook.coding_agent_handoff_failed", diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 8bf9efc54050..f4d32cca2525 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -4,7 +4,7 @@ from typing import Any from drf_spectacular.utils import extend_schema -from rest_framework import serializers +from rest_framework import serializers, status from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.response import Response @@ -38,6 +38,7 @@ get_autofix_explorer_state, trigger_autofix_explorer, trigger_coding_agent_handoff, + trigger_push_changes, ) from sentry.seer.autofix.coding_agent import ( poll_claude_code_agents, @@ -87,6 +88,7 @@ class ExplorerAutofixRequestSerializer(CamelSnakeSerializer): "root_cause", "solution", "code_changes", + "open_pr", "impact_assessment", "triage", "coding_agent_handoff", @@ -166,7 +168,6 @@ def _should_use_explorer(self, request: Request, organization: Organization) -> "organizations:seer-explorer", # Access to seer explorer powered autofix "organizations:autofix-on-explorer", - "organizations:autofix-on-explorer-v2", ] batch_features = features.batch_has( @@ -223,15 +224,15 @@ def _post_explorer(self, request: Request, group: Group) -> Response: """Handle POST for Explorer-based autofix.""" serializer = ExplorerAutofixRequestSerializer(data=request.data) if not serializer.is_valid(): - return Response(serializer.errors, status=400) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) data = serializer.validated_data step = data.get("step", "root_cause") stopping_point = data.get("stopping_point") + run_id = data.get("run_id") # Handle third-party coding agent handoff separately if step == "coding_agent_handoff": - run_id = data.get("run_id") integration_id = data.get("integration_id") provider = data.get("provider") if not run_id or (not integration_id and not provider): @@ -239,22 +240,38 @@ def _post_explorer(self, request: Request, group: Group) -> Response: { "detail": "run_id and either integration_id or provider are required for coding_agent_handoff" }, - status=400, + status=status.HTTP_400_BAD_REQUEST, ) if integration_id and provider: return Response( {"detail": "Cannot specify both integration_id and provider"}, - status=400, + status=status.HTTP_400_BAD_REQUEST, ) result = trigger_coding_agent_handoff( group=group, run_id=run_id, + referrer=AutofixReferrer.GROUP_AUTOFIX_ENDPOINT, integration_id=integration_id, provider=provider, user_id=request.user.id if request.user else None, ) - return Response(result, status=202) + return Response(result, status=status.HTTP_202_ACCEPTED) + + if step == "open_pr": + if not run_id: + return Response( + {"detail": "run_id is required for open_pr"}, status=status.HTTP_400_BAD_REQUEST + ) + try: + trigger_push_changes( + group, + run_id, + referrer=AutofixReferrer.GROUP_AUTOFIX_ENDPOINT, + ) + except SeerPermissionError: + return Response(status=status.HTTP_404_NOT_FOUND) + return Response({"run_id": run_id}, status=status.HTTP_202_ACCEPTED) # Handle all built-in Seer steps try: @@ -263,11 +280,11 @@ def _post_explorer(self, request: Request, group: Group) -> Response: step=AutofixStep(step), referrer=AutofixReferrer.GROUP_AUTOFIX_ENDPOINT, stopping_point=AutofixStoppingPoint(stopping_point) if stopping_point else None, - run_id=data.get("run_id"), + run_id=run_id, intelligence_level=data["intelligence_level"], user_context=data.get("user_context"), ) - return Response({"run_id": run_id}, status=202) + return Response({"run_id": run_id}, status=status.HTTP_202_ACCEPTED) except SeerPermissionError as e: raise PermissionDenied(str(e)) @@ -275,7 +292,7 @@ def _post_legacy(self, request: Request, group: Group) -> Response: """Handle POST for legacy autofix.""" serializer = AutofixRequestSerializer(data=request.data) if not serializer.is_valid(): - return Response(serializer.errors, status=400) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) data = serializer.validated_data diff --git a/src/sentry/seer/endpoints/group_autofix_setup_check.py b/src/sentry/seer/endpoints/group_autofix_setup_check.py index 569f0a323b2d..c54e313341c8 100644 --- a/src/sentry/seer/endpoints/group_autofix_setup_check.py +++ b/src/sentry/seer/endpoints/group_autofix_setup_check.py @@ -23,7 +23,6 @@ from sentry.seer.autofix.utils import ( get_autofix_repos_from_project_code_mappings, has_project_connected_repos, - is_seer_seat_based_tier_enabled, ) from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS from sentry.seer.models import SeerApiError @@ -151,11 +150,9 @@ def get(self, request: Request, group: Group) -> Response: org_id=org.id, data_category=DataCategory.SEER_AUTOFIX ) - seer_seat_based_tier_enabled = is_seer_seat_based_tier_enabled(org) - seer_repos_linked = False # Check if org has github integration and is on seat-based tier. - if integration_check is None and seer_seat_based_tier_enabled: + if integration_check is None: try: # Check if project has repos linked in Seer. # Skip cache to ensure latest data from Seer API. @@ -168,12 +165,11 @@ def get(self, request: Request, group: Group) -> Response: autofix_enabled = False autofix_automation_tuning = group.project.get_option("sentry:autofix_automation_tuning") - if seer_seat_based_tier_enabled: - if ( - autofix_automation_tuning - and autofix_automation_tuning != AutofixAutomationTuningSettings.OFF - ): - autofix_enabled = True + if ( + autofix_automation_tuning + and autofix_automation_tuning != AutofixAutomationTuningSettings.OFF + ): + autofix_enabled = True return Response( { diff --git a/src/sentry/seer/entrypoints/operator.py b/src/sentry/seer/entrypoints/operator.py index 8214a2d3e642..9bf789887814 100644 --- a/src/sentry/seer/entrypoints/operator.py +++ b/src/sentry/seer/entrypoints/operator.py @@ -165,9 +165,9 @@ def trigger_autofix_explorer( ) -> None: from sentry.seer.autofix.autofix_agent import ( AutofixStep, - get_autofix_explorer_client, get_autofix_explorer_state, trigger_autofix_explorer, + trigger_push_changes, ) event_lifecyle = SeerOperatorEventLifecycleMetric( @@ -227,14 +227,17 @@ def trigger_autofix_explorer( run_id=None, ) elif stopping_point == AutofixStoppingPoint.OPEN_PR: - client = get_autofix_explorer_client(group) - client.push_changes(run_id, blocking=False) + trigger_push_changes( + group, + run_id, + referrer=AutofixReferrer.SLACK, + ) else: # NOTE: Stopping point here is really just what # step to run next. Not the same as the stopping_point # argument supported by `trigger_autofix_explorer` which allows one # to run multiple steps at once - run_id = trigger_autofix_explorer( + trigger_autofix_explorer( group=group, step=AutofixStep.from_autofix_stopping_point(stopping_point), referrer=AutofixReferrer.SLACK, diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index 4ed8424bc362..1438e4ed1064 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -162,7 +162,6 @@ def has_seer_explorer_access_with_detail( "organizations:seer-explorer", # Access to seer explorer powered autofix "organizations:autofix-on-explorer", - "organizations:autofix-on-explorer-v2", ] batch_features = features.batch_has( diff --git a/src/sentry/seer/signed_seer_api.py b/src/sentry/seer/signed_seer_api.py index d8d48e0ca2d7..10e6034f4392 100644 --- a/src/sentry/seer/signed_seer_api.py +++ b/src/sentry/seer/signed_seer_api.py @@ -537,10 +537,12 @@ def make_delete_grouping_records_by_project_request( timeout: int | float | None = None, viewer_context: SeerViewerContext | None = None, ) -> BaseHTTPResponse: + project_id = body["project_id"] return make_signed_seer_api_request( seer_grouping_default_connection_pool, - "/v0/issues/similar-issues/grouping-record/delete", - body=orjson.dumps(body), + f"/v0/issues/similar-issues/grouping-record/delete/{project_id}", + body=b"", + method="GET", timeout=timeout, viewer_context=viewer_context, ) diff --git a/src/sentry/sentry_apps/tasks/__init__.py b/src/sentry/sentry_apps/tasks/__init__.py index f96910bb9349..8cda77d55dd8 100644 --- a/src/sentry/sentry_apps/tasks/__init__.py +++ b/src/sentry/sentry_apps/tasks/__init__.py @@ -1,7 +1,7 @@ from .sentry_apps import ( broadcast_webhooks_for_organization, build_comment_webhook, - clear_region_cache, + clear_cell_cache, create_or_update_service_hooks_for_sentry_app, installation_webhook, process_resource_change_bound, @@ -16,7 +16,7 @@ __all__ = ( "broadcast_webhooks_for_organization", "build_comment_webhook", - "clear_region_cache", + "clear_cell_cache", "create_or_update_service_hooks_for_sentry_app", "installation_webhook", "process_resource_change_bound", diff --git a/src/sentry/sentry_apps/tasks/sentry_apps.py b/src/sentry/sentry_apps/tasks/sentry_apps.py index 2fed5c8a7450..8677fe9e15a5 100644 --- a/src/sentry/sentry_apps/tasks/sentry_apps.py +++ b/src/sentry/sentry_apps/tasks/sentry_apps.py @@ -493,6 +493,7 @@ def installation_webhook(installation_id: int, user_id: int, *args: Any, **kwarg ).run() +# TODO(cells): remove once in-flight tasks with old name have drained @instrumented_task( name="sentry.sentry_apps.tasks.sentry_apps.clear_region_cache", namespace=sentryapp_control_tasks, @@ -501,6 +502,17 @@ def installation_webhook(installation_id: int, user_id: int, *args: Any, **kwarg silo_mode=SiloMode.CONTROL, ) def clear_region_cache(sentry_app_id: int, region_name: str) -> None: + clear_cell_cache(sentry_app_id=sentry_app_id, cell_name=region_name) + + +@instrumented_task( + name="sentry.sentry_apps.tasks.sentry_apps.clear_cell_cache", + namespace=sentryapp_control_tasks, + retry=Retry(times=3, delay=60 * 5), + processing_deadline_duration=30, + silo_mode=SiloMode.CONTROL, +) +def clear_cell_cache(sentry_app_id: int, cell_name: str) -> None: try: sentry_app = SentryApp.objects.get(id=sentry_app_id) except SentryApp.DoesNotExist: @@ -519,23 +531,23 @@ def clear_region_cache(sentry_app_id: int, region_name: str) -> None: # Clear application_id cache cell_caching_service.clear_key( - key=get_by_application_id.key_from(sentry_app.application_id), cell_name=region_name + key=get_by_application_id.key_from(sentry_app.application_id), cell_name=cell_name ) - # Limit our operations to the region this outbox is for. + # Limit our operations to the cell this outbox is for. # This could be a single query if we use raw_sql. - region_query = OrganizationMapping.objects.filter( - organization_id__in=list(install_map.keys()), cell_name=region_name + cell_query = OrganizationMapping.objects.filter( + organization_id__in=list(install_map.keys()), cell_name=cell_name ).values("organization_id") - for region_row in region_query: + for cell_row in cell_query: cell_caching_service.clear_key( - key=get_installations_for_organization.key_from(region_row["organization_id"]), - cell_name=region_name, + key=get_installations_for_organization.key_from(cell_row["organization_id"]), + cell_name=cell_name, ) - installs = install_map[region_row["organization_id"]] + installs = install_map[cell_row["organization_id"]] for install_id in installs: cell_caching_service.clear_key( - key=get_installation.key_from(install_id), cell_name=region_name + key=get_installation.key_from(install_id), cell_name=cell_name ) diff --git a/src/sentry/services/organization/provisioning.py b/src/sentry/services/organization/provisioning.py index ffbca2aade20..951b572b6a12 100644 --- a/src/sentry/services/organization/provisioning.py +++ b/src/sentry/services/organization/provisioning.py @@ -207,7 +207,7 @@ def handle_organization_provisioning_outbox_payload( @receiver(process_control_outbox, sender=OutboxCategory.PROVISION_ORGANIZATION) def process_provision_organization_outbox( - object_identifier: int, region_name: str, payload: Any, **kwds: Any + object_identifier: int, cell_name: str, payload: Any, **kwds: Any ): try: provision_payload = OrganizationProvisioningOptions.parse_obj(payload) @@ -218,7 +218,7 @@ def process_provision_organization_outbox( handle_organization_provisioning_outbox_payload( organization_id=object_identifier, - cell_name=region_name, + cell_name=cell_name, provisioning_payload=provision_payload, ) @@ -286,9 +286,9 @@ def handle_possible_organization_slug_swap(*, cell_name: str, org_slug_reservati @receiver(process_control_outbox, sender=OutboxCategory.ORGANIZATION_SLUG_RESERVATION_UPDATE) def update_organization_slug_reservation( - object_identifier: int, region_name: str, **kwds: Any + object_identifier: int, cell_name: str, **kwds: Any ) -> None: handle_possible_organization_slug_swap( - cell_name=region_name, + cell_name=cell_name, org_slug_reservation_id=object_identifier, ) diff --git a/static/app/components/dnd/dragReorderButton.tsx b/static/app/components/dnd/dragReorderButton.tsx new file mode 100644 index 000000000000..00e6b8008161 --- /dev/null +++ b/static/app/components/dnd/dragReorderButton.tsx @@ -0,0 +1,28 @@ +import {Button, type ButtonProps} from '@sentry/scraps/button'; + +import {IconGrabbable} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {IconSize} from 'sentry/utils/theme'; + +type DragReorderButtonProps = Omit & { + iconSize?: IconSize; +}; + +export function DragReorderButton({ + size = 'zero', + iconSize = 'xs', + ref, + ...props +}: DragReorderButtonProps) { + return ( +