github-org-manager 0.2.1__tar.gz → 0.3.1__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.2.1 → github_org_manager-0.3.1}/PKG-INFO +4 -3
- {github_org_manager-0.2.1 → github_org_manager-0.3.1}/gh_org_mgr/_gh_api.py +18 -0
- github_org_manager-0.3.1/gh_org_mgr/_gh_org.py +743 -0
- {github_org_manager-0.2.1 → github_org_manager-0.3.1}/gh_org_mgr/manage.py +10 -1
- {github_org_manager-0.2.1 → github_org_manager-0.3.1}/pyproject.toml +4 -2
- github_org_manager-0.2.1/gh_org_mgr/_gh_org.py +0 -405
- {github_org_manager-0.2.1 → github_org_manager-0.3.1}/LICENSE.txt +0 -0
- {github_org_manager-0.2.1 → github_org_manager-0.3.1}/LICENSES/Apache-2.0.txt +0 -0
- {github_org_manager-0.2.1 → github_org_manager-0.3.1}/LICENSES/CC-BY-4.0.txt +0 -0
- {github_org_manager-0.2.1 → github_org_manager-0.3.1}/LICENSES/CC0-1.0.txt +0 -0
- {github_org_manager-0.2.1 → github_org_manager-0.3.1}/LICENSES/MIT.txt +0 -0
- {github_org_manager-0.2.1 → github_org_manager-0.3.1}/README.md +0 -0
- {github_org_manager-0.2.1 → github_org_manager-0.3.1}/gh_org_mgr/__init__.py +0 -0
- {github_org_manager-0.2.1 → github_org_manager-0.3.1}/gh_org_mgr/_config.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: github-org-manager
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Manage a GitHub Organization, its teams, repository permissions, and more
|
|
5
|
-
Home-page: https://github.com/OpenRailAssociation/
|
|
5
|
+
Home-page: https://github.com/OpenRailAssociation/github-org-manager
|
|
6
6
|
License: Apache-2.0
|
|
7
7
|
Keywords: github,github-management,permissions,access-control
|
|
8
8
|
Author: Max Mehl
|
|
@@ -21,7 +21,8 @@ Classifier: Topic :: Software Development :: Version Control :: Git
|
|
|
21
21
|
Classifier: Topic :: Utilities
|
|
22
22
|
Requires-Dist: pygithub (>=2.3.0,<3.0.0)
|
|
23
23
|
Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
|
|
24
|
-
|
|
24
|
+
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
25
|
+
Project-URL: Repository, https://github.com/OpenRailAssociation/github-org-manager
|
|
25
26
|
Description-Content-Type: text/markdown
|
|
26
27
|
|
|
27
28
|
<!--
|
|
@@ -10,6 +10,8 @@ import logging
|
|
|
10
10
|
import os
|
|
11
11
|
import sys
|
|
12
12
|
|
|
13
|
+
import requests
|
|
14
|
+
|
|
13
15
|
|
|
14
16
|
def get_github_token(token: str = "") -> str:
|
|
15
17
|
"""Get the GitHub token from config or environment, while environment overrides"""
|
|
@@ -25,3 +27,19 @@ def get_github_token(token: str = "") -> str:
|
|
|
25
27
|
)
|
|
26
28
|
|
|
27
29
|
return token
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Function to execute GraphQL query
|
|
33
|
+
def run_graphql_query(query, variables, token):
|
|
34
|
+
"""Run a query against the GitHub GraphQL API"""
|
|
35
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
36
|
+
request = requests.post(
|
|
37
|
+
"https://api.github.com/graphql",
|
|
38
|
+
json={"query": query, "variables": variables},
|
|
39
|
+
headers=headers,
|
|
40
|
+
timeout=10,
|
|
41
|
+
)
|
|
42
|
+
if request.status_code == 200:
|
|
43
|
+
return request.json()
|
|
44
|
+
|
|
45
|
+
sys.exit(f"Query failed to run by returning code of {query}: {request.status_code}")
|
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 DB Systel GmbH
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Class for the GitHub organization which contains most of the logic"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
|
|
10
|
+
from github import Github, UnknownObjectException
|
|
11
|
+
from github.NamedUser import NamedUser
|
|
12
|
+
from github.Organization import Organization
|
|
13
|
+
from github.Repository import Repository
|
|
14
|
+
from github.Team import Team
|
|
15
|
+
|
|
16
|
+
from ._gh_api import get_github_token, run_graphql_query
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class GHorg: # pylint: disable=too-many-instance-attributes
|
|
21
|
+
"""Dataclass holding GH organization data and functions"""
|
|
22
|
+
|
|
23
|
+
gh: Github = None # type: ignore
|
|
24
|
+
org: Organization = None # type: ignore
|
|
25
|
+
gh_token: str = ""
|
|
26
|
+
default_repository_permission: str = ""
|
|
27
|
+
org_owners: list[NamedUser] = field(default_factory=list)
|
|
28
|
+
org_members: list[NamedUser] = field(default_factory=list)
|
|
29
|
+
current_teams: dict[Team, dict] = field(default_factory=dict)
|
|
30
|
+
configured_teams: dict[str, dict | None] = field(default_factory=dict)
|
|
31
|
+
current_repos_teams: dict[Repository, dict[Team, str]] = field(default_factory=dict)
|
|
32
|
+
current_repos_collaborators: dict[Repository, dict[str, str]] = field(default_factory=dict)
|
|
33
|
+
configured_repos_collaborators: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
34
|
+
archived_repos: list[Repository] = field(default_factory=list)
|
|
35
|
+
unconfigured_team_repo_permissions: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
# --------------------------------------------------------------------------
|
|
38
|
+
# Helper functions
|
|
39
|
+
# --------------------------------------------------------------------------
|
|
40
|
+
def _sluggify_teamname(self, team: str) -> str:
|
|
41
|
+
"""Slugify a GitHub team name"""
|
|
42
|
+
# TODO: this is very naive, no other special chars are
|
|
43
|
+
# supported, or multiple spaces etc.
|
|
44
|
+
return team.replace(" ", "-")
|
|
45
|
+
|
|
46
|
+
def login(self, orgname: str, token: str):
|
|
47
|
+
"""Login to GH, gather org data"""
|
|
48
|
+
self.gh_token = get_github_token(token)
|
|
49
|
+
self.gh = Github(self.gh_token)
|
|
50
|
+
self.org = self.gh.get_organization(orgname)
|
|
51
|
+
|
|
52
|
+
def ratelimit(self):
|
|
53
|
+
"""Get current rate limit"""
|
|
54
|
+
core = self.gh.get_rate_limit().core
|
|
55
|
+
logging.debug(
|
|
56
|
+
"Current rate limit: %s/%s (reset: %s)", core.remaining, core.limit, core.reset
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def df2json(self) -> str:
|
|
60
|
+
"""Convert the dataclass to a JSON string"""
|
|
61
|
+
d = asdict(self)
|
|
62
|
+
|
|
63
|
+
# Censor sensible fields
|
|
64
|
+
def censor_half_string(string: str) -> str:
|
|
65
|
+
"""Censor 50% of a string (rounded up)"""
|
|
66
|
+
half1 = int(len(string) / 2)
|
|
67
|
+
half2 = len(string) - half1
|
|
68
|
+
return string[:half1] + "*" * (half2)
|
|
69
|
+
|
|
70
|
+
sensible_keys = ["gh_token"]
|
|
71
|
+
for key in sensible_keys:
|
|
72
|
+
d[key] = censor_half_string(d.get(key, ""))
|
|
73
|
+
|
|
74
|
+
# Print dict nicely
|
|
75
|
+
def pretty(d, indent=0):
|
|
76
|
+
string = ""
|
|
77
|
+
for key, value in d.items():
|
|
78
|
+
string += " " * indent + str(key) + ":\n"
|
|
79
|
+
if isinstance(value, dict):
|
|
80
|
+
string += pretty(value, indent + 1)
|
|
81
|
+
else:
|
|
82
|
+
string += " " * (indent + 1) + str(value) + "\n"
|
|
83
|
+
|
|
84
|
+
return string
|
|
85
|
+
|
|
86
|
+
return pretty(d)
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------------------
|
|
89
|
+
# Teams
|
|
90
|
+
# --------------------------------------------------------------------------
|
|
91
|
+
def _get_current_teams(self):
|
|
92
|
+
"""Get teams of the existing organisation"""
|
|
93
|
+
for team in list(self.org.get_teams()):
|
|
94
|
+
self.current_teams[team] = {"members": {}, "repos": {}}
|
|
95
|
+
|
|
96
|
+
def create_missing_teams(self, dry: bool = False):
|
|
97
|
+
"""Find out which teams are configured but not part of the org yet"""
|
|
98
|
+
|
|
99
|
+
# Get list of current teams
|
|
100
|
+
self._get_current_teams()
|
|
101
|
+
|
|
102
|
+
# Get the names of the existing teams
|
|
103
|
+
existent_team_names = [team.name for team in self.current_teams]
|
|
104
|
+
|
|
105
|
+
for team, attributes in self.configured_teams.items():
|
|
106
|
+
if team not in existent_team_names:
|
|
107
|
+
if parent := attributes.get("parent"): # type: ignore
|
|
108
|
+
parent_id = self.org.get_team_by_slug(self._sluggify_teamname(parent)).id
|
|
109
|
+
|
|
110
|
+
logging.info("Creating team '%s' with parent ID '%s'", team, parent_id)
|
|
111
|
+
if not dry:
|
|
112
|
+
self.org.create_team(team, parent_team_id=parent_id)
|
|
113
|
+
|
|
114
|
+
else:
|
|
115
|
+
logging.info("Creating team '%s' without parent", team)
|
|
116
|
+
if not dry:
|
|
117
|
+
self.org.create_team(team, privacy="closed")
|
|
118
|
+
|
|
119
|
+
else:
|
|
120
|
+
logging.debug("Team '%s' already exists", team)
|
|
121
|
+
|
|
122
|
+
# Re-scan current teams as new ones may have been created
|
|
123
|
+
self._get_current_teams()
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------------------------
|
|
126
|
+
# Members
|
|
127
|
+
# --------------------------------------------------------------------------
|
|
128
|
+
def _get_org_members(self):
|
|
129
|
+
"""Get all owners of the org"""
|
|
130
|
+
for member in self.org.get_members(role="admin"):
|
|
131
|
+
self.org_owners.append(member)
|
|
132
|
+
for member in self.org.get_members(role="member"):
|
|
133
|
+
self.org_members.append(member)
|
|
134
|
+
|
|
135
|
+
def _get_configured_team_members(
|
|
136
|
+
self, team_config: dict, team_name: str, role: str
|
|
137
|
+
) -> list[str]:
|
|
138
|
+
"""Read configured members/maintainers from the configuration"""
|
|
139
|
+
|
|
140
|
+
if isinstance(team_config, dict) and team_config.get(role):
|
|
141
|
+
configured_team_members = []
|
|
142
|
+
for user in team_config.get(role, []):
|
|
143
|
+
configured_team_members.append(user)
|
|
144
|
+
|
|
145
|
+
return configured_team_members
|
|
146
|
+
|
|
147
|
+
logging.debug("Team '%s' has no configured %ss", team_name, role)
|
|
148
|
+
return []
|
|
149
|
+
|
|
150
|
+
def _get_current_team_members(self, team: Team) -> dict[NamedUser, str]:
|
|
151
|
+
"""Return dict of current users with their respective roles. Also
|
|
152
|
+
contains members of child teams"""
|
|
153
|
+
current_users: dict[NamedUser, str] = {}
|
|
154
|
+
for role in ("member", "maintainer"):
|
|
155
|
+
# Make a two-step check whether person is actually in team, as
|
|
156
|
+
# get_members() also return child-team members
|
|
157
|
+
for user in list(team.get_members(role=role)):
|
|
158
|
+
current_users.update({user: role})
|
|
159
|
+
|
|
160
|
+
return current_users
|
|
161
|
+
|
|
162
|
+
def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser | None:
|
|
163
|
+
"""Turn a username into a proper GitHub user object"""
|
|
164
|
+
try:
|
|
165
|
+
gh_user: NamedUser = self.gh.get_user(username) # type: ignore
|
|
166
|
+
except UnknownObjectException:
|
|
167
|
+
logging.error(
|
|
168
|
+
"The user '%s' configured as member of team '%s' does not "
|
|
169
|
+
"exist on GitHub. Spelling error or did they rename themselves?",
|
|
170
|
+
username,
|
|
171
|
+
teamname,
|
|
172
|
+
)
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
return gh_user
|
|
176
|
+
|
|
177
|
+
def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-many-branches
|
|
178
|
+
"""Check the configured members of each team, add missing ones and delete unconfigured"""
|
|
179
|
+
logging.debug("Starting to sync team members")
|
|
180
|
+
|
|
181
|
+
# Gather all members and owners of the organisation
|
|
182
|
+
self._get_org_members()
|
|
183
|
+
|
|
184
|
+
# Get open invitations
|
|
185
|
+
open_invitations = [user.login for user in self.org.invitations()]
|
|
186
|
+
|
|
187
|
+
for team, team_attrs in self.current_teams.items():
|
|
188
|
+
# Update current team members with dict[NamedUser, str (role)]
|
|
189
|
+
team_attrs["members"] = self._get_current_team_members(team)
|
|
190
|
+
|
|
191
|
+
# For the rest of the function however, we use just the login name
|
|
192
|
+
# for each current user
|
|
193
|
+
current_team_members = {
|
|
194
|
+
user.login: role for user, role in team_attrs["members"].items()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# Handle the team not being configured locally
|
|
198
|
+
if team.name not in self.configured_teams:
|
|
199
|
+
logging.warning(
|
|
200
|
+
"Team '%s' does not seem to be configured locally. "
|
|
201
|
+
"Taking no action about this team at all",
|
|
202
|
+
team.name,
|
|
203
|
+
)
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
# Get configuration from current team
|
|
207
|
+
if team_configuration := self.configured_teams.get(team.name):
|
|
208
|
+
pass
|
|
209
|
+
else:
|
|
210
|
+
team_configuration = {}
|
|
211
|
+
|
|
212
|
+
# Analog to team_attrs["members"], add members and maintainers to shared
|
|
213
|
+
# dict with respective role, while maintainer role dominates
|
|
214
|
+
configured_users: dict[str, str] = {}
|
|
215
|
+
for config_role in ("member", "maintainer"):
|
|
216
|
+
team_members = self._get_configured_team_members(
|
|
217
|
+
team_configuration, team.name, config_role
|
|
218
|
+
)
|
|
219
|
+
for team_member in team_members:
|
|
220
|
+
# Add user with role to dict
|
|
221
|
+
configured_users.update({team_member: config_role})
|
|
222
|
+
|
|
223
|
+
# Consider all GitHub organisation team maintainers if they are member of the team
|
|
224
|
+
# This is because GitHub API returns them as maintainers even if they are just members
|
|
225
|
+
for user in self.org_owners:
|
|
226
|
+
if user.login in configured_users:
|
|
227
|
+
logging.debug(
|
|
228
|
+
"Overriding role of organisation owner '%s' to maintainer", user.login
|
|
229
|
+
)
|
|
230
|
+
configured_users[user.login] = "maintainer"
|
|
231
|
+
|
|
232
|
+
# Only make edits to the team membership if the current state differs from config
|
|
233
|
+
if configured_users == current_team_members:
|
|
234
|
+
logging.info("Team '%s' configuration is in sync, no changes", team.name)
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
# Loop through the configured users, add / update them if necessary
|
|
238
|
+
for config_user, config_role in configured_users.items():
|
|
239
|
+
# Add user if they haven't been in the team yet
|
|
240
|
+
if config_user not in current_team_members:
|
|
241
|
+
# Turn user to GitHub object, trying to find them
|
|
242
|
+
if not (gh_user := self._resolve_gh_username(config_user, team.name)):
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
# Do not reinvite user if their invitation is already pending
|
|
246
|
+
if config_user in open_invitations:
|
|
247
|
+
logging.info(
|
|
248
|
+
"User '%s' shall be added to team '%s' as %s, invitation is pending",
|
|
249
|
+
gh_user.login,
|
|
250
|
+
team.name,
|
|
251
|
+
config_role,
|
|
252
|
+
)
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
logging.info(
|
|
256
|
+
"Adding user '%s' to team '%s' as %s",
|
|
257
|
+
gh_user.login,
|
|
258
|
+
team.name,
|
|
259
|
+
config_role,
|
|
260
|
+
)
|
|
261
|
+
if not dry:
|
|
262
|
+
team.add_membership(member=gh_user, role=config_role)
|
|
263
|
+
|
|
264
|
+
# Update roles if they differ from old role
|
|
265
|
+
elif config_role != current_team_members.get(config_user, ""):
|
|
266
|
+
# Turn user to GitHub object, trying to find them
|
|
267
|
+
if not (gh_user := self._resolve_gh_username(config_user, team.name)):
|
|
268
|
+
continue
|
|
269
|
+
logging.info(
|
|
270
|
+
"Updating role of '%s' in team '%s' to %s",
|
|
271
|
+
config_user,
|
|
272
|
+
team.name,
|
|
273
|
+
config_role,
|
|
274
|
+
)
|
|
275
|
+
if not dry:
|
|
276
|
+
team.add_membership(member=gh_user, role=config_role)
|
|
277
|
+
|
|
278
|
+
# Loop through all current members. Remove them if they are not configured
|
|
279
|
+
for current_user in current_team_members:
|
|
280
|
+
if current_user not in configured_users:
|
|
281
|
+
# Turn user to GitHub object, trying to find them
|
|
282
|
+
if not (gh_user := self._resolve_gh_username(current_user, team.name)):
|
|
283
|
+
continue
|
|
284
|
+
if team.has_in_members(gh_user):
|
|
285
|
+
logging.info(
|
|
286
|
+
"Removing '%s' from team '%s' as they are not configured",
|
|
287
|
+
gh_user.login,
|
|
288
|
+
team.name,
|
|
289
|
+
)
|
|
290
|
+
if not dry:
|
|
291
|
+
team.remove_membership(gh_user)
|
|
292
|
+
else:
|
|
293
|
+
logging.debug(
|
|
294
|
+
"User '%s' does not need to be removed from team '%s' "
|
|
295
|
+
"as they are just member of a child-team",
|
|
296
|
+
gh_user.login,
|
|
297
|
+
team.name,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def get_members_without_team(self) -> None:
|
|
301
|
+
"""Get all organisation members without any team membership"""
|
|
302
|
+
# Combine org owners and org members
|
|
303
|
+
all_org_members = set(self.org_members + self.org_owners)
|
|
304
|
+
|
|
305
|
+
# Get all members of all teams
|
|
306
|
+
all_team_members_lst = []
|
|
307
|
+
for _, team_attrs in self.current_teams.items():
|
|
308
|
+
for member in team_attrs.get("members", {}):
|
|
309
|
+
all_team_members_lst.append(member)
|
|
310
|
+
all_team_members = set(all_team_members_lst)
|
|
311
|
+
|
|
312
|
+
# Find members that are in org_members but not team_members
|
|
313
|
+
members_without_team = all_org_members.difference(all_team_members)
|
|
314
|
+
|
|
315
|
+
if members_without_team:
|
|
316
|
+
members_without_team_str = [user.login for user in members_without_team]
|
|
317
|
+
logging.warning(
|
|
318
|
+
"The following members of your GitHub organisation are not member of any team: %s",
|
|
319
|
+
", ".join(members_without_team_str),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# --------------------------------------------------------------------------
|
|
323
|
+
# Repos
|
|
324
|
+
# --------------------------------------------------------------------------
|
|
325
|
+
def _get_current_repos_and_team_perms(self, ignore_archived: bool) -> None:
|
|
326
|
+
"""Get all repos, their current teams and their permissions"""
|
|
327
|
+
for repo in list(self.org.get_repos()):
|
|
328
|
+
# Check if repo is archived. If so, ignore it, if user requested so
|
|
329
|
+
if ignore_archived and repo.archived:
|
|
330
|
+
logging.debug(
|
|
331
|
+
"Ignoring %s as it is archived and user requested to ignore such repos",
|
|
332
|
+
repo.name,
|
|
333
|
+
)
|
|
334
|
+
self.archived_repos.append(repo)
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
self.current_repos_teams[repo] = {}
|
|
338
|
+
for team in list(repo.get_teams()):
|
|
339
|
+
self.current_repos_teams[repo][team] = team.permission
|
|
340
|
+
|
|
341
|
+
def _create_perms_changelist_for_teams(
|
|
342
|
+
self,
|
|
343
|
+
) -> dict[Team, dict[Repository, str]]:
|
|
344
|
+
"""Create a permission/repo changelist from the perspective of configured teams"""
|
|
345
|
+
team_changelist: dict[Team, dict[Repository, str]] = {}
|
|
346
|
+
for team_name, team_attrs in self.configured_teams.items():
|
|
347
|
+
# Handle unset configured attributes
|
|
348
|
+
if team_attrs is None:
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
# Convert team name to Team object
|
|
352
|
+
team = self.org.get_team_by_slug(self._sluggify_teamname(team_name))
|
|
353
|
+
|
|
354
|
+
# Get configured repo permissions
|
|
355
|
+
for repo, perm in team_attrs.get("repos", {}).items():
|
|
356
|
+
# Convert repo to Repo object
|
|
357
|
+
try:
|
|
358
|
+
repo = self.org.get_repo(repo)
|
|
359
|
+
except UnknownObjectException:
|
|
360
|
+
logging.warning(
|
|
361
|
+
"Configured repository '%s' for team '%s' has not been "
|
|
362
|
+
"found in the organisation",
|
|
363
|
+
repo,
|
|
364
|
+
team.name,
|
|
365
|
+
)
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
if perm != self.current_repos_teams[repo].get(team):
|
|
369
|
+
# Add the changeset to the changelist
|
|
370
|
+
if team not in team_changelist:
|
|
371
|
+
team_changelist[team] = {}
|
|
372
|
+
team_changelist[team][repo] = perm
|
|
373
|
+
|
|
374
|
+
return team_changelist
|
|
375
|
+
|
|
376
|
+
def _document_unconfigured_team_repo_permissions(
|
|
377
|
+
self, team: Team, team_permission: str, repo_name: str
|
|
378
|
+
) -> None:
|
|
379
|
+
"""Create a record of all members of a team and their permissions on a
|
|
380
|
+
repo due to being member of an unconfigured team"""
|
|
381
|
+
users_of_unconfigured_team: dict[NamedUser, str] = self.current_teams[team].get(
|
|
382
|
+
"members"
|
|
383
|
+
) # type: ignore
|
|
384
|
+
# Initiate this repo in the dict as dict if not present
|
|
385
|
+
if repo_name not in self.unconfigured_team_repo_permissions:
|
|
386
|
+
self.unconfigured_team_repo_permissions[repo_name] = {}
|
|
387
|
+
# Add actual permission for each user of this unconfigured team
|
|
388
|
+
for user in users_of_unconfigured_team:
|
|
389
|
+
# Handle if another, potentially higher permission is already set by
|
|
390
|
+
# membership in another team
|
|
391
|
+
if exist_perm := self.unconfigured_team_repo_permissions[repo_name].get(user.login, ""):
|
|
392
|
+
logging.debug(
|
|
393
|
+
"Permissions for %s on %s already exist: %s. "
|
|
394
|
+
"Checking whether new permission is higher.",
|
|
395
|
+
user.login,
|
|
396
|
+
repo_name,
|
|
397
|
+
exist_perm,
|
|
398
|
+
)
|
|
399
|
+
self.unconfigured_team_repo_permissions[repo_name][user.login] = (
|
|
400
|
+
self._get_highest_permission(exist_perm, team_permission)
|
|
401
|
+
)
|
|
402
|
+
else:
|
|
403
|
+
self.unconfigured_team_repo_permissions[repo_name][user.login] = team_permission
|
|
404
|
+
|
|
405
|
+
def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False) -> None:
|
|
406
|
+
"""Synchronise the repository permissions of all teams"""
|
|
407
|
+
logging.debug("Starting to sync repo/team permissions")
|
|
408
|
+
|
|
409
|
+
# Get all repos and their current permissions from GitHub
|
|
410
|
+
self._get_current_repos_and_team_perms(ignore_archived)
|
|
411
|
+
|
|
412
|
+
# Find differences between configured permissions for a team's repo and the current state
|
|
413
|
+
for team, repos in self._create_perms_changelist_for_teams().items():
|
|
414
|
+
for repo, perm in repos.items():
|
|
415
|
+
logging.info(
|
|
416
|
+
"Changing permission of repository '%s' for team '%s' to '%s'",
|
|
417
|
+
repo.name,
|
|
418
|
+
team.name,
|
|
419
|
+
perm,
|
|
420
|
+
)
|
|
421
|
+
if not dry:
|
|
422
|
+
# Update permissions or newly add a team to a repo
|
|
423
|
+
team.update_team_repository(repo, perm)
|
|
424
|
+
|
|
425
|
+
# Find out whether repos' permissions contain *configured* teams that
|
|
426
|
+
# should not have permissions
|
|
427
|
+
for repo, teams in self.current_repos_teams.items():
|
|
428
|
+
for team in teams:
|
|
429
|
+
# Get configured repos for this team, finding out whether repo
|
|
430
|
+
# is configured for this team
|
|
431
|
+
remove = False
|
|
432
|
+
# Handle: Team is not configured at all
|
|
433
|
+
if team.name not in self.configured_teams:
|
|
434
|
+
logging.warning(
|
|
435
|
+
"Team '%s' has permissions on repository '%s', but this team "
|
|
436
|
+
"is not configured locally",
|
|
437
|
+
team.name,
|
|
438
|
+
repo.name,
|
|
439
|
+
)
|
|
440
|
+
# Store information about these team members and their
|
|
441
|
+
# permissions on the repo. We will use it later in the
|
|
442
|
+
# collaborators step
|
|
443
|
+
self._document_unconfigured_team_repo_permissions(
|
|
444
|
+
team=team, team_permission=teams[team], repo_name=repo.name
|
|
445
|
+
)
|
|
446
|
+
# Abort handling the repo sync as we don't touch unconfigured teams
|
|
447
|
+
continue
|
|
448
|
+
# Handle: Team is configured, but contains no config
|
|
449
|
+
if self.configured_teams[team.name] is None:
|
|
450
|
+
remove = True
|
|
451
|
+
# Handle: Team is configured, contains config
|
|
452
|
+
elif repos := self.configured_teams[team.name].get("repos", []): # type: ignore
|
|
453
|
+
# If this repo has not been found in the configured repos
|
|
454
|
+
# for the team, remove all permissions
|
|
455
|
+
if repo.name not in repos:
|
|
456
|
+
remove = True
|
|
457
|
+
# Handle: Team is configured, contains config, but no "repos" key
|
|
458
|
+
else:
|
|
459
|
+
remove = True
|
|
460
|
+
|
|
461
|
+
# Remove if any mismatch has been found
|
|
462
|
+
if remove:
|
|
463
|
+
logging.info("Removing team '%s' from repository '%s'", team.name, repo.name)
|
|
464
|
+
if not dry:
|
|
465
|
+
team.remove_from_repos(repo)
|
|
466
|
+
|
|
467
|
+
# --------------------------------------------------------------------------
|
|
468
|
+
# Collaborators
|
|
469
|
+
# --------------------------------------------------------------------------
|
|
470
|
+
def _aggregate_lists(self, *lists: list[str | int]) -> list[str | int]:
|
|
471
|
+
"""Combine multiple lists into one while removing duplicates"""
|
|
472
|
+
complete = []
|
|
473
|
+
for single_list in lists:
|
|
474
|
+
complete.extend(single_list)
|
|
475
|
+
|
|
476
|
+
return list(set(complete))
|
|
477
|
+
|
|
478
|
+
def _get_highest_permission(self, *permissions: str) -> str:
|
|
479
|
+
"""Get the highest GitHub repo permissions out of multiple permissions"""
|
|
480
|
+
perms_ranking = ["admin", "maintain", "push", "triage", "pull"]
|
|
481
|
+
for perm in perms_ranking:
|
|
482
|
+
# If e.g. "maintain" matches one of the two permissions
|
|
483
|
+
if perm in permissions:
|
|
484
|
+
logging.debug("%s is the highest permission", perm)
|
|
485
|
+
return perm
|
|
486
|
+
|
|
487
|
+
return ""
|
|
488
|
+
|
|
489
|
+
def _get_direct_repo_permissions_of_team(self, team_dict: dict) -> tuple[dict[str, str], str]:
|
|
490
|
+
"""Get a list of directly configured repo permissions for a team, and
|
|
491
|
+
whether the team has a parent"""
|
|
492
|
+
repo_perms: dict[str, str] = {}
|
|
493
|
+
# Direct permissions
|
|
494
|
+
for repo, perm in team_dict.get("repos", {}).items():
|
|
495
|
+
repo_perms[repo] = perm
|
|
496
|
+
|
|
497
|
+
# Parent team
|
|
498
|
+
parent = team_dict.get("parent", "")
|
|
499
|
+
|
|
500
|
+
return repo_perms, parent
|
|
501
|
+
|
|
502
|
+
def _get_all_repo_permissions_for_team_and_parents(self, team_name: str, team_dict: dict):
|
|
503
|
+
"""Get a list of all configured repo permissions for a team, also those
|
|
504
|
+
inherited by parent teams"""
|
|
505
|
+
all_repo_perms, parent = self._get_direct_repo_permissions_of_team(team_dict=team_dict)
|
|
506
|
+
# If parents have been found, iterate and merge them
|
|
507
|
+
while parent:
|
|
508
|
+
logging.debug(
|
|
509
|
+
"Checking for repository permissions of %s's parent team %s", team_name, parent
|
|
510
|
+
)
|
|
511
|
+
parent_team_dict = self.configured_teams[parent]
|
|
512
|
+
|
|
513
|
+
# Handle empty parent dict
|
|
514
|
+
if not parent_team_dict:
|
|
515
|
+
break
|
|
516
|
+
|
|
517
|
+
# Get repo permissions and potential parent, and add it
|
|
518
|
+
repo_perm, parent = self._get_direct_repo_permissions_of_team(
|
|
519
|
+
team_dict=parent_team_dict
|
|
520
|
+
)
|
|
521
|
+
for repo, perm in repo_perm.items():
|
|
522
|
+
# Add (highest) repo permission
|
|
523
|
+
all_repo_perms[repo] = self._get_highest_permission(
|
|
524
|
+
perm, all_repo_perms.get(repo, "")
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
return all_repo_perms
|
|
528
|
+
|
|
529
|
+
def _get_configured_repos_and_user_perms(self):
|
|
530
|
+
"""
|
|
531
|
+
Get a list of repos with a list of individuals and their permissions,
|
|
532
|
+
based on their team memberships
|
|
533
|
+
"""
|
|
534
|
+
for team_name, team_attrs in self.configured_teams.items():
|
|
535
|
+
logging.debug("Getting configured repository permissions for team %s", team_name)
|
|
536
|
+
repo_perms = self._get_all_repo_permissions_for_team_and_parents(team_name, team_attrs)
|
|
537
|
+
for repo, perm in repo_perms.items():
|
|
538
|
+
# Create repo if non-exist
|
|
539
|
+
if repo not in self.configured_repos_collaborators:
|
|
540
|
+
self.configured_repos_collaborators[repo] = {}
|
|
541
|
+
|
|
542
|
+
# Get team maintainers and members
|
|
543
|
+
team_members = self._aggregate_lists(
|
|
544
|
+
team_attrs.get("maintainer", []), team_attrs.get("member", [])
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# Add team member to repo with their repo permissions
|
|
548
|
+
for team_member in team_members:
|
|
549
|
+
# Check if permissions already exist
|
|
550
|
+
if self.configured_repos_collaborators[repo].get(team_member, {}):
|
|
551
|
+
logging.debug(
|
|
552
|
+
"Permissions for %s on %s already exist: %s. "
|
|
553
|
+
"Checking whether new permission is higher.",
|
|
554
|
+
team_member,
|
|
555
|
+
repo,
|
|
556
|
+
self.configured_repos_collaborators[repo][team_member],
|
|
557
|
+
)
|
|
558
|
+
self.configured_repos_collaborators[repo][team_member] = (
|
|
559
|
+
self._get_highest_permission(
|
|
560
|
+
perm, self.configured_repos_collaborators[repo][team_member]
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
else:
|
|
564
|
+
self.configured_repos_collaborators[repo][team_member] = perm
|
|
565
|
+
|
|
566
|
+
def _convert_graphql_perm_to_rest(self, permission: str) -> str:
|
|
567
|
+
"""Convert a repo permission coming from the GraphQL API to the ones
|
|
568
|
+
coming from the REST API"""
|
|
569
|
+
perm_conversion = {
|
|
570
|
+
"none": "",
|
|
571
|
+
"read": "pull",
|
|
572
|
+
"triage": "triage",
|
|
573
|
+
"write": "push",
|
|
574
|
+
"maintain": "maintain",
|
|
575
|
+
"admin": "admin",
|
|
576
|
+
}
|
|
577
|
+
if permission.lower() in perm_conversion:
|
|
578
|
+
replacement = perm_conversion.get(permission.lower(), "")
|
|
579
|
+
return replacement
|
|
580
|
+
|
|
581
|
+
return permission
|
|
582
|
+
|
|
583
|
+
def _fetch_collaborators_of_repo(self, repo: Repository):
|
|
584
|
+
"""Get all collaborators (individuals) of a GitHub repo with their
|
|
585
|
+
permissions using the GraphQL API"""
|
|
586
|
+
# TODO: Consider doing this for all repositories at once, but calculate
|
|
587
|
+
# costs beforehand
|
|
588
|
+
query = """
|
|
589
|
+
query($owner: String!, $name: String!, $cursor: String) {
|
|
590
|
+
repository(owner: $owner, name: $name) {
|
|
591
|
+
collaborators(first: 100, after: $cursor) {
|
|
592
|
+
edges {
|
|
593
|
+
node {
|
|
594
|
+
login
|
|
595
|
+
}
|
|
596
|
+
permission
|
|
597
|
+
}
|
|
598
|
+
pageInfo {
|
|
599
|
+
endCursor
|
|
600
|
+
hasNextPage
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
"""
|
|
606
|
+
|
|
607
|
+
# Initial query parameters
|
|
608
|
+
variables = {"owner": self.org.login, "name": repo.name, "cursor": None}
|
|
609
|
+
|
|
610
|
+
collaborators = []
|
|
611
|
+
has_next_page = True
|
|
612
|
+
|
|
613
|
+
while has_next_page:
|
|
614
|
+
logging.debug("Requesting collaborators for %s", repo.name)
|
|
615
|
+
result = run_graphql_query(query, variables, self.gh_token)
|
|
616
|
+
try:
|
|
617
|
+
collaborators.extend(result["data"]["repository"]["collaborators"]["edges"])
|
|
618
|
+
has_next_page = result["data"]["repository"]["collaborators"]["pageInfo"][
|
|
619
|
+
"hasNextPage"
|
|
620
|
+
]
|
|
621
|
+
variables["cursor"] = result["data"]["repository"]["collaborators"]["pageInfo"][
|
|
622
|
+
"endCursor"
|
|
623
|
+
]
|
|
624
|
+
except (TypeError, KeyError):
|
|
625
|
+
logging.debug("Repo %s does not seem to have any collaborators", repo.name)
|
|
626
|
+
continue
|
|
627
|
+
|
|
628
|
+
# Extract relevant data
|
|
629
|
+
for collaborator in collaborators:
|
|
630
|
+
login = collaborator["node"]["login"]
|
|
631
|
+
# Skip entry if collaborator is org owner, which is "admin" anyway
|
|
632
|
+
if login in [user.login for user in self.org_owners]:
|
|
633
|
+
continue
|
|
634
|
+
permission = self._convert_graphql_perm_to_rest(collaborator["permission"])
|
|
635
|
+
self.current_repos_collaborators[repo][login] = permission
|
|
636
|
+
|
|
637
|
+
def _get_current_repos_and_user_perms(self):
|
|
638
|
+
"""Get all repos, their current collaborators and their permissions"""
|
|
639
|
+
# We copy the list of repos from self.current_repos_teams
|
|
640
|
+
for repo in self.current_repos_teams:
|
|
641
|
+
self.current_repos_collaborators[repo] = {}
|
|
642
|
+
|
|
643
|
+
for repo in self.current_repos_collaborators:
|
|
644
|
+
# Get users for this repo
|
|
645
|
+
self._fetch_collaborators_of_repo(repo)
|
|
646
|
+
|
|
647
|
+
def _get_default_repository_permission(self):
|
|
648
|
+
"""Get the default repository permission for all users. Convert to
|
|
649
|
+
admin/maintain/push/triage/pull scheme that the REST API provides"""
|
|
650
|
+
self.default_repository_permission = self._convert_graphql_perm_to_rest(
|
|
651
|
+
self.org.default_repository_permission
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
def _permission1_higher_than_permission2(self, permission1: str, permission2: str) -> bool:
|
|
655
|
+
"""Check whether permission 1 is higher than permission 2"""
|
|
656
|
+
perms_ranking = ["admin", "maintain", "push", "triage", "pull", ""]
|
|
657
|
+
|
|
658
|
+
def get_rank(permission):
|
|
659
|
+
return perms_ranking.index(permission) if permission in perms_ranking else 99
|
|
660
|
+
|
|
661
|
+
rank_permission1 = get_rank(permission1)
|
|
662
|
+
rank_permission2 = get_rank(permission2)
|
|
663
|
+
|
|
664
|
+
# The lower the index, the higher the permission. If lower than
|
|
665
|
+
# permission2, return True
|
|
666
|
+
return rank_permission1 < rank_permission2
|
|
667
|
+
|
|
668
|
+
def sync_repo_collaborator_permissions(self, dry: bool = False):
|
|
669
|
+
"""Compare the configured with the current repo permissions for all
|
|
670
|
+
repositories' collaborators"""
|
|
671
|
+
# Collect info about all repos, their configured collaborators (through
|
|
672
|
+
# team membership) and the current state (either through team membership
|
|
673
|
+
# or individual).
|
|
674
|
+
# The resulting structure is:
|
|
675
|
+
# - configured_repos_collaborators: dict[Repository, dict[username, permission]]
|
|
676
|
+
# - current_repos_collaborators: dict[Repository, dict[username, permission]]
|
|
677
|
+
logging.debug("Starting to sync collaborator/individual permissions")
|
|
678
|
+
self._get_configured_repos_and_user_perms()
|
|
679
|
+
self._get_current_repos_and_user_perms()
|
|
680
|
+
|
|
681
|
+
# Get and convert the default permission for all members so we can check for it
|
|
682
|
+
self._get_default_repository_permission()
|
|
683
|
+
|
|
684
|
+
# Loop over all factually existing repositories. This will be a one-way
|
|
685
|
+
# sync. Team permissions have been set before, we are now removing
|
|
686
|
+
# surplus permissions. As no individual permissions are allowed, these
|
|
687
|
+
# will be fully revoked.
|
|
688
|
+
for repo, current_repo_perms in self.current_repos_collaborators.items():
|
|
689
|
+
for username, current_perm in current_repo_perms.items():
|
|
690
|
+
# Get configured user permissions for this repo
|
|
691
|
+
try:
|
|
692
|
+
config_perm = self.configured_repos_collaborators[repo.name][username]
|
|
693
|
+
# There is no configured permission for this user in this repo,
|
|
694
|
+
# so we assume the default permission
|
|
695
|
+
except KeyError:
|
|
696
|
+
config_perm = self.default_repository_permission
|
|
697
|
+
|
|
698
|
+
# Evaluate whether current permission is higher than configured
|
|
699
|
+
# permission
|
|
700
|
+
if self._permission1_higher_than_permission2(current_perm, config_perm):
|
|
701
|
+
# Find out whether user has these unconfigured permissions
|
|
702
|
+
# due to being member of an unconfigured team. Check whether
|
|
703
|
+
# these are the same permissions as the team would get them.
|
|
704
|
+
unconfigured_team_repo_permission = self.unconfigured_team_repo_permissions.get(
|
|
705
|
+
repo.name, {}
|
|
706
|
+
).get(username, "")
|
|
707
|
+
|
|
708
|
+
if unconfigured_team_repo_permission:
|
|
709
|
+
if current_perm == unconfigured_team_repo_permission:
|
|
710
|
+
logging.info(
|
|
711
|
+
"User %s has '%s' permission on repo '%s' due to being member of "
|
|
712
|
+
"an unconfigured team, and this matches their current permission. "
|
|
713
|
+
"Will not make any changes therefore.",
|
|
714
|
+
username,
|
|
715
|
+
current_perm,
|
|
716
|
+
repo.name,
|
|
717
|
+
)
|
|
718
|
+
continue
|
|
719
|
+
|
|
720
|
+
logging.info(
|
|
721
|
+
"User %s should have '%s' permissions on repo '%s' due to being member "
|
|
722
|
+
"of an unconfigured team, but their current permission on the "
|
|
723
|
+
"repo is '%s'. Removing them from collaborators therefore.",
|
|
724
|
+
username,
|
|
725
|
+
unconfigured_team_repo_permission,
|
|
726
|
+
repo.name,
|
|
727
|
+
current_perm,
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
# Remove person from repo, but only if their repository also
|
|
731
|
+
# diverges from the default repository permission given by
|
|
732
|
+
# the organization
|
|
733
|
+
logging.info(
|
|
734
|
+
"Remove %s from %s. They have '%s' there but should only have '%s'.",
|
|
735
|
+
username,
|
|
736
|
+
repo.name,
|
|
737
|
+
current_perm,
|
|
738
|
+
config_perm,
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
# Remove collaborator
|
|
742
|
+
if not dry:
|
|
743
|
+
repo.remove_from_collaborators(username)
|
|
@@ -23,6 +23,12 @@ parser.add_argument(
|
|
|
23
23
|
)
|
|
24
24
|
parser.add_argument("--debug", action="store_true", help="Get verbose logging output")
|
|
25
25
|
parser.add_argument("--dry", action="store_true", help="Do not make any changes at GitHub")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"-A",
|
|
28
|
+
"--ignore-archived",
|
|
29
|
+
action="store_true",
|
|
30
|
+
help="Do not take any action in ignored repositories",
|
|
31
|
+
)
|
|
26
32
|
parser.add_argument("--version", action="version", version="GitHub Team Manager " + __version__)
|
|
27
33
|
|
|
28
34
|
|
|
@@ -59,7 +65,10 @@ def main():
|
|
|
59
65
|
# Report about organisation members that do not belong to any team
|
|
60
66
|
org.get_members_without_team()
|
|
61
67
|
# Synchronise the permissions of teams for all repositories
|
|
62
|
-
org.sync_repo_permissions(dry=args.dry)
|
|
68
|
+
org.sync_repo_permissions(dry=args.dry, ignore_archived=args.ignore_archived)
|
|
69
|
+
# Remove individual collaborator permissions if they are higher than the one
|
|
70
|
+
# from team membership (or if they are in no configured team at all)
|
|
71
|
+
org.sync_repo_collaborator_permissions(dry=args.dry)
|
|
63
72
|
|
|
64
73
|
# Debug output
|
|
65
74
|
logging.debug("Final dataclass:\n%s", org.df2json())
|
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "github-org-manager"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
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"
|
|
11
11
|
license = "Apache-2.0"
|
|
12
|
-
repository = "https://github.com/OpenRailAssociation/
|
|
12
|
+
repository = "https://github.com/OpenRailAssociation/github-org-manager"
|
|
13
13
|
keywords = ["github", "github-management", "permissions", "access-control"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 5 - Production/Stable",
|
|
@@ -29,6 +29,7 @@ gh-org-mgr = 'gh_org_mgr.manage:main'
|
|
|
29
29
|
python = "^3.10"
|
|
30
30
|
pygithub = "^2.3.0"
|
|
31
31
|
pyyaml = "^6.0.1"
|
|
32
|
+
requests = "^2.32.3"
|
|
32
33
|
|
|
33
34
|
[tool.poetry.group.dev.dependencies]
|
|
34
35
|
black = "^24.3.0"
|
|
@@ -36,6 +37,7 @@ isort = "^5.13.2"
|
|
|
36
37
|
mypy = "^1.9.0"
|
|
37
38
|
pylint = "^3.1.0"
|
|
38
39
|
types-pyyaml = "^6.0.12.20240311"
|
|
40
|
+
types-requests = "^2.32.0.20240712"
|
|
39
41
|
|
|
40
42
|
[build-system]
|
|
41
43
|
requires = ["poetry-core"]
|
|
@@ -1,405 +0,0 @@
|
|
|
1
|
-
# SPDX-FileCopyrightText: 2024 DB Systel GmbH
|
|
2
|
-
#
|
|
3
|
-
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
-
|
|
5
|
-
"""Class for the GitHub organization which contains most of the logic"""
|
|
6
|
-
|
|
7
|
-
import logging
|
|
8
|
-
from dataclasses import asdict, dataclass, field
|
|
9
|
-
|
|
10
|
-
from github import (
|
|
11
|
-
Github,
|
|
12
|
-
NamedUser,
|
|
13
|
-
Organization,
|
|
14
|
-
Repository,
|
|
15
|
-
Team,
|
|
16
|
-
UnknownObjectException,
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
from ._gh_api import get_github_token
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@dataclass
|
|
23
|
-
class GHorg: # pylint: disable=too-many-instance-attributes
|
|
24
|
-
"""Dataclass holding GH organization data and functions"""
|
|
25
|
-
|
|
26
|
-
gh: Github = None # type: ignore
|
|
27
|
-
org: Organization.Organization = None # type: ignore
|
|
28
|
-
org_owners: list[NamedUser.NamedUser] = field(default_factory=list)
|
|
29
|
-
org_members: list[NamedUser.NamedUser] = field(default_factory=list)
|
|
30
|
-
current_teams: dict[Team.Team, dict] = field(default_factory=dict)
|
|
31
|
-
configured_teams: dict[str, dict | None] = field(default_factory=dict)
|
|
32
|
-
current_repos: dict[Repository.Repository, dict[Team.Team, str]] = field(default_factory=dict)
|
|
33
|
-
|
|
34
|
-
# --------------------------------------------------------------------------
|
|
35
|
-
# Helper functions
|
|
36
|
-
# --------------------------------------------------------------------------
|
|
37
|
-
def _sluggify_teamname(self, team: str) -> str:
|
|
38
|
-
"""Slugify a GitHub team name"""
|
|
39
|
-
# TODO: this is very naive, no other special chars are
|
|
40
|
-
# supported, or multiple spaces etc.
|
|
41
|
-
return team.replace(" ", "-")
|
|
42
|
-
|
|
43
|
-
def login(self, orgname: str, token: str):
|
|
44
|
-
"""Login to GH, gather org data"""
|
|
45
|
-
self.gh = Github(get_github_token(token))
|
|
46
|
-
self.org = self.gh.get_organization(orgname)
|
|
47
|
-
|
|
48
|
-
def ratelimit(self):
|
|
49
|
-
"""Get current rate limit"""
|
|
50
|
-
core = self.gh.get_rate_limit().core
|
|
51
|
-
logging.debug(
|
|
52
|
-
"Current rate limit: %s/%s (reset: %s)", core.remaining, core.limit, core.reset
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
def df2json(self) -> str:
|
|
56
|
-
"""Convert the dataclass to a JSON string"""
|
|
57
|
-
d = asdict(self)
|
|
58
|
-
|
|
59
|
-
def pretty(d, indent=0):
|
|
60
|
-
string = ""
|
|
61
|
-
for key, value in d.items():
|
|
62
|
-
string += " " * indent + str(key) + ":\n"
|
|
63
|
-
if isinstance(value, dict):
|
|
64
|
-
string += pretty(value, indent + 1)
|
|
65
|
-
else:
|
|
66
|
-
string += " " * (indent + 1) + str(value) + "\n"
|
|
67
|
-
|
|
68
|
-
return string
|
|
69
|
-
|
|
70
|
-
return pretty(d)
|
|
71
|
-
|
|
72
|
-
# --------------------------------------------------------------------------
|
|
73
|
-
# Teams
|
|
74
|
-
# --------------------------------------------------------------------------
|
|
75
|
-
def get_current_teams(self):
|
|
76
|
-
"""Get teams of the existing organisation"""
|
|
77
|
-
|
|
78
|
-
for team in list(self.org.get_teams()):
|
|
79
|
-
self.current_teams[team] = {"members": {}, "repos": {}}
|
|
80
|
-
|
|
81
|
-
def create_missing_teams(self, dry: bool = False):
|
|
82
|
-
"""Find out which teams are configured but not part of the org yet"""
|
|
83
|
-
|
|
84
|
-
# Get list of current teams
|
|
85
|
-
self.get_current_teams()
|
|
86
|
-
|
|
87
|
-
# Get the names of the existing teams
|
|
88
|
-
existent_team_names = [team.name for team in self.current_teams]
|
|
89
|
-
|
|
90
|
-
for team, attributes in self.configured_teams.items():
|
|
91
|
-
if team not in existent_team_names:
|
|
92
|
-
if parent := attributes.get("parent"): # type: ignore
|
|
93
|
-
parent_id = self.org.get_team_by_slug(self._sluggify_teamname(parent)).id
|
|
94
|
-
|
|
95
|
-
logging.info("Creating team '%s' with parent ID '%s'", team, parent_id)
|
|
96
|
-
if not dry:
|
|
97
|
-
self.org.create_team(team, parent_team_id=parent_id)
|
|
98
|
-
|
|
99
|
-
else:
|
|
100
|
-
logging.info("Creating team '%s' without parent", team)
|
|
101
|
-
if not dry:
|
|
102
|
-
self.org.create_team(team, privacy="closed")
|
|
103
|
-
|
|
104
|
-
else:
|
|
105
|
-
logging.debug("Team '%s' already exists", team)
|
|
106
|
-
|
|
107
|
-
# Re-scan current teams as new ones may have been created
|
|
108
|
-
self.get_current_teams()
|
|
109
|
-
|
|
110
|
-
# --------------------------------------------------------------------------
|
|
111
|
-
# Members
|
|
112
|
-
# --------------------------------------------------------------------------
|
|
113
|
-
def _get_org_members(self):
|
|
114
|
-
"""Get all owners of the org"""
|
|
115
|
-
for member in self.org.get_members(role="admin"):
|
|
116
|
-
self.org_owners.append(member)
|
|
117
|
-
for member in self.org.get_members(role="member"):
|
|
118
|
-
self.org_members.append(member)
|
|
119
|
-
|
|
120
|
-
def _get_configured_team_members(
|
|
121
|
-
self, team_config: dict, team_name: str, role: str
|
|
122
|
-
) -> list[str]:
|
|
123
|
-
"""Read configured members/maintainers from the configuration"""
|
|
124
|
-
|
|
125
|
-
if isinstance(team_config, dict) and team_config.get(role):
|
|
126
|
-
configured_team_members = []
|
|
127
|
-
for user in team_config.get(role, []):
|
|
128
|
-
configured_team_members.append(user)
|
|
129
|
-
|
|
130
|
-
return configured_team_members
|
|
131
|
-
|
|
132
|
-
logging.debug("Team '%s' has no configured %ss", team_name, role)
|
|
133
|
-
return []
|
|
134
|
-
|
|
135
|
-
def _get_current_team_members(self, team: Team.Team) -> dict[NamedUser.NamedUser, str]:
|
|
136
|
-
"""Return dict of current users with their respective roles. Also
|
|
137
|
-
contains members of child teams"""
|
|
138
|
-
current_users: dict[NamedUser.NamedUser, str] = {}
|
|
139
|
-
for role in ("member", "maintainer"):
|
|
140
|
-
# Make a two-step check whether person is actually in team, as
|
|
141
|
-
# get_members() also return child-team members
|
|
142
|
-
for user in list(team.get_members(role=role)):
|
|
143
|
-
current_users.update({user: role})
|
|
144
|
-
|
|
145
|
-
return current_users
|
|
146
|
-
|
|
147
|
-
def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser.NamedUser | None:
|
|
148
|
-
"""Turn a username into a proper GitHub user object"""
|
|
149
|
-
try:
|
|
150
|
-
gh_user: NamedUser.NamedUser = self.gh.get_user(username) # type: ignore
|
|
151
|
-
except UnknownObjectException:
|
|
152
|
-
logging.error(
|
|
153
|
-
"The user '%s' configured as member of team '%s' does not "
|
|
154
|
-
"exist on GitHub. Spelling error or did they rename themselves?",
|
|
155
|
-
username,
|
|
156
|
-
teamname,
|
|
157
|
-
)
|
|
158
|
-
return None
|
|
159
|
-
|
|
160
|
-
return gh_user
|
|
161
|
-
|
|
162
|
-
def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-many-branches
|
|
163
|
-
"""Check the configured members of each team, add missing ones and delete unconfigured"""
|
|
164
|
-
logging.debug("Starting to sync team members")
|
|
165
|
-
|
|
166
|
-
# Gather all members and owners of the organisation
|
|
167
|
-
self._get_org_members()
|
|
168
|
-
|
|
169
|
-
# Get open invitations
|
|
170
|
-
open_invitations = [user.login for user in self.org.invitations()]
|
|
171
|
-
|
|
172
|
-
for team, team_attrs in self.current_teams.items():
|
|
173
|
-
# Update current team members with dict[NamedUser, str (role)]
|
|
174
|
-
team_attrs["members"] = self._get_current_team_members(team)
|
|
175
|
-
|
|
176
|
-
# For the rest of the function however, we use just the login name
|
|
177
|
-
# for each current user
|
|
178
|
-
current_team_members = {
|
|
179
|
-
user.login: role for user, role in team_attrs["members"].items()
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
# Handle the team not being configured locally
|
|
183
|
-
if team.name not in self.configured_teams:
|
|
184
|
-
logging.warning(
|
|
185
|
-
"Team '%s' does not seem to be configured locally. "
|
|
186
|
-
"Taking no action about this team at all",
|
|
187
|
-
team.name,
|
|
188
|
-
)
|
|
189
|
-
continue
|
|
190
|
-
|
|
191
|
-
# Get configuration from current team
|
|
192
|
-
if team_configuration := self.configured_teams.get(team.name):
|
|
193
|
-
pass
|
|
194
|
-
else:
|
|
195
|
-
team_configuration = {}
|
|
196
|
-
|
|
197
|
-
# Analog to team_attrs["members"], add members and maintainers to shared
|
|
198
|
-
# dict with respective role, while maintainer role dominates
|
|
199
|
-
configured_users: dict[str, str] = {}
|
|
200
|
-
for config_role in ("member", "maintainer"):
|
|
201
|
-
team_members = self._get_configured_team_members(
|
|
202
|
-
team_configuration, team.name, config_role
|
|
203
|
-
)
|
|
204
|
-
for team_member in team_members:
|
|
205
|
-
# Add user with role to dict
|
|
206
|
-
configured_users.update({team_member: config_role})
|
|
207
|
-
|
|
208
|
-
# Consider all GitHub organisation team maintainers if they are member of the team
|
|
209
|
-
# This is because GitHub API returns them as maintainers even if they are just members
|
|
210
|
-
for user in self.org_owners:
|
|
211
|
-
if user.login in configured_users:
|
|
212
|
-
logging.debug(
|
|
213
|
-
"Overriding role of organisation owner '%s' to maintainer", user.login
|
|
214
|
-
)
|
|
215
|
-
configured_users[user.login] = "maintainer"
|
|
216
|
-
|
|
217
|
-
# Only make edits to the team membership if the current state differs from config
|
|
218
|
-
if configured_users == current_team_members:
|
|
219
|
-
logging.info("Team '%s' configuration is in sync, no changes", team.name)
|
|
220
|
-
continue
|
|
221
|
-
|
|
222
|
-
# Loop through the configured users, add / update them if necessary
|
|
223
|
-
for config_user, config_role in configured_users.items():
|
|
224
|
-
# Add user if they haven't been in the team yet
|
|
225
|
-
if config_user not in current_team_members:
|
|
226
|
-
# Turn user to GitHub object, trying to find them
|
|
227
|
-
if not (gh_user := self._resolve_gh_username(config_user, team.name)):
|
|
228
|
-
continue
|
|
229
|
-
|
|
230
|
-
# Do not reinvite user if their invitation is already pending
|
|
231
|
-
if config_user in open_invitations:
|
|
232
|
-
logging.info(
|
|
233
|
-
"User '%s' shall be added to team '%s' as %s, invitation is pending",
|
|
234
|
-
gh_user.login,
|
|
235
|
-
team.name,
|
|
236
|
-
config_role,
|
|
237
|
-
)
|
|
238
|
-
continue
|
|
239
|
-
|
|
240
|
-
logging.info(
|
|
241
|
-
"Adding user '%s' to team '%s' as %s",
|
|
242
|
-
gh_user.login,
|
|
243
|
-
team.name,
|
|
244
|
-
config_role,
|
|
245
|
-
)
|
|
246
|
-
if not dry:
|
|
247
|
-
team.add_membership(member=gh_user, role=config_role)
|
|
248
|
-
|
|
249
|
-
# Update roles if they differ from old role
|
|
250
|
-
elif config_role != current_team_members.get(config_user, ""):
|
|
251
|
-
# Turn user to GitHub object, trying to find them
|
|
252
|
-
if not (gh_user := self._resolve_gh_username(config_user, team.name)):
|
|
253
|
-
continue
|
|
254
|
-
logging.info(
|
|
255
|
-
"Updating role of '%s' in team '%s' to %s",
|
|
256
|
-
config_user,
|
|
257
|
-
team.name,
|
|
258
|
-
config_role,
|
|
259
|
-
)
|
|
260
|
-
if not dry:
|
|
261
|
-
team.add_membership(member=gh_user, role=config_role)
|
|
262
|
-
|
|
263
|
-
# Loop through all current members. Remove them if they are not configured
|
|
264
|
-
for current_user in current_team_members:
|
|
265
|
-
if current_user not in configured_users:
|
|
266
|
-
# Turn user to GitHub object, trying to find them
|
|
267
|
-
if not (gh_user := self._resolve_gh_username(current_user, team.name)):
|
|
268
|
-
continue
|
|
269
|
-
if team.has_in_members(gh_user):
|
|
270
|
-
logging.info(
|
|
271
|
-
"Removing '%s' from team '%s' as they are not configured",
|
|
272
|
-
gh_user.login,
|
|
273
|
-
team.name,
|
|
274
|
-
)
|
|
275
|
-
if not dry:
|
|
276
|
-
team.remove_membership(gh_user)
|
|
277
|
-
else:
|
|
278
|
-
logging.debug(
|
|
279
|
-
"User '%s' does not need to be removed from team '%s' "
|
|
280
|
-
"as they are just member of a child-team",
|
|
281
|
-
gh_user.login,
|
|
282
|
-
team.name,
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
def get_members_without_team(self) -> None:
|
|
286
|
-
"""Get all organisation members without any team membership"""
|
|
287
|
-
# Combine org owners and org members
|
|
288
|
-
all_org_members = set(self.org_members + self.org_owners)
|
|
289
|
-
|
|
290
|
-
# Get all members of all teams
|
|
291
|
-
all_team_members_lst = []
|
|
292
|
-
for _, team_attrs in self.current_teams.items():
|
|
293
|
-
for member in team_attrs.get("members", {}):
|
|
294
|
-
all_team_members_lst.append(member)
|
|
295
|
-
all_team_members = set(all_team_members_lst)
|
|
296
|
-
|
|
297
|
-
# Find members that are in org_members but not team_members
|
|
298
|
-
members_without_team = all_org_members.difference(all_team_members)
|
|
299
|
-
|
|
300
|
-
if members_without_team:
|
|
301
|
-
members_without_team_str = [user.login for user in members_without_team]
|
|
302
|
-
logging.warning(
|
|
303
|
-
"The following members of your GitHub organisation are not member of any team: %s",
|
|
304
|
-
", ".join(members_without_team_str),
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
# --------------------------------------------------------------------------
|
|
308
|
-
# Repos
|
|
309
|
-
# --------------------------------------------------------------------------
|
|
310
|
-
def _get_current_repos_and_perms(self) -> None:
|
|
311
|
-
"""Get all repos, their current teams and their permissions"""
|
|
312
|
-
for repo in list(self.org.get_repos()):
|
|
313
|
-
self.current_repos[repo] = {}
|
|
314
|
-
for team in list(repo.get_teams()):
|
|
315
|
-
self.current_repos[repo][team] = team.permission
|
|
316
|
-
|
|
317
|
-
def _create_perms_changelist_for_teams(
|
|
318
|
-
self,
|
|
319
|
-
) -> dict[Team.Team, dict[Repository.Repository, str]]:
|
|
320
|
-
"""Create a permission/repo changelist from the perspective of configured teams"""
|
|
321
|
-
team_changelist: dict[Team.Team, dict[Repository.Repository, str]] = {}
|
|
322
|
-
for team_name, team_attrs in self.configured_teams.items():
|
|
323
|
-
# Handle unset configured attributes
|
|
324
|
-
if team_attrs is None:
|
|
325
|
-
continue
|
|
326
|
-
|
|
327
|
-
# Convert team name to Team object
|
|
328
|
-
team = self.org.get_team_by_slug(self._sluggify_teamname(team_name))
|
|
329
|
-
|
|
330
|
-
# Get configured repo permissions
|
|
331
|
-
for repo, perm in team_attrs.get("repos", {}).items():
|
|
332
|
-
# Convert repo to Repo object
|
|
333
|
-
try:
|
|
334
|
-
repo = self.org.get_repo(repo)
|
|
335
|
-
except UnknownObjectException:
|
|
336
|
-
logging.warning(
|
|
337
|
-
"Configured repository '%s' for team '%s' has not been "
|
|
338
|
-
"found in the organisation",
|
|
339
|
-
repo,
|
|
340
|
-
team.name,
|
|
341
|
-
)
|
|
342
|
-
continue
|
|
343
|
-
|
|
344
|
-
if perm != self.current_repos[repo].get(team):
|
|
345
|
-
# Add the changeset to the changelist
|
|
346
|
-
if team not in team_changelist:
|
|
347
|
-
team_changelist[team] = {}
|
|
348
|
-
team_changelist[team][repo] = perm
|
|
349
|
-
|
|
350
|
-
return team_changelist
|
|
351
|
-
|
|
352
|
-
def sync_repo_permissions(self, dry: bool = False) -> None:
|
|
353
|
-
"""Synchronise the repository permissions of all teams"""
|
|
354
|
-
logging.debug("Starting to sync repo/team permissions")
|
|
355
|
-
|
|
356
|
-
# Get all repos and their current permissions from GitHub
|
|
357
|
-
self._get_current_repos_and_perms()
|
|
358
|
-
|
|
359
|
-
# Find differences between configured permissions for a team's repo and the current state
|
|
360
|
-
for team, repos in self._create_perms_changelist_for_teams().items():
|
|
361
|
-
for repo, perm in repos.items():
|
|
362
|
-
logging.info(
|
|
363
|
-
"Changing permission of repository '%s' for team '%s' to '%s'",
|
|
364
|
-
repo.name,
|
|
365
|
-
team.name,
|
|
366
|
-
perm,
|
|
367
|
-
)
|
|
368
|
-
if not dry:
|
|
369
|
-
# Update permissions or newly add a team to a repo
|
|
370
|
-
team.update_team_repository(repo, perm)
|
|
371
|
-
|
|
372
|
-
# Find out whether repos' permissions contain *configured* teams that
|
|
373
|
-
# should not have permissions
|
|
374
|
-
for repo, teams in self.current_repos.items():
|
|
375
|
-
for team in teams:
|
|
376
|
-
# Get configured repos for this team, finding out whether repo
|
|
377
|
-
# is configured for this team
|
|
378
|
-
remove = False
|
|
379
|
-
# Handle: Team is not configured at all
|
|
380
|
-
if team.name not in self.configured_teams:
|
|
381
|
-
logging.warning(
|
|
382
|
-
"Team '%s' has permissions on repository '%s', but this team "
|
|
383
|
-
"is not configured locally",
|
|
384
|
-
team.name,
|
|
385
|
-
repo.name,
|
|
386
|
-
)
|
|
387
|
-
continue
|
|
388
|
-
# Handle: Team is configured, but contains no config
|
|
389
|
-
if self.configured_teams[team.name] is None:
|
|
390
|
-
remove = True
|
|
391
|
-
# Handle: Team is configured, contains config
|
|
392
|
-
elif repos := self.configured_teams[team.name].get("repos", []): # type: ignore
|
|
393
|
-
# If this repo has not been found in the configured repos
|
|
394
|
-
# for the team, remove all permissions
|
|
395
|
-
if repo.name not in repos:
|
|
396
|
-
remove = True
|
|
397
|
-
# Handle: Team is configured, contains config, but no "repos" key
|
|
398
|
-
else:
|
|
399
|
-
remove = True
|
|
400
|
-
|
|
401
|
-
# Remove if any mismatch has been found
|
|
402
|
-
if remove:
|
|
403
|
-
logging.info("Removing team '%s' from repository '%s'", team.name, repo.name)
|
|
404
|
-
if not dry:
|
|
405
|
-
team.remove_from_repos(repo)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|