github-org-manager 0.5.4__tar.gz → 0.5.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: github-org-manager
3
- Version: 0.5.4
3
+ Version: 0.5.6
4
4
  Summary: Manage a GitHub Organization, its teams, repository permissions, and more
5
5
  Home-page: https://github.com/OpenRailAssociation/github-org-manager
6
6
  License: Apache-2.0
@@ -40,6 +40,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
40
40
  configured_teams: dict[str, dict | None] = field(default_factory=dict)
41
41
  newly_added_users: list[NamedUser] = field(default_factory=list)
42
42
  current_repos_teams: dict[Repository, dict[Team, str]] = field(default_factory=dict)
43
+ graphql_repos_collaborators: dict[str, list[dict]] = field(default_factory=dict)
43
44
  current_repos_collaborators: dict[Repository, dict[str, str]] = field(default_factory=dict)
44
45
  configured_repos_collaborators: dict[str, dict[str, str]] = field(default_factory=dict)
45
46
  archived_repos: list[Repository] = field(default_factory=list)
@@ -77,11 +78,15 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
77
78
 
78
79
  # Decide how to login. If app set, prefer this
79
80
  if self.gh_app_id and self.gh_app_private_key:
80
- logging.debug("Logged in via app %s", self.gh_app_id)
81
+ logging.debug("Logging in via app %s", self.gh_app_id)
81
82
  auth = Auth.AppAuth(app_id=self.gh_app_id, private_key=self.gh_app_private_key)
82
83
  app = GithubIntegration(auth=auth)
83
- installation = app.get_installations()[0]
84
+ installation = app.get_org_installation(org=orgname)
84
85
  self.gh = installation.get_github_for_installation()
86
+ logging.debug("Logged in via app installation %s", installation.id)
87
+
88
+ logging.debug("Getting access token for installation %s", installation.id)
89
+ self.gh_token = app.get_access_token(installation_id=installation.id).token
85
90
  elif self.gh_token:
86
91
  logging.debug("Logging in as user with PAT")
87
92
  self.gh = Github(auth=Auth.Token(self.gh_token))
@@ -90,6 +95,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
90
95
  logging.error("No GitHub token or App ID+private key provided")
91
96
  sys.exit(1)
92
97
 
98
+ logging.debug("Gathering data from organization '%s'", orgname)
93
99
  self.org = self.gh.get_organization(orgname)
94
100
  logging.debug("Gathered data from organization '%s' (%s)", self.org.login, self.org.name)
95
101
 
@@ -110,7 +116,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
110
116
  half2 = len(string) - half1
111
117
  return string[:half1] + "*" * (half2)
112
118
 
113
- sensible_keys = ["gh_token"]
119
+ sensible_keys = ["gh_token", "gh_app_private_key"]
114
120
  for key in sensible_keys:
115
121
  if value := dictionary.get(key, ""):
116
122
  dictionary[key] = censor_half_string(value)
@@ -702,7 +708,25 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
702
708
  continue
703
709
 
704
710
  # Convert team name to Team object
705
- team = self.org.get_team_by_slug(self._sluggify_teamname(team_name))
711
+ try:
712
+ team = self.org.get_team_by_slug(self._sluggify_teamname(team_name))
713
+ # Team not found, probably because a new team should be created, but it's a dry-run
714
+ except UnknownObjectException:
715
+ logging.debug(
716
+ "Team %s not found, probably because it should be created but it's a dry-run",
717
+ team_name,
718
+ )
719
+ # Initialise a new Team() object with the name, manually
720
+ team = Team(
721
+ requester=None, # type: ignore
722
+ headers={}, # No headers required
723
+ attributes={
724
+ "id": 0,
725
+ "name": team_name,
726
+ "slug": self._sluggify_teamname(team_name),
727
+ },
728
+ completed=True, # Mark as fully initialized
729
+ )
706
730
 
707
731
  # Get configured repo permissions
708
732
  for repo, perm in team_attrs.get("repos", {}).items():
@@ -941,59 +965,220 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
941
965
 
