Skip to content

Commit dd22ad6

Browse files
authored
Merge branch 'master' into scraps/kbd
2 parents dff61a5 + 88d0cb7 commit dd22ad6

File tree

65 files changed

+1919
-483
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+1919
-483
lines changed

src/sentry/api/bases/organization_events.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,7 @@ def get_additional_queries(self, request: Request) -> AdditionalQueries:
831831
span=request.GET.getlist("spanQuery"),
832832
log=request.GET.getlist("logQuery"),
833833
metric=request.GET.getlist("metricQuery"),
834+
occurrences=request.GET.getlist("occurrencesQuery"),
834835
)
835836

836837

src/sentry/api/endpoints/organization_trace_item_attributes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -818,7 +818,7 @@ def string_autocomplete_function(self) -> list[TagValue]:
818818

819819
values: Sequence[str] = rpc_response.values
820820
if self.context_definition:
821-
context = self.context_definition.constructor(self.snuba_params)
821+
context = self.context_definition.constructor(self.snuba_params, self.resolver)
822822
values = [context.value_map.get(value, value) for value in values]
823823

824824
return [

src/sentry/deletions/models/scheduleddeletion.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class BaseScheduledDeletion(Model):
3333
the tasks/deletion/scheduled.py job in the future. They are cancellable, and provide automatic, batched cascade
3434
in an async way for performance reasons.
3535
36-
Note that BOTH region AND control silos need to be able to schedule deletions of different records that will be
36+
Note that BOTH cell AND control silos need to be able to schedule deletions of different records that will be
3737
reconciled in different places. For that reason, the ScheduledDeletion model is split into two identical models
3838
representing this split. Use the corresponding ScheduledDeletion based on the silo of the model being scheduled
3939
for deletion.
@@ -139,9 +139,9 @@ def get_actor(self) -> RpcUser | None:
139139
class ScheduledDeletion(BaseScheduledDeletion):
140140
"""
141141
This model schedules deletions to be processed in control and monolith silo modes. All historic schedule deletions
142-
occur in this table. In the future, when RegionScheduledDeletions have proliferated for the appropriate models,
143-
we will allow any region models scheduled in this table to finish processing before ensuring that all models discretely
144-
process in either this table or the region table.
142+
occur in this table. In the future, when CellScheduledDeletions have proliferated for the appropriate models,
143+
we will allow any cell models scheduled in this table to finish processing before ensuring that all models discretely
144+
process in either this table or the cell table.
145145
"""
146146

147147
class Meta:
@@ -165,7 +165,7 @@ class Meta:
165165
db_table = "sentry_regionscheduleddeletion"
166166

167167

168-
def get_regional_scheduled_deletion(mode: SiloMode) -> type[BaseScheduledDeletion]:
168+
def get_cell_scheduled_deletion(mode: SiloMode) -> type[BaseScheduledDeletion]:
169169
if mode != SiloMode.CONTROL:
170170
return CellScheduledDeletion
171171
return ScheduledDeletion

src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ def get_attrs(
257257
for detector_trigger in detector_trigger_data_conditions
258258
],
259259
condition_group__in=Subquery(workflow_dcg_ids),
260-
)
260+
).select_related("condition_group")
261261

