diff --git a/README.adoc b/README.adoc index e6317e3e5f..fa9faf91ab 100644 --- a/README.adoc +++ b/README.adoc @@ -11,23 +11,101 @@ link:https://img.shields.io/github/actions/workflow/status/updatecli/updatecli/g link:https://api.securityscorecards.dev/projects/github.com/updatecli/updatecli[image:https://api.securityscorecards.dev/projects/github.com/updatecli/updatecli/badge[OpenSSF Scorecard]] link:https://bestpractices.coreinfrastructure.org/projects/6731[image:https://bestpractices.coreinfrastructure.org/projects/6731/badge[OpenSSF Best Practices]] -_"Automatically open a PR on your GitOps repository when a third party service publishes an update"_ +_"Automatically open a PR on your Git repository when a file update is needed"_ -Updatecli is a tool used to apply file update strategies. Designed to be used from everywhere, each application "run" detects if a value needs to be updated using a custom strategy then apply changes according to the strategy. +Updatecli is a universal declarative update policy engine. Designed to be used from everywhere, each Updatecli "run" detects if a file needs to be updated using a tailored update policy then apply changes. -You describe your update strategy in a file then you run updatecli to it. +You describe your update strategy in a policy then you enforce it using Updatecli. -Updatecli reads a yaml or a go template configuration file, then works into three stages +Every Updatecli policy is a YAML (or Go template) file that runs through three stages: -1. Sources: Based on a rule, updatecli fetches a value that will be injected in later stages such as latest application version. -2. Conditions: Ensure that conditions are met based on the value retrieved during the source stage. -3. Targets: Update and publish the target files based on a value retrieved from the source stage. +1. *Sources* — Fetch the new value to apply (e.g. latest Docker image tag, newest Helm chart version, latest GitHub release). +2. *Conditions* — Verify that all prerequisites are met before making any change (optional but recommended). +3. *Targets* — Apply the change to the right file or service, and open a pull request if an SCM is configured. + +== What Can You Update? + +Updatecli ships with 30+ built-in integrations. Here are the most common scenarios: + +* 📄 *File formats* — + Update values in YAML, JSON, TOML, XML, HCL, CSV, Dockerfiles, and `.tool-versions` files, + or any text file with pattern matching. + +* 🐳 *Container images* — + Track Docker image tags and digests from Docker Hub or any OCI-compliant registry. + +* 📦 *Package registries* — + Helm charts (including OCI), npm, PyPI, Maven, Cargo (Rust crates), Go modules, + and Terraform providers/modules. + +* 🏷️ *Git & releases* — + GitHub/GitLab releases, Git tags and branches. + +* ☕ *Languages & runtimes* — + Jenkins LTS/weekly releases, Eclipse Temurin (JDK), Go language versions, + Bazel modules and registry. + +* ☁️ *Cloud* — + AWS AMIs. + +* 🔧 *Custom logic* — + Shell scripts and HTTP endpoints, for anything not covered above. + +Find the full list and documentation on link:https://www.updatecli.io/docs/prologue/introduction/[www.updatecli.io]. == Feature -* *Flexibility*: Easy to define tailored update strategies, you are just one yaml file from it. -* *Portability*: Easy to add to your workflow whatever it is. Just one command to run. Of course, it's easy to remove. -* *Extensibility*: Easy to add new go packages to support more workflows. +[cols="1,1,3,1", options="header"] +|=== +| SCM Platform | Supported | Capabilities | Plugin + +| GitHub +| ✅ +| Clone, branch, commit, push, Pull Requests, releases +| `github` + +| GitLab +| ✅ +| Clone, branch, commit, push, Merge Requests, releases +| `gitlab` + +| Gitea +| ✅ +| Clone, branch, commit, push, Pull Requests, releases +| `gitea` + +| Forgejo +| ✅ +| Compatible through Gitea API support +| `gitea` + +| Bitbucket Cloud +| ✅ +| Clone, branch, commit, push, Pull Requests +| `bitbucket` + +| Bitbucket Server / Stash +| ✅ +| Clone, branch, commit, push, Pull Requests +| `stash` + +| Azure DevOps +| ✅ +| Clone, branch, commit, push, Pull Requests +| `azuredevops` + +| Generic Git Repository +| ✅ +| Clone, branch, commit, push +| `git` + +|=== + +* *Declarative* — Define your update policy once in YAML; Updatecli handles detection and application. +* *30+ integrations* — Docker, Helm, GitHub releases, npm, PyPI, Terraform, AWS AMIs, and more — out of the box. +* *Any CI/CD* — Runs as a single binary. Drop it into GitHub Actions, Jenkins, GitLab CI, or any shell. +* *Safe by default* — Use `--dry-run` to preview every change before it is applied. +* *Extensible* — Add custom logic via shell scripts or HTTP, or contribute a new Go plugin. == Why @@ -213,10 +291,11 @@ More information available at link:https://github.com/updatecli/updatecli/blob/m * link:https://www.updatecli.io/docs/prologue/introduction/[DOCUMENTATION] * link:https://github.com/updatecli/updatecli/blob/main/LICENSE[LICENSE] -== Thanks to all the contributors ❤️ +== Thanks to you +=== Contributors ❤️ link:https://github.com/updatecli/updatecli/graphs/contributors"[image:https://contrib.rocks/image?repo=updatecli/updatecli[]] -== Thanks to the sponsors ❤️ +=== Sponsors ❤️ link:https://www.prefect.io/[image:https://avatars.githubusercontent.com/u/41086007?v=4[alt=PrefectHQ,height=200, width=200]] diff --git a/e2e/updatecli.d/success.d/azuredevops/scm.yaml b/e2e/updatecli.d/success.d/azuredevops/scm.yaml new file mode 100644 index 0000000000..c3293fc379 --- /dev/null +++ b/e2e/updatecli.d/success.d/azuredevops/scm.yaml @@ -0,0 +1,44 @@ +name: Test Azure DevOps scm +pipelineid: azuredevops/scm + +scms: + azuredevops: + kind: azuredevops + spec: + organization: updatecli + project: "updatecli-test" + repository: "updatecli-test" + branch: main + +sources: + license: + name: Retrieve license file content + kind: file + scmid: azuredevops + spec: + file: LICENSE + +conditions: + license: + name: Retrieve license file content + kind: file + sourceid: license + scmid: azuredevops + spec: + file: LICENSE + +targets: + license: + name: Update license title + kind: file + disablesourceinput: true + scmid: azuredevops + spec: + file: LICENSE + content: "# Title" + line: 1 + +actions: + default: + kind: azuredevops/pullrequest + scmid: azuredevops diff --git a/e2e/updatecli.d/success.d/azuredevopssearch/scm.yaml b/e2e/updatecli.d/success.d/azuredevopssearch/scm.yaml new file mode 100644 index 0000000000..1f6fe1e0d7 --- /dev/null +++ b/e2e/updatecli.d/success.d/azuredevopssearch/scm.yaml @@ -0,0 +1,28 @@ +name: Test Azure Devops scm +pipelineid: azuredevops/scm + +scms: + azuredevops: + kind: azuredevopssearch + spec: + organization: updatecli + #project: "^updatecli-test$" + #repository: "^updatecli-test$" + branch: "^main$" + +targets: + readme: + name: Test update + kind: yaml + disablesourceinput: true + scmid: azuredevops + spec: + file: .github/workflows/updatecli.yaml + key: "$.permissions.contents" + value: read + +actions: + default: + title: Change readme title + kind: azuredevops/pullrequest + scmid: azuredevops diff --git a/go.mod b/go.mod index 0e4717cc52..f9ebc80ff6 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,7 @@ require ( github.com/invopop/jsonschema v0.14.0 github.com/jferrl/go-githubauth v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/microsoft/azure-devops-go-api/azuredevops/v7 v7.1.0 github.com/minamijoyo/hcledit v0.2.17 github.com/minamijoyo/tfupdate v0.8.0 github.com/muesli/mango-cobra v1.3.0 @@ -277,7 +278,7 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/google/uuid v1.6.0 github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/gosuri/uitable v0.0.4 // indirect diff --git a/go.sum b/go.sum index 9e81a37e41..e3d71a52c8 100644 --- a/go.sum +++ b/go.sum @@ -440,6 +440,7 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 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.3.0/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= @@ -575,6 +576,8 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/minamijoyo/hcledit v0.2.17 h1:5FFiUu8BtJeldqb8OhZ4fKoGZuH3oiW30AM0dHuUIPQ= diff --git a/pkg/core/engine/configuration.go b/pkg/core/engine/configuration.go index b85916bae4..784a557886 100644 --- a/pkg/core/engine/configuration.go +++ b/pkg/core/engine/configuration.go @@ -15,6 +15,8 @@ import ( "github.com/updatecli/updatecli/pkg/core/pipeline/scm" "github.com/updatecli/updatecli/pkg/core/reports" "github.com/updatecli/updatecli/pkg/core/result" + "github.com/updatecli/updatecli/pkg/plugins/scms/azuredevops" + "github.com/updatecli/updatecli/pkg/plugins/scms/azuredevopssearch" "github.com/updatecli/updatecli/pkg/plugins/scms/github" "github.com/updatecli/updatecli/pkg/plugins/scms/githubsearch" "github.com/updatecli/updatecli/pkg/plugins/scms/gitlab" @@ -174,6 +176,44 @@ func (e *Engine) LoadConfigurations() error { logrus.Debugf("githubsearch scm %q added new pipeline configuration for repository %s/%s", scmID, spec.Owner, spec.Repository) } + case azuredevopssearch.Kind: + + logrus.Debugf("Processing azuredevopssearch scm %q for potential multiple repository discovery", scmID) + + ctx := context.Background() + azdoSearchScms, err := azuredevopssearch.New(scmConfig.Spec) + if err != nil { + return fmt.Errorf("unable to instantiate azuredevopssearch scm %q: %w", scmID, err) + } + discoveredAzureDevOpsSCms, err := azdoSearchScms.ScmsGenerator(ctx) + if err != nil { + return fmt.Errorf("unable to generate scm specs for azuredevopssearch scm %q: %w", scmID, err) + } + + if len(discoveredAzureDevOpsSCms) == 0 { + return fmt.Errorf("no scm discovered for azuredevopssearch scm %q", scmID) + } + + scmConfig.Kind = azuredevops.Kind + scmConfig.Spec = discoveredAzureDevOpsSCms[0] + + loadedConfigurations[i].Spec.SCMs[scmID] = scmConfig + + for _, spec := range discoveredAzureDevOpsSCms[1:] { + newPipeline := loadedConfiguration + + newPipeline.Spec.SCMs = make(map[string]scm.Config, len(loadedConfiguration.Spec.SCMs)) + maps.Copy(newPipeline.Spec.SCMs, loadedConfiguration.Spec.SCMs) + + newSCM := newPipeline.Spec.SCMs[scmID] + newSCM.Kind = azuredevops.Kind + newSCM.Spec = spec + + newPipeline.Spec.SCMs[scmID] = newSCM + + loadedConfigurations = append(loadedConfigurations, newPipeline) + } + case gitlabsearch.Kind: logrus.Debugf("Processing gitlabsearch scm %q for potential multiple repository discovery", scmID) diff --git a/pkg/core/pipeline/action/main.go b/pkg/core/pipeline/action/main.go index a1ae56db7d..c4b87a9a99 100644 --- a/pkg/core/pipeline/action/main.go +++ b/pkg/core/pipeline/action/main.go @@ -12,10 +12,12 @@ import ( "github.com/updatecli/updatecli/pkg/core/jsonschema" "github.com/updatecli/updatecli/pkg/core/pipeline/scm" "github.com/updatecli/updatecli/pkg/core/reports" + azuredevops "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/pullrequest" bitbucket "github.com/updatecli/updatecli/pkg/plugins/resources/bitbucket/pullrequest" gitea "github.com/updatecli/updatecli/pkg/plugins/resources/gitea/pullrequest" gitlab "github.com/updatecli/updatecli/pkg/plugins/resources/gitlab/mergerequest" stash "github.com/updatecli/updatecli/pkg/plugins/resources/stash/pullrequest" + azuredevopsscm "github.com/updatecli/updatecli/pkg/plugins/scms/azuredevops" bitbucketscm "github.com/updatecli/updatecli/pkg/plugins/scms/bitbucket" giteascm "github.com/updatecli/updatecli/pkg/plugins/scms/gitea" "github.com/updatecli/updatecli/pkg/plugins/scms/github" @@ -24,11 +26,12 @@ import ( ) const ( - gitlabIdentifier = "gitlab" - githubIdentifier = "github" - giteaIdentifier = "gitea" - stashIdentifier = "stash" - bitbucketIdentifier = "bitbucket" + azuredevopsIdentifier = "azuredevops" + gitlabIdentifier = "gitlab" + githubIdentifier = "github" + giteaIdentifier = "gitea" + stashIdentifier = "stash" + bitbucketIdentifier = "bitbucket" ) // ErrWrongConfig is returned when an action has missing mandatory attributes. @@ -148,6 +151,25 @@ func (a *Action) Update() error { func (a *Action) generateActionHandler() error { // Don't forget to update the JSONSchema() method when adding/updating/removing a case switch a.Config.Kind { + case "azuredevops/pullrequest": + if a.Scm.Config.Kind != azuredevopsIdentifier { + return fmt.Errorf("scm of kind %q is not compatible with action of kind %q", + a.Scm.Config.Kind, + a.Config.Kind) + } + + ge, ok := a.Scm.Handler.(*azuredevopsscm.AzureDevOps) + if !ok { + return fmt.Errorf("scm is not of kind 'azuredevops'") + } + + g, err := azuredevops.New(a.Config.Spec, ge) + if err != nil { + return err + } + + a.Handler = &g + case "bitbucket/pullrequest", bitbucketIdentifier: if a.Scm.Config.Kind != bitbucketIdentifier { return fmt.Errorf("scm of kind %q is not compatible with action of kind %q", @@ -267,11 +289,12 @@ func (Config) JSONSchema() *jschema.Schema { type configAlias Config anyOfSpec := map[string]interface{}{ - "github/pullrequest": &github.ActionSpec{}, - "gitea/pullrequest": &gitea.Spec{}, - "stash/pullrequest": &stash.Spec{}, - "gitlab/mergerequest": &gitlab.Spec{}, - "bitbucket/pullrequest": &bitbucket.Spec{}, + "azuredevops/pullrequest": &azuredevops.Spec{}, + "github/pullrequest": &github.ActionSpec{}, + "gitea/pullrequest": &gitea.Spec{}, + "stash/pullrequest": &stash.Spec{}, + "gitlab/mergerequest": &gitlab.Spec{}, + "bitbucket/pullrequest": &bitbucket.Spec{}, } return jsonschema.AppendOneOfToJsonSchema(configAlias{}, anyOfSpec) diff --git a/pkg/core/pipeline/action/main_test.go b/pkg/core/pipeline/action/main_test.go index ed54b70681..212b1d25c3 100644 --- a/pkg/core/pipeline/action/main_test.go +++ b/pkg/core/pipeline/action/main_test.go @@ -75,6 +75,17 @@ func Test_Validate(t *testing.T) { ScmID: "default", }, }, + { + name: "Passing case with 'azuredevops/pullrequest'", + config: Config{ + Kind: "azuredevops/pullrequest", + ScmID: "default", + }, + wantConfig: Config{ + Kind: "azuredevops/pullrequest", + ScmID: "default", + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/core/pipeline/scm/config.go b/pkg/core/pipeline/scm/config.go index 76c4843df8..ae0bb5fd1d 100644 --- a/pkg/core/pipeline/scm/config.go +++ b/pkg/core/pipeline/scm/config.go @@ -9,6 +9,8 @@ import ( jschema "github.com/invopop/jsonschema" "github.com/sirupsen/logrus" "github.com/updatecli/updatecli/pkg/core/jsonschema" + "github.com/updatecli/updatecli/pkg/plugins/scms/azuredevops" + "github.com/updatecli/updatecli/pkg/plugins/scms/azuredevopssearch" "github.com/updatecli/updatecli/pkg/plugins/scms/bitbucket" "github.com/updatecli/updatecli/pkg/plugins/scms/git" "github.com/updatecli/updatecli/pkg/plugins/scms/gitea" @@ -156,14 +158,16 @@ func (Config) JSONSchema() *jschema.Schema { type configAlias Config anyOfSpec := map[string]interface{}{ - "bitbucket": &bitbucket.Spec{}, - "git": &git.Spec{}, - "gitea": &gitea.Spec{}, - "github": &github.Spec{}, - "gitlab": &gitlab.Spec{}, - "stash": &stash.Spec{}, - "githubsearch": &githubsearch.Spec{}, - "gitlabsearch": &gitlabsearch.Spec{}, + "azuredevops": &azuredevops.Spec{}, + "azuredevopssearch": &azuredevopssearch.Spec{}, + "bitbucket": &bitbucket.Spec{}, + "git": &git.Spec{}, + "gitea": &gitea.Spec{}, + "github": &github.Spec{}, + "gitlab": &gitlab.Spec{}, + "stash": &stash.Spec{}, + "githubsearch": &githubsearch.Spec{}, + "gitlabsearch": &gitlabsearch.Spec{}, } return jsonschema.AppendOneOfToJsonSchema(configAlias{}, anyOfSpec) diff --git a/pkg/core/pipeline/scm/main.go b/pkg/core/pipeline/scm/main.go index db612a7f0e..9b94368bb4 100644 --- a/pkg/core/pipeline/scm/main.go +++ b/pkg/core/pipeline/scm/main.go @@ -6,11 +6,15 @@ import ( "fmt" "github.com/go-viper/mapstructure/v2" + "github.com/updatecli/updatecli/pkg/plugins/scms/azuredevops" + "github.com/updatecli/updatecli/pkg/plugins/scms/azuredevopssearch" "github.com/updatecli/updatecli/pkg/plugins/scms/bitbucket" "github.com/updatecli/updatecli/pkg/plugins/scms/git" "github.com/updatecli/updatecli/pkg/plugins/scms/gitea" "github.com/updatecli/updatecli/pkg/plugins/scms/github" + "github.com/updatecli/updatecli/pkg/plugins/scms/githubsearch" "github.com/updatecli/updatecli/pkg/plugins/scms/gitlab" + "github.com/updatecli/updatecli/pkg/plugins/scms/gitlabsearch" "github.com/updatecli/updatecli/pkg/plugins/scms/stash" ) @@ -65,6 +69,14 @@ func (s *Scm) GenerateSCM() error { } switch s.Config.Kind { + case "azuredevops": + g, err := azuredevops.New(s.Config.Spec, s.PipelineID) + if err != nil { + return err + } + + s.Handler = g + case "bitbucket": g, err := bitbucket.New(s.Config.Spec, s.PipelineID) if err != nil { @@ -126,9 +138,11 @@ func (s *Scm) GenerateSCM() error { } s.Handler = g - case "githubsearch": + case githubsearch.Kind: // githubsearch scm kind is handled during engine preparation step - case "gitlabsearch": + case azuredevopssearch.Kind: + // azuredevopssearch scm kind is handled during engine preparation step + case gitlabsearch.Kind: // gitlabsearch scm kind is handled during engine preparation step default: return fmt.Errorf("scm of kind %q is not supported", s.Config.Kind) diff --git a/pkg/core/scaffold/assets/updatecli.d/_scm.azuredevops.yaml b/pkg/core/scaffold/assets/updatecli.d/_scm.azuredevops.yaml new file mode 100644 index 0000000000..7d5115d6cd --- /dev/null +++ b/pkg/core/scaffold/assets/updatecli.d/_scm.azuredevops.yaml @@ -0,0 +1,34 @@ +# NOTE: Files prefixed with an underscore are partial Updatecli configuration +# templates. They are intended to be included into other Updatecli manifests +# and are not full standalone documents unless you explicitly make them so +# (for example by adding a leading `---` and providing all required fields). + +# {{ if and .scm.enabled ( eq .scm.kind "azuredevops") }} +# {{ $AzureDevOpsPAT := env .scm.env_token }} +actions: + default: + kind: "azuredevops/pullrequest" + scmid: "default" + +scms: + default: + kind: "azuredevops" + spec: + # {{ if .scm.user }} + user: '{{ .scm.user }}' + # {{ end }} + # {{ if .scm.email }} + email: '{{ .scm.email }}' + # {{ end }} + url: '{{ .scm.url }}' + project: '{{ .scm.project }}' + repository: '{{ .scm.repository }}' + organization: '{{ .scm.organization }}' + token: '{{ default $AzureDevOpsPAT .scm.token }}' + # {{ if .scm.username }} + username: '{{ .scm.username }}' + # {{ end }} + # {{ if .scm.branch }} + branch: '{{ .scm.branch }}' + # {{ end }} +# {{ end }} diff --git a/pkg/core/scaffold/assets/updatecli.d/_scm.azuredevopssearch.yaml b/pkg/core/scaffold/assets/updatecli.d/_scm.azuredevopssearch.yaml new file mode 100644 index 0000000000..cae7353ed9 --- /dev/null +++ b/pkg/core/scaffold/assets/updatecli.d/_scm.azuredevopssearch.yaml @@ -0,0 +1,35 @@ +# NOTE: Files prefixed with an underscore are partial Updatecli configuration +# templates. They are intended to be included into other Updatecli manifests +# and are not full standalone documents unless you explicitly make them so +# (for example by adding a leading `---` and providing all required fields). + +# {{ if and .scm.enabled ( eq .scm.kind "azuredevopssearch") }} +# {{ $AzureDevOpsPAT := env .scm.env_token }} +actions: + default: + kind: "azuredevops/pullrequest" + scmid: "default" + +scms: + default: + kind: "azuredevopssearch" + spec: + # {{ if .scm.user }} + user: '{{ .scm.user }}' + # {{ end }} + # {{ if .scm.email }} + email: '{{ .scm.email }}' + # {{ end }} + url: '{{ .scm.url }}' + project: '{{ .scm.project }}' + repository: '{{ .scm.repository }}' + organization: '{{ .scm.organization }}' + limit: {{ .scm.limit }} + token: '{{ default $AzureDevOpsPAT .scm.token }}' + # {{ if .scm.username }} + username: '{{ .scm.username }}' + # {{ end }} + # {{ if .scm.branch }} + branch: '{{ .scm.branch }}' + # {{ end }} +# {{ end }} diff --git a/pkg/core/scaffold/assets/updatecli.d/_scm.githubsearch.yaml b/pkg/core/scaffold/assets/updatecli.d/_scm.githubsearch.yaml index 83ed20aabc..29222db867 100644 --- a/pkg/core/scaffold/assets/updatecli.d/_scm.githubsearch.yaml +++ b/pkg/core/scaffold/assets/updatecli.d/_scm.githubsearch.yaml @@ -33,7 +33,7 @@ scms: email: '{{ .scm.email }}' # {{ end }} search: '{{ .scm.search }}' - limit: {{ default 3 .scm.limit }} + limit: {{ .scm.limit }} token: '{{ default $GitHubPAT .scm.token }}' username: '{{ default $GitHubUsername .scm.username }}' #{{ if .scm.branch }} diff --git a/pkg/core/scaffold/assets/values.yaml b/pkg/core/scaffold/assets/values.yaml index 96034fc5a7..bc0ae9dbc0 100644 --- a/pkg/core/scaffold/assets/values.yaml +++ b/pkg/core/scaffold/assets/values.yaml @@ -27,5 +27,7 @@ scm: # user: updatecli-bot # email: updatecli-bot@example.com # branch: main + + limit: 3 # Example: to disable SCM during local dry-runs, set `scm.enabled: false`. \ No newline at end of file diff --git a/pkg/plugins/resources/azuredevops/client/main.go b/pkg/plugins/resources/azuredevops/client/main.go new file mode 100644 index 0000000000..521ea93997 --- /dev/null +++ b/pkg/plugins/resources/azuredevops/client/main.go @@ -0,0 +1,64 @@ +package client + +import ( + "context" + "net/url" + "time" + + azdosdk "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + azdocore "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + azdogit "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" +) + +var ( + // DefaultAzureDevOpsURL is the default URL for Azure DevOps organizations. + DefaultAzureDevOpsURL string = "https://dev.azure.com" +) + +type Client struct { + Spec Spec + connection *azdosdk.Connection +} + +func New(s Spec) (Client, error) { + if err := s.Sanitize(); err != nil { + return Client{}, err + } + + URL, err := url.JoinPath( + s.URL, + url.PathEscape(s.Organization), + ) + if err != nil { + return Client{}, err + } + + timeout := 30 * time.Second + connection := azdosdk.NewPatConnection(URL, s.Token) + connection.Timeout = &timeout + + return Client{ + Spec: s, + connection: connection, + }, nil +} + +func (c Client) NewGitClient(ctx context.Context) (azdogit.Client, error) { + return azdogit.NewClient(ctx, c.connection) +} + +func (c Client) NewCoreClient(ctx context.Context) (azdocore.Client, error) { + return azdocore.NewClient(ctx, c.connection) +} + +func (c Client) GetRepository(ctx context.Context, project, repository string) (*azdogit.GitRepository, error) { + gitClient, err := c.NewGitClient(ctx) + if err != nil { + return nil, err + } + + return gitClient.GetRepository(ctx, azdogit.GetRepositoryArgs{ + Project: &project, + RepositoryId: &repository, + }) +} diff --git a/pkg/plugins/resources/azuredevops/client/spec.go b/pkg/plugins/resources/azuredevops/client/spec.go new file mode 100644 index 0000000000..7c4c32648f --- /dev/null +++ b/pkg/plugins/resources/azuredevops/client/spec.go @@ -0,0 +1,110 @@ +package client + +import ( + "fmt" + "net/url" + "path" + "strconv" + "strings" + + "github.com/sirupsen/logrus" +) + +// Spec defines a specification for an Azure DevOps resource +// parsed from an updatecli manifest file. +type Spec struct { + // Organization defines the Azure DevOps organization URL to interact with. + Organization string `yaml:",omitempty"` + // "url" defines the Azure DevOps organization URL to interact with. + URL string `yaml:",omitempty"` + // "project" defines the Azure DevOps project containing the repository. + Project string `yaml:",omitempty" jsonschema:"required"` + // "repository" defines the Azure DevOps repository name. + Repository string `yaml:",omitempty" jsonschema:"required"` + // "username" defines the username used for git authentication. + Username string `yaml:",omitempty"` + // "token" specifies the personal access token used to authenticate with Azure DevOps. + Token string `yaml:",omitempty"` +} + +// Validate validates that a spec contains the required Azure DevOps settings. +func (s Spec) Validate() error { + missingParameters := []string{} + + if s.Organization == "" { + missingParameters = append(missingParameters, "organization") + } + + if len(missingParameters) > 0 { + logrus.Errorf("missing parameter(s) [%s]", strings.Join(missingParameters, ",")) + return fmt.Errorf("wrong azure devops configuration") + } + + return nil +} + +// Sanitize normalizes a spec content. +func (s *Spec) Sanitize() error { + if err := s.Validate(); err != nil { + return err + } + + s.URL = EnsureValidURL(s.URL) + + return nil +} + +// EnsureValidURL normalizes an Azure DevOps organization URL. +func EnsureValidURL(rawURL string) string { + if rawURL == "" { + return DefaultAzureDevOpsURL + } + + if !strings.HasPrefix(rawURL, "https://") && !strings.HasPrefix(rawURL, "http://") { + rawURL = "https://" + rawURL + } + + return strings.TrimRight(rawURL, "/") +} + +// GitURL returns the repository git URL used by the SCM implementation. +func GitURL(baseURL, organization, project, repository string) string { + u, err := url.Parse(EnsureValidURL(baseURL)) + if err != nil { + return strings.TrimRight(EnsureValidURL(baseURL), "/") + "/" + project + "/_git/" + repository + } + + u.Path = path.Join( + u.Path, + url.PathEscape(organization), + url.PathEscape(project), + "_git", + url.PathEscape(repository), + ) + + return u.String() +} + +// PullRequestURL returns the Azure DevOps web URL for a pull request. +func PullRequestURL(baseURL, organization, project, repository string, pullRequestID int) string { + u, err := url.Parse(EnsureValidURL(baseURL)) + if err != nil { + return strings.TrimRight(EnsureValidURL(baseURL), "/") + + "/" + organization + + "/" + project + + "/_git/" + repository + + "/pullrequest/" + strconv.Itoa(pullRequestID) + } + + u.Path = path.Join( + u.Path, + url.PathEscape(organization), + url.PathEscape(project), + "_git", + url.PathEscape(repository), + "pullrequest", + strconv.Itoa(pullRequestID), + ) + + return u.String() +} diff --git a/pkg/plugins/resources/azuredevops/credential/main.go b/pkg/plugins/resources/azuredevops/credential/main.go new file mode 100644 index 0000000000..83cb25938b --- /dev/null +++ b/pkg/plugins/resources/azuredevops/credential/main.go @@ -0,0 +1,27 @@ +package credential + +import ( + "os" + + "github.com/sirupsen/logrus" +) + +var ( + // ENVIRONMENT_AZURE_DEVOPS_USERNAME is the environment variable used to set the Azure DevOps username. + ENVIRONMENT_AZURE_DEVOPS_USERNAME string = "UPDATECLI_AZURE_DEVOPS_USERNAME" + // ENVIRONMENT_AZURE_DEVOPS_TOKEN is the environment variable used to set the Azure DevOps token. + ENVIRONMENT_AZURE_DEVOPS_TOKEN string = "UPDATECLI_AZURE_DEVOPS_TOKEN" // #nosec G101 -- This is not a hardcoded credential. +) + +// GetCredentialsFromEnv retrieves the Azure DevOps username and token from environment variables. +func GetCredentialsFromEnv() (username string, token string) { + username = os.Getenv(ENVIRONMENT_AZURE_DEVOPS_USERNAME) + if username != "" { + logrus.Debugf("Azure DevOps username found in environment variable %s", ENVIRONMENT_AZURE_DEVOPS_USERNAME) + } + token = os.Getenv(ENVIRONMENT_AZURE_DEVOPS_TOKEN) + if token != "" { + logrus.Debugf("Azure DevOps token found in environment variable %s", ENVIRONMENT_AZURE_DEVOPS_TOKEN) + } + return username, token +} diff --git a/pkg/plugins/resources/azuredevops/pullrequest/clean.go b/pkg/plugins/resources/azuredevops/pullrequest/clean.go new file mode 100644 index 0000000000..57296b077b --- /dev/null +++ b/pkg/plugins/resources/azuredevops/pullrequest/clean.go @@ -0,0 +1,67 @@ +package pullrequest + +import ( + "context" + "fmt" + + "github.com/sirupsen/logrus" + "github.com/updatecli/updatecli/pkg/core/reports" +) + +// CleanAction verifies if existing action requires cleanup such as closing a pull request with no changes. +func (a *AzureDevOps) CleanAction(ctx context.Context, report *reports.Action) error { + existingPR, err := a.findExistingPullRequest(ctx) + if err != nil { + return fmt.Errorf("finding existing pull request: %w", err) + } + + if existingPR == nil || existingPR.PullRequestId == nil { + logrus.Debugln("nothing to clean") + return nil + } + + repository, err := a.client.GetRepository(ctx, a.Project, a.Repository) + if err != nil { + return fmt.Errorf("get Azure DevOps repository: %w", err) + } + + repositoryID, err := repositoryID(repository) + if err != nil { + return err + } + + gitClient, err := a.client.NewGitClient(ctx) + if err != nil { + return fmt.Errorf("create Azure DevOps git client: %w", err) + } + + headMatches, err := a.retryUntilPullRequestHeadMatchesRemoteBranchHead(ctx, gitClient, repositoryID, *existingPR.PullRequestId, 3) + if err != nil { + return err + } + + if !headMatches { + logrus.Debugf("Skipping Azure DevOps pull request cleanup because PR head does not match remote branch head:\n\t%s", a.pullRequestLink(existingPR)) + return nil + } + + isEmpty, err := a.isPullRequestEmpty(ctx, gitClient, repositoryID, *existingPR.PullRequestId) + if err != nil { + return err + } + + if !isEmpty { + return nil + } + + logrus.Debugf("No changed file detected at pull request:\n\t%s", a.pullRequestLink(existingPR)) + + if err := a.closePullRequest(ctx, gitClient, repositoryID, *existingPR.PullRequestId); err != nil { + return fmt.Errorf("closing pull request: %w", err) + } + + report.Link = "" + report.Description = "pull request closed as no changed file detected" + + return nil +} diff --git a/pkg/plugins/resources/azuredevops/pullrequest/clean_test.go b/pkg/plugins/resources/azuredevops/pullrequest/clean_test.go new file mode 100644 index 0000000000..e3a8800616 --- /dev/null +++ b/pkg/plugins/resources/azuredevops/pullrequest/clean_test.go @@ -0,0 +1,257 @@ +package pullrequest + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + azdogit "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" +) + +type mockGitClient struct { + getPullRequestsFunc func(context.Context, azdogit.GetPullRequestsArgs) (*[]azdogit.GitPullRequest, error) + getPullRequestIterationChangesFunc func(context.Context, azdogit.GetPullRequestIterationChangesArgs) (*azdogit.GitPullRequestIterationChanges, error) + getPullRequestIterationsFunc func(context.Context, azdogit.GetPullRequestIterationsArgs) (*[]azdogit.GitPullRequestIteration, error) + getRefsFunc func(context.Context, azdogit.GetRefsArgs) (*azdogit.GetRefsResponseValue, error) + updatePullRequestFunc func(context.Context, azdogit.UpdatePullRequestArgs) (*azdogit.GitPullRequest, error) +} + +func (m mockGitClient) GetPullRequests(ctx context.Context, args azdogit.GetPullRequestsArgs) (*[]azdogit.GitPullRequest, error) { + if m.getPullRequestsFunc == nil { + return &[]azdogit.GitPullRequest{}, nil + } + + return m.getPullRequestsFunc(ctx, args) +} + +func (m mockGitClient) GetPullRequestIterationChanges(ctx context.Context, args azdogit.GetPullRequestIterationChangesArgs) (*azdogit.GitPullRequestIterationChanges, error) { + if m.getPullRequestIterationChangesFunc == nil { + return &azdogit.GitPullRequestIterationChanges{}, nil + } + + return m.getPullRequestIterationChangesFunc(ctx, args) +} + +func (m mockGitClient) GetPullRequestIterations(ctx context.Context, args azdogit.GetPullRequestIterationsArgs) (*[]azdogit.GitPullRequestIteration, error) { + if m.getPullRequestIterationsFunc == nil { + return &[]azdogit.GitPullRequestIteration{}, nil + } + + return m.getPullRequestIterationsFunc(ctx, args) +} + +func (m mockGitClient) GetRefs(ctx context.Context, args azdogit.GetRefsArgs) (*azdogit.GetRefsResponseValue, error) { + if m.getRefsFunc == nil { + return &azdogit.GetRefsResponseValue{}, nil + } + + return m.getRefsFunc(ctx, args) +} + +func (m mockGitClient) UpdatePullRequest(ctx context.Context, args azdogit.UpdatePullRequestArgs) (*azdogit.GitPullRequest, error) { + if m.updatePullRequestFunc == nil { + return &azdogit.GitPullRequest{}, nil + } + + return m.updatePullRequestFunc(ctx, args) +} + +func TestIsPullRequestEmpty(t *testing.T) { + t.Run("returns true when latest iteration has no changes", func(t *testing.T) { + pr := AzureDevOps{Project: "project"} + pullRequestID := 42 + + isEmpty, err := pr.isPullRequestEmpty(context.Background(), mockGitClient{ + getPullRequestIterationsFunc: func(ctx context.Context, args azdogit.GetPullRequestIterationsArgs) (*[]azdogit.GitPullRequestIteration, error) { + require.Equal(t, pullRequestID, *args.PullRequestId) + return &[]azdogit.GitPullRequestIteration{ + {Id: intPtr(1)}, + {Id: intPtr(3)}, + {Id: intPtr(2)}, + }, nil + }, + getPullRequestIterationChangesFunc: func(ctx context.Context, args azdogit.GetPullRequestIterationChangesArgs) (*azdogit.GitPullRequestIterationChanges, error) { + require.Equal(t, 3, *args.IterationId) + return &azdogit.GitPullRequestIterationChanges{ + ChangeEntries: &[]azdogit.GitPullRequestChange{}, + }, nil + }, + }, "repository-id", pullRequestID) + + require.NoError(t, err) + assert.True(t, isEmpty) + }) + + t.Run("returns false when latest iteration has changes", func(t *testing.T) { + pr := AzureDevOps{Project: "project"} + pullRequestID := 42 + + isEmpty, err := pr.isPullRequestEmpty(context.Background(), mockGitClient{ + getPullRequestIterationsFunc: func(ctx context.Context, args azdogit.GetPullRequestIterationsArgs) (*[]azdogit.GitPullRequestIteration, error) { + return &[]azdogit.GitPullRequestIteration{ + {Id: intPtr(1)}, + }, nil + }, + getPullRequestIterationChangesFunc: func(ctx context.Context, args azdogit.GetPullRequestIterationChangesArgs) (*azdogit.GitPullRequestIterationChanges, error) { + return &azdogit.GitPullRequestIterationChanges{ + ChangeEntries: &[]azdogit.GitPullRequestChange{{}}, + }, nil + }, + }, "repository-id", pullRequestID) + + require.NoError(t, err) + assert.False(t, isEmpty) + }) +} + +func TestClosePullRequest(t *testing.T) { + pr := AzureDevOps{Project: "project"} + pullRequestID := 7 + + err := pr.closePullRequest(context.Background(), mockGitClient{ + updatePullRequestFunc: func(ctx context.Context, args azdogit.UpdatePullRequestArgs) (*azdogit.GitPullRequest, error) { + require.Equal(t, "repository-id", *args.RepositoryId) + require.Equal(t, "project", *args.Project) + require.Equal(t, pullRequestID, *args.PullRequestId) + require.NotNil(t, args.GitPullRequestToUpdate) + require.NotNil(t, args.GitPullRequestToUpdate.Status) + assert.Equal(t, azdogit.PullRequestStatusValues.Abandoned, *args.GitPullRequestToUpdate.Status) + + return &azdogit.GitPullRequest{}, nil + }, + }, "repository-id", pullRequestID) + + require.NoError(t, err) +} + +func TestDoesPullRequestHeadMatchRemoteBranchHead(t *testing.T) { + t.Run("returns true when latest iteration head matches remote branch head", func(t *testing.T) { + pr := AzureDevOps{ + Project: "project", + SourceBranch: "main", + } + pullRequestID := 42 + commitID := "abc123" + + matches, err := pr.doesPullRequestHeadMatchRemoteBranchHead(context.Background(), mockGitClient{ + getPullRequestIterationsFunc: func(ctx context.Context, args azdogit.GetPullRequestIterationsArgs) (*[]azdogit.GitPullRequestIteration, error) { + return &[]azdogit.GitPullRequestIteration{ + {Id: intPtr(1)}, + { + Id: intPtr(2), + SourceRefCommit: &azdogit.GitCommitRef{ + CommitId: stringPtr(commitID), + }, + }, + }, nil + }, + getRefsFunc: func(ctx context.Context, args azdogit.GetRefsArgs) (*azdogit.GetRefsResponseValue, error) { + require.Equal(t, "repository-id", *args.RepositoryId) + require.Equal(t, "project", *args.Project) + return &azdogit.GetRefsResponseValue{ + Value: []azdogit.GitRef{ + { + Name: stringPtr("refs/heads/main"), + ObjectId: stringPtr(commitID), + }, + }, + }, nil + }, + }, "repository-id", pullRequestID) + + require.NoError(t, err) + assert.True(t, matches) + }) + + t.Run("returns false when latest iteration head does not match remote branch head", func(t *testing.T) { + pr := AzureDevOps{ + Project: "project", + SourceBranch: "main", + } + + matches, err := pr.doesPullRequestHeadMatchRemoteBranchHead(context.Background(), mockGitClient{ + getPullRequestIterationsFunc: func(ctx context.Context, args azdogit.GetPullRequestIterationsArgs) (*[]azdogit.GitPullRequestIteration, error) { + return &[]azdogit.GitPullRequestIteration{ + { + Id: intPtr(2), + SourceRefCommit: &azdogit.GitCommitRef{ + CommitId: stringPtr("abc123"), + }, + }, + }, nil + }, + getRefsFunc: func(ctx context.Context, args azdogit.GetRefsArgs) (*azdogit.GetRefsResponseValue, error) { + return &azdogit.GetRefsResponseValue{ + Value: []azdogit.GitRef{ + { + Name: stringPtr("refs/heads/main"), + ObjectId: stringPtr("def456"), + }, + }, + }, nil + }, + }, "repository-id", 42) + + require.NoError(t, err) + assert.False(t, matches) + }) +} + +func TestRetryUntilPullRequestHeadMatchesRemoteBranchHead(t *testing.T) { + originalSleep := cleanupHeadMatchSleep + cleanupHeadMatchSleep = func(time.Duration) {} + defer func() { + cleanupHeadMatchSleep = originalSleep + }() + + t.Run("retries until head matches", func(t *testing.T) { + pr := AzureDevOps{ + Project: "project", + SourceBranch: "main", + } + attempts := 0 + + matches, err := pr.retryUntilPullRequestHeadMatchesRemoteBranchHead(context.Background(), mockGitClient{ + getPullRequestIterationsFunc: func(ctx context.Context, args azdogit.GetPullRequestIterationsArgs) (*[]azdogit.GitPullRequestIteration, error) { + attempts++ + commitID := "abc123" + if attempts < 3 { + commitID = "stale" + } + return &[]azdogit.GitPullRequestIteration{ + { + Id: intPtr(1), + SourceRefCommit: &azdogit.GitCommitRef{ + CommitId: &commitID, + }, + }, + }, nil + }, + getRefsFunc: func(ctx context.Context, args azdogit.GetRefsArgs) (*azdogit.GetRefsResponseValue, error) { + return &azdogit.GetRefsResponseValue{ + Value: []azdogit.GitRef{ + { + Name: stringPtr("refs/heads/main"), + ObjectId: stringPtr("abc123"), + }, + }, + }, nil + }, + }, "repository-id", 42, 3) + + require.NoError(t, err) + assert.True(t, matches) + assert.Equal(t, 3, attempts) + }) +} + +func intPtr(v int) *int { + return &v +} + +func stringPtr(v string) *string { + return &v +} diff --git a/pkg/plugins/resources/azuredevops/pullrequest/create.go b/pkg/plugins/resources/azuredevops/pullrequest/create.go new file mode 100644 index 0000000000..c977bba06c --- /dev/null +++ b/pkg/plugins/resources/azuredevops/pullrequest/create.go @@ -0,0 +1,159 @@ +package pullrequest + +import ( + "context" + "fmt" + + "github.com/sirupsen/logrus" + "github.com/updatecli/updatecli/pkg/core/reports" + utils "github.com/updatecli/updatecli/pkg/plugins/utils/action" + + azdogit "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" +) + +// CreateAction opens or updates a pull request on Azure DevOps. +func (a *AzureDevOps) CreateAction(ctx context.Context, report *reports.Action, resetDescription bool) error { + title := report.Title + if a.spec.Title != "" { + title = a.spec.Title + } + + body, err := a.pullRequestBody("", report, resetDescription) + if err != nil { + return fmt.Errorf("generate Azure DevOps pull request body: %w", err) + } + + existingPR, err := a.findExistingPullRequest(ctx) + if err != nil { + return fmt.Errorf("find existing pullrequest: %w", err) + } + + if existingPR != nil { + logrus.Debugln("Azure DevOps pull request already exists, updating it") + + body, err = a.pullRequestBody(stringValue(existingPR.Description), report, resetDescription) + if err != nil { + return fmt.Errorf("generate Azure DevOps pull request body: %w", err) + } + + updatePR := azdogit.GitPullRequest{ + Title: &title, + Description: &body, + } + + if a.spec.Draft != nil { + updatePR.IsDraft = a.spec.Draft + } + + repository, err := a.client.GetRepository(ctx, a.Project, a.Repository) + if err != nil { + return fmt.Errorf("get Azure DevOps repository: %w", err) + } + + repositoryID, err := repositoryID(repository) + if err != nil { + return err + } + + gitClient, err := a.client.NewGitClient(ctx) + if err != nil { + return fmt.Errorf("create Azure DevOps git client: %w", err) + } + + pr, err := gitClient.UpdatePullRequest(ctx, azdogit.UpdatePullRequestArgs{ + GitPullRequestToUpdate: &updatePR, + Project: &a.Project, + RepositoryId: &repositoryID, + PullRequestId: existingPR.PullRequestId, + }) + if err != nil { + return fmt.Errorf("update Azure DevOps pull request: %w", err) + } + + report.Title = stringValue(pr.Title) + report.Description = stringValue(pr.Description) + report.Link = a.pullRequestLink(pr) + + return nil + } + + ok, err := a.isRemoteBranchesExist(ctx) + if err != nil { + return err + } + + if !ok { + return fmt.Errorf("remote branches %q and %q do not exist, we can't open a pull request", a.SourceBranch, a.TargetBranch) + } + + repository, err := a.client.GetRepository(ctx, a.Project, a.Repository) + if err != nil { + return fmt.Errorf("get Azure DevOps repository: %w", err) + } + + repositoryID, err := repositoryID(repository) + if err != nil { + return err + } + + sourceRefName := refName(a.SourceBranch) + targetRefName := refName(a.TargetBranch) + + createPR := azdogit.GitPullRequest{ + Title: &title, + Description: &body, + SourceRefName: &sourceRefName, + TargetRefName: &targetRefName, + } + + if a.spec.Draft != nil { + createPR.IsDraft = a.spec.Draft + } + + gitClient, err := a.client.NewGitClient(ctx) + if err != nil { + return fmt.Errorf("create Azure DevOps git client: %w", err) + } + + pr, err := gitClient.CreatePullRequest(ctx, azdogit.CreatePullRequestArgs{ + GitPullRequestToCreate: &createPR, + Project: &a.Project, + RepositoryId: &repositoryID, + }) + if err != nil { + return fmt.Errorf("create Azure DevOps pull request: %w", err) + } + + report.Title = stringValue(pr.Title) + report.Description = stringValue(pr.Description) + report.Link = a.pullRequestLink(pr) + + logrus.Infof("Azure DevOps pull request successfully opened on %q", report.Link) + + return nil +} + +func (a *AzureDevOps) pullRequestBody(existingDescription string, report *reports.Action, resetBody bool) (string, error) { + if a.spec.Body != "" { + return a.spec.Body, nil + } + + if resetBody { + logrus.Debugf("Resetting pull request description with new report") + return utils.GeneratePullRequestBodyMarkdown("", report.ToActionsMarkdownString()) + } + + logrus.Debugf("Merging pull request description with new report") + + actionsMarkdown := report.ToActionsMarkdownString() + if existingDescription != "" { + mergedDescription, err := reports.MergeFromMarkdown(existingDescription, actionsMarkdown) + if err != nil { + return "", err + } + + return utils.GeneratePullRequestBodyMarkdown("", mergedDescription) + } + + return utils.GeneratePullRequestBodyMarkdown("", actionsMarkdown) +} diff --git a/pkg/plugins/resources/azuredevops/pullrequest/create_test.go b/pkg/plugins/resources/azuredevops/pullrequest/create_test.go new file mode 100644 index 0000000000..81a0720868 --- /dev/null +++ b/pkg/plugins/resources/azuredevops/pullrequest/create_test.go @@ -0,0 +1,60 @@ +package pullrequest + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/updatecli/updatecli/pkg/core/reports" + utils "github.com/updatecli/updatecli/pkg/plugins/utils/action" +) + +func TestPullRequestBody(t *testing.T) { + t.Run("uses custom body when specified", func(t *testing.T) { + pr := AzureDevOps{ + spec: Spec{ + Body: "custom body", + }, + } + + body, err := pr.pullRequestBody("existing body", &reports.Action{}, false) + + require.NoError(t, err) + assert.Equal(t, "custom body", body) + }) + + t.Run("generates markdown body for new pull requests", func(t *testing.T) { + report := &reports.Action{ + Title: "test", + Description: "update description", + } + pr := AzureDevOps{} + + got, err := pr.pullRequestBody("", report, false) + require.NoError(t, err) + + expected, err := utils.GeneratePullRequestBodyMarkdown("", report.ToActionsMarkdownString()) + require.NoError(t, err) + + assert.Equal(t, expected, got) + }) + + t.Run("merges existing markdown body for pull request updates", func(t *testing.T) { + report := &reports.Action{ + Title: "test", + Description: "new update description", + } + pr := AzureDevOps{} + + got, err := pr.pullRequestBody("existing body", report, true) + require.NoError(t, err) + + mergedDescription, err := reports.MergeFromMarkdown("existing body", report.ToActionsMarkdownString()) + require.NoError(t, err) + + expected, err := utils.GeneratePullRequestBodyMarkdown("", mergedDescription) + require.NoError(t, err) + + assert.Equal(t, expected, got) + }) +} diff --git a/pkg/plugins/resources/azuredevops/pullrequest/exist.go b/pkg/plugins/resources/azuredevops/pullrequest/exist.go new file mode 100644 index 0000000000..a9162fece5 --- /dev/null +++ b/pkg/plugins/resources/azuredevops/pullrequest/exist.go @@ -0,0 +1,27 @@ +package pullrequest + +import ( + "context" + "fmt" + + "github.com/sirupsen/logrus" + "github.com/updatecli/updatecli/pkg/core/reports" +) + +// CheckActionExist verifies if an Azure DevOps pull request is already opened. +func (a *AzureDevOps) CheckActionExist(ctx context.Context, report *reports.Action) error { + pr, err := a.findExistingPullRequest(ctx) + if err != nil { + return fmt.Errorf("find existing pullrequest: %w", err) + } + + if pr != nil { + logrus.Debugf("Azure DevOps pull request detected") + + report.Title = stringValue(pr.Title) + report.Link = a.pullRequestLink(pr) + report.Description = stringValue(pr.Description) + } + + return nil +} diff --git a/pkg/plugins/resources/azuredevops/pullrequest/main.go b/pkg/plugins/resources/azuredevops/pullrequest/main.go new file mode 100644 index 0000000000..9eb30e4a57 --- /dev/null +++ b/pkg/plugins/resources/azuredevops/pullrequest/main.go @@ -0,0 +1,122 @@ +package pullrequest + +import ( + "fmt" + + "github.com/go-viper/mapstructure/v2" + "github.com/sirupsen/logrus" + azdoclient "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/client" + "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/credential" + azdoscm "github.com/updatecli/updatecli/pkg/plugins/scms/azuredevops" +) + +// AzureDevOps contains information to interact with Azure DevOps pull requests. +type AzureDevOps struct { + spec Spec + // client handles the API authentication and helpers. + client azdoclient.Client + // scm allows to interact with a scm object. + scm *azdoscm.AzureDevOps + // SourceBranch specifies the pull request source branch. + SourceBranch string `yaml:",omitempty"` + // TargetBranch specifies the pull request target branch. + TargetBranch string `yaml:",omitempty"` + // Project specifies the Azure DevOps project. + Project string `yaml:",omitempty" jsonschema:"required"` + // Repository specifies the Azure DevOps repository. + Repository string `yaml:",omitempty" jsonschema:"required"` +} + +// New returns a new valid Azure DevOps action object. +func New(spec interface{}, scm *azdoscm.AzureDevOps) (AzureDevOps, error) { + var clientSpec azdoclient.Spec + var s Spec + + err := mapstructure.Decode(spec, &s) + if err != nil { + return AzureDevOps{}, fmt.Errorf("error decoding spec: %w", err) + } + + err = mapstructure.Decode(spec, &clientSpec) + if err != nil { + return AzureDevOps{}, fmt.Errorf("error decoding client spec: %w", err) + } + + if clientSpec.URL == "" { + clientSpec.URL = s.URL + } + + if clientSpec.Project == "" { + clientSpec.Project = s.Project + } + + if clientSpec.Repository == "" { + clientSpec.Repository = s.Repository + } + + if clientSpec.Organization == "" { + clientSpec.Organization = s.Organization + } + + usernameFromEnv, tokenFromEnv := credential.GetCredentialsFromEnv() + + if clientSpec.Username == "" { + clientSpec.Username = s.Username + + if usernameFromEnv != "" { + logrus.Debugf("using Azure DevOps username from environment variable %s", credential.ENVIRONMENT_AZURE_DEVOPS_USERNAME) + clientSpec.Username = usernameFromEnv + } + } + + if clientSpec.Token == "" { + clientSpec.Token = s.Token + if tokenFromEnv != "" { + logrus.Debugf("using Azure DevOps token from environment variable %s", credential.ENVIRONMENT_AZURE_DEVOPS_TOKEN) + clientSpec.Token = tokenFromEnv + } + } + + if scm != nil { + if clientSpec.Token == "" && scm.Spec.Token != "" { + clientSpec.Token = scm.Spec.Token + } + + if clientSpec.URL == "" && scm.Spec.URL != "" { + clientSpec.URL = scm.Spec.URL + } + + if clientSpec.Username == "" && scm.Spec.Username != "" { + clientSpec.Username = scm.Spec.Username + } + + if clientSpec.Project == "" && scm.Spec.Project != "" { + clientSpec.Project = scm.Spec.Project + } + + if clientSpec.Repository == "" && scm.Spec.Repository != "" { + clientSpec.Repository = scm.Spec.Repository + } + + if clientSpec.Organization == "" && scm.Spec.Organization != "" { + clientSpec.Organization = scm.Spec.Organization + } + } + + c, err := azdoclient.New(clientSpec) + if err != nil { + return AzureDevOps{}, err + } + + s.Spec = c.Spec + + a := AzureDevOps{ + spec: s, + client: c, + scm: scm, + } + + a.inheritFromScm() + + return a, nil +} diff --git a/pkg/plugins/resources/azuredevops/pullrequest/main_test.go b/pkg/plugins/resources/azuredevops/pullrequest/main_test.go new file mode 100644 index 0000000000..94dd3febda --- /dev/null +++ b/pkg/plugins/resources/azuredevops/pullrequest/main_test.go @@ -0,0 +1,114 @@ +package pullrequest + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + azdoclient "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/client" + azdoscm "github.com/updatecli/updatecli/pkg/plugins/scms/azuredevops" +) + +func TestNew(t *testing.T) { + testData := []struct { + name string + spec Spec + scm *azdoscm.AzureDevOps + expectedProject string + expectedRepository string + expectedSourceBranch string + expectedTargetBranch string + wantErr bool + }{ + { + name: "Test basic scenario", + spec: Spec{ + SourceBranch: "workingBranch", + TargetBranch: "main", + Spec: azdoclient.Spec{ + URL: "https://dev.azure.com", + Organization: "updatecli", + Project: "updatecli", + Repository: "updatecli", + }, + }, + expectedProject: "updatecli", + expectedRepository: "updatecli", + expectedSourceBranch: "workingBranch", + expectedTargetBranch: "main", + }, + { + name: "Test parameter inheritance", + spec: Spec{}, + scm: &azdoscm.AzureDevOps{ + Spec: azdoscm.Spec{ + Branch: "main", + Spec: azdoclient.Spec{ + URL: "https://dev.azure.com", + Organization: "updatecli", + Project: "updatecli-project", + Repository: "updatecli-repository", + }, + }, + }, + expectedProject: "updatecli-project", + expectedRepository: "updatecli-repository", + expectedSourceBranch: "main", + expectedTargetBranch: "main", + }, + { + name: "Test default URL when not specified", + spec: Spec{ + Spec: azdoclient.Spec{ + Organization: "updatecli", + Project: "updatecli", + Repository: "updatecli", + }, + }, + expectedProject: "updatecli", + expectedRepository: "updatecli", + }, + } + + for _, tt := range testData { + t.Run(tt.name, func(t *testing.T) { + g, gotErr := New(tt.spec, tt.scm) + + if tt.wantErr { + require.Error(t, gotErr) + return + } + + require.NoError(t, gotErr) + assert.Equal(t, g.Project, tt.expectedProject) + assert.Equal(t, g.Repository, tt.expectedRepository) + assert.Equal(t, g.SourceBranch, tt.expectedSourceBranch) + assert.Equal(t, g.TargetBranch, tt.expectedTargetBranch) + }) + } +} + +func TestRefName(t *testing.T) { + testData := []struct { + name string + branch string + expected string + }{ + { + name: "Plain branch", + branch: "main", + expected: "refs/heads/main", + }, + { + name: "Already full ref", + branch: "refs/heads/main", + expected: "refs/heads/main", + }, + } + + for _, tt := range testData { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, refName(tt.branch)) + }) + } +} diff --git a/pkg/plugins/resources/azuredevops/pullrequest/spec.go b/pkg/plugins/resources/azuredevops/pullrequest/spec.go new file mode 100644 index 0000000000..6a52ddebd3 --- /dev/null +++ b/pkg/plugins/resources/azuredevops/pullrequest/spec.go @@ -0,0 +1,18 @@ +package pullrequest + +import azdoclient "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/client" + +// Spec defines settings used to interact with Azure DevOps pull requests. +type Spec struct { + azdoclient.Spec + // "sourcebranch" defines the source branch used to create the pull request. + SourceBranch string `yaml:",omitempty"` + // "targetbranch" defines the target branch used to create the pull request. + TargetBranch string `yaml:",omitempty"` + // "title" defines the pull request title. + Title string `yaml:",omitempty"` + // "body" defines a custom pull request body. + Body string `yaml:",omitempty"` + // "draft" defines if the pull request should be created as draft. + Draft *bool `yaml:",omitempty"` +} diff --git a/pkg/plugins/resources/azuredevops/pullrequest/utils.go b/pkg/plugins/resources/azuredevops/pullrequest/utils.go new file mode 100644 index 0000000000..448b1042d3 --- /dev/null +++ b/pkg/plugins/resources/azuredevops/pullrequest/utils.go @@ -0,0 +1,322 @@ +package pullrequest + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/sirupsen/logrus" + "github.com/updatecli/updatecli/pkg/core/result" + azdoclient "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/client" + + azdogit "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" +) + +type gitClient interface { + GetPullRequests(context.Context, azdogit.GetPullRequestsArgs) (*[]azdogit.GitPullRequest, error) + GetPullRequestIterationChanges(context.Context, azdogit.GetPullRequestIterationChangesArgs) (*azdogit.GitPullRequestIterationChanges, error) + GetPullRequestIterations(context.Context, azdogit.GetPullRequestIterationsArgs) (*[]azdogit.GitPullRequestIteration, error) + GetRefs(context.Context, azdogit.GetRefsArgs) (*azdogit.GetRefsResponseValue, error) + UpdatePullRequest(context.Context, azdogit.UpdatePullRequestArgs) (*azdogit.GitPullRequest, error) +} + +const cleanupHeadMatchRetryDelay = time.Second + +var ( + // cleanupHeadMatchSleep is a variable to allow overriding time.Sleep in tests. + cleanupHeadMatchSleep = time.Sleep +) + +func (a *AzureDevOps) findExistingPullRequest(ctx context.Context) (*azdogit.GitPullRequest, error) { + repository, err := a.client.GetRepository(ctx, a.Project, a.Repository) + if err != nil { + return nil, fmt.Errorf("find existing pull request: %w", err) + } + + repositoryID, err := repositoryID(repository) + if err != nil { + return nil, err + } + + gitClient, err := a.client.NewGitClient(ctx) + if err != nil { + return nil, fmt.Errorf("create Azure DevOps git client: %w", err) + } + + sourceRefName := refName(a.SourceBranch) + targetRefName := refName(a.TargetBranch) + status := azdogit.PullRequestStatusValues.Active + + pullRequests, err := gitClient.GetPullRequests(ctx, azdogit.GetPullRequestsArgs{ + Project: &a.Project, + RepositoryId: &repositoryID, + SearchCriteria: &azdogit.GitPullRequestSearchCriteria{ + RepositoryId: repository.Id, + SourceRefName: &sourceRefName, + Status: &status, + TargetRefName: &targetRefName, + }, + }) + if err != nil { + return nil, fmt.Errorf("list Azure DevOps pull requests: %w", err) + } + + for _, pr := range *pullRequests { + if stringValue(pr.SourceRefName) == sourceRefName && + stringValue(pr.TargetRefName) == targetRefName && + pr.Status != nil && + *pr.Status == azdogit.PullRequestStatusValues.Active { + logrus.Infof("%s Azure DevOps pull request detected at:\n\t%s", + result.SUCCESS, + a.pullRequestLink(&pr)) + + return &pr, nil + } + } + + return nil, nil +} + +func (a *AzureDevOps) isRemoteBranchesExist(ctx context.Context) (bool, error) { + repository, err := a.client.GetRepository(ctx, a.Project, a.Repository) + if err != nil { + return false, fmt.Errorf("is remote branch exist: %w", err) + } + + repositoryID, err := repositoryID(repository) + if err != nil { + return false, err + } + + gitClient, err := a.client.NewGitClient(ctx) + if err != nil { + return false, fmt.Errorf("create Azure DevOps git client: %w", err) + } + + sourceExists, err := branchExists(ctx, gitClient, a.Project, repositoryID, a.SourceBranch) + if err != nil { + return false, err + } + + targetExists, err := branchExists(ctx, gitClient, a.Project, repositoryID, a.TargetBranch) + if err != nil { + return false, err + } + + if !sourceExists { + logrus.Debugf("Branch %q not found on remote repository %s/%s", a.SourceBranch, a.Project, a.Repository) + } + + if !targetExists { + logrus.Debugf("Branch %q not found on remote repository %s/%s", a.TargetBranch, a.Project, a.Repository) + } + + return sourceExists && targetExists, nil +} + +func (a *AzureDevOps) closePullRequest(ctx context.Context, gitClient gitClient, repositoryID string, pullRequestID int) error { + status := azdogit.PullRequestStatusValues.Abandoned + + _, err := gitClient.UpdatePullRequest(ctx, azdogit.UpdatePullRequestArgs{ + GitPullRequestToUpdate: &azdogit.GitPullRequest{ + Status: &status, + }, + Project: &a.Project, + RepositoryId: &repositoryID, + PullRequestId: &pullRequestID, + }) + if err != nil { + return fmt.Errorf("update Azure DevOps pull request: %w", err) + } + + return nil +} + +func (a *AzureDevOps) isPullRequestEmpty(ctx context.Context, gitClient gitClient, repositoryID string, pullRequestID int) (bool, error) { + latestIteration, err := a.getLatestPullRequestIteration(ctx, gitClient, repositoryID, pullRequestID) + if err != nil { + return false, err + } + + if latestIteration == nil || latestIteration.Id == nil { + return false, nil + } + + top := 1 + compareTo := 0 + + changes, err := gitClient.GetPullRequestIterationChanges(ctx, azdogit.GetPullRequestIterationChangesArgs{ + Project: &a.Project, + RepositoryId: &repositoryID, + PullRequestId: &pullRequestID, + IterationId: latestIteration.Id, + Top: &top, + CompareTo: &compareTo, + }) + if err != nil { + return false, fmt.Errorf("list Azure DevOps pull request changes: %w", err) + } + + return changes.ChangeEntries == nil || len(*changes.ChangeEntries) == 0, nil +} + +func (a *AzureDevOps) doesPullRequestHeadMatchRemoteBranchHead(ctx context.Context, gitClient gitClient, repositoryID string, pullRequestID int) (bool, error) { + latestIteration, err := a.getLatestPullRequestIteration(ctx, gitClient, repositoryID, pullRequestID) + if err != nil { + return false, err + } + + if latestIteration == nil || latestIteration.SourceRefCommit == nil || latestIteration.SourceRefCommit.CommitId == nil { + return false, nil + } + + sourceBranchRef, err := getBranchRef(ctx, gitClient, a.Project, repositoryID, a.SourceBranch) + if err != nil { + return false, err + } + + if sourceBranchRef == nil || sourceBranchRef.ObjectId == nil { + return false, nil + } + + return *latestIteration.SourceRefCommit.CommitId == *sourceBranchRef.ObjectId, nil +} + +func (a *AzureDevOps) retryUntilPullRequestHeadMatchesRemoteBranchHead(ctx context.Context, gitClient gitClient, repositoryID string, pullRequestID int, maxAttempts int) (bool, error) { + for attempt := 1; attempt <= maxAttempts; attempt++ { + matches, err := a.doesPullRequestHeadMatchRemoteBranchHead(ctx, gitClient, repositoryID, pullRequestID) + if err != nil { + return false, err + } + + if matches { + return true, nil + } + + if attempt < maxAttempts { + logrus.Debugf("Azure DevOps pull request head does not match remote branch head yet, retrying (%d/%d)", attempt, maxAttempts) + cleanupHeadMatchSleep(cleanupHeadMatchRetryDelay) + } + } + + return false, nil +} + +func (a *AzureDevOps) getLatestPullRequestIteration(ctx context.Context, gitClient gitClient, repositoryID string, pullRequestID int) (*azdogit.GitPullRequestIteration, error) { + iterations, err := gitClient.GetPullRequestIterations(ctx, azdogit.GetPullRequestIterationsArgs{ + Project: &a.Project, + RepositoryId: &repositoryID, + PullRequestId: &pullRequestID, + }) + if err != nil { + return nil, fmt.Errorf("list Azure DevOps pull request iterations: %w", err) + } + + var latestIteration *azdogit.GitPullRequestIteration + + for i := range *iterations { + iteration := (*iterations)[i] + if iteration.Id == nil { + continue + } + + if latestIteration == nil || *iteration.Id > *latestIteration.Id { + latestIteration = &iteration + } + } + + return latestIteration, nil +} + +func (a *AzureDevOps) inheritFromScm() { + if a.scm != nil { + _, a.SourceBranch, a.TargetBranch = a.scm.GetBranches() + a.Project = a.scm.Spec.Project + a.Repository = a.scm.Spec.Repository + } + + if a.spec.SourceBranch != "" { + a.SourceBranch = a.spec.SourceBranch + } + + if a.spec.TargetBranch != "" { + a.TargetBranch = a.spec.TargetBranch + } + + if a.spec.Project != "" { + a.Project = a.spec.Project + } + + if a.spec.Repository != "" { + a.Repository = a.spec.Repository + } +} + +func (a *AzureDevOps) pullRequestLink(pr *azdogit.GitPullRequest) string { + if pr == nil || pr.PullRequestId == nil { + return "" + } + + return azdoclient.PullRequestURL(a.client.Spec.URL, a.client.Spec.Organization, a.Project, a.Repository, *pr.PullRequestId) +} + +func refName(branch string) string { + if strings.HasPrefix(branch, "refs/") { + return branch + } + + return fmt.Sprintf("refs/heads/%s", branch) +} + +func branchExists(ctx context.Context, gitClient gitClient, project, repositoryID, branch string) (bool, error) { + ref, err := getBranchRef(ctx, gitClient, project, repositoryID, branch) + if err != nil { + return false, err + } + + return ref != nil, nil +} + +func getBranchRef(ctx context.Context, gitClient gitClient, project, repositoryID, branch string) (*azdogit.GitRef, error) { + filter := refName(branch) + top := 1000 + + refs, err := gitClient.GetRefs(ctx, azdogit.GetRefsArgs{ + Project: &project, + RepositoryId: &repositoryID, + // We need to retrieve a batch of refs. + // For some reason the filter doesn't seem to work + // So we use the FilterContains parameter to reduce the number of refs + // returned and filter the rest manually + FilterContains: &branch, + Top: &top, + }) + if err != nil { + return nil, fmt.Errorf("list Azure DevOps refs for branch %q: %w", branch, err) + } + + for _, ref := range refs.Value { + if stringValue(ref.Name) == filter { + return &ref, nil + } + } + + return nil, nil +} + +func repositoryID(repository *azdogit.GitRepository) (string, error) { + if repository == nil || repository.Id == nil { + return "", fmt.Errorf("azure devops repository not found") + } + + return repository.Id.String(), nil +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + + return *value +} diff --git a/pkg/plugins/scms/azuredevops/main.go b/pkg/plugins/scms/azuredevops/main.go new file mode 100644 index 0000000000..f46daf9274 --- /dev/null +++ b/pkg/plugins/scms/azuredevops/main.go @@ -0,0 +1,437 @@ +package azuredevops + +import ( + "context" + "errors" + "fmt" + "os" + "path" + "strings" + + "github.com/go-git/go-git/v5/plumbing/protocol/packp/capability" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-viper/mapstructure/v2" + "github.com/sirupsen/logrus" + "github.com/updatecli/updatecli/pkg/core/tmp" + azdoclient "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/client" + "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/credential" + "github.com/updatecli/updatecli/pkg/plugins/scms/git/commit" + "github.com/updatecli/updatecli/pkg/plugins/scms/git/sign" + "github.com/updatecli/updatecli/pkg/plugins/utils/gitgeneric" +) + +const ( + // Kind defines the SCM kind for Azure DevOps. + Kind = "azuredevops" +) + +// Spec defines settings used to interact with Azure DevOps Git repositories. +type Spec struct { + azdoclient.Spec `yaml:",inline,omitempty"` + // "commitMessage" is used to generate the final commit message. + CommitMessage commit.Commit `yaml:",omitempty"` + // "directory" defines the local path where the git repository is cloned. + Directory string `yaml:",omitempty"` + // Depth defines the depth used when cloning the git repository. + Depth *int `yaml:",omitempty"` + // "email" defines the email used to commit changes. + Email string `yaml:",omitempty"` + // "force" is used during the git push phase to run `git push --force`. + Force *bool `yaml:",omitempty"` + // "gpg" specifies the GPG key and passphrased used for commit signing. + GPG sign.GPGSpec `yaml:",omitempty"` + // "user" specifies the user associated with new git commit messages created by Updatecli. + User string `yaml:",omitempty"` + // "branch" defines the git branch to work on. + Branch string `yaml:",omitempty"` + // WorkingBranchPrefix defines the prefix used to create a working branch. + WorkingBranchPrefix *string `yaml:",omitempty"` + // WorkingBranchSeparator defines the separator used to create a working branch. + WorkingBranchSeparator *string `yaml:",omitempty"` + // "submodules" defines if Updatecli should checkout submodules. + Submodules *bool `yaml:",omitempty"` + // "workingBranch" defines if Updatecli should use a temporary branch to work on. + WorkingBranch *bool `yaml:",omitempty"` +} + +// AzureDevOps contains settings to interact with Azure DevOps. +type AzureDevOps struct { + force bool + Spec Spec + client azdoclient.Client + pipelineID string + nativeGitHandler gitgeneric.GitHandler + workingBranch bool + workingBranchPrefix string + workingBranchSeparator string +} + +// New returns a new valid Azure DevOps object. +func New(spec interface{}, pipelineID string) (*AzureDevOps, error) { + var s Spec + var clientSpec azdoclient.Spec + + // mapstructure.Decode cannot handle embedded fields + // hence we decode it in two steps + err := mapstructure.Decode(spec, &clientSpec) + if err != nil { + return &AzureDevOps{}, err + } + + err = mapstructure.Decode(spec, &s) + if err != nil { + return &AzureDevOps{}, err + } + + if clientSpec.Organization == "" { + clientSpec.Organization = s.Organization + } + + if clientSpec.URL == "" { + clientSpec.URL = s.URL + } + + if clientSpec.Project == "" { + clientSpec.Project = s.Project + } + + if clientSpec.Repository == "" { + clientSpec.Repository = s.Repository + } + + usernameFromEnv, tokenFromEnv := credential.GetCredentialsFromEnv() + if clientSpec.Username == "" { + clientSpec.Username = s.Username + + if usernameFromEnv != "" { + logrus.Debugf("using Azure DevOps username from environment variable %s", credential.ENVIRONMENT_AZURE_DEVOPS_USERNAME) + clientSpec.Username = usernameFromEnv + } + } + + if clientSpec.Token == "" { + clientSpec.Token = s.Token + if tokenFromEnv != "" { + logrus.Debugf("using Azure DevOps token from environment variable %s", credential.ENVIRONMENT_AZURE_DEVOPS_TOKEN) + clientSpec.Token = tokenFromEnv + } + } + + err = clientSpec.Sanitize() + if err != nil { + return &AzureDevOps{}, err + } + + s.Spec = clientSpec + + err = s.Validate() + if err != nil { + return &AzureDevOps{}, err + } + + if s.Directory == "" { + s.Directory = path.Join(tmp.Directory, Kind, s.Project, s.Repository) + } + + if s.Branch == "" { + logrus.Warningf("no git branch specified, fallback to %q", "main") + s.Branch = "main" + } + + workingBranch := true + if s.WorkingBranch != nil { + workingBranch = *s.WorkingBranch + } + + workingBranchPrefix := "updatecli" + if s.WorkingBranchPrefix != nil { + workingBranchPrefix = *s.WorkingBranchPrefix + } + + workingBranchSeparator := "_" + if s.WorkingBranchSeparator != nil { + workingBranchSeparator = *s.WorkingBranchSeparator + } + + force := true + if s.Force != nil { + force = *s.Force + } + + if force && !workingBranch && s.Force == nil { + errorMsg := fmt.Sprintf(` +Better safe than sorry. + +Updatecli may be pushing unwanted changes to the branch %q. + +The Azure DevOps scm plugin has by default the force option set to true, +The scm force option set to true means that Updatecli is going to run "git push --force" +Some target plugin, like the shell one, run "git commit -A" to catch all changes done by that target. + +If you know what you are doing, please set the force option to true in your configuration file to ignore this error message. +`, s.Branch) + + logrus.Errorln(errorMsg) + return nil, errors.New("unclear configuration, better safe than sorry") + } + + if s.Email == "" { + s.Email = gitgeneric.DefaultGitCommitEmailAddress + } + + if s.User == "" { + s.User = gitgeneric.DefaultGitCommitUserName + } + + if s.Username == "" && s.Token != "" { + s.Username = gitgeneric.DefaultGitCommitUserName + } + + c, err := azdoclient.New(clientSpec) + if err != nil { + return &AzureDevOps{}, err + } + + nativeGitHandler := gitgeneric.GoGit{} + + azdo := AzureDevOps{ + force: force, + Spec: s, + client: c, + pipelineID: pipelineID, + nativeGitHandler: &nativeGitHandler, + workingBranch: workingBranch, + workingBranchPrefix: workingBranchPrefix, + workingBranchSeparator: workingBranchSeparator, + } + + return &azdo, nil +} + +func (s *Spec) Validate() error { + return s.Spec.Validate() +} + +func (a *AzureDevOps) repositoryURL() string { + return azdoclient.GitURL(a.Spec.URL, a.Spec.Organization, a.Spec.Project, a.Spec.Repository) +} + +// GetBranches returns the source, working and target branches. +func (a *AzureDevOps) GetBranches() (sourceBranch, workingBranch, targetBranch string) { + sourceBranch = a.Spec.Branch + workingBranch = a.Spec.Branch + targetBranch = a.Spec.Branch + + if len(a.pipelineID) > 0 && a.workingBranch { + workingBranch = a.nativeGitHandler.SanitizeBranchName( + strings.Join([]string{a.workingBranchPrefix, targetBranch, a.pipelineID}, a.workingBranchSeparator)) + } + + return sourceBranch, workingBranch, targetBranch +} + +// Clone runs `git clone`. +func (a *AzureDevOps) Clone() (string, error) { + + // This global is shared across all SCMs/pipelines in the same process, + // Clone() mutates the global go-git setting transport.UnsupportedCapabilities and then need to be restored. + // otherwise it can cause surprising behavior (and data races) when multiple pipelines run or + // when other SCMs clone/fetch after an Azure DevOps clone. + currentUnsupportedCapabilities := transport.UnsupportedCapabilities + defer func() { + transport.UnsupportedCapabilities = currentUnsupportedCapabilities + }() + + transport.UnsupportedCapabilities = []capability.Capability{ + capability.ThinPack, + } + + // Source: + // * https://github.com/go-git/go-git/blob/master/_examples/azure_devops/main.go + // Azure DevOps requires capabilities multi_ack / multi_ack_detailed, + // which are not fully implemented and by default are included in + // transport.UnsupportedCapabilities. + // + // The initial clone operations require a full download of the repository, + // and therefore those unsupported capabilities are not as crucial, so + // by removing them from that list allows for the first clone to work + // successfully. + // + // Additional fetches will yield issues, therefore work always from a clean + // clone until those capabilities are fully supported. + // + // New commits and pushes against a remote worked without any issues. + transport.UnsupportedCapabilities = []capability.Capability{ + capability.ThinPack, + } + + err := a.nativeGitHandler.Clone( + a.Spec.Username, + a.Spec.Token, + a.GetURL(), + a.GetDirectory(), + a.Spec.Submodules, + a.Spec.Depth, + ) + if err != nil { + logrus.Errorf("failed cloning Azure DevOps repository %q", a.GetURL()) + return "", err + } + + return a.Spec.Directory, nil +} + +// Checkout creates and then uses a temporary git branch. +func (a *AzureDevOps) Checkout() error { + sourceBranch, workingBranch, _ := a.GetBranches() + + return a.nativeGitHandler.Checkout( + a.Spec.Username, + a.Spec.Token, + sourceBranch, + workingBranch, + a.Spec.Directory, + a.force, + a.Spec.Depth, + ) +} + +// Commit runs `git commit`. +func (a *AzureDevOps) Commit(ctx context.Context, message string) error { + commitMessage, err := a.Spec.CommitMessage.Generate(message) + if err != nil { + return err + } + + err = a.nativeGitHandler.Commit( + a.Spec.User, + a.Spec.Email, + commitMessage, + a.GetDirectory(), + a.Spec.GPG.SigningKey, + a.Spec.GPG.Passphrase, + ) + if err != nil { + return err + } + + if a.Spec.CommitMessage.IsSquash() { + sourceBranch, workingBranch, _ := a.GetBranches() + if err = a.nativeGitHandler.SquashCommit(a.GetDirectory(), sourceBranch, workingBranch, gitgeneric.SquashCommitOptions{ + IncludeCommitTitles: true, + Message: commitMessage, + SigninKey: a.Spec.GPG.SigningKey, + SigninPassphrase: a.Spec.GPG.Passphrase, + }); err != nil { + return err + } + } + + return nil +} + +// Add runs `git add`. +func (a *AzureDevOps) Add(files []string) error { + return a.nativeGitHandler.Add(files, a.Spec.Directory) +} + +// CleanWorkingBranch checks if the working branch is diverged from the target branch and removes it if not. +func (a *AzureDevOps) CleanWorkingBranch() (bool, error) { + _, workingBranch, targetBranch := a.GetBranches() + + if workingBranch == targetBranch { + logrus.Infof("Skipping cleaning working branch %q on %q (same as target branch)\n", workingBranch, a.GetURL()) + return false, nil + } + + isSimilarBranch, err := a.nativeGitHandler.IsSimilarBranch(workingBranch, targetBranch, a.GetDirectory()) + if err != nil { + return false, fmt.Errorf("failed to compare working branch %q with target branch %q: %w", workingBranch, targetBranch, err) + } + + if isSimilarBranch { + if err = a.nativeGitHandler.DeleteBranch(workingBranch, a.GetDirectory(), a.Spec.Username, a.Spec.Token); err != nil { + return false, fmt.Errorf("failed to delete working branch %q from %q: %w", workingBranch, a.GetDirectory(), err) + } + return true, nil + } + + return false, nil +} + +// GetDirectory returns the local git repository path. +func (a *AzureDevOps) GetDirectory() (directory string) { + return a.Spec.Directory +} + +// GetURL returns an Azure DevOps git URL. +func (a *AzureDevOps) GetURL() string { + return a.repositoryURL() +} + +// IsRemoteBranchUpToDate checks if the local working branch is up to date with the remote branch. +func (a *AzureDevOps) IsRemoteBranchUpToDate() (bool, error) { + sourceBranch, workingBranch, _ := a.GetBranches() + + return a.nativeGitHandler.IsLocalBranchSyncedWithRemote( + sourceBranch, + workingBranch, + a.Spec.Username, + a.Spec.Token, + a.GetDirectory(), + ) +} + +// Clean deletes the local working directory. +func (a *AzureDevOps) Clean() error { + return os.RemoveAll(a.Spec.Directory) +} + +// IsRemoteWorkingBranchExist checks if the remote working branch exists. +func (a *AzureDevOps) IsRemoteWorkingBranchExist() (bool, error) { + _, workingBranch, _ := a.GetBranches() + + return a.nativeGitHandler.IsRemoteBranchExist( + workingBranch, + a.Spec.Username, + a.Spec.Token, + a.GetDirectory(), + ) +} + +// Push runs `git push`. +func (a *AzureDevOps) Push() (bool, error) { + return a.nativeGitHandler.Push( + a.Spec.Username, + a.Spec.Token, + a.GetDirectory(), + a.force, + ) +} + +// PushTag pushes tags. +func (a *AzureDevOps) PushTag(tag string) error { + return a.nativeGitHandler.PushTag( + tag, + a.Spec.Username, + a.Spec.Token, + a.GetDirectory(), + a.force, + ) +} + +// PushBranch pushes branches. +func (a *AzureDevOps) PushBranch(branch string) error { + return a.nativeGitHandler.PushBranch( + branch, + a.Spec.Username, + a.Spec.Token, + a.GetDirectory(), + a.force, + ) +} + +// GetChangedFiles returns a list of changed files. +func (a *AzureDevOps) GetChangedFiles(workingDir string) ([]string, error) { + return a.nativeGitHandler.GetChangedFiles(workingDir) +} diff --git a/pkg/plugins/scms/azuredevops/summary.go b/pkg/plugins/scms/azuredevops/summary.go new file mode 100644 index 0000000000..c4cbf51708 --- /dev/null +++ b/pkg/plugins/scms/azuredevops/summary.go @@ -0,0 +1,22 @@ +package azuredevops + +import ( + "fmt" + "net/url" + "strings" + + azdoclient "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/client" +) + +// Summary returns a brief description of the Azure DevOps SCM configuration. +func (a *AzureDevOps) Summary() string { + URL, err := url.Parse(azdoclient.GitURL(a.Spec.URL, a.Spec.Organization, a.Spec.Project, a.Spec.Repository)) + if err != nil || URL == nil { + return "" + } + + URL.User = nil + URL.Scheme = "" + + return fmt.Sprintf("%s@%s", strings.TrimPrefix(URL.String(), "//"), a.Spec.Branch) +} diff --git a/pkg/plugins/scms/azuredevops/summary_test.go b/pkg/plugins/scms/azuredevops/summary_test.go new file mode 100644 index 0000000000..1973c56828 --- /dev/null +++ b/pkg/plugins/scms/azuredevops/summary_test.go @@ -0,0 +1,55 @@ +package azuredevops + +import ( + "testing" + + azdoclient "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/client" +) + +func TestSummary(t *testing.T) { + tests := []struct { + name string + repository *AzureDevOps + expected string + }{ + { + name: "Test Summary", + repository: &AzureDevOps{ + Spec: Spec{ + Branch: "main", + Spec: azdoclient.Spec{ + URL: "https://dev.azure.com/updatecli", + Project: "updatecli", + Repository: "updatecli", + }, + }, + }, + expected: "dev.azure.com/updatecli/updatecli/_git/updatecli@main", + }, + { + name: "Test Summary with URL credentials", + repository: &AzureDevOps{ + Spec: Spec{ + Branch: "main", + Spec: azdoclient.Spec{ + // #nosec G101 + URL: "https://username:password@dev.azure.com/updatecli", + Project: "updatecli", + Repository: "updatecli", + }, + }, + }, + expected: "dev.azure.com/updatecli/updatecli/_git/updatecli@main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.repository.Summary() + + if result != tt.expected { + t.Errorf("Summary() = %v, want %v", result, tt.expected) + } + }) + } +} diff --git a/pkg/plugins/scms/azuredevopssearch/description.go b/pkg/plugins/scms/azuredevopssearch/description.go new file mode 100644 index 0000000000..e3bdb21d24 --- /dev/null +++ b/pkg/plugins/scms/azuredevopssearch/description.go @@ -0,0 +1,8 @@ +package azuredevopssearch + +import "fmt" + +// Summary returns a brief description of the Azure DevOps search SCM configuration. +func (a *AzureDevOpsSearch) Summary() string { + return fmt.Sprintf("Azure DevOps Search:\n\tOrganization: %q\n\tProject: %q\n\tRepository: %q\n\tBranch: %q\n\tLimit: %d", a.spec.Organization, a.projectPattern, a.repositoryPattern, a.branch, a.limit) +} diff --git a/pkg/plugins/scms/azuredevopssearch/main.go b/pkg/plugins/scms/azuredevopssearch/main.go new file mode 100644 index 0000000000..2cc31c3649 --- /dev/null +++ b/pkg/plugins/scms/azuredevopssearch/main.go @@ -0,0 +1,147 @@ +package azuredevopssearch + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/go-viper/mapstructure/v2" + azdocore "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + azdogit "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" + "github.com/sirupsen/logrus" + azclient "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/client" + + "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/credential" +) + +const Kind = "azuredevopssearch" + +type client interface { + GetProjects(context.Context, azdocore.GetProjectsArgs) (*azdocore.GetProjectsResponseValue, error) + GetBranches(context.Context, azdogit.GetBranchesArgs) (*[]azdogit.GitBranchStats, error) + GetRepositories(context.Context, azdogit.GetRepositoriesArgs) (*[]azdogit.GitRepository, error) +} + +type AzureDevOpsSearch struct { + spec Spec + limit int + branch string + projectPattern string + repositoryPattern string + client client +} + +func New(s interface{}) (*AzureDevOpsSearch, error) { + var spec Spec + + if err := mapstructure.Decode(s, &spec); err != nil { + return nil, err + } + + spec.sanitize() + if err := spec.Validate(); err != nil { + return nil, err + } + + limit := DefaultRepositoryLimit + if spec.Limit != nil { + limit = *spec.Limit + } + + branchPattern := "^main$" + if spec.Branch != "" { + branchPattern = spec.Branch + } + + repositoryPattern := ".*" + if spec.Repository != "" { + repositoryPattern = spec.Repository + } + + projectPattern := ".*" + if spec.Project != "" { + projectPattern = spec.Project + } + + username, token := spec.Username, spec.Token + usernameFromEnv, tokenFromEnv := credential.GetCredentialsFromEnv() + if usernameFromEnv != "" { + logrus.Debugf("using Azure DevOps username from environment variable %s", credential.ENVIRONMENT_AZURE_DEVOPS_USERNAME) + username = usernameFromEnv + } + + if tokenFromEnv != "" { + logrus.Debugf("using Azure DevOps token from environment variable %s", credential.ENVIRONMENT_AZURE_DEVOPS_TOKEN) + token = tokenFromEnv + } + + if _, err := regexp.Compile(projectPattern); err != nil { + return nil, fmt.Errorf("invalid project regex %q: %w", projectPattern, err) + } + + if _, err := regexp.Compile(repositoryPattern); err != nil { + return nil, fmt.Errorf("invalid repository regex %q: %w", repositoryPattern, err) + } + + if _, err := regexp.Compile(branchPattern); err != nil { + return nil, fmt.Errorf("invalid branch regex %q: %w", branchPattern, err) + } + + client := azureDevOpsClient{} + + azureDevOpsClient, err := azclient.New(azclient.Spec{ + URL: spec.URL, + Organization: spec.Organization, + Project: spec.Project, + Repository: spec.Repository, + Token: token, + Username: username, + }) + if err != nil { + return nil, fmt.Errorf("creating Azure DevOps client: %w", err) + } + + coreClient, err := azureDevOpsClient.NewCoreClient(context.Background()) + if err != nil { + return nil, fmt.Errorf("creating Azure DevOps core client: %w", err) + } + + gitClient, err := azureDevOpsClient.NewGitClient(context.Background()) + if err != nil { + return nil, fmt.Errorf("creating Azure DevOps git client: %w", err) + } + + client.core = coreClient + client.git = gitClient + + return &AzureDevOpsSearch{ + spec: spec, + limit: limit, + branch: branchPattern, + projectPattern: projectPattern, + repositoryPattern: repositoryPattern, + client: client, + }, nil +} + +type azureDevOpsClient struct { + core azdocore.Client + git azdogit.Client +} + +func (c azureDevOpsClient) GetProjects(ctx context.Context, args azdocore.GetProjectsArgs) (*azdocore.GetProjectsResponseValue, error) { + return c.core.GetProjects(ctx, args) +} + +func (c azureDevOpsClient) GetBranches(ctx context.Context, args azdogit.GetBranchesArgs) (*[]azdogit.GitBranchStats, error) { + return c.git.GetBranches(ctx, args) +} + +func (c azureDevOpsClient) GetRepositories(ctx context.Context, args azdogit.GetRepositoriesArgs) (*[]azdogit.GitRepository, error) { + return c.git.GetRepositories(ctx, args) +} + +func normalizeBranchName(branch string) string { + return strings.TrimPrefix(branch, "refs/heads/") +} diff --git a/pkg/plugins/scms/azuredevopssearch/main_test.go b/pkg/plugins/scms/azuredevopssearch/main_test.go new file mode 100644 index 0000000000..3ee7f7a49f --- /dev/null +++ b/pkg/plugins/scms/azuredevopssearch/main_test.go @@ -0,0 +1,56 @@ +package azuredevopssearch + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + t.Run("uses defaults and sanitizes values", func(t *testing.T) { + search, err := New(map[string]interface{}{ + "organization": " updatecli ", + "project": " updatecli-.* ", + "repository": " charts-.* ", + }) + + require.NoError(t, err) + assert.Equal(t, DefaultRepositoryLimit, search.limit) + assert.Equal(t, "^main$", search.branch) + assert.Equal(t, "updatecli", search.spec.Organization) + assert.Equal(t, "updatecli-.*", search.projectPattern) + assert.Equal(t, "charts-.*", search.repositoryPattern) + assert.Equal(t, "https://dev.azure.com", search.spec.URL) + }) + t.Run("uses minimal values", func(t *testing.T) { + search, err := New(map[string]interface{}{ + "organization": " updatecli ", + }) + + require.NoError(t, err) + assert.Equal(t, DefaultRepositoryLimit, search.limit) + assert.Equal(t, "^main$", search.branch) + assert.Equal(t, "updatecli", search.spec.Organization) + assert.Equal(t, ".*", search.projectPattern) + assert.Equal(t, ".*", search.repositoryPattern) + assert.Equal(t, "https://dev.azure.com", search.spec.URL) + }) + + t.Run("fails when organization is missing", func(t *testing.T) { + _, err := New(map[string]interface{}{ + "project": "updatecli-project", + }) + + require.ErrorContains(t, err, ErrOrganizationEmpty) + }) + + t.Run("fails when project regex is invalid", func(t *testing.T) { + _, err := New(map[string]interface{}{ + "organization": "updatecli", + "project": "[", + }) + + require.ErrorContains(t, err, "invalid project regex") + }) +} diff --git a/pkg/plugins/scms/azuredevopssearch/spec.go b/pkg/plugins/scms/azuredevopssearch/spec.go new file mode 100644 index 0000000000..b5b82a2707 --- /dev/null +++ b/pkg/plugins/scms/azuredevopssearch/spec.go @@ -0,0 +1,74 @@ +package azuredevopssearch + +import ( + "errors" + "strings" + + azdoclient "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/client" + "github.com/updatecli/updatecli/pkg/plugins/scms/git/commit" + "github.com/updatecli/updatecli/pkg/plugins/scms/git/sign" +) + +const ( + DefaultRepositoryLimit = 10 + ErrOrganizationEmpty = "azure DevOps organization is required for azuredevopssearch SCM" +) + +// Spec represents the configuration input for the azuredevopssearch SCM. +type Spec struct { + // "organization" defines the Azure DevOps organization. + Organization string `yaml:",omitempty" jsonschema:"required"` + // "url" defines the Azure DevOps base URL. + URL string `yaml:",omitempty"` + // "project" defines the Azure DevOps project regex used to match projects to search in. + Project string `yaml:",omitempty"` + // "repository" defines the Azure DevOps repository regex used to match repositories. + Repository string `yaml:",omitempty"` + // Limit defines the maximum number of repositories to return. + Limit *int `yaml:",omitempty"` + // "branch" defines the git branch regex to work on. + Branch string `yaml:",omitempty"` + // WorkingBranchPrefix defines the prefix used to create a working branch. + WorkingBranchPrefix *string `yaml:",omitempty"` + // WorkingBranchSeparator defines the separator used to create a working branch. + WorkingBranchSeparator *string `yaml:",omitempty"` + // "directory" defines the local path where the git repository is cloned. + Directory string `yaml:",omitempty"` + // Depth defines the depth used when cloning the git repository. + Depth *int `yaml:",omitempty"` + // "email" defines the email used to commit changes. + Email string `yaml:",omitempty"` + // "token" specifies the personal access token used to authenticate with Azure DevOps. + Token string `yaml:",omitempty"` + // "username" specifies the username used for git authentication. + Username string `yaml:",omitempty"` + // "user" specifies the user associated with new git commit messages created by Updatecli. + User string `yaml:",omitempty"` + // "gpg" specifies the GPG key and passphrase used for commit signing. + GPG sign.GPGSpec `yaml:",omitempty"` + // "force" is used during the git push phase to run `git push --force`. + Force *bool `yaml:",omitempty"` + // "commitMessage" is used to generate the final commit message. + CommitMessage commit.Commit `yaml:",omitempty"` + // "submodules" defines if Updatecli should checkout submodules. + Submodules *bool `yaml:",omitempty"` + // "workingBranch" defines if Updatecli should use a temporary branch to work on. + WorkingBranch *bool `yaml:",omitempty"` +} + +// Validate validates the Spec fields. +func (s Spec) Validate() error { + switch { + case strings.TrimSpace(s.Organization) == "": + return errors.New(ErrOrganizationEmpty) + default: + return nil + } +} + +func (s *Spec) sanitize() { + s.Organization = strings.TrimSpace(s.Organization) + s.Project = strings.TrimSpace(s.Project) + s.Repository = strings.TrimSpace(s.Repository) + s.URL = azdoclient.EnsureValidURL(s.URL) +} diff --git a/pkg/plugins/scms/azuredevopssearch/specGenerator.go b/pkg/plugins/scms/azuredevopssearch/specGenerator.go new file mode 100644 index 0000000000..aba661338e --- /dev/null +++ b/pkg/plugins/scms/azuredevopssearch/specGenerator.go @@ -0,0 +1,189 @@ +package azuredevopssearch + +import ( + "context" + "fmt" + "regexp" + "strconv" + + azdocore "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + azdogit "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" + "github.com/sirupsen/logrus" + azdoclient "github.com/updatecli/updatecli/pkg/plugins/resources/azuredevops/client" + azdoscm "github.com/updatecli/updatecli/pkg/plugins/scms/azuredevops" +) + +// ScmsGenerator discovers Azure DevOps repositories within the configured project +// and returns a list of azuredevops.Spec, one per matching branch. +func (a *AzureDevOpsSearch) ScmsGenerator(ctx context.Context) ([]azdoscm.Spec, error) { + results := make([]azdoscm.Spec, 0) + + branchRegex, err := regexp.Compile(a.branch) + if err != nil { + return nil, fmt.Errorf("invalid branch regex %q: %w", a.branch, err) + } + + projectRegex, err := regexp.Compile(a.projectPattern) + if err != nil { + return nil, fmt.Errorf("invalid project regex %q: %w", a.projectPattern, err) + } + + repositoryRegex, err := regexp.Compile(a.repositoryPattern) + if err != nil { + return nil, fmt.Errorf("invalid repository regex %q: %w", a.repositoryPattern, err) + } + + projects, err := a.listProjects(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list Azure DevOps projects for organization %q: %w", a.spec.Organization, err) + } + + logrus.Debugf("Found %d Azure DevOps projects in organization %q", len(projects), a.spec.Organization) + + for _, project := range projects { + projectName := stringValue(project.Name) + if projectName == "" { + continue + } + + if !projectRegex.MatchString(projectName) { + logrus.Debugf("Skipping Azure DevOps project %q that does not match regex %q", projectName, a.projectPattern) + continue + } + + repositories, err := a.client.GetRepositories(ctx, azdogit.GetRepositoriesArgs{ + Project: &projectName, + }) + if err != nil { + return nil, fmt.Errorf("failed to list Azure DevOps repositories for project %q: %w", projectName, err) + } + + logrus.Debugf("Found %d Azure DevOps repositories in project %q", len(*repositories), projectName) + + for _, repository := range *repositories { + if repository.IsDisabled != nil && *repository.IsDisabled { + continue + } + + repositoryName := stringValue(repository.Name) + if repositoryName == "" { + logrus.Warning("Skipping Azure DevOps repository with no name") + continue + } + + if !repositoryRegex.MatchString(repositoryName) { + logrus.Debugf("Skipping Azure DevOps repository %q that does not match regex %q", repositoryName, a.repositoryPattern) + continue + } + + if repository.Id == nil { + logrus.Warningf("Skipping Azure DevOps repository %q with no ID", repositoryName) + continue + } + + logrus.Debugf("Processing Azure DevOps repository: %s/%s", projectName, repositoryName) + + branches, err := a.listBranches(ctx, projectName, repository.Id.String()) + if err != nil { + return nil, fmt.Errorf("failed to list branches for Azure DevOps repository %q in project %q: %w", repositoryName, projectName, err) + } + + for _, branchName := range branches { + if !branchRegex.MatchString(branchName) { + continue + } + + spec := azdoscm.Spec{ + Branch: branchName, + CommitMessage: a.spec.CommitMessage, + Directory: a.spec.Directory, + Depth: a.spec.Depth, + Email: a.spec.Email, + Force: a.spec.Force, + GPG: a.spec.GPG, + Submodules: a.spec.Submodules, + User: a.spec.User, + WorkingBranch: a.spec.WorkingBranch, + WorkingBranchPrefix: a.spec.WorkingBranchPrefix, + WorkingBranchSeparator: a.spec.WorkingBranchSeparator, + Spec: azdoclient.Spec{ + Organization: a.spec.Organization, + URL: a.spec.URL, + Project: projectName, + Repository: repositoryName, + Token: a.spec.Token, + Username: a.spec.Username, + }, + } + + results = append(results, spec) + + if a.limit > 0 && len(results) >= a.limit { + return results, nil + } + } + } + } + + return results, nil +} + +func (a *AzureDevOpsSearch) listProjects(ctx context.Context) ([]azdocore.TeamProjectReference, error) { + results := make([]azdocore.TeamProjectReference, 0) + top := 100 + var continuationToken *int + + for { + response, err := a.client.GetProjects(ctx, azdocore.GetProjectsArgs{ + Top: &top, + ContinuationToken: continuationToken, + }) + if err != nil { + return nil, err + } + + results = append(results, response.Value...) + + if response.ContinuationToken == "" { + break + } + + nextToken, err := strconv.Atoi(response.ContinuationToken) + if err != nil { + return nil, fmt.Errorf("parse Azure DevOps continuation token %q: %w", response.ContinuationToken, err) + } + continuationToken = &nextToken + } + + return results, nil +} + +func (a *AzureDevOpsSearch) listBranches(ctx context.Context, projectName, repositoryID string) ([]string, error) { + branches, err := a.client.GetBranches(ctx, azdogit.GetBranchesArgs{ + Project: &projectName, + RepositoryId: &repositoryID, + }) + if err != nil { + return nil, err + } + + results := make([]string, 0, len(*branches)) + for _, branch := range *branches { + branchName := normalizeBranchName(stringValue(branch.Name)) + if branchName == "" { + continue + } + + results = append(results, branchName) + } + + return results, nil +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + + return *value +} diff --git a/pkg/plugins/scms/azuredevopssearch/specGenerator_test.go b/pkg/plugins/scms/azuredevopssearch/specGenerator_test.go new file mode 100644 index 0000000000..f0bf1b4cc6 --- /dev/null +++ b/pkg/plugins/scms/azuredevopssearch/specGenerator_test.go @@ -0,0 +1,172 @@ +package azuredevopssearch + +import ( + "context" + "testing" + + "github.com/google/uuid" + azdocore "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + azdogit "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockClient struct { + getProjectsFunc func(context.Context, azdocore.GetProjectsArgs) (*azdocore.GetProjectsResponseValue, error) + getBranchesFunc func(context.Context, azdogit.GetBranchesArgs) (*[]azdogit.GitBranchStats, error) + getRepositoriesFunc func(context.Context, azdogit.GetRepositoriesArgs) (*[]azdogit.GitRepository, error) +} + +func (m mockClient) GetProjects(ctx context.Context, args azdocore.GetProjectsArgs) (*azdocore.GetProjectsResponseValue, error) { + if m.getProjectsFunc == nil { + return &azdocore.GetProjectsResponseValue{}, nil + } + + return m.getProjectsFunc(ctx, args) +} + +func (m mockClient) GetBranches(ctx context.Context, args azdogit.GetBranchesArgs) (*[]azdogit.GitBranchStats, error) { + if m.getBranchesFunc == nil { + return &[]azdogit.GitBranchStats{}, nil + } + + return m.getBranchesFunc(ctx, args) +} + +func (m mockClient) GetRepositories(ctx context.Context, args azdogit.GetRepositoriesArgs) (*[]azdogit.GitRepository, error) { + if m.getRepositoriesFunc == nil { + return &[]azdogit.GitRepository{}, nil + } + + return m.getRepositoriesFunc(ctx, args) +} + +func TestScmsGenerator(t *testing.T) { + t.Run("generates Azure DevOps SCM specs for matching repositories and branches", func(t *testing.T) { + firstRepositoryID := uuid.New() + secondRepositoryID := uuid.New() + + search := AzureDevOpsSearch{ + spec: Spec{ + Organization: "updatecli", + URL: "https://dev.azure.com", + Project: "platform-.*", + Repository: "service-.*", + }, + limit: DefaultRepositoryLimit, + branch: "^(main|release/.+)$", + projectPattern: "platform-.*", + repositoryPattern: "service-.*", + client: mockClient{ + getProjectsFunc: func(ctx context.Context, args azdocore.GetProjectsArgs) (*azdocore.GetProjectsResponseValue, error) { + return &azdocore.GetProjectsResponseValue{ + Value: []azdocore.TeamProjectReference{ + {Name: stringPtr("platform-core")}, + {Name: stringPtr("website")}, + {Name: stringPtr("platform-edge")}, + }, + }, nil + }, + getRepositoriesFunc: func(ctx context.Context, args azdogit.GetRepositoriesArgs) (*[]azdogit.GitRepository, error) { + switch *args.Project { + case "platform-core": + return &[]azdogit.GitRepository{ + {Name: stringPtr("service-api"), Id: &firstRepositoryID}, + {Name: stringPtr("website")}, + }, nil + case "platform-edge": + return &[]azdogit.GitRepository{ + {Name: stringPtr("service-worker"), Id: &secondRepositoryID}, + }, nil + default: + return &[]azdogit.GitRepository{}, nil + } + }, + getBranchesFunc: func(ctx context.Context, args azdogit.GetBranchesArgs) (*[]azdogit.GitBranchStats, error) { + switch *args.RepositoryId { + case firstRepositoryID.String(): + require.Equal(t, "platform-core", *args.Project) + return &[]azdogit.GitBranchStats{ + {Name: stringPtr("refs/heads/main")}, + {Name: stringPtr("refs/heads/release/1.0.0")}, + {Name: stringPtr("refs/heads/develop")}, + }, nil + case secondRepositoryID.String(): + require.Equal(t, "platform-edge", *args.Project) + return &[]azdogit.GitBranchStats{ + {Name: stringPtr("refs/heads/main")}, + }, nil + default: + return &[]azdogit.GitBranchStats{}, nil + } + }, + }, + } + + specs, err := search.ScmsGenerator(context.Background()) + require.NoError(t, err) + require.Len(t, specs, 3) + + assert.Equal(t, "service-api", specs[0].Repository) + assert.Equal(t, "platform-core", specs[0].Project) + assert.Equal(t, "updatecli", specs[0].Organization) + assert.Equal(t, "main", specs[0].Branch) + assert.Equal(t, "release/1.0.0", specs[1].Branch) + assert.Equal(t, "platform-edge", specs[2].Project) + assert.Equal(t, "service-worker", specs[2].Repository) + }) + + t.Run("honors the repository limit", func(t *testing.T) { + firstID := uuid.New() + search := AzureDevOpsSearch{ + spec: Spec{ + Organization: "updatecli", + URL: "https://dev.azure.com", + Project: "platform-.*", + }, + limit: 1, + branch: "^main$", + projectPattern: "platform-.*", + repositoryPattern: ".*", + client: mockClient{ + getProjectsFunc: func(ctx context.Context, args azdocore.GetProjectsArgs) (*azdocore.GetProjectsResponseValue, error) { + return &azdocore.GetProjectsResponseValue{ + Value: []azdocore.TeamProjectReference{ + {Name: stringPtr("platform-core")}, + }, + }, nil + }, + getRepositoriesFunc: func(ctx context.Context, args azdogit.GetRepositoriesArgs) (*[]azdogit.GitRepository, error) { + return &[]azdogit.GitRepository{ + {Name: stringPtr("service-a"), Id: &firstID}, + }, nil + }, + getBranchesFunc: func(ctx context.Context, args azdogit.GetBranchesArgs) (*[]azdogit.GitBranchStats, error) { + return &[]azdogit.GitBranchStats{ + {Name: stringPtr("refs/heads/main")}, + }, nil + }, + }, + } + + specs, err := search.ScmsGenerator(context.Background()) + require.NoError(t, err) + require.Len(t, specs, 1) + assert.Equal(t, "service-a", specs[0].Repository) + }) + + t.Run("fails on invalid repository regex", func(t *testing.T) { + search := AzureDevOpsSearch{ + branch: "^main$", + projectPattern: "^platform$", + repositoryPattern: "[", + } + + _, err := search.ScmsGenerator(context.Background()) + require.ErrorContains(t, err, "invalid repository regex") + }) +} + +func stringPtr(value string) *string { + return &value +}