942
966
  return permission
943
967
 
944
- def _fetch_collaborators_of_repo(self, repo: Repository):
945
- """Get all collaborators (individuals) of a GitHub repo with their
946
- permissions using the GraphQL API"""
947
- # TODO: Consider doing this for all repositories at once, but calculate
948
- # costs beforehand
968
+ def _fetch_collaborators_of_all_organization_repos(self) -> None:
969
+ """Get all collaborators (individuals) of all repos of a GitHub
970
+ organization with their permissions using the GraphQL API"""
971
+
949
972
  graphql_query = """
950
- query($owner: String!, $name: String!, $cursor: String) {
951
- repository(owner: $owner, name: $name) {
952
- collaborators(first: 100, after: $cursor) {
973
+ query($owner: String!, $cursor: String) {
974
+ organization(login: $owner) {
975
+ repositories(first: 100, after: $cursor) {
953
976
  edges {
954
977
  node {
955
- login
978
+ name
979
+ collaborators(first: 100) {
980
+ edges {
981
+ node {
982
+ login
983
+ }
984
+ permission
985
+ }
986
+ pageInfo {
987
+ endCursor
988
+ hasNextPage
989
+ }
990
+ }
956
991
  }
957
- permission
958
992
  }
959
993
  pageInfo {
960
994
  endCursor
961
995
  hasNextPage
962
996
  }
997
+ }
963
998
  }
964
999
  }
965
- }
966
1000
  """
967
1001
 
968
- # Initial query parameters
969
- variables = {"owner": self.org.login, "name": repo.name, "cursor": None}
1002
+ # Initial query parameters for org-level request
1003
+ variables = {"owner": self.org.login, "cursor": None}
1004
+
1005
+ # dict in which we store repos for which there are more than 100
1006
+ # collaborators, and their respective end cursors
1007
+ next_page_cursors_for_repos: dict[str, str] = {}
1008
+
1009
+ more_repos_in_org = True
1010
+ while more_repos_in_org:
1011
+ logging.debug("Requesting collaborators for %s", self.org.login)
1012
+ org_result = run_graphql_query(graphql_query, variables, self.gh_token)
1013
+ more_repos_in_org, variables["cursor"] = self._extract_data_from_graphql_response(
1014
+ graphql_response=org_result, next_page_cursors_for_repos=next_page_cursors_for_repos
1015
+ )
1016
+
1017
+ # If there are more than 100 collaborators in a repo, we need to fetch
1018
+ # rest via individual GraphQL queries
1019
+ if next_page_cursors_for_repos:
1020
+ logging.debug(
1021
+ "Not all collaborators of all repos have been fetched. Missing data: %s",
1022
+ next_page_cursors_for_repos,
1023
+ )
1024
+ for repo_name, end_cursor in next_page_cursors_for_repos.items():
1025
+ more_collaborators_in_repo = True
1026
+ while more_collaborators_in_repo:
1027
+ logging.debug("Requesting additional collaborators for repo %s", repo_name)
1028
+ # Initial query parameters for repo-level request
1029
+ repo_variables = {
1030
+ "owner": self.org.login,
1031
+ "repo": repo_name,
1032
+ "cursor": end_cursor,
1033
+ }
1034
+ repo_query = """
1035
+ query($owner: String!, $repo: String!, $cursor: String) {
1036
+ repository(owner: $owner, name: $repo) {
1037
+ collaborators(first: 100, after: $cursor) {
1038
+ edges {
1039
+ node {
1040
+ login
1041
+ }
1042
+ permission
1043
+ }
1044
+ pageInfo {
1045
+ endCursor
1046
+ hasNextPage
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+ """
1052
+ repo_result = run_graphql_query(repo_query, repo_variables, self.gh_token)
1053
+ more_collaborators_in_repo, end_cursor = (
1054
+ self._extract_data_from_graphql_response(
1055
+ graphql_response=repo_result,
1056
+ next_page_cursors_for_repos=next_page_cursors_for_repos,
1057
+ single_repo_name=repo_name,
1058
+ )
1059
+ )
970
1060
 
