This is an automated email from the ASF dual-hosted git repository.

markusb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 466db4478 feat: add organization as an optional parameter in Azure 
DevOps connection form (#7459)
466db4478 is described below

commit 466db447887d301b4789900ae7fe7e9227beac4e
Author: Markus Braunbeck <[email protected]>
AuthorDate: Fri May 17 07:43:10 2024 +0200

    feat: add organization as an optional parameter in Azure DevOps connection 
form (#7459)
    
    * feat: add organization as an optional parameter in Azure DevOps 
connection creation.
    
    Users can now create an Azure DevOps connection using a PAT that is limited 
to a single organization. This means users are not required to use PATs with 
access to all their organizations.
    
    * feat: add organization as an optional parameter in Azure DevOps 
connection creation.
    
    Users can now create an Azure DevOps connection using a PAT that is limited 
to a single organization. This means users are not required to use PATs with 
access to all their organizations.
    
    * fix: adjust the test data to fix the failed unit test
    
    The test case was incorrect. When an invalid PAT is provided, the Azure 
DevOps API redirects the user to the login page. Go's HTTP client automatically 
follows the redirect when it receives a 302 status code. To circumvent this, I 
made adjustments to the test case.
    
    * fix: TestExistingConnection now incorporates the updated values as well
    
    * chore: replace custom merge logic
    
    ---------
    
    Co-authored-by: Klesh Wong <[email protected]>
---
 .../azuredevops_go/api/azuredevops/client.go       | 11 ++-
 .../api/azuredevops/testdata/test.txt              |  4 +-
 .../plugins/azuredevops_go/api/connection_api.go   | 59 +++++++++-----
 backend/plugins/azuredevops_go/api/init.go         |  2 +
 .../plugins/azuredevops_go/api/remote_helper.go    | 24 +++---
 .../plugins/azuredevops_go/models/connection.go    |  2 +-
 config-ui/src/api/connection/index.ts              | 12 ++-
 config-ui/src/features/connections/utils.ts        |  1 +
 .../plugins/components/connection-form/index.tsx   |  2 +
 config-ui/src/plugins/register/azure/config.tsx    | 17 +++--
 .../register/azure/connection-fields/index.ts      |  1 +
 .../azure/connection-fields/organization.tsx       | 89 ++++++++++++++++++++++
 config-ui/src/types/connection.ts                  |  2 +
 13 files changed, 187 insertions(+), 39 deletions(-)

diff --git a/backend/plugins/azuredevops_go/api/azuredevops/client.go 
b/backend/plugins/azuredevops_go/api/azuredevops/client.go
index 36a9ef7c6..bfe4beb6d 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 299bce594..8887e5d89 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: <html><head><title>Object moved</title></head><body>
+       StatusCode: 203
+       Body: <html><head><title>Azure DevOps Services | Sign 
In</title></head><body>
        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 d29cac85b..7da66cd1f 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 e00bcc56c..0401e2b36 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 300a3fe81..00110daf6 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 53e6582ae..3bb9f4a96 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 e376036c0..5a1ff5f96 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<IConnectionTestResult> =>
@@ -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<IConnectionOldTestResult> => 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 3fe4fac75..3e6731ded 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 af2852599..a0b60b0f7 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 cf3098751..8a2324888 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: (
-          <span>
-            <ExternalLink link={DOC_URL.PLUGIN.AZUREDEVOPS.AUTH_TOKEN}>Learn 
about how to create a PAT</ExternalLink>{' '}
-            Please select ALL ACCESSIBLE ORGANIZATIONS for the Organization 
field when you create the PAT.
-          </span>
-        ),
       },
+      ({ initialValues, values, setValues }: any) => (
+        <ConnectionOrganization
+          initialValue={initialValues}
+          label="Personal Access Token Scope"
+          key="ado-organization"
+          value={values.organization}
+          setValue={(value) => 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 de415ebac..302837ff8 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 000000000..1e2972ca3
--- /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<OrganizationSettings>({ 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<HTMLInputElement>) => {
+    const organization = e.target.value;
+    setValue(organization);
+    setSettings({ ...settings, organization });
+  };
+
+  return (
+    <>
+      <Block title={label || 'Personal Access Token Scope'}>
+        <p>
+          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 !== '' && (
+            <ExternalLink link={DOC_URL.PLUGIN.AZUREDEVOPS.AUTH_TOKEN}>Learn 
about how to create a PAT</ExternalLink>
+          )}
+        </p>
+        <Radio.Group value={settings.scoped} onChange={handleChange}>
+          <Radio value={false}>Unscoped</Radio>
+          <Radio value={true}>Scoped</Radio>
+        </Radio.Group>
+      </Block>
+      <Block>
+        <Input
+          style={{ width: 386 }}
+          placeholder="Your organization"
+          value={value}
+          onChange={handleChangeValue}
+          disabled={!settings.scoped}
+        />
+      </Block>
+    </>
+  );
+};
diff --git a/config-ui/src/types/connection.ts 
b/config-ui/src/types/connection.ts
index b5b957306..6f0226c41 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;
 }

Reply via email to