You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
List pipeline runs (builds) in an Azure DevOps project, filtered by pipeline, branch, status, result, reason, requester, time range, and tags. The project scope is supplied as a required positional argument in the form [ORGANIZATION/]PROJECT; when the organization segment is omitted, the configured default organization is used. This is the user-facing companion to azdo pipelines build list (#211). Both commands call the same build.Client.GetBuilds SDK method but expose different CLI surface to match the Azure CLI's split between the legacy pipelines build group and the modern pipelines runs group (see the Python extension's azure-devops/azext_devops/dev/pipelines/pipeline_run.py and build.py).
Locked Decisions
The following decisions are final; do not re-derive during implementation.
SDK call: build.Client.GetBuilds(ctx, GetBuildsArgs) (*GetBuildsResponseValue, error) from vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/client.go:1659. The same SDK call as feat: Implement azdo pipelines build list command #211 (build list); the distinction is purely CLI ergonomics.
Package layout: command file internal/cmd/pipelines/runs/list/list.go, test file internal/cmd/pipelines/runs/list/list_test.go. Parent group internal/cmd/pipelines/runs/runs.go registers list.NewCmd(ctx) (parent is being created in feat: Introduce azdo pipelines runs command group #214).
Factory function: func NewCmd(ctx util.CmdContext) *cobra.Command (no NewCmdPipelinesRunsList prefix; this style is deprecated).
Project scope is a required positional argument of the form [ORGANIZATION/]PROJECT (the azdo convention per AGENTS.md and internal/cmd/util/scope.go:78-108). Parse via util.ParseProjectScope(ctx, opts.scopeArg). The function returns a *Scope whose Organization field is either the user-supplied value or the configured default. There are no--organization or --project flags on this command. Args: util.ExactArgs(1, "project argument required") enforces exactly one positional.
Pagination: loop on resp.ContinuationToken (string, value type — terminate on == ""); on each iteration, set args.ContinuationToken = &resp.ContinuationToken to drive the next page. Stop early when len(out) >= maxItems (when maxItems > 0).
Filter SDK constraints (these are *<EnumType> not slices in GetBuildsArgs):
StatusFilter *BuildStatus (single value).
ResultFilter *BuildResult (single value).
ReasonFilter *BuildReason (single value).
QueryOrder *BuildQueryOrder (single value).
When the user passes multiple values, only the first is mapped to the SDK; the rest are dropped with a zap.L().Debug log entry. Flag bindings stay StringSlice / IntSlice for azdo CLI consistency with the Azure CLI.
Identity resolution for --requested-for: if the value equals @me (case-insensitive), resolve via the ExtensionsClient.GetSelfID + IdentityClient.ReadIdentities chain used in internal/cmd/boards/workitem/list/list.go:506-520, then pass the resolved ID to the SDK. Use scope.Organization (already parsed) when creating those clients — do not call cfg.Authentication().GetDefaultOrganization() again.
Branch resolution: if the value does not start with refs/, prepend refs/heads/ inline in a 3-line helper inside list.go (do not promote to a shared package). The SDK BranchName is *string; only the first --branch value is honored (matches the Python extension and the SDK constraint).
--top semantics: server-side cap passed to GetBuildsArgs.Top *int. If the user does not set --top, the field is left nil so the server applies its default page size.
--max-items semantics: client-side cap applied after the SDK ContinuationToken loop returns. Default 0 means unlimited. Truncation happens after all pages are fetched (or once maxItems is reached mid-page, whichever comes first).
Output routing: when opts.exporter != nil, emit JSON via opts.exporter.Write(ios, result) with the raw []build.Build payload. When opts.exporter == nil, render the table described below. No view struct is needed — the SDK Build model is already JSON-tagged and stable, no redaction is required for a list view, and the Python extension returns the same raw payload.
Table columns (left to right): ID, NUMBER, STATUS, RESULT, REASON, PIPELINE, BRANCH, REQUESTED FOR, STARTED, FINISHED — matches az pipelines runs list defaults.
Table cell rendering:
PIPELINE = Definition.Name falling back to strconv.Itoa(*Definition.Id) when Name is empty (mirrors the Python extension's behavior).
REQUESTED FOR = RequestedFor.DisplayName falling back to RequestedFor.UniqueName (use types.GetValue for nil-safety per the pattern at internal/cmd/boards/workitem/list/list.go:1062-1080). Build.RequestedFor is typed *webapi.IdentityRef (see vendor/.../build/models.go:186).
STARTED = StartTime.Time().Format("2006-01-02 15:04:05 MST") (local time, matches azdo boards workitem list).
FINISHED = FinishTime.Time().Format(...) or empty if FinishTime is nil.
No confirmation prompt (read-only command).
No JSON redaction needed for a runs list payload.
Mock reuse: MockBuildClient.GetBuilds already exists at internal/mocks/build_client_mock.go:701-714 with the exact signature GetBuilds(ctx context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error). Do not regenerate mocks for this command.
No --build-number flag: the Python extension's pipeline_run_list does not expose a build-number filter either; users who need a specific run can pipe the output through azdo pipelines runs list Fabrikam --json id,buildNumber and jq '.[] | select(.buildNumber=="…")'. Keep the surface minimal.
Command Signature
azdo pipelines runs list [ORGANIZATION/]PROJECT [flags]
Aliases:
ls, l
The project is always supplied positionally; the organization is optional (defaults to the configured default organization). There are no --organization or --project flags. This matches internal/cmd/pipelines/variablegroup/list/list.go:51-67 and internal/cmd/repo/list/list.go:24-56.
Flags (mapped to SDK/REST)
Flag
Type
Required
SDK / REST mapping
Default
--pipeline-id
intSlice
no
GetBuildsArgs.Definitions *[]int (first value used)
empty (all pipelines)
--branch
stringSlice
no
GetBuildsArgs.BranchName *string (first value used, refs/heads/ prepended if missing)
empty (all branches)
--status
stringSlice
no
GetBuildsArgs.StatusFilter *BuildStatus (first value mapped via BuildStatusValues)
empty (all statuses)
--result
stringSlice
no
GetBuildsArgs.ResultFilter *BuildResult (first value mapped via BuildResultValues)
empty (all results)
--reason
stringSlice
no
GetBuildsArgs.ReasonFilter *BuildReason (first value mapped via BuildReasonValues)
empty (all reasons)
--requested-for
string
no
GetBuildsArgs.RequestedFor *string (identity resolved for @me)
BuildQueryOrderValues enum: finishTimeAscending, finishTimeDescending, queueTimeAscending, queueTimeDescending, startTimeAscending, startTimeDescending (from vendor/.../build/models.go:1112-1128). Match case-insensitively against the constant names; reject unknown values with a clear error.
JSON Output Contract
When --json is set, emit the raw []build.Build slice (no view struct, no redaction). Field names match the SDK struct's json:"…" tags. The --json field whitelist registers all top-level Build fields the SDK exposes.
When --json is not set, the table described in the Locked Decisions renders.
Factory: build.Client from internal/azdo/factory.go via ctx.ClientFactory().Build(ctx.Context(), scope.Organization).
Code Skeleton (canonical, copy verbatim)
The following four blocks are the complete and final skeleton. Copy them verbatim into the new files; adjust only the constant values and table column headers as required.
§1 runOptions struct in internal/cmd/pipelines/runs/list/list.go
package list
import (
"fmt""strconv""strings""github.com/MakeNowJust/heredoc""github.com/spf13/cobra""go.uber.org/zap""github.com/microsoft/azure-devops-go-api/azuredevops/v7""github.com/microsoft/azure-devops-go-api/azuredevops/v7/build""github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi""github.com/tmeckel/azdo-cli/internal/cmd/util""github.com/tmeckel/azdo-cli/internal/types"
)
typerunOptionsstruct {
scopeArgstringpipelineIDs []intbranches []stringstatuses []stringresults []stringreasons []stringrequestedForstringtags []stringqueryOrderstringtopintmaxItemsintexporter util.Exporter
}
funcNewCmd(ctx util.CmdContext) *cobra.Command {
opts:=&runOptions{}
cmd:=&cobra.Command{
Use: "list [ORGANIZATION/]PROJECT",
Short: "List runs of pipelines in a project.",
Long: heredoc.Doc(` List runs of pipelines in an Azure DevOps project. Mirrors 'az pipelines runs list'. Filters support pipeline, branch, status, result, reason, requester, and tags. The full result set is paginated server-side; use --max-items to cap the response client-side. `),
Example: heredoc.Doc(` # List the 20 most recent runs for a project (default org) azdo pipelines runs list Fabrikam --top 20 # Filter by pipeline and branch azdo pipelines runs list MyOrg/Fabrikam --pipeline-id 42 --branch main # Order by queue time, descending azdo pipelines runs list Fabrikam --query-order queueTimeDescending # Export as JSON azdo pipelines runs list Fabrikam --json id,buildNumber,status,result `),
Aliases: []string{"l", "ls"},
Args: util.ExactArgs(1, "project argument required"),
RunE: func(cmd*cobra.Command, args []string) error {
opts.scopeArg=args[0]
returnrunList(ctx, opts)
},
}
cmd.Flags().IntSliceVar(&opts.pipelineIDs, "pipeline-id", nil, "Limit to runs for these pipeline IDs (repeatable; first value is honored by the SDK).")
cmd.Flags().StringSliceVar(&opts.branches, "branch", nil, "Filter by source branch (repeatable; first value is honored by the SDK). Bare names get refs/heads/ prepended.")
cmd.Flags().StringSliceVar(&opts.statuses, "status", nil, "Filter by status (repeatable; first value is honored). Valid: none, inProgress, completed, cancelling, postponed, notStarted, all.")
cmd.Flags().StringSliceVar(&opts.results, "result", nil, "Filter by result (repeatable; first value is honored). Valid: none, succeeded, partiallySucceeded, failed, canceled.")
cmd.Flags().StringSliceVar(&opts.reasons, "reason", nil, "Filter by reason (repeatable; first value is honored). Valid: manual, individualCI, batchedCI, schedule, scheduleForced, userCreated, pullRequest, etc.")
cmd.Flags().StringVar(&opts.requestedFor, "requested-for", "", "Filter by the user who queued the run. Accepts @me to mean the authenticated user.")
cmd.Flags().StringSliceVar(&opts.tags, "tag", nil, "Filter by tags (all supplied tags must match).")
cmd.Flags().StringVar(&opts.queryOrder, "query-order", "", "Order the results: finishTimeAscending, finishTimeDescending, queueTimeAscending, queueTimeDescending, startTimeAscending, startTimeDescending.")
cmd.Flags().IntVar(&opts.top, "top", 0, "Maximum number of runs to request per server page (0 = server default).")
cmd.Flags().IntVar(&opts.maxItems, "max-items", 0, "Maximum number of runs to return client-side (0 = unlimited).")
util.AddJSONFlags(cmd, &opts.exporter,
"id", "buildNumber", "status", "result", "reason",
"definition", "project", "sourceBranch", "sourceVersion",
"startTime", "finishTime", "queueTime", "requestedBy", "requestedFor",
"tags", "uri", "url",
)
returncmd
}
§2 runList + fetchBuilds in internal/cmd/pipelines/runs/list/list.go
§3 Test file internal/cmd/pipelines/runs/list/list_test.go
package list_test
import (
"bytes""testing""github.com/stretchr/testify/assert""github.com/stretchr/testify/require""github.com/tmeckel/azdo-cli/internal/cmd/pipelines/runs/list""github.com/tmeckel/azdo-cli/internal/cmd/util""github.com/tmeckel/azdo-cli/internal/mocks"
)
// Mirror the setupFakeDeps pattern from// internal/cmd/boards/workitem/list/list_test.go:765-844 verbatim,// substituting the Build mock for the WorkItem mock.//// Tests must be written FIRST (TDD) and must cover, at minimum:// 1. Default invocation: no filters, returns all runs, no error.// 2. Scope arg "Fabrikam" parses to org=default, project=Fabrikam.// 3. Scope arg "MyOrg/Fabrikam" parses to org=MyOrg, project=Fabrikam.// 4. --pipeline-id: passed through to args.Definitions.// 5. --branch: bare name gets refs/heads/ prepended; refs/ untouched.// 6. --status / --result / --reason: unknown value returns a clear error.// 7. --requested-for @me: triggers identity resolution.// 8. --query-order: passed through to args.QueryOrder.// 9. --top: passed through to args.Top.// 10. --max-items: client-side truncation after pagination.// 11. ContinuationToken loop: page2, page3, then empty token terminates.// 12. --json: exporter.Write is called with the raw []build.Build slice.// 13. Table: 10 columns in the documented order.funcsetupRunsFakeDeps(t*testing.T) (*mocks.MockBuildClient, util.CmdContext, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
// ... see workitem/list_test.go:765-844 for the canonical scaffolding ...
}
funcTestRunList_DefaultReturnsAllRuns(t*testing.T) {
t.Parallel()
// ... assert mock.GetBuilds was called once, args.Project == expected ...
}
Vendored: Build model — vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/models.go:122-217. Build.RequestedFor is *webapi.IdentityRef (:186).
Copy the setupFakeDeps scaffolding from internal/cmd/boards/workitem/list/list_test.go:765-844 into internal/cmd/pipelines/runs/list/list_test.go, swapping the WorkItem mock for the Build mock. Rename to setupRunsFakeDeps for clarity. Aim for ~120 LOC of test scaffolding.
Write the 13 tests listed in §3 above. All must FAIL on the un-stubbed package.
Run go test ./internal/cmd/pipelines/runs/list/... — confirm all 13 tests fail for the right reason (e.g., undefined: list.NewCmd or nil pointer dereference).
Phase 2 (GREEN) — minimal implementation.
Add internal/cmd/pipelines/runs/list/list.go with the §1 struct, §2 wiring, runList, fetchBuilds, renderRunsTable, validateRunOptions, and the 6 helper functions (4 enum lookups are simple case-insensitive scanners, ~5 LOC each).
Add cmd.AddCommand(runs.NewCmd(ctx)) to internal/cmd/pipelines/pipelines.go.
Run go test ./internal/cmd/pipelines/runs/... — all 13 tests pass.
Run go build ./... — clean.
Run go run cmd/azdo/azdo.go pipelines runs list --help — output matches the Use: "list [ORGANIZATION/]PROJECT" line and lists all 11 filter/format flags (no --organization / --project flags).
LOC budget: ~250 LOC for list.go (struct + cmd + run + helpers), ~140 LOC for list_test.go, ~40 LOC for runs.go. Total: ~430 LOC.
Tooling and Verification Checklist
go test ./internal/cmd/pipelines/runs/... passes (all 13 cases).
go test ./... passes (no regressions elsewhere).
go build ./... is clean.
go run cmd/azdo/azdo.go pipelines runs list --help renders without panic and shows Use: "list [ORGANIZATION/]PROJECT".
go run cmd/azdo/azdo.go pipelines runs list Fabrikam --help errors with "project argument required" if no positional is supplied.
go run cmd/azdo/azdo.go pipelines runs list MyOrg/Fabrikam --top 1 (with AZDO_TOKEN and AZDO_ORGANIZATION env) returns one row.
make lint passes (golangci-lint, per .golangci.yml).
make docs regenerates docs/pipelines_runs_list.md with the new command.
Command Description
List pipeline runs (builds) in an Azure DevOps project, filtered by pipeline, branch, status, result, reason, requester, time range, and tags. The project scope is supplied as a required positional argument in the form
[ORGANIZATION/]PROJECT; when the organization segment is omitted, the configured default organization is used. This is the user-facing companion toazdo pipelines build list(#211). Both commands call the samebuild.Client.GetBuildsSDK method but expose different CLI surface to match the Azure CLI's split between the legacypipelines buildgroup and the modernpipelines runsgroup (see the Python extension'sazure-devops/azext_devops/dev/pipelines/pipeline_run.pyandbuild.py).Locked Decisions
The following decisions are final; do not re-derive during implementation.
build.Client.GetBuilds(ctx, GetBuildsArgs) (*GetBuildsResponseValue, error)fromvendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/client.go:1659. The same SDK call as feat: Implementazdo pipelines build listcommand #211 (build list); the distinction is purely CLI ergonomics.internal/cmd/pipelines/runs/list/list.go, test fileinternal/cmd/pipelines/runs/list/list_test.go. Parent groupinternal/cmd/pipelines/runs/runs.goregisterslist.NewCmd(ctx)(parent is being created in feat: Introduceazdo pipelines runscommand group #214).func NewCmd(ctx util.CmdContext) *cobra.Command(noNewCmdPipelinesRunsListprefix; this style is deprecated).[ORGANIZATION/]PROJECT(theazdoconvention perAGENTS.mdandinternal/cmd/util/scope.go:78-108). Parse viautil.ParseProjectScope(ctx, opts.scopeArg). The function returns a*ScopewhoseOrganizationfield is either the user-supplied value or the configured default. There are no--organizationor--projectflags on this command.Args: util.ExactArgs(1, "project argument required")enforces exactly one positional.resp.ContinuationToken(string, value type — terminate on== ""); on each iteration, setargs.ContinuationToken = &resp.ContinuationTokento drive the next page. Stop early whenlen(out) >= maxItems(whenmaxItems > 0).*<EnumType>not slices inGetBuildsArgs):StatusFilter *BuildStatus(single value).ResultFilter *BuildResult(single value).ReasonFilter *BuildReason(single value).QueryOrder *BuildQueryOrder(single value).zap.L().Debuglog entry. Flag bindings stayStringSlice/IntSliceforazdoCLI consistency with the Azure CLI.--requested-for: if the value equals@me(case-insensitive), resolve via theExtensionsClient.GetSelfID+IdentityClient.ReadIdentitieschain used ininternal/cmd/boards/workitem/list/list.go:506-520, then pass the resolved ID to the SDK. Usescope.Organization(already parsed) when creating those clients — do not callcfg.Authentication().GetDefaultOrganization()again.refs/, prependrefs/heads/inline in a 3-line helper insidelist.go(do not promote to a shared package). The SDKBranchNameis*string; only the first--branchvalue is honored (matches the Python extension and the SDK constraint).--topsemantics: server-side cap passed toGetBuildsArgs.Top *int. If the user does not set--top, the field is left nil so the server applies its default page size.--max-itemssemantics: client-side cap applied after the SDKContinuationTokenloop returns. Default0means unlimited. Truncation happens after all pages are fetched (or oncemaxItemsis reached mid-page, whichever comes first).opts.exporter != nil, emit JSON viaopts.exporter.Write(ios, result)with the raw[]build.Buildpayload. Whenopts.exporter == nil, render the table described below. No view struct is needed — the SDKBuildmodel is already JSON-tagged and stable, no redaction is required for a list view, and the Python extension returns the same raw payload.ID,NUMBER,STATUS,RESULT,REASON,PIPELINE,BRANCH,REQUESTED FOR,STARTED,FINISHED— matchesaz pipelines runs listdefaults.PIPELINE=Definition.Namefalling back tostrconv.Itoa(*Definition.Id)whenNameis empty (mirrors the Python extension's behavior).REQUESTED FOR=RequestedFor.DisplayNamefalling back toRequestedFor.UniqueName(usetypes.GetValuefor nil-safety per the pattern atinternal/cmd/boards/workitem/list/list.go:1062-1080).Build.RequestedForis typed*webapi.IdentityRef(seevendor/.../build/models.go:186).STARTED=StartTime.Time().Format("2006-01-02 15:04:05 MST")(local time, matchesazdo boards workitem list).FINISHED=FinishTime.Time().Format(...)or empty ifFinishTimeis nil.MockBuildClient.GetBuildsalready exists atinternal/mocks/build_client_mock.go:701-714with the exact signatureGetBuilds(ctx context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error). Do not regenerate mocks for this command.--build-numberflag: the Python extension'spipeline_run_listdoes not expose a build-number filter either; users who need a specific run can pipe the output throughazdo pipelines runs list Fabrikam --json id,buildNumberandjq '.[] | select(.buildNumber=="…")'. Keep the surface minimal.Command Signature
The project is always supplied positionally; the organization is optional (defaults to the configured default organization). There are no
--organizationor--projectflags. This matchesinternal/cmd/pipelines/variablegroup/list/list.go:51-67andinternal/cmd/repo/list/list.go:24-56.Flags (mapped to SDK/REST)
--pipeline-idGetBuildsArgs.Definitions *[]int(first value used)--branchGetBuildsArgs.BranchName *string(first value used,refs/heads/prepended if missing)--statusGetBuildsArgs.StatusFilter *BuildStatus(first value mapped viaBuildStatusValues)--resultGetBuildsArgs.ResultFilter *BuildResult(first value mapped viaBuildResultValues)--reasonGetBuildsArgs.ReasonFilter *BuildReason(first value mapped viaBuildReasonValues)--requested-forGetBuildsArgs.RequestedFor *string(identity resolved for@me)--tagGetBuildsArgs.TagFilters *[]string(all values)--query-orderGetBuildsArgs.QueryOrder *BuildQueryOrder(first/only value)--topGetBuildsArgs.Top *int--max-items0(unlimited)--jsonutil.AddJSONFlagsregistration--jqutil.AddJSONFlagsregistration--templateutil.AddJSONFlagsregistrationBuildQueryOrderValuesenum:finishTimeAscending,finishTimeDescending,queueTimeAscending,queueTimeDescending,startTimeAscending,startTimeDescending(fromvendor/.../build/models.go:1112-1128). Match case-insensitively against the constant names; reject unknown values with a clear error.JSON Output Contract
When
--jsonis set, emit the raw[]build.Buildslice (no view struct, no redaction). Field names match the SDK struct'sjson:"…"tags. The--jsonfield whitelist registers all top-levelBuildfields the SDK exposes.When
--jsonis not set, the table described in the Locked Decisions renders.Command Wiring
internal/cmd/pipelines/runs/list/list.go(packagelist).internal/cmd/pipelines/runs/runs.go(packageruns, being created in feat: Introduceazdo pipelines runscommand group #214) wirescmd.AddCommand(list.NewCmd(ctx)).internal/cmd/pipelines/pipelines.gowirescmd.AddCommand(runs.NewCmd(ctx))(will be added in the umbrella update that follows feat: Introduceazdo pipelines buildcommand group #213 / feat: Introduceazdo pipelines runscommand group #214).build.Clientfrominternal/azdo/factory.goviactx.ClientFactory().Build(ctx.Context(), scope.Organization).Code Skeleton (canonical, copy verbatim)
The following four blocks are the complete and final skeleton. Copy them verbatim into the new files; adjust only the constant values and table column headers as required.
§1
runOptionsstruct ininternal/cmd/pipelines/runs/list/list.go§2
runList+fetchBuildsininternal/cmd/pipelines/runs/list/list.go§3 Test file
internal/cmd/pipelines/runs/list/list_test.goAPI Surface
Build.GetBuilds(ctx, GetBuildsArgs) (*GetBuildsResponseValue, error)—vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/client.go:1659.GetBuildsArgsstruct —vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/client.go:1757-1802.GetBuildsResponseValuestruct —vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/client.go:1803-1806(Value []Build,ContinuationToken string).Buildmodel —vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/models.go:122-217.Build.RequestedForis*webapi.IdentityRef(:186).BuildStatusValues—vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/models.go:1062-1080.BuildResultValues—vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/models.go:978-985.BuildReasonValues—vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/models.go:862-887.BuildQueryOrderValues—vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/build/models.go:1112-1128.azuredevops.Time— wrapstime.TimewithTime() time.Timeaccessor.webapi.IdentityRef—vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi(hasId,DisplayName,UniqueName).MockBuildClient.GetBuilds—internal/mocks/build_client_mock.go:701-714.Build(ctx, organization)—internal/azdo/connection.go:50-51(interface) /internal/azdo/factory.go:61(impl).util.ParseProjectScope(ctx, arg)—internal/cmd/util/scope.go:78-108.Implementation Approach (TDD, reuse-first, minimal)
Phase 1 (RED) — write tests first.
setupFakeDepsscaffolding frominternal/cmd/boards/workitem/list/list_test.go:765-844intointernal/cmd/pipelines/runs/list/list_test.go, swapping the WorkItem mock for the Build mock. Rename tosetupRunsFakeDepsfor clarity. Aim for ~120 LOC of test scaffolding.go test ./internal/cmd/pipelines/runs/list/...— confirm all 13 tests fail for the right reason (e.g.,undefined: list.NewCmdornil pointer dereference).Phase 2 (GREEN) — minimal implementation.
internal/cmd/pipelines/runs/list/list.gowith the §1 struct, §2 wiring,runList,fetchBuilds,renderRunsTable,validateRunOptions, and the 6 helper functions (4 enum lookups are simple case-insensitive scanners, ~5 LOC each).internal/cmd/pipelines/runs/runs.go(group parent — see feat: Introduceazdo pipelines runscommand group #214 for the canonical structure, which registerslist.NewCmd(ctx)).cmd.AddCommand(runs.NewCmd(ctx))tointernal/cmd/pipelines/pipelines.go.go test ./internal/cmd/pipelines/runs/...— all 13 tests pass.go build ./...— clean.go run cmd/azdo/azdo.go pipelines runs list --help— output matches theUse: "list [ORGANIZATION/]PROJECT"line and lists all 11 filter/format flags (no--organization/--projectflags).LOC budget: ~250 LOC for
list.go(struct + cmd + run + helpers), ~140 LOC forlist_test.go, ~40 LOC forruns.go. Total: ~430 LOC.Tooling and Verification Checklist
go test ./internal/cmd/pipelines/runs/...passes (all 13 cases).go test ./...passes (no regressions elsewhere).go build ./...is clean.go run cmd/azdo/azdo.go pipelines runs list --helprenders without panic and showsUse: "list [ORGANIZATION/]PROJECT".go run cmd/azdo/azdo.go pipelines runs list Fabrikam --helperrors with "project argument required" if no positional is supplied.go run cmd/azdo/azdo.go pipelines runs list MyOrg/Fabrikam --top 1(withAZDO_TOKENandAZDO_ORGANIZATIONenv) returns one row.make lintpasses (golangci-lint, per.golangci.yml).make docsregeneratesdocs/pipelines_runs_list.mdwith the new command.Reference Existing Patterns
internal/cmd/pipelines/variablegroup/list/list.go:47-93— canonical pipelines-scoped list command (Use: "list [ORGANIZATION/]PROJECT",util.ExactArgs(1, ...),util.ParseProjectScope(...)).internal/cmd/boards/workitem/list/list.go:48-115— reference forNewCmdshape with[ORGANIZATION/]PROJECTpositional.internal/cmd/boards/workitem/list/list_test.go:765-844— canonicalsetupFakeDepstest fixture; copy verbatim, swap the mock.internal/cmd/boards/workitem/list/list.go:506-520—@meresolution viaExtensionsClient+IdentityClient.internal/cmd/boards/workitem/list/list.go:1062-1080—fieldIdentityDisplayforIdentityRefrendering (same type used here).internal/cmd/util/scope.go:78-108—ParseProjectScopesource (the function called fromrunList).github.com/tmeckel/azdo-cli/issues/211— sibling leaf (build list); uses the same SDK call and the same positional pattern.References
azure-dev-ops-cli-extension/azure-devops/azext_devops/dev/pipelines/pipeline_run.py(thepipeline_run_listfunction).azure-dev-ops-cli-extension/azure-devops/azext_devops/dev/pipelines/commands.py#L94-L97(pipelines runscommand group).azdo pipelines runscommand group #214 (feat: Introduce \azdo pipelines runs` command group`).azdo pipelines build listcommand #211 (feat: Implement \azdo pipelines build list` command`).azdo pipelinescommand group #116 (feat: \azdo pipelines` command group`).