From 14bbac87c707d776e22941fbe7e42244dc72b304 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 15:16:21 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(team):=20=E2=9C=A8=20add=20create=20co?= =?UTF-8?q?mmand=20for=20teams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmd/root/root.go | 2 + internal/cmd/team/create/create.go | 121 +++++++++++++++++++++++++++++ internal/cmd/team/team.go | 22 ++++++ 3 files changed, 145 insertions(+) create mode 100644 internal/cmd/team/create/create.go create mode 100644 internal/cmd/team/team.go diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 8f44819..186370d 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -17,6 +17,7 @@ import ( "github.com/tmeckel/azdo-cli/internal/cmd/repo" "github.com/tmeckel/azdo-cli/internal/cmd/security" "github.com/tmeckel/azdo-cli/internal/cmd/serviceendpoint" + "github.com/tmeckel/azdo-cli/internal/cmd/team" "github.com/tmeckel/azdo-cli/internal/cmd/util" versionCmd "github.com/tmeckel/azdo-cli/internal/cmd/version" "github.com/tmeckel/azdo-cli/internal/validation" @@ -97,6 +98,7 @@ func NewCmdRoot(ctx util.CmdContext, version, buildDate string) (*cobra.Command, cmd.AddCommand(serviceendpoint.NewCmd(ctx)) cmd.AddCommand(boards.NewCmd(ctx)) cmd.AddCommand(pipelines.NewCmd(ctx)) + cmd.AddCommand(team.NewCmd(ctx)) // Help topics var referenceCmd *cobra.Command diff --git a/internal/cmd/team/create/create.go b/internal/cmd/team/create/create.go new file mode 100644 index 0000000..218dad9 --- /dev/null +++ b/internal/cmd/team/create/create.go @@ -0,0 +1,121 @@ +package create + +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 createOptions struct { + scopeArg string + name string + description string + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &createOptions{} + + cmd := &cobra.Command{ + Use: "create [ORGANIZATION/]PROJECT", + Short: "Create a new team in a project.", + Long: heredoc.Doc(` + Create a new team in the specified project. The --name flag is required. + The project argument is required; the organization falls back to the + configured default when omitted. + `), + Example: heredoc.Doc(` + # Create a team in the default organization + azdo team create Fabrikam --name "Fabrikam Engineering" + + # Create a team with a description + azdo team create MyOrg/Fabrikam --name "My Team" --description "Owns the web app" + `), + Aliases: []string{ + "c", + "cr", + "new", + "n", + "add", + "a", + }, + Args: util.ExactArgs(1, "project argument required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.scopeArg = args[0] + return runCreate(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.name, "name", "", "Name of the new team (required)") + cmd.Flags().StringVar(&opts.description, "description", "", "Description of the new team") + _ = cmd.MarkFlagRequired("name") + + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "name", "description", "url", + "identity", "identityUrl", "projectId", "projectName", + }) + + return cmd +} + +func runCreate(ctx util.CmdContext, opts *createOptions) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParseProjectScope(ctx, opts.scopeArg) + 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) + } + + team := &core.WebApiTeam{ + Name: types.ToPtr(opts.name), + Description: types.ToPtr(opts.description), + } + + created, err := client.CreateTeam(ctx.Context(), core.CreateTeamArgs{ + Team: team, + ProjectId: &scope.Project, + }) + if err != nil { + return fmt.Errorf("failed to create team: %w", err) + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, created) + } + + return renderTeam(ctx, created) +} + +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() +} diff --git a/internal/cmd/team/team.go b/internal/cmd/team/team.go new file mode 100644 index 0000000..1f303f2 --- /dev/null +++ b/internal/cmd/team/team.go @@ -0,0 +1,22 @@ +package team + +import ( + "github.com/spf13/cobra" + "github.com/tmeckel/azdo-cli/internal/cmd/team/create" + "github.com/tmeckel/azdo-cli/internal/cmd/util" +) + +func NewCmd(ctx util.CmdContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "team ", + Short: "Manage Azure DevOps teams.", + GroupID: "core", + Aliases: []string{ + "t", + }, + } + + cmd.AddCommand(create.NewCmd(ctx)) + + return cmd +} From 4f629d9a5b2a622420fd9e0e65d336143124028d Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 15:19:13 +0000 Subject: [PATCH 2/3] =?UTF-8?q?test(team):=20=F0=9F=A7=AA=20add=20tests=20?= =?UTF-8?q?for=20team=20create=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmd/team/create/create_test.go | 259 ++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 internal/cmd/team/create/create_test.go diff --git a/internal/cmd/team/create/create_test.go b/internal/cmd/team/create/create_test.go new file mode 100644 index 0000000..e06d615 --- /dev/null +++ b/internal/cmd/team/create/create_test.go @@ -0,0 +1,259 @@ +package create + +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 TestNewCmd_RequiresNameFlag(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockCmdCtx := mocks.NewMockCmdContext(ctrl) + + cmd := NewCmd(mockCmdCtx) + cmd.SetArgs([]string{"myproject"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "required flag(s) \"name\" not set") +} + +func TestNewCmd_MissingProjectArg(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(), "project argument required") +} + +type fakeCreateDeps struct { + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + core *mocks.MockCoreClient + config *mocks.MockConfig + authCfg *mocks.MockAuthConfig + stdout *bytes.Buffer +} + +func setupFakeCreateDeps(t *testing.T, organization string) *fakeCreateDeps { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + deps := &fakeCreateDeps{ + 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 TestCreate_ProjectScope_ParsesOrgSlashProject(t *testing.T) { + deps := setupFakeCreateDeps(t, "myOrg") + + teamID := uuid.New() + teamName := "MyTeam" + teamDesc := "desc" + projectName := "myProject" + teamURL := "https://dev.azure.com/myOrg/_apis/teams/" + teamID.String() + + deps.core.EXPECT().CreateTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.CreateTeamArgs) (*core.WebApiTeam, error) { + require.NotNil(t, args.ProjectId) + assert.Equal(t, "myProject", *args.ProjectId) + return &core.WebApiTeam{ + Id: &teamID, + Name: &teamName, + Description: &teamDesc, + ProjectName: &projectName, + Url: &teamURL, + }, nil + }) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject", "--name", "MyTeam", "--description", "desc"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestCreate_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() + teamName := "MyTeam" + + mockCoreClient.EXPECT().CreateTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.CreateTeamArgs) (*core.WebApiTeam, error) { + require.NotNil(t, args.ProjectId) + assert.Equal(t, "myProject", *args.ProjectId) + return &core.WebApiTeam{ + Id: &teamID, + Name: &teamName, + }, 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", "--name", "MyTeam"}) + err = cmd.Execute() + require.NoError(t, err) +} + +func TestCreate_PayloadContainsNameAndDescription(t *testing.T) { + deps := setupFakeCreateDeps(t, "myOrg") + + teamID := uuid.New() + + deps.core.EXPECT().CreateTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.CreateTeamArgs) (*core.WebApiTeam, error) { + require.NotNil(t, args.Team) + require.NotNil(t, args.Team.Name) + require.NotNil(t, args.Team.Description) + assert.Equal(t, "MyTeam", *args.Team.Name) + assert.Equal(t, "my desc", *args.Team.Description) + return &core.WebApiTeam{ + Id: &teamID, + Name: types.ToPtr("MyTeam"), + }, nil + }) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject", "--name", "MyTeam", "--description", "my desc"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestCreate_JSONOutput(t *testing.T) { + deps := setupFakeCreateDeps(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().CreateTeam(gomock.Any(), gomock.Any()). + Return(&core.WebApiTeam{ + Id: &teamID, + Name: &teamName, + Url: &teamURL, + }, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject", "--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 TestCreate_TableOutput_ContainsAllColumns(t *testing.T) { + deps := setupFakeCreateDeps(t, "myOrg") + + teamID := uuid.MustParse("22222222-2222-2222-2222-222222222222") + teamName := "MyTeam" + teamDesc := "my team desc" + projectName := "myProject" + teamURL := "https://dev.azure.com/myOrg/_apis/teams/22222222-2222-2222-2222-222222222222" + + deps.core.EXPECT().CreateTeam(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", "--name", "MyTeam", "--description", "my team 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, "MyTeam") + assert.Contains(t, output, "my team desc") + assert.Contains(t, output, "myProject") +} + +func TestCreate_PropagatesSDKError(t *testing.T) { + deps := setupFakeCreateDeps(t, "myOrg") + + deps.core.EXPECT().CreateTeam(gomock.Any(), gomock.Any()). + Return(nil, errors.New("API error")) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject", "--name", "MyTeam"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create team: API error") +} From 02358195a964a3fd91f3c01bc6dc5eaf209d3295 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 15:20:37 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs(team):=20=F0=9F=93=84=20add=20document?= =?UTF-8?q?ation=20for=20team=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add documentation for the `team` command and `team create` subcommand. Update the docs index and help reference to include the new pages. --- docs/azdo.md | 1 + docs/azdo_help_reference.md | 28 +++++++++++++++++ docs/azdo_team.md | 15 +++++++++ docs/azdo_team_create.md | 61 +++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 docs/azdo_team.md create mode 100644 docs/azdo_team_create.md diff --git a/docs/azdo.md b/docs/azdo.md index a771662..4226e42 100644 --- a/docs/azdo.md +++ b/docs/azdo.md @@ -11,6 +11,7 @@ Work seamlessly with Azure DevOps from the command line. * [azdo project](./azdo_project.md) * [azdo repo](./azdo_repo.md) * [azdo service-endpoint](./azdo_service-endpoint.md) +* [azdo team](./azdo_team.md) ### Security commands diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 393c207..e21d104 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -1293,6 +1293,34 @@ Update a service endpoint. --url string New service endpoint URL. ``` +## `azdo team ` + +Manage Azure DevOps teams. + +Aliases + +``` +t +``` + +### `azdo team create [ORGANIZATION/]PROJECT [flags]` + +Create a new team in a project. + +``` + --description string Description of the new 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 Name of the new team (required) +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +c, cr, new, n, add, a +``` + ### See also diff --git a/docs/azdo_team.md b/docs/azdo_team.md new file mode 100644 index 0000000..9f7235e --- /dev/null +++ b/docs/azdo_team.md @@ -0,0 +1,15 @@ +## Command `azdo team` + +Manage Azure DevOps teams. + +### Available commands + +* [azdo team create](./azdo_team_create.md) + +### ALIASES + +- `t` + +### See also + +* [azdo](./azdo.md) diff --git a/docs/azdo_team_create.md b/docs/azdo_team_create.md new file mode 100644 index 0000000..b43d1a8 --- /dev/null +++ b/docs/azdo_team_create.md @@ -0,0 +1,61 @@ +## Command `azdo team create` + +``` +azdo team create [ORGANIZATION/]PROJECT [flags] +``` + +Create a new team in the specified project. The --name flag is required. +The project argument is required; the organization falls back to the +configured default when omitted. + + +### Options + + +* `--description` `string` + + Description of the new 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` + + Name of the new team (required) + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `c` +- `cr` +- `new` +- `n` +- `add` +- `a` + +### JSON Fields + +`description`, `id`, `identity`, `identityUrl`, `name`, `projectId`, `projectName`, `url` + +### Examples + +```bash +# Create a team in the default organization +azdo team create Fabrikam --name "Fabrikam Engineering" + +# Create a team with a description +azdo team create MyOrg/Fabrikam --name "My Team" --description "Owns the web app" +``` + +### See also + +* [azdo team](./azdo_team.md)