From 8ebf99fa1597a3699916d7eea6110776848569ed Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 16:35:56 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(team):=20=E2=9C=A8add=20update=20comma?= =?UTF-8?q?nd=20for=20teams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new `update` subcommand that enables modifying an Azure DevOps team's name and description. The command validates that at least one of `--name` or `--description` is provided, resolves the target team within a project, calls the Core API to apply changes, and renders the updated team details. --- internal/cmd/team/team.go | 2 + internal/cmd/team/update/update.go | 124 +++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 internal/cmd/team/update/update.go diff --git a/internal/cmd/team/team.go b/internal/cmd/team/team.go index 64f5a33..9150bd3 100644 --- a/internal/cmd/team/team.go +++ b/internal/cmd/team/team.go @@ -5,6 +5,7 @@ import ( "github.com/tmeckel/azdo-cli/internal/cmd/team/create" "github.com/tmeckel/azdo-cli/internal/cmd/team/list" "github.com/tmeckel/azdo-cli/internal/cmd/team/show" + "github.com/tmeckel/azdo-cli/internal/cmd/team/update" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -21,6 +22,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(create.NewCmd(ctx)) cmd.AddCommand(list.NewCmd(ctx)) cmd.AddCommand(show.NewCmd(ctx)) + cmd.AddCommand(update.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/team/update/update.go b/internal/cmd/team/update/update.go new file mode 100644 index 0000000..da6de13 --- /dev/null +++ b/internal/cmd/team/update/update.go @@ -0,0 +1,124 @@ +package update + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + "github.com/spf13/cobra" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type updateOptions struct { + targetArg string + name string + description string + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &updateOptions{} + + cmd := &cobra.Command{ + Use: "update [ORGANIZATION/]PROJECT/TEAM", + Short: "Update a team's name and/or description.", + Long: heredoc.Doc(` + Update a team's name and/or description. At least one of --name or + --description must be provided. The team is identified by its name or + GUID inside the project. + `), + Example: heredoc.Doc(` + # Rename a team + azdo team update Fabrikam/"Old Name" --name "New Name" + + # Update a team's description only + azdo team update MyOrg/Fabrikam/MyTeam --description "New description" + `), + Aliases: []string{ + "u", + }, + Args: util.ExactArgs(1, "team argument required"), + PreRunE: func(cmd *cobra.Command, args []string) error { + nameChanged := cmd.Flags().Changed("name") + descChanged := cmd.Flags().Changed("description") + if !nameChanged && !descChanged { + return util.FlagErrorf("at least one of --name or --description is required") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runUpdate(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.name, "name", "", "New name of the team") + cmd.Flags().StringVar(&opts.description, "description", "", "New description of the team") + + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "name", "description", "url", + "identity", "identityUrl", "projectId", "projectName", + }) + + return cmd +} + +func runUpdate(ctx util.CmdContext, opts *updateOptions) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParseProjectTargetWithDefaultOrganization(ctx, opts.targetArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + client, err := ctx.ClientFactory().Core(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create Core client: %w", err) + } + + payload := &core.WebApiTeam{ + Name: types.ToPtr(opts.name), + Description: types.ToPtr(opts.description), + } + + updated, err := client.UpdateTeam(ctx.Context(), core.UpdateTeamArgs{ + TeamData: payload, + ProjectId: &scope.Project, + TeamId: &scope.Targets[0], + }) + if err != nil { + return fmt.Errorf("failed to update team: %w", err) + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, updated) + } + + return renderTeam(ctx, updated) +} + +func renderTeam(ctx util.CmdContext, team *core.WebApiTeam) error { + tp, err := ctx.Printer("list") + if err != nil { + return err + } + tp.AddColumns("ID", "NAME", "DESCRIPTION", "PROJECT", "URL") + tp.EndRow() + tp.AddField(types.GetValue(team.Id, uuid.UUID{}).String()) + tp.AddField(types.GetValue(team.Name, "")) + tp.AddField(types.GetValue(team.Description, "")) + tp.AddField(types.GetValue(team.ProjectName, "")) + tp.AddField(types.GetValue(team.Url, "")) + tp.EndRow() + return tp.Render() +} From 67e653d45b360d73b3c2c444e2416cd3fafa6ba0 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 16:36:20 +0000 Subject: [PATCH 2/3] =?UTF-8?q?test(team):=20=F0=9F=A7=AAadd=20tests=20for?= =?UTF-8?q?=20team=20update=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmd/team/update/update_test.go | 298 ++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 internal/cmd/team/update/update_test.go diff --git a/internal/cmd/team/update/update_test.go b/internal/cmd/team/update/update_test.go new file mode 100644 index 0000000..c7fc49e --- /dev/null +++ b/internal/cmd/team/update/update_test.go @@ -0,0 +1,298 @@ +package update + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/printer" + "github.com/tmeckel/azdo-cli/internal/types" + "go.uber.org/mock/gomock" +) + +func TestUpdate_RequiresNameOrDescription(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockCmdCtx := mocks.NewMockCmdContext(ctrl) + + cmd := NewCmd(mockCmdCtx) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least one of --name or --description is required") +} + +func TestUpdate_MissingTeamArg(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockCmdCtx := mocks.NewMockCmdContext(ctrl) + + cmd := NewCmd(mockCmdCtx) + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "team argument required") +} + +type fakeUpdateDeps struct { + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + core *mocks.MockCoreClient + config *mocks.MockConfig + authCfg *mocks.MockAuthConfig + stdout *bytes.Buffer +} + +func setupFakeUpdateDeps(t *testing.T, organization string) *fakeUpdateDeps { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + deps := &fakeUpdateDeps{ + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + core: mocks.NewMockCoreClient(ctrl), + config: mocks.NewMockConfig(ctrl), + authCfg: mocks.NewMockAuthConfig(ctrl), + stdout: out, + } + + deps.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + deps.clientFact.EXPECT().Core(gomock.Any(), organization).Return(deps.core, nil).AnyTimes() + + tp, err := printer.NewListPrinter(out) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + return deps +} + +func TestUpdate_TargetArg_ParsesOrgSlashProjectSlashTeam(t *testing.T) { + deps := setupFakeUpdateDeps(t, "myOrg") + + teamID := uuid.New() + teamName := "UpdatedTeam" + + deps.core.EXPECT().UpdateTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.UpdateTeamArgs) (*core.WebApiTeam, error) { + require.NotNil(t, args.ProjectId) + assert.Equal(t, "myProject", *args.ProjectId) + require.NotNil(t, args.TeamId) + assert.Equal(t, "My Team", *args.TeamId) + return &core.WebApiTeam{ + Id: &teamID, + Name: &teamName, + }, nil + }) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/My Team", "--name", "UpdatedTeam"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestUpdate_DefaultsToConfiguredOrganization(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + mockCmdCtx := mocks.NewMockCmdContext(ctrl) + mockClientFactory := mocks.NewMockClientFactory(ctrl) + mockCoreClient := mocks.NewMockCoreClient(ctrl) + mockConfig := mocks.NewMockConfig(ctrl) + mockAuthCfg := mocks.NewMockAuthConfig(ctrl) + + defaultOrg := "defaultOrg" + + mockCmdCtx.EXPECT().Config().Return(mockConfig, nil).AnyTimes() + mockConfig.EXPECT().Authentication().Return(mockAuthCfg).AnyTimes() + mockAuthCfg.EXPECT().GetDefaultOrganization().Return(defaultOrg, nil).AnyTimes() + mockCmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + mockCmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + mockCmdCtx.EXPECT().ClientFactory().Return(mockClientFactory).AnyTimes() + mockClientFactory.EXPECT().Core(gomock.Any(), defaultOrg).Return(mockCoreClient, nil).AnyTimes() + + teamID := uuid.New() + + mockCoreClient.EXPECT().UpdateTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.UpdateTeamArgs) (*core.WebApiTeam, error) { + require.NotNil(t, args.ProjectId) + assert.Equal(t, "myProject", *args.ProjectId) + return &core.WebApiTeam{ + Id: &teamID, + Name: types.ToPtr("UpdatedTeam"), + }, nil + }) + + tp, err := printer.NewListPrinter(out) + require.NoError(t, err) + mockCmdCtx.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + cmd := NewCmd(mockCmdCtx) + cmd.SetArgs([]string{"myProject/My Team", "--name", "UpdatedTeam"}) + err = cmd.Execute() + require.NoError(t, err) +} + +func TestUpdate_PayloadContainsNameOnly(t *testing.T) { + deps := setupFakeUpdateDeps(t, "myOrg") + + teamID := uuid.New() + + deps.core.EXPECT().UpdateTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.UpdateTeamArgs) (*core.WebApiTeam, error) { + require.NotNil(t, args.TeamData) + require.NotNil(t, args.TeamData.Name) + assert.Equal(t, "NewName", *args.TeamData.Name) + return &core.WebApiTeam{ + Id: &teamID, + Name: types.ToPtr("NewName"), + }, nil + }) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "--name", "NewName"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestUpdate_PayloadContainsDescriptionOnly(t *testing.T) { + deps := setupFakeUpdateDeps(t, "myOrg") + + teamID := uuid.New() + + deps.core.EXPECT().UpdateTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.UpdateTeamArgs) (*core.WebApiTeam, error) { + require.NotNil(t, args.TeamData) + require.NotNil(t, args.TeamData.Description) + assert.Equal(t, "NewDesc", *args.TeamData.Description) + return &core.WebApiTeam{ + Id: &teamID, + Name: types.ToPtr("MyTeam"), + }, nil + }) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "--description", "NewDesc"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestUpdate_PayloadContainsBoth(t *testing.T) { + deps := setupFakeUpdateDeps(t, "myOrg") + + teamID := uuid.New() + + deps.core.EXPECT().UpdateTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.UpdateTeamArgs) (*core.WebApiTeam, error) { + require.NotNil(t, args.TeamData) + require.NotNil(t, args.TeamData.Name) + require.NotNil(t, args.TeamData.Description) + assert.Equal(t, "N", *args.TeamData.Name) + assert.Equal(t, "D", *args.TeamData.Description) + return &core.WebApiTeam{ + Id: &teamID, + Name: types.ToPtr("N"), + }, nil + }) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "--name", "N", "--description", "D"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestUpdate_JSONOutput(t *testing.T) { + deps := setupFakeUpdateDeps(t, "myOrg") + + teamID := uuid.MustParse("11111111-1111-1111-1111-111111111111") + teamName := "MyTeam" + teamURL := "https://dev.azure.com/myOrg/_apis/teams/11111111-1111-1111-1111-111111111111" + + deps.core.EXPECT().UpdateTeam(gomock.Any(), gomock.Any()). + Return(&core.WebApiTeam{ + Id: &teamID, + Name: &teamName, + Url: &teamURL, + }, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "--name", "MyTeam", "--json=id,name,description"}) + err := cmd.Execute() + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(deps.stdout.Bytes(), &parsed)) + assert.Equal(t, "11111111-1111-1111-1111-111111111111", parsed["id"]) + assert.Equal(t, "MyTeam", parsed["name"]) + assert.Equal(t, nil, parsed["description"]) + assert.NotContains(t, parsed, "url") +} + +func TestUpdate_TableOutput_ContainsAllColumns(t *testing.T) { + deps := setupFakeUpdateDeps(t, "myOrg") + + teamID := uuid.MustParse("22222222-2222-2222-2222-222222222222") + teamName := "UpdatedTeam" + teamDesc := "updated desc" + projectName := "myProject" + teamURL := "https://dev.azure.com/myOrg/_apis/teams/22222222-2222-2222-2222-222222222222" + + deps.core.EXPECT().UpdateTeam(gomock.Any(), gomock.Any()). + Return(&core.WebApiTeam{ + Id: &teamID, + Name: &teamName, + Description: &teamDesc, + ProjectName: &projectName, + Url: &teamURL, + }, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "--name", "UpdatedTeam", "--description", "updated desc"}) + err := cmd.Execute() + require.NoError(t, err) + + output := deps.stdout.String() + assert.Contains(t, output, "ID:") + assert.Contains(t, output, "NAME:") + assert.Contains(t, output, "DESCRIPTION:") + assert.Contains(t, output, "PROJECT:") + assert.Contains(t, output, "URL:") + assert.Contains(t, output, "22222222-2222-2222-2222-222222222222") + assert.Contains(t, output, "UpdatedTeam") + assert.Contains(t, output, "updated desc") + assert.Contains(t, output, "myProject") +} + +func TestUpdate_PropagatesSDKError(t *testing.T) { + deps := setupFakeUpdateDeps(t, "myOrg") + + deps.core.EXPECT().UpdateTeam(gomock.Any(), gomock.Any()). + Return(nil, errors.New("API error")) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "--name", "MyTeam"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to update team: API error") +} From 87c47b86857d0f87d737a53be3d6441b407b9cc5 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 16:36:39 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs(team):=20=F0=9F=93=84add=20documentati?= =?UTF-8?q?on=20for=20team=20update=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/azdo_help_reference.md | 18 ++++++++++++ docs/azdo_team.md | 1 + docs/azdo_team_update.md | 56 +++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 docs/azdo_team_update.md diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index b95e7a6..c96c03a 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -1357,6 +1357,24 @@ Aliases s ``` +### `azdo team update [ORGANIZATION/]PROJECT/TEAM [flags]` + +Update a team's name and/or description. + +``` + --description string New description of the team +-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 name of the team +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +u +``` + ### See also diff --git a/docs/azdo_team.md b/docs/azdo_team.md index a99a8f5..7f1b0d4 100644 --- a/docs/azdo_team.md +++ b/docs/azdo_team.md @@ -7,6 +7,7 @@ Manage Azure DevOps teams. * [azdo team create](./azdo_team_create.md) * [azdo team list](./azdo_team_list.md) * [azdo team show](./azdo_team_show.md) +* [azdo team update](./azdo_team_update.md) ### ALIASES diff --git a/docs/azdo_team_update.md b/docs/azdo_team_update.md new file mode 100644 index 0000000..95f3ba3 --- /dev/null +++ b/docs/azdo_team_update.md @@ -0,0 +1,56 @@ +## Command `azdo team update` + +``` +azdo team update [ORGANIZATION/]PROJECT/TEAM [flags] +``` + +Update a team's name and/or description. At least one of --name or +--description must be provided. The team is identified by its name or +GUID inside the project. + + +### Options + + +* `--description` `string` + + New description of the team + +* `-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 name of the team + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `u` + +### JSON Fields + +`description`, `id`, `identity`, `identityUrl`, `name`, `projectId`, `projectName`, `url` + +### Examples + +```bash +# Rename a team +azdo team update Fabrikam/"Old Name" --name "New Name" + +# Update a team's description only +azdo team update MyOrg/Fabrikam/MyTeam --description "New description" +``` + +### See also + +* [azdo team](./azdo_team.md)