Skip to content

Commit 32a7334

Browse files
authored
Merge pull request #89 from OpenRailAssociation/stats
Inform user about changes
2 parents 125928c + 646756b commit 32a7334

File tree

6 files changed

+560
-129
lines changed

6 files changed

+560
-129
lines changed

gh_org_mgr/__init__.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,6 @@
44

55
"""Global init file"""
66

7-
import logging
87
from importlib.metadata import version
98

109
__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

gh_org_mgr/_gh_org.py

Lines changed: 52 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,18 @@
1919
from github.NamedUser import NamedUser
2020
from github.Organization import Organization
2121
from github.Repository import Repository
22+
from github.Requester import Requester
2223
from github.Team import Team
2324
from jwt.exceptions import InvalidKeyError
2425

2526
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
2634

2735

2836
@dataclass
@@ -47,6 +55,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
4755
configured_repos_collaborators: dict[str, dict[str, str]] = field(default_factory=dict)
4856
archived_repos: list[Repository] = field(default_factory=list)
4957
unconfigured_team_repo_permissions: dict[str, dict[str, str]] = field(default_factory=dict)
58+
stats: OrgChanges = field(default_factory=OrgChanges)
5059

5160
# Re-usable Constants
5261
TEAM_CONFIG_FIELDS: dict[str, dict[str, str | None]] = field( # pylint: disable=invalid-name
@@ -61,12 +70,6 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
6170
# --------------------------------------------------------------------------
6271
# Helper functions
6372
# --------------------------------------------------------------------------
64-
def _sluggify_teamname(self, team: str) -> str:
65-
"""Slugify a GitHub team name"""
66-
# TODO: this is very naive, no other special chars are
67-
# supported, or multiple spaces etc.
68-
return team.replace(" ", "-")
69-
7073
# amazonq-ignore-next-line
7174
def login(
7275
self, orgname: str, token: str = "", app_id: str | int = "", app_private_key: str = ""
@@ -117,88 +120,9 @@ def ratelimit(self):
117120
"Current rate limit: %s/%s (reset: %s)", core.remaining, core.limit, core.reset
118121
)
119122

120-
def pretty_print_dict(self, dictionary: dict) -> str:
121-
"""Convert a dict to a pretty-printed output"""
122-
123-
# Censor sensible fields
124-
def censor_half_string(string: str) -> str:
125-
"""Censor 50% of a string (rounded up)"""
126-
half1 = int(len(string) / 2)
127-
half2 = len(string) - half1
128-
return string[:half1] + "*" * (half2)
129-
130-
sensible_keys = ["gh_token", "gh_app_private_key"]
131-
for key in sensible_keys:
132-
if value := dictionary.get(key, ""):
133-
dictionary[key] = censor_half_string(value)
134-
135-
# Print dict nicely
136-
def pretty(d, indent=0):
137-
string = ""
138-
for key, value in d.items():
139-
string += " " * indent + str(key) + ":\n"
140-
if isinstance(value, dict):
141-
string += pretty(value, indent + 1)
142-
else:
143-
string += " " * (indent + 1) + str(value) + "\n"
144-
145-
return string
146-
147-
return pretty(dictionary)
148-
149123
def pretty_print_dataclass(self) -> str:
150124
"""Convert this dataclass to a pretty-printed output"""
151-
return self.pretty_print_dict(asdict(self))
152-
153-
def compare_two_lists(self, list1: list[str], list2: list[str]):
154-
"""
155-
Compares two lists of strings and returns a tuple containing elements
156-
missing in each list and common elements.
157-
158-
Args:
159-
list1 (list of str): The first list of strings.
160-
list2 (list of str): The second list of strings.
161-
162-
Returns:
163-
tuple: A tuple containing three lists:
164-
1. The first list contains elements in `list2` that are missing in `list1`.
165-
2. The second list contains elements that are present in both `list1` and `list2`.
166-
3. The third list contains elements in `list1` that are missing in `list2`.
167-
168-
Example:
169-
>>> list1 = ["apple", "banana", "cherry"]
170-
>>> list2 = ["banana", "cherry", "date", "fig"]
171-
>>> compare_lists(list1, list2)
172-
(['date', 'fig'], ['banana', 'cherry'], ['apple'])
173-
"""
174-
# Convert lists to sets for easier comparison
175-
set1, set2 = set(list1), set(list2)
176-
177-
# Elements in list2 that are missing in list1
178-
missing_in_list1 = list(set2 - set1)
179-
180-
# Elements present in both lists
181-
common_elements = list(set1 & set2)
182-
183-
# Elements in list1 that are missing in list2
184-
missing_in_list2 = list(set1 - set2)
185-
186-
# Return the result as a tuple
187-
return (missing_in_list1, common_elements, missing_in_list2)
188-
189-
def compare_two_dicts(self, dict1: dict, dict2: dict) -> dict[str, dict[str, str | int | None]]:
190-
"""Compares two dictionaries. Assume that the keys are the same. Output
191-
a dict with keys that have differing values"""
192-
# Create an empty dictionary to store differences
193-
differences = {}
194-
195-
# Iterate through the keys (assuming both dictionaries have the same keys)
196-
for key in dict1:
197-
# Compare the values for each key
198-
if dict1[key] != dict2[key]:
199-
differences[key] = {"dict1": dict1[key], "dict2": dict2[key]}
200-
201-
return differences
125+
return dict_to_pretty_string(asdict(self), sensible_keys=["gh_token", "gh_app_private_key"])
202126

203127
def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser | None:
204128
"""Turn a username into a proper GitHub user object"""
@@ -293,7 +217,7 @@ def sync_org_owners(self, dry: bool = False, force: bool = False) -> None:
293217
return
294218

295219
# Get differences between the current and configured owners
296-
owners_remove, owners_ok, owners_add = self.compare_two_lists(
220+
owners_remove, owners_ok, owners_add = compare_two_lists(
297221
self.configured_org_owners, [user.login for user in self.current_org_owners]
298222
)
299223
# Compare configured (lower-cased) owners with lower-cased list of current owners
@@ -314,6 +238,7 @@ def sync_org_owners(self, dry: bool = False, force: bool = False) -> None:
314238
for user in owners_add:
315239
if gh_user := self._resolve_gh_username(user, "<org owners>"):
316240
logging.info("Adding user '%s' as organization owner", gh_user.login)
241+
self.stats.add_owner(gh_user.login)
317242
if not dry:
318243
self.org.add_to_members(gh_user, "admin")
319244

@@ -325,6 +250,7 @@ def sync_org_owners(self, dry: bool = False, force: bool = False) -> None:
325250
"Will make them a normal member",
326251
gh_user.login,
327252
)
253+
self.stats.degrade_owner(gh_user.login)
328254
# Handle authenticated user being the same as the one you want to degrade
329255
if self._is_user_authenticated_user(gh_user):
330256
logging.warning(
@@ -370,9 +296,10 @@ def create_missing_teams(self, dry: bool = False):
370296
for team, attributes in self.configured_teams.items():
371297
if team not in existent_team_names:
372298
if parent := attributes.get("parent"): # type: ignore
373-
parent_id = self.org.get_team_by_slug(self._sluggify_teamname(parent)).id
299+
parent_id = self.org.get_team_by_slug(sluggify_teamname(parent)).id
374300

375301
logging.info("Creating team '%s' with parent ID '%s'", team, parent_id)
302+
self.stats.create_team(team)
376303
# NOTE: We do not specify any team settings (description etc)
377304
# here, this will happen later
378305
if not dry:
@@ -385,6 +312,7 @@ def create_missing_teams(self, dry: bool = False):
385312

386313
else:
387314
logging.info("Creating team '%s' without parent", team)
315+
self.stats.create_team(team)
388316
if not dry:
389317
self.org.create_team(
390318
team,
@@ -410,7 +338,7 @@ def _prepare_team_config_for_sync(
410338
# team coming from config, and valid string
411339
elif isinstance(parent, str) and parent:
412340
team_config["parent_team_id"] = self.org.get_team_by_slug(
413-
self._sluggify_teamname(parent)
341+
sluggify_teamname(parent)
414342
).id
415343
# empty from string, so probably default value
416344
elif isinstance(parent, str) and not parent:
@@ -471,16 +399,17 @@ def sync_current_teams_settings(self, dry: bool = False) -> None:
471399
)
472400

473401
# Compare settings and update if necessary
474-
if differences := self.compare_two_dicts(configured_team_configs, current_team_configs):
402+
if differences := compare_two_dicts(configured_team_configs, current_team_configs):
475403
# Log differences
476404
logging.info(
477405
"Team settings for '%s' differ from the configuration. Updating them:",
478406
team.name,
479407
)
480408
for setting, diff in differences.items():
481-
logging.info(
482-
"Setting '%s': '%s' --> '%s'", setting, diff["dict2"], diff["dict1"]
483-
)
409+
change_str = f"Setting '{setting}': '{diff['dict2']}' --> '{diff['dict1']}'"
410+
logging.info(change_str)
411+
self.stats.edit_team_config(team.name, new_config=change_str)
412+
484413
# Execute team setting changes
485414
if not dry:
486415
try:
@@ -489,7 +418,7 @@ def sync_current_teams_settings(self, dry: bool = False) -> None:
489418
logging.critical(
490419
"Team '%s' settings could not be edited. Error: \n%s",
491420
team.name,
492-
self.pretty_print_dict(exc.data),
421+
dict_to_pretty_string(exc.data),
493422
)
494423
sys.exit(1)
495424
else:
@@ -611,6 +540,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-
611540
team.name,
612541
config_role,
613542
)
543+
self.stats.pending_team_member(team=team.name, user=gh_user.login)
614544
continue
615545

616546
logging.info(
@@ -619,6 +549,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-
619549
team.name,
620550
config_role,
621551
)
552+
self.stats.add_team_member(team=team.name, user=gh_user.login)
622553
if not dry:
623554
self._add_or_update_user_in_team(team=team, user=gh_user, role=config_role)
624555

@@ -633,6 +564,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-
633564
team.name,
634565
config_role,
635566
)
567+
self.stats.change_team_member_role(team=team.name, user=gh_user.login)
636568
if not dry:
637569
self._add_or_update_user_in_team(team=team, user=gh_user, role=config_role)
638570

@@ -651,6 +583,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-
651583
gh_user.login,
652584
team.name,
653585
)
586+
self.stats.remove_team_member(team=team.name, user=gh_user.login)
654587
if not dry:
655588
team.remove_membership(gh_user)
656589
else:
@@ -675,6 +608,7 @@ def get_unconfigured_teams(
675608
if delete_unconfigured_teams:
676609
for team in unconfigured_teams:
677610
logging.info("Deleting team '%s' as it is not configured locally", team.name)
611+
self.stats.delete_team(team=team.name, deleted=True)
678612
if not dry:
679613
team.delete()
680614
else:
@@ -684,6 +618,8 @@ def get_unconfigured_teams(
684618
"configured locally: %s. Taking no action about these teams.",
685619
", ".join(unconfigured_teams_str),
686620
)
621+
for team in unconfigured_teams:
622+
self.stats.delete_team(team=team.name, deleted=False)
687623

688624
def get_members_without_team(
689625
self, dry: bool = False, remove_members_without_team: bool = False
@@ -713,6 +649,7 @@ def get_members_without_team(
713649
"Removing user '%s' from organisation as they are not member of any team",
714650
user.login,
715651
)
652+
self.stats.remove_member_without_team(user=user.login, removed=True)
716653
if not dry:
717654
self.org.remove_from_membership(user)
718655
else:
@@ -722,6 +659,8 @@ def get_members_without_team(
722659
"member of any team: %s",
723660
", ".join(members_without_team_str),
724661
)
662+
for user in members_without_team:
663+
self.stats.remove_member_without_team(user=user.login, removed=False)
725664

726665
# --------------------------------------------------------------------------
727666
# Repos
@@ -754,7 +693,7 @@ def _create_perms_changelist_for_teams(
754693

755694
# Convert team name to Team object
756695
try:
757-
team = self.org.get_team_by_slug(self._sluggify_teamname(team_name))
696+
team = self.org.get_team_by_slug(sluggify_teamname(team_name))
758697
# Team not found, probably because a new team should be created, but it's a dry-run
759698
except UnknownObjectException:
760699
logging.debug(
@@ -763,12 +702,21 @@ def _create_perms_changelist_for_teams(
763702
)
764703
# Initialise a new Team() object with the name, manually
765704
team = Team(
766-
requester=None, # type: ignore
705+
requester=Requester(
706+
auth=None,
707+
base_url="https://api.github.com",
708+
timeout=10,
709+
user_agent="",
710+
per_page=100,
711+
verify=True,
712+
retry=3,
713+
pool_size=200,
714+
),
767715
headers={}, # No headers required
768716
attributes={
769717
"id": 0,
770718
"name": team_name,
771-
"slug": self._sluggify_teamname(team_name),
719+
"slug": sluggify_teamname(team_name),
772720
},
773721
completed=True, # Mark as fully initialized
774722
)
@@ -840,6 +788,7 @@ def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False
840788
team.name,
841789
perm,
842790
)
791+
self.stats.change_repo_team_permissions(repo=repo.name, team=team.name, perm=perm)
843792
if not dry:
844793
# Update permissions or newly add a team to a repo
845794
team.update_team_repository(repo, perm)
@@ -865,6 +814,10 @@ def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False
865814
self._document_unconfigured_team_repo_permissions(
866815
team=team, team_permission=teams[team], repo_name=repo.name
867816
)
817+
# Collect this status in the stats
818+
self.stats.document_unconfigured_team_permissions(
819+
team=team.name, repo=repo.name, perm=teams[team]
820+
)
868821
# Abort handling the repo sync as we don't touch unconfigured teams
869822
continue
870823
# Handle: Team is configured, but contains no config
@@ -883,6 +836,7 @@ def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False
883836
# Remove if any mismatch has been found
884837
if remove:
885838
logging.info("Removing team '%s' from repository '%s'", team.name, repo.name)
839+
self.stats.remove_team_from_repo(repo=repo.name, team=team.name)
886840
if not dry:
887841
team.remove_from_repos(repo)
888842

@@ -1328,5 +1282,6 @@ def sync_repo_collaborator_permissions(self, dry: bool = False):
13281282
)
13291283

13301284
# Remove collaborator
1285+
self.stats.remove_repo_collaborator(repo=repo.name, user=username)
13311286
if not dry:
13321287
repo.remove_from_collaborators(username)

0 commit comments

Comments
 (0)