diff --git a/otterdog/models/github_organization.py b/otterdog/models/github_organization.py index 0e22a2e4..ecab5a57 100644 --- a/otterdog/models/github_organization.py +++ b/otterdog/models/github_organization.py @@ -581,8 +581,18 @@ async def _load_teams() -> None: and default_org.get_team(team_name) is None ): continue - team_members = await provider.get_org_team_members(github_id, team_slug) - team["members"] = team_members + + # Team-Sync is only available for Enterprise organizations + if org.settings.plan == "enterprise": + team_sync = await provider.get_org_team_sync_mapping(github_id, team_slug) + else: + team_sync = None + + if team_sync: + team["team_sync"] = team_sync + else: + team_members = await provider.get_org_team_members(github_id, team_slug) + team["members"] = team_members org.add_team(Team.from_provider_data(github_id, team)) else: _logger.debug("not reading teams, no default config available") diff --git a/otterdog/models/team.py b/otterdog/models/team.py index 288e0eab..015eab81 100644 --- a/otterdog/models/team.py +++ b/otterdog/models/team.py @@ -21,6 +21,7 @@ ModelObject, ValidationContext, ) +from otterdog.providers.github.rest import TeamSyncMapping from otterdog.utils import UNSET, is_set_and_valid, unwrap if TYPE_CHECKING: @@ -42,7 +43,8 @@ class Team(ModelObject, abc.ABC): description: str | None privacy: str notifications: bool - members: list[str] + team_sync: list[TeamSyncMapping] | None + members: list[str] | None # members remain unset if team-sync is enabled skip_members: bool = dataclasses.field(metadata={"model_only": True}, default=False) skip_non_organization_members: bool = dataclasses.field(metadata={"model_only": True}, default=False) @@ -55,7 +57,8 @@ def get_jsonnet_template_function(self, jsonnet_config: JsonnetConfig, extend: b def include_field_for_diff_computation(self, field: dataclasses.Field) -> bool: if field.name == "members": - return not self.skip_members + # if team-sync is enabled, members are managed externally and should not be part of diff computation + return not (self.team_sync or self.skip_members) return True @@ -98,6 +101,13 @@ def validate(self, context: ValidationContext, parent_object: Any) -> None: f"but 'members' contains user '{member}' who is not an organization member.", ) + if self.team_sync and self.members: + context.add_failure( + FailureType.ERROR, + f"{self.get_model_header(parent_object)} has 'team_sync' enabled, " + f"but 'members' is also set to {self.members}.", + ) + @classmethod def get_mapping_from_provider(cls, org_id: str, data: dict[str, Any]) -> dict[str, Any]: mapping = super().get_mapping_from_provider(org_id, data) diff --git a/otterdog/providers/github/__init__.py b/otterdog/providers/github/__init__.py index ee2c8555..8022532c 100644 --- a/otterdog/providers/github/__init__.py +++ b/otterdog/providers/github/__init__.py @@ -16,6 +16,7 @@ from importlib_resources import files from otterdog import resources +from otterdog.providers.github.rest import TeamSyncMapping from otterdog.utils import get_logger, is_ghsa_repo, is_set_and_present if TYPE_CHECKING: @@ -163,6 +164,9 @@ async def delete_org_custom_role(self, org_id: str, role_id: int, role_name: str async def get_org_teams(self, org_id: str) -> list[dict[str, Any]]: return await self.rest_api.team.get_teams(org_id) + async def get_org_team_sync_mapping(self, org_id: str, team_slug: str) -> list[TeamSyncMapping]: + return await self.rest_api.team.get_team_sync_mapping(org_id, team_slug) + async def get_org_team_members(self, org_id: str, team_slug: str) -> list[dict[str, Any]]: return await self.rest_api.team.get_team_members(org_id, team_slug) diff --git a/otterdog/providers/github/rest/__init__.py b/otterdog/providers/github/rest/__init__.py index 2bddd162..4b3c9ccc 100644 --- a/otterdog/providers/github/rest/__init__.py +++ b/otterdog/providers/github/rest/__init__.py @@ -8,6 +8,7 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import datetime from functools import cached_property from typing import TYPE_CHECKING @@ -166,3 +167,11 @@ def encrypt_value(public_key: str, secret_value: str) -> str: def parse_iso_date_string(date: str) -> datetime: return datetime.fromisoformat(date) + +@dataclass +class TeamSyncMapping: + group_id: str + group_name: str + group_description: str + status: str | None + synced_at: str | None diff --git a/otterdog/providers/github/rest/team_client.py b/otterdog/providers/github/rest/team_client.py index fdd4d728..cb65d00f 100644 --- a/otterdog/providers/github/rest/team_client.py +++ b/otterdog/providers/github/rest/team_client.py @@ -12,7 +12,7 @@ from otterdog.logging import get_logger from otterdog.providers.github.exception import GitHubException -from otterdog.providers.github.rest import RestApi, RestClient +from otterdog.providers.github.rest import RestApi, RestClient, TeamSyncMapping _logger = get_logger(__name__) @@ -99,6 +99,17 @@ async def update_team(self, org_id: str, team_slug: str, team: dict[str, Any]) - except GitHubException as ex: raise RuntimeError(f"failed to update team '{team_slug}':\n{ex}") from ex + async def get_team_sync_mapping(self, org_id: str, team_slug: str) -> list[TeamSyncMapping]: + _logger.debug("retrieving team-sync for team '%s/%s'", org_id, team_slug) + + try: + # https://docs.github.com/en/enterprise-cloud@latest/rest/teams/team-sync?apiVersion=2022-11-28#list-idp-groups-for-a-team + data: dict[str, list[dict[str, str]]] = await self.requester.request_json("GET", f"/orgs/{org_id}/teams/{team_slug}/team-sync/group-mappings") + except GitHubException as ex: + raise RuntimeError(f"failed retrieving team-sync for team '{org_id}/{team_slug}':\n{ex}") from ex + + return [TeamSyncMapping(**group) for group in data.get("groups", [])] + 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) diff --git a/otterdog/resources/schemas/team.json b/otterdog/resources/schemas/team.json index c247d5f9..f0a40535 100644 --- a/otterdog/resources/schemas/team.json +++ b/otterdog/resources/schemas/team.json @@ -9,6 +9,19 @@ "type": "array", "items": { "type": "string" } }, + "team_sync": { + "type": "array", + "items": { + "type": "object", + "properties": { + "group_id": { "type": "string" }, + "group_name": { "type": "string" }, + "group_description": { "type": "string" } + }, + "required": [ "group_id", "group_name", "group_description" ], + "additionalProperties": false + } + }, "privacy": { "type": "string" }, "notifications": { "type": "boolean" }, "skip_members": { "type": "boolean" },