github-org-manager 0.6.0__tar.gz → 0.7.2__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.
- {github_org_manager-0.6.0 → github_org_manager-0.7.2}/PKG-INFO +1 -1
- github_org_manager-0.7.2/gh_org_mgr/__init__.py +9 -0
- {github_org_manager-0.6.0 → github_org_manager-0.7.2}/gh_org_mgr/_gh_org.py +52 -97
- github_org_manager-0.7.2/gh_org_mgr/_helpers.py +152 -0
- github_org_manager-0.7.2/gh_org_mgr/_stats.py +258 -0
- {github_org_manager-0.6.0 → github_org_manager-0.7.2}/gh_org_mgr/manage.py +28 -4
- {github_org_manager-0.6.0 → github_org_manager-0.7.2}/pyproject.toml +2 -2
- github_org_manager-0.6.0/gh_org_mgr/__init__.py +0 -26
- {github_org_manager-0.6.0 → github_org_manager-0.7.2}/LICENSE.txt +0 -0
- {github_org_manager-0.6.0 → github_org_manager-0.7.2}/LICENSES/Apache-2.0.txt +0 -0
- {github_org_manager-0.6.0 → github_org_manager-0.7.2}/LICENSES/CC-BY-4.0.txt +0 -0
- {github_org_manager-0.6.0 → github_org_manager-0.7.2}/LICENSES/CC0-1.0.txt +0 -0
- {github_org_manager-0.6.0 → github_org_manager-0.7.2}/LICENSES/MIT.txt +0 -0
- {github_org_manager-0.6.0 → github_org_manager-0.7.2}/README.md +0 -0
- {github_org_manager-0.6.0 → github_org_manager-0.7.2}/gh_org_mgr/_config.py +0 -0
- {github_org_manager-0.6.0 → github_org_manager-0.7.2}/gh_org_mgr/_gh_api.py +0 -0
- {github_org_manager-0.6.0 → github_org_manager-0.7.2}/gh_org_mgr/_setup_team.py +0 -0
|
@@ -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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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 :=
|
|
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
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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(
|
|
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=
|
|
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":
|
|
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__
|
|
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("--
|
|
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.
|
|
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.
|
|
7
|
+
version = "0.7.2"
|
|
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 = "^
|
|
42
|
+
bump-my-version = "^1.1.2"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|