Skip to content

Implement azdo boards work-item relation show command #275

@tmeckel

Description

@tmeckel

Sub-issue of new boards work-item relation umbrella (#271). Hardened spec — do not re-derive decisions. Sibling of #272 (add), #273 (list-type), #274 (remove).

Command Description

List all relations of a single Azure Boards work item. Mirrors az boards work-item relation show. The command fetches the work item with expand='All' and replaces each relation's referenceName with its friendly name, then renders the result as a table or JSON.

The REST surface is Work Items - Get (REST 7.1):

GET https://dev.azure.com/{organization}/_apis/wit/workitems/{id}?$expand=All&api-version=7.1-preview.3

The Python reference implementation is at azure-devops/azext_devops/dev/boards/relations.py:103-106 (show_work_item function) and the table transformer is at azure-devops/azext_devops/dev/boards/_format.py:14-24 (transform_work_item_relations).

The function simply re-uses get_work_item(id, expand='All') and the friendly-name fill (also used by add and remove). The show command is therefore a thin read wrapper.

Locked Decisions

# Decision Rationale
1 Org-scoped target. Use: "show [ORGANIZATION/]ID". The work item ID is unique within the organization. Mirrors az boards work-item relation show. Symmetric with #272, #273, #274.
2 Aliases: []string{"s"}. Per AGENTS.md show command convention.
3 cobra.ExactArgs(1). One positional target: the work item ID.
4 Parse the single positional with util.ParseTargetWithDefaultOrganization(ctx, args[0]). scope.Targets[0] is the ID string. Existing helper. Symmetric with #272, #273, #274.
5 No --project flag. The endpoint is org-scoped. Matches az boards work-item relation show.
6 ID must parse as a positive integer. Symmetric with #272 Decision 6, #273 Decision 6, #274 Decision 6, #269 Decision 14, #270 Decision 6.
7 JSON output passes the raw SDK *WorkItem to opts.exporter.Write. Do not introduce a view struct. Mirrors #272 (add), #274 (remove).
8 Default output: table with columns TYPE, URL. The TYPE cell is the relation type's friendly name (not referenceName) — friendly-name resolution is performed by the shared helper internal/cmd/boards/workitem/relation/shared/relation.go (introduced by #271). Mirrors Python's transform_work_item_relations (_format.py:14-24).
9 Empty relations (work item has relations = nil or empty) renders as an empty table — no error. Matches Python: if result['relations'] is None: return [].
10 No --expand flag. All responses use expand='All' internally. The user gets the full work item with relations populated. Mirrors #272 Decision 12, #274 Decision 19.
11 No --relation-type filter. The Python always lists all relations; filtering is the caller's job. Mirrors Python.
12 Uses vendored workitemtracking.Client.{GetWorkItem, GetRelationTypes}. Vendor verified at vendor/.../v7/workitemtracking/client.go:137, 113.
13 No new mocks needed. Already generated.
14 No go mod tidy / go mod vendor / scripts/generate_mocks.sh work.
15 No confirmation prompt. The operation is read-only. Mirrors Python (no confirmation).
16 No progress indicator for a single read operation. The start/stop is still called for symmetry with siblings. Mirrors siblings.
17 No friendly-name failure is fatal. If a relation's Rel is a referenceName that is not in the list returned by GetRelationTypes (e.g., the type was disabled in the org), the raw referenceName is shown. Defensive: matches Python's behavior (fill_friendly_name_for_relations_in_work_item does not error on missing mappings).

Command Signature

var showCmd = &cobra.Command{
    Use:     "show [ORGANIZATION/]ID",
    Aliases: []string{"s"},
    Short:   "Show relations of a work item.",
    Long: heredoc.Doc(`
        List all relations of a single work item. Each row in the table
        shows the relation type (friendly name) and the target URL.
    `),
    Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        return runShow(cmd.Context(), opts, args[0])
    },
}

Flags

Flag Notes
--id not a flag — positional [ORGANIZATION/]ID.
--organization Default org from config.

Also add util.AddJSONFlags(cmd, &opts.exporter, []string{"id", "rev", "fields", "url", "_links", "relations", "commentVersionRef"}).

Code Skeleton (canonical, copy verbatim)

§1. showOptions struct

type showOptions struct {
    targetArg string

    exporter util.Exporter
}

§2. runShow skeleton

func runShow(cmdCtx util.CmdContext, opts *showOptions, targetArg string) error {
    ios, err := cmdCtx.IOStreams()
    if err != nil { return err }
    ios.StartProgressIndicator()
    defer ios.StopProgressIndicator()

    scope, err := util.ParseTargetWithDefaultOrganization(cmdCtx, targetArg)
    if err != nil { return util.FlagErrorWrap(err) }

    id, err := strconv.Atoi(scope.Targets[0])
    if err != nil || id <= 0 {
        return util.FlagErrorf("work item ID must be a positive integer; got %q", scope.Targets[0])
    }

    wit, err := cmdCtx.ClientFactory().WorkItemTracking(cmdCtx.Context(), scope.Organization)
    if err != nil { return err }

    expand := workitemtracking.WorkItemExpandValues.All
    populated, err := wit.GetWorkItem(cmdCtx.Context(), workitemtracking.GetWorkItemArgs{
        Id:     &id,
        Expand: &expand,
    })
    if err != nil { return err }

    // Populate friendly relation-type names (best effort; never fatal).
    _ = shared.PopulateFriendlyNames(cmdCtx.Context(), wit, populated)

    if opts.exporter != nil {
        return opts.exporter.Write(ios, populated)
    }
    tp, err := cmdCtx.Printer("list")
    if err != nil { return err }
    tp.AddColumns("TYPE", "URL")
    if populated.Relations != nil {
        for _, rel := range *populated.Relations {
            tp.AddField(types.GetValue(rel.Rel, ""))
            tp.AddField(types.GetValue(rel.Url, ""))
            tp.EndRow()
        }
    }
    return tp.Render()
}

shared.PopulateFriendlyNames mutates populated.Relations in place, replacing each Rel (referenceName) with the friendly Name from GetRelationTypes. It is a best-effort helper introduced by #271 and shared by all three read/write leaves.

JSON Output Contract

Pass the raw *workitemtracking.WorkItem (post GetWorkItem(expand=All)) to opts.exporter.Write. No view struct.

JSON fields exposed: id, rev, fields, url, _links, relations, commentVersionRef.

Table Output Contract

Use ctx.Printer("list") with 2 columns: TYPE, URL. Same as #272 (add) and #274 (remove).

Command Wiring

  1. Create internal/cmd/boards/workitem/relation/show/show.go with package show, factory func NewCmd(ctx util.CmdContext) *cobra.Command.
  2. Add import "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/relation/show" to internal/cmd/boards/workitem/relation/relation.go.
  3. Register in relation.go with cmd.AddCommand(show.NewCmd(ctx)).
  4. Reuse internal/cmd/boards/workitem/relation/shared/relation.go (introduced by Introduce azdo boards work-item relation command group #271) — shared.PopulateFriendlyNames.

API Surface

Vendored SDK calls (from vendor/.../v7/workitemtracking/client.go):

// 1. Fetch the work item with expand=All
expand := workitemtracking.WorkItemExpandValues.All
populated, _ := wit.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{Id: &id, Expand: &expand})

// 2. (Inside shared.PopulateFriendlyNames) Resolve referenceNames
relTypes, _ := wit.GetRelationTypes(ctx, workitemtracking.GetRelationTypesArgs{})

Reference Existing Patterns

Reference implementations (for UX parity only):

  • AzDO Extension azure-devops/azext_devops/dev/boards/relations.py:103-106 - show_work_item function. Confirms: org-scoped, expand='All', friendly-name fill.
  • AzDO Extension azure-devops/azext_devops/dev/boards/_format.py:14-24 - transform_work_item_relations confirms table columns Relation Type, URL.
  • AzDO MCP Server (TypeScript): no dedicated show-relations tool. The expand: relations parameter on the read tool is the closest equivalent.

TDD Test Plan

Test name Scenario Expected
TestNewCmd_show Inspect the command struct. Use == "show [ORGANIZATION/]ID", Aliases == ["s"], Args == cobra.ExactArgs(1).
Test_runShow_minimal Mock returns a work item with 2 relations. Table has 2 rows; TYPE cells are the friendly names.
Test_runShow_noRelations Mock returns a work item with relations = nil. Table has 0 rows; no error.
Test_runShow_invalidSourceID Positional is "abc". Returns util.FlagErrorf with "work item ID must be a positive integer". SDK is not called.
Test_runShow_zeroSourceID Positional is "0". Returns util.FlagErrorf. SDK is not called.
Test_runShow_negativeSourceID Positional is "-5". Returns util.FlagErrorf. SDK is not called.
Test_runShow_orgScopeOnly Positional is "1234". args.Id == ptr(1234), scope.Organization is the default.
Test_runShow_explicitOrg Positional is "myorg/1234". args.Id == ptr(1234), scope.Organization is "myorg".
Test_runShow_APIError Mock GetWorkItem returns errors.New("boom"). Returns wrapped error.
Test_runShow_success_JSON --json flag set. Exporter receives the raw *WorkItem.
Test_runShow_tableOutput Default output, mock returns a work item with 2 relations. Table has 2 rows; TYPE cells match the friendly names returned by GetRelationTypes.
Test_runShow_unknownRelationType A relation has Rel = "Custom.Unmapped". TYPE cell renders "Custom.Unmapped" (raw referenceName); no error.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions