From de065b33657900d6fe336a73b2c78542bc070e95 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 10:59:54 +0000 Subject: [PATCH 1/9] refactor(util): use fmt.Fprintf for binary conversion --- internal/util/stringBuilder.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From e10fc0c346d862918ed2c9a04bbf82bcfad0ce4f Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 11:00:23 +0000 Subject: [PATCH 2/9] feat(types): add pointer helper functions Add NotZeroPtrOrNil and PositivePtrOrNil generic functions to convert values to pointers based on non-zero or positive checks. Also add missing documentation comments to existing ToPtr and GetValue helpers. --- internal/types/types.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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 +} From 0051a1ff6e78ad3555f5e3933241a4c98702b064 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 11:00:41 +0000 Subject: [PATCH 3/9] refactor(util): replace deprecated reflect.Ptr with reflect.Pointer --- internal/cmd/util/json_flags.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 { From ffd0b27c8209dab38da32d0c39e3d940f2dc6e9b Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 11:01:07 +0000 Subject: [PATCH 4/9] refactor(serviceendpoint): simplify mutating flag check --- internal/cmd/serviceendpoint/update/update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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") } From f48ed7b5321adbe7739bb6ebdd036a242303bb02 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 11:03:09 +0000 Subject: [PATCH 5/9] refactor(pr): use fmt.Fprintf and simplify check Replace WriteString(fmt.Sprintf(...)) with fmt.Fprintf when building PR descriptions. Simplify the non-interactive mode validation. --- internal/cmd/pr/create/create.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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() From 19926e3e14c52d07c90cfe6b91ef16c3e6951277 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 11:04:48 +0000 Subject: [PATCH 6/9] refactor(config): use fmt.Fprintf for option docs --- internal/cmd/config/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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') } From b5ad2a011f56a7d0e0118b52afa1a1f9770d4351 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 11:09:13 +0000 Subject: [PATCH 7/9] feat(pipelines): support listing pipeline definitions via CLI Introduces the 'list' subcommand (with 'ls' and 'l' aliases) to the pipelines group for fetching build definitions. Users can apply filters on names, repositories (with type), folder paths, and specify ordering or result caps using dedicated flags. Closes: #256 --- internal/cmd/pipelines/list/list.go | 198 ++++++++++++++++++++++++++++ internal/cmd/pipelines/pipelines.go | 2 + 2 files changed, 200 insertions(+) create mode 100644 internal/cmd/pipelines/list/list.go 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/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)) From c94cc9517792bd0ad785a2f7229390939c7a10e8 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 11:09:44 +0000 Subject: [PATCH 8/9] =?UTF-8?q?test(pipelines):=20=F0=9F=A7=AA=20add=20uni?= =?UTF-8?q?t=20tests=20for=20pipeline=20list=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adds comprehensive test coverage for the pipeline list command covering flag parsing, argument validation, organization resolution, filtering, sorting, pagination, json and table output, draft column rendering, and error handling --- internal/cmd/pipelines/list/list_test.go | 601 +++++++++++++++++++++++ 1 file changed, 601 insertions(+) create mode 100644 internal/cmd/pipelines/list/list_test.go 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") +} From 151af54de57e6ff44268fd54d5c314a73ba97da1 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 11:10:11 +0000 Subject: [PATCH 9/9] =?UTF-8?q?docs(pipelines):=20=F0=9F=93=84=20add=20pip?= =?UTF-8?q?elines=20list=20command=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/azdo_help_reference.md | 23 +++++++++++ docs/azdo_pipelines.md | 1 + docs/azdo_pipelines_list.md | 81 +++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 docs/azdo_pipelines_list.md 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)