From 8321cc8373f638b3647c39fbc1cfdd1651498f35 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 18:58:11 +0000 Subject: [PATCH 1/3] feat: Implement `azdo pipelines folder update` command Fixes #266 --- internal/cmd/pipelines/folder/folder.go | 2 + .../cmd/pipelines/folder/update/update.go | 131 ++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 internal/cmd/pipelines/folder/update/update.go diff --git a/internal/cmd/pipelines/folder/folder.go b/internal/cmd/pipelines/folder/folder.go index d5e7c7f6..24cb0718 100644 --- a/internal/cmd/pipelines/folder/folder.go +++ b/internal/cmd/pipelines/folder/folder.go @@ -6,6 +6,7 @@ import ( "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/pipelines/folder/update" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -22,5 +23,6 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(create.NewCmd(ctx)) cmd.AddCommand(list.NewCmd(ctx)) + cmd.AddCommand(update.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/pipelines/folder/update/update.go b/internal/cmd/pipelines/folder/update/update.go new file mode 100644 index 00000000..631d526f --- /dev/null +++ b/internal/cmd/pipelines/folder/update/update.go @@ -0,0 +1,131 @@ +package update + +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 + newPath string + newDescription string + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &opts{} + + cmd := &cobra.Command{ + Use: "update [ORG:]PROJECT/PATH", + Short: "Update a folder.", + Aliases: []string{"u"}, + Long: heredoc.Doc(` + Update the path or description of a build definition folder. + + Mirrors 'az pipelines folder update'. At least one of --new-path or + --new-description must be specified. The full updated folder is sent + to the server (full replace, not a partial patch). + `), + Example: heredoc.Doc(` + # Rename a folder in the default organization + azdo pipelines folder update Fabrikam/External/CI --new-path Fabrikam/External/Release + + # Change only the description + azdo pipelines folder update myorg:Fabrikam/External/CI --new-description "Release pipeline folder" + + # Rename and re-describe, output as JSON + azdo pipelines folder update Fabrikam/External/CI --new-path Fabrikam/External/Release --new-description "Release pipelines" --json + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runUpdate(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.newPath, "new-path", "", "New full path for the folder.") + cmd.Flags().StringVar(&opts.newDescription, "new-description", "", "New description for the folder.") + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "createdBy", + "createdOn", + "description", + "lastChangedBy", + "lastChangedDate", + "path", + "project", + }) + + return cmd +} + +func runUpdate(cmdCtx util.CmdContext, opts *opts) error { + ios, err := cmdCtx.IOStreams() + if err != nil { + return err + } + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + if opts.newPath == "" && opts.newDescription == "" { + return util.FlagErrorf("specify at least one of --new-path or --new-description") + } + + 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) + } + + list, err := client.GetFolders(cmdCtx.Context(), build.GetFoldersArgs{ + Project: types.ToPtr(scope.Project), + Path: types.ToPtr(path), + }) + if err != nil { + return fmt.Errorf("failed to fetch folder %s: %w", path, err) + } + folders := *list + if len(folders) == 0 { + return fmt.Errorf("folder %s not found in project %s", path, scope.Project) + } + if len(folders) > 1 { + return fmt.Errorf("path %s matched %d folders; expected exactly 1", path, len(folders)) + } + current := folders[0] + + if opts.newPath != "" { + current.Path = types.ToPtr(opts.newPath) + } + if opts.newDescription != "" { + current.Description = types.ToPtr(opts.newDescription) + } + + updated, err := client.UpdateFolder(cmdCtx.Context(), build.UpdateFolderArgs{ + Folder: ¤t, + Project: types.ToPtr(scope.Project), + Path: types.ToPtr(path), + }) + if err != nil { + return fmt.Errorf("failed to update folder %s: %w", path, err) + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, updated) + } + + fmt.Fprintf(ios.Out, "Updated folder %s\n", types.GetValue(updated.Path, path)) + return nil +} From 14109d7074a020ecb735a0f920fdbb000788c2da Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 18:58:27 +0000 Subject: [PATCH 2/3] test: add unit tests for pipelines folder update command --- .../pipelines/folder/update/update_test.go | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 internal/cmd/pipelines/folder/update/update_test.go diff --git a/internal/cmd/pipelines/folder/update/update_test.go b/internal/cmd/pipelines/folder/update/update_test.go new file mode 100644 index 00000000..e9c98cd6 --- /dev/null +++ b/internal/cmd/pipelines/folder/update/update_test.go @@ -0,0 +1,287 @@ +package update + +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_update(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Equal(t, "update [ORG:]PROJECT/PATH", cmd.Use) + assert.ElementsMatch(t, []string{"u"}, 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("new-path")) + assert.NotNil(t, f.Lookup("new-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 TestRunUpdate_success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + targetArg string + newPath string + newDescription string + expectedOrg string + expectedProject string + expectedPath string + fetchedFolder build.Folder + expectedFolder build.Folder + expectedOutput string + }{ + { + name: "rename only", + targetArg: "MyProject/P/OldName", + newPath: "P/NewName", + expectedOrg: "myorg", + expectedProject: "MyProject", + expectedPath: "P/OldName", + fetchedFolder: build.Folder{Path: types.ToPtr("P/OldName"), Description: types.ToPtr("d")}, + expectedFolder: build.Folder{Path: types.ToPtr("P/NewName"), Description: types.ToPtr("d")}, + expectedOutput: "Updated folder P/NewName\n", + }, + { + name: "description only", + targetArg: "MyProject/P/Foo", + newDescription: "newDesc", + expectedOrg: "myorg", + expectedProject: "MyProject", + expectedPath: "P/Foo", + fetchedFolder: build.Folder{Path: types.ToPtr("P/Foo"), Description: types.ToPtr("oldDesc")}, + expectedFolder: build.Folder{Path: types.ToPtr("P/Foo"), Description: types.ToPtr("newDesc")}, + expectedOutput: "Updated folder P/Foo\n", + }, + { + name: "both", + targetArg: "myorg:MyProject/P/Foo", + newPath: "P/NewName", + newDescription: "newDesc", + expectedOrg: "myorg", + expectedProject: "MyProject", + expectedPath: "P/Foo", + fetchedFolder: build.Folder{Path: types.ToPtr("P/Foo"), Description: types.ToPtr("oldDesc")}, + expectedFolder: build.Folder{Path: types.ToPtr("P/NewName"), Description: types.ToPtr("newDesc")}, + expectedOutput: "Updated folder P/NewName\n", + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, tc.expectedOrg) + deps.setupDefaultOrg("myorg") + + deps.buildCli.EXPECT().GetFolders(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetFoldersArgs) (*[]build.Folder, error) { + 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 &[]build.Folder{tc.fetchedFolder}, nil + }, + ) + + var captured *build.Folder + updated := build.Folder{Path: types.ToPtr("P/NewName")} + if tc.newPath == "" { + updated.Path = types.ToPtr(tc.expectedPath) + } + if tc.newDescription != "" { + updated.Description = types.ToPtr(tc.newDescription) + } + deps.buildCli.EXPECT().UpdateFolder(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.UpdateFolderArgs) (*build.Folder, error) { + require.NotNil(t, args.Folder) + require.NotNil(t, args.Project) + assert.Equal(t, tc.expectedProject, *args.Project) + require.NotNil(t, args.Path) + assert.Equal(t, tc.expectedPath, *args.Path) + captured = args.Folder + return &updated, nil + }, + ) + + err := runUpdate(deps.cmd, &opts{targetArg: tc.targetArg, newPath: tc.newPath, newDescription: tc.newDescription}) + require.NoError(t, err) + require.NotNil(t, captured) + assert.Equal(t, types.GetValue(tc.expectedFolder.Path, ""), types.GetValue(captured.Path, "")) + assert.Equal(t, types.GetValue(tc.expectedFolder.Description, ""), types.GetValue(captured.Description, "")) + assert.Equal(t, tc.expectedOutput, deps.stdout.String()) + }) + } +} + +func TestRunUpdate_mutexViolation(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + + err := runUpdate(deps.cmd, &opts{targetArg: "MyProject/P/Foo"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "specify at least one of --new-path or --new-description") +} + +func TestRunUpdate_folderNotFound(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.buildCli.EXPECT().GetFolders(gomock.Any(), gomock.Any()).Return(&[]build.Folder{}, nil) + + err := runUpdate(deps.cmd, &opts{targetArg: "MyProject/P/Foo", newDescription: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestRunUpdate_pathAmbiguous(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.buildCli.EXPECT().GetFolders(gomock.Any(), gomock.Any()).Return( + &[]build.Folder{ + {Path: types.ToPtr("P/Foo")}, + {Path: types.ToPtr("P/Foo")}, + }, nil, + ) + + err := runUpdate(deps.cmd, &opts{targetArg: "MyProject/P/Foo", newDescription: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "matched 2 folders; expected exactly 1") +} + +func TestRunUpdate_getFoldersError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.buildCli.EXPECT().GetFolders(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("boom")) + + err := runUpdate(deps.cmd, &opts{targetArg: "MyProject/P/Foo", newDescription: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch folder P/Foo: boom") +} + +func TestRunUpdate_updateError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.buildCli.EXPECT().GetFolders(gomock.Any(), gomock.Any()).Return( + &[]build.Folder{{Path: types.ToPtr("P/Foo")}}, nil, + ) + deps.buildCli.EXPECT().UpdateFolder(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("boom")) + + err := runUpdate(deps.cmd, &opts{targetArg: "MyProject/P/Foo", newDescription: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to update folder P/Foo: boom") +} + +func TestRunUpdate_JSON(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "myorg") + deps.setupDefaultOrg("myorg") + deps.buildCli.EXPECT().GetFolders(gomock.Any(), gomock.Any()).Return( + &[]build.Folder{{Path: types.ToPtr("P/OldName"), Description: types.ToPtr("d")}}, nil, + ) + updated := build.Folder{Path: types.ToPtr("P/NewName"), Description: types.ToPtr("newDesc")} + deps.buildCli.EXPECT().UpdateFolder(gomock.Any(), gomock.Any()).Return(&updated, nil) + + exporter := util.NewJSONExporter() + err := runUpdate(deps.cmd, &opts{targetArg: "MyProject/P/OldName", newPath: "P/NewName", newDescription: "newDesc", 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, "P/NewName", *parsed.Path) + assert.Equal(t, "newDesc", *parsed.Description) +} From 0bded67cb4717bfab22fa6f626c6d56a14318bb9 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Thu, 13 Aug 2026 18:59:05 +0000 Subject: [PATCH 3/3] docs: extend azdo pipelines folder reference with update support --- docs/azdo_help_reference.md | 18 ++++++++ docs/azdo_pipelines_folder.md | 1 + docs/azdo_pipelines_folder_update.md | 61 ++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 docs/azdo_pipelines_folder_update.md diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 60b38de8..559c6f1f 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -453,6 +453,24 @@ Aliases ls, l ``` +#### `azdo pipelines folder update [ORG:]PROJECT/PATH [flags]` + +Update a 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. + --new-description string New description for the folder. + --new-path string New full path for the folder. +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +u +``` + ### `azdo pipelines list [ORG:]PROJECT [flags]` List pipeline definitions diff --git a/docs/azdo_pipelines_folder.md b/docs/azdo_pipelines_folder.md index 909ea5d9..1a9021be 100644 --- a/docs/azdo_pipelines_folder.md +++ b/docs/azdo_pipelines_folder.md @@ -8,6 +8,7 @@ and organize pipeline definitions. * [azdo pipelines folder create](./azdo_pipelines_folder_create.md) * [azdo pipelines folder list](./azdo_pipelines_folder_list.md) +* [azdo pipelines folder update](./azdo_pipelines_folder_update.md) ### ALIASES diff --git a/docs/azdo_pipelines_folder_update.md b/docs/azdo_pipelines_folder_update.md new file mode 100644 index 00000000..565b7f1f --- /dev/null +++ b/docs/azdo_pipelines_folder_update.md @@ -0,0 +1,61 @@ +## Command `azdo pipelines folder update` + +``` +azdo pipelines folder update [ORG:]PROJECT/PATH [flags] +``` + +Update the path or description of a build definition folder. + +Mirrors 'az pipelines folder update'. At least one of --new-path or +--new-description must be specified. The full updated folder is sent +to the server (full replace, not a partial patch). + + +### 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. + +* `--new-description` `string` + + New description for the folder. + +* `--new-path` `string` + + New full path for the folder. + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `u` + +### JSON Fields + +`createdBy`, `createdOn`, `description`, `lastChangedBy`, `lastChangedDate`, `path`, `project` + +### Examples + +```bash +# Rename a folder in the default organization +azdo pipelines folder update Fabrikam/External/CI --new-path Fabrikam/External/Release + +# Change only the description +azdo pipelines folder update myorg:Fabrikam/External/CI --new-description "Release pipeline folder" + +# Rename and re-describe, output as JSON +azdo pipelines folder update Fabrikam/External/CI --new-path Fabrikam/External/Release --new-description "Release pipelines" --json +``` + +### See also + +* [azdo pipelines folder](./azdo_pipelines_folder.md)