diff --git a/go.mod b/go.mod index 7286c706..a9da369c 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.19 require ( github.com/AlecAivazis/survey/v2 v2.3.5 github.com/MakeNowJust/heredoc/v2 v2.0.1 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.9.1 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.10.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 github.com/hashicorp/go-multierror v1.1.1 @@ -28,7 +28,7 @@ require ( github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect - github.com/go-playground/validator/v10 v10.11.0 // indirect + github.com/go-playground/validator/v10 v10.11.1 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect @@ -47,9 +47,9 @@ require ( github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/subosito/gotenv v1.3.0 // indirect - golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect - golang.org/x/sys v0.0.0-20220731174439-a90be440212d // indirect - golang.org/x/text v0.3.7 // indirect + golang.org/x/crypto v0.0.0-20221012134737-56aed061732a // indirect + golang.org/x/sys v0.1.0 // indirect + golang.org/x/text v0.4.0 // indirect gopkg.in/ini.v1 v1.66.4 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 41d51324..fcfaf33c 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/OctopusDeploy/go-octopusdeploy/v2 v2.8.1 h1:jaMlQAeI93w/BlwUf5LNXXlm0 github.com/OctopusDeploy/go-octopusdeploy/v2 v2.8.1/go.mod h1:XWqxyDUVElUlTaPqyCBblukpsHSnPcAKkAHgJgbsIAs= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.9.1 h1:rnG8B0t759J3en86aTSi9nZ/dv8VcNIz3s+iKWQxVrI= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.9.1/go.mod h1:XWqxyDUVElUlTaPqyCBblukpsHSnPcAKkAHgJgbsIAs= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.10.0 h1:DdKeV+JYaF5Z5fl+P1LTByuZXBj+tKy+1xEuiADs/JM= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.10.0/go.mod h1:l8uu7lnA1mz/JwW3pXflKqzxTrIDfl/0r5jWBjzywTs= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= github.com/briandowns/spinner v1.19.0/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -90,6 +92,8 @@ github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/j github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2BOGlCyvTqsp/xIw= github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= +github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ= +github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -268,6 +272,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20221012134737-56aed061732a h1:NmSIgad6KjE6VvHciPZuNRTKxGhlPfD6OA87W/PLkqg= +golang.org/x/crypto v0.0.0-20221012134737-56aed061732a/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -397,6 +403,8 @@ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220731174439-a90be440212d h1:Sv5ogFZatcgIMMtBSTTAgMYsicp25MXBubjXNDKwm80= golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 h1:Q5284mrmYTpACcm+eAKjKJH48BBwSyfJqmmGDTtT8Vc= @@ -410,6 +418,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/pkg/cmd/dependencies.go b/pkg/cmd/dependencies.go new file mode 100644 index 00000000..e0d7660d --- /dev/null +++ b/pkg/cmd/dependencies.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "io" + + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" + "github.com/spf13/cobra" +) + +type Dependable interface { + Commit() error + GenerateAutomationCmd() +} + +type Dependencies struct { + Out io.Writer + Client *client.Client + Host string + Space *spaces.Space + NoPrompt bool + Ask question.Asker + CmdPath string + ShowMessagePrefix bool +} + +func NewDependencies(f factory.Factory, cmd *cobra.Command) *Dependencies { + client, err := f.GetSpacedClient() + if err != nil { + panic(err) + } + + return &Dependencies{ + Ask: f.Ask, + CmdPath: cmd.CommandPath(), + Out: cmd.OutOrStdout(), + Client: client, + Host: f.GetCurrentHost(), + NoPrompt: !f.IsPromptEnabled(), + Space: f.GetCurrentSpace(), + } +} + +func NewDependenciesFromExisting(opts *Dependencies, cmdPath string) *Dependencies { + return &Dependencies{ + Ask: opts.Ask, + CmdPath: cmdPath, + Out: opts.Out, + Client: opts.Client, + Host: opts.Host, + NoPrompt: opts.NoPrompt, + Space: opts.Space, + ShowMessagePrefix: true, + } +} diff --git a/pkg/cmd/project/create/create.go b/pkg/cmd/project/create/create.go new file mode 100644 index 00000000..f22ea71e --- /dev/null +++ b/pkg/cmd/project/create/create.go @@ -0,0 +1,322 @@ +package create + +import ( + "fmt" + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/credentials" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectgroups" + "github.com/spf13/cobra" +) + +const ( + FlagGroup = "group" + FlagName = "name" + FlagDescription = "description" + FlagLifecycle = "lifecycle" + FlagConfigAsCode = "process-vcs" + FlagGitUrl = "git-url" + FlagGitBranch = "git-branch" + FlagGitLibraryCredentials = "git-credentials" + FlagGitUsername = "git-username" + FlagGitPassword = "git-password" + FlagGitCredentialStorage = "git-credential-store" + FlagGitInitialCommitMessage = "git-initial-commit" + FlagGitBasePath = "git-base-path" + + DefaultGitCommitMessage = "Initial commit of deployment process" + DefaultBasePath = ".octopus/" + DefaultBranch = "main" + GitPersistenceType = "VersionControlled" + + GitStorageProject = "project" + GitStorageLibrary = "library" +) + +type CreateFlags struct { + Group *flag.Flag[string] + Name *flag.Flag[string] + Description *flag.Flag[string] + Lifecycle *flag.Flag[string] + ConfigAsCode *flag.Flag[bool] + + GitUrl *flag.Flag[string] + GitBranch *flag.Flag[string] + GitCredentials *flag.Flag[string] + GitUsername *flag.Flag[string] + GitPassword *flag.Flag[string] + GitStorage *flag.Flag[string] + GitInitialCommitMessage *flag.Flag[string] + GitBasePath *flag.Flag[string] +} + +func NewCreateFlags() *CreateFlags { + return &CreateFlags{ + Group: flag.New[string](FlagGroup, false), + Name: flag.New[string](FlagName, false), + Description: flag.New[string](FlagDescription, false), + Lifecycle: flag.New[string](FlagLifecycle, false), + ConfigAsCode: flag.New[bool](FlagConfigAsCode, false), + GitStorage: flag.New[string](FlagGitCredentialStorage, false), + GitUrl: flag.New[string](FlagGitUrl, false), + GitBranch: flag.New[string](FlagGitBranch, false), + GitInitialCommitMessage: flag.New[string](FlagGitInitialCommitMessage, false), + GitCredentials: flag.New[string](FlagGitLibraryCredentials, false), + GitUsername: flag.New[string](FlagGitUsername, false), + GitPassword: flag.New[string](FlagGitPassword, true), + GitBasePath: flag.New[string](FlagGitBasePath, false), + } +} + +func NewCmdCreate(f factory.Factory) *cobra.Command { + createFlags := NewCreateFlags() + + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a new project in Octopus Deploy", + Long: "Creates a new project in Octopus Deploy.", + Example: fmt.Sprintf(heredoc.Doc(` + $ %s project create + $ %s project create --process-vcs + $ %s project create --name 'Deploy web app' --lifecycle 'Default Lifecycle' --group 'Default Project Group' + `), constants.ExecutableName, constants.ExecutableName, constants.ExecutableName), + RunE: func(c *cobra.Command, _ []string) error { + opts := NewCreateOptions(createFlags, cmd.NewDependencies(f, c)) + + return createRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&createFlags.Name.Value, createFlags.Name.Name, "n", "", "Name of the project") + flags.StringVarP(&createFlags.Description.Value, createFlags.Description.Name, "d", "", "Description of the project") + flags.StringVarP(&createFlags.Group.Value, createFlags.Group.Name, "g", "", "Project group of the project") + flags.StringVarP(&createFlags.Lifecycle.Value, createFlags.Lifecycle.Name, "l", "", "Lifecycle of the project") + flags.BoolVar(&createFlags.ConfigAsCode.Value, createFlags.ConfigAsCode.Name, false, "Use Config As Code for the project") + + flags.StringVar(&createFlags.GitUrl.Value, createFlags.GitUrl.Name, "", "Url of the Git repository for storing project configuration") + flags.StringVar(&createFlags.GitBranch.Value, createFlags.GitBranch.Name, "", fmt.Sprintf("The default branch to use for Config As Code. Default is '%s'.", DefaultBranch)) + flags.StringVar(&createFlags.GitCredentials.Value, createFlags.GitCredentials.Name, "", "The Id or name of the Git credentials stored in Octopus") + flags.StringVar(&createFlags.GitUsername.Value, createFlags.GitUsername.Name, "", "The username to authenticate with Git") + flags.StringVar(&createFlags.GitPassword.Value, createFlags.GitPassword.Name, "", "The password to authenticate with Git") + flags.StringVar(&createFlags.GitStorage.Value, createFlags.GitStorage.Name, "", "The location to store the supplied Git credentials. Options are library or project. Default is library") + flags.StringVar(&createFlags.GitInitialCommitMessage.Value, createFlags.GitInitialCommitMessage.Name, "", "The initial commit message for configuring Config As Code.") + flags.StringVar(&createFlags.GitBasePath.Value, createFlags.GitBasePath.Name, "", fmt.Sprintf("The directory where Octopus should store the project files in the repository. Default is '%s'", DefaultBasePath)) + flags.SortFlags = false + + return cmd +} + +func createRun(opts *CreateOptions) error { + var optsArray []cmd.Dependable + var err error + if !opts.NoPrompt { + optsArray, err = PromptMissing(opts) + if err != nil { + return err + } + } else { + optsArray = append(optsArray, opts) + } + + for _, o := range optsArray { + if err := o.Commit(); err != nil { + return err + } + } + + if !opts.NoPrompt { + fmt.Fprintln(opts.Out, "\nAutomation Commands:") + for _, o := range optsArray { + o.GenerateAutomationCmd() + } + } + + return nil +} + +func PromptMissing(opts *CreateOptions) ([]cmd.Dependable, error) { + nestedOpts := []cmd.Dependable{} + + question.AskName(opts.Ask, "", "project", &opts.Name.Value) + + if opts.Lifecycle.Value == "" { + lc, err := selectors.Lifecycle("You have not specified a Lifecycle for this project. Please select one:", opts.Client, opts.Ask) + if err != nil { + return nil, err + } + opts.Lifecycle.Value = lc.Name + } + + value, projectGroupOpt, err := AskProjectGroups(opts.Ask, opts.Group.Value, opts.GetAllGroupsCallback, opts.CreateProjectGroupCallback) + if err != nil { + return nil, err + } + opts.Group.Value = value + if projectGroupOpt != nil { + nestedOpts = append(nestedOpts, projectGroupOpt) + } + + err = PromptForConfigAsCode(opts, opts.GetAllGitCredentialsCallback) + if err != nil { + return nil, err + } + + nestedOpts = append(nestedOpts, opts) + return nestedOpts, nil +} + +func AskProjectGroups(ask question.Asker, value string, getAllGroupsCallback GetAllGroupsCallback, createProjectGroupCallback CreateProjectGroupCallback) (string, cmd.Dependable, error) { + if value != "" { + return value, nil, nil + } + var shouldCreateNewProjectGroup bool + ask(&survey.Confirm{ + Message: "Would you like to create a new Project Group?", + Default: false, + }, &shouldCreateNewProjectGroup) + + if shouldCreateNewProjectGroup { + return createProjectGroupCallback() + } + + g, err := selectors.Select(ask, "You have not specified a Project group for this project. Please select one:", getAllGroupsCallback, func(pg *projectgroups.ProjectGroup) string { + return pg.Name + }) + if err != nil { + return "", nil, err + } + return g.Name, nil, nil + +} + +func PromptForConfigAsCode(opts *CreateOptions, getGitCredentialsCallback GetAllGitCredentialsCallback) error { + if !opts.ConfigAsCode.Value { + opts.Ask(&survey.Confirm{ + Message: "Would you like to use Config as Code?", + Default: false, + }, &opts.ConfigAsCode.Value) + } + + if opts.ConfigAsCode.Value { + if opts.GitStorage.Value == "" { + selectedOption, err := selectors.SelectOptions[string](opts.Ask, "Select where to store the Git credentials", getGitStorageOptions) + + if err != nil { + return err + } + opts.GitStorage.Value = selectedOption.Value + } + + if opts.GitUrl.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Git URL", + Help: "The URL of the Git repository to store configuration.", + }, &opts.GitUrl.Value, survey.WithValidator(survey.ComposeValidators( + survey.MaxLength(200), + survey.Required, + ))); err != nil { + return err + } + } + + if opts.GitBasePath.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Git repository base path", + Help: fmt.Sprintf("The path in the repository where Config As Code settings are stored. Default value is '%s'.", DefaultBasePath), + }, &opts.GitBasePath.Value, survey.WithValidator(survey.ComposeValidators( + survey.MaxLength(200), + ))); err != nil { + return err + } + } + + if opts.GitBranch.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Git branch", + Help: fmt.Sprintf("The default branch to use. Default value is '%s'.", DefaultBranch), + }, &opts.GitBranch.Value, survey.WithValidator(survey.ComposeValidators( + survey.MaxLength(200), + ))); err != nil { + return err + } + } + + if opts.GitInitialCommitMessage.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Initial Git commit message", + Help: fmt.Sprintf("The commit message used in initializing. Default value is '%s'.", DefaultGitCommitMessage), + }, &opts.GitInitialCommitMessage.Value, survey.WithValidator(survey.ComposeValidators( + survey.MaxLength(50), + ))); err != nil { + return err + } + } + + if opts.GitStorage.Value == GitStorageLibrary { + err := promptLibraryGitCredentials(opts, getGitCredentialsCallback) + if err != nil { + return err + } + } else { + err := promptProjectGitCredentials(opts) + if err != nil { + return err + } + } + } + + return nil +} + +func promptProjectGitCredentials(opts *CreateOptions) error { + if opts.GitUsername.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Git username", + Help: "The Git username.", + }, &opts.GitUsername.Value, survey.WithValidator(survey.ComposeValidators( + survey.MaxLength(200), + survey.Required, + ))); err != nil { + return err + } + } + + if opts.GitPassword.Value == "" { + if err := opts.Ask(&survey.Password{ + Message: "Git password", + Help: "The Git password.", + }, &opts.GitPassword.Value, survey.WithValidator(survey.ComposeValidators( + survey.MaxLength(200), + survey.Required, + ))); err != nil { + return err + } + } + return nil +} + +func promptLibraryGitCredentials(opts *CreateOptions, gitCredentialsCallback GetAllGitCredentialsCallback) error { + if opts.GitCredentials.Value == "" { + selectedOption, err := selectors.Select(opts.Ask, "Select which Git credentials to use", gitCredentialsCallback, func(resource *credentials.Resource) string { return resource.Name }) + + if err != nil { + return err + } + opts.GitCredentials.Value = selectedOption.GetName() + } + return nil +} + +func getGitStorageOptions() []*selectors.SelectOption[string] { + return []*selectors.SelectOption[string]{ + {Display: "Library", Value: GitStorageLibrary}, + {Display: "Project", Value: GitStorageProject}, + } +} diff --git a/pkg/cmd/project/create/create_opts.go b/pkg/cmd/project/create/create_opts.go new file mode 100644 index 00000000..81a7be87 --- /dev/null +++ b/pkg/cmd/project/create/create_opts.go @@ -0,0 +1,177 @@ +package create + +import ( + "fmt" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/credentials" + "net/url" + "strings" + + "github.com/OctopusDeploy/cli/pkg/cmd" + projectGroupCreate "github.com/OctopusDeploy/cli/pkg/cmd/projectgroup/create" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectgroups" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" +) + +type CreateProjectGroupCallback func() (string, cmd.Dependable, error) + +type GetAllGroupsCallback func() ([]*projectgroups.ProjectGroup, error) + +type GetAllGitCredentialsCallback func() ([]*credentials.Resource, error) + +type CreateOptions struct { + *CreateFlags + *cmd.Dependencies + GetAllGroupsCallback GetAllGroupsCallback + CreateProjectGroupCallback CreateProjectGroupCallback + GetAllGitCredentialsCallback GetAllGitCredentialsCallback +} + +func NewCreateOptions(createFlags *CreateFlags, dependencies *cmd.Dependencies) *CreateOptions { + return &CreateOptions{ + CreateFlags: createFlags, + Dependencies: dependencies, + GetAllGroupsCallback: func() ([]*projectgroups.ProjectGroup, error) { return getAllGroups(*dependencies.Client) }, + CreateProjectGroupCallback: func() (string, cmd.Dependable, error) { return createProjectGroupCallback(dependencies) }, + GetAllGitCredentialsCallback: func() ([]*credentials.Resource, error) { + return createGetAllGitCredentialsCallback(*dependencies.Client) + }, + } +} + +func getAllGroups(client client.Client) ([]*projectgroups.ProjectGroup, error) { + res, err := client.ProjectGroups.GetAll() + if err != nil { + return nil, err + } + return res, nil +} + +func createProjectGroupCallback(dependencies *cmd.Dependencies) (string, cmd.Dependable, error) { + optValues := projectGroupCreate.NewCreateFlags() + projectGroupOpts := cmd.NewDependenciesFromExisting(dependencies, "octopus project-group create") + + projectGroupCreateOpts := projectGroupCreate.NewCreateOptions(optValues, projectGroupOpts) + projectGroupCreate.PromptMissing(projectGroupCreateOpts) + returnValue := projectGroupCreateOpts.Name.Value + return returnValue, projectGroupCreateOpts, nil +} + +func createGetAllGitCredentialsCallback(client client.Client) ([]*credentials.Resource, error) { + res, err := client.GitCredentials.Get(credentials.Query{}) + if err != nil { + return nil, err + } + return res.Items, nil +} + +func (co *CreateOptions) Commit() error { + lifecycle, err := co.Client.Lifecycles.GetByIDOrName(co.Lifecycle.Value) + if err != nil { + return err + } + + projectGroup, err := co.Client.ProjectGroups.GetByIDOrName(co.Group.Value) + if err != nil { + return err + } + + project := projects.NewProject(co.Name.Value, lifecycle.GetID(), projectGroup.GetID()) + project.Description = co.Description.Value + + createdProject, err := co.Client.Projects.Add(project) + if err != nil { + return err + } + + if co.ConfigAsCode.Value { + vcs, err := co.buildVersionControlSettings() + if err != nil { + return err + } + + _, err = co.Client.Projects.ConvertToVcs(createdProject, getInitialCommitMessage(co), vcs) + } + + _, err = fmt.Fprintf(co.Out, "\nSuccessfully created project %s (%s), with lifecycle %s in project group %s.\n", createdProject.Name, createdProject.Slug, co.Lifecycle.Value, co.Group.Value) + if err != nil { + return err + } + + link := output.Bluef("%s/app#/%s/projects/%s", co.Host, co.Space.GetID(), createdProject.GetID()) + fmt.Fprintf(co.Out, "View this project on Octopus Deploy: %s\n", link) + + return nil +} + +func (co *CreateOptions) buildVersionControlSettings() (*projects.VersionControlSettings, error) { + var credentials credentials.IGitCredential + var err error + if strings.EqualFold(co.GitStorage.Value, GitStorageLibrary) { + credentials, err = co.buildLibraryGitVersionControlSettings() + if err != nil { + return nil, err + } + } else { + credentials, err = co.buildProjectGitVersionControlSettings() + if err != nil { + return nil, err + } + } + url, err := url.Parse(co.GitUrl.Value) + if err != nil { + return nil, err + } + + vcs := projects.NewVersionControlSettings(getBasePath(co), credentials, getGitBranch(co), GitPersistenceType, url) + return vcs, nil +} + +func (co *CreateOptions) buildLibraryGitVersionControlSettings() (credentials.IGitCredential, error) { + creds, err := co.Client.GitCredentials.GetByIDOrName(co.GitCredentials.Value) + if err != nil { + return nil, err + } + + credentials := credentials.NewReference(creds.GetID()) + return credentials, nil +} + +func (co *CreateOptions) buildProjectGitVersionControlSettings() (credentials.IGitCredential, error) { + credentials := credentials.NewUsernamePassword(co.GitUsername.Value, core.NewSensitiveValue(co.GitPassword.Value)) + return credentials, nil +} + +func getGitBranch(opts *CreateOptions) string { + if opts.GitBranch.Value == "" { + return "main" + } + + return opts.GitBranch.Value +} + +func getBasePath(opts *CreateOptions) string { + if opts.GitBasePath.Value == "" { + return DefaultBasePath + } + + return opts.GitBasePath.Value +} + +func getInitialCommitMessage(opts *CreateOptions) string { + if opts.GitInitialCommitMessage.Value == "" { + return DefaultGitCommitMessage + } + + return opts.GitInitialCommitMessage.Value +} + +func (co *CreateOptions) GenerateAutomationCmd() { + if !co.NoPrompt { + autoCmd := flag.GenerateAutomationCmd(co.CmdPath, co.Name, co.Description, co.Group, co.Lifecycle, co.ConfigAsCode, co.GitStorage, co.GitBasePath, co.GitUrl, co.GitBranch, co.GitInitialCommitMessage, co.GitCredentials, co.GitUsername, co.GitPassword) + fmt.Fprintf(co.Out, "%s\n", autoCmd) + } +} diff --git a/pkg/cmd/project/create/create_test.go b/pkg/cmd/project/create/create_test.go new file mode 100644 index 00000000..834abe36 --- /dev/null +++ b/pkg/cmd/project/create/create_test.go @@ -0,0 +1,163 @@ +package create_test + +import ( + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/credentials" + "net/url" + "testing" + + "github.com/AlecAivazis/survey/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + projectCreate "github.com/OctopusDeploy/cli/pkg/cmd/project/create" + projectGroupCreate "github.com/OctopusDeploy/cli/pkg/cmd/projectgroup/create" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectgroups" + "github.com/stretchr/testify/assert" +) + +var serverUrl, _ = url.Parse("https://serverurl") +var spinner = &testutil.FakeSpinner{} +var rootResource = testutil.NewRootResource() + +func TestAskProjectGroup_WithProvidedName(t *testing.T) { + + value, _, err := projectCreate.AskProjectGroups(nil, "FooBar", nil, nil) + assert.NoError(t, err) + assert.Equal(t, "FooBar", value) +} + +func TestAskProjectGroup_WithExistingProjectGroup(t *testing.T) { + pa := []*testutil.PA{ + { + Prompt: &survey.Confirm{ + Message: "Would you like to create a new Project Group?", + }, + Answer: false, + }, + { + Prompt: &survey.Select{ + Message: "You have not specified a Project group for this project. Please select one:", + Options: []string{ + "foo", + "bar", + }, + }, + Answer: "bar", + }, + } + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + + getFakeProjectGroups := func() ([]*projectgroups.ProjectGroup, error) { + return []*projectgroups.ProjectGroup{ + projectgroups.NewProjectGroup("foo"), + projectgroups.NewProjectGroup("bar"), + }, nil + } + + value, _, err := projectCreate.AskProjectGroups(asker, "", getFakeProjectGroups, nil) + checkRemainingPrompts() + assert.NoError(t, err) + assert.Equal(t, "bar", value) +} + +func TestAskProjectGroup_WithNewProjectGroup(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewConfirmPrompt("Would you like to create a new Project Group?", "", true), + } + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + + projectGroupCreateOpts := projectGroupCreate.NewCreateOptions(nil, nil) + createProjectGroup := func() (string, cmd.Dependable, error) { + return "foo", projectGroupCreateOpts, nil + } + + value, pgOpts, err := projectCreate.AskProjectGroups(asker, "", nil, createProjectGroup) + checkRemainingPrompts() + assert.NoError(t, err) + assert.Equal(t, "foo", value) + assert.Equal(t, projectGroupCreateOpts, pgOpts) +} + +func TestPromptForConfigAsCode_NotUsingCac(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewConfirmPrompt("Would you like to use Config as Code?", "", false), + } + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + flags := projectCreate.NewCreateFlags() + flags.ConfigAsCode.Value = false + opts := projectCreate.NewCreateOptions(flags, &cmd.Dependencies{Ask: asker}) + err := projectCreate.PromptForConfigAsCode(opts, nil) + checkRemainingPrompts() + assert.NoError(t, err) + assert.False(t, opts.ConfigAsCode.Value) +} + +func TestPromptForConfigAsCode_UsingCacWithProjectStorage(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewConfirmPrompt("Would you like to use Config as Code?", "", true), + testutil.NewSelectPrompt("Select where to store the Git credentials", "", []string{"Library", "Project"}, "Project"), + testutil.NewInputPrompt("Git URL", "The URL of the Git repository to store configuration.", "https://github.com/blah.git"), + testutil.NewInputPrompt("Git repository base path", "The path in the repository where Config As Code settings are stored. Default value is '.octopus/'.", "./octopus/project"), + testutil.NewInputPrompt("Git branch", "The default branch to use. Default value is 'main'.", "main"), + testutil.NewInputPrompt("Initial Git commit message", "The commit message used in initializing. Default value is 'Initial commit of deployment process'.", "init message"), + testutil.NewInputPrompt("Git username", "The Git username.", "user1"), + testutil.NewPasswordPrompt("Git password", "The Git password.", "password"), + } + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + + flags := projectCreate.NewCreateFlags() + flags.ConfigAsCode.Value = false + opts := projectCreate.NewCreateOptions(flags, &cmd.Dependencies{Ask: asker}) + err := projectCreate.PromptForConfigAsCode(opts, nil) + checkRemainingPrompts() + assert.NoError(t, err) + assert.True(t, opts.ConfigAsCode.Value) + assert.Equal(t, "project", opts.GitStorage.Value) + assert.Equal(t, "https://github.com/blah.git", opts.GitUrl.Value) + assert.Equal(t, "./octopus/project", opts.GitBasePath.Value) + assert.Equal(t, "main", opts.GitBranch.Value) + assert.Equal(t, "init message", opts.GitInitialCommitMessage.Value) + assert.Equal(t, "user1", opts.GitUsername.Value) + assert.Equal(t, "password", opts.GitPassword.Value) +} + +func TestPromptForConfigAsCode_UsingCacWithLibraryStorage(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewConfirmPrompt("Would you like to use Config as Code?", "", true), + testutil.NewSelectPrompt("Select where to store the Git credentials", "", []string{"Library", "Project"}, "Library"), + testutil.NewInputPrompt("Git URL", "The URL of the Git repository to store configuration.", "https://github.com/blah.git"), + testutil.NewInputPrompt("Git repository base path", "The path in the repository where Config As Code settings are stored. Default value is '.octopus/'.", "./octopus/project"), + testutil.NewInputPrompt("Git branch", "The default branch to use. Default value is 'main'.", "main"), + testutil.NewInputPrompt("Initial Git commit message", "The commit message used in initializing. Default value is 'Initial commit of deployment process'.", "init message"), + testutil.NewSelectPrompt("Select which Git credentials to use", "", []string{"Git Creds 1", "Git Creds 2"}, "Git Creds 2"), + } + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + + gitCredsCallbackWasCalled := false + getGitCredentials := func() ([]*credentials.Resource, error) { + gitCredsCallbackWasCalled = true + creds := credentials.NewResource("Git Creds 1", credentials.NewReference("gitcreds-1")) + creds.ID = "gitcreds-1" + creds2 := credentials.NewResource("Git Creds 2", credentials.NewReference("gitcreds-2")) + creds2.ID = "gitcreds-2" + return []*credentials.Resource{creds, creds2}, nil + } + flags := projectCreate.NewCreateFlags() + flags.ConfigAsCode.Value = false + opts := projectCreate.NewCreateOptions(flags, &cmd.Dependencies{Ask: asker}) + err := projectCreate.PromptForConfigAsCode(opts, getGitCredentials) + checkRemainingPrompts() + assert.NoError(t, err) + assert.True(t, opts.ConfigAsCode.Value) + assert.Equal(t, "library", opts.GitStorage.Value) + assert.Equal(t, "https://github.com/blah.git", opts.GitUrl.Value) + assert.Equal(t, "./octopus/project", opts.GitBasePath.Value) + assert.Equal(t, "main", opts.GitBranch.Value) + assert.Equal(t, "init message", opts.GitInitialCommitMessage.Value) + assert.Equal(t, "Git Creds 2", opts.GitCredentials.Value) + assert.True(t, gitCredsCallbackWasCalled) + assert.Empty(t, opts.GitUsername.Value) + assert.Empty(t, opts.GitPassword.Value) +} diff --git a/pkg/cmd/project/project.go b/pkg/cmd/project/project.go index 84f02a9d..f4606a85 100644 --- a/pkg/cmd/project/project.go +++ b/pkg/cmd/project/project.go @@ -3,6 +3,7 @@ package project import ( "fmt" "github.com/MakeNowJust/heredoc/v2" + cmdCreate "github.com/OctopusDeploy/cli/pkg/cmd/project/create" cmdDelete "github.com/OctopusDeploy/cli/pkg/cmd/project/delete" cmdList "github.com/OctopusDeploy/cli/pkg/cmd/project/list" cmdView "github.com/OctopusDeploy/cli/pkg/cmd/project/view" @@ -29,6 +30,7 @@ func NewCmdProject(f factory.Factory) *cobra.Command { cmd.AddCommand(cmdList.NewCmdList(f)) cmd.AddCommand(cmdView.NewCmdView(f)) + cmd.AddCommand(cmdCreate.NewCmdCreate(f)) cmd.AddCommand(cmdDelete.NewCmdList(f)) return cmd diff --git a/pkg/cmd/projectgroup/create/create.go b/pkg/cmd/projectgroup/create/create.go new file mode 100644 index 00000000..2dcd105c --- /dev/null +++ b/pkg/cmd/projectgroup/create/create.go @@ -0,0 +1,130 @@ +package create + +import ( + "fmt" + "github.com/OctopusDeploy/cli/pkg/question" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectgroups" + "github.com/spf13/cobra" +) + +const ( + FlagName = "name" + FlagDescription = "description" +) + +type CreateFlags struct { + Name *flag.Flag[string] + Description *flag.Flag[string] +} + +type CreateOptions struct { + *CreateFlags + *cmd.Dependencies +} + +func NewCreateOptions(flags *CreateFlags, opts *cmd.Dependencies) *CreateOptions { + return &CreateOptions{ + CreateFlags: flags, + Dependencies: opts, + } +} + +func (co *CreateOptions) Commit() error { + projectGroup := projectgroups.NewProjectGroup(co.Name.Value) + projectGroup.Description = co.Description.Value + + createdGroupProject, err := co.Client.ProjectGroups.Add(projectGroup) + if err != nil { + return err + } + _, err = fmt.Fprintf(co.Out, "\nSuccessfully created project group %s.\n", createdGroupProject.Name) + if err != nil { + return err + } + link := output.Bluef("%s/app#/%s/projectGroups/%s", co.Host, co.Space.GetID(), createdGroupProject.GetID()) + fmt.Fprintf(co.Out, "View this project group on Octopus Deploy: %s\n", link) + return nil +} + +func (co *CreateOptions) GenerateAutomationCmd() { + if !co.NoPrompt { + autoCmd := flag.GenerateAutomationCmd(co.CmdPath, co.Name, co.Description) + fmt.Fprintf(co.Out, "%s\n", autoCmd) + } +} + +func NewCreateFlags() *CreateFlags { + return &CreateFlags{ + Name: flag.New[string](FlagName, false), + Description: flag.New[string](FlagDescription, false), + } +} + +func NewCmdCreate(f factory.Factory) *cobra.Command { + optFlags := NewCreateFlags() + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a new project group in Octopus Deploy", + Long: "Creates a new project group in Octopus Deploy.", + Example: heredoc.Doc(` + $ octopus project-group create + `), + RunE: func(c *cobra.Command, _ []string) error { + opts := NewCreateOptions(optFlags, cmd.NewDependencies(f, c)) + + return createRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&optFlags.Name.Value, optFlags.Name.Name, "n", "", "Name of the project group") + flags.StringVarP(&optFlags.Description.Value, optFlags.Description.Name, "d", "", "Description of the project group") + flags.SortFlags = false + + return cmd +} + +func createRun(opts *CreateOptions) error { + if !opts.NoPrompt { + if err := PromptMissing(opts); err != nil { + return err + } + } + + if err := opts.Commit(); err != nil { + return err + } + if !opts.NoPrompt { + fmt.Fprint(opts.Out, "Automation Command: ") + opts.GenerateAutomationCmd() + } + + return nil +} + +func PromptMissing(opts *CreateOptions) error { + messagePrefix := "" + if opts.ShowMessagePrefix { + messagePrefix = "Project Group " + } + + question.AskName(opts.Ask, messagePrefix, "project group", &opts.Name.Value) + + if opts.Description.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: messagePrefix + "Description", + Help: "A short, memorable, unique name for this project.", + }, &opts.Description.Value); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/cmd/projectgroup/project-group.go b/pkg/cmd/projectgroup/project-group.go new file mode 100644 index 00000000..53410fb5 --- /dev/null +++ b/pkg/cmd/projectgroup/project-group.go @@ -0,0 +1,30 @@ +package projectgroup + +import ( + "fmt" + "github.com/MakeNowJust/heredoc/v2" + createCmd "github.com/OctopusDeploy/cli/pkg/cmd/projectgroup/create" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/constants/annotations" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/spf13/cobra" +) + +func NewCmdProjectGroup(f factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "project-group ", + Short: "Manage project groups", + Long: `Work with Octopus Deploy project groups.`, + Example: fmt.Sprintf(heredoc.Doc(` + $ %s project-group list + $ %s project-group ls + `), constants.ExecutableName, constants.ExecutableName), + Annotations: map[string]string{ + annotations.IsCore: "true", + }, + } + + cmd.AddCommand(createCmd.NewCmdCreate(f)) + + return cmd +} diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 24dd257d..69494d52 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -14,6 +14,7 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/channels" octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/credentials" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deployments" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/feeds" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/packages" @@ -1192,11 +1193,15 @@ func TestReleaseCreate_AutomationMode(t *testing.T) { depProcess := fixtures.NewDeploymentProcessForProject(space1.ID, cacProjectID) + conversionState := projects.NewConversionState(false) + protectedBranchNamePatterns := []string{} cacProject := fixtures.NewProject(space1.ID, cacProjectID, "CaC Project", "Lifecycles-1", "ProjectGroups-1", depProcess.ID) cacProject.PersistenceSettings = projects.NewGitPersistenceSettings( ".octopus", - projects.NewAnonymousGitCredential(), + conversionState, + credentials.NewAnonymous(), "main", + protectedBranchNamePatterns, fakeRepoUrl, ) diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 9204738b..fec30809 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -7,6 +7,7 @@ import ( environmentCmd "github.com/OctopusDeploy/cli/pkg/cmd/environment" packageCmd "github.com/OctopusDeploy/cli/pkg/cmd/package" projectCmd "github.com/OctopusDeploy/cli/pkg/cmd/project" + projectGroupCmd "github.com/OctopusDeploy/cli/pkg/cmd/projectgroup" releaseCmd "github.com/OctopusDeploy/cli/pkg/cmd/release" runbookCmd "github.com/OctopusDeploy/cli/pkg/cmd/runbook" spaceCmd "github.com/OctopusDeploy/cli/pkg/cmd/space" @@ -38,6 +39,7 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro cmd.AddCommand(packageCmd.NewCmdPackage(f)) // core + cmd.AddCommand(projectGroupCmd.NewCmdProjectGroup(f)) cmd.AddCommand(projectCmd.NewCmdProject(f)) // configuration diff --git a/pkg/question/input.go b/pkg/question/input.go index b0d44f45..854752fb 100644 --- a/pkg/question/input.go +++ b/pkg/question/input.go @@ -45,3 +45,19 @@ func DeleteWithConfirmation(ask Asker, itemType string, itemName string, itemID fmt.Printf("%s The %s, \"%s\" %s was deleted successfully.\n", output.Red("✔"), itemType, itemName, output.Dimf("(%s)", itemID)) return nil } + +func AskName(ask Asker, messagePrefix string, resourceDescription string, value *string) error { + if *value == "" { + if err := ask(&survey.Input{ + Message: messagePrefix + "Name", + Help: fmt.Sprintf("A short, memorable, unique name for this %s.", resourceDescription), + }, value, survey.WithValidator(survey.ComposeValidators( + survey.MaxLength(200), + survey.MinLength(1), + survey.Required, + ))); err != nil { + return err + } + } + return nil +} diff --git a/pkg/question/input_test.go b/pkg/question/input_test.go index 98d1363e..5a24f852 100644 --- a/pkg/question/input_test.go +++ b/pkg/question/input_test.go @@ -67,3 +67,15 @@ func TestQuestion_DeleteWithConfirmation_deleteError(t *testing.T) { err := <-errReceiver assert.Equal(t, errors.New("ouch"), err) } + +func TestAskName(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewInputPrompt("prefix Name", "A short, memorable, unique name for this resource.", "answer"), + } + qa, _ := testutil.NewMockAsker(t, pa) + + var value string + err := question.AskName(qa, "prefix ", "resource", &value) + assert.NoError(t, err) + assert.Equal(t, value, "answer") +} diff --git a/pkg/question/selectors/gitCredentialStorage.go b/pkg/question/selectors/gitCredentialStorage.go new file mode 100644 index 00000000..3cc7bcff --- /dev/null +++ b/pkg/question/selectors/gitCredentialStorage.go @@ -0,0 +1,26 @@ +package selectors + +import ( + "github.com/OctopusDeploy/cli/pkg/question" +) + +func GitCredentialStorage(questionText string, ask question.Asker) (string, error) { + options := []*SelectOption[string]{ + {Display: "Project", Value: "project"}, + {Display: "Library", Value: "library"}, + } + + optionsCallback := func() ([]*SelectOption[string], error) { + return options, nil + } + + selectedOption, err := Select(ask, questionText, optionsCallback, func(option *SelectOption[string]) string { + return option.Display + }) + + if err != nil { + return "", err + } + + return selectedOption.Value, nil +} diff --git a/pkg/question/selectors/lifecycles.go b/pkg/question/selectors/lifecycles.go new file mode 100644 index 00000000..2256544f --- /dev/null +++ b/pkg/question/selectors/lifecycles.go @@ -0,0 +1,21 @@ +package selectors + +import ( + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/lifecycles" +) + +func Lifecycle(questionText string, octopus *client.Client, ask question.Asker) (*lifecycles.Lifecycle, error) { + existingLifecycles, err := octopus.Lifecycles.GetAll() + if err != nil { + return nil, err + } + lifecyclesCallback := func() ([]*lifecycles.Lifecycle, error) { + return existingLifecycles, nil + } + + return Select(ask, questionText, lifecyclesCallback, func(lc *lifecycles.Lifecycle) string { + return lc.Name + }) +} diff --git a/pkg/question/selectors/selector_test.go b/pkg/question/selectors/selector_test.go new file mode 100644 index 00000000..94f153e9 --- /dev/null +++ b/pkg/question/selectors/selector_test.go @@ -0,0 +1,58 @@ +package selectors + +import ( + "github.com/AlecAivazis/survey/v2" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/stretchr/testify/assert" + "testing" +) + +type Item struct { + id string + name string +} + +func TestSelectForSingleItem(t *testing.T) { + itemsCallback := func() ([]*Item, error) { + return []*Item{ + { + id: "1", + name: "name", + }, + }, nil + } + + selectedItem, err := Select(nil, "question", itemsCallback, func(item *Item) string { return item.name }) + assert.Nil(t, err) + assert.Equal(t, selectedItem.id, "1") +} + +func TestSelectForMultipleItem(t *testing.T) { + items := []*Item{ + { + id: "1", + name: "name", + }, + { + id: "2", + name: "name 2", + }, + } + itemsCallback := func() ([]*Item, error) { + return items, nil + } + pa := []*testutil.PA{ + { + Prompt: &survey.Select{ + Message: "question", + Options: []string{items[0].name, items[1].name}, + }, + Answer: "name 2", + }, + } + mockAsker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + selectedItem, err := Select(mockAsker, "question", itemsCallback, func(item *Item) string { return item.name }) + checkRemainingPrompts() + assert.Nil(t, err) + assert.Equal(t, selectedItem.id, "2") +} diff --git a/pkg/question/selectors/selectors.go b/pkg/question/selectors/selectors.go index b5f3d2e5..291a0e18 100644 --- a/pkg/question/selectors/selectors.go +++ b/pkg/question/selectors/selectors.go @@ -8,6 +8,11 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/accounts" ) +type SelectOption[T any] struct { + Value T + Display string +} + type NameOrID interface { GetName() string GetID() string @@ -34,3 +39,23 @@ func Account(ask question.Asker, list []accounts.IAccount, message string) (acco } return selectedItem, nil } + +func SelectOptions[T any](ask question.Asker, questionText string, itemsCallback func() []*SelectOption[T]) (*SelectOption[T], error) { + items := itemsCallback() + callback := func() ([]*SelectOption[T], error) { + return items, nil + } + return Select(ask, questionText, callback, func(option *SelectOption[T]) string { return option.Display }) +} + +func Select[T any](ask question.Asker, questionText string, itemsCallback func() ([]*T, error), getKey func(item *T) string) (*T, error) { + items, err := itemsCallback() + if err != nil { + return nil, err + } + if len(items) == 1 { + return items[0], nil + } + + return question.SelectMap(ask, questionText, items, getKey) +} diff --git a/test/fixtures/projects.go b/test/fixtures/projects.go index 607c4a4d..67cf045c 100644 --- a/test/fixtures/projects.go +++ b/test/fixtures/projects.go @@ -5,6 +5,7 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/channels" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/credentials" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deployments" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" @@ -80,9 +81,11 @@ func NewProject(spaceID string, projectID string, projectName string, lifecycleI func NewVersionControlledProject(spaceID string, projectID string, projectName string, lifecycleID string, projectGroupID string, deploymentProcessID string) *projects.Project { repoUrl, _ := url.Parse("https://server/repo.git") + conversionState := projects.NewConversionState(false) + protectedBranchNamePatterns := []string{} result := NewProject(spaceID, projectID, projectName, lifecycleID, projectGroupID, deploymentProcessID) result.VersioningStrategy = nil // CaC projects seem to always report nil here via the API - result.PersistenceSettings = projects.NewGitPersistenceSettings(".octopus", projects.NewAnonymousGitCredential(), "main", repoUrl) + result.PersistenceSettings = projects.NewGitPersistenceSettings(".octopus", conversionState, credentials.NewAnonymous(), "main", protectedBranchNamePatterns, repoUrl) // CaC projects have different values in these links result.Links["DeploymentProcess"] = fmt.Sprintf("/api/%s/projects/%s/{gitRef}/deploymentprocesses", spaceID, projectID) // note gitRef is a template param in the middle of the url path diff --git a/test/testutil/fakeoctopusserver.go b/test/testutil/fakeoctopusserver.go index 0157c166..37ffdfac 100644 --- a/test/testutil/fakeoctopusserver.go +++ b/test/testutil/fakeoctopusserver.go @@ -207,5 +207,7 @@ func NewRootResource() *octopusApiClient.RootResource { root.Links[constants.LinkTenants] = "/api/Spaces-1/tenants{/id}{?skip,projectId,name,tags,take,ids,clone,partialName,clonedFromTenantId}" root.Links[constants.LinkAccounts] = "/api/Spaces-1/accounts{/id}{?skip,take,ids,partialName,accountType}" root.Links[constants.LinkPackages] = "/api/Spaces-1/packages{/id}{?nuGetPackageId,filter,latest,skip,take,includeNotes}" + root.Links[constants.LinkLifecycles] = "/api/Spaces-1/lifecycles{/id}{?skip,take,ids,partialName}" + root.Links[constants.LinkProjectGroups] = "/api/Spaces-1/projectgroups{/id}{?skip,take,ids,partialName}" return root } diff --git a/test/testutil/fakesurvey.go b/test/testutil/fakesurvey.go index ad97add0..97c95f1a 100644 --- a/test/testutil/fakesurvey.go +++ b/test/testutil/fakesurvey.go @@ -2,12 +2,137 @@ package testutil import ( "errors" + "fmt" + "testing" + "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/core" + "github.com/OctopusDeploy/cli/pkg/question" "github.com/stretchr/testify/assert" - "testing" ) +type PA struct { + Prompt survey.Prompt + Answer any + ShouldSkipValidation bool +} + +type CheckRemaining func() + +func NewInputPrompt(prompt string, help string, response string) *PA { + return &PA{ + Prompt: &survey.Input{ + Message: prompt, + Help: help, + }, + Answer: response, + } +} + +func NewPasswordPrompt(prompt string, help string, response string) *PA { + return &PA{ + Prompt: &survey.Password{ + Message: prompt, + Help: help, + }, + Answer: response, + } +} + +func NewSelectPrompt(prompt string, help string, options []string, response string) *PA { + return &PA{ + Prompt: &survey.Select{ + Message: prompt, + Options: options, + Help: help, + }, + Answer: response, + } +} + +func NewConfirmPrompt(prompt string, help string, response any) *PA { + return &PA{ + Prompt: &survey.Confirm{ + Message: prompt, + Help: help, + }, + Answer: response, + } +} + +func NewMockAsker(t *testing.T, pa []*PA) (question.Asker, CheckRemaining) { + expectedQuestionIndex := 0 + + checkRemaining := func() { + if expectedQuestionIndex >= len(pa) { + return + } + remainingPA := pa[expectedQuestionIndex:] + for _, remaining := range remainingPA { + assert.Fail(t, fmt.Sprintf("Expected the following prompt: %+v", remaining.Prompt)) + } + } + + mockAsker := func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { + if expectedQuestionIndex >= len(pa) { + assert.FailNow(t, fmt.Sprintf("Did not expect anymore questions but got: %+v", p)) + return fmt.Errorf("did not expect anymore questions") + } + + options := &survey.AskOptions{} + for _, opt := range opts { + if opt == nil { + continue + } + if err := opt(options); err != nil { + return err + } + } + + if response == nil { + return errors.New("cannot call Ask() with a nil reference to record the answers") + } + + validate := func(q *survey.Question, val interface{}) error { + if q.Validate != nil { + if err := q.Validate(val); err != nil { + return err + } + } + for _, v := range options.Validators { + if err := v(val); err != nil { + return err + } + } + return nil + } + + expectedQA := pa[expectedQuestionIndex] + expectedQuestionIndex += 1 + + isEqual := assert.Equal(t, expectedQA.Prompt, p) + if !isEqual { + return fmt.Errorf("did not get expected question") + } + + currentQuestion := survey.Question{Prompt: p} + + if !expectedQA.ShouldSkipValidation { + validationErr := validate(¤tQuestion, expectedQA.Answer) + if !assert.NoError(t, validationErr) { + return validationErr + } + } + + if err := core.WriteAnswer(response, "", expectedQA.Answer); err != nil { + return err + } + + return nil + } + return mockAsker, checkRemaining +} + type answerOrError struct { answer any error error