From b2204cefe46de6ae85403fd17b4710f0bd83470d Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 17:14:46 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(team):=20=E2=9C=A8=20add=20team=20memb?= =?UTF-8?q?ership=20listing=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `azdo team list-member` to fetch and display team members from the Core API. The command supports paging controls, optional JSON export, and a tabular view sorted by identity name. --- internal/cmd/team/listmember/listmember.go | 184 +++++++++++++++++++++ internal/cmd/team/team.go | 2 + 2 files changed, 186 insertions(+) create mode 100644 internal/cmd/team/listmember/listmember.go diff --git a/internal/cmd/team/listmember/listmember.go b/internal/cmd/team/listmember/listmember.go new file mode 100644 index 0000000..a0b93a2 --- /dev/null +++ b/internal/cmd/team/listmember/listmember.go @@ -0,0 +1,184 @@ +package listmember + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" + "github.com/spf13/cobra" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type listOptions struct { + targetArg string + top int + skip int + maxItems int + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &listOptions{} + + cmd := &cobra.Command{ + Use: "list-member [ORGANIZATION/]PROJECT/TEAM", + Short: "List members of a team.", + Long: heredoc.Doc(` + List members of a team. The TEAM argument accepts the ID (GUID) + or name of the team. Supports server-side paging via --top and + --skip. + `), + Example: heredoc.Doc(` + # List members of a team + azdo team list-member Fabrikam/"Fabrikam Engineering" + + # List the first 10 members in a specific organization + azdo team list-member MyOrg/Fabrikam/MyTeam --top 10 + `), + Aliases: []string{"members"}, + Args: util.ExactArgs(1, "team argument required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runList(ctx, opts) + }, + } + + cmd.Flags().IntVar(&opts.top, "top", 0, "Maximum number of members to return per page (server-side; 0 = server default)") + cmd.Flags().IntVar(&opts.skip, "skip", 0, "Number of members to skip (server-side)") + cmd.Flags().IntVar(&opts.maxItems, "max-items", 0, "Maximum number of members to return across all pages (client-side; 0 = unlimited)") + + util.AddJSONFlags(cmd, &opts.exporter, []string{"identity", "isTeamAdmin"}) + + 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.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) + } + + teamID := scope.Targets[0] + + members, err := fetchTeamMembers(ctx, client, scope.Project, teamID, opts) + if err != nil { + return err + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, &members) + } + + return renderMembersTable(ctx, members) +} + +func fetchTeamMembers(ctx util.CmdContext, client core.Client, project, teamID string, opts *listOptions) ([]webapi.TeamMember, error) { + if opts.maxItems < 0 { + return nil, util.FlagErrorf("--max-items must be >= 0") + } + + out := make([]webapi.TeamMember, 0) + skip := opts.skip + + for { + args := core.GetTeamMembersWithExtendedPropertiesArgs{ + ProjectId: &project, + TeamId: &teamID, + } + if opts.top > 0 { + top := opts.top + args.Top = &top + } + if skip > 0 { + s := skip + args.Skip = &s + } + + resp, err := client.GetTeamMembersWithExtendedProperties(ctx.Context(), args) + if err != nil { + return nil, fmt.Errorf("failed to list team members: %w", err) + } + if resp == nil || len(*resp) == 0 { + return out, nil + } + + for _, m := range *resp { + out = append(out, m) + 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 renderMembersTable(ctx util.CmdContext, members []webapi.TeamMember) error { + sort.Slice(members, func(i, j int) bool { + li := strings.ToLower(fieldIdentityDisplay(members[i].Identity)) + lj := strings.ToLower(fieldIdentityDisplay(members[j].Identity)) + return li < lj + }) + + tp, err := ctx.Printer("list") + if err != nil { + return err + } + + tp.AddColumns("ID", "DISPLAY NAME", "UNIQUE NAME", "IS TEAM ADMIN") + tp.EndRow() + + for _, m := range members { + identity := m.Identity + var id, display, unique string + if identity != nil { + id = types.GetValue(identity.Id, "") + display = types.GetValue(identity.DisplayName, "") + unique = types.GetValue(identity.UniqueName, "") + } + tp.AddField(id) + tp.AddField(display) + tp.AddField(unique) + tp.AddField(strconv.FormatBool(types.GetValue(m.IsTeamAdmin, false))) + tp.EndRow() + } + + return tp.Render() +} + +func fieldIdentityDisplay(id *webapi.IdentityRef) string { + if id == nil { + return "" + } + if d := types.GetValue(id.DisplayName, ""); d != "" { + return d + } + return types.GetValue(id.UniqueName, "") +} diff --git a/internal/cmd/team/team.go b/internal/cmd/team/team.go index 98d84a1..0f69cad 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/delete" "github.com/tmeckel/azdo-cli/internal/cmd/team/list" + "github.com/tmeckel/azdo-cli/internal/cmd/team/listmember" "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" @@ -22,6 +23,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(create.NewCmd(ctx)) cmd.AddCommand(delete.NewCmd(ctx)) + cmd.AddCommand(listmember.NewCmd(ctx)) cmd.AddCommand(list.NewCmd(ctx)) cmd.AddCommand(show.NewCmd(ctx)) cmd.AddCommand(update.NewCmd(ctx)) From c8199a9a046b3609ec0ba8a96966ac1a613293dc Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 17:16:56 +0000 Subject: [PATCH 2/3] =?UTF-8?q?test(team):=20=F0=9F=A7=AA=20add=20list=20t?= =?UTF-8?q?eam=20member=20command=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cmd/team/listmember/listmember_test.go | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 internal/cmd/team/listmember/listmember_test.go diff --git a/internal/cmd/team/listmember/listmember_test.go b/internal/cmd/team/listmember/listmember_test.go new file mode 100644 index 0000000..62d10b2 --- /dev/null +++ b/internal/cmd/team/listmember/listmember_test.go @@ -0,0 +1,289 @@ +package listmember + +import ( + "bytes" + "context" + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" + "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" + "go.uber.org/mock/gomock" +) + +type fakeDeps struct { + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + coreClient *mocks.MockCoreClient + prompter *mocks.MockPrompter + config *mocks.MockConfig + authCfg *mocks.MockAuthConfig +} + +func setupFakeDeps(t *testing.T, organization string) (*fakeDeps, *bytes.Buffer) { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + tp, err := printer.NewTablePrinter(out, false, 200) + require.NoError(t, err) + + deps := &fakeDeps{ + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + coreClient: mocks.NewMockCoreClient(ctrl), + prompter: mocks.NewMockPrompter(ctrl), + config: mocks.NewMockConfig(ctrl), + authCfg: mocks.NewMockAuthConfig(ctrl), + } + + 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.cmd.EXPECT().Printer(gomock.Any()).Return(tp, nil).AnyTimes() + deps.clientFact.EXPECT().Core(gomock.Any(), organization).Return(deps.coreClient, nil).AnyTimes() + + return deps, out +} + +func member(id, displayName, uniqueName string, isAdmin bool) webapi.TeamMember { + m := webapi.TeamMember{ + Identity: &webapi.IdentityRef{ + Id: &id, + DisplayName: &displayName, + UniqueName: &uniqueName, + }, + IsTeamAdmin: &isAdmin, + } + return m +} + +func TestList_EmptyResult(t *testing.T) { + deps, _ := setupFakeDeps(t, "myOrg") + + deps.coreClient.EXPECT().GetTeamMembersWithExtendedProperties(gomock.Any(), gomock.Any()). + Return(&[]webapi.TeamMember{}, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestList_NoFilters(t *testing.T) { + deps, out := setupFakeDeps(t, "myOrg") + + members := []webapi.TeamMember{ + member("3", "Charlie", "c@x", false), + member("1", "Alice", "a@x", true), + member("2", "Bob", "b@x", false), + } + + deps.coreClient.EXPECT().GetTeamMembersWithExtendedProperties(gomock.Any(), gomock.Any()). + Return(&members, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam"}) + err := cmd.Execute() + require.NoError(t, err) + + output := out.String() + assert.Contains(t, output, "Alice") + assert.Contains(t, output, "Bob") + assert.Contains(t, output, "Charlie") + + aliceIdx := bytes.Index(out.Bytes(), []byte("Alice")) + bobIdx := bytes.Index(out.Bytes(), []byte("Bob")) + charlieIdx := bytes.Index(out.Bytes(), []byte("Charlie")) + assert.True(t, aliceIdx < bobIdx && bobIdx < charlieIdx, "expected sorted order") +} + +func TestList_FiltersPassedToSDK(t *testing.T) { + deps, _ := setupFakeDeps(t, "myOrg") + + var capturedArgs core.GetTeamMembersWithExtendedPropertiesArgs + deps.coreClient.EXPECT().GetTeamMembersWithExtendedProperties(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.GetTeamMembersWithExtendedPropertiesArgs) (*[]webapi.TeamMember, error) { + capturedArgs = args + return &[]webapi.TeamMember{}, nil + }) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "--top", "10", "--skip", "5"}) + err := cmd.Execute() + require.NoError(t, err) + + require.NotNil(t, capturedArgs.Top) + assert.Equal(t, 10, *capturedArgs.Top) + require.NotNil(t, capturedArgs.Skip) + assert.Equal(t, 5, *capturedArgs.Skip) +} + +func TestList_MaxItemsCap(t *testing.T) { + deps, out := setupFakeDeps(t, "myOrg") + + members := []webapi.TeamMember{ + member("1", "A", "a@x", false), + member("2", "B", "b@x", false), + member("3", "C", "c@x", false), + member("4", "D", "d@x", false), + member("5", "E", "e@x", false), + } + + deps.coreClient.EXPECT().GetTeamMembersWithExtendedProperties(gomock.Any(), gomock.Any()). + Return(&members, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "--max-items", "3"}) + err := cmd.Execute() + require.NoError(t, err) + + assert.Contains(t, out.String(), "A") + assert.Contains(t, out.String(), "B") + assert.Contains(t, out.String(), "C") + assert.NotContains(t, out.String(), "D") + assert.NotContains(t, out.String(), "E") +} + +func TestList_JSONOutput(t *testing.T) { + deps, out := setupFakeDeps(t, "myOrg") + + display := "Alice" + members := []webapi.TeamMember{ + { + Identity: &webapi.IdentityRef{ + DisplayName: &display, + }, + IsTeamAdmin: boolPtr(true), + }, + } + + deps.coreClient.EXPECT().GetTeamMembersWithExtendedProperties(gomock.Any(), gomock.Any()). + Return(&members, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "--json=identity,isTeamAdmin"}) + err := cmd.Execute() + require.NoError(t, err) + + output := out.String() + assert.Contains(t, output, "displayName") + assert.Contains(t, output, "isTeamAdmin") + assert.NotContains(t, output, "ID") + assert.NotContains(t, output, "DISPLAY NAME") +} + +func TestList_PaginatesUntilShortPage(t *testing.T) { + deps, out := setupFakeDeps(t, "myOrg") + + page1 := []webapi.TeamMember{ + member("1", "A", "a@x", false), + member("2", "B", "b@x", false), + } + page2 := []webapi.TeamMember{ + member("3", "C", "c@x", false), + } + + var callCount int + deps.coreClient.EXPECT().GetTeamMembersWithExtendedProperties(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.GetTeamMembersWithExtendedPropertiesArgs) (*[]webapi.TeamMember, error) { + callCount++ + if callCount == 1 { + if args.Skip != nil { + assert.Equal(t, 0, *args.Skip) + } + return &page1, nil + } + require.NotNil(t, args.Skip) + assert.Equal(t, 2, *args.Skip) + return &page2, nil + }).Times(2) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam", "--top", "2"}) + err := cmd.Execute() + require.NoError(t, err) + assert.Equal(t, 2, callCount) + assert.Contains(t, out.String(), "A") + assert.Contains(t, out.String(), "B") + assert.Contains(t, out.String(), "C") +} + +func TestList_TargetArg_ParsesOrgSlashProjectSlashTeam(t *testing.T) { + deps, _ := setupFakeDeps(t, "myOrg") + + var capturedArgs core.GetTeamMembersWithExtendedPropertiesArgs + deps.coreClient.EXPECT().GetTeamMembersWithExtendedProperties(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.GetTeamMembersWithExtendedPropertiesArgs) (*[]webapi.TeamMember, error) { + capturedArgs = args + return &[]webapi.TeamMember{}, nil + }) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/My Team"}) + err := cmd.Execute() + require.NoError(t, err) + + require.NotNil(t, capturedArgs.ProjectId) + assert.Equal(t, "myProject", *capturedArgs.ProjectId) + require.NotNil(t, capturedArgs.TeamId) + assert.Equal(t, "My Team", *capturedArgs.TeamId) +} + +func TestList_IdentityRefs_NilSafe(t *testing.T) { + deps, _ := setupFakeDeps(t, "myOrg") + + members := []webapi.TeamMember{ + {Identity: nil, IsTeamAdmin: nil}, + } + + deps.coreClient.EXPECT().GetTeamMembersWithExtendedProperties(gomock.Any(), gomock.Any()). + Return(&members, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestList_IdentityDisplayFallsBackToUniqueName(t *testing.T) { + deps, out := setupFakeDeps(t, "myOrg") + + uniqueName := "user@example.com" + members := []webapi.TeamMember{ + { + Identity: &webapi.IdentityRef{ + Id: strPtr("1"), + UniqueName: &uniqueName, + }, + IsTeamAdmin: boolPtr(false), + }, + } + + deps.coreClient.EXPECT().GetTeamMembersWithExtendedProperties(gomock.Any(), gomock.Any()). + Return(&members, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/MyTeam"}) + err := cmd.Execute() + require.NoError(t, err) + + assert.Contains(t, out.String(), "user@example.com") +} + +func boolPtr(b bool) *bool { + return &b +} + +func strPtr(s string) *string { + return &s +} From caea982bacf7011eb1dd735b76e69217f5c10c61 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 17:17:47 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=F0=9F=93=84=20document=20roster=20?= =?UTF-8?q?command=20for=20team=20membership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete reference for `azdo team list-member` including flags, pagination controls, output formatting, and shell examples. Link the new page from the team command index and central help reference. --- docs/azdo_help_reference.md | 19 +++++++++++ docs/azdo_team.md | 1 + docs/azdo_team_list-member.md | 60 +++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 docs/azdo_team_list-member.md diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 9fa4479..9de7423 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -1358,6 +1358,25 @@ Aliases ls, l ``` +### `azdo team list-member [ORGANIZATION/]PROJECT/TEAM [flags]` + +List members of a 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. + --max-items int Maximum number of members to return across all pages (client-side; 0 = unlimited) + --skip int Number of members to skip (server-side) +-t, --template string Format JSON output using a Go template; see "azdo help formatting" + --top int Maximum number of members to return per page (server-side; 0 = server default) +``` + +Aliases + +``` +members +``` + ### `azdo team show [ORGANIZATION/]PROJECT/TEAM [flags]` Show details of a team. diff --git a/docs/azdo_team.md b/docs/azdo_team.md index ebf3bea..109d984 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 delete](./azdo_team_delete.md) * [azdo team list](./azdo_team_list.md) +* [azdo team list-member](./azdo_team_list-member.md) * [azdo team show](./azdo_team_show.md) * [azdo team update](./azdo_team_update.md) diff --git a/docs/azdo_team_list-member.md b/docs/azdo_team_list-member.md new file mode 100644 index 0000000..53dcea1 --- /dev/null +++ b/docs/azdo_team_list-member.md @@ -0,0 +1,60 @@ +## Command `azdo team list-member` + +``` +azdo team list-member [ORGANIZATION/]PROJECT/TEAM [flags] +``` + +List members of a team. The TEAM argument accepts the ID (GUID) +or name of the team. Supports server-side paging via --top and +--skip. + + +### 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 members to return across all pages (client-side; 0 = unlimited) + +* `--skip` `int` (default `0`) + + Number of members 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 members to return per page (server-side; 0 = server default) + + +### ALIASES + +- `members` + +### JSON Fields + +`identity`, `isTeamAdmin` + +### Examples + +```bash +# List members of a team +azdo team list-member Fabrikam/"Fabrikam Engineering" + +# List the first 10 members in a specific organization +azdo team list-member MyOrg/Fabrikam/MyTeam --top 10 +``` + +### See also + +* [azdo team](./azdo_team.md)