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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,8 @@
"@types/d3-zoom": "^3.0.8",
"@types/gettext-parser": "8.0.0",
"@types/node": "^22.9.1",
"@typescript-eslint/rule-tester": "8.56.1",
"@typescript-eslint/utils": "8.56.1",
"@typescript-eslint/rule-tester": "8.58.0",
"@typescript-eslint/utils": "8.58.0",
"@typescript/native-preview": "7.0.0-dev.20260112.1",
"@volar/typescript": "^2.4.28",
"babel-jest": "30.3.0",
Expand Down Expand Up @@ -286,7 +286,7 @@
"stylelint": "16.10.0",
"stylelint-config-recommended": "^14.0.1",
"terser": "5.40.0",
"typescript-eslint": "8.56.1"
"typescript-eslint": "8.58.0"
},
"optionalDependencies": {
"fsevents": "^2.3.3"
Expand Down
220 changes: 122 additions & 98 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ dependencies = [
"mmh3>=4.0.0",
"msgspec>=0.19.0",
"msgpack>=1.1.0",
"objectstore-client>=0.1.1",
"objectstore-client>=0.1.5",
"openai>=1.3.5",
"orjson>=3.10.10",
"p4python>=2025.1.2767466",
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/api/serializers/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,9 @@ def format_options(self, attrs: Mapping[str, Any]) -> dict[str, Any]:
"sentry:preprod_distribution_pr_comments_enabled_by_customer": self.get_value_with_default(
attrs, "sentry:preprod_distribution_pr_comments_enabled_by_customer"
),
"sentry:preprod_snapshot_pr_comments_enabled": self.get_value_with_default(
attrs, "sentry:preprod_snapshot_pr_comments_enabled"
),
}

def get_value_with_default(self, attrs, key):
Expand Down
128 changes: 73 additions & 55 deletions src/sentry/backup/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from collections import defaultdict
from collections import defaultdict, deque
from dataclasses import dataclass
from enum import Enum, auto, unique
from functools import lru_cache
Expand Down Expand Up @@ -667,41 +667,47 @@ def get_exportable_sentry_models() -> set[type[models.base.Model]]:
)


def dedupe_and_reassign_groupseen_in_org(
organization_id: int, from_user_id: int, to_user_id: int
) -> None:
"""
Dedupe GroupSeen rows inside an organization and reassign them to the new user.
def _get_org_scope_condition(model_relations: ModelRelations, organization_id: int) -> Q:
"""
from sentry.models.groupseen import GroupSeen

scoped = Q(group__project__organization_id=organization_id)
# Remove from_user rows that would collide with to_user for the same group
GroupSeen.objects.filter(
scoped,
user_id=from_user_id,
group_id__in=GroupSeen.objects.filter(scoped, user_id=to_user_id).values("group_id"),
).delete()
GroupSeen.objects.filter(scoped, user_id=from_user_id).update(user_id=to_user_id)
Finds a path from this model to Organization through FK relationships and returns a Q object
scoping the model to the given organization_id. Uses BFS to find the shortest path.

Only traverses real DB-level FK types (FlexibleForeignKey, DefaultForeignKey, OneToOneField
variants). HybridCloudForeignKey and ImplicitForeignKey are skipped because they don't support
Django ORM __ traversal. We skip over nullable relations to avoid generating conditions
that don't find any records.

def dedupe_and_reassign_groupsubscription_in_org(
organization_id: int, from_user_id: int, to_user_id: int
) -> None:
"""
Dedupe GroupSubscription rows inside an organization and reassign them to the new user.
Returns Q() if no path to Organization is found (caller's queries will be unscoped).
"""
from sentry.models.groupsubscription import GroupSubscription
from sentry.models.organization import Organization

traversable = {
ForeignFieldKind.FlexibleForeignKey,
ForeignFieldKind.DefaultForeignKey,
ForeignFieldKind.OneToOneCascadeDeletes,
ForeignFieldKind.DefaultOneToOneField,
}
all_deps = dependencies()
visited: set[NormalizedModelName] = {get_model_name(model_relations.model)}
queue: deque[tuple[ModelRelations, str]] = deque([(model_relations, "")])

while queue:
current, prefix = queue.popleft()
for field_name, fk in current.foreign_keys.items():
if fk.model is Organization:
col = field_name if field_name.endswith("_id") else f"{field_name}_id"
return Q(**{f"{prefix}{col}": organization_id})
if fk.kind not in traversable:
continue
if fk.nullable:
continue
related_name = get_model_name(fk.model)
if related_name not in visited and related_name in all_deps:
visited.add(related_name)
traversal = field_name[:-3] if field_name.endswith("_id") else field_name
queue.append((all_deps[related_name], f"{prefix}{traversal}__"))

scoped = Q(group__project__organization_id=organization_id)
GroupSubscription.objects.filter(
scoped,
user_id=from_user_id,
group_id__in=GroupSubscription.objects.filter(scoped, user_id=to_user_id).values(
"group_id"
),
).delete()
GroupSubscription.objects.filter(scoped, user_id=from_user_id).update(user_id=to_user_id)
return Q()


def merge_users_for_model_in_org(
Expand All @@ -710,34 +716,46 @@ def merge_users_for_model_in_org(
"""
All instances of this model in a certain organization that reference both the organization and
user in question will be pointed at the new user instead.
"""

from sentry.models.groupseen import GroupSeen
from sentry.models.groupsubscription import GroupSubscription
from sentry.models.organization import Organization
For models with unique constraints that include a user field, conflicting rows (where the
to_user already has a row matching the other unique fields) are deleted before the update to
avoid IntegrityErrors.
"""
from sentry.users.models.user import User

# Special-case: GroupSeen has unique_together (user_id, group). Dedupe conflicts inside org
# then update remaining rows.
if model is GroupSeen:
dedupe_and_reassign_groupseen_in_org(organization_id, from_user_id, to_user_id)
return

# Special-case: GroupSubscription has unique_together (group, user_id). Same pattern.
if model is GroupSubscription:
dedupe_and_reassign_groupsubscription_in_org(organization_id, from_user_id, to_user_id)
return

model_relations = dependencies()[get_model_name(model)]
user_refs = {k for k, v in model_relations.foreign_keys.items() if v.model == User}

org_refs = {
k if k.endswith("_id") else f"{k}_id"
for k, v in model_relations.foreign_keys.items()
if v.model == Organization
}
for_this_org = Q(**{field_name: organization_id for field_name in org_refs})
for_this_org = _get_org_scope_condition(model_relations, organization_id)

# model_relations.uniques only contains fields, and needs to be json encodable.
unique_constraints: list[tuple[frozenset[str], Q]] = []
for unique_fields in model._meta.unique_together:
unique_constraints.append((frozenset(unique_fields), Q()))
for constraint in model._meta.constraints:
if not isinstance(constraint, UniqueConstraint):
continue
unique_constraints.append((frozenset(constraint.fields), constraint.condition or Q()))

for user_ref in user_refs:
q = for_this_org & Q(**{user_ref: from_user_id})
model.objects.filter(q).update(**{user_ref: to_user_id})
# For any unique constraint that includes a user/user_id field, delete rows that would
# collide after reassignment before doing the update.
user_uniques = [u for u in unique_constraints if user_ref in u[0]]
for user_constraint in user_uniques:
other_fields = list(user_constraint[0] - {user_ref})
if not other_fields:
# user_ref is unique on its own, delete from_user row so that
# updates of to_user -> from_user don't conflict.
model.objects.filter(
for_this_org, user_constraint[1], **{user_ref: from_user_id}
).delete()
else:
for matching in model.objects.filter(
for_this_org, user_constraint[1], **{user_ref: to_user_id}
).values(*other_fields):
model.objects.filter(
for_this_org, user_constraint[1], **{user_ref: from_user_id}, **matching
).delete()

model.objects.filter(for_this_org & Q(**{user_ref: from_user_id})).update(
**{user_ref: to_user_id}
)
1 change: 1 addition & 0 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2225,6 +2225,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"sentry.integrations.bitbucket.integration.BitbucketIntegrationProvider",
"sentry.integrations.bitbucket_server.integration.BitbucketServerIntegrationProvider",
"sentry.integrations.slack.SlackIntegrationProvider",
"sentry.integrations.slack.staging.integration.SlackStagingIntegrationProvider",
"sentry.integrations.github.integration.GitHubIntegrationProvider",
"sentry.integrations.github_enterprise.integration.GitHubEnterpriseIntegrationProvider",
"sentry.integrations.gitlab.integration.GitlabIntegrationProvider",
Expand Down
10 changes: 10 additions & 0 deletions src/sentry/core/endpoints/project_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ class ProjectMemberSerializer(serializers.Serializer):
preprodDistributionPrCommentsEnabledByCustomer = serializers.BooleanField(
required=False, allow_null=True
)
preprodSnapshotPrCommentsEnabled = serializers.BooleanField(required=False, allow_null=True)
preprodSizeEnabledQuery = serializers.CharField(required=False, allow_null=True)
preprodDistributionEnabledQuery = serializers.CharField(required=False, allow_null=True)

Expand Down Expand Up @@ -167,6 +168,7 @@ class ProjectMemberSerializer(serializers.Serializer):
"preprodSnapshotStatusChecksFailOnAdded",
"preprodSnapshotStatusChecksFailOnRemoved",
"preprodDistributionPrCommentsEnabledByCustomer",
"preprodSnapshotPrCommentsEnabled",
]
)
class ProjectAdminSerializer(ProjectMemberSerializer):
Expand Down Expand Up @@ -889,6 +891,14 @@ def put(self, request: Request, project) -> Response:
changed_proj_settings[
"sentry:preprod_distribution_pr_comments_enabled_by_customer"
] = result["preprodDistributionPrCommentsEnabledByCustomer"]
if "preprodSnapshotPrCommentsEnabled" in result:
if project.update_option(
"sentry:preprod_snapshot_pr_comments_enabled",
result["preprodSnapshotPrCommentsEnabled"],
):
changed_proj_settings["sentry:preprod_snapshot_pr_comments_enabled"] = result[
"preprodSnapshotPrCommentsEnabled"
]
if "debugFilesRole" in result:
if result["debugFilesRole"] is None:
project.delete_option("sentry:debug_files_role")
Expand Down
10 changes: 8 additions & 2 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,15 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:increased-issue-owners-rate-limit", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False)
# Starfish: extract metrics from the spans
manager.add("organizations:indexed-spans-extraction", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False)
# These flags follow the pattern expected by IntegrationProvider.requires_feature_flag's usage on the config endpoint
# Enable integration functionality to work deployment integrations like Vercel
manager.add("organizations:integrations-deployment", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=True, api_expose=True)
manager.add("organizations:integrations-claude-code", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
manager.add("organizations:integrations-cursor", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
manager.add("organizations:integrations-github-copilot-agent", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
manager.add("organizations:integrations-github-platform-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
manager.add("organizations:integrations-perforce", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
manager.add("organizations:integrations-slack-staging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
manager.add("organizations:scm-source-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Project Management Integrations Feature Parity Flags
manager.add("organizations:integrations-github_enterprise-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
Expand Down Expand Up @@ -232,6 +234,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:preprod-issues", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable preprod PR comments for build distribution
manager.add("organizations:preprod-build-distribution-pr-comments", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable preprod PR comments for snapshots
manager.add("organizations:preprod-snapshot-pr-comments", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable enforcement of preprod size quota checks (when disabled, size quota checks always return True)
manager.add("organizations:preprod-enforce-size-quota", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable enforcement of preprod distribution quota checks (when disabled, distribution quota checks always return True)
Expand Down Expand Up @@ -311,8 +315,6 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:seer-slack-workflows", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable new compact issue alert UI in Slack
manager.add("organizations:slack-compact-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable the Slack staging app
manager.add("organizations:slack-staging-app", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable Seer Explorer in Slack via @mentions
manager.add("organizations:seer-slack-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable search query attribute validation
Expand Down Expand Up @@ -420,6 +422,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:workflow-engine-metric-alert-dual-processing-logs", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable Creation of Metric Alerts that use the `group_by` field in the workflow_engine
manager.add("organizations:workflow-engine-metric-alert-group-by-creation", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable caching for workflow action filters
manager.add("organizations:workflow-engine-action-filters-cache", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable ingestion through trusted relays only
manager.add("organizations:ingest-through-trusted-relays-only", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable metric issue UI for issue alerts
Expand Down Expand Up @@ -459,6 +463,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:ourlogs-stats", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable overlaying charts in logs
manager.add("organizations:ourlogs-overlay-charts-ui", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable the expand/collapse table height toggle in the logs UI
manager.add("organizations:ourlogs-table-expando", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable alerting on trace metrics
manager.add("organizations:tracemetrics-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable attributes dropdown side panel in trace metrics
Expand Down
14 changes: 14 additions & 0 deletions src/sentry/hybridcloud/tasks/backfill_outboxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from __future__ import annotations

import logging
from dataclasses import dataclass

from django.apps import apps
Expand All @@ -21,6 +22,8 @@
from sentry.users.models.user import User
from sentry.utils import json, metrics, redis

logger = logging.getLogger(__name__)


def _get_redis_client() -> RedisCluster[str] | StrictRedis[str]:
return redis.redis_clusters.get(settings.SENTRY_HYBRIDCLOUD_BACKFILL_OUTBOXES_REDIS_CLUSTER)
Expand Down Expand Up @@ -138,8 +141,19 @@ def process_outbox_backfill_batch(
model, batch_size=batch_size, force_synchronous=force_synchronous
)
if not processing_state:
logger.info("processing_state.missing", extra={"model": model.__name__})
return None

logger.info(
"processing_state.current",
extra={
"model": model.__name__,
"batch_low": processing_state.low,
"batch_up": processing_state.up,
"version": processing_state.version,
},
)

for inst in model.objects.filter(id__gte=processing_state.low, id__lte=processing_state.up):
with outbox_context(transaction.atomic(router.db_for_write(model)), flush=False):
if isinstance(inst, CellOutboxProducingModel):
Expand Down
3 changes: 2 additions & 1 deletion src/sentry/identity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ def _register_providers() -> None:
from .github_enterprise.provider import GitHubEnterpriseIdentityProvider
from .gitlab.provider import GitlabIdentityProvider
from .google.provider import GoogleIdentityProvider
from .slack.provider import SlackIdentityProvider
from .slack.provider import SlackIdentityProvider, SlackStagingIdentityProvider
from .vercel.provider import VercelIdentityProvider
from .vsts.provider import VSTSIdentityProvider, VSTSNewIdentityProvider
from .vsts_extension.provider import VstsExtensionIdentityProvider

# TODO(epurkhiser): Should this be moved into it's own plugin, it should be
# initialized there.
register(SlackIdentityProvider)
register(SlackStagingIdentityProvider)
register(GitHubIdentityProvider)
register(GitHubEnterpriseIdentityProvider)
register(VSTSNewIdentityProvider)
Expand Down
16 changes: 16 additions & 0 deletions src/sentry/identity/slack/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@ def build_identity(self, data):
}


class SlackStagingIdentityProvider(SlackIdentityProvider):
key = IntegrationProviderSlug.SLACK_STAGING.value
name = "Slack (Staging)"

def get_oauth_client_id(self):
return options.get("slack-staging.client-id")

def get_oauth_client_secret(self):
return options.get("slack-staging.client-secret")

def build_identity(self, data):
production_identity = super().build_identity(data)
production_identity["type"] = IntegrationProviderSlug.SLACK_STAGING.value
return production_identity


class SlackOAuth2LoginView(OAuth2LoginView):
"""
We need to customize the OAuth2LoginView in order to support passing through
Expand Down
1 change: 1 addition & 0 deletions src/sentry/integrations/api/bases/external_actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
ExternalProviders.GITHUB_ENTERPRISE,
ExternalProviders.GITLAB,
ExternalProviders.SLACK,
ExternalProviders.SLACK_STAGING,
ExternalProviders.MSTEAMS,
ExternalProviders.JIRA_SERVER,
ExternalProviders.PERFORCE,
Expand Down
Loading
Loading