Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/apache/incubator-devlake

go 1.26
go 1.21

require (
github.com/aws/aws-sdk-go v1.55.6
Expand Down
50 changes: 37 additions & 13 deletions backend/plugins/azuredevops_go/api/azuredevops/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand All @@ -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 kurulumlarında global VSSPS profil API'si bulunmaz.
// Bu durumda isteği bypass edip sahte/geçerli bir profil döndürerek kilitlenmeyi önlüyoruz.
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")
}

Expand All @@ -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 kurulumlarda global accounts API'si olmadığı için boş liste dönüyoruz.
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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -147,7 +163,12 @@ 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 := fmt.Sprintf("%s/_apis/projects", args.OrgId)
// GÜNCELLENMİŞ KOD:
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
Expand Down Expand Up @@ -190,6 +211,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
Expand Down
6 changes: 5 additions & 1 deletion backend/plugins/azuredevops_go/api/blueprint_v200.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 35 additions & 14 deletions backend/plugins/azuredevops_go/api/connection_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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()
Expand Down
39 changes: 29 additions & 10 deletions backend/plugins/azuredevops_go/api/remote_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,28 +62,40 @@ func listAzuredevopsRemoteScopes(
vsc := azuredevops.NewClient(connection, apiClient, "https://app.vssps.visualstudio.com")

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
Expand Down Expand Up @@ -114,8 +126,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,
})
Expand Down Expand Up @@ -153,7 +169,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,
Expand Down
37 changes: 31 additions & 6 deletions backend/plugins/azuredevops_go/models/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -53,13 +54,37 @@ 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 yeni ekledik
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"
// Eğer kullanıcı Endpoint alanını doldurduysa onu dön, boş bıraktıysa varsayılan Cloud URL'ini dön
if conn.Endpoint != "" {
ep := conn.Endpoint
if !strings.HasSuffix(ep, "/") {
ep += "/"
}
return ep
}
return "https://dev.azure.com/"
}

func (conn *AzuredevopsConn) GetProxy() string {
Expand All @@ -79,9 +104,9 @@ type AzuredevopsConnection struct {
}

func (c AzuredevopsConnection) GetEndpoint() string {
return "https://dev.azure.com"
// AzuredevopsConn içindeki GetEndpoint mantığıyla aynı dinamik yapıyı çağır
return c.AzuredevopsConn.GetEndpoint()
}

func (c AzuredevopsConnection) GetProxy() string {
return c.Proxy
}
Expand Down
Loading