From 7b121cb2c9c25ed016b0bb9f6c30e0d9d6ed6874 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 16 Sep 2022 20:55:24 +0000 Subject: [PATCH 1/4] auto detection for ci and scm providers --- cli/azd/cmd/pipeline.go | 14 +- cli/azd/pkg/commands/pipeline/pipeline.go | 84 ++++++++ .../pkg/commands/pipeline/pipeline_test.go | 185 ++++++++++++++++++ 3 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 cli/azd/pkg/commands/pipeline/pipeline_test.go diff --git a/cli/azd/cmd/pipeline.go b/cli/azd/cmd/pipeline.go index f66d07e065d..a70f45b3fea 100644 --- a/cli/azd/cmd/pipeline.go +++ b/cli/azd/cmd/pipeline.go @@ -94,12 +94,14 @@ func (p *pipelineConfigAction) Run( return fmt.Errorf("loading environment: %w", err) } - // TODO: Providers can be init at this point either from azure.yaml or from command args - // Using GitHub by default for now. To be updated to either GitHub or Azdo. - // The CI provider might need to have a reference to the SCM provider if its implementation - // will depend on where is the SCM. For example, azdo support any SCM source. - p.manager.ScmProvider = &pipeline.GitHubScmProvider{} - p.manager.CiProvider = &pipeline.GitHubCiProvider{} + // Detect the SCM and CI providers based on the project directory + p.manager.ScmProvider, + p.manager.CiProvider, + err = pipeline.DetectProviders(ctx, console) + + if err != nil { + return err + } // set context for manager p.manager.AzdCtx = azdCtx diff --git a/cli/azd/pkg/commands/pipeline/pipeline.go b/cli/azd/pkg/commands/pipeline/pipeline.go index b130990207a..755be5ea5c4 100644 --- a/cli/azd/pkg/commands/pipeline/pipeline.go +++ b/cli/azd/pkg/commands/pipeline/pipeline.go @@ -6,8 +6,12 @@ package pipeline import ( "context" "encoding/json" + "fmt" + "os" + "path" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/tools" @@ -72,3 +76,83 @@ type CiProvider interface { credential json.RawMessage, console input.Console) error } + +func folderExists(folderPath string) bool { + if _, err := os.Stat(folderPath); err == nil { + return true + } + return false +} + +// DetectProviders get azd context from the context and pulls the project directory from it. +// Depending on the project directory, returns pipeline scm and ci providers based on: +// - if .github folder is found and .azdo folder is missing: GitHub scm and ci as provider +// - if .azdo folder is found and .github folder is missing: Azdo scm and ci as provider +// - both .github and .azdo folders found: prompt user to choose provider for scm and ci +// - none of the folders found: return error +// - no azd context in the ctx: return error +func DetectProviders(ctx context.Context, console input.Console) (ScmProvider, CiProvider, error) { + azdContext, err := azdcontext.GetAzdContext(ctx) + if err != nil { + return nil, nil, err + } + + projectDir := azdContext.ProjectDirectory() + + hasGitHubFolder := folderExists(path.Join(projectDir, ".github")) + hasAzDevOpsProject := folderExists(path.Join(projectDir, ".azdo")) + + if !hasGitHubFolder && !hasAzDevOpsProject { + return nil, nil, fmt.Errorf("no CI/CD provider found in template root folders.") + } + + if !hasAzDevOpsProject && hasGitHubFolder { + // GitHub only + return &GitHubScmProvider{}, &GitHubCiProvider{}, nil + } + + if hasAzDevOpsProject && !hasGitHubFolder { + // Azdo only + return &AzdoHubScmProvider{}, &AzdoCiProvider{}, nil + } + + // Both folders exist. Prompt to select SCM first + scmElection, err := console.Prompt(ctx, input.ConsoleOptions{ + Message: "Select what SCM provider to use", + Options: []string{ + "GitHub", + "Azure DevOps", + }, + DefaultValue: "GitHub", + }) + + if err != nil { + return nil, nil, err + } + + if scmElection == "Azure DevOps" { + // using azdo for scm would only support using azdo pipelines + return &AzdoHubScmProvider{}, &AzdoCiProvider{}, nil + } + + // GitHub selected for SCM, prompt for CI provider + ciElection, err := console.Prompt(ctx, input.ConsoleOptions{ + Message: "Select what CI provider to use", + Options: []string{ + "GitHub Actions", + "Azure DevOps Pipelines", + }, + DefaultValue: "GitHub Actions", + }) + + if err != nil { + return nil, nil, err + } + + if ciElection == "GitHub Actions" { + return &GitHubScmProvider{}, &GitHubCiProvider{}, nil + } + + // GitHub plus azdo pipelines otherwise + return &GitHubScmProvider{}, &AzdoCiProvider{}, nil +} diff --git a/cli/azd/pkg/commands/pipeline/pipeline_test.go b/cli/azd/pkg/commands/pipeline/pipeline_test.go new file mode 100644 index 00000000000..81b3277d14c --- /dev/null +++ b/cli/azd/pkg/commands/pipeline/pipeline_test.go @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package pipeline + +import ( + "context" + "errors" + "io" + "os" + "path" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/stretchr/testify/assert" +) + +func Test_detectProviders(t *testing.T) { + tempDir := t.TempDir() + ctx := context.Background() + + t.Run("no azd context within context", func(t *testing.T) { + scmProvider, ciProvider, err := DetectProviders(ctx, &nullConsole{}) + assert.Nil(t, scmProvider) + assert.Nil(t, ciProvider) + assert.EqualError(t, err, "cannot find AzdContext on go context") + }) + + azdContext := &azdcontext.AzdContext{} + azdContext.SetProjectDirectory(tempDir) + ctx = azdcontext.WithAzdContext(ctx, azdContext) + + t.Run("no folders error", func(t *testing.T) { + scmProvider, ciProvider, err := DetectProviders(ctx, &nullConsole{}) + assert.Nil(t, scmProvider) + assert.Nil(t, ciProvider) + assert.EqualError(t, err, "no CI/CD provider found in template root folders.") + }) + t.Run("github folder only", func(t *testing.T) { + ghFolder := path.Join(tempDir, ".github") + err := os.Mkdir(ghFolder, osutil.PermissionDirectory) + assert.NoError(t, err) + + scmProvider, ciProvider, err := DetectProviders(ctx, &nullConsole{}) + assert.IsType(t, &GitHubScmProvider{}, scmProvider) + assert.IsType(t, &GitHubCiProvider{}, ciProvider) + assert.NoError(t, err) + // Remove folder - reset state + os.Remove(ghFolder) + }) + t.Run("azdo folder only", func(t *testing.T) { + azdoFolder := path.Join(tempDir, ".azdo") + err := os.Mkdir(azdoFolder, osutil.PermissionDirectory) + assert.NoError(t, err) + + scmProvider, ciProvider, err := DetectProviders(ctx, &nullConsole{}) + assert.IsType(t, &AzdoHubScmProvider{}, scmProvider) + assert.IsType(t, &AzdoCiProvider{}, ciProvider) + assert.NoError(t, err) + // Remove folder - reset state + os.Remove(azdoFolder) + }) + t.Run("multi provider select defaults", func(t *testing.T) { + azdoFolder := path.Join(tempDir, ".azdo") + ghFolder := path.Join(tempDir, ".github") + err := os.Mkdir(azdoFolder, osutil.PermissionDirectory) + assert.NoError(t, err) + err = os.Mkdir(ghFolder, osutil.PermissionDirectory) + assert.NoError(t, err) + + scmProvider, ciProvider, err := DetectProviders(ctx, &selectDefaultConsole{}) + assert.IsType(t, &GitHubScmProvider{}, scmProvider) + assert.IsType(t, &GitHubCiProvider{}, ciProvider) + assert.NoError(t, err) + + // Remove folder - reset state + os.Remove(azdoFolder) + os.Remove(ghFolder) + }) + t.Run("multi provider select azdo", func(t *testing.T) { + azdoFolder := path.Join(tempDir, ".azdo") + ghFolder := path.Join(tempDir, ".github") + err := os.Mkdir(azdoFolder, osutil.PermissionDirectory) + assert.NoError(t, err) + err = os.Mkdir(ghFolder, osutil.PermissionDirectory) + assert.NoError(t, err) + + scmProvider, ciProvider, err := DetectProviders(ctx, &circularConsole{ + promptReturnValues: []string{"Azure DevOps"}, + }) + assert.IsType(t, &AzdoHubScmProvider{}, scmProvider) + assert.IsType(t, &AzdoCiProvider{}, ciProvider) + assert.NoError(t, err) + + // Remove folder - reset state + os.Remove(azdoFolder) + os.Remove(ghFolder) + }) + t.Run("multi provider select git and azdo", func(t *testing.T) { + azdoFolder := path.Join(tempDir, ".azdo") + ghFolder := path.Join(tempDir, ".github") + err := os.Mkdir(azdoFolder, osutil.PermissionDirectory) + assert.NoError(t, err) + err = os.Mkdir(ghFolder, osutil.PermissionDirectory) + assert.NoError(t, err) + + scmProvider, ciProvider, err := DetectProviders(ctx, &circularConsole{ + promptReturnValues: []string{"GitHub", "Azure DevOps Pipelines"}, + }) + assert.IsType(t, &GitHubScmProvider{}, scmProvider) + assert.IsType(t, &AzdoCiProvider{}, ciProvider) + assert.NoError(t, err) + + // Remove folder - reset state + os.Remove(azdoFolder) + os.Remove(ghFolder) + }) +} + +// ------------- Test implementations ------------------- +// Test consoles to control input and define deterministic tests + +// For tests where the console won't matter at all +type nullConsole struct { +} + +func (console *nullConsole) Message(ctx context.Context, message string) {} +func (console *nullConsole) Prompt(ctx context.Context, options input.ConsoleOptions) (string, error) { + return "", nil +} +func (console *nullConsole) Select(ctx context.Context, options input.ConsoleOptions) (int, error) { + return 0, nil +} +func (console *nullConsole) Confirm(ctx context.Context, options input.ConsoleOptions) (bool, error) { + return false, nil +} +func (console *nullConsole) SetWriter(writer io.Writer) {} + +// For tests where console.prompt returns defaults only +type selectDefaultConsole struct { +} + +func (console *selectDefaultConsole) Message(ctx context.Context, message string) {} +func (console *selectDefaultConsole) Prompt(ctx context.Context, options input.ConsoleOptions) (string, error) { + return options.DefaultValue.(string), nil +} +func (console *selectDefaultConsole) Select(ctx context.Context, options input.ConsoleOptions) (int, error) { + return 0, nil +} +func (console *selectDefaultConsole) Confirm(ctx context.Context, options input.ConsoleOptions) (bool, error) { + return false, nil +} +func (console *selectDefaultConsole) SetWriter(writer io.Writer) {} + +// For tests where console.prompt returns values provided in its internal []string +type circularConsole struct { + promptReturnValues []string + index int +} + +func (console *circularConsole) Message(ctx context.Context, message string) {} +func (console *circularConsole) Prompt(ctx context.Context, options input.ConsoleOptions) (string, error) { + // If no values where provided, return error + arraySize := len(console.promptReturnValues) + if arraySize == 0 { + return "", errors.New("no values to return") + } + + // Reset index when it reaches size (back to first value) + if console.index == arraySize { + console.index = 0 + } + returnValue := console.promptReturnValues[console.index] + console.index += 1 + return returnValue, nil +} +func (console *circularConsole) Select(ctx context.Context, options input.ConsoleOptions) (int, error) { + return 0, nil +} +func (console *circularConsole) Confirm(ctx context.Context, options input.ConsoleOptions) (bool, error) { + return false, nil +} +func (console *circularConsole) SetWriter(writer io.Writer) {} From 26168fc905874aa6b937dcda86b61a8d7ba39941 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 16 Sep 2022 21:07:15 +0000 Subject: [PATCH 2/4] use select instead of prompt --- cli/azd/pkg/commands/pipeline/pipeline.go | 8 +++--- .../pkg/commands/pipeline/pipeline_test.go | 25 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/cli/azd/pkg/commands/pipeline/pipeline.go b/cli/azd/pkg/commands/pipeline/pipeline.go index 755be5ea5c4..d8eea803e44 100644 --- a/cli/azd/pkg/commands/pipeline/pipeline.go +++ b/cli/azd/pkg/commands/pipeline/pipeline.go @@ -117,7 +117,7 @@ func DetectProviders(ctx context.Context, console input.Console) (ScmProvider, C } // Both folders exist. Prompt to select SCM first - scmElection, err := console.Prompt(ctx, input.ConsoleOptions{ + scmElection, err := console.Select(ctx, input.ConsoleOptions{ Message: "Select what SCM provider to use", Options: []string{ "GitHub", @@ -130,13 +130,13 @@ func DetectProviders(ctx context.Context, console input.Console) (ScmProvider, C return nil, nil, err } - if scmElection == "Azure DevOps" { + if scmElection == 1 { // using azdo for scm would only support using azdo pipelines return &AzdoHubScmProvider{}, &AzdoCiProvider{}, nil } // GitHub selected for SCM, prompt for CI provider - ciElection, err := console.Prompt(ctx, input.ConsoleOptions{ + ciElection, err := console.Select(ctx, input.ConsoleOptions{ Message: "Select what CI provider to use", Options: []string{ "GitHub Actions", @@ -149,7 +149,7 @@ func DetectProviders(ctx context.Context, console input.Console) (ScmProvider, C return nil, nil, err } - if ciElection == "GitHub Actions" { + if ciElection == 0 { return &GitHubScmProvider{}, &GitHubCiProvider{}, nil } diff --git a/cli/azd/pkg/commands/pipeline/pipeline_test.go b/cli/azd/pkg/commands/pipeline/pipeline_test.go index 81b3277d14c..ed600fe5ef3 100644 --- a/cli/azd/pkg/commands/pipeline/pipeline_test.go +++ b/cli/azd/pkg/commands/pipeline/pipeline_test.go @@ -88,7 +88,7 @@ func Test_detectProviders(t *testing.T) { assert.NoError(t, err) scmProvider, ciProvider, err := DetectProviders(ctx, &circularConsole{ - promptReturnValues: []string{"Azure DevOps"}, + selectReturnValues: []int{1}, }) assert.IsType(t, &AzdoHubScmProvider{}, scmProvider) assert.IsType(t, &AzdoCiProvider{}, ciProvider) @@ -107,7 +107,7 @@ func Test_detectProviders(t *testing.T) { assert.NoError(t, err) scmProvider, ciProvider, err := DetectProviders(ctx, &circularConsole{ - promptReturnValues: []string{"GitHub", "Azure DevOps Pipelines"}, + selectReturnValues: []int{0, 1}, }) assert.IsType(t, &GitHubScmProvider{}, scmProvider) assert.IsType(t, &AzdoCiProvider{}, ciProvider) @@ -147,6 +147,11 @@ func (console *selectDefaultConsole) Prompt(ctx context.Context, options input.C return options.DefaultValue.(string), nil } func (console *selectDefaultConsole) Select(ctx context.Context, options input.ConsoleOptions) (int, error) { + for index, value := range options.Options { + if value == options.DefaultValue { + return index, nil + } + } return 0, nil } func (console *selectDefaultConsole) Confirm(ctx context.Context, options input.ConsoleOptions) (bool, error) { @@ -156,29 +161,31 @@ func (console *selectDefaultConsole) SetWriter(writer io.Writer) {} // For tests where console.prompt returns values provided in its internal []string type circularConsole struct { - promptReturnValues []string + selectReturnValues []int index int } func (console *circularConsole) Message(ctx context.Context, message string) {} func (console *circularConsole) Prompt(ctx context.Context, options input.ConsoleOptions) (string, error) { + return "", nil +} + +func (console *circularConsole) Select(ctx context.Context, options input.ConsoleOptions) (int, error) { // If no values where provided, return error - arraySize := len(console.promptReturnValues) + arraySize := len(console.selectReturnValues) if arraySize == 0 { - return "", errors.New("no values to return") + return 0, errors.New("no values to return") } // Reset index when it reaches size (back to first value) if console.index == arraySize { console.index = 0 } - returnValue := console.promptReturnValues[console.index] + + returnValue := console.selectReturnValues[console.index] console.index += 1 return returnValue, nil } -func (console *circularConsole) Select(ctx context.Context, options input.ConsoleOptions) (int, error) { - return 0, nil -} func (console *circularConsole) Confirm(ctx context.Context, options input.ConsoleOptions) (bool, error) { return false, nil } From b464e7540b8d2a231f988348eec622b00818d1a4 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 16 Sep 2022 21:11:32 +0000 Subject: [PATCH 3/4] rename var --- cli/azd/pkg/commands/pipeline/pipeline.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/azd/pkg/commands/pipeline/pipeline.go b/cli/azd/pkg/commands/pipeline/pipeline.go index d8eea803e44..c933d7914e9 100644 --- a/cli/azd/pkg/commands/pipeline/pipeline.go +++ b/cli/azd/pkg/commands/pipeline/pipeline.go @@ -100,18 +100,18 @@ func DetectProviders(ctx context.Context, console input.Console) (ScmProvider, C projectDir := azdContext.ProjectDirectory() hasGitHubFolder := folderExists(path.Join(projectDir, ".github")) - hasAzDevOpsProject := folderExists(path.Join(projectDir, ".azdo")) + hasAzDevOpsFolder := folderExists(path.Join(projectDir, ".azdo")) - if !hasGitHubFolder && !hasAzDevOpsProject { + if !hasGitHubFolder && !hasAzDevOpsFolder { return nil, nil, fmt.Errorf("no CI/CD provider found in template root folders.") } - if !hasAzDevOpsProject && hasGitHubFolder { + if !hasAzDevOpsFolder && hasGitHubFolder { // GitHub only return &GitHubScmProvider{}, &GitHubCiProvider{}, nil } - if hasAzDevOpsProject && !hasGitHubFolder { + if hasAzDevOpsFolder && !hasGitHubFolder { // Azdo only return &AzdoHubScmProvider{}, &AzdoCiProvider{}, nil } From 1f95010f32a850d0f027bd0eeacddc703be5b00c Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 16 Sep 2022 21:15:57 +0000 Subject: [PATCH 4/4] update error --- cli/azd/pkg/commands/pipeline/pipeline.go | 2 +- cli/azd/pkg/commands/pipeline/pipeline_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/pkg/commands/pipeline/pipeline.go b/cli/azd/pkg/commands/pipeline/pipeline.go index c933d7914e9..c07d6350fe7 100644 --- a/cli/azd/pkg/commands/pipeline/pipeline.go +++ b/cli/azd/pkg/commands/pipeline/pipeline.go @@ -103,7 +103,7 @@ func DetectProviders(ctx context.Context, console input.Console) (ScmProvider, C hasAzDevOpsFolder := folderExists(path.Join(projectDir, ".azdo")) if !hasGitHubFolder && !hasAzDevOpsFolder { - return nil, nil, fmt.Errorf("no CI/CD provider found in template root folders.") + return nil, nil, fmt.Errorf("no CI/CD provider configuration found. Expecting either .github and/or .azdo folder in the project root directory.") } if !hasAzDevOpsFolder && hasGitHubFolder { diff --git a/cli/azd/pkg/commands/pipeline/pipeline_test.go b/cli/azd/pkg/commands/pipeline/pipeline_test.go index ed600fe5ef3..78a1966133a 100644 --- a/cli/azd/pkg/commands/pipeline/pipeline_test.go +++ b/cli/azd/pkg/commands/pipeline/pipeline_test.go @@ -36,7 +36,7 @@ func Test_detectProviders(t *testing.T) { scmProvider, ciProvider, err := DetectProviders(ctx, &nullConsole{}) assert.Nil(t, scmProvider) assert.Nil(t, ciProvider) - assert.EqualError(t, err, "no CI/CD provider found in template root folders.") + assert.EqualError(t, err, "no CI/CD provider configuration found. Expecting either .github and/or .azdo folder in the project root directory.") }) t.Run("github folder only", func(t *testing.T) { ghFolder := path.Join(tempDir, ".github")