| 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 false → util.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 false → util.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 |
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 existinggraph.Client.RemoveMembershipAPI, using the team's underlying security-group descriptor (the same descriptor used by theaddleaf, #288).This is a NEW
azdocapability: the vendoredcore.Clientdoes not expose a direct "remove team member" method, and no reference CLI (MSaz 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
remove [ORGANIZATION/]PROJECT/TEAM["r", "rm", "del", "d"](unchanged from the original single-member spec). The command accepts one or many members; no new alias is introduced.cobra.ExactArgs(1); error message:"team argument required".util.ParseProjectTargetWithDefaultOrganization(ctx, targetArg). PROJECT is always required; ORGANIZATION falls back to default config.--user/-uis aStringSliceVarP(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 existingextensions.Client.ResolveSubjecthelper (internal/azdo/extensions/member_lookup.go:19). Pass the flag multiple times to remove several members in a single command invocation:--useris required.len(opts.users) == 0→cobra.MarkFlagRequired("--user")produces a"required flag(s) missing"error before any API call. No empty-batch silent-success.mapkeyed by the raw input string is used to dedupe; iteration order is the first-seen position). A user who passes-u alice -u alicegets exactly one row in the output.IsTeamAdminflag — 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 Introduceazdo team membercommand group #287.--yes/-y(bool, defaultfalse). 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: Implementedazdo auth statuscommand #10).--yesis 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 failedResolveSubject). Exact prompt text:"... 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 failedResolveSubjectare also NOT listed in the prompt.DisplayName→ resolved subject'sDescriptor→ raw input string. For the team:team.Name→ raw team input string.y(case-insensitive) proceeds; any other input returnsutil.ErrCancel(per AGENTS.md: "Return this — notutil.SilentExit— when the user cancels a confirmation prompt"). Whenutil.ErrCancelis returned, noRemoveMembershipcalls have been made — the prompt is shown and accepted BEFORE any mutations, so cancellation is atomic.azdo auth statuscommand #10):RemoveMembership, callgraph.Client.CheckMembershipExistencewith the resolved descriptors.CheckMembershipExistencereturns 404 → user is not a member → emitStatus: "not a member", exit 0 for that row.CheckMembershipExistencereturns nil → user is a member → proceed toRemoveMembership.RemoveMembershipreturns HTTP 404 (race: was just removed by another process) → also treat asStatus: "not a member"(defense in depth against race conditions).Status: "not found"(resolve failure) orStatus: "error"(other), and continue with the next member."removed"—RemoveMembershipsucceeded."not a member"—CheckMembershipExistencereturned 404, orRemoveMembershipreturned 404."not found"—ResolveSubjectreturned an error (no subject matched the input)."error"— any other error (e.g. network, 5xx, unexpected SDK error).0if every result is"removed"or"not a member".1if any result is"not found"or"error".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.--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 liketeamGroupDescriptorinternally (e.g. as local variable names) — the constraint is on user-facing output only.internal/cmd/team/member/remove/remove.go; do NOT introduce ateam/member/shared/package.Command Signature
Flags
--user-u[]string(StringSliceVarP)--yes-ybool(defaultfalse)--json[]string--jqstring--templatestringJSON Output Contract
remove.go, see Command Signature above).teamNamefield and aresultsarray (one entry per deduplicated input member, in input order).omitempty):memberDescriptor— the resolved AzDO subject descriptor.memberDisplayName— the resolved subject's display name (fall back tomemberDescriptorifResolveSubjectreturned no display name).memberOrigin— the origin identity system (e.g.vsts,aad); fromgraph.GraphSubject.Origin.memberOriginId— the legacy descriptor / origin-specific ID; fromgraph.GraphSubject.LegacyDescriptor.status— one of"removed","not a member","not found","error".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 callaz devops security group listor read the descriptor prefix.groupDescriptor/groupDisplayName— superseded by the top-levelteamNameenvelope field. Per decision fix: correct documentation #14 the prefix "group" is also avoided.relationshipRemoved— the AzDOGraphMembershipresponse (graph/models.go:157) only carriesContainerDescriptorandSubjectDescriptor, no timestamp. Dropped; thestatusfield already encodes "removed" vs "not a member" so callers can infer the relationship state.util.AddJSONFlags):["teamName", "results", "memberDescriptor", "memberDisplayName", "memberOrigin", "memberOriginId", "status"]. Same field set as feat: Implementazdo team member addcommand #288'saddfor consistency. Field names use lowerCamelCase withomitemptyon optional fields.Table Output Contract
MEMBER | DESCRIPTOR | STATUS. NoGROUPcolumn (per decision fix: correct documentation #14).MEMBER←memberSubject.DisplayName(fall back tomemberSubject.Descriptorif display name is empty; never the raw input string — that is what the descriptor column is for).DESCRIPTOR←memberSubject.Descriptor(always set, never empty in practice; fall back to the raw input string ifResolveSubjectfailed but the input was a valid descriptor literal).STATUS← the per-row status ("removed","not a member","not found", or"error").tp.AddColumns("MEMBER", "DESCRIPTOR", "STATUS")and data rows viatp.AddField(...)in the same order.tp.EndRow()after each data row.len(dedupedUsers)(one per member), never 0 (cobra rejects empty--user).util.ErrCancel), no table is rendered.Command Wiring
internal/cmd/team/member/remove/remove.go— implementation.internal/cmd/team/member/member.go— register viacmd.AddCommand(remove.NewCmd(ctx)).internal/cmd/team/team.go— register the newmembersubgroup viacmd.AddCommand(member.NewCmd(ctx))(one of the umbrella Introduceazdo team membercommand group #287 implementation steps).internal/cmd/root/root.go— theteamgroup is already registered.API Surface
All methods already vendored at
vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/:core.Client.GetTeam(ctx, GetTeamArgs{ProjectId, TeamId})core/client.go:451Identity.SubjectDescriptoris the team's group descriptor.graph.Client.CheckMembershipExistence(ctx, CheckMembershipExistenceArgs{ContainerDescriptor, SubjectDescriptor})graph/client.go:724nilif user is a member, HTTP 404 wrapped error if not.graph.Client.RemoveMembership(ctx, RemoveMembershipArgs{ContainerDescriptor, SubjectDescriptor})graph/client.go:964nilon success; HTTP 404 if membership did not exist.extensions.Client.ResolveSubject(ctx, member)internal/azdo/extensions/member_lookup.go:19*graph.GraphSubject.Mocks already generated at:
internal/mocks/core_client_mock.go(forGetTeam)internal/mocks/graph_client_mock.go(forCheckMembershipExistence,RemoveMembership)internal/mocks/extension_client_mock.go(forResolveSubject)Implementation Outline
core.Clientviactx.ClientFactory().Core(ctx.Context(), org).core.Client.GetTeamwithProjectId=project,TeamId=team. Error if team not found.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.)team.Namefor the JSON envelope'steamNamefield and for the confirmation prompt.extensions.Clientviactx.ClientFactory().Extensions(ctx.Context(), org).graph.Clientviactx.ClientFactory().Graph(ctx.Context(), org).opts.usersin input order (preserve first-seen position).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.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, noRemoveMembershipcall."error"(any other): the failure will be reported, but processing continues.!opts.yesAND at least one member is in the"to-remove"bucket):azdo auth statuscommand #10 (single vs bulk wording, cap at 5 with... and N-5 more).prompter.Confirm(promptString)— iffalse, returnutil.ErrCancelimmediately. NoRemoveMembershipcalls have been made at this point; cancellation is atomic across the batch.RemoveMembershipcalls are made. The batch proceeds straight to phase 5."to-remove"subject, callgraphClient.RemoveMembership(...):Status: "removed".Status: "not a member"(race-condition defense).Status: "error".removeResultViewwith the appropriateStatusand per-row fields (descriptor, display name, origin, origin ID — populated from the resolved subject when available; nil for"not found"rows).Statusnot in{"removed", "not a member"}, seterr = fmt.Errorf("remove completed with %d failure(s)", count). Apply viarunRemove's return value so cobra exits non-zero.MEMBER | DESCRIPTOR | STATUS, one row per result.{teamName: <team.Name>, results: [...]}.tp.Render()(table) oropts.exporter.Write(ios, view)(JSON).Reference Existing Patterns
internal/cmd/security/group/membership/remove/remove.go(entire file). The 4 differences are: (1) target parser isParseProjectTargetWithDefaultOrganizationinstead ofParseTargetWithDefaultOrganization; (2) the "container" is resolved viacore.Client.GetTeaminstead ofshared.FindGroupByName; (3) positional is always 3-segment[ORGANIZATION/]PROJECT/TEAM; (4)--useris aStringSliceVarP, the result collection is an array, and the destructive prompt uses the team + member display names resolved in phase 1.internal/cmd/pipelines/build/tag/add/add.go(per feat: Implementazdo pipelines build tag addcommand #277) — same approach for collecting multiple values via repeated--userflags.internal/cmd/team/listmember/listmember.go:176-184(fieldIdentityDisplay).internal/cmd/team/listmember/listmember.go:66-67(same pattern).util.ErrCancel:internal/cmd/util/prompter.go(per AGENTS.md: "Return this — notutil.SilentExit— when the user cancels a confirmation prompt").internal/cmd/security/group/membership/list/list.go(or similar list-style commands that return a top-level object containing aresultsarray).Testing
internal/mocks/(no real AzDO calls per AGENTS.md).setupFakeDeps(t, org, yesFlag bool)mirrorsinternal/cmd/team/listmember/listmember_test.go:27-56(add extension client expectation + prompter expectation; the prompter is expected only whenyesFlagis false and the batch has pending removals).TestRemove_Bulk_TableHasNoGroupColumn) is a regression guard for decision fix: correct documentation #14.TestRemove_SingleMember_HappyPath_YesFlag--yes, 1 input, status"removed", table has 1 data row, no prompter callTestRemove_SingleMember_HappyPath_InteractiveConfirmtrue, status"removed"TestRemove_SingleMember_InteractiveCancelfalse→util.ErrCancel, noRemoveMembershipcallTestRemove_SingleMember_NotAMemberCheckMembershipExistencereturns 404 → status"not a member", noRemoveMembershipcall, no prompt shownTestRemove_SingleMember_Race_404OnRemoveRemoveMembershipreturns 404 → status"not a member", no error propagatedTestRemove_TeamNotFoundGetTeamreturns 404 → wrapped error, no resolve/check/remove callsTestRemove_TeamHasNoIdentityWebApiTeam.Identityis nil → error, no resolve/check/remove callsTestRemove_MemberNotFoundResolveSubjectreturns error → status"not found", exit 1TestRemove_TargetArg_ParsesOrgSlashProjectSlashTeamlistmember_test.go:221-240TestRemove_JSONOutput_EnvelopeShapeteamName+resultsarray, all 7 registered fields presentTestRemove_PromptText_SingleTestRemove_Bulk_MultipleMembersRemoved_YesFlag--yes, 3 inputs all "to-remove", results in input order, exit 0TestRemove_Bulk_MixedResults_YesFlagTestRemove_Bulk_DedupePreservesInputOrder-u alice -u bob -u alice→ 2 results,[alice, bob]order, only one membership check + oneRemoveMembershipforaliceTestRemove_Bulk_ExitCodePartialSuccessrunRemovereturns non-nil error, exit 1TestRemove_Bulk_JSONEnvelopeShapeteamNamematchesteam.Name;resultsis an array withlen(deduped)entriesTestRemove_Bulk_TableHasNoGroupColumn"MEMBER\tDESCRIPTOR\tSTATUS", body contains no occurrence of the literal string"Group"TestRemove_Bulk_PromptText_ListsAllMembersTestRemove_Bulk_InteractiveCancel_AbortsBatch--yesNOT set, prompter returnsfalse→util.ErrCancel, NORemoveMembershipcalls (atomic cancellation)TestRemove_Bulk_Prompt_NotShownWhenAllNotAMember--yesNOT set, no prompter call, exit 0, both rows status"not a member"TestRemove_Bulk_Prompt_CapsAt5_WithAndNMore--yesNOT set, prompter receives a prompt that lists exactly 5 members followed by"... and 3 more"lineTestRemove_MissingUserFlag--usernot provided →cobrarejects with"required flag(s) missing", exit 2References
azdo team membercommand group #287 (Introduce azdo team member command group)azdo team member addcommand #288 (azdo team member add— same bulk + no-Group pattern, useful as a structural template), refactor: Moveazdo team list-membertoazdo team member list#290 (azdo team member listmove)internal/cmd/security/group/membership/remove/remove.go(entire file)internal/cmd/pipelines/build/tag/add/add.go(per feat: Implementazdo pipelines build tag addcommand #277)internal/azdo/extensions/member_lookup.go:19util.ErrCancel(per AGENTS.md "util.ErrCancel" guidance)core/client.go:451,graph/client.go:724, 964core/models.go:466(WebApiTeamwithIdentity *identity.Identity),graph/models.go:157(GraphMembership),graph/models.go:311(GraphSubject)