1919from github .NamedUser import NamedUser
2020from github .Organization import Organization
2121from github .Repository import Repository
22+ from github .Requester import Requester
2223from github .Team import Team
2324from jwt .exceptions import InvalidKeyError
2425
2526from ._gh_api import get_github_secrets_from_env , run_graphql_query
27+ from ._helpers import (
28+ compare_two_dicts ,
29+ compare_two_lists ,
30+ dict_to_pretty_string ,
31+ sluggify_teamname ,
32+ )
33+ from ._stats import OrgChanges
2634
2735
2836@dataclass
@@ -47,6 +55,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
4755 configured_repos_collaborators : dict [str , dict [str , str ]] = field (default_factory = dict )
4856 archived_repos : list [Repository ] = field (default_factory = list )
4957 unconfigured_team_repo_permissions : dict [str , dict [str , str ]] = field (default_factory = dict )
58+ stats : OrgChanges = field (default_factory = OrgChanges )
5059
5160 # Re-usable Constants
5261 TEAM_CONFIG_FIELDS : dict [str , dict [str , str | None ]] = field ( # pylint: disable=invalid-name
@@ -61,12 +70,6 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
6170 # --------------------------------------------------------------------------
6271 # Helper functions
6372 # --------------------------------------------------------------------------
64- def _sluggify_teamname (self , team : str ) -> str :
65- """Slugify a GitHub team name"""
66- # TODO: this is very naive, no other special chars are
67- # supported, or multiple spaces etc.
68- return team .replace (" " , "-" )
69-
7073 # amazonq-ignore-next-line
7174 def login (
7275 self , orgname : str , token : str = "" , app_id : str | int = "" , app_private_key : str = ""
@@ -117,88 +120,9 @@ def ratelimit(self):
117120 "Current rate limit: %s/%s (reset: %s)" , core .remaining , core .limit , core .reset
118121 )
119122
120- def pretty_print_dict (self , dictionary : dict ) -> str :
121- """Convert a dict to a pretty-printed output"""
122-
123- # Censor sensible fields
124- def censor_half_string (string : str ) -> str :
125- """Censor 50% of a string (rounded up)"""
126- half1 = int (len (string ) / 2 )
127- half2 = len (string ) - half1
128- return string [:half1 ] + "*" * (half2 )
129-
130- sensible_keys = ["gh_token" , "gh_app_private_key" ]
131- for key in sensible_keys :
132- if value := dictionary .get (key , "" ):
133- dictionary [key ] = censor_half_string (value )
134-
135- # Print dict nicely
136- def pretty (d , indent = 0 ):
137- string = ""
138- for key , value in d .items ():
139- string += " " * indent + str (key ) + ":\n "
140- if isinstance (value , dict ):
141- string += pretty (value , indent + 1 )
142- else :
143- string += " " * (indent + 1 ) + str (value ) + "\n "
144-
145- return string
146-
147- return pretty (dictionary )
148-
149123 def pretty_print_dataclass (self ) -> str :
150124 """Convert this dataclass to a pretty-printed output"""
151- return self .pretty_print_dict (asdict (self ))
152-
153- def compare_two_lists (self , list1 : list [str ], list2 : list [str ]):
154- """
155- Compares two lists of strings and returns a tuple containing elements
156- missing in each list and common elements.
157-
158- Args:
159- list1 (list of str): The first list of strings.
160- list2 (list of str): The second list of strings.
161-
162- Returns:
163- tuple: A tuple containing three lists:
164- 1. The first list contains elements in `list2` that are missing in `list1`.
165- 2. The second list contains elements that are present in both `list1` and `list2`.
166- 3. The third list contains elements in `list1` that are missing in `list2`.
167-
168- Example:
169- >>> list1 = ["apple", "banana", "cherry"]
170- >>> list2 = ["banana", "cherry", "date", "fig"]
171- >>> compare_lists(list1, list2)
172- (['date', 'fig'], ['banana', 'cherry'], ['apple'])
173- """
174- # Convert lists to sets for easier comparison
175- set1 , set2 = set (list1 ), set (list2 )
176-
177- # Elements in list2 that are missing in list1
178- missing_in_list1 = list (set2 - set1 )
179-
180- # Elements present in both lists
181- common_elements = list (set1 & set2 )
182-
183- # Elements in list1 that are missing in list2
184- missing_in_list2 = list (set1 - set2 )
185-
186- # Return the result as a tuple
187- return (missing_in_list1 , common_elements , missing_in_list2 )
188-
189- def compare_two_dicts (self , dict1 : dict , dict2 : dict ) -> dict [str , dict [str , str | int | None ]]:
190- """Compares two dictionaries. Assume that the keys are the same. Output
191- a dict with keys that have differing values"""
192- # Create an empty dictionary to store differences
193- differences = {}
194-
195- # Iterate through the keys (assuming both dictionaries have the same keys)
196- for key in dict1 :
197- # Compare the values for each key
198- if dict1 [key ] != dict2 [key ]:
199- differences [key ] = {"dict1" : dict1 [key ], "dict2" : dict2 [key ]}
200-
201- return differences
125+ return dict_to_pretty_string (asdict (self ), sensible_keys = ["gh_token" , "gh_app_private_key" ])
202126
203127 def _resolve_gh_username (self , username : str , teamname : str ) -> NamedUser | None :
204128 """Turn a username into a proper GitHub user object"""
@@ -293,7 +217,7 @@ def sync_org_owners(self, dry: bool = False, force: bool = False) -> None:
293217 return
294218
295219 # Get differences between the current and configured owners
296- owners_remove , owners_ok , owners_add = self . compare_two_lists (
220+ owners_remove , owners_ok , owners_add = compare_two_lists (
297221 self .configured_org_owners , [user .login for user in self .current_org_owners ]
298222 )
299223 # Compare configured (lower-cased) owners with lower-cased list of current owners
@@ -314,6 +238,7 @@ def sync_org_owners(self, dry: bool = False, force: bool = False) -> None:
314238 for user in owners_add :
315239 if gh_user := self ._resolve_gh_username (user , "<org owners>" ):
316240 logging .info ("Adding user '%s' as organization owner" , gh_user .login )
241+ self .stats .add_owner (gh_user .login )
317242 if not dry :
318243 self .org .add_to_members (gh_user , "admin" )
319244
@@ -325,6 +250,7 @@ def sync_org_owners(self, dry: bool = False, force: bool = False) -> None:
325250 "Will make them a normal member" ,
326251 gh_user .login ,
327252 )
253+ self .stats .degrade_owner (gh_user .login )
328254 # Handle authenticated user being the same as the one you want to degrade
329255 if self ._is_user_authenticated_user (gh_user ):
330256 logging .warning (
@@ -370,9 +296,10 @@ def create_missing_teams(self, dry: bool = False):
370296 for team , attributes in self .configured_teams .items ():
371297 if team not in existent_team_names :
372298 if parent := attributes .get ("parent" ): # type: ignore
373- parent_id = self .org .get_team_by_slug (self . _sluggify_teamname (parent )).id
299+ parent_id = self .org .get_team_by_slug (sluggify_teamname (parent )).id
374300
375301 logging .info ("Creating team '%s' with parent ID '%s'" , team , parent_id )
302+ self .stats .create_team (team )
376303 # NOTE: We do not specify any team settings (description etc)
377304 # here, this will happen later
378305 if not dry :
@@ -385,6 +312,7 @@ def create_missing_teams(self, dry: bool = False):
385312
386313 else :
387314 logging .info ("Creating team '%s' without parent" , team )
315+ self .stats .create_team (team )
388316 if not dry :
389317 self .org .create_team (
390318 team ,
@@ -410,7 +338,7 @@ def _prepare_team_config_for_sync(
410338 # team coming from config, and valid string
411339 elif isinstance (parent , str ) and parent :
412340 team_config ["parent_team_id" ] = self .org .get_team_by_slug (
413- self . _sluggify_teamname (parent )
341+ sluggify_teamname (parent )
414342 ).id
415343 # empty from string, so probably default value
416344 elif isinstance (parent , str ) and not parent :
@@ -471,16 +399,17 @@ def sync_current_teams_settings(self, dry: bool = False) -> None:
471399 )
472400
473401 # Compare settings and update if necessary
474- if differences := self . compare_two_dicts (configured_team_configs , current_team_configs ):
402+ if differences := compare_two_dicts (configured_team_configs , current_team_configs ):
475403 # Log differences
476404 logging .info (
477405 "Team settings for '%s' differ from the configuration. Updating them:" ,
478406 team .name ,
479407 )
480408 for setting , diff in differences .items ():
481- logging .info (
482- "Setting '%s': '%s' --> '%s'" , setting , diff ["dict2" ], diff ["dict1" ]
483- )
409+ change_str = f"Setting '{ setting } ': '{ diff ['dict2' ]} ' --> '{ diff ['dict1' ]} '"
410+ logging .info (change_str )
411+ self .stats .edit_team_config (team .name , new_config = change_str )
412+
484413 # Execute team setting changes
485414 if not dry :
486415 try :
@@ -489,7 +418,7 @@ def sync_current_teams_settings(self, dry: bool = False) -> None:
489418 logging .critical (
490419 "Team '%s' settings could not be edited. Error: \n %s" ,
491420 team .name ,
492- self . pretty_print_dict (exc .data ),
421+ dict_to_pretty_string (exc .data ),
493422 )
494423 sys .exit (1 )
495424 else :
@@ -611,6 +540,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-
611540 team .name ,
612541 config_role ,
613542 )
543+ self .stats .pending_team_member (team = team .name , user = gh_user .login )
614544 continue
615545
616546 logging .info (
@@ -619,6 +549,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-
619549 team .name ,
620550 config_role ,
621551 )
552+ self .stats .add_team_member (team = team .name , user = gh_user .login )
622553 if not dry :
623554 self ._add_or_update_user_in_team (team = team , user = gh_user , role = config_role )
624555
@@ -633,6 +564,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-
633564 team .name ,
634565 config_role ,
635566 )
567+ self .stats .change_team_member_role (team = team .name , user = gh_user .login )
636568 if not dry :
637569 self ._add_or_update_user_in_team (team = team , user = gh_user , role = config_role )
638570
@@ -651,6 +583,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-
651583 gh_user .login ,
652584 team .name ,
653585 )
586+ self .stats .remove_team_member (team = team .name , user = gh_user .login )
654587 if not dry :
655588 team .remove_membership (gh_user )
656589 else :
@@ -675,6 +608,7 @@ def get_unconfigured_teams(
675608 if delete_unconfigured_teams :
676609 for team in unconfigured_teams :
677610 logging .info ("Deleting team '%s' as it is not configured locally" , team .name )
611+ self .stats .delete_team (team = team .name , deleted = True )
678612 if not dry :
679613 team .delete ()
680614 else :
@@ -684,6 +618,8 @@ def get_unconfigured_teams(
684618 "configured locally: %s. Taking no action about these teams." ,
685619 ", " .join (unconfigured_teams_str ),
686620 )
621+ for team in unconfigured_teams :
622+ self .stats .delete_team (team = team .name , deleted = False )
687623
688624 def get_members_without_team (
689625 self , dry : bool = False , remove_members_without_team : bool = False
@@ -713,6 +649,7 @@ def get_members_without_team(
713649 "Removing user '%s' from organisation as they are not member of any team" ,
714650 user .login ,
715651 )
652+ self .stats .remove_member_without_team (user = user .login , removed = True )
716653 if not dry :
717654 self .org .remove_from_membership (user )
718655 else :
@@ -722,6 +659,8 @@ def get_members_without_team(
722659 "member of any team: %s" ,
723660 ", " .join (members_without_team_str ),
724661 )
662+ for user in members_without_team :
663+ self .stats .remove_member_without_team (user = user .login , removed = False )
725664
726665 # --------------------------------------------------------------------------
727666 # Repos
@@ -754,7 +693,7 @@ def _create_perms_changelist_for_teams(
754693
755694 # Convert team name to Team object
756695 try :
757- team = self .org .get_team_by_slug (self . _sluggify_teamname (team_name ))
696+ team = self .org .get_team_by_slug (sluggify_teamname (team_name ))
758697 # Team not found, probably because a new team should be created, but it's a dry-run
759698 except UnknownObjectException :
760699 logging .debug (
@@ -763,12 +702,21 @@ def _create_perms_changelist_for_teams(
763702 )
764703 # Initialise a new Team() object with the name, manually
765704 team = Team (
766- requester = None , # type: ignore
705+ requester = Requester (
706+ auth = None ,
707+ base_url = "https://api.github.com" ,
708+ timeout = 10 ,
709+ user_agent = "" ,
710+ per_page = 100 ,
711+ verify = True ,
712+ retry = 3 ,
713+ pool_size = 200 ,
714+ ),
767715 headers = {}, # No headers required
768716 attributes = {
769717 "id" : 0 ,
770718 "name" : team_name ,
771- "slug" : self . _sluggify_teamname (team_name ),
719+ "slug" : sluggify_teamname (team_name ),
772720 },
773721 completed = True , # Mark as fully initialized
774722 )
@@ -840,6 +788,7 @@ def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False
840788 team .name ,
841789 perm ,
842790 )
791+ self .stats .change_repo_team_permissions (repo = repo .name , team = team .name , perm = perm )
843792 if not dry :
844793 # Update permissions or newly add a team to a repo
845794 team .update_team_repository (repo , perm )
@@ -865,6 +814,10 @@ def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False
865814 self ._document_unconfigured_team_repo_permissions (
866815 team = team , team_permission = teams [team ], repo_name = repo .name
867816 )
817+ # Collect this status in the stats
818+ self .stats .document_unconfigured_team_permissions (
819+ team = team .name , repo = repo .name , perm = teams [team ]
820+ )
868821 # Abort handling the repo sync as we don't touch unconfigured teams
869822 continue
870823 # Handle: Team is configured, but contains no config
@@ -883,6 +836,7 @@ def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False
883836 # Remove if any mismatch has been found
884837 if remove :
885838 logging .info ("Removing team '%s' from repository '%s'" , team .name , repo .name )
839+ self .stats .remove_team_from_repo (repo = repo .name , team = team .name )
886840 if not dry :
887841 team .remove_from_repos (repo )
888842
@@ -1328,5 +1282,6 @@ def sync_repo_collaborator_permissions(self, dry: bool = False):
13281282 )
13291283
13301284 # Remove collaborator
1285+ self .stats .remove_repo_collaborator (repo = repo .name , user = username )
13311286 if not dry :
13321287 repo .remove_from_collaborators (username )
0 commit comments