github-org-manager 0.5.7__tar.gz → 0.7.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.3
2
2
  Name: github-org-manager
3
- Version: 0.5.7
3
+ Version: 0.7.1
4
4
  Summary: Manage a GitHub Organization, its teams, repository permissions, and more
5
5
  License: Apache-2.0
6
6
  Keywords: github,github-management,permissions,access-control
@@ -76,7 +76,7 @@ Afterwards, the tool is executable with the command `gh-org-mgr`. The `--help` f
76
76
 
77
77
  Inside [`config/example`](./config/example), you can find an example configuration that shall help you to understand the structure:
78
78
 
79
- * `app.yaml`: Configuration necessary to run this tool
79
+ * `app.yaml`: Configuration necessary to run this tool and controlling some behaviour
80
80
  * `org.yaml`: Organization-wide configuration
81
81
  * `teams/*.yaml`: Configuration concerning the teams of your organization.
82
82
 
@@ -48,7 +48,7 @@ Afterwards, the tool is executable with the command `gh-org-mgr`. The `--help` f
48
48
 
49
49
  Inside [`config/example`](./config/example), you can find an example configuration that shall help you to understand the structure:
50
50
 
51
- * `app.yaml`: Configuration necessary to run this tool
51
+ * `app.yaml`: Configuration necessary to run this tool and controlling some behaviour
52
52
  * `org.yaml`: Organization-wide configuration
53
53
  * `teams/*.yaml`: Configuration concerning the teams of your organization.
54
54
 
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: 2024 DB Systel GmbH
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Global init file"""
6
+
7
+ from importlib.metadata import version
8
+
9
+ __version__ = version("github-org-manager")
@@ -19,10 +19,18 @@ from github.GithubException import BadCredentialsException
19
19
  from github.NamedUser import NamedUser
20
20
  from github.Organization import Organization
21
21
  from github.Repository import Repository
22
+ from github.Requester import Requester
22
23
  from github.Team import Team
23
24
  from jwt.exceptions import InvalidKeyError
24
25
 
25
26
  from ._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
26
34
 
27
35
 
28
36
  @dataclass
@@ -47,6 +55,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
47
55
  configured_repos_collaborators: dict[str, dict[str, str]] = field(default_factory=dict)
48
56
  archived_repos: list[Repository] = field(default_factory=list)
49
57
  unconfigured_team_repo_permissions: dict[str, dict[str, str]] = field(default_factory=dict)
58
+ stats: OrgChanges = field(default_factory=OrgChanges)
50
59
 
51
60
  # Re-usable Constants
52
61
  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
61
70
  # --------------------------------------------------------------------------
62
71
  # Helper functions
63
72
  # --------------------------------------------------------------------------
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
-
70
73
  # amazonq-ignore-next-line
71
74
  def login(
72
75
  self, orgname: str, token: str = "", app_id: str | int = "", app_private_key: str = ""
@@ -117,88 +120,9 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
117
120
  "Current rate limit: %s/%s (reset: %s)", core.remaining, core.limit, core.reset
118
121
  )
119
122
 
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
-
149
123
  def pretty_print_dataclass(self) -> str:
150
124
  """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"])
202
126
 
203
127
  def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser | None:
204
128
  """Turn a username into a proper GitHub user object"""
@@ -293,7 +217,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
293
217
  return
294
218
 
295
219
  # 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(
297
221
  self.configured_org_owners, [user.login for user in self.current_org_owners]
298
222
  )
299
223
  # Compare configured (lower-cased) owners with lower-cased list of current owners
@@ -314,6 +238,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
314
238
  for user in owners_add:
315
239
  if gh_user := self._resolve_gh_username(user, "<org owners>"):
316
240
  logging.info("Adding user '%s' as organization owner", gh_user.login)
241
+ self.stats.add_owner(gh_user.login)
317
242
  if not dry:
318
243
  self.org.add_to_members(gh_user, "admin")
319
244
 
@@ -325,6 +250,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
325
250
  "Will make them a normal member",
326
251
  gh_user.login,
327
252
  )
253
+ self.stats.degrade_owner(gh_user.login)
328
254
  # Handle authenticated user being the same as the one you want to degrade
329
255
  if self._is_user_authenticated_user(gh_user):
