diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index fae8f05b..33787978 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -407,6 +407,35 @@ Aliases d, del, rm ``` +### `azdo pipelines folder` + +Manage Azure DevOps pipeline folders + +Aliases + +``` +folders +``` + +#### `azdo pipelines folder list [ORGANIZATION/]PROJECT [flags]` + +List folders. + +``` +-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 folders to return (client-side; 0 = unlimited) + --path string Limit the listing to folders at or under this path. + --query-order string Sort folders by path: {asc|desc} +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +ls, l +``` + ### `azdo pipelines list [ORGANIZATION/]PROJECT [flags]` List pipeline definitions diff --git a/docs/azdo_pipelines.md b/docs/azdo_pipelines.md index e351a97e..f8d795c4 100644 --- a/docs/azdo_pipelines.md +++ b/docs/azdo_pipelines.md @@ -7,6 +7,7 @@ Manage Azure DevOps pipelines * [azdo pipelines agent](./azdo_pipelines_agent.md) * [azdo pipelines build](./azdo_pipelines_build.md) * [azdo pipelines delete](./azdo_pipelines_delete.md) +* [azdo pipelines folder](./azdo_pipelines_folder.md) * [azdo pipelines list](./azdo_pipelines_list.md) * [azdo pipelines pool](./azdo_pipelines_pool.md) * [azdo pipelines queue](./azdo_pipelines_queue.md) diff --git a/docs/azdo_pipelines_folder.md b/docs/azdo_pipelines_folder.md new file mode 100644 index 00000000..afa4ed89 --- /dev/null +++ b/docs/azdo_pipelines_folder.md @@ -0,0 +1,17 @@ +## Command `azdo pipelines folder` + +Manage Azure DevOps build definition folders. Folders are project-scoped +and organize pipeline definitions. + + +### Available commands + +* [azdo pipelines folder list](./azdo_pipelines_folder_list.md) + +### ALIASES + +- `folders` + +### See also + +* [azdo pipelines](./azdo_pipelines.md) diff --git a/docs/azdo_pipelines_folder_list.md b/docs/azdo_pipelines_folder_list.md new file mode 100644 index 00000000..1a8b8588 --- /dev/null +++ b/docs/azdo_pipelines_folder_list.md @@ -0,0 +1,68 @@ +## Command `azdo pipelines folder list` + +``` +azdo pipelines folder list [ORGANIZATION/]PROJECT [flags] +``` + +List build definition folders in PROJECT. + +Mirrors 'az pipelines folder list'. Use --path to limit the listing to +a sub-folder. Use --query-order to sort by path ascending or descending. + + +### Options + + +* `-q`, `--jq` `expression` + + Filter JSON output using a jq expression + +* `--json` `fields` + + Output JSON with the specified fields. Prefix a field with '-' to exclude it. + +* `--max-items` `int` (default `0`) + + Maximum number of folders to return (client-side; 0 = unlimited) + +* `--path` `string` + + Limit the listing to folders at or under this path. + +* `--query-order` `string` + + Sort folders by path: {asc|desc} + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `ls` +- `l` + +### JSON Fields + +`description`, `path` + +### Examples + +```bash +# List top-level folders in a project +azdo pipelines folder list Fabrikam + +# List folders at or under a sub-path +azdo pipelines folder list Fabrikam --path /Shared + +# List folders sorted descending by path +azdo pipelines folder list myorg/Fabrikam --query-order desc + +# Output as JSON +azdo pipelines folder list Fabrikam --json +``` + +### See also + +* [azdo pipelines folder](./azdo_pipelines_folder.md) diff --git a/internal/cmd/pipelines/folder/folder.go b/internal/cmd/pipelines/folder/folder.go new file mode 100644 index 00000000..06044f10 --- /dev/null +++ b/internal/cmd/pipelines/folder/folder.go @@ -0,0 +1,24 @@ +package folder + +import ( + "github.com/MakeNowJust/heredoc/v2" + "github.com/spf13/cobra" + + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/folder/list" + "github.com/tmeckel/azdo-cli/internal/cmd/util" +) + +func NewCmd(ctx util.CmdContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "folder", + Short: "Manage Azure DevOps pipeline folders", + Long: heredoc.Doc(` + Manage Azure DevOps build definition folders. Folders are project-scoped + and organize pipeline definitions. + `), + Aliases: []string{"folders"}, + } + + cmd.AddCommand(list.NewCmd(ctx)) + return cmd +} diff --git a/internal/cmd/pipelines/folder/list/list.go b/internal/cmd/pipelines/folder/list/list.go new file mode 100644 index 00000000..f8a85995 --- /dev/null +++ b/internal/cmd/pipelines/folder/list/list.go @@ -0,0 +1,155 @@ +package list + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + "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 { + targetArg string + path 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 folders.", + Long: heredoc.Doc(` + List build definition folders in PROJECT. + + Mirrors 'az pipelines folder list'. Use --path to limit the listing to + a sub-folder. Use --query-order to sort by path ascending or descending. + `), + Example: heredoc.Doc(` + # List top-level folders in a project + azdo pipelines folder list Fabrikam + + # List folders at or under a sub-path + azdo pipelines folder list Fabrikam --path /Shared + + # List folders sorted descending by path + azdo pipelines folder list myorg/Fabrikam --query-order desc + + # Output as JSON + azdo pipelines folder list Fabrikam --json + `), + Aliases: []string{ + "ls", + "l", + }, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runList(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.path, "path", "", "Limit the listing to folders at or under this path.") + util.StringEnumFlag(cmd, &opts.queryOrder, "query-order", "", "", []string{"asc", "desc"}, "Sort folders by path") + cmd.Flags().IntVar(&opts.maxItems, "max-items", 0, "Maximum number of folders to return (client-side; 0 = unlimited)") + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "createdBy", + "createdOn", + "description", + "lastChangedBy", + "lastChangedDate", + "path", + "project", + }) + + 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.maxItems < 0 { + return util.FlagErrorf("--max-items must be >= 0") + } + + scope, err := util.ParseProjectScope(cmdCtx, opts.targetArg) + if err != nil { + return util.FlagErrorf("invalid project argument: %w", err) + } + + ctx := cmdCtx.Context() + + client, err := cmdCtx.ClientFactory().Build(ctx, scope.Organization) + if err != nil { + return fmt.Errorf("failed to create build client: %w", err) + } + + args := build.GetFoldersArgs{ + Project: types.ToPtr(scope.Project), + } + if opts.path != "" { + args.Path = types.ToPtr(opts.path) + } + if opts.queryOrder != "" { + q := build.FolderQueryOrderValues.FolderAscending + if opts.queryOrder == "desc" { + q = build.FolderQueryOrderValues.FolderDescending + } + args.QueryOrder = &q + } + + zap.L().Debug( + "listing folders", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.String("path", types.GetValue(args.Path, "")), + ) + + resp, err := client.GetFolders(ctx, args) + if err != nil { + return fmt.Errorf("failed to list folders: %w", err) + } + + folders := []build.Folder{} + if resp != nil { + folders = *resp + } + + if opts.maxItems > 0 && len(folders) > opts.maxItems { + zap.L().Debug("truncating result set to max-items", zap.Int("maxItems", opts.maxItems)) + folders = folders[:opts.maxItems] + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, folders) + } + + tp, err := cmdCtx.Printer("list") + if err != nil { + return err + } + + tp.AddColumns("PATH", "DESCRIPTION") + tp.EndRow() + for _, f := range folders { + tp.AddField(types.GetValue(f.Path, "")) + tp.AddField(types.GetValue(f.Description, "")) + tp.EndRow() + } + + return tp.Render() +} diff --git a/internal/cmd/pipelines/folder/list/list_test.go b/internal/cmd/pipelines/folder/list/list_test.go new file mode 100644 index 00000000..3b1bb9e5 --- /dev/null +++ b/internal/cmd/pipelines/folder/list/list_test.go @@ -0,0 +1,383 @@ +package list + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + "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 + buildCli *mocks.MockBuildClient + 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), + buildCli: mocks.NewMockBuildClient(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().Build(gomock.Any(), organization).Return(deps.buildCli, 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 sampleFolder(path, description string) build.Folder { + return build.Folder{ + Path: types.ToPtr(path), + Description: types.ToPtr(description), + } +} + +func sampleFolderWithMetadata(path, description string) build.Folder { + createdOn := azuredevops.Time{} + lastChangedDate := azuredevops.Time{} + + return build.Folder{ + CreatedOn: &createdOn, + Description: types.ToPtr(description), + LastChangedDate: &lastChangedDate, + Path: types.ToPtr(path), + Project: &core.TeamProjectReference{ + Name: types.ToPtr("Fabrikam"), + }, + } +} + +func TestNewCmd_list(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) + + f := cmd.Flags() + assert.NotNil(t, f.Lookup("path")) + assert.NotNil(t, f.Lookup("query-order")) + assert.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 TestNewCmd_invalidQueryOrder(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + cmd.SetArgs([]string{"Fabrikam", "--query-order", "banana"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "valid values are {asc|desc}") +} + +func TestNewCmd_missingProject(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "accepts 1 arg(s)") +} + +func TestRunList_success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts opts + returned []build.Folder + assertArgs func(*testing.T, build.GetFoldersArgs) + assertOutput func(*testing.T, string) + }{ + { + name: "no path", + opts: opts{targetArg: "myorg/Fabrikam"}, + returned: []build.Folder{ + sampleFolder("P/Foo", "Foo folder"), + sampleFolder("P/Bar", "Bar folder"), + }, + assertArgs: func(t *testing.T, args build.GetFoldersArgs) { + t.Helper() + require.NotNil(t, args.Project) + assert.Equal(t, "Fabrikam", *args.Project) + assert.Nil(t, args.Path) + assert.Nil(t, args.QueryOrder) + }, + assertOutput: func(t *testing.T, output string) { + t.Helper() + assert.Contains(t, output, "P/Foo") + assert.Contains(t, output, "P/Bar") + assert.Contains(t, output, "Foo folder") + assert.Contains(t, output, "Bar folder") + }, + }, + { + name: "with path", + opts: opts{targetArg: "myorg/Fabrikam", path: "P"}, + returned: []build.Folder{sampleFolder("P/Foo", "")}, + assertArgs: func(t *testing.T, args build.GetFoldersArgs) { + t.Helper() + require.NotNil(t, args.Path) + assert.Equal(t, "P", *args.Path) + }, + assertOutput: func(t *testing.T, output string) { + t.Helper() + assert.Contains(t, output, "P/Foo") + }, + }, + { + name: "query order asc", + opts: opts{targetArg: "myorg/Fabrikam", queryOrder: "asc"}, + returned: []build.Folder{sampleFolder("P/Foo", "")}, + assertArgs: func(t *testing.T, args build.GetFoldersArgs) { + t.Helper() + require.NotNil(t, args.QueryOrder) + assert.Equal(t, build.FolderQueryOrderValues.FolderAscending, *args.QueryOrder) + }, + assertOutput: func(t *testing.T, output string) { + t.Helper() + assert.Contains(t, output, "P/Foo") + }, + }, + { + name: "query order desc", + opts: opts{targetArg: "myorg/Fabrikam", queryOrder: "desc"}, + returned: []build.Folder{sampleFolder("P/Foo", "")}, + assertArgs: func(t *testing.T, args build.GetFoldersArgs) { + t.Helper() + require.NotNil(t, args.QueryOrder) + assert.Equal(t, build.FolderQueryOrderValues.FolderDescending, *args.QueryOrder) + }, + assertOutput: func(t *testing.T, output string) { + t.Helper() + assert.Contains(t, output, "P/Foo") + }, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.buildCli.EXPECT().GetFolders(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetFoldersArgs) (*[]build.Folder, error) { + tc.assertArgs(t, args) + return &tc.returned, nil + }, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("list").Return(tp, nil) + + err = runList(deps.cmd, &tc.opts) + require.NoError(t, err) + + tc.assertOutput(t, deps.stdout.String()) + }) + } +} + +func TestRunList_success_maxItems(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + folders := []build.Folder{ + sampleFolder("P/First", ""), + sampleFolder("P/Second", ""), + sampleFolder("P/Third", ""), + sampleFolder("P/Fourth", ""), + sampleFolder("P/Fifth", ""), + } + deps.buildCli.EXPECT().GetFolders(gomock.Any(), gomock.Any()).Return(&folders, nil) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = runList(deps.cmd, &opts{targetArg: "myorg/Fabrikam", maxItems: 2}) + require.NoError(t, err) + + output := deps.stdout.String() + assert.Contains(t, output, "P/First") + assert.Contains(t, output, "P/Second") + assert.NotContains(t, output, "P/Third") + assert.NotContains(t, output, "P/Fourth") + assert.NotContains(t, output, "P/Fifth") +} + +func TestRunList_success_JSON(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + folders := []build.Folder{ + sampleFolderWithMetadata("P/Foo", "Foo folder"), + sampleFolder("P/Bar", "Bar folder"), + } + deps.buildCli.EXPECT().GetFolders(gomock.Any(), gomock.Any()).Return(&folders, nil) + deps.cmd.EXPECT().Printer(gomock.Any()).Times(0) + + exporter := util.NewJSONExporter() + err := runList(deps.cmd, &opts{targetArg: "myorg/Fabrikam", exporter: exporter}) + require.NoError(t, err) + + var parsed []build.Folder + err = json.Unmarshal(deps.stdout.Bytes(), &parsed) + require.NoError(t, err) + require.Len(t, parsed, 2) + + require.NotNil(t, parsed[0].Path) + require.NotNil(t, parsed[0].Description) + require.NotNil(t, parsed[0].CreatedOn) + require.NotNil(t, parsed[0].LastChangedDate) + require.NotNil(t, parsed[0].Project) + require.NotNil(t, parsed[0].Project.Name) + require.NotNil(t, parsed[1].Path) + require.NotNil(t, parsed[1].Description) + + assert.Equal(t, "P/Foo", *parsed[0].Path) + assert.Equal(t, "Foo folder", *parsed[0].Description) + assert.Equal(t, "Fabrikam", *parsed[0].Project.Name) + assert.Equal(t, "P/Bar", *parsed[1].Path) + assert.Equal(t, "Bar folder", *parsed[1].Description) +} + +func TestRunList_empty(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.buildCli.EXPECT().GetFolders(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ build.GetFoldersArgs) (*[]build.Folder, error) { + empty := []build.Folder{} + return &empty, nil + }, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = runList(deps.cmd, &opts{targetArg: "myorg/Fabrikam"}) + require.NoError(t, err) + assert.Equal(t, "\n", deps.stdout.String()) +} + +func TestRunList_APIError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.buildCli.EXPECT().GetFolders(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("boom")) + + err := runList(deps.cmd, &opts{targetArg: "myorg/Fabrikam"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} + +func TestRunList_missingProject(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "") + deps.setupDefaultOrg("myorg") + + err := runList(deps.cmd, &opts{targetArg: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid project argument") +} + +func TestRunList_invalidMaxItems(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + + err := runList(deps.cmd, &opts{targetArg: "myorg/Fabrikam", maxItems: -1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--max-items must be >= 0") +} + +func TestRunList_clientFactoryError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "") + deps.setupDefaultOrg("myorg") + deps.clientFact.EXPECT().Build(gomock.Any(), "myorg").Return(nil, fmt.Errorf("connection failed")) + + err := runList(deps.cmd, &opts{targetArg: "Fabrikam"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "connection failed") +} + +func TestRunList_defaultOrg(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "") + deps.setupDefaultOrg("default-org") + deps.clientFact.EXPECT().Build(gomock.Any(), "default-org").Return(deps.buildCli, nil).AnyTimes() + + folders := []build.Folder{sampleFolder("P/Foo", "Foo")} + deps.buildCli.EXPECT().GetFolders(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetFoldersArgs) (*[]build.Folder, error) { + assert.Equal(t, "Fabrikam", *args.Project) + return &folders, nil + }, + ) + + tp, err := printer.NewTablePrinter(deps.stdout, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = runList(deps.cmd, &opts{targetArg: "Fabrikam"}) + require.NoError(t, err) + + assert.Contains(t, deps.stdout.String(), "P/Foo") +} diff --git a/internal/cmd/pipelines/pipelines.go b/internal/cmd/pipelines/pipelines.go index b9e8c64a..2a7852b1 100644 --- a/internal/cmd/pipelines/pipelines.go +++ b/internal/cmd/pipelines/pipelines.go @@ -6,6 +6,7 @@ import ( "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/agent" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/build" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/delete" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/folder" "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" @@ -29,6 +30,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(build.NewCmd(ctx)) cmd.AddCommand(delete.NewCmd(ctx)) + cmd.AddCommand(folder.NewCmd(ctx)) cmd.AddCommand(list.NewCmd(ctx)) cmd.AddCommand(run.NewCmd(ctx)) cmd.AddCommand(runs.NewCmd(ctx))