Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: project create #115

Merged
merged 29 commits into from
Oct 19, 2022
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f3e7fca
basic working project create
benPearce1 Oct 7, 2022
c283bde
project create with lifecycle and group prompts
benPearce1 Oct 10, 2022
9653db3
project create initial integration tests
benPearce1 Oct 10, 2022
88b416b
skip select if there is only one option
benPearce1 Oct 10, 2022
98d10ca
feat: nested commands (#109)
domenicsim1 Oct 11, 2022
f0a5a76
Merge branch 'bp/project-create' of https://github.com/OctopusDeploy/…
benPearce1 Oct 11, 2022
5f50b52
working project vcs config
benPearce1 Oct 11, 2022
d55ff9b
cac support with project stored credentials
benPearce1 Oct 13, 2022
16acf77
chore: code refactor and testing changes
domenicsim1 Oct 13, 2022
5a94d1f
prompt ordering
benPearce1 Oct 13, 2022
4d79676
Merge from 'main'
benPearce1 Oct 14, 2022
4085d57
fix/check-remaining-for-mock-asker (#114)
domenicsim1 Oct 14, 2022
b0311f2
move common ask name function
benPearce1 Oct 14, 2022
15f4428
tests for CaC questions
benPearce1 Oct 14, 2022
86c5baa
move funcs closer to where they are being used
benPearce1 Oct 14, 2022
c86ad72
use the helper method
benPearce1 Oct 14, 2022
5d97470
accident
benPearce1 Oct 14, 2022
b00c777
Add support for reference git credentials
benPearce1 Oct 17, 2022
086a0aa
missed automation on git base path
benPearce1 Oct 17, 2022
cf38f22
rename cac
benPearce1 Oct 17, 2022
a69591d
fixed up examples in doc
benPearce1 Oct 18, 2022
f79b8f7
update client reference
benPearce1 Oct 18, 2022
2c0d949
update indirect package references
benPearce1 Oct 18, 2022
57d6f46
missed flag
benPearce1 Oct 18, 2022
487aaa7
fixed up commit structure for no-prompt scenario
benPearce1 Oct 18, 2022
91a4f0c
use formatting commands rather than concatenation
benPearce1 Oct 18, 2022
3b5aa46
fix up tests
benPearce1 Oct 18, 2022
ec7cbd3
another test fix
benPearce1 Oct 18, 2022
2f2b6d2
remove redundant input constraint
benPearce1 Oct 19, 2022
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
57 changes: 57 additions & 0 deletions pkg/cmd/dependencies.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package cmd
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q for @domenicsim1
Given that this isn't a command itself, should it go under the cmd directory structure, or should it go under just pkg or pkg/somethingelse?

Copy link
Collaborator

@domenicsim1 domenicsim1 Oct 18, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe, although it is suppose to be the basis of cmds. Not sure yet, happy to leave it for now.


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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I completely understand we're trying to fit within the patterns and idioms of golang... but Dependable is perhaps too vague. Does this mean it's never going to let us down? 🤣

Commit() error
GenerateAutomationCmd()
}

type Dependencies struct {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps Dependencies might be a bit of a vague name here? My immediate thought is "which dependencies does this struct represent? who depends on them and why?"

From a quick look, it appears as though this struct is meant to carry services (client, asker) and options (space, noprompt) that are commonly used across many commands? If so, some possible naming suggestions?

  • ServicesAndOptions
  • CommandContext
  • CliCommandContext
  • CommandCommonData
  • StandardCommandProperties
  • CommonCommandProperties
  • GeneralCommandProps

... something to think about anyway

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tag @slewis74

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,
}
}
293 changes: 293 additions & 0 deletions pkg/cmd/project/create/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
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/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/projectgroups"
"github.com/spf13/cobra"
)

const (
FlagGroup = "group"
FlagName = "name"
FlagDescription = "description"
FlagLifecycle = "lifecycle"
FlagConfigAsCode = "cac"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure I am happy with name. Suggestions?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found when doing release create that it's referred to as "Version Controlled" in almost all places in the octopus codebase and web portal and other documentation. E.g. you'll see documentation snippets like "for a version controlled project...", so I used that term there.
So, within the CLI there's precedent to call this "versionControlled".

On the other hand, the "brand name" that keeps showing up on twitter/linkedin/customer forums is always Config As Code, so 🤷 ? tag @jbristowe

FlagGitUrl = "git-url"
FlagGitBranch = "git-branch"
FlagGitLibraryCredentials = "git-credentials"
FlagGitUsername = "git-username"
FlagGitPassword = "git-password"
FlagGitCredentialStorage = "git-cred-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),
GitUrl: flag.New[string](FlagGitUrl, false),
GitBranch: flag.New[string](FlagGitBranch, false),
GitCredentials: flag.New[string](FlagGitLibraryCredentials, false),
GitUsername: flag.New[string](FlagGitUsername, false),
GitPassword: flag.New[string](FlagGitPassword, true),
GitStorage: flag.New[string](FlagGitCredentialStorage, false),
GitInitialCommitMessage: flag.New[string](FlagGitInitialCommitMessage, false),
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: heredoc.Doc(`
$ octopus project create .... fill this in later
`),
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.StringVarP(&createFlags.GitUrl.Value, createFlags.GitUrl.Name, "u", "", "Url of the Git repository for storing project configuration")
flags.StringVarP(&createFlags.GitBranch.Value, createFlags.GitBranch.Name, "b", "", "The default branch to use for Config As Code, default is main.")
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.SortFlags = false

return cmd
}

func createRun(opts *CreateOptions) error {
if !opts.NoPrompt {
optsArray, err := PromptMissing(opts)
if err != nil {
return err
}

for _, o := range optsArray {
if err := o.Commit(); err != nil {
return err
}
}

if !opts.NoPrompt {
benPearce1 marked this conversation as resolved.
Show resolved Hide resolved
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)
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) 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.MinLength(1),
survey.Required,
))); err != nil {
return err
}
}

if opts.GitBasePath.Value == "" {
if err := opts.Ask(&survey.Input{
Message: "Git repository base path",
Help: "The path in the repository where Config As Code settings are stored. Default value: '" + 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: "The default branch to use. Default value: '" + 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: "The commit message used in initializing. Default value: '" + DefaultGitCommitMessage + "'",
benPearce1 marked this conversation as resolved.
Show resolved Hide resolved
}, &opts.GitInitialCommitMessage.Value, survey.WithValidator(survey.ComposeValidators(
survey.MaxLength(50),
))); err != nil {
return err
}
}

if opts.GitStorage.Value == GitStorageLibrary {
benPearce1 marked this conversation as resolved.
Show resolved Hide resolved
panic("library storage not currently supported")
} else {
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.MinLength(1),
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.MinLength(1),
survey.Required,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC both Required and MinLength(1) do the same job, and we probably don't need both of them.

I ran into this when selecting environments for release deployment, which is a multi-select with min-items(1); Required did the same job but produced a better error message so I used that instead

))); err != nil {
return err
}
}
}
}

return nil
}

func getGitStorageOptions() []*selectors.SelectOption[string] {
return []*selectors.SelectOption[string]{
{Display: "Project", Value: GitStorageProject},
{Display: "Library", Value: GitStorageLibrary},
}
}
Loading