Skip to content

Commit

Permalink
feat(appset): add Pull Request Generator for Azure DevOps Repos (#13367)
Browse files Browse the repository at this point in the history
Signed-off-by: Robin Lieb <robin.j.lieb@gmail.com>
Signed-off-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>
Co-authored-by: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com>
  • Loading branch information
robinlieb and crenshaw-dev authored Jun 27, 2023
1 parent 6424cde commit 46d4609
Show file tree
Hide file tree
Showing 15 changed files with 2,094 additions and 710 deletions.
8 changes: 8 additions & 0 deletions applicationset/generators/pull_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,14 @@ func (g *PullRequestGenerator) selectServiceProvider(ctx context.Context, genera
return pullrequest.NewBitbucketCloudServiceNoAuth(providerConfig.API, providerConfig.Owner, providerConfig.Repo)
}
}
if generatorConfig.AzureDevOps != nil {
providerConfig := generatorConfig.AzureDevOps
token, err := g.getSecretRef(ctx, providerConfig.TokenRef, applicationSetInfo.Namespace)
if err != nil {
return nil, fmt.Errorf("error fetching Secret token: %v", err)
}
return pullrequest.NewAzureDevOpsService(ctx, token, providerConfig.API, providerConfig.Organization, providerConfig.Project, providerConfig.Repo, providerConfig.Labels)
}
return nil, fmt.Errorf("no Pull Request provider implementation configured")
}

Expand Down
145 changes: 145 additions & 0 deletions applicationset/services/pull_request/azure_devops.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package pull_request

import (
"context"
"fmt"
"strings"

"github.com/microsoft/azure-devops-go-api/azuredevops"
core "github.com/microsoft/azure-devops-go-api/azuredevops/core"
git "github.com/microsoft/azure-devops-go-api/azuredevops/git"
)

const AZURE_DEVOPS_DEFAULT_URL = "https://dev.azure.com"

type AzureDevOpsClientFactory interface {
// Returns an Azure Devops Client interface.
GetClient(ctx context.Context) (git.Client, error)
}

type devopsFactoryImpl struct {
connection *azuredevops.Connection
}

func (factory *devopsFactoryImpl) GetClient(ctx context.Context) (git.Client, error) {
gitClient, err := git.NewClient(ctx, factory.connection)
if err != nil {
return nil, fmt.Errorf("failed to get new Azure DevOps git client for pull request generator: %w", err)
}
return gitClient, nil
}

type AzureDevOpsService struct {
clientFactory AzureDevOpsClientFactory
project string
repo string
labels []string
}

var _ PullRequestService = (*AzureDevOpsService)(nil)
var _ AzureDevOpsClientFactory = &devopsFactoryImpl{}

func NewAzureDevOpsService(ctx context.Context, token, url, organization, project, repo string, labels []string) (PullRequestService, error) {
organizationUrl := buildURL(url, organization)

var connection *azuredevops.Connection
if token == "" {
connection = azuredevops.NewAnonymousConnection(organizationUrl)
} else {
connection = azuredevops.NewPatConnection(organizationUrl, token)
}

return &AzureDevOpsService{
clientFactory: &devopsFactoryImpl{connection: connection},
project: project,
repo: repo,
labels: labels,
}, nil
}

func (a *AzureDevOpsService) List(ctx context.Context) ([]*PullRequest, error) {
client, err := a.clientFactory.GetClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get Azure DevOps client: %w", err)
}

args := git.GetPullRequestsByProjectArgs{
Project: &a.project,
SearchCriteria: &git.GitPullRequestSearchCriteria{},
}

azurePullRequests, err := client.GetPullRequestsByProject(ctx, args)
if err != nil {
return nil, fmt.Errorf("failed to get pull requests by project: %w", err)
}

pullRequests := []*PullRequest{}

for _, pr := range *azurePullRequests {
if pr.Repository == nil ||
pr.Repository.Name == nil ||
pr.PullRequestId == nil ||
pr.SourceRefName == nil ||
pr.LastMergeSourceCommit == nil ||
pr.LastMergeSourceCommit.CommitId == nil {
continue
}

azureDevOpsLabels := convertLabels(pr.Labels)
if !containAzureDevOpsLabels(a.labels, azureDevOpsLabels) {
continue
}

if *pr.Repository.Name == a.repo {
pullRequests = append(pullRequests, &PullRequest{
Number: *pr.PullRequestId,
Branch: strings.Replace(*pr.SourceRefName, "refs/heads/", "", 1),
HeadSHA: *pr.LastMergeSourceCommit.CommitId,
Labels: azureDevOpsLabels,
})
}
}

return pullRequests, nil
}

// convertLabels converts WebApiTagDefinitions to strings
func convertLabels(tags *[]core.WebApiTagDefinition) []string {
if tags == nil {
return []string{}
}
labelStrings := make([]string, len(*tags))
for i, label := range *tags {
labelStrings[i] = *label.Name
}
return labelStrings
}

// containAzureDevOpsLabels returns true if gotLabels contains expectedLabels
func containAzureDevOpsLabels(expectedLabels []string, gotLabels []string) bool {
for _, expected := range expectedLabels {
found := false
for _, got := range gotLabels {
if expected == got {
found = true
break
}
}
if !found {
return false
}
}
return true
}

func buildURL(url, organization string) string {
if url == "" {
url = AZURE_DEVOPS_DEFAULT_URL
}
separator := ""
if !strings.HasSuffix(url, "/") {
separator = "/"
}
devOpsURL := fmt.Sprintf("%s%s%s", url, separator, organization)
return devOpsURL
}
221 changes: 221 additions & 0 deletions applicationset/services/pull_request/azure_devops_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package pull_request

