github-org-manager 0.6.0__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.6.0
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
@@ -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:
@@ -611,6 +540,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
611
540
  team.name,
612
541
  config_role,
613
542
  )
543
+ self.stats.pending_team_member(team=team.name, user=gh_user.login)
614
544
  continue
615
545
 
616
546
  logging.info(
@@ -619,6 +549,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
619
549
  team.name,
620
550
  config_role,
621
551
  )
552
+ self.stats.add_team_member(team=team.name, user=gh_user.login)
622
553
  if not dry:
623
554
  self._add_or_update_user_in_team(team=team, user=gh_user, role=config_role)
624
555
 
@@ -633,6 +564,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
633
564
  team.name,
634
565
  config_role,
635
566
  )
567
+ self.stats.change_team_member_role(team=team.name, user=gh_user.login)
636
568
  if not dry:
637
569
  self._add_or_update_user_in_team(team=team, user=gh_user, role=config_role)
638
570
 
@@ -651,6 +583,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
651
583
  gh_user.login,
652
584
  team.name,
653
585
  )
586
+ self.stats.remove_team_member(team=team.name, user=gh_user.login)
654
587
  if not dry:
655
588
  team.remove_membership(gh_user)
656
589
  else:
@@ -675,6 +608,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
675
608
  if delete_unconfigured_teams:
676
609
  for team in unconfigured_teams:
677
610
  logging.info("Deleting team '%s' as it is not configured locally", team.name)
611
+ self.stats.delete_team(team=team.name, deleted=True)
678
612
  if not dry:
679
613
  team.delete()
680
614
  else:
@@ -684,6 +618,8 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
684
618
  "configured locally: %s. Taking no action about these teams.",
685
619
  ", ".join(unconfigured_teams_str),
686
620
  )
621
+ for team in unconfigured_teams:
622
+ self.stats.delete_team(team=team.name, deleted=False)
687
623
 
688
624
  def get_members_without_team(
689
625
  self, dry: bool = False, remove_members_without_team: bool = False
@@ -713,6 +649,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
713
649
  "Removing user '%s' from organisation as they are not member of any team",
714
650
  user.login,
715
651
  )
652
+ self.stats.remove_member_without_team(user=user.login, removed=True)
716
653
  if not dry:
717
654
  self.org.remove_from_membership(user)
718
655
  else:
@@ -722,6 +659,8 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
722
659
  "member of any team: %s",
723
660
  ", ".join(members_without_team_str),
724
661
  )
662
+ for user in members_without_team:
663
+ self.stats.remove_member_without_team(user=user.login, removed=False)
725
664
 
726
665
  # --------------------------------------------------------------------------
727
666
  # Repos
@@ -754,7 +693,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
754
693
 
755
694
  # Convert team name to Team object
756
695
  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))
758
697
  # Team not found, probably because a new team should be created, but it's a dry-run
759
698
  except UnknownObjectException:
760
699
  logging.debug(
@@ -763,12 +702,21 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
763
702
  )
764
703
  # Initialise a new Team() object with the name, manually
765
704
  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
+ ),
767
715
  headers={}, # No headers required
768
716
  attributes={
769
717
  "id": 0,
770
718
  "name": team_name,
771
- "slug": self._sluggify_teamname(team_name),
719
+ "slug": sluggify_teamname(team_name),
772
720
  },
773
721
  completed=True, # Mark as fully initialized
774
722
  )
@@ -840,6 +788,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
840
788
  team.name,
841
789
  perm,
842
790
  )
791
+ self.stats.change_repo_team_permissions(repo=repo.name, team=team.name, perm=perm)
843
792
  if not dry:
844
793
  # Update permissions or newly add a team to a repo
845
794
  team.update_team_repository(repo, perm)
@@ -865,6 +814,10 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
865
814
  self._document_unconfigured_team_repo_permissions(
866
815
  team=team, team_permission=teams[team], repo_name=repo.name
867
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
+ )
868
821
  # Abort handling the repo sync as we don't touch unconfigured teams
869
822
  continue
870
823
  # Handle: Team is configured, but contains no config
@@ -883,6 +836,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
883
836
  # Remove if any mismatch has been found
884
837
  if remove:
885
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)
886
840
  if not dry:
887
841
  team.remove_from_repos(repo)
888
842
 
@@ -1328,5 +1282,6 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
1328
1282
  )
1329
1283
 
1330
1284
  # Remove collaborator
1285
+ self.stats.remove_repo_collaborator(repo=repo.name, user=username)
1331
1286
  if not dry:
1332
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,33 +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
146
  # Report and act on teams that are not configured locally
147
+ log_progress("Checking for unconfigured teams...")
133
148
  org.get_unconfigured_teams(
134
149
  dry=args.dry,
135
150
  delete_unconfigured_teams=cfg_app.get("delete_unconfigured_teams", False),
136
151
  )
137
152
  # Report and act on organisation members that do not belong to any team
153
+ log_progress("Checking for members without team...")
138
154
  org.get_members_without_team(
139
155
  dry=args.dry,
140
156
  remove_members_without_team=cfg_app.get("remove_members_without_team", False),
141
157
  )
142
158
  # Synchronise the permissions of teams for all repositories
159
+ log_progress("Synchronising team permissions...")
143
160
  org.sync_repo_permissions(dry=args.dry, ignore_archived=args.ignore_archived)
144
161
  # Remove individual collaborator permissions if they are higher than the one
145
162
  # from team membership (or if they are in no configured team at all)
163
+ log_progress("Synchronising individual collaborator permissions...")
146
164
  org.sync_repo_collaborator_permissions(dry=args.dry)
147
165
 
148
166
  # Debug output
167
+ log_progress("") # clear progress
149
168
  logging.debug("Final dataclass:\n%s", org.pretty_print_dataclass())
150
169
  org.ratelimit()
151
170
 
171
+ # Print changes
172
+ org.stats.print_changes(
173
+ orgname=cfg_org.get("org_name", ""), output=args.output, dry=args.dry
174
+ )
175
+
152
176
  # Setup Team command
153
177
  elif args.command == "setup-team":
154
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.6.0"
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.32.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