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

klesh 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 ca89492a9 feat: add remote repository support for azure devops (#7364)
ca89492a9 is described below

commit ca89492a9ba56ad7ce1981009deb3f5e4aea6148
Author: Markus Braunbeck <[email protected]>
AuthorDate: Thu Apr 25 05:05:41 2024 +0200

    feat: add remote repository support for azure devops (#7364)
    
    It is now possible to choose remote repositories connected through 'service 
connections' as a data scope. Tasks in the cicd and code domains can be 
performed based on the visibility of these repositories (public or private).
---
 .../azuredevops_go/api/azuredevops/client.go       | 282 +++++++++++++++++++++
 .../client_test.go}                                |   6 +-
 .../azuredevops_go/api/azuredevops/models.go       | 132 ++++++++++
 .../api/{ => azuredevops}/testdata/test.txt        |   0
 .../plugins/azuredevops_go/api/blueprint_v200.go   |  61 ++++-
 .../azuredevops_go/api/blueprint_v200_test.go      |  75 +++++-
 .../plugins/azuredevops_go/api/connection_api.go   |   9 +-
 backend/plugins/azuredevops_go/api/remote_data.go  |  65 -----
 .../plugins/azuredevops_go/api/remote_helper.go    | 223 +++++++++++-----
 backend/plugins/azuredevops_go/api/vs_client.go    | 137 ----------
 backend/plugins/azuredevops_go/e2e/build_test.go   |   1 +
 backend/plugins/azuredevops_go/models/base.go      |   5 +
 .../20240413_add_remote_repo_support.go            |  68 +++++
 .../models/migrationscripts/register.go            |   1 +
 backend/plugins/azuredevops_go/models/repo.go      |  13 +-
 .../azuredevops_go/tasks/ci_cd_build_collector.go  |  12 +-
 .../azuredevops_go/tasks/ci_cd_build_converter.go  |   2 +-
 backend/plugins/azuredevops_go/tasks/task_data.go  |   4 +-
 backend/test/e2e/manual/azuredevops/models.go      |   1 +
 config-ui/src/routes/pipeline/components/task.tsx  |   3 +
 20 files changed, 806 insertions(+), 294 deletions(-)

diff --git a/backend/plugins/azuredevops_go/api/azuredevops/client.go 
b/backend/plugins/azuredevops_go/api/azuredevops/client.go
new file mode 100644
index 000000000..36a9ef7c6
--- /dev/null
+++ b/backend/plugins/azuredevops_go/api/azuredevops/client.go
@@ -0,0 +1,282 @@
+/*
+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.
+*/
+
+package azuredevops
+
+import (
+       "encoding/json"
+       "fmt"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/plugin"
+       "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+       "github.com/apache/incubator-devlake/plugins/azuredevops_go/models"
+       "io"
+       "net/http"
+       "net/url"
+       "strconv"
+       "time"
+)
+
+const apiVersion = "7.1"
+const maxPageSize = 100
+
+type Client struct {
+       c http.Client
+
+       apiClient  plugin.ApiClient
+       connection *models.AzuredevopsConnection
+       url        string
+}
+
+func NewClient(con *models.AzuredevopsConnection, apiClient plugin.ApiClient, 
url string) Client {
+       return Client{
+               c: http.Client{
+                       Timeout: 2 * time.Second,
+               },
+               connection: con,
+               url:        url,
+               apiClient:  apiClient,
+       }
+}
+
+func (c *Client) GetUserProfile() (Profile, errors.Error) {
+       var p Profile
+       endpoint, err := url.JoinPath(c.url, "/_apis/profile/profiles/me")
+       if err != nil {
+               return Profile{}, errors.Internal.Wrap(err, "failed to join 
user profile path")
+       }
+
+       res, err := c.doGet(endpoint)
+       if err != nil {
+               return Profile{}, errors.Internal.Wrap(err, "failed to read 
user accounts")
+       }
+
+       if res.StatusCode == 302 || res.StatusCode == 401 {
+               return Profile{}, errors.Unauthorized.New("failed to read user 
profile")
+       }
+
+       defer res.Body.Close()
+       resBody, err := io.ReadAll(res.Body)
+       if err != nil {
+               return Profile{}, errors.Internal.Wrap(err, "failed to read 
response body")
+       }
+
+       if err := json.Unmarshal(resBody, &p); err != nil {
+               panic(err)
+       }
+       return p, nil
+}
+
+func (c *Client) GetUserAccounts(memberId string) (AccountResponse, 
errors.Error) {
+       var a AccountResponse
+       endpoint := fmt.Sprintf(c.url+"/_apis/accounts?memberId=%s", memberId)
+       res, err := c.doGet(endpoint)
+       if err != nil {
+               return nil, errors.Internal.Wrap(err, "failed to read user 
accounts")
+       }
+
+       if res.StatusCode == 302 || res.StatusCode == 401 {
+               return nil, errors.Unauthorized.New("failed to read user 
accounts")
+       }
+
+       defer res.Body.Close()
+       resBody, err := io.ReadAll(res.Body)
+       if err != nil {
+               return nil, errors.Internal.Wrap(err, "failed to read response 
body")
+       }
+
+       if err := json.Unmarshal(resBody, &a); err != nil {
+               return nil, errors.Internal.Wrap(err, "failed to read unmarshal 
response body")
+       }
+       return a, nil
+}
+
+func (c *Client) doGet(url string) (*http.Response, error) {
+       req, err := http.NewRequest(http.MethodGet, url, nil)
+       if err != nil {
+               return nil, err
+       }
+
+       if err = 
c.connection.GetAccessTokenAuthenticator().SetupAuthentication(req); err != nil 
{
+               return nil, errors.Internal.Wrap(err, "failed to authorize the 
request using the plugin connection")
+       }
+       return http.DefaultClient.Do(req)
+}
+
+type GetProjectsArgs struct {
+       // (optional) Pagination
+       *OffsetPagination
+
+       OrgId string
+}
+
+func (c *Client) GetProjects(args GetProjectsArgs) ([]Project, errors.Error) {
+       query := url.Values{}
+       query.Set("api-version", apiVersion)
+
+       var top, skip int
+       top = maxPageSize
+       skip = 0
+       if args.OffsetPagination != nil {
+               top = args.Top
+               skip = args.Skip
+       }
+
+       var data struct {
+               Count    int       `json:"count"`
+               Projects []Project `json:"value"`
+       }
+
+       var projects []Project
+
+       for {
+               query.Set("$top", strconv.Itoa(top))
+               query.Set("$skip", strconv.Itoa(skip))
+
+               path := fmt.Sprintf("%s/_apis/projects", args.OrgId)
+               res, err := c.apiClient.Get(path, query, nil)
+               if err != nil {
+                       return nil, err
+               }
+               err = api.UnmarshalResponse(res, &data)
+               if err != nil {
+                       return nil, err
+               }
+
+               projects = append(projects, data.Projects...)
+
+               if data.Count < top {
+                       return projects, nil
+               }
+
+               skip += top
+       }
+}
+
+type GetRepositoriesArgs struct {
+       OrgId     string
+       ProjectId string
+}
+
+func (c *Client) GetRepositories(args GetRepositoriesArgs) ([]Repository, 
errors.Error) {
+       query := url.Values{}
+       query.Set("api-version", apiVersion)
+
+       var data struct {
+               Repos []Repository `json:"value"`
+       }
+
+       path := fmt.Sprintf("%s/%s/_apis/git/repositories", args.OrgId, 
args.ProjectId)
+       res, err := c.apiClient.Get(path, query, nil)
+       if err != nil {
+               return nil, err
+       }
+
+       switch res.StatusCode {
+       case 401:
+               fallthrough
+       case 403:
+               return nil, errors.Unauthorized.New("failed to authorize the 
'.../_apis/git/repositories' request using the plugin connection")
+       case 404:
+               return nil, errors.NotFound.New("failed to find requested 
resource on '.../_apis/git/repositories'")
+       default:
+       }
+
+       err = api.UnmarshalResponse(res, &data)
+       if err != nil {
+               return nil, err
+       }
+
+       return data.Repos, nil
+}
+
+type GetServiceEndpointsArgs struct {
+       ProjectId string
+       OrgId     string
+}
+
+func (c *Client) GetServiceEndpoints(args GetServiceEndpointsArgs) 
([]ServiceEndpoint, errors.Error) {
+       query := url.Values{}
+       query.Set("api-version", apiVersion)
+
+       path := fmt.Sprintf("%s/%s/_apis/serviceendpoint/endpoints/", 
args.OrgId, args.ProjectId)
+       res, err := c.apiClient.Get(path, query, nil)
+       if err != nil {
+               return nil, err
+       }
+
+       switch res.StatusCode {
+       case 401:
+               fallthrough
+       case 403:
+               return nil, errors.Unauthorized.New("failed to authorize the 
'.../serviceendpoint/endpoints' request using the plugin connection")
+       case 404:
+               return nil, errors.NotFound.New("failed to find requested 
resource on '.../serviceendpoint/endpoints'")
+       default:
+       }
+
+       var data struct {
+               ServiceEndpoints []ServiceEndpoint `json:"value"`
+       }
+
+       err = api.UnmarshalResponse(res, &data)
+       if err != nil {
+               return nil, err
+       }
+       return data.ServiceEndpoints, nil
+}
+
+type GetRemoteRepositoriesArgs struct {
+       ProjectId string
+       OrgId     string
+       Provider  string
+       // (optional) Service Endpoint to filter for
+       ServiceEndpoint string
+}
+
+func (c *Client) GetRemoteRepositories(args GetRemoteRepositoriesArgs) 
([]RemoteRepository, error) {
+       query := url.Values{}
+       query.Set("api-version", apiVersion)
+       if args.ServiceEndpoint != "" {
+               query.Set("serviceEndpointId", args.ServiceEndpoint)
+       }
+
+       var repos []RemoteRepository
+       var response struct {
+               Repository []RemoteRepository `json:"repositories"`
+       }
+
+       for {
+               path := 
fmt.Sprintf("%s/%s/_apis/sourceProviders/%s/repositories/", args.OrgId, 
args.ProjectId, args.Provider)
+               res, err := c.apiClient.Get(path, query, nil)
+               if err != nil {
+                       return nil, errors.Internal.Wrap(err, "failed to read 
remote repositories")
+               }
+               err = api.UnmarshalResponse(res, &response)
+               if err != nil {
+                       return nil, errors.Internal.Wrap(err, "failed to 
unmarshal remote repositories response")
+               }
+
+               repos = append(repos, response.Repository...)
+               contToken := res.Header.Get("X-Ms-Continuationtoken")
+               if contToken == "" {
+                       return repos, nil
+               }
+
+               query.Set("continuationToken", contToken)
+       }
+}
diff --git a/backend/plugins/azuredevops_go/api/vs_client_test.go 
b/backend/plugins/azuredevops_go/api/azuredevops/client_test.go
similarity index 97%
rename from backend/plugins/azuredevops_go/api/vs_client_test.go
rename to backend/plugins/azuredevops_go/api/azuredevops/client_test.go
index 7aa8ed54c..6b184e8a6 100644
--- a/backend/plugins/azuredevops_go/api/vs_client_test.go
+++ b/backend/plugins/azuredevops_go/api/azuredevops/client_test.go
@@ -15,7 +15,7 @@ See the License for the specific language governing 
permissions and
 limitations under the License.
 */
 
-package api
+package azuredevops
 
 import (
        "bytes"
@@ -89,8 +89,8 @@ func TestRetrieveUserProfile(t *testing.T) {
                                },
                        }
 
-                       client := newVsClient(conn, ts.URL)
-                       p, err := client.UserProfile()
+                       client := NewClient(conn, nil, ts.URL)
+                       p, err := client.GetUserProfile()
                        if err != nil && err.GetType().GetHttpCode() != code {
                                t.Errorf("User Profile API Response = %d; want: 
%d", err.GetType().GetHttpCode(), code)
                        }
diff --git a/backend/plugins/azuredevops_go/api/azuredevops/models.go 
b/backend/plugins/azuredevops_go/api/azuredevops/models.go
new file mode 100644
index 000000000..388d60cfc
--- /dev/null
+++ b/backend/plugins/azuredevops_go/api/azuredevops/models.go
@@ -0,0 +1,132 @@
+/*
+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.
+*/
+
+package azuredevops
+
+import "time"
+
+type Profile struct {
+       DisplayName  string    `json:"displayName"`
+       PublicAlias  string    `json:"publicAlias"`
+       EmailAddress string    `json:"emailAddress"`
+       CoreRevision int       `json:"coreRevision"`
+       TimeStamp    time.Time `json:"timeStamp"`
+       Id           string    `json:"id"`
+       Revision     int       `json:"revision"`
+}
+
+type Account struct {
+       AccountId        string      `json:"AccountId"`
+       NamespaceId      string      `json:"NamespaceId"`
+       AccountName      string      `json:"AccountName"`
+       OrganizationName interface{} `json:"OrganizationName"`
+       AccountType      int         `json:"AccountType"`
+       AccountOwner     string      `json:"AccountOwner"`
+       CreatedBy        string      `json:"CreatedBy"`
+       CreatedDate      string      `json:"CreatedDate"`
+       AccountStatus    int         `json:"AccountStatus"`
+       StatusReason     interface{} `json:"StatusReason"`
+       LastUpdatedBy    string      `json:"LastUpdatedBy"`
+       Properties       struct {
+       } `json:"Properties"`
+}
+
+type AccountResponse []Account
+
+type RemoteRepository struct {
+       Properties struct {
+               ApiUrl              string    `json:"apiUrl"`
+               BranchesUrl         string    `json:"branchesUrl"`
+               CloneUrl            string    `json:"cloneUrl"`
+               ConnectedServiceId  string    `json:"connectedServiceId"`
+               DefaultBranch       string    `json:"defaultBranch"`
+               FullName            string    `json:"fullName"`
+               HasAdminPermissions string    `json:"hasAdminPermissions"`
+               IsFork              string    `json:"isFork"`
+               IsPrivate           string    `json:"isPrivate"`
+               LastUpdated         time.Time `json:"lastUpdated"`
+               ManageUrl           string    `json:"manageUrl"`
+               NodeId              string    `json:"nodeId"`
+               OwnerId             string    `json:"ownerId"`
+               OrgName             string    `json:"orgName"`
+               RefsUrl             string    `json:"refsUrl"`
+               SafeRepository      string    `json:"safeRepository"`
+               ShortName           string    `json:"shortName"`
+               OwnerAvatarUrl      string    `json:"ownerAvatarUrl"`
+               Archived            string    `json:"archived"`
+               ExternalId          string    `json:"externalId"`
+               OwnerIsAUser        string    `json:"ownerIsAUser"`
+       } `json:"properties"`
+       Id                 string `json:"id"`
+       SourceProviderName string `json:"sourceProviderName"`
+       Name               string `json:"name"`
+       FullName           string `json:"fullName"`
+       Url                string `json:"url"`
+       DefaultBranch      string `json:"defaultBranch"`
+}
+
+type ServiceEndpoint struct {
+       Data        interface{} `json:"data"`
+       Id          string      `json:"id"`
+       Name        string      `json:"name"`
+       Type        string      `json:"type"`
+       Url         string      `json:"url"`
+       Description string      `json:"description"`
+       IsShared    bool        `json:"isShared"`
+       IsOutdated  bool        `json:"isOutdated"`
+       IsReady     bool        `json:"isReady"`
+       Owner       string      `json:"owner"`
+}
+
+type Project struct {
+       Id             string    `json:"id"`
+       Name           string    `json:"name"`
+       Description    string    `json:"description"`
+       Url            string    `json:"url"`
+       State          string    `json:"state"`
+       Revision       int       `json:"revision"`
+       Visibility     string    `json:"visibility"`
+       LastUpdateTime time.Time `json:"lastUpdateTime"`
+}
+
+type OffsetPagination struct {
+       Skip int
+       Top  int
+}
+
+type Repository struct {
+       Id      string `json:"id"`
+       Name    string `json:"name"`
+       Url     string `json:"url"`
+       Project struct {
+               Id             string    `json:"id"`
+               Name           string    `json:"name"`
+               Description    string    `json:"description"`
+               Url            string    `json:"url"`
+               State          string    `json:"state"`
+               Revision       int       `json:"revision"`
+               Visibility     string    `json:"visibility"`
+               LastUpdateTime time.Time `json:"lastUpdateTime"`
+       } `json:"project"`
+       DefaultBranch   string `json:"defaultBranch"`
+       Size            int    `json:"size"`
+       RemoteUrl       string `json:"remoteUrl"`
+       SshUrl          string `json:"sshUrl"`
+       WebUrl          string `json:"webUrl"`
+       IsDisabled      bool   `json:"isDisabled"`
+       IsInMaintenance bool   `json:"isInMaintenance"`
+}
diff --git a/backend/plugins/azuredevops_go/api/testdata/test.txt 
b/backend/plugins/azuredevops_go/api/azuredevops/testdata/test.txt
similarity index 100%
rename from backend/plugins/azuredevops_go/api/testdata/test.txt
rename to backend/plugins/azuredevops_go/api/azuredevops/testdata/test.txt
diff --git a/backend/plugins/azuredevops_go/api/blueprint_v200.go 
b/backend/plugins/azuredevops_go/api/blueprint_v200.go
index f1df9be8c..4aa539319 100644
--- a/backend/plugins/azuredevops_go/api/blueprint_v200.go
+++ b/backend/plugins/azuredevops_go/api/blueprint_v200.go
@@ -18,6 +18,7 @@ limitations under the License.
 package api
 
 import (
+       "golang.org/x/exp/slices"
        "net/url"
 
        "github.com/apache/incubator-devlake/core/errors"
@@ -70,13 +71,15 @@ func makeScopeV200(
 
        for _, scope := range scopeDetails {
                azuredevopsRepo, scopeConfig := scope.Scope, scope.ScopeConfig
+               if azuredevopsRepo.Type != models.RepositoryTypeADO {
+                       continue
+               }
                id := 
didgen.NewDomainIdGenerator(&models.AzuredevopsRepo{}).Generate(connectionId, 
azuredevopsRepo.Id)
 
                if utils.StringsContains(scopeConfig.Entities, 
plugin.DOMAIN_TYPE_CODE_REVIEW) ||
                        utils.StringsContains(scopeConfig.Entities, 
plugin.DOMAIN_TYPE_CODE) {
                        // if we don't need to collect gitex, we need to add 
repo to scopes here
                        scopeRepo := code.NewRepo(id, azuredevopsRepo.Name)
-
                        sc = append(sc, scopeRepo)
                }
 
@@ -93,6 +96,26 @@ func makeScopeV200(
                }
        }
 
+       for _, scope := range scopeDetails {
+               azuredevopsRepo, scopeConfig := scope.Scope, scope.ScopeConfig
+               if azuredevopsRepo.Type == models.RepositoryTypeADO {
+                       continue
+               }
+               id := 
didgen.NewDomainIdGenerator(&models.AzuredevopsRepo{}).Generate(connectionId, 
azuredevopsRepo.Id)
+
+               // Azure DevOps Pipeline can be used with remote repositories 
such as GitHub and Bitbucket
+               if utils.StringsContains(scopeConfig.Entities, 
plugin.DOMAIN_TYPE_CICD) {
+                       scopeCICD := devops.NewCicdScope(id, 
azuredevopsRepo.Name)
+                       sc = append(sc, scopeCICD)
+               }
+
+               // DOMAIN_TYPE_CODE (i.e. gitextractor, rediff) only works if 
the repository is public
+               if !azuredevopsRepo.IsPrivate && 
utils.StringsContains(scopeConfig.Entities, plugin.DOMAIN_TYPE_CODE) {
+                       scopeRepo := code.NewRepo(id, azuredevopsRepo.Name)
+                       sc = append(sc, scopeRepo)
+               }
+       }
+
        return sc, nil
 }
 
@@ -106,16 +129,32 @@ func makePipelinePlanV200(
                azuredevopsRepo, scopeConfig := scope.Scope, scope.ScopeConfig
                var stage coreModels.PipelineStage
                var err errors.Error
-               // get repo
 
                options := make(map[string]interface{})
+               options["name"] = azuredevopsRepo.Name // this is solely for 
the FE to display the repo name of a task
+
                options["connectionId"] = connection.ID
                options["organizationId"] = azuredevopsRepo.OrganizationId
                options["projectId"] = azuredevopsRepo.ProjectId
+               options["externalId"] = azuredevopsRepo.ExternalId
                options["repositoryId"] = azuredevopsRepo.Id
+               options["repositoryType"] = azuredevopsRepo.Type
 
                // construct subtasks
-               subtasks, err := helper.MakePipelinePlanSubtasks(subtaskMetas, 
scopeConfig.Entities)
+               var entities []string
+               if scope.Scope.Type == models.RepositoryTypeADO {
+                       entities = append(entities, scopeConfig.Entities...)
+               } else {
+                       if i := slices.Index(scopeConfig.Entities, 
plugin.DOMAIN_TYPE_CICD); i >= 0 {
+                               entities = append(entities, 
scopeConfig.Entities[i])
+                       }
+
+                       if i := slices.Index(scopeConfig.Entities, 
plugin.DOMAIN_TYPE_CODE); i >= 0 && !scope.Scope.IsPrivate {
+                               entities = append(entities, 
scopeConfig.Entities[i])
+                       }
+               }
+
+               subtasks, err := helper.MakePipelinePlanSubtasks(subtaskMetas, 
entities)
                if err != nil {
                        return nil, err
                }
@@ -127,20 +166,22 @@ func makePipelinePlanV200(
                })
 
                // collect git data by gitextractor if CODE was requested
-               if utils.StringsContains(scopeConfig.Entities, 
plugin.DOMAIN_TYPE_CODE) || len(scopeConfig.Entities) == 0 {
+               if utils.StringsContains(scopeConfig.Entities, 
plugin.DOMAIN_TYPE_CODE) && !scope.Scope.IsPrivate || len(scopeConfig.Entities) 
== 0 {
                        cloneUrl, err := 
errors.Convert01(url.Parse(azuredevopsRepo.RemoteUrl))
                        if err != nil {
                                return nil, err
                        }
-                       cloneUrl.User = url.UserPassword("git", 
connection.Token)
+
+                       if scope.Scope.Type == models.RepositoryTypeADO {
+                               cloneUrl.User = url.UserPassword("git", 
connection.Token)
+                       }
                        stage = append(stage, &coreModels.PipelineTask{
                                Plugin: "gitextractor",
                                Options: map[string]interface{}{
-                                       "url":            cloneUrl.String(),
-                                       "name":           azuredevopsRepo.Name,
-                                       "repoId":         
didgen.NewDomainIdGenerator(&models.AzuredevopsRepo{}).Generate(connection.ID, 
azuredevopsRepo.Id),
-                                       "proxy":          connection.Proxy,
-                                       "noShallowClone": true,
+                                       "url":    cloneUrl.String(),
+                                       "name":   azuredevopsRepo.Name,
+                                       "repoId": 
didgen.NewDomainIdGenerator(&models.AzuredevopsRepo{}).Generate(connection.ID, 
azuredevopsRepo.Id),
+                                       "proxy":  connection.Proxy,
                                },
                        })
                }
diff --git a/backend/plugins/azuredevops_go/api/blueprint_v200_test.go 
b/backend/plugins/azuredevops_go/api/blueprint_v200_test.go
index 8fb55c893..1c1281c62 100644
--- a/backend/plugins/azuredevops_go/api/blueprint_v200_test.go
+++ b/backend/plugins/azuredevops_go/api/blueprint_v200_test.go
@@ -19,6 +19,8 @@ package api
 
 import (
        "fmt"
+       "reflect"
+       "strings"
        "testing"
 
        coreModels "github.com/apache/incubator-devlake/core/models"
@@ -58,7 +60,8 @@ func TestMakeScopes(t *testing.T) {
                                        Scope: common.Scope{
                                                ConnectionId: connectionID,
                                        },
-                                       Id: azuredevopsRepoId,
+                                       Id:   azuredevopsRepoId,
+                                       Type: models.RepositoryTypeADO,
                                },
                                ScopeConfig: &models.AzuredevopsScopeConfig{
                                        ScopeConfig: common.ScopeConfig{
@@ -115,6 +118,7 @@ func TestMakeDataSourcePipelinePlanV200(t *testing.T) {
                                        Name:      azureDevOpsProjectName,
                                        Url:       httpUrlToRepo,
                                        RemoteUrl: httpUrlToRepo,
+                                       Type:      models.RepositoryTypeADO,
                                },
                                ScopeConfig: &models.AzuredevopsScopeConfig{
                                        ScopeConfig: common.ScopeConfig{
@@ -144,20 +148,22 @@ func TestMakeDataSourcePipelinePlanV200(t *testing.T) {
                                        tasks.ExtractApiBuildsMeta.Name,
                                },
                                Options: map[string]interface{}{
+                                       "name":           
azureDevOpsProjectName,
                                        "connectionId":   connectionID,
                                        "projectId":      
azureDevOpsProjectName,
                                        "repositoryId":   
fmt.Sprint(azuredevopsRepoId),
                                        "organizationId": azureDevOpsOrgName,
+                                       "repositoryType": 
models.RepositoryTypeADO,
+                                       "externalId":     "",
                                },
                        },
                        {
                                Plugin: "gitextractor",
                                Options: map[string]interface{}{
-                                       "proxy":          "",
-                                       "repoId":         expectDomainScopeId,
-                                       "name":           
azureDevOpsProjectName,
-                                       "url":            
"https://git:personal-access-token@this_is_cloneUrl";,
-                                       "noShallowClone": true,
+                                       "proxy":  "",
+                                       "repoId": expectDomainScopeId,
+                                       "name":   azureDevOpsProjectName,
+                                       "url":    
"https://git:personal-access-token@this_is_cloneUrl";,
                                },
                        },
                },
@@ -175,3 +181,60 @@ func TestMakeDataSourcePipelinePlanV200(t *testing.T) {
 
        assert.Equal(t, expectPlans, actualPlans)
 }
+
+func TestMakeRemoteRepoScopes(t *testing.T) {
+       mockAzuredevopsPlugin(t)
+
+       data := []struct {
+               Name           string
+               Type           string
+               Private        bool
+               ExpectedScopes []string
+       }{
+               {Name: "Azure DevOps Repository", Type: 
models.RepositoryTypeADO, Private: false, ExpectedScopes: 
[]string{"*code.Repo", "*ticket.Board", "*devops.CicdScope"}},
+
+               {Name: "Public GitHub Repository", Type: 
models.RepositoryTypeGithub, Private: false, ExpectedScopes: 
[]string{"*code.Repo", "*devops.CicdScope"}},
+
+               {Name: "Private GitHub Repository", Type: 
models.RepositoryTypeGithub, Private: true, ExpectedScopes: 
[]string{"*devops.CicdScope"}},
+       }
+
+       for _, d := range data {
+
+               t.Run(d.Name, func(t *testing.T) {
+                       id := strings.ToLower(d.Name)
+                       id = strings.ReplaceAll(id, " ", "-")
+                       actualScopes, err := makeScopeV200(
+                               connectionID,
+                               
[]*srvhelper.ScopeDetail[models.AzuredevopsRepo, models.AzuredevopsScopeConfig]{
+                                       {
+                                               Scope: models.AzuredevopsRepo{
+                                                       Scope: common.Scope{
+                                                               ConnectionId: 
connectionID,
+                                                       },
+                                                       Id:        id,
+                                                       Type:      d.Type,
+                                                       Name:      d.Name,
+                                                       IsPrivate: d.Private,
+                                               },
+                                               ScopeConfig: 
&models.AzuredevopsScopeConfig{
+                                                       ScopeConfig: 
common.ScopeConfig{
+                                                               Entities: 
[]string{plugin.DOMAIN_TYPE_CODE, plugin.DOMAIN_TYPE_TICKET,
+                                                                       
plugin.DOMAIN_TYPE_CICD, plugin.DOMAIN_TYPE_CODE_REVIEW},
+                                                       },
+                                               },
+                                       },
+                               },
+                       )
+                       assert.Nil(t, err)
+                       var count int
+
+                       for _, s := range actualScopes {
+                               xType := reflect.TypeOf(s)
+                               assert.Contains(t, d.ExpectedScopes, 
xType.String())
+                               count++
+                       }
+                       assert.Equal(t, count, len(d.ExpectedScopes))
+               })
+
+       }
+}
diff --git a/backend/plugins/azuredevops_go/api/connection_api.go 
b/backend/plugins/azuredevops_go/api/connection_api.go
index b096890d9..d29cac85b 100644
--- a/backend/plugins/azuredevops_go/api/connection_api.go
+++ b/backend/plugins/azuredevops_go/api/connection_api.go
@@ -21,6 +21,7 @@ import (
        "github.com/apache/incubator-devlake/core/errors"
        "github.com/apache/incubator-devlake/core/plugin"
        "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+       
"github.com/apache/incubator-devlake/plugins/azuredevops_go/api/azuredevops"
        "github.com/apache/incubator-devlake/plugins/azuredevops_go/models"
        "github.com/apache/incubator-devlake/server/api/shared"
        "net/http"
@@ -51,9 +52,9 @@ func TestConnection(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput,
                },
                AzuredevopsConn: conn,
        }
-       vsc := newVsClient(&connection, "https://app.vssps.visualstudio.com/";)
+       vsc := azuredevops.NewClient(&connection, nil, 
"https://app.vssps.visualstudio.com/";)
 
-       _, err := vsc.UserProfile()
+       _, err := vsc.GetUserProfile()
        if err != nil {
                return nil, err
        }
@@ -79,8 +80,8 @@ func TestExistingConnection(input *plugin.ApiResourceInput) 
(*plugin.ApiResource
                return nil, errors.BadInput.Wrap(err, "can't read connection 
from database")
        }
 
-       vsc := newVsClient(connection, "https://app.vssps.visualstudio.com/";)
-       _, err = vsc.UserProfile()
+       vsc := azuredevops.NewClient(connection, nil, 
"https://app.vssps.visualstudio.com/";)
+       _, err = vsc.GetUserProfile()
        if err != nil {
                return nil, err
        }
diff --git a/backend/plugins/azuredevops_go/api/remote_data.go 
b/backend/plugins/azuredevops_go/api/remote_data.go
deleted file mode 100644
index 8596d1c84..000000000
--- a/backend/plugins/azuredevops_go/api/remote_data.go
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
-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.
-*/
-
-package api
-
-import (
-       "github.com/apache/incubator-devlake/plugins/azuredevops_go/models"
-       "time"
-)
-
-type AzuredevopsRemotePagination struct {
-       Skip int
-       Top  int
-}
-
-type AzuredevopsApiRepo struct {
-       Id      string `json:"id"`
-       Name    string `json:"name"`
-       Url     string `json:"url"`
-       Project struct {
-               Id             string    `json:"id"`
-               Name           string    `json:"name"`
-               Description    string    `json:"description"`
-               Url            string    `json:"url"`
-               State          string    `json:"state"`
-               Revision       int       `json:"revision"`
-               Visibility     string    `json:"visibility"`
-               LastUpdateTime time.Time `json:"lastUpdateTime"`
-       } `json:"project"`
-       DefaultBranch   string `json:"defaultBranch"`
-       Size            int    `json:"size"`
-       RemoteUrl       string `json:"remoteUrl"`
-       SshUrl          string `json:"sshUrl"`
-       WebUrl          string `json:"webUrl"`
-       IsDisabled      bool   `json:"isDisabled"`
-       IsInMaintenance bool   `json:"isInMaintenance"`
-       IsFork          bool   `json:"isFork"`
-}
-
-func (r AzuredevopsApiRepo) toRepoModel() models.AzuredevopsRepo {
-       return models.AzuredevopsRepo{
-               Id:   r.Id,
-               Name: r.Name,
-               Url:  r.WebUrl,
-               AzureDevOpsPK: models.AzureDevOpsPK{
-                       ProjectId: r.Project.Id,
-               },
-               RemoteUrl: r.RemoteUrl,
-               IsFork:    r.IsFork,
-       }
-}
diff --git a/backend/plugins/azuredevops_go/api/remote_helper.go 
b/backend/plugins/azuredevops_go/api/remote_helper.go
index 9ec2c31f5..300a3fe81 100644
--- a/backend/plugins/azuredevops_go/api/remote_helper.go
+++ b/backend/plugins/azuredevops_go/api/remote_helper.go
@@ -18,21 +18,35 @@ limitations under the License.
 package api
 
 import (
+       "context"
        "fmt"
        "github.com/apache/incubator-devlake/core/errors"
        "github.com/apache/incubator-devlake/core/plugin"
        "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
        dsmodels 
"github.com/apache/incubator-devlake/helpers/pluginhelper/api/models"
+       
"github.com/apache/incubator-devlake/plugins/azuredevops_go/api/azuredevops"
        "github.com/apache/incubator-devlake/plugins/azuredevops_go/models"
-       "net/url"
+       "golang.org/x/exp/slices"
+       "golang.org/x/sync/errgroup"
+       "strconv"
        "strings"
+       "sync"
 )
 
 const (
-       itemsPerPage = 100
-       idSeparator  = "/"
+       idSeparator    = "/"
+       maxConcurrency = 10
 )
 
+type AzuredevopsRemotePagination struct {
+       Skip int
+       Top  int
+}
+
+// 
https://learn.microsoft.com/en-us/azure/devops/pipelines/repos/?view=azure-devops
+// 
https://learn.microsoft.com/en-us/azure/devops/pipelines/repos/multi-repo-checkout?view=azure-devops
+var supportedSourceRepositories = []string{"github", "githubenterprise", 
"bitbucket", "git"}
+
 func listAzuredevopsRemoteScopes(
        connection *models.AzuredevopsConnection,
        apiClient plugin.ApiClient,
@@ -43,102 +57,189 @@ func listAzuredevopsRemoteScopes(
        nextPage *AzuredevopsRemotePagination,
        err errors.Error,
 ) {
-       if page.Top == 0 {
-               page.Top = itemsPerPage
+
+       vsc := azuredevops.NewClient(connection, apiClient, 
"https://app.vssps.visualstudio.com";)
+
+       if groupId == "" {
+               return listAzuredevopsProjects(vsc, page)
        }
 
-       if groupId != "" {
-               id := strings.Split(groupId, idSeparator)
-               return listAzuredevopsRepos(apiClient, id[0], id[1])
+       id := strings.Split(groupId, idSeparator)
+
+       if remote, err := listRemoteRepos(vsc, id[0], id[1]); err == nil {
+               children = append(children, remote...)
+       }
+
+       if remote, err := listAzuredevopsRepos(vsc, id[0], id[1]); err == nil {
+               children = append(children, remote...)
        }
-       return listAzuredevopsProjects(connection, apiClient, page)
+       return children, nextPage, nil
 }
 
-func listAzuredevopsProjects(
-       connection *models.AzuredevopsConnection,
-       apiClient plugin.ApiClient,
-       page AzuredevopsRemotePagination,
-) (
+func listAzuredevopsProjects(vsc azuredevops.Client, _ 
AzuredevopsRemotePagination) (
        children []dsmodels.DsRemoteApiScopeListEntry[models.AzuredevopsRepo],
        nextPage *AzuredevopsRemotePagination,
        err errors.Error) {
 
-       query := url.Values{}
-       query.Set("$top", fmt.Sprint(page.Top))
-       query.Set("$skip", fmt.Sprint(page.Skip))
-       query.Set("api-version", "7.1")
-
-       vsc := newVsClient(connection, "https://app.vssps.visualstudio.com";)
-
-       profile, err := vsc.UserProfile()
+       profile, err := vsc.GetUserProfile()
        if err != nil {
                return nil, nil, err
        }
-       accounts, err := vsc.UserAccounts(profile.Id)
+       accounts, err := vsc.GetUserAccounts(profile.Id)
        if err != nil {
                return nil, nil, err
        }
 
-       var data struct {
-               Projects 
[]dsmodels.DsRemoteApiScopeListEntry[models.AzuredevopsProject] `json:"value"`
-       }
+       g, _ := errgroup.WithContext(context.Background())
+       g.SetLimit(maxConcurrency)
+
+       var mu sync.Mutex
 
        for _, v := range accounts {
-               res, err := apiClient.Get(fmt.Sprintf("%s/_apis/projects", 
v.AccountName), query, nil)
-               if err != nil {
-                       return nil, nil, err
-               }
-               err = api.UnmarshalResponse(res, &data)
-               if err != nil {
+               accountName := v.AccountName
+               g.Go(func() error {
+                       args := azuredevops.GetProjectsArgs{
+                               OrgId: accountName,
+                       }
+                       projects, err := vsc.GetProjects(args)
                        if err != nil {
-                               return nil, nil, err
+                               return err
                        }
-               }
 
-               for _, vv := range data.Projects {
-                       children = append(children, 
dsmodels.DsRemoteApiScopeListEntry[models.AzuredevopsRepo]{
-                               Id:   v.AccountName + idSeparator + vv.Name,
-                               Type: api.RAS_ENTRY_TYPE_GROUP,
-                               Name: vv.Name,
-                       })
-               }
+                       var tmp 
[]dsmodels.DsRemoteApiScopeListEntry[models.AzuredevopsRepo]
+                       for _, vv := range projects {
+                               tmp = append(tmp, 
dsmodels.DsRemoteApiScopeListEntry[models.AzuredevopsRepo]{
+                                       Id:   accountName + idSeparator + 
vv.Name,
+                                       Type: api.RAS_ENTRY_TYPE_GROUP,
+                                       Name: vv.Name,
+                               })
+                       }
+                       mu.Lock()
+                       children = append(children, tmp...)
+                       mu.Unlock()
+                       return nil
+               })
        }
 
-       if len(data.Projects) >= itemsPerPage {
-               nextPage = &AzuredevopsRemotePagination{
-                       Top:  itemsPerPage,
-                       Skip: page.Skip + itemsPerPage,
-               }
-       }
+       err = errors.Convert(g.Wait())
        return
 }
 
 func listAzuredevopsRepos(
-       apiClient plugin.ApiClient,
+       vsc azuredevops.Client,
        orgId, projectId string,
 ) (
        children []dsmodels.DsRemoteApiScopeListEntry[models.AzuredevopsRepo],
-       nextPage *AzuredevopsRemotePagination,
        err errors.Error) {
 
-       query := url.Values{}
-       query.Set("api-version", "7.1")
-
-       var data struct {
-               Repos []AzuredevopsApiRepo `json:"value"`
+       args := azuredevops.GetRepositoriesArgs{
+               OrgId:     orgId,
+               ProjectId: projectId,
        }
 
-       res, err := apiClient.Get(fmt.Sprintf("%s/%s/_apis/git/repositories", 
orgId, projectId), query, nil)
+       repos, err := vsc.GetRepositories(args)
        if err != nil {
-               return nil, nil, err
+               return nil, err
+       }
+
+       for _, v := range repos {
+               if v.IsDisabled {
+                       continue
+               }
+
+               pID := orgId + idSeparator + projectId
+               repo := models.AzuredevopsRepo{
+                       Id:        v.Id,
+                       Type:      models.RepositoryTypeADO,
+                       Name:      v.Name,
+                       Url:       v.Url,
+                       RemoteUrl: v.RemoteUrl,
+                       IsFork:    false,
+               }
+               repo.ProjectId = projectId
+               repo.OrganizationId = orgId
+               children = append(children, 
dsmodels.DsRemoteApiScopeListEntry[models.AzuredevopsRepo]{
+                       Type:     api.RAS_ENTRY_TYPE_SCOPE,
+                       ParentId: &pID,
+                       Id:       v.Id,
+                       Name:     v.Name,
+                       FullName: v.Name,
+                       Data:     &repo,
+               })
+       }
+       return
+}
+
+func listRemoteRepos(
+       vsc azuredevops.Client,
+       orgId, projectId string,
+) (
+       children []dsmodels.DsRemoteApiScopeListEntry[models.AzuredevopsRepo],
+       err errors.Error) {
+
+       args := azuredevops.GetServiceEndpointsArgs{
+               OrgId:     orgId,
+               ProjectId: projectId,
        }
-       err = api.UnmarshalResponse(res, &data)
+
+       endpoints, err := vsc.GetServiceEndpoints(args)
        if err != nil {
-               return nil, nil, err
+               return nil, err
        }
-       for _, v := range data.Repos {
+
+       var mu sync.Mutex
+       var remoteRepos []azuredevops.RemoteRepository
+
+       g, _ := errgroup.WithContext(context.Background())
+       g.SetLimit(maxConcurrency)
+
+       for _, v := range endpoints {
+               if !slices.Contains(supportedSourceRepositories, v.Type) {
+                       continue
+               }
+
+               remoteRepoArgs := azuredevops.GetRemoteRepositoriesArgs{
+                       ProjectId:       projectId,
+                       OrgId:           orgId,
+                       Provider:        v.Type,
+                       ServiceEndpoint: v.Id,
+               }
+
+               g.Go(func() error {
+                       repos, err := vsc.GetRemoteRepositories(remoteRepoArgs)
+                       mu.Lock()
+                       remoteRepos = append(remoteRepos, repos...)
+                       mu.Unlock()
+                       return err
+               })
+       }
+
+       if err := g.Wait(); err != nil {
+               return nil, errors.Internal.Wrap(err, "failed to call 
'GetRemoteRepositories', falling back to empty list")
+       }
+
+       for _, v := range remoteRepos {
                pID := orgId + idSeparator + projectId
-               repo := v.toRepoModel()
+               isFork, _ := strconv.ParseBool(v.Properties.IsFork)
+               isPrivate, _ := strconv.ParseBool(v.Properties.IsPrivate)
+
+               // IDs must not contain URL reserved characters (e.g., "/"), as 
this breaks the routing in the scope API.
+               // Accessing 
/plugins/azuredevops_go/connections/<id>/apache/incubator-devlake results in a 
404 error, where
+               // "apache/incubator-devlake" is the repository ID returned by 
ADOs sourceProviders API.
+               // Therefore, we are creating our own ID, by combining the 
Service Connection and the External ID
+               remoteId := fmt.Sprintf("%s-%s", 
v.Properties.ConnectedServiceId, v.Properties.ExternalId)
+
+               repo := models.AzuredevopsRepo{
+                       Id:         remoteId,
+                       Type:       v.SourceProviderName,
+                       Name:       v.SourceProviderName + idSeparator + 
v.FullName,
+                       Url:        v.Properties.ManageUrl,
+                       RemoteUrl:  v.Properties.CloneUrl,
+                       ExternalId: v.Id,
+                       IsFork:     isFork,
+                       IsPrivate:  isPrivate,
+               }
+
                repo.ProjectId = projectId
                repo.OrganizationId = orgId
                children = append(children, 
dsmodels.DsRemoteApiScopeListEntry[models.AzuredevopsRepo]{
@@ -146,7 +247,7 @@ func listAzuredevopsRepos(
                        ParentId: &pID,
                        Id:       v.Id,
                        Name:     v.Name,
-                       FullName: v.Name,
+                       FullName: v.FullName,
                        Data:     &repo,
                })
        }
diff --git a/backend/plugins/azuredevops_go/api/vs_client.go 
b/backend/plugins/azuredevops_go/api/vs_client.go
deleted file mode 100644
index 961e761d2..000000000
--- a/backend/plugins/azuredevops_go/api/vs_client.go
+++ /dev/null
@@ -1,137 +0,0 @@
-/*
-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.
-*/
-
-package api
-
-import (
-       "encoding/json"
-       "fmt"
-       "github.com/apache/incubator-devlake/core/errors"
-       "github.com/apache/incubator-devlake/plugins/azuredevops_go/models"
-       "io"
-       "net/http"
-       "net/url"
-       "time"
-)
-
-type Profile struct {
-       DisplayName  string    `json:"displayName"`
-       PublicAlias  string    `json:"publicAlias"`
-       EmailAddress string    `json:"emailAddress"`
-       CoreRevision int       `json:"coreRevision"`
-       TimeStamp    time.Time `json:"timeStamp"`
-       Id           string    `json:"id"`
-       Revision     int       `json:"revision"`
-}
-
-type Account struct {
-       AccountId        string      `json:"AccountId"`
-       NamespaceId      string      `json:"NamespaceId"`
-       AccountName      string      `json:"AccountName"`
-       OrganizationName interface{} `json:"OrganizationName"`
-       AccountType      int         `json:"AccountType"`
-       AccountOwner     string      `json:"AccountOwner"`
-       CreatedBy        string      `json:"CreatedBy"`
-       CreatedDate      string      `json:"CreatedDate"`
-       AccountStatus    int         `json:"AccountStatus"`
-       StatusReason     interface{} `json:"StatusReason"`
-       LastUpdatedBy    string      `json:"LastUpdatedBy"`
-       Properties       struct {
-       } `json:"Properties"`
-}
-
-type AccountResponse []Account
-
-type vsClient struct {
-       c          http.Client
-       connection *models.AzuredevopsConnection
-       url        string
-}
-
-func newVsClient(con *models.AzuredevopsConnection, url string) vsClient {
-       return vsClient{
-               c: http.Client{
-                       Timeout: 2 * time.Second,
-               },
-               connection: con,
-               url:        url,
-       }
-}
-
-func (vsc *vsClient) UserProfile() (Profile, errors.Error) {
-       var p Profile
-       endpoint, err := url.JoinPath(vsc.url, "/_apis/profile/profiles/me")
-       if err != nil {
-               return Profile{}, errors.Internal.Wrap(err, "failed to join 
user profile path")
-       }
-
-       res, err := vsc.doGet(endpoint)
-       if err != nil {
-               return Profile{}, errors.Internal.Wrap(err, "failed to read 
user accounts")
-       }
-
-       if res.StatusCode == 302 || res.StatusCode == 401 {
-               return Profile{}, errors.Unauthorized.New("failed to read user 
profile")
-       }
-
-       defer res.Body.Close()
-       resBody, err := io.ReadAll(res.Body)
-       if err != nil {
-               return Profile{}, errors.Internal.Wrap(err, "failed to read 
response body")
-       }
-
-       if err := json.Unmarshal(resBody, &p); err != nil {
-               panic(err)
-       }
-       return p, nil
-}
-
-func (vsc *vsClient) UserAccounts(memberId string) (AccountResponse, 
errors.Error) {
-       var a AccountResponse
-       endpoint := fmt.Sprintf(vsc.url+"/_apis/accounts?memberId=%s", memberId)
-       res, err := vsc.doGet(endpoint)
-       if err != nil {
-               return nil, errors.Internal.Wrap(err, "failed to read user 
accounts")
-       }
-
-       if res.StatusCode == 302 || res.StatusCode == 401 {
-               return nil, errors.Unauthorized.New("failed to read user 
accounts")
-       }
-
-       defer res.Body.Close()
-       resBody, err := io.ReadAll(res.Body)
-       if err != nil {
-               return nil, errors.Internal.Wrap(err, "failed to read response 
body")
-       }
-
-       if err := json.Unmarshal(resBody, &a); err != nil {
-               return nil, errors.Internal.Wrap(err, "failed to read unmarshal 
response body")
-       }
-       return a, nil
-}
-
-func (vsc *vsClient) doGet(url string) (*http.Response, error) {
-       req, err := http.NewRequest(http.MethodGet, url, nil)
-       if err != nil {
-               return nil, err
-       }
-
-       if err = 
vsc.connection.GetAccessTokenAuthenticator().SetupAuthentication(req); err != 
nil {
-               return nil, errors.Internal.Wrap(err, "failed to authorize the 
request using the plugin connection")
-       }
-       return http.DefaultClient.Do(req)
-}
diff --git a/backend/plugins/azuredevops_go/e2e/build_test.go 
b/backend/plugins/azuredevops_go/e2e/build_test.go
index 61a776a0e..a27eebd2b 100644
--- a/backend/plugins/azuredevops_go/e2e/build_test.go
+++ b/backend/plugins/azuredevops_go/e2e/build_test.go
@@ -44,6 +44,7 @@ func TestAzuredevopsBuildDataFlow(t *testing.T) {
                        ProjectId:      "test-project",
                        OrganizationId: "johndoe",
                        RepositoryId:   "0d50ba13-f9ad-49b0-9b21-d29eda50ca33",
+                       RepositoryType: models.RepositoryTypeADO,
                        ScopeConfig:    new(models.AzuredevopsScopeConfig),
                },
                RegexEnricher: regexEnricher,
diff --git a/backend/plugins/azuredevops_go/models/base.go 
b/backend/plugins/azuredevops_go/models/base.go
index 9c60b81a1..e30369a7d 100644
--- a/backend/plugins/azuredevops_go/models/base.go
+++ b/backend/plugins/azuredevops_go/models/base.go
@@ -21,3 +21,8 @@ type AzureDevOpsPK struct {
        OrganizationId string
        ProjectId      string
 }
+
+const (
+       RepositoryTypeADO    = "TfsGit"
+       RepositoryTypeGithub = "github"
+)
diff --git 
a/backend/plugins/azuredevops_go/models/migrationscripts/20240413_add_remote_repo_support.go
 
b/backend/plugins/azuredevops_go/models/migrationscripts/20240413_add_remote_repo_support.go
new file mode 100644
index 000000000..d9c514697
--- /dev/null
+++ 
b/backend/plugins/azuredevops_go/models/migrationscripts/20240413_add_remote_repo_support.go
@@ -0,0 +1,68 @@
+/*
+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.
+*/
+
+package migrationscripts
+
+import (
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/dal"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/helpers/migrationhelper"
+)
+
+type extendRepoTable struct{}
+
+type ExtendAzuredevopsRepo struct {
+       ConnectionId uint64 `gorm:"primaryKey"`
+       Id           string `gorm:"primaryKey;column:id"`
+
+       Type       string `gorm:"type:varchar(100)"`
+       IsPrivate  bool
+       ExternalId string `gorm:"type:varchar(255)"`
+}
+
+func (ExtendAzuredevopsRepo) TableName() string {
+       return "_tool_azuredevops_go_repos"
+}
+
+func (*extendRepoTable) Up(baseRes context.BasicRes) errors.Error {
+       err := migrationhelper.AutoMigrateTables(baseRes, 
&ExtendAzuredevopsRepo{})
+       if err != nil {
+               return err
+       }
+
+       err = baseRes.GetDal().UpdateColumn(
+               &ExtendAzuredevopsRepo{}, "type", "TfsGit",
+               dal.Where("type IS NULL"))
+
+       if err != nil {
+               return err
+       }
+
+       return baseRes.GetDal().UpdateColumn(
+               &ExtendAzuredevopsRepo{}, "is_private", false,
+               dal.Where("is_private IS NULL"))
+
+}
+
+func (*extendRepoTable) Version() uint64 {
+       return 20240413100000
+}
+
+func (*extendRepoTable) Name() string {
+       return "add [type, is_private, external_id] to 
_tool_azuredevops_go_repos in order to support remote repositories"
+}
diff --git a/backend/plugins/azuredevops_go/models/migrationscripts/register.go 
b/backend/plugins/azuredevops_go/models/migrationscripts/register.go
index ec054748c..59fa7832f 100644
--- a/backend/plugins/azuredevops_go/models/migrationscripts/register.go
+++ b/backend/plugins/azuredevops_go/models/migrationscripts/register.go
@@ -25,5 +25,6 @@ import (
 func All() []plugin.MigrationScript {
        return []plugin.MigrationScript{
                new(addInitTables),
+               new(extendRepoTable),
        }
 }
diff --git a/backend/plugins/azuredevops_go/models/repo.go 
b/backend/plugins/azuredevops_go/models/repo.go
index e3f799a91..dd171996f 100644
--- a/backend/plugins/azuredevops_go/models/repo.go
+++ b/backend/plugins/azuredevops_go/models/repo.go
@@ -28,11 +28,14 @@ type AzuredevopsRepo struct {
        common.Scope  `mapstructure:",squash"`
        AzureDevOpsPK `mapstructure:",squash"`
 
-       Id        string `json:"id" validate:"required" mapstructure:"id" 
gorm:"primaryKey"`
-       Name      string `json:"name" mapstructure:"name,omitempty"`
-       Url       string `json:"url" mapstructure:"url,omitempty"`
-       RemoteUrl string `json:"remoteUrl"`
-       IsFork    bool
+       Id         string `json:"id" validate:"required" mapstructure:"id" 
gorm:"primaryKey"`
+       Type       string `json:"type" validate:"required" mapstructure:"type"`
+       Name       string `json:"name" mapstructure:"name,omitempty"`
+       Url        string `json:"url" mapstructure:"url,omitempty"`
+       RemoteUrl  string `json:"remoteUrl"`
+       ExternalId string
+       IsFork     bool
+       IsPrivate  bool
 }
 
 func (repo AzuredevopsRepo) ScopeId() string {
diff --git a/backend/plugins/azuredevops_go/tasks/ci_cd_build_collector.go 
b/backend/plugins/azuredevops_go/tasks/ci_cd_build_collector.go
index f70a12076..257c5fac0 100644
--- a/backend/plugins/azuredevops_go/tasks/ci_cd_build_collector.go
+++ b/backend/plugins/azuredevops_go/tasks/ci_cd_build_collector.go
@@ -19,6 +19,7 @@ package tasks
 
 import (
        "encoding/json"
+       "github.com/apache/incubator-devlake/plugins/azuredevops_go/models"
        "net/url"
        "strconv"
        "time"
@@ -46,6 +47,15 @@ var CollectBuildsMeta = plugin.SubTaskMeta{
 func CollectBuilds(taskCtx plugin.SubTaskContext) errors.Error {
        rawDataSubTaskArgs, data := CreateRawDataSubTaskArgs(taskCtx, 
RawBuildTable)
        repoId := data.Options.RepositoryId
+       if data.Options.RepositoryType != models.RepositoryTypeADO {
+               repoId = data.Options.ExternalId
+       }
+       repoType := data.Options.RepositoryType
+       if repoType == "" {
+               repoType = models.RepositoryTypeADO
+               taskCtx.GetLogger().Warn(nil, "repository type for repoId: %v 
not found. falling back to TfsGit", repoId)
+       }
+
        collector, err := 
api.NewStatefulApiCollectorForFinalizableEntity(api.FinalizableApiCollectorArgs{
                RawDataSubTaskArgs: *rawDataSubTaskArgs,
                ApiClient:          data.ApiClient,
@@ -56,7 +66,7 @@ func CollectBuilds(taskCtx plugin.SubTaskContext) 
errors.Error {
                                UrlTemplate: "{{ .Params.OrganizationId }}/{{ 
.Params.ProjectId }}/_apis/build/builds?api-version=7.1",
                                Query: func(reqData *api.RequestData, 
createdAfter *time.Time) (url.Values, errors.Error) {
                                        query := url.Values{}
-                                       query.Set("repositoryType", "tfsgit")
+                                       query.Set("repositoryType", repoType)
                                        query.Set("repositoryId", repoId)
                                        query.Set("$top", 
strconv.Itoa(reqData.Pager.Size))
                                        query.Set("queryOrder", 
"queueTimeDescending")
diff --git a/backend/plugins/azuredevops_go/tasks/ci_cd_build_converter.go 
b/backend/plugins/azuredevops_go/tasks/ci_cd_build_converter.go
index 505984ca7..a3d6c240b 100644
--- a/backend/plugins/azuredevops_go/tasks/ci_cd_build_converter.go
+++ b/backend/plugins/azuredevops_go/tasks/ci_cd_build_converter.go
@@ -38,7 +38,7 @@ var ConvertBuildsMeta = plugin.SubTaskMeta{
        EntryPoint:       ConvertBuilds,
        EnabledByDefault: true,
        Description:      "Convert tool layer table azuredevops_builds into  
domain layer table cicd_pipelines",
-       DomainTypes:      []string{plugin.DOMAIN_TYPE_CODE_REVIEW},
+       DomainTypes:      []string{plugin.DOMAIN_TYPE_CICD},
        DependencyTables: []string{
                models.AzuredevopsBuild{}.TableName(),
        },
diff --git a/backend/plugins/azuredevops_go/tasks/task_data.go 
b/backend/plugins/azuredevops_go/tasks/task_data.go
index 1e5baa62a..2ac479156 100644
--- a/backend/plugins/azuredevops_go/tasks/task_data.go
+++ b/backend/plugins/azuredevops_go/tasks/task_data.go
@@ -31,6 +31,8 @@ type AzuredevopsOptions struct {
        ProjectId      string `json:"projectId" 
mapstructure:"projectId,omitempty"`
        OrganizationId string `json:"organizationId" 
mapstructure:"organizationId,omitempty"`
        RepositoryId   string `json:"repositoryId"  
mapstructure:"repositoryId,omitempty"`
+       RepositoryType string `json:"repositoryType"  
mapstructure:"repositoryType,omitempty"`
+       ExternalId     string `json:"externalId"  
mapstructure:"externalId,omitempty"`
 
        ScopeConfigId uint64                         `json:"scopeConfigId" 
mapstructure:"scopeConfigId,omitempty"`
        TimeAfter     string                         `json:"timeAfter" 
mapstructure:"timeAfter,omitempty"`
@@ -62,7 +64,7 @@ type AzuredevopsParams struct {
 func (p *AzuredevopsOptions) GetParams() any {
        return AzuredevopsParams{
                OrganizationId: p.OrganizationId,
-               RepositoryId:   p.RepositoryId,
                ProjectId:      p.ProjectId,
+               RepositoryId:   p.RepositoryId,
        }
 }
diff --git a/backend/test/e2e/manual/azuredevops/models.go 
b/backend/test/e2e/manual/azuredevops/models.go
index 3ea19ffb3..e9b83dac9 100644
--- a/backend/test/e2e/manual/azuredevops/models.go
+++ b/backend/test/e2e/manual/azuredevops/models.go
@@ -46,6 +46,7 @@ type (
        AzureGitRepo struct {
                RawDataParams       string `json:"_raw_data_params"`
                Id                  string
+               Type                string
                Name                string
                ConnectionId        uint64
                Url                 string
diff --git a/config-ui/src/routes/pipeline/components/task.tsx 
b/config-ui/src/routes/pipeline/components/task.tsx
index 3f7212042..d4a012a11 100644
--- a/config-ui/src/routes/pipeline/components/task.tsx
+++ b/config-ui/src/routes/pipeline/components/task.tsx
@@ -86,6 +86,9 @@ export const PipelineTask = ({ task }: Props) => {
       case ['bamboo'].includes(config.plugin):
         name = `${name}:${options.planKey}`;
         break;
+      case ['azuredevops_go'].includes(config.plugin):
+        name = `ado:${options.name}`;
+        break;
     }
 
     return [config.icon, name];

Reply via email to