262262
dcgas = DataConditionGroupAction.objects.filter(
263263
condition_group__in=[

src/sentry/integrations/base.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
organization_service,
2828
)
2929
from sentry.pipeline.provider import PipelineProvider
30-
from sentry.pipeline.views.base import PipelineView
30+
from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView
3131
from sentry.shared_integrations.constants import (
3232
ERR_INTERNAL,
3333
ERR_UNAUTHORIZED,
@@ -312,6 +312,14 @@ def get_pipeline_views(
312312
"""
313313
raise NotImplementedError
314314

315+
def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]:
316+
"""
317+
Return API step objects for this provider's pipeline, or None if API
318+
mode is not supported. Override to enable the pipeline API for this
319+
integration.
320+
"""
321+
return None
322+
315323
def build_integration(self, state: Mapping[str, Any]) -> IntegrationData:
316324
"""
317325
Given state captured during the setup pipeline, return a dictionary

src/sentry/integrations/gitlab/client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,18 @@ def get_pr_diffs(self, repo: Repository, pr: PullRequest) -> list[dict[str, Any]
690690
path = GitLabApiClientPath.build_pr_diffs(project=project_id, pr_key=pr.key, unidiff=True)
691691
return self.get(path)
692692

693+
def get_repository_tree(
694+
self, project_id: str, ref: str, recursive: bool = True
695+
) -> list[dict[str, Any]]:
696+
"""List repository tree at a given ref.
697+
698+
See https://docs.gitlab.com/ee/api/repositories.html#list-repository-tree
699+
"""
700+
params: dict[str, str] = {"ref": ref, "per_page": "100"}
701+
if recursive:
702+
params["recursive"] = "true"
703+
return self.get(GitLabApiClientPath.tree.format(project=project_id), params=params)
704+
693705
def get_merge_request_diffs(self, project_id: str, pr_key: str) -> list[dict[str, Any]]:
694706
return self.get(GitLabApiClientPath.pr_diffs.format(project=project_id, pr_key=pr_key))
695707

src/sentry/integrations/gitlab/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class GitLabApiClientPath:
7474
statuses = "/projects/{project}/statuses/{sha}"
7575
commit_statuses = "/projects/{project}/repository/commits/{sha}/statuses"
7676
archive = "/projects/{project}/repository/archive{format}"
77+
tree = "/projects/{project}/repository/tree"
7778
branches = "/projects/{project_id}/repository/branches"
7879
branch = "/projects/{project_id}/repository/branches/{branch}"
7980
user = "/user"

src/sentry/integrations/pipeline.py

Lines changed: 141 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@
44
from collections.abc import Callable, Sequence
55
from typing import Any, Never, TypedDict
66

7+
import sentry_sdk
78
from django.db import IntegrityError
9+
from django.http.request import HttpRequest
810
from django.http.response import HttpResponseBase, HttpResponseRedirect
911
from django.utils import timezone
1012
from django.utils.translation import gettext as _
13+
from sentry_sdk.tracing import TransactionSource
1114

1215
from sentry import analytics, features
1316
from sentry.analytics.events.integration_pipeline_step import IntegrationPipelineStep
1417
from sentry.api.serializers import serialize
1518
from sentry.auth.superuser import superuser_has_permission
1619
from sentry.constants import ObjectStatus
20+
from sentry.features.exceptions import FeatureNotRegistered
1721
from sentry.integrations.base import IntegrationData, IntegrationDomain, IntegrationProvider
1822
from sentry.integrations.manager import default_manager
1923
from sentry.integrations.models.integration import Integration
@@ -22,25 +26,76 @@
2226
IntegrationPipelineViewEvent,
2327
IntegrationPipelineViewType,
2428
)
29+
from sentry.models.organization import Organization
2530
from sentry.models.organizationmapping import OrganizationMapping
2631
from sentry.organizations.absolute_url import generate_organization_url
2732
from sentry.organizations.services.organization import organization_service
2833
from sentry.organizations.services.organization.model import RpcOrganization
2934
from sentry.pipeline.base import Pipeline
3035
from sentry.pipeline.store import PipelineSessionStore
31-
from sentry.pipeline.views.base import PipelineView
36+
from sentry.pipeline.types import PipelineStepResult
37+
from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView
3238
from sentry.shared_integrations.exceptions import IntegrationError, IntegrationProviderError
3339
from sentry.silo.base import SiloMode
3440
from sentry.users.models.identity import Identity, IdentityProvider, IdentityStatus
3541
from sentry.utils import metrics
3642
from sentry.web.helpers import render_to_response
3743
from sentry.workflow_engine.service.action import action_service
3844

39-
__all__ = ["IntegrationPipeline"]
45+
__all__ = ["IntegrationPipeline", "IntegrationPipelineError", "initialize_integration_pipeline"]
4046

4147
logger = logging.getLogger(__name__)
4248

4349

50+
class IntegrationPipelineError(Exception):
51+
"""Raised when an integration pipeline cannot be initialized."""
52+
53+
def __init__(self, message: str, not_found: bool = False) -> None:
54+
self.not_found = not_found
55+
super().__init__(message)
56+
57+
58+
def initialize_integration_pipeline(
59+
request: HttpRequest,
60+
organization: Organization | RpcOrganization,
61+
provider_id: str,
62+
) -> IntegrationPipeline:
63+
"""
64+
Creates, validates, and initializes an IntegrationPipeline for the given
65+
organization and provider. Raises IntegrationPipelineError if any pre-checks
66+
fail (feature flags disabled or provider cannot be added).
67+
"""
68+
scope = sentry_sdk.get_current_scope()
69+
scope.set_transaction_name(f"integration.{provider_id}", source=TransactionSource.VIEW)
70+
71+
pipeline = IntegrationPipeline(
72+
request=request, organization=organization, provider_key=provider_id
73+
)
74+
75+
assert isinstance(pipeline.provider, IntegrationProvider)
76+
77+
is_feature_enabled: dict[str, bool] = {}
78+
for feature in pipeline.provider.features:
79+
feature_flag_name = "organizations:integrations-%s" % feature.value
80+
try:
81+
features.get(feature_flag_name, None)
82+
is_feature_enabled[feature_flag_name] = features.has(feature_flag_name, organization)
83+
except FeatureNotRegistered:
84+
is_feature_enabled[feature_flag_name] = True
85+
86+
if not any(is_feature_enabled.values()):
87+
raise IntegrationPipelineError(
88+
"At least one feature from this list has to be enabled in order to setup the integration:\n%s"
89+
% "\n".join(is_feature_enabled)
90+
)
91+
92+
if not pipeline.provider.can_add:
93+
raise IntegrationPipelineError("Integration cannot be added.", not_found=True)
94+
95+
pipeline.initialize()
96+
return pipeline
97+
98+
4499
class _IntegrationDefaults(TypedDict):
45100
metadata: dict[str, Any]
46101
name: str
@@ -117,6 +172,9 @@ def get_pipeline_views(
117172
]:
118173
return self.provider.get_pipeline_views()
119174

175+
def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]:
176+
return self.provider.get_pipeline_api_steps()
177+
120178
def get_analytics_event(self) -> analytics.Event | None:
121179
pipeline_type = "reauth" if self.fetch_state("integration_id") else "install"
122180
return IntegrationPipelineStep(
@@ -146,9 +204,8 @@ def finish_pipeline(self) -> HttpResponseBase:
146204
id=self.organization.id, user_id=self.request.user.id
147205
)
148206

149-
if (
150-
org_context
151-
and (not org_context.member or "org:integrations" not in org_context.member.scopes)
207+
if not org_context or (
208+
(not org_context.member or "org:integrations" not in org_context.member.scopes)
152209
and not superuser_has_permission(self.request, ["org:integrations"])
153210
):
154211
error_message = "You must be an organization owner, manager or admin to install this integration."
@@ -188,14 +245,17 @@ def finish_pipeline(self) -> HttpResponseBase:
188245
)
189246
return self.render_warning(str(e))
190247

191-
response = self._finish_pipeline(data)
248+
try:
249+
response = self._finish_pipeline(data)
250+
except IntegrationError as e:
251+
lifecycle.record_failure(e)
252+
return self._dialog_response({"error": str(e)}, False)
192253

193254
extra = data.get("post_install_data", {})
194255

195256
self.provider.create_audit_log_entry(
196257
self.integration, self.organization, self.request, "install", extra=extra
197258
)
198-
# Enable all actions for the organization installing the integration
199259
self._enable_actions()
200260
self.provider.post_install(self.integration, self.organization, extra=extra)
201261
self.clear_session()
@@ -208,7 +268,13 @@ def finish_pipeline(self) -> HttpResponseBase:
208268

209269
return response
210270

211-
def _finish_pipeline(self, data: IntegrationData) -> HttpResponseBase:
271+
def _install_integration(self, data: IntegrationData) -> OrganizationIntegration:
272+
"""
273+
Core model operations for finishing the pipeline: create/update
274+
Integration, link identity, create OrganizationIntegration.
275+
276+
Raises IntegrationError on failure. Returns the created OrganizationIntegration.
277+
"""
212278
if "expect_exists" in data:
213279
self.integration = Integration.objects.get(
214280
provider=self.provider.integration_key, external_id=data["external_id"]
@@ -229,7 +295,6 @@ def _finish_pipeline(self, data: IntegrationData) -> HttpResponseBase:
229295
idp_external_id = data.get("idp_external_id", data["external_id"])
230296
idp_config = data.get("idp_config", {})
231297

232-
# Create identity provider for this integration if necessary
233298
idp, created = IdentityProvider.objects.get_or_create(
234299
external_id=idp_external_id, type=identity["type"], defaults={"config": idp_config}
235300
)
@@ -278,19 +343,15 @@ def _finish_pipeline(self, data: IntegrationData) -> HttpResponseBase:
278343
"provider_key": self.provider.key,
279344
},
280345
)
281-
# if we don't need a default identity, we don't have to throw an error
346+
# If we don't need a default identity, we don't have to throw an error
282347
if self.provider.needs_default_identity:
283-
# The external_id is linked to a different user.
284348
proper_name = idp.get_provider().name
285-
return self._dialog_response(
286-
{
287-
"error": _(
288-
"The provided %(proper_name)s account is linked to a different Sentry user. "
289-
"To continue linking the current Sentry user, please use a different %(proper_name)s account."
290-
)
291-
% ({"proper_name": proper_name})
292-
},
293-
False,
349+
raise IntegrationError(
350+
_(
351+
"The provided %(proper_name)s account is linked to a different Sentry user. "
352+
"To continue linking the current Sentry user, please use a different %(proper_name)s account."
353+
)
354+
% ({"proper_name": proper_name})
294355
)
295356

296357
default_auth_id = None
@@ -309,16 +370,23 @@ def _finish_pipeline(self, data: IntegrationData) -> HttpResponseBase:
309370
"integration_id": self.integration.id,
310371
},
311372
)
312-
return self.error(
373+
raise IntegrationError(
313374
"This integration has already been installed on another Sentry organization which resides in a different cell. Installation could not be completed."
314375
)
315376

316377
org_integration = self.integration.add_organization(
317378
self.organization, self.request.user, default_auth_id=default_auth_id
318379
)
319380

381+
if org_integration is None:
382+
raise IntegrationError("Could not create the integration for this organization.")
383+
384+
return org_integration
385+
386+
def _finish_pipeline(self, data: IntegrationData) -> HttpResponseBase:
387+
org_integration = self._install_integration(data)
388+
320389
extra = data.get("post_install_data", {})
321-
# If a particular provider has a redirect for a successful install, use that instead of the generic success
322390
redirect_url_format = extra.get("redirect_url_format", None)
323391
if redirect_url_format is not None:
324392
return self._get_redirect_response(redirect_url_format=redirect_url_format)
@@ -360,6 +428,57 @@ def _enable_actions(self) -> None:
360428
status=ObjectStatus.ACTIVE,
361429
)
362430

431+
def api_finish_pipeline(self) -> PipelineStepResult:
432+
with IntegrationPipelineViewEvent(
433+
interaction_type=IntegrationPipelineViewType.FINISH_PIPELINE,
434+
domain=IntegrationDomain.GENERAL,
435+
provider_key=self.provider.key,
436+
).capture() as lifecycle:
437+
org_context = organization_service.get_organization_by_id(
438+
id=self.organization.id, user_id=self.request.user.id
439+
)
440+
441+
if not org_context or (
442+
(not org_context.member or "org:integrations" not in org_context.member.scopes)
443+
and not superuser_has_permission(self.request, ["org:integrations"])
444+
):
445+
return PipelineStepResult.error(
446+
"You must be an organization owner, manager or admin to install this integration."
447+
)
448+
449+
try:
450+
data = self.provider.build_integration(self.state.data)
451+
except IntegrationError as e:
452+
lifecycle.record_failure(e)
453+
return PipelineStepResult.error(str(e))
454+
except IntegrationProviderError as e:
455+
return PipelineStepResult.error(str(e))
456+
457+
try:
458+
org_integration = self._install_integration(data)
459+
except IntegrationError as e:
460+
lifecycle.record_failure(e)
461+
return PipelineStepResult.error(str(e))
462+
463+
extra = data.get("post_install_data", {})
464+
465+
self.provider.create_audit_log_entry(
466+
self.integration, self.organization, self.request, "install", extra=extra
467+
)
468+
self._enable_actions()
469+
self.provider.post_install(self.integration, self.organization, extra=extra)
470+
self.clear_session()
471+
472+
metrics.incr(
473+
"sentry.integrations.installation_finished",
474+
tags={"integration_name": self.provider.key},
475+
sample_rate=1.0,
476+
)
477+
478+
return PipelineStepResult.complete(
479+
data=serialize(org_integration, self.request.user),
480+
)
481+
363482
def _get_redirect_response(self, redirect_url_format: str) -> HttpResponseRedirect:
364483
redirect_url = redirect_url_format.format(org_slug=self.organization.slug)
365484
return HttpResponseRedirect(redirect_url)

0 commit comments

Comments
 (0)