From 562071df6c998a2f8951f81588aef2922f78af11 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Fri, 5 Jun 2026 20:59:21 +0000 Subject: [PATCH 1/7] feat(util): add time formatting utilities --- internal/cmd/util/formats.go | 33 +++++++++++++++++++ internal/cmd/util/formats_test.go | 53 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 internal/cmd/util/formats.go create mode 100644 internal/cmd/util/formats_test.go diff --git a/internal/cmd/util/formats.go b/internal/cmd/util/formats.go new file mode 100644 index 0000000..e68b8cc --- /dev/null +++ b/internal/cmd/util/formats.go @@ -0,0 +1,33 @@ +package util + +import ( + "strings" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" +) + +// FormatTimePtr formats an *azuredevops.Time as a *string using its query parameter format. +// Returns nil for nil input. +func FormatTimePtr(ts *azuredevops.Time) *string { + if ts == nil { + return nil + } + formatted := ts.AsQueryParameter() + if strings.TrimSpace(formatted) == "" { + return nil + } + return &formatted +} + +// FormatTimeShort formats an *azuredevops.Time as a human-readable string ("2006-01-02 15:04:05"). +// Returns "" for nil or zero-value input. +func FormatTimeShort(ts *azuredevops.Time) string { + if ts == nil { + return "" + } + t := ts.Time + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} diff --git a/internal/cmd/util/formats_test.go b/internal/cmd/util/formats_test.go new file mode 100644 index 0000000..db40ed1 --- /dev/null +++ b/internal/cmd/util/formats_test.go @@ -0,0 +1,53 @@ +package util + +import ( + "testing" + "time" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tmeckel/azdo-cli/internal/types" +) + +func TestFormatTimePtr(t *testing.T) { + now := azuredevops.Time{Time: time.Date(2024, 6, 5, 14, 30, 0, 0, time.UTC)} + tests := []struct { + name string + ts *azuredevops.Time + want *string + }{ + {name: "nil", ts: nil, want: nil}, + {name: "valid time", ts: &now, want: types.ToPtr(now.AsQueryParameter())}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatTimePtr(tt.ts) + if tt.want == nil { + assert.Nil(t, got) + return + } + require.NotNil(t, got) + assert.Equal(t, *tt.want, *got) + }) + } +} + +func TestFormatTimeShort(t *testing.T) { + now := azuredevops.Time{Time: time.Date(2024, 6, 5, 14, 30, 0, 0, time.UTC)} + tests := []struct { + name string + ts *azuredevops.Time + want string + }{ + {name: "nil", ts: nil, want: ""}, + {name: "zero value", ts: &azuredevops.Time{}, want: ""}, + {name: "valid time", ts: &now, want: "2024-06-05 14:30:00"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, FormatTimeShort(tt.ts)) + }) + } +} From 10441eb7ad2d41d4508286bdeb238edd2462f558 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Fri, 5 Jun 2026 21:00:22 +0000 Subject: [PATCH 2/7] refactor(pipelines/variablegroup): use shared util time formatting --- .../cmd/pipelines/variablegroup/list/list.go | 16 ++-------------- .../cmd/pipelines/variablegroup/show/show.go | 13 +------------ 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/internal/cmd/pipelines/variablegroup/list/list.go b/internal/cmd/pipelines/variablegroup/list/list.go index 63b699c..a7b2ef5 100644 --- a/internal/cmd/pipelines/variablegroup/list/list.go +++ b/internal/cmd/pipelines/variablegroup/list/list.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/MakeNowJust/heredoc/v2" - "github.com/microsoft/azure-devops-go-api/azuredevops/v7" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" "github.com/spf13/cobra" @@ -216,9 +215,9 @@ func newVariableGroupJSON(vg taskagent.VariableGroup) variableGroupJSON { Description: vg.Description, IsShared: vg.IsShared, CreatedBy: newIdentityJSON(vg.CreatedBy), - CreatedOn: formatTimePtr(vg.CreatedOn), + CreatedOn: util.FormatTimePtr(vg.CreatedOn), ModifiedBy: newIdentityJSON(vg.ModifiedBy), - ModifiedOn: formatTimePtr(vg.ModifiedOn), + ModifiedOn: util.FormatTimePtr(vg.ModifiedOn), ProjectRefs: vg.VariableGroupProjectReferences, Variables: vg.Variables, } @@ -243,14 +242,3 @@ func newIdentityJSON(ref *webapi.IdentityRef) *identityJSON { UniqueName: unique, } } - -func formatTimePtr(ts *azuredevops.Time) *string { - if ts == nil { - return nil - } - formatted := ts.AsQueryParameter() - if strings.TrimSpace(formatted) == "" { - return nil - } - return &formatted -} diff --git a/internal/cmd/pipelines/variablegroup/show/show.go b/internal/cmd/pipelines/variablegroup/show/show.go index d4f6989..79bd153 100644 --- a/internal/cmd/pipelines/variablegroup/show/show.go +++ b/internal/cmd/pipelines/variablegroup/show/show.go @@ -205,7 +205,7 @@ func run(cmdCtx util.CmdContext, o *opts) error { "i": func(v *int) string { return strconv.Itoa(types.GetValue(v, 0)) }, "s": template.StringOrEmpty, "b": template.BoolString, - "ts": func(v *azuredevops.Time) string { return types.GetValue(formatTimePtr(v), "") }, + "ts": func(v *azuredevops.Time) string { return types.GetValue(util.FormatTimePtr(v), "") }, "identity": func(id *webapi.IdentityRef) string { if id == nil { return "" @@ -245,17 +245,6 @@ func run(cmdCtx util.CmdContext, o *opts) error { return t.ExecuteData(view) } -func formatTimePtr(ts *azuredevops.Time) *string { - if ts == nil { - return nil - } - formatted := ts.AsQueryParameter() - if strings.TrimSpace(formatted) == "" { - return nil - } - return &formatted -} - func toAuthorizedPipelines(perms *pipelinepermissions.ResourcePipelinePermissions, idToName map[int]string) []authorizedPipelineView { if perms == nil { return nil From ed558c7631b6183cd8c68b96baac7670c2e18416 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 14:32:19 +0000 Subject: [PATCH 3/7] chore(mise): add gotestsum --- .mise.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.mise.toml b/.mise.toml index ddd3044..d63703b 100644 --- a/.mise.toml +++ b/.mise.toml @@ -8,3 +8,4 @@ goreleaser = "latest" prek = "latest" gofumpt = "latest" "github:oligot/go-mod-upgrade" = "latest" +gotestsum = "latest" From 7f763712b706a96f1138de122b7740bb0cacc35e Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 14:33:11 +0000 Subject: [PATCH 4/7] =?UTF-8?q?test:=20=F0=9F=A7=AA=20=20add=20gocoverdir?= =?UTF-8?q?=20env=20var=20to=20test=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/git/client_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/git/client_test.go b/internal/git/client_test.go index b7ba20d..c7ccf8b 100644 --- a/internal/git/client_test.go +++ b/internal/git/client_test.go @@ -707,6 +707,7 @@ func createCommitsCommandContext(t *testing.T, testData stubbedCommitsCommandDat cmd.Env = []string{ "GH_WANT_HELPER_PROCESS=1", "GH_COMMITS_TEST_DATA=" + string(b), + "GOCOVERDIR=" + t.TempDir(), } return cmd, func(ctx context.Context, exe string, args ...string) *exec.Cmd { cmd.Args = append(cmd.Args, exe) @@ -1484,6 +1485,7 @@ func createCommandContext(t *testing.T, exitStatus int, stdout, stderr string) ( fmt.Sprintf("GH_HELPER_PROCESS_STDOUT=%s", stdout), fmt.Sprintf("GH_HELPER_PROCESS_STDERR=%s", stderr), fmt.Sprintf("GH_HELPER_PROCESS_EXIT_STATUS=%v", exitStatus), + fmt.Sprintf("GOCOVERDIR=%s", t.TempDir()), } return cmd, func(ctx context.Context, exe string, args ...string) *exec.Cmd { cmd.Args = append(cmd.Args, exe) From 2d17376567b535e9e9317c5fa00f99bae46c5cf0 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 14:38:03 +0000 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20`pipelines=20agen?= =?UTF-8?q?t=20list`=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmd/pipelines/agent/list/list.go | 194 ++++++++ .../cmd/pipelines/agent/list/list_test.go | 414 ++++++++++++++++++ 2 files changed, 608 insertions(+) create mode 100644 internal/cmd/pipelines/agent/list/list.go create mode 100644 internal/cmd/pipelines/agent/list/list_test.go diff --git a/internal/cmd/pipelines/agent/list/list.go b/internal/cmd/pipelines/agent/list/list.go new file mode 100644 index 0000000..5223caf --- /dev/null +++ b/internal/cmd/pipelines/agent/list/list.go @@ -0,0 +1,194 @@ +package list + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" + "github.com/spf13/cobra" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/agent/shared" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" + "go.uber.org/zap" +) + +type opts struct { + targetArg string + filter string + includeCapabilities bool + maxItems int + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &opts{} + + cmd := &cobra.Command{ + Use: "list [ORGANIZATION/]POOL", + Short: "List agents in an agent pool", + Long: heredoc.Doc(` + List every agent in an Azure DevOps agent pool. + The pool is identified by a positional target that can be a numeric ID or a name. + `), + Example: heredoc.Doc(` + # List all agents in pool 1 + $ azdo pipelines agent list 1 + + # List agents in a named pool + $ azdo pipelines agent list Default + + # List agents in pool 1 in a specific organization + $ azdo pipelines agent list "myorg/1" + + # List agents in a named pool in a specific organization + $ azdo pipelines agent list "myorg/Default" + + # List agents filtered by name + $ azdo pipelines agent list 1 --filter "my-agent" + + # List agents filtered by name in a specific organization + $ azdo pipelines agent list "myorg/1" --filter "my-agent" + + # List agents with capabilities included + $ azdo pipelines agent list 1 --include-capabilities + + # Output as JSON + $ azdo pipelines agent list 1 --json + `), + Aliases: []string{ + "ls", + "l", + }, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return run(ctx, opts) + }, + } + + cmd.Flags().StringVarP(&opts.filter, "filter", "f", "", "Filter agents by name") + cmd.Flags().BoolVar(&opts.includeCapabilities, "include-capabilities", false, "Include agent capabilities in the response") + cmd.Flags().IntVar(&opts.maxItems, "max-items", 0, "Optional client-side cap on results") + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "_links", + "accessPoint", + "assignedAgentCloudRequest", + "assignedRequest", + "authorization", + "createdOn", + "id", + "lastCompletedRequest", + "maxParallelism", + "name", + "status", + "enabled", + "osDescription", + "pendingUpdate", + "properties", + "provisioningState", + "statusChangedOn", + "version", + "systemCapabilities", + "userCapabilities", + }) + + return cmd +} + +func run(cmdCtx util.CmdContext, opts *opts) error { + ios, err := cmdCtx.IOStreams() + if err != nil { + return err + } + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + if opts.maxItems < 0 { + return util.FlagErrorf("invalid --max-items value %d; must be greater than 0", opts.maxItems) + } + + scope, err := util.ParseTargetWithDefaultOrganization(cmdCtx, opts.targetArg) + if err != nil { + return util.FlagErrorf("invalid agent list target: %w", err) + } + if scope.Project != "" { + return util.FlagErrorf("agent list does not accept a project scope; got %q", opts.targetArg) + } + + org := scope.Organization + + tac, err := cmdCtx.ClientFactory().TaskAgent(cmdCtx.Context(), org) + if err != nil { + return err + } + + poolID, err := shared.ResolvePool(cmdCtx, tac, scope.Targets[0]) + if err != nil { + return err + } + + args := taskagent.GetAgentsArgs{ + PoolId: types.ToPtr(poolID), + AgentName: nil, + IncludeCapabilities: nil, + } + + if opts.filter != "" { + args.AgentName = types.ToPtr(opts.filter) + } + if opts.includeCapabilities { + args.IncludeCapabilities = types.ToPtr(true) + } + + agents, err := tac.GetAgents(cmdCtx.Context(), args) + if err != nil { + return err + } + + logger := zap.L() + + agentList := *agents + + if opts.maxItems > 0 && len(agentList) > opts.maxItems { + logger.Debug("truncating result set to max-items", zap.Int("maxItems", opts.maxItems)) + agentList = agentList[:opts.maxItems] + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, agentList) + } + + tp, err := cmdCtx.Printer("table") + if err != nil { + return err + } + + tp.AddColumns("ID", "NAME", "STATUS", "ENABLED", "VERSION", "OS", "CREATED ON") + + for _, a := range agentList { + id := types.GetValue(a.Id, 0) + name := types.GetValue(a.Name, "") + status := "" + if a.Status != nil { + status = string(*a.Status) + } + enabled := strconv.FormatBool(types.GetValue(a.Enabled, false)) + version := types.GetValue(a.Version, "") + osDesc := types.GetValue(a.OsDescription, "") + createdOn := util.FormatTimeShort(a.CreatedOn) + + tp.AddField(fmt.Sprintf("%d", id)) + tp.AddField(name) + tp.AddField(status) + tp.AddField(enabled) + tp.AddField(version) + tp.AddField(osDesc) + tp.AddField(createdOn) + tp.EndRow() + } + + return tp.Render() +} diff --git a/internal/cmd/pipelines/agent/list/list_test.go b/internal/cmd/pipelines/agent/list/list_test.go new file mode 100644 index 0000000..ad1d8b2 --- /dev/null +++ b/internal/cmd/pipelines/agent/list/list_test.go @@ -0,0 +1,414 @@ +package list + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "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" +) + +type fakeListDeps struct { + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + tac *mocks.MockTaskAgentClient + stdout *bytes.Buffer + stderr *bytes.Buffer +} + +func setupFakeDeps(t *testing.T, organization string) *fakeListDeps { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, errOut := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + deps := &fakeListDeps{ + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + tac: mocks.NewMockTaskAgentClient(ctrl), + stdout: out, + stderr: errOut, + } + + 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().TaskAgent(gomock.Any(), organization).Return(deps.tac, nil).AnyTimes() + + return deps +} + +func sampleAgent(id int, name string, status taskagent.TaskAgentStatus, enabled bool) taskagent.TaskAgent { + createdOn := azuredevops.Time{Time: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)} + return taskagent.TaskAgent{ + Id: types.ToPtr(id), + Name: types.ToPtr(name), + Status: &status, + Enabled: types.ToPtr(enabled), + Version: types.ToPtr("4.240.0"), + OsDescription: types.ToPtr("Linux 5.15.0-1050-azure"), + MaxParallelism: types.ToPtr(1), + CreatedOn: &createdOn, + } +} + +func TestNewCmd_RegistersAsListLeaf(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Equal(t, "list [ORGANIZATION/]POOL", cmd.Use) + assert.ElementsMatch(t, []string{"ls", "l"}, cmd.Aliases) + assert.NotNil(t, cmd.RunE) +} + +func TestNewCmd_RequiresExactlyOneArg(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + err := cmd.Args(cmd, []string{}) + require.Error(t, err) + err = cmd.Args(cmd, []string{"org", "extra"}) + require.Error(t, err) +} + +func TestNewCmd_HasFlags(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + f := cmd.Flags() + + assert.Nil(t, f.Lookup("pool-id")) + require.NotNil(t, f.Lookup("filter")) + require.NotNil(t, f.Lookup("include-capabilities")) + require.NotNil(t, f.Lookup("max-items")) + assert.NotNil(t, f.Lookup("json")) + assert.NotNil(t, f.Lookup("jq")) + assert.NotNil(t, f.Lookup("template")) +} + +func TestRunList_BasicCall(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "myorg") + agents := []taskagent.TaskAgent{ + sampleAgent(7, "agent-01", taskagent.TaskAgentStatusValues.Online, true), + sampleAgent(8, "agent-02", taskagent.TaskAgentStatusValues.Offline, true), + } + deps.tac.EXPECT().GetAgentPools(gomock.Any(), gomock.Any()).Return(&[]taskagent.TaskAgentPool{{Id: types.ToPtr(1), Name: types.ToPtr("1")}}, nil).AnyTimes() + deps.tac.EXPECT().GetAgents(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args taskagent.GetAgentsArgs) (*[]taskagent.TaskAgent, error) { + require.NotNil(t, args.PoolId) + assert.Equal(t, 1, *args.PoolId) + return &agents, nil + }, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = run(deps.cmd, &opts{targetArg: "myorg/1"}) + require.NoError(t, err) + + output := deps.stdout.String() + assert.Contains(t, output, "7") + assert.Contains(t, output, "agent-01") + assert.Contains(t, output, "8") + assert.Contains(t, output, "agent-02") + assert.Contains(t, output, "online") + assert.Contains(t, output, "offline") + assert.Contains(t, output, "true") + assert.Contains(t, output, "4.240.0") + assert.Contains(t, output, "Linux") +} + +func TestRunList_OrgFromArg(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "explicit-org") + agents := []taskagent.TaskAgent{sampleAgent(1, "agent-01", taskagent.TaskAgentStatusValues.Online, true)} + deps.tac.EXPECT().GetAgentPools(gomock.Any(), gomock.Any()).Return(&[]taskagent.TaskAgentPool{{Id: types.ToPtr(1), Name: types.ToPtr("1")}}, nil).AnyTimes() + deps.tac.EXPECT().GetAgents(gomock.Any(), gomock.Any()).Return(&agents, nil) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = run(deps.cmd, &opts{targetArg: "explicit-org/1"}) + require.NoError(t, err) + assert.Contains(t, deps.stdout.String(), "agent-01") +} + +func TestRunList_ProjectScopeRejected(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + err := run(deps.cmd, &opts{targetArg: "org/proj/1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not accept a project scope") +} + +func TestRunList_NoDefaultOrg(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, _, _ := iostreams.Test() + cmd := mocks.NewMockCmdContext(ctrl) + cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + cmd.EXPECT().ClientFactory().Return(mocks.NewMockClientFactory(ctrl)).AnyTimes() + + cfg := mocks.NewMockConfig(ctrl) + auth := mocks.NewMockAuthConfig(ctrl) + cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return("", fmt.Errorf("no default org")) + + err := run(cmd, &opts{targetArg: "1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no organization specified") +} + +func TestRunList_InvalidMaxItemsNegative(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + err := run(deps.cmd, &opts{targetArg: "org/1", maxItems: -5}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --max-items") +} + +func TestRunList_ClientFactoryError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, _, _ := iostreams.Test() + cmd := mocks.NewMockCmdContext(ctrl) + clientFact := mocks.NewMockClientFactory(ctrl) + cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + cmd.EXPECT().ClientFactory().Return(clientFact).AnyTimes() + + cfg := mocks.NewMockConfig(ctrl) + auth := mocks.NewMockAuthConfig(ctrl) + cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return("myorg", nil) + + clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(nil, fmt.Errorf("connection failed")) + + err := run(cmd, &opts{targetArg: "1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "connection failed") +} + +func TestRunList_SDKError(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + deps.tac.EXPECT().GetAgents(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("API error")) + + err := run(deps.cmd, &opts{targetArg: "org/1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "API error") +} + +func TestRunList_EmptyResult(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + agents := []taskagent.TaskAgent{} + deps.tac.EXPECT().GetAgents(gomock.Any(), gomock.Any()).Return(&agents, nil) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = run(deps.cmd, &opts{targetArg: "org/1"}) + require.NoError(t, err) +} + +func TestRunList_FilterByName(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + agents := []taskagent.TaskAgent{sampleAgent(1, "filtered-agent", taskagent.TaskAgentStatusValues.Online, true)} + deps.tac.EXPECT().GetAgents(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args taskagent.GetAgentsArgs) (*[]taskagent.TaskAgent, error) { + require.NotNil(t, args.AgentName) + assert.Equal(t, "filtered-agent", *args.AgentName) + return &agents, nil + }, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = run(deps.cmd, &opts{targetArg: "org/1", filter: "filtered-agent"}) + require.NoError(t, err) +} + +func TestRunList_IncludeCapabilities(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + agents := []taskagent.TaskAgent{sampleAgent(1, "agent-01", taskagent.TaskAgentStatusValues.Online, true)} + deps.tac.EXPECT().GetAgents(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args taskagent.GetAgentsArgs) (*[]taskagent.TaskAgent, error) { + require.NotNil(t, args.IncludeCapabilities) + assert.True(t, *args.IncludeCapabilities) + return &agents, nil + }, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = run(deps.cmd, &opts{targetArg: "org/1", includeCapabilities: true}) + require.NoError(t, err) +} + +func TestRunList_MaxItemsCaps(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + agents := []taskagent.TaskAgent{ + sampleAgent(1, "a1", taskagent.TaskAgentStatusValues.Online, true), + sampleAgent(2, "a2", taskagent.TaskAgentStatusValues.Online, true), + sampleAgent(3, "a3", taskagent.TaskAgentStatusValues.Online, true), + } + deps.tac.EXPECT().GetAgents(gomock.Any(), gomock.Any()).Return(&agents, nil) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = run(deps.cmd, &opts{targetArg: "org/1", maxItems: 1}) + require.NoError(t, err) + + output := deps.stdout.String() + assert.Contains(t, output, "a1") + assert.NotContains(t, output, "a2") + assert.NotContains(t, output, "a3") +} + +func TestRunList_MaxItemsExceedsResult(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + agents := []taskagent.TaskAgent{ + sampleAgent(1, "a1", taskagent.TaskAgentStatusValues.Online, true), + sampleAgent(2, "a2", taskagent.TaskAgentStatusValues.Online, true), + } + deps.tac.EXPECT().GetAgents(gomock.Any(), gomock.Any()).Return(&agents, nil) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = run(deps.cmd, &opts{targetArg: "org/1", maxItems: 100}) + require.NoError(t, err) + + output := deps.stdout.String() + assert.Contains(t, output, "a1") + assert.Contains(t, output, "a2") +} + +func TestRunList_JSONOutput(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + agents := []taskagent.TaskAgent{sampleAgent(7, "agent-01", taskagent.TaskAgentStatusValues.Online, true)} + deps.tac.EXPECT().GetAgents(gomock.Any(), gomock.Any()).Return(&agents, nil) + + exporter := util.NewJSONExporter() + + err := run(deps.cmd, &opts{targetArg: "org/1", exporter: exporter}) + require.NoError(t, err) + + var parsed []map[string]any + err = json.Unmarshal(deps.stdout.Bytes(), &parsed) + require.NoError(t, err) + require.Len(t, parsed, 1) + + assert.Equal(t, float64(7), parsed[0]["id"]) + assert.Equal(t, "agent-01", parsed[0]["name"]) + assert.Equal(t, "online", parsed[0]["status"]) +} + +func TestRunList_JSONOutputEmpty(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + agents := []taskagent.TaskAgent{} + deps.tac.EXPECT().GetAgents(gomock.Any(), gomock.Any()).Return(&agents, nil) + + exporter := util.NewJSONExporter() + + err := run(deps.cmd, &opts{targetArg: "org/1", exporter: exporter}) + require.NoError(t, err) + + assert.Equal(t, "[]\n", deps.stdout.String()) +} + +func TestRunList_JSONOutputAllFields(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + createdOn := azuredevops.Time{Time: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)} + agents := []taskagent.TaskAgent{ + { + Id: types.ToPtr(7), + Name: types.ToPtr("agent-01"), + Status: types.ToPtr(taskagent.TaskAgentStatusValues.Online), + Enabled: types.ToPtr(true), + Version: types.ToPtr("4.240.0"), + OsDescription: types.ToPtr("Linux"), + MaxParallelism: types.ToPtr(2), + CreatedOn: &createdOn, + }, + } + deps.tac.EXPECT().GetAgents(gomock.Any(), gomock.Any()).Return(&agents, nil) + + exporter := util.NewJSONExporter() + + err := run(deps.cmd, &opts{targetArg: "org/1", exporter: exporter}) + require.NoError(t, err) + + var parsed []map[string]any + err = json.Unmarshal(deps.stdout.Bytes(), &parsed) + require.NoError(t, err) + require.Len(t, parsed, 1) + + assert.Equal(t, float64(7), parsed[0]["id"]) + assert.Equal(t, "agent-01", parsed[0]["name"]) + assert.Equal(t, "online", parsed[0]["status"]) + assert.Equal(t, true, parsed[0]["enabled"]) + assert.Equal(t, "4.240.0", parsed[0]["version"]) + assert.Equal(t, "Linux", parsed[0]["osDescription"]) + assert.Equal(t, float64(2), parsed[0]["maxParallelism"]) + assert.Contains(t, parsed[0]["createdOn"], "2024-01-15") +} From 7ddb1181ec60fb5e2e661cce8cb9ba12f636177b Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 14:39:03 +0000 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20=F0=9F=93=84=20add=20documentation?= =?UTF-8?q?=20for=20pipelines=20agent=20list=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/azdo_pipelines_agent_list.md | 78 +++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/azdo_pipelines_agent_list.md diff --git a/docs/azdo_pipelines_agent_list.md b/docs/azdo_pipelines_agent_list.md new file mode 100644 index 0000000..282995b --- /dev/null +++ b/docs/azdo_pipelines_agent_list.md @@ -0,0 +1,78 @@ +## Command `azdo pipelines agent list` + +``` +azdo pipelines agent list [ORGANIZATION/]POOL [flags] +``` + +List every agent in an Azure DevOps agent pool. +The pool is identified by a positional target that can be a numeric ID or a name. + + +### Options + + +* `-f`, `--filter` `string` + + Filter agents by name + +* `--include-capabilities` + + Include agent capabilities in the response + +* `-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`) + + Optional client-side cap on results + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `ls` +- `l` + +### JSON Fields + +`_links`, `accessPoint`, `assignedAgentCloudRequest`, `assignedRequest`, `authorization`, `createdOn`, `enabled`, `id`, `lastCompletedRequest`, `maxParallelism`, `name`, `osDescription`, `pendingUpdate`, `properties`, `provisioningState`, `status`, `statusChangedOn`, `systemCapabilities`, `userCapabilities`, `version` + +### Examples + +```bash +# List all agents in pool 1 +$ azdo pipelines agent list 1 + +# List agents in a named pool +$ azdo pipelines agent list Default + +# List agents in pool 1 in a specific organization +$ azdo pipelines agent list "myorg/1" + +# List agents in a named pool in a specific organization +$ azdo pipelines agent list "myorg/Default" + +# List agents filtered by name +$ azdo pipelines agent list 1 --filter "my-agent" + +# List agents filtered by name in a specific organization +$ azdo pipelines agent list "myorg/1" --filter "my-agent" + +# List agents with capabilities included +$ azdo pipelines agent list 1 --include-capabilities + +# Output as JSON +$ azdo pipelines agent list 1 --json +``` + +### See also + +* [azdo pipelines agent](./azdo_pipelines_agent.md) From c946b8c98eee60e1ee8d10ce6f38b606f4b77e7b Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sat, 6 Jun 2026 14:42:31 +0000 Subject: [PATCH 7/7] chore(makefile): add fmt and test targets --- Makefile | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 78dba41..a01929e 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,8 @@ endif TIMEOUT ?= 120m GOMAXPROCS ?= 5 TESTARGS ?= ./... +COVEROUT ?= .coverage/coverage.out +TESTRESULTS ?= test-results.xml .PHONY: build build: ## build program @@ -20,12 +22,21 @@ dist: ## create new release .PHONY: clean clean: ## clean repositorty rm -f azdo - rm -rf dist + rm -rf dist .coverage test-results.xml + +.PHONY: fmt +fmt: ## format source with gofumpt + gofumpt -w ./internal ./cmd .PHONY: lint lint: ## lint source @echo "Check for golangci-lint"; [ -e "$(shell which golangci-lint)" ] - @echo "Executing golangci-lint"; golangci-lint run -v --timeout $(TIMEOUT) + @echo "Executing golangci-lint"; golangci-lint run -v --timeout $(TIMEOUT) ./internal + +.PHONY: test +test: ## run unit tests with gotestsum + mkdir -p $(dir $(COVEROUT)) \ + && gotestsum --format testname --junitfile $(TESTRESULTS) -- -timeout=$(TIMEOUT) -coverprofile=$(COVEROUT) $(TESTARGS) .PHONY: tidy tidy: ## call go mod tidy on all existing go.mod files