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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 64 additions & 5 deletions pkg/cmd/project/list/list.go
Original file line number Diff line number Diff line change
@@ -1,30 +1,60 @@
package list

import (
"errors"
"fmt"

"github.com/MakeNowJust/heredoc/v2"
"github.com/OctopusDeploy/cli/pkg/apiclient"
"github.com/OctopusDeploy/cli/pkg/constants"
"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/OctopusDeploy/go-octopusdeploy/v2/pkg/projects"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/services"
"github.com/spf13/cobra"
)

const (
FlagGroup = "group"
)

type ListFlags struct {
Group *flag.Flag[string]
}

func NewListFlags() *ListFlags {
return &ListFlags{
Group: flag.New[string](FlagGroup, false),
}
}

type GetAllProjectsCallback func() ([]*projects.Project, error)
type GetProjectGroupCallback func(idOrName string) (*projectgroups.ProjectGroup, error)
type GetProjectsInGroupCallback func(projectGroup *projectgroups.ProjectGroup) ([]*projects.Project, error)

func NewCmdList(f factory.Factory) *cobra.Command {
listFlags := NewListFlags()

cmd := &cobra.Command{
Use: "list",
Short: "List projects",
Long: "List projects in Octopus Deploy",
Example: heredoc.Docf(`
%[1]s project list
%[1]s project ls
%[1]s project list --group 'Default Project Group'
%[1]s project ls -g ProjectGroups-1
`, constants.ExecutableName),
Aliases: []string{"ls"},
RunE: func(cmd *cobra.Command, args []string) error {
return listRun(cmd, f)
return listRun(cmd, f, listFlags)
},
}

flags := cmd.Flags()
flags.StringVarP(&listFlags.Group.Value, listFlags.Group.Name, "g", "", "list only the projects in this project group")

return cmd
}

Expand All @@ -35,18 +65,22 @@ type ProjectAsJson struct {
ProjectTags []string `json:"ProjectTags,omitempty"`
}

