3737
3838_ALREADY_MEMBER_TEXT = "user is already a member of the organization"
3939_ORGANIZATION_EXISTS_TEXT = "organization name is already taken"
40+ _ONLY_MEMBER_TEXT = "remove the only member of an organization"
4041
4142# Re-exported here for callers that think in org-sync terms; the naming
4243# rule lives in shared.saml_groups so user compaction can share it.
@@ -424,6 +425,8 @@ def _apply_organization_sync(
424425 "remove" ,
425426 parallelism ,
426427 worker_pool ,
428+ current_user = sync_state .current_user ,
429+ targets = sync_state .targets ,
427430 )
428431 return _OrganizationApplyResult (
429432 creates = create_counts ,
@@ -1272,6 +1275,9 @@ def _apply_user_changes(
12721275 change_kind : organization_types .OrganizationChangeKind ,
12731276 parallelism : int ,
12741277 worker_pool : ThreadPoolExecutor | None = None ,
1278+ * ,
1279+ current_user : organization_types .OrgMember | None = None ,
1280+ targets : dict [str , organization_types .TargetOrganization ] | None = None ,
12751281) -> shared_types .MutationCounts :
12761282 if not changes :
12771283 return shared_types .MutationCounts ()
@@ -1292,6 +1298,8 @@ def apply_change(change: organization_types.OrganizationUserChange) -> None:
12921298 change ,
12931299 current_states [change .organization_name ],
12941300 change_kind ,
1301+ current_user = current_user ,
1302+ target = targets .get (change .organization_name ) if targets is not None else None ,
12951303 )
12961304
12971305 def record_result (
@@ -1353,6 +1361,9 @@ def _apply_user_change(
13531361 change : organization_types .OrganizationUserChange ,
13541362 state : organization_types .OrganizationState ,
13551363 change_kind : organization_types .OrganizationChangeKind ,
1364+ * ,
1365+ current_user : organization_types .OrgMember | None = None ,
1366+ target : organization_types .TargetOrganization | None = None ,
13561367) -> None :
13571368 if state .id is None :
13581369 raise RuntimeError (f"organization { change .organization_name !r} has no ID" )
@@ -1382,10 +1393,60 @@ def _apply_user_change(
13821393 organization_name = change .organization_name ,
13831394 username = change .username ,
13841395 ):
1385- client .graphql (
1386- queries .MUTATION_REMOVE_USER_FROM_ORGANIZATION ,
1387- {"organization" : state .id , "user" : change .user_id },
1388- )
1396+ try :
1397+ client .graphql (
1398+ queries .MUTATION_REMOVE_USER_FROM_ORGANIZATION ,
1399+ {"organization" : state .id , "user" : change .user_id },
1400+ )
1401+ except src .GraphQLError as exception :
1402+ if not _should_retry_last_member_removal (
1403+ exception , change , state , target , current_user
1404+ ):
1405+ raise
1406+ _remove_last_other_member_via_current_user (client , change , state , current_user )
1407+
1408+
1409+ def _should_retry_last_member_removal (
1410+ exception : src .GraphQLError ,
1411+ change : organization_types .OrganizationUserChange ,
1412+ state : organization_types .OrganizationState ,
1413+ target : organization_types .TargetOrganization | None ,
1414+ current_user : organization_types .OrgMember | None ,
1415+ ) -> bool :
1416+ return (
1417+ _ONLY_MEMBER_TEXT in str (exception )
1418+ and current_user is not None
1419+ and target is not None
1420+ and not target .desired_members_by_id
1421+ and change .user_id != current_user ["id" ]
1422+ and len (state .members_by_id ) == 1
1423+ and change .user_id in state .members_by_id
1424+ )
1425+
1426+
1427+ def _remove_last_other_member_via_current_user (
1428+ client : src .SourcegraphClient ,
1429+ change : organization_types .OrganizationUserChange ,
1430+ state : organization_types .OrganizationState ,
1431+ current_user : organization_types .OrgMember | None ,
1432+ ) -> None :
1433+ if current_user is None :
1434+ raise RuntimeError ("current user is required for last-member org cleanup" )
1435+ log .info (
1436+ " Sourcegraph forbids removing %s as the only member of org %s; "
1437+ "temporarily adding current user %s so the stale synced org can be emptied." ,
1438+ change .username ,
1439+ change .organization_name ,
1440+ current_user ["username" ],
1441+ )
1442+ temporary_membership = organization_types .OrganizationUserChange (
1443+ organization_name = change .organization_name ,
1444+ user_id = current_user ["id" ],
1445+ username = current_user ["username" ],
1446+ )
1447+ _apply_user_change (client , temporary_membership , state , "add" )
1448+ _apply_user_change (client , change , state , "remove" )
1449+ _apply_user_change (client , temporary_membership , state , "remove" )
13891450
13901451
13911452def _snapshot_from_states (
0 commit comments