Skip to content

feat: Implement azdo pipelines folder list command #265

Description

@tmeckel

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

Command Description

List build definition folders under PROJECT, optionally scoped to a sub-path. Mirrors az pipelines folder list.

Locked Decisions

# Decision Rationale
1 Project-scoped, with optional path filter. Folders are owned by projects.
2 Use: "list [ORGANIZATION/]PROJECT". Mirrors variable-group list's Use: "list [ORGANIZATION/]PROJECT" (internal/cmd/pipelines/variablegroup/list/list.go). The path is an optional --path flag, not a positional.
3 Aliases: []string{"ls", "l"}. Per AGENTS.md list command convention.
4 cobra.ExactArgs(1). One positional target: [ORGANIZATION/]PROJECT.
5 Parse the single positional with util.ParseProjectTargetWithDefaultOrganization(ctx, args[0], &opts.org, &opts.project). The existing helper handles the 2-segment form correctly.
6 --path is an optional string flag. When set, GetFolders is called with Path = &opts.path; when empty, top-level folders are returned. Matches AzDO Extension's pipeline_folder_list --path <value> (optional).
7 --query-order is an optional string enum flag accepting asc or desc. Maps to build.FolderQueryOrderValues.FolderAscending / FolderDescending. Use util.StringEnumFlag (or equivalent) for the validation. Mirrors az pipelines folder list --query-order.
8 Plain output: table with columns PATH, DESCRIPTION. Mirrors the AzDO Extension's transform_pipelines_folders formatter.
9 JSON output: dedicated view struct folderView with fields path and description. Pass a []folderView (built from the SDK []Folder) to opts.exporter.Write. The SDK Folder struct exposes many fields (CreatedBy, CreatedOn, LastChangedBy, LastChangedDate, Project), but the AzDO Extension's table output only shows PATH and DESCRIPTION — match that for the list view. The full SDK type is exposed by show-style commands (none here).
10 --max-items is not supported by GetFolders (no continuation token on the response) — but we still accept it for consistency with the rest of the CLI and silently apply it as a post-fetch cap. Precedent: --max-items in internal/cmd/pipelines/variablegroup/list/list.go.
11 Uses vendored build.Client.GetFolders. Vendor verified at vendor/.../v7/build/client.go:2490.
12 No new mock needed. internal/mocks/build_client_mock.go:881-893 already mocks GetFolders.
13 Predecessors: the create/delete sub-issues (so the ParseProjectPathTargetWithDefaultOrganization wrapper is available; not strictly required for this issue since list uses a 2-segment target).
14 No go mod tidy / go mod vendor / scripts/generate_mocks.sh work.

Command Signature

var listCmd = &cobra.Command{
    Use:     "list [ORGANIZATION/]PROJECT",
    Aliases: []string{"ls", "l"},
    Short:   "List folders.",
    Long: `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.`,
    Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        return runList(cmd.Context(), opts, args[0])
    },
}

Flags

Flag Type Default Required Description
--path string "" no Limit the listing to folders at or under this path.
--query-order string (enum: asc, desc) "" no Sort order. ascFolderAscending, descFolderDescending. Empty → server default.
--max-items int 0 (no cap) no Maximum number of folders to return.
--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, "path", "description") (only the list view fields are exposed, not the full SDK model).

JSON Output Contract

View struct (file-scope, in list.go):

type folderView struct {
    Path        string `json:"path,omitempty"`
    Description string `json:"description,omitempty"`
}

Helper (file-scope):

func newFolderView(f build.Folder) folderView {
    return folderView{
        Path:        types.GetValue(f.Path),
        Description: types.GetValue(f.Description),
    }
}

Build views := types.MapSlice(*resp, newFolderView) and pass views to opts.exporter.Write. Apply --max-items after the map if set.

Table Output Contract

Use ctx.Printer("list"):

tp, err := ctx.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()

If the result is empty, the printer renders an empty table (no rows). Do not print a synthetic "no folders" message.

Command Wiring

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

API Surface

Vendored SDK call (from vendor/.../v7/build/client.go:2490):

args := build.GetFoldersArgs{
    Project: &opts.project,
}
if opts.path != "" {
    args.Path = &opts.path
}
if opts.queryOrder != "" {
    q := build.FolderQueryOrderValues.FolderAscending
    if opts.queryOrder == "desc" {
        q = build.FolderQueryOrderValues.FolderDescending
    }
    args.QueryOrder = &q
}
resp, err := client.GetFolders(ctx, args)

The vendored SDK enforces args.Project != "" — will return ArgumentNilOrEmptyError if missing. No extra validation needed in our wrapper.

GetFoldersArgs.Path is optional. GetFoldersArgs.QueryOrder is optional. The vendored SDK serializes a nil QueryOrder as an empty query param (no queryOrder added to the URL); the server then returns the default order.

Reference Existing Patterns

  • internal/cmd/pipelines/variablegroup/list/list.goprimary reference. Mirrors the Use, the Aliases, the view struct pattern, the AddColumns + AddField + EndRow + Render table pattern, and the GetValue/MapSlice helpers.
  • internal/cmd/repo/list/list.go — secondary reference for the ctx.Printer("list") pattern.
  • util.StringEnumFlag (or equivalent in internal/cmd/util/flags.go) — for --query-order validation.

TDD Test Plan

Test name Scenario Expected
TestNewCmd_list Inspect the command struct. Use == "list [ORGANIZATION/]PROJECT", Aliases == ["ls", "l"], Args == cobra.ExactArgs(1).
Test_runList_success_noPath Mock GetFolders returns []build.Folder{{Path: ptr("P/Foo")}, {Path: ptr("P/Bar")}}. Returns nil error; table has 2 rows; PATH column has P/Foo, P/Bar; DESCRIPTION column is empty.
Test_runList_success_withPath --path=P is set; mock returns 1 folder. args.Path == ptr("P") was passed to the SDK.
Test_runList_success_queryOrderAsc --query-order=asc is set. args.QueryOrder == ptr(build.FolderQueryOrderValues.FolderAscending) was passed.
Test_runList_success_queryOrderDesc --query-order=desc is set. args.QueryOrder == ptr(build.FolderQueryOrderValues.FolderDescending) was passed.
Test_runList_invalidQueryOrder --query-order=banana is set. Cobra's StringEnumFlag returns a flag-value error. SDK is not called.
Test_runList_success_maxItems Mock returns 5 folders. --max-items=2 is set. Table has 2 rows (first 2 from the response).
Test_runList_success_JSON --json flag set; mock returns 2 folders. Exporter receives []folderView with 2 entries, each with the right Path and Description.
Test_runList_empty Mock returns []build.Folder{}. Table renders with header only, no rows. No error.
Test_runList_APIError Mock returns errors.New("boom"). Returns wrapped error.
Test_runList_missingProject User runs azdo pipelines folder list 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).
  • Vendored model: vendor/.../v7/build/models.go:1401 (Folder), :1419 (FolderQueryOrder), :1427 (FolderQueryOrderValues).
  • Mock: internal/mocks/build_client_mock.go:881-893 (GetFolders).
  • 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-list
  • Azure DevOps REST API: Folders — Get Folders (GET, route id a906531b-d2da-4f55-bda7-f3e676cc50d9, API version 7.1-preview.2). Path is a route value (not a query param) and is optional.
  • Umbrella: Introduce azdo pipelines folder command group #262.
  • Pattern sibling: internal/cmd/pipelines/variablegroup/list/list.go (project-scope, list view, table output).

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions