diff --git a/.typos.toml b/.typos.toml index 0cdb4efb..953a8693 100644 --- a/.typos.toml +++ b/.typos.toml @@ -30,3 +30,6 @@ extend-exclude = [ # extend-ignore-re = [ # "https?://[^`\\s]+", # ] +extend-ignore-re = [ + "UpdatePipelinePermisionsForResource" +] diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 7df1f247..75c724f2 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -372,6 +372,29 @@ Aliases view, status ``` +### `azdo pipelines queue` + +Manage Azure DevOps agent queues + +#### `azdo pipelines queue list [ORGANIZATION/]PROJECT [flags]` + +List agent queues + +``` + --action-filter string Filter queues by caller permissions: {manage|none|use} +-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 Optional client-side cap on results + --name string Filter queues by name +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +ls, l +``` + ### `azdo pipelines run [ORGANIZATION/]PROJECT/PIPELINE [flags]` Queue a pipeline run @@ -395,16 +418,16 @@ Manage pipeline runs List runs of pipelines in a project. ``` - --branch strings Filter by source branch (repeatable; first value is honored by the SDK). Bare names get refs/heads/ prepended. + --branch string Filter by source branch. Bare names get refs/heads/ prepended. -q, --jq expression Filter JSON output using a jq expression --json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it. --max-items int Maximum number of runs to return client-side (0 = unlimited). - --pipeline-id ints Limit to runs for these pipeline IDs (repeatable; first value is honored by the SDK). - --query-order string Order the results: finishTimeAscending, finishTimeDescending, queueTimeAscending, queueTimeDescending, startTimeAscending, startTimeDescending. - --reason strings Filter by reason (repeatable; first value is honored). Valid: manual, individualCI, batchedCI, schedule, scheduleForced, userCreated, pullRequest, etc. + --pipeline-id ints Filter by pipeline IDs (repeatable). + --query-order string Order the results: {finishtimeascending|finishtimedescending|queuetimeascending|queuetimedescending|starttimeascending|starttimedescending} + --reason string Filter by reason: {all|batchedci|buildcompletion|checkinshelveset|individualci|manual|none|pullrequest|resourcetrigger|schedule|scheduleforced|triggered|usercreated|validateshelveset} --requested-for string Filter by the user who queued the run. Accepts @me to mean the authenticated user. - --result strings Filter by result (repeatable; first value is honored). Valid: none, succeeded, partiallySucceeded, failed, canceled. - --status strings Filter by status (repeatable; first value is honored). Valid: none, inProgress, completed, cancelling, postponed, notStarted, all. + --result string Filter by result: {canceled|failed|none|partiallysucceeded|succeeded} + --status string Filter by status: {all|cancelling|completed|inprogress|none|notstarted|postponed} --tag strings Filter by tags (all supplied tags must match). -t, --template string Format JSON output using a Go template; see "azdo help formatting" --top int Maximum number of runs to request per server page (0 = server default). diff --git a/docs/azdo_pipelines.md b/docs/azdo_pipelines.md index 19705e01..8a7cd566 100644 --- a/docs/azdo_pipelines.md +++ b/docs/azdo_pipelines.md @@ -8,6 +8,7 @@ Manage Azure DevOps pipelines * [azdo pipelines delete](./azdo_pipelines_delete.md) * [azdo pipelines list](./azdo_pipelines_list.md) * [azdo pipelines pool](./azdo_pipelines_pool.md) +* [azdo pipelines queue](./azdo_pipelines_queue.md) * [azdo pipelines run](./azdo_pipelines_run.md) * [azdo pipelines runs](./azdo_pipelines_runs.md) * [azdo pipelines show](./azdo_pipelines_show.md) diff --git a/docs/azdo_pipelines_queue.md b/docs/azdo_pipelines_queue.md new file mode 100644 index 00000000..61ca5e61 --- /dev/null +++ b/docs/azdo_pipelines_queue.md @@ -0,0 +1,20 @@ +## Command `azdo pipelines queue` + +Manage Azure DevOps agent queues. Queues are project-scoped +and connect a project to an agent pool. + + +### Available commands + +* [azdo pipelines queue list](./azdo_pipelines_queue_list.md) + +### Examples + +```bash +# List queues in a project +azdo pipelines queue list Fabrikam +``` + +### See also + +* [azdo pipelines](./azdo_pipelines.md) diff --git a/docs/azdo_pipelines_queue_list.md b/docs/azdo_pipelines_queue_list.md new file mode 100644 index 00000000..2c8309bf --- /dev/null +++ b/docs/azdo_pipelines_queue_list.md @@ -0,0 +1,65 @@ +## Command `azdo pipelines queue list` + +``` +azdo pipelines queue list [ORGANIZATION/]PROJECT [flags] +``` + +List agent queues in an Azure DevOps project. + + +### Options + + +* `--action-filter` `string` + + Filter queues by caller permissions: {manage|none|use} + +* `-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 + +* `--name` `string` + + Filter queues by name + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `ls` +- `l` + +### JSON Fields + +`id`, `name`, `pool`, `projectId` + +### Examples + +```bash +# List all queues in a project +azdo pipelines queue list Fabrikam + +# List queues in a specific organization +azdo pipelines queue list myorg/Fabrikam + +# List queues filtered by name +azdo pipelines queue list myorg/Fabrikam --name Default + +# Output as JSON +azdo pipelines queue list Fabrikam --json +``` + +### See also + +* [azdo pipelines queue](./azdo_pipelines_queue.md) diff --git a/docs/azdo_pipelines_runs_list.md b/docs/azdo_pipelines_runs_list.md index c5ca8e2f..5ace025c 100644 --- a/docs/azdo_pipelines_runs_list.md +++ b/docs/azdo_pipelines_runs_list.md @@ -15,9 +15,9 @@ and tags. The full result set is paginated server-side; use ### Options -* `--branch` `strings` +* `--branch` `string` - Filter by source branch (repeatable; first value is honored by the SDK). Bare names get refs/heads/ prepended. + Filter by source branch. Bare names get refs/heads/ prepended. * `-q`, `--jq` `expression` @@ -33,27 +33,27 @@ and tags. The full result set is paginated server-side; use * `--pipeline-id` `ints` - Limit to runs for these pipeline IDs (repeatable; first value is honored by the SDK). + Filter by pipeline IDs (repeatable). * `--query-order` `string` - Order the results: finishTimeAscending, finishTimeDescending, queueTimeAscending, queueTimeDescending, startTimeAscending, startTimeDescending. + Order the results: {finishtimeascending|finishtimedescending|queuetimeascending|queuetimedescending|starttimeascending|starttimedescending} -* `--reason` `strings` +* `--reason` `string` - Filter by reason (repeatable; first value is honored). Valid: manual, individualCI, batchedCI, schedule, scheduleForced, userCreated, pullRequest, etc. + Filter by reason: {all|batchedci|buildcompletion|checkinshelveset|individualci|manual|none|pullrequest|resourcetrigger|schedule|scheduleforced|triggered|usercreated|validateshelveset} * `--requested-for` `string` Filter by the user who queued the run. Accepts @me to mean the authenticated user. -* `--result` `strings` +* `--result` `string` - Filter by result (repeatable; first value is honored). Valid: none, succeeded, partiallySucceeded, failed, canceled. + Filter by result: {canceled|failed|none|partiallysucceeded|succeeded} -* `--status` `strings` +* `--status` `string` - Filter by status (repeatable; first value is honored). Valid: none, inProgress, completed, cancelling, postponed, notStarted, all. + Filter by status: {all|cancelling|completed|inprogress|none|notstarted|postponed} * `--tag` `strings` diff --git a/internal/cmd/pipelines/pipelines.go b/internal/cmd/pipelines/pipelines.go index 78ab80c3..0cc73217 100644 --- a/internal/cmd/pipelines/pipelines.go +++ b/internal/cmd/pipelines/pipelines.go @@ -7,6 +7,7 @@ import ( "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/delete" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/list" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/pool" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/queue" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/run" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/runs" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/show" @@ -33,5 +34,6 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(variablegroup.NewCmd(ctx)) cmd.AddCommand(agent.NewCmd(ctx)) cmd.AddCommand(pool.NewCmd(ctx)) + cmd.AddCommand(queue.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/pipelines/queue/list/list.go b/internal/cmd/pipelines/queue/list/list.go new file mode 100644 index 00000000..0297d342 --- /dev/null +++ b/internal/cmd/pipelines/queue/list/list.go @@ -0,0 +1,173 @@ +package list + +import ( + "fmt" + "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/types" +) + +type opts struct { + scope string + name string + actionFilter *string + maxItems int + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &opts{} + + cmd := &cobra.Command{ + Use: "list [ORGANIZATION/]PROJECT", + Short: "List agent queues", + Long: heredoc.Doc(` + List agent queues in an Azure DevOps project. + `), + Example: heredoc.Doc(` + # List all queues in a project + azdo pipelines queue list Fabrikam + + # List queues in a specific organization + azdo pipelines queue list myorg/Fabrikam + + # List queues filtered by name + azdo pipelines queue list myorg/Fabrikam --name Default + + # Output as JSON + azdo pipelines queue list Fabrikam --json + `), + Aliases: []string{ + "ls", + "l", + }, + Args: util.ExactArgs(1, "project argument required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.scope = args[0] + return run(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.name, "name", "", "Filter queues by name") + util.NilStringEnumFlag(cmd, &opts.actionFilter, "action-filter", "", actionFilterMap.Keys(), "Filter queues by caller permissions") + cmd.Flags().IntVar(&opts.maxItems, "max-items", 0, "Optional client-side cap on results") + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", + "name", + "pool", + "projectId", + }) + + return cmd +} + +var actionFilterMap = types.EnumLookup[taskagent.TaskAgentQueueActionFilter]{ + "none": taskagent.TaskAgentQueueActionFilterValues.None, + "manage": taskagent.TaskAgentQueueActionFilterValues.Manage, + "use": taskagent.TaskAgentQueueActionFilterValues.Use, +} + +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 >= 0", opts.maxItems) + } + + scopeArg := strings.TrimSpace(opts.scope) + if strings.Count(scopeArg, "/") > 1 { + return util.FlagErrorf("invalid project argument: expected [ORGANIZATION/]PROJECT") + } + + scope, err := util.ParseProjectScope(cmdCtx, scopeArg) + if err != nil { + return util.FlagErrorf("invalid project argument: %w", err) + } + if len(scope.Targets) != 0 { + return util.FlagErrorf("invalid project argument: expected [ORGANIZATION/]PROJECT") + } + + taskClient, err := cmdCtx.ClientFactory().TaskAgent(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create task agent client: %w", err) + } + + args := taskagent.GetAgentQueuesArgs{ + Project: types.ToPtr(scope.Project), + QueueName: types.NotZeroPtrOrNil(strings.TrimSpace(opts.name)), + } + + actionFilter, ok := actionFilterMap.GetValuePtr(opts.actionFilter) + if !ok { + return util.FlagErrorf("invalid action filter %q; expected none, manage, or use", *opts.actionFilter) + } + if actionFilter != nil { + args.ActionFilter = actionFilter + } + + zap.L().Debug( + "listing agent queues", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.String("queueName", types.GetValue(args.QueueName, "")), + ) + + resp, err := taskClient.GetAgentQueues(cmdCtx.Context(), args) + if err != nil { + return fmt.Errorf("failed to list queues: %w", err) + } + + var queues []taskagent.TaskAgentQueue + if resp != nil { + queues = *resp + } + + if opts.maxItems > 0 && len(queues) > opts.maxItems { + zap.L().Debug("truncating result set to max-items", zap.Int("maxItems", opts.maxItems)) + queues = queues[:opts.maxItems] + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, queues) + } + + tp, err := cmdCtx.Printer("list") + if err != nil { + return err + } + + tp.AddColumns("ID", "NAME", "POOL", "PROJECT") + + for _, q := range queues { + poolName := "" + if q.Pool != nil { + poolName = types.GetValue(q.Pool.Name, "") + } + + projectID := "" + if q.ProjectId != nil { + projectID = q.ProjectId.String() + } + + tp.AddField(fmt.Sprintf("%d", types.GetValue(q.Id, 0))) + tp.AddField(types.GetValue(q.Name, "")) + tp.AddField(poolName) + tp.AddField(projectID) + tp.EndRow() + } + + return tp.Render() +} diff --git a/internal/cmd/pipelines/queue/list/list_test.go b/internal/cmd/pipelines/queue/list/list_test.go new file mode 100644 index 00000000..0bd8c629 --- /dev/null +++ b/internal/cmd/pipelines/queue/list/list_test.go @@ -0,0 +1,295 @@ +package list + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "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/printer" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type dependencies struct { + ctrl *gomock.Controller + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + taskClient *mocks.MockTaskAgentClient + config *mocks.MockConfig + auth *mocks.MockAuthConfig + stdout *bytes.Buffer +} + +func newDependencies(t *testing.T, organization string) *dependencies { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + deps := &dependencies{ + ctrl: ctrl, + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + taskClient: mocks.NewMockTaskAgentClient(ctrl), + stdout: out, + } + + deps.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + if organization != "" { + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), organization).Return(deps.taskClient, nil).AnyTimes() + } + + return deps +} + +func (d *dependencies) setupDefaultOrg(org string) { + d.config = mocks.NewMockConfig(d.ctrl) + d.auth = mocks.NewMockAuthConfig(d.ctrl) + 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(id int, name, poolName, projectID string) taskagent.TaskAgentQueue { + queue := taskagent.TaskAgentQueue{ + Id: types.ToPtr(id), + Name: types.ToPtr(name), + Pool: &taskagent.TaskAgentPoolReference{ + Id: types.ToPtr(id + 100), + Name: types.ToPtr(poolName), + }, + } + if projectID != "" { + queue.ProjectId = types.ToPtr(uuid.MustParse(projectID)) + } + return queue +} + +func TestNewCmd(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Equal(t, "list [ORGANIZATION/]PROJECT", cmd.Use) + assert.ElementsMatch(t, []string{"ls", "l"}, cmd.Aliases) + assert.NotNil(t, cmd.RunE) + assert.Equal(t, "", cmd.Flag("action-filter").DefValue) + + err := cmd.ParseFlags([]string{"--action-filter", "view"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "valid values are {") + assert.Contains(t, err.Error(), "none") + assert.Contains(t, err.Error(), "manage") + assert.Contains(t, err.Error(), "use") +} + +func TestRun_ScopeResolution(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scope string + defaultOrg string + taskAgentOrg string + }{ + {name: "explicit org", scope: "myorg/Fabrikam", taskAgentOrg: "myorg"}, + {name: "default org", scope: "Fabrikam", defaultOrg: "default-org", taskAgentOrg: "default-org"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := newDependencies(t, "") + if tt.defaultOrg != "" { + deps.setupDefaultOrg(tt.defaultOrg) + } + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), tt.taskAgentOrg).Return(deps.taskClient, nil).AnyTimes() + + queues := []taskagent.TaskAgentQueue{sampleQueue(1, "Default", "pool-1", "")} + deps.taskClient.EXPECT().GetAgentQueues(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args taskagent.GetAgentQueuesArgs) (*[]taskagent.TaskAgentQueue, error) { + require.NotNil(t, args.Project) + assert.Equal(t, "Fabrikam", *args.Project) + return &queues, nil + }, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = run(deps.cmd, &opts{scope: tt.scope}) + require.NoError(t, err) + }) + } +} + +func TestRun_InvalidInput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts opts + wantErr string + }{ + {name: "invalid project scope", opts: opts{scope: "org/project/extra"}, wantErr: "invalid project argument"}, + {name: "negative max items", opts: opts{scope: "org/project", maxItems: -1}, wantErr: "invalid --max-items"}, + {name: "invalid action filter", opts: opts{scope: "org/project", actionFilter: types.ToPtr("view")}, wantErr: "invalid action filter"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := newDependencies(t, "org") + err := run(deps.cmd, &tt.opts) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestRun_ClientFactoryError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "") + deps.setupDefaultOrg("org") + deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "org").Return(nil, fmt.Errorf("connection failed")) + + err := run(deps.cmd, &opts{scope: "project"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "connection failed") +} + +func TestRun_SDKError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + deps.taskClient.EXPECT().GetAgentQueues(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("API error")) + + err := run(deps.cmd, &opts{scope: "org/project"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "API error") +} + +func TestRun_ActionFilter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + actionFilter *string + want *taskagent.TaskAgentQueueActionFilter + }{ + {name: "manage", actionFilter: types.ToPtr("manage"), want: types.ToPtr(taskagent.TaskAgentQueueActionFilterValues.Manage)}, + {name: "omitted", actionFilter: nil, want: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := newDependencies(t, "org") + queues := []taskagent.TaskAgentQueue{sampleQueue(1, "Default", "pool-1", "")} + deps.taskClient.EXPECT().GetAgentQueues(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args taskagent.GetAgentQueuesArgs) (*[]taskagent.TaskAgentQueue, error) { + assert.Equal(t, tt.want, args.ActionFilter) + return &queues, nil + }, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = run(deps.cmd, &opts{scope: "org/project", actionFilter: tt.actionFilter}) + require.NoError(t, err) + }) + } +} + +func TestRun_BasicCall(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + queues := []taskagent.TaskAgentQueue{sampleQueue(7, "Default", "pool-1", "11111111-1111-1111-1111-111111111111")} + deps.taskClient.EXPECT().GetAgentQueues(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args taskagent.GetAgentQueuesArgs) (*[]taskagent.TaskAgentQueue, error) { + require.NotNil(t, args.Project) + assert.Equal(t, "Fabrikam", *args.Project) + assert.Nil(t, args.QueueName) + return &queues, nil + }, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = run(deps.cmd, &opts{scope: "myorg/Fabrikam"}) + require.NoError(t, err) + + assert.Contains(t, deps.stdout.String(), "7\tDefault\tpool-1\t11111111-1111-1111-1111-111111111111") +} + +func TestRun_FilterByNameAndMaxItemsCap(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + queues := []taskagent.TaskAgentQueue{ + sampleQueue(1, "Default", "pool-1", ""), + sampleQueue(2, "Default Copy", "pool-2", ""), + } + deps.taskClient.EXPECT().GetAgentQueues(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args taskagent.GetAgentQueuesArgs) (*[]taskagent.TaskAgentQueue, error) { + require.NotNil(t, args.QueueName) + assert.Equal(t, "Default", *args.QueueName) + return &queues, nil + }, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = run(deps.cmd, &opts{scope: "org/project", name: "Default", maxItems: 1}) + require.NoError(t, err) + + assert.Contains(t, deps.stdout.String(), "Default") + assert.NotContains(t, deps.stdout.String(), "Default Copy") +} + +func TestRun_JSONOutput(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + queues := []taskagent.TaskAgentQueue{sampleQueue(7, "Default", "pool-1", "11111111-1111-1111-1111-111111111111")} + deps.taskClient.EXPECT().GetAgentQueues(gomock.Any(), gomock.Any()).Return(&queues, nil) + + exporter := util.NewJSONExporter() + err := run(deps.cmd, &opts{scope: "org/project", 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, "Default", parsed[0]["name"]) + + pool := parsed[0]["pool"].(map[string]any) + assert.Equal(t, float64(107), pool["id"]) + assert.Equal(t, "pool-1", pool["name"]) + assert.Equal(t, "11111111-1111-1111-1111-111111111111", parsed[0]["projectId"]) + assert.NotContains(t, parsed[0], "poolName") +} diff --git a/internal/cmd/pipelines/queue/queue.go b/internal/cmd/pipelines/queue/queue.go new file mode 100644 index 00000000..9300d526 --- /dev/null +++ b/internal/cmd/pipelines/queue/queue.go @@ -0,0 +1,27 @@ +package queue + +import ( + "github.com/MakeNowJust/heredoc/v2" + "github.com/spf13/cobra" + + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/queue/list" + "github.com/tmeckel/azdo-cli/internal/cmd/util" +) + +func NewCmd(ctx util.CmdContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "queue", + Short: "Manage Azure DevOps agent queues", + Long: heredoc.Doc(` + Manage Azure DevOps agent queues. Queues are project-scoped + and connect a project to an agent pool. + `), + Example: heredoc.Doc(` + # List queues in a project + azdo pipelines queue list Fabrikam + `), + } + + cmd.AddCommand(list.NewCmd(ctx)) + return cmd +} diff --git a/internal/cmd/pipelines/runs/list/list.go b/internal/cmd/pipelines/runs/list/list.go index f8f1d9dc..7887f319 100644 --- a/internal/cmd/pipelines/runs/list/list.go +++ b/internal/cmd/pipelines/runs/list/list.go @@ -19,13 +19,13 @@ type runOptions struct { scopeArg string pipelineIDs []int - branches []string - statuses []string - results []string - reasons []string - requestedFor string + branch *string + status *string + result *string + reason *string + requestedFor *string tags []string - queryOrder string + queryOrder *string top int maxItems int @@ -68,14 +68,14 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { }, } - cmd.Flags().IntSliceVar(&opts.pipelineIDs, "pipeline-id", nil, "Limit to runs for these pipeline IDs (repeatable; first value is honored by the SDK).") - cmd.Flags().StringSliceVar(&opts.branches, "branch", nil, "Filter by source branch (repeatable; first value is honored by the SDK). Bare names get refs/heads/ prepended.") - cmd.Flags().StringSliceVar(&opts.statuses, "status", nil, "Filter by status (repeatable; first value is honored). Valid: none, inProgress, completed, cancelling, postponed, notStarted, all.") - cmd.Flags().StringSliceVar(&opts.results, "result", nil, "Filter by result (repeatable; first value is honored). Valid: none, succeeded, partiallySucceeded, failed, canceled.") - cmd.Flags().StringSliceVar(&opts.reasons, "reason", nil, "Filter by reason (repeatable; first value is honored). Valid: manual, individualCI, batchedCI, schedule, scheduleForced, userCreated, pullRequest, etc.") - cmd.Flags().StringVar(&opts.requestedFor, "requested-for", "", "Filter by the user who queued the run. Accepts @me to mean the authenticated user.") + cmd.Flags().IntSliceVar(&opts.pipelineIDs, "pipeline-id", nil, "Filter by pipeline IDs (repeatable).") + util.NilStringFlag(cmd, &opts.branch, "branch", "", "Filter by source branch. Bare names get refs/heads/ prepended.") + util.NilStringEnumFlag(cmd, &opts.status, "status", "", buildStatusLookup.Keys(), "Filter by status") + util.NilStringEnumFlag(cmd, &opts.result, "result", "", buildResultLookup.Keys(), "Filter by result") + util.NilStringEnumFlag(cmd, &opts.reason, "reason", "", buildReasonLookup.Keys(), "Filter by reason") + util.NilStringFlag(cmd, &opts.requestedFor, "requested-for", "", "Filter by the user who queued the run. Accepts @me to mean the authenticated user.") cmd.Flags().StringSliceVar(&opts.tags, "tag", nil, "Filter by tags (all supplied tags must match).") - cmd.Flags().StringVar(&opts.queryOrder, "query-order", "", "Order the results: finishTimeAscending, finishTimeDescending, queueTimeAscending, queueTimeDescending, startTimeAscending, startTimeDescending.") + util.NilStringEnumFlag(cmd, &opts.queryOrder, "query-order", "", buildQueryOrderLookup.Keys(), "Order the results") cmd.Flags().IntVar(&opts.top, "top", 0, "Maximum number of runs to request per server page (0 = server default).") cmd.Flags().IntVar(&opts.maxItems, "max-items", 0, "Maximum number of runs to return client-side (0 = unlimited).") @@ -103,38 +103,34 @@ func runCmd(ctx util.CmdContext, opts *runOptions) error { } ios.StartProgressIndicator() + defer ios.StopProgressIndicator() scope, err := util.ParseProjectScope(ctx, opts.scopeArg) if err != nil { - ios.StopProgressIndicator() return util.FlagErrorWrap(err) } client, err := ctx.ClientFactory().Build(ctx.Context(), scope.Organization) if err != nil { - ios.StopProgressIndicator() return fmt.Errorf("failed to create Build client: %w", err) } - requestedFor := opts.requestedFor + requestedFor := types.GetValue(opts.requestedFor, "") if strings.EqualFold(requestedFor, "@me") { zap.L().Debug("resolving @me to current user identity", zap.String("organization", scope.Organization)) extensionsClient, err := ctx.ClientFactory().Extensions(ctx.Context(), scope.Organization) if err != nil { - ios.StopProgressIndicator() return fmt.Errorf("failed to create Extensions client: %w", err) } identityClient, err := ctx.ClientFactory().Identity(ctx.Context(), scope.Organization) if err != nil { - ios.StopProgressIndicator() return fmt.Errorf("failed to create Identity client: %w", err) } selfID, err := extensionsClient.GetSelfID(ctx.Context()) if err != nil { - ios.StopProgressIndicator() return fmt.Errorf("failed to resolve @me identity: %w", err) } @@ -143,11 +139,9 @@ func runCmd(ctx util.CmdContext, opts *runOptions) error { IdentityIds: &idStr, }) if err != nil { - ios.StopProgressIndicator() return fmt.Errorf("failed to resolve @me identity details: %w", err) } if identities == nil || len(*identities) != 1 { - ios.StopProgressIndicator() return fmt.Errorf("failed to resolve @me identity details") } @@ -157,39 +151,35 @@ func runCmd(ctx util.CmdContext, opts *runOptions) error { project := scope.Project bArgs := build.GetBuildsArgs{Project: &project} if ids := opts.pipelineIDs; len(ids) > 0 { - first := ids - bArgs.Definitions = &first + bArgs.Definitions = &opts.pipelineIDs } - if len(opts.branches) > 0 { - branch := opts.branches[0] + if opts.branch != nil { + branch := *opts.branch if !strings.HasPrefix(branch, "refs/") { branch = "refs/heads/" + branch } bArgs.BranchName = &branch } - if len(opts.statuses) > 0 { - status, ok := types.LookupEnum(opts.statuses[0], allBuildStatuses) + if opts.status != nil { + status, ok := buildStatusLookup.GetValue(*opts.status) if !ok { - ios.StopProgressIndicator() - return util.FlagErrorf("unknown --status value %q", opts.statuses[0]) + return util.FlagErrorf("unknown --status value %q", *opts.status) } - bArgs.StatusFilter = &status + bArgs.StatusFilter = types.ToPtr(status) } - if len(opts.results) > 0 { - result, ok := types.LookupEnum(opts.results[0], allBuildResults) + if opts.result != nil { + result, ok := buildResultLookup.GetValue(*opts.result) if !ok { - ios.StopProgressIndicator() - return util.FlagErrorf("unknown --result value %q", opts.results[0]) + return util.FlagErrorf("unknown --result value %q", *opts.result) } - bArgs.ResultFilter = &result + bArgs.ResultFilter = types.ToPtr(result) } - if len(opts.reasons) > 0 { - reason, ok := types.LookupEnum(opts.reasons[0], allBuildReasons) + if opts.reason != nil { + reason, ok := buildReasonLookup.GetValue(*opts.reason) if !ok { - ios.StopProgressIndicator() - return util.FlagErrorf("unknown --reason value %q", opts.reasons[0]) + return util.FlagErrorf("unknown --reason value %q", *opts.reason) } - bArgs.ReasonFilter = &reason + bArgs.ReasonFilter = types.ToPtr(reason) } if requestedFor != "" { bArgs.RequestedFor = &requestedFor @@ -197,24 +187,22 @@ func runCmd(ctx util.CmdContext, opts *runOptions) error { if len(opts.tags) > 0 { bArgs.TagFilters = &opts.tags } - if opts.queryOrder != "" { - order, ok := types.LookupEnum(opts.queryOrder, allBuildQueryOrders) + if opts.queryOrder != nil { + order, ok := buildQueryOrderLookup.GetValue(*opts.queryOrder) if !ok { - ios.StopProgressIndicator() - return util.FlagErrorf("unknown --query-order value %q", opts.queryOrder) + return util.FlagErrorf("unknown --query-order value %q", *opts.queryOrder) } - bArgs.QueryOrder = &order + bArgs.QueryOrder = types.ToPtr(order) } if opts.top > 0 { bArgs.Top = &opts.top } - runs := make([]build.Build, 0) + var runs []build.Build paginate: for { resp, err := client.GetBuilds(ctx.Context(), bArgs) if err != nil { - ios.StopProgressIndicator() return fmt.Errorf("GetBuilds: %w", err) } if resp != nil { @@ -232,8 +220,6 @@ paginate: bArgs.ContinuationToken = &token } - ios.StopProgressIndicator() - if opts.exporter != nil { return opts.exporter.Write(ios, runs) } @@ -253,10 +239,9 @@ paginate: var defName string if def := run.Definition; def != nil { - if def.Name != nil && *def.Name != "" { - defName = *def.Name - } else if def.Id != nil { - defName = strconv.Itoa(*def.Id) + defName = types.GetValue(def.Name, "") + if defName == "" && def.Id != nil { + defName = strconv.Itoa(types.GetValue(def.Id, 0)) } } tp.AddField(defName) @@ -265,11 +250,7 @@ paginate: var identName string if ref := run.RequestedFor; ref != nil { - if name := types.GetValue(ref.DisplayName, ""); name != "" { - identName = name - } else { - identName = types.GetValue(ref.UniqueName, "") - } + identName = types.GetValue(ref.DisplayName, types.GetValue(ref.UniqueName, "")) } tp.AddField(identName) @@ -280,46 +261,46 @@ paginate: return tp.Render() } -var allBuildStatuses = []build.BuildStatus{ - build.BuildStatusValues.None, - build.BuildStatusValues.InProgress, - build.BuildStatusValues.Completed, - build.BuildStatusValues.Cancelling, - build.BuildStatusValues.Postponed, - build.BuildStatusValues.NotStarted, - build.BuildStatusValues.All, +var buildStatusLookup = types.EnumLookup[build.BuildStatus]{ + "none": build.BuildStatusValues.None, + "inprogress": build.BuildStatusValues.InProgress, + "completed": build.BuildStatusValues.Completed, + "cancelling": build.BuildStatusValues.Cancelling, + "postponed": build.BuildStatusValues.Postponed, + "notstarted": build.BuildStatusValues.NotStarted, + "all": build.BuildStatusValues.All, } -var allBuildResults = []build.BuildResult{ - build.BuildResultValues.None, - build.BuildResultValues.Succeeded, - build.BuildResultValues.PartiallySucceeded, - build.BuildResultValues.Failed, - build.BuildResultValues.Canceled, +var buildResultLookup = types.EnumLookup[build.BuildResult]{ + "none": build.BuildResultValues.None, + "succeeded": build.BuildResultValues.Succeeded, + "partiallysucceeded": build.BuildResultValues.PartiallySucceeded, + "failed": build.BuildResultValues.Failed, + "canceled": build.BuildResultValues.Canceled, } -var allBuildReasons = []build.BuildReason{ - build.BuildReasonValues.None, - build.BuildReasonValues.Manual, - build.BuildReasonValues.IndividualCI, - build.BuildReasonValues.BatchedCI, - build.BuildReasonValues.Schedule, - build.BuildReasonValues.ScheduleForced, - build.BuildReasonValues.UserCreated, - build.BuildReasonValues.ValidateShelveset, - build.BuildReasonValues.CheckInShelveset, - build.BuildReasonValues.PullRequest, - build.BuildReasonValues.BuildCompletion, - build.BuildReasonValues.ResourceTrigger, - build.BuildReasonValues.Triggered, - build.BuildReasonValues.All, +var buildReasonLookup = types.EnumLookup[build.BuildReason]{ + "none": build.BuildReasonValues.None, + "manual": build.BuildReasonValues.Manual, + "individualci": build.BuildReasonValues.IndividualCI, + "batchedci": build.BuildReasonValues.BatchedCI, + "schedule": build.BuildReasonValues.Schedule, + "scheduleforced": build.BuildReasonValues.ScheduleForced, + "usercreated": build.BuildReasonValues.UserCreated, + "validateshelveset": build.BuildReasonValues.ValidateShelveset, + "checkinshelveset": build.BuildReasonValues.CheckInShelveset, + "pullrequest": build.BuildReasonValues.PullRequest, + "buildcompletion": build.BuildReasonValues.BuildCompletion, + "resourcetrigger": build.BuildReasonValues.ResourceTrigger, + "triggered": build.BuildReasonValues.Triggered, + "all": build.BuildReasonValues.All, } -var allBuildQueryOrders = []build.BuildQueryOrder{ - build.BuildQueryOrderValues.FinishTimeAscending, - build.BuildQueryOrderValues.FinishTimeDescending, - build.BuildQueryOrderValues.QueueTimeDescending, - build.BuildQueryOrderValues.QueueTimeAscending, - build.BuildQueryOrderValues.StartTimeDescending, - build.BuildQueryOrderValues.StartTimeAscending, +var buildQueryOrderLookup = types.EnumLookup[build.BuildQueryOrder]{ + "finishtimeascending": build.BuildQueryOrderValues.FinishTimeAscending, + "finishtimedescending": build.BuildQueryOrderValues.FinishTimeDescending, + "queuetimedescending": build.BuildQueryOrderValues.QueueTimeDescending, + "queuetimeascending": build.BuildQueryOrderValues.QueueTimeAscending, + "starttimedescending": build.BuildQueryOrderValues.StartTimeDescending, + "starttimeascending": build.BuildQueryOrderValues.StartTimeAscending, } diff --git a/internal/cmd/pipelines/runs/list/list_test.go b/internal/cmd/pipelines/runs/list/list_test.go index 2082f18c..4a3f4a0b 100644 --- a/internal/cmd/pipelines/runs/list/list_test.go +++ b/internal/cmd/pipelines/runs/list/list_test.go @@ -18,6 +18,7 @@ import ( "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 dependencies struct { @@ -108,6 +109,52 @@ func (s *spyExporter) Write(_ *iostreams.IOStreams, v any) error { return nil } +func TestNewCmd_ParseInvalidEnum(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flags []string + want string + }{ + {name: "invalid status", flags: []string{"--status", "BAD"}, want: "valid values are"}, + {name: "invalid result", flags: []string{"--result", "BAD"}, want: "valid values are"}, + {name: "invalid reason", flags: []string{"--reason", "BAD"}, want: "valid values are"}, + {name: "invalid query-order", flags: []string{"--query-order", "BAD"}, want: "valid values are"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := NewCmd(nil) + err := cmd.ParseFlags(tt.flags) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + }) + } +} + +func TestNewCmd_ParseValidEnum(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flags []string + }{ + {name: "status completed", flags: []string{"--status", "completed"}}, + {name: "result succeeded", flags: []string{"--result", "succeeded"}}, + {name: "reason manual", flags: []string{"--reason", "manual"}}, + {name: "query-order", flags: []string{"--query-order", "queueTimeDescending"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := NewCmd(nil) + err := cmd.ParseFlags(tt.flags) + require.NoError(t, err, "valid enum value must parse") + }) + } +} + func TestRunList_DefaultNoFilters(t *testing.T) { t.Parallel() deps := newDependencies(t, "MyOrg") @@ -183,7 +230,7 @@ func TestRunList_BranchRefsHeadsPrepended(t *testing.T) { return &build.GetBuildsResponseValue{ContinuationToken: ""}, nil }) - err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", branches: []string{"main"}}) + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", branch: types.ToPtr("main")}) require.NoError(t, err) } @@ -198,33 +245,10 @@ func TestRunList_BranchRefsUnchanged(t *testing.T) { return &build.GetBuildsResponseValue{ContinuationToken: ""}, nil }) - err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", branches: []string{"refs/tags/v1.0"}}) + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", branch: types.ToPtr("refs/tags/v1.0")}) require.NoError(t, err) } -func TestRunList_InvalidFilters(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - opts runOptions - want string - }{ - {name: "invalid status", opts: runOptions{scopeArg: "MyOrg/Fabrikam", statuses: []string{"INVALID_STATUS"}}, want: "unknown --status"}, - {name: "invalid result", opts: runOptions{scopeArg: "MyOrg/Fabrikam", results: []string{"INVALID_RESULT"}}, want: "unknown --result"}, - {name: "invalid reason", opts: runOptions{scopeArg: "MyOrg/Fabrikam", reasons: []string{"INVALID_REASON"}}, want: "unknown --reason"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - deps := newDependencies(t, "MyOrg") - err := runCmd(deps.cmd, &tt.opts) - require.Error(t, err) - assert.Contains(t, err.Error(), tt.want) - }) - } -} - func TestRunList_RequestedForAtMe(t *testing.T) { t.Parallel() deps := newDependencies(t, "MyOrg") @@ -253,7 +277,7 @@ func TestRunList_RequestedForAtMe(t *testing.T) { return &build.GetBuildsResponseValue{ContinuationToken: ""}, nil }) - err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", requestedFor: "@me"}) + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", requestedFor: types.ToPtr("@me")}) require.NoError(t, err) assert.Equal(t, "Alice", capturedRequestedFor) } @@ -269,7 +293,7 @@ func TestRunList_QueryOrder(t *testing.T) { return &build.GetBuildsResponseValue{ContinuationToken: ""}, nil }) - err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", queryOrder: "queueTimeDescending"}) + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", queryOrder: types.ToPtr("queueTimeDescending")}) require.NoError(t, err) } diff --git a/internal/cmd/serviceendpoint/shared/pipeline_permissions.go b/internal/cmd/serviceendpoint/shared/pipeline_permissions.go index ac103674..8722ab10 100644 --- a/internal/cmd/serviceendpoint/shared/pipeline_permissions.go +++ b/internal/cmd/serviceendpoint/shared/pipeline_permissions.go @@ -71,7 +71,7 @@ func runCleanup(opErr error, cleanup CleanupFunc) error { return opErr } if err := cleanup(); err != nil { - return fmt.Errorf("%w (cleanup failed: %v)", opErr, err) + return fmt.Errorf("%w (cleanup failed: %w)", opErr, err) } return opErr } diff --git a/internal/cmd/util/flags.go b/internal/cmd/util/flags.go index 40b75f92..0f4c980d 100644 --- a/internal/cmd/util/flags.go +++ b/internal/cmd/util/flags.go @@ -28,16 +28,26 @@ func NilBoolFlag(cmd *cobra.Command, p **bool, name string, shorthand string, us func StringEnumFlag(cmd *cobra.Command, p *string, name, shorthand, defaultValue string, options []string, usage string) *pflag.Flag { *p = defaultValue val := &enumValue{string: p, options: options} - f := cmd.Flags().VarPF(val, name, shorthand, fmt.Sprintf("%s: %s", usage, formatValuesForUsageDocs(options))) - _ = cmd.RegisterFlagCompletionFunc(name, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return options, cobra.ShellCompDirectiveNoFileComp - }) - return f + return registerEnumFlag(cmd, val, name, shorthand, options, usage) +} + +func NilStringEnumFlag(cmd *cobra.Command, p **string, name, shorthand string, options []string, usage string) *pflag.Flag { + val := &nullableEnumValue{string: p, options: options} + return registerEnumFlag(cmd, val, name, shorthand, options, usage) } func StringSliceEnumFlag(cmd *cobra.Command, p *[]string, name, shorthand string, defaultValues, options []string, usage string) *pflag.Flag { *p = defaultValues val := &enumMultiValue{value: p, options: options} + return registerEnumFlag(cmd, val, name, shorthand, options, usage) +} + +func NilStringSliceEnumFlag(cmd *cobra.Command, p *[]string, name, shorthand string, options []string, usage string) *pflag.Flag { + val := &nullableEnumMultiValue{value: p, options: options} + return registerEnumFlag(cmd, val, name, shorthand, options, usage) +} + +func registerEnumFlag(cmd *cobra.Command, val pflag.Value, name, shorthand string, options []string, usage string) *pflag.Flag { f := cmd.Flags().VarPF(val, name, shorthand, fmt.Sprintf("%s: %s", usage, formatValuesForUsageDocs(options))) _ = cmd.RegisterFlagCompletionFunc(name, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return options, cobra.ShellCompDirectiveNoFileComp @@ -130,8 +140,8 @@ type enumValue struct { } func (e *enumValue) Set(value string) error { - if !isIncluded(value, e.options) { - return fmt.Errorf("valid values are %s", formatValuesForUsageDocs(e.options)) + if err := validateEnumValue(value, e.options); err != nil { + return err } *e.string = value return nil @@ -145,6 +155,30 @@ func (e *enumValue) Type() string { return "string" } +type nullableEnumValue struct { + string **string + options []string +} + +func (e *nullableEnumValue) Set(value string) error { + if err := validateEnumValue(value, e.options); err != nil { + return err + } + *e.string = &value + return nil +} + +func (e *nullableEnumValue) String() string { + if e.string == nil || *e.string == nil { + return "" + } + return **e.string +} + +func (e *nullableEnumValue) Type() string { + return "string" +} + type enumMultiValue struct { value *[]string options []string @@ -172,6 +206,40 @@ func (e *enumMultiValue) Type() string { return "stringSlice" } +type nullableEnumMultiValue struct { + value *[]string + options []string +} + +func (e *nullableEnumMultiValue) Set(value string) error { + items := strings.Split(value, ",") + for _, item := range items { + if !isIncluded(item, e.options) { + return fmt.Errorf("valid values are %s", formatValuesForUsageDocs(e.options)) + } + } + *e.value = append(*e.value, items...) + return nil +} + +func (e *nullableEnumMultiValue) String() string { + if e.value == nil || len(*e.value) == 0 { + return "" + } + return fmt.Sprintf("{%s}", strings.Join(*e.value, ", ")) +} + +func (e *nullableEnumMultiValue) Type() string { + return "stringSlice" +} + +func validateEnumValue(value string, opts []string) error { + if !isIncluded(value, opts) { + return fmt.Errorf("valid values are %s", formatValuesForUsageDocs(opts)) + } + return nil +} + func isIncluded(value string, opts []string) bool { for _, opt := range opts { if strings.EqualFold(opt, value) { diff --git a/internal/cmd/util/flags_test.go b/internal/cmd/util/flags_test.go new file mode 100644 index 00000000..67619c76 --- /dev/null +++ b/internal/cmd/util/flags_test.go @@ -0,0 +1,295 @@ +package util + +import ( + "context" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNilStringFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want *string + }{ + {name: "omitted", args: nil, want: nil}, + {name: "set", args: []string{"--name", "hello"}, want: ptr("hello")}, + {name: "set empty", args: []string{"--name", ""}, want: ptr("")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got *string + cmd := &cobra.Command{Use: "test"} + NilStringFlag(cmd, &got, "name", "", "a name") + + err := cmd.ParseFlags(tt.args) + require.NoError(t, err) + + if tt.want == nil { + assert.Nil(t, got) + return + } + + require.NotNil(t, got) + assert.Equal(t, *tt.want, *got) + }) + } +} + +func TestNilBoolFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want *bool + }{ + {name: "omitted", args: nil, want: nil}, + {name: "long true", args: []string{"--verbose"}, want: ptr(true)}, + {name: "long false", args: []string{"--verbose=false"}, want: ptr(false)}, + {name: "short true", args: []string{"-v"}, want: ptr(true)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got *bool + cmd := &cobra.Command{Use: "test"} + NilBoolFlag(cmd, &got, "verbose", "v", "verbose mode") + + err := cmd.ParseFlags(tt.args) + require.NoError(t, err) + + if tt.want == nil { + assert.Nil(t, got) + return + } + + require.NotNil(t, got) + assert.Equal(t, *tt.want, *got) + }) + } +} + +func TestStringEnumFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want string + wantErr string + }{ + {name: "default", args: nil, want: "auto"}, + {name: "valid", args: []string{"--mode", "manual"}, want: "manual"}, + {name: "case insensitive", args: []string{"--mode", "Manual"}, want: "Manual"}, + {name: "invalid", args: []string{"--mode", "hybrid"}, wantErr: "valid values are {auto|manual}"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := "" + cmd := &cobra.Command{Use: "test"} + StringEnumFlag(cmd, &got, "mode", "", "auto", []string{"auto", "manual"}, "select mode") + + err := cmd.ParseFlags(tt.args) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNilStringEnumFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want *string + wantErr string + }{ + {name: "omitted", args: nil, want: nil}, + {name: "valid", args: []string{"--action", "manage"}, want: ptr("manage")}, + {name: "invalid", args: []string{"--action", "view"}, wantErr: "valid values are {none|manage|use}"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got *string + cmd := &cobra.Command{Use: "test"} + NilStringEnumFlag(cmd, &got, "action", "", []string{"none", "manage", "use"}, "filter action") + + err := cmd.ParseFlags(tt.args) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.want == nil { + assert.Nil(t, got) + return + } + + require.NotNil(t, got) + assert.Equal(t, *tt.want, *got) + }) + } +} + +func TestStringSliceEnumFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + defaultValue []string + want []string + wantErr string + }{ + {name: "default", args: nil, defaultValue: []string{"a"}, want: []string{"a"}}, + {name: "valid single", args: []string{"--type", "b"}, want: []string{"b"}}, + {name: "valid multi", args: []string{"--type", "a,b"}, want: []string{"a", "b"}}, + {name: "invalid single", args: []string{"--type", "d"}, wantErr: "valid values are {a|b|c}"}, + {name: "invalid multi", args: []string{"--type", "a,d"}, wantErr: "valid values are {a|b|c}"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got []string + cmd := &cobra.Command{Use: "test"} + StringSliceEnumFlag(cmd, &got, "type", "", tt.defaultValue, []string{"a", "b", "c"}, "select types") + + err := cmd.ParseFlags(tt.args) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNilStringSliceEnumFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want []string + wantNil bool + wantErr string + }{ + {name: "omitted", args: nil, wantNil: true}, + {name: "valid", args: []string{"--type", "b"}, want: []string{"b"}}, + {name: "invalid single", args: []string{"--type", "d"}, wantErr: "valid values are {a|b|c}"}, + {name: "invalid multi", args: []string{"--type", "a,d"}, wantErr: "valid values are {a|b|c}"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got []string + cmd := &cobra.Command{Use: "test"} + NilStringSliceEnumFlag(cmd, &got, "type", "", []string{"a", "b", "c"}, "select types") + + err := cmd.ParseFlags(tt.args) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantNil { + assert.Nil(t, got) + return + } + + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRegisterBranchCompletionFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flags []string + markRepoChanged bool + want []string + wantDirective cobra.ShellCompDirective + wantRegisterError string + }{ + { + name: "returns branch names", + flags: []string{"branch"}, + want: []string{"pref-main", "pref-dev"}, + wantDirective: cobra.ShellCompDirectiveNoFileComp, + }, + { + name: "missing flag returns error", + flags: []string{"missing"}, + wantRegisterError: "flag 'missing' does not exist", + }, + { + name: "repo changed disables completion", + flags: []string{"branch"}, + markRepoChanged: true, + want: nil, + wantDirective: cobra.ShellCompDirectiveNoFileComp, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("branch", "", "branch name") + cmd.Flags().String("repo", "", "repo") + + err := RegisterBranchCompletionFlags(fakeGitClient{}, cmd, tt.flags...) + if tt.wantRegisterError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantRegisterError) + return + } + + require.NoError(t, err) + if tt.markRepoChanged { + require.NoError(t, cmd.Flags().Set("repo", "example")) + } + + completion, ok := cmd.GetFlagCompletionFunc("branch") + require.True(t, ok) + + got, directive := completion(cmd, nil, "pref-") + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantDirective, directive) + }) + } +} + +type fakeGitClient struct{} + +func (fakeGitClient) TrackingBranchNames(_ context.Context, prefix string) []string { + return []string{prefix + "main", prefix + "dev"} +} + +func ptr[T any](v T) *T { + return &v +} diff --git a/internal/types/types.go b/internal/types/types.go index 18958791..c5353ceb 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -2,6 +2,8 @@ package types import ( "cmp" + "maps" + "slices" "strings" ) @@ -37,13 +39,30 @@ func PositivePtrOrNil[T cmp.Ordered](v T) *T { return &v } -// LookupEnum returns first case-insensitive match from values. -func LookupEnum[T ~string](input string, values []T) (T, bool) { - for _, v := range values { - if strings.EqualFold(input, string(v)) { - return v, true - } +type EnumLookup[T any] map[string]T + +func (l EnumLookup[T]) Keys() []string { + keys := slices.Collect(maps.Keys(l)) + slices.Sort(keys) + return keys +} + +func (l EnumLookup[T]) GetValue(value string) (T, bool) { + v, ok := l[strings.ToLower(strings.TrimSpace(value))] + if !ok { + var zero T + return zero, false } - var zero T - return zero, false + return v, true +} + +func (l EnumLookup[T]) GetValuePtr(value *string) (*T, bool) { + if value == nil { + return nil, true + } + v, ok := l.GetValue(*value) + if !ok { + return nil, false + } + return ToPtr(v), true } diff --git a/internal/types/types_test.go b/internal/types/types_test.go index 2df0e0a6..9c81f290 100644 --- a/internal/types/types_test.go +++ b/internal/types/types_test.go @@ -7,31 +7,73 @@ import ( "github.com/stretchr/testify/require" ) -func TestLookupEnum_Match(t *testing.T) { +func TestEnumLookup(t *testing.T) { t.Parallel() - got, ok := LookupEnum("BeTa", []string{"alpha", "beta", "gamma"}) + lookup := EnumLookup[string]{ + "none": "none", + "manage": "manage", + "use": "use", + } - require.True(t, ok) - assert.Equal(t, "beta", got) -} + t.Run("keys are sorted", func(t *testing.T) { + t.Parallel() + assert.Equal(t, []string{"manage", "none", "use"}, lookup.Keys()) + }) -func TestLookupEnum_NoMatch(t *testing.T) { - t.Parallel() + t.Run("get value", func(t *testing.T) { + t.Parallel() - got, ok := LookupEnum("delta", []string{"alpha", "beta", "gamma"}) + tests := []struct { + name string + input string + want string + wantOK bool + }{ + {name: "exact", input: "manage", want: "manage", wantOK: true}, + {name: "trimmed and lowercased", input: " Use ", want: "use", wantOK: true}, + {name: "invalid", input: "admin", wantOK: false}, + } - assert.False(t, ok) - assert.Equal(t, "", got) -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := lookup.GetValue(tt.input) + assert.Equal(t, tt.wantOK, ok) + if !tt.wantOK { + assert.Equal(t, "", got) + return + } + assert.Equal(t, tt.want, got) + }) + } + }) -func TestLookupEnum_NamedStringType(t *testing.T) { - t.Parallel() + t.Run("get value ptr", func(t *testing.T) { + t.Parallel() - type enum string + tests := []struct { + name string + input *string + want *string + wantOK bool + }{ + {name: "nil", input: nil, want: nil, wantOK: true}, + {name: "valid", input: ToPtr(" Manage "), want: ToPtr("manage"), wantOK: true}, + {name: "invalid", input: ToPtr("admin"), want: nil, wantOK: false}, + } - got, ok := LookupEnum("SECOND", []enum{"first", "second"}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := lookup.GetValuePtr(tt.input) + assert.Equal(t, tt.wantOK, ok) + if tt.want == nil { + assert.Nil(t, got) + return + } - require.True(t, ok) - assert.Equal(t, enum("second"), got) + require.NotNil(t, got) + assert.Equal(t, *tt.want, *got) + }) + } + }) }