From 18ff60d7317fe998cf5ee075cc8bd0630157ab5b Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Tue, 14 Apr 2026 06:32:02 +0000 Subject: [PATCH 1/4] fix: prompt user to select agent queue for AzDO pipeline config (#4248) Instead of hardcoding the agent queue name to "Default", query all usable queues (filtered by ActionFilter: Use) and: - Auto-select if only one queue is available - Prompt the user to choose if multiple queues exist - Return a clear error if no queues are available This follows the same interactive Select pattern used for AzDo project selection in project.go. Fixes #4248 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/azdo/pipeline.go | 62 +++++++++++++++++---- cli/azd/pkg/azdo/pipeline_test.go | 91 +++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 cli/azd/pkg/azdo/pipeline_test.go diff --git a/cli/azd/pkg/azdo/pipeline.go b/cli/azd/pkg/azdo/pipeline.go index 18419759d83..39595fc8d7e 100644 --- a/cli/azd/pkg/azdo/pipeline.go +++ b/cli/azd/pkg/azdo/pipeline.go @@ -27,29 +27,67 @@ func createBuildDefinitionVariable(value string, isSecret bool, allowOverride bo } } -// returns the default agent queue. This is used to associate a Pipeline with a default agent pool queue +// selectAgentQueue picks the agent queue to use from the provided list. +// Auto-selects if only one queue exists, prompts the user if multiple. +func selectAgentQueue( + ctx context.Context, + projectId string, + queues []taskagent.TaskAgentQueue, + console input.Console, +) (*taskagent.TaskAgentQueue, error) { + if len(queues) == 0 { + return nil, fmt.Errorf("no agent queues available in project %s", projectId) + } + + if len(queues) == 1 { + console.Message(ctx, fmt.Sprintf("Using agent queue: %s", *queues[0].Name)) + return &queues[0], nil + } + + options := make([]string, 0, len(queues)) + for _, q := range queues { + options = append(options, *q.Name) + } + + idx, err := console.Select(ctx, input.ConsoleOptions{ + Message: "Choose an agent queue for the pipeline", + Options: options, + }) + if err != nil { + return nil, fmt.Errorf("selecting agent queue: %w", err) + } + + return &queues[idx], nil +} + +// getAgentQueue returns the agent queue to associate with the pipeline. +// It queries all usable queues and auto-selects if only one is available, +// or prompts the user to choose if multiple queues exist. func getAgentQueue( ctx context.Context, projectId string, connection *azuredevops.Connection, + console input.Console, ) (*taskagent.TaskAgentQueue, error) { client, err := taskagent.NewClient(ctx, connection) if err != nil { return nil, err } - getAgentQueuesArgs := taskagent.GetAgentQueuesArgs{ - Project: &projectId, - } - queues, err := client.GetAgentQueues(ctx, getAgentQueuesArgs) + + actionFilter := taskagent.TaskAgentQueueActionFilterValues.Use + queues, err := client.GetAgentQueues(ctx, taskagent.GetAgentQueuesArgs{ + Project: &projectId, + ActionFilter: &actionFilter, + }) if err != nil { - return nil, err + return nil, fmt.Errorf("listing agent queues: %w", err) } - for _, queue := range *queues { - if *queue.Name == "Default" { - return &queue, nil - } + + if queues == nil { + return nil, fmt.Errorf("no agent queues available in project %s", projectId) } - return nil, fmt.Errorf("could not find a default agent queue in project %s", projectId) + + return selectAgentQueue(ctx, projectId, *queues, console) } // find pipeline by name @@ -128,7 +166,7 @@ func CreatePipeline( return definition, nil } - queue, err := getAgentQueue(ctx, projectId, connection) + queue, err := getAgentQueue(ctx, projectId, connection, console) if err != nil { return nil, err } diff --git a/cli/azd/pkg/azdo/pipeline_test.go b/cli/azd/pkg/azdo/pipeline_test.go new file mode 100644 index 00000000000..449587a4519 --- /dev/null +++ b/cli/azd/pkg/azdo/pipeline_test.go @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdo + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_selectAgentQueue(t *testing.T) { + t.Run("no queues returns error", func(t *testing.T) { + mockConsole := mockinput.NewMockConsole() + + queue, err := selectAgentQueue(t.Context(), "project-1", nil, mockConsole) + assert.Nil(t, queue) + assert.ErrorContains(t, err, "no agent queues available in project project-1") + }) + + t.Run("empty queues returns error", func(t *testing.T) { + mockConsole := mockinput.NewMockConsole() + + queue, err := selectAgentQueue(t.Context(), "project-1", []taskagent.TaskAgentQueue{}, mockConsole) + assert.Nil(t, queue) + assert.ErrorContains(t, err, "no agent queues available in project project-1") + }) + + t.Run("single queue auto-selects", func(t *testing.T) { + mockConsole := mockinput.NewMockConsole() + queueName := "Azure Pipelines" + queueId := 1 + queues := []taskagent.TaskAgentQueue{ + {Id: &queueId, Name: &queueName}, + } + + queue, err := selectAgentQueue(t.Context(), "project-1", queues, mockConsole) + require.NoError(t, err) + require.NotNil(t, queue) + assert.Equal(t, "Azure Pipelines", *queue.Name) + assert.Equal(t, 1, *queue.Id) + }) + + t.Run("multiple queues prompts user", func(t *testing.T) { + mockConsole := mockinput.NewMockConsole() + mockConsole.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Choose an agent queue for the pipeline" + }).Respond(1) // select second queue + + name1 := "Default" + id1 := 1 + name2 := "Azure Pipelines" + id2 := 2 + queues := []taskagent.TaskAgentQueue{ + {Id: &id1, Name: &name1}, + {Id: &id2, Name: &name2}, + } + + queue, err := selectAgentQueue(t.Context(), "project-1", queues, mockConsole) + require.NoError(t, err) + require.NotNil(t, queue) + assert.Equal(t, "Azure Pipelines", *queue.Name) + assert.Equal(t, 2, *queue.Id) + }) + + t.Run("multiple queues selects first", func(t *testing.T) { + mockConsole := mockinput.NewMockConsole() + mockConsole.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Choose an agent queue for the pipeline" + }).Respond(0) // select first queue + + name1 := "Default" + id1 := 1 + name2 := "Azure Pipelines" + id2 := 2 + queues := []taskagent.TaskAgentQueue{ + {Id: &id1, Name: &name1}, + {Id: &id2, Name: &name2}, + } + + queue, err := selectAgentQueue(t.Context(), "project-1", queues, mockConsole) + require.NoError(t, err) + require.NotNil(t, queue) + assert.Equal(t, "Default", *queue.Name) + assert.Equal(t, 1, *queue.Id) + }) +} From 8fbc1c69b0e4e25f623a05cb232bf0ad37c9fa47 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Tue, 14 Apr 2026 06:44:10 +0000 Subject: [PATCH 2/4] fix: address Copilot review feedback (iteration 1) - Add bounds check on Select index to prevent potential panics - Add DefaultValue for no-prompt mode support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/azdo/pipeline.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cli/azd/pkg/azdo/pipeline.go b/cli/azd/pkg/azdo/pipeline.go index 39595fc8d7e..66f714d6070 100644 --- a/cli/azd/pkg/azdo/pipeline.go +++ b/cli/azd/pkg/azdo/pipeline.go @@ -50,13 +50,18 @@ func selectAgentQueue( } idx, err := console.Select(ctx, input.ConsoleOptions{ - Message: "Choose an agent queue for the pipeline", - Options: options, + Message: "Choose an agent queue for the pipeline", + Options: options, + DefaultValue: options[0], }) if err != nil { return nil, fmt.Errorf("selecting agent queue: %w", err) } + if idx < 0 || idx >= len(queues) { + return nil, fmt.Errorf("selecting agent queue: invalid queue index %d for %d queues", idx, len(queues)) + } + return &queues[idx], nil } From a324e4b57b9fd211a1d5c220ea54a7894786ce8f Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Tue, 14 Apr 2026 06:54:14 +0000 Subject: [PATCH 3/4] fix: filter queues with nil/empty names to prevent panics (iteration 2) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/azdo/pipeline.go | 27 ++++++++++++++++++--------- cli/azd/pkg/azdo/pipeline_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/cli/azd/pkg/azdo/pipeline.go b/cli/azd/pkg/azdo/pipeline.go index 66f714d6070..292ee686bd2 100644 --- a/cli/azd/pkg/azdo/pipeline.go +++ b/cli/azd/pkg/azdo/pipeline.go @@ -29,23 +29,32 @@ func createBuildDefinitionVariable(value string, isSecret bool, allowOverride bo // selectAgentQueue picks the agent queue to use from the provided list. // Auto-selects if only one queue exists, prompts the user if multiple. +// Queues with nil or empty names are filtered out. func selectAgentQueue( ctx context.Context, projectId string, queues []taskagent.TaskAgentQueue, console input.Console, ) (*taskagent.TaskAgentQueue, error) { - if len(queues) == 0 { + // Filter out queues with nil or empty names + valid := make([]taskagent.TaskAgentQueue, 0, len(queues)) + for _, q := range queues { + if q.Name != nil && *q.Name != "" { + valid = append(valid, q) + } + } + + if len(valid) == 0 { return nil, fmt.Errorf("no agent queues available in project %s", projectId) } - if len(queues) == 1 { - console.Message(ctx, fmt.Sprintf("Using agent queue: %s", *queues[0].Name)) - return &queues[0], nil + if len(valid) == 1 { + console.Message(ctx, fmt.Sprintf("Using agent queue: %s", *valid[0].Name)) + return &valid[0], nil } - options := make([]string, 0, len(queues)) - for _, q := range queues { + options := make([]string, 0, len(valid)) + for _, q := range valid { options = append(options, *q.Name) } @@ -58,11 +67,11 @@ func selectAgentQueue( return nil, fmt.Errorf("selecting agent queue: %w", err) } - if idx < 0 || idx >= len(queues) { - return nil, fmt.Errorf("selecting agent queue: invalid queue index %d for %d queues", idx, len(queues)) + if idx < 0 || idx >= len(valid) { + return nil, fmt.Errorf("selecting agent queue: invalid queue index %d for %d queues", idx, len(valid)) } - return &queues[idx], nil + return &valid[idx], nil } // getAgentQueue returns the agent queue to associate with the pipeline. diff --git a/cli/azd/pkg/azdo/pipeline_test.go b/cli/azd/pkg/azdo/pipeline_test.go index 449587a4519..3a443cc3cd1 100644 --- a/cli/azd/pkg/azdo/pipeline_test.go +++ b/cli/azd/pkg/azdo/pipeline_test.go @@ -30,6 +30,34 @@ func Test_selectAgentQueue(t *testing.T) { assert.ErrorContains(t, err, "no agent queues available in project project-1") }) + t.Run("queues with nil names are filtered out", func(t *testing.T) { + mockConsole := mockinput.NewMockConsole() + queueId := 1 + queues := []taskagent.TaskAgentQueue{ + {Id: &queueId, Name: nil}, + } + + queue, err := selectAgentQueue(t.Context(), "project-1", queues, mockConsole) + assert.Nil(t, queue) + assert.ErrorContains(t, err, "no agent queues available in project project-1") + }) + + t.Run("nil name queues filtered leaving one valid", func(t *testing.T) { + mockConsole := mockinput.NewMockConsole() + id1 := 1 + id2 := 2 + validName := "Azure Pipelines" + queues := []taskagent.TaskAgentQueue{ + {Id: &id1, Name: nil}, + {Id: &id2, Name: &validName}, + } + + queue, err := selectAgentQueue(t.Context(), "project-1", queues, mockConsole) + require.NoError(t, err) + require.NotNil(t, queue) + assert.Equal(t, "Azure Pipelines", *queue.Name) + }) + t.Run("single queue auto-selects", func(t *testing.T) { mockConsole := mockinput.NewMockConsole() queueName := "Azure Pipelines" From 5310a1505d30dd8b44e7ec1ac452b8c7f8ed454a Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Tue, 14 Apr 2026 07:05:10 +0000 Subject: [PATCH 4/4] fix: address Copilot review feedback (iteration 3) - Filter queues with nil Id in addition to nil/empty Name - Rename test to TestSelectAgentQueue (CamelCase convention) - Use strings.Contains for mock predicates to reduce brittleness - Add test for Select error wrapping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/azdo/pipeline.go | 6 ++--- cli/azd/pkg/azdo/pipeline_test.go | 43 ++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/cli/azd/pkg/azdo/pipeline.go b/cli/azd/pkg/azdo/pipeline.go index 292ee686bd2..b31d4a2a1a6 100644 --- a/cli/azd/pkg/azdo/pipeline.go +++ b/cli/azd/pkg/azdo/pipeline.go @@ -29,17 +29,17 @@ func createBuildDefinitionVariable(value string, isSecret bool, allowOverride bo // selectAgentQueue picks the agent queue to use from the provided list. // Auto-selects if only one queue exists, prompts the user if multiple. -// Queues with nil or empty names are filtered out. +// Queues with nil IDs or nil/empty names are filtered out. func selectAgentQueue( ctx context.Context, projectId string, queues []taskagent.TaskAgentQueue, console input.Console, ) (*taskagent.TaskAgentQueue, error) { - // Filter out queues with nil or empty names + // Filter out queues with nil IDs or nil/empty names valid := make([]taskagent.TaskAgentQueue, 0, len(queues)) for _, q := range queues { - if q.Name != nil && *q.Name != "" { + if q.Id != nil && q.Name != nil && *q.Name != "" { valid = append(valid, q) } } diff --git a/cli/azd/pkg/azdo/pipeline_test.go b/cli/azd/pkg/azdo/pipeline_test.go index 3a443cc3cd1..c212bef43af 100644 --- a/cli/azd/pkg/azdo/pipeline_test.go +++ b/cli/azd/pkg/azdo/pipeline_test.go @@ -4,6 +4,8 @@ package azdo import ( + "fmt" + "strings" "testing" "github.com/azure/azure-dev/cli/azd/pkg/input" @@ -13,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -func Test_selectAgentQueue(t *testing.T) { +func TestSelectAgentQueue(t *testing.T) { t.Run("no queues returns error", func(t *testing.T) { mockConsole := mockinput.NewMockConsole() @@ -42,6 +44,18 @@ func Test_selectAgentQueue(t *testing.T) { assert.ErrorContains(t, err, "no agent queues available in project project-1") }) + t.Run("queues with nil id are filtered out", func(t *testing.T) { + mockConsole := mockinput.NewMockConsole() + name := "Default" + queues := []taskagent.TaskAgentQueue{ + {Id: nil, Name: &name}, + } + + queue, err := selectAgentQueue(t.Context(), "project-1", queues, mockConsole) + assert.Nil(t, queue) + assert.ErrorContains(t, err, "no agent queues available in project project-1") + }) + t.Run("nil name queues filtered leaving one valid", func(t *testing.T) { mockConsole := mockinput.NewMockConsole() id1 := 1 @@ -76,7 +90,7 @@ func Test_selectAgentQueue(t *testing.T) { t.Run("multiple queues prompts user", func(t *testing.T) { mockConsole := mockinput.NewMockConsole() mockConsole.WhenSelect(func(options input.ConsoleOptions) bool { - return options.Message == "Choose an agent queue for the pipeline" + return strings.Contains(options.Message, "agent queue") }).Respond(1) // select second queue name1 := "Default" @@ -98,7 +112,7 @@ func Test_selectAgentQueue(t *testing.T) { t.Run("multiple queues selects first", func(t *testing.T) { mockConsole := mockinput.NewMockConsole() mockConsole.WhenSelect(func(options input.ConsoleOptions) bool { - return options.Message == "Choose an agent queue for the pipeline" + return strings.Contains(options.Message, "agent queue") }).Respond(0) // select first queue name1 := "Default" @@ -116,4 +130,27 @@ func Test_selectAgentQueue(t *testing.T) { assert.Equal(t, "Default", *queue.Name) assert.Equal(t, 1, *queue.Id) }) + + t.Run("select error is wrapped", func(t *testing.T) { + mockConsole := mockinput.NewMockConsole() + mockConsole.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "agent queue") + }).RespondFn(func(_ input.ConsoleOptions) (any, error) { + return 0, fmt.Errorf("user cancelled") + }) + + name1 := "Default" + id1 := 1 + name2 := "Azure Pipelines" + id2 := 2 + queues := []taskagent.TaskAgentQueue{ + {Id: &id1, Name: &name1}, + {Id: &id2, Name: &name2}, + } + + queue, err := selectAgentQueue(t.Context(), "project-1", queues, mockConsole) + assert.Nil(t, queue) + assert.ErrorContains(t, err, "selecting agent queue") + assert.ErrorContains(t, err, "user cancelled") + }) }