Skip to content

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

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.

Project Scope Requirement

PROJECT is mandatory for this command, even though relation types are organization metadata and the SDK endpoint has no project parameter.

  • Use [ORG:]PROJECT. If ORG is omitted, resolve the configured default organization. Missing PROJECT, missing organization, and legacy slash forms such as ORG/PROJECT are errors.
  • Parse and validate the full project scope before constructing the organization client; use scope.Organization for GetRelationTypes and retain scope.Project as the required CLI scope.

Locked Decisions

# Decision Rationale
1 Project scope required. Use: "list-type [ORG:]PROJECT". PROJECT is required even though this command lists organization metadata and accepts no work-item ID. User requirement for consistent project scoping.
1a Parse and validate the project scope; use scope.Organization for the organization-scoped SDK call and retain scope.Project as required CLI scope. Keeps command grammar consistent without inventing an SDK project parameter.
2 Aliases: []string{"lt", "list-types"}. Per AGENTS.md list command convention (the lt short form is preferred for clarity here).
3 util.ExactArgs(1, "project argument required"). Project scope is required.
3a Legacy slash forms and missing PROJECT are rejected. Scope must be explicit.
4 Parse the required positional with util.ParseProjectScope(ctx, args[0]). ORG may be omitted only when configured default organization exists; PROJECT may not be omitted.
5 No --project flag; PROJECT is the required positional scope. The endpoint remains organization-scoped, so only scope.Organization is sent to the SDK. Project is still mandatory at CLI boundary.
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 [ORG:]PROJECT",
    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: util.ExactArgs(1, "project argument required"),
    RunE: func(cmd *cobra.Command, args []string) error {
        opts.scopeArg = 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()

    scope, err := util.ParseProjectScope(cmdCtx, opts.scopeArg)
    if err != nil { return util.FlagErrorWrap(err) }
    org := scope.Organization

    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 [ORG:]PROJECT", Aliases == ["lt", "list-types"], Args == util.ExactArgs(1, "project argument required").
Test_runListType_projectScopeDefaultOrg Positional is "Fabrikam". Mock GetRelationTypes called with empty org from config.
Test_runListType_explicitOrganizationProject Positional is "myorg". Mock GetRelationTypes called with "myorg".
Test_runListType_twoArgRejected Two positionals. Cobra returns the standard "accepts exactly 1 arg" error.
Test_runListType_missingProject Positional is "" (zero args). Returns the required project argument error.
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

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions