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
51 changes: 41 additions & 10 deletions pkg/cmd/project/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package list
import (
"github.com/MakeNowJust/heredoc/v2"
"github.com/OctopusDeploy/cli/pkg/apiclient"
"github.com/OctopusDeploy/cli/pkg/cmd/project/shared"
"github.com/OctopusDeploy/cli/pkg/constants"
"github.com/OctopusDeploy/cli/pkg/factory"
"github.com/OctopusDeploy/cli/pkg/output"
Expand All @@ -29,10 +30,19 @@ func NewCmdList(f factory.Factory) *cobra.Command {
}

type ProjectAsJson struct {
Id string `json:"Id"`
Name string `json:"Name"`
Description string `json:"Description"`
ProjectTags []string `json:"ProjectTags,omitempty"`
Id string `json:"Id"`
Name string `json:"Name"`
Description string `json:"Description"`
ProjectTags []string `json:"ProjectTags,omitempty"`
Slug string `json:"Slug"`
SpaceId string `json:"SpaceId"`
ProjectGroupId string `json:"ProjectGroupId"`
ProjectGroupName string `json:"ProjectGroupName,omitempty"`
LifecycleId string `json:"LifecycleId"`
LifecycleName string `json:"LifecycleName,omitempty"`
IsDisabled bool `json:"IsDisabled"`
IsVersionControlled bool `json:"IsVersionControlled"`
TenantedDeploymentMode string `json:"TenantedDeploymentMode"`
}

func listRun(cmd *cobra.Command, f factory.Factory) error {
Expand All @@ -46,19 +56,40 @@ func listRun(cmd *cobra.Command, f factory.Factory) error {
return err
}

// two lookups for the whole list rather than one per project, and best-effort
// as channel list is: listing still works without access to either
lifecycleMap := shared.GetLifecycleMap(client)
projectGroupMap := shared.GetProjectGroupMap(client)

return output.PrintArray(allProjects, cmd, output.Mappers[*projects.Project]{
Json: func(p *projects.Project) any {
return ProjectAsJson{
Id: p.GetID(),
Name: p.GetName(),
Description: p.Description,
ProjectTags: p.ProjectTags,
Id: p.GetID(),
Name: p.GetName(),
Description: p.Description,
ProjectTags: p.ProjectTags,
Slug: p.Slug,
SpaceId: p.SpaceID,
ProjectGroupId: p.ProjectGroupID,
ProjectGroupName: projectGroupMap[p.ProjectGroupID],
LifecycleId: p.LifecycleID,
LifecycleName: lifecycleMap[p.LifecycleID],
IsDisabled: p.IsDisabled,
IsVersionControlled: p.IsVersionControlled,
TenantedDeploymentMode: shared.TenantedDeploymentMode(p),
}
},
Table: output.TableDefinition[*projects.Project]{
Header: []string{"NAME", "DESCRIPTION", "TAGS"},
Header: []string{"NAME", "SLUG", "PROJECT GROUP", "LIFECYCLE", "DESCRIPTION", "TAGS"},
Row: func(p *projects.Project) []string {
return []string{output.Bold(p.Name), p.Description, output.FormatAsList(p.ProjectTags)}
return []string{
output.Bold(p.Name),
p.Slug,
shared.DisplayName(p.ProjectGroupID, projectGroupMap[p.ProjectGroupID]),
shared.DisplayName(p.LifecycleID, lifecycleMap[p.LifecycleID]),
p.Description,
output.FormatAsList(p.ProjectTags),
}
},
},
Basic: func(p *projects.Project) string {
Expand Down
191 changes: 191 additions & 0 deletions pkg/cmd/project/list/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package list_test

import (
"bytes"
"testing"

"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/lifecycles"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectgroups"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)

var rootResource = testutil.NewRootResource()

func TestProjectList(t *testing.T) {
const spaceID = "Spaces-1"

space1 := fixtures.NewSpace(spaceID, "Default Space")

lifecycle := lifecycles.NewLifecycle("Default Lifecycle")
lifecycle.ID = "Lifecycles-1"

projectGroup := projectgroups.NewProjectGroup("Default Project Group")
projectGroup.ID = "ProjectGroups-1"

fireProject := fixtures.NewProject(spaceID, "Projects-22", "Fire Project", "Lifecycles-1", "ProjectGroups-1", "")
fireProject.SpaceID = spaceID
fireProject.Slug = "fire-project"
fireProject.ProjectTags = []string{"team/red"}

waterProject := fixtures.NewProject(spaceID, "Projects-23", "Water Project", "Lifecycles-99", "ProjectGroups-1", "")
waterProject.SpaceID = spaceID
waterProject.Slug = "water-project"
waterProject.Description = "Wet things"
waterProject.IsDisabled = true
waterProject.ProjectTags = []string{"team/blue"}

expectListRequests := func(t *testing.T, api *testutil.MockHttpServer) {
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})
api.ExpectRequest(t, "GET", "/api/Spaces-1/lifecycles/all").RespondWith([]*lifecycles.Lifecycle{lifecycle})
api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/all").RespondWith([]*projectgroups.ProjectGroup{projectGroup})
}

tests := []struct {
name string
run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer)
}{
{"project list resolves group and lifecycle names, and falls back to the ID", 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{"project", "list", "--no-prompt", "-f", "table"})
return rootCmd.ExecuteC()
})

expectListRequests(t, api)

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

assert.Equal(t, heredoc.Doc(`
NAME SLUG PROJECT GROUP LIFECYCLE DESCRIPTION TAGS
Fire Project fire-project Default Project Group Default Lifecycle team/red
Water Project water-project Default Project Group Lifecycles-99 Wet things team/blue
`), stdOut.String())
assert.Equal(t, "", stdErr.String())
}},

{"project list still works when the lookups fail", 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{"project", "list", "--no-prompt", "-f", "table"})
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})
api.ExpectRequest(t, "GET", "/api/Spaces-1/lifecycles/all").RespondWithStatus(403, "403 Forbidden", nil)
api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/all").RespondWithStatus(403, "403 Forbidden", nil)

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

