From f10d1cc9866d7b0c200ec5d0797cc453834c224a Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 19 Aug 2026 11:41:53 +1000 Subject: [PATCH 1/9] 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) + }) + } +} From b7017c2c81b661b71f40e40a448d7f6bf828a34c Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:04:49 +1000 Subject: [PATCH 2/9] fix: don't panic when a version controlled project has no git settings versionControlBranch used a bare type assertion on project.PersistenceSettings. IsVersionControlled and PersistenceSettings are independent fields on the wire, and the SDK leaves PersistenceSettings as a nil interface when the block is absent, so a project reporting IsVersionControlled: true without Git-typed settings crashed the command. Use a comma-ok assertion and fall back to an empty branch. Added a regression test that reproduces the panic on the previous code. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/project/view/view.go | 8 +++++++- pkg/cmd/project/view/view_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/project/view/view.go b/pkg/cmd/project/view/view.go index 7f7fd310..d8802bfe 100644 --- a/pkg/cmd/project/view/view.go +++ b/pkg/cmd/project/view/view.go @@ -185,7 +185,13 @@ func versionControlBranch(project *projects.Project) string { if !project.IsVersionControlled { return "Not version controlled" } - return project.PersistenceSettings.(projects.GitPersistenceSettings).DefaultBranch() + // IsVersionControlled and PersistenceSettings are independent fields on the + // wire, so a project can claim to be version controlled without carrying + // Git-typed settings. Don't panic on the type assertion if that happens. + if gitSettings, ok := project.PersistenceSettings.(projects.GitPersistenceSettings); ok { + return gitSettings.DefaultBranch() + } + return "" } func webUrl(opts *ViewOptions, project *projects.Project) string { diff --git a/pkg/cmd/project/view/view_test.go b/pkg/cmd/project/view/view_test.go index e8cb61c8..654fc40b 100644 --- a/pkg/cmd/project/view/view_test.go +++ b/pkg/cmd/project/view/view_test.go @@ -2,6 +2,7 @@ package view_test import ( "bytes" + "encoding/json" "testing" "github.com/MakeNowJust/heredoc/v2" @@ -128,6 +129,35 @@ func TestProjectView(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, + {"project view does not panic when a version controlled project has no git settings", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + // IsVersionControlled and PersistenceSettings are independent on the wire; + // the CLI must not assume the settings block is present, or Git-typed. + raw := map[string]any{} + encoded, err := json.Marshal(fireProject) + assert.Nil(t, err) + assert.Nil(t, json.Unmarshal(encoded, &raw)) + raw["IsVersionControlled"] = true + delete(raw, "PersistenceSettings") + + 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(raw) + api.ExpectRequest(t, "GET", "/api/Spaces-1/lifecycles/Lifecycles-1").RespondWith(lifecycle) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/ProjectGroups-1").RespondWith(projectGroup) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Contains(t, stdOut.String(), "Version control branch: \n") + 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() From e5622b19bf1c27c6bd5c24806b3a828b2eaf9627 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:08:19 +1000 Subject: [PATCH 3/9] perf: skip the name lookups for project list -f basic project list fetched all lifecycles and all project groups before printing, but the basic mapper only emits the project name, so scripting callers paid two extra /all round trips for data that was never rendered. Extract the format resolution PrintArray and PrintResource already do into output.ResolveOutputFormat, and use it to skip the lookups for basic output. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/project/list/list.go | 12 ++++++++---- pkg/cmd/project/list/list_test.go | 9 +++++++-- pkg/output/print_array.go | 9 ++------- pkg/output/print_resource.go | 9 ++------- pkg/output/shared.go | 20 ++++++++++++++++++++ 5 files changed, 39 insertions(+), 20 deletions(-) diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go index 3e836182..c96cece8 100644 --- a/pkg/cmd/project/list/list.go +++ b/pkg/cmd/project/list/list.go @@ -56,10 +56,14 @@ 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) + // 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 = shared.GetLifecycleMap(client) + projectGroupMap = shared.GetProjectGroupMap(client) + } return output.PrintArray(allProjects, cmd, output.Mappers[*projects.Project]{ Json: func(p *projects.Project) any { diff --git a/pkg/cmd/project/list/list_test.go b/pkg/cmd/project/list/list_test.go index 46791d00..f7a7d2e3 100644 --- a/pkg/cmd/project/list/list_test.go +++ b/pkg/cmd/project/list/list_test.go @@ -156,14 +156,19 @@ func TestProjectList(t *testing.T) { 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) { + {"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() }) - expectListRequests(t, api) + // 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) diff --git a/pkg/output/print_array.go b/pkg/output/print_array.go index d7aa19c3..6ca098a6 100644 --- a/pkg/output/print_array.go +++ b/pkg/output/print_array.go @@ -4,23 +4,18 @@ import ( "encoding/json" "errors" "fmt" - "strings" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/usage" "github.com/spf13/cobra" - "github.com/spf13/viper" ) func PrintArray[T any](items []T, cmd *cobra.Command, mappers Mappers[T]) error { - outputFormat, _ := cmd.Flags().GetString(constants.FlagOutputFormat) - if outputFormat == "" { - outputFormat = viper.GetString(constants.ConfigOutputFormat) - } + outputFormat := ResolveOutputFormat(cmd) - switch strings.ToLower(outputFormat) { + switch outputFormat { case constants.OutputFormatJson: jsonMapper := mappers.Json if jsonMapper == nil { diff --git a/pkg/output/print_resource.go b/pkg/output/print_resource.go index a58141a4..74eb10b7 100644 --- a/pkg/output/print_resource.go +++ b/pkg/output/print_resource.go @@ -4,23 +4,18 @@ import ( "encoding/json" "errors" "fmt" - "strings" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/usage" "github.com/spf13/cobra" - "github.com/spf13/viper" ) func PrintResource[T any](item T, cmd *cobra.Command, mappers Mappers[T]) error { - outputFormat, _ := cmd.Flags().GetString(constants.FlagOutputFormat) - if outputFormat == "" { - outputFormat = viper.GetString(constants.ConfigOutputFormat) - } + outputFormat := ResolveOutputFormat(cmd) - switch strings.ToLower(outputFormat) { + switch outputFormat { case constants.OutputFormatJson: jsonMapper := mappers.Json if jsonMapper == nil { diff --git a/pkg/output/shared.go b/pkg/output/shared.go index e6f69075..241b1ec6 100644 --- a/pkg/output/shared.go +++ b/pkg/output/shared.go @@ -1,5 +1,25 @@ package output +import ( + "strings" + + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// ResolveOutputFormat returns the lower-cased output format that PrintArray and +// PrintResource will use for this command: the --output-format flag, else the +// configured default. Exported so commands can skip work that only one format +// needs (e.g. name lookups that basic output never prints). +func ResolveOutputFormat(cmd *cobra.Command) string { + outputFormat, _ := cmd.Flags().GetString(constants.FlagOutputFormat) + if outputFormat == "" { + outputFormat = viper.GetString(constants.ConfigOutputFormat) + } + return strings.ToLower(outputFormat) +} + // Common struct used for rendering JSON summaries of things that just have an ID and a Name type IdAndName struct { Id string `json:"Id"` From d6f8c22d216cc9bb36da40880678c177a3c59563 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:09:28 +1000 Subject: [PATCH 4/9] refactor: move ID-to-name lookups into a neutral pkg/lookups package pkg/cmd/project/shared.GetLifecycleMap was a byte-for-byte copy of getLifecycleMap in pkg/cmd/channel/list, so the two would drift as soon as either grew pagination or error reporting. Move the lifecycle and project group lookups, plus the ID fallback helper, into pkg/lookups so channel commands can use them without importing a project command's shared package, and delete the channel copy. project/shared keeps TenantedDeploymentMode, which is project specific. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/channel/list/list.go | 16 +------- pkg/cmd/project/list/list.go | 9 ++-- pkg/cmd/project/shared/shared.go | 62 ---------------------------- pkg/cmd/project/view/view.go | 13 +++--- pkg/lookups/lookups.go | 70 ++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 86 deletions(-) create mode 100644 pkg/lookups/lookups.go diff --git a/pkg/cmd/channel/list/list.go b/pkg/cmd/channel/list/list.go index 13be42ba..968c3067 100644 --- a/pkg/cmd/channel/list/list.go +++ b/pkg/cmd/channel/list/list.go @@ -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" ) @@ -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)) @@ -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 -} diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go index c96cece8..5d71a14a 100644 --- a/pkg/cmd/project/list/list.go +++ b/pkg/cmd/project/list/list.go @@ -6,6 +6,7 @@ import ( "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" @@ -61,8 +62,8 @@ func listRun(cmd *cobra.Command, f factory.Factory) error { // 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 = shared.GetLifecycleMap(client) - projectGroupMap = shared.GetProjectGroupMap(client) + lifecycleMap = lookups.GetLifecycleMap(client) + projectGroupMap = lookups.GetProjectGroupMap(client) } return output.PrintArray(allProjects, cmd, output.Mappers[*projects.Project]{ @@ -89,8 +90,8 @@ func listRun(cmd *cobra.Command, f factory.Factory) error { return []string{ output.Bold(p.Name), p.Slug, - shared.DisplayName(p.ProjectGroupID, projectGroupMap[p.ProjectGroupID]), - shared.DisplayName(p.LifecycleID, lifecycleMap[p.LifecycleID]), + lookups.DisplayName(p.ProjectGroupID, projectGroupMap[p.ProjectGroupID]), + lookups.DisplayName(p.LifecycleID, lifecycleMap[p.LifecycleID]), p.Description, output.FormatAsList(p.ProjectTags), } diff --git a/pkg/cmd/project/shared/shared.go b/pkg/cmd/project/shared/shared.go index 2c96fed6..f3fe8167 100644 --- a/pkg/cmd/project/shared/shared.go +++ b/pkg/cmd/project/shared/shared.go @@ -48,68 +48,6 @@ 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 { diff --git a/pkg/cmd/project/view/view.go b/pkg/cmd/project/view/view.go index d8802bfe..98042310 100644 --- a/pkg/cmd/project/view/view.go +++ b/pkg/cmd/project/view/view.go @@ -11,6 +11,7 @@ import ( "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/cli/pkg/usage" "github.com/OctopusDeploy/cli/pkg/util" @@ -90,8 +91,8 @@ func viewRun(opts *ViewOptions) error { } // 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) + lifecycleName := lookups.GetLifecycleName(opts.Client, project.LifecycleID) + projectGroupName := lookups.GetProjectGroupName(opts.Client, project.ProjectGroupID) return output.PrintResource(project, opts.Command, output.Mappers[*projects.Project]{ Json: func(p *projects.Project) any { @@ -136,8 +137,8 @@ func viewRun(opts *ViewOptions) error { return []string{ output.Bold(p.Name), p.Slug, - shared.DisplayName(p.ProjectGroupID, projectGroupName), - shared.DisplayName(p.LifecycleID, lifecycleName), + lookups.DisplayName(p.ProjectGroupID, projectGroupName), + lookups.DisplayName(p.LifecycleID, lifecycleName), description, versionControlBranch(p), output.FormatAsList(p.ProjectTags), @@ -205,8 +206,8 @@ func formatProjectForBasic(opts *ViewOptions, project *projects.Project, project 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("Project group: %s\n", output.Cyan(lookups.DisplayName(project.ProjectGroupID, projectGroupName)))) + result.WriteString(fmt.Sprintf("Lifecycle: %s\n", output.Cyan(lookups.DisplayName(project.LifecycleID, lifecycleName)))) result.WriteString(fmt.Sprintf("Tenanted deployment mode: %s\n", output.Cyan(shared.TenantedDeploymentMode(project)))) // version control branch diff --git a/pkg/lookups/lookups.go b/pkg/lookups/lookups.go new file mode 100644 index 00000000..b667befc --- /dev/null +++ b/pkg/lookups/lookups.go @@ -0,0 +1,70 @@ +// Package lookups resolves resource IDs to human readable names for display. +// It lives outside pkg/cmd so any command group can use it without importing +// another command's shared package. +package lookups + +import ( + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" +) + +// 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 resource 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 +} From 5e6d020419ac25892ba6b56e1655f91a670c5e8b Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:09:54 +1000 Subject: [PATCH 5/9] fix: don't print an empty project group or lifecycle label in basic view DisplayName returns "" when both the resolved name and the ID are empty, which rendered as a bare "Project group: " / "Lifecycle: " line. Skip the line instead. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/project/view/view.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/project/view/view.go b/pkg/cmd/project/view/view.go index 98042310..1340bf4f 100644 --- a/pkg/cmd/project/view/view.go +++ b/pkg/cmd/project/view/view.go @@ -205,9 +205,14 @@ func formatProjectForBasic(opts *ViewOptions, project *projects.Project, project // 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(lookups.DisplayName(project.ProjectGroupID, projectGroupName)))) - result.WriteString(fmt.Sprintf("Lifecycle: %s\n", output.Cyan(lookups.DisplayName(project.LifecycleID, lifecycleName)))) + // where the project sits and how it releases; skip a label rather than print + // it with nothing after it when neither the name nor the ID is available + if group := lookups.DisplayName(project.ProjectGroupID, projectGroupName); group != "" { + result.WriteString(fmt.Sprintf("Project group: %s\n", output.Cyan(group))) + } + if lifecycle := lookups.DisplayName(project.LifecycleID, lifecycleName); lifecycle != "" { + result.WriteString(fmt.Sprintf("Lifecycle: %s\n", output.Cyan(lifecycle))) + } result.WriteString(fmt.Sprintf("Tenanted deployment mode: %s\n", output.Cyan(shared.TenantedDeploymentMode(project)))) // version control branch From 46aab4382b71bbffdc336acf25b1c335a8276e31 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:10:12 +1000 Subject: [PATCH 6/9] fix: honour project view --web for every output format browser.OpenURL lived inside the Basic formatter, so `octopus project view X --web -f table` and `-f json` printed the URL but never opened anything. Hoist the flag check into viewRun. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/project/view/view.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/project/view/view.go b/pkg/cmd/project/view/view.go index 1340bf4f..3e0376df 100644 --- a/pkg/cmd/project/view/view.go +++ b/pkg/cmd/project/view/view.go @@ -94,6 +94,12 @@ func viewRun(opts *ViewOptions) error { lifecycleName := lookups.GetLifecycleName(opts.Client, project.LifecycleID) projectGroupName := lookups.GetProjectGroupName(opts.Client, project.ProjectGroupID) + // --web is honoured for every output format, not just the one whose + // formatter happens to open the browser + if opts.flags.Web.Value { + _ = browser.OpenURL(webUrl(opts, project)) + } + return output.PrintResource(project, opts.Command, output.Mappers[*projects.Project]{ Json: func(p *projects.Project) any { return ProjectAsJson{ @@ -237,12 +243,7 @@ func formatProjectForBasic(opts *ViewOptions, project *projects.Project, project } // footer with web URL - url := webUrl(opts, project) - result.WriteString(fmt.Sprintf("View this project in Octopus Deploy: %s\n", output.Blue(url))) - - if opts.flags.Web.Value { - browser.OpenURL(url) - } + result.WriteString(fmt.Sprintf("View this project in Octopus Deploy: %s\n", output.Blue(webUrl(opts, project)))) return result.String() } From f515225fe1c9d8fe497eeaf12d309853430fb3ef Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 14 Sep 2026 22:44:12 +1000 Subject: [PATCH 7/9] fix: don't print an empty version control branch label in basic view versionControlBranch returns "" when a project claims to be version controlled but carries no Git settings, which rendered a bare "Version control branch: " line. Skip it, as the project group and lifecycle labels already do. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/project/view/view.go | 7 +++++-- pkg/cmd/project/view/view_test.go | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/project/view/view.go b/pkg/cmd/project/view/view.go index 3e0376df..335acce0 100644 --- a/pkg/cmd/project/view/view.go +++ b/pkg/cmd/project/view/view.go @@ -221,8 +221,11 @@ func formatProjectForBasic(opts *ViewOptions, project *projects.Project, project } result.WriteString(fmt.Sprintf("Tenanted deployment mode: %s\n", output.Cyan(shared.TenantedDeploymentMode(project)))) - // version control branch - result.WriteString(fmt.Sprintf("Version control branch: %s\n", output.Cyan(versionControlBranch(project)))) + // version control branch; empty when the project claims to be version + // controlled but carries no Git settings, so skip the label as above + if branch := versionControlBranch(project); branch != "" { + result.WriteString(fmt.Sprintf("Version control branch: %s\n", output.Cyan(branch))) + } // tags if len(project.ProjectTags) > 0 { diff --git a/pkg/cmd/project/view/view_test.go b/pkg/cmd/project/view/view_test.go index 654fc40b..deb19282 100644 --- a/pkg/cmd/project/view/view_test.go +++ b/pkg/cmd/project/view/view_test.go @@ -154,7 +154,9 @@ func TestProjectView(t *testing.T) { _, err = testutil.ReceivePair(cmdReceiver) assert.Nil(t, err) - assert.Contains(t, stdOut.String(), "Version control branch: \n") + // the command completes instead of panicking, and no blank labelled line + assert.Contains(t, stdOut.String(), "Fire Project") + assert.NotContains(t, stdOut.String(), "Version control branch:") assert.Equal(t, "", stdErr.String()) }}, From 4b84883eb71125a687358fd033716eb6bafa0fdc Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 16 Sep 2026 10:07:34 +1000 Subject: [PATCH 8/9] refactor: name the lookup maps for their key and value GetLifecycleMap / GetProjectGroupMap did not say which side of the map was the ID and which was the name. Rename both to GetLifecycleIdToNameMap / GetProjectGroupIdToNameMap, along with the locals they are assigned to, and give all four lookups doc comments that state what is returned when the name cannot be resolved. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/channel/list/list.go | 2 +- pkg/cmd/project/list/list.go | 14 +++++++------- pkg/lookups/lookups.go | 35 ++++++++++++++++++----------------- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/pkg/cmd/channel/list/list.go b/pkg/cmd/channel/list/list.go index 968c3067..9e0f6b13 100644 --- a/pkg/cmd/channel/list/list.go +++ b/pkg/cmd/channel/list/list.go @@ -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 := lookups.GetLifecycleMap(octopus) + lifecycleMap := lookups.GetLifecycleIdToNameMap(octopus) filter := strings.ToLower(flags.Filter.Value) viewModels := make([]ChannelViewModel, 0, len(allChannels)) diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go index 5d71a14a..9a082767 100644 --- a/pkg/cmd/project/list/list.go +++ b/pkg/cmd/project/list/list.go @@ -60,10 +60,10 @@ func listRun(cmd *cobra.Command, f factory.Factory) error { // 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 + var lifecycleIdToNameMap, projectGroupIdToNameMap map[string]string if output.ResolveOutputFormat(cmd) != constants.OutputFormatBasic { - lifecycleMap = lookups.GetLifecycleMap(client) - projectGroupMap = lookups.GetProjectGroupMap(client) + lifecycleIdToNameMap = lookups.GetLifecycleIdToNameMap(client) + projectGroupIdToNameMap = lookups.GetProjectGroupIdToNameMap(client) } return output.PrintArray(allProjects, cmd, output.Mappers[*projects.Project]{ @@ -76,9 +76,9 @@ func listRun(cmd *cobra.Command, f factory.Factory) error { Slug: p.Slug, SpaceId: p.SpaceID, ProjectGroupId: p.ProjectGroupID, - ProjectGroupName: projectGroupMap[p.ProjectGroupID], + ProjectGroupName: projectGroupIdToNameMap[p.ProjectGroupID], LifecycleId: p.LifecycleID, - LifecycleName: lifecycleMap[p.LifecycleID], + LifecycleName: lifecycleIdToNameMap[p.LifecycleID], IsDisabled: p.IsDisabled, IsVersionControlled: p.IsVersionControlled, TenantedDeploymentMode: shared.TenantedDeploymentMode(p), @@ -90,8 +90,8 @@ func listRun(cmd *cobra.Command, f factory.Factory) error { return []string{ output.Bold(p.Name), p.Slug, - lookups.DisplayName(p.ProjectGroupID, projectGroupMap[p.ProjectGroupID]), - lookups.DisplayName(p.LifecycleID, lifecycleMap[p.LifecycleID]), + lookups.DisplayName(p.ProjectGroupID, projectGroupIdToNameMap[p.ProjectGroupID]), + lookups.DisplayName(p.LifecycleID, lifecycleIdToNameMap[p.LifecycleID]), p.Description, output.FormatAsList(p.ProjectTags), } diff --git a/pkg/lookups/lookups.go b/pkg/lookups/lookups.go index b667befc..5503f90b 100644 --- a/pkg/lookups/lookups.go +++ b/pkg/lookups/lookups.go @@ -7,36 +7,36 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" ) -// 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) +// GetLifecycleIdToNameMap resolves lifecycle IDs to names for display. +// If the name cannot be resolved, the caller should fall back to the ID. +func GetLifecycleIdToNameMap(octopus *client.Client) map[string]string { + lifecycleIdToNameMap := make(map[string]string) allLifecycles, err := octopus.Lifecycles.GetAll() if err != nil { - return lifecycleMap + return lifecycleIdToNameMap } for _, l := range allLifecycles { - lifecycleMap[l.GetID()] = l.Name + lifecycleIdToNameMap[l.GetID()] = l.Name } - return lifecycleMap + return lifecycleIdToNameMap } -// 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) +// GetProjectGroupIdToNameMap resolves project group IDs to names for display. +// If the name cannot be resolved, the caller should fall back to the ID. +func GetProjectGroupIdToNameMap(octopus *client.Client) map[string]string { + projectGroupIdToNameMap := make(map[string]string) allProjectGroups, err := octopus.ProjectGroups.GetAll() if err != nil { - return projectGroupMap + return projectGroupIdToNameMap } for _, pg := range allProjectGroups { - projectGroupMap[pg.GetID()] = pg.Name + projectGroupIdToNameMap[pg.GetID()] = pg.Name } - return projectGroupMap + return projectGroupIdToNameMap } -// GetLifecycleName resolves a single lifecycle ID, which is cheaper than a whole -// map when only one resource is being displayed. Empty when it can't be resolved. +// GetLifecycleName resolves a single lifecycle name given its ID. +// An empty string is returned when the name cannot be resolved. func GetLifecycleName(octopus *client.Client, lifecycleID string) string { if lifecycleID == "" { return "" @@ -48,7 +48,8 @@ func GetLifecycleName(octopus *client.Client, lifecycleID string) string { return lifecycle.Name } -// GetProjectGroupName resolves a single project group ID, as GetLifecycleName does. +// GetProjectGroupName resolves a single project group name given its ID. +// An empty string is returned when the name cannot be resolved. func GetProjectGroupName(octopus *client.Client, projectGroupID string) string { if projectGroupID == "" { return "" From cdae0ecd49c7eb035c62dc2b6fdc31a890b56b25 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 16 Sep 2026 10:07:41 +1000 Subject: [PATCH 9/9] docs: trim the comments that restate the code The comment above the project list lookups spent three lines on things the code already says; keep only the part that explains the branch. Drop the best-effort note in viewRun, which said nothing the lookups' own doc comments don't, and shorten the two label-skipping comments in the basic formatter. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/project/list/list.go | 4 +--- pkg/cmd/project/view/view.go | 7 ++----- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go index 9a082767..4c4199bd 100644 --- a/pkg/cmd/project/list/list.go +++ b/pkg/cmd/project/list/list.go @@ -57,9 +57,7 @@ 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. + // Basic output only prints names, so don't pay for the lookups there var lifecycleIdToNameMap, projectGroupIdToNameMap map[string]string if output.ResolveOutputFormat(cmd) != constants.OutputFormatBasic { lifecycleIdToNameMap = lookups.GetLifecycleIdToNameMap(client) diff --git a/pkg/cmd/project/view/view.go b/pkg/cmd/project/view/view.go index 335acce0..63c95a28 100644 --- a/pkg/cmd/project/view/view.go +++ b/pkg/cmd/project/view/view.go @@ -90,7 +90,6 @@ func viewRun(opts *ViewOptions) error { return err } - // best-effort, as channel list is: viewing still works without access to either lifecycleName := lookups.GetLifecycleName(opts.Client, project.LifecycleID) projectGroupName := lookups.GetProjectGroupName(opts.Client, project.ProjectGroupID) @@ -211,8 +210,7 @@ func formatProjectForBasic(opts *ViewOptions, project *projects.Project, project // 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; skip a label rather than print - // it with nothing after it when neither the name nor the ID is available + // Skip a label rather than print it with nothing after it when neither the name nor the ID is available if group := lookups.DisplayName(project.ProjectGroupID, projectGroupName); group != "" { result.WriteString(fmt.Sprintf("Project group: %s\n", output.Cyan(group))) } @@ -221,8 +219,7 @@ func formatProjectForBasic(opts *ViewOptions, project *projects.Project, project } result.WriteString(fmt.Sprintf("Tenanted deployment mode: %s\n", output.Cyan(shared.TenantedDeploymentMode(project)))) - // version control branch; empty when the project claims to be version - // controlled but carries no Git settings, so skip the label as above + // version control branch; empty when there are no Git settings, so skip the label as above if branch := versionControlBranch(project); branch != "" { result.WriteString(fmt.Sprintf("Version control branch: %s\n", output.Cyan(branch))) }