diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index d5a5258f..c8c8fc51 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -263,6 +263,29 @@ Aliases view, status ``` +### `azdo pipelines list [ORGANIZATION/]PROJECT [flags]` + +List pipeline definitions + +``` + --folder-path string Filter by folder path (e.g. "user1/production") +-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 by pipeline name (prefix or exact) + --query-order string Order of definitions: {none|definitionNameAscending|definitionNameDescending|lastModifiedAscending|lastModifiedDescending} + --repository string Filter by repository name or ID + --repository-type string Repository type filter: {tfsgit|github} +-t, --template string Format JSON output using a Go template; see "azdo help formatting" + --top int Maximum number of definitions to return +``` + +Aliases + +``` +ls, l +``` + ### `azdo pipelines pool` Manage agent pools diff --git a/docs/azdo_pipelines.md b/docs/azdo_pipelines.md index 9e93ab03..f720ed92 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 list](./azdo_pipelines_list.md) * [azdo pipelines pool](./azdo_pipelines_pool.md) * [azdo pipelines variable-group](./azdo_pipelines_variable-group.md) diff --git a/docs/azdo_pipelines_list.md b/docs/azdo_pipelines_list.md new file mode 100644 index 00000000..0a83c477 --- /dev/null +++ b/docs/azdo_pipelines_list.md @@ -0,0 +1,81 @@ +## Command `azdo pipelines list` + +``` +azdo pipelines list [ORGANIZATION/]PROJECT [flags] +``` + +List pipeline definitions (YAML or classic) in a project. + + +### Options + + +* `--folder-path` `string` + + Filter by folder path (e.g. "user1/production") + +* `-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 by pipeline name (prefix or exact) + +* `--query-order` `string` + + Order of definitions: {none|definitionNameAscending|definitionNameDescending|lastModifiedAscending|lastModifiedDescending} + +* `--repository` `string` + + Filter by repository name or ID + +* `--repository-type` `string` + + Repository type filter: {tfsgit|github} + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + +* `--top` `int` (default `0`) + + Maximum number of definitions to return + + +### ALIASES + +- `ls` +- `l` + +### JSON Fields + +`_links`, `authoredBy`, `createdDate`, `draftOf`, `drafts`, `id`, `latestBuild`, `latestCompletedBuild`, `metrics`, `name`, `path`, `project`, `quality`, `queue`, `queueStatus`, `revision`, `type`, `uri`, `url` + +### Examples + +```bash +# List all pipelines in a project +$ azdo pipelines list "my-project" + +# List pipelines with a specific name +$ azdo pipelines list "my-project" --name "my-pipeline" + +# List pipelines using a specific repository +$ azdo pipelines list "my-project" --repository "my-repo" + +# Output as JSON +$ azdo pipelines list "my-project" --json +``` + +### See also + +* [azdo pipelines](./azdo_pipelines.md) diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index b92052ee..46b20efc 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -17,9 +17,9 @@ func NewCmdConfig(ctx util.CmdContext) *cobra.Command { longDoc.WriteString("Display or change configuration settings for azdo.\n\n") longDoc.WriteString("Current respected settings:\n") for _, co := range config.Options() { - longDoc.WriteString(fmt.Sprintf("- %s: %s", co.Key, co.Description)) + fmt.Fprintf(&longDoc, "- %s: %s", co.Key, co.Description) if co.DefaultValue != "" { - longDoc.WriteString(fmt.Sprintf(" (default: %q)", co.DefaultValue)) + fmt.Fprintf(&longDoc, " (default: %q)", co.DefaultValue) } longDoc.WriteRune('\n') } diff --git a/internal/cmd/pipelines/list/list.go b/internal/cmd/pipelines/list/list.go new file mode 100644 index 00000000..68ef10a0 --- /dev/null +++ b/internal/cmd/pipelines/list/list.go @@ -0,0 +1,198 @@ +package list + +import ( + "fmt" + "sort" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + "github.com/spf13/cobra" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type opts struct { + scope string + name string + repository string + repositoryType string + top int + folderPath string + queryOrder string + maxItems int + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &opts{} + + cmd := &cobra.Command{ + Use: "list [ORGANIZATION/]PROJECT", + Short: "List pipeline definitions", + Long: heredoc.Doc(` + List pipeline definitions (YAML or classic) in a project. + `), + Example: heredoc.Doc(` + # List all pipelines in a project + $ azdo pipelines list "my-project" + + # List pipelines with a specific name + $ azdo pipelines list "my-project" --name "my-pipeline" + + # List pipelines using a specific repository + $ azdo pipelines list "my-project" --repository "my-repo" + + # Output as JSON + $ azdo pipelines list "my-project" --json + `), + Aliases: []string{ + "ls", + "l", + }, + Args: util.ExactArgs(1, "project argument is required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.scope = args[0] + return runList(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.name, "name", "", "Filter by pipeline name (prefix or exact)") + cmd.Flags().StringVar(&opts.repository, "repository", "", "Filter by repository name or ID") + util.StringEnumFlag(cmd, &opts.repositoryType, "repository-type", "", "", + []string{"tfsgit", "github"}, "Repository type filter") + cmd.Flags().IntVar(&opts.top, "top", 0, "Maximum number of definitions to return") + cmd.Flags().StringVar(&opts.folderPath, "folder-path", "", "Filter by folder path (e.g. \"user1/production\")") + util.StringEnumFlag(cmd, &opts.queryOrder, "query-order", "", "", + []string{"none", "definitionNameAscending", "definitionNameDescending", "lastModifiedAscending", "lastModifiedDescending"}, + "Order of definitions") + cmd.Flags().IntVar(&opts.maxItems, "max-items", 0, "Optional client-side cap on results") + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "name", "path", "revision", "type", "quality", "queueStatus", + "createdDate", "project", "authoredBy", "latestBuild", "latestCompletedBuild", + "draftOf", "drafts", "metrics", "queue", "uri", "url", "_links", + }) + + return cmd +} + +func runList(cmdCtx util.CmdContext, opts *opts) error { + ios, err := cmdCtx.IOStreams() + if err != nil { + return err + } + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + if opts.top < 0 { + return util.FlagErrorf("invalid --top value %d; must be greater than 0", opts.top) + } + if opts.maxItems < 0 { + return util.FlagErrorf("invalid --max-items value %d; must be greater than 0", opts.maxItems) + } + + scope, err := util.ParseProjectScope(cmdCtx, opts.scope) + if err != nil { + return util.FlagErrorf("invalid project argument: %w", err) + } + + if opts.repository != "" && opts.repositoryType == "" { + opts.repositoryType = "tfsgit" + } + + buildClient, err := cmdCtx.ClientFactory().Build(cmdCtx.Context(), scope.Organization) + if err != nil { + return err + } + + var definitions []build.BuildDefinitionReference + var continuationToken *string + + for { + args := build.GetDefinitionsArgs{ + Project: types.ToPtr(scope.Project), + Name: types.NotZeroPtrOrNil(opts.name), + RepositoryId: types.NotZeroPtrOrNil(opts.repository), + RepositoryType: types.NotZeroPtrOrNil(opts.repositoryType), + Top: types.PositivePtrOrNil(opts.top), + Path: types.NotZeroPtrOrNil(opts.folderPath), + ContinuationToken: continuationToken, + } + if opts.queryOrder != "" { + order := build.DefinitionQueryOrder(opts.queryOrder) + args.QueryOrder = &order + } + + resp, err := buildClient.GetDefinitions(cmdCtx.Context(), args) + if err != nil { + return err + } + + definitions = append(definitions, resp.Value...) + + if opts.maxItems > 0 && len(definitions) >= opts.maxItems { + definitions = definitions[:opts.maxItems] + break + } + + if resp.ContinuationToken == "" { + break + } + continuationToken = &resp.ContinuationToken + + if opts.top > 0 && len(definitions) >= opts.top { + break + } + } + + sort.Slice(definitions, func(i, j int) bool { + return types.GetValue(definitions[i].Id, 0) < types.GetValue(definitions[j].Id, 0) + }) + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, definitions) + } + + tp, err := cmdCtx.Printer("table") + if err != nil { + return err + } + + hasDraft := false + for _, def := range definitions { + if types.GetValue(def.Quality, "") == "draft" { + hasDraft = true + break + } + } + + columns := []string{"ID", "PATH", "NAME"} + if hasDraft { + columns = append(columns, "DRAFT") + } + columns = append(columns, "STATUS", "DEFAULT QUEUE") + tp.AddColumns(columns...) + + for _, def := range definitions { + tp.AddField(fmt.Sprintf("%d", types.GetValue(def.Id, 0))) + tp.AddField(types.GetValue(def.Path, "")) + tp.AddField(types.GetValue(def.Name, "")) + if hasDraft { + if types.GetValue(def.Quality, "") == "draft" { + tp.AddField("*") + } else { + tp.AddField("") + } + } + tp.AddField(string(types.GetValue(def.QueueStatus, ""))) + qName := "" + if def.Queue != nil { + qName = types.GetValue(def.Queue.Name, "") + } + tp.AddField(qName) + tp.EndRow() + } + + return tp.Render() +} diff --git a/internal/cmd/pipelines/list/list_test.go b/internal/cmd/pipelines/list/list_test.go new file mode 100644 index 00000000..bf4cd425 --- /dev/null +++ b/internal/cmd/pipelines/list/list_test.go @@ -0,0 +1,601 @@ +package list + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/printer" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type fakeListDeps struct { + ctrl *gomock.Controller + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + buildClient *mocks.MockBuildClient + stdout *bytes.Buffer + stderr *bytes.Buffer +} + +func setupFakeDeps(t *testing.T, organization string) *fakeListDeps { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, errOut := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + deps := &fakeListDeps{ + ctrl: ctrl, + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + buildClient: mocks.NewMockBuildClient(ctrl), + stdout: out, + stderr: errOut, + } + + deps.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + deps.clientFact.EXPECT().Build(gomock.Any(), organization).Return(deps.buildClient, nil).AnyTimes() + + return deps +} + +func sampleDefinition(id int, name, path string, quality build.DefinitionQuality, queueStatus build.DefinitionQueueStatus) build.BuildDefinitionReference { + def := build.BuildDefinitionReference{ + Id: types.ToPtr(id), + Name: types.ToPtr(name), + Path: types.ToPtr(path), + Type: types.ToPtr(build.DefinitionTypeValues.Build), + Quality: types.ToPtr(quality), + QueueStatus: types.ToPtr(queueStatus), + Revision: types.ToPtr(1), + } + if queueStatus == build.DefinitionQueueStatusValues.Enabled { + def.Queue = &build.AgentPoolQueue{Name: types.ToPtr("default")} + } + return def +} + +func TestNewCmd_RegistersAsListLeaf(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) +} + +func TestNewCmd_RequiresExactlyOneArg(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + err := cmd.Args(cmd, []string{}) + require.Error(t, err) + err = cmd.Args(cmd, []string{"org", "extra"}) + require.Error(t, err) +} + +func TestNewCmd_HasFlags(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + f := cmd.Flags() + + require.NotNil(t, f.Lookup("name")) + require.NotNil(t, f.Lookup("repository")) + require.NotNil(t, f.Lookup("repository-type")) + require.NotNil(t, f.Lookup("top")) + require.NotNil(t, f.Lookup("folder-path")) + require.NotNil(t, f.Lookup("query-order")) + require.NotNil(t, f.Lookup("max-items")) + assert.NotNil(t, f.Lookup("json")) + assert.NotNil(t, f.Lookup("jq")) + assert.NotNil(t, f.Lookup("template")) +} + +func TestRunList_BasicCall(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "myorg") + defs := []build.BuildDefinitionReference{ + sampleDefinition(7, "pipeline-a", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + sampleDefinition(8, "pipeline-b", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Paused), + } + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Return( + &build.GetDefinitionsResponseValue{Value: defs}, nil, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = runList(deps.cmd, &opts{scope: "myorg/myproject"}) + require.NoError(t, err) + + output := deps.stdout.String() + assert.Contains(t, output, "7") + assert.Contains(t, output, "pipeline-a") + assert.Contains(t, output, "8") + assert.Contains(t, output, "pipeline-b") + assert.Contains(t, output, "enabled") + assert.Contains(t, output, "paused") + assert.Contains(t, output, "default") +} + +func TestRunList_OrgFromArg(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "explicit-org") + defs := []build.BuildDefinitionReference{ + sampleDefinition(1, "pipe-1", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + } + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Return( + &build.GetDefinitionsResponseValue{Value: defs}, nil, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = runList(deps.cmd, &opts{scope: "explicit-org/myproject"}) + require.NoError(t, err) + assert.Contains(t, deps.stdout.String(), "pipe-1") +} + +func TestRunList_NoDefaultOrg(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, _, _ := iostreams.Test() + cmd := mocks.NewMockCmdContext(ctrl) + cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + cmd.EXPECT().ClientFactory().Return(mocks.NewMockClientFactory(ctrl)).AnyTimes() + + cfg := mocks.NewMockConfig(ctrl) + auth := mocks.NewMockAuthConfig(ctrl) + cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return("", fmt.Errorf("no default org")) + + err := runList(cmd, &opts{scope: "myproject"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no organization specified") +} + +func TestRunList_InvalidFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts opts + wantMsg string + }{ + {"negative top", opts{scope: "org/proj", top: -1}, "invalid --top"}, + {"negative max-items", opts{scope: "org/proj", maxItems: -5}, "invalid --max-items"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := setupFakeDeps(t, "org") + err := runList(deps.cmd, &tt.opts) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantMsg) + }) + } +} + +func TestRunList_ClientFactoryError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, _, _ := iostreams.Test() + cmd := mocks.NewMockCmdContext(ctrl) + clientFact := mocks.NewMockClientFactory(ctrl) + cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + cmd.EXPECT().ClientFactory().Return(clientFact).AnyTimes() + + cfg := mocks.NewMockConfig(ctrl) + auth := mocks.NewMockAuthConfig(ctrl) + cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return("myorg", nil) + + clientFact.EXPECT().Build(gomock.Any(), "myorg").Return(nil, fmt.Errorf("connection failed")) + + err := runList(cmd, &opts{scope: "myproject"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "connection failed") +} + +func TestRunList_SDKError(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("API error")) + + err := runList(deps.cmd, &opts{scope: "org/proj"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "API error") +} + +func TestRunList_EmptyResult(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Return( + &build.GetDefinitionsResponseValue{Value: []build.BuildDefinitionReference{}}, nil, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = runList(deps.cmd, &opts{scope: "org/proj"}) + require.NoError(t, err) +} + +func TestRunList_FilterByName(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + defs := []build.BuildDefinitionReference{ + sampleDefinition(1, "my-pipeline", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + } + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + require.NotNil(t, args.Name) + assert.Equal(t, "my-pipeline", *args.Name) + return &build.GetDefinitionsResponseValue{Value: defs}, nil + }, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = runList(deps.cmd, &opts{scope: "org/proj", name: "my-pipeline"}) + require.NoError(t, err) +} + +func TestRunList_FolderPathFilter(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + defs := []build.BuildDefinitionReference{ + sampleDefinition(1, "pipe", "\\user1\\prod", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + } + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + require.NotNil(t, args.Path) + assert.Equal(t, "user1/production", *args.Path) + return &build.GetDefinitionsResponseValue{Value: defs}, nil + }, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = runList(deps.cmd, &opts{scope: "org/proj", folderPath: "user1/production"}) + require.NoError(t, err) +} + +func TestRunList_RepositoryFilter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts opts + wantType string + }{ + {"default type tfsgit", opts{scope: "org/proj", repository: "my-repo"}, "tfsgit"}, + {"explicit type github", opts{scope: "org/proj", repository: "my-repo", repositoryType: "github"}, "github"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := setupFakeDeps(t, "org") + defs := []build.BuildDefinitionReference{ + sampleDefinition(1, "pipe", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + } + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + require.NotNil(t, args.RepositoryId) + assert.Equal(t, "my-repo", *args.RepositoryId) + require.NotNil(t, args.RepositoryType) + assert.Equal(t, tt.wantType, *args.RepositoryType) + return &build.GetDefinitionsResponseValue{Value: defs}, nil + }, + ) + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + err = runList(deps.cmd, &tt.opts) + require.NoError(t, err) + }) + } +} + +func TestRunList_MaxItemsFilter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + maxItems int + defs []build.BuildDefinitionReference + want []string + notWant []string + }{ + { + "caps at limit", + 1, + []build.BuildDefinitionReference{ + sampleDefinition(1, "p1", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + sampleDefinition(2, "p2", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + sampleDefinition(3, "p3", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + }, + []string{"p1"}, + []string{"p2", "p3"}, + }, + { + "exceeds result count", + 100, + []build.BuildDefinitionReference{ + sampleDefinition(1, "p1", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + sampleDefinition(2, "p2", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + }, + []string{"p1", "p2"}, + nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := setupFakeDeps(t, "org") + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Return( + &build.GetDefinitionsResponseValue{Value: tt.defs}, nil, + ) + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + err = runList(deps.cmd, &opts{scope: "org/proj", maxItems: tt.maxItems}) + require.NoError(t, err) + output := deps.stdout.String() + for _, w := range tt.want { + assert.Contains(t, output, w) + } + for _, n := range tt.notWant { + assert.NotContains(t, output, n) + } + }) + } +} + +func TestRunList_DraftColumnPresent(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + defs := []build.BuildDefinitionReference{ + sampleDefinition(1, "draft-pipe", "\\", build.DefinitionQualityValues.Draft, build.DefinitionQueueStatusValues.Enabled), + sampleDefinition(2, "normal-pipe", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + } + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Return( + &build.GetDefinitionsResponseValue{Value: defs}, nil, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = runList(deps.cmd, &opts{scope: "org/proj"}) + require.NoError(t, err) + + assert.Contains(t, deps.stdout.String(), "*") +} + +func TestRunList_DraftColumnAbsent(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + defs := []build.BuildDefinitionReference{ + sampleDefinition(1, "pipe-1", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + } + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Return( + &build.GetDefinitionsResponseValue{Value: defs}, nil, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = runList(deps.cmd, &opts{scope: "org/proj"}) + require.NoError(t, err) + + assert.NotContains(t, deps.stdout.String(), "*") +} + +func TestRunList_JSONOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + defs []build.BuildDefinitionReference + check func(t *testing.T, out []byte) + }{ + { + "empty", + []build.BuildDefinitionReference{}, + func(t *testing.T, out []byte) { + assert.Equal(t, "[]\n", string(out)) + }, + }, + { + "minimal fields from sample", + []build.BuildDefinitionReference{ + sampleDefinition(7, "pipeline-a", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + }, + func(t *testing.T, out []byte) { + var parsed []map[string]any + err := json.Unmarshal(out, &parsed) + require.NoError(t, err) + require.Len(t, parsed, 1) + assert.Equal(t, float64(7), parsed[0]["id"]) + assert.Equal(t, "pipeline-a", parsed[0]["name"]) + assert.Equal(t, "build", parsed[0]["type"]) + assert.Equal(t, "definition", parsed[0]["quality"]) + assert.Equal(t, "enabled", parsed[0]["queueStatus"]) + assert.Equal(t, float64(1), parsed[0]["revision"]) + queue, ok := parsed[0]["queue"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "default", queue["name"]) + }, + }, + { + "full raw struct with all fields", + []build.BuildDefinitionReference{ + { + Id: types.ToPtr(42), + Name: types.ToPtr("full-pipeline"), + Path: types.ToPtr("\\team\\services"), + Type: types.ToPtr(build.DefinitionTypeValues.Build), + Quality: types.ToPtr(build.DefinitionQualityValues.Draft), + QueueStatus: types.ToPtr(build.DefinitionQueueStatusValues.Paused), + Revision: types.ToPtr(3), + Queue: &build.AgentPoolQueue{Name: types.ToPtr("linux-pool")}, + }, + }, + func(t *testing.T, out []byte) { + var parsed []map[string]any + err := json.Unmarshal(out, &parsed) + require.NoError(t, err) + require.Len(t, parsed, 1) + assert.Equal(t, float64(42), parsed[0]["id"]) + assert.Equal(t, "full-pipeline", parsed[0]["name"]) + assert.Equal(t, "\\team\\services", parsed[0]["path"]) + assert.Equal(t, float64(3), parsed[0]["revision"]) + assert.Equal(t, "build", parsed[0]["type"]) + assert.Equal(t, "draft", parsed[0]["quality"]) + assert.Equal(t, "paused", parsed[0]["queueStatus"]) + queue, ok := parsed[0]["queue"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "linux-pool", queue["name"]) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := setupFakeDeps(t, "org") + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Return( + &build.GetDefinitionsResponseValue{Value: tt.defs}, nil, + ) + exporter := util.NewJSONExporter() + err := runList(deps.cmd, &opts{scope: "org/proj", exporter: exporter}) + require.NoError(t, err) + tt.check(t, deps.stdout.Bytes()) + }) + } +} + +func TestRunList_QueryOrderFlag(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + defs := []build.BuildDefinitionReference{ + sampleDefinition(1, "pipe", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + } + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + require.NotNil(t, args.QueryOrder) + assert.Equal(t, build.DefinitionQueryOrderValues.DefinitionNameAscending, *args.QueryOrder) + return &build.GetDefinitionsResponseValue{Value: defs}, nil + }, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = runList(deps.cmd, &opts{scope: "org/proj", queryOrder: "definitionNameAscending"}) + require.NoError(t, err) +} + +func TestRunList_SortsByIDAscending(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + unsorted := []build.BuildDefinitionReference{ + sampleDefinition(3, "c", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + sampleDefinition(1, "a", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + sampleDefinition(2, "b", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + } + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Return( + &build.GetDefinitionsResponseValue{Value: unsorted}, nil, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = runList(deps.cmd, &opts{scope: "org/proj"}) + require.NoError(t, err) + + output := deps.stdout.String() + aIdx := strings.Index(output, "a") + bIdx := strings.Index(output, "b") + cIdx := strings.Index(output, "c") + assert.True(t, aIdx < bIdx && bIdx < cIdx, "definitions should appear sorted by ID") +} + +func TestRunList_Pagination(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + page1 := []build.BuildDefinitionReference{ + sampleDefinition(1, "p1", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + } + page2 := []build.BuildDefinitionReference{ + sampleDefinition(2, "p2", "\\", build.DefinitionQualityValues.Definition, build.DefinitionQueueStatusValues.Enabled), + } + gomock.InOrder( + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Return( + &build.GetDefinitionsResponseValue{Value: page1, ContinuationToken: "token-1"}, nil, + ), + deps.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + require.NotNil(t, args.ContinuationToken) + assert.Equal(t, "token-1", *args.ContinuationToken) + return &build.GetDefinitionsResponseValue{Value: page2}, nil + }, + ), + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + err = runList(deps.cmd, &opts{scope: "org/proj"}) + require.NoError(t, err) + + output := deps.stdout.String() + assert.Contains(t, output, "p1") + assert.Contains(t, output, "p2") +} diff --git a/internal/cmd/pipelines/pipelines.go b/internal/cmd/pipelines/pipelines.go index 08c62099..0565afc6 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/list" "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" @@ -15,6 +16,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { Aliases: []string{"p"}, } + cmd.AddCommand(list.NewCmd(ctx)) cmd.AddCommand(variablegroup.NewCmd(ctx)) cmd.AddCommand(agent.NewCmd(ctx)) cmd.AddCommand(pool.NewCmd(ctx)) diff --git a/internal/cmd/pr/create/create.go b/internal/cmd/pr/create/create.go index 8137effe..87929a63 100644 --- a/internal/cmd/pr/create/create.go +++ b/internal/cmd/pr/create/create.go @@ -165,7 +165,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { opts.description = string(t.Body()) } - if !iostreams.CanPrompt() && !(opts.fillVerbose || opts.autofill || opts.fillFirst) && (opts.title == "" || opts.description == "") { + if !iostreams.CanPrompt() && !opts.fillVerbose && !opts.autofill && !opts.fillFirst && (opts.title == "" || opts.description == "") { return util.FlagErrorf("must provide `--title` and `--description` (`--description-file`) or `--fill` or `fill-first` or `--fillverbose` when not running interactively") } @@ -296,9 +296,9 @@ func runCmd(ctx util.CmdContext, opts *createOptions) (err error) { var sb strings.Builder for _, c := range commits { if opts.fillVerbose { - sb.WriteString(fmt.Sprintf("### %s\n%s\n", c.Title, c.Body)) + fmt.Fprintf(&sb, "### %s\n%s\n", c.Title, c.Body) } else { - sb.WriteString(fmt.Sprintf("* %s", c.Title)) + fmt.Fprintf(&sb, "* %s", c.Title) } } opts.description = sb.String() diff --git a/internal/cmd/serviceendpoint/update/update.go b/internal/cmd/serviceendpoint/update/update.go index 0f502d5b..462cbdf0 100644 --- a/internal/cmd/serviceendpoint/update/update.go +++ b/internal/cmd/serviceendpoint/update/update.go @@ -87,7 +87,7 @@ func run(ctx util.CmdContext, o *opts) error { fromFileSet := strings.TrimSpace(o.fromFile) != "" - if !(o.nameChanged || o.descChanged || o.urlChanged || fromFileSet || o.enableForAllUsed) { + if !o.nameChanged && !o.descChanged && !o.urlChanged && !fromFileSet && !o.enableForAllUsed { return util.FlagErrorf("at least one mutating flag must be supplied") } diff --git a/internal/cmd/util/json_flags.go b/internal/cmd/util/json_flags.go index 63b6d8fc..120be2c2 100644 --- a/internal/cmd/util/json_flags.go +++ b/internal/cmd/util/json_flags.go @@ -335,7 +335,7 @@ func (e *jsonExporter) Write(ios *iostreams.IOStreams, data any) error { func (e *jsonExporter) exportData(v reflect.Value) any { switch v.Kind() { //nolint:exhaustive - case reflect.Ptr, reflect.Interface: + case reflect.Pointer, reflect.Interface: if !v.IsNil() { return e.exportData(v.Elem()) } @@ -400,7 +400,7 @@ var ( ) func structExportData(v reflect.Value, fields []string, strict bool) map[string]any { - if v.Kind() == reflect.Ptr { + if v.Kind() == reflect.Pointer { if v.IsNil() { return nil } @@ -480,7 +480,7 @@ func flattenStructFields(v reflect.Value) ([]structFieldInfo, map[string]int) { var walk func(reflect.Value) walk = func(val reflect.Value) { - if val.Kind() == reflect.Ptr { + if val.Kind() == reflect.Pointer { if val.IsNil() { return } @@ -497,10 +497,10 @@ func flattenStructFields(v reflect.Value) ([]structFieldInfo, map[string]int) { fv := val.Field(i) if sf.Anonymous { - if fv.Kind() == reflect.Ptr && fv.IsNil() { + if fv.Kind() == reflect.Pointer && fv.IsNil() { continue } - if fv.Kind() == reflect.Ptr || fv.Kind() == reflect.Interface { + if fv.Kind() == reflect.Pointer || fv.Kind() == reflect.Interface { fv = fv.Elem() } if fv.Kind() == reflect.Struct { diff --git a/internal/types/types.go b/internal/types/types.go index 3e27b531..030e5d99 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -1,12 +1,35 @@ package types +import "cmp" + +// ToPtr returns a pointer to value. func ToPtr[T any](value T) *T { return &value } +// GetValue dereferences ptr and returns the value, or defaultVal if ptr is nil. func GetValue[T any](ptr *T, defaultVal T) T { if ptr == nil { return defaultVal } return *ptr } + +// NotZeroPtrOrNil returns a pointer to v if v is not the zero value, otherwise nil. +func NotZeroPtrOrNil[T comparable](v T) *T { + var zero T + if v == zero { + return nil + } + return &v +} + +// PositivePtrOrNil returns a pointer to v if v > zero value, otherwise nil. +// Useful for converting numeric flags where non-positive means "not set". +func PositivePtrOrNil[T cmp.Ordered](v T) *T { + var zero T + if v <= zero { + return nil + } + return &v +} diff --git a/internal/util/stringBuilder.go b/internal/util/stringBuilder.go index d20afd56..93dcccf6 100644 --- a/internal/util/stringBuilder.go +++ b/internal/util/stringBuilder.go @@ -132,7 +132,7 @@ func (sb *StringBuilder) Generate(length int) (string, error) { case "binary": var strBuilder strings.Builder for _, by := range []byte(s) { - strBuilder.WriteString(fmt.Sprintf("%08b", by)) + fmt.Fprintf(&strBuilder, "%08b", by) } return strBuilder.String(), nil default: