Skip to content

Implement azdo boards work-item relation list-type command #273

@tmeckel

Description

@tmeckel

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

Command Description

List the relation (link) types supported in the Azure DevOps organization, together with each type's referenceName and attributes (e.g., enabled, usage, isDirectory). Mirrors az boards work-item relation list-type.

The REST surface is Relation Types - Get (REST 7.1):

GET https://dev.azure.com/{organization}/_apis/wit/workitemrelationtypes?api-version=7.1-preview.2

The Python reference implementation is at azure-devops/azext_devops/dev/boards/relations.py:16-19 (get_relation_types_show function) and the table transformer is at azure-devops/azext_devops/dev/boards/_format.py:7-19 (transform_work_item_relation_type_table_output).

The Python's table transformer renders 4 columns: Name, ReferenceName, Enabled, Usage. The attributes object can have additional fields like isDirectory and resourceSubType; we surface only the documented four to keep the table narrow, but include all of attributes in the JSON output.

Locked Decisions

# Decision Rationale
1 Org-scoped. Use: "list-type [ORGANIZATION]". No positional ID — this is a list of static metadata, not tied to a work item. Mirrors az boards work-item relation list-type.
2 Aliases: []string{"lt", "list-types"}. Per AGENTS.md list command convention (the lt short form is preferred for clarity here).
3 cobra.MaximumNArgs(1). Accepts 0 or 1 positional: an optional explicit organization. Mirrors az boards work-item relation list-type (no required ID).
4 Parse the optional positional with util.ParseTargetWithDefaultOrganization(ctx, args[0]) when 1 arg is given; otherwise resolve the default org from cmdCtx. Existing helper handles 1- and 2-segment forms; 0 args means use the default org.
5 No --project flag. The endpoint is org-scoped. Matches az boards work-item relation list-type (no project flag).
6 Uses vendored workitemtracking.Client.GetRelationTypes. Vendor verified at vendor/.../v7/workitemtracking/client.go:113 (interface), :1854 (impl).
7 JSON output uses a dedicated view struct with omitempty pointer fields. The struct flattens the Attributes map into Enabled, Usage, IsDirectory, ResourceSubType (each *string with omitempty). Mirrors #249 (variablegroup list) conventions. List commands use a view struct, not the raw SDK type.
8 Default output: table with columns NAME, REFERENCE NAME, ENABLED, USAGE (4 columns, matching the Python's transform_work_item_relation_type_table_output). Mirrors Python.
9 Stable alphabetical sort by Name (case-insensitive) in the default table view. JSON output is unsorted. Determinism. The Python's transform_work_item_relation_type_table_output does not sort, but azdo table output is sorted by the first column for stability.
10 --max-items flag with the standard semantics from internal/cmd/util/max_items.go (introduced by #249). Caps the row count after sort. Mirrors variablegroup/list/list.go.
11 --json, --jq, --template via util.AddJSONFlags(cmd, &opts.exporter, []string{"name", "referenceName", "enabled", "usage", "isDirectory", "resourceSubType", "attributes", "url", "_links"}). Every JSON field of the view struct.
12 No pagination. GetRelationTypes returns the full list (no continuation token). SDK confirmed at client.go:1854 — no ContinuationToken in the response.
13 No new mocks needed. internal/mocks/workitemtracking_client_mock.go:669 already mocks GetRelationTypes.
14 No go mod tidy / go mod vendor / scripts/generate_mocks.sh work.
15 No progress indicator for trivial list operations — match the pattern from variablegroup/list/list.go. Decision is consistent with sibling list commands.

Command Signature

var listTypeCmd = &cobra.Command{
    Use:     "list-type [ORGANIZATION]",
    Aliases: []string{"lt", "list-types"},
    Short:   "List work item relation types.",
    Long: heredoc.Doc(`
        List the relation (link) types supported in the Azure DevOps
        organization. Each entry includes a friendly name and its
        reference name; use the friendly name with 'add' and 'remove'.
    `),
    Args: cobra.MaximumNArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        opts.orgArg = ""
        if len(args) == 1 {
            opts.orgArg = args[0]
        }
        return runListType(cmd.Context(), opts)
    },
}

Flags

Flag Notes
--organization Default org from config.
--max-items Caps row count after sort.

Also add util.AddJSONFlags(cmd, &opts.exporter, []string{"name", "referenceName", "enabled", "usage", "isDirectory", "resourceSubType", "attributes", "url", "_links"}).

Code Skeleton (canonical, copy verbatim)

§1. listTypeOptions struct and relationTypeView view struct

type listTypeOptions struct {
    orgArg   string
    maxItems int
    exporter util.Exporter
}

type relationTypeView struct {
    Name           *string                `json:"name,omitempty"`
    ReferenceName  *string                `json:"referenceName,omitempty"`
    Enabled        *string                `json:"enabled,omitempty"`
    Usage          *string                `json:"usage,omitempty"`
    IsDirectory    *string                `json:"isDirectory,omitempty"`
    ResourceSubType *string               `json:"resourceSubType,omitempty"`
    Attributes     *map[string]any        `json:"attributes,omitempty"`
    Url            *string                `json:"url,omitempty"`
    Links          any                    `json:"_links,omitempty"`
}

§2. View mapping helper

func mapRelationType(rt *workitemtracking.WorkItemRelationType) relationTypeView {
    v := relationTypeView{
        Name:          rt.Name,
        ReferenceName: rt.ReferenceName,
        Url:           rt.Url,
        Links:         rt.Links,
    }
    if rt.Attributes != nil {
        if a, ok := (*rt.Attributes)["enabled"]; ok { v.Enabled = attrString(a) }
        if a, ok := (*rt.Attributes)["usage"]; ok { v.Usage = attrString(a) }
        if a, ok := (*rt.Attributes)["isDirectory"]; ok { v.IsDirectory = attrString(a) }
        if a, ok := (*rt.Attributes)["resourceSubType"]; ok { v.ResourceSubType = attrString(a) }
        if a, ok := (*rt.Attributes)["resourceSubType"]; ok { v.ResourceSubType = attrString(a) }
        // Always include the full attributes map for parity with the JSON contract.
        attrs := make(map[string]any, len(*rt.Attributes))
        for k, v := range *rt.Attributes { attrs[k] = v }
        v.Attributes = &attrs
    }
    return v
}

func attrString(a any) *string {
    if b, ok := a.(bool); ok {
        return types.ToPtr(strconv.FormatBool(b))
    }
    if s, ok := a.(string); ok {
        return &s
    }
    if i, ok := a.(int); ok {
        return types.ToPtr(strconv.Itoa(i))
    }
    return nil
}

§3. runListType skeleton

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

    org := ""
    if opts.orgArg != "" {
        scope, err := util.ParseTargetWithDefaultOrganization(cmdCtx, opts.orgArg)
        if err != nil { return util.FlagErrorWrap(err) }
        org = scope.Organization
    } else {
        org = cmdCtx.Config().DefaultOrganization()
    }

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

    types, err := wit.GetRelationTypes(cmdCtx.Context(), workitemtracking.GetRelationTypesArgs{})
    if err != nil { return err }

    // Stable sort by Name (case-insensitive).
    sort.SliceStable(*types, func(i, j int) bool {
        return strings.ToLower(types.GetValue((*types)[i].Name, "")) <
            strings.ToLower(types.GetValue((*types)[j].Name, ""))
    })

    if opts.maxItems > 0 && len(*types) > opts.maxItems {
        truncated := (*types)[:opts.maxItems]
        types = &truncated
    }

    if opts.exporter != nil {
        views := types.MapSlicePtr(*types, mapRelationType) // types.MapSlicePtr is the project's generic slice mapper
        return opts.exporter.Write(ios, views)
    }
    tp, err := cmdCtx.Printer("list")
    if err != nil { return err }
    tp.AddColumns("NAME", "REFERENCE NAME", "ENABLED", "USAGE")
    for _, rt := range *types {
        tp.AddField(types.GetValue(rt.Name, ""))
        tp.AddField(types.GetValue(rt.ReferenceName, ""))
        enabled := ""
        if rt.Attributes != nil {
            if a, ok := (*rt.Attributes)["enabled"]; ok { enabled = types.GetValue(attrString(a), "") }
        }
        tp.AddField(enabled)
        usage := ""
        if rt.Attributes != nil {
            if a, ok := (*rt.Attributes)["usage"]; ok { usage = types.GetValue(attrString(a), "") }
        }
        tp.AddField(usage)
        tp.EndRow()
    }
    return tp.Render()
}

