Skip to content

feat: Implement azdo pipelines show command #243

Description

@tmeckel

Sub-issue of #116. Hardened spec — do not re-derive decisions. Mirrors internal/cmd/pr/view/view.go and uses Go text templates via internal/template.Template (the same engine used by azdo pr view).

> No SDK client for the pipelines resource. The vendored Azure DevOps Go SDK does not expose a dedicated pipelines package — only pipelinepermissions, pipelinesapproval, pipelineschecks, and pipelinestaskcheck. The az pipelines show command surfaces pipeline definitions (YAML/UI-defined reusable pipeline definitions) and operates against the Pipelines REST API. We will use the low-level Client.CreateRequestMessage / Send plumbing exposed by internal/azdo/connection.go:25-35 (the Client interface wraps azuredevops.Connection). This is the same pattern the SDK's own ClientImpl uses internally for any endpoint that doesn't have a dedicated Go method.

Command Description

Display the details of a single Azure Pipelines definition by integer ID or name. The user supplies the [ORGANIZATION/]PROJECT/PIPELINE scope; the command resolves the target (a positive integer is used directly; a string is resolved via GetDefinitions), fetches the matching BuildDefinition via the Definitions REST 7.1 endpoint, and renders it as a Go text template. The definition's Process, Repository, AuthoredBy, and Queue are rendered with their respective child types when present.

GET https://dev.azure.com/{organization}/{project}/_apis/pipelines/definitions/{definitionId}?api-version=7.1

> Why "pipelines show" and not "pipelines definition show"? The Azure CLI's az pipelines show is a top-level command (az pipelines show --id X), not a definition subgroup. It surfaces the same BuildDefinition resource that az pipelines build definition show would, but the user-facing shape is a top-level leaf. We mirror that.

Locked Decisions (do not re-derive)

# Decision Rationale
1 No vendored SDK client for the pipelines resource exists. Use the low-level Client.CreateRequestMessage + Client.Send plumbing from internal/azdo/connection.go:25-35 (the same plumbing the SDK uses internally) to call the REST endpoint. The raw JSON response is unmarshaled into a *build.BuildDefinition model from the vendored build package (the response shape matches the BuildDefinition struct in vendor/.../build/models.go). The Azure DevOps Go SDK does not expose a dedicated pipelines package. The response is structurally identical to BuildDefinition.
2 The pipeline is identified by a positional PIPELINE argument ([ORGANIZATION/]PROJECT/PIPELINE). The target segment is resolved via a new shared.ResolvePipelineDefinition helper at internal/cmd/pipelines/show/shared/resolve.go that mirrors Python's get_definition_id_from_name: if it parses as a positive integer, use it directly; otherwise call build.Client.GetDefinitions and pick the first match. Folds the previous --id flag into the positional. Consistent with sibling leaves (#255, #257, #258). One positional for either ID or name.
2.5 Parse the positional using util.ParseProjectTargetWithDefaultOrganization from internal/cmd/util/scope.go:183. The function returns a *Target with Organization, Project, Target fields, accepting 2- or 3-segment inputs. Consistent with internal/cmd/pipelines/variablegroup/{create,delete,show,update}/ precedent.
3 util.ExactArgs(1, "pipeline target is required"). Standard cobra pattern; the 1st positional is the full target.
4 Use the Go text template engine from internal/template/template.go with an //go:embed show.tpl file. Mirror internal/cmd/pr/view/view.go and view.tpl structure: bold, hyperlink, s, timeago, timefmt, markdown, pluck, join, truncate, stripprefix, tablerow, tablerender. The user explicitly requested template rendering like pr/view.
5 Aliases: view, status. Primary name is show. cmd.Use: "show [ORGANIZATION/]PROJECT/PIPELINE", cmd.Aliases: []string{"view", "status"}. Mirrors the pr/view aliasing pattern but with show as the primary.
6 JSON output passes the unmarshaled *build.BuildDefinition to opts.exporter.Write. No view struct. Symmetric with other show siblings.
7 No confirmation prompt. Show is read-only. Show is non-destructive.
8 --raw flag dumps the full SDK definition with spew.Dump to stderr for debugging. Mirrors pr/view --raw.
9 No new SDK client, no new helper beyond shared.ResolvePipelineDefinition, no new package beyond internal/cmd/pipelines/show. The HTTP plumbing uses the existing Client interface from internal/azdo/connection.go:25-35. Mandate: minimal code.
10 The unmarshaled response is *build.BuildDefinition — we reuse the vendored build model struct rather than introducing a hand-written DTO. This means we do not need to regenerate any mocks: there is no SDK method to mock. Tests use httptest.NewServer to stub the HTTP endpoint instead. build.BuildDefinition is the canonical struct for this resource; the Azure DevOps REST API does not require a different shape.

Command Signature

azdo pipelines show [ORGANIZATION/]PROJECT/PIPELINE
  [--raw]
  [--json ...]
  • Aliases: view, status
  • Positional parsing: args[0] → target (via util.ParseProjectTargetWithDefaultOrganization).
  • The Target field of the parsed *Target is resolved via shared.ResolvePipelineDefinition(ctx, clientFact, args[0]).

Flags

Flag Maps to Notes
--raw (bool) debug dump spew.Dump of full SDK definition to stderr
--json / --jq / --template util.AddJSONFlags JSON export of unmarshaled definition

JSON Output Contract

util.AddJSONFlags(cmd, &opts.exporter, []string{
    "id", "name", "revision", "description", "path", "type", "url", "_links",
    "process", "repository", "queue", "authoredBy", "createdDate", "quality",
})

Pass the unmarshaled *build.BuildDefinition (Decision 6, Decision 10).

Template Output Contract (show.tpl)

The default template renders (see internal/cmd/pr/view/view.tpl for the established pattern):

url:           
id:            
name:          
revision:      
path:          
type:          (e.g., build)
process:       () - when present
repository:    () - when present (name and id)
queue:         () - when present
authored by:   () - when present
created on:    
description:   (rendered via markdown template func)
quality:       (e.g., definition)

Command Wiring

  • Package path: internal/cmd/pipelines/show
  • Files:
    • show.goNewCmd(ctx util.CmdContext) *cobra.Command + showOptions + runShow
    • show.tpl — Go text template
    • shared/resolve.goResolvePipelineDefinition(ctx, clientFact, raw) (int, error) (positive-int fast path + GetDefinitions first-match lookup)
    • show_test.go — table-driven tests using httptest.NewServer to stub the HTTP endpoint (no gomock — no SDK method to mock).
  • The leaf is wired directly under the existing internal/cmd/pipelines/pipelines.go (the umbrella), via cmd.AddCommand(show.NewCmd(ctx)). No new subgroup package is required.
  • Existing higher-level parents must already remain wired: pipelinesshow.

API Surface

No new SDK clients required. Use the low-level Client interface from internal/azdo/connection.go:25-35.

  • REST endpoint: GET https://dev.azure.com/{organization}/{project}/_apis/pipelines/definitions/{definitionId}?api-version=7.1Definitions - Get (REST 7.1)
  • Response model: *build.BuildDefinition (vendored in vendor/.../build/models.go).
  • HTTP plumbing: client.CreateRequestMessage(ctx, "GET", route, "7.1", nil, "", "application/json", nil)client.Send(ctx, ...)client.UnmarshalBody(resp, &definition).
  • API version: 7.1.
  • Resource location ID: discovered from Connection.GetResourceAreas at startup; for pipelines (resource area 5d6898bb-45ec-463f-95f9-54d49c71752e) the location ID is //apis/ResourceAreas/5d6898bb-45ec-463f-95f9-54d49c71752e (verify in vendor/.../core/client.go or internal/azdo/extensions/ if not found).
  • build.Client.GetDefinitions is used for name resolution: args.Project + args.Name; pick first *BuildDefinitionReference from the response Value slice; error on zero or ambiguous matches.

Template engine: internal/template.Template with bold, hyperlink, s, timeago, timefmt, markdown, pluck, join, truncate, stripprefix, tablerow, tablerender.

> Mock strategy: Because there is no SDK method to mock, tests use httptest.NewServer to intercept the HTTP request and return a canned JSON body. The test must construct a Client whose Send method calls the test server. Reuse the pattern from internal/cmd/extensions/ if it exists; otherwise use httptest.NewServer + a Client adapter that rewrites the base URL. The GetDefinitions call used during name resolution IS mocked via build.Client gomock (mock at internal/mocks/build_client_mock.go:837).

Implementation Approach (TDD, reuse-first, minimal)

Phase 1 — RED (tests first). Add show_test.go with the following table-driven / behaviour tests, all using t.Parallel() and httptest:

  • TestNewCmd_RegistersAsShowLeaf — asserts cmd.Name() == "show", cmd.Aliases contains view and status, cmd.Use starts with show [ORGANIZATION/]PROJECT/PIPELINE.
  • TestNewCmd_RequiresOneArg — runs cmd.SetArgs([]string{}) + cmd.Execute(); asserts cobra ExactArgs error.
  • TestRunShow_ResolveByPositiveInteger — sets Fabrikam/42; asserts shared.ResolvePipelineDefinition returns 42 without calling GetDefinitions.
  • TestRunShow_ResolveByName — sets Fabrikam/My Pipeline; stubs build.EXPECT().GetDefinitions(...); asserts HTTP request URL path contains _apis/pipelines/definitions/{resolvedId}.
  • TestRunShow_NameNotFound — stubs GetDefinitions returning empty; asserts util.FlagErrorf (or wrapped error).
  • TestRunShow_NameAmbiguous — stubs GetDefinitions returning 2 matches; asserts error.
  • TestRunShow_TemplateOutput_BasicFields — httptest server returns full BuildDefinition JSON; asserts rendered output contains all field labels and values.
  • TestRunShow_TemplateOutput_Hyperlink — asserts the url: line uses ANSI hyperlink escape sequence.
  • TestRunShow_TemplateOutput_DescriptionMarkdown — httptest returns Description: "<p>Hello</p>"; asserts the rendered output contains the markdown-converted text.
  • TestRunShow_TemplateOutput_NoDescription — httptest returns no description; asserts the description: block shows None given (or is omitted — see sibling boards show for canonical behavior).
  • TestRunShow_TemplateOutput_ProcessAndRepository_Nested — httptest returns Process: {Type: "yaml"} and Repository: {Name, Id}; asserts both rendered.
  • TestRunShow_JSONOutput — sets --json; httptest returns definition; asserts JSON contains id, name, revision, description, path, type, url, process, repository, queue, authoredBy, createdDate, quality.
  • TestRunShow_RawFlag — sets --raw; asserts spew.Dump was invoked.
  • TestRunShow_ProjectScopeParsing — table-driven: myorg/Fabrikam/42, Fabrikam/42, invalid ("org/proj/extra/x"), empty.
  • TestRunShow_InvalidProjectScope — asserts util.FlagErrorWrap returned.
  • TestRunShow_ClientFactoryError — stubs factory to return error; asserts wrapped error.
  • TestRunShow_HTTPError — httptest returns 500; asserts wrapped error.
  • TestRunShow_HTTPNotFound — httptest returns 404; asserts wrapped error (with 404 status surfaced).
  • TestRunShow_OrganizationFromConfigDefault — when scopeArg is Fabrikam/42 (no org), asserts the HTTP request includes the configured default organization URL.

