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;