From 630726686803136b4b8b15624e238a2b68c0256f Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 18:08:31 +0000 Subject: [PATCH 1/4] feat(scope): add project path target with default organization --- internal/cmd/util/scope.go | 13 ++++++++ internal/cmd/util/scope_test.go | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/internal/cmd/util/scope.go b/internal/cmd/util/scope.go index e4444f3a..6eb3c904 100644 --- a/internal/cmd/util/scope.go +++ b/internal/cmd/util/scope.go @@ -397,6 +397,19 @@ func ParseProjectTargetWithDefaultOrganization(ctx CmdContext, target string) (* }) } +// ParseProjectPathTargetWithDefaultOrganization resolves a project-scoped path +// target that allows an implicit organization by falling back to the configured +// default. Accepted forms are PROJECT/PATH and ORG:PROJECT/PATH. PATH may +// itself contain '/' and is split into Target segments; callers rejoin them +// with "/" to recover the full path. +func ParseProjectPathTargetWithDefaultOrganization(ctx CmdContext, raw string) (*Path, error) { + return Parse(ctx, raw, ParseOptions{ + AllowImplicitOrg: true, + RequireProject: true, + MinTargets: 1, + }) +} + // ParsePoolAgentTargetWithDefaultOrganization resolves a pool/agent target that // allows an implicit organization by falling back to the configured default. // Accepted forms are /POOL/AGENT and ORG:/POOL/AGENT; the no-project marker is diff --git a/internal/cmd/util/scope_test.go b/internal/cmd/util/scope_test.go index 7ab4d5c8..f70576af 100644 --- a/internal/cmd/util/scope_test.go +++ b/internal/cmd/util/scope_test.go @@ -539,6 +539,64 @@ func TestParseProjectTargetWithDefaultOrganization(t *testing.T) { } } +func TestParseProjectPathTargetWithDefaultOrganization(t *testing.T) { + ctx := defaultOrgCtx(t) + tests := []parseCase{ + { + name: "project and path use default organization", + raw: "project/path", + ctx: ctx, + want: &util.Path{Organization: "default-org", Project: "project", Targets: []string{"path"}}, + }, + { + name: "explicit organization with project and path", + raw: "myorg:project/path", + ctx: ctx, + want: &util.Path{Organization: "myorg", Project: "project", Targets: []string{"path"}}, + }, + { + name: "nested path keeps every segment", + raw: "myorg:project/path/to/folder", + ctx: ctx, + want: &util.Path{Organization: "myorg", Project: "project", Targets: []string{"path", "to", "folder"}}, + }, + { + name: "single segment misses path", + raw: "project", + ctx: ctx, + wantErr: "expected at least 1 targets, got 0", + }, + { + name: "no-project marker misses project", + raw: "/path", + ctx: ctx, + wantErr: "project is required", + }, + { + name: "organization only misses project", + raw: "myorg:", + ctx: ctx, + wantErr: "project is required", + }, + { + name: "empty input misses project", + raw: "", + ctx: ctx, + wantErr: "project is required", + }, + { + name: "project path without default organization errors", + raw: "project/path", + ctx: nil, + wantErr: "no organization specified and no default organization configured", + }, + } + + for _, tt := range tests { + runParseCase(t, tt, util.ParseProjectPathTargetWithDefaultOrganization) + } +} + func TestParsePoolAgentTargetWithDefaultOrganization(t *testing.T) { ctx := defaultOrgCtx(t) tests := []parseCase{ From 619eeba6b983d0118e9c5d84251426c3da65fe5b Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 18:09:18 +0000 Subject: [PATCH 2/4] feat: Implement `azdo pipelines folder create` command Fixes #263 --- .../cmd/pipelines/folder/create/create.go | 106 ++++++++++++++++++ internal/cmd/pipelines/folder/folder.go | 2 + 2 files changed, 108 insertions(+) create mode 100644 internal/cmd/pipelines/folder/create/create.go diff --git a/internal/cmd/pipelines/folder/create/create.go b/internal/cmd/pipelines/folder/create/create.go new file mode 100644 index 00000000..da279606 --- /dev/null +++ b/internal/cmd/pipelines/folder/create/create.go @@ -0,0 +1,106 @@ +package create + +import ( + "fmt" + "strings" + + "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 { + targetArg string + description string + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &opts{} + + cmd := &cobra.Command{ + Use: "create [ORG:]PROJECT/PATH", + Short: "Create a folder.", + Aliases: []string{"c", "cr"}, + Long: heredoc.Doc(` + Create a build definition folder at PATH under PROJECT. + + Mirrors 'az pipelines folder create'. PATH is the full path + (e.g. "External/CI"). Azure DevOps stores folder paths with '/'. + `), + Example: heredoc.Doc(` + # Create a folder in the default organization + azdo pipelines folder create Fabrikam/External/CI + + # Create a folder in a specific organization + azdo pipelines folder create myorg:Fabrikam/External/CI + + # Create a folder with a description + azdo pipelines folder create Fabrikam/External/CI --description "CI folders" + + # Output as JSON + azdo pipelines folder create Fabrikam/External/CI --json + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runCreate(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.description, "description", "", "Description of the folder.") + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "createdBy", + "createdOn", + "description", + "lastChangedBy", + "lastChangedDate", + "path", + "project", + }) + + return cmd +} + +func runCreate(cmdCtx util.CmdContext, opts *opts) error { + ios, err := cmdCtx.IOStreams() + if err != nil { + return err + } + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParseProjectPathTargetWithDefaultOrganization(cmdCtx, opts.targetArg) + if err != nil { + return util.FlagErrorWrap(err) + } + path := strings.Join(scope.Targets, "/") + + client, err := cmdCtx.ClientFactory().Build(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create build client: %w", err) + } + + created, err := client.CreateFolder(cmdCtx.Context(), build.CreateFolderArgs{ + Folder: &build.Folder{ + Description: types.ToPtr(opts.description), + }, + Project: types.ToPtr(scope.Project), + Path: types.ToPtr(path), + }) + if err != nil { + return fmt.Errorf("failed to create folder: %w", err) + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, created) + } + + fmt.Fprintf(ios.Out, "Created folder %s\n", types.GetValue(created.Path, path)) + return nil +} diff --git a/internal/cmd/pipelines/folder/folder.go b/internal/cmd/pipelines/folder/folder.go index 06044f10..d5e7c7f6 100644 --- a/internal/cmd/pipelines/folder/folder.go +++ b/internal/cmd/pipelines/folder/folder.go @@ -4,6 +4,7 @@ import ( "github.com/MakeNowJust/heredoc/v2" "github.com/spf13/cobra" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/folder/create" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/folder/list" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -19,6 +20,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { Aliases: []string{"folders"}, } + cmd.AddCommand(create.NewCmd(ctx)) cmd.AddCommand(list.NewCmd(ctx)) return cmd } From 42e49339c6b0ce4d08a2964f32b1d300affc9095 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 18:09:42 +0000 Subject: [PATCH 3/4] test: add unit tests for pipelines folder create command --- .../pipelines/folder/create/create_test.go | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 internal/cmd/pipelines/folder/create/create_test.go diff --git a/internal/cmd/pipelines/folder/create/create_test.go b/internal/cmd/pipelines/folder/create/create_test.go new file mode 100644 index 00000000..6e8aa478 --- /dev/null +++ b/internal/cmd/pipelines/folder/create/create_test.go @@ -0,0 +1,236 @@ +package create + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "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/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 TestNewCmd_create(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Equal(t, "create [ORG:]PROJECT/PATH", cmd.Use) + assert.ElementsMatch(t, []string{"c", "cr"}, cmd.Aliases) + assert.NotNil(t, cmd.RunE) + require.NoError(t, cmd.Args(cmd, []string{"Fabrikam/External"})) + assert.Error(t, cmd.Args(cmd, []string{"Fabrikam/External", "Extra"})) + + f := cmd.Flags() + assert.NotNil(t, f.Lookup("description")) + assert.NotNil(t, f.Lookup("json")) + assert.NotNil(t, f.Lookup("jq")) + assert.NotNil(t, f.Lookup("template")) +} + +func TestNewCmd_missingPath(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 TestRunCreate_success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + targetArg string + description string + defaultOrg string + expectedOrg string + expectedProject string + expectedPath string + expectedOutput string + returnedPath string + }{ + { + name: "implicit org without description", + targetArg: "MyProject/Foo", + defaultOrg: "myorg", + expectedOrg: "myorg", + expectedProject: "MyProject", + expectedPath: "Foo", + expectedOutput: "Created folder MyProject/Foo\n", + returnedPath: "MyProject/Foo", + }, + { + name: "implicit org with description", + targetArg: "MyProject/Foo", + description: "hello", + defaultOrg: "myorg", + expectedOrg: "myorg", + expectedProject: "MyProject", + expectedPath: "Foo", + expectedOutput: "Created folder MyProject/Foo\n", + returnedPath: "MyProject/Foo", + }, + { + name: "explicit org nested path", + targetArg: "myorg:MyProject/External/CI", + expectedOrg: "myorg", + expectedProject: "MyProject", + expectedPath: "External/CI", + expectedOutput: "Created folder MyProject/External/CI\n", + returnedPath: "MyProject/External/CI", + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, tc.expectedOrg) + if tc.defaultOrg != "" { + deps.setupDefaultOrg(tc.defaultOrg) + } + + returned := build.Folder{Path: types.ToPtr(tc.returnedPath)} + deps.buildCli.EXPECT().CreateFolder(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.CreateFolderArgs) (*build.Folder, error) { + require.NotNil(t, args.Folder) + require.NotNil(t, args.Folder.Description) + assert.Equal(t, tc.description, *args.Folder.Description) + require.NotNil(t, args.Project) + assert.Equal(t, tc.expectedProject, *args.Project) + require.NotNil(t, args.Path) + assert.Equal(t, tc.expectedPath, *args.Path) + return &returned, nil + }, + ) + + err := runCreate(deps.cmd, &opts{targetArg: tc.targetArg, description: tc.description}) + require.NoError(t, err) + assert.Equal(t, tc.expectedOutput, deps.stdout.String()) + }) + } +} + +func TestRunCreate_APIError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.buildCli.EXPECT().CreateFolder(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("boom")) + + err := runCreate(deps.cmd, &opts{targetArg: "MyProject/Foo"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create folder: boom") +} + +func TestRunCreate_JSON(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + returned := build.Folder{Path: types.ToPtr("MyProject/Foo"), Description: types.ToPtr("hello")} + deps.buildCli.EXPECT().CreateFolder(gomock.Any(), gomock.Any()).Return(&returned, nil) + + exporter := util.NewJSONExporter() + err := runCreate(deps.cmd, &opts{targetArg: "MyProject/Foo", exporter: exporter}) + require.NoError(t, err) + + var parsed struct { + Path *string `json:"path"` + Description *string `json:"description"` + } + err = json.Unmarshal(deps.stdout.Bytes(), &parsed) + require.NoError(t, err) + require.NotNil(t, parsed.Path) + require.NotNil(t, parsed.Description) + assert.Equal(t, "MyProject/Foo", *parsed.Path) + assert.Equal(t, "hello", *parsed.Description) +} + +func TestRunCreate_defaultOrganization(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "") + deps.setupDefaultOrg("myorg") + deps.clientFact.EXPECT().Build(gomock.Any(), "myorg").Return(deps.buildCli, nil).AnyTimes() + deps.buildCli.EXPECT().CreateFolder(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.CreateFolderArgs) (*build.Folder, error) { + require.NotNil(t, args.Project) + assert.Equal(t, "MyProject", *args.Project) + return &build.Folder{Path: types.ToPtr("MyProject/Foo")}, nil + }, + ) + + err := runCreate(deps.cmd, &opts{targetArg: "MyProject/Foo"}) + require.NoError(t, err) + assert.Equal(t, "Created folder MyProject/Foo\n", deps.stdout.String()) +} + +func TestRunCreate_missingDefaultOrganization(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "") + deps.setupDefaultOrg("") + + err := runCreate(deps.cmd, &opts{targetArg: "MyProject/Foo"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no organization specified") +} From 61c50bab92953795c5c291c1583136841ee8eefe Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 18:10:45 +0000 Subject: [PATCH 4/4] docs: extend azdo pipelines folder reference with creation support --- docs/azdo_help_reference.md | 17 ++++++++ docs/azdo_pipelines_folder.md | 1 + docs/azdo_pipelines_folder_create.md | 60 ++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 docs/azdo_pipelines_folder_create.md diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 3001ff51..60b38de8 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -417,6 +417,23 @@ Aliases folders ``` +#### `azdo pipelines folder create [ORG:]PROJECT/PATH [flags]` + +Create a folder. + +``` + --description string Description of the folder. +-q, --jq expression Filter JSON output using a jq expression + --json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it. +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +c, cr +``` + #### `azdo pipelines folder list [ORG:]PROJECT [flags]` List folders. diff --git a/docs/azdo_pipelines_folder.md b/docs/azdo_pipelines_folder.md index afa4ed89..909ea5d9 100644 --- a/docs/azdo_pipelines_folder.md +++ b/docs/azdo_pipelines_folder.md @@ -6,6 +6,7 @@ and organize pipeline definitions. ### Available commands +* [azdo pipelines folder create](./azdo_pipelines_folder_create.md) * [azdo pipelines folder list](./azdo_pipelines_folder_list.md) ### ALIASES diff --git a/docs/azdo_pipelines_folder_create.md b/docs/azdo_pipelines_folder_create.md new file mode 100644 index 00000000..cf8f0396 --- /dev/null +++ b/docs/azdo_pipelines_folder_create.md @@ -0,0 +1,60 @@ +## Command `azdo pipelines folder create` + +``` +azdo pipelines folder create [ORG:]PROJECT/PATH [flags] +``` + +Create a build definition folder at PATH under PROJECT. + +Mirrors 'az pipelines folder create'. PATH is the full path +(e.g. "External/CI"). Azure DevOps stores folder paths with '/'. + + +### Options + + +* `--description` `string` + + Description of the folder. + +* `-q`, `--jq` `expression` + + Filter JSON output using a jq expression + +* `--json` `fields` + + Output JSON with the specified fields. Prefix a field with '-' to exclude it. + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `c` +- `cr` + +### JSON Fields + +`createdBy`, `createdOn`, `description`, `lastChangedBy`, `lastChangedDate`, `path`, `project` + +### Examples + +```bash +# Create a folder in the default organization +azdo pipelines folder create Fabrikam/External/CI + +# Create a folder in a specific organization +azdo pipelines folder create myorg:Fabrikam/External/CI + +# Create a folder with a description +azdo pipelines folder create Fabrikam/External/CI --description "CI folders" + +# Output as JSON +azdo pipelines folder create Fabrikam/External/CI --json +``` + +### See also + +* [azdo pipelines folder](./azdo_pipelines_folder.md)