Skip to content

feat: Implement azdo pipelines agent list command #248

Description

@tmeckel

Sub-issue of #246. Hardened spec — do not re-derive decisions. Mirrors internal/cmd/pipelines/agent/show/show.go (the org-scoped sibling that already uses a 1-3 segment positional with shared.ResolvePool/shared.ResolvePoolAgent) and internal/cmd/pipelines/build/queue (the planned spec #253, which folds ID/name resolution into a single positional).

Command Description

List the agents in an Azure DevOps agent pool. The command takes the pool as a positional target (accepts either a numeric ID or a name), resolves it to a pool ID via shared.ResolvePool, and then fetches the matching agents via the Agents REST 7.1 endpoint. The result is rendered as a default table (with optional JSON export). All output passes through ctx.Printer("list") for the table and opts.exporter.Write(ios, view) for JSON.

GET https://dev.azure.com/{organization}/_apis/distributedtask/pools/{poolId}/agents?agentName={filter}&includeCapabilities={bool}&propertyFilters={csv}&demands={csv}&api-version=7.1

Locked Decisions (do not re-derive)

# Decision Rationale
1 Use the vendored SDK taskagent.Client.GetAgents (not raw HTTP). Mock already generated at internal/mocks/taskagent_client_mock.go:531-540. Consistent with the other taskagent subgroup siblings.
2 The pool is required and identified by the positional POOL argument (the last segment of [ORGANIZATION/]POOL). The pool target accepts either a numeric ID (fast path) or a name (resolved via shared.ResolvePool from internal/cmd/pipelines/agent/shared/resolve.go:50). Folds the previous --pool-id flag into a single positional. Mirrors the agent show (#247) and build queue (#253) target-resolution pattern. UX is consistent with the rest of the taskagent subgroup.
3 The pool target is validated by shared.ResolvePool: a positive integer is used directly, a string calls taskagent.Client.GetAgentPools(name=...) and the first case-insensitive name match is selected. Ambiguous matches (multiple pools with the same name) or missing pools return fmt.Errorf. Removes the previous --pool-id int-flag validation since pool ID validation is now a property of the resolver. One source of truth for "target → ID" resolution. Matches agent show (#247) and build queue (#253) sibling behavior.
4 The positional [ORGANIZATION/]POOL is parsed with util.ParseTargetWithDefaultOrganization from internal/cmd/util/scope.go:157. The function returns a *util.Path with Organization populated (defaults resolved from ctx.Config().Authentication().GetDefaultOrganization() when the org segment is omitted) and Targets[0] set to the pool target. Project is not used. Mirrors the security-membership and work-item delete/update target parsers; the pool is a single-target org-scoped lookup.
4.5 cobra.ExactArgs(1) — exactly 1 positional. Combined with the ParseTargetWithDefaultOrganization parser, the resulting shape is azdo pipelines agent list [ORGANIZATION/]POOL, where POOL accepts either an ID or a name. The PoolId of GetAgentsArgs is the integer returned by shared.ResolvePool, NOT the raw scope.Targets[0] string. Mirrors the build queue (#253) UX: 1- or 2-segment positional with ID-or-name target resolution.
5 Default output is a table. JSON output via --json passes a dedicated view struct []agentJSON to opts.exporter.Write. The view struct flattens identity and time fields with omitempty. Mirrors pipelines/variablegroup/list/list.go and boards/workitem/list/list.go.
6 JSON view struct fields: id, name, status, enabled, version, osDescription, pool (name only when available), maxParallelism, createdOn (string), createdBy (flattened to id, displayName, uniqueName). Same shape as the boards/list siblings.
7 Optional filter --filter (string, short form -f) maps to GetAgentsArgs.AgentName. Renames the previous --name flag to --filter to make the agent-name filtering role explicit (the positional is the pool, not the agent name). Aligns the agent-name filter with the same flag used by the security/group add/remove siblings. Short form -f is the same letter used by other --filter flags in the codebase.
8 --include-capabilities (bool) maps to GetAgentsArgs.IncludeCapabilities. Mirrors Azure CLI filter.
9 --max-items (int, default 0 = no cap) — if positive, truncate the result slice client-side. Mirrors pipelines/variablegroup/list --max-items.
10 Aliases: ls, l. Project convention from pr/list and pipelines/variablegroup/list.
11 No new SDK client, no new package beyond internal/cmd/pipelines/agent/list. Reuse shared.ResolvePool from internal/cmd/pipelines/agent/shared/resolve.go (already implemented and tested). Mandate: minimal code.
12 Mock for GetAgents is already generated at internal/mocks/taskagent_client_mock.go:531-540. Do not regenerate. Verified.

Command Signature

azdo pipelines agent list [ORGANIZATION/]POOL
  [--filter NAME]                (optional string filter, short: -f)
  [--include-capabilities]       (bool, default false)
  [--max-items MAX]              (int, default 0 = no cap)
  [--json ...]                   (JSON output: --json / --jq / --template)

Aliases: ls, l.

Positional structure (parser: util.ParseTargetWithDefaultOrganization at internal/cmd/util/scope.go:157)

The parser accepts 1 or 2 /-separated segments in the single positional. The resulting *util.Path has the following fields after a successful parse:

Field Value Notes
*Path.Organization string Required. Resolved from the first segment, or from ctx.Config().Authentication().GetDefaultOrganization() when the segment is omitted.
*Path.Project "" Always empty. This command is org-only; a 3-segment input (e.g., "org/proj/pool") is rejected by the command (after parsing) via an explicit if scope.Project != "" guard returning util.FlagErrorf("agent list does not accept a project scope; got %q", args[0]).
*Path.Targets []string{rawPoolTarget} Length 1. The single target is the raw pool identifier passed to shared.ResolvePool.

Segment-count table

Input Result
(no positional) Error"accepts 1 arg(s), received 0" (cobra-level).
"1" Organization = default org, Targets[0] = "1". Resolver fast-path: pool ID = 1.
"Default" Organization = default org, Targets[0] = "Default". Resolver calls GetAgentPools(name=...) and picks the first case-insensitive match.
"myorg/1" Organization = "myorg", Targets[0] = "1". Resolver fast-path: pool ID = 1.
"myorg/Default" Organization = "myorg", Targets[0] = "Default". Resolver calls GetAgentPools(name=...).
"myorg/proj/pool" Parser returns Organization="myorg", Project="proj", Targets[0]="pool". Command-level guard rejects with "agent list does not accept a project scope; got \"myorg/proj/pool\"".
"" Error — parser's "invalid input …" (empty input).

cobra.Args validator

cobra.ExactArgs(1). The RunE handler assigns args[0] to opts.targetArg, then run():

  1. Calls util.ParseTargetWithDefaultOrganization(ctx, opts.targetArg) to obtain the *util.Path.
  2. Guards scope.Project == "" — returns util.FlagErrorf if non-empty.
  3. Calls cmdCtx.ClientFactory().TaskAgent(ctx, scope.Organization) to obtain the SDK client.
  4. Calls shared.ResolvePool(ctx, taskClient, scope.Targets[0]) to obtain the integer poolID.
  5. Calls taskClient.GetAgents(ctx, GetAgentsArgs{PoolId: &poolID, AgentName: …, IncludeCapabilities: …}) with the resolved pool ID.
  6. Applies --max-items truncation and renders the table or JSON.

Flags

Flag Type Default Required Maps to Description
--filter, -f string "" no GetAgentsArgs.AgentName Optional name filter on the agent. Empty string → no name filter. Replaces the previous --name flag.
--include-capabilities bool false no GetAgentsArgs.IncludeCapabilities When true, the response includes each agent's capabilities.
--max-items int 0 no (client-side cap) Maximum number of agents to return. 0 = no cap; truncates the slice after the SDK call.
--json / --jq / --template no util.AddJSONFlags JSON output. --json id,name,status filters fields; --template @file accepts a Go-template file.

No --pool-id flag — the pool is now a positional target (Decision 2). For backward compatibility, run azdo pipelines agent list 1 to list agents in pool ID 1.

JSON Output Contract

type identityJSON struct {
    ID          string `json:"id,omitempty"`
    DisplayName string `json:"displayName,omitempty"`
    UniqueName  string `json:"uniqueName,omitempty"`
}

type agentJSON struct {
    ID              *int          `json:"id,omitempty"`
    Name            *string       `json:"name,omitempty"`
    Status          *string       `json:"status,omitempty"`
    Enabled         *bool         `json:"enabled,omitempty"`
    Version         *string       `json:"version,omitempty"`
    OsDescription   *string       `json:"osDescription,omitempty"`
    MaxParallelism  *int          `json:"maxParallelism,omitempty"`
    CreatedOn       *string       `json:"createdOn,omitempty"`
    CreatedBy       *identityJSON `json:"createdBy,omitempty"`
}

Table Output Contract

Default columns: ID, NAME, STATUS, ENABLED, VERSION, OS, CREATED ON (mirrors taskagent.TaskAgent core fields, sorted by ID asc).

Command Wiring

  • Package path: internal/cmd/pipelines/agent/list
  • Files:
    • list.goNewCmd(ctx util.CmdContext) *cobra.Command + listOptions + runList
    • list_test.go — table-driven gomock tests
  • Update internal/cmd/pipelines/agent/agent.go to add listcmd "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/agent/list" and cmd.AddCommand(listcmd.NewCmd(ctx)). Update the Example block to use the new positional shape (e.g., $ azdo pipelines agent list Default --filter my-agent).
  • Higher-level parents must already remain wired: pipelinesagentlist.
  • Add an import for internal/cmd/pipelines/agent/shared to call shared.ResolvePool.

API Surface

Reuse the already-vendored client and the existing shared.ResolvePool helper. No new SDK clients required.

  • shared.ResolvePool (internal/cmd/pipelines/agent/shared/resolve.go:50) → (int, error). Accepts a pool ID or name; performs the strconv.Atoi fast path and the GetAgentPools name-lookup fallback. Returns the integer pool ID.
  • taskagent.Client.GetAgentsAgents - List (REST 7.1)
  • taskagent.GetAgentsArgs struct: {PoolId *int, AgentName *string, IncludeCapabilities *bool, IncludeAssignedRequest *bool, IncludeLastCompletedRequest *bool, PropertyFilters *[]string, Demands *[]string}.

Mock for GetAgents is already generated at internal/mocks/taskagent_client_mock.go:531-540. GetAgentPools mock is already generated (used by shared.ResolvePool). No mock regeneration needed.

Reference Existing Patterns

  • internal/cmd/pipelines/agent/show/show.go:107primary sibling reference. Already uses the 1-3 segment positional [ORGANIZATION/]POOL/AGENT parsed with util.ParsePoolAgentTargetWithDefaultOrganization and resolved via shared.ResolvePoolAgent (which itself calls shared.ResolvePool). Copy the parsing flow + project-scope guard.
  • internal/cmd/pipelines/agent/shared/resolve.go:50shared.ResolvePool (Decision 2). Already implemented, tested, and ready to reuse. Tests cover numeric fast path, name lookup, not-found, and ambiguous-name error cases.
  • internal/cmd/pipelines/build/queue (planned spec feat: Implement azdo pipelines build queue command #253) — closest UX sibling. Uses shared.ResolvePipelineDefinition to fold ID/name resolution into a single positional. The agent list redesign mirrors this UX.
  • internal/cmd/boards/workitem/list/list_test.go:765-844setupFakeDeps / stub* fixture.
  • internal/mocks/taskagent_client_mock.go:531-540 — mock for GetAgents (already generated, do not regenerate).
  • internal/azdo/factory.go:133-139ClientFactory().TaskAgent(...) accessor (reuse).
  • internal/cmd/util/scope.go:157util.ParseTargetWithDefaultOrganization (org-only target parser; also used by work-item delete feat: Implement azdo boards work-item delete command #269, work-item update feat: Implement azdo boards work-item update command #270, and the security membership commands).

References

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