func listRun(cmd *cobra.Command, f factory.Factory) error {
func listRun(cmd *cobra.Command, f factory.Factory, flags *ListFlags) error {
client, err := f.GetSpacedClient(apiclient.NewRequester(cmd))
if err != nil {
return err
}

allProjects, err := client.Projects.GetAll()
projectsToList, err := getProjects(
flags.Group.Value,
client.Projects.GetAll,
client.ProjectGroups.GetByIDOrName,
client.ProjectGroups.GetProjects)
if err != nil {
return err
}

return output.PrintArray(allProjects, cmd, output.Mappers[*projects.Project]{
return output.PrintArray(projectsToList, cmd, output.Mappers[*projects.Project]{
Json: func(p *projects.Project) any {
return ProjectAsJson{
Id: p.GetID(),
Expand All @@ -66,3 +100,28 @@ func listRun(cmd *cobra.Command, f factory.Factory) error {
},
})
}

// getProjects lists every project in the space, or only the projects in the
// named group when the group filter is supplied. The group is resolved rather
// than the project list filtered client side, so the server only sends back the
// projects that were asked for.
func getProjects(
group string,
getAllProjects GetAllProjectsCallback,
getProjectGroup GetProjectGroupCallback,
getProjectsInGroup GetProjectsInGroupCallback) ([]*projects.Project, error) {
if group == "" {
return getAllProjects()
}

projectGroup, err := getProjectGroup(group)
if err != nil && !errors.Is(err, services.ErrItemNotFound) {
return nil, err
}
// GetByIDOrName reports a miss as ErrItemNotFound; the nil check is defensive.
if err != nil || projectGroup == nil {
return nil, fmt.Errorf("cannot find a project group with name or ID of '%s'", group)
}
Comment on lines +117 to +124

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The "cannot find a project group" message is built in two places, and the projectGroup == nil branch is unreachable with the real callback (ProjectGroups.GetByIDOrName never returns nil, nil — it either returns a group or services.ErrItemNotFound from GetByName). Collapsing them keeps one copy of the message while staying defensive:

Suggested change
projectGroup, err := getProjectGroup(group)
if err != nil {
if errors.Is(err, services.ErrItemNotFound) {
return nil, fmt.Errorf("cannot find a project group with name or ID of '%s'", group)
}
return nil, err
}
if projectGroup == nil {
return nil, fmt.Errorf("cannot find a project group with name or ID of '%s'", group)
}
projectGroup, err := getProjectGroup(group)
if err != nil && !errors.Is(err, services.ErrItemNotFound) {
return nil, err
}
if err != nil || projectGroup == nil {
return nil, fmt.Errorf("cannot find a project group with name or ID of '%s'", group)
}


return getProjectsInGroup(projectGroup)
}
162 changes: 162 additions & 0 deletions pkg/cmd/project/list/list_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package list_test

import (
"bytes"
"testing"

"github.com/MakeNowJust/heredoc/v2"
cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root"
"github.com/OctopusDeploy/cli/test/fixtures"
"github.com/OctopusDeploy/cli/test/testutil"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectgroups"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)

var rootResource = testutil.NewRootResource()

const spaceID = "Spaces-1"

func newProjectGroup(id string, name string) *projectgroups.ProjectGroup {
group := projectgroups.NewProjectGroup(name)
group.ID = id
group.Links = map[string]string{
"Projects": "/api/" + spaceID + "/projectgroups/" + id + "/projects",
"Self": "/api/" + spaceID + "/projectgroups/" + id,
}
return group
}

func TestProjectList(t *testing.T) {
space1 := fixtures.NewSpace(spaceID, "Default Space")

fireProject := fixtures.NewProject(spaceID, "Projects-1", "Fire Project", "Lifecycles-1", "ProjectGroups-1", "")
fireProject.Description = "the fire one"
waterProject := fixtures.NewProject(spaceID, "Projects-2", "Water Project", "Lifecycles-1", "ProjectGroups-2", "")

tests := []struct {
name string
run func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer)
}{
{"lists every project when no group is given", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
defer api.Close()
rootCmd.SetArgs([]string{"project", "list"})
return rootCmd.ExecuteC()
})

api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)

api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/all").
RespondWith([]*projects.Project{fireProject, waterProject})

_, err := testutil.ReceivePair(cmdReceiver)
assert.Nil(t, err)
assert.Equal(t, heredoc.Doc(`
NAME DESCRIPTION TAGS
Fire Project the fire one
Water Project
`), stdOut.String())
assert.Equal(t, "", stdErr.String())
}},

{"lists only the group's projects when --group is given", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
defer api.Close()
rootCmd.SetArgs([]string{"project", "list", "--group", "ProjectGroups-1"})
return rootCmd.ExecuteC()
})

api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)

api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/ProjectGroups-1").
RespondWith(newProjectGroup("ProjectGroups-1", "Default Project Group"))

api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/ProjectGroups-1/projects").
RespondWith(&resources.Resources[*projects.Project]{Items: []*projects.Project{fireProject}})

_, err := testutil.ReceivePair(cmdReceiver)
assert.Nil(t, err)
assert.Equal(t, heredoc.Doc(`
NAME DESCRIPTION TAGS
Fire Project the fire one
`), stdOut.String())
assert.Equal(t, "", stdErr.String())
}},

{"accepts the -g shorthand and a group name, in json", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
defer api.Close()
rootCmd.SetArgs([]string{"project", "ls", "-g", "Default Project Group", "-f", "json"})
return rootCmd.ExecuteC()
})

api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)

// a name isn't an ID, so the lookup by ID misses first
api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/Default Project Group").
RespondWithStatus(404, "404 Not Found", nil)

api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups?partialName=Default+Project+Group").
RespondWith(&resources.Resources[*projectgroups.ProjectGroup]{
Items: []*projectgroups.ProjectGroup{newProjectGroup("ProjectGroups-1", "Default Project Group")},
})

api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/ProjectGroups-1/projects").
RespondWith(&resources.Resources[*projects.Project]{Items: []*projects.Project{fireProject}})

_, err := testutil.ReceivePair(cmdReceiver)
assert.Nil(t, err)

type projectJson struct {
Id string
Name string
Description string
}
parsed, err := testutil.ParseJsonStrict[[]projectJson](stdOut)
assert.Nil(t, err)
assert.Equal(t, []projectJson{
{Id: "Projects-1", Name: "Fire Project", Description: "the fire one"},
}, parsed)
assert.Equal(t, "", stdErr.String())
}},

{"reports an unknown group by the name that was asked for", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
defer api.Close()
rootCmd.SetArgs([]string{"project", "list", "--group", "Nope"})
return rootCmd.ExecuteC()
})

api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)

api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/Nope").
RespondWithStatus(404, "404 Not Found", nil)

api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups?partialName=Nope").
RespondWith(&resources.Resources[*projectgroups.ProjectGroup]{Items: []*projectgroups.ProjectGroup{}})

_, err := testutil.ReceivePair(cmdReceiver)
assert.EqualError(t, err, "cannot find a project group with name or ID of 'Nope'")
assert.Equal(t, "", stdOut.String())
}},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
api := testutil.NewMockHttpServer()
fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, nil)
rootCmd := cmdRoot.NewCmdRoot(fac, nil, nil)
rootCmd.SetOut(stdout)
rootCmd.SetErr(stderr)
test.run(t, api, rootCmd, stdout, stderr)
})
}
}
96 changes: 96 additions & 0 deletions pkg/cmd/project/list/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package list

import (
"errors"
"testing"

"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectgroups"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/services"
"github.com/stretchr/testify/assert"
)

func newProject(name string) *projects.Project {
return projects.NewProject(name, "Lifecycles-1", "ProjectGroups-1")
}

func TestGetProjects_WithoutGroupListsEverything(t *testing.T) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These tests cover the extracted getProjects helper nicely, but nothing exercises the cobra wiring: that --group/-g is registered, that listRun passes the flag value through, or the output mapping. Every sibling list-command test (pkg/cmd/channel/list/list_test.go, release/list, worker/list, target/list, package/list) drives the command end-to-end through testutil.MockHttpServer and rootCmd.ExecuteC(), which would catch a regression in the flag registration or run path that these unit tests can't. Worth adding one end-to-end case in that established harness (e.g. project list -g ProjectGroups-1 -f json expecting GET /api/Spaces-1/projectgroups/ProjectGroups-1 then the group's projects link).

getAllProjects := func() ([]*projects.Project, error) {
return []*projects.Project{newProject("foo"), newProject("bar")}, nil
}
getProjectGroup := func(idOrName string) (*projectgroups.ProjectGroup, error) {
t.Errorf("did not expect a project group lookup, got '%s'", idOrName)
return nil, nil
}
getProjectsInGroup := func(projectGroup *projectgroups.ProjectGroup) ([]*projects.Project, error) {
t.Error("did not expect the projects in a group to be requested")
return nil, nil
}

result, err := getProjects("", getAllProjects, getProjectGroup, getProjectsInGroup)

assert.NoError(t, err)
assert.Equal(t, []string{"foo", "bar"}, projectNames(result))
}

func TestGetProjects_WithGroupListsOnlyThatGroup(t *testing.T) {
getAllProjects := func() ([]*projects.Project, error) {
t.Error("did not expect every project to be requested")
return nil, nil
}
getProjectGroup := func(idOrName string) (*projectgroups.ProjectGroup, error) {
assert.Equal(t, "Default Project Group", idOrName)
return projectgroups.NewProjectGroup("Default Project Group"), nil
}
getProjectsInGroup := func(projectGroup *projectgroups.ProjectGroup) ([]*projects.Project, error) {
assert.Equal(t, "Default Project Group", projectGroup.Name)
return []*projects.Project{newProject("foo")}, nil
}

result, err := getProjects("Default Project Group", getAllProjects, getProjectGroup, getProjectsInGroup)

assert.NoError(t, err)
assert.Equal(t, []string{"foo"}, projectNames(result))
}

func TestGetProjects_WithUnknownGroupReportsTheGroup(t *testing.T) {
getProjectGroup := func(idOrName string) (*projectgroups.ProjectGroup, error) {
return nil, services.ErrItemNotFound
}

result, err := getProjects("Nope", nil, getProjectGroup, nil)

assert.Nil(t, result)
assert.EqualError(t, err, "cannot find a project group with name or ID of 'Nope'")
}

func TestGetProjects_WithNilGroupReportsTheGroup(t *testing.T) {
getProjectGroup := func(idOrName string) (*projectgroups.ProjectGroup, error) {
return nil, nil
}

result, err := getProjects("Nope", nil, getProjectGroup, nil)

assert.Nil(t, result)
assert.EqualError(t, err, "cannot find a project group with name or ID of 'Nope'")
}

func TestGetProjects_SurfacesOtherLookupErrors(t *testing.T) {
expected := errors.New("the remote server returned 401 unauthorized")
getProjectGroup := func(idOrName string) (*projectgroups.ProjectGroup, error) {
return nil, expected
}

result, err := getProjects("Default Project Group", nil, getProjectGroup, nil)

assert.Nil(t, result)
assert.Equal(t, expected, err)
}

func projectNames(items []*projects.Project) []string {
names := make([]string, 0, len(items))
for _, item := range items {
names = append(names, item.Name)
}
return names
}