Skip to content

feat: Implement azdo pipelines runs show command #242

Description

@tmeckel

Sub-issue of #214. Sibling of #212 (list). 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).

> Why this is structurally identical to build show (#TBD build show): The Azure DevOps REST API does not differentiate "builds" from "runs" — they are the same Build resource. The runs subgroup exposes the same data through azdo pipelines runs show to match the Azure CLI's user-facing split. Internally both build show and runs show call build.Client.GetBuild. The default table columns and template differ slightly (modern Pipelines API surface), but the implementation structure is identical. The body below is co-written with build show and explicitly references it.

Command Description

Display the details of a single Azure Pipelines run by integer ID. The command fetches the matching Build via the Builds REST 7.1 endpoint (same call as build show) and renders it as a Go text template, with default columns tailored to the modern Pipelines API surface.

GET https://dev.azure.com/{organization}/{project}/_apis/build/builds/{buildId}?api-version=7.1

Locked Decisions (do not re-derive)

# Decision Rationale
1 Use the vendored SDK build.Client.GetBuild (not raw HTTP). Same SDK call as build show. Mock shared at internal/mocks/build_client_mock.go:387-402. The Azure DevOps REST API does not differentiate "builds" from "runs". Both runs show and build show call GetBuild.
2 The run is identified by an integer positional ID (e.g. azdo pipelines runs show Fabrikam 12345). The --id flag is not offered. Mirrors az pipelines runs show ergonomics. The ID is a single integer — clean as a positional.
3 ID is required. Non-numeric or zero / negative values rejected with util.FlagErrorf before the SDK call. Cheap pre-flight validation; REST 4xx is unhelpful.
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 RUN_ID", cmd.Aliases: []string{"view", "status"}. Mirrors the pr/view aliasing pattern but with show as the primary. RUN_ID (instead of BUILD_ID) reflects the user-facing language of the modern Pipelines API.
6 JSON output passes the raw SDK *build.Build to opts.exporter.Write. No view struct. Symmetric with #212.
7 No confirmation prompt. Show is read-only. Show is non-destructive.
8 --raw flag dumps the full SDK build with spew.Dump to stderr for debugging. Mirrors pr/view --raw.
9 No new SDK client, no new helper, no new package beyond internal/cmd/pipelines/runs/show. Reuse SDK call from the vendored build package. Mandate: minimal code.
10 Mock for GetBuild is already generated at internal/mocks/build_client_mock.go:387-402 (shared with build show). Do not regenerate. Verified.
11 Implementation duplication with build show is intentional and minimal. The only divergence is the table column labels (PIPELINE vs DEFINITION) and the RUN_ID positional name in the Use: string. The show.tpl, JSON field list, and SDK call are byte-identical. If future divergence grows, extract a shared internal/cmd/pipelines/build/show helper. Mirrors the split between build list (#211) and runs list (#212) — both already share GetBuilds SDK call.

Command Signature

azdo pipelines runs show [ORGANIZATION/]PROJECT RUN_ID
  • Aliases: view, status
  • cobra.ExactArgs(2)util.ExactArgs(2, "project and run id required").
  • Positional parsing: args[0] → project scope (via util.ParseProjectScope); args[1] → integer RUN_ID (parsed in RunE).
  • RUN_ID is the second positional so azdo pipelines runs show 12345 is rejected.

Flags

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

JSON Output Contract

util.AddJSONFlags(cmd, &opts.exporter, []string{
    "id", "buildNumber", "status", "result", "queueTime", "startTime", "finishTime", "url",
    "definition", "queue", "requestedBy", "requestedFor", "lastChangedBy", "sourceVersion",
    "sourceBranch", "reason", "priority", "tags", "parameters", "triggerInfo", "retainedByRelease",
})

Pass the raw *build.Build returned by the SDK (Decision 6). Identical to build show.

Template Output Contract (show.tpl)

The default template renders (see internal/cmd/pr/view/view.tpl for the established pattern; byte-identical to build show's show.tpl):

url:           
id:            
build number:  
status:        (e.g., completed, inProgress)
result:        (e.g., succeeded, failed, canceled) - when status is completed
reason:        (e.g., manual, individualCI, schedule)
definition:    () - when present
queue:         () - when present
source branch: 
source version:  (short SHA, 8 chars when available)
requested by:   () - when present
requested for:  () - when present
priority:      
queue time:     ()
start time:     () - when started
finish time:    () - when finished
duration:        (e.g., "2m 13s") - when started and finished
tags:          ; ; ...  (if non-empty)

The template uses definition: (not pipeline:) because the SDK model field is Definition. The label is consistent with the raw SDK output.

Command Wiring

  • Package path: internal/cmd/pipelines/runs/show
  • Files:
    • show.goNewCmd(ctx util.CmdContext) *cobra.Command + showOptions + runShow
    • show.tpl — Go text template (byte-identical to build/show/show.tpl initially; consider extracting to a shared internal/cmd/pipelines/shared/show.tpl only if divergence appears)
    • show_test.go — table-driven gomock tests (mirror build/show/show_test.go; only the Use: string assertions and table-column labels differ)
  • Update internal/cmd/pipelines/runs/runs.go to add showcmd "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/runs/show" and cmd.AddCommand(showcmd.NewCmd(ctx)). Update the Example block.
  • Existing higher-level parents must already remain wired: pipelinesrunsshow.

API Surface

Reuse the already-vendored client. No new SDK clients required.

  • build.Client.GetBuildBuilds - Get (REST 7.1) (same call as build show)
  • build.GetBuildArgs struct: {Project *string, BuildId *int}.
  • Template engine: internal/template.Template with bold, hyperlink, s, timeago, timefmt, markdown, pluck, join, truncate, stripprefix, tablerow, tablerender.

Mock for GetBuild is already generated at internal/mocks/build_client_mock.go:387-402 (shared with build show). No mock regeneration needed.

Implementation Approach (TDD, reuse-first, minimal)

Phase 1 — RED (tests first). Mirror setupFakeDeps from internal/cmd/boards/workitem/list/list_test.go:765-844. Add show_test.go with the following table-driven / behaviour tests, all using t.Parallel() and gomock:

  • TestNewCmd_RegistersAsShowLeaf — asserts cmd.Name() == "show", cmd.Aliases contains view and status, cmd.Use starts with show [ORGANIZATION/]PROJECT RUN_ID.
  • TestNewCmd_RequiresTwoArgs — runs with []string{"Fabrikam"}; asserts cobra.ExactArgs(2) triggers "project and run id required".
  • TestRunShow_RunIDMustBeInteger — sets RUN_ID "abc"; asserts util.FlagErrorf returned.
  • TestRunShow_RunIDMustBePositive — sets RUN_ID 0 and -1; asserts util.FlagErrorf returned.
  • TestRunShow_BasicCall — sets [Fabrikam, 12345]; stubs build.EXPECT().GetBuild(gomock.Any(), gomock.Any()); asserts *args.BuildId == 12345, *args.Project == "Fabrikam".
  • TestRunShow_TemplateOutput_BasicFields — mocks return *Build{Id, BuildNumber, Status, Result, SourceBranch, SourceVersion, Url}; asserts rendered output contains all field labels and values.
  • TestRunShow_TemplateOutput_Hyperlink — asserts the url: line uses ANSI hyperlink escape sequence.
  • TestRunShow_TemplateOutput_DurationFormatted — mocks return StartTime: 2024-01-01T12:00:00Z, FinishTime: 2024-01-01T12:02:13Z; asserts duration: 2m 13s rendered.
  • TestRunShow_TemplateOutput_NoDuration_NotStarted — mocks return FinishTime: nil; asserts duration: line is omitted.
  • TestRunShow_TemplateOutput_Tags — mocks return Tags: []string{"release", "nightly"}; asserts both tags rendered.
  • TestRunShow_TemplateOutput_NoTags — mocks return no tags; asserts tags: line is omitted.
  • TestRunShow_TemplateOutput_DefinitionAndQueue_Nested — mocks return Definition: &BuildDefinition{Name, Id} and Queue: &Pool{Name, Id}; asserts both rendered.
  • TestRunShow_TemplateOutput_ResultOnlyWhenCompleted — mocks return Status: "inProgress", Result: ""; asserts result: line is omitted.
  • TestRunShow_TemplateOutput_ResultShownWhenCompleted — mocks return Status: "completed", Result: "succeeded"; asserts result: succeeded rendered.
  • TestRunShow_JSONOutput — sets --json; mocks return build; asserts JSON contains id, buildNumber, status, result, url, sourceBranch, sourceVersion, definition, queue, requestedBy, tags.
  • TestRunShow_RawFlag — sets --raw; asserts spew.Dump was invoked.
  • TestRunShow_ProjectScopeParsing — table-driven: myorg/Fabrikam, Fabrikam, invalid ("org/proj/extra"), empty.
  • TestRunShow_InvalidProjectScope — asserts util.FlagErrorWrap returned.
  • TestRunShow_ClientFactoryError — stubs factory to return error; asserts wrapped error.
  • TestRunShow_SDKError — stubs SDK to return error; asserts wrapped error.
  • TestRunShow_OrganizationFromConfigDefault — when scopeArg is Fabrikam (no org), asserts clientFact.Build(ctx, defaultOrg) is called with the configured default.

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

  • No new helpers beyond the inline parseRunID(raw string) (int, error) (~5 lines) for the integer-parse + positive check, and formatDuration(start, finish *time.Time) string (~10 lines) for the human-readable duration. Consider sharing formatDuration with build/show via internal/cmd/pipelines/build/show/shared if it stabilizes (do not pre-extract).
  • Reuse util.ParseProjectScope, util.AddJSONFlags, util.FlagErrorf/FlagErrorWrap, util.ExactArgs, 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 raw SDK *Build; template via template.New(...).ExecuteData(templateData{Build: res}).
  • Debug log at the point of the SDK call: organization, project, runId.

Target delta: show.go ≤ ~120 LOC, show.tpl ≤ ~50 LOC, show_test.go ≤ ~400 LOC (22 tests), parent runs.go +3 LOC, docs/pipelines_runs_show.md regenerated via make docs. No changes to list.go or other runs siblings.

Tooling and Verification Checklist

  • Run gofmt / gofumpt on touched files
  • go test ./internal/cmd/pipelines/runs/...
  • 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/pipelines/build/show/show.go (sibling under the build show sub-issue filed in the same wave) — closest sibling; mirror the entire file structure. The only differences are: (a) the Use: string (show [ORGANIZATION/]PROJECT RUN_ID vs BUILD_ID); (b) the table column PIPELINE vs DEFINITION; (c) the parseRunID vs parseBuildID helper name.
  • 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/pipelines/runs/list/list.go (sibling under feat: Implement azdo pipelines runs list command #212) — closest sibling; reuse JSON/table split pattern, default columns for --format table.
  • internal/cmd/boards/workitem/list/list_test.go:765-844setupFakeDeps / stub* fixture; copy structure.
  • internal/mocks/build_client_mock.go:387-402 — mock for GetBuild (already generated, do not regenerate).
  • internal/azdo/factory.go:61-67ClientFactory().Build(...) accessor (reuse).
  • internal/template/template.go — template engine + funcs (reuse, do not reimplement).

References

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions