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.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: github-org-manager
3
- Version: 0.2.1
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/openrail-org-config
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
- Project-URL: Repository, https://github.com/OpenRailAssociation/openrail-org-config
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.2.1"
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/openrail-org-config"
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)