Skip to content

Commit cc8e27b

Browse files
committed
feat: log changes in users, teams and repos in separate dataclasses
1 parent 6be1f48 commit cc8e27b

File tree

3 files changed

+177
-3
lines changed

3 files changed

+177
-3
lines changed

gh_org_mgr/_gh_org.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from jwt.exceptions import InvalidKeyError
2525

2626
from ._gh_api import get_github_secrets_from_env, run_graphql_query
27+
from ._stats import OrgChanges
2728

2829

2930
@dataclass
@@ -48,6 +49,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
4849
configured_repos_collaborators: dict[str, dict[str, str]] = field(default_factory=dict)
4950
archived_repos: list[Repository] = field(default_factory=list)
5051
unconfigured_team_repo_permissions: dict[str, dict[str, str]] = field(default_factory=dict)
52+
stats: OrgChanges = field(default_factory=OrgChanges)
5153

5254
# Re-usable Constants
5355
TEAM_CONFIG_FIELDS: dict[str, dict[str, str | None]] = field( # pylint: disable=invalid-name
@@ -315,6 +317,7 @@ def sync_org_owners(self, dry: bool = False, force: bool = False) -> None:
315317
for user in owners_add:
316318
if gh_user := self._resolve_gh_username(user, "<org owners>"):
317319
logging.info("Adding user '%s' as organization owner", gh_user.login)
320+
self.stats.add_owner(gh_user.login)
318321
if not dry:
319322
self.org.add_to_members(gh_user, "admin")
320323

@@ -326,6 +329,7 @@ def sync_org_owners(self, dry: bool = False, force: bool = False) -> None:
326329
"Will make them a normal member",
327330
gh_user.login,
328331
)
332+
self.stats.degrade_owner(gh_user.login)
329333
# Handle authenticated user being the same as the one you want to degrade
330334
if self._is_user_authenticated_user(gh_user):
331335
logging.warning(
@@ -374,6 +378,7 @@ def create_missing_teams(self, dry: bool = False):
374378
parent_id = self.org.get_team_by_slug(self._sluggify_teamname(parent)).id
375379

376380
logging.info("Creating team '%s' with parent ID '%s'", team, parent_id)
381+
self.stats.create_team(team)
377382
# NOTE: We do not specify any team settings (description etc)
378383
# here, this will happen later
379384
if not dry:
@@ -386,6 +391,7 @@ def create_missing_teams(self, dry: bool = False):
386391

387392
else:
388393
logging.info("Creating team '%s' without parent", team)
394+
self.stats.create_team(team)
389395
if not dry:
390396
self.org.create_team(
391397
team,
@@ -479,9 +485,10 @@ def sync_current_teams_settings(self, dry: bool = False) -> None:
479485
team.name,
480486
)
481487
for setting, diff in differences.items():
482-
logging.info(
483-
"Setting '%s': '%s' --> '%s'", setting, diff["dict2"], diff["dict1"]
484-
)
488+
change_str = f"Setting '{setting}': '{diff['dict2']}' --> '{diff['dict1']}'"
489+
logging.info(change_str)
490+
self.stats.edit_team_config(team.name, new_config=change_str)
491+
485492
# Execute team setting changes
486493
if not dry:
487494
try:
@@ -612,6 +619,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-
612619
team.name,
613620
config_role,
614621
)
622+
self.stats.pending_team_member(team=team.name, user=gh_user.login)
615623
continue
616624

617625
logging.info(
@@ -620,6 +628,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-
620628
team.name,
621629
config_role,
622630
)
631+
self.stats.add_team_member(team=team.name, user=gh_user.login)
623632
if not dry:
624633
self._add_or_update_user_in_team(team=team, user=gh_user, role=config_role)
625634

@@ -634,6 +643,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-
634643
team.name,
635644
config_role,
636645
)
646+
self.stats.change_team_member_role(team=team.name, user=gh_user.login)
637647
if not dry:
638648
self._add_or_update_user_in_team(team=team, user=gh_user, role=config_role)
639649

@@ -652,6 +662,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-
652662
gh_user.login,
653663
team.name,
654664
)
665+
self.stats.remove_team_member(team=team.name, user=gh_user.login)
655666
if not dry:
656667
team.remove_membership(gh_user)
657668
else:
@@ -676,6 +687,7 @@ def get_unconfigured_teams(
676687
if delete_unconfigured_teams:
677688
for team in unconfigured_teams:
678689
logging.info("Deleting team '%s' as it is not configured locally", team.name)
690+
self.stats.delete_team(team=team.name, deleted=True)
679691
if not dry:
680692
team.delete()
681693
else:
@@ -685,6 +697,8 @@ def get_unconfigured_teams(
685697
"configured locally: %s. Taking no action about these teams.",
686698
", ".join(unconfigured_teams_str),
687699
)
700+
for team in unconfigured_teams:
701+
self.stats.delete_team(team=team.name, deleted=False)
688702

689703
def get_members_without_team(
690704
self, dry: bool = False, remove_members_without_team: bool = False
@@ -714,6 +728,7 @@ def get_members_without_team(
714728
"Removing user '%s' from organisation as they are not member of any team",
715729
user.login,
716730
)
731+
self.stats.remove_member_without_team(user=user.login, removed=True)
717732
if not dry:
718733
self.org.remove_from_membership(user)
719734
else:
@@ -723,6 +738,8 @@ def get_members_without_team(
723738
"member of any team: %s",
724739
", ".join(members_without_team_str),
725740
)
741+
for user in members_without_team:
742+
self.stats.remove_member_without_team(user=user.login, removed=False)
726743

727744
# --------------------------------------------------------------------------
728745
# Repos
@@ -850,6 +867,7 @@ def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False
850867
team.name,
851868
perm,
852869
)
870+
self.stats.change_repo_team_permissions(repo=repo.name, team=team.name, perm=perm)
853871
if not dry:
854872
# Update permissions or newly add a team to a repo
855873
team.update_team_repository(repo, perm)
@@ -875,6 +893,10 @@ def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False
875893
self._document_unconfigured_team_repo_permissions(
876894
team=team, team_permission=teams[team], repo_name=repo.name
877895
)
896+
# Collect this status in the stats
897+
self.stats.document_unconfigured_team_permissions(
898+
team=team.name, repo=repo.name, perm=teams[team]
899+
)
878900
# Abort handling the repo sync as we don't touch unconfigured teams
879901
continue
880902
# Handle: Team is configured, but contains no config
@@ -893,6 +915,7 @@ def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False
893915
# Remove if any mismatch has been found
894916
if remove:
895917
logging.info("Removing team '%s' from repository '%s'", team.name, repo.name)
918+
self.stats.remove_team_from_repo(repo=repo.name, team=team.name)
896919
if not dry:
897920
team.remove_from_repos(repo)
898921

@@ -1338,5 +1361,6 @@ def sync_repo_collaborator_permissions(self, dry: bool = False):
13381361
)
13391362

13401363
# Remove collaborator
1364+
self.stats.remove_repo_collaborator(repo=repo.name, user=username)
13411365
if not dry:
13421366
repo.remove_from_collaborators(username)

