Skip to content

feat: Implement azdo pipelines build list command #211

Description

@tmeckel

Command Description

List classic build records in a project with filter and pagination support. This is the Build-v1 surface (XAML and early YAML); for the modern Pipelines "runs" surface, see azdo pipelines runs list (#214). The Azure CLI command fetches a []Build from the Build REST API and the azdo command mirrors that contract while exposing JSON output and a deterministic table layout.

GET {organization}/{project}/_apis/build/builds?api-version=7.1

Locked Decisions (do not re-derive)

# Decision Rationale
1 Use the vendored build.Client.GetBuilds SDK method (vendor/.../build/client.go:1659); do not hand-roll HTTP. SDK has every filter we need (Definitions, BranchName, BuildNumber, Top, ContinuationToken, ResultFilter, StatusFilter, ReasonFilter, TagFilters, RequestedFor, QueryOrder, MinTime/MaxTime, RepositoryId/RepositoryType, BuildIds, MaxBuildsPerDefinition, DeletedFilter).
2 Mock for GetBuilds already exists at internal/mocks/build_client_mock.go:701-714; no new mock generation required. Confirmed via grep; satisfies the "reuse-first" rule.
3 Resolve client via ctx.ClientFactory().Build(ctx.Context(), scope.Organization). Matches the factory pattern used by internal/cmd/pipelines/variablegroup/list/list.go and internal/cmd/boards/workitem/list/list.go.
4 Use the [ORGANIZATION/]PROJECT positional pattern via util.ParseProjectScope (defined in internal/cmd/util/scope.go:78-108); fall back to the configured default organization when the segment is omitted. Project-scoped commands must mirror the convention from AGENTS.md.
5 Loop on ContinuationToken until empty (default behavior). SDK returns a value-type ContinuationToken string; the loop is bounded by an empty string.
6 --top is not an alias for --limit — pass it through to args.Top directly so server-side paging semantics match Azure CLI. az pipelines build list --top 50 returns the most recent 50 builds; the server applies ordering, so a Top larger than the available set is fine.
7 --max-items is a client-side cap applied after the SDK loop terminates (default 0 = unlimited). Mirrors azdo's convention from internal/cmd/pipelines/variablegroup/list/list.go; respects AGENTS.md "Pagination for List Operations".
8 --definition-id, --branch, --tag, --status, --result, --reason are repeatable / comma-aware but pass through to the SDK as scalars / slices. SDK accepts slices for some and a single value for others; we keep the CLI surface simple by collapsing repeated invocations into a slice (or a single value for status/result/reason per call).
9 Default sort: server-determined (no QueryOrder set). Matches az pipelines build list default in the Python extension.
10 When opts.exporter != nil, emit the raw []build.Build slice to JSON; do not invent an intermediate view struct unless filtering/redaction is required. Build model fields are already JSON-tagged and stable; no redaction needed for a list view.
11 Default table columns: ID, NUMBER, STATUS, RESULT, REASON, DEFINITION, BRANCH, REQUESTED FOR, STARTED, FINISHED. Matches az pipelines build list default; uses *Build fields: Id, BuildNumber, Status, Result, Reason, Definition.Name, SourceBranch, RequestedFor.DisplayName, StartTime, FinishTime.
12 Identity columns (RequestedBy, RequestedFor) display IdentityRef.DisplayName (fallback to UniqueName); use types.GetValue for nil-safety. Same pattern as internal/cmd/boards/workitem/list/list.go:1074-1080.
13 Time columns render in 2006-01-02 15:04:05 MST local time; UTC for empty values. Matches the existing list command style.
14 Empty result set: render an empty table (no error) and no JSON entries. Mirrors internal/cmd/boards/workitem/list/list.go:212-214.
15 No confirmation prompt (read-only). Same as every other list command.
16 azdo pipelines build is a new Cobra subgroup under internal/cmd/pipelines; tracked in #211. The existing internal/cmd/pipelines/pipelines.go only registers variablegroup.

Command Signature

azdo pipelines build list [ORGANIZATION/]PROJECT [flags]

Aliases:
  ls, l

Flags (mapped to SDK/REST)

Flag Maps to Notes
--definition-id intSlice GetBuildsArgs.Definitions The Python extension uses --definition-ids (plural); we expose the singular per azdo convention and accept it repeated.
--branch stringSlice GetBuildsArgs.BranchName The SDK accepts a single value per call so only the first is honored (the Python extension has the same limitation). Bare branch names get refs/heads/ prefix.
--build-number string GetBuildsArgs.BuildNumber Append * for prefix search (server-side).
--status stringSlice GetBuildsArgs.StatusFilter The SDK accepts a single value so only the first is honored. Valid values: none, inProgress, completed, cancelling, postponed, notStarted, all.
--result stringSlice GetBuildsArgs.ResultFilter The SDK accepts a single value so only the first is honored. Valid values: none, succeeded, partiallySucceeded, failed, canceled.
--reason stringSlice GetBuildsArgs.ReasonFilter The SDK accepts a single value so only the first is honored. Valid values: manual, individualCI, batchedCI, schedule, scheduleForced, userCreated, validateShelveset, checkInShelveset, pullRequest, buildCompletion, resourceTrigger, triggered, all.
--tag stringSlice GetBuildsArgs.TagFilters Builds must have all specified tags (AND semantics).
--requested-for string GetBuildsArgs.RequestedFor Resolves @me via the Extensions client. Same pattern as internal/cmd/boards/workitem/list/list.go:506-520.
--top int GetBuildsArgs.Top Pass-through to server. Defaults to server's page size if unset.
--max-items int Client-side cap. Applied after the SDK loop. --max-items 0 means unlimited (default).
--json / --jq / --template util.AddJSONFlags registration JSON fields list matches the Build model struct tags.

JSON Output Contract

util.AddJSONFlags(cmd, &opts.exporter,
    "id", "buildNumber", "status", "result", "reason",
    "queueTime", "startTime", "finishTime",
    "sourceBranch", "sourceVersion",
    "definition", "project", "requestedBy", "requestedFor",
    "tags", "uri", "url",
)

When opts.exporter != nil, emit the raw []build.Build returned by GetBuilds after the ContinuationToken loop. Do not introduce a view struct; the SDK's Build already carries JSON tags and no field redaction is required for a list view.

Command Wiring

Code Skeleton (canonical, copy verbatim)

§1. listOptions struct

type listOptions struct {
	scopeArg string

	definitionIDs []int
	branches      []string
	buildNumber   string

	statuses  []string
	results   []string
	reasons   []string
	tags      []string
	requested string

	top      int
	maxItems int

	exporter util.Exporter
}

§2. NewCmd shape (no surprises)

func NewCmd(ctx util.CmdContext) *cobra.Command {
	opts := &listOptions{}

	cmd := &cobra.Command{
		Use:   "list [ORGANIZATION/]PROJECT",
		Short: "List classic build results in a project.",
		Long: heredoc.Doc(`
			List classic build (Build v1) records in a project. Supports filter,
			pagination, and JSON export. For the modern Pipelines runs surface,
			see 'azdo pipelines runs list'.
		`),
		Example: heredoc.Doc(`
			# List the 20 most recent builds for a project
			azdo pipelines build list Fabrikam --top 20

			# Filter by branch, status, and tag
			azdo pipelines build list Fabrikam --branch main --status completed --tag release

			# Export as JSON
			azdo pipelines build list Fabrikam --json id,buildNumber,status,result
		`),
		Aliases: []string{"ls", "l"},
		Args:    util.ExactArgs(1, "project argument required"),
		RunE: func(cmd *cobra.Command, args []string) error {
			opts.scopeArg = args[0]
			return runList(ctx, opts)
		},
	}

	cmd.Flags().IntSliceVar(&opts.definitionIDs, "definition-id", nil, "Limit to builds for these definition IDs (repeatable)")
	cmd.Flags().StringSliceVar(&opts.branches, "branch", nil, "Limit to builds for these branches (repeatable; bare names get refs/heads/ prefix)")
	cmd.Flags().StringVar(&opts.buildNumber, "build-number", "", "Limit to builds that match this build number (append * for prefix search)")
	cmd.Flags().StringSliceVar(&opts.statuses, "status", nil, "Limit to builds with this status (repeatable; valid: none, inProgress, completed, cancelling, postponed, notStarted, all)")
	cmd.Flags().StringSliceVar(&opts.results, "result", nil, "Limit to builds with this result (repeatable; valid: none, succeeded, partiallySucceeded, failed, canceled)")
	cmd.Flags().StringSliceVar(&opts.reasons, "reason", nil, "Limit to builds with this reason (repeatable; valid: manual, individualCI, batchedCI, schedule, scheduleForced, userCreated, pullRequest, buildCompletion, all)")
	cmd.Flags().StringSliceVar(&opts.tags, "tag", nil, "Limit to builds that have all of the specified tags (repeatable)")
	cmd.Flags().StringVar(&opts.requested, "requested-for", "", "Limit to builds requested for this user or group; supports @me")
	cmd.Flags().IntVar(&opts.top, "top", 0, "Maximum number of builds to return per page (server-side; 0 = server default)")
	cmd.Flags().IntVar(&opts.maxItems, "max-items", 0, "Maximum number of builds to return across all pages (client-side; 0 = unlimited)")

	util.AddJSONFlags(cmd, &opts.exporter,
		"id", "buildNumber", "status", "result", "reason",
		"queueTime", "startTime", "finishTime",
		"sourceBranch", "sourceVersion",
		"definition", "project", "requestedBy", "requestedFor",
		"tags", "uri", "url",
	)

	return cmd
}

§3. runList skeleton

func runList(ctx util.CmdContext, opts *listOptions) error {
	ios, err := ctx.IOStreams()
	if err != nil {
		return err
	}

	ios.StartProgressIndicator()
	defer ios.StopProgressIndicator()

	if err := validateListOptions(opts); err != nil {
		return err
	}

	scope, err := util.ParseProjectScope(ctx, opts.scopeArg)
	if err != nil {
		return util.FlagErrorWrap(err)
	}

	client, err := ctx.ClientFactory().Build(ctx.Context(), scope.Organization)
	if err != nil {
		return fmt.Errorf("failed to create Build client: %w", err)
	}

	args := build.GetBuildsArgs{Project: &scope.Project}
	if ids := dedupeInts(opts.definitionIDs); len(ids) > 0 {
		args.Definitions = &ids
	}
	if branch := firstStringValue(opts.branches); branch != "" {
		prefixed := resolveGitBranchRef(branch)
		args.BranchName = &prefixed
	}
	if opts.buildNumber != "" {
		args.BuildNumber = &opts.buildNumber
	}
	if status := firstStringValue(opts.statuses); status != "" {
		v := build.BuildStatus(status)
		args.StatusFilter = &v
	}
	if result := firstStringValue(opts.results); result != "" {
		v := build.BuildResult(result)
		args.ResultFilter = &v
	}
	if reason := firstStringValue(opts.reasons); reason != "" {
		v := build.BuildReason(reason)
		args.ReasonFilter = &v
	}
	if len(opts.tags) > 0 {
		args.TagFilters = &opts.tags
	}
	if opts.requested != "" {
		resolved, err := resolveRequestedFor(ctx, scope.Organization, opts.requested)
		if err != nil {
			return err
		}
		args.RequestedFor = &resolved
	}
	if opts.top > 0 {
		args.Top = &opts.top
	}

	builds, err := fetchBuilds(ctx, client, args, opts.maxItems)
	if err != nil {
		return err
	}

	ios.StopProgressIndicator()

	if opts.exporter != nil {
		return opts.exporter.Write(ios, builds)
	}

	return renderBuildsTable(ctx, builds)
}

func fetchBuilds(ctx util.CmdContext, client build.Client, args build.GetBuildsArgs, maxItems int) ([]build.Build, error) {
	if maxItems < 0 {
		return nil, util.FlagErrorf("--max-items must be >= 0")
	}
	out := make([]build.Build, 0)
	for {
		resp, err := client.GetBuilds(ctx.Context(), args)
		if err != nil {
			return nil, fmt.Errorf("failed to list builds: %w", err)
		}
		if resp != nil && len(resp.Value) > 0 {
			for _, b := range resp.Value {
				out = append(out, b)
				if maxItems > 0 && len(out) >= maxItems {
					return out, nil
				}
			}
		}
		if resp == nil || resp.ContinuationToken == "" {
			return out, nil
		}
		token := resp.ContinuationToken
		args.ContinuationToken = &token
	}
}

§4. Test fixture (copy from internal/cmd/boards/workitem/list/list_test.go:765-844 + a Build-client stub)
The hermetic setupFakeDeps fixture used by the boards work-item list test handles a fake ConnectionFactory, a fake ClientFactory, and a fake IOStreams. Add a setupBuildFakeDeps(t, organization) variant that stubs MockBuildClient.GetBuilds with a GetBuildsResponseValue containing a []Build payload. Reuse the gomock patterns from internal/cmd/boards/workitem/list/list_test.go:765-844. The Build mock is internal/mocks/build_client_mock.go (see MockBuildClient).

API Surface

  • build.Client.GetBuilds(ctx, GetBuildsArgs) (*GetBuildsResponseValue, error) — see vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/client.go:1659.
  • GetBuildsArgs — see vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/client.go:1757-1802. Fields used: Project, Definitions, BranchName, BuildNumber, StatusFilter, ResultFilter, ReasonFilter, TagFilters, RequestedFor, Top, ContinuationToken.
  • GetBuildsResponseValue — see vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/client.go:1803-1806. Fields: Value []Build, ContinuationToken string (value type, not pointer).
  • Build model — see vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/models.go:122-217. Fields used: Id, BuildNumber, Status, Result, Reason, Definition, Project, SourceBranch, SourceVersion, StartTime, FinishTime, QueueTime, RequestedBy, RequestedFor, Tags, Uri, URL.
  • BuildStatusValuesvendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/models.go:1062-1080.
  • BuildResultValuesvendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/models.go:978-985.
  • BuildReasonValuesvendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/models.go:862-887.
  • Mock: internal/mocks/build_client_mock.go:701-714 (MockBuildClient.GetBuilds).
  • Client factory: internal/azdo/connection.go:50-51 (ClientFactory.Build); internal/azdo/factory.go:61 (impl).

Implementation Approach (TDD, reuse-first, minimal)

Phase 1 — RED (tests first).

  1. internal/cmd/pipelines/build/list/list_test.go:
    • TestList_EmptyResult — stub GetBuilds to return {Value: nil, ContinuationToken: ""} and assert the table renderer prints headers only, no error.
    • TestList_NoFilters — stub a []Build of 3 items; assert all 3 are rendered in order.
    • TestList_ContinuationToken_Loops — stub page 1 returning ContinuationToken: "tok-1" with 2 items, then page 2 returning ContinuationToken: "" with 1 item; assert all 3 items collected and GetBuilds called twice with the same args (token reset across calls).
    • TestList_FiltersPassedToSDK — set --definition-id 1 --definition-id 2 --branch main --status completed --result succeeded --reason manual --tag release --requested-for @me --top 50; assert the recorded GetBuildsArgs contains Definitions=[1,2], BranchName=refs/heads/main, StatusFilter=completed, ResultFilter=succeeded, ReasonFilter=manual, TagFilters=[release], RequestedFor=, Top=50.
    • TestList_MaxItemsCap — stub 5 items; run with --max-items 3; assert 3 items rendered and only one GetBuilds call recorded.
    • TestList_JSONOutput — set --json id,buildNumber,status; assert the JSON output contains only those keys.
    • TestList_PaginationAcrossPages_CappedByMaxItems — stub 2 pages of 5 items each; --max-items 6; assert only 6 items rendered.
    • TestList_RequestedFor_ResolvesAtMe — stub the Extensions client; assert RequestedFor equals the resolved ID.
    • TestList_InvalidStatus--status nonsense; expect util.FlagErrorf with a message listing the valid values.
    • TestList_ScopeArg_ParsesOrgSlashProject"myOrg/myProject"; assert args.Project is "myProject" and the org used in ClientFactory.Build(...) is "myOrg".
  2. Use setupFakeDeps pattern from internal/cmd/boards/workitem/list/list_test.go:765-844 plus a MockBuildClient wired via the MockClientFactory (same wiring style as work-item list test).

Phase 2 — GREEN (minimal implementation).

  • Implement list.go per the §3 skeleton; no new helpers outside what is sketched.
  • Reuse util.ParseProjectScope, util.FlagErrorWrap, util.AddJSONFlags, types.GetValue, types.Unique. No new helpers unless mandated.
  • If @me resolution is needed, reuse the ExtensionsClient.GetSelfID + IdentityClient.ReadIdentities chain from internal/cmd/boards/workitem/list/list.go:506-520.
  • For branch prefixing, inline a 3-line resolveGitBranchRef helper in list.go (do not promote to a shared package).
  • Keep the file under ~250 LOC.

Tooling and Verification Checklist

  • gofmt -w and goimports -w on the new files.
  • gofumpt -w after the implementation lands (acceptable to lag during drafts).
  • go build ./... — must succeed.
  • go test ./... — must pass.
  • make lint — must pass.
  • make docs — must regenerate without warnings; new flags appear in docs/pipelines_build_list.md.

Reference Existing Patterns

  • internal/cmd/boards/workitem/list/list.goNewCmd shape, util.ParseProjectScope usage, identity resolution, table rendering, JSON output.
  • internal/cmd/boards/workitem/list/list_test.go:765-844setupFakeDeps test fixture (canonical hermetic pattern).
  • internal/cmd/pipelines/variablegroup/list/list.go — pipelines-scoped list command; same Cobra wiring.
  • internal/mocks/build_client_mock.go:701-714 — pre-existing GetBuilds mock.
  • internal/azdo/connection.go:50-51ClientFactory.Build accessor.
  • internal/cmd/boards/workitem/list/list.go:1062-1080fieldIdentityDisplay for IdentityRef rendering.

References

  • Azure CLI implementation: build_list.
  • Azure CLI command registration: commands.py#L84-L92.
  • REST: Builds - List.
  • Vendored SDK: vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/client.go:1659 (GetBuilds).
  • Vendored model: vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/models.go:122-217 (Build), :1062-1080 (BuildStatusValues), :978-985 (BuildResultValues), :862-887 (BuildReasonValues).
  • Mock: internal/mocks/build_client_mock.go:701-714 (MockBuildClient.GetBuilds).
  • Parent tracking: feat: Implement azdo pipelines build list command #211 (build subgroup).

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