44import re
55from collections .abc import Mapping , MutableMapping , Sequence
66from time import time
7- from typing import Any
7+ from typing import Any , TypedDict
88from urllib .parse import parse_qs , quote , unquote , urlencode , urlparse
99
1010from django import forms
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,74 @@ def default_project(self) -> str | None:
414442 return None
415443
416444
445+ class VstsAccountSelectionSerializer (Serializer ):
446+ account = CharField (required = True )
447+
448+
449+ class VstsAccountStepData (TypedDict ):
450+ accountId : str
451+ accountName : str
452+
453+
454+ class VstsAccountSelectionStepData (TypedDict ):
455+ accounts : list [VstsAccountStepData ]
456+
457+
458+ class VstsAccountSelectionApiStep :
459+ step_name = "account_selection"
460+
461+ def get_serializer_cls (self ) -> type :
462+ return VstsAccountSelectionSerializer
463+
464+ def get_step_data (
465+ self , pipeline : IntegrationPipeline , request : HttpRequest
466+ ) -> VstsAccountSelectionStepData :
467+ with IntegrationPipelineViewEvent (
468+ IntegrationPipelineViewType .ACCOUNT_CONFIG ,
469+ IntegrationDomain .SOURCE_CODE_MANAGEMENT ,
470+ VstsIntegrationProvider .key ,
471+ ).capture () as lifecycle :
472+ oauth_data = pipeline .fetch_state ("oauth_data" ) or {}
473+ access_token : str = oauth_data ["access_token" ]
474+ user = get_user_info (access_token )
475+
476+ accounts = get_accounts (access_token , user ["uuid" ])
477+ extra = {
478+ "organization_id" : pipeline .organization .id if pipeline .organization else None ,
479+ "user_id" : request .user .id ,
480+ "accounts" : accounts ,
481+ }
482+
483+ if not accounts or not accounts .get ("value" ):
484+ lifecycle .record_halt (IntegrationPipelineHaltReason .NO_ACCOUNTS , extra = extra )
485+ pipeline .bind_state ("accounts" , [])
486+ return {"accounts" : []}
487+
488+ accounts_list = accounts ["value" ]
489+ pipeline .bind_state ("accounts" , accounts_list )
490+ return {
491+ "accounts" : [
492+ {"accountId" : account ["accountId" ], "accountName" : account ["accountName" ]}
493+ for account in accounts_list
494+ ]
495+ }
496+
497+ def handle_post (
498+ self ,
499+ validated_data : dict [str , str ],
500+ pipeline : IntegrationPipeline ,
501+ request : HttpRequest ,
502+ ) -> PipelineStepResult :
503+ account_id = validated_data ["account" ]
504+ state_accounts : Sequence [Mapping [str , Any ]] | None = pipeline .fetch_state (key = "accounts" )
505+ account = get_account_from_id (account_id , state_accounts or [])
506+ if account is None :
507+ return PipelineStepResult .error ("Invalid Azure DevOps account" )
508+
509+ pipeline .bind_state ("account" , account )
510+ return PipelineStepResult .advance ()
511+
512+
417513class VstsIntegrationProvider (IntegrationProvider ):
418514 key = IntegrationProviderSlug .AZURE_DEVOPS .value
419515 name = "Azure DevOps"
@@ -486,8 +582,34 @@ def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]:
486582 AccountConfigView (),
487583 ]
488584
585+ def get_pipeline_api_steps (self ) -> ApiPipelineSteps [IntegrationPipeline ]:
586+ return [
587+ self ._make_oauth_api_step (),
588+ VstsAccountSelectionApiStep (),
589+ ]
590+
591+ def _make_oauth_api_step (self ) -> OAuth2ApiStep :
592+ provider = VSTSNewIdentityProvider (
593+ oauth_scopes = sorted (self .get_scopes ()),
594+ redirect_url = absolute_uri (self .oauth_redirect_url ),
595+ )
596+ extra_authorize_params = {"response_mode" : "query" }
597+ if options .get ("vsts.consent-prompt" ):
598+ extra_authorize_params ["prompt" ] = "consent"
599+
600+ return provider .make_oauth_api_step (
601+ bind_key = "oauth_data" ,
602+ extra_authorize_params = extra_authorize_params ,
603+ )
604+
489605 def build_integration (self , state : Mapping [str , Any ]) -> IntegrationData :
490- data = state ["identity" ]["data" ]
606+ # TODO: legacy views write token data to state["identity"]["data"] via
607+ # NestedPipelineView. API steps write directly to state["oauth_data"].
608+ # Remove the legacy path once the old views are retired.
609+ if "oauth_data" in state :
610+ data = state ["oauth_data" ]
611+ else :
612+ data = state ["identity" ]["data" ]
491613 oauth_data = self .get_oauth_data (data )
492614 account = state ["account" ]
493615 user = get_user_info (data ["access_token" ])
@@ -679,7 +801,7 @@ def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpR
679801 state_accounts : Sequence [Mapping [str , Any ]] | None = pipeline .fetch_state (
680802 key = "accounts"
681803 )
682- account = self . get_account_from_id (account_id , state_accounts or [])
804+ account = get_account_from_id (account_id , state_accounts or [])
683805 if account is not None :
684806 pipeline .bind_state ("account" , account )
685807 return pipeline .next_step ()
@@ -688,7 +810,7 @@ def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpR
688810 access_token = (state or {}).get ("data" , {}).get ("access_token" )
689811 user = get_user_info (access_token )
690812
691- accounts = self . get_accounts (access_token , user ["uuid" ])
813+ accounts = get_accounts (access_token , user ["uuid" ])
692814 extra = {
693815 "organization_id" : pipeline .organization .id if pipeline .organization else None ,
694816 "user_id" : request .user .id ,
@@ -710,30 +832,6 @@ def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpR
710832 request = request ,
711833 )
712834
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-
737835
738836class AccountForm (forms .Form ):
739837 def __init__ (self , accounts : Sequence [Mapping [str , str ]], * args : Any , ** kwargs : Any ) -> None :
0 commit comments