971
- collaborators = []
972
- has_next_page = True
1061
+ # All collaborators from all repos have been fetched, now populate the
1062
+ # actual dictionary
1063
+ self._populate_current_repos_collaborators()
1064
+
1065
+ def _extract_data_from_graphql_response(
1066
+ self,
1067
+ graphql_response: dict,
1068
+ next_page_cursors_for_repos: dict[str, str],
1069
+ single_repo_name: str = "",
1070
+ ) -> tuple[bool, str]:
1071
+ """
1072
+ Extracts collaborator data from a GraphQL response for either an
1073
+ organization or a single repository.
1074
+
1075
+ Args:
1076
+ graphql_response (dict): The GraphQL response containing the data.
1077
+ next_page_cursors_for_repos (dict[str, str]): A dictionary to store
1078
+ the next page cursors for repositories.
1079
+ single_repo_name (str, optional): The name of a single repository to
1080
+ extract data for. Defaults to "".
973
1081
 
974
- while has_next_page:
975
- logging.debug("Requesting collaborators for %s", repo.name)
976
- result = run_graphql_query(graphql_query, variables, self.gh_token)
1082
+ Returns:
1083
+ tuple[bool, str]: A tuple containing a boolean indicating if there
1084
+ is a next page and a string for the cursor.
1085
+ - For organization level extraction:
1086
+ - bool: Indicates if there is a next page of repositories.
1087
+ - str: The cursor for the next page of repositories.
1088
+ - For single repository extraction:
1089
+ - bool: Indicates if there is a next page of collaborators.
1090
+ - str: The cursor for the next page of collaborators.
1091
+
1092
+ Raises:
1093
+ SystemExit: If a repository name is not found in the GraphQL
1094
+ response at the organization level.
1095
+
1096
+ This method processes the GraphQL response to extract information about
1097
+ repositories and their collaborators. It handles pagination by
1098
+ identifying if there are more pages of repositories or collaborators to
1099
+ be fetched.
1100
+ """
1101
+ if not single_repo_name and "organization" in graphql_response["data"]:
1102
+ logging.debug("Extracting collaborators for organization from GraphQL response")
1103
+
1104
+ # Initialise returns
1105
+ org_has_next_page = graphql_response["data"]["organization"]["repositories"][
1106
+ "pageInfo"
1107
+ ]["hasNextPage"]
1108
+ org_cursor = graphql_response["data"]["organization"]["repositories"]["pageInfo"][
1109
+ "endCursor"
1110
+ ]
1111
+
1112
+ for repo_edges in graphql_response["data"]["organization"]["repositories"]["edges"]:
1113
+ try:
1114
+ repo_name: str = repo_edges["node"]["name"]
1115
+ logging.debug(
1116
+ "Extracting collaborators for %s from GraphQL response", repo_name
1117
+ )
1118
+ except KeyError:
1119
+ logging.error(
1120
+ "Did not find a repo name in the GraphQL response "
1121
+ "(organization level) which seems to hint to a bug: %s",
1122
+ repo_edges,
1123
+ )
1124
+ sys.exit(1)
1125
+
1126
+ # fill in collaborators of repo
1127
+ try:
1128
+ repo_collaborators = repo_edges["node"]["collaborators"]["edges"]
1129
+ self.graphql_repos_collaborators[repo_name] = repo_collaborators
1130
+ except (TypeError, KeyError):
1131
+ logging.debug("Repo %s does not seem to have any collaborators", repo_name)
1132
+
1133
+ # Find out if there are more than 100 collaborators in the
1134
+ # GraphQL response for this repo
1135
+ if repo_edges["node"]["collaborators"]["pageInfo"]["hasNextPage"]:
1136
+ next_page_cursors_for_repos[repo_name] = repo_edges["node"]["collaborators"][
1137
+ "pageInfo"
1138
+ ]["endCursor"]
1139
+
1140
+ return org_has_next_page, org_cursor
1141
+
1142
+ if single_repo_name and "repository" in graphql_response["data"]:
1143
+ logging.debug(
1144
+ "Extracting collaborators for repository %s from GraphQL response", single_repo_name
1145
+ )
1146
+
1147
+ # Initialise returns
1148
+ repo_has_next_page = graphql_response["data"]["repository"]["collaborators"][
1149
+ "pageInfo"
1150
+ ]["hasNextPage"]
1151
+ repo_cursor = graphql_response["data"]["repository"]["collaborators"]["pageInfo"][
1152
+ "endCursor"
1153
+ ]
1154
+
1155
+ # fill in collaborators of repo
977
1156
  try:
978
- collaborators.extend(result["data"]["repository"]["collaborators"]["edges"])
979
- has_next_page = result["data"]["repository"]["collaborators"]["pageInfo"][
980
- "hasNextPage"
981
- ]
982
- variables["cursor"] = result["data"]["repository"]["collaborators"]["pageInfo"][
983
- "endCursor"
1157
+ repo_collaborators = graphql_response["data"]["repository"]["collaborators"][
1158
+ "edges"
984
1159
  ]
1160
+ self.graphql_repos_collaborators[single_repo_name].extend(repo_collaborators)
985
1161
  except (TypeError, KeyError):
986
- logging.debug("Repo %s does not seem to have any collaborators", repo.name)
987
- continue
988
-
989
- # Extract relevant data
990
- for collaborator in collaborators:
991
- login: str = collaborator["node"]["login"]
992
- # Skip entry if collaborator is org owner, which is "admin" anyway
993
- if login.lower() in [user.login.lower() for user in self.current_org_owners]:
994
- continue
995
- permission = self._convert_graphql_perm_to_rest(collaborator["permission"])
996
- self.current_repos_collaborators[repo][login.lower()] = permission
1162
+ logging.debug("Repo %s does not seem to have any collaborators", single_repo_name)
1163
+
1164
+ return repo_has_next_page, repo_cursor
1165
+
1166
+ logging.warning("No relevant data found in GraphQL response")
1167
+ logging.debug("GraphQL response: %s", graphql_response)
1168
+ return False, ""
1169
+
1170
+ def _populate_current_repos_collaborators(self) -> None:
1171
+ """Populate self.current_repos_collaborators with data from repo_collaborators"""
1172
+ for repo, collaborators in self.current_repos_collaborators.items():
1173
+ if repo.name in self.graphql_repos_collaborators:
1174
+ # Extract each collaborator from the GraphQL response for this repo
1175
+ for collaborator in self.graphql_repos_collaborators[repo.name]:
1176
+ login: str = collaborator["node"]["login"]
1177
+ # Skip entry if collaborator is org owner, which is "admin" anyway
1178
+ if login.lower() in [user.login.lower() for user in self.current_org_owners]:
1179
+ continue
1180
+ permission = self._convert_graphql_perm_to_rest(collaborator["permission"])
1181
+ collaborators[login.lower()] = permission
997
1182
 
998
1183
  def _get_current_repos_and_user_perms(self):
999
1184
  """Get all repos, their current collaborators and their permissions"""
@@ -1001,9 +1186,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
1001
1186
  for repo in self.current_repos_teams:
1002
1187
  self.current_repos_collaborators[repo] = {}
1003
1188
 
1004
- for repo in self.current_repos_collaborators:
1005
- # Get users for this repo
1006
- self._fetch_collaborators_of_repo(repo)
1189
+ self._fetch_collaborators_of_all_organization_repos()
1007
1190
 
1008
1191
  def _get_default_repository_permission(self):
1009
1192
  """Get the default repository permission for all users. Convert to
@@ -4,7 +4,7 @@
4
4
 
5
5
  [tool.poetry]
6
6
  name = "github-org-manager"
7
- version = "0.5.4"
7
+ version = "0.5.6"
8
8
  description = "Manage a GitHub Organization, its teams, repository permissions, and more"
9
9
  authors = ["Max Mehl <max.mehl@deutschebahn.com>"]
10
10
  readme = "README.md"