Skip to content

feat: Implement azdo pipelines folder update command #266

@tmeckel

Description

@tmeckel

Sub-issue of #262. Hardened spec — do not re-derive decisions.

Command Description

Rename or re-describe a build definition folder at PATH under PROJECT. Mirrors az pipelines folder update.

Locked Decisions

# Decision Rationale
1 Project-scoped, with embedded path target. Folders are owned by projects.
2 Use: "update [ORGANIZATION/]PROJECT/PATH". Mirrors variable-group update's Use: "update [ORGANIZATION/]PROJECT/GROUP" (internal/cmd/pipelines/variablegroup/update/update.go).
3 Aliases: []string{"u"}. Per AGENTS.md update command convention.
4 cobra.ExactArgs(1). One positional target.
5 Parse the single positional with util.ParseProjectPathTargetWithDefaultOrganization(ctx, args[0], ...). The wrapper is added as part of the create sub-issue's implementation. No existing helper supports the 3-segment form.
6 --new-path and --new-description are optional flags. At least one of the two must be set; if neither is, the command returns a flag error via util.FlagErrorf ("specify at least one of --new-path or --new-description"). Mirrors az pipelines folder update (requires at least one).
7 Behavior: fetch the folder at PATH via GetFolders(Project, Path), pick the unique match (the SDK returns a single folder when the path is exact), mutate the desired field(s), then call UpdateFolder(Project, Path, Folder) (the SDK's POST is full-replace, not PATCH — so the body must include the complete updated Folder). Mirrors internal/cmd/pipelines/variablegroup/update/update.go (fetch → mutate → PUT-style POST).
8 Plain output: print Updated folder <PROJECT>/<new-path> to stdout on success, where <new-path> is the new path (or the original if only the description changed). Mirrors variable-group update.
9 JSON output: pass the raw *build.Folder returned by UpdateFolder directly to opts.exporter.Write. No view struct. The SDK type is the API surface.
10 Uses vendored build.Client.GetFolders + build.Client.UpdateFolder. Vendor verified at vendor/.../v7/build/client.go:2490 and :3919.
11 No new mock needed. internal/mocks/build_client_mock.go:881-893 (GetFolders) and :1420-1432 (UpdateFolder) are already generated.
12 Predecessors: the create sub-issue (so the ParseProjectPathTargetWithDefaultOrganization wrapper exists) and the list sub-issue (so the GetFolders call to resolve the existing folder is well-understood).
13 No go mod tidy / go mod vendor / scripts/generate_mocks.sh work.

Command Signature

var updateCmd = &cobra.Command{
    Use:     "update [ORGANIZATION/]PROJECT/PATH",
    Aliases: []string{"u"},
    Short:   "Update a folder.",
    Long: `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).`,
    Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        return runUpdate(cmd.Context(), opts, args[0])
    },
}

Flags

Flag Type Default Required Description
--new-path string "" no New full path for the folder.
--new-description string "" no New description for the folder.
--organization string $AZDO_ORGANIZATION or default config no Azure DevOps organization. Omit to use the default.
--project string "" no Project name or ID. Omit to use the project from the positional target.

Also add util.AddJSONFlags(cmd, &opts.exporter, ...) registering every field of *build.Folder: createdBy, createdOn, description, lastChangedBy, lastChangedDate, path, project.

Mutex Check

After flag parsing (in runUpdate):

if opts.newPath == "" && opts.newDescription == "" {
    return util.FlagErrorf("specify at least one of --new-path or --new-description")
}

JSON Output Contract

Pass the raw *build.Folder returned by UpdateFolder to opts.exporter.Write. No view struct.

JSON fields exposed (matching SDK struct tags): createdBy, createdOn, description, lastChangedBy, lastChangedDate, path, project.

Table Output Contract

N/A. On success, print one line to stdout:

Updated folder <PROJECT>/<new-path-or-original>

If --new-path was set, use the new path; otherwise, use the original path (only the description was changed).

Command Wiring

  1. Create internal/cmd/pipelines/folder/update/update.go with package update, factory func NewCmd(ctx util.CmdContext) *cobra.Command.
  2. Add import "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/folder/update" to internal/cmd/pipelines/folder/folder.go.
  3. Register in folder.go with cmd.AddCommand(update.NewCmd(ctx)).

API Surface

Vendored SDK calls (from vendor/.../v7/build/client.go:2490 and :3919):

// 1. Fetch the current folder (to preserve any fields the user did not set).
list, err := client.GetFolders(ctx, build.GetFoldersArgs{
    Project: &opts.project,
    Path:    &opts.path,
})
if err != nil {
    return fmt.Errorf("failed to fetch folder %s: %w", opts.path, err)
}
folders := *list
if len(folders) == 0 {
    return fmt.Errorf("folder %s not found in project %s", opts.path, opts.project)
}
if len(folders) > 1 {
    return fmt.Errorf("path %s matched %d folders; expected exactly 1", opts.path, len(folders))
}
current := folders[0]

