diff --git a/otterdog/models/github_organization.py b/otterdog/models/github_organization.py index 776c4622..84c25e24 100644 --- a/otterdog/models/github_organization.py +++ b/otterdog/models/github_organization.py @@ -717,9 +717,7 @@ async def _load_repos_from_provider( if repo_filter is not None: repo_names = fnmatch.filter(repo_names, repo_filter) - teams = { - str(team["id"]): f"{github_id}/{team['slug']}" for team in await provider.rest_api.org.get_teams(github_id) - } + teams = {str(team["id"]): f"{github_id}/{team['slug']}" for team in await provider.get_org_teams(github_id)} app_installations = { str(installation["app_id"]): installation["app_slug"] diff --git a/otterdog/models/ruleset.py b/otterdog/models/ruleset.py index 577fc320..395634da 100644 --- a/otterdog/models/ruleset.py +++ b/otterdog/models/ruleset.py @@ -606,7 +606,7 @@ def extract_actor_and_bypass_mode(encoded_data: str) -> tuple[str, str]: elif actor.startswith("@"): team, bypass_mode = extract_actor_and_bypass_mode(actor[1:]) actor_type = "Team" - actor_id = (await provider.rest_api.org.get_team_ids(team))[0] + actor_id = (await provider.rest_api.team.get_team_ids(team))[0] else: app, bypass_mode = extract_actor_and_bypass_mode(actor) actor_type = "Integration" diff --git a/otterdog/providers/github/__init__.py b/otterdog/providers/github/__init__.py index 8efb2962..b45886b7 100644 --- a/otterdog/providers/github/__init__.py +++ b/otterdog/providers/github/__init__.py @@ -1,5 +1,5 @@ # ******************************************************************************* -# Copyright (c) 2023-2024 Eclipse Foundation and others. +# Copyright (c) 2023-2025 Eclipse Foundation and others. # This program and the accompanying materials are made available # under the terms of the Eclipse Public License 2.0 # which is available at http://www.eclipse.org/legal/epl-v20.html @@ -161,19 +161,19 @@ async def delete_org_custom_role(self, org_id: str, role_id: int, role_name: str await self.rest_api.org.delete_custom_role(org_id, role_id, role_name) async def get_org_teams(self, org_id: str) -> list[dict[str, Any]]: - return await self.rest_api.org.get_teams(org_id) + return await self.rest_api.team.get_teams(org_id) async def get_org_team_members(self, org_id: str, team_slug: str) -> list[dict[str, Any]]: - return await self.rest_api.org.get_team_members(org_id, team_slug) + return await self.rest_api.team.get_team_members(org_id, team_slug) async def add_org_team(self, org_id: str, team_name: str, data: dict[str, str]) -> None: - return await self.rest_api.org.add_team(org_id, team_name, data) + await self.rest_api.team.add_team(org_id, team_name, data) async def update_org_team(self, org_id: str, team_slug: str, data: dict[str, str]) -> None: - return await self.rest_api.org.update_team(org_id, team_slug, data) + await self.rest_api.team.update_team(org_id, team_slug, data) async def delete_org_team(self, org_id: str, team_slug: str) -> None: - return await self.rest_api.org.delete_team(org_id, team_slug) + await self.rest_api.team.delete_team(org_id, team_slug) async def get_org_custom_properties(self, org_id: str) -> list[dict[str, Any]]: return await self.rest_api.org.get_custom_properties(org_id) @@ -441,7 +441,7 @@ async def get_actor_ids_with_type(self, actor_names: list[str]) -> list[tuple[st # - user-names are not allowed to contain a / if "/" in actor: try: - result.append(("Team", await self.rest_api.org.get_team_ids(actor[1:]))) + result.append(("Team", await self.rest_api.team.get_team_ids(actor[1:]))) except RuntimeError: _logger.warning(f"team '{actor[1:]}' does not exist, skipping") else: diff --git a/otterdog/providers/github/rest/org_client.py b/otterdog/providers/github/rest/org_client.py index 2d4bedee..9632f4d7 100644 --- a/otterdog/providers/github/rest/org_client.py +++ b/otterdog/providers/github/rest/org_client.py @@ -7,7 +7,6 @@ # ******************************************************************************* import json -import re from typing import Any from otterdog.providers.github.exception import GitHubException @@ -473,121 +472,6 @@ async def get_public_key(self, org_id: str) -> tuple[str, str]: except GitHubException as ex: raise RuntimeError(f"failed retrieving org public key:\n{ex}") from ex - async def get_team_ids(self, combined_slug: str) -> tuple[int, str]: - _logger.debug("retrieving team ids for slug '%s'", combined_slug) - org_id, team_slug = re.split("/", combined_slug) - - try: - response = await self.requester.request_json("GET", f"/orgs/{org_id}/teams/{team_slug}") - return response["id"], response["node_id"] - except GitHubException as ex: - raise RuntimeError(f"failed retrieving team node id:\n{ex}") from ex - - async def get_teams(self, org_id: str) -> list[dict[str, Any]]: - _logger.debug("retrieving teams for org '%s'", org_id) - - try: - return await self.requester.request_json("GET", f"/orgs/{org_id}/teams") - except GitHubException as ex: - raise RuntimeError(f"failed retrieving teams for org '{org_id}':\n{ex}") from ex - - async def get_team_members(self, org_id: str, team_slug: str) -> list[dict[str, Any]]: - _logger.debug("retrieving team members for team '%s/%s'", org_id, team_slug) - - try: - return await self.requester.request_paged_json("GET", f"/orgs/{org_id}/teams/{team_slug}/members") - except GitHubException as ex: - raise RuntimeError(f"failed retrieving team members for team '{org_id}/{team_slug}':\n{ex}") from ex - - async def add_team(self, org_id: str, team_name: str, data: dict[str, str]) -> None: - _logger.debug("adding team '%s' for org '%s'", team_name, org_id) - - status, body = await self.requester.request_raw("POST", f"/orgs/{org_id}/teams", json.dumps(data)) - - if status != 201: - raise RuntimeError(f"failed to add team '{team_name}': {body}") - - if "members" in data: - team_data = json.loads(body) - team_slug = team_data["slug"] - members = data["members"] - for user in members: - await self.add_member_to_team(org_id, team_slug, user) - - _logger.debug("added team '%s'", team_name) - - async def update_team(self, org_id: str, team_slug: str, team: dict[str, Any]) -> None: - _logger.debug("updating team '%s' for org '%s'", team_slug, org_id) - - try: - await self.requester.request_json("PATCH", f"/orgs/{org_id}/teams/{team_slug}", team) - - if "members" in team: - await self.update_team_members(org_id, team_slug, team["members"]) - - _logger.debug("updated team '%s'", team_slug) - except GitHubException as ex: - raise RuntimeError(f"failed to update team '{team_slug}':\n{ex}") from ex - - async def update_team_members(self, org_id: str, team_slug: str, members: list[str]) -> None: - _logger.debug("updating team members for team '%s' in org '%s'", team_slug, org_id) - - current_members = {x["login"] for x in await self.get_team_members(org_id, team_slug)} - - # first, add all users that are not members yet. - for member in members: - if member in current_members: - current_members.remove(member) - else: - await self.add_member_to_team(org_id, team_slug, member) - - # second, remove the current members that are remaining. - for member in current_members: - await self.remove_member_from_team(org_id, team_slug, member) - - async def add_member_to_team(self, org_id: str, team_slug: str, user: str) -> None: - _logger.debug("adding user with id '%s' to team '%s' in org '%s'", user, team_slug, org_id) - - status, body = await self.requester.request_raw("PUT", f"/orgs/{org_id}/teams/{team_slug}/memberships/{user}") - - if status == 200: - _logger.debug("added user '%s' to team '%s' for org '%s'", user, team_slug, org_id) - else: - raise RuntimeError( - f"failed adding user '{user}' to team '{team_slug}' in org '{org_id}'" f"\n{status}: {body}" - ) - - async def remove_member_from_team(self, org_id: str, team_slug: str, user: str) -> None: - _logger.debug("removing user '%s' from team '%s' in org '%s'", user, team_slug, org_id) - - status, body = await self.requester.request_raw( - "DELETE", f"/orgs/{org_id}/teams/{team_slug}/memberships/{user}" - ) - if status != 204: - raise RuntimeError( - f"failed removing user '{user}' from team '{team_slug}' in org '{org_id}'" f"\n{status}: {body}" - ) - - _logger.debug("removed user '%s' from team '%s' in org '%s'", user, team_slug, org_id) - - async def delete_team(self, org_id: str, team_slug: str) -> None: - _logger.debug("deleting team '%s' for org '%s'", team_slug, org_id) - - status, body = await self.requester.request_raw("DELETE", f"/orgs/{org_id}/teams/{team_slug}") - - if status != 204: - raise RuntimeError(f"failed to delete team '{team_slug}': {body}") - - _logger.debug("removed team '%s'", team_slug) - - async def get_membership(self, org_id: str, user_name: str) -> dict[str, Any]: - _logger.debug("retrieving membership for user '%s' in org '%s'", user_name, org_id) - - try: - return await self.requester.request_json("GET", f"/orgs/{org_id}/memberships/{user_name}") - except GitHubException as ex: - raise RuntimeError(f"failed retrieving membership for user '{user_name}' in org '{org_id}':\n{ex}") from ex - async def get_app_installations(self, org_id: str) -> list[dict[str, Any]]: _logger.debug("retrieving app installations for org '%s'", org_id) diff --git a/otterdog/providers/github/rest/team_client.py b/otterdog/providers/github/rest/team_client.py index 3519df21..2c0713cd 100644 --- a/otterdog/providers/github/rest/team_client.py +++ b/otterdog/providers/github/rest/team_client.py @@ -1,11 +1,13 @@ # ******************************************************************************* -# Copyright (c) 2024 Eclipse Foundation and others. +# Copyright (c) 2024-2025 Eclipse Foundation and others. # This program and the accompanying materials are made available # under the terms of the Eclipse Public License 2.0 # which is available at http://www.eclipse.org/legal/epl-v20.html # SPDX-License-Identifier: EPL-2.0 # ******************************************************************************* +import json +import re from typing import Any from otterdog.logging import get_logger @@ -19,15 +21,130 @@ class TeamClient(RestClient): def __init__(self, rest_api: RestApi): super().__init__(rest_api) - async def get_team_slugs(self, org_id: str) -> list[dict[str, Any]]: + async def get_team_ids(self, combined_slug: str) -> tuple[int, str]: + _logger.debug("retrieving team ids for slug '%s'", combined_slug) + org_id, team_slug = re.split("/", combined_slug) + + try: + response = await self.requester.request_json("GET", f"/orgs/{org_id}/teams/{team_slug}") + return response["id"], response["node_id"] + except GitHubException as ex: + raise RuntimeError(f"failed retrieving team node id:\n{ex}") from ex + + async def get_teams(self, org_id: str) -> list[dict[str, Any]]: _logger.debug("retrieving teams for org '%s'", org_id) try: - response = await self.requester.request_json("GET", f"/orgs/{org_id}/teams") - return [team["slug"] for team in response] + return await self.requester.request_json("GET", f"/orgs/{org_id}/teams") + except GitHubException as ex: + raise RuntimeError(f"failed retrieving teams for org '{org_id}':\n{ex}") from ex + + async def get_team_slugs(self, org_id: str) -> list[dict[str, Any]]: + _logger.debug("retrieving team slugs for org '%s'", org_id) + + try: + teams = await self.get_teams(org_id) + return [team["slug"] for team in teams] except GitHubException as ex: raise RuntimeError(f"failed retrieving teams:\n{ex}") from ex + async def add_team(self, org_id: str, team_name: str, data: dict[str, str]) -> str: + _logger.debug("adding team '%s' for org '%s'", team_name, org_id) + + status, body = await self.requester.request_raw("POST", f"/orgs/{org_id}/teams", json.dumps(data)) + + if status != 201: + raise RuntimeError(f"failed to add team '{team_name}': {body}") + + team_data = json.loads(body) + team_slug = team_data["slug"] + + # GitHub automatically adds the creator of the team to the list of maintainers + # Remove any member of the team right after creation again + current_members = await self.get_team_members(org_id, team_slug) + for current_member in current_members: + await self.remove_member_from_team(org_id, team_slug, current_member["login"]) + + if "members" in data: + members = data["members"] + for user in members: + await self.add_member_to_team(org_id, team_slug, user) + + _logger.debug("added team '%s'", team_name) + return team_slug + + async def update_team(self, org_id: str, team_slug: str, team: dict[str, Any]) -> None: + _logger.debug("updating team '%s' for org '%s'", team_slug, org_id) + + try: + await self.requester.request_json("PATCH", f"/orgs/{org_id}/teams/{team_slug}", team) + + if "members" in team: + await self.update_team_members(org_id, team_slug, team["members"]) + + _logger.debug("updated team '%s'", team_slug) + except GitHubException as ex: + raise RuntimeError(f"failed to update team '{team_slug}':\n{ex}") from ex + + async def get_team_members(self, org_id: str, team_slug: str) -> list[dict[str, Any]]: + _logger.debug("retrieving team members for team '%s/%s'", org_id, team_slug) + + try: + return await self.requester.request_paged_json("GET", f"/orgs/{org_id}/teams/{team_slug}/members") + except GitHubException as ex: + raise RuntimeError(f"failed retrieving team members for team '{org_id}/{team_slug}':\n{ex}") from ex + + async def update_team_members(self, org_id: str, team_slug: str, members: list[str]) -> None: + _logger.debug("updating team members for team '%s' in org '%s'", team_slug, org_id) + + current_members = {x["login"] for x in await self.get_team_members(org_id, team_slug)} + + # first, add all users that are not members yet. + for member in members: + if member in current_members: + current_members.remove(member) + else: + await self.add_member_to_team(org_id, team_slug, member) + + # second, remove the current members that are remaining. + for member in current_members: + await self.remove_member_from_team(org_id, team_slug, member) + + async def add_member_to_team(self, org_id: str, team_slug: str, user: str) -> None: + _logger.debug("adding user with id '%s' to team '%s' in org '%s'", user, team_slug, org_id) + + status, body = await self.requester.request_raw("PUT", f"/orgs/{org_id}/teams/{team_slug}/memberships/{user}") + + if status == 200: + _logger.debug("added user '%s' to team '%s' for org '%s'", user, team_slug, org_id) + else: + raise RuntimeError( + f"failed adding user '{user}' to team '{team_slug}' in org '{org_id}'" f"\n{status}: {body}" + ) + + async def remove_member_from_team(self, org_id: str, team_slug: str, user: str) -> None: + _logger.debug("removing user '%s' from team '%s' in org '%s'", user, team_slug, org_id) + + status, body = await self.requester.request_raw( + "DELETE", f"/orgs/{org_id}/teams/{team_slug}/memberships/{user}" + ) + if status != 204: + raise RuntimeError( + f"failed removing user '{user}' from team '{team_slug}' in org '{org_id}'" f"\n{status}: {body}" + ) + + _logger.debug("removed user '%s' from team '%s' in org '%s'", user, team_slug, org_id) + + async def delete_team(self, org_id: str, team_slug: str) -> None: + _logger.debug("deleting team '%s' for org '%s'", team_slug, org_id) + + status, body = await self.requester.request_raw("DELETE", f"/orgs/{org_id}/teams/{team_slug}") + + if status != 204: + raise RuntimeError(f"failed to delete team '{team_slug}': {body}") + + _logger.debug("removed team '%s'", team_slug) + async def is_user_member_of_team(self, org_id: str, team_slug: str, user: str) -> bool: _logger.debug("retrieving membership of user '%s' for team '%s' in org '%s'", user, team_slug, org_id) @@ -41,3 +158,11 @@ async def is_user_member_of_team(self, org_id: str, team_slug: str, user: str) - raise RuntimeError( f"failed retrieving team membership for user '{user}' in org '{org_id}'" f"\n{status}: {body}" ) + + async def get_membership(self, org_id: str, user_name: str) -> dict[str, Any]: + _logger.debug("retrieving membership for user '%s' in org '%s'", user_name, org_id) + + try: + return await self.requester.request_json("GET", f"/orgs/{org_id}/memberships/{user_name}") + except GitHubException as ex: + raise RuntimeError(f"failed retrieving membership for user '{user_name}' in org '{org_id}':\n{ex}") from ex diff --git a/otterdog/webapp/tasks/retrieve_team_membership.py b/otterdog/webapp/tasks/retrieve_team_membership.py index 4a357f4b..8e0e95d2 100644 --- a/otterdog/webapp/tasks/retrieve_team_membership.py +++ b/otterdog/webapp/tasks/retrieve_team_membership.py @@ -1,5 +1,5 @@ # ******************************************************************************* -# Copyright (c) 2024 Eclipse Foundation and others. +# Copyright (c) 2024-2025 Eclipse Foundation and others. # This program and the accompanying materials are made available # under the terms of the Eclipse Public License 2.0 # which is available at http://www.eclipse.org/legal/epl-v20.html @@ -73,7 +73,7 @@ async def _execute(self) -> None: user = self._pull_request.user.login try: - membership = await rest_api.org.get_membership(self.org_id, user) + membership = await rest_api.team.get_membership(self.org_id, user) state = membership["state"] role = membership["role"]