From 4e955bb305fa904ddce47b39c49944ecb36401e2 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Tue, 18 Aug 2026 15:36:31 +1000 Subject: [PATCH] feat: add a group filter to project list `octopus project list` had no way to narrow to a single project group, so finding the projects in a group meant either `project-group view`, whose output is group metadata with the projects nested inside it, or filtering the full list client side. Add an optional `-g/--group` filter taking a group name or ID, matching the flag `project create` and `project clone` already use. With no flag the command behaves exactly as before. When a group is given the projects come from that group's own Projects link, which the server pages for us, rather than fetching every project in the space and discarding most of them. An unknown group reports `cannot find a project group with name or ID of 'X'`, the wording the delete commands use, rather than the SDK's bare "cannot find the item". Other lookup errors pass through untouched. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/project/list/list.go | 72 +++++++++++++++++++++-- pkg/cmd/project/list/list_test.go | 96 +++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 pkg/cmd/project/list/list_test.go diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go index 702e80ce..9e4b76ff 100644 --- a/pkg/cmd/project/list/list.go +++ b/pkg/cmd/project/list/list.go @@ -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 } @@ -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(), @@ -66,3 +100,31 @@ 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 { + 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) + } + + return getProjectsInGroup(projectGroup) +} diff --git a/pkg/cmd/project/list/list_test.go b/pkg/cmd/project/list/list_test.go new file mode 100644 index 00000000..12d34a37 --- /dev/null +++ b/pkg/cmd/project/list/list_test.go @@ -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) { + 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 +}