// 2. Mutate the fields the user wants to change.
if opts.newPath != "" {
    current.Path = &opts.newPath
}
if opts.newDescription != "" {
    current.Description = &opts.newDescription
}

// 3. Send the full updated folder (the SDK's POST is full-replace, not PATCH).
updated, err := client.UpdateFolder(ctx, build.UpdateFolderArgs{
    Folder:  &current,
    Project: &opts.project,
    Path:    &opts.path, // the ORIGINAL path, before any rename
})
if err != nil {
    return fmt.Errorf("failed to update folder %s: %w", opts.path, err)
}

The vendored SDK enforces args.Folder != nil, args.Project != "", args.Path != nil on UpdateFolder — will return ArgumentNilError / ArgumentNilOrEmptyError if missing. No extra validation needed in our wrapper.

Reference Existing Patterns

  • internal/cmd/pipelines/variablegroup/update/update.goprimary reference. Mirrors the Use, the Aliases, the fetch→mutate→PUT-style POST pattern, the success message, and the mutex check between two update flags.
  • internal/cmd/pipelines/folder/list/list.go — sibling for the GetFolders SDK call signature.
  • internal/cmd/pipelines/folder/create/create.go — sibling for the parsing of [ORGANIZATION/]PROJECT/PATH.

TDD Test Plan

Test name Scenario Expected
TestNewCmd_update Inspect the command struct. Use == "update [ORGANIZATION/]PROJECT/PATH", Aliases == ["u"], Args == cobra.ExactArgs(1).
Test_runUpdate_success_renameOnly --new-path=P/NewName is set. Mock GetFolders returns []build.Folder{{Path: ptr("P/OldName"), Description: ptr("d")}}. Mock UpdateFolder returns the renamed folder. args.Path == ptr("P/OldName") (the original path) is passed to UpdateFolder; the body has Path == ptr("P/NewName"). Output: Updated folder P/NewName.
Test_runUpdate_success_descriptionOnly --new-description=newDesc is set. Mock GetFolders returns []build.Folder{{Path: ptr("P/Foo"), Description: ptr("oldDesc")}}. The body has Path == ptr("P/Foo") (unchanged) and Description == ptr("newDesc"). Output: Updated folder P/Foo.
Test_runUpdate_success_both Both --new-path and --new-description are set. The body reflects both new values.
Test_runUpdate_mutexViolation Neither --new-path nor --new-description is set. Returns util.FlagErrorf("specify at least one of --new-path or --new-description"). SDK is not called.
Test_runUpdate_folderNotFound Mock GetFolders returns []build.Folder{}. Returns a clear "folder not found" error. UpdateFolder is not called.
Test_runUpdate_pathAmbiguous Mock GetFolders returns []build.Folder{...} with 2 entries. Returns a clear "path matched N folders" error. UpdateFolder is not called.
Test_runUpdate_getFoldersError Mock GetFolders returns errors.New("boom"). Returns wrapped error. UpdateFolder is not called.
Test_runUpdate_updateError Mock GetFolders returns 1 folder. Mock UpdateFolder returns errors.New("boom"). Returns wrapped error.
Test_runUpdate_success_JSON --json flag set. Mock UpdateFolder returns the renamed folder. Exporter receives *build.Folder with the new Path.
Test_runUpdate_missingPath User runs azdo pipelines folder update with no args. Cobra args validation returns the standard "accepts 1 arg(s)" error.

References

  • Vendored SDK: vendor/.../v7/build/client.go:2490 (GetFolders), :2516 (GetFoldersArgs), :3919 (UpdateFolder), :3950 (UpdateFolderArgs).
  • Vendored model: vendor/.../v7/build/models.go:1401 (Folder).
  • Mock: internal/mocks/build_client_mock.go:881-893 (GetFolders), :1420-1432 (UpdateFolder).
  • Internal client accessor: internal/azdo/factory.go:61ClientFactory().Build(ctx, organization).
  • Azure CLI reference: https://learn.microsoft.com/en-us/cli/azure/pipelines/folder?view=azure-cli-latest#az-pipelines-folder-update
  • Azure DevOps REST API: Folders — Update (POST, route id a906531b-d2da-4f55-bda7-f3e676cc50d9, API version 7.1-preview.2). Full replace (not PATCH).
  • Umbrella: Introduce azdo pipelines folder command group #262.
  • Pattern sibling: internal/cmd/pipelines/variablegroup/update/update.go (fetch → mutate → POST, mutex check between two update flags).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions