Skip to content

feat: Implement azdo team update command #218

@tmeckel

Description

@tmeckel

Command Description

Update a team's name and/or description in a project. Mirrors az devops team update from the Python extension. The team is identified by its name or GUID inside the project; at least one of --name or --description must be provided. The updated team is returned and rendered as a single-object table (or full JSON).

REST endpoint:

PATCH {organization}/{projectId}/_apis/teams/{teamId}?api-version=7.1-preview.3

Locked Decisions (do not re-derive)

# Decision Rationale
1 Use the vendored core.Client.UpdateTeam SDK method (vendor/.../core/client.go:880); do not hand-roll HTTP. SDK accepts the WebApiTeam payload, ProjectId route value, and TeamId.
2 Mock for UpdateTeam already exists at internal/mocks/core_client_mock.go:416-428; no new mock generation required. Confirmed via grep.
3 Resolve client via ctx.ClientFactory().Core(ctx.Context(), scope.Organization). Matches internal/cmd/project/create/create.go:122.
4 Positional argument is [ORGANIZATION/]PROJECT/TEAM_ID_OR_NAME (org optional, project required, team required) via util.ParseProjectTargetWithDefaultOrganization (defined in internal/cmd/util/scope.go:183-188). Mirrors az devops team update --team TEAM --project PROJECT.
5 Require at least one of --name or --description; reject with util.FlagErrorf if both are unset. Mirrors Python az devops team update (knack marks both flags as mutually-exclusive-or-mandatory).
6 Build the core.WebApiTeam payload from opts.name (or empty if only --description is set) and opts.description (or empty if only --name is set); never send a fully-empty payload. The REST PATCH accepts partial bodies; the SDK marshals the whole struct.
7 When opts.exporter != nil, emit the raw *core.WebApiTeam to JSON. WebApiTeam already carries JSON tags.
8 Default table columns: ID, NAME, DESCRIPTION, PROJECT, URL. Mirrors az devops team update table output.
9 No confirmation prompt (update is not destructive). No --yes flag.

Command Signature

azdo team update [ORGANIZATION/]PROJECT/TEAM_ID_OR_NAME [--name NEW_NAME] [--description NEW_DESCRIPTION] [flags]

Aliases:
  u

Flags (mapped to SDK/REST)

Flag Maps to Notes
--name string WebApiTeam.Name New team name.
--description string WebApiTeam.Description New team description.
--json / --jq / --template util.AddJSONFlags registration JSON fields list matches the WebApiTeam struct tags.

Pre-run validation: at least one of --name or --description must be set. Reject otherwise.

JSON Output Contract

util.AddJSONFlags(cmd, &opts.exporter,
    "id", "name", "description", "url",
    "identity", "identityUrl", "projectId", "projectName",
)

When opts.exporter != nil, emit the *core.WebApiTeam returned by UpdateTeam. Do not introduce a view struct.

Command Wiring

Code Skeleton (canonical, copy verbatim)

§1. updateOptions struct

type updateOptions struct {
	targetArg  string
	name       string
	description string
	exporter   util.Exporter
}

§2. NewCmd shape (no surprises)

func NewCmd(ctx util.CmdContext) *cobra.Command {
	opts := &updateOptions{}

	cmd := &cobra.Command{
		Use:   "update [ORGANIZATION/]PROJECT/TEAM_ID_OR_NAME",
		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,
		"id", "name", "description", "url",
		"identity", "identityUrl", "projectId", "projectName",
	)

	return cmd
}

§3. runUpdate skeleton

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.Target,
	})
	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).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()
}

§4. Test fixture (copy from internal/cmd/boards/workitem/list/list_test.go:765-844 + a Core-client stub)
Add a setupCoreFakeDeps(t, organization) variant that stubs MockCoreClient.UpdateTeam with a *core.WebApiTeam payload. Reuse the gomock patterns from internal/cmd/boards/workitem/list/list_test.go:765-844. The Core mock is internal/mocks/core_client_mock.go (see MockCoreClient).

API Surface

  • core.Client.UpdateTeam(ctx, UpdateTeamArgs) (*WebApiTeam, error) — see vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/core/client.go:880.
  • UpdateTeamArgs — see vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/core/client.go:910. Fields: TeamData *WebApiTeam (required), ProjectId *string (required), TeamId *string (required).
  • WebApiTeam model — see vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/core/models.go:466-481.
  • Mock: internal/mocks/core_client_mock.go:416-428 (MockCoreClient.UpdateTeam).
  • Client factory: internal/azdo/connection.go:55 (ClientFactory.Core).

Implementation Approach (TDD, reuse-first, minimal)

Phase 1 — RED (tests first).

  1. internal/cmd/team/update/update_test.go:
    • TestUpdate_RequiresNameOrDescription — set neither flag; expect util.FlagErrorf from PreRunE.
    • TestUpdate_MissingTeamArg — zero positional args; expect util.FlagErrorf from ExactArgs.
    • TestUpdate_TargetArg_ParsesOrgSlashProjectSlashTeam"myOrg/myProject/My Team"; assert args.ProjectId == "myProject", args.TeamId == "My Team", and the org used in ClientFactory.Core(...) is "myOrg".
    • TestUpdate_DefaultsToConfiguredOrganization"myProject/My Team"; assert ClientFactory.Core was called with the default org from config.
    • TestUpdate_PayloadContainsNameOnly — set --name NewName; assert the recorded UpdateTeamArgs.TeamData.Name == "NewName".
    • TestUpdate_PayloadContainsDescriptionOnly — set --description NewDesc; assert the recorded UpdateTeamArgs.TeamData.Description == "NewDesc".
    • TestUpdate_PayloadContainsBoth — set --name N --description D; assert both fields are set.
    • TestUpdate_JSONOutput — set --json id,name,description; assert the JSON contains only those keys.
    • TestUpdate_PropagatesSDKErrorMockCoreClient.UpdateTeam returns an error; assert the error is wrapped and returned to the caller.

Phase 2 — GREEN (minimal implementation).

  • Implement update.go per the §3 skeleton; no new helpers outside what is sketched.
  • Reuse util.ParseProjectTargetWithDefaultOrganization, util.FlagErrorWrap, util.AddJSONFlags, types.ToPtr, types.GetValue. No new helpers unless mandated.
  • Keep the file under ~110 LOC.

Tooling and Verification Checklist

  • gofmt -w and goimports -w on the new files.
  • gofumpt -w after the implementation lands (acceptable to lag during drafts).
  • go build ./... — must succeed.
  • go test ./... — must pass.
  • make lint — must pass.
  • make docs — must regenerate without warnings; new flags appear in docs/team_update.md.

Reference Existing Patterns

  • internal/cmd/project/create/create.go:39-106NewCmd shape, util.ParseProjectScope usage, table rendering, JSON output.
  • internal/cmd/boards/workitem/list/list_test.go:765-844setupFakeDeps test fixture (canonical hermetic pattern).
  • internal/mocks/core_client_mock.go:416-428 — pre-existing UpdateTeam mock.
  • internal/azdo/connection.go:55ClientFactory.Core accessor.
  • vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/core/client.go:880UpdateTeam.

References

  • Python implementation: update_team.
  • Python command registration: commands.py#L105.
  • REST: Teams - Update.
  • Vendored SDK: vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/core/client.go:880 (UpdateTeam), :910 (UpdateTeamArgs).
  • Vendored model: vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/core/models.go:466-481 (WebApiTeam).
  • Mock: internal/mocks/core_client_mock.go:416-428 (MockCoreClient.UpdateTeam).
  • Parent tracking: feat: Implement azdo team command group #221 (azdo team command group umbrella).

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions