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..c07d6350fe7 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")) + hasAzDevOpsFolder := folderExists(path.Join(projectDir, ".azdo")) + + if !hasGitHubFolder && !hasAzDevOpsFolder { + 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 { + // GitHub only + return &GitHubScmProvider{}, &GitHubCiProvider{}, nil + } + + if hasAzDevOpsFolder && !hasGitHubFolder { + // Azdo only + return &AzdoHubScmProvider{}, &AzdoCiProvider{}, nil + } + + // Both folders exist. Prompt to select SCM first + scmElection, err := console.Select(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 == 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.Select(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 == 0 { + 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..78a1966133a --- /dev/null +++ b/cli/azd/pkg/commands/pipeline/pipeline_test.go @@ -0,0 +1,192 @@ +// 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 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") + 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{ + selectReturnValues: []int{1}, + }) + 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{ + selectReturnValues: []int{0, 1}, + }) + 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) { + 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) { + 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 { + 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.selectReturnValues) + if arraySize == 0 { + 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.selectReturnValues[console.index] + console.index += 1 + return returnValue, nil +} +func (console *circularConsole) Confirm(ctx context.Context, options input.ConsoleOptions) (bool, error) { + return false, nil +} +func (console *circularConsole) SetWriter(writer io.Writer) {}