JSON Output Contract

Pass a []relationTypeView (one entry per relation type, in the unsorted order returned by the SDK) to opts.exporter.Write. The view struct uses omitempty so absent attributes do not appear in the payload.

JSON fields exposed: name, referenceName, enabled, usage, isDirectory, resourceSubType, attributes, url, _links.

Table Output Contract

Use ctx.Printer("list") with 4 columns: NAME, REFERENCE NAME, ENABLED, USAGE. Rows are sorted by Name (case-insensitive) and capped at --max-items if set.

Command Wiring

  1. Create internal/cmd/boards/workitem/relation/listtype/listtype.go with package listtype, factory func NewCmd(ctx util.CmdContext) *cobra.Command.
  2. Add import "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/relation/listtype" to internal/cmd/boards/workitem/relation/relation.go.
  3. Register in relation.go with cmd.AddCommand(listtype.NewCmd(ctx)).
  4. The directory name is listtype (single word) to match the Use: "list-type" form (Go package directories cannot contain hyphens).

API Surface

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

types, err := wit.GetRelationTypes(ctx, workitemtracking.GetRelationTypesArgs{})

The GetRelationTypesArgs struct is empty (no Project, no ContinuationToken). The endpoint is org-scoped.

Reference Existing Patterns

  • internal/cmd/pipelines/variablegroup/list/list.go — primary list reference (mirrors --max-items, ctx.Printer("list"), view struct, stable sort).
  • internal/cmd/boards/workitem/list/list.go — sibling work-item list (sibling reference, table shape).