gh_org_mgr/_stats.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# SPDX-FileCopyrightText: 2025 DB Systel GmbH
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""Dataclasses and functions for statistics"""
6+
7+
from dataclasses import dataclass, field
8+
9+
10+
@dataclass
11+
class TeamChanges: # pylint: disable=too-many-instance-attributes
12+
"""Dataclass holding information about the changes made to a team"""
13+
14+
newly_created: bool = False
15+
deleted: bool = False
16+
unconfigured: bool = False
17+
changed_config: list[str] = field(default_factory=list)
18+
added_members: list[str] = field(default_factory=list)
19+
changed_members_role: list[str] = field(default_factory=list)
20+
removed_members: list[str] = field(default_factory=list)
21+
pending_members: list[str] = field(default_factory=list)
22+
23+
24+
@dataclass
25+
class RepoChanges: # pylint: disable=too-many-instance-attributes
26+
"""Dataclass holding information about the changes made to a repository"""
27+
28+
changed_permissions_for_teams: list[str] = field(default_factory=list)
29+
removed_teams: list[str] = field(default_factory=list)
30+
unconfigured_teams_with_permissions: list[str] = field(default_factory=list)
31+
removed_collaborators: list[str] = field(default_factory=list)
32+
33+
34+
@dataclass
35+
class OrgChanges: # pylint: disable=too-many-instance-attributes
36+
"""Dataclass holding general statistics about the changes made to the organization"""
37+
38+
added_owners: list[str] = field(default_factory=list)
39+
degraded_owners: list[str] = field(default_factory=list)
40+
teams: dict[str, TeamChanges] = field(default_factory=dict)
41+
repos: dict[str, RepoChanges] = field(default_factory=dict)
42+
members_without_team: list[str] = field(default_factory=list)
43+
removed_members: list[str] = field(default_factory=list)
44+
45+
# --------------------------------------------------------------------------
46+
# Owners
47+
# --------------------------------------------------------------------------
48+
def add_owner(self, user: str) -> None:
49+
"""User has been added as owner"""
50+
self.added_owners.append(user)
51+
52+
def degrade_owner(self, user: str) -> None:
53+
"""User has been degraded from owner to member"""
54+
self.degraded_owners.append(user)
55+
56+
# --------------------------------------------------------------------------
57+
# Teams
58+
# --------------------------------------------------------------------------
59+
def update_team(self, team_name: str, **changes: bool | str | list[str]) -> None:
60+
"""Update team changes"""
61+
# Initialise team if not present
62+
if team_name not in self.teams:
63+
self.teams[team_name] = TeamChanges()
64+
65+
implement_changes(dc_object=self.teams[team_name], **changes)
66+
67+
def create_team(self, team: str) -> None:
68+
"""Team has been created"""
69+
self.update_team(team_name=team, newly_created=True)
70+
71+
def edit_team_config(self, team: str, new_config: str) -> None:
72+
"""Team config has been changed"""
73+
self.update_team(team_name=team, changed_config=new_config)
74+
75+
def delete_team(self, team: str, deleted: bool) -> None:
76+
"""Teams are not configured"""
77+
self.update_team(team_name=team, unconfigured=True, deleted=deleted)
78+
79+
# --------------------------------------------------------------------------
80+
# Members
81+
# --------------------------------------------------------------------------
82+
def add_team_member(self, team: str, user: str) -> None:
83+
"""User has been added to team"""
84+
self.update_team(team_name=team, added_members=user)
85+
86+
def change_team_member_role(self, team: str, user: str) -> None:
87+
"""User role has been changed in team"""
88+
self.update_team(team_name=team, changed_members_role=user)
89+
90+
def pending_team_member(self, team: str, user: str) -> None:
91+
"""User has a pending invitation"""
92+
self.update_team(team_name=team, pending_members=user)
93+
94+
def remove_team_member(self, team: str, user: str) -> None:
95+
"""User has been removed from team"""
96+
self.update_team(team_name=team, removed_members=user)
97+
98+
def remove_member_without_team(self, user: str, removed: bool) -> None:
99+
"""User is not in any team"""
100+
self.members_without_team.append(user)
101+
if removed:
102+
self.removed_members.append(user)
103+
104+
# --------------------------------------------------------------------------
105+
# Repos
106+
# --------------------------------------------------------------------------
107+
def update_repo(self, repo_name: str, **changes: bool | str | list[str]) -> None:
108+
"""Update team changes"""
109+
# Initialise repo if not present
110+
if repo_name not in self.teams:
111+
self.repos[repo_name] = RepoChanges()
112+
113+
implement_changes(dc_object=self.repos[repo_name], **changes)
114+
115+
def change_repo_team_permissions(self, repo: str, team: str, perm: str) -> None:
116+
"""Team permissions have been changed for a repo"""
117+
self.update_repo(repo_name=repo, changed_permissions_for_teams=f"{team}: {perm}")
118+
119+
def remove_team_from_repo(self, repo: str, team: str) -> None:
120+
"""Team has been removed form a repo"""
121+
self.update_repo(repo_name=repo, removed_teams=team)
122+
123+
def document_unconfigured_team_permissions(self, repo: str, team: str, perm: str) -> None:
124+
"""Unconfigured team has permissions on repo"""
125+
self.update_repo(repo_name=repo, unconfigured_teams_with_permissions=f"{team}: {perm}")
126+
127+
def remove_repo_collaborator(self, repo: str, user: str) -> None:
128+
"""Remove collaborator"""
129+
self.update_repo(repo_name=repo, removed_collaborators=user)
130+
131+
132+
def implement_changes(dc_object, **changes: bool | str | list[str]):
133+
"""Smartly add changes to a dataclass object"""
134+
for attribute, value in changes.items():
135+
current_value = getattr(dc_object, attribute)
136+
# attribute is list
137+
if isinstance(current_value, list):
138+
# input change is list
139+
if isinstance(value, list):
140+
current_value.extend(value)
141+
# input change is not list
142+
else:
143+
current_value.append(value)
144+
# All other cases, bool
145+
else:
146+
setattr(dc_object, attribute, value)
147+
148+
return dc_object

gh_org_mgr/manage.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ def main():
149149
logging.debug("Final dataclass:\n%s", org.pretty_print_dataclass())
150150
org.ratelimit()
151151

152+
print(org.stats)
153+
152154
# Setup Team command
153155
elif args.command == "setup-team":
154156
setup_team(team_name=args.name, config_path=args.config, file_path=args.file)

0 commit comments

Comments
 (0)