github-org-manager 0.3.0__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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: github-org-manager
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Manage a GitHub Organization, its teams, repository permissions, and more
5
5
  Home-page: https://github.com/OpenRailAssociation/github-org-manager
6
6
  License: Apache-2.0
@@ -23,6 +23,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
23
23
  gh: Github = None # type: ignore
24
24
  org: Organization = None # type: ignore
25
25
  gh_token: str = ""
26
+ default_repository_permission: str = ""
26
27
  org_owners: list[NamedUser] = field(default_factory=list)
27
28
  org_members: list[NamedUser] = field(default_factory=list)
28
29
  current_teams: dict[Team, dict] = field(default_factory=dict)
@@ -59,6 +60,18 @@ class GHorg: # pylint: disable=too-many-instance-attributes
59
60
  """Convert the dataclass to a JSON string"""
60
61
  d = asdict(self)
61
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
62
75
  def pretty(d, indent=0):
63
76
  string = ""
64
77
  for key, value in d.items():
@@ -75,9 +88,8 @@ class GHorg: # pylint: disable=too-many-instance-attributes
75
88
  # --------------------------------------------------------------------------
76
89
  # Teams
77
90
  # --------------------------------------------------------------------------
78
- def get_current_teams(self):
91
+ def _get_current_teams(self):
79
92
  """Get teams of the existing organisation"""
80
-
81
93
  for team in list(self.org.get_teams()):
82
94
  self.current_teams[team] = {"members": {}, "repos": {}}
83
95
 
@@ -85,7 +97,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
85
97
  """Find out which teams are configured but not part of the org yet"""
86
98
 
87
99
  # Get list of current teams
88
- self.get_current_teams()
100
+ self._get_current_teams()
89
101
 
90
102
  # Get the names of the existing teams
91
103
  existent_team_names = [team.name for team in self.current_teams]
@@ -108,7 +120,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
108
120
  logging.debug("Team '%s' already exists", team)
109
121
 
110
122
  # Re-scan current teams as new ones may have been created
111
- self.get_current_teams()
123
+ self._get_current_teams()
112
124
 
113
125
  # --------------------------------------------------------------------------
114
126
  # Members
@@ -474,13 +486,55 @@ class GHorg: # pylint: disable=too-many-instance-attributes
474
486
 
475
487
  return ""
476
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
+
477
529
  def _get_configured_repos_and_user_perms(self):
478
530
  """
479
531
  Get a list of repos with a list of individuals and their permissions,
480
532
  based on their team memberships
481
533
  """
482
- for _, team_attrs in self.configured_teams.items():
483
- for repo, perm in team_attrs.get("repos", {}).items():
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():
484
538
  # Create repo if non-exist
485
539
  if repo not in self.configured_repos_collaborators:
486
540
  self.configured_repos_collaborators[repo] = {}
@@ -513,14 +567,15 @@ class GHorg: # pylint: disable=too-many-instance-attributes
513
567
  """Convert a repo permission coming from the GraphQL API to the ones
514
568
  coming from the REST API"""
515
569
  perm_conversion = {
516
- "READ": "pull",
517
- "TRIAGE": "triage",
518
- "WRITE": "push",
519
- "MAINTAIN": "maintain",
520
- "ADMIN": "admin",
570
+ "none": "",
571
+ "read": "pull",
572
+ "triage": "triage",
573
+ "write": "push",
574
+ "maintain": "maintain",
575
+ "admin": "admin",
521
576
  }
522
- if permission in perm_conversion:
523
- replacement = perm_conversion.get(permission, "")
577
+ if permission.lower() in perm_conversion:
578
+ replacement = perm_conversion.get(permission.lower(), "")
524
579
  return replacement
525
580
 
526
581
  return permission
@@ -528,20 +583,22 @@ class GHorg: # pylint: disable=too-many-instance-attributes
528
583
  def _fetch_collaborators_of_repo(self, repo: Repository):
529
584
  """Get all collaborators (individuals) of a GitHub repo with their
530
585
  permissions using the GraphQL API"""
586
+ # TODO: Consider doing this for all repositories at once, but calculate
587
+ # costs beforehand
531
588
  query = """
532
589
  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
- }
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
+ }
545
602
  }
546
603
  }
547
604
  }
@@ -554,6 +611,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
554
611
  has_next_page = True
555
612
 
556
613
  while has_next_page:
614
+ logging.debug("Requesting collaborators for %s", repo.name)
557
615
  result = run_graphql_query(query, variables, self.gh_token)
558
616
  try:
559
617
  collaborators.extend(result["data"]["repository"]["collaborators"]["edges"])
@@ -586,6 +644,27 @@ class GHorg: # pylint: disable=too-many-instance-attributes
586
644
  # Get users for this repo
587
645
  self._fetch_collaborators_of_repo(repo)
588
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
+
589
668
  def sync_repo_collaborator_permissions(self, dry: bool = False):
590
669
  """Compare the configured with the current repo permissions for all
591
670
  repositories' collaborators"""
@@ -595,9 +674,13 @@ class GHorg: # pylint: disable=too-many-instance-attributes
595
674
  # The resulting structure is:
596
675
  # - configured_repos_collaborators: dict[Repository, dict[username, permission]]
597
676
  # - current_repos_collaborators: dict[Repository, dict[username, permission]]
677
+ logging.debug("Starting to sync collaborator/individual permissions")
598
678
  self._get_configured_repos_and_user_perms()
599
679
  self._get_current_repos_and_user_perms()
600
680
 
681
+ # Get and convert the default permission for all members so we can check for it
682
+ self._get_default_repository_permission()
683
+
601
684
  # Loop over all factually existing repositories. This will be a one-way
602
685
  # sync. Team permissions have been set before, we are now removing
603
686
  # surplus permissions. As no individual permissions are allowed, these
@@ -607,17 +690,21 @@ class GHorg: # pylint: disable=too-many-instance-attributes
607
690
  # Get configured user permissions for this repo
608
691
  try:
609
692
  config_perm = self.configured_repos_collaborators[repo.name][username]
610
- # There is no configured permission for this user in this repo
693
+ # There is no configured permission for this user in this repo,
694
+ # so we assume the default permission
611
695
  except KeyError:
612
- config_perm = ""
696
+ config_perm = self.default_repository_permission
613
697
 
614
- if current_perm != config_perm:
698
+ # Evaluate whether current permission is higher than configured
699
+ # permission
700
+ if self._permission1_higher_than_permission2(current_perm, config_perm):
615
701
  # Find out whether user has these unconfigured permissions
616
702
  # due to being member of an unconfigured team. Check whether
617
703
  # these are the same permissions as the team would get them.
618
704
  unconfigured_team_repo_permission = self.unconfigured_team_repo_permissions.get(
619
705
  repo.name, {}
620
706
  ).get(username, "")
707
+
621
708
  if unconfigured_team_repo_permission:
622
709
  if current_perm == unconfigured_team_repo_permission:
623
710
  logging.info(
@@ -640,6 +727,9 @@ class GHorg: # pylint: disable=too-many-instance-attributes
640
727
  current_perm,
641
728
  )
642
729
 
730
+ # Remove person from repo, but only if their repository also
731
+ # diverges from the default repository permission given by
732
+ # the organization
643
733
  logging.info(
644
734
  "Remove %s from %s. They have '%s' there but should only have '%s'.",
645
735
  username,
@@ -4,7 +4,7 @@
4
4
 
5
5
  [tool.poetry]
6
6
  name = "github-org-manager"
7
- version = "0.3.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"