Skip to content

Implement azdo boards work-item relation remove command #274

@tmeckel

Description

@tmeckel

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

Command Description

Detach a relation (link) from one Azure Boards work item to one or more target work items. Mirrors az boards work-item relation remove. The command builds a JSON-Patch document containing remove operations on /relations/{index} and sends it to the server.

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

PATCH https://dev.azure.com/{organization}/_apis/wit/workitems/{id}?api-version=7.1-preview.3
Content-Type: application/json-patch+json

The Python reference implementation is at azure-devops/azext_devops/dev/boards/relations.py:60-91 (remove_relation function). The function:

  1. Fetches the source work item with expand='All'.
  2. For each target ID, fetches the target work item to obtain its url.
  3. Walks the source's relations array, finding the entry whose rel matches the friendly relation type and whose url matches the target's URL; collects /relations/{index} remove ops.
  4. Fails with a CLI error if the number of remove ops does not match the number of target IDs requested (Decision 15).
  5. Sends the PATCH.
  6. Re-fetches with expand='All' to populate the response.

This is a destructive operation. The Python's commands.py:97 adds a confirmation prompt: 'Are you sure you want to remove this relation(s)?'.

Locked Decisions

# Decision Rationale
1 Org-scoped target. Use: "remove [ORGANIZATION/]ID". The source work item ID is unique within the organization. Mirrors az boards work-item relation remove. Symmetric with #271 (add).
2 Aliases: []string{"r", "rm"}. Per AGENTS.md delete command convention.
3 cobra.ExactArgs(1). One positional target: the source work item ID.
4 Parse the single positional with util.ParseTargetWithDefaultOrganization(ctx, args[0]). scope.Targets[0] is the ID string. Existing helper handles 1- and 2-segment forms. Symmetric with #271 (add).
5 No --project flag. Matches az boards work-item relation remove.
6 ID must parse as a positive integer. If strconv.Atoi(scope.Targets[0]) fails or returns <= 0, return util.FlagErrorf("work item ID must be a positive integer; got %q", scope.Targets[0]) and do not call the SDK. Symmetric with #271 Decision 6, #269 Decision 14, #270 Decision 6.
7 --relation-type is a single friendly name (e.g., parent, child). Case-insensitive match against the list returned by GetRelationTypes. Mirrors Python's get_system_relation_name.
8 Invalid relation type surfaces a CLI error with the exact Python message: --relation-type is not valid. Use "azdo boards work-item relation list-type" command to list possible relation types in your project. Mirrors get_system_relation_name.
9 --target-id is repeatable; multiple invocations concatenate. Each ID must parse as a positive integer. Mirrors Python's comma-separated form via target_id.split(',') plus the Go-native repeatable-flag form.
10 --target-id is required (no --target-url support — Python only supports IDs in remove). Mirrors Python's remove_relation(id, relation_type, target_id, ...).
11 For each target ID, call GetWorkItem(id) to obtain its url and use that URL when matching relations. Mirrors Python's client.get_work_item(target_work_item_id, expand='All').
12 Fetch the source work item with expand='All' to read its current relations array. Mirrors Python.
13 Walk the source's relations array, finding each entry whose Rel matches the relation type's referenceName AND whose Url matches the target's URL. Collect a /relations/{index} remove op for each match. Indices are computed before any modification; the indices are positional in the SDK's relations slice. Mirrors Python's if relation.rel == relation_type_system_name and relation.url == target_work_item_url.
14 Patch ops are applied in reverse index order (highest index first) so that earlier indices remain valid after each op. Test asserts this order. Mirrors Python's for relation in main_work_item.relations: ... index = index + 1 — but with reverse to avoid index drift. Decision 14 supersedes the Python's forward order: applying forward would shift indices after each removal, causing later ops to target the wrong relations.
15 Validate: number of remove ops must equal number of target IDs requested. If fewer matches are found, return util.FlagErrorf with the exact Python message: "Id(s) supplied in --target-id is not valid". SDK is not called for UpdateWorkItem. Mirrors Python's if len(patch_document) != len(target_work_item_ids): raise CLIError('Id(s) supplied in --target-id is not valid').
16 Destructive — confirmation prompt is required with the exact Python prompt: Are you sure you want to remove this relation(s)?. Skip with --yes. On cancel, return util.ErrCancel. Mirrors Python's confirmation='Are you sure you want to remove this relation(s)?'.
17 JSON output passes the raw SDK *WorkItem (the post-PATCH GetWorkItem with expand=All) to opts.exporter.Write. Mirrors #271 (add).
18 Default output: table with columns TYPE, URL (mirroring the add leaf). Mirrors Python's transform_work_item_relations and #271's Table Output Contract.
19 No --expand flag. All responses use expand='All' internally. Mirrors #271 Decision 12.
20 No --target-url flag. Python's remove_relation does not support URLs. Mirrors Python.
21 Uses vendored workitemtracking.Client.{UpdateWorkItem, GetWorkItem, GetRelationTypes}. Vendor verified.
22 No new mocks needed. Already generated.
23 No go mod tidy / go mod vendor / scripts/generate_mocks.sh work.
24 No new shared helper beyond what the umbrella introduces. The RemoveRelationPatchOps helper (in internal/cmd/boards/workitem/relation/shared/relation.go) returns the list of /relations/{index} ops and the matches count, but the leaf owns the policy of when to error (Decision 15). Keep the leaf logic visible.
25 Empty source work item relations (no relations at all) returns util.FlagErrorf("Id(s) supplied in --target-id is not valid") per Decision 15 (zero matches != len(targets)). Mirrors Python's if main_work_item.relations: guard, which falls through to the count check.
26 No mutual-exclusion between --target-id and --target-url--target-url is simply not supported (Decision 20). Matches Python surface.

Command Signature

var removeCmd = &cobra.Command{
    Use:     "remove [ORGANIZATION/]ID",
    Aliases: []string{"r", "rm"},
    Short:   "Remove a relation(s) from a work item.",
    Long: heredoc.Doc(`
        Detach one or more relations from an existing work item. The relation
        type must be one of the friendly names returned by 'list-type'.
        Targets are specified by work item ID.
    `),
    Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        return runRemove(cmd.Context(), opts, args[0])
    },
}

Flags

Flag Notes
--id not a flag — positional [ORGANIZATION/]ID.
--relation-type Required. Case-insensitive.
--target-id (repeatable) Required.
--yes Skip the confirmation prompt.
--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. removeOptions struct

type removeOptions struct {
    targetArg string

    relationType string   // --relation-type
    targetIDs    []string // --target-id (repeatable; comma-separated also accepted)
    yes          bool     // --yes

    exporter util.Exporter
}

§2. runRemove skeleton

func runRemove(cmdCtx util.CmdContext, opts *removeOptions, 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])
    }

    targetIDs, err := util.SplitAndTrimCSV(opts.targetIDs)
    if err != nil { return util.FlagErrorWrap(err) }
    if len(targetIDs) == 0 {
        return util.FlagErrorf("--target-id must be provided")
    }
    for _, tid := range targetIDs {
        n, err := strconv.Atoi(tid)
        if err != nil || n <= 0 {
            return util.FlagErrorf("target work item ID must be a positive integer; got %q", tid)
        }
    }

    // Confirmation prompt (skipped with --yes).
    if !opts.yes {
        confirmed, err := cmdCtx.Prompter().Confirm("Are you sure you want to remove this relation(s)?", false)
        if err != nil { return err }
        if !confirmed { return util.ErrCancel }
    }

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

    relRefName, err := shared.ResolveRelationType(cmdCtx.Context(), wit, opts.relationType)
    if err != nil { return util.FlagErrorWrap(err) }

    // Resolve target IDs to URLs.
    targetURLs := make(map[string]struct{}, len(targetIDs))
    for _, tid := range targetIDs {
        n, _ := strconv.Atoi(tid)
        target, err := wit.GetWorkItem(cmdCtx.Context(), workitemtracking.GetWorkItemArgs{Id: &n})
        if err != nil {
            return fmt.Errorf("failed to resolve target work item %d: %w", n, err)
        }
        if target.Url == nil || *target.Url == "" {
            return fmt.Errorf("target work item %d has no URL; cannot remove relation", n)
        }
        targetURLs[*target.Url] = struct{}{}
    }

    // Fetch the source work item to find matching relations.
    expand := workitemtracking.WorkItemExpandValues.All
    src, err := wit.GetWorkItem(cmdCtx.Context(), workitemtracking.GetWorkItemArgs{
        Id:     &id,
        Expand: &expand,
    })
    if err != nil { return err }

    // Build a list of indices in reverse order (Decision 14).
    indices := []int{}
    if src.Relations != nil {
        for i, rel := range *src.Relations {
            if rel.Rel == nil || rel.Url == nil { continue }
            if *rel.Rel != relRefName { continue }
            if _, ok := targetURLs[*rel.Url]; !ok { continue }
            indices = append(indices, i)
        }
    }
    sort.Sort(sort.Reverse(sort.IntSlice(indices)))

    if len(indices) != len(targetIDs) {
        return util.FlagErrorf("Id(s) supplied in --target-id is not valid")
    }

    remove := webapi.OperationValues.Remove
    doc := []webapi.JsonPatchOperation{}
    for _, idx := range indices {
        p := fmt.Sprintf("/relations/%d", idx)
        doc = append(doc, webapi.JsonPatchOperation{Op: &remove, Path: &p})
    }

    _, err = wit.UpdateWorkItem(cmdCtx.Context(), workitemtracking.UpdateWorkItemArgs{
        Document: &doc,
        Id:       &id,
    })
    if err != nil { return err }

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

    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()
}

JSON Output Contract

Pass the raw *workitemtracking.WorkItem (post-PATCH) 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 #271 (add).

Command Wiring

  1. Create internal/cmd/boards/workitem/relation/remove/remove.go with package remove, factory func NewCmd(ctx util.CmdContext) *cobra.Command.
  2. Add import "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/relation/remove" to internal/cmd/boards/workitem/relation/relation.go.
  3. Register in relation.go with cmd.AddCommand(remove.NewCmd(ctx)).
  4. Reuse internal/cmd/boards/workitem/relation/shared/relation.go (introduced by the umbrella) — shared.ResolveRelationType.

API Surface

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

// 1. Resolve friendly relation type
relTypes, _ := wit.GetRelationTypes(ctx, workitemtracking.GetRelationTypesArgs{})

// 2. Resolve each target ID to its URL
target, _ := wit.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{Id: &n})

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

// 4. PATCH
_, _ = wit.UpdateWorkItem(ctx, workitemtracking.UpdateWorkItemArgs{Document: &doc, Id: &id})

// 5. Re-fetch with expand=All
populated, _ := wit.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{Id: &id, Expand: &expand})

Reference Existing Patterns

Reference implementations (for UX parity only):

  • AzDO Extension azure-devops/azext_devops/dev/boards/relations.py:60-91 - remove_relation function. Confirms: confirmation prompt, target-ID-only support, count-validation message.
  • AzDO Extension azure-devops/azext_devops/dev/boards/commands.py:97 - confirmation='Are you sure you want to remove this relation(s)?'.
  • AzDO MCP Server (TypeScript): no dedicated remove-relation tool.

TDD Test Plan

Test name Scenario Expected
TestNewCmd_remove Inspect the command struct. Use == "remove [ORGANIZATION/]ID", Aliases == ["r", "rm"], Args == cobra.ExactArgs(1).
Test_runRemove_minimal --relation-type parent --target-id 2. Source has 1 relation matching. One remove op on /relations/0.
Test_runRemove_multipleTargets --target-id 2 --target-id 3; source has matching relations at indices [0, 2]. Two remove ops in reverse order: /relations/2 first, then /relations/0.
Test_runRemove_commaSeparated --target-id 2,3. Same as multipleTargets.
Test_runRemove_noMatch Source has no relations. Returns util.FlagErrorf with "Id(s) supplied in --target-id is not valid". SDK's UpdateWorkItem is not called.
Test_runRemove_partialMatch 2 targets requested, only 1 relation matches. Returns util.FlagErrorf with "Id(s) supplied in --target-id is not valid". SDK's UpdateWorkItem is not called.
Test_runRemove_invalidRelationType --relation-type bogus. Returns util.FlagErrorf with "--relation-type is not valid...". SDK is not called.
Test_runRemove_invalidSourceID Positional is "abc". Returns util.FlagErrorf with "work item ID must be a positive integer". SDK is not called.
Test_runRemove_zeroSourceID Positional is "0". Returns util.FlagErrorf. SDK is not called.
Test_runRemove_negativeSourceID Positional is "-5". Returns util.FlagErrorf. SDK is not called.
Test_runRemove_invalidTargetID --target-id abc. Returns util.FlagErrorf with "target work item ID must be a positive integer". SDK is not called.
Test_runRemove_noTargets No --target-id. Returns util.FlagErrorf with "--target-id must be provided". SDK is not called.
Test_runRemove_targetIDNotFound Mock GetWorkItem for the target returns 404. Returns wrapped error; PATCH is not sent.
Test_runRemove_confirmationPrompt_yes --yes set. Prompter.Confirm is not called; PATCH proceeds.
Test_runRemove_confirmationPrompt_userConfirms No --yes; mock Prompter.Confirm returns (true, nil). PATCH proceeds.
Test_runRemove_confirmationPrompt_userDeclines No --yes; mock Prompter.Confirm returns (false, nil). Returns util.ErrCancel. SDK is not called.
Test_runRemove_orgScopeOnly Positional is "1234". args.Id == ptr(1234), scope.Organization is the default.
Test_runRemove_explicitOrg Positional is "myorg/1234". args.Id == ptr(1234), scope.Organization is "myorg".
Test_runRemove_APIError Mock UpdateWorkItem returns errors.New("boom"). Returns wrapped error.
Test_runRemove_success_JSON --json flag set. Exporter receives the raw *WorkItem from the post-PATCH GetWorkItem.
Test_runRemove_tableOutput Default output, mock returns a work item with 1 relation. Table has 1 row with the 2 columns populated.
Test_runRemove_emptyRelationsAfterRemove Source had 1 relation; after PATCH the work item has 0 relations. Table has 0 rows; no error.
Test_runRemove_indexOrdering Source has 3 relations; targets match indices [0, 1]. PATCH ops are in order: /relations/1, /relations/0 (reverse).
Test_runRemove_relationTypeMatchIsCaseInsensitive --relation-type PARENT. Resolves to System.LinkTypes.Hierarchy-Reverse exactly as --relation-type parent.

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