From b08026678f28260bce7f1e23a95e9733b631f9ac Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Thu, 13 Nov 2025 15:56:47 +1000 Subject: [PATCH] feat:Add support to view/add/update project tags --- go.mod | 2 +- go.sum | 8 +- pkg/cmd/project/create/create.go | 26 +++++ pkg/cmd/project/create/create_opts.go | 22 +++- pkg/cmd/project/list/list.go | 12 ++- pkg/cmd/project/project.go | 2 + pkg/cmd/project/tag/tag.go | 148 ++++++++++++++++++++++++++ pkg/cmd/project/tag/tag_opts.go | 116 ++++++++++++++++++++ pkg/cmd/project/view/view.go | 24 +++-- 9 files changed, 339 insertions(+), 21 deletions(-) create mode 100644 pkg/cmd/project/tag/tag.go create mode 100644 pkg/cmd/project/tag/tag_opts.go diff --git a/go.mod b/go.mod index fe5cbd52..564f083a 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.88.0 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.89.1 github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index d12df80c..c31b9cfd 100644 --- a/go.sum +++ b/go.sum @@ -46,12 +46,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/OctopusDeploy/go-octodiff v1.0.0 h1:U+ORg6azniwwYo+O44giOw6TiD5USk8S4VDhOQ0Ven0= github.com/OctopusDeploy/go-octodiff v1.0.0/go.mod h1:Mze0+EkOWTgTmi8++fyUc6r0aLZT7qD9gX+31t8MmIU= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.86.0 h1:yGoohDFkruQ13mxoK21J+U+IUkxoU5jSxGEpknW/E5Y= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.86.0/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.86.1-0.20251107005245-10b49a98c515 h1:7L4l7zszH5TKP14BXCzgqT86xmHPQ4vQSmrqHc4Bj7E= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.86.1-0.20251107005245-10b49a98c515/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.88.0 h1:doBP2Xk11l3tNqJfrF0cYqX0df3UHIQiQCts1qAux+w= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.88.0/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.89.1 h1:EDo4CdA7jYZBHiaKu8nZRf9ndonJXC70OlU29+6KXKc= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.89.1/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= diff --git a/pkg/cmd/project/create/create.go b/pkg/cmd/project/create/create.go index d0728c54..027ecadd 100644 --- a/pkg/cmd/project/create/create.go +++ b/pkg/cmd/project/create/create.go @@ -21,6 +21,7 @@ const ( FlagDescription = "description" FlagLifecycle = "lifecycle" FlagConfigAsCode = "process-vcs" + FlagTag = "tag" ) type CreateFlags struct { @@ -29,6 +30,7 @@ type CreateFlags struct { Description *flag.Flag[string] Lifecycle *flag.Flag[string] ConfigAsCode *flag.Flag[bool] + Tag *flag.Flag[[]string] ProjectConvertFlags *convert.ConvertFlags } @@ -40,6 +42,7 @@ func NewCreateFlags() *CreateFlags { Description: flag.New[string](FlagDescription, false), Lifecycle: flag.New[string](FlagLifecycle, false), ConfigAsCode: flag.New[bool](FlagConfigAsCode, false), + Tag: flag.New[[]string](FlagTag, false), ProjectConvertFlags: convert.NewConvertFlags(), } } @@ -69,6 +72,7 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { flags.StringVarP(&createFlags.Group.Value, createFlags.Group.Name, "g", "", "Project group of the project") flags.StringVarP(&createFlags.Lifecycle.Value, createFlags.Lifecycle.Name, "l", "", "Lifecycle of the project") flags.BoolVar(&createFlags.ConfigAsCode.Value, createFlags.ConfigAsCode.Name, false, "Use Config As Code for the project") + flags.StringArrayVarP(&createFlags.Tag.Value, createFlags.Tag.Name, "t", []string{}, "Tag to apply to project, must use canonical name: /") convert.RegisterCacFlags(flags, createFlags.ProjectConvertFlags) flags.SortFlags = false @@ -84,6 +88,17 @@ func createRun(opts *CreateOptions) error { return err } } else { + // Validate tags when running with --no-prompt + if len(opts.Tag.Value) > 0 { + tagSets, err := opts.GetAllTagsCallback() + if err != nil { + return err + } + if err := selectors.ValidateTags(opts.Tag.Value, tagSets); err != nil { + return err + } + } + optsArray = append(optsArray, opts) if opts.ConfigAsCode.Value { opts.ConvertOptions.Project.Value = opts.Name.Value @@ -131,6 +146,17 @@ func PromptMissing(opts *CreateOptions) ([]cmd.Dependable, error) { nestedOpts = append(nestedOpts, opts) + tagSets, err := opts.GetAllTagsCallback() + if err != nil { + return nil, err + } + + tags, err := selectors.Tags(opts.Ask, []string{}, opts.Tag.Value, tagSets) + if err != nil { + return nil, err + } + opts.Tag.Value = tags + configAsCodeOpts, err := PromptForConfigAsCode(opts) opts.ConvertOptions.Project.Value = opts.Name.Value if err != nil { diff --git a/pkg/cmd/project/create/create_opts.go b/pkg/cmd/project/create/create_opts.go index 68e1688b..626ebede 100644 --- a/pkg/cmd/project/create/create_opts.go +++ b/pkg/cmd/project/create/create_opts.go @@ -7,14 +7,18 @@ import ( "github.com/OctopusDeploy/cli/pkg/cmd/project/shared" "github.com/OctopusDeploy/cli/pkg/output" "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectgroups" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tagsets" ) type ConvertProjectToConfigAsCodeCallback func() (cmd.Dependable, error) type GetAllGroupsCallback func() ([]*projectgroups.ProjectGroup, error) +type GetAllTagSetsCallback func() ([]*tagsets.TagSet, error) + type CreateOptions struct { *CreateFlags *cmd.Dependencies @@ -22,6 +26,7 @@ type CreateOptions struct { GetAllGroupsCallback shared.GetAllGroupsCallback CreateProjectGroupCallback shared.CreateProjectGroupCallback ConvertProjectCallback ConvertProjectToConfigAsCodeCallback + GetAllTagsCallback GetAllTagSetsCallback } func NewCreateOptions(createFlags *CreateFlags, dependencies *cmd.Dependencies) *CreateOptions { @@ -34,6 +39,7 @@ func NewCreateOptions(createFlags *CreateFlags, dependencies *cmd.Dependencies) GetAllGroupsCallback: func() ([]*projectgroups.ProjectGroup, error) { return shared.GetAllGroups(*dependencies.Client) }, CreateProjectGroupCallback: func() (string, cmd.Dependable, error) { return shared.CreateProjectGroup(dependencies) }, ConvertProjectCallback: func() (cmd.Dependable, error) { return convertProjectCallback(convertOptions) }, + GetAllTagsCallback: getAllTagSetsCallback(dependencies.Client), } } @@ -57,6 +63,7 @@ func (co *CreateOptions) Commit() error { project := projects.NewProject(co.Name.Value, lifecycle.GetID(), projectGroup.GetID()) project.Description = co.Description.Value + project.ProjectTags = co.Tag.Value createdProject, err := co.Client.Projects.Add(project) if err != nil { @@ -76,7 +83,20 @@ func (co *CreateOptions) Commit() error { func (co *CreateOptions) GenerateAutomationCmd() { if !co.NoPrompt { - autoCmd := flag.GenerateAutomationCmd(co.CmdPath, co.Name, co.Description, co.Group, co.Lifecycle) + autoCmd := flag.GenerateAutomationCmd(co.CmdPath, co.Name, co.Description, co.Group, co.Lifecycle, co.Tag) fmt.Fprintf(co.Out, "%s\n", autoCmd) } } + +func getAllTagSetsCallback(client *client.Client) GetAllTagSetsCallback { + return func() ([]*tagsets.TagSet, error) { + query := tagsets.TagSetsQuery{ + Scopes: []string{string(tagsets.TagSetScopeProject)}, + } + result, err := tagsets.Get(client, client.GetSpaceID(), query) + if err != nil { + return nil, err + } + return result.Items, nil + } +} diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go index dbe767a2..ce40f92d 100644 --- a/pkg/cmd/project/list/list.go +++ b/pkg/cmd/project/list/list.go @@ -29,9 +29,10 @@ func NewCmdList(f factory.Factory) *cobra.Command { } type ProjectAsJson struct { - Id string `json:"Id"` - Name string `json:"Name"` - Description string `json:"Description"` + Id string `json:"Id"` + Name string `json:"Name"` + Description string `json:"Description"` + ProjectTags []string `json:"ProjectTags,omitempty"` } func listRun(cmd *cobra.Command, f factory.Factory) error { @@ -51,12 +52,13 @@ func listRun(cmd *cobra.Command, f factory.Factory) error { Id: p.GetID(), Name: p.GetName(), Description: p.Description, + ProjectTags: p.ProjectTags, } }, Table: output.TableDefinition[*projects.Project]{ - Header: []string{"NAME", "DESCRIPTION"}, + Header: []string{"NAME", "DESCRIPTION", "TAGS"}, Row: func(p *projects.Project) []string { - return []string{output.Bold(p.Name), p.Description} + return []string{output.Bold(p.Name), p.Description, output.FormatAsList(p.ProjectTags)} }, }, Basic: func(p *projects.Project) string { diff --git a/pkg/cmd/project/project.go b/pkg/cmd/project/project.go index a0121087..bc825eb1 100644 --- a/pkg/cmd/project/project.go +++ b/pkg/cmd/project/project.go @@ -12,6 +12,7 @@ import ( cmdDisconnect "github.com/OctopusDeploy/cli/pkg/cmd/project/disconnect" cmdEnable "github.com/OctopusDeploy/cli/pkg/cmd/project/enable" cmdList "github.com/OctopusDeploy/cli/pkg/cmd/project/list" + cmdTag "github.com/OctopusDeploy/cli/pkg/cmd/project/tag" cmdVariables "github.com/OctopusDeploy/cli/pkg/cmd/project/variables" cmdView "github.com/OctopusDeploy/cli/pkg/cmd/project/view" "github.com/OctopusDeploy/cli/pkg/constants" @@ -47,6 +48,7 @@ func NewCmdProject(f factory.Factory) *cobra.Command { cmd.AddCommand(cmdVariables.NewCmdVariables(f)) cmd.AddCommand(cmdClone.NewCmdClone(f)) cmd.AddCommand(cmdBranch.NewCmdBranch(f)) + cmd.AddCommand(cmdTag.NewCmdTag(f)) return cmd } diff --git a/pkg/cmd/project/tag/tag.go b/pkg/cmd/project/tag/tag.go new file mode 100644 index 00000000..a00700c5 --- /dev/null +++ b/pkg/cmd/project/tag/tag.go @@ -0,0 +1,148 @@ +package tag + +import ( + "fmt" + "io" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/spf13/cobra" +) + +const ( + FlagTag = "tag" + FlagProject = "project" +) + +type TagFlags struct { + Tag *flag.Flag[[]string] + Project *flag.Flag[string] +} + +func NewTagFlags() *TagFlags { + return &TagFlags{ + Tag: flag.New[[]string](FlagTag, false), + Project: flag.New[string](FlagProject, false), + } +} + +func NewCmdTag(f factory.Factory) *cobra.Command { + createFlags := NewTagFlags() + + cmd := &cobra.Command{ + Use: "tag", + Short: "Override tags for a project", + Long: "Override tags for a project in Octopus Deploy", + Example: heredoc.Docf("$ %s project tag Project-1", constants.ExecutableName), + RunE: func(c *cobra.Command, _ []string) error { + opts := NewTagOptions(createFlags, cmd.NewDependencies(f, c)) + + return createRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringArrayVarP(&createFlags.Tag.Value, createFlags.Tag.Name, "t", []string{}, "Tag to apply to project, must use canonical name: /") + flags.StringVar(&createFlags.Project.Value, createFlags.Project.Name, "", "Name or ID of the project you wish to update") + + return cmd +} + +func createRun(opts *TagOptions) error { + var optsArray []cmd.Dependable + var err error + if !opts.NoPrompt { + optsArray, err = PromptMissing(opts) + if err != nil { + return err + } + } else { + // Validate tags when running with --no-prompt + if len(opts.Tag.Value) > 0 { + tagSets, err := opts.GetAllTagsCallback() + if err != nil { + return err + } + if err := selectors.ValidateTags(opts.Tag.Value, tagSets); err != nil { + return err + } + } + optsArray = append(optsArray, opts) + } + + for _, o := range optsArray { + if err := o.Commit(); err != nil { + return err + } + } + + if !opts.NoPrompt { + fmt.Fprintln(opts.Out, "\nAutomation Commands:") + for _, o := range optsArray { + o.GenerateAutomationCmd() + } + } + + return nil +} + +func PromptMissing(opts *TagOptions) ([]cmd.Dependable, error) { + nestedOpts := []cmd.Dependable{} + + project, err := AskProjects(opts.Ask, opts.Out, opts.Project.Value, opts.GetProjectsCallback, opts.GetProjectCallback) + if err != nil { + return nil, err + } + opts.project = project + opts.Project.Value = project.Name + + tagSets, err := opts.GetAllTagsCallback() + if err != nil { + return nil, err + } + + tags, err := selectors.Tags(opts.Ask, opts.project.ProjectTags, opts.Tag.Value, tagSets) + if err != nil { + return nil, err + } + opts.Tag.Value = tags + + nestedOpts = append(nestedOpts, opts) + return nestedOpts, nil +} + +func AskProjects(ask question.Asker, out io.Writer, value string, getProjectsCallback GetProjectsCallback, getProjectCallback GetProjectCallback) (*projects.Project, error) { + if value != "" { + project, err := getProjectCallback(value) + if err != nil { + return nil, err + } + return project, nil + } + + // Check if there's only one project + projs, err := getProjectsCallback() + if err != nil { + return nil, err + } + if len(projs) == 1 { + fmt.Fprintf(out, "Selecting only available project '%s'.\n", output.Cyan(projs[0].Name)) + return projs[0], nil + } + + project, err := selectors.Select(ask, "Select the Project you would like to update", getProjectsCallback, func(item *projects.Project) string { + return item.Name + }) + if err != nil { + return nil, err + } + + return project, nil +} diff --git a/pkg/cmd/project/tag/tag_opts.go b/pkg/cmd/project/tag/tag_opts.go new file mode 100644 index 00000000..1d58fe2a --- /dev/null +++ b/pkg/cmd/project/tag/tag_opts.go @@ -0,0 +1,116 @@ +package tag + +import ( + "fmt" + "strings" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tagsets" + + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/util/flag" +) + +type GetAllTagSetsCallback func() ([]*tagsets.TagSet, error) +type GetProjectCallback func(projectIdentifier string) (*projects.Project, error) +type GetProjectsCallback func() ([]*projects.Project, error) + +type TagOptions struct { + *TagFlags + *cmd.Dependencies + GetAllTagsCallback GetAllTagSetsCallback + GetProjectCallback GetProjectCallback + GetProjectsCallback GetProjectsCallback + project *projects.Project +} + +func NewTagOptions(tagFlags *TagFlags, dependencies *cmd.Dependencies) *TagOptions { + return &TagOptions{ + TagFlags: tagFlags, + Dependencies: dependencies, + GetAllTagsCallback: getAllTagSetsCallback(dependencies.Client), + GetProjectCallback: getProjectCallback(dependencies.Client), + GetProjectsCallback: getProjectsCallback(dependencies.Client), + project: nil, + } +} + +func (to *TagOptions) Commit() error { + if to.project == nil { + project, err := to.GetProjectCallback(to.Project.Value) + if err != nil { + return err + } + to.project = project + } + + to.project.ProjectTags = to.Tag.Value + + updatedProject, err := to.Client.Projects.Update(to.project) + if err != nil { + return err + } + + _, err = fmt.Fprintf(to.Out, "\nSuccessfully updated project %s (%s).\n", updatedProject.Name, updatedProject.ID) + if err != nil { + return err + } + return nil +} + +func (to *TagOptions) GenerateAutomationCmd() { + if !to.NoPrompt { + autoCmd := flag.GenerateAutomationCmd(to.CmdPath, to.Project, to.Tag) + fmt.Fprintf(to.Out, "%s\n", autoCmd) + } +} + +func getProjectCallback(client *client.Client) GetProjectCallback { + return func(projectIdentifier string) (*projects.Project, error) { + // Try to get by ID first + project, _ := projects.GetByID(client, client.GetSpaceID(), projectIdentifier) + if project != nil { + return project, nil + } + + // Fall back to lookup by name + allProjects, err := projects.Get(client, client.GetSpaceID(), projects.ProjectsQuery{ + PartialName: projectIdentifier, + }) + if err != nil { + return nil, err + } + + for _, proj := range allProjects.Items { + if strings.EqualFold(proj.Name, projectIdentifier) { + return proj, nil + } + } + + return nil, fmt.Errorf("project '%s' not found", projectIdentifier) + } +} + +func getProjectsCallback(client *client.Client) GetProjectsCallback { + return func() ([]*projects.Project, error) { + allProjects, err := projects.GetAll(client, client.GetSpaceID()) + if err != nil { + return nil, err + } + return allProjects, nil + } +} + +func getAllTagSetsCallback(client *client.Client) GetAllTagSetsCallback { + return func() ([]*tagsets.TagSet, error) { + query := tagsets.TagSetsQuery{ + Scopes: []string{string(tagsets.TagSetScopeProject)}, + } + result, err := tagsets.Get(client, client.GetSpaceID(), query) + if err != nil { + return nil, err + } + return result.Items, nil + } +} diff --git a/pkg/cmd/project/view/view.go b/pkg/cmd/project/view/view.go index 6ff00add..b3e5dd3d 100644 --- a/pkg/cmd/project/view/view.go +++ b/pkg/cmd/project/view/view.go @@ -99,11 +99,12 @@ func viewRun(opts *ViewOptions) error { Description: p.Description, IsVersionControlled: p.IsVersionControlled, VersionControlBranch: cacBranch, + ProjectTags: p.ProjectTags, WebUrl: util.GenerateWebURL(opts.Host, p.SpaceID, fmt.Sprintf("projects/%s", p.GetID())), } }, Table: output.TableDefinition[*projects.Project]{ - Header: []string{"NAME", "SLUG", "DESCRIPTION", "VERSION CONTROL", "WEB URL"}, + Header: []string{"NAME", "SLUG", "DESCRIPTION", "VERSION CONTROL", "TAGS", "WEB URL"}, Row: func(p *projects.Project) []string { description := p.Description if description == "" { @@ -120,6 +121,7 @@ func viewRun(opts *ViewOptions) error { p.Slug, description, cacBranch, + output.FormatAsList(p.ProjectTags), output.Blue(util.GenerateWebURL(opts.Host, p.SpaceID, fmt.Sprintf("projects/%s", p.GetID()))), } }, @@ -131,13 +133,14 @@ func viewRun(opts *ViewOptions) error { } 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"` - 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"` } func formatProjectForBasic(opts *ViewOptions, project *projects.Project) string { @@ -153,6 +156,11 @@ func formatProjectForBasic(opts *ViewOptions, project *projects.Project) string } result.WriteString(fmt.Sprintf("Version control branch: %s\n", output.Cyan(cacBranch))) + // tags + if len(project.ProjectTags) > 0 { + result.WriteString(fmt.Sprintf("Tags: %s\n", output.Cyan(output.FormatAsList(project.ProjectTags)))) + } + // description if project.Description == "" { result.WriteString(fmt.Sprintln(output.Dim(constants.NoDescription)))