github-org-manager 0.2.1__tar.gz → 0.3.0__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.0
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}")
@@ -7,16 +7,13 @@
7
7
  import logging
8
8
  from dataclasses import asdict, dataclass, field
9
9
 
10
- from github import (
11
- Github,
12
- NamedUser,
13
- Organization,
14
- Repository,
15
- Team,
16
- UnknownObjectException,
17
- )
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
18
15
 
19
- from ._gh_api import get_github_token
16
+ from ._gh_api import get_github_token, run_graphql_query
20
17
 
21
18
 
22
19
  @dataclass
@@ -24,12 +21,17 @@ class GHorg: # pylint: disable=too-many-instance-attributes
24
21
  """Dataclass holding GH organization data and functions"""
25
22
 
26
23
  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)
24
+ org: Organization = None # type: ignore
25
+ gh_token: str = ""
26
+ org_owners: list[NamedUser] = field(default_factory=list)
27
+ org_members: list[NamedUser] = field(default_factory=list)
28
+ current_teams: dict[Team, dict] = field(default_factory=dict)
31
29
  configured_teams: dict[str, dict | None] = field(default_factory=dict)
32
- current_repos: dict[Repository.Repository, dict[Team.Team, str]] = field(default_factory=dict)
30
+ current_repos_teams: dict[Repository, dict[Team, str]] = field(default_factory=dict)
31
+ current_repos_collaborators: dict[Repository, dict[str, str]] = field(default_factory=dict)
32
+ configured_repos_collaborators: dict[str, dict[str, str]] = field(default_factory=dict)
33
+ archived_repos: list[Repository] = field(default_factory=list)
34
+ unconfigured_team_repo_permissions: dict[str, dict[str, str]] = field(default_factory=dict)
33
35
 
34
36
  # --------------------------------------------------------------------------
35
37
  # Helper functions
@@ -42,7 +44,8 @@ class GHorg: # pylint: disable=too-many-instance-attributes
42
44
 
43
45
  def login(self, orgname: str, token: str):
44
46
  """Login to GH, gather org data"""
45
- self.gh = Github(get_github_token(token))
47
+ self.gh_token = get_github_token(token)
48
+ self.gh = Github(self.gh_token)
46
49
  self.org = self.gh.get_organization(orgname)
47
50
 
48
51
  def ratelimit(self):
@@ -132,10 +135,10 @@ class GHorg: # pylint: disable=too-many-instance-attributes
132
135
  logging.debug("Team '%s' has no configured %ss", team_name, role)
133
136
  return []
134
137
 
135
- def _get_current_team_members(self, team: Team.Team) -> dict[NamedUser.NamedUser, str]:
138
+ def _get_current_team_members(self, team: Team) -> dict[NamedUser, str]:
136
139
  """Return dict of current users with their respective roles. Also
137
140
  contains members of child teams"""
138
- current_users: dict[NamedUser.NamedUser, str] = {}
141
+ current_users: dict[NamedUser, str] = {}
139
142
  for role in ("member", "maintainer"):
140
143
  # Make a two-step check whether person is actually in team, as
141
144
  # get_members() also return child-team members
@@ -144,10 +147,10 @@ class GHorg: # pylint: disable=too-many-instance-attributes
144
147
 
145
148
  return current_users
146
149
 
147
- def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser.NamedUser | None:
150
+ def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser | None:
148
151
  """Turn a username into a proper GitHub user object"""
149
152
  try:
150
- gh_user: NamedUser.NamedUser = self.gh.get_user(username) # type: ignore
153
+ gh_user: NamedUser = self.gh.get_user(username) # type: ignore
151
154
  except UnknownObjectException:
