diff --git a/backend/plugins/azuredevops_go/api/azuredevops/client.go b/backend/plugins/azuredevops_go/api/azuredevops/client.go index 36a9ef7c619..bfe4beb6d0e 100644 --- a/backend/plugins/azuredevops_go/api/azuredevops/client.go +++ b/backend/plugins/azuredevops_go/api/azuredevops/client.go @@ -65,7 +65,7 @@ func (c *Client) GetUserProfile() (Profile, errors.Error) { return Profile{}, errors.Internal.Wrap(err, "failed to read user accounts") } - if res.StatusCode == 302 || res.StatusCode == 401 { + if res.StatusCode == 203 || res.StatusCode == 401 { return Profile{}, errors.Unauthorized.New("failed to read user profile") } @@ -152,6 +152,15 @@ func (c *Client) GetProjects(args GetProjectsArgs) ([]Project, errors.Error) { if err != nil { return nil, err } + + if res.StatusCode == 203 || res.StatusCode == 401 { + return nil, errors.Unauthorized.New("failed to read projects") + } + + if res.StatusCode != 200 { + return nil, errors.Internal.New(fmt.Sprintf("failed to read projects, upstream api call failed with (%v)", res.StatusCode)) + } + err = api.UnmarshalResponse(res, &data) if err != nil { return nil, err diff --git a/backend/plugins/azuredevops_go/api/azuredevops/testdata/test.txt b/backend/plugins/azuredevops_go/api/azuredevops/testdata/test.txt index 299bce594f2..8887e5d898a 100644 --- a/backend/plugins/azuredevops_go/api/azuredevops/testdata/test.txt +++ b/backend/plugins/azuredevops_go/api/azuredevops/testdata/test.txt @@ -13,8 +13,8 @@ Basic OnRlc3QtdG9rZW4= Content-Type: application/json; charset=utf-8; api-version=7.1 Basic OmludmFsaWQtdG9rZW4= - StatusCode: 302 - Body: Object moved + StatusCode: 203 + Body: Azure DevOps Services | Sign In Content-Type: text/html; charset=utf-8 Location: https://app.vssps.visualstudio.com/_signin diff --git a/backend/plugins/azuredevops_go/api/connection_api.go b/backend/plugins/azuredevops_go/api/connection_api.go index d29cac85b8d..7da66cd1fe5 100644 --- a/backend/plugins/azuredevops_go/api/connection_api.go +++ b/backend/plugins/azuredevops_go/api/connection_api.go @@ -18,6 +18,7 @@ limitations under the License. package api import ( + "context" "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/plugin" "github.com/apache/incubator-devlake/helpers/pluginhelper/api" @@ -52,17 +53,10 @@ func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, }, AzuredevopsConn: conn, } - vsc := azuredevops.NewClient(&connection, nil, "https://app.vssps.visualstudio.com/") - - _, err := vsc.GetUserProfile() + body, err := testConnection(context.TODO(), connection) if err != nil { - return nil, err + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) } - - body := AzuredevopsTestConnResponse{} - body.Success = true - body.Message = "success" - return &plugin.ApiResourceOutput{Body: body, Status: http.StatusOK}, nil } @@ -75,21 +69,15 @@ func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, // @Failure 500 {string} errcode.Error "Internal Error" // @Router /plugins/azuredevops/connections/{connectionId}/test [POST] func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { - connection, err := dsHelper.ConnApi.FindByPk(input) + connection, err := dsHelper.ConnApi.GetMergedConnection(input) if err != nil { return nil, errors.BadInput.Wrap(err, "can't read connection from database") } - vsc := azuredevops.NewClient(connection, nil, "https://app.vssps.visualstudio.com/") - _, err = vsc.GetUserProfile() + body, err := testConnection(context.TODO(), *connection) if err != nil { - return nil, err + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) } - - body := AzuredevopsTestConnResponse{} - body.Success = true - body.Message = "success" - return &plugin.ApiResourceOutput{Body: body, Status: http.StatusOK}, nil } @@ -155,3 +143,38 @@ func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { return dsHelper.ConnApi.GetDetail(input) } + +func testConnection(ctx context.Context, connection models.AzuredevopsConnection) (*AzuredevopsTestConnResponse, errors.Error) { + // validate + if vld != nil { + if err := vld.Struct(connection); err != nil { + return nil, errors.Default.Wrap(err, "error validating connection") + } + } + apiClient, err := api.NewApiClientFromConnection(ctx, basicRes, &connection) + if err != nil { + return nil, err + } + + vsc := azuredevops.NewClient(&connection, apiClient, "https://app.vssps.visualstudio.com/") + org := connection.Organization + + if org == "" { + _, err = vsc.GetUserProfile() + } else { + args := azuredevops.GetProjectsArgs{ + OrgId: org, + } + _, err = vsc.GetProjects(args) + } + if err != nil { + return nil, err + } + + connection = connection.Sanitize() + body := AzuredevopsTestConnResponse{} + body.Success = true + body.Message = "success" + + return &body, nil +} diff --git a/backend/plugins/azuredevops_go/api/init.go b/backend/plugins/azuredevops_go/api/init.go index e00bcc56c81..0401e2b3662 100644 --- a/backend/plugins/azuredevops_go/api/init.go +++ b/backend/plugins/azuredevops_go/api/init.go @@ -26,6 +26,7 @@ import ( ) var vld *validator.Validate +var basicRes context.BasicRes var dsHelper *api.DsHelper[models.AzuredevopsConnection, models.AzuredevopsRepo, models.AzuredevopsScopeConfig] var raProxy *api.DsRemoteApiProxyHelper[models.AzuredevopsConnection] @@ -34,6 +35,7 @@ var raScopeSearch *api.DsRemoteApiScopeSearchHelper[models.AzuredevopsConnection func Init(br context.BasicRes, p plugin.PluginMeta) { vld = validator.New() + basicRes = br dsHelper = api.NewDataSourceHelper[ models.AzuredevopsConnection, models.AzuredevopsRepo, diff --git a/backend/plugins/azuredevops_go/api/remote_helper.go b/backend/plugins/azuredevops_go/api/remote_helper.go index 300a3fe812a..00110daf673 100644 --- a/backend/plugins/azuredevops_go/api/remote_helper.go +++ b/backend/plugins/azuredevops_go/api/remote_helper.go @@ -58,10 +58,11 @@ func listAzuredevopsRemoteScopes( err errors.Error, ) { + org := connection.Organization vsc := azuredevops.NewClient(connection, apiClient, "https://app.vssps.visualstudio.com") if groupId == "" { - return listAzuredevopsProjects(vsc, page) + return listAzuredevopsProjects(vsc, page, org) } id := strings.Split(groupId, idSeparator) @@ -76,18 +77,23 @@ func listAzuredevopsRemoteScopes( return children, nextPage, nil } -func listAzuredevopsProjects(vsc azuredevops.Client, _ AzuredevopsRemotePagination) ( +func listAzuredevopsProjects(vsc azuredevops.Client, _ AzuredevopsRemotePagination, org string) ( children []dsmodels.DsRemoteApiScopeListEntry[models.AzuredevopsRepo], nextPage *AzuredevopsRemotePagination, err errors.Error) { - profile, err := vsc.GetUserProfile() - if err != nil { - return nil, nil, err - } - accounts, err := vsc.GetUserAccounts(profile.Id) - if err != nil { - return nil, nil, err + var accounts azuredevops.AccountResponse + if org == "" { + profile, err := vsc.GetUserProfile() + if err != nil { + return nil, nil, err + } + accounts, err = vsc.GetUserAccounts(profile.Id) + if err != nil { + return nil, nil, err + } + } else { + accounts = append(accounts, azuredevops.Account{AccountName: org}) } g, _ := errgroup.WithContext(context.Background()) diff --git a/backend/plugins/azuredevops_go/models/connection.go b/backend/plugins/azuredevops_go/models/connection.go index 53e6582ae41..3bb9f4a9614 100644 --- a/backend/plugins/azuredevops_go/models/connection.go +++ b/backend/plugins/azuredevops_go/models/connection.go @@ -52,7 +52,7 @@ func (at *AzuredevopsAccessToken) SetupAuthentication(req *http.Request) errors. type AzuredevopsConn struct { //api.RestConnection `mapstructure:",squash"` AzuredevopsAccessToken `mapstructure:",squash"` - Organization string + Organization string `json:"organization"` //Endpoint string `mapstructure:"endpoint" json:"endpoint"` Proxy string `mapstructure:"proxy" json:"proxy"` //RateLimitPerHour int `comment:"api request rate limit per hour" json:"rateLimitPerHour"` diff --git a/config-ui/src/api/connection/index.ts b/config-ui/src/api/connection/index.ts index e376036c009..5a1ff5f96aa 100644 --- a/config-ui/src/api/connection/index.ts +++ b/config-ui/src/api/connection/index.ts @@ -52,6 +52,7 @@ export const test = ( | 'proxy' | 'dbUrl' | 'companyId' + | 'organization' > >, ): Promise => @@ -61,6 +62,15 @@ export const testOld = ( plugin: string, payload: Pick< IConnectionAPI, - 'endpoint' | 'authMethod' | 'username' | 'password' | 'token' | 'appId' | 'secretKey' | 'proxy' | 'dbUrl' + | 'endpoint' + | 'authMethod' + | 'username' + | 'password' + | 'token' + | 'appId' + | 'secretKey' + | 'proxy' + | 'dbUrl' + | 'organization' >, ): Promise => request(`/plugins/${plugin}/test`, { method: 'post', data: payload }); diff --git a/config-ui/src/features/connections/utils.ts b/config-ui/src/features/connections/utils.ts index 3fe4fac7524..3e6731ded99 100644 --- a/config-ui/src/features/connections/utils.ts +++ b/config-ui/src/features/connections/utils.ts @@ -40,6 +40,7 @@ export const transformConnection = (plugin: string, connection: IConnectionAPI): proxy: connection.proxy, enableGraphql: connection.enableGraphql, rateLimitPerHour: connection.rateLimitPerHour, + organization: connection.organization, }; }; diff --git a/config-ui/src/plugins/components/connection-form/index.tsx b/config-ui/src/plugins/components/connection-form/index.tsx index af285259989..a0b60b0f743 100644 --- a/config-ui/src/plugins/components/connection-form/index.tsx +++ b/config-ui/src/plugins/components/connection-form/index.tsx @@ -73,6 +73,7 @@ export const ConnectionForm = ({ plugin, connectionId, onSuccess }: Props) => { proxy: isEqual(connection?.proxy, values.proxy) ? undefined : values.proxy, dbUrl: isEqual(connection?.dbUrl, values.dbUrl) ? undefined : values.dbUrl, companyId: isEqual(connection?.companyId, values.companyId) ? undefined : values.companyId, + organization: isEqual(connection?.organization, values.organization) ? undefined : values.organization, }) : API.connection.testOld( plugin, @@ -89,6 +90,7 @@ export const ConnectionForm = ({ plugin, connectionId, onSuccess }: Props) => { 'tenantType', 'dbUrl', 'companyId', + 'organization', ]), ), { diff --git a/config-ui/src/plugins/register/azure/config.tsx b/config-ui/src/plugins/register/azure/config.tsx index cf309875112..8a232488832 100644 --- a/config-ui/src/plugins/register/azure/config.tsx +++ b/config-ui/src/plugins/register/azure/config.tsx @@ -21,7 +21,7 @@ import { DOC_URL } from '@/release'; import { IPluginConfig } from '@/types'; import Icon from './assets/icon.svg?react'; -import { BaseURL } from './connection-fields'; +import { BaseURL, ConnectionOrganization } from './connection-fields'; export const AzureConfig: IPluginConfig = { plugin: 'azuredevops', @@ -88,13 +88,16 @@ export const AzureGoConfig: IPluginConfig = { { key: 'token', label: 'Personal Access Token', - subLabel: ( - - Learn about how to create a PAT{' '} - Please select ALL ACCESSIBLE ORGANIZATIONS for the Organization field when you create the PAT. - - ), }, + ({ initialValues, values, setValues }: any) => ( + setValues({ organization: value })} + /> + ), 'proxy', { key: 'rateLimitPerHour', diff --git a/config-ui/src/plugins/register/azure/connection-fields/index.ts b/config-ui/src/plugins/register/azure/connection-fields/index.ts index de415ebacf7..302837ff891 100644 --- a/config-ui/src/plugins/register/azure/connection-fields/index.ts +++ b/config-ui/src/plugins/register/azure/connection-fields/index.ts @@ -17,3 +17,4 @@ */ export * from './base-url'; +export * from './organization'; diff --git a/config-ui/src/plugins/register/azure/connection-fields/organization.tsx b/config-ui/src/plugins/register/azure/connection-fields/organization.tsx new file mode 100644 index 00000000000..1e2972ca382 --- /dev/null +++ b/config-ui/src/plugins/register/azure/connection-fields/organization.tsx @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import React, { useEffect, useState } from 'react'; +import { Input, Radio, type RadioChangeEvent } from 'antd'; + +import { Block, ExternalLink } from '@/components'; +import { DOC_URL } from '@/release'; + +interface Props { + initialValue: OrganizationSettings; + value: string; + label?: string; + setValue: (value: string) => void; +} + +interface OrganizationSettings { + organization: string; + scoped: boolean; +} + +export const ConnectionOrganization = ({ label, initialValue, value, setValue }: Props) => { + const [settings, setSettings] = useState({ scoped: false, organization: '' }); + + useEffect(() => { + const org = initialValue.organization || ''; + setValue(org); + + setSettings({ organization: initialValue.organization, scoped: org !== '' }); + }, [initialValue.organization]); + + const handleChange = (e: RadioChangeEvent) => { + const scoped = e.target.value; + if (scoped) { + setValue(settings.organization); + } else { + setValue(''); + } + setSettings({ ...settings, scoped }); + }; + + const handleChangeValue = (e: React.ChangeEvent) => { + const organization = e.target.value; + setValue(organization); + setSettings({ ...settings, organization }); + }; + + return ( + <> + +

+ If you are using an organization-scoped token, please enter the organization. Otherwise make sure to create an + unscoped token.{' '} + {DOC_URL.PLUGIN.AZUREDEVOPS.AUTH_TOKEN !== '' && ( + Learn about how to create a PAT + )} +

+ + Unscoped + Scoped + +
+ + + + + ); +}; diff --git a/config-ui/src/types/connection.ts b/config-ui/src/types/connection.ts index b5b9573069e..6f0226c415c 100644 --- a/config-ui/src/types/connection.ts +++ b/config-ui/src/types/connection.ts @@ -31,6 +31,7 @@ export interface IConnectionAPI { companyId?: number; proxy: string; rateLimitPerHour?: number; + organization?: string; } export interface IConnectionTestResult { @@ -85,4 +86,5 @@ export interface IConnection { companyId?: number; proxy: string; rateLimitPerHour?: number; + organization?: string; }