assert.Equal(t, heredoc.Doc(`
NAME SLUG PROJECT GROUP LIFECYCLE DESCRIPTION TAGS
Fire Project fire-project ProjectGroups-1 Lifecycles-1 team/red
`), 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{"project", "list", "--no-prompt", "-f", "json"})
return rootCmd.ExecuteC()
})

expectListRequests(t, api)

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

type x struct {
Id string
Name string
Description string
ProjectTags []string
Slug string
SpaceId string
ProjectGroupId string
ProjectGroupName string
LifecycleId string
LifecycleName string
IsDisabled bool
IsVersionControlled bool
TenantedDeploymentMode string
}
parsedStdout, err := testutil.ParseJsonStrict[[]x](stdOut)
assert.Nil(t, err)

assert.Equal(t, []x{
{
Id: "Projects-22",
Name: "Fire Project",
ProjectTags: []string{"team/red"},
Slug: "fire-project",
SpaceId: spaceID,
ProjectGroupId: "ProjectGroups-1",
ProjectGroupName: "Default Project Group",
LifecycleId: "Lifecycles-1",
LifecycleName: "Default Lifecycle",
TenantedDeploymentMode: "Untenanted",
},
{
Id: "Projects-23",
Name: "Water Project",
Description: "Wet things",
ProjectTags: []string{"team/blue"},
Slug: "water-project",
SpaceId: spaceID,
ProjectGroupId: "ProjectGroups-1",
ProjectGroupName: "Default Project Group",
LifecycleId: "Lifecycles-99",
IsDisabled: true,
TenantedDeploymentMode: "Untenanted",
},
}, parsedStdout)
assert.Equal(t, "", stdErr.String())
}},

{"outputFormat basic still lists just names", 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{"project", "list", "--no-prompt", "-f", "basic"})
return rootCmd.ExecuteC()
})

expectListRequests(t, api)

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

assert.Equal(t, heredoc.Doc(`
Fire Project
Water Project
`), 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)
})
}
}
73 changes: 73 additions & 0 deletions pkg/cmd/project/shared/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"github.com/OctopusDeploy/cli/pkg/question"
"github.com/OctopusDeploy/cli/pkg/question/selectors"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectgroups"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects"
)

type CreateProjectGroupCallback func() (string, cmd.Dependable, error)
Expand Down Expand Up @@ -45,3 +47,74 @@ func AskProjectGroups(ask question.Asker, value string, getAllGroupsCallback Get
}
return g.Name, nil, nil
}

// GetLifecycleMap resolves lifecycle IDs to names for display. Best-effort: a
// failed lookup yields an empty map and callers fall back to the ID.
func GetLifecycleMap(octopus *client.Client) map[string]string {
lifecycleMap := make(map[string]string)
allLifecycles, err := octopus.Lifecycles.GetAll()
if err != nil {
return lifecycleMap
}
for _, l := range allLifecycles {
lifecycleMap[l.GetID()] = l.Name
}
return lifecycleMap
}

// GetProjectGroupMap resolves project group IDs to names for display. Best-effort,
// as GetLifecycleMap is.
func GetProjectGroupMap(octopus *client.Client) map[string]string {
projectGroupMap := make(map[string]string)
allProjectGroups, err := octopus.ProjectGroups.GetAll()
if err != nil {
return projectGroupMap
}
for _, pg := range allProjectGroups {
projectGroupMap[pg.GetID()] = pg.Name
}
return projectGroupMap
}

// GetLifecycleName resolves a single lifecycle ID, which is cheaper than a whole
// map when only one project is being displayed. Empty when it can't be resolved.
func GetLifecycleName(octopus *client.Client, lifecycleID string) string {
if lifecycleID == "" {
return ""
}
lifecycle, err := octopus.Lifecycles.GetByID(lifecycleID)
if err != nil {
return ""
}
return lifecycle.Name
}

// GetProjectGroupName resolves a single project group ID, as GetLifecycleName does.
func GetProjectGroupName(octopus *client.Client, projectGroupID string) string {
if projectGroupID == "" {
return ""
}
projectGroup, err := octopus.ProjectGroups.GetByID(projectGroupID)
if err != nil {
return ""
}
return projectGroup.Name
}

// DisplayName prefers the resolved name, falling back to the ID so there is always
// something to show.
func DisplayName(id string, name string) string {
if name == "" {
return id
}
return name
}

// TenantedDeploymentMode reports the project's mode, defaulting to Untenanted as
// the server does when the project doesn't carry one.
func TenantedDeploymentMode(project *projects.Project) string {
if project.TenantedDeploymentMode == "" {
return string(core.TenantedDeploymentModeUntenanted)
}
return string(project.TenantedDeploymentMode)
}
Loading