diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 476a032..4e66d5c 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -282,6 +282,24 @@ Show variable group details -t, --template string Format JSON output using a Go template; see "azdo help formatting" ``` +#### `azdo pipelines variable-group update [ORGANIZATION/]PROJECT/VARIABLE_GROUP_ID_OR_NAME [flags]` + +Update variable group metadata and permissions + +``` + --authorize Grant (true) or remove (false) access permission to all pipelines + --clear-project-references Overwrite existing project references with the provided set; when provided without any --project-reference, removes all references + --clear-provider-data Clear providerData (mutually exclusive with --provider-data-json) + --description string New description (empty string clears it) +-q, --jq expression Filter JSON output using a jq expression + --json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it. + --name string New display name + --project-reference stringArray Project reference to share with (repeatable) + --provider-data-json string Raw JSON payload for providerData +-t, --template string Format JSON output using a Go template; see "azdo help formatting" + --type string Variable group type (e.g., Vsts, AzureKeyVault) +``` + #### `azdo pipelines variable-group variable` Manage variables in a variable group diff --git a/docs/azdo_pipelines_variable-group.md b/docs/azdo_pipelines_variable-group.md index 6b63a08..16e2205 100644 --- a/docs/azdo_pipelines_variable-group.md +++ b/docs/azdo_pipelines_variable-group.md @@ -8,6 +8,7 @@ Manage Azure DevOps variable groups * [azdo pipelines variable-group delete](./azdo_pipelines_variable-group_delete.md) * [azdo pipelines variable-group list](./azdo_pipelines_variable-group_list.md) * [azdo pipelines variable-group show](./azdo_pipelines_variable-group_show.md) +* [azdo pipelines variable-group update](./azdo_pipelines_variable-group_update.md) * [azdo pipelines variable-group variable](./azdo_pipelines_variable-group_variable.md) ### ALIASES diff --git a/docs/azdo_pipelines_variable-group_update.md b/docs/azdo_pipelines_variable-group_update.md new file mode 100644 index 0000000..4f92e2a --- /dev/null +++ b/docs/azdo_pipelines_variable-group_update.md @@ -0,0 +1,65 @@ +## Command `azdo pipelines variable-group update` + +``` +azdo pipelines variable-group update [ORGANIZATION/]PROJECT/VARIABLE_GROUP_ID_OR_NAME [flags] +``` + +Update a variable group's metadata (name, description, type, providerData), +manage cross-project sharing, and optionally toggle 'authorize for all pipelines'. + + +### Options + + +* `--authorize` + + Grant (true) or remove (false) access permission to all pipelines + +* `--clear-project-references` + + Overwrite existing project references with the provided set; when provided without any --project-reference, removes all references + +* `--clear-provider-data` + + Clear providerData (mutually exclusive with --provider-data-json) + +* `--description` `string` + + New description (empty string clears it) + +* `-q`, `--jq` `expression` + + Filter JSON output using a jq expression + +* `--json` `fields` + + Output JSON with the specified fields. Prefix a field with '-' to exclude it. + +* `--name` `string` + + New display name + +* `--project-reference` `stringArray` + + Project reference to share with (repeatable) + +* `--provider-data-json` `string` + + Raw JSON payload for providerData + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + +* `--type` `string` + + Variable group type (e.g., Vsts, AzureKeyVault) + + +### JSON Fields + +`createdBy`, `createdOn`, `description`, `id`, `isShared`, `modifiedBy`, `modifiedOn`, `name`, `pipelinePermissions`, `providerData`, `type`, `variableGroupProjectReferences`, `variables` + +### See also + +* [azdo pipelines variable-group](./azdo_pipelines_variable-group.md) diff --git a/internal/cmd/pipelines/variablegroup/update/update.go b/internal/cmd/pipelines/variablegroup/update/update.go new file mode 100644 index 0000000..36c32ce --- /dev/null +++ b/internal/cmd/pipelines/variablegroup/update/update.go @@ -0,0 +1,258 @@ +package update + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" + "github.com/spf13/cobra" + + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/variablegroup/shared" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +const variableGroupResourceType = "variablegroup" + +type opts struct { + targetArg string + + name string + nameChanged bool + description string + descriptionChanged bool + vgType string + vgTypeChanged bool + providerDataJSON string + providerDataJSONChanged bool + clearProviderData bool + clearProviderDataChanged bool + + projectReferences []string + projectReferencesChanged bool + clearProjectReferences bool + clearProjectReferencesChanged bool + + authorize bool + authorizeChanged bool + + exporter util.Exporter +} + +type variableGroupView struct { + *taskagent.VariableGroup + PipelinePermissions *pipelinepermissions.ResourcePipelinePermissions `json:"pipelinePermissions,omitempty"` +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + o := &opts{} + + cmd := &cobra.Command{ + Use: "update [ORGANIZATION/]PROJECT/VARIABLE_GROUP_ID_OR_NAME", + Short: "Update variable group metadata and permissions", + Long: heredoc.Doc(` + Update a variable group's metadata (name, description, type, providerData), + manage cross-project sharing, and optionally toggle 'authorize for all pipelines'. + `), + Args: util.ExactArgs(1, "target argument is required and must be in the form [ORGANIZATION/]PROJECT/VARIABLE_GROUP_ID_OR_NAME"), + RunE: func(cmd *cobra.Command, args []string) error { + o.targetArg = args[0] + + // capture which flags were explicitly set + o.nameChanged = cmd.Flags().Changed("name") + o.descriptionChanged = cmd.Flags().Changed("description") + o.vgTypeChanged = cmd.Flags().Changed("type") + o.providerDataJSONChanged = cmd.Flags().Changed("provider-data-json") + o.clearProviderDataChanged = cmd.Flags().Changed("clear-provider-data") + o.projectReferencesChanged = cmd.Flags().Changed("project-reference") + o.clearProjectReferencesChanged = cmd.Flags().Changed("clear-project-references") + o.authorizeChanged = cmd.Flags().Changed("authorize") + + return run(ctx, o) + }, + } + + cmd.Flags().StringVar(&o.name, "name", "", "New display name") + cmd.Flags().StringVar(&o.description, "description", "", "New description (empty string clears it)") + cmd.Flags().StringVar(&o.vgType, "type", "", "Variable group type (e.g., Vsts, AzureKeyVault)") + cmd.Flags().StringVar(&o.providerDataJSON, "provider-data-json", "", "Raw JSON payload for providerData") + cmd.Flags().BoolVar(&o.clearProviderData, "clear-provider-data", false, "Clear providerData (mutually exclusive with --provider-data-json)") + + cmd.Flags().StringArrayVar(&o.projectReferences, "project-reference", nil, "Project reference to share with (repeatable)") + cmd.Flags().BoolVar(&o.clearProjectReferences, "clear-project-references", false, "Overwrite existing project references with the provided set; when provided without any --project-reference, removes all references") + + cmd.Flags().BoolVar(&o.authorize, "authorize", false, "Grant (true) or remove (false) access permission to all pipelines") + + util.AddJSONFlags(cmd, &o.exporter, []string{ + "id", + "name", + "type", + "description", + "isShared", + "variables", + "variableGroupProjectReferences", + "providerData", + "createdBy", + "createdOn", + "modifiedBy", + "modifiedOn", + "pipelinePermissions", + }) + + return cmd +} + +func run(cmdCtx util.CmdContext, o *opts) error { + ios, err := cmdCtx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParseProjectTargetWithDefaultOrganization(cmdCtx, o.targetArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + // validate mutual exclusivity + if o.providerDataJSONChanged && o.clearProviderDataChanged { + return util.FlagErrorf("--provider-data-json and --clear-provider-data are mutually exclusive") + } + + // Determine whether any model fields will be changed + willUpdateGroup := o.nameChanged || o.descriptionChanged || o.vgTypeChanged || o.providerDataJSONChanged || o.clearProviderDataChanged || o.projectReferencesChanged || o.clearProjectReferencesChanged + if !willUpdateGroup && !o.authorizeChanged { + return util.FlagErrorf("at least one mutating flag must be supplied") + } + + taskClient, err := cmdCtx.ClientFactory().TaskAgent(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create task agent client: %w", err) + } + + // resolve variable group + group, err := shared.ResolveVariableGroup(cmdCtx, taskClient, scope.Project, scope.Target) + if err != nil { + return err + } + if group == nil || group.Id == nil { + return fmt.Errorf("variable group %q not found", scope.Target) + } + + var updatedGroup *taskagent.VariableGroup + + // If we need to update the variable group model + if willUpdateGroup { + params := taskagent.VariableGroupParameters{} + + if o.nameChanged { + params.Name = types.ToPtr(o.name) + } + if o.descriptionChanged { + params.Description = types.ToPtr(o.description) + } + if o.vgTypeChanged { + params.Type = types.ToPtr(o.vgType) + } + if o.providerDataJSONChanged { + // parse JSON + var pd interface{} + if err := json.Unmarshal([]byte(o.providerDataJSON), &pd); err != nil { + return util.FlagErrorWrap(fmt.Errorf("invalid provider-data-json: %w", err)) + } + params.ProviderData = pd + } + if o.clearProviderDataChanged && o.clearProviderData { + params.ProviderData = nil + } + + if o.projectReferencesChanged || o.clearProjectReferencesChanged { + // build project references if provided + if o.projectReferencesChanged && len(o.projectReferences) > 0 { + refs := make([]taskagent.VariableGroupProjectReference, 0, len(o.projectReferences)) + for _, p := range o.projectReferences { + pr := taskagent.VariableGroupProjectReference{ + ProjectReference: &taskagent.ProjectReference{Name: types.ToPtr(p)}, + } + refs = append(refs, pr) + } + params.VariableGroupProjectReferences = &refs + } else if o.clearProjectReferencesChanged && o.clearProjectReferences && !o.projectReferencesChanged { + // explicit clear -> empty slice + refs := make([]taskagent.VariableGroupProjectReference, 0) + params.VariableGroupProjectReferences = &refs + } else if o.clearProjectReferencesChanged && o.clearProjectReferences && o.projectReferencesChanged && len(o.projectReferences) == 0 { + // clear provided without any --project-reference -> remove all + refs := make([]taskagent.VariableGroupProjectReference, 0) + params.VariableGroupProjectReferences = &refs + } + } + + // call UpdateVariableGroup + updated, err := taskClient.UpdateVariableGroup(cmdCtx.Context(), taskagent.UpdateVariableGroupArgs{ + VariableGroupParameters: ¶ms, + GroupId: types.ToPtr(*group.Id), + }) + if err != nil { + return err + } + updatedGroup = updated + } else { + // no model changes, keep original + updatedGroup = group + } + + // If authorize changed, call pipeline permissions API + var perms *pipelinepermissions.ResourcePipelinePermissions + if o.authorizeChanged { + permClient, err := cmdCtx.ClientFactory().PipelinePermissions(cmdCtx.Context(), scope.Organization) + if err != nil { + return err + } + + desired := &pipelinepermissions.Permission{Authorized: types.ToPtr(o.authorize)} + rp := pipelinepermissions.ResourcePipelinePermissions{AllPipelines: desired} + updatedPerms, err := permClient.UpdatePipelinePermisionsForResource(cmdCtx.Context(), pipelinepermissions.UpdatePipelinePermisionsForResourceArgs{ + ResourceAuthorization: &rp, + Project: types.ToPtr(scope.Project), + ResourceType: types.ToPtr(variableGroupResourceType), + ResourceId: types.ToPtr(strconv.Itoa(*group.Id)), + }) + if err != nil { + return err + } + perms = updatedPerms + } else { + // fetch existing perms to include in output + permClient, err := cmdCtx.ClientFactory().PipelinePermissions(cmdCtx.Context(), scope.Organization) + if err == nil { + p, _ := permClient.GetPipelinePermissionsForResource(cmdCtx.Context(), pipelinepermissions.GetPipelinePermissionsForResourceArgs{ + Project: types.ToPtr(scope.Project), + ResourceType: types.ToPtr(variableGroupResourceType), + ResourceId: types.ToPtr(strconv.Itoa(*group.Id)), + }) + perms = p + } + } + + ios.StopProgressIndicator() + + view := variableGroupView{VariableGroup: updatedGroup, PipelinePermissions: perms} + + if o.exporter != nil { + return o.exporter.Write(ios, view) + } + + // simple textual output + fmt.Fprintf(ios.Out, "Updated variable group %q (id: %d)\n", types.GetValue(updatedGroup.Name, ""), types.GetValue(updatedGroup.Id, 0)) + if perms != nil && perms.AllPipelines != nil && perms.AllPipelines.Authorized != nil { + fmt.Fprintf(ios.Out, "Authorize for all pipelines: %v\n", *perms.AllPipelines.Authorized) + } + + return nil +} diff --git a/internal/cmd/pipelines/variablegroup/update/update_test.go b/internal/cmd/pipelines/variablegroup/update/update_test.go new file mode 100644 index 0000000..0e593fd --- /dev/null +++ b/internal/cmd/pipelines/variablegroup/update/update_test.go @@ -0,0 +1,297 @@ +package update_test + +import ( + "context" + "errors" + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + updatecmd "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/variablegroup/update" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/types" + "go.uber.org/mock/gomock" +) + +func TestUpdateCmd_SuccessCases(t *testing.T) { + tests := []struct { + name string + args []string + setupMocks func(t *testing.T, taskClient *mocks.MockTaskAgentClient, permClient *mocks.MockPipelinePermissionsClient, clientFactory *mocks.MockClientFactory) + wantOut string + wantErrOut string + }{ + { + name: "updates name and description", + args: []string{"org/project/123", "--name", "new name", "--description", "new desc"}, + setupMocks: func(t *testing.T, taskClient *mocks.MockTaskAgentClient, permClient *mocks.MockPipelinePermissionsClient, clientFactory *mocks.MockClientFactory) { + clientFactory.EXPECT().TaskAgent(gomock.Any(), "org").Return(taskClient, nil) + taskClient.EXPECT().GetVariableGroupsById(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args taskagent.GetVariableGroupsByIdArgs) (*[]taskagent.VariableGroup, error) { + require.NotNil(t, args.Project) + assert.Equal(t, "project", *args.Project) + require.NotNil(t, args.GroupIds) + require.Len(t, *args.GroupIds, 1) + assert.Equal(t, 123, (*args.GroupIds)[0]) + return &[]taskagent.VariableGroup{{ + Id: types.ToPtr(123), + Name: types.ToPtr("old name"), + Description: types.ToPtr("old desc"), + }}, nil + }, + ) + taskClient.EXPECT().UpdateVariableGroup(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args taskagent.UpdateVariableGroupArgs) (*taskagent.VariableGroup, error) { + require.NotNil(t, args.GroupId) + assert.Equal(t, 123, *args.GroupId) + require.NotNil(t, args.VariableGroupParameters) + assert.Equal(t, "new name", types.GetValue(args.VariableGroupParameters.Name, "")) + assert.Equal(t, "new desc", types.GetValue(args.VariableGroupParameters.Description, "")) + return &taskagent.VariableGroup{ + Id: types.ToPtr(123), + Name: types.ToPtr("new name"), + Description: types.ToPtr("new desc"), + }, nil + }, + ) + + clientFactory.EXPECT().PipelinePermissions(gomock.Any(), "org").Return(permClient, nil) + permClient.EXPECT().GetPipelinePermissionsForResource(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args pipelinepermissions.GetPipelinePermissionsForResourceArgs) (*pipelinepermissions.ResourcePipelinePermissions, error) { + require.NotNil(t, args.Project) + assert.Equal(t, "project", *args.Project) + require.NotNil(t, args.ResourceType) + assert.Equal(t, "variablegroup", *args.ResourceType) + require.NotNil(t, args.ResourceId) + assert.Equal(t, "123", *args.ResourceId) + return nil, nil + }, + ) + }, + wantOut: "Updated variable group \"new name\" (id: 123)\n", + wantErrOut: "", + }, + { + name: "authorize only", + args: []string{"org/project/123", "--authorize"}, + setupMocks: func(t *testing.T, taskClient *mocks.MockTaskAgentClient, permClient *mocks.MockPipelinePermissionsClient, clientFactory *mocks.MockClientFactory) { + clientFactory.EXPECT().TaskAgent(gomock.Any(), "org").Return(taskClient, nil) + taskClient.EXPECT().GetVariableGroupsById(gomock.Any(), gomock.Any()).Return( + &[]taskagent.VariableGroup{{ + Id: types.ToPtr(123), + Name: types.ToPtr("group-one"), + }}, nil, + ) + taskClient.EXPECT().UpdateVariableGroup(gomock.Any(), gomock.Any()).Times(0) + + clientFactory.EXPECT().PipelinePermissions(gomock.Any(), "org").Return(permClient, nil) + permClient.EXPECT().UpdatePipelinePermisionsForResource(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args pipelinepermissions.UpdatePipelinePermisionsForResourceArgs) (*pipelinepermissions.ResourcePipelinePermissions, error) { + require.NotNil(t, args.ResourceAuthorization) + require.NotNil(t, args.ResourceAuthorization.AllPipelines) + assert.Equal(t, true, types.GetValue(args.ResourceAuthorization.AllPipelines.Authorized, false)) + require.NotNil(t, args.ResourceId) + assert.Equal(t, "123", *args.ResourceId) + require.NotNil(t, args.Project) + assert.Equal(t, "project", *args.Project) + return &pipelinepermissions.ResourcePipelinePermissions{ + AllPipelines: &pipelinepermissions.Permission{Authorized: types.ToPtr(true)}, + }, nil + }, + ) + }, + wantOut: "Updated variable group \"group-one\" (id: 123)\n" + + "Authorize for all pipelines: true\n", + wantErrOut: "", + }, + { + name: "clears project references", + args: []string{"org/project/123", "--clear-project-references"}, + setupMocks: func(t *testing.T, taskClient *mocks.MockTaskAgentClient, permClient *mocks.MockPipelinePermissionsClient, clientFactory *mocks.MockClientFactory) { + clientFactory.EXPECT().TaskAgent(gomock.Any(), "org").Return(taskClient, nil) + taskClient.EXPECT().GetVariableGroupsById(gomock.Any(), gomock.Any()).Return( + &[]taskagent.VariableGroup{{ + Id: types.ToPtr(123), + Name: types.ToPtr("group-two"), + }}, nil, + ) + taskClient.EXPECT().UpdateVariableGroup(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args taskagent.UpdateVariableGroupArgs) (*taskagent.VariableGroup, error) { + require.NotNil(t, args.VariableGroupParameters) + require.NotNil(t, args.VariableGroupParameters.VariableGroupProjectReferences) + assert.Len(t, *args.VariableGroupParameters.VariableGroupProjectReferences, 0) + return &taskagent.VariableGroup{ + Id: types.ToPtr(123), + Name: types.ToPtr("group-two"), + }, nil + }, + ) + + clientFactory.EXPECT().PipelinePermissions(gomock.Any(), "org").Return(permClient, nil) + permClient.EXPECT().GetPipelinePermissionsForResource(gomock.Any(), gomock.Any()).Return(nil, nil) + }, + wantOut: "Updated variable group \"group-two\" (id: 123)\n", + wantErrOut: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, outBuf, errBuf := iostreams.Test() + + cmdCtx := mocks.NewMockCmdContext(ctrl) + clientFactory := mocks.NewMockClientFactory(ctrl) + taskClient := mocks.NewMockTaskAgentClient(ctrl) + permClient := mocks.NewMockPipelinePermissionsClient(ctrl) + + cmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + cmdCtx.EXPECT().ClientFactory().Return(clientFactory).AnyTimes() + + tt.setupMocks(t, taskClient, permClient, clientFactory) + + cmd := updatecmd.NewCmd(cmdCtx) + cmd.SetArgs(tt.args) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + assert.Equal(t, tt.wantOut, outBuf.String()) + assert.Equal(t, tt.wantErrOut, errBuf.String()) + }) + } +} + +func TestUpdateCmd_ValidationErrors(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + setupMocks func(t *testing.T, cmdCtx *mocks.MockCmdContext, clientFactory *mocks.MockClientFactory, taskClient *mocks.MockTaskAgentClient) + }{ + { + name: "provider data flags mutually exclusive", + args: []string{"org/project/123", "--provider-data-json", "{}", "--clear-provider-data"}, + wantErr: "--provider-data-json and --clear-provider-data are mutually exclusive", + }, + { + name: "requires mutating flag", + args: []string{"org/project/123"}, + wantErr: "at least one mutating flag must be supplied", + }, + { + name: "invalid provider data json", + args: []string{"org/project/123", "--provider-data-json", "not-json"}, + wantErr: "invalid provider-data-json", + setupMocks: func(t *testing.T, cmdCtx *mocks.MockCmdContext, clientFactory *mocks.MockClientFactory, taskClient *mocks.MockTaskAgentClient) { + cmdCtx.EXPECT().ClientFactory().Return(clientFactory).AnyTimes() + clientFactory.EXPECT().TaskAgent(gomock.Any(), "org").Return(taskClient, nil) + taskClient.EXPECT().GetVariableGroupsById(gomock.Any(), gomock.Any()).Return(&[]taskagent.VariableGroup{{ + Id: types.ToPtr(123), + Name: types.ToPtr("group-one"), + }}, nil) + taskClient.EXPECT().UpdateVariableGroup(gomock.Any(), gomock.Any()).Times(0) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, outBuf, errBuf := iostreams.Test() + + cmdCtx := mocks.NewMockCmdContext(ctrl) + clientFactory := mocks.NewMockClientFactory(ctrl) + taskClient := mocks.NewMockTaskAgentClient(ctrl) + cmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + if tt.setupMocks != nil { + tt.setupMocks(t, cmdCtx, clientFactory, taskClient) + } + + cmd := updatecmd.NewCmd(cmdCtx) + cmd.SetArgs(tt.args) + + _, err := cmd.ExecuteC() + require.Error(t, err) + var flagErr *util.FlagError + assert.True(t, errors.As(err, &flagErr)) + assert.Contains(t, err.Error(), tt.wantErr) + assert.Equal(t, "", outBuf.String()) + // errBuf may contain Cobra usage; ensure it is not empty only when usage printed + _ = errBuf + }) + } +} + +func TestUpdateCmd_VariableGroupNotFound(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, outBuf, errBuf := iostreams.Test() + + cmdCtx := mocks.NewMockCmdContext(ctrl) + clientFactory := mocks.NewMockClientFactory(ctrl) + taskClient := mocks.NewMockTaskAgentClient(ctrl) + + cmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + cmdCtx.EXPECT().ClientFactory().Return(clientFactory).AnyTimes() + + clientFactory.EXPECT().TaskAgent(gomock.Any(), "org").Return(taskClient, nil) + taskClient.EXPECT().GetVariableGroupsById(gomock.Any(), gomock.Any()).Return(&[]taskagent.VariableGroup{}, nil) + + cmd := updatecmd.NewCmd(cmdCtx) + cmd.SetArgs([]string{"org/project/123", "--name", "new"}) + + _, err := cmd.ExecuteC() + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + assert.Equal(t, "", outBuf.String()) + _ = errBuf +} + +func TestUpdateCmd_AuthorizeFails(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, outBuf, errBuf := iostreams.Test() + + cmdCtx := mocks.NewMockCmdContext(ctrl) + clientFactory := mocks.NewMockClientFactory(ctrl) + taskClient := mocks.NewMockTaskAgentClient(ctrl) + permClient := mocks.NewMockPipelinePermissionsClient(ctrl) + + cmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + cmdCtx.EXPECT().ClientFactory().Return(clientFactory).AnyTimes() + + clientFactory.EXPECT().TaskAgent(gomock.Any(), "org").Return(taskClient, nil) + taskClient.EXPECT().GetVariableGroupsById(gomock.Any(), gomock.Any()).Return( + &[]taskagent.VariableGroup{{ + Id: types.ToPtr(123), + Name: types.ToPtr("group-one"), + }}, nil, + ) + taskClient.EXPECT().UpdateVariableGroup(gomock.Any(), gomock.Any()).Times(0) + + clientFactory.EXPECT().PipelinePermissions(gomock.Any(), "org").Return(permClient, nil) + permClient.EXPECT().UpdatePipelinePermisionsForResource(gomock.Any(), gomock.Any()).Return(nil, errors.New("perm failure")) + + cmd := updatecmd.NewCmd(cmdCtx) + cmd.SetArgs([]string{"org/project/123", "--authorize"}) + + _, err := cmd.ExecuteC() + require.Error(t, err) + assert.Contains(t, err.Error(), "perm failure") + assert.Equal(t, "", outBuf.String()) + _ = errBuf +} diff --git a/internal/cmd/pipelines/variablegroup/variablegroup.go b/internal/cmd/pipelines/variablegroup/variablegroup.go index 6e01c6a..5425283 100644 --- a/internal/cmd/pipelines/variablegroup/variablegroup.go +++ b/internal/cmd/pipelines/variablegroup/variablegroup.go @@ -6,6 +6,7 @@ import ( "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/variablegroup/delete" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/variablegroup/list" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/variablegroup/show" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/variablegroup/update" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/variablegroup/variable" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -26,6 +27,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(create.NewCmd(ctx)) cmd.AddCommand(delete.NewCmd(ctx)) cmd.AddCommand(show.NewCmd(ctx)) + cmd.AddCommand(update.NewCmd(ctx)) cmd.AddCommand(variable.NewCmd(ctx)) return cmd }