44from collections .abc import Callable , Sequence
55from typing import Any , Never , TypedDict
66
7+ import sentry_sdk
78from django .db import IntegrityError
9+ from django .http .request import HttpRequest
810from django .http .response import HttpResponseBase , HttpResponseRedirect
911from django .utils import timezone
1012from django .utils .translation import gettext as _
13+ from sentry_sdk .tracing import TransactionSource
1114
1215from sentry import analytics , features
1316from sentry .analytics .events .integration_pipeline_step import IntegrationPipelineStep
1417from sentry .api .serializers import serialize
1518from sentry .auth .superuser import superuser_has_permission
1619from sentry .constants import ObjectStatus
20+ from sentry .features .exceptions import FeatureNotRegistered
1721from sentry .integrations .base import IntegrationData , IntegrationDomain , IntegrationProvider
1822from sentry .integrations .manager import default_manager
1923from sentry .integrations .models .integration import Integration
2226 IntegrationPipelineViewEvent ,
2327 IntegrationPipelineViewType ,
2428)
29+ from sentry .models .organization import Organization
2530from sentry .models .organizationmapping import OrganizationMapping
2631from sentry .organizations .absolute_url import generate_organization_url
2732from sentry .organizations .services .organization import organization_service
2833from sentry .organizations .services .organization .model import RpcOrganization
2934from sentry .pipeline .base import Pipeline
3035from 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
3238from sentry .shared_integrations .exceptions import IntegrationError , IntegrationProviderError
3339from sentry .silo .base import SiloMode
3440from sentry .users .models .identity import Identity , IdentityProvider , IdentityStatus
3541from sentry .utils import metrics
3642from sentry .web .helpers import render_to_response
3743from sentry .workflow_engine .service .action import action_service
3844
39- __all__ = ["IntegrationPipeline" ]
45+ __all__ = ["IntegrationPipeline" , "IntegrationPipelineError" , "initialize_integration_pipeline" ]
4046
4147logger = 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+
4499class _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