Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions cli/azd/cmd/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions cli/azd/pkg/commands/pipeline/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
192 changes: 192 additions & 0 deletions cli/azd/pkg/commands/pipeline/pipeline_test.go
Original file line number Diff line number Diff line change
@@ -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) {}