diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index e21d104..1ceb602 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -1321,6 +1321,26 @@ Aliases c, cr, new, n, add, a ``` +### `azdo team list [ORGANIZATION/]PROJECT [flags]` + +List teams in a project. + +``` +-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. + --max-items int Maximum number of teams to return across all pages (client-side; 0 = unlimited) + --mine Return only teams the current user is a member of + --skip int Number of teams to skip (server-side) +-t, --template string Format JSON output using a Go template; see "azdo help formatting" + --top int Maximum number of teams to return per page (server-side; 0 = server default) +``` + +Aliases + +``` +ls, l +``` + ### See also diff --git a/docs/azdo_team.md b/docs/azdo_team.md index 9f7235e..fc5c157 100644 --- a/docs/azdo_team.md +++ b/docs/azdo_team.md @@ -5,6 +5,7 @@ Manage Azure DevOps teams. ### Available commands * [azdo team create](./azdo_team_create.md) +* [azdo team list](./azdo_team_list.md) ### ALIASES diff --git a/docs/azdo_team_list.md b/docs/azdo_team_list.md new file mode 100644 index 0000000..438c4ad --- /dev/null +++ b/docs/azdo_team_list.md @@ -0,0 +1,67 @@ +## Command `azdo team list` + +``` +azdo team list [ORGANIZATION/]PROJECT [flags] +``` + +List all teams in the specified project. Supports server-side paging via +--top and --skip, --mine filtering, and JSON export. + + +### Options + + +* `-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. + +* `--max-items` `int` (default `0`) + + Maximum number of teams to return across all pages (client-side; 0 = unlimited) + +* `--mine` + + Return only teams the current user is a member of + +* `--skip` `int` (default `0`) + + Number of teams to skip (server-side) + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + +* `--top` `int` (default `0`) + + Maximum number of teams to return per page (server-side; 0 = server default) + + +### ALIASES + +- `ls` +- `l` + +### JSON Fields + +`description`, `id`, `identity`, `identityUrl`, `name`, `projectId`, `projectName`, `url` + +### Examples + +```bash +# List all teams in the default organization +azdo team list Fabrikam + +# List the first 10 teams in a specific organization +azdo team list MyOrg/Fabrikam --top 10 + +# List teams you are a member of +azdo team list Fabrikam --mine +``` + +### See also + +* [azdo team](./azdo_team.md) diff --git a/internal/cmd/team/list/list.go b/internal/cmd/team/list/list.go new file mode 100644 index 0000000..49a434f --- /dev/null +++ b/internal/cmd/team/list/list.go @@ -0,0 +1,170 @@ +package list + +import ( + "fmt" + "sort" + "strings" + + "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 listOptions struct { + scopeArg string + top int + skip int + mine bool + maxItems int + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &listOptions{} + + cmd := &cobra.Command{ + Use: "list [ORGANIZATION/]PROJECT", + Short: "List teams in a project.", + Long: heredoc.Doc(` + List all teams in the specified project. Supports server-side paging via + --top and --skip, --mine filtering, and JSON export. + `), + Example: heredoc.Doc(` + # List all teams in the default organization + azdo team list Fabrikam + + # List the first 10 teams in a specific organization + azdo team list MyOrg/Fabrikam --top 10 + + # List teams you are a member of + azdo team list Fabrikam --mine + `), + Aliases: []string{"ls", "l"}, + Args: util.ExactArgs(1, "project argument required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.scopeArg = args[0] + return runList(ctx, opts) + }, + } + + cmd.Flags().IntVar(&opts.top, "top", 0, "Maximum number of teams to return per page (server-side; 0 = server default)") + cmd.Flags().IntVar(&opts.skip, "skip", 0, "Number of teams to skip (server-side)") + cmd.Flags().BoolVar(&opts.mine, "mine", false, "Return only teams the current user is a member of") + cmd.Flags().IntVar(&opts.maxItems, "max-items", 0, "Maximum number of teams to return across all pages (client-side; 0 = unlimited)") + + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "name", "description", "url", + "identity", "identityUrl", "projectId", "projectName", + }) + + return cmd +} + +func runList(ctx util.CmdContext, opts *listOptions) 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) + } + + teams, err := fetchTeams(ctx, client, scope.Project, opts) + if err != nil { + return err + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, teams) + } + + return renderTeamsTable(ctx, teams) +} + +func fetchTeams(ctx util.CmdContext, client core.Client, project string, opts *listOptions) ([]core.WebApiTeam, error) { + if opts.maxItems < 0 { + return nil, util.FlagErrorf("--max-items must be >= 0") + } + + out := make([]core.WebApiTeam, 0) + skip := opts.skip + + for { + args := core.GetTeamsArgs{ProjectId: &project} + if opts.mine { + mine := true + args.Mine = &mine + } + if opts.top > 0 { + top := opts.top + args.Top = &top + } + if skip > 0 { + s := skip + args.Skip = &s + } + + resp, err := client.GetTeams(ctx.Context(), args) + if err != nil { + return nil, fmt.Errorf("failed to list teams: %w", err) + } + if resp == nil || len(*resp) == 0 { + return out, nil + } + + for _, t := range *resp { + out = append(out, t) + if opts.maxItems > 0 && len(out) >= opts.maxItems { + return out, nil + } + } + + if opts.top > 0 && len(*resp) < opts.top { + return out, nil + } + + skip += opts.top + if opts.top == 0 { + return out, nil + } + } +} + +func renderTeamsTable(ctx util.CmdContext, teams []core.WebApiTeam) error { + sort.Slice(teams, func(i, j int) bool { + return strings.ToLower(types.GetValue(teams[i].Name, "")) < strings.ToLower(types.GetValue(teams[j].Name, "")) + }) + + tp, err := ctx.Printer("list") + if err != nil { + return err + } + + tp.AddColumns("ID", "NAME", "DESCRIPTION", "PROJECT") + tp.EndRow() + + for _, t := range teams { + tp.AddField(types.GetValue(t.Id, uuid.UUID{}).String()) + tp.AddField(types.GetValue(t.Name, "")) + tp.AddField(types.GetValue(t.Description, "")) + tp.AddField(types.GetValue(t.ProjectName, "")) + tp.EndRow() + } + + return tp.Render() +} diff --git a/internal/cmd/team/list/list_test.go b/internal/cmd/team/list/list_test.go new file mode 100644 index 0000000..7c89ee8 --- /dev/null +++ b/internal/cmd/team/list/list_test.go @@ -0,0 +1,319 @@ +package list + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "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" +) + +type fakeListDeps struct { + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + core *mocks.MockCoreClient + config *mocks.MockConfig + authCfg *mocks.MockAuthConfig + stdout *bytes.Buffer +} + +func setupFakeDeps(t *testing.T, organization string) *fakeListDeps { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + deps := &fakeListDeps{ + 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.NewTablePrinter(out, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + return deps +} + +func sampleTeams() []core.WebApiTeam { + beta := uuid.MustParse("10000000-0000-0000-0000-000000000002") + alpha := uuid.MustParse("10000000-0000-0000-0000-000000000001") + gamma := uuid.MustParse("10000000-0000-0000-0000-000000000003") + + return []core.WebApiTeam{ + { + Id: &beta, + Name: types.ToPtr("Beta Team"), + Description: types.ToPtr("Second team"), + ProjectName: types.ToPtr("Fabrikam"), + }, + { + Id: &alpha, + Name: types.ToPtr("Alpha Team"), + Description: types.ToPtr("First team"), + ProjectName: types.ToPtr("Fabrikam"), + }, + { + Id: &gamma, + Name: types.ToPtr("Gamma Team"), + Description: types.ToPtr("Third team"), + ProjectName: types.ToPtr("Fabrikam"), + }, + } +} + +func TestList_EmptyResult(t *testing.T) { + deps := setupFakeDeps(t, "myOrg") + + deps.core.EXPECT().GetTeams(gomock.Any(), gomock.Any()). + Return(&[]core.WebApiTeam{}, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestList_NoFilters(t *testing.T) { + deps := setupFakeDeps(t, "myOrg") + + teams := sampleTeams() + deps.core.EXPECT().GetTeams(gomock.Any(), gomock.Any()). + Return(&teams, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject"}) + err := cmd.Execute() + require.NoError(t, err) + + output := deps.stdout.String() + assert.Contains(t, output, "Alpha Team") + assert.Contains(t, output, "Beta Team") + assert.Contains(t, output, "Gamma Team") + + rIdx := strings.Index(output, "Alpha Team") + bIdx := strings.Index(output, "Beta Team") + gIdx := strings.Index(output, "Gamma Team") + assert.True(t, rIdx < bIdx, "Alpha Team should appear before Beta Team") + assert.True(t, bIdx < gIdx, "Beta Team should appear before Gamma Team") +} + +func TestList_FiltersPassedToSDK(t *testing.T) { + deps := setupFakeDeps(t, "myOrg") + + deps.core.EXPECT().GetTeams(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.GetTeamsArgs) (*[]core.WebApiTeam, error) { + assert.NotNil(t, args.Top) + assert.Equal(t, 10, *args.Top) + assert.NotNil(t, args.Skip) + assert.Equal(t, 5, *args.Skip) + assert.NotNil(t, args.Mine) + assert.True(t, *args.Mine) + return &[]core.WebApiTeam{}, nil + }) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject", "--top", "10", "--skip", "5", "--mine"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestList_MaxItemsCap(t *testing.T) { + deps := setupFakeDeps(t, "myOrg") + + teams := sampleTeams() + deps.core.EXPECT().GetTeams(gomock.Any(), gomock.Any()). + Return(&teams, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject", "--max-items", "2"}) + err := cmd.Execute() + require.NoError(t, err) + + output := deps.stdout.String() + assert.Contains(t, output, "Alpha Team") + assert.Contains(t, output, "Beta Team") + assert.NotContains(t, output, "Gamma Team") +} + +func TestList_JSONOutput(t *testing.T) { + deps := setupFakeDeps(t, "myOrg") + + beta := uuid.MustParse("10000000-0000-0000-0000-000000000002") + teams := []core.WebApiTeam{ + {Id: &beta, Name: types.ToPtr("Beta Team"), Description: types.ToPtr("Second team")}, + } + deps.core.EXPECT().GetTeams(gomock.Any(), gomock.Any()). + Return(&teams, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject", "--json=id,name"}) + err := cmd.Execute() + require.NoError(t, err) + + var parsed []map[string]any + require.NoError(t, json.Unmarshal(deps.stdout.Bytes(), &parsed)) + require.Len(t, parsed, 1) + assert.Equal(t, "10000000-0000-0000-0000-000000000002", parsed[0]["id"]) + assert.Equal(t, "Beta Team", parsed[0]["name"]) + assert.NotContains(t, parsed[0], "description") +} + +func TestList_PaginatesUntilShortPage(t *testing.T) { + deps := setupFakeDeps(t, "myOrg") + + page1 := []core.WebApiTeam{ + {Id: types.ToPtr(uuid.MustParse("10000000-0000-0000-0000-000000000001")), Name: types.ToPtr("Team A"), ProjectName: types.ToPtr("P")}, + {Id: types.ToPtr(uuid.MustParse("10000000-0000-0000-0000-000000000002")), Name: types.ToPtr("Team B"), ProjectName: types.ToPtr("P")}, + } + page2 := []core.WebApiTeam{ + {Id: types.ToPtr(uuid.MustParse("10000000-0000-0000-0000-000000000003")), Name: types.ToPtr("Team C"), ProjectName: types.ToPtr("P")}, + } + + var callCount int + deps.core.EXPECT().GetTeams(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.GetTeamsArgs) (*[]core.WebApiTeam, error) { + callCount++ + switch callCount { + case 1: + assert.Nil(t, args.Skip) + return &page1, nil + case 2: + require.NotNil(t, args.Skip) + assert.Equal(t, 2, *args.Skip) + return &page2, nil + default: + return &[]core.WebApiTeam{}, nil + } + }).Times(2) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject", "--top", "2"}) + err := cmd.Execute() + require.NoError(t, err) + + assert.Equal(t, 2, callCount) + + output := deps.stdout.String() + assert.Contains(t, output, "Team A") + assert.Contains(t, output, "Team B") + assert.Contains(t, output, "Team C") +} + +func TestList_ScopeArg_ParsesOrgSlashProject(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) + + 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(), "myOrg").Return(mockCoreClient, nil).AnyTimes() + + mockCoreClient.EXPECT().GetTeams(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.GetTeamsArgs) (*[]core.WebApiTeam, error) { + require.NotNil(t, args.ProjectId) + assert.Equal(t, "myProject", *args.ProjectId) + return &[]core.WebApiTeam{}, nil + }) + + tp, err := printer.NewTablePrinter(out, false, 200) + require.NoError(t, err) + mockCmdCtx.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + cmd := NewCmd(mockCmdCtx) + cmd.SetArgs([]string{"myOrg/myProject"}) + err = cmd.Execute() + require.NoError(t, err) +} + +func TestList_PropagatesSDKError(t *testing.T) { + deps := setupFakeDeps(t, "myOrg") + + deps.core.EXPECT().GetTeams(gomock.Any(), gomock.Any()). + Return(nil, errors.New("API error")) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to list teams: API error") +} + +func TestList_MaxItemsNegative_ReturnsError(t *testing.T) { + deps := setupFakeDeps(t, "myOrg") + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject", "--max-items", "-1"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "--max-items must be >= 0") +} + +func TestList_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() + + mockCoreClient.EXPECT().GetTeams(gomock.Any(), gomock.Any()). + Return(&[]core.WebApiTeam{}, nil) + + tp, err := printer.NewTablePrinter(out, false, 200) + require.NoError(t, err) + mockCmdCtx.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + cmd := NewCmd(mockCmdCtx) + cmd.SetArgs([]string{"myProject"}) + err = cmd.Execute() + require.NoError(t, err) +} diff --git a/internal/cmd/team/team.go b/internal/cmd/team/team.go index 1f303f2..fbef998 100644 --- a/internal/cmd/team/team.go +++ b/internal/cmd/team/team.go @@ -3,6 +3,7 @@ package team import ( "github.com/spf13/cobra" "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/util" ) @@ -17,6 +18,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { } cmd.AddCommand(create.NewCmd(ctx)) + cmd.AddCommand(list.NewCmd(ctx)) return cmd }