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.
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/PKG-INFO +1 -1
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/gh_org_mgr/_gh_org.py +225 -42
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/pyproject.toml +1 -1
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/LICENSE.txt +0 -0
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/LICENSES/Apache-2.0.txt +0 -0
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/LICENSES/CC-BY-4.0.txt +0 -0
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/LICENSES/CC0-1.0.txt +0 -0
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/LICENSES/MIT.txt +0 -0
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/README.md +0 -0
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/gh_org_mgr/__init__.py +0 -0
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/gh_org_mgr/_config.py +0 -0
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/gh_org_mgr/_gh_api.py +0 -0
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/gh_org_mgr/_setup_team.py +0 -0
- {github_org_manager-0.5.4 → github_org_manager-0.5.6}/gh_org_mgr/manage.py +0 -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("
|
|
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.
|
|
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
|
-
|
|
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
|
|
945
|
-
"""Get all collaborators (individuals) of
|
|
946
|
-
permissions using the GraphQL API"""
|
|
947
|
-
|
|
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!, $
|
|
951
|
-
|
|
952
|
-
|
|
973
|
+
query($owner: String!, $cursor: String) {
|
|
974
|
+
organization(login: $owner) {
|
|
975
|
+
repositories(first: 100, after: $cursor) {
|
|
953
976
|
edges {
|
|
954
977
|
node {
|
|
955
|
-
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
979
|
-
|
|
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",
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|