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
#3128)

Signed-off-by: Diego Caspi <[email protected]>
Signed-off-by: Diego Caspi <[email protected]>
Signed-off-by: Mayursinh Sarvaiya <[email protected]>
Signed-off-by: Hidde Beydals <[email protected]>
Signed-off-by: Faeka Ansari <[email protected]>
Signed-off-by: dependabot[bot] <[email protected]>
Signed-off-by: Justin Marquis <[email protected]>
Signed-off-by: Kent Rancourt <[email protected]>
Co-authored-by: Mayursinh Sarvaiya <[email protected]>
Co-authored-by: Hidde Beydals <[email protected]>
Co-authored-by: Faeka Ansari <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Justin Marquis <[email protected]>
Co-authored-by: Kent Rancourt <[email protected]>
  • Loading branch information
7 people authored Dec 19, 2024
1 parent 96713ae commit d551ab8
Show file tree
Hide file tree
Showing 10 changed files with 349 additions and 17 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ require (
github.com/jferrl/go-githubauth v1.1.1
github.com/kelseyhightower/envconfig v1.4.0
github.com/klauspost/compress v1.17.11
github.com/microsoft/azure-devops-go-api/azuredevops/v7 v7.1.0
github.com/oklog/ulid/v2 v2.1.0
github.com/otiai10/copy v1.14.0
github.com/patrickmn/go-cache v2.1.0+incompatible
Expand Down Expand Up @@ -197,7 +198,6 @@ require (
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/vbatts/tar-split v0.11.3 // indirect
github.com/xanzy/go-gitlab v0.115.0
github.com/xlab/treeprint v1.2.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
go.opentelemetry.io/otel v1.31.0 // indirect
Expand Down
5 changes: 3 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,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.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
Expand Down Expand Up @@ -347,6 +348,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 Expand Up @@ -486,8 +489,6 @@ github.com/vbatts/tar-split v0.11.3 h1:hLFqsOLQ1SsppQNTMpkpPXClLDfC2A3Zgy9OUU+RV
github.com/vbatts/tar-split v0.11.3/go.mod h1:9QlHN18E+fEH7RdG+QAJJcuya3rqT7eXSTY7wGrAokY=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8=
github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
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
13 changes: 7 additions & 6 deletions internal/directives/zz_config_types.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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"
"strings"

"github.com/microsoft/azure-devops-go-api/azuredevops/v7"
adogit "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git"
"k8s.io/utils/ptr"

"github.com/akuity/kargo/internal/git"
"github.com/akuity/kargo/internal/gitprovider"
)

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.
const (
legacyHostSuffix = "visualstudio.com"
modernHostSuffix = "dev.azure.com"
)

var registration = gitprovider.Registration{
Predicate: func(repoURL string) bool {
u, err := url.Parse(repoURL)
if err != nil {
return false
}
return u.Host == modernHostSuffix || strings.HasSuffix(u.Host, legacyHostSuffix)
},
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("token is required for Azure DevOps provider")
}
org, project, repo, err := parseRepoURL(repoURL)
if err != nil {
return nil, err
}
organizationUrl := fmt.Sprintf("https://%s/%s", modernHostSuffix, 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, fmt.Errorf("error creating Azure DevOps client: %w", err)
}
repository, err := gitClient.GetRepository(ctx, adogit.GetRepositoryArgs{
Project: &p.project,
RepositoryId: &p.repo,
})
if err != nil {
return nil, fmt.Errorf("error getting repository %q: %w", p.repo, 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 == modernHostSuffix {
return parseModernRepoURL(u)
} else if strings.HasSuffix(u.Host, legacyHostSuffix) {
return parseLegacyRepoURL(u)
}
return "", "", "", fmt.Errorf("unsupported host %q", u.Host)
}

// parseModernRepoURL parses a modern Azure DevOps repository URL.
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.
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
}
Loading

0 comments on commit d551ab8

Please sign in to comment.