330
256
  logging.warning(
@@ -370,9 +296,10 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
370
296
  for team, attributes in self.configured_teams.items():
371
297
  if team not in existent_team_names:
372
298
  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
374
300
 
375
301
  logging.info("Creating team '%s' with parent ID '%s'", team, parent_id)
302
+ self.stats.create_team(team)
376
303
  # NOTE: We do not specify any team settings (description etc)
377
304
  # here, this will happen later
378
305
  if not dry:
@@ -385,6 +312,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
385
312
 
386
313
  else:
387
314
  logging.info("Creating team '%s' without parent", team)
315
+ self.stats.create_team(team)
388
316
  if not dry:
389
317
  self.org.create_team(
390
318
  team,
@@ -410,7 +338,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
410
338
  # team coming from config, and valid string
411
339
  elif isinstance(parent, str) and parent:
412
340
  team_config["parent_team_id"] = self.org.get_team_by_slug(
413
- self._sluggify_teamname(parent)
341
+ sluggify_teamname(parent)
414
342
  ).id
415
343
  # empty from string, so probably default value
416
344
  elif isinstance(parent, str) and not parent:
@@ -471,16 +399,17 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
471
399
  )
472
400
 
473
401
  # 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):
475
403
  # Log differences
476
404
  logging.info(
477
405
  "Team settings for '%s' differ from the configuration. Updating them:",
478
406
  team.name,
479
407
  )
480
408
  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
+
484
413
  # Execute team setting changes
485
414
  if not dry:
486
415
  try:
@@ -489,7 +418,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
489
418
  logging.critical(
490
419
  "Team '%s' settings could not be edited. Error: \n%s",
491
420
  team.name,
492
- self.pretty_print_dict(exc.data),
421
+ dict_to_pretty_string(exc.data),
493
422
  )
494
423
  sys.exit(1)
495
424
  else:
@@ -550,6 +479,10 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
550
479
  open_invitations = [user.login.lower() for user in self.org.invitations()]
551
480
 
552
481
  for team, team_attrs in self.current_teams.items():
482
+ # Ignore any team not being configured locally, will be handled later
483
+ if team.name not in self.configured_teams:
484
+ continue
485
+
553
486
  # Update current team members with dict[NamedUser, str (role)]
554
487
  team_attrs["members"] = self._get_current_team_members(team)
555
488
 
@@ -559,15 +492,6 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
559
492
  user.login.lower(): role for user, role in team_attrs["members"].items()
560
493
  }
561
494
 
562
- # Handle the team not being configured locally
563
- if team.name not in self.configured_teams:
564
- logging.warning(
565
- "Team '%s' does not seem to be configured locally. "
566
- "Taking no action about this team at all",
567
- team.name,
568
- )
569
- continue
570
-
571
495
  # Get configuration from current team
572
496
  if team_configuration := self.configured_teams.get(team.name):
573
497
  pass
@@ -616,6 +540,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
616
540
  team.name,
617
541
  config_role,
618
542
  )
543
+ self.stats.pending_team_member(team=team.name, user=gh_user.login)
619
544
  continue
620
545
 
621
546
  logging.info(
@@ -624,6 +549,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
624
549
  team.name,
625
550
  config_role,
626
551
  )
552
+ self.stats.add_team_member(team=team.name, user=gh_user.login)
627
553
  if not dry:
628
554
  self._add_or_update_user_in_team(team=team, user=gh_user, role=config_role)
629
555
 
@@ -638,6 +564,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
638
564
  team.name,
639
565
  config_role,
640
566
  )
567
+ self.stats.change_team_member_role(team=team.name, user=gh_user.login)
641
568
  if not dry:
642
569
  self._add_or_update_user_in_team(team=team, user=gh_user, role=config_role)
643
570
 
@@ -656,6 +583,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
656
583
  gh_user.login,
657
584
  team.name,
658
585
  )
586
+ self.stats.remove_team_member(team=team.name, user=gh_user.login)
659
587
  if not dry:
660
588
  team.remove_membership(gh_user)
661
589
  else:
@@ -666,8 +594,38 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
666
594
  team.name,
667
595
  )
668
596
 
669
- def get_members_without_team(self) -> None:
670
- """Get all organisation members without any team membership"""
597
+ def get_unconfigured_teams(
598
+ self, dry: bool = False, delete_unconfigured_teams: bool = False
599
+ ) -> None:
600
+ """Get all teams that are not configured locally and optionally remove them"""
601
+ # Get all teams that are not configured locally
602
+ unconfigured_teams: list[Team] = []
603
+ for team in self.current_teams:
604
+ if team.name not in self.configured_teams:
605
+ unconfigured_teams.append(team)
606
+
607
+ if unconfigured_teams:
608
+ if delete_unconfigured_teams:
609
+ for team in unconfigured_teams:
610
+ logging.info("Deleting team '%s' as it is not configured locally", team.name)
611
+ self.stats.delete_team(team=team.name, deleted=True)
612
+ if not dry:
613
+ team.delete()
614
+ else:
615
+ unconfigured_teams_str = [team.name for team in unconfigured_teams]
616
+ logging.warning(
617
+ "The following teams of your GitHub organisation are not "
618
+ "configured locally: %s. Taking no action about these teams.",
619
+ ", ".join(unconfigured_teams_str),
620
+ )
621
+ for team in unconfigured_teams:
622
+ self.stats.delete_team(team=team.name, deleted=False)
623
+
624
+ def get_members_without_team(
625
+ self, dry: bool = False, remove_members_without_team: bool = False
626
+ ) -> None:
627
+ """Get all organisation members without any team membership, and
628
+ optionally remove them"""
671
629
  # Combine org owners and org members
