From 3d1b4112d93da347de6f9b0d9d53c69056f49967 Mon Sep 17 00:00:00 2001 From: Daniel Brauer Date: Thu, 5 Dec 2024 13:58:05 -0500 Subject: [PATCH] child teams, modeled, loaded, tested, documented Signed-off-by: Daniel Brauer --- cartography/intel/github/teams.py | 100 +++++++- cartography/models/github/teams.py | 17 ++ docs/root/modules/github/schema.md | 13 + tests/data/github/teams.py | 13 + .../cartography/intel/github/test_teams.py | 14 +- .../cartography/intel/github/test_teams.py | 241 +++++++++++++++++- 6 files changed, 386 insertions(+), 12 deletions(-) diff --git a/cartography/intel/github/teams.py b/cartography/intel/github/teams.py index 4bd619f9d..1a92a6e14 100644 --- a/cartography/intel/github/teams.py +++ b/cartography/intel/github/teams.py @@ -21,6 +21,9 @@ RepoPermission = namedtuple('RepoPermission', ['repo_url', 'permission']) # A team member's role: https://docs.github.com/en/graphql/reference/enums#teammemberrole UserRole = namedtuple('UserRole', ['user_url', 'role']) +# Unlike the other tuples here, there is no qualification (like 'role' or 'permission') to the relationship. +# A child team is just a child team: https://docs.github.com/en/graphql/reference/objects#teamconnection +ChildTeam = namedtuple('ChildTeam', ['team_url']) def backoff_handler(details: Dict) -> None: @@ -53,6 +56,9 @@ def get_teams(org: str, api_url: str, token: str) -> Tuple[PaginatedGraphqlData, members(membership: IMMEDIATE) { totalCount } + childTeams { + totalCount + } } pageInfo{ endCursor @@ -252,11 +258,89 @@ def _get_team_users(org: str, api_url: str, token: str, team: str) -> PaginatedG return team_users +def _get_child_teams_for_multiple_teams( + team_raw_data: list[dict[str, Any]], + org: str, + api_url: str, + token: str, +) -> dict[str, list[ChildTeam]]: + result: dict[str, list[ChildTeam]] = {} + for team in team_raw_data: + team_name = team['slug'] + team_count = team['childTeams']['totalCount'] + + if team_count == 0: + # This team has no child teams so let's move on + result[team_name] = [] + continue + + team_urls: List[str] = [] + + def get_child_teams_inner_func( + org: str, api_url: str, token: str, team_name: str, team_urls: List[str], + ) -> None: + logger.info(f"Loading child teams for {team_name}.") + child_teams = _get_child_teams(org, api_url, token, team_name) + # The `or []` is because `.nodes` can be None. See: + # https://docs.github.com/en/graphql/reference/objects#teammemberconnection + for cteam in child_teams.nodes or []: + team_urls.append(cteam['url']) + # No edges to process here, the GitHub response for child teams has no relevant info in edges. + + retries_with_backoff(get_child_teams_inner_func, TypeError, 5, backoff_handler)( + org=org, api_url=api_url, token=token, team_name=team_name, team_urls=team_urls, + ) + + result[team_name] = [ChildTeam(url) for url in team_urls] + return result + + +def _get_child_teams(org: str, api_url: str, token: str, team: str) -> PaginatedGraphqlData: + team_users_gql = """ + query($login: String!, $team: String!, $cursor: String) { + organization(login: $login) { + url + login + team(slug: $team) { + slug + childTeams(first: 100, after: $cursor) { + totalCount + nodes { + url + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + rateLimit { + limit + cost + remaining + resetAt + } + } + """ + team_users, _ = fetch_all( + token, + api_url, + org, + team_users_gql, + 'team', + resource_inner_type='childTeams', + team=team, + ) + return team_users + + def transform_teams( team_paginated_data: PaginatedGraphqlData, org_data: Dict[str, Any], team_repo_data: dict[str, list[RepoPermission]], team_user_data: dict[str, list[UserRole]], + team_child_team_data: dict[str, list[ChildTeam]], ) -> list[dict[str, Any]]: result = [] for team in team_paginated_data.nodes: @@ -267,13 +351,15 @@ def transform_teams( 'description': team['description'], 'repo_count': team['repositories']['totalCount'], 'member_count': team['members']['totalCount'], + 'child_team_count': team['childTeams']['totalCount'], 'org_url': org_data['url'], 'org_login': org_data['login'], } repo_permissions = team_repo_data[team_name] user_roles = team_user_data[team_name] + child_teams = team_child_team_data[team_name] - if not repo_permissions and not user_roles: + if not repo_permissions and not user_roles and not child_teams: result.append(repo_info) continue @@ -289,6 +375,15 @@ def transform_teams( repo_info_copy = repo_info.copy() repo_info_copy[role] = user_url result.append(repo_info_copy) + if child_teams: + for (team_url,) in child_teams: + repo_info_copy = repo_info.copy() + # GitHub does not itself seem to have a label to the team-childTeam relationship. But elsewhere, it + # does distinguish between team members who are in a team directly or via a child team: + # https://docs.github.com/en/graphql/reference/enums#teammembershiptype + # We borrow the 'CHILD_TEAM' label from there, as it seems to be the most appropriate to use here. + repo_info_copy['CHILD_TEAM'] = team_url + result.append(repo_info_copy) return result @@ -325,7 +420,8 @@ def sync_github_teams( teams_paginated, org_data = get_teams(organization, github_url, github_api_key) team_repos = _get_team_repos_for_multiple_teams(teams_paginated.nodes, organization, github_url, github_api_key) team_users = _get_team_users_for_multiple_teams(teams_paginated.nodes, organization, github_url, github_api_key) - processed_data = transform_teams(teams_paginated, org_data, team_repos, team_users) + team_children = _get_child_teams_for_multiple_teams(teams_paginated.nodes, organization, github_url, github_api_key) + processed_data = transform_teams(teams_paginated, org_data, team_repos, team_users, team_children) load_team_repos(neo4j_session, processed_data, common_job_parameters['UPDATE_TAG'], org_data['url']) common_job_parameters['org_url'] = org_data['url'] cleanup(neo4j_session, common_job_parameters) diff --git a/cartography/models/github/teams.py b/cartography/models/github/teams.py index 2f3376870..c49e51f4a 100644 --- a/cartography/models/github/teams.py +++ b/cartography/models/github/teams.py @@ -123,6 +123,22 @@ class GitHubTeamToOrganizationRel(CartographyRelSchema): properties: GitHubTeamToOrganizationRelProperties = GitHubTeamToOrganizationRelProperties() +@dataclass(frozen=True) +class GitHubTeamToChildTeamRelProperties(CartographyRelProperties): + lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True) + + +@dataclass(frozen=True) +class GitHubTeamChildTeamRel(CartographyRelSchema): + target_node_label: str = 'GitHubTeam' + target_node_matcher: TargetNodeMatcher = make_target_node_matcher( + {'id': PropertyRef('CHILD_TEAM')}, + ) + direction: LinkDirection = LinkDirection.INWARD + rel_label: str = "CHILD_TEAM" + properties: GitHubTeamToChildTeamRelProperties = GitHubTeamToChildTeamRelProperties() + + @dataclass(frozen=True) class GitHubTeamSchema(CartographyNodeSchema): label: str = 'GitHubTeam' @@ -136,6 +152,7 @@ class GitHubTeamSchema(CartographyNodeSchema): GitHubTeamWriteRepoRel(), GitHubTeamMaintainerUserRel(), GitHubTeamMemberUserRel(), + GitHubTeamChildTeamRel(), ], ) sub_resource_relationship: GitHubTeamToOrganizationRel = GitHubTeamToOrganizationRel() diff --git a/docs/root/modules/github/schema.md b/docs/root/modules/github/schema.md index 61bd519e8..5bcda9db2 100644 --- a/docs/root/modules/github/schema.md +++ b/docs/root/modules/github/schema.md @@ -140,12 +140,19 @@ A GitHubTeam [organization object](https://docs.github.com/en/graphql/reference/ (GitHubOrganization)-[RESOURCE]->(GitHubTeam) ``` +- GitHubTeams may be children of other teams: + + ``` + (GitHubTeam)-[CHILD_TEAM]->(GitHubTeam) + ``` + - GitHubUsers may be ['immediate'](https://docs.github.com/en/graphql/reference/enums#teammembershiptype) members of a team (as opposed to being members via membership in a child team), with their membership [role](https://docs.github.com/en/graphql/reference/enums#teammemberrole) being MEMBER or MAINTAINER. ``` (GitHubUser)-[MEMBER|MAINTAINER]->(GitHubTeam) ``` + ### GitHubUser Representation of a single GitHubUser [user object](https://developer.github.com/v4/object/user/). This node contains minimal data for the GitHub User. @@ -205,6 +212,12 @@ WRITE, MAINTAIN, TRIAGE, and READ ([Reference](https://docs.github.com/en/graphq (GitHubUser)-[UNAFFILIATED]->(GitHubOrganization) ``` +- GitHubTeams may be children of other teams: + + ``` + (GitHubTeam)-[CHILD_TEAM]->(GitHubTeam) + ``` + - GitHubUsers may be ['immediate'](https://docs.github.com/en/graphql/reference/enums#teammembershiptype) members of a team (as opposed to being members via membership in a child team), with their membership [role](https://docs.github.com/en/graphql/reference/enums#teammemberrole) being MEMBER or MAINTAINER. ``` diff --git a/tests/data/github/teams.py b/tests/data/github/teams.py index debf40407..dae7b330a 100644 --- a/tests/data/github/teams.py +++ b/tests/data/github/teams.py @@ -9,6 +9,7 @@ 'description': None, 'repositories': {'totalCount': 0}, 'members': {'totalCount': 0}, + 'childTeams': {'totalCount': 0}, }, { 'slug': 'team-b', @@ -16,6 +17,7 @@ 'description': None, 'repositories': {'totalCount': 3}, 'members': {'totalCount': 0}, + 'childTeams': {'totalCount': 0}, }, { 'slug': 'team-c', @@ -23,6 +25,7 @@ 'description': None, 'repositories': {'totalCount': 0}, 'members': {'totalCount': 3}, + 'childTeams': {'totalCount': 0}, }, { 'slug': 'team-d', @@ -30,6 +33,7 @@ 'description': 'Team D', 'repositories': {'totalCount': 0}, 'members': {'totalCount': 0}, + 'childTeams': {'totalCount': 2}, }, { 'slug': 'team-e', @@ -37,6 +41,7 @@ 'description': 'some description here', 'repositories': {'totalCount': 0}, 'members': {'totalCount': 0}, + 'childTeams': {'totalCount': 0}, }, ], edges=[], @@ -71,3 +76,11 @@ {'role': 'MAINTAINER'}, ], ) + +GH_TEAM_CHILD_TEAM = PaginatedGraphqlData( + nodes=[ + {'url': 'https://github.com/orgs/example_org/teams/team-a'}, + {'url': 'https://github.com/orgs/example_org/teams/team-b'}, + ], + edges=[], +) diff --git a/tests/integration/cartography/intel/github/test_teams.py b/tests/integration/cartography/intel/github/test_teams.py index 6583c1f50..c557b9461 100644 --- a/tests/integration/cartography/intel/github/test_teams.py +++ b/tests/integration/cartography/intel/github/test_teams.py @@ -2,6 +2,7 @@ import cartography.intel.github.teams from cartography.intel.github.teams import sync_github_teams +from tests.data.github.teams import GH_TEAM_CHILD_TEAM from tests.data.github.teams import GH_TEAM_DATA from tests.data.github.teams import GH_TEAM_REPOS from tests.data.github.teams import GH_TEAM_USERS @@ -16,10 +17,11 @@ FAKE_API_KEY = 'asdf' +@patch.object(cartography.intel.github.teams, '_get_child_teams', return_value=GH_TEAM_CHILD_TEAM) @patch.object(cartography.intel.github.teams, '_get_team_users', return_value=GH_TEAM_USERS) @patch.object(cartography.intel.github.teams, '_get_team_repos', return_value=GH_TEAM_REPOS) @patch.object(cartography.intel.github.teams, 'get_teams', return_value=GH_TEAM_DATA) -def test_sync_github_teams(mock_teams, mock_team_repos, mock_team_users, neo4j_session): +def test_sync_github_teams(mock_teams, mock_team_repos, mock_team_users, mock_child_teams, neo4j_session): # Arrange test_repos._ensure_local_neo4j_has_test_data(neo4j_session) test_users._ensure_local_neo4j_has_test_data(neo4j_session) @@ -139,3 +141,13 @@ def test_sync_github_teams(mock_teams, mock_team_repos, mock_team_users, neo4j_s ('https://github.com/orgs/example_org/teams/team-c', 'https://example.com/lmsimpson'), ('https://github.com/orgs/example_org/teams/team-c', 'https://example.com/mbsimpson'), } + assert check_rels( + neo4j_session, + 'GitHubTeam', 'id', + 'GitHubTeam', 'id', + 'CHILD_TEAM', + rel_direction_right=False, + ) == { + ('https://github.com/orgs/example_org/teams/team-d', 'https://github.com/orgs/example_org/teams/team-a'), + ('https://github.com/orgs/example_org/teams/team-d', 'https://github.com/orgs/example_org/teams/team-b'), + } diff --git a/tests/unit/cartography/intel/github/test_teams.py b/tests/unit/cartography/intel/github/test_teams.py index 7fd2c6c83..a44de9717 100644 --- a/tests/unit/cartography/intel/github/test_teams.py +++ b/tests/unit/cartography/intel/github/test_teams.py @@ -3,8 +3,10 @@ import pytest +from cartography.intel.github.teams import _get_child_teams_for_multiple_teams from cartography.intel.github.teams import _get_team_repos_for_multiple_teams from cartography.intel.github.teams import _get_team_users_for_multiple_teams +from cartography.intel.github.teams import ChildTeam from cartography.intel.github.teams import RepoPermission from cartography.intel.github.teams import transform_teams from cartography.intel.github.teams import UserRole @@ -40,6 +42,18 @@ def test_get_team_users_empty_team_list(mock_get_team_users): mock_get_team_users.assert_not_called() +@patch('cartography.intel.github.teams._get_child_teams') +def test_get_child_teams_empty_team_list(mock_get_child_teams): + # Assert that if we pass in empty data then we get back empty data + assert _get_child_teams_for_multiple_teams( + [], + 'test-org', + 'https://api.github.com', + 'test-token', + ) == {} + mock_get_child_teams.assert_not_called() + + @patch('cartography.intel.github.teams._get_team_repos') def test_get_team_repos_team_with_no_repos(mock_get_team_repos): # Arrange @@ -70,6 +84,21 @@ def test_get_team_users_team_with_no_users(mock_get_team_users): mock_get_team_users.assert_not_called() +@patch('cartography.intel.github.teams._get_child_teams') +def test_get_team_with_no_child_teams(mock_get_child_teams): + # Arrange + team_data = [{'slug': 'team1', 'childTeams': {'totalCount': 0}}] + + # Assert that we retrieve data where a team has no repos + assert _get_child_teams_for_multiple_teams( + team_data, + 'test-org', + 'https://api.github.com', + 'test-token', + ) == {'team1': []} + mock_get_child_teams.assert_not_called() + + @patch('cartography.intel.github.teams._get_team_repos') def test_get_team_repos_happy_path(mock_get_team_repos): # Arrange @@ -122,7 +151,34 @@ def test_get_team_users_happy_path(mock_get_team_users): mock_get_team_users.assert_called_once_with('test-org', 'https://api.github.com', 'test-token', 'team1') -@patch('time.sleep', return_value=None) +@patch('cartography.intel.github.teams._get_child_teams') +def test_get_child_teams_happy_path(mock_get_child_teams): + # Arrange + team_data = [{'slug': 'team1', 'childTeams': {'totalCount': 2}}] + mock_child_teams = MagicMock() + mock_child_teams.nodes = [ + {'url': 'https://github.com/orgs/foo/teams/team1'}, {'url': 'https://github.com/orgs/foo/teams/team2'}, + ] + mock_child_teams.edges = [] + mock_get_child_teams.return_value = mock_child_teams + + # Act + assert that the returned data is correct + assert _get_child_teams_for_multiple_teams( + team_data, + 'test-org', + 'https://api.github.com', + 'test-token', + ) == { + 'team1': [ + ChildTeam('https://github.com/orgs/foo/teams/team1'), + ChildTeam('https://github.com/orgs/foo/teams/team2'), + ], + } + + # Assert that we did not retry because this was the happy path + mock_get_child_teams.assert_called_once_with('test-org', 'https://api.github.com', 'test-token', 'team1') + + @patch('cartography.intel.github.teams._get_team_repos') @patch('cartography.intel.github.teams.backoff_handler', spec=True) def test_get_team_repos_github_returns_none(mock_backoff_handler, mock_get_team_repos, mock_sleep): @@ -176,17 +232,45 @@ def test_get_team_users_github_returns_none(mock_backoff_handler, mock_get_team_ assert mock_backoff_handler.call_count == 4 +@patch('cartography.intel.github.teams._get_child_teams') +@patch('cartography.intel.github.teams.backoff_handler', spec=True) +def test_get_child_teams_github_returns_none(mock_backoff_handler, mock_get_child_teams): + # Arrange + team_data = [{'slug': 'team1', 'childTeams': {'totalCount': 1}}] + mock_child_teams = MagicMock() + # Set up the condition where GitHub returns a None url and None edge as in #1334. + mock_child_teams.nodes = [None] + mock_child_teams.edges = [None] + mock_get_child_teams.return_value = mock_child_teams + + # Assert we raise an exception + with pytest.raises(TypeError): + _get_child_teams_for_multiple_teams( + team_data, + 'test-org', + 'https://api.github.com', + 'test-token', + ) + + # Assert that we retry and give up + assert mock_get_child_teams.call_count == 5 + assert mock_backoff_handler.call_count == 4 + + def test_transform_teams_empty_team_data(): # Arrange team_paginated_data = PaginatedGraphqlData(nodes=[], edges=[]) team_repo_data: dict[str, list[RepoPermission]] = {} team_user_data: dict[str, list[UserRole]] = {} + team_child_team_data: dict[str, list[ChildTeam]] = {} # Act + assert - assert transform_teams(team_paginated_data, TEST_ORG_DATA, team_repo_data, team_user_data) == [] + assert transform_teams( + team_paginated_data, TEST_ORG_DATA, team_repo_data, team_user_data, team_child_team_data, + ) == [] -def test_transform_teams_team_with_no_repos_no_users(): +def test_transform_teams_team_with_no_relationships(): # Arrange team_paginated_data = PaginatedGraphqlData( nodes=[ @@ -196,21 +280,26 @@ def test_transform_teams_team_with_no_repos_no_users(): 'description': 'Test Team 1', 'repositories': {'totalCount': 0}, 'members': {'totalCount': 0}, + 'childTeams': {'totalCount': 0}, }, ], edges=[], ) team_repo_data = {'team1': []} team_user_data = {'team1': []} + team_child_team_data = {'team1': []} # Act + Assert - assert transform_teams(team_paginated_data, TEST_ORG_DATA, team_repo_data, team_user_data) == [ + assert transform_teams( + team_paginated_data, TEST_ORG_DATA, team_repo_data, team_user_data, team_child_team_data, + ) == [ { 'name': 'team1', 'url': 'https://github.com/testorg/team1', 'description': 'Test Team 1', 'repo_count': 0, 'member_count': 0, + 'child_team_count': 0, 'org_url': 'https://github.com/testorg', 'org_login': 'testorg', }, @@ -227,6 +316,7 @@ def test_transform_teams_team_with_repos(): 'description': 'Test Team 1', 'repositories': {'totalCount': 2}, 'members': {'totalCount': 0}, + 'childTeams': {'totalCount': 0}, }, ], edges=[], @@ -238,15 +328,19 @@ def test_transform_teams_team_with_repos(): ], } team_user_data = {'team1': []} + team_child_team_data = {'team1': []} # Act - assert transform_teams(team_paginated_data, TEST_ORG_DATA, team_repo_data, team_user_data) == [ + assert transform_teams( + team_paginated_data, TEST_ORG_DATA, team_repo_data, team_user_data, team_child_team_data, + ) == [ { 'name': 'team1', 'url': 'https://github.com/testorg/team1', 'description': 'Test Team 1', 'repo_count': 2, 'member_count': 0, + 'child_team_count': 0, 'org_url': 'https://github.com/testorg', 'org_login': 'testorg', 'READ': 'https://github.com/testorg/repo1', @@ -257,6 +351,7 @@ def test_transform_teams_team_with_repos(): 'description': 'Test Team 1', 'repo_count': 2, 'member_count': 0, + 'child_team_count': 0, 'org_url': 'https://github.com/testorg', 'org_login': 'testorg', 'WRITE': 'https://github.com/testorg/repo2', @@ -274,6 +369,7 @@ def test_transform_teams_team_with_members(): 'description': 'Test Team 1', 'repositories': {'totalCount': 0}, 'members': {'totalCount': 2}, + 'childTeams': {'totalCount': 0}, }, ], edges=[], @@ -285,15 +381,19 @@ def test_transform_teams_team_with_members(): UserRole('https://github.com/user2', 'MAINTAINER'), ], } + team_child_team_data = {'team1': []} # Act - assert transform_teams(team_paginated_data, TEST_ORG_DATA, team_repo_data, team_user_data) == [ + assert transform_teams( + team_paginated_data, TEST_ORG_DATA, team_repo_data, team_user_data, team_child_team_data, + ) == [ { 'name': 'team1', 'url': 'https://github.com/testorg/team1', 'description': 'Test Team 1', 'repo_count': 0, 'member_count': 2, + 'child_team_count': 0, 'org_url': 'https://github.com/testorg', 'org_login': 'testorg', 'MEMBER': 'https://github.com/user1', @@ -304,6 +404,7 @@ def test_transform_teams_team_with_members(): 'description': 'Test Team 1', 'repo_count': 0, 'member_count': 2, + 'child_team_count': 0, 'org_url': 'https://github.com/testorg', 'org_login': 'testorg', 'MAINTAINER': 'https://github.com/user2', @@ -311,7 +412,60 @@ def test_transform_teams_team_with_members(): ] -def test_transform_teams_team_with_repos_and_members(): +def test_transform_teams_team_with_child_teams(): + # Arrange + team_paginated_data = PaginatedGraphqlData( + nodes=[ + { + 'slug': 'team1', + 'url': 'https://github.com/testorg/team1', + 'description': 'Test Team 1', + 'repositories': {'totalCount': 0}, + 'members': {'totalCount': 0}, + 'childTeams': {'totalCount': 2}, + }, + ], + edges=[], + ) + team_repo_data = {'team1': []} + team_user_data = {'team1': []} + team_child_team_data = { + 'team1': [ + ChildTeam('https://github.com/orgs/foo/teams/team1'), + ChildTeam('https://github.com/orgs/foo/teams/team2'), + ], + } + + # Act + assert transform_teams( + team_paginated_data, TEST_ORG_DATA, team_repo_data, team_user_data, team_child_team_data, + ) == [ + { + 'name': 'team1', + 'url': 'https://github.com/testorg/team1', + 'description': 'Test Team 1', + 'repo_count': 0, + 'member_count': 0, + 'child_team_count': 2, + 'org_url': 'https://github.com/testorg', + 'org_login': 'testorg', + 'CHILD_TEAM': 'https://github.com/orgs/foo/teams/team1', + }, + { + 'name': 'team1', + 'url': 'https://github.com/testorg/team1', + 'description': 'Test Team 1', + 'repo_count': 0, + 'member_count': 0, + 'child_team_count': 2, + 'org_url': 'https://github.com/testorg', + 'org_login': 'testorg', + 'CHILD_TEAM': 'https://github.com/orgs/foo/teams/team2', + }, + ] + + +def test_transform_teams_team_with_all_the_relationships(): # Arrange team_paginated_data = PaginatedGraphqlData( nodes=[ @@ -321,6 +475,7 @@ def test_transform_teams_team_with_repos_and_members(): 'description': 'Test Team 1', 'repositories': {'totalCount': 2}, 'members': {'totalCount': 2}, + 'childTeams': {'totalCount': 2}, }, ], edges=[], @@ -337,15 +492,24 @@ def test_transform_teams_team_with_repos_and_members(): UserRole('https://github.com/user2', 'MAINTAINER'), ], } + team_child_team_data = { + 'team1': [ + ChildTeam('https://github.com/orgs/foo/teams/team1'), + ChildTeam('https://github.com/orgs/foo/teams/team2'), + ], + } # Act - assert transform_teams(team_paginated_data, TEST_ORG_DATA, team_repo_data, team_user_data) == [ + assert transform_teams( + team_paginated_data, TEST_ORG_DATA, team_repo_data, team_user_data, team_child_team_data, + ) == [ { 'name': 'team1', 'url': 'https://github.com/testorg/team1', 'description': 'Test Team 1', 'repo_count': 2, 'member_count': 2, + 'child_team_count': 2, 'org_url': 'https://github.com/testorg', 'org_login': 'testorg', 'READ': 'https://github.com/testorg/repo1', @@ -356,6 +520,7 @@ def test_transform_teams_team_with_repos_and_members(): 'description': 'Test Team 1', 'repo_count': 2, 'member_count': 2, + 'child_team_count': 2, 'org_url': 'https://github.com/testorg', 'org_login': 'testorg', 'WRITE': 'https://github.com/testorg/repo2', @@ -366,6 +531,7 @@ def test_transform_teams_team_with_repos_and_members(): 'description': 'Test Team 1', 'repo_count': 2, 'member_count': 2, + 'child_team_count': 2, 'org_url': 'https://github.com/testorg', 'org_login': 'testorg', 'MEMBER': 'https://github.com/user1', @@ -376,10 +542,33 @@ def test_transform_teams_team_with_repos_and_members(): 'description': 'Test Team 1', 'repo_count': 2, 'member_count': 2, + 'child_team_count': 2, 'org_url': 'https://github.com/testorg', 'org_login': 'testorg', 'MAINTAINER': 'https://github.com/user2', }, + { + 'name': 'team1', + 'url': 'https://github.com/testorg/team1', + 'description': 'Test Team 1', + 'repo_count': 2, + 'member_count': 2, + 'child_team_count': 2, + 'org_url': 'https://github.com/testorg', + 'org_login': 'testorg', + 'CHILD_TEAM': 'https://github.com/orgs/foo/teams/team1', + }, + { + 'name': 'team1', + 'url': 'https://github.com/testorg/team1', + 'description': 'Test Team 1', + 'repo_count': 2, + 'member_count': 2, + 'child_team_count': 2, + 'org_url': 'https://github.com/testorg', + 'org_login': 'testorg', + 'CHILD_TEAM': 'https://github.com/orgs/foo/teams/team2', + }, ] @@ -393,6 +582,7 @@ def test_transform_teams_multiple_teams(): 'description': 'Test Team 1', 'repositories': {'totalCount': 1}, 'members': {'totalCount': 0}, + 'childTeams': {'totalCount': 0}, }, { 'slug': 'team2', @@ -400,6 +590,15 @@ def test_transform_teams_multiple_teams(): 'description': 'Test Team 2', 'repositories': {'totalCount': 0}, 'members': {'totalCount': 1}, + 'childTeams': {'totalCount': 0}, + }, + { + 'slug': 'team3', + 'url': 'https://github.com/testorg/team3', + 'description': 'Test Team 3', + 'repositories': {'totalCount': 0}, + 'members': {'totalCount': 0}, + 'childTeams': {'totalCount': 1}, }, ], edges=[], @@ -409,22 +608,34 @@ def test_transform_teams_multiple_teams(): RepoPermission('https://github.com/testorg/repo1', 'ADMIN'), ], 'team2': [], + 'team3': [], } team_user_data = { 'team1': [], 'team2': [ UserRole('https://github.com/user1', 'MEMBER'), ], + 'team3': [], + } + team_child_team_data = { + 'team1': [], + 'team2': [], + 'team3': [ + ChildTeam('https://github.com/testorg/team3'), + ], } # Act + assert - assert transform_teams(team_paginated_data, TEST_ORG_DATA, team_repo_data, team_user_data) == [ + assert transform_teams( + team_paginated_data, TEST_ORG_DATA, team_repo_data, team_user_data, team_child_team_data, + ) == [ { 'name': 'team1', 'url': 'https://github.com/testorg/team1', 'description': 'Test Team 1', 'repo_count': 1, 'member_count': 0, + 'child_team_count': 0, 'org_url': 'https://github.com/testorg', 'org_login': 'testorg', 'ADMIN': 'https://github.com/testorg/repo1', @@ -435,8 +646,20 @@ def test_transform_teams_multiple_teams(): 'description': 'Test Team 2', 'repo_count': 0, 'member_count': 1, + 'child_team_count': 0, 'org_url': 'https://github.com/testorg', 'org_login': 'testorg', 'MEMBER': 'https://github.com/user1', }, + { + 'name': 'team3', + 'url': 'https://github.com/testorg/team3', + 'description': 'Test Team 3', + 'repo_count': 0, + 'member_count': 0, + 'child_team_count': 1, + 'org_url': 'https://github.com/testorg', + 'org_login': 'testorg', + 'CHILD_TEAM': 'https://github.com/testorg/team3', + }, ]