import (
"context"
"testing"

"github.com/microsoft/azure-devops-go-api/azuredevops/core"
git "github.com/microsoft/azure-devops-go-api/azuredevops/git"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"

azureMock "github.com/argoproj/argo-cd/v2/applicationset/services/scm_provider/azure_devops/git/mocks"
)

func createBoolPtr(x bool) *bool {
return &x
}

func createStringPtr(x string) *string {
return &x
}

func createIntPtr(x int) *int {
return &x
}

func createLabelsPtr(x []core.WebApiTagDefinition) *[]core.WebApiTagDefinition {
return &x
}

type AzureClientFactoryMock struct {
mock *mock.Mock
}

func (m *AzureClientFactoryMock) GetClient(ctx context.Context) (git.Client, error) {
args := m.mock.Called(ctx)

var client git.Client
c := args.Get(0)
if c != nil {
client = c.(git.Client)
}

var err error
if len(args) > 1 {
if e, ok := args.Get(1).(error); ok {
err = e
}
}

return client, err
}

func TestListPullRequest(t *testing.T) {
teamProject := "myorg_project"
repoName := "myorg_project_repo"
pr_id := 123
pr_head_sha := "cd4973d9d14a08ffe6b641a89a68891d6aac8056"
ctx := context.Background()

pullRequestMock := []git.GitPullRequest{
{
PullRequestId: createIntPtr(pr_id),
SourceRefName: createStringPtr("refs/heads/feature-branch"),
LastMergeSourceCommit: &git.GitCommitRef{
CommitId: createStringPtr(pr_head_sha),
},
Labels: &[]core.WebApiTagDefinition{},
Repository: &git.GitRepository{
Name: createStringPtr(repoName),
},
},
}

args := git.GetPullRequestsByProjectArgs{
Project: &teamProject,
SearchCriteria: &git.GitPullRequestSearchCriteria{},
}

gitClientMock := azureMock.Client{}
clientFactoryMock := &AzureClientFactoryMock{mock: &mock.Mock{}}
clientFactoryMock.mock.On("GetClient", mock.Anything).Return(&gitClientMock, nil)
gitClientMock.On("GetPullRequestsByProject", ctx, args).Return(&pullRequestMock, nil)

provider := AzureDevOpsService{
clientFactory: clientFactoryMock,
project: teamProject,
repo: repoName,
labels: nil,
}

list, err := provider.List(ctx)
assert.NoError(t, err)
assert.Equal(t, 1, len(list))
assert.Equal(t, "feature-branch", list[0].Branch)
assert.Equal(t, pr_head_sha, list[0].HeadSHA)
assert.Equal(t, pr_id, list[0].Number)
}

func TestConvertLabes(t *testing.T) {
testCases := []struct {
name string
gotLabels *[]core.WebApiTagDefinition
expectedLabels []string
}{
{
name: "empty labels",
gotLabels: createLabelsPtr([]core.WebApiTagDefinition{}),
expectedLabels: []string{},
},
{
name: "nil labels",
gotLabels: createLabelsPtr(nil),
expectedLabels: []string{},
},
{
name: "one label",
gotLabels: createLabelsPtr([]core.WebApiTagDefinition{
{Name: createStringPtr("label1"), Active: createBoolPtr(true)},
}),
expectedLabels: []string{"label1"},
},
{
name: "two label",
gotLabels: createLabelsPtr([]core.WebApiTagDefinition{
{Name: createStringPtr("label1"), Active: createBoolPtr(true)},
{Name: createStringPtr("label2"), Active: createBoolPtr(true)},
}),
expectedLabels: []string{"label1", "label2"},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := convertLabels(tc.gotLabels)
assert.Equal(t, tc.expectedLabels, got)
})
}
}

func TestContainAzureDevOpsLabels(t *testing.T) {
testCases := []struct {
name string
expectedLabels []string
gotLabels []string
expectedResult bool
}{
{
name: "empty labels",
expectedLabels: []string{},
gotLabels: []string{},
expectedResult: true,
},
{
name: "no matching labels",
expectedLabels: []string{"label1", "label2"},
gotLabels: []string{"label3", "label4"},
expectedResult: false,
},
{
name: "some matching labels",
expectedLabels: []string{"label1", "label2"},
gotLabels: []string{"label1", "label3"},
expectedResult: false,
},
{
name: "all matching labels",
expectedLabels: []string{"label1", "label2"},
gotLabels: []string{"label1", "label2"},
expectedResult: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := containAzureDevOpsLabels(tc.expectedLabels, tc.gotLabels)
assert.Equal(t, tc.expectedResult, got)
})
}
}

func TestBuildURL(t *testing.T) {
testCases := []struct {
name string
url string
organization string
expected string
}{
{
name: "Provided default URL and organization",
url: "https://dev.azure.com/",
organization: "myorganization",
expected: "https://dev.azure.com/myorganization",
},
{
name: "Provided default URL and organization without trailing slash",
url: "https://dev.azure.com",
organization: "myorganization",
expected: "https://dev.azure.com/myorganization",
},
{
name: "Provided no URL and organization",
url: "",
organization: "myorganization",
expected: "https://dev.azure.com/myorganization",
},
{
name: "Provided custom URL and organization",
url: "https://azuredevops.mycompany.com/",
organization: "myorganization",
expected: "https://azuredevops.mycompany.com/myorganization",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := buildURL(tc.url, tc.organization)
assert.Equal(t, result, tc.expected)
})
}
}
Loading

0 comments on commit 46d4609

Please sign in to comment.