672
630
  all_org_members = set(self.org_members + self.current_org_owners)
673
631
 
@@ -676,18 +634,33 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
676
634
  for _, team_attrs in self.current_teams.items():
677
635
  for member in team_attrs.get("members", {}):
678
636
  all_team_members_lst.append(member)
679
- # Also add users that have just been added to a team, and unify them
680
- all_team_members: set[NamedUser] = set(all_team_members_lst + self.newly_added_users)
637
+ # Also add org owners and users that have just been added to a team, and unify them
638
+ all_team_members: set[NamedUser] = set(
639
+ all_team_members_lst + self.newly_added_users + self.current_org_owners
640
+ )
681
641
 
682
642
  # Find members that are in org_members but not team_members
683
643
  members_without_team = all_org_members.difference(all_team_members)
684
644
 
685
645
  if members_without_team:
686
- members_without_team_str = [user.login for user in members_without_team]
687
- logging.warning(
688
- "The following members of your GitHub organisation are not member of any team: %s",
689
- ", ".join(members_without_team_str),
690
- )
646
+ if remove_members_without_team:
647
+ for user in members_without_team:
648
+ logging.info(
649
+ "Removing user '%s' from organisation as they are not member of any team",
650
+ user.login,
651
+ )
652
+ self.stats.remove_member_without_team(user=user.login, removed=True)
653
+ if not dry:
654
+ self.org.remove_from_membership(user)
655
+ else:
656
+ members_without_team_str = [user.login for user in members_without_team]
657
+ logging.warning(
658
+ "The following members of your GitHub organisation are not "
659
+ "member of any team: %s",
660
+ ", ".join(members_without_team_str),
661
+ )
662
+ for user in members_without_team:
663
+ self.stats.remove_member_without_team(user=user.login, removed=False)
691
664
 
692
665
  # --------------------------------------------------------------------------
693
666
  # Repos
@@ -720,7 +693,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
720
693
 
721
694
  # Convert team name to Team object
722
695
  try:
723
- team = self.org.get_team_by_slug(self._sluggify_teamname(team_name))
696
+ team = self.org.get_team_by_slug(sluggify_teamname(team_name))
724
697
  # Team not found, probably because a new team should be created, but it's a dry-run
725
698
  except UnknownObjectException:
726
699
  logging.debug(
@@ -729,12 +702,21 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
729
702
  )
730
703
  # Initialise a new Team() object with the name, manually
731
704
  team = Team(
732
- 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
+ ),
733
715
  headers={}, # No headers required
734
716
  attributes={
735
717
  "id": 0,
736
718
  "name": team_name,
737
- "slug": self._sluggify_teamname(team_name),
719
+ "slug": sluggify_teamname(team_name),
738
720
  },
739
721
  completed=True, # Mark as fully initialized
740
722
  )
@@ -806,6 +788,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
806
788
  team.name,
807
789
  perm,
808
790
  )
791
+ self.stats.change_repo_team_permissions(repo=repo.name, team=team.name, perm=perm)
809
792
  if not dry:
810
793
  # Update permissions or newly add a team to a repo
811
794
  team.update_team_repository(repo, perm)
@@ -831,6 +814,10 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
831
814
  self._document_unconfigured_team_repo_permissions(
832
815
  team=team, team_permission=teams[team], repo_name=repo.name
833
816
  )
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
+ )
834
821
  # Abort handling the repo sync as we don't touch unconfigured teams
835
822
  continue
836
823
  # Handle: Team is configured, but contains no config
@@ -849,6 +836,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
849
836
  # Remove if any mismatch has been found
850
837
  if remove:
851
838
  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)
852
840
  if not dry:
853
841
  team.remove_from_repos(repo)
854
842
 
@@ -1294,5 +1282,6 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
1294
1282
  )
1295
1283
 
1296
1284
  # Remove collaborator
1285
+ self.stats.remove_repo_collaborator(repo=repo.name, user=username)
1297
1286
  if not dry:
1298
1287
  repo.remove_from_collaborators(username)
