diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 75c724f2..e699a7a6 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -395,6 +395,22 @@ Aliases ls, l ``` +#### `azdo pipelines queue show [ORGANIZATION/]PROJECT/QUEUE [flags]` + +Show details of an agent queue + +``` +-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. +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +view, status +``` + ### `azdo pipelines run [ORGANIZATION/]PROJECT/PIPELINE [flags]` Queue a pipeline run diff --git a/docs/azdo_pipelines_queue.md b/docs/azdo_pipelines_queue.md index 61ca5e61..aef5f5fb 100644 --- a/docs/azdo_pipelines_queue.md +++ b/docs/azdo_pipelines_queue.md @@ -7,6 +7,7 @@ and connect a project to an agent pool. ### Available commands * [azdo pipelines queue list](./azdo_pipelines_queue_list.md) +* [azdo pipelines queue show](./azdo_pipelines_queue_show.md) ### Examples diff --git a/docs/azdo_pipelines_queue_show.md b/docs/azdo_pipelines_queue_show.md new file mode 100644 index 00000000..2122cbc8 --- /dev/null +++ b/docs/azdo_pipelines_queue_show.md @@ -0,0 +1,52 @@ +## Command `azdo pipelines queue show` + +``` +azdo pipelines queue show [ORGANIZATION/]PROJECT/QUEUE [flags] +``` + +Display the details of a single Azure DevOps agent queue. +The queue 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. + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `view` +- `status` + +### JSON Fields + +`id`, `name`, `pool`, `projectId` + +### Examples + +```bash +# Show a queue by ID +azdo pipelines queue show Fabrikam/7 + +# Show a queue by name +azdo pipelines queue show 'Fabrikam/Default' + +# Show a queue in a specific organization +azdo pipelines queue show 'myorg/Fabrikam/Default' +``` + +### See also + +* [azdo pipelines queue](./azdo_pipelines_queue.md) diff --git a/internal/cmd/pipelines/queue/queue.go b/internal/cmd/pipelines/queue/queue.go index 9300d526..f455fdc2 100644 --- a/internal/cmd/pipelines/queue/queue.go +++ b/internal/cmd/pipelines/queue/queue.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/queue/list" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/queue/show" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -23,5 +24,6 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { } cmd.AddCommand(list.NewCmd(ctx)) + cmd.AddCommand(show.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/pipelines/queue/show/show.go b/internal/cmd/pipelines/queue/show/show.go new file mode 100644 index 00000000..454826cf --- /dev/null +++ b/internal/cmd/pipelines/queue/show/show.go @@ -0,0 +1,161 @@ +package show + +import ( + _ "embed" + "fmt" + "strconv" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/template" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type showOptions struct { + targetArg string + 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/]PROJECT/QUEUE", + Short: "Show details of an agent queue", + Long: heredoc.Doc(` + Display the details of a single Azure DevOps agent queue. + The queue is identified by integer ID or name, with an + optional organization prefix. + `), + Example: heredoc.Doc(` + # Show a queue by ID + azdo pipelines queue show Fabrikam/7 + + # Show a queue by name + azdo pipelines queue show 'Fabrikam/Default' + + # Show a queue in a specific organization + azdo pipelines queue show 'myorg/Fabrikam/Default' + `), + Aliases: []string{"view", "status"}, + Args: util.ExactArgs(1, "queue target is required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runShow(ctx, opts) + }, + } + + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "name", "pool", "projectId", + }) + + 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.ParseProjectTargetWithDefaultOrganization(cmdCtx, opts.targetArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + if len(scope.Targets) == 0 { + return util.FlagErrorf("queue target is required") + } + taskClient, err := cmdCtx.ClientFactory().TaskAgent(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create task agent client: %w", err) + } + + queueID, err := strconv.Atoi(scope.Targets[0]) + if err == nil { + if queueID <= 0 { + return util.FlagErrorf("invalid queue id %d", queueID) + } + } else { + queues, listErr := taskClient.GetAgentQueues(cmdCtx.Context(), taskagent.GetAgentQueuesArgs{ + Project: types.ToPtr(scope.Project), + QueueName: types.ToPtr(scope.Targets[0]), + }) + if listErr != nil { + return fmt.Errorf("failed to list queues: %w", listErr) + } + + matchCount := 0 + for _, q := range types.GetValue(queues, []taskagent.TaskAgentQueue{}) { + if q.Name == nil || !strings.EqualFold(*q.Name, scope.Targets[0]) { + continue + } + if q.Id == nil { + return fmt.Errorf("queue %q returned without an ID", scope.Targets[0]) + } + queueID = *q.Id + matchCount++ + } + + switch { + case matchCount == 0: + return fmt.Errorf("queue %q not found", scope.Targets[0]) + case matchCount > 1: + return fmt.Errorf("multiple queues named %q found; specify the numeric ID", scope.Targets[0]) + } + } + + zap.L().Debug( + "fetching queue", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.Int("queueId", queueID), + ) + + queue, err := taskClient.GetAgentQueue(cmdCtx.Context(), taskagent.GetAgentQueueArgs{ + QueueId: types.ToPtr(queueID), + Project: types.ToPtr(scope.Project), + }) + if err != nil { + return fmt.Errorf("failed to get queue: %w", err) + } + if queue == nil { + return fmt.Errorf("queue %q not found", scope.Targets[0]) + } + + if opts.exporter != nil { + ios.StopProgressIndicator() + return opts.exporter.Write(ios, queue) + } + + 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(queue) +} diff --git a/internal/cmd/pipelines/queue/show/show.tpl b/internal/cmd/pipelines/queue/show/show.tpl new file mode 100644 index 00000000..b44509d9 --- /dev/null +++ b/internal/cmd/pipelines/queue/show/show.tpl @@ -0,0 +1,4 @@ +{{bold "id:"}} {{.Id}} +{{bold "name:"}} {{s .Name}} +{{if .ProjectId}}{{bold "project id:"}} {{u .ProjectId}}{{end}} +{{if .Pool}}{{bold "pool:"}} {{if .Pool.Id}}{{.Pool.Id}}{{end}}{{if hasText (s .Pool.Name)}} ({{s .Pool.Name}}){{end}}{{end}} diff --git a/internal/cmd/pipelines/queue/show/show_test.go b/internal/cmd/pipelines/queue/show/show_test.go new file mode 100644 index 00000000..5de01fae --- /dev/null +++ b/internal/cmd/pipelines/queue/show/show_test.go @@ -0,0 +1,396 @@ +package show + +import ( + "bytes" + "context" + "fmt" + "regexp" + "strings" + "testing" + + "github.com/google/uuid" + "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/types" +) + +type dependencies struct { + 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 newDependencies(t *testing.T) *dependencies { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStderrTTY(true) + + deps := &dependencies{ + 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 *dependencies) 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 sampleQueue() *taskagent.TaskAgentQueue { + return &taskagent.TaskAgentQueue{ + Id: types.ToPtr(7), + Name: types.ToPtr("Default"), + ProjectId: types.ToPtr(uuid.MustParse("11111111-1111-1111-1111-111111111111")), + Pool: &taskagent.TaskAgentPoolReference{ + Id: types.ToPtr(42), + Name: types.ToPtr("pool-1"), + }, + } +} + +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/]PROJECT/QUEUE")) + assert.NotNil(t, cmd.RunE) +} + +func TestNewCmd_RequiresOneArg(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.setupDefaultOrg("myorg") + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "queue target is required") +} + +func TestRunShow_ResolveByPositiveInteger(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + deps.taskClient.EXPECT().GetAgentQueue(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args taskagent.GetAgentQueueArgs) (*taskagent.TaskAgentQueue, error) { + assert.Equal(t, 7, *args.QueueId) + return sampleQueue(), nil + }) + + opts := &showOptions{targetArg: "myorg/Fabrikam/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) +} + +func TestRunShow_ResolveByName(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + queueList := []taskagent.TaskAgentQueue{ + {Id: types.ToPtr(7), Name: types.ToPtr("Default")}, + } + deps.taskClient.EXPECT().GetAgentQueues(gomock.Any(), gomock.Any()). + Return(&queueList, nil) + deps.taskClient.EXPECT().GetAgentQueue(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args taskagent.GetAgentQueueArgs) (*taskagent.TaskAgentQueue, error) { + assert.Equal(t, 7, *args.QueueId) + return sampleQueue(), nil + }) + + opts := &showOptions{targetArg: "myorg/Fabrikam/Default"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) +} + +func TestRunShow_TemplateOutput_BasicFields(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + deps.taskClient.EXPECT().GetAgentQueue(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args taskagent.GetAgentQueueArgs) (*taskagent.TaskAgentQueue, error) { + assert.Equal(t, 7, *args.QueueId) + assert.Equal(t, "Fabrikam", *args.Project) + return sampleQueue(), nil + }) + + opts := &showOptions{targetArg: "myorg/Fabrikam/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, "project id: 11111111-1111-1111-1111-111111111111") +} + +func TestRunShow_InvalidQueueID(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + opts := &showOptions{targetArg: "myorg/Fabrikam/0"} + err := runShow(deps.cmd, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid queue id 0") +} + +func TestRunShow_TemplateOutput_Pool_Nested(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + q := sampleQueue() + q.Pool = &taskagent.TaskAgentPoolReference{ + Id: types.ToPtr(42), + Name: types.ToPtr("pool-1"), + } + deps.taskClient.EXPECT().GetAgentQueue(gomock.Any(), gomock.Any()).Return(q, nil) + + opts := &showOptions{targetArg: "myorg/Fabrikam/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "pool: 42 (pool-1)") +} + +func TestRunShow_TemplateOutput_NoPool(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + + q := sampleQueue() + q.Pool = nil + deps.taskClient.EXPECT().GetAgentQueue(gomock.Any(), gomock.Any()).Return(q, nil) + + opts := &showOptions{targetArg: "myorg/Fabrikam/7"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.NotContains(t, output, "pool:") +} + +func TestRunShow_JSONOutput(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + deps.taskClient.EXPECT().GetAgentQueue(gomock.Any(), gomock.Any()).Return(sampleQueue(), nil) + + exporter := util.NewJSONExporter() + opts := &showOptions{targetArg: "myorg/Fabrikam/7", exporter: exporter} + 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, `"projectId":"11111111-1111-1111-1111-111111111111"`) + assert.NotContains(t, output, `"url":`) + assert.NotContains(t, output, `"createdBy":`) +} + +func TestRunShow_ProjectScopeParsing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + targetArg string + defaultOrg string + wantOrg string + wantErr string + }{ + { + name: "explicit org", + targetArg: "myorg/Fabrikam/7", + wantOrg: "myorg", + }, + { + name: "implicit org from config", + targetArg: "Fabrikam/7", + defaultOrg: "default-org", + wantOrg: "default-org", + }, + { + name: "invalid input with too many segments", + targetArg: "org/proj/extra/7", + wantErr: "invalid input", + }, + { + name: "missing project segment", + targetArg: "7", + wantErr: "invalid input", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := newDependencies(t) + if tt.defaultOrg != "" { + deps.setupDefaultOrg(tt.defaultOrg) + } + + if tt.wantErr == "" { + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), tt.wantOrg).Return(deps.taskClient, nil) + deps.taskClient.EXPECT().GetAgentQueue(gomock.Any(), gomock.Any()).Return(sampleQueue(), nil) + } + + opts := &showOptions{targetArg: tt.targetArg} + err := runShow(deps.cmd, opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestRunShow_ClientFactoryError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + expectedErr := fmt.Errorf("connection failed") + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(nil, expectedErr) + + opts := &showOptions{targetArg: "myorg/Fabrikam/7"} + err := runShow(deps.cmd, opts) + require.Error(t, err) + assert.ErrorIs(t, err, expectedErr) +} + +func TestRunShow_ResolveByName_ListError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + expectedErr := fmt.Errorf("list failed") + deps.taskClient.EXPECT().GetAgentQueues(gomock.Any(), gomock.Any()).Return(nil, expectedErr) + + opts := &showOptions{targetArg: "myorg/Fabrikam/Default"} + err := runShow(deps.cmd, opts) + require.Error(t, err) + assert.ErrorIs(t, err, expectedErr) +} + +func TestRunShow_ResolveByName_MultipleMatches(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + queueList := []taskagent.TaskAgentQueue{ + {Id: types.ToPtr(7), Name: types.ToPtr("Default")}, + {Id: types.ToPtr(8), Name: types.ToPtr("default")}, + } + deps.taskClient.EXPECT().GetAgentQueues(gomock.Any(), gomock.Any()).Return(&queueList, nil) + + opts := &showOptions{targetArg: "myorg/Fabrikam/Default"} + err := runShow(deps.cmd, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "multiple queues named") +} + +func TestRunShow_ResolveByName_MissingID(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + queueList := []taskagent.TaskAgentQueue{ + {Name: types.ToPtr("Default")}, + } + deps.taskClient.EXPECT().GetAgentQueues(gomock.Any(), gomock.Any()).Return(&queueList, nil) + + opts := &showOptions{targetArg: "myorg/Fabrikam/Default"} + err := runShow(deps.cmd, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), `queue "Default" returned without an ID`) +} + +func TestRunShow_ResolveByName_NotFound(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + queueList := []taskagent.TaskAgentQueue{{Id: types.ToPtr(7), Name: types.ToPtr("Other")}} + deps.taskClient.EXPECT().GetAgentQueues(gomock.Any(), gomock.Any()).Return(&queueList, nil) + + opts := &showOptions{targetArg: "myorg/Fabrikam/Default"} + err := runShow(deps.cmd, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), `queue "Default" not found`) +} + +func TestRunShow_SDKError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) + expectedErr := fmt.Errorf("API error") + deps.taskClient.EXPECT().GetAgentQueue(gomock.Any(), gomock.Any()).Return(nil, expectedErr) + + opts := &showOptions{targetArg: "myorg/Fabrikam/7"} + err := runShow(deps.cmd, opts) + require.Error(t, err) + assert.ErrorIs(t, err, expectedErr) +} +func TestNewCmd_HasFlags(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + jsonFlag := cmd.Flag("json") + require.NotNil(t, jsonFlag) +} + +func TestNewCmd_DoesNotExposeRawFlag(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Nil(t, cmd.Flag("raw")) +}