Reference implementations (for UX parity only — azdo is the implementation target):

  • AzDO Extension azure-devops/azext_devops/dev/boards/relations.py:16-19 - get_relation_types_show function. Confirms: org-scoped, returns the full list.
  • AzDO Extension azure-devops/azext_devops/dev/boards/_format.py:7-19 - transform_work_item_relation_type_table_output confirms table columns Name, ReferenceName, Enabled, Usage.
  • AzDO MCP Server (TypeScript): no dedicated relation-type tool. The static set of relation types is documented inline in the server's prompt context.

TDD Test Plan

Test name Scenario Expected
TestNewCmd_listType Inspect the command struct. Use == "list-type [ORGANIZATION]", Aliases == ["lt", "list-types"], Args == cobra.MaximumNArgs(1).
Test_runListType_defaultOrg No positional. Mock GetRelationTypes called with empty org from config.
Test_runListType_explicitOrg Positional is "myorg". Mock GetRelationTypes called with "myorg".
Test_runListType_twoArgRejected Two positionals. Cobra returns the standard "accepts at most 1 arg" error.
Test_runListType_emptyOrg Positional is "" (zero args). Default org from config used.
Test_runListType_basic Mock returns 3 relation types in input order [c, a, b]. Table rows rendered in order [a, b, c] (sorted by Name).
Test_runListType_maxItems Mock returns 10 types; --max-items 3 set. Table has 3 rows: [a, b, c].
Test_runListType_maxItemsZeroOrNegative --max-items 0 or --max-items -1. All rows are returned (no cap when <= 0).
Test_runListType_JSON --json flag set. Exporter receives []relationTypeView with 3 entries; referenceName is the referenceName from SDK; attributes map is preserved.
Test_runListType_JSON_omitsAbsentAttributes Mock returns a type with only enabled in attributes (no usage). The corresponding view has enabled set; usage, isDirectory, resourceSubType are absent (omitempty).
Test_runListType_tableEnabledBool attributes["enabled"] = true (bool). Table ENABLED cell renders "true".
Test_runListType_tableEnabledFalse attributes["enabled"] = false. Table ENABLED cell renders "false".
Test_runListType_tableUsageString attributes["usage"] = "workItemLink". Table USAGE cell renders "workItemLink".
Test_runListType_APIError Mock returns errors.New("boom"). Returns wrapped error.
Test_runListType_nilAttributes Attributes is nil on a type. ENABLED and USAGE cells render as empty string; no panic.

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