1111from django .http .request import HttpRequest
1212from django .http .response import HttpResponseBase
1313from django .utils .translation import gettext as _
14+ from rest_framework .fields import CharField
15+ from rest_framework .serializers import Serializer
1416
15- from sentry import features , http
17+ from sentry import features , http , options
1618from sentry .auth .exceptions import IdentityNotValid
1719from sentry .constants import ObjectStatus
20+ from sentry .identity .oauth2 import OAuth2ApiStep
1821from sentry .identity .pipeline import IdentityPipeline
1922from sentry .identity .services .identity .model import RpcIdentity
20- from sentry .identity .vsts .provider import get_user_info
23+ from sentry .identity .vsts .provider import VSTSNewIdentityProvider , get_user_info
2124from sentry .integrations .base import (
2225 FeatureDescription ,
2326 IntegrationData ,
4750from sentry .models .apitoken import generate_token
4851from sentry .models .repository import Repository
4952from sentry .organizations .services .organization .model import RpcOrganization
50- from sentry .pipeline .views .base import PipelineView
53+ from sentry .pipeline .types import PipelineStepResult
54+ from sentry .pipeline .views .base import ApiPipelineSteps , PipelineView
5155from sentry .pipeline .views .nested import NestedPipelineView
5256from sentry .shared_integrations .exceptions import (
5357 ApiError ,
135139logger = logging .getLogger ("sentry.integrations" )
136140
137141
142+ def get_account_from_id (
143+ account_id : str , accounts : Sequence [Mapping [str , Any ]]
144+ ) -> Mapping [str , Any ] | None :
145+ for account in accounts :
146+ if account ["accountId" ] == account_id :
147+ return account
148+ return None
149+
150+
151+ def get_accounts (access_token : str , user_id : str ) -> Any | None :
152+ url = f"https://app.vssps.visualstudio.com/_apis/accounts?memberId={ user_id } &api-version=4.1"
153+ with http .build_session () as session :
154+ response = session .get (
155+ url ,
156+ headers = {
157+ "Content-Type" : "application/json" ,
158+ "Authorization" : f"Bearer { access_token } " ,
159+ },
160+ )
161+ if response .status_code == 200 :
162+ return response .json ()
163+ return None
164+
165+
138166class VstsIntegration (RepositoryIntegration [VstsApiClient ], VstsIssuesSpec ):
139167 logger = logger
140168 comment_key = "sync_comments"
@@ -414,6 +442,63 @@ def default_project(self) -> str | None:
414442 return None
415443
416444
445+ class VstsAccountSelectionSerializer (Serializer ):
446+ account = CharField (required = True )
447+
448+
449+ class VstsAccountSelectionApiStep :
450+ step_name = "account_selection"
451+
452+ def get_serializer_cls (self ) -> type :
453+ return VstsAccountSelectionSerializer
454+
455+ def get_step_data (self , pipeline : IntegrationPipeline , request : HttpRequest ) -> dict [str , Any ]:
456+ with IntegrationPipelineViewEvent (
457+ IntegrationPipelineViewType .ACCOUNT_CONFIG ,
458+ IntegrationDomain .SOURCE_CODE_MANAGEMENT ,
459+ VstsIntegrationProvider .key ,
460+ ).capture () as lifecycle :
461+ oauth_data = pipeline .fetch_state ("oauth_data" ) or {}
462+ access_token = oauth_data .get ("access_token" )
463+ user = get_user_info (access_token )
464+
465+ accounts = get_accounts (access_token , user ["uuid" ])
466+ extra = {
467+ "organization_id" : pipeline .organization .id if pipeline .organization else None ,
468+ "user_id" : request .user .id ,
469+ "accounts" : accounts ,
470+ }
471+
472+ if not accounts or not accounts .get ("value" ):
473+ lifecycle .record_halt (IntegrationPipelineHaltReason .NO_ACCOUNTS , extra = extra )
474+ pipeline .bind_state ("accounts" , [])
475+ return {"accounts" : []}
476+
477+ accounts_list = accounts ["value" ]
478+ pipeline .bind_state ("accounts" , accounts_list )
479+ return {
480+ "accounts" : [
481+ {"accountId" : account ["accountId" ], "accountName" : account ["accountName" ]}
482+ for account in accounts_list
483+ ]
484+ }
485+
486+ def handle_post (
487+ self ,
488+ validated_data : dict [str , str ],
489+ pipeline : IntegrationPipeline ,
490+ request : HttpRequest ,
491+ ) -> PipelineStepResult :
492+ account_id = validated_data ["account" ]
493+ state_accounts : Sequence [Mapping [str , Any ]] | None = pipeline .fetch_state (key = "accounts" )
494+ account = get_account_from_id (account_id , state_accounts or [])
495+ if account is None :
496+ return PipelineStepResult .error ("Invalid Azure DevOps account" )
497+
498+ pipeline .bind_state ("account" , account )
499+ return PipelineStepResult .advance ()
500+
501+
417502class VstsIntegrationProvider (IntegrationProvider ):
418503 key = IntegrationProviderSlug .AZURE_DEVOPS .value
419504 name = "Azure DevOps"
@@ -486,8 +571,34 @@ def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]:
486571 AccountConfigView (),
487572 ]
488573
574+ def get_pipeline_api_steps (self ) -> ApiPipelineSteps [IntegrationPipeline ]:
575+ return [
576+ self ._make_oauth_api_step (),
577+ VstsAccountSelectionApiStep (),
578+ ]
579+
580+ def _make_oauth_api_step (self ) -> OAuth2ApiStep :
581+ provider = VSTSNewIdentityProvider (
582+ oauth_scopes = sorted (self .get_scopes ()),
583+ redirect_url = absolute_uri (self .oauth_redirect_url ),
584+ )
585+ extra_authorize_params = {"response_mode" : "query" }
586+ if options .get ("vsts.consent-prompt" ):
587+ extra_authorize_params ["prompt" ] = "consent"
588+
589+ return provider .make_oauth_api_step (
590+ bind_key = "oauth_data" ,
591+ extra_authorize_params = extra_authorize_params ,
592+ )
593+
489594 def build_integration (self , state : Mapping [str , Any ]) -> IntegrationData :
490- data = state ["identity" ]["data" ]
595+ # TODO: legacy views write token data to state["identity"]["data"] via
596+ # NestedPipelineView. API steps write directly to state["oauth_data"].
597+ # Remove the legacy path once the old views are retired.
598+ if "oauth_data" in state :
599+ data = state ["oauth_data" ]
600+ else :
601+ data = state ["identity" ]["data" ]
491602 oauth_data = self .get_oauth_data (data )
492603 account = state ["account" ]
493604 user = get_user_info (data ["access_token" ])
@@ -679,7 +790,7 @@ def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpR
679790 state_accounts : Sequence [Mapping [str , Any ]] | None = pipeline .fetch_state (
680791 key = "accounts"
681792 )
682- account = self . get_account_from_id (account_id , state_accounts or [])
793+ account = get_account_from_id (account_id , state_accounts or [])
683794 if account is not None :
684795 pipeline .bind_state ("account" , account )
685796 return pipeline .next_step ()
@@ -688,7 +799,7 @@ def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpR
688799 access_token = (state or {}).get ("data" , {}).get ("access_token" )
689800 user = get_user_info (access_token )
690801
691- accounts = self . get_accounts (access_token , user ["uuid" ])
802+ accounts = get_accounts (access_token , user ["uuid" ])
692803 extra = {
693804 "organization_id" : pipeline .organization .id if pipeline .organization else None ,
694805 "user_id" : request .user .id ,
@@ -710,30 +821,6 @@ def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpR
710821 request = request ,
711822 )
712823
713- def get_account_from_id (
714- self , account_id : str , accounts : Sequence [Mapping [str , Any ]]
715- ) -> Mapping [str , Any ] | None :
716- for account in accounts :
717- if account ["accountId" ] == account_id :
718- return account
719- return None
720-
721- def get_accounts (self , access_token : str , user_id : int ) -> Any | None :
722- url = (
723- f"https://app.vssps.visualstudio.com/_apis/accounts?memberId={ user_id } &api-version=4.1"
724- )
725- with http .build_session () as session :
726- response = session .get (
727- url ,
728- headers = {
729- "Content-Type" : "application/json" ,
730- "Authorization" : f"Bearer { access_token } " ,
731- },
732- )
733- if response .status_code == 200 :
734- return response .json ()
735- return None
736-
737824
738825class AccountForm (forms .Form ):
739826 def __init__ (self , accounts : Sequence [Mapping [str , str ]], * args : Any , ** kwargs : Any ) -> None :
0 commit comments