@@ -0,0 +1,152 @@
1
+ # SPDX-FileCopyrightText: 2025 DB Systel GmbH
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Helper functions"""
6
+
7
+ import logging
8
+ import sys
9
+ from dataclasses import asdict
10
+
11
+
12
+ def configure_logger(verbose: bool = False, debug: bool = False) -> logging.Logger:
13
+ """Set logging options"""
14
+ log = logging.getLogger()
15
+ logging.basicConfig(
16
+ encoding="utf-8",
17
+ format="[%(asctime)s] %(levelname)s: %(message)s",
18
+ datefmt="%Y-%m-%d %H:%M:%S",
19
+ )
20
+ if debug:
21
+ log.setLevel(logging.DEBUG)
22
+ elif verbose:
23
+ log.setLevel(logging.INFO)
24
+ else:
25
+ log.setLevel(logging.WARNING)
26
+
27
+ return log
28
+
29
+
30
+ def log_progress(message: str) -> None:
31
+ """Log progress messages to stderr"""
32
+ # Clear line if no message is given
33
+ if not message:
34
+ sys.stderr.write("\r\033[K")
35
+ sys.stderr.flush()
36
+ else:
37
+ sys.stderr.write(f"\r\033[K⏳ {message}")
38
+ sys.stderr.flush()
39
+
40
+
41
+ def sluggify_teamname(team: str) -> str:
42
+ """Slugify a GitHub team name"""
43
+ # TODO: this is very naive, no other special chars are
44
+ # supported, or multiple spaces etc.
45
+ return team.replace(" ", "-")
46
+
47
+
48
+ def compare_two_lists(list1: list[str], list2: list[str]):
49
+ """
50
+ Compares two lists of strings and returns a tuple containing elements
51
+ missing in each list and common elements.
52
+
53
+ Args:
54
+ list1 (list of str): The first list of strings.
55
+ list2 (list of str): The second list of strings.
56
+
57
+ Returns:
58
+ tuple: A tuple containing three lists:
59
+ 1. The first list contains elements in `list2` that are missing in `list1`.
60
+ 2. The second list contains elements that are present in both `list1` and `list2`.
61
+ 3. The third list contains elements in `list1` that are missing in `list2`.
62
+
63
+ Example:
64
+ >>> list1 = ["apple", "banana", "cherry"]
65
+ >>> list2 = ["banana", "cherry", "date", "fig"]
66
+ >>> compare_lists(list1, list2)
67
+ (['date', 'fig'], ['banana', 'cherry'], ['apple'])
68
+ """
69
+ # Convert lists to sets for easier comparison
70
+ set1, set2 = set(list1), set(list2)
71
+
72
+ # Elements in list2 that are missing in list1
73
+ missing_in_list1 = list(set2 - set1)
74
+
75
+ # Elements present in both lists
76
+ common_elements = list(set1 & set2)
77
+
78
+ # Elements in list1 that are missing in list2
79
+ missing_in_list2 = list(set1 - set2)
80
+
81
+ # Return the result as a tuple
82
+ return (missing_in_list1, common_elements, missing_in_list2)
83
+
84
+
85
+ def compare_two_dicts(dict1: dict, dict2: dict) -> dict[str, dict[str, str | int | None]]:
86
+ """Compares two dictionaries. Assume that the keys are the same. Output
87
+ a dict with keys that have differing values"""
88
+ # Create an empty dictionary to store differences
89
+ differences = {}
90
+
91
+ # Iterate through the keys (assuming both dictionaries have the same keys)
92
+ for key in dict1:
93
+ # Compare the values for each key
94
+ if dict1[key] != dict2[key]:
95
+ differences[key] = {"dict1": dict1[key], "dict2": dict2[key]}
96
+
97
+ return differences
98
+
99
+
100
+ def dict_to_pretty_string(dictionary: dict, sensible_keys: None | list[str] = None) -> str:
101
+ """Convert a dict to a pretty-printed output"""
102
+
103
+ # Censor sensible fields
104
+ def censor_half_string(string: str) -> str:
105
+ """Censor 50% of a string (rounded up)"""
106
+ half1 = int(len(string) / 2)
107
+ half2 = len(string) - half1
108
+ return string[:half1] + "*" * (half2)
109
+
110
+ if sensible_keys is None:
111
+ sensible_keys = []
112
+ for key in sensible_keys:
113
+ if value := dictionary.get(key, ""):
114
+ dictionary[key] = censor_half_string(value)
115
+
116
+ # Print dict nicely
117
+ def pretty(d, indent=0):
118
+ string = ""
119
+ for key, value in d.items():
120
+ string += " " * indent + str(key) + ":\n"
121
+ if isinstance(value, dict):
122
+ string += pretty(value, indent + 1)
123
+ else:
124
+ string += " " * (indent + 1) + str(value) + "\n"
125
+
126
+ return string
127
+
128
+ return pretty(dictionary)
129
+
130
+
131
+ def pretty_print_dataclass(dc):
132
+ """Convert dataclass to a pretty-printed output"""
133
+ dict_to_pretty_string(asdict(dc))
134
+
135
+
136
+ def implement_changes_into_class(dc_object, **changes: bool | str | list[str]):
137
+ """Smartly add changes to a (data)class object"""
138
+ for attribute, value in changes.items():
139
+ current_value = getattr(dc_object, attribute)
140
+ # attribute is list
141
+ if isinstance(current_value, list):
142
+ # input change is list
143
+ if isinstance(value, list):
144
+ current_value.extend(value)
145
+ # input change is not list
146
+ else:
147
+ current_value.append(value)
148
+ # All other cases, bool
149
+ else:
150
+ setattr(dc_object, attribute, value)
151
+
152
+ return dc_object
@@ -0,0 +1,258 @@
1
+ # SPDX-FileCopyrightText: 2025 DB Systel GmbH
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Dataclasses and functions for statistics"""
6
+
7
+ import json
8
+ from dataclasses import dataclass, field
9
+
10
+ from ._helpers import implement_changes_into_class
11
+
12
+
13
+ @dataclass
14
+ class TeamChanges: # pylint: disable=too-many-instance-attributes
15
+ """Dataclass holding information about the changes made to a team"""
16
+
17
+ newly_created: bool = False
18
+ deleted: bool = False
19
+ unconfigured: bool = False
20
+ changed_config: list[str] = field(default_factory=list)
21
+ added_members: list[str] = field(default_factory=list)
22
+ changed_members_role: list[str] = field(default_factory=list)
23
+ removed_members: list[str] = field(default_factory=list)
24
+ pending_members: list[str] = field(default_factory=list)
25
+
26
+
27
+ @dataclass
28
+ class RepoChanges: # pylint: disable=too-many-instance-attributes
29
+ """Dataclass holding information about the changes made to a repository"""
30
+
31
+ changed_permissions_for_teams: list[str] = field(default_factory=list)
32
+ removed_teams: list[str] = field(default_factory=list)
33
+ unconfigured_teams_with_permissions: list[str] = field(default_factory=list)
34
+ removed_collaborators: list[str] = field(default_factory=list)
35
+
36
+
37
+ @dataclass
38
+ class OrgChanges: # pylint: disable=too-many-instance-attributes
39
+ """Dataclass holding general statistics about the changes made to the organization"""
40
+
41
+ dry: bool = False
42
+ added_owners: list[str] = field(default_factory=list)
43
+ degraded_owners: list[str] = field(default_factory=list)
44
+ members_without_team: list[str] = field(default_factory=list)
45
+ removed_members: list[str] = field(default_factory=list)
46
+ teams: dict[str, TeamChanges] = field(default_factory=dict)
47
+ repos: dict[str, RepoChanges] = field(default_factory=dict)
48
+
49
+ # --------------------------------------------------------------------------
50
+ # Owners
51
+ # --------------------------------------------------------------------------
52
+ def add_owner(self, user: str) -> None:
53
+ """User has been added as owner"""
54
+ self.added_owners.append(user)
55
+
56
+ def degrade_owner(self, user: str) -> None:
57
+ """User has been degraded from owner to member"""
58
+ self.degraded_owners.append(user)
59
+
60
+ # --------------------------------------------------------------------------
61
+ # Teams
62
+ # --------------------------------------------------------------------------
63
+ def update_team(self, team_name: str, **changes: bool | str | list[str]) -> None:
64
+ """Update team changes"""
65
+ # Initialise team if not present
66
+ if team_name not in self.teams:
67
+ self.teams[team_name] = TeamChanges()
68
+
69
+ implement_changes_into_class(dc_object=self.teams[team_name], **changes)
70
+
71
+ def create_team(self, team: str) -> None:
72
+ """Team has been created"""
73
+ self.update_team(team_name=team, newly_created=True)
74
+
75
+ def edit_team_config(self, team: str, new_config: str) -> None:
76
+ """Team config has been changed"""
77
+ self.update_team(team_name=team, changed_config=new_config)
78
+
79
+ def delete_team(self, team: str, deleted: bool) -> None:
80
+ """Teams are not configured"""
81
+ self.update_team(team_name=team, unconfigured=True, deleted=deleted)
82
+
83
+ # --------------------------------------------------------------------------
84
+ # Members
85
+ # --------------------------------------------------------------------------
86
+ def add_team_member(self, team: str, user: str) -> None:
87
+ """User has been added to team"""
88
+ self.update_team(team_name=team, added_members=user)
89
+
90
+ def change_team_member_role(self, team: str, user: str) -> None:
91
+ """User role has been changed in team"""
92
+ self.update_team(team_name=team, changed_members_role=user)
93
+
94
+ def pending_team_member(self, team: str, user: str) -> None:
95
+ """User has a pending invitation"""
96
+ self.update_team(team_name=team, pending_members=user)
97
+
98
+ def remove_team_member(self, team: str, user: str) -> None:
99
+ """User has been removed from team"""
100
+ self.update_team(team_name=team, removed_members=user)
101
+
102
+ def remove_member_without_team(self, user: str, removed: bool) -> None:
103
+ """User is not in any team"""
104
+ self.members_without_team.append(user)
105
+ if removed:
106
+ self.removed_members.append(user)
107
+
108
+ # --------------------------------------------------------------------------
109
+ # Repos
110
+ # --------------------------------------------------------------------------
111
+ def update_repo(self, repo_name: str, **changes: bool | str | list[str]) -> None:
112
+ """Update team changes"""
113
+ # Initialise repo if not present
114
+ if repo_name not in self.teams:
115
+ self.repos[repo_name] = RepoChanges()
116
+
117
+ implement_changes_into_class(dc_object=self.repos[repo_name], **changes)
118
+
119
+ def change_repo_team_permissions(self, repo: str, team: str, perm: str) -> None:
120
+ """Team permissions have been changed for a repo"""
121
+ self.update_repo(repo_name=repo, changed_permissions_for_teams=f"{team}: {perm}")
122
+
123
+ def remove_team_from_repo(self, repo: str, team: str) -> None:
124
+ """Team has been removed form a repo"""
125
+ self.update_repo(repo_name=repo, removed_teams=team)
126
+
127
+ def document_unconfigured_team_permissions(self, repo: str, team: str, perm: str) -> None:
128
+ """Unconfigured team has permissions on repo"""
129
+ self.update_repo(repo_name=repo, unconfigured_teams_with_permissions=f"{team}: {perm}")
130
+
131
+ def remove_repo_collaborator(self, repo: str, user: str) -> None:
132
+ """Remove collaborator"""
133
+ self.update_repo(repo_name=repo, removed_collaborators=user)
134
+
135
+ # --------------------------------------------------------------------------
136
+ # Output
137
+ # --------------------------------------------------------------------------
138
+ def changes_into_dict(self) -> dict:
139
+ """Convert dataclass to dict, and only use classes that are not empty/False"""
140
+ changes_dict: dict[str, list[str] | dict] = {
141
+ key: value # type: ignore
142
+ for key, value in {
143
+ "dry": self.dry,
144
+ "added_owners": self.added_owners,
145
+ "degraded_owners": self.degraded_owners,
146
+ "members_without_team": self.members_without_team,
147
+ "removed_members": self.removed_members,
148
+ "teams": self.teams,
149
+ "repos": self.repos,
150
+ }.items()
151
+ if value # Exclude empty values
152
+ }
153
+
154
+ team_changes: dict[str, TeamChanges] = changes_dict.get("teams", {}) # type: ignore
155
+ repo_changes: dict[str, RepoChanges] = changes_dict.get("repos", {}) # type: ignore
156
+
157
+ for team, tchanges in team_changes.items():
158
+ new_changes = {
159
+ key: value
160
+ for key, value in tchanges.__dict__.items()
161
+ if value # Exclude empty values
162
+ }
163
+ team_changes[team] = new_changes # type: ignore
164
+ changes_dict["teams"] = team_changes
165
+
166
+ for repo, rchanges in repo_changes.items():
167
+ new_changes = {
168
+ key: value
169
+ for key, value in rchanges.__dict__.items()
170
+ if value # Exclude empty values
171
+ }
172
+ repo_changes[repo] = new_changes # type: ignore
173
+ changes_dict["repos"] = repo_changes
174
+
175
+ return changes_dict
176
+
177
+ def print_changes( # pylint: disable=too-many-branches, too-many-statements
178
+ self, orgname: str, output: str, dry: bool
179
+ ) -> None:
180
+ """Print the changes, either in pretty format or as JSON"""
181
+
182
+ # Add dry run information to stats dataclass
183
+ self.dry = dry
184
+
185
+ # Output in the requested format
186
+ if output == "json":
187
+ changes_dict = self.changes_into_dict()
188
+ print(json.dumps(changes_dict, indent=2))
189
+ else:
190
+ output = (
191
+ f"#-------------------------------------{len(orgname)*'-'}\n"
192
+ f"# Changes made to GitHub organisation {orgname}\n"
193
+ f"#-------------------------------------{len(orgname)*'-'}\n\n"
194
+ )
195
+ if dry:
196
+ output += "⚠️ Dry-run mode, no changes executed\n\n"
197
+ if self.added_owners:
198
+ output += f"➕ Added owners: {', '.join(self.added_owners)}\n"
199
+ if self.degraded_owners:
200
+ output += f"🔻 Degraded owners: {', '.join(self.degraded_owners)}\n"
201
+ if self.members_without_team:
202
+ output += f"⚠️ Members without team: {', '.join(self.members_without_team)}\n"
203
+ if self.removed_members:
204
+ output += (
205
+ f"❌ Members removed from organisation: {', '.join(self.removed_members)}\n"
206
+ )
207
+ if self.teams:
208
+ output += "\n🤝 Team Changes:\n"
209
+ for team, tchanges in self.teams.items():
210
+ output += f" 🔹 {team}:\n"
211
+ if tchanges.unconfigured:
212
+ output += " ⚠️ Is/was unconfigured\n"
213
+ if tchanges.newly_created:
214
+ output += " 🆕 Has been created\n"
215
+ if tchanges.deleted:
216
+ output += " ❌ Has been deleted\n"
217
+ if tchanges.changed_config:
218
+ output += " 🔧 Changed config:\n"
219
+ for item in tchanges.changed_config:
220
+ output += f" - {item}\n"
221
+ if tchanges.added_members:
222
+ output += " ➕ Added members:\n"
223
+ for item in tchanges.added_members:
224
+ output += f" - {item}\n"
225
+ if tchanges.changed_members_role:
226
+ output += ' 🔄 Changed members role:"'
227
+ for item in tchanges.changed_members_role:
228
+ output += f" - {item}\n"
229
+ if tchanges.removed_members:
230
+ output += " ❌ Removed members:\n"
231
+ for item in tchanges.removed_members:
232
+ output += f" - {item}\n"
233
+ if tchanges.pending_members:
234
+ output += " ⏳ Pending members:\n"
235
+ for item in tchanges.pending_members:
236
+ output += f" - {item}\n"
237
+ if self.repos:
238
+ output += "\n📂 Repository Changes:\n"
239
+ for repo, rchanges in self.repos.items():
240
+ output += f" 🔹 {repo}:\n"
241
+ if rchanges.changed_permissions_for_teams:
242
+ output += " 🔧 Changed permissions for teams:\n"
243
+ for item in rchanges.changed_permissions_for_teams:
244
+ output += f" - {item}\n"
245
+ if rchanges.removed_teams:
246
+ output += " ❌ Removed teams:\n"
247
+ for item in rchanges.removed_teams:
248
+ output += f" - {item}\n"
249
+ if rchanges.unconfigured_teams_with_permissions:
250
+ output += " ⚠️ Unconfigured teams with permissions:\n"
251
+ for item in rchanges.unconfigured_teams_with_permissions:
252
+ output += f" - {item}\n"
253
+ if rchanges.removed_collaborators:
254
+ output += " ❌ Removed collaborators:\n"
255
+ for item in rchanges.removed_collaborators:
256
+ output += f" - {item}\n"
257
+
258
+ print(output.strip())
@@ -8,9 +8,10 @@ import argparse
8
8
  import logging
