From a01db8d833c7c776a29a45f590a072156c4d5a15 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Fri, 5 Jun 2026 19:54:21 +0000 Subject: [PATCH 1/3] feat(pipelines/pool): add show command for agent pools --- internal/cmd/pipelines/pipelines.go | 2 + internal/cmd/pipelines/pool/pool.go | 34 ++ internal/cmd/pipelines/pool/show/show.go | 146 +++++ internal/cmd/pipelines/pool/show/show.tpl | 22 + internal/cmd/pipelines/pool/show/show_test.go | 562 ++++++++++++++++++ 5 files changed, 766 insertions(+) create mode 100644 internal/cmd/pipelines/pool/pool.go create mode 100644 internal/cmd/pipelines/pool/show/show.go create mode 100644 internal/cmd/pipelines/pool/show/show.tpl create mode 100644 internal/cmd/pipelines/pool/show/show_test.go diff --git a/internal/cmd/pipelines/pipelines.go b/internal/cmd/pipelines/pipelines.go index bba3a62..08c6209 100644 --- a/internal/cmd/pipelines/pipelines.go +++ b/internal/cmd/pipelines/pipelines.go @@ -3,6 +3,7 @@ package pipelines import ( "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/agent" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/pool" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/variablegroup" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -16,5 +17,6 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(variablegroup.NewCmd(ctx)) cmd.AddCommand(agent.NewCmd(ctx)) + cmd.AddCommand(pool.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/pipelines/pool/pool.go b/internal/cmd/pipelines/pool/pool.go new file mode 100644 index 0000000..107ae83 --- /dev/null +++ b/internal/cmd/pipelines/pool/pool.go @@ -0,0 +1,34 @@ +package pool + +import ( + "github.com/MakeNowJust/heredoc/v2" + "github.com/spf13/cobra" + + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/pool/show" + "github.com/tmeckel/azdo-cli/internal/cmd/util" +) + +func NewCmd(ctx util.CmdContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "pool", + Short: "Manage agent pools", + Long: heredoc.Doc(` + Manage Azure DevOps agent pools. Agent pools are logical groupings + of agents that target build, release, and other pipeline jobs. + `), + Example: heredoc.Doc(` + # Show a pool by ID + azdo pipelines pool show 42 + + # Show a pool by name + azdo pipelines pool show 'Default' + + # Show a pool in a specific organization + azdo pipelines pool show 'myorg/Default' + `), + Aliases: []string{"pools"}, + } + + cmd.AddCommand(show.NewCmd(ctx)) + return cmd +} diff --git a/internal/cmd/pipelines/pool/show/show.go b/internal/cmd/pipelines/pool/show/show.go new file mode 100644 index 0000000..7a1d4ae --- /dev/null +++ b/internal/cmd/pipelines/pool/show/show.go @@ -0,0 +1,146 @@ +package show + +import ( + _ "embed" + "fmt" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" + "github.com/spewerspew/spew" + "github.com/spf13/cobra" + "go.uber.org/zap" + + agentShared "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/agent/shared" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/template" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type templateData struct { + Pool *taskagent.TaskAgentPool +} + +type showOptions struct { + targetArg string + raw bool + exporter util.Exporter +} + +//go:embed show.tpl +var showTempl string + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &showOptions{} + + cmd := &cobra.Command{ + Use: "show [ORGANIZATION/]POOL", + Short: "Show details of an agent pool", + Long: heredoc.Doc(` + Display the details of a single Azure DevOps agent pool. + The pool is identified by integer ID or name, with an + optional organization prefix. + `), + Example: heredoc.Doc(` + # Show a pool by ID + azdo pipelines pool show 42 + + # Show a pool by name + azdo pipelines pool show 'Default' + + # Show a pool in a specific organization + azdo pipelines pool show 'myorg/Default' + `), + Aliases: []string{"view", "status"}, + Args: util.ExactArgs(1, "pool target is required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runShow(ctx, opts) + }, + } + + cmd.Flags().BoolVarP(&opts.raw, "raw", "r", false, "Dump raw pool object to stderr") + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "name", "poolType", "isHosted", "isLegacy", + "autoProvision", "autoUpdate", "createdOn", "createdBy", + "owner", "options", "properties", "scope", "size", "targetSize", + }) + + return cmd +} + +func runShow(cmdCtx util.CmdContext, opts *showOptions) error { + ios, err := cmdCtx.IOStreams() + if err != nil { + return err + } + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParseTargetWithDefaultOrganization(cmdCtx, opts.targetArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + if len(scope.Targets) == 0 { + return util.FlagErrorf("pool target is required") + } + poolTarget := scope.Targets[0] + + taskClient, err := cmdCtx.ClientFactory().TaskAgent(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create task agent client: %w", err) + } + + poolID, err := agentShared.ResolvePool(cmdCtx, taskClient, poolTarget) + if err != nil { + return err + } + + zap.L().Debug( + "fetching pool", + zap.String("organization", scope.Organization), + zap.Int("poolId", poolID), + ) + + pool, err := taskClient.GetAgentPool(cmdCtx.Context(), taskagent.GetAgentPoolArgs{ + PoolId: types.ToPtr(poolID), + }) + if err != nil { + return fmt.Errorf("failed to get pool: %w", err) + } + if pool == nil { + return fmt.Errorf("pool %q not found", poolTarget) + } + + if opts.raw { + ios.StopProgressIndicator() + spew.Dump(pool) + return nil + } + + if opts.exporter != nil { + ios.StopProgressIndicator() + return opts.exporter.Write(ios, pool) + } + + ios.StopProgressIndicator() + + t := template.New( + ios.Out, + ios.TerminalWidth(), + ios.ColorEnabled(), + ). + WithTheme(ios.TerminalTheme()). + WithFuncs(map[string]any{ + "hasText": template.HasText, + "s": template.StringOrEmpty, + "u": template.UUIDString, + }) + + err = t.Parse(showTempl) + if err != nil { + return err + } + + return t.ExecuteData(templateData{Pool: pool}) +} diff --git a/internal/cmd/pipelines/pool/show/show.tpl b/internal/cmd/pipelines/pool/show/show.tpl new file mode 100644 index 0000000..e57e84b --- /dev/null +++ b/internal/cmd/pipelines/pool/show/show.tpl @@ -0,0 +1,22 @@ +{{bold "id:"}} {{.Pool.Id}} +{{bold "name:"}} {{s .Pool.Name}} +{{if hasText (s .Pool.PoolType)}}{{bold "type:"}} {{.Pool.PoolType}} +{{end -}} +{{if hasText (u .Pool.Scope)}}{{bold "scope:"}} {{u .Pool.Scope}} +{{end -}} +{{if .Pool.Size}}{{bold "size:"}} {{.Pool.Size}} +{{end -}} +{{if .Pool.IsHosted}}{{bold "is hosted:"}} {{.Pool.IsHosted}} +{{end -}} +{{if .Pool.IsLegacy}}{{bold "is legacy:"}} {{.Pool.IsLegacy}} +{{end -}} +{{if .Pool.AutoProvision}}{{bold "auto provision:"}} {{.Pool.AutoProvision}} +{{end -}} +{{if .Pool.AutoUpdate}}{{bold "auto update:"}} {{.Pool.AutoUpdate}} +{{end -}} +{{if .Pool.CreatedOn}}{{bold "created on:"}} {{timeago .Pool.CreatedOn.Time}} ({{timefmt "2006-01-02 15:04 MST" .Pool.CreatedOn.Time}}) +{{end -}} +{{if .Pool.CreatedBy}}{{if hasText (s .Pool.CreatedBy.DisplayName)}}{{bold "created by:"}} {{s .Pool.CreatedBy.DisplayName}} ({{s .Pool.CreatedBy.UniqueName}}) +{{end}}{{end -}} +{{if .Pool.Owner}}{{if hasText (s .Pool.Owner.DisplayName)}}{{bold "owner:"}} {{s .Pool.Owner.DisplayName}} ({{s .Pool.Owner.UniqueName}}) +{{end}}{{end -}} diff --git a/internal/cmd/pipelines/pool/show/show_test.go b/internal/cmd/pipelines/pool/show/show_test.go new file mode 100644 index 0000000..b3d4e6f --- /dev/null +++ b/internal/cmd/pipelines/pool/show/show_test.go @@ -0,0 +1,562 @@ +package show + +import ( + "bytes" + "context" + "fmt" + "regexp" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "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" + "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/types" +) + +type fakeShowDeps struct { + ctrl *gomock.Controller + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + taskClient *mocks.MockTaskAgentClient + config *mocks.MockConfig + auth *mocks.MockAuthConfig + ios *iostreams.IOStreams + stdout *bytes.Buffer +} + +var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func cleanOutput(out *bytes.Buffer) string { + return ansiRegexp.ReplaceAllString(out.String(), "") +} + +func setupFakeDeps(t *testing.T) *fakeShowDeps { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStderrTTY(true) + + deps := &fakeShowDeps{ + ctrl: ctrl, + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + taskClient: mocks.NewMockTaskAgentClient(ctrl), + config: mocks.NewMockConfig(ctrl), + auth: mocks.NewMockAuthConfig(ctrl), + ios: io, + stdout: out, + } + + deps.cmd.EXPECT().IOStreams().Return(deps.ios, nil).AnyTimes() + deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + + return deps +} + +func (d *fakeShowDeps) setupDefaultOrg(org string) { + d.cmd.EXPECT().Config().Return(d.config, nil).AnyTimes() + d.config.EXPECT().Authentication().Return(d.auth).AnyTimes() + d.auth.EXPECT().GetDefaultOrganization().Return(org, nil).AnyTimes() +} + +func samplePool() *taskagent.TaskAgentPool { + return &taskagent.TaskAgentPool{ + Id: types.ToPtr(7), + Name: types.ToPtr("Default"), + PoolType: &taskagent.TaskAgentPoolTypeValues.Automation, + IsHosted: types.ToPtr(true), + IsLegacy: types.ToPtr(false), + Size: types.ToPtr(3), + AutoProvision: types.ToPtr(true), + AutoUpdate: types.ToPtr(true), + Scope: types.ToPtr(uuid.MustParse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), + CreatedOn: &azuredevops.Time{Time: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)}, + CreatedBy: &webapi.IdentityRef{ + DisplayName: types.ToPtr("Alice"), + UniqueName: types.ToPtr("alice@contoso.com"), + }, + Owner: &webapi.IdentityRef{ + DisplayName: types.ToPtr("Bob"), + UniqueName: types.ToPtr("bob@contoso.com"), + }, + } +} + +func TestNewCmd_RegistersAsShowLeaf(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Equal(t, "show", cmd.Name()) + assert.Contains(t, cmd.Aliases, "view") + assert.Contains(t, cmd.Aliases, "status") + assert.True(t, strings.HasPrefix(cmd.Use, "show [ORGANIZATION/]POOL")) + assert.NotNil(t, cmd.RunE) +} + +func TestNewCmd_RequiresOneArg(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.setupDefaultOrg("myorg") + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "pool target is required") +} + +func TestRunShow_ResolveByPositiveInteger(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + // For numeric "7", ResolvePool uses Atoi directly; no GetAgentPools call. + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args taskagent.GetAgentPoolArgs) (*taskagent.TaskAgentPool, error) { + assert.Equal(t, 7, *args.PoolId) + return samplePool(), nil + }) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) +} + +func TestRunShow_ResolveByName(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + poolList := []taskagent.TaskAgentPool{ + {Id: types.ToPtr(7), Name: types.ToPtr("Default")}, + } + deps.taskClient.EXPECT().GetAgentPools(gomock.Any(), gomock.Any()). + Return(&poolList, nil) + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args taskagent.GetAgentPoolArgs) (*taskagent.TaskAgentPool, error) { + assert.Equal(t, 7, *args.PoolId) + return samplePool(), nil + }) + + opts := &showOptions{targetArg: "myorg/Default"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) +} + +func TestRunShow_BasicCall(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(samplePool(), nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "id: 7") + assert.Contains(t, output, "name: Default") +} + +func TestRunShow_OrgFromConfigDefault(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.setupDefaultOrg("myorg") + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(samplePool(), nil) + + opts := &showOptions{targetArg: "7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "id: 7") +} + +func TestRunShow_OrgFromPositional(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "otherorg").Return(deps.taskClient, nil) + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(samplePool(), nil) + + opts := &showOptions{targetArg: "otherorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "id: 7") +} + +func TestRunShow_TemplateOutput_BasicFields(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(samplePool(), nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "id: 7") + assert.Contains(t, output, "name: Default") + assert.Contains(t, output, "size: 3") + assert.Contains(t, output, "created by: Alice (alice@contoso.com)") + assert.Contains(t, output, "owner: Bob (bob@contoso.com)") +} + +func TestRunShow_TemplateOutput_NoCreatedBy(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + pool := samplePool() + pool.CreatedBy = nil + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(pool, nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.NotContains(t, output, "created by:") +} + +func TestRunShow_TemplateOutput_NoOwner(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + pool := samplePool() + pool.Owner = nil + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(pool, nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.NotContains(t, output, "owner:") +} + +func TestRunShow_TemplateOutput_AutoUpdateTrue(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + pool := samplePool() + pool.AutoUpdate = types.ToPtr(true) + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(pool, nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "auto update: true") +} + +func TestRunShow_TemplateOutput_AutoUpdateFalse(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + pool := samplePool() + pool.AutoUpdate = types.ToPtr(false) + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(pool, nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "auto update: false") +} + +func TestRunShow_TemplateOutput_AutoProvisionTrue(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + pool := samplePool() + pool.AutoProvision = types.ToPtr(true) + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(pool, nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "auto provision: true") +} + +func TestRunShow_TemplateOutput_IsHosted(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + pool := samplePool() + pool.IsHosted = types.ToPtr(true) + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(pool, nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "is hosted: true") +} + +func TestRunShow_TemplateOutput_IsLegacy(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + pool := samplePool() + pool.IsLegacy = types.ToPtr(true) + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(pool, nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "is legacy: true") +} + +func TestRunShow_TemplateOutput_CreatedOn(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(samplePool(), nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "created on:") + assert.Contains(t, output, "2024-01-15") +} + +func TestRunShow_JSONOutput(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(samplePool(), nil) + + exporter := util.NewJSONExporter() + opts := &showOptions{targetArg: "myorg/7", exporter: exporter} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, `"id"`) + assert.Contains(t, output, `"name"`) + assert.Contains(t, output, `"poolType"`) + assert.Contains(t, output, `"isHosted"`) + assert.Contains(t, output, `"createdOn"`) + assert.Contains(t, output, `"createdBy"`) + assert.Contains(t, output, `7`) + assert.Contains(t, output, `"Default"`) + assert.True(t, strings.HasPrefix(strings.TrimSpace(output), "{")) +} + +func TestRunShow_RawFlag(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + pool := samplePool() + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(pool, nil) + + opts := &showOptions{targetArg: "myorg/7", raw: true} + err := runShow(deps.cmd, opts) + require.NoError(t, err) +} + +func TestRunShow_ClientFactoryError(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + expectedErr := fmt.Errorf("connection failed") + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(nil, expectedErr) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.Error(t, err) + assert.ErrorIs(t, err, expectedErr) +} + +func TestRunShow_SDKError(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + expectedErr := fmt.Errorf("API error") + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(nil, expectedErr) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.Error(t, err) + assert.ErrorIs(t, err, expectedErr) +} + +func TestRunShow_PoolNotFound(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(nil, nil) + + opts := &showOptions{targetArg: "myorg/999"} + err := runShow(deps.cmd, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestRunShow_InvalidTarget(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + deps.taskClient.EXPECT().GetAgentPools(gomock.Any(), gomock.Any()). + Return(&[]taskagent.TaskAgentPool{}, nil) + + opts := &showOptions{targetArg: "myorg/NonExistentPool"} + err := runShow(deps.cmd, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestRunShow_PoolTypeAutomation(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + pool := samplePool() + pool.PoolType = &taskagent.TaskAgentPoolTypeValues.Automation + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(pool, nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "automation") +} + +func TestRunShow_PoolTypeDeployment(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + pool := samplePool() + pool.PoolType = &taskagent.TaskAgentPoolTypeValues.Deployment + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(pool, nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "deployment") +} + +func TestNewCmd_HasFlags(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + rawFlag := cmd.Flag("raw") + require.NotNil(t, rawFlag) + assert.Equal(t, "r", rawFlag.Shorthand) + + jsonFlag := cmd.Flag("json") + require.NotNil(t, jsonFlag) +} + +func TestRunShow_ParentCommandWiring(t *testing.T) { + t.Parallel() + + // Verify the cobra path: pipelines pool show + rootCmd := &cobra.Command{Use: "root"} + poolCmd := &cobra.Command{Use: "pool"} + showCmd := NewCmd(nil) + poolCmd.AddCommand(showCmd) + rootCmd.AddCommand(poolCmd) + + found, _, err := rootCmd.Find([]string{"pool", "show", "myorg/7"}) + require.NoError(t, err) + assert.Equal(t, "show", found.Name()) +} + +func TestRunShow_TemplateOutput_Scope(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(samplePool(), nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "scope:") + assert.NotContains(t, output, "scope: \n") +} + +func TestRunShow_TemplateOutput_ScopeNil(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + pool := samplePool() + pool.Scope = nil + deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(pool, nil) + + opts := &showOptions{targetArg: "myorg/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.NotContains(t, output, "scope:") +} From f383b6df8c2b1c0a8454d8d8798bcaf70288a428 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Fri, 5 Jun 2026 19:54:58 +0000 Subject: [PATCH 2/3] docs(pipelines): add documentation for pipelines pool commands --- docs/azdo_help_reference.md | 27 +++++++++++++++ docs/azdo_pipelines.md | 1 + docs/azdo_pipelines_pool.md | 30 +++++++++++++++++ docs/azdo_pipelines_pool_show.md | 56 ++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 docs/azdo_pipelines_pool.md create mode 100644 docs/azdo_pipelines_pool_show.md diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 14edb94..393c207 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -263,6 +263,33 @@ Aliases view, status ``` +### `azdo pipelines pool` + +Manage agent pools + +Aliases + +``` +pools +``` + +#### `azdo pipelines pool show [ORGANIZATION/]POOL [flags]` + +Show details of an agent pool + +``` +-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. +-r, --raw Dump raw pool object to stderr +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +view, status +``` + ### `azdo pipelines variable-group` Manage Azure DevOps variable groups diff --git a/docs/azdo_pipelines.md b/docs/azdo_pipelines.md index 4ec1b9c..9e93ab0 100644 --- a/docs/azdo_pipelines.md +++ b/docs/azdo_pipelines.md @@ -5,6 +5,7 @@ Manage Azure DevOps pipelines ### Available commands * [azdo pipelines agent](./azdo_pipelines_agent.md) +* [azdo pipelines pool](./azdo_pipelines_pool.md) * [azdo pipelines variable-group](./azdo_pipelines_variable-group.md) ### ALIASES diff --git a/docs/azdo_pipelines_pool.md b/docs/azdo_pipelines_pool.md new file mode 100644 index 0000000..5a0311b --- /dev/null +++ b/docs/azdo_pipelines_pool.md @@ -0,0 +1,30 @@ +## Command `azdo pipelines pool` + +Manage Azure DevOps agent pools. Agent pools are logical groupings +of agents that target build, release, and other pipeline jobs. + + +### Available commands + +* [azdo pipelines pool show](./azdo_pipelines_pool_show.md) + +### ALIASES + +- `pools` + +### Examples + +```bash +# Show a pool by ID +azdo pipelines pool show 42 + +# Show a pool by name +azdo pipelines pool show 'Default' + +# Show a pool in a specific organization +azdo pipelines pool show 'myorg/Default' +``` + +### See also + +* [azdo pipelines](./azdo_pipelines.md) diff --git a/docs/azdo_pipelines_pool_show.md b/docs/azdo_pipelines_pool_show.md new file mode 100644 index 0000000..c9368d2 --- /dev/null +++ b/docs/azdo_pipelines_pool_show.md @@ -0,0 +1,56 @@ +## Command `azdo pipelines pool show` + +``` +azdo pipelines pool show [ORGANIZATION/]POOL [flags] +``` + +Display the details of a single Azure DevOps agent pool. +The pool is identified by integer ID or name, with an +optional organization prefix. + + +### 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. + +* `-r`, `--raw` + + Dump raw pool object to stderr + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `view` +- `status` + +### JSON Fields + +`autoProvision`, `autoUpdate`, `createdBy`, `createdOn`, `id`, `isHosted`, `isLegacy`, `name`, `options`, `owner`, `poolType`, `properties`, `scope`, `size`, `targetSize` + +### Examples + +```bash +# Show a pool by ID +azdo pipelines pool show 42 + +# Show a pool by name +azdo pipelines pool show 'Default' + +# Show a pool in a specific organization +azdo pipelines pool show 'myorg/Default' +``` + +### See also + +* [azdo pipelines pool](./azdo_pipelines_pool.md) From 90fc5290e524e728b04eaa290dd30295629214d4 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Fri, 5 Jun 2026 19:55:39 +0000 Subject: [PATCH 3/3] =?UTF-8?q?style:=20=F0=9F=92=85=F0=9F=8F=BC=20fix=20c?= =?UTF-8?q?ode=20formatting=20and=20alignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align struct field assignments and map entries for consistent formatting across command packages. --- internal/cmd/pipelines/agent/shared/resolve_test.go | 2 -- internal/cmd/pipelines/variablegroup/show/show.go | 4 ++-- internal/cmd/security/permission/shared/target.go | 6 +++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/internal/cmd/pipelines/agent/shared/resolve_test.go b/internal/cmd/pipelines/agent/shared/resolve_test.go index 3570c75..ab6f4f9 100644 --- a/internal/cmd/pipelines/agent/shared/resolve_test.go +++ b/internal/cmd/pipelines/agent/shared/resolve_test.go @@ -349,8 +349,6 @@ func TestResolveAgent_Ambiguous(t *testing.T) { assert.Contains(t, err.Error(), "multiple agents") } - - func TestResolvePoolAgent_NegativePoolID(t *testing.T) { t.Parallel() ctrl := newCtrl(t) diff --git a/internal/cmd/pipelines/variablegroup/show/show.go b/internal/cmd/pipelines/variablegroup/show/show.go index 173b2d1..d4f6989 100644 --- a/internal/cmd/pipelines/variablegroup/show/show.go +++ b/internal/cmd/pipelines/variablegroup/show/show.go @@ -203,8 +203,8 @@ func run(cmdCtx util.CmdContext, o *opts) error { return types.ToPtr(types.GetValue(permission.Authorized, false)) }, "i": func(v *int) string { return strconv.Itoa(types.GetValue(v, 0)) }, - "s": template.StringOrEmpty, - "b": template.BoolString, + "s": template.StringOrEmpty, + "b": template.BoolString, "ts": func(v *azuredevops.Time) string { return types.GetValue(formatTimePtr(v), "") }, "identity": func(id *webapi.IdentityRef) string { if id == nil { diff --git a/internal/cmd/security/permission/shared/target.go b/internal/cmd/security/permission/shared/target.go index a484575..84e2a65 100644 --- a/internal/cmd/security/permission/shared/target.go +++ b/internal/cmd/security/permission/shared/target.go @@ -47,7 +47,7 @@ func ParseSubjectTarget(ctx util.CmdContext, input string) (*SubjectTarget, erro return nil, err } return &SubjectTarget{ - Path: *scope, + Path: *scope, Subject: "", }, nil case 2: @@ -60,7 +60,7 @@ func ParseSubjectTarget(ctx util.CmdContext, input string) (*SubjectTarget, erro return nil, err } return &SubjectTarget{ - Path: *scope, + Path: *scope, Subject: subject, }, nil case 3: @@ -78,7 +78,7 @@ func ParseSubjectTarget(ctx util.CmdContext, input string) (*SubjectTarget, erro return nil, err } return &SubjectTarget{ - Path: *scope, + Path: *scope, Subject: subject, }, nil default: