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/devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 5b3a51f77 feat(azuredevops): add Azure DevOps Server (On-Premises) 
support (#9014) (#9014)
5b3a51f77 is described below

commit 5b3a51f77177383d4624531a768f04598b44fb18
Author: Tuğrulhan Karslı <[email protected]>
AuthorDate: Sun Aug 16 10:00:10 2026 +0300

    feat(azuredevops): add Azure DevOps Server (On-Premises) support (#9014) 
(#9014)
---
 .../azuredevops_go/api/azuredevops/client.go       |  48 +++++--
 .../api/azuredevops/client_onprem_test.go          | 107 +++++++++++++++
 .../plugins/azuredevops_go/api/blueprint_v200.go   |   6 +-
 .../plugins/azuredevops_go/api/connection_api.go   |  49 +++++--
 .../plugins/azuredevops_go/api/remote_helper.go    |  46 +++++--
 .../plugins/azuredevops_go/models/connection.go    |  38 +++++-
 .../azuredevops_go/models/connection_test.go       | 144 +++++++++++++++++++++
 ....go => 20260722_add_endpoint_to_azuredevops.go} |  32 +++--
 ....go => 20260726_add_username_to_azuredevops.go} |  32 +++--
 .../models/migrationscripts/archived/connection.go |   1 +
 .../models/migrationscripts/register.go            |   2 +
 .../azuredevops_go/tasks/account_collector.go      |   5 +
 .../azuredevops_go/tasks/ci_cd_build_collector.go  |   2 +-
 .../tasks/ci_cd_timeline_records_collector.go      |   2 +-
 .../azuredevops_go/tasks/commit_collector.go       |   2 +-
 .../plugins/azuredevops_go/tasks/pr_collector.go   |   2 +-
 .../azuredevops_go/tasks/pr_commit_collector.go    |   2 +-
 .../plugins/gitextractor/parser/clone_gitcli.go    |  39 ++++--
 .../components/data-scope-remote/search-local.tsx  |   2 +-
 .../components/data-scope-remote/search-remote.tsx |   4 +-
 .../plugins/components/data-scope-select/index.tsx |   6 +-
 config-ui/src/plugins/register/azure/config.tsx    |  20 ++-
 .../register/azure/connection-fields/base-url.tsx  |  47 +++++--
 .../azure/connection-fields/organization.tsx       |   8 +-
 24 files changed, 544 insertions(+), 102 deletions(-)

diff --git a/backend/plugins/azuredevops_go/api/azuredevops/client.go 
b/backend/plugins/azuredevops_go/api/azuredevops/client.go
index bfe4beb6d..207967f43 100644
--- a/backend/plugins/azuredevops_go/api/azuredevops/client.go
+++ b/backend/plugins/azuredevops_go/api/azuredevops/client.go
@@ -20,15 +20,17 @@ 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"
+       "strings"
        "time"
+
+       "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"
 )
 
 const apiVersion = "7.1"
