Skip to content

Commit

Permalink
feat(gitprovider): add Azure DevOps support and update provider confi…
Browse files Browse the repository at this point in the history
…gurations

- Introduced Azure DevOps as a supported Git provider in the application.
- Updated configuration schemas to include 'azure' in the list of supported providers.
- Added Azure-specific provider implementation with functionality for creating and managing pull requests.
- Included tests for Azure repository URL parsing.

This enhancement allows users to interact with Azure DevOps repositories alongside existing GitHub and GitLab support.

Signed-off-by: Diego Caspi <[email protected]>
Signed-off-by: Diego Caspi <[email protected]>
  • Loading branch information
diegocaspi committed Dec 12, 2024
1 parent aecc74f commit bf03c7f
Show file tree
Hide file tree
Showing 9 changed files with 342 additions and 8 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ require (
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/microsoft/azure-devops-go-api/azuredevops/v7 v7.1.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
Expand Down Expand Up @@ -373,6 +374,8 @@ github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microsoft/azure-devops-go-api/azuredevops/v7 v7.1.0 h1:mmJCWLe63QvybxhW1iBmQWEaCKdc4SKgALfTNZ+OphU=
github.com/microsoft/azure-devops-go-api/azuredevops/v7 v7.1.0/go.mod h1:mDunUZ1IUJdJIRHvFb+LPBUtxe3AYB5MI6BMXNg8194=
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
github.com/migueleliasweb/go-github-mock v1.0.1 h1:amLEECVny28RCD1ElALUpQxrAimamznkg9rN2O7t934=
Expand Down
1 change: 1 addition & 0 deletions internal/directives/git_pr_opener.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/akuity/kargo/internal/credentials"
"github.com/akuity/kargo/internal/gitprovider"

_ "github.com/akuity/kargo/internal/gitprovider/azure" // Azure provider registration
_ "github.com/akuity/kargo/internal/gitprovider/github" // GitHub provider registration
_ "github.com/akuity/kargo/internal/gitprovider/gitlab" // GitLab provider registration
)
Expand Down
4 changes: 2 additions & 2 deletions internal/directives/schemas/git-open-pr-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
},
"provider": {
"type": "string",
"description": "The name of the Git provider to use. Currently only 'github' and 'gitlab' are supported. Kargo will try to infer the provider if it is not explicitly specified.",
"enum": ["github", "gitlab"]
"description": "The name of the Git provider to use. Currently only 'github', 'gitlab' and 'azure' are supported. Kargo will try to infer the provider if it is not explicitly specified.",
"enum": ["github", "gitlab", "azure"]
},
"repoURL": {
"type": "string",
Expand Down
4 changes: 2 additions & 2 deletions internal/directives/schemas/git-wait-for-pr-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
},
"provider": {
"type": "string",
"description": "The name of the Git provider to use. Currently only 'github' and 'gitlab' are supported. Kargo will try to infer the provider if it is not explicitly specified.",
"enum": ["github", "gitlab"]
"description": "The name of the Git provider to use. Currently only 'github', 'gitlab' and 'azure' are supported. Kargo will try to infer the provider if it is not explicitly specified.",
"enum": ["github", "gitlab", "azure"]
},
"prNumber": {
"type": "number",
Expand Down
234 changes: 234 additions & 0 deletions internal/gitprovider/azure/azure.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package azure

import (
"context"
"fmt"
"net/url"
"slices"
"strings"

"github.com/akuity/kargo/internal/git"
"github.com/akuity/kargo/internal/gitprovider"
"k8s.io/utils/ptr"

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

const ProviderName = "azure"

// Azure DevOps URLs can be of two different forms:
//
// - https://dev.azure.com/org/project/_git/repo
// - https://org.visualstudio.com/project/_git/repo
//
// We support both forms.
var providerSuffixes = []string{"dev.azure.com", "visualstudio.com"}

var registration = gitprovider.Registration{
Predicate: func(repoURL string) bool {
u, err := url.Parse(repoURL)
if err != nil {
return false
}
return slices.ContainsFunc(providerSuffixes, func(suffix string) bool {
return strings.HasSuffix(u.Host, suffix)
})
},
NewProvider: func(
repoURL string,
opts *gitprovider.Options,
) (gitprovider.Interface, error) {
return NewProvider(repoURL, opts)
},
}

func init() {
gitprovider.Register(ProviderName, registration)
}

type provider struct {
org string
project string
repo string
connection *azuredevops.Connection
}

// NewProvider returns an Azure DevOps-based implementation of gitprovider.Interface.
func NewProvider(
repoURL string,
opts *gitprovider.Options,
) (gitprovider.Interface, error) {
if opts == nil || opts.Token == "" {
return nil, fmt.Errorf("options are required for Azure DevOps provider")
}
org, project, repo, err := parseRepoURL(repoURL)
if err != nil {
return nil, fmt.Errorf("error creating Azure DevOps provider: %w", err)
}
organizationUrl := fmt.Sprintf("https://dev.azure.com/%s", org)
connection := azuredevops.NewPatConnection(organizationUrl, opts.Token)

return &provider{
org: org,
project: project,
repo: repo,
connection: connection,
}, nil
}

// CreatePullRequest implements gitprovider.Interface.
func (p *provider) CreatePullRequest(
ctx context.Context,
opts *gitprovider.CreatePullRequestOpts,
) (*gitprovider.PullRequest, error) {
gitClient, err := adogit.NewClient(ctx, p.connection)
if err != nil {
return nil, err
}
repository, err := gitClient.GetRepository(ctx, adogit.GetRepositoryArgs{
Project: &p.project,
RepositoryId: &p.repo,
})
if err != nil {
return nil, err
}
repoID := ptr.To(repository.Id.String())
sourceRefName := ptr.To(fmt.Sprintf("refs/heads/%s", opts.Head))
targetRefName := ptr.To(fmt.Sprintf("refs/heads/%s", opts.Base))
adoPR, err := gitClient.CreatePullRequest(ctx, adogit.CreatePullRequestArgs{
Project: &p.project,
RepositoryId: repoID,
GitPullRequestToCreate: &adogit.GitPullRequest{
Title: &opts.Title,
Description: &opts.Description,
SourceRefName: sourceRefName,
TargetRefName: targetRefName,
},
})
if err != nil {
return nil, fmt.Errorf("error creating pull request from %q to %q: %w", opts.Head, opts.Base, err)
}
pr, err := convertADOPullRequest(adoPR)
if err != nil {
return nil, fmt.Errorf("error converting pull request %d: %w", adoPR.PullRequestId, err)
}
return pr, nil
}

// GetPullRequest implements gitprovider.Interface.
func (p *provider) GetPullRequest(
ctx context.Context,
id int64,
) (*gitprovider.PullRequest, error) {
gitClient, err := adogit.NewClient(ctx, p.connection)
if err != nil {
return nil, err
}
adoPR, err := gitClient.GetPullRequest(ctx, adogit.GetPullRequestArgs{
Project: &p.project,
RepositoryId: &p.repo,
PullRequestId: ptr.To(int(id)),
})
if err != nil {
return nil, err
}
pr, err := convertADOPullRequest(adoPR)
if err != nil {
return nil, fmt.Errorf("error converting pull request %d: %w", id, err)
}
return pr, nil
}

// ListPullRequests implements gitprovider.Interface.
func (p *provider) ListPullRequests(
ctx context.Context,
opts *gitprovider.ListPullRequestOptions,
) ([]gitprovider.PullRequest, error) {
gitClient, err := adogit.NewClient(ctx, p.connection)
if err != nil {
return nil, err
}
adoPRs, err := gitClient.GetPullRequests(ctx, adogit.GetPullRequestsArgs{
Project: &p.project,
RepositoryId: &p.repo,
SearchCriteria: &adogit.GitPullRequestSearchCriteria{
Status: ptr.To(mapADOPrState(opts.State)),
SourceRefName: ptr.To(opts.HeadBranch),
TargetRefName: ptr.To(opts.BaseBranch),
},
})
if err != nil {
return nil, err
}

pts := []gitprovider.PullRequest{}
for _, adoPR := range *adoPRs {
pr, err := convertADOPullRequest(&adoPR)
if err != nil {
return nil, fmt.Errorf("error converting pull request %d: %w", adoPR.PullRequestId, err)
}
pts = append(pts, *pr)
}
return pts, nil
}

// mapADOPrState maps a gitprovider.PullRequestState to an adogit.PullRequestStatus.
func mapADOPrState(state gitprovider.PullRequestState) adogit.PullRequestStatus {
switch state {
case gitprovider.PullRequestStateOpen:
return adogit.PullRequestStatusValues.Active
case gitprovider.PullRequestStateClosed:
return adogit.PullRequestStatusValues.Completed
}
return adogit.PullRequestStatusValues.All
}

// convertADOPullRequest converts an adogit.GitPullRequest to a gitprovider.PullRequest.
func convertADOPullRequest(pr *adogit.GitPullRequest) (*gitprovider.PullRequest, error) {
if pr.LastMergeSourceCommit == nil {
return nil, fmt.Errorf("no last merge source commit found for pull request %d", ptr.Deref(pr.PullRequestId, 0))
}
mergeCommit := ptr.Deref(pr.LastMergeCommit, adogit.GitCommitRef{})
return &gitprovider.PullRequest{
Number: int64(ptr.Deref(pr.PullRequestId, 0)),
URL: ptr.Deref(pr.Url, ""),
Open: ptr.Deref(pr.Status, "notSet") == "active",
Merged: ptr.Deref(pr.Status, "notSet") == "completed",
MergeCommitSHA: ptr.Deref(mergeCommit.CommitId, ""),
Object: pr,
HeadSHA: ptr.Deref(pr.LastMergeSourceCommit.CommitId, ""),
}, nil
}

func parseRepoURL(repoURL string) (string, string, string, error) {
u, err := url.Parse(git.NormalizeURL(repoURL))
if err != nil {
return "", "", "", fmt.Errorf("error parsing Azure DevOps repository URL %q: %w", repoURL, err)
}
if u.Host == "dev.azure.com" {
return parseModernRepoURL(u)
} else if strings.HasSuffix(u.Host, ".visualstudio.com") {
return parseLegacyRepoURL(u)
}
return "", "", "", fmt.Errorf("unsupported host %q", u.Host)
}

// parseModernRepoURL parses a modern Azure DevOps repository URL. example: https://dev.azure.com/org/project/_git/repo
func parseModernRepoURL(u *url.URL) (string, string, string, error) {
parts := strings.Split(u.Path, "/")
if len(parts) != 5 {
return "", "", "", fmt.Errorf("could not extract repository organization, project, and name from URL %q", u)
}
return parts[1], parts[2], parts[4], nil
}

// parseLegacyRepoURL parses a legacy Azure DevOps repository URL. example: https://org.visualstudio.com/project/_git/repo
func parseLegacyRepoURL(u *url.URL) (string, string, string, error) {
organization := strings.TrimSuffix(u.Host, ".visualstudio.com")
parts := strings.Split(u.Path, "/")
if len(parts) != 4 {
return "", "", "", fmt.Errorf("could not extract repository organization, project, and name from URL %q", u)
}
return organization, parts[1], parts[3], nil
}
93 changes: 93 additions & 0 deletions internal/gitprovider/azure/azure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package azure

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestParseRepoURL(t *testing.T) {
testCases := []struct {
name string
url string
expectedOrg string
expectedProj string
expectedRepo string
errExpected bool
}{
{
name: "invalid URL",
url: "not-a-url",
errExpected: true,
},
{
name: "unsupported host",
url: "https://github.com/org/repo",
errExpected: true,
},
{
name: "modern URL with missing parts",
url: "https://dev.azure.com/org",
errExpected: true,
},
{
name: "legacy URL with missing parts",
url: "https://org.visualstudio.com",
errExpected: true,
},
{
name: "modern URL format",
url: "https://dev.azure.com/myorg/myproject/_git/myrepo",
expectedOrg: "myorg",
expectedProj: "myproject",
expectedRepo: "myrepo",
errExpected: false,
},
{
name: "modern URL format with .git suffix",
url: "https://dev.azure.com/myorg/myproject/_git/myrepo.git",
expectedOrg: "myorg",
expectedProj: "myproject",
expectedRepo: "myrepo",
errExpected: false,
},
{
name: "legacy URL format",
url: "https://myorg.visualstudio.com/myproject/_git/myrepo",
expectedOrg: "myorg",
expectedProj: "myproject",
expectedRepo: "myrepo",
errExpected: false,
},
{
name: "legacy URL format with .git suffix",
url: "https://myorg.visualstudio.com/myproject/_git/myrepo.git",
expectedOrg: "myorg",
expectedProj: "myproject",
expectedRepo: "myrepo",
errExpected: false,
},
{
name: "modern URL format with dot in repo name",
url: "https://dev.azure.com/myorg/myproject/_git/my.repo",
expectedOrg: "myorg",
expectedProj: "myproject",
expectedRepo: "my.repo",
errExpected: false,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
org, proj, repo, err := parseRepoURL(tc.url)
if tc.errExpected {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expectedOrg, org)
require.Equal(t, tc.expectedProj, proj)
require.Equal(t, tc.expectedRepo, repo)
}
})
}
}
5 changes: 3 additions & 2 deletions ui/src/gen/directives/git-open-pr-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
},
"provider": {
"type": "string",
"description": "The name of the Git provider to use. Currently only 'github' and 'gitlab' are supported. Kargo will try to infer the provider if it is not explicitly specified.",
"description": "The name of the Git provider to use. Currently only 'github', 'gitlab' and 'azure' are supported. Kargo will try to infer the provider if it is not explicitly specified.",
"enum": [
"github",
"gitlab"
"gitlab",
"azure"
]
},
"repoURL": {
Expand Down
Loading

0 comments on commit bf03c7f

Please sign in to comment.