Skip to content
16 changes: 2 additions & 14 deletions pkg/cmd/channel/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import (
"github.com/OctopusDeploy/cli/pkg/cmd/channel/shared"
"github.com/OctopusDeploy/cli/pkg/constants"
"github.com/OctopusDeploy/cli/pkg/factory"
"github.com/OctopusDeploy/cli/pkg/lookups"
"github.com/OctopusDeploy/cli/pkg/output"
"github.com/OctopusDeploy/cli/pkg/question/selectors"
"github.com/OctopusDeploy/cli/pkg/util/flag"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -102,7 +102,7 @@ func listRun(cmd *cobra.Command, f factory.Factory, flags *ListFlags) error {
}

// best-effort, as channel view is: listing still works without access to lifecycles
lifecycleMap := getLifecycleMap(octopus)
lifecycleMap := lookups.GetLifecycleMap(octopus)

filter := strings.ToLower(flags.Filter.Value)
viewModels := make([]ChannelViewModel, 0, len(allChannels))
Expand Down Expand Up @@ -147,15 +147,3 @@ func listRun(cmd *cobra.Command, f factory.Factory, flags *ListFlags) error {
},
})
}

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
}
56 changes: 46 additions & 10 deletions pkg/cmd/project/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ 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/lookups"
"github.com/OctopusDeploy/cli/pkg/output"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects"
"github.com/spf13/cobra"
Expand All @@ -29,10 +31,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 +57,44 @@ 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. Basic
// output only prints names, so don't pay for the round trips there.
var lifecycleMap, projectGroupMap map[string]string
if output.ResolveOutputFormat(cmd) != constants.OutputFormatBasic {
lifecycleMap = lookups.GetLifecycleMap(client)
projectGroupMap = lookups.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,
lookups.DisplayName(p.ProjectGroupID, projectGroupMap[p.ProjectGroupID]),
lookups.DisplayName(p.LifecycleID, lifecycleMap[p.LifecycleID]),
p.Description,
output.FormatAsList(p.ProjectTags),
}
},
},
Basic: func(p *projects.Project) string {
Expand Down
196 changes: 196 additions & 0 deletions pkg/cmd/project/list/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
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 lists just names, without the name lookups", 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()
})

// basic output prints names only, so no lifecycle or project group
// requests should be made; the mock server has no response queued for
// an unexpected request, so this test blocks if they are
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(`
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)
})
}
}
11 changes: 11 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,12 @@ func AskProjectGroups(ask question.Asker, value string, getAllGroupsCallback Get
}
return g.Name, nil, nil
}

// 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