@@ -45,7 +47,7 @@ type Client struct {
 func NewClient(con *models.AzuredevopsConnection, apiClient plugin.ApiClient, 
url string) Client {
        return Client{
                c: http.Client{
-                       Timeout: 2 * time.Second,
+                       Timeout: 10 * time.Second,
                },
                connection: con,
                url:        url,
@@ -54,18 +56,27 @@ func NewClient(con *models.AzuredevopsConnection, apiClient 
plugin.ApiClient, ur
 }
 
 func (c *Client) GetUserProfile() (Profile, errors.Error) {
+       // On-Premises Azure DevOps Server does not have the global VSSPS 
profile API.
+       // Return a placeholder profile to avoid blocking the connection test 
flow.
+       if c.connection != nil && c.connection.Endpoint != "" {
+               return Profile{
+                       DisplayName: "On-Premises User",
+               }, nil
+       }
+
        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")
+       baseUrl := strings.TrimRight(c.url, "/")
+       if baseUrl == "" {
+               baseUrl = "https://app.vssps.visualstudio.com";
        }
+       endpoint := 
fmt.Sprintf("%s/_apis/profile/profiles/me?api-version=7.1-preview.1", baseUrl)
 
        res, err := c.doGet(endpoint)
        if err != nil {
                return Profile{}, errors.Internal.Wrap(err, "failed to read 
user accounts")
        }
 
-       if res.StatusCode == 203 || res.StatusCode == 401 {
+       if res.StatusCode == 203 || res.StatusCode == 302 || res.StatusCode == 
401 {
                return Profile{}, errors.Unauthorized.New("failed to read user 
profile")
        }
 
@@ -76,14 +87,19 @@ func (c *Client) GetUserProfile() (Profile, errors.Error) {
        }
 
        if err := json.Unmarshal(resBody, &p); err != nil {
-               panic(err)
+               return Profile{}, errors.Internal.Wrap(err, "failed to 
unmarshal user profile")
        }
        return p, nil
 }
 
 func (c *Client) GetUserAccounts(memberId string) (AccountResponse, 
errors.Error) {
+       // On-Premises installations do not have the global accounts API, 
return empty list.
+       if c.connection != nil && c.connection.Endpoint != "" {
+               return AccountResponse{}, nil
+       }
+
        var a AccountResponse
-       endpoint := fmt.Sprintf(c.url+"/_apis/accounts?memberId=%s", memberId)
+       endpoint := 
fmt.Sprintf("%s/_apis/accounts?memberId=%s&api-version=7.1-preview.1", 
strings.TrimRight(c.url, "/"), memberId)
        res, err := c.doGet(endpoint)
        if err != nil {
                return nil, errors.Internal.Wrap(err, "failed to read user 
accounts")
@@ -114,7 +130,7 @@ func (c *Client) doGet(url string) (*http.Response, error) {
        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)
+       return c.c.Do(req)
 }
 
 type GetProjectsArgs struct {
@@ -147,7 +163,10 @@ func (c *Client) GetProjects(args GetProjectsArgs) 
([]Project, errors.Error) {
                query.Set("$top", strconv.Itoa(top))
                query.Set("$skip", strconv.Itoa(skip))
 
-               path := fmt.Sprintf("%s/_apis/projects", args.OrgId)
+               path := "_apis/projects"
+               if args.OrgId != "" {
+                       path = fmt.Sprintf("%s/_apis/projects", args.OrgId)
+               }
                res, err := c.apiClient.Get(path, query, nil)
                if err != nil {
                        return nil, err
@@ -190,6 +209,9 @@ func (c *Client) GetRepositories(args GetRepositoriesArgs) 
([]Repository, errors
        }
 
        path := fmt.Sprintf("%s/%s/_apis/git/repositories", args.OrgId, 
args.ProjectId)
+       if args.OrgId == "" {
+               path = fmt.Sprintf("%s/_apis/git/repositories", args.ProjectId)
+       }
        res, err := c.apiClient.Get(path, query, nil)
        if err != nil {
                return nil, err
diff --git 
a/backend/plugins/azuredevops_go/api/azuredevops/client_onprem_test.go 
b/backend/plugins/azuredevops_go/api/azuredevops/client_onprem_test.go
new file mode 100644
index 000000000..b2b766809
--- /dev/null
+++ b/backend/plugins/azuredevops_go/api/azuredevops/client_onprem_test.go
@@ -0,0 +1,107 @@
+/*
+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 (
+       "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+       "github.com/apache/incubator-devlake/plugins/azuredevops_go/models"
+       "testing"
+)
+
+func TestGetUserProfile_OnPremises(t *testing.T) {
+       conn := &models.AzuredevopsConnection{
+               BaseConnection: api.BaseConnection{},
+               AzuredevopsConn: models.AzuredevopsConn{
+                       AzuredevopsAccessToken: models.AzuredevopsAccessToken{
+                               Token: "test-token",
+                       },
+                       Endpoint: 
"https://tfs.company.local/DefaultCollection/";,
+               },
+       }
+
+       client := NewClient(conn, nil, 
"https://tfs.company.local/DefaultCollection/";)
+       profile, err := client.GetUserProfile()
+       if err != nil {
+               t.Fatalf("GetUserProfile() returned unexpected error for 
On-Premises: %v", err)
+       }
+       if profile.DisplayName != "On-Premises User" {
+               t.Errorf("GetUserProfile() DisplayName = %q; want %q", 
profile.DisplayName, "On-Premises User")
+       }
+}
+
+func TestGetUserAccounts_OnPremises(t *testing.T) {
+       conn := &models.AzuredevopsConnection{
+               BaseConnection: api.BaseConnection{},
+               AzuredevopsConn: models.AzuredevopsConn{
+                       AzuredevopsAccessToken: models.AzuredevopsAccessToken{
+                               Token: "test-token",
+                       },
+                       Endpoint: 
"https://tfs.company.local/DefaultCollection/";,
+               },
+       }
+
+       client := NewClient(conn, nil, 
"https://tfs.company.local/DefaultCollection/";)
+       accounts, err := client.GetUserAccounts("test-member-id")
+       if err != nil {
+               t.Fatalf("GetUserAccounts() returned unexpected error for 
On-Premises: %v", err)
+       }
+       if len(accounts) != 0 {
+               t.Errorf("GetUserAccounts() returned %d accounts; want 0 for 
On-Premises", len(accounts))
+       }
+}
+
+func TestGetUserProfile_Cloud(t *testing.T) {
+       // For Cloud connections (no Endpoint set), GetUserProfile should NOT 
return
+       // the placeholder profile. It should attempt to call the VSSPS API.
+       conn := &models.AzuredevopsConnection{
+               BaseConnection: api.BaseConnection{},
+               AzuredevopsConn: models.AzuredevopsConn{
+                       AzuredevopsAccessToken: models.AzuredevopsAccessToken{
+                               Token: "test-token",
+                       },
+                       // Endpoint is empty = Cloud mode
+               },
+       }
+
+       // Without a mock server, this will fail with a connection error,
+       // which is expected - the important thing is it does NOT bypass to 
placeholder
+       client := NewClient(conn, nil, "http://localhost:0";)
+       _, err := client.GetUserProfile()
+       if err == nil {
+               t.Error("GetUserProfile() for Cloud should attempt real API 
call and fail without a server")
+       }
+}
+
+func TestGetUserAccounts_Cloud(t *testing.T) {
+       conn := &models.AzuredevopsConnection{
+               BaseConnection: api.BaseConnection{},
+               AzuredevopsConn: models.AzuredevopsConn{
+                       AzuredevopsAccessToken: models.AzuredevopsAccessToken{
+                               Token: "test-token",
+                       },
+                       // Endpoint is empty = Cloud mode
+               },
+       }
+
+       // Without a mock server, this will fail with a connection error
+       client := NewClient(conn, nil, "http://localhost:0";)
+       _, err := client.GetUserAccounts("test-member-id")
+       if err == nil {
+               t.Error("GetUserAccounts() for Cloud should attempt real API 
call and fail without a server")
+       }
+}
diff --git a/backend/plugins/azuredevops_go/api/blueprint_v200.go 
b/backend/plugins/azuredevops_go/api/blueprint_v200.go
index 018d9d1d7..3cd4acb71 100644
--- a/backend/plugins/azuredevops_go/api/blueprint_v200.go
+++ b/backend/plugins/azuredevops_go/api/blueprint_v200.go
@@ -180,7 +180,11 @@ func makePipelinePlanV200(
                        }
 
                        if scope.Scope.Type == models.RepositoryTypeADO {
-                               cloneUrl.User = url.UserPassword("git", 
connection.Token)
+                               username := connection.Username
+                               if username == "" {
+                                       username = "git"
+                               }
+                               cloneUrl.User = url.UserPassword(username, 
connection.Token)
                        }
                        stage = append(stage, &coreModels.PipelineTask{
                                Plugin: "gitextractor",
diff --git a/backend/plugins/azuredevops_go/api/connection_api.go 
b/backend/plugins/azuredevops_go/api/connection_api.go
index 7da66cd1f..811235034 100644
--- a/backend/plugins/azuredevops_go/api/connection_api.go
+++ b/backend/plugins/azuredevops_go/api/connection_api.go
@@ -19,13 +19,15 @@ package api
 
 import (
        "context"
+       "fmt"
+       "net/http"
+
        "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"
 )
 
 type AzuredevopsTestConnResponse struct {
@@ -55,7 +57,7 @@ func TestConnection(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput,
        }
        body, err := testConnection(context.TODO(), connection)
        if err != nil {
-               return nil, plugin.WrapTestConnectionErrResp(basicRes, err)
+               return nil, errors.BadInput.Wrap(err, err.Error())
        }
        return &plugin.ApiResourceOutput{Body: body, Status: http.StatusOK}, nil
 }
@@ -76,7 +78,7 @@ func TestExistingConnection(input *plugin.ApiResourceInput) 
(*plugin.ApiResource
 
        body, err := testConnection(context.TODO(), *connection)
        if err != nil {
-               return nil, plugin.WrapTestConnectionErrResp(basicRes, err)
+               return nil, errors.BadInput.Wrap(err, err.Error())
        }
        return &plugin.ApiResourceOutput{Body: body, Status: http.StatusOK}, nil
 }
@@ -155,20 +157,39 @@ func testConnection(ctx context.Context, connection 
models.AzuredevopsConnection
        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 {
+       if connection.Endpoint != "" {
+               // On-Premises: Test connection by directly fetching projects 
from On-Premises server endpoint
+               vsc := azuredevops.NewClient(&connection, apiClient, 
connection.Endpoint)
                args := azuredevops.GetProjectsArgs{
-                       OrgId: org,
+                       OrgId: connection.Organization,
                }
                _, err = vsc.GetProjects(args)
-       }
-       if err != nil {
-               return nil, err
+               if err != nil {
+                       // On-Premises Azure DevOps Server HTTP endpoints may 
use Windows NTLM Auth (which Git CLI succeeds with).
+                       // If GetProjects returns 401/Unauthorized, log a 
warning but allow connection test to succeed.
+                       basicRes.GetLogger().Warn(err, "GetProjects returned 
error for On-Premises connection, proceeding with connection setup")
+               }
+       } else {
+               // Cloud: Azure DevOps Cloud test logic
+               org := connection.Organization
+               if org != "" {
+                       // Organization is specified: use dev.azure.com 
directly (works with org-scoped PATs too)
+                       vsc := azuredevops.NewClient(&connection, apiClient, 
"https://dev.azure.com/";)
+                       args := azuredevops.GetProjectsArgs{
+                               OrgId: org,
+                       }
+                       _, err = vsc.GetProjects(args)
+                       if err != nil {
+                               return nil, errors.BadInput.Wrap(err, 
fmt.Sprintf("Failed to fetch projects for Azure DevOps Cloud Organization '%s'. 
Please check your Organization name and PAT token permissions.", org))
+                       }
+               } else {
+                       // No organization specified: try VSSPS global profile 
(requires 'All accessible organizations' PAT)
+                       vsc := azuredevops.NewClient(&connection, apiClient, 
"https://app.vssps.visualstudio.com/";)
+                       _, profileErr := vsc.GetUserProfile()
+                       if profileErr != nil {
+                               return nil, errors.BadInput.New("Azure DevOps 
Cloud authentication failed. If your PAT token was created for a specific 
Organization, please enter your Organization name in the form. Otherwise ensure 
your PAT was created with 'All accessible organizations'.")
+                       }
+               }
        }
 
        connection = connection.Sanitize()
diff --git a/backend/plugins/azuredevops_go/api/remote_helper.go 
b/backend/plugins/azuredevops_go/api/remote_helper.go
index 00110daf6..ba3fa875d 100644
--- a/backend/plugins/azuredevops_go/api/remote_helper.go
+++ b/backend/plugins/azuredevops_go/api/remote_helper.go
@@ -59,31 +59,48 @@ func listAzuredevopsRemoteScopes(
 ) {
 
        org := connection.Organization
-       vsc := azuredevops.NewClient(connection, apiClient, 
"https://app.vssps.visualstudio.com";)
+       // Use the connection endpoint for On-Premises, or VSSPS for Cloud
+       clientUrl := "https://app.vssps.visualstudio.com";
+       if connection.Endpoint != "" {
+               clientUrl = connection.GetEndpoint()
+       }
+       vsc := azuredevops.NewClient(connection, apiClient, clientUrl)
 
        if groupId == "" {
-               return listAzuredevopsProjects(vsc, page, org)
+               return listAzuredevopsProjects(connection, vsc, page, org)
        }
 
        id := strings.Split(groupId, idSeparator)
-
-       if remote, err := listRemoteRepos(vsc, id[0], id[1]); err == nil {
-               children = append(children, remote...)
+       orgId := id[0]
+       projectId := ""
+       if len(id) > 1 {
+               projectId = id[1]
+       } else if connection.Endpoint != "" {
+               orgId = ""
+               projectId = id[0]
        }
 
-       if remote, err := listAzuredevopsRepos(vsc, id[0], id[1]); err == nil {
-               children = append(children, remote...)
+       if projectId != "" {
+               if remote, err := listRemoteRepos(vsc, orgId, projectId); err 
== nil {
+                       children = append(children, remote...)
+               }
+
+               if remote, err := listAzuredevopsRepos(vsc, orgId, projectId); 
err == nil {
+                       children = append(children, remote...)
+               }
        }
        return children, nextPage, nil
 }
 
-func listAzuredevopsProjects(vsc azuredevops.Client, _ 
AzuredevopsRemotePagination, org string) (
+func listAzuredevopsProjects(connection *models.AzuredevopsConnection, vsc 
azuredevops.Client, _ AzuredevopsRemotePagination, org string) (
        children []dsmodels.DsRemoteApiScopeListEntry[models.AzuredevopsRepo],
        nextPage *AzuredevopsRemotePagination,
        err errors.Error) {
 
        var accounts azuredevops.AccountResponse
-       if org == "" {
+       if connection.Endpoint != "" {
+               accounts = append(accounts, azuredevops.Account{AccountName: 
org})
+       } else if org == "" {
                profile, err := vsc.GetUserProfile()
                if err != nil {
                        return nil, nil, err
@@ -114,8 +131,12 @@ func listAzuredevopsProjects(vsc azuredevops.Client, _ 
AzuredevopsRemotePaginati
 
                        var tmp 
[]dsmodels.DsRemoteApiScopeListEntry[models.AzuredevopsRepo]
                        for _, vv := range projects {
+                               groupId := vv.Name
+                               if accountName != "" {
+                                       groupId = accountName + idSeparator + 
vv.Name
+                               }
                                tmp = append(tmp, 
dsmodels.DsRemoteApiScopeListEntry[models.AzuredevopsRepo]{
-                                       Id:   accountName + idSeparator + 
vv.Name,
+                                       Id:   groupId,
                                        Type: api.RAS_ENTRY_TYPE_GROUP,
                                        Name: vv.Name,
                                })
@@ -153,7 +174,10 @@ func listAzuredevopsRepos(
                        continue
                }
 
-               pID := orgId + idSeparator + projectId
+               pID := projectId
+               if orgId != "" {
+                       pID = orgId + idSeparator + projectId
+               }
                repo := models.AzuredevopsRepo{
                        Id:        v.Id,
                        Type:      models.RepositoryTypeADO,
diff --git a/backend/plugins/azuredevops_go/models/connection.go 
b/backend/plugins/azuredevops_go/models/connection.go
index 3bb9f4a96..a2af72be2 100644
--- a/backend/plugins/azuredevops_go/models/connection.go
+++ b/backend/plugins/azuredevops_go/models/connection.go
@@ -25,6 +25,7 @@ import (
        "github.com/apache/incubator-devlake/core/utils"
        "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
        "net/http"
+       "strings"
 )
 
 var _ plugin.ApiConnection = (*AzuredevopsConn)(nil)
@@ -50,16 +51,39 @@ func (at *AzuredevopsAccessToken) SetupAuthentication(req 
*http.Request) errors.
 
 // AzuredevopsConn holds the essential information to connect to the Azure 
DevOps API
 type AzuredevopsConn struct {
-       //api.RestConnection `mapstructure:",squash"`
        AzuredevopsAccessToken `mapstructure:",squash"`
        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"`
+       Username               string `mapstructure:"username" json:"username"`
+       Proxy                  string `mapstructure:"proxy" json:"proxy"`
+
+       // Endpoint is the base URL for On-Premises Azure DevOps Server 
(optional, empty for Cloud)
+       Endpoint string `mapstructure:"endpoint" json:"endpoint" 
validate:"omitempty,url"`
+}
+
+func (conn *AzuredevopsConn) GetAccessTokenAuthenticator() 
plugin.ApiAuthenticator {
+       return conn
+}
+
+func (conn *AzuredevopsConn) SetupAuthentication(req *http.Request) 
errors.Error {
+       username := conn.Username
+       if conn.Endpoint != "" {
+               // On-Premises Azure DevOps REST API uses PAT token auth with 
empty username over HTTP Basic Auth
+               username = ""
+       }
+       req.SetBasicAuth(username, conn.Token)
+       return nil
 }
 
 func (conn *AzuredevopsConn) GetEndpoint() string {
-       return "https://dev.azure.com";
+       // Returns the On-Premises endpoint if configured, otherwise defaults 
to Azure DevOps Cloud URL
+       if conn.Endpoint != "" {
+               ep := conn.Endpoint
+               if !strings.HasSuffix(ep, "/") {
+                       ep += "/"
+               }
+               return ep
+       }
+       return "https://dev.azure.com/";
 }
 
 func (conn *AzuredevopsConn) GetProxy() string {
@@ -79,9 +103,9 @@ type AzuredevopsConnection struct {
 }
 
 func (c AzuredevopsConnection) GetEndpoint() string {
-       return "https://dev.azure.com";
+       // Delegates to the embedded AzuredevopsConn.GetEndpoint()
+       return c.AzuredevopsConn.GetEndpoint()
 }
-
 func (c AzuredevopsConnection) GetProxy() string {
        return c.Proxy
 }
diff --git a/backend/plugins/azuredevops_go/models/connection_test.go 
b/backend/plugins/azuredevops_go/models/connection_test.go
new file mode 100644
index 000000000..10f265d11
--- /dev/null
+++ b/backend/plugins/azuredevops_go/models/connection_test.go
@@ -0,0 +1,144 @@
+/*
+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 models
+
+import (
+       "net/http"
+       "testing"
+)
+
+func TestGetEndpoint_CloudDefault(t *testing.T) {
+       conn := &AzuredevopsConn{}
+       endpoint := conn.GetEndpoint()
+       if endpoint != "https://dev.azure.com/"; {
+               t.Errorf("GetEndpoint() = %q; want %q", endpoint, 
"https://dev.azure.com/";)
+       }
+}
+
+func TestGetEndpoint_OnPremises(t *testing.T) {
+       conn := &AzuredevopsConn{
+               Endpoint: "https://tfs.company.local/DefaultCollection/";,
+       }
+       endpoint := conn.GetEndpoint()
+       if endpoint != "https://tfs.company.local/DefaultCollection/"; {
+               t.Errorf("GetEndpoint() = %q; want %q", endpoint, 
"https://tfs.company.local/DefaultCollection/";)
+       }
+}
+
+func TestGetEndpoint_TrailingSlash(t *testing.T) {
+       conn := &AzuredevopsConn{
+               Endpoint: "https://tfs.company.local/DefaultCollection";,
+       }
+       endpoint := conn.GetEndpoint()
+       expected := "https://tfs.company.local/DefaultCollection/";
+       if endpoint != expected {
+               t.Errorf("GetEndpoint() = %q; want %q (trailing slash should be 
appended)", endpoint, expected)
+       }
+}
+
+func TestGetEndpoint_ConnectionWrapper(t *testing.T) {
+       conn := AzuredevopsConnection{
+               AzuredevopsConn: AzuredevopsConn{
+                       Endpoint: 
"https://tfs.company.local/DefaultCollection/";,
+               },
+       }
+       endpoint := conn.GetEndpoint()
+       if endpoint != "https://tfs.company.local/DefaultCollection/"; {
+               t.Errorf("AzuredevopsConnection.GetEndpoint() = %q; want %q", 
endpoint, "https://tfs.company.local/DefaultCollection/";)
+       }
+}
+
+func TestGetEndpoint_ConnectionWrapperCloud(t *testing.T) {
+       conn := AzuredevopsConnection{}
+       endpoint := conn.GetEndpoint()
+       if endpoint != "https://dev.azure.com/"; {
+               t.Errorf("AzuredevopsConnection.GetEndpoint() = %q; want %q", 
endpoint, "https://dev.azure.com/";)
+       }
+}
+
+func TestSetupAuthentication_Cloud(t *testing.T) {
+       conn := &AzuredevopsConn{
+               AzuredevopsAccessToken: AzuredevopsAccessToken{
+                       Token: "test-pat-token",
+               },
+               Username: "testuser",
+       }
+
+       req, _ := http.NewRequest("GET", 
"https://dev.azure.com/org/_apis/projects";, nil)
+       err := conn.SetupAuthentication(req)
+       if err != nil {
+               t.Fatalf("SetupAuthentication() returned error: %v", err)
+       }
+
+       user, pass, ok := req.BasicAuth()
+       if !ok {
+               t.Fatal("SetupAuthentication() did not set Basic Auth header")
+       }
+       if user != "testuser" {
+               t.Errorf("Basic Auth username = %q; want %q", user, "testuser")
+       }
+       if pass != "test-pat-token" {
+               t.Errorf("Basic Auth password = %q; want %q", pass, 
"test-pat-token")
+       }
+}
+
+func TestSetupAuthentication_OnPremises(t *testing.T) {
+       conn := &AzuredevopsConn{
+               AzuredevopsAccessToken: AzuredevopsAccessToken{
+                       Token: "on-prem-pat-token",
+               },
+               Username: "domain\\admin",
+               Endpoint: "https://tfs.company.local/DefaultCollection/";,
+       }
+
+       req, _ := http.NewRequest("GET", 
"https://tfs.company.local/DefaultCollection/_apis/projects";, nil)
+       err := conn.SetupAuthentication(req)
+       if err != nil {
+               t.Fatalf("SetupAuthentication() returned error: %v", err)
+       }
+
+       user, pass, ok := req.BasicAuth()
+       if !ok {
+               t.Fatal("SetupAuthentication() did not set Basic Auth header")
+       }
+       // On-Premises: username should be empty (PAT auth with empty username)
+       if user != "" {
+               t.Errorf("On-Premises Basic Auth username = %q; want empty 
string", user)
+       }
+       if pass != "on-prem-pat-token" {
+               t.Errorf("On-Premises Basic Auth password = %q; want %q", pass, 
"on-prem-pat-token")
+       }
+}
+
+func TestSanitize(t *testing.T) {
+       conn := &AzuredevopsConn{
+               AzuredevopsAccessToken: AzuredevopsAccessToken{
+                       Token: "secret-token",
+               },
+               Username: "testuser",
+               Endpoint: "https://tfs.company.local/";,
+       }
+
+       sanitized := conn.Sanitize()
+       if sanitized.Endpoint != "https://tfs.company.local/"; {
+               t.Errorf("Sanitize() should preserve Endpoint, got %q", 
sanitized.Endpoint)
+       }
+       if sanitized.Username != "testuser" {
+               t.Errorf("Sanitize() should preserve Username, got %q", 
sanitized.Username)
+       }
+}
diff --git 
a/backend/plugins/azuredevops_go/models/migrationscripts/archived/connection.go 
b/backend/plugins/azuredevops_go/models/migrationscripts/20260722_add_endpoint_to_azuredevops.go
similarity index 52%
copy from 
backend/plugins/azuredevops_go/models/migrationscripts/archived/connection.go
copy to 
backend/plugins/azuredevops_go/models/migrationscripts/20260722_add_endpoint_to_azuredevops.go
index 18e9a6266..ae71e0676 100644
--- 
a/backend/plugins/azuredevops_go/models/migrationscripts/archived/connection.go
+++ 
b/backend/plugins/azuredevops_go/models/migrationscripts/20260722_add_endpoint_to_azuredevops.go
@@ -15,21 +15,35 @@ See the License for the specific language governing 
permissions and
 limitations under the License.
 */
 
-package archived
+package migrationscripts
 
 import (
-       
"github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/helpers/migrationhelper"
 )
 
-type AzuredevopsConnection struct {
-       archived.Model
+type addEndpointToAzuredevops struct{}
 
-       Name         string `gorm:"type:varchar(100);uniqueIndex" json:"name" 
validate:"required"`
-       Token        string `mapstructure:"token" validate:"required" 
encrypt:"yes"`
-       Proxy        string `gorm:"type:varchar(255)"`
-       Organization string `gorm:"type:varchar(255)"`
+type azuredevopsConnection20260722 struct {
+       Endpoint string `gorm:"type:varchar(255)"`
 }
 
-func (AzuredevopsConnection) TableName() string {
+func (azuredevopsConnection20260722) TableName() string {
        return "_tool_azuredevops_go_connections"
 }
+
+func (script *addEndpointToAzuredevops) Up(basicRes context.BasicRes) 
errors.Error {
+       return migrationhelper.AutoMigrateTables(
+               basicRes,
+               &azuredevopsConnection20260722{},
+       )
+}
+
+func (*addEndpointToAzuredevops) Version() uint64 {
+       return 20260722000002
+}
+
+func (*addEndpointToAzuredevops) Name() string {
+       return "add endpoint field to _tool_azuredevops_go_connections"
+}
diff --git 
a/backend/plugins/azuredevops_go/models/migrationscripts/archived/connection.go 
b/backend/plugins/azuredevops_go/models/migrationscripts/20260726_add_username_to_azuredevops.go
similarity index 52%
copy from 
backend/plugins/azuredevops_go/models/migrationscripts/archived/connection.go
copy to 
backend/plugins/azuredevops_go/models/migrationscripts/20260726_add_username_to_azuredevops.go
index 18e9a6266..c35ad48c8 100644
--- 
a/backend/plugins/azuredevops_go/models/migrationscripts/archived/connection.go
+++ 
b/backend/plugins/azuredevops_go/models/migrationscripts/20260726_add_username_to_azuredevops.go
@@ -15,21 +15,35 @@ See the License for the specific language governing 
permissions and
 limitations under the License.
 */
 
-package archived
+package migrationscripts
 
 import (
-       
"github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/helpers/migrationhelper"
 )
 
-type AzuredevopsConnection struct {
-       archived.Model
+type addUsernameToAzuredevops struct{}
 
-       Name         string `gorm:"type:varchar(100);uniqueIndex" json:"name" 
validate:"required"`
-       Token        string `mapstructure:"token" validate:"required" 
encrypt:"yes"`
-       Proxy        string `gorm:"type:varchar(255)"`
-       Organization string `gorm:"type:varchar(255)"`
+type azuredevopsConnection20260726 struct {
+       Username string `gorm:"type:varchar(255)"`
 }
 
-func (AzuredevopsConnection) TableName() string {
+func (azuredevopsConnection20260726) TableName() string {
        return "_tool_azuredevops_go_connections"
 }
+
+func (script *addUsernameToAzuredevops) Up(basicRes context.BasicRes) 
errors.Error {
+       return migrationhelper.AutoMigrateTables(
+               basicRes,
+               &azuredevopsConnection20260726{},
+       )
+}
+
+func (*addUsernameToAzuredevops) Version() uint64 {
+       return 20260726000001
+}
+
+func (*addUsernameToAzuredevops) Name() string {
+       return "add username field to _tool_azuredevops_go_connections"
+}
diff --git 
a/backend/plugins/azuredevops_go/models/migrationscripts/archived/connection.go 
b/backend/plugins/azuredevops_go/models/migrationscripts/archived/connection.go
index 18e9a6266..9bbeb0a45 100644
--- 
a/backend/plugins/azuredevops_go/models/migrationscripts/archived/connection.go
+++ 
b/backend/plugins/azuredevops_go/models/migrationscripts/archived/connection.go
@@ -28,6 +28,7 @@ type AzuredevopsConnection struct {
        Token        string `mapstructure:"token" validate:"required" 
encrypt:"yes"`
        Proxy        string `gorm:"type:varchar(255)"`
        Organization string `gorm:"type:varchar(255)"`
+       Endpoint     string `gorm:"type:varchar(255)"`
 }
 
 func (AzuredevopsConnection) TableName() string {
diff --git a/backend/plugins/azuredevops_go/models/migrationscripts/register.go 
b/backend/plugins/azuredevops_go/models/migrationscripts/register.go
index 59fa7832f..cd08322ae 100644
--- a/backend/plugins/azuredevops_go/models/migrationscripts/register.go
+++ b/backend/plugins/azuredevops_go/models/migrationscripts/register.go
@@ -26,5 +26,7 @@ func All() []plugin.MigrationScript {
        return []plugin.MigrationScript{
                new(addInitTables),
                new(extendRepoTable),
+               new(addEndpointToAzuredevops),
+               new(addUsernameToAzuredevops),
        }
 }
diff --git a/backend/plugins/azuredevops_go/tasks/account_collector.go 
b/backend/plugins/azuredevops_go/tasks/account_collector.go
index 08942679a..f326bde7a 100644
--- a/backend/plugins/azuredevops_go/tasks/account_collector.go
+++ b/backend/plugins/azuredevops_go/tasks/account_collector.go
@@ -43,6 +43,11 @@ func CollectAccounts(taskCtx plugin.SubTaskContext) 
errors.Error {
        rawDataSubTaskArgs, data := CreateRawDataSubTaskArgs(taskCtx, 
rawUserTable)
        logger := taskCtx.GetLogger()
 
+       if data.Options.OrganizationId == "" {
+               logger.Info("OrganizationId is empty or On-Premises mode, 
skipping Cloud Graph user collection")
+               return nil
+       }
+
        collector, err := api.NewApiCollector(api.ApiCollectorArgs{
                RawDataSubTaskArgs: *rawDataSubTaskArgs,
                ApiClient:          data.ApiClient,
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 257c5fac0..72e9f141b 100644
--- a/backend/plugins/azuredevops_go/tasks/ci_cd_build_collector.go
+++ b/backend/plugins/azuredevops_go/tasks/ci_cd_build_collector.go
@@ -63,7 +63,7 @@ func CollectBuilds(taskCtx plugin.SubTaskContext) 
errors.Error {
                        GetNextPageCustomData: ExtractContToken,
                        PageSize:              100,
                        FinalizableApiCollectorCommonArgs: 
api.FinalizableApiCollectorCommonArgs{
-                               UrlTemplate: "{{ .Params.OrganizationId }}/{{ 
.Params.ProjectId }}/_apis/build/builds?api-version=7.1",
+                               UrlTemplate: "{{ if .Params.OrganizationId }}{{ 
.Params.OrganizationId }}/{{ end }}{{ .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", repoType)
diff --git 
a/backend/plugins/azuredevops_go/tasks/ci_cd_timeline_records_collector.go 
b/backend/plugins/azuredevops_go/tasks/ci_cd_timeline_records_collector.go
index 9ac766917..36643d645 100644
--- a/backend/plugins/azuredevops_go/tasks/ci_cd_timeline_records_collector.go
+++ b/backend/plugins/azuredevops_go/tasks/ci_cd_timeline_records_collector.go
@@ -65,7 +65,7 @@ func CollectRecords(taskCtx plugin.SubTaskContext) 
errors.Error {
                ApiClient:          data.ApiClient,
                Input:              iterator,
                Incremental:        false,
-               UrlTemplate:        "{{ .Params.OrganizationId }}/{{ 
.Params.ProjectId }}/_apis/build/builds/{{ .Input.AzuredevopsId 
}}/Timeline?api-version=7.1",
+               UrlTemplate:        "{{ if .Params.OrganizationId }}{{ 
.Params.OrganizationId }}/{{ end }}{{ .Params.ProjectId 
}}/_apis/build/builds/{{ .Input.AzuredevopsId }}/Timeline?api-version=7.1",
                Query:              BuildPaginator(true),
                ResponseParser:     ParseRawMessageFromRecords,
                AfterResponse:      ignoreInvalidTimelineResponse, // Skip 
builds with missing/malformed timelines (e.g. YAML syntax errors)
diff --git a/backend/plugins/azuredevops_go/tasks/commit_collector.go 
b/backend/plugins/azuredevops_go/tasks/commit_collector.go
index fe27de1ec..64a0af9d5 100644
--- a/backend/plugins/azuredevops_go/tasks/commit_collector.go
+++ b/backend/plugins/azuredevops_go/tasks/commit_collector.go
@@ -46,7 +46,7 @@ func CollectApiCommits(taskCtx plugin.SubTaskContext) 
errors.Error {
                ApiClient:          data.ApiClient,
                PageSize:           100,
                Incremental:        false,
-               UrlTemplate:        "{{ .Params.OrganizationId }}/{{ 
.Params.ProjectId }}/_apis/git/repositories/{{ .Params.RepositoryId 
}}/commits?api-version=7.1",
+               UrlTemplate:        "{{ if .Params.OrganizationId }}{{ 
.Params.OrganizationId }}/{{ end }}{{ .Params.ProjectId 
}}/_apis/git/repositories/{{ .Params.RepositoryId }}/commits?api-version=7.1",
                Query:              BuildPaginator(false),
                ResponseParser:     ParseRawMessageFromValue,
                AfterResponse:      change203To401,
diff --git a/backend/plugins/azuredevops_go/tasks/pr_collector.go 
b/backend/plugins/azuredevops_go/tasks/pr_collector.go
index 30a63dba7..e9a306f81 100644
--- a/backend/plugins/azuredevops_go/tasks/pr_collector.go
+++ b/backend/plugins/azuredevops_go/tasks/pr_collector.go
@@ -61,7 +61,7 @@ func CollectApiPullRequests(taskCtx plugin.SubTaskContext) 
errors.Error {
                RawDataSubTaskArgs: *rawDataSubTaskArgs,
                ApiClient:          data.ApiClient,
                PageSize:           100,
-               UrlTemplate:        "{{ .Params.OrganizationId }}/{{ 
.Params.ProjectId }}/_apis/git/repositories/{{ .Params.RepositoryId 
}}/pullrequests?api-version=7.1",
+               UrlTemplate:        "{{ if .Params.OrganizationId }}{{ 
.Params.OrganizationId }}/{{ end }}{{ .Params.ProjectId 
}}/_apis/git/repositories/{{ .Params.RepositoryId 
}}/pullrequests?api-version=7.1",
                Query: func(reqData *api.RequestData) (url.Values, 
errors.Error) {
                        query := url.Values{}
                        query.Set("searchCriteria.status", "all")
diff --git a/backend/plugins/azuredevops_go/tasks/pr_commit_collector.go 
b/backend/plugins/azuredevops_go/tasks/pr_commit_collector.go
index dbbddd67b..a7a7c00dc 100644
--- a/backend/plugins/azuredevops_go/tasks/pr_commit_collector.go
+++ b/backend/plugins/azuredevops_go/tasks/pr_commit_collector.go
@@ -71,7 +71,7 @@ func CollectApiPullRequestCommits(taskCtx 
plugin.SubTaskContext) errors.Error {
                PageSize:              100,
                Input:                 iterator,
                Incremental:           false,
-               UrlTemplate:           "{{ .Params.OrganizationId }}/{{ 
.Params.ProjectId }}/_apis/git/repositories/{{ .Params.RepositoryId 
}}/pullRequests/{{ .Input.AzuredevopsId }}/commits?api-version=7.1",
+               UrlTemplate:           "{{ if .Params.OrganizationId }}{{ 
.Params.OrganizationId }}/{{ end }}{{ .Params.ProjectId 
}}/_apis/git/repositories/{{ .Params.RepositoryId }}/pullRequests/{{ 
.Input.AzuredevopsId }}/commits?api-version=7.1",
                Query:                 BuildPaginator(true),
                ResponseParser:        ParseRawMessageFromValue,
                GetNextPageCustomData: ExtractContToken,
diff --git a/backend/plugins/gitextractor/parser/clone_gitcli.go 
b/backend/plugins/gitextractor/parser/clone_gitcli.go
index 61931b521..9d0ac2ed2 100644
--- a/backend/plugins/gitextractor/parser/clone_gitcli.go
+++ b/backend/plugins/gitextractor/parser/clone_gitcli.go
@@ -18,6 +18,7 @@ limitations under the License.
 package parser
 
 import (
+       "encoding/base64"
        "fmt"
        "net/url"
        "os"
@@ -46,16 +47,17 @@ type CloneRepoConfig struct {
 }
 
 type GitcliCloner struct {
-       ctx          plugin.SubTaskContext
-       taskData     *GitExtractorTaskData
-       logger       log.Logger
-       stateManager *api.SubtaskStateManager
-       since        *time.Time
-       remoteUrl    string
-       localDir     string
-       success      bool
-       syncEnvs     []string
-       syncArgs     []string
+       ctx              plugin.SubTaskContext
+       taskData         *GitExtractorTaskData
+       logger           log.Logger
+       stateManager     *api.SubtaskStateManager
+       since            *time.Time
+       remoteUrl        string
+       localDir         string
+       success          bool
+       syncEnvs         []string
+       syncArgs         []string
+       globalConfigArgs []string
 }
 
 func NewGitcliCloner(ctx plugin.SubTaskContext, localDir string) 
(*GitcliCloner, errors.Error) {
@@ -93,8 +95,17 @@ func (g *GitcliCloner) prepareSync() errors.Error {
        if e != nil {
                return errors.Convert(e)
        }
-       // support proxy
+       // support proxy and http auth
        if remoteUrl.Scheme == "http" || remoteUrl.Scheme == "https" {
+               g.globalConfigArgs = append(g.globalConfigArgs, "-c", 
"credential.helper=")
+               if remoteUrl.User != nil {
+                       password, ok := remoteUrl.User.Password()
+                       if ok && password != "" {
+                               username := remoteUrl.User.Username()
+                               auth := 
base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, 
password)))
+                               g.globalConfigArgs = append(g.globalConfigArgs, 
"-c", fmt.Sprintf("http.extraHeader=Authorization: Basic %s", auth))
+                       }
+               }
                if taskData.Options.Proxy != "" {
                        g.syncEnvs = append(g.syncEnvs, 
fmt.Sprintf("HTTPS_PROXY=%s", taskData.Options.Proxy))
                }
@@ -308,8 +319,10 @@ func (g *GitcliCloner) gitCmd(gitcmd string, args 
...string) errors.Error {
 
 func (g *GitcliCloner) git(env []string, dir string, gitcmd string, args 
...string) errors.Error {
        g.logger.Debug("git %s %v", gitcmd, sanitizeArgs(args)) // CWE-532: 
sanitize before logging
-       args = append([]string{gitcmd}, args...)
-       cmd := exec.CommandContext(g.ctx.GetContext(), "git", args...)
+       cmdArgs := append([]string{}, g.globalConfigArgs...)
+       cmdArgs = append(cmdArgs, gitcmd)
+       cmdArgs = append(cmdArgs, args...)
+       cmd := exec.CommandContext(g.ctx.GetContext(), "git", cmdArgs...)
        cmd.Env = env
        cmd.Dir = dir
        return g.execCommand(cmd)
diff --git 
a/config-ui/src/plugins/components/data-scope-remote/search-local.tsx 
b/config-ui/src/plugins/components/data-scope-remote/search-local.tsx
index a84fbc494..a1e382bf0 100644
--- a/config-ui/src/plugins/components/data-scope-remote/search-local.tsx
+++ b/config-ui/src/plugins/components/data-scope-remote/search-local.tsx
@@ -102,7 +102,7 @@ export const SearchLocal = ({ mode, plugin, connectionId, 
config, disabledScope,
         pageToken: currentPageToken,
       });
 
-      newItems = (res.children ?? []).map((it) => ({
+      newItems = (res?.children ?? []).map((it) => ({
         ...it,
         title: it.name,
       }));
diff --git 
a/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx 
b/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx
index 5f61d7410..2a23a66d3 100644
--- a/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx
+++ b/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx
@@ -96,7 +96,7 @@ export const SearchRemote = ({ mode, plugin, connectionId, 
config, disabledScope
         pageToken: currentPageToken,
       });
 
-      newItems = (res.children ?? []).map((it) => ({
+      newItems = (res?.children ?? []).map((it) => ({
         ...it,
         title: getPluginScopeName(plugin, it) || it.name,
       }));
@@ -140,7 +140,7 @@ export const SearchRemote = ({ mode, plugin, connectionId, 
config, disabledScope
         pageSize: PAGE_SIZE,
       });
 
-      const newItems = (res.children ?? []).map((it) => ({
+      const newItems = (res?.children ?? []).map((it) => ({
         ...it,
         title: getPluginScopeName(plugin, it) || it.fullName || it.name,
       }));
diff --git a/config-ui/src/plugins/components/data-scope-select/index.tsx 
b/config-ui/src/plugins/components/data-scope-select/index.tsx
index 860cce6cd..2401c37b6 100644
--- a/config-ui/src/plugins/components/data-scope-select/index.tsx
+++ b/config-ui/src/plugins/components/data-scope-select/index.tsx
@@ -67,7 +67,7 @@ export const DataScopeSelect = ({
     const res = await API.scope.list(plugin, connectionId, { page, pageSize });
     setItems((items) => [
       ...items,
-      ...res.scopes.map((sc) => ({
+      ...(res.scopes ?? []).map((sc) => ({
         parentId: null,
         id: getPluginScopeId(plugin, sc.scope),
         title: getPluginScopeName(plugin, sc.scope) || sc.scope.fullName || 
sc.scope.name,
@@ -92,10 +92,10 @@ export const DataScopeSelect = ({
 
   const searchOptions = useMemo(
     () =>
-      data?.scopes.map((sc) => ({
+      (data?.scopes ?? []).map((sc) => ({
         label: getPluginScopeName(plugin, sc.scope) || sc.scope.fullName || 
sc.scope.name,
         value: getPluginScopeId(plugin, sc.scope),
-      })) ?? [],
+      })),
     [data],
   );
 
diff --git a/config-ui/src/plugins/register/azure/config.tsx 
b/config-ui/src/plugins/register/azure/config.tsx
index a3f574d61..978a6af09 100644
--- a/config-ui/src/plugins/register/azure/config.tsx
+++ b/config-ui/src/plugins/register/azure/config.tsx
@@ -33,7 +33,9 @@ export const AzureConfig: IPluginConfig = {
     initialValues: {},
     fields: [
       'name',
-      () => <BaseURL key="base-url" />,
+      ({ values, setValues }: any) => (
+        <BaseURL key="base-url" value={values.endpoint} onChange={(val) => 
setValues({ endpoint: val })} />
+      ),
       {
         key: 'token',
         label: 'Personal Access Token',
@@ -86,10 +88,24 @@ export const AzureGoConfig: IPluginConfig = {
     initialValues: {},
     fields: [
       'name',
-      () => <BaseURL key="base-url" />,
+      ({ values, setValues }: any) => (
+        <BaseURL key="base-url" value={values.endpoint} onChange={(val) => 
setValues({ endpoint: val })} />
+      ),
+      {
+        key: 'username',
+        label: 'Username (Optional for On-Premises)',
+        subLabel:
+          'For On-Premises Azure DevOps Server, enter your Windows/Domain 
username if required (e.g. Administrator or domain\\user). Leave empty for 
Cloud.',
+      },
       {
         key: 'token',
         label: 'Personal Access Token',
+        subLabel: (
+          <span>
+            For Azure DevOps Cloud, use a Personal Access Token (PAT). For 
On-Premises Server, use a PAT or your domain
+            password.
+          </span>
+        ),
       },
       ({ initialValues, values, setValues }: any) => (
         <ConnectionOrganization
diff --git 
a/config-ui/src/plugins/register/azure/connection-fields/base-url.tsx 
b/config-ui/src/plugins/register/azure/connection-fields/base-url.tsx
index f60859f61..28bd28ba6 100644
--- a/config-ui/src/plugins/register/azure/connection-fields/base-url.tsx
+++ b/config-ui/src/plugins/register/azure/connection-fields/base-url.tsx
@@ -16,20 +16,51 @@
  *
  */
 
-import { Radio } from 'antd';
-
+import React, { useState } from 'react';
+import { Input, Radio } from 'antd';
 import { Block } from '@/components';
 
-export const BaseURL = () => {
+interface Props {
+  value?: string;
+  onChange?: (value: string) => void;
+}
+
+export const BaseURL = ({ value, onChange }: Props) => {
+  // Default to 'server' mode if an endpoint value already exists, 'cloud' 
otherwise
+  const [version, setVersion] = useState<'cloud' | 'server'>(value ? 'server' 
: 'cloud');
+
+  const handleVersionChange = (e: any) => {
+    const selectedVersion = e.target.value;
+    setVersion(selectedVersion);
+    if (selectedVersion === 'cloud') {
+      onChange?.(''); // Reset endpoint when switching to Cloud mode
+    }
+  };
+
   return (
     <Block title="Azure DevOps Version" required>
-      <Radio.Group value="cloud" onChange={() => {}}>
+      <Radio.Group value={version} onChange={handleVersionChange}>
         <Radio value="cloud">Azure DevOps Cloud</Radio>
-        <Radio value="server" disabled>
-          Azure DevOps Server (not supported)
-        </Radio>
+        <Radio value="server">Azure DevOps Server (On-Premises)</Radio>
       </Radio.Group>
-      <p style={{ margin: 0 }}>If you are using Azure DevOps Cloud, you do not 
need to enter the endpoint URL.</p>
+
+      {version === 'cloud' ? (
+        <p style={{ margin: '8px 0 0 0', color: 'gray' }}>
+          If you are using Azure DevOps Cloud, you do not need to enter the 
endpoint URL.
+        </p>
+      ) : (
+        <div style={{ marginTop: 12 }}>
+          <p style={{ margin: '0 0 8px 0', fontWeight: 500 }}>Endpoint URL 
*</p>
+          <Input
+            placeholder="e.g., https://your-server/tfs/DefaultCollection/";
+            value={value}
+            onChange={(e) => onChange?.(e.target.value)}
+          />
+          <p style={{ margin: '4px 0 0 0', color: 'gray', fontSize: 12 }}>
+            Enter your full Azure DevOps Server base URL including the 
collection name.
+          </p>
+        </div>
+      )}
     </Block>
   );
 };
diff --git 
a/config-ui/src/plugins/register/azure/connection-fields/organization.tsx 
b/config-ui/src/plugins/register/azure/connection-fields/organization.tsx
index 1e2972ca3..aae10c894 100644
--- a/config-ui/src/plugins/register/azure/connection-fields/organization.tsx
+++ b/config-ui/src/plugins/register/azure/connection-fields/organization.tsx
@@ -23,7 +23,7 @@ import { Block, ExternalLink } from '@/components';
 import { DOC_URL } from '@/release';
 
 interface Props {
-  initialValue: OrganizationSettings;
+  initialValue?: OrganizationSettings | null;
   value: string;
   label?: string;
   setValue: (value: string) => void;
@@ -38,11 +38,11 @@ export const ConnectionOrganization = ({ label, 
initialValue, value, setValue }:
   const [settings, setSettings] = useState<OrganizationSettings>({ scoped: 
false, organization: '' });
 
   useEffect(() => {
-    const org = initialValue.organization || '';
+    const org = initialValue?.organization || '';
     setValue(org);
 
-    setSettings({ organization: initialValue.organization, scoped: org !== '' 
});
-  }, [initialValue.organization]);
+    setSettings({ organization: initialValue?.organization ?? '', scoped: org 
!== '' });
+  }, [initialValue?.organization]);
 
   const handleChange = (e: RadioChangeEvent) => {
     const scoped = e.target.value;

Reply via email to