From ce93d7edc32b7d9924268328662193cede2a309e Mon Sep 17 00:00:00 2001 From: Orion Edwards Date: Thu, 25 Aug 2022 22:52:46 +1200 Subject: [PATCH] feat: release list and release create --- pkg/cmd/release/create/create.go | 66 +---- pkg/cmd/release/delete/delete.go | 214 ++++++++++++++ pkg/cmd/release/delete/delete_test.go | 401 ++++++++++++++++++++++++++ pkg/cmd/release/list/list.go | 196 +++++++------ pkg/cmd/release/list/list_test.go | 285 ++++++++++++++++++ pkg/cmd/release/release.go | 2 + pkg/factory/factory.go | 9 + pkg/output/print_array.go | 7 +- pkg/util/selecting_finding_common.go | 49 ++++ pkg/util/util.go | 12 + test/fixtures/projects.go | 30 +- test/testutil/fakefactory.go | 27 +- test/testutil/fakeoctopusserver.go | 24 +- test/testutil/testutil.go | 9 + 14 files changed, 1163 insertions(+), 168 deletions(-) create mode 100644 pkg/cmd/release/delete/delete.go create mode 100644 pkg/cmd/release/delete/delete_test.go create mode 100644 pkg/cmd/release/list/list_test.go create mode 100644 pkg/util/selecting_finding_common.go diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 2a8a89e5..0f1e342c 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -4,8 +4,10 @@ import ( "errors" "fmt" "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc/v2" cliErrors "github.com/OctopusDeploy/cli/pkg/errors" "github.com/OctopusDeploy/cli/pkg/executor" + "github.com/OctopusDeploy/cli/pkg/factory" "github.com/OctopusDeploy/cli/pkg/output" "github.com/OctopusDeploy/cli/pkg/question" "github.com/OctopusDeploy/cli/pkg/surveyext" @@ -17,16 +19,12 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/feeds" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" + "github.com/spf13/cobra" "io" "os" "regexp" "sort" "strings" - "unicode" - - "github.com/MakeNowJust/heredoc/v2" - "github.com/OctopusDeploy/cli/pkg/factory" - "github.com/spf13/cobra" ) const ( @@ -277,15 +275,6 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error return nil } -func quoteStringIfRequired(str string) string { - for _, c := range str { - if unicode.IsSpace(c) { - return fmt.Sprintf("\"%s\"", str) - } - } - return str -} - type StepPackageVersion struct { // these 3 fields are the main ones for showing the user PackageID string @@ -748,13 +737,13 @@ func printPackageVersions(ioWriter io.Writer, packages []*StepPackageVersion) er func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, spinner factory.Spinner, options *executor.TaskOptionsCreateRelease) error { if octopus == nil { - return errors.New("api client is required") + return cliErrors.NewArgumentNullOrEmptyError("octopus") } if asker == nil { - return errors.New("asker is required") + return cliErrors.NewArgumentNullOrEmptyError("asker") } if options == nil { - return errors.New("options is required") + return cliErrors.NewArgumentNullOrEmptyError("options") } // Note: we don't get here at all if no-prompt is enabled, so we know we are free to ask questions @@ -765,12 +754,12 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques var err error var selectedProject *projects.Project if options.ProjectName == "" { - selectedProject, err = selectProject(octopus, asker, spinner) + selectedProject, err = util.SelectProject("Select the project in which the release will be created", octopus, asker, spinner) if err != nil { return err } } else { // project name is already provided, fetch the object because it's needed for further questions - selectedProject, err = findProject(octopus, spinner, options.ProjectName) + selectedProject, err = util.FindProject(octopus, spinner, options.ProjectName) if err != nil { return err } @@ -1116,45 +1105,6 @@ func findChannel(octopus *octopusApiClient.Client, spinner factory.Spinner, proj return nil, fmt.Errorf("no channel found with name of %s", channelName) } -func findProject(octopus *octopusApiClient.Client, spinner factory.Spinner, projectName string) (*projects.Project, error) { - // projectsQuery has "Name" but it's just an alias in the server for PartialName; we need to filter client side - spinner.Start() - projectsPage, err := octopus.Projects.Get(projects.ProjectsQuery{PartialName: projectName}) - if err != nil { - spinner.Stop() - return nil, err - } - for projectsPage != nil && len(projectsPage.Items) > 0 { - for _, c := range projectsPage.Items { // server doesn't support channel search by exact name so we must emulate it - if strings.EqualFold(c.Name, projectName) { - spinner.Stop() - return c, nil - } - } - projectsPage, err = projectsPage.GetNextPage(octopus.Projects.GetClient()) - if err != nil { - spinner.Stop() - return nil, err - } // if there are no more pages, then GetNextPage will return nil, which breaks us out of the loop - } - - spinner.Stop() - return nil, fmt.Errorf("no project found with name of %s", projectName) -} - -func selectProject(octopus *octopusApiClient.Client, ask question.Asker, spinner factory.Spinner) (*projects.Project, error) { - spinner.Start() - existingProjects, err := octopus.Projects.GetAll() - spinner.Stop() - if err != nil { - return nil, err - } - - return question.SelectMap(ask, "Select the project in which the release will be created", existingProjects, func(p *projects.Project) string { - return p.Name - }) -} - func selectGitReference(octopus *octopusApiClient.Client, ask question.Asker, spinner factory.Spinner, project *projects.Project) (*projects.GitReference, error) { spinner.Start() branches, err := octopus.Projects.GetGitBranches(project) diff --git a/pkg/cmd/release/delete/delete.go b/pkg/cmd/release/delete/delete.go new file mode 100644 index 00000000..221152c2 --- /dev/null +++ b/pkg/cmd/release/delete/delete.go @@ -0,0 +1,214 @@ +package delete + +import ( + "errors" + "fmt" + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/cli/pkg/util/flag" + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" + "github.com/hashicorp/go-multierror" + "github.com/spf13/cobra" +) + +const ( + FlagProject = "project" + FlagVersion = "version" +) + +type Flags struct { + Project *flag.Flag[string] + Version *flag.Flag[[]string] +} + +func NewFlags() *Flags { + return &Flags{ + Project: flag.New[string](FlagProject, false), + Version: flag.New[[]string](FlagVersion, false), + } +} + +func NewCmdDelete(f factory.Factory) *cobra.Command { + cmdFlags := NewFlags() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a release in Octopus Deploy", + Long: "Delete a release in Octopus Deploy.", + Example: heredoc.Doc(` + $ octopus release delete myProject 2.0 + $ octopus release delete --project myProject --version 2.0 + $ octopus release rm "Other Project" -v 2.0 + `), + Aliases: []string{"del", "rm"}, + RunE: func(cmd *cobra.Command, args []string) error { + return deleteRun(cmd, f, cmdFlags, args) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&cmdFlags.Project.Value, cmdFlags.Project.Name, "p", "", "Name or ID of the project to delete releases in") + flags.StringSliceVarP(&cmdFlags.Version.Value, cmdFlags.Version.Name, "v", make([]string, 0), "Release version to delete, can be specified multiple times") + return cmd +} + +func deleteRun(cmd *cobra.Command, f factory.Factory, flags *Flags, args []string) error { + // command line arg interpretation depends on which flags are present. + // e.g. `release delete -p MyProject -v 2.0` means we don't need to look at args at + // e.g. `release delete -p MyProject 2.0` means args[0] is the version + // e.g. `release delete MyProject -v 2.0` means args[0] is the project + // e.g. `release delete MyProject 2.0` means args[0] is the project and args[1] is the version + projectNameOrID := flags.Project.Value + versionsToDelete := flags.Version.Value + + possibleVersionArgs := args + if projectNameOrID == "" && len(args) > 0 { + projectNameOrID = args[0] + possibleVersionArgs = args[1:] // we've consumed this arg, take it out of consideration for a version + } + for _, a := range possibleVersionArgs { + versionsToDelete = append(versionsToDelete, a) + } + + // now off we go + octopus, err := f.GetSpacedClient() + if err != nil { + return err + } + spinner := f.Spinner() + + var selectedProject *projects.Project + var releasesToDelete []*releases.Release + + if f.IsPromptEnabled() { // this would be AskQuestions if it were bigger + if projectNameOrID == "" { + selectedProject, err = util.SelectProject("Select the project to delete a release in", octopus, f.Ask, spinner) + if err != nil { + return err + } + } else { // project name is already provided, fetch the object because it's needed for further questions + selectedProject, err = util.FindProject(octopus, spinner, projectNameOrID) + if err != nil { + return err + } + cmd.Printf("Project %s\n", output.Cyan(selectedProject.Name)) + } + + if len(versionsToDelete) == 0 { + releasesToDelete, err = selectReleases(octopus, selectedProject, f.Ask, spinner) + if err != nil { + return err + } + } else { + releasesToDelete, err = findReleases(octopus, spinner, selectedProject, versionsToDelete) + if err != nil { + return err + } + } + + if len(releasesToDelete) == 0 { + return nil // no work to do, just exit + } + + // prompt for confirmation + cmd.Printf("You are about to delete the following releases:\n") + for _, r := range releasesToDelete { + cmd.Printf("%s\n", r.Version) + } + + var isConfirmed bool + if err = f.Ask(&survey.Confirm{ + Message: fmt.Sprintf("Confirm delete of %d release(s)", len(releasesToDelete)), + Default: false, + }, &isConfirmed); err != nil { + return err + } + if !isConfirmed { + return nil // nothing to be done here + } + + } else { // we don't have the executions API backing us and allowing NameOrID; we need to do the lookups ourselves + // validation + if projectNameOrID == "" { + return errors.New("project must be specified") + } + if len(versionsToDelete) == 0 { + return errors.New("at least one release version must be specified") + } + + selectedProject, err = util.FindProject(octopus, factory.NoSpinner, projectNameOrID) + if err != nil { + return err + } + releasesToDelete, err = findReleases(octopus, factory.NoSpinner, selectedProject, versionsToDelete) + if err != nil { + return err + } + } + + if len(releasesToDelete) == 0 { + // no work to do, just exit + return nil + } + + spinner.Start() + var releaseDeleteErrors = &multierror.Error{} + for _, r := range releasesToDelete { + err = octopus.Releases.DeleteByID(r.ID) + if err != nil { + wrappedErr := fmt.Errorf("failed to delete release %s: %s", r.Version, err) + cmd.PrintErr(fmt.Sprintf("%s\n", wrappedErr.Error())) + releaseDeleteErrors = multierror.Append(releaseDeleteErrors, wrappedErr) + } + } + spinner.Stop() + + failedCount := releaseDeleteErrors.Len() + actuallyDeletedCount := len(releasesToDelete) - failedCount + + if failedCount == 0 { // all good + cmd.Printf("Successfully deleted %d releases\n", actuallyDeletedCount) + } else if actuallyDeletedCount == 0 { // all bad + cmd.Printf("Failed to delete %d releases\n", failedCount) + } else { // partial + cmd.Printf("Deleted %d releases. %d releases failed\n", actuallyDeletedCount, failedCount) + } + return releaseDeleteErrors.ErrorOrNil() +} + +func selectReleases(octopus *octopusApiClient.Client, project *projects.Project, ask question.Asker, spinner factory.Spinner) ([]*releases.Release, error) { + spinner.Start() + existingReleases, err := octopus.Projects.GetReleases(project) // gets all of them, no paging + spinner.Stop() + if err != nil { + return nil, err + } + + return question.MultiSelectMap(ask, "Select Releases to delete", existingReleases, func(p *releases.Release) string { + return p.Version + }) +} + +func findReleases(octopus *octopusApiClient.Client, spinner factory.Spinner, project *projects.Project, versionStrings []string) ([]*releases.Release, error) { + spinner.Start() + existingReleases, err := octopus.Projects.GetReleases(project) // gets all of them, no paging + spinner.Stop() + if err != nil { + return nil, err + } + + versionStringLookup := make(map[string]bool, len(versionStrings)) + for _, s := range versionStrings { + versionStringLookup[s] = true + } + + return util.SliceFilter(existingReleases, func(p *releases.Release) bool { + _, exists := versionStringLookup[p.Version] + return exists + }), nil +} diff --git a/pkg/cmd/release/delete/delete_test.go b/pkg/cmd/release/delete/delete_test.go new file mode 100644 index 00000000..a2626a30 --- /dev/null +++ b/pkg/cmd/release/delete/delete_test.go @@ -0,0 +1,401 @@ +package delete_test + +import ( + "bytes" + "errors" + "fmt" + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc/v2" + cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/hashicorp/go-multierror" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "testing" +) + +var rootResource = testutil.NewRootResource() + +func TestReleaseDelete(t *testing.T) { + const spaceID = "Spaces-1" + const fireProjectID = "Projects-22" + const waterProjectID = "Projects-29" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + + fireProject := fixtures.NewProject(spaceID, fireProjectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "") + waterProject := fixtures.NewProject(spaceID, waterProjectID, "Water Project", "Lifecycles-1", "ProjectGroups-1", "") + defaultChannelID := "Channels-1" + betaChannelID := "Channels-13" + + rDefault21 := fixtures.NewRelease(spaceID, "Releases-21", "2.1", fireProjectID, defaultChannelID) + rDefault20 := fixtures.NewRelease(spaceID, "Releases-20", "2.0", fireProjectID, defaultChannelID) + rBeta20b2 := fixtures.NewRelease(spaceID, "Releases-12", "2.0-beta2", fireProjectID, betaChannelID) + rBeta20b1 := fixtures.NewRelease(spaceID, "Releases-11", "2.0-beta1", fireProjectID, betaChannelID) + + // we have a load of tests which are all the same except they vary the cmdline args; this is a common test body + standardDeleteTestBody := func(api *testutil.MockHttpServer, releaseIDs ...string) { + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?clonedFromProjectId=&partialName=Fire+Project"). + RespondWith(resources.Resources[*projects.Project]{ + Items: []*projects.Project{fireProject}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases"). + RespondWith(resources.Resources[*releases.Release]{ + Items: []*releases.Release{rDefault21, rDefault20, rBeta20b2, rBeta20b1}, + }) + + // then loop(delete release x) + for _, id := range releaseIDs { + api.ExpectRequest(t, "DELETE", "/api/Spaces-1/releases/"+id).RespondWith(nil) + } + } + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"noprompt: requires a project", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "project must be specified") + + assert.Equal(t, "", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"noprompt: requires at least one version", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", "--project", fireProject.Name, "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "at least one release version must be specified") + + assert.Equal(t, "", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"noprompt: picks up version and project from flags and deletes matching releases", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", "--project", fireProject.Name, "--version", "2.0", "--version", "2.1", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + standardDeleteTestBody(api, rDefault21.ID, rDefault20.ID) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, "Successfully deleted 2 releases\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + + }}, + + {"noprompt: picks up version and project from args and deletes matching releases", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", fireProject.Name, "2.0", "2.1", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + standardDeleteTestBody(api, rDefault21.ID, rDefault20.ID) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, "Successfully deleted 2 releases\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"noprompt: picks up version and project from args and deletes matching releases", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", fireProject.Name, "2.0", "2.1", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + standardDeleteTestBody(api, rDefault21.ID, rDefault20.ID) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, "Successfully deleted 2 releases\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"noprompt: picks up project from first arg and versions from subsequent", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", "--version", "2.0", fireProject.Name, "2.1", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + standardDeleteTestBody(api, rDefault21.ID, rDefault20.ID) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, "Successfully deleted 2 releases\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"noprompt: picks up version from first arg if project is specified using a flag", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + // super weird, but it's possible so make sure it works + rootCmd.SetArgs([]string{"release", "delete", "2.0", "--project", fireProject.Name, "2.1", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + standardDeleteTestBody(api, rDefault21.ID, rDefault20.ID) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, "Successfully deleted 2 releases\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + // ----- failure modes ------ + + {"noprompt: error when deleting 1 release and it fails", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", fireProject.Name, "2.1", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?clonedFromProjectId=&partialName=Fire+Project"). + RespondWith(resources.Resources[*projects.Project]{ + Items: []*projects.Project{fireProject}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases"). + RespondWith(resources.Resources[*releases.Release]{ + Items: []*releases.Release{rDefault21, rDefault20, rBeta20b2, rBeta20b1}, + }) + + someKindOfNetworkError := errors.New("some kind of network error") + api.ExpectRequest(t, "DELETE", "/api/Spaces-1/releases/"+rDefault21.ID). + RespondWithError(someKindOfNetworkError) + + // this error output is terrible but we can't fix it until the go API client wrapper vNext + nastyErrorString := "failed to delete release 2.1: cannot get endpoint /api/Spaces-1/releases/Releases-21 from server. failure from http client Delete \"http://server/api/Spaces-1/releases/Releases-21\": some kind of network error" + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Equal(t, &multierror.Error{ + Errors: []error{errors.New(nastyErrorString)}, + }, err) + + assert.Equal(t, "Failed to delete 1 releases\n", stdOut.String()) + assert.Equal(t, nastyErrorString+"\n", stdErr.String()) + }}, + + {"noprompt: error when deleting 1 release and it fails due to HTTP statuscode rather than network error", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", fireProject.Name, "2.1", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?clonedFromProjectId=&partialName=Fire+Project"). + RespondWith(resources.Resources[*projects.Project]{ + Items: []*projects.Project{fireProject}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases"). + RespondWith(resources.Resources[*releases.Release]{ + Items: []*releases.Release{rDefault21, rDefault20, rBeta20b2, rBeta20b1}, + }) + + api.ExpectRequest(t, "DELETE", "/api/Spaces-1/releases/"+rDefault21.ID).RespondWithStatus(400, "400 Bad Request", struct { + Details string + }{Details: "server doesn't like this"}) + + // this error output is terrible but we can't fix it until the go API client wrapper vNext + nastyErrorString := "failed to delete release 2.1: bad request from endpoint /api/Spaces-1/releases/Releases-21. response from server 400 Bad Request" + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Equal(t, &multierror.Error{ + Errors: []error{errors.New(nastyErrorString)}, + }, err) + + assert.Equal(t, "Failed to delete 1 releases\n", stdOut.String()) + assert.Equal(t, nastyErrorString+"\n", stdErr.String()) + }}, + + {"noprompt: error when deleting 4 releases and two fail; it keeps going past errors", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", fireProject.Name, rDefault21.Version, rDefault20.Version, rBeta20b2.Version, rBeta20b1.Version, "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?clonedFromProjectId=&partialName=Fire+Project"). + RespondWith(resources.Resources[*projects.Project]{ + Items: []*projects.Project{fireProject}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases"). + RespondWith(resources.Resources[*releases.Release]{ + Items: []*releases.Release{rDefault21, rDefault20, rBeta20b2, rBeta20b1}, + }) + + // first one passes + api.ExpectRequest(t, "DELETE", "/api/Spaces-1/releases/"+rDefault21.ID).RespondWith(nil) + + // second one fails + api.ExpectRequest(t, "DELETE", "/api/Spaces-1/releases/"+rDefault20.ID). + RespondWithError(errors.New("network error on 2.0")) + + // third also fails + api.ExpectRequest(t, "DELETE", "/api/Spaces-1/releases/"+rBeta20b2.ID). + RespondWithError(errors.New("network error on 2.0-beta2")) + + // final works + api.ExpectRequest(t, "DELETE", "/api/Spaces-1/releases/"+rBeta20b1.ID).RespondWith(nil) + + // this error output is terrible but we can't fix it until the go API client wrapper vNext + nastyErrorString := func(r *releases.Release) string { + return fmt.Sprintf("failed to delete release %s: cannot get endpoint /api/Spaces-1/releases/%s from server. failure from http client Delete \"http://server/api/Spaces-1/releases/%s\": network error on %s", + r.Version, r.ID, r.ID, r.Version) + } + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Equal(t, &multierror.Error{ + Errors: []error{ + errors.New(nastyErrorString(rDefault20)), + errors.New(nastyErrorString(rBeta20b2)), + }, + }, err) + + assert.Equal(t, "Deleted 2 releases. 2 releases failed\n", stdOut.String()) + assert.Equal(t, nastyErrorString(rDefault20)+"\n"+nastyErrorString(rBeta20b2)+"\n", stdErr.String()) + }}, + + // ----- interactive ------ + + {"interactive: prompt for everything and delete multiple releases with confirm", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/all"). + RespondWith([]*projects.Project{fireProject, waterProject}) + + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Select the project to delete a release in", + Options: []string{fireProject.Name, waterProject.Name}, + }).AnswerWith(fireProject.Name) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases"). + RespondWith(resources.Resources[*releases.Release]{ + Items: []*releases.Release{rDefault21, rDefault20, rBeta20b2, rBeta20b1}, + }) + + _ = qa.ExpectQuestion(t, &survey.MultiSelect{ + Message: "Select Releases to delete", + Options: []string{ + rDefault21.Version, + rDefault20.Version, + rBeta20b2.Version, + rBeta20b1.Version, + }, + }).AnswerWith([]string{rDefault21.Version, rDefault20.Version}) + + q := qa.ExpectQuestion(t, &survey.Confirm{Message: "Confirm delete of 2 release(s)"}) + assert.Equal(t, heredoc.Doc(` + You are about to delete the following releases: + 2.1 + 2.0 + `), stdOut.String()) + stdOut.Reset() + _ = q.AnswerWith(true) + + for _, id := range []string{rDefault21.ID, rDefault20.ID} { + api.ExpectRequest(t, "DELETE", "/api/Spaces-1/releases/"+id).RespondWith(nil) + } + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, "Successfully deleted 2 releases\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"interactive: project and releases specified on cmdline, only prompt for confirmation", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", "Fire Project", "2.1"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?clonedFromProjectId=&partialName=Fire+Project"). + RespondWith(resources.Resources[*projects.Project]{ + Items: []*projects.Project{fireProject}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases"). + RespondWith(resources.Resources[*releases.Release]{ + Items: []*releases.Release{rDefault21, rDefault20, rBeta20b2, rBeta20b1}, + }) + + q := qa.ExpectQuestion(t, &survey.Confirm{Message: "Confirm delete of 1 release(s)"}) + assert.Equal(t, heredoc.Doc(` + Project Fire Project + You are about to delete the following releases: + 2.1 + `), stdOut.String()) + stdOut.Reset() + _ = q.AnswerWith(true) + + for _, id := range []string{rDefault21.ID} { + api.ExpectRequest(t, "DELETE", "/api/Spaces-1/releases/"+id).RespondWith(nil) + } + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, "Successfully deleted 1 releases\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + api, qa := testutil.NewMockServerAndAsker() + askProvider := question.NewAskProvider(qa.AsAsker()) + fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider) + rootCmd := cmdRoot.NewCmdRoot(fac, nil, askProvider) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + test.run(t, api, qa, rootCmd, stdout, stderr) + }) + } +} diff --git a/pkg/cmd/release/list/list.go b/pkg/cmd/release/list/list.go index 2f8a6584..398b83bc 100644 --- a/pkg/cmd/release/list/list.go +++ b/pkg/cmd/release/list/list.go @@ -1,117 +1,149 @@ package list import ( + "errors" "github.com/MakeNowJust/heredoc/v2" "github.com/OctopusDeploy/cli/pkg/factory" "github.com/OctopusDeploy/cli/pkg/output" "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/cli/pkg/util/flag" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/channels" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" "github.com/spf13/cobra" ) -func NewCmdList(client factory.Factory) *cobra.Command { +const ( + FlagProject = "project" +) + +type ListFlags struct { + Project *flag.Flag[string] +} + +func NewListFlags() *ListFlags { + return &ListFlags{ + Project: flag.New[string](FlagProject, false), + } +} + +func NewCmdList(f factory.Factory) *cobra.Command { + listFlags := NewListFlags() cmd := &cobra.Command{ Use: "list", - Short: "List releases in an instance of Octopus Deploy", - Long: "List releases in an instance of Octopus Deploy.", + Short: "List releases in Octopus Deploy", + Long: "List releases in Octopus Deploy.", Example: heredoc.Doc(` - $ octopus release list" + $ octopus release list myProject + $ octopus release ls "Other Project" + $ octopus release list --project myProject `), Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { - octopusClient, err := client.GetSpacedClient() - if err != nil { - return err + if len(args) > 0 && listFlags.Project.Value == "" { + listFlags.Project.Value = args[0] } - type ReleaseViewModel struct { - Channel string - ChannelID string `json:",omitempty"` - Project string - ProjectID string `json:",omitempty"` - Version string - } + return listRun(cmd, f, listFlags) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&listFlags.Project.Value, listFlags.Project.Name, "p", "", "Name or ID of the project to list releases for") + return cmd +} - var allReleases []ReleaseViewModel +func listRun(cmd *cobra.Command, f factory.Factory, flags *ListFlags) error { + projectNameOrID := flags.Project.Value - caches := util.MapCollectionCacheContainer{} + octopus, err := f.GetSpacedClient() + if err != nil { + return err + } + spinner := f.Spinner() - pageOfReleases, err := octopusClient.Releases.Get(releases.ReleasesQuery{}) // get all; server's default page size + var selectedProject *projects.Project + if f.IsPromptEnabled() { // this would be AskQuestions if it were bigger + if projectNameOrID == "" { + selectedProject, err = util.SelectProject("Select the project to list releases for", octopus, f.Ask, spinner) + if err != nil { + return err + } + } else { // project name is already provided, fetch the object because it's needed for further questions + selectedProject, err = util.FindProject(octopus, spinner, projectNameOrID) if err != nil { return err } - for pageOfReleases != nil && len(pageOfReleases.Items) > 0 { - pageOutput, err := util.MapCollectionWithLookups( - &caches, - pageOfReleases.Items, - func(item *releases.Release) []string { // set of keys to lookup - return []string{item.ChannelID, item.ProjectID} - }, - func(item *releases.Release, lookup []string) ReleaseViewModel { // result producer - return ReleaseViewModel{ - ChannelID: item.ChannelID, - Channel: lookup[0], - ProjectID: item.ProjectID, - Project: lookup[1], - Version: item.Version} - }, - // lookup for channel names - func(keys []string) ([]string, error) { - // Take(len) is important here just in case we have more than 30 channelsToLookup (server's default page size is 30 and we'd have to deal with pagination) - lookupResult, err := octopusClient.Channels.Get(channels.Query{IDs: keys, Take: len(keys)}) - if err != nil { - return nil, err - } - return util.ExtractValuesMatchingKeys( - lookupResult.Items, - keys, - func(x *channels.Channel) string { return x.ID }, - func(x *channels.Channel) string { return x.Name }, - ), nil - }, - // lookup for project names - func(keys []string) ([]string, error) { - lookupResult, err := octopusClient.Projects.Get(projects.ProjectsQuery{IDs: keys, Take: len(keys)}) - if err != nil { - return nil, err - } - return util.ExtractValuesMatchingKeys( - lookupResult.Items, - keys, - func(x *projects.Project) string { return x.ID }, - func(x *projects.Project) string { return x.Name }, - ), nil - }) + cmd.Printf("Project %s\n", output.Cyan(selectedProject.Name)) + } + } else { // we don't have the executions API backing us and allowing NameOrID; we need to do the lookup ourselves + if projectNameOrID == "" { + return errors.New("project must be specified") + } + selectedProject, err = util.FindProject(octopus, factory.NoSpinner, projectNameOrID) + if err != nil { + return err + } + } - if err != nil { - return err - } + type ReleaseViewModel struct { + Channel string + ChannelID string `json:",omitempty"` + Version string + } - allReleases = append(allReleases, pageOutput...) + spinner.Start() - pageOfReleases, err = pageOfReleases.GetNextPage(octopusClient.Releases.GetClient()) - if err != nil { - return err - } // if there are no more pages, then GetNextPage will return nil, which breaks us out of the loop - } + foundReleases, err := octopus.Projects.GetReleases(selectedProject) // does paging internally + if err != nil { + spinner.Stop() + return err + } - return output.PrintArray(allReleases, cmd, output.Mappers[ReleaseViewModel]{ - Json: func(item ReleaseViewModel) any { - return item - }, - Table: output.TableDefinition[ReleaseViewModel]{ - Header: []string{"VERSION", "PROJECT", "CHANNEL"}, - Row: func(item ReleaseViewModel) []string { - return []string{item.Version, item.Project, item.Channel} - }}, - Basic: func(item ReleaseViewModel) string { - return item.Version - }, - }) + caches := util.MapCollectionCacheContainer{} + allReleases, err := util.MapCollectionWithLookups( + &caches, + foundReleases, + func(item *releases.Release) []string { // set of keys to lookup + return []string{item.ChannelID} }, + func(item *releases.Release, lookup []string) ReleaseViewModel { // result producer + return ReleaseViewModel{ + ChannelID: item.ChannelID, + Channel: lookup[0], + Version: item.Version} + }, + // lookup for channel names + func(keys []string) ([]string, error) { + // Take(len) is important here just in case we have more than 30 channelsToLookup (server's default page size is 30 and we'd have to deal with pagination) + lookupResult, err := octopus.Channels.Get(channels.Query{IDs: keys, Take: len(keys)}) + if err != nil { + return nil, err + } + return util.ExtractValuesMatchingKeys( + lookupResult.Items, + keys, + func(x *channels.Channel) string { return x.ID }, + func(x *channels.Channel) string { return x.Name }, + ), nil + }, + ) + spinner.Stop() + if err != nil { + return err } - return cmd + return output.PrintArray(allReleases, cmd, output.Mappers[ReleaseViewModel]{ + Json: func(item ReleaseViewModel) any { + return item + }, + Table: output.TableDefinition[ReleaseViewModel]{ + Header: []string{"VERSION", "CHANNEL"}, + Row: func(item ReleaseViewModel) []string { + return []string{item.Version, item.Channel} + }}, + Basic: func(item ReleaseViewModel) string { + return item.Version + }, + }) } diff --git a/pkg/cmd/release/list/list_test.go b/pkg/cmd/release/list/list_test.go new file mode 100644 index 00000000..76ac1bb2 --- /dev/null +++ b/pkg/cmd/release/list/list_test.go @@ -0,0 +1,285 @@ +package list_test + +import ( + "bytes" + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc/v2" + cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/channels" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "testing" +) + +var rootResource = testutil.NewRootResource() + +// if this were bigger we'd split out the AskQuestions into a seperate test group, but because there's only one we don't worry about it +func TestReleaseList(t *testing.T) { + const spaceID = "Spaces-1" + const fireProjectID = "Projects-22" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + + fireProject := fixtures.NewProject(spaceID, fireProjectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "") + defaultChannel := fixtures.NewChannel(spaceID, "Channels-1", "Default Channel", fireProjectID) + betaChannel := fixtures.NewChannel(spaceID, "Channels-13", "Beta Channel", fireProjectID) + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"release list requires a project name in automation mode", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "list", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "project must be specified") + + assert.Equal(t, "", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"release list prompts for project name in interactive mode", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "list"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/all").RespondWith([]*projects.Project{fireProject}) + + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Select the project to list releases for", + Options: []string{fireProject.Name}, + }).AnswerWith(fireProject.Name) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases"). + RespondWith(resources.Resources[*releases.Release]{ + Items: []*releases.Release{ + releases.NewRelease(defaultChannel.ID, fireProjectID, "2.1"), + releases.NewRelease(defaultChannel.ID, fireProjectID, "2.0"), + releases.NewRelease(betaChannel.ID, fireProjectID, "2.0-beta2"), + releases.NewRelease(betaChannel.ID, fireProjectID, "2.0-beta1"), + }, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels?ids=Channels-1%2CChannels-13&take=2"). + RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{defaultChannel, betaChannel}, + }) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + VERSION CHANNEL + 2.1 Default Channel + 2.0 Default Channel + 2.0-beta2 Beta Channel + 2.0-beta1 Beta Channel + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"release list picks up project from args in automation mode and prints list with multiple channels", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "list", fireProject.Name, "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?clonedFromProjectId=&partialName=Fire+Project"). + RespondWith(resources.Resources[*projects.Project]{ + Items: []*projects.Project{fireProject}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases"). + RespondWith(resources.Resources[*releases.Release]{ + Items: []*releases.Release{ + releases.NewRelease(defaultChannel.ID, fireProjectID, "2.1"), + releases.NewRelease(defaultChannel.ID, fireProjectID, "2.0"), + releases.NewRelease(betaChannel.ID, fireProjectID, "2.0-beta2"), + releases.NewRelease(betaChannel.ID, fireProjectID, "2.0-beta1"), + }, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels?ids=Channels-1%2CChannels-13&take=2"). + RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{defaultChannel, betaChannel}, + }) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + VERSION CHANNEL + 2.1 Default Channel + 2.0 Default Channel + 2.0-beta2 Beta Channel + 2.0-beta1 Beta Channel + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + {"release list picks up project from flag in automation mode and prints list", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "list", "--project", fireProject.Name, "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?clonedFromProjectId=&partialName=Fire+Project"). + RespondWith(resources.Resources[*projects.Project]{ + Items: []*projects.Project{fireProject}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases"). + RespondWith(resources.Resources[*releases.Release]{ + Items: []*releases.Release{releases.NewRelease(defaultChannel.ID, fireProjectID, "2.1")}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels?ids=Channels-1&take=1"). + RespondWith(resources.Resources[*channels.Channel]{Items: []*channels.Channel{defaultChannel}}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + VERSION CHANNEL + 2.1 Default Channel + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + {"release list picks up project from short flag in automation mode and prints list", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "list", "-p", fireProject.Name, "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?clonedFromProjectId=&partialName=Fire+Project"). + RespondWith(resources.Resources[*projects.Project]{ + Items: []*projects.Project{fireProject}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases"). + RespondWith(resources.Resources[*releases.Release]{ + Items: []*releases.Release{releases.NewRelease(defaultChannel.ID, fireProjectID, "2.1")}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels?ids=Channels-1&take=1"). + RespondWith(resources.Resources[*channels.Channel]{Items: []*channels.Channel{defaultChannel}}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + VERSION CHANNEL + 2.1 Default Channel + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + {"outputFormat json", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "list", "-p", fireProject.Name, "--output-format", "json", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?clonedFromProjectId=&partialName=Fire+Project"). + RespondWith(resources.Resources[*projects.Project]{ + Items: []*projects.Project{fireProject}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases"). + RespondWith(resources.Resources[*releases.Release]{ + Items: []*releases.Release{releases.NewRelease(defaultChannel.ID, fireProjectID, "2.1")}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels?ids=Channels-1&take=1"). + RespondWith(resources.Resources[*channels.Channel]{Items: []*channels.Channel{defaultChannel}}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + type x struct { + Channel string + ChannelID string + Version string + } + parsedStdout, err := testutil.ParseJsonStrict[[]x](stdOut) + assert.Nil(t, err) + + assert.Equal(t, []x{{ + Channel: defaultChannel.Name, + ChannelID: defaultChannel.ID, + Version: "2.1", + }}, parsedStdout) + assert.Equal(t, "", stdErr.String()) + }}, + {"outputFormat basic", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "list", "-p", fireProject.Name, "--output-format", "basic", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?clonedFromProjectId=&partialName=Fire+Project"). + RespondWith(resources.Resources[*projects.Project]{ + Items: []*projects.Project{fireProject}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases"). + RespondWith(resources.Resources[*releases.Release]{ + Items: []*releases.Release{releases.NewRelease(defaultChannel.ID, fireProjectID, "2.1")}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels?ids=Channels-1&take=1"). + RespondWith(resources.Resources[*channels.Channel]{Items: []*channels.Channel{defaultChannel}}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + 2.1 + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + api, qa := testutil.NewMockServerAndAsker() + askProvider := question.NewAskProvider(qa.AsAsker()) + fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider) + rootCmd := cmdRoot.NewCmdRoot(fac, nil, askProvider) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + test.run(t, api, qa, rootCmd, stdout, stderr) + }) + } + +} diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index 3963e76a..fc8e4feb 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -4,6 +4,7 @@ import ( "fmt" cmdCreate "github.com/OctopusDeploy/cli/pkg/cmd/release/create" + cmdDelete "github.com/OctopusDeploy/cli/pkg/cmd/release/delete" cmdList "github.com/OctopusDeploy/cli/pkg/cmd/release/list" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/constants/annotations" @@ -24,5 +25,6 @@ func NewCmdRelease(f factory.Factory) *cobra.Command { cmd.AddCommand(cmdCreate.NewCmdCreate(f)) cmd.AddCommand(cmdList.NewCmdList(f)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f)) return cmd } diff --git a/pkg/factory/factory.go b/pkg/factory/factory.go index a3914696..3e5b3e0a 100644 --- a/pkg/factory/factory.go +++ b/pkg/factory/factory.go @@ -74,3 +74,12 @@ func (f *factory) Ask(p survey.Prompt, response interface{}, opts ...survey.AskO func (f *factory) Spinner() Spinner { return f.spinner } + +// NoSpinner is a static singleton "does nothing" stand-in for spinner if you want to +// call an API that expects a spinner while you're in automation mode. +var NoSpinner Spinner = &noSpinner{} + +type noSpinner struct{} + +func (f *noSpinner) Start() {} +func (f *noSpinner) Stop() {} diff --git a/pkg/output/print_array.go b/pkg/output/print_array.go index 0bf6fc8b..26b3f559 100644 --- a/pkg/output/print_array.go +++ b/pkg/output/print_array.go @@ -61,7 +61,7 @@ func PrintArray[T any](items []T, cmd *cobra.Command, mappers Mappers[T]) error } data, _ := json.MarshalIndent(outputJson, "", " ") - fmt.Println(string(data)) + cmd.Println(string(data)) case "basic", "text": textMapper := mappers.Basic @@ -69,7 +69,7 @@ func PrintArray[T any](items []T, cmd *cobra.Command, mappers Mappers[T]) error return errors.New("command does not support output in plain text") } for _, e := range items { - fmt.Println(textMapper(e)) + cmd.Println(textMapper(e)) } case "table", "": // table is the default of unspecified @@ -78,8 +78,7 @@ func PrintArray[T any](items []T, cmd *cobra.Command, mappers Mappers[T]) error return errors.New("command does not support output in table format") } - ioWriter := cmd.OutOrStdout() - t := NewTable(ioWriter) + t := NewTable(cmd.OutOrStdout()) if tableMapper.Header != nil { for k, v := range tableMapper.Header { tableMapper.Header[k] = Bold(v) diff --git a/pkg/util/selecting_finding_common.go b/pkg/util/selecting_finding_common.go new file mode 100644 index 00000000..9181601b --- /dev/null +++ b/pkg/util/selecting_finding_common.go @@ -0,0 +1,49 @@ +package util + +import ( + "fmt" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/question" + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "strings" +) + +func SelectProject(questionText string, octopus *octopusApiClient.Client, ask question.Asker, spinner factory.Spinner) (*projects.Project, error) { + spinner.Start() + existingProjects, err := octopus.Projects.GetAll() + spinner.Stop() + if err != nil { + return nil, err + } + + return question.SelectMap(ask, questionText, existingProjects, func(p *projects.Project) string { + return p.Name + }) +} + +func FindProject(octopus *octopusApiClient.Client, spinner factory.Spinner, projectName string) (*projects.Project, error) { + // projectsQuery has "Name" but it's just an alias in the server for PartialName; we need to filter client side + spinner.Start() + projectsPage, err := octopus.Projects.Get(projects.ProjectsQuery{PartialName: projectName}) + if err != nil { + spinner.Stop() + return nil, err + } + for projectsPage != nil && len(projectsPage.Items) > 0 { + for _, c := range projectsPage.Items { // server doesn't support channel search by exact name so we must emulate it + if strings.EqualFold(c.Name, projectName) { + spinner.Stop() + return c, nil + } + } + projectsPage, err = projectsPage.GetNextPage(octopus.Projects.GetClient()) + if err != nil { + spinner.Stop() + return nil, err + } // if there are no more pages, then GetNextPage will return nil, which breaks us out of the loop + } + + spinner.Stop() + return nil, fmt.Errorf("no project found with name of %s", projectName) +} diff --git a/pkg/util/util.go b/pkg/util/util.go index d45ec141..fd3c7764 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -24,6 +24,18 @@ func SliceTransform[T any, TResult any](slice []T, transform func(item T) TResul return results } +// SliceFilter takes an input collection and returns elements where `predicate` returns true +// Known as 'filter' in most other languages or 'Select' in C# Linq. +func SliceFilter[T any](slice []T, predicate func(item T) bool) []T { + var results []T = nil + for _, item := range slice { + if predicate(item) { + results = append(results, item) + } + } + return results +} + // ExtractValuesMatchingKeys returns a collection of values which matched a specified set of keys, in the exact order of keys. // Given a collection of items, and a collection of keys to match within that collection // This makes no sense, hopefully an example helps: diff --git a/test/fixtures/projects.go b/test/fixtures/projects.go index 0b1bc7c7..fd5122f5 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/deployments" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" "net/url" ) @@ -60,6 +61,7 @@ func NewProject(spaceID string, projectID string, projectName string, lifecycleI "Channels": fmt.Sprintf("/api/%s/projects/%s/channels{/id}{?skip,take,partialName}", spaceID, projectID), "DeploymentProcess": fmt.Sprintf("/api/%s/projects/%s/deploymentprocesses", spaceID, projectID), "DeploymentSettings": fmt.Sprintf("/api/%s/projects/%s/deploymentsettings", spaceID, projectID), + "Releases": fmt.Sprintf("/api/%s/projects/%s/releases{/version}{?skip,take,searchByVersion}", spaceID, projectID), } return result } @@ -67,19 +69,18 @@ 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") - result := projects.NewProject(projectName, lifecycleID, projectGroupID) - result.ID = projectID + 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.DeploymentProcessID = deploymentProcessID - result.Links = map[string]string{ - "Channels": fmt.Sprintf("/api/%s/projects/%s/channels{/id}{?skip,take,partialName}", spaceID, projectID), - "DeploymentProcess": fmt.Sprintf("/api/%s/projects/%s/{gitRef}/deploymentprocesses", spaceID, projectID), // note gitRef is a template param in the middle of the url path - "DeploymentSettings": fmt.Sprintf("/api/%s/projects/%s/{gitRef}/deploymentsettings", spaceID, projectID), // note gitRef is a template param in the middle of the url path - "Tags": fmt.Sprintf("/api/%s/projects/%s/git/tags{/name}{?skip,take,searchByName,refresh}", spaceID, projectID), - "Branches": fmt.Sprintf("/api/%s/projects/%s/git/branches{/name}{?skip,take,searchByName,refresh}", spaceID, projectID), - "Commits": fmt.Sprintf("/api/%s/projects/%s/git/commits{/hash}{?skip,take,refresh}", spaceID, projectID), - } + + // 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 + result.Links["DeploymentSettings"] = fmt.Sprintf("/api/%s/projects/%s/{gitRef}/deploymentsettings", spaceID, projectID) // note gitRef is a template param in the middle of the url path + + // CaC projects have extra links + result.Links["Tags"] = fmt.Sprintf("/api/%s/projects/%s/git/tags{/name}{?skip,take,searchByName,refresh}", spaceID, projectID) + result.Links["Branches"] = fmt.Sprintf("/api/%s/projects/%s/git/branches{/name}{?skip,take,searchByName,refresh}", spaceID, projectID) + result.Links["Commits"] = fmt.Sprintf("/api/%s/projects/%s/git/commits{/hash}{?skip,take,refresh}", spaceID, projectID) return result } @@ -89,3 +90,10 @@ func NewChannel(spaceID string, channelID string, channelName string, projectID result.SpaceID = spaceID return result } + +func NewRelease(spaceID string, releaseID string, releaseVersion string, projectID string, channelID string) *releases.Release { + result := releases.NewRelease(channelID, projectID, releaseVersion) + result.ID = releaseID + result.SpaceID = spaceID + return result +} diff --git a/test/testutil/fakefactory.go b/test/testutil/fakefactory.go index 8341bce3..8373eb87 100644 --- a/test/testutil/fakefactory.go +++ b/test/testutil/fakefactory.go @@ -4,6 +4,7 @@ import ( "errors" "github.com/AlecAivazis/survey/v2" "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/question" octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" "net/url" @@ -25,18 +26,22 @@ func NewMockFactory(api *MockHttpServer) *MockFactory { } func NewMockFactoryWithSpace(api *MockHttpServer, space *spaces.Space) *MockFactory { + return NewMockFactoryWithSpaceAndPrompt(api, space, nil) +} + +func NewMockFactoryWithSpaceAndPrompt(api *MockHttpServer, space *spaces.Space, askProvider question.AskProvider) *MockFactory { result := NewMockFactory(api) result.CurrentSpace = space + result.AskProvider = askProvider return result } type MockFactory struct { - api *MockHttpServer // must not be nil - ApiClient *octopusApiClient.Client // nil; lazily created like with the real factory - CurrentSpace *spaces.Space - RawSpinner factory.Spinner - PromptEnabled bool - Asker func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error + api *MockHttpServer // must not be nil + ApiClient *octopusApiClient.Client // nil; lazily created like with the real factory + CurrentSpace *spaces.Space + RawSpinner factory.Spinner + AskProvider question.AskProvider } // refactor this later if there's ever a need for unit tests to vary the server url or API key (why would there be?) @@ -71,8 +76,14 @@ func (f *MockFactory) Spinner() factory.Spinner { return f.RawSpinner } func (f *MockFactory) IsPromptEnabled() bool { - return f.PromptEnabled + if f.AskProvider == nil { + return false + } + return f.AskProvider.IsInteractive() } func (f *MockFactory) Ask(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - return f.Asker(p, response, opts...) + if f.AskProvider == nil { + return errors.New("method Ask called on fake factory when provider was nil") + } + return f.AskProvider.Ask(p, response, opts...) } diff --git a/test/testutil/fakeoctopusserver.go b/test/testutil/fakeoctopusserver.go index 971717c2..ed79c64a 100644 --- a/test/testutil/fakeoctopusserver.go +++ b/test/testutil/fakeoctopusserver.go @@ -156,23 +156,37 @@ type RequestWrapper struct { } func (r *RequestWrapper) RespondWith(responseObject any) { - if responseObject == nil { - panic("TODO: implement responses with no body") + r.RespondWithStatus(http.StatusOK, "200 OK", responseObject) +} + +func (r *RequestWrapper) RespondWithStatus(statusCode int, statusString string, responseObject any) { + var body []byte + if responseObject != nil { + b, err := json.Marshal(responseObject) + if err != nil { + panic(err) // you shouldn't feed unserializable stuff into RespondWithStatus + } + body = b + } else { + body = make([]byte, 0) } - body, _ := json.Marshal(responseObject) - // Regarding response errors: // Note that we would use an error here for a low level thing like a network error. // An HTTP error like a 404 or 500 would be considered a valid response with an // appropriate status code r.Server.Respond(&http.Response{ - StatusCode: http.StatusOK, + StatusCode: statusCode, + Status: statusString, Body: ioutil.NopCloser(bytes.NewReader(body)), ContentLength: int64(len(body)), }, nil) } +func (r *RequestWrapper) RespondWithError(err error) { + r.Server.Respond(nil, err) +} + func NewRootResource() *octopusApiClient.RootResource { root := octopusApiClient.NewRootResource() root.Links[constants.LinkSpaces] = "/api/spaces{/id}{?skip,ids,take,partialName}" diff --git a/test/testutil/testutil.go b/test/testutil/testutil.go index 3b615986..c9f10947 100644 --- a/test/testutil/testutil.go +++ b/test/testutil/testutil.go @@ -1,6 +1,7 @@ package testutil import ( + "bytes" "encoding/json" "io" "net/http" @@ -93,3 +94,11 @@ func Close(server *MockHttpServer, qa *AskMocker) { qa.Close() } } + +// ParseJsonStrict parses the incoming byte buffer into objects of type T, failing if any unexpected fields are present +func ParseJsonStrict[T any](input *bytes.Buffer) (T, error) { + var parsedStdout T + decoder := json.NewDecoder(input) + decoder.DisallowUnknownFields() + return parsedStdout, decoder.Decode(&parsedStdout) +}