-
Notifications
You must be signed in to change notification settings - Fork 163
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(gitprovider): add Azure DevOps support and update provider confi…
…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
1 parent
aecc74f
commit bf03c7f
Showing
9 changed files
with
342 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.