Phase 2 — GREEN (minimal implementation). Strict reuse rules:

  • No new helpers beyond the inline shared.ResolvePipelineDefinition(ctx, clientFact, raw) (int, error) (~30 lines): positive-int fast path + build.Client.GetDefinitions first-match lookup; error on zero/ambiguous matches.
  • Reuse util.ParseProjectTargetWithDefaultOrganization, util.AddJSONFlags, util.FlagErrorf/FlagErrorWrap, types.GetValue, types.ToPtr, ios.StartProgressIndicator/StopProgressIndicator, iostreams.Test as-is.
  • Reuse internal/template.New(...).WithFuncs(...).Parse(show.tpl).ExecuteData(data) — exact same pattern as internal/cmd/pr/view/view.go:483-549.
  • Progress indicator: ios.StartProgressIndicator() + defer ios.StopProgressIndicator(); call ios.StopProgressIndicator() immediately before template execution.
  • Output split: JSON via opts.exporter.Write(ios, res) passing the unmarshaled *build.BuildDefinition; template via template.New(...).ExecuteData(templateData{Definition: res}).
  • Debug log at the point of the HTTP call: organization, project, pipelineId.
  • Wiring: in internal/cmd/pipelines/pipelines.go, add show "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/show" to imports and cmd.AddCommand(show.NewCmd(ctx)) to the existing NewCmd.

Target delta: show.go ≤ ~150 LOC (slightly larger than the SDK-cmd siblings due to manual HTTP plumbing), show.tpl ≤ ~50 LOC, shared/resolve.go ≤ ~30 LOC, show_test.go ≤ ~450 LOC (21 tests), internal/cmd/pipelines/pipelines.go +3 LOC, docs/pipelines_show.md regenerated via make docs. No changes to existing subgroup packages (build, runs, variablegroup, pool, queue).

Tooling and Verification Checklist

  • Run gofmt / gofumpt on touched files
  • go test ./internal/cmd/pipelines/...
  • go test ./...
  • make lint
  • make docs

Reference Existing Patterns

  • internal/cmd/pr/view/view.goprimary template-engine reference (Primary: view.go:483-549; viewOptions struct at view.go:23-31; template embed at view.go:33-34).
  • internal/cmd/pr/view/view.tplprimary template-file reference (45 lines; same field-bullet style).
  • internal/cmd/extensions/primary HTTP-plumbing reference (if it exists; otherwise use the pattern in internal/azdo/extensions/ for raw HTTP calls via the Client interface).
  • internal/cmd/boards/workitem/show/show.go (sibling under feat: Implement azdo boards work-item show command #238) — copy structure for the show flow, raw-SDK JSON output, progress lifecycle.
  • internal/cmd/boards/workitem/list/list_test.go:765-844setupFakeDeps / stub* fixture; copy structure (but use httptest instead of gomock).
  • internal/azdo/connection.go:25-35Client interface (the low-level HTTP plumbing).
  • internal/template/template.go — template engine + funcs (reuse, do not reimplement).
  • internal/cmd/pipelines/variablegroup/delete/delete.goprimary target-resolution precedent (Decision 2 / 2.5): uses Use: "delete [ORGANIZATION/]PROJECT/GROUP", util.ExactArgs(1, "..."), util.ParseProjectTargetWithDefaultOrganization, and a shared.ResolveVariableGroup helper.
  • internal/cmd/util/scope.go:183util.ParseProjectTargetWithDefaultOrganization (the parser to call on args[0]).
  • internal/mocks/build_client_mock.go:837 — mock for GetDefinitions (used for name → ID resolution).

References

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions