From 161a0845392f4642e6e048be1a65e81ff08246e0 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 19 Aug 2026 11:41:53 +1000 Subject: [PATCH] feat: add project metadata to project list and view Both commands returned far less than the REST API does. list and view now carry the project group, lifecycle, slug, space, disabled state and tenanted deployment mode, and view additionally carries the process, variable set, library variable sets, release settings, connectivity policy and templates. Group and lifecycle IDs resolve to names the way channel list resolves lifecycles: two GetAll lookups for the whole list rather than one per project, best-effort, falling back to the ID when a name can't be resolved. Existing JSON fields keep their names and their presence, so scripts parsing the current output are unaffected. Refs #491 Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/project/list/list.go | 51 ++++++-- pkg/cmd/project/list/list_test.go | 191 +++++++++++++++++++++++++++ pkg/cmd/project/shared/shared.go | 73 +++++++++++ pkg/cmd/project/view/view.go | 129 ++++++++++++------ pkg/cmd/project/view/view_test.go | 209 ++++++++++++++++++++++++++++++ 5 files changed, 606 insertions(+), 47 deletions(-) create mode 100644 pkg/cmd/project/list/list_test.go create mode 100644 pkg/cmd/project/view/view_test.go diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go index 702e80ce..3e836182 100644 --- a/pkg/cmd/project/list/list.go +++ b/pkg/cmd/project/list/list.go @@ -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" @@ -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 { @@ -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 { diff --git a/pkg/cmd/project/list/list_test.go b/pkg/cmd/project/list/list_test.go new file mode 100644 index 00000000..46791d00 --- /dev/null +++ b/pkg/cmd/project/list/list_test.go @@ -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) + }) + } +} diff --git a/pkg/cmd/project/shared/shared.go b/pkg/cmd/project/shared/shared.go index 071fa7fd..2c96fed6 100644 --- a/pkg/cmd/project/shared/shared.go +++ b/pkg/cmd/project/shared/shared.go @@ -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) @@ -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) +} diff --git a/pkg/cmd/project/view/view.go b/pkg/cmd/project/view/view.go index 625d67b7..7f7fd310 100644 --- a/pkg/cmd/project/view/view.go +++ b/pkg/cmd/project/view/view.go @@ -8,13 +8,16 @@ import ( "github.com/OctopusDeploy/cli/pkg/apiclient" "github.com/MakeNowJust/heredoc/v2" + "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" "github.com/OctopusDeploy/cli/pkg/usage" "github.com/OctopusDeploy/cli/pkg/util" "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/actiontemplates" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" "github.com/pkg/browser" "github.com/spf13/cobra" @@ -86,76 +89,122 @@ func viewRun(opts *ViewOptions) error { return err } + // best-effort, as channel list is: viewing still works without access to either + lifecycleName := shared.GetLifecycleName(opts.Client, project.LifecycleID) + projectGroupName := shared.GetProjectGroupName(opts.Client, project.ProjectGroupID) + return output.PrintResource(project, opts.Command, output.Mappers[*projects.Project]{ Json: func(p *projects.Project) any { - cacBranch := "Not version controlled" - if p.IsVersionControlled { - cacBranch = p.PersistenceSettings.(projects.GitPersistenceSettings).DefaultBranch() - } - return ProjectAsJson{ - Id: p.GetID(), - Name: p.Name, - Slug: p.Slug, - Description: p.Description, - IsVersionControlled: p.IsVersionControlled, - VersionControlBranch: cacBranch, - ProjectTags: p.ProjectTags, - WebUrl: util.GenerateWebURL(opts.Host, p.SpaceID, fmt.Sprintf("projects/%s", p.GetID())), + Id: p.GetID(), + Name: p.Name, + Slug: p.Slug, + Description: p.Description, + IsVersionControlled: p.IsVersionControlled, + VersionControlBranch: versionControlBranch(p), + ProjectTags: p.ProjectTags, + WebUrl: webUrl(opts, p), + SpaceId: p.SpaceID, + IsDisabled: p.IsDisabled, + ProjectGroupId: p.ProjectGroupID, + ProjectGroupName: projectGroupName, + LifecycleId: p.LifecycleID, + LifecycleName: lifecycleName, + TenantedDeploymentMode: shared.TenantedDeploymentMode(p), + DeploymentProcessId: p.DeploymentProcessID, + VariableSetId: p.VariableSetID, + IncludedLibraryVariableSetIds: p.IncludedLibraryVariableSets, + ClonedFromProjectId: p.ClonedFromProjectID, + AutoCreateRelease: p.AutoCreateRelease, + DefaultGuidedFailureMode: p.DefaultGuidedFailureMode, + DefaultToSkipIfAlreadyInstalled: p.DefaultToSkipIfAlreadyInstalled, + DiscreteChannelRelease: p.IsDiscreteChannelRelease, + ReleaseNotesTemplate: p.ReleaseNotesTemplate, + VersioningStrategy: p.VersioningStrategy, + ProjectConnectivityPolicy: p.ConnectivityPolicy, + Templates: p.Templates, } }, Table: output.TableDefinition[*projects.Project]{ - Header: []string{"NAME", "SLUG", "DESCRIPTION", "VERSION CONTROL", "TAGS", "WEB URL"}, + Header: []string{"NAME", "SLUG", "PROJECT GROUP", "LIFECYCLE", "DESCRIPTION", "VERSION CONTROL", "TAGS", "WEB URL"}, Row: func(p *projects.Project) []string { description := p.Description if description == "" { description = constants.NoDescription } - cacBranch := "Not version controlled" - if p.IsVersionControlled { - cacBranch = p.PersistenceSettings.(projects.GitPersistenceSettings).DefaultBranch() - } - return []string{ output.Bold(p.Name), p.Slug, + shared.DisplayName(p.ProjectGroupID, projectGroupName), + shared.DisplayName(p.LifecycleID, lifecycleName), description, - cacBranch, + versionControlBranch(p), output.FormatAsList(p.ProjectTags), - output.Blue(util.GenerateWebURL(opts.Host, p.SpaceID, fmt.Sprintf("projects/%s", p.GetID()))), + output.Blue(webUrl(opts, p)), } }, }, Basic: func(p *projects.Project) string { - return formatProjectForBasic(opts, p) + return formatProjectForBasic(opts, p, projectGroupName, lifecycleName) }, }) } type ProjectAsJson struct { - Id string `json:"Id"` - Name string `json:"Name"` - Slug string `json:"Slug"` - Description string `json:"Description"` - IsVersionControlled bool `json:"IsVersionControlled"` - VersionControlBranch string `json:"VersionControlBranch"` - ProjectTags []string `json:"ProjectTags,omitempty"` - WebUrl string `json:"WebUrl"` + Id string `json:"Id"` + Name string `json:"Name"` + Slug string `json:"Slug"` + Description string `json:"Description"` + IsVersionControlled bool `json:"IsVersionControlled"` + VersionControlBranch string `json:"VersionControlBranch"` + ProjectTags []string `json:"ProjectTags,omitempty"` + WebUrl string `json:"WebUrl"` + SpaceId string `json:"SpaceId"` + IsDisabled bool `json:"IsDisabled"` + ProjectGroupId string `json:"ProjectGroupId"` + ProjectGroupName string `json:"ProjectGroupName,omitempty"` + LifecycleId string `json:"LifecycleId"` + LifecycleName string `json:"LifecycleName,omitempty"` + TenantedDeploymentMode string `json:"TenantedDeploymentMode"` + DeploymentProcessId string `json:"DeploymentProcessId,omitempty"` + VariableSetId string `json:"VariableSetId,omitempty"` + IncludedLibraryVariableSetIds []string `json:"IncludedLibraryVariableSetIds,omitempty"` + ClonedFromProjectId string `json:"ClonedFromProjectId,omitempty"` + AutoCreateRelease bool `json:"AutoCreateRelease"` + DefaultGuidedFailureMode string `json:"DefaultGuidedFailureMode,omitempty"` + DefaultToSkipIfAlreadyInstalled bool `json:"DefaultToSkipIfAlreadyInstalled"` + DiscreteChannelRelease bool `json:"DiscreteChannelRelease"` + ReleaseNotesTemplate string `json:"ReleaseNotesTemplate,omitempty"` + VersioningStrategy *projects.VersioningStrategy `json:"VersioningStrategy,omitempty"` + ProjectConnectivityPolicy *core.ConnectivityPolicy `json:"ProjectConnectivityPolicy,omitempty"` + Templates []actiontemplates.ActionTemplateParameter `json:"Templates,omitempty"` +} + +func versionControlBranch(project *projects.Project) string { + if !project.IsVersionControlled { + return "Not version controlled" + } + return project.PersistenceSettings.(projects.GitPersistenceSettings).DefaultBranch() } -func formatProjectForBasic(opts *ViewOptions, project *projects.Project) string { +func webUrl(opts *ViewOptions, project *projects.Project) string { + return util.GenerateWebURL(opts.Host, project.SpaceID, fmt.Sprintf("projects/%s", project.GetID())) +} + +func formatProjectForBasic(opts *ViewOptions, project *projects.Project, projectGroupName string, lifecycleName string) string { var result strings.Builder // header result.WriteString(fmt.Sprintf("%s %s\n", output.Bold(project.Name), output.Dimf("(%s)", project.Slug))) + // where the project sits and how it releases + result.WriteString(fmt.Sprintf("Project group: %s\n", output.Cyan(shared.DisplayName(project.ProjectGroupID, projectGroupName)))) + result.WriteString(fmt.Sprintf("Lifecycle: %s\n", output.Cyan(shared.DisplayName(project.LifecycleID, lifecycleName)))) + result.WriteString(fmt.Sprintf("Tenanted deployment mode: %s\n", output.Cyan(shared.TenantedDeploymentMode(project)))) + // version control branch - cacBranch := "Not version controlled" - if project.IsVersionControlled { - cacBranch = project.PersistenceSettings.(projects.GitPersistenceSettings).DefaultBranch() - } - result.WriteString(fmt.Sprintf("Version control branch: %s\n", output.Cyan(cacBranch))) + result.WriteString(fmt.Sprintf("Version control branch: %s\n", output.Cyan(versionControlBranch(project)))) // tags if len(project.ProjectTags) > 0 { @@ -169,8 +218,14 @@ func formatProjectForBasic(opts *ViewOptions, project *projects.Project) string result.WriteString(fmt.Sprintln(output.Dim(project.Description))) } + if project.IsDisabled { + result.WriteString(fmt.Sprintln("Project is disabled")) + } else { + result.WriteString(fmt.Sprintln("Project is enabled")) + } + // footer with web URL - url := util.GenerateWebURL(opts.Host, project.SpaceID, fmt.Sprintf("projects/%s", project.GetID())) + url := webUrl(opts, project) result.WriteString(fmt.Sprintf("View this project in Octopus Deploy: %s\n", output.Blue(url))) if opts.flags.Web.Value { diff --git a/pkg/cmd/project/view/view_test.go b/pkg/cmd/project/view/view_test.go new file mode 100644 index 00000000..e8cb61c8 --- /dev/null +++ b/pkg/cmd/project/view/view_test.go @@ -0,0 +1,209 @@ +package view_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/core" + "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 TestProjectView(t *testing.T) { + const spaceID = "Spaces-1" + const projectID = "Projects-22" + + 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, projectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "deploymentprocess-Projects-22") + fireProject.SpaceID = spaceID + fireProject.Slug = "fire-project" + fireProject.Description = "Fire things" + fireProject.ProjectTags = []string{"team/red"} + fireProject.VariableSetID = "variableset-Projects-22" + fireProject.IncludedLibraryVariableSets = []string{"LibraryVariableSets-1"} + + expectViewRequests := 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/Projects-22").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/lifecycles/Lifecycles-1").RespondWith(lifecycle) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/ProjectGroups-1").RespondWith(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 view (table)", 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", "view", "Projects-22", "--no-prompt", "-f", "table"}) + return rootCmd.ExecuteC() + }) + + expectViewRequests(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + NAME SLUG PROJECT GROUP LIFECYCLE DESCRIPTION VERSION CONTROL TAGS WEB URL + Fire Project fire-project Default Project Group Default Lifecycle Fire things Not version controlled team/red http://server/app#/Spaces-1/projects/Projects-22 + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"project view (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{"project", "view", "Projects-22", "--no-prompt", "-f", "basic"}) + return rootCmd.ExecuteC() + }) + + expectViewRequests(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + Fire Project (fire-project) + Project group: Default Project Group + Lifecycle: Default Lifecycle + Tenanted deployment mode: Untenanted + Version control branch: Not version controlled + Tags: team/red + Fire things + Project is enabled + View this project in Octopus Deploy: http://server/app#/Spaces-1/projects/Projects-22 + + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"project view falls back to IDs when the lookups fail (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{"project", "view", "Projects-22", "--no-prompt", "-f", "basic"}) + 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/Projects-22").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/lifecycles/Lifecycles-1").RespondWithStatus(403, "403 Forbidden", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/ProjectGroups-1").RespondWithStatus(403, "403 Forbidden", nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + Fire Project (fire-project) + Project group: ProjectGroups-1 + Lifecycle: Lifecycles-1 + Tenanted deployment mode: Untenanted + Version control branch: Not version controlled + Tags: team/red + Fire things + Project is enabled + View this project in Octopus Deploy: http://server/app#/Spaces-1/projects/Projects-22 + + `), 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", "view", "Projects-22", "--no-prompt", "-f", "json"}) + return rootCmd.ExecuteC() + }) + + expectViewRequests(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + type x struct { + Id string + Name string + Slug string + Description string + IsVersionControlled bool + VersionControlBranch string + ProjectTags []string + WebUrl string + SpaceId string + IsDisabled bool + ProjectGroupId string + ProjectGroupName string + LifecycleId string + LifecycleName string + TenantedDeploymentMode string + DeploymentProcessId string + VariableSetId string + IncludedLibraryVariableSetIds []string + AutoCreateRelease bool + DefaultToSkipIfAlreadyInstalled bool + DiscreteChannelRelease bool + VersioningStrategy *projects.VersioningStrategy + ProjectConnectivityPolicy *core.ConnectivityPolicy + } + parsedStdout, err := testutil.ParseJsonStrict[x](stdOut) + assert.Nil(t, err) + + assert.Equal(t, x{ + Id: projectID, + Name: "Fire Project", + Slug: "fire-project", + Description: "Fire things", + VersionControlBranch: "Not version controlled", + ProjectTags: []string{"team/red"}, + WebUrl: "http://server/app#/Spaces-1/projects/Projects-22", + SpaceId: spaceID, + ProjectGroupId: "ProjectGroups-1", + ProjectGroupName: "Default Project Group", + LifecycleId: "Lifecycles-1", + LifecycleName: "Default Lifecycle", + TenantedDeploymentMode: "Untenanted", + DeploymentProcessId: "deploymentprocess-Projects-22", + VariableSetId: "variableset-Projects-22", + IncludedLibraryVariableSetIds: []string{"LibraryVariableSets-1"}, + VersioningStrategy: &projects.VersioningStrategy{ + Template: "#{Octopus.Version.LastMajor}.#{Octopus.Version.LastMinor}.#{Octopus.Version.NextPatch}", + }, + ProjectConnectivityPolicy: &core.ConnectivityPolicy{}, + }, parsedStdout) + 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) + }) + } +}