Skip to content

Commit 9a68ff2

Browse files
committed
feat(vsts): Add API-driven integration setup
Add API-driven setup steps for the Azure DevOps (VSTS) integration so it can use the pipeline API instead of the legacy server-rendered flow. Use the shared OAuth API step with the VSTS new identity provider and add a dedicated account selection step for choosing the Azure DevOps organization. Refs [VDY-43: Azure DevOps (VSTS): API-driven integration setup](https://linear.app/getsentry/issue/VDY-43/azure-devops-vsts-api-driven-integration-setup)
1 parent 1093817 commit 9a68ff2

File tree

3 files changed

+304
-35
lines changed

3 files changed

+304
-35
lines changed

src/sentry/integrations/vsts/integration.py

Lines changed: 129 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,23 @@
44
import re
55
from collections.abc import Mapping, MutableMapping, Sequence
66
from time import time
7-
from typing import Any
7+
from typing import Any, TypedDict
88
from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
99

1010
from django import forms
1111
from django.http.request import HttpRequest
1212
from django.http.response import HttpResponseBase
1313
from 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
1618
from sentry.auth.exceptions import IdentityNotValid
1719
from sentry.constants import ObjectStatus
20+
from sentry.identity.oauth2 import OAuth2ApiStep
1821
from sentry.identity.pipeline import IdentityPipeline
1922
from 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
2124
from sentry.integrations.base import (
2225
FeatureDescription,
2326
IntegrationData,
@@ -47,7 +50,8 @@
4750
from sentry.models.apitoken import generate_token
4851
from sentry.models.repository import Repository
4952
from 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
5155
from sentry.pipeline.views.nested import NestedPipelineView
5256
from sentry.shared_integrations.exceptions import (
5357
ApiError,
@@ -135,6 +139,30 @@
135139
logger = 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+
138166
class 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+
417513
class 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

738836
class AccountForm(forms.Form):
739837
def __init__(self, accounts: Sequence[Mapping[str, str]], *args: Any, **kwargs: Any) -> None:

0 commit comments

Comments
 (0)