152
155
  logging.error(
153
156
  "The user '%s' configured as member of team '%s' does not "
@@ -307,18 +310,27 @@ class GHorg: # pylint: disable=too-many-instance-attributes
307
310
  # --------------------------------------------------------------------------
308
311
  # Repos
309
312
  # --------------------------------------------------------------------------
310
- def _get_current_repos_and_perms(self) -> None:
313
+ def _get_current_repos_and_team_perms(self, ignore_archived: bool) -> None:
311
314
  """Get all repos, their current teams and their permissions"""
312
315
  for repo in list(self.org.get_repos()):
313
- self.current_repos[repo] = {}
316
+ # Check if repo is archived. If so, ignore it, if user requested so
317
+ if ignore_archived and repo.archived:
318
+ logging.debug(
319
+ "Ignoring %s as it is archived and user requested to ignore such repos",
320
+ repo.name,
321
+ )
322
+ self.archived_repos.append(repo)
323
+ continue
324
+
325
+ self.current_repos_teams[repo] = {}
314
326
  for team in list(repo.get_teams()):
315
- self.current_repos[repo][team] = team.permission
327
+ self.current_repos_teams[repo][team] = team.permission
316
328
 
317
329
  def _create_perms_changelist_for_teams(
318
330
  self,
319
- ) -> dict[Team.Team, dict[Repository.Repository, str]]:
331
+ ) -> dict[Team, dict[Repository, str]]:
320
332
  """Create a permission/repo changelist from the perspective of configured teams"""
321
- team_changelist: dict[Team.Team, dict[Repository.Repository, str]] = {}
333
+ team_changelist: dict[Team, dict[Repository, str]] = {}
322
334
  for team_name, team_attrs in self.configured_teams.items():
323
335
  # Handle unset configured attributes
324
336
  if team_attrs is None:
@@ -341,7 +353,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
341
353
  )
342
354
  continue
343
355
 
344
- if perm != self.current_repos[repo].get(team):
356
+ if perm != self.current_repos_teams[repo].get(team):
345
357
  # Add the changeset to the changelist
346
358
  if team not in team_changelist:
347
359
  team_changelist[team] = {}
@@ -349,12 +361,41 @@ class GHorg: # pylint: disable=too-many-instance-attributes
349
361
 
350
362
  return team_changelist
351
363
 
352
- def sync_repo_permissions(self, dry: bool = False) -> None:
364
+ def _document_unconfigured_team_repo_permissions(
365
+ self, team: Team, team_permission: str, repo_name: str
366
+ ) -> None:
367
+ """Create a record of all members of a team and their permissions on a
368
+ repo due to being member of an unconfigured team"""
369
+ users_of_unconfigured_team: dict[NamedUser, str] = self.current_teams[team].get(
370
+ "members"
371
+ ) # type: ignore
372
+ # Initiate this repo in the dict as dict if not present
373
+ if repo_name not in self.unconfigured_team_repo_permissions:
374
+ self.unconfigured_team_repo_permissions[repo_name] = {}
375
+ # Add actual permission for each user of this unconfigured team
376
+ for user in users_of_unconfigured_team:
377
+ # Handle if another, potentially higher permission is already set by
378
+ # membership in another team
379
+ if exist_perm := self.unconfigured_team_repo_permissions[repo_name].get(user.login, ""):
380
+ logging.debug(
381
+ "Permissions for %s on %s already exist: %s. "
382
+ "Checking whether new permission is higher.",
383
+ user.login,
384
+ repo_name,
385
+ exist_perm,
386
+ )
387
+ self.unconfigured_team_repo_permissions[repo_name][user.login] = (
388
+ self._get_highest_permission(exist_perm, team_permission)
389
+ )
390
+ else:
391
+ self.unconfigured_team_repo_permissions[repo_name][user.login] = team_permission
392
+
393
+ def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False) -> None:
353
394
  """Synchronise the repository permissions of all teams"""
354
395
  logging.debug("Starting to sync repo/team permissions")
355
396
 
356
397
  # Get all repos and their current permissions from GitHub
357
- self._get_current_repos_and_perms()
398
+ self._get_current_repos_and_team_perms(ignore_archived)
358
399
 
359
400
  # Find differences between configured permissions for a team's repo and the current state
360
401
  for team, repos in self._create_perms_changelist_for_teams().items():
@@ -371,7 +412,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
371
412
 
372
413
  # Find out whether repos' permissions contain *configured* teams that
373
414
  # should not have permissions
374
- for repo, teams in self.current_repos.items():
415
+ for repo, teams in self.current_repos_teams.items():
375
416
  for team in teams:
376
417
  # Get configured repos for this team, finding out whether repo
377
418
  # is configured for this team
@@ -384,6 +425,13 @@ class GHorg: # pylint: disable=too-many-instance-attributes
384
425
  team.name,
385
426
  repo.name,
386
427
  )