9
9
  import sys
10
10
 
11
- from . import __version__, configure_logger
11
+ from . import __version__
12
12
  from ._config import parse_config_files
13
13
  from ._gh_org import GHorg
14
+ from ._helpers import configure_logger, log_progress
14
15
  from ._setup_team import setup_team
15
16
 
16
17
  # Main parser with root-level flags
@@ -26,7 +27,8 @@ subparsers = parser.add_subparsers(dest="command", help="Available commands", re
26
27
 
27
28
  # Common flags, usable for all effective subcommands
28
29
  common_flags = argparse.ArgumentParser(add_help=False) # No automatic help to avoid duplication
29
- common_flags.add_argument("--debug", action="store_true", help="Get verbose logging output")
30
+ common_flags.add_argument("-v", "--verbose", action="store_true", help="Get INFO logging output")
31
+ common_flags.add_argument("-vv", "--debug", action="store_true", help="Get DEBUG logging output")
30
32
 
31
33
  # Sync commands
32
34
  parser_sync = subparsers.add_parser(
@@ -40,6 +42,13 @@ parser_sync.add_argument(
40
42
  required=True,
41
43
  help="Path to the directory in which the configuration of an GitHub organisation is located",
42
44
  )
45
+ parser_sync.add_argument(
46
+ "-o",
47
+ "--output",
48
+ help="Output format for report",
49
+ choices=["json", "text"],
50
+ default="text",
51
+ )
43
52
  parser_sync.add_argument("--dry", action="store_true", help="Do not make any changes at GitHub")
44
53
  parser_sync.add_argument(
45
54
  "-A",
@@ -88,14 +97,15 @@ def main():
88
97
  # Process arguments
89
98
  args = parser.parse_args()
90
99
 
91
- configure_logger(args.debug)
100
+ configure_logger(verbose=args.verbose, debug=args.debug)
92
101
 
93
102
  # Sync command
94
103
  if args.command == "sync":
104
+ log_progress("Preparing...")
95
105
  if args.dry:
96
106
  logging.info("Dry-run mode activated, will not make any changes at GitHub")
97
107
  if args.force:
98
- logging.info("Force mode activated, will make potentially dangerous actions")
108
+ logging.warning("Force mode activated, will make potentially dangerous actions")
99
109
 
100
110
  org = GHorg()
101
111
 
@@ -122,25 +132,47 @@ def main():
122
132
  org.ratelimit()
123
133
 
124
134
  # Synchronise organisation owners
135
+ log_progress("Synchronising organisation owners...")
125
136
  org.sync_org_owners(dry=args.dry, force=args.force)
126
137
  # Create teams that aren't present at Github yet
138
+ log_progress("Creating missing teams...")
127
139
  org.create_missing_teams(dry=args.dry)
128
140
  # Configure general settings of teams
141
+ log_progress("Configuring team settings...")
129
142
  org.sync_current_teams_settings(dry=args.dry)
130
143
  # Synchronise the team memberships
144
+ log_progress("Synchronising team memberships...")
131
145
  org.sync_teams_members(dry=args.dry)
132
- # Report about organisation members that do not belong to any team
133
- org.get_members_without_team()
146
+ # Report and act on teams that are not configured locally
147
+ log_progress("Checking for unconfigured teams...")
148
+ org.get_unconfigured_teams(
149
+ dry=args.dry,
150
+ delete_unconfigured_teams=cfg_app.get("delete_unconfigured_teams", False),
151
+ )
152
+ # Report and act on organisation members that do not belong to any team
153
+ log_progress("Checking for members without team...")
154
+ org.get_members_without_team(
155
+ dry=args.dry,
156
+ remove_members_without_team=cfg_app.get("remove_members_without_team", False),
157
+ )
134
158
  # Synchronise the permissions of teams for all repositories
159
+ log_progress("Synchronising team permissions...")
135
160
  org.sync_repo_permissions(dry=args.dry, ignore_archived=args.ignore_archived)
136
161
  # Remove individual collaborator permissions if they are higher than the one
137
162
  # from team membership (or if they are in no configured team at all)
163
+ log_progress("Synchronising individual collaborator permissions...")
138
164
  org.sync_repo_collaborator_permissions(dry=args.dry)
139
165
 
140
166
  # Debug output
167
+ log_progress("") # clear progress
141
168
  logging.debug("Final dataclass:\n%s", org.pretty_print_dataclass())
142
169
  org.ratelimit()
143
170
 
171
+ # Print changes
172
+ org.stats.print_changes(
173
+ orgname=cfg_org.get("org_name", ""), output=args.output, dry=args.dry
174
+ )
175
+
144
176
  # Setup Team command
145
177
  elif args.command == "setup-team":
146
178
  setup_team(team_name=args.name, config_path=args.config, file_path=args.file)
@@ -4,7 +4,7 @@
4
4
 
5
5
  [tool.poetry]
6
6
  name = "github-org-manager"
7
- version = "0.5.7"
7
+ version = "0.7.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"
@@ -39,7 +39,7 @@ mypy = "^1.9.0"
39
39
  pylint = "^3.1.0"
40
40
  types-pyyaml = "^6.0.12.20240311"
41
41
  types-requests = "^2.32.0.20240712"
42
- bump-my-version = "^0.30.0"
42
+ bump-my-version = "^1.0.0"
43
43
 
44
44
  [build-system]
45
45
  requires = ["poetry-core"]
@@ -1,26 +0,0 @@
1
- # SPDX-FileCopyrightText: 2024 DB Systel GmbH
2
- #
3
- # SPDX-License-Identifier: Apache-2.0
4
-
5
- """Global init file"""
6
-
7
- import logging
8
- from importlib.metadata import version
9
-
10
- __version__ = version("github-org-manager")
11
-
12
-
13
- def configure_logger(debug: bool = False) -> logging.Logger:
14
- """Set logging options"""
15
- log = logging.getLogger()
16
- logging.basicConfig(
17
- encoding="utf-8",
18
- format="[%(asctime)s] %(levelname)s: %(message)s",
19
- datefmt="%Y-%m-%d %H:%M:%S",
20
- )
21
- if debug:
22
- log.setLevel(logging.DEBUG)
23
- else:
24
- log.setLevel(logging.INFO)
25
-
26
- return log