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.
- {github_org_manager-0.5.7 → github_org_manager-0.7.1}/PKG-INFO +2 -2
- {github_org_manager-0.5.7 → github_org_manager-0.7.1}/README.md +1 -1
- github_org_manager-0.7.1/gh_org_mgr/__init__.py +9 -0
- {github_org_manager-0.5.7 → github_org_manager-0.7.1}/gh_org_mgr/_gh_org.py +104 -115
- github_org_manager-0.7.1/gh_org_mgr/_helpers.py +152 -0
- github_org_manager-0.7.1/gh_org_mgr/_stats.py +258 -0
- {github_org_manager-0.5.7 → github_org_manager-0.7.1}/gh_org_mgr/manage.py +38 -6
- {github_org_manager-0.5.7 → github_org_manager-0.7.1}/pyproject.toml +2 -2
- github_org_manager-0.5.7/gh_org_mgr/__init__.py +0 -26
- {github_org_manager-0.5.7 → github_org_manager-0.7.1}/LICENSE.txt +0 -0
- {github_org_manager-0.5.7 → github_org_manager-0.7.1}/LICENSES/Apache-2.0.txt +0 -0
- {github_org_manager-0.5.7 → github_org_manager-0.7.1}/LICENSES/CC-BY-4.0.txt +0 -0
- {github_org_manager-0.5.7 → github_org_manager-0.7.1}/LICENSES/CC0-1.0.txt +0 -0
- {github_org_manager-0.5.7 → github_org_manager-0.7.1}/LICENSES/MIT.txt +0 -0
- {github_org_manager-0.5.7 → github_org_manager-0.7.1}/gh_org_mgr/_config.py +0 -0
- {github_org_manager-0.5.7 → github_org_manager-0.7.1}/gh_org_mgr/_gh_api.py +0 -0
- {github_org_manager-0.5.7 → github_org_manager-0.7.1}/gh_org_mgr/_setup_team.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: github-org-manager
|
|
3
|
-
Version: 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
|
|
@@ -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
|
|
|
@@ -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:
|
|
@@ -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
|
|
670
|
-
|
|
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(
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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(
|
|
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=
|
|
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":
|
|
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__
|
|
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,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
|
|
133
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|