From d551ab810bd94d0d3e846fb78594d63c8326cfd8 Mon Sep 17 00:00:00 2001 From: Diego Caspi <55052203+diegocaspi@users.noreply.github.com> Date: Thu, 19 Dec 2024 23:30:30 +0100 Subject: [PATCH] =?UTF-8?q?feat(gitprovider):=20add=20Azure=20DevOps=20sup?= =?UTF-8?q?port=20and=20update=20provider=20confi=E2=80=A6=20(#3128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Diego Caspi Signed-off-by: Diego Caspi Signed-off-by: Mayursinh Sarvaiya Signed-off-by: Hidde Beydals Signed-off-by: Faeka Ansari Signed-off-by: dependabot[bot] Signed-off-by: Justin Marquis Signed-off-by: Kent Rancourt Co-authored-by: Mayursinh Sarvaiya Co-authored-by: Hidde Beydals Co-authored-by: Faeka Ansari Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Justin Marquis <76892343+34fathombelow@users.noreply.github.com> Co-authored-by: Kent Rancourt --- go.mod | 2 +- go.sum | 5 +- internal/directives/git_pr_opener.go | 1 + .../schemas/git-open-pr-config.json | 4 +- .../schemas/git-wait-for-pr-config.json | 4 +- internal/directives/zz_config_types.go | 13 +- internal/gitprovider/azure/azure.go | 234 ++++++++++++++++++ internal/gitprovider/azure/azure_test.go | 93 +++++++ ui/src/gen/directives/git-open-pr-config.json | 5 +- .../directives/git-wait-for-pr-config.json | 5 +- 10 files changed, 349 insertions(+), 17 deletions(-) create mode 100644 internal/gitprovider/azure/azure.go create mode 100644 internal/gitprovider/azure/azure_test.go diff --git a/go.mod b/go.mod index aa45f2695..af4b52c71 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/go.sum b/go.sum index 35b802280..ed6591e5b 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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= diff --git a/internal/directives/git_pr_opener.go b/internal/directives/git_pr_opener.go index 24259edfc..bcb080dd1 100644 --- a/internal/directives/git_pr_opener.go +++ b/internal/directives/git_pr_opener.go @@ -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 ) diff --git a/internal/directives/schemas/git-open-pr-config.json b/internal/directives/schemas/git-open-pr-config.json index 6f0a97d07..6438c32fe 100644 --- a/internal/directives/schemas/git-open-pr-config.json +++ b/internal/directives/schemas/git-open-pr-config.json @@ -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", diff --git a/internal/directives/schemas/git-wait-for-pr-config.json b/internal/directives/schemas/git-wait-for-pr-config.json index dd7a711fa..59f2c0899 100644 --- a/internal/directives/schemas/git-wait-for-pr-config.json +++ b/internal/directives/schemas/git-wait-for-pr-config.json @@ -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", diff --git a/internal/directives/zz_config_types.go b/internal/directives/zz_config_types.go index 5ffe2e456..d97c0a2eb 100644 --- a/internal/directives/zz_config_types.go +++ b/internal/directives/zz_config_types.go @@ -187,8 +187,8 @@ type GitOpenPRConfig struct { CreateTargetBranch bool `json:"createTargetBranch,omitempty"` // Indicates whether to skip TLS verification when cloning the repository. Default is false. InsecureSkipTLSVerify bool `json:"insecureSkipTLSVerify,omitempty"` - // 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. + // 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. Provider *Provider `json:"provider,omitempty"` // The URL of a remote Git repository to clone. RepoURL string `json:"repoURL"` @@ -232,8 +232,8 @@ type GitWaitForPRConfig struct { // This field references the 'prNumber' output from a previous step and uses it as the // number of the pull request to wait for. PRNumberFromStep string `json:"prNumberFromStep,omitempty"` - // 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. + // 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. Provider *Provider `json:"provider,omitempty"` // The URL of a remote Git repository to clone. RepoURL string `json:"repoURL"` @@ -434,11 +434,12 @@ const ( Warehouse Kind = "Warehouse" ) -// 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. +// 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. type Provider string const ( + Azure Provider = "azure" Github Provider = "github" Gitlab Provider = "gitlab" ) diff --git a/internal/gitprovider/azure/azure.go b/internal/gitprovider/azure/azure.go new file mode 100644 index 000000000..dcf97bad5 --- /dev/null +++ b/internal/gitprovider/azure/azure.go @@ -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//_git/ +// - https://.visualstudio.com//_git/ +// +// 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 +} diff --git a/internal/gitprovider/azure/azure_test.go b/internal/gitprovider/azure/azure_test.go new file mode 100644 index 000000000..b081592dd --- /dev/null +++ b/internal/gitprovider/azure/azure_test.go @@ -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) + } + }) + } +} diff --git a/ui/src/gen/directives/git-open-pr-config.json b/ui/src/gen/directives/git-open-pr-config.json index 8f554bf2d..8a29fd2b7 100644 --- a/ui/src/gen/directives/git-open-pr-config.json +++ b/ui/src/gen/directives/git-open-pr-config.json @@ -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": { diff --git a/ui/src/gen/directives/git-wait-for-pr-config.json b/ui/src/gen/directives/git-wait-for-pr-config.json index 5d4296707..0ff58f6d6 100644 --- a/ui/src/gen/directives/git-wait-for-pr-config.json +++ b/ui/src/gen/directives/git-wait-for-pr-config.json @@ -10,10 +10,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" ] }, "prNumber": {