428
+ # Store information about these team members and their
429
+ # permissions on the repo. We will use it later in the
430
+ # collaborators step
431
+ self._document_unconfigured_team_repo_permissions(
432
+ team=team, team_permission=teams[team], repo_name=repo.name
433
+ )
434
+ # Abort handling the repo sync as we don't touch unconfigured teams
387
435
  continue
388
436
  # Handle: Team is configured, but contains no config
389
437
  if self.configured_teams[team.name] is None:
@@ -403,3 +451,203 @@ class GHorg: # pylint: disable=too-many-instance-attributes
403
451
  logging.info("Removing team '%s' from repository '%s'", team.name, repo.name)
404
452
  if not dry:
405
453
  team.remove_from_repos(repo)
454
+
455
+ # --------------------------------------------------------------------------
456
+ # Collaborators
457
+ # --------------------------------------------------------------------------
458
+ def _aggregate_lists(self, *lists: list[str | int]) -> list[str | int]:
459
+ """Combine multiple lists into one while removing duplicates"""
460
+ complete = []
461
+ for single_list in lists:
462
+ complete.extend(single_list)
463
+
464
+ return list(set(complete))
465
+
466
+ def _get_highest_permission(self, *permissions: str) -> str:
467
+ """Get the highest GitHub repo permissions out of multiple permissions"""
468
+ perms_ranking = ["admin", "maintain", "push", "triage", "pull"]
469
+ for perm in perms_ranking:
470
+ # If e.g. "maintain" matches one of the two permissions
471
+ if perm in permissions:
472
+ logging.debug("%s is the highest permission", perm)
473
+ return perm
474
+
475
+ return ""
476
+
477
+ def _get_configured_repos_and_user_perms(self):
478
+ """
479
+ Get a list of repos with a list of individuals and their permissions,
480
+ based on their team memberships
481
+ """
482
+ for _, team_attrs in self.configured_teams.items():
483
+ for repo, perm in team_attrs.get("repos", {}).items():
484
+ # Create repo if non-exist
485
+ if repo not in self.configured_repos_collaborators:
486
+ self.configured_repos_collaborators[repo] = {}
487
+
488
+ # Get team maintainers and members
489
+ team_members = self._aggregate_lists(
490
+ team_attrs.get("maintainer", []), team_attrs.get("member", [])
491
+ )
492
+
493
+ # Add team member to repo with their repo permissions
494
+ for team_member in team_members:
495
+ # Check if permissions already exist
496
+ if self.configured_repos_collaborators[repo].get(team_member, {}):
497
+ logging.debug(
498
+ "Permissions for %s on %s already exist: %s. "
499
+ "Checking whether new permission is higher.",
500
+ team_member,
501
+ repo,
502
+ self.configured_repos_collaborators[repo][team_member],
503
+ )
504
+ self.configured_repos_collaborators[repo][team_member] = (
505
+ self._get_highest_permission(
506
+ perm, self.configured_repos_collaborators[repo][team_member]
507
+ )
508
+ )
509
+ else:
510
+ self.configured_repos_collaborators[repo][team_member] = perm
511
+
512
+ def _convert_graphql_perm_to_rest(self, permission: str) -> str:
513
+ """Convert a repo permission coming from the GraphQL API to the ones
514
+ coming from the REST API"""
515
+ perm_conversion = {
516
+ "READ": "pull",
517
+ "TRIAGE": "triage",
518
+ "WRITE": "push",
519
+ "MAINTAIN": "maintain",
520
+ "ADMIN": "admin",
521
+ }
522
+ if permission in perm_conversion:
523
+ replacement = perm_conversion.get(permission, "")
524
+ return replacement
525
+
526
+ return permission
527
+
528
+ def _fetch_collaborators_of_repo(self, repo: Repository):
529
+ """Get all collaborators (individuals) of a GitHub repo with their
530
+ permissions using the GraphQL API"""
531
+ query = """
532
+ query($owner: String!, $name: String!, $cursor: String) {
533
+ repository(owner: $owner, name: $name) {
534
+ collaborators(first: 20, after: $cursor) {
535
+ edges {
536
+ node {
537
+ login
538
+ }
539
+ permission
540
+ }
541
+ pageInfo {
542
+ endCursor
543
+ hasNextPage
544
+ }
545
+ }
546
+ }
547
+ }
548
+ """
549
+
550
+ # Initial query parameters
551
+ variables = {"owner": self.org.login, "name": repo.name, "cursor": None}
552
+
553
+ collaborators = []
554
+ has_next_page = True
555
+
556
+ while has_next_page:
557
+ result = run_graphql_query(query, variables, self.gh_token)
558
+ try:
559
+ collaborators.extend(result["data"]["repository"]["collaborators"]["edges"])
560
+ has_next_page = result["data"]["repository"]["collaborators"]["pageInfo"][
561
+ "hasNextPage"
562
+ ]
563
+ variables["cursor"] = result["data"]["repository"]["collaborators"]["pageInfo"][
564
+ "endCursor"
565
+ ]
566
+ except (TypeError, KeyError):
567
+ logging.debug("Repo %s does not seem to have any collaborators", repo.name)
568
+ continue
569
+
570
+ # Extract relevant data
571
+ for collaborator in collaborators:
572
+ login = collaborator["node"]["login"]
573
+ # Skip entry if collaborator is org owner, which is "admin" anyway
574
+ if login in [user.login for user in self.org_owners]:
575
+ continue
576
+ permission = self._convert_graphql_perm_to_rest(collaborator["permission"])
577
+ self.current_repos_collaborators[repo][login] = permission
578
+
579
+ def _get_current_repos_and_user_perms(self):
580
+ """Get all repos, their current collaborators and their permissions"""
581
+ # We copy the list of repos from self.current_repos_teams
582
+ for repo in self.current_repos_teams:
583
+ self.current_repos_collaborators[repo] = {}
584
+
585
+ for repo in self.current_repos_collaborators:
586
+ # Get users for this repo
587
+ self._fetch_collaborators_of_repo(repo)
588
+
589
+ def sync_repo_collaborator_permissions(self, dry: bool = False):
590
+ """Compare the configured with the current repo permissions for all
591
+ repositories' collaborators"""
592
+ # Collect info about all repos, their configured collaborators (through
593
+ # team membership) and the current state (either through team membership
594
+ # or individual).
595
+ # The resulting structure is:
596
+ # - configured_repos_collaborators: dict[Repository, dict[username, permission]]
597
+ # - current_repos_collaborators: dict[Repository, dict[username, permission]]
598
+ self._get_configured_repos_and_user_perms()
599
+ self._get_current_repos_and_user_perms()
600
+
601
+ # Loop over all factually existing repositories. This will be a one-way
602
+ # sync. Team permissions have been set before, we are now removing
603
+ # surplus permissions. As no individual permissions are allowed, these
604
+ # will be fully revoked.
605
+ for repo, current_repo_perms in self.current_repos_collaborators.items():
606
+ for username, current_perm in current_repo_perms.items():
607
+ # Get configured user permissions for this repo
608
+ try:
609
+ config_perm = self.configured_repos_collaborators[repo.name][username]
610
+ # There is no configured permission for this user in this repo
611
+ except KeyError:
612
+ config_perm = ""
613
+
614
+ if current_perm != config_perm:
615
+ # Find out whether user has these unconfigured permissions
616
+ # due to being member of an unconfigured team. Check whether
617
+ # these are the same permissions as the team would get them.
618
+ unconfigured_team_repo_permission = self.unconfigured_team_repo_permissions.get(
619
+ repo.name, {}
620
+ ).get(username, "")
621
+ if unconfigured_team_repo_permission:
622
+ if current_perm == unconfigured_team_repo_permission:
623
+ logging.info(
624
+ "User %s has '%s' permission on repo '%s' due to being member of "
625
+ "an unconfigured team, and this matches their current permission. "
626
+ "Will not make any changes therefore.",
627
+ username,
628
+ current_perm,
629
+ repo.name,
630
+ )
631
+ continue
632
+
633
+ logging.info(
634
+ "User %s should have '%s' permissions on repo '%s' due to being member "
635
+ "of an unconfigured team, but their current permission on the "
636
+ "repo is '%s'. Removing them from collaborators therefore.",
637
+ username,
638
+ unconfigured_team_repo_permission,
639
+ repo.name,
640
+ current_perm,
641
+ )
642
+
643
+ logging.info(
644
+ "Remove %s from %s. They have '%s' there but should only have '%s'.",
645
+ username,
646
+ repo.name,
647
+ current_perm,
648
+ config_perm,
649
+ )
650
+
651
+ # Remove collaborator
652
+ if not dry:
653
+ 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.0"
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"]