Skip to content

feat: Implement azdo team member remove command #289

Description

@tmeckel

Sub-issue of #287. Hardened spec — do not re-derive decisions.

Command Description

Remove one or more members from a team in a single command invocation. Each member is resolved, idempotency-checked, and removed independently — one failure does not abort the rest of the batch. Destructive: requires a confirmation prompt (skippable with --yes) only when at least one member is actually a member and would be removed. Implemented as a wrapper around the existing graph.Client.RemoveMembership API, using the team's underlying security-group descriptor (the same descriptor used by the add leaf, #288).

This is a NEW azdo capability: the vendored core.Client does not expose a direct "remove team member" method, and no reference CLI (MS az devops, Python AzDO Extension, AzDO MCP Server) implements this command. The AzDO REST API itself does not expose a direct endpoint either; the mechanism is the Graph API on the team's group.

Locked Decisions

  1. Use: remove [ORGANIZATION/]PROJECT/TEAM
  2. Aliases: ["r", "rm", "del", "d"] (unchanged from the original single-member spec). The command accepts one or many members; no new alias is introduced.
  3. Args: cobra.ExactArgs(1); error message: "team argument required".
  4. Target parsing: util.ParseProjectTargetWithDefaultOrganization(ctx, targetArg). PROJECT is always required; ORGANIZATION falls back to default config.
  5. --user / -u is a StringSliceVarP (multi-value). Accepts any of: subject descriptor (vssgs., aad., svc.), email address, principal name (UPN), security identifier (SID), or identity ID (UUID). Internally routed through the existing extensions.Client.ResolveSubject helper (internal/azdo/extensions/member_lookup.go:19). Pass the flag multiple times to remove several members in a single command invocation:
    azdo team member remove -u alice@contoso.com -u bob@contoso.com -y MyProject/MyTeam
    
  6. At least one --user is required. len(opts.users) == 0cobra.MarkFlagRequired("--user") produces a "required flag(s) missing" error before any API call. No empty-batch silent-success.
  7. Duplicate values are silently deduplicated in input order (a map keyed by the raw input string is used to dedupe; iteration order is the first-seen position). A user who passes -u alice -u alice gets exactly one row in the output.
  8. No IsTeamAdmin flag — out of scope; team admin management would require a separate command (admin status is a property of a different security group inside the team). Documented in umbrella Introduce azdo team member command group #287.
  9. --yes / -y (bool, default false). When set, the confirmation prompt is skipped. Has no effect on a batch where every member is already not a member (no prompt is shown anyway in that case — see decision feat: Implemented azdo auth status command #10).
  10. Confirmation prompt (only when --yes is not set AND at least one resolved member is currently a member of the team; the prompt is suppressed entirely if the resolved batch has zero pending removals — e.g. all members are already not a member, or all members failed ResolveSubject). Exact prompt text:
    • Single member (one row in the deduplicated batch that is currently a member):
      Are you sure you want to remove the member <MEMBER_DISPLAY> from team <TEAM_DISPLAY>? (y/n)
      
    • Bulk (N ≥ 2 members currently a member of the team):
      Are you sure you want to remove <N> members from team <TEAM_DISPLAY>? (y/n)
        - <MEMBER_1_DISPLAY>
        - <MEMBER_2_DISPLAY>
        - <MEMBER_3_DISPLAY>
        - <MEMBER_4_DISPLAY>
        - <MEMBER_5_DISPLAY>
        ... and <N-5> more
      
      The bulleted list is capped at the first 5 members; if N > 5, a final "... and <N-5> more" line is appended. Members that are already not a member (i.e. would be a no-op) are NOT listed in the prompt — only the members that will actually be removed. Members that failed ResolveSubject are also NOT listed in the prompt.
    • Display name fallbacks (in priority order): resolved subject's DisplayName → resolved subject's Descriptor → raw input string. For the team: team.Name → raw team input string.
    • User input: y (case-insensitive) proceeds; any other input returns util.ErrCancel (per AGENTS.md: "Return this — not util.SilentExit — when the user cancels a confirmation prompt"). When util.ErrCancel is returned, no RemoveMembership calls have been made — the prompt is shown and accepted BEFORE any mutations, so cancellation is atomic.
  11. Per-member idempotency semantics (each member is processed independently; a failure on member N does not abort the batch — but cancellation via the prompt IS atomic across the batch, see decision feat: Implemented azdo auth status command #10):
    • Before calling RemoveMembership, call graph.Client.CheckMembershipExistence with the resolved descriptors.
    • If CheckMembershipExistence returns 404 → user is not a member → emit Status: "not a member", exit 0 for that row.
    • If CheckMembershipExistence returns nil → user is a member → proceed to RemoveMembership.
    • If RemoveMembership returns HTTP 404 (race: was just removed by another process) → also treat as Status: "not a member" (defense in depth against race conditions).
    • Any other error during resolve or remove → emit Status: "not found" (resolve failure) or Status: "error" (other), and continue with the next member.
  12. Status enum (4 values, all ASCII, no "Group" word in any value or label):
    • "removed"RemoveMembership succeeded.
    • "not a member"CheckMembershipExistence returned 404, or RemoveMembership returned 404.
    • "not found"ResolveSubject returned an error (no subject matched the input).
    • "error" — any other error (e.g. network, 5xx, unexpected SDK error).
  13. Exit code (single rule, applied once after the batch):
    • 0 if every result is "removed" or "not a member".
    • 1 if any result is "not found" or "error".
    • A partial-success batch (e.g. 2 removed, 1 not found) exits 1, even though some members were removed successfully. The table and JSON outputs make per-row status visible; the exit code is for scripting and CI use.
    • A cancelled confirmation prompt returns util.ErrCancel (cobra maps this to exit 0 in TTY mode with a printed "cancelled" message, or exit 1 in non-TTY mode) — no results are rendered.
  14. No "Group" word in any user-visible output (column headers, JSON field names, JSON values, help text, error messages, prompt text, log lines). The word "Group" never appears in stdout, stderr, or --help. Rationale: teams are themselves groups in AzDO, and a column header or JSON field called "Group" would confuse users about whether they are managing security groups or team members. Implementation detail: the Go source may still use identifiers like teamGroupDescriptor internally (e.g. as local variable names) — the constraint is on user-facing output only.
  15. No new SDK client, no new mocks required — all primitives already vendored and mocked (see API Surface below).
  16. No new helper / no shared file — single command in internal/cmd/team/member/remove/remove.go; do NOT introduce a team/member/shared/ package.

Command Signature

// internal/cmd/team/member/remove/remove.go
package remove

func NewCmd(ctx util.CmdContext) *cobra.Command
func runRemove(ctx util.CmdContext, opts *removeOptions) error

type removeOptions struct {
    users   []string
    yes     bool
    exporter *util.Exporter
}

type removeResultView struct {
    MemberDescriptor  *string `json:"memberDescriptor,omitempty"`
    MemberDisplayName *string `json:"memberDisplayName,omitempty"`
    MemberOrigin      *string `json:"memberOrigin,omitempty"`
    MemberOriginID    *string `json:"memberOriginId,omitempty"`
    Status            *string `json:"status,omitempty"`
}

type removeView struct {
    TeamName *string            `json:"teamName,omitempty"`
    Results  []removeResultView `json:"results"`
}

Flags

Flag Short Type Required Description
--user -u []string (StringSliceVarP) yes Members to remove. Accepts a descriptor, email, principal name, SID, or identity ID. Pass the flag multiple times to remove several members in a single command invocation. Duplicate values are silently deduplicated in input order.
--yes -y bool (default false) no Skip the confirmation prompt. Has no effect when the batch has no pending removals (e.g. all members are already not a member).
--json []string no Output fields (see JSON section).
--jq string no JQ expression filter.
--template string no Go template string.

JSON Output Contract

  • View struct (defined inline in remove.go, see Command Signature above).
  • Envelope shape: top-level object with a teamName field and a results array (one entry per deduplicated input member, in input order).
  • Per-result fields (all pointer + omitempty):
    • memberDescriptor — the resolved AzDO subject descriptor.
    • memberDisplayName — the resolved subject's display name (fall back to memberDescriptor if ResolveSubject returned no display name).
    • memberOrigin — the origin identity system (e.g. vsts, aad); from graph.GraphSubject.Origin.
    • memberOriginId — the legacy descriptor / origin-specific ID; from graph.GraphSubject.LegacyDescriptor.
    • status — one of "removed", "not a member", "not found", "error".
  • Intentionally omitted fields (and the reason):
    • memberSubjectKind — AzDO returns "User" or "Group" for this field. Per decision fix: correct documentation #14 the word "Group" must not appear in any user-visible output, so this field is dropped from the JSON contract entirely. Scripts that need the kind can call az devops security group list or read the descriptor prefix.
    • groupDescriptor / groupDisplayName — superseded by the top-level teamName envelope field. Per decision fix: correct documentation #14 the prefix "group" is also avoided.
    • relationshipRemoved — the AzDO GraphMembership response (graph/models.go:157) only carries ContainerDescriptor and SubjectDescriptor, no timestamp. Dropped; the status field already encodes "removed" vs "not a member" so callers can infer the relationship state.
  • Registered fields (must match the slice passed to util.AddJSONFlags): ["teamName", "results", "memberDescriptor", "memberDisplayName", "memberOrigin", "memberOriginId", "status"]. Same field set as feat: Implement azdo team member add command #288's add for consistency. Field names use lowerCamelCase with omitempty on optional fields.

Table Output Contract

  • One row per deduplicated input member, in input order. The team is the same for every row and is not repeated as a column.
  • Columns (in order, exactly 3): MEMBER | DESCRIPTOR | STATUS. No GROUP column (per decision fix: correct documentation #14).
  • MEMBERmemberSubject.DisplayName (fall back to memberSubject.Descriptor if display name is empty; never the raw input string — that is what the descriptor column is for).
  • DESCRIPTORmemberSubject.Descriptor (always set, never empty in practice; fall back to the raw input string if ResolveSubject failed but the input was a valid descriptor literal).
  • STATUS ← the per-row status ("removed", "not a member", "not found", or "error").
  • Header row is emitted via tp.AddColumns("MEMBER", "DESCRIPTOR", "STATUS") and data rows via tp.AddField(...) in the same order. tp.EndRow() after each data row.
  • Row count: len(dedupedUsers) (one per member), never 0 (cobra rejects empty --user).
  • Table is rendered ONLY on success or partial-success. When the confirmation prompt is cancelled (util.ErrCancel), no table is rendered.

Command Wiring

  1. internal/cmd/team/member/remove/remove.go — implementation.
  2. internal/cmd/team/member/member.go — register via cmd.AddCommand(remove.NewCmd(ctx)).
  3. internal/cmd/team/team.go — register the new member subgroup via cmd.AddCommand(member.NewCmd(ctx)) (one of the umbrella Introduce azdo team member command group #287 implementation steps).
  4. No changes to internal/cmd/root/root.go — the team group is already registered.

API Surface

All methods already vendored at vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/:

Method Location Purpose
core.Client.GetTeam(ctx, GetTeamArgs{ProjectId, TeamId}) core/client.go:451 Fetch the team; return value's Identity.SubjectDescriptor is the team's group descriptor.
graph.Client.CheckMembershipExistence(ctx, CheckMembershipExistenceArgs{ContainerDescriptor, SubjectDescriptor}) graph/client.go:724 Returns nil if user is a member, HTTP 404 wrapped error if not.
graph.Client.RemoveMembership(ctx, RemoveMembershipArgs{ContainerDescriptor, SubjectDescriptor}) graph/client.go:964 Returns nil on success; HTTP 404 if membership did not exist.
extensions.Client.ResolveSubject(ctx, member) internal/azdo/extensions/member_lookup.go:19 Resolves user/group identifier to a *graph.GraphSubject.

Mocks already generated at:

  • internal/mocks/core_client_mock.go (for GetTeam)
  • internal/mocks/graph_client_mock.go (for CheckMembershipExistence, RemoveMembership)
  • internal/mocks/extension_client_mock.go (for ResolveSubject)

Implementation Outline

  1. Parse target → org / project / team.
  2. Acquire core.Client via ctx.ClientFactory().Core(ctx.Context(), org).
  3. Call core.Client.GetTeam with ProjectId=project, TeamId=team. Error if team not found.
  4. Extract team.Identity.SubjectDescriptor → if nil/empty, error "team has no underlying descriptor (Identity.SubjectDescriptor is empty)". (No "Group" word in the error message — per decision fix: correct documentation #14.)
  5. Capture team.Name for the JSON envelope's teamName field and for the confirmation prompt.
  6. Acquire extensions.Client via ctx.ClientFactory().Extensions(ctx.Context(), org).
  7. Acquire graph.Client via ctx.ClientFactory().Graph(ctx.Context(), org).
  8. Dedupe opts.users in input order (preserve first-seen position).
  9. Phase 1 — resolve all members: for each unique input member, call extensionsClient.ResolveSubject(ctx, rawInput). On success, stage the resolved subject. On error, stage a "not found" placeholder keyed by the raw input. Order: input order. No membership checks yet — that is phase 2.
  10. Phase 2 — check membership for all resolved subjects: for each resolved subject, call graphClient.CheckMembershipExistence(...). Classify into:
    • "to-remove" (nil error — currently a member): will be removed if the user confirms.
    • "already-not-a-member" (404 — not a member): idempotent no-op, no RemoveMembership call.
    • "error" (any other): the failure will be reported, but processing continues.
  11. Phase 3 — confirmation prompt (only if !opts.yes AND at least one member is in the "to-remove" bucket):
    • Build the prompt string per decision feat: Implemented azdo auth status command #10 (single vs bulk wording, cap at 5 with ... and N-5 more).
    • Call prompter.Confirm(promptString) — if false, return util.ErrCancel immediately. No RemoveMembership calls have been made at this point; cancellation is atomic across the batch.
    • If the bucket is empty (all "already-not-a-member" or "not found"), no prompt is shown and no RemoveMembership calls are made. The batch proceeds straight to phase 5.
  12. Phase 4 — remove pending members: for each "to-remove" subject, call graphClient.RemoveMembership(...):
    • success → assign Status: "removed".
    • HTTP 404 → assign Status: "not a member" (race-condition defense).
    • other error → assign Status: "error".
  13. Phase 5 — assemble results in input order: for each unique input (in input order), look up the corresponding classified subject and append an removeResultView with the appropriate Status and per-row fields (descriptor, display name, origin, origin ID — populated from the resolved subject when available; nil for "not found" rows).
  14. Compute exit code: if any result has Status not in {"removed", "not a member"}, set err = fmt.Errorf("remove completed with %d failure(s)", count). Apply via runRemove's return value so cobra exits non-zero.
  15. Build the output:
    • Table: 3 columns MEMBER | DESCRIPTOR | STATUS, one row per result.
    • JSON: envelope {teamName: <team.Name>, results: [...]}.
  16. Render via tp.Render() (table) or opts.exporter.Write(ios, view) (JSON).

Reference Existing Patterns

  • Primary template: internal/cmd/security/group/membership/remove/remove.go (entire file). The 4 differences are: (1) target parser is ParseProjectTargetWithDefaultOrganization instead of ParseTargetWithDefaultOrganization; (2) the "container" is resolved via core.Client.GetTeam instead of shared.FindGroupByName; (3) positional is always 3-segment [ORGANIZATION/]PROJECT/TEAM; (4) --user is a StringSliceVarP, the result collection is an array, and the destructive prompt uses the team + member display names resolved in phase 1.
  • StringSliceVarP pattern: internal/cmd/pipelines/build/tag/add/add.go (per feat: Implement azdo pipelines build tag add command #277) — same approach for collecting multiple values via repeated --user flags.
  • Display name fallback: internal/cmd/team/listmember/listmember.go:176-184 (fieldIdentityDisplay).
  • Progress indicator: internal/cmd/team/listmember/listmember.go:66-67 (same pattern).
  • Confirmation prompt + util.ErrCancel: internal/cmd/util/prompter.go (per AGENTS.md: "Return this — not util.SilentExit — when the user cancels a confirmation prompt").
  • JSON envelope (top-level container + array of results): internal/cmd/security/group/membership/list/list.go (or similar list-style commands that return a top-level object containing a results array).

Testing

  • 22 table-driven tests required (RED → GREEN), all hermetic, all using mocks from internal/mocks/ (no real AzDO calls per AGENTS.md).
  • Test helper setupFakeDeps(t, org, yesFlag bool) mirrors internal/cmd/team/listmember/listmember_test.go:27-56 (add extension client expectation + prompter expectation; the prompter is expected only when yesFlag is false and the batch has pending removals).
  • Tests 1-11 cover single-member behaviour; tests 12-22 cover bulk behaviour. The last bulk test (TestRemove_Bulk_TableHasNoGroupColumn) is a regression guard for decision fix: correct documentation #14.
# Test Asserts
1 TestRemove_SingleMember_HappyPath_YesFlag --yes, 1 input, status "removed", table has 1 data row, no prompter call
2 TestRemove_SingleMember_HappyPath_InteractiveConfirm 1 input, prompter returns true, status "removed"
3 TestRemove_SingleMember_InteractiveCancel 1 input, prompter returns falseutil.ErrCancel, no RemoveMembership call
4 TestRemove_SingleMember_NotAMember CheckMembershipExistence returns 404 → status "not a member", no RemoveMembership call, no prompt shown
5 TestRemove_SingleMember_Race_404OnRemove RemoveMembership returns 404 → status "not a member", no error propagated
6 TestRemove_TeamNotFound GetTeam returns 404 → wrapped error, no resolve/check/remove calls
7 TestRemove_TeamHasNoIdentity WebApiTeam.Identity is nil → error, no resolve/check/remove calls
8 TestRemove_MemberNotFound ResolveSubject returns error → status "not found", exit 1
9 TestRemove_TargetArg_ParsesOrgSlashProjectSlashTeam mirror of listmember_test.go:221-240
10 TestRemove_JSONOutput_EnvelopeShape JSON has top-level teamName + results array, all 7 registered fields present
11 TestRemove_PromptText_Single exact prompt text matches the single-member wording from decision #10
12 TestRemove_Bulk_MultipleMembersRemoved_YesFlag --yes, 3 inputs all "to-remove", results in input order, exit 0
13 TestRemove_Bulk_MixedResults_YesFlag 3 inputs: 1 removed, 1 already not a member, 1 not found → 3 results in order, exit 1
14 TestRemove_Bulk_DedupePreservesInputOrder -u alice -u bob -u alice → 2 results, [alice, bob] order, only one membership check + one RemoveMembership for alice
15 TestRemove_Bulk_ExitCodePartialSuccess mixed results → runRemove returns non-nil error, exit 1
16 TestRemove_Bulk_JSONEnvelopeShape top-level teamName matches team.Name; results is an array with len(deduped) entries
17 TestRemove_Bulk_TableHasNoGroupColumn regression guard for decision #14: table column count is 3, header row is "MEMBER\tDESCRIPTOR\tSTATUS", body contains no occurrence of the literal string "Group"
18 TestRemove_Bulk_PromptText_ListsAllMembers 3 inputs, bulk prompt lists all 3 members, exact wording per decision #10
19 TestRemove_Bulk_InteractiveCancel_AbortsBatch 3 inputs all "to-remove", --yes NOT set, prompter returns falseutil.ErrCancel, NO RemoveMembership calls (atomic cancellation)
20 TestRemove_Bulk_Prompt_NotShownWhenAllNotAMember 2 inputs both "already-not-a-member", --yes NOT set, no prompter call, exit 0, both rows status "not a member"
21 TestRemove_Bulk_Prompt_CapsAt5_WithAndNMore 8 inputs all "to-remove", --yes NOT set, prompter receives a prompt that lists exactly 5 members followed by "... and 3 more" line
22 TestRemove_MissingUserFlag --user not provided → cobra rejects with "required flag(s) missing", exit 2

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