github-org-manager 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: github-org-manager
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Manage a GitHub Organization, its teams, repository permissions, and more
5
5
  Home-page: https://github.com/OpenRailAssociation/github-org-manager
6
6
  License: Apache-2.0
@@ -45,12 +45,20 @@ The basic principle: all settings reside in YAML configuration files which will
45
45
 
46
46
  ## Features
47
47
 
48
- * Manage GitHub teams and their members and maintainers
49
- * Support of sub-teams
48
+ * Manage GitHub organization owners
49
+ * Manage GitHub teams, their members, maintainers and settings
50
+ * Support of parent/child teams
50
51
  * Manage teams' permissions on organizations' repositories
51
52
  * Invite members to the organization if they aren't part of it yet
52
53
  * Warn about unmanaged teams
53
54
  * Warn about organization members who are not part of any team
55
+ * Handle individual collaborator permissions to repositories
56
+
57
+ The tool's philosophy:
58
+
59
+ * All relevant configuration shall happen in the YAML configuration files, no actions in GitHub UI shall be necessary.
60
+ * All repository permissions shall be managed by team membership. Outside collaborators and individual permissions are discouraged.
61
+ * All teams shall be managed by this tool. While it can deal with unmanaged teams, it's not a priority and may cause warnings.
54
62
 
55
63
  Are you missing a feature? Please check whether it's [already posted as an issue](https://github.com/OpenRailAssociation/github-org-manager/issues), and create one of this isn't the case.
56
64
 
@@ -17,12 +17,20 @@ The basic principle: all settings reside in YAML configuration files which will
17
17
 
18
18
  ## Features
19
19
 
20
- * Manage GitHub teams and their members and maintainers
21
- * Support of sub-teams
20
+ * Manage GitHub organization owners
21
+ * Manage GitHub teams, their members, maintainers and settings
22
+ * Support of parent/child teams
22
23
  * Manage teams' permissions on organizations' repositories
23
24
  * Invite members to the organization if they aren't part of it yet
24
25
  * Warn about unmanaged teams
25
26
  * Warn about organization members who are not part of any team
27
+ * Handle individual collaborator permissions to repositories
28
+
29
+ The tool's philosophy:
30
+
31
+ * All relevant configuration shall happen in the YAML configuration files, no actions in GitHub UI shall be necessary.
32
+ * All repository permissions shall be managed by team membership. Outside collaborators and individual permissions are discouraged.
33
+ * All teams shall be managed by this tool. While it can deal with unmanaged teams, it's not a priority and may cause warnings.
26
34
 
27
35
  Are you missing a feature? Please check whether it's [already posted as an issue](https://github.com/OpenRailAssociation/github-org-manager/issues), and create one of this isn't the case.
28
36
 
@@ -85,8 +85,9 @@ def _read_config_file(file: str) -> dict:
85
85
  return config
86
86
 
87
87
 
88
- def parse_config_files(path: str) -> tuple[dict, dict, dict]:
89
- """Parse all relevant files in the configuration directory"""
88
+ def parse_config_files(path: str) -> tuple[dict[str, str | dict[str, str]], dict, dict]:
89
+ """Parse all relevant files in the configuration directory. Returns a tuple
90
+ of org config, app config, and merged teams config"""
90
91
  # Find the relevant config files for app, org, and teams
91
92
  cfg_app_files = _find_matching_files(path, APP_CONFIG_FILE, only_one=True)
92
93
  cfg_org_files = _find_matching_files(path, ORG_CONFIG_FILE, only_one=True)
@@ -5,9 +5,10 @@
5
5
  """Class for the GitHub organization which contains most of the logic"""
6
6
 
7
7
  import logging
8
+ import sys
8
9
  from dataclasses import asdict, dataclass, field
9
10
 
10
- from github import Github, UnknownObjectException
11
+ from github import Github, GithubException, UnknownObjectException
11
12
  from github.NamedUser import NamedUser
12
13
  from github.Organization import Organization
13
14
  from github.Repository import Repository
@@ -17,14 +18,15 @@ from ._gh_api import get_github_token, run_graphql_query
17
18
 
18
19
 
19
20
  @dataclass
20
- class GHorg: # pylint: disable=too-many-instance-attributes
21
+ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
21
22
  """Dataclass holding GH organization data and functions"""
22
23
 
23
24
  gh: Github = None # type: ignore
24
25
  org: Organization = None # type: ignore
25
26
  gh_token: str = ""
26
27
  default_repository_permission: str = ""
27
- org_owners: list[NamedUser] = field(default_factory=list)
28
+ current_org_owners: list[NamedUser] = field(default_factory=list)
29
+ configured_org_owners: list[str] = field(default_factory=list)
28
30
  org_members: list[NamedUser] = field(default_factory=list)
29
31
  current_teams: dict[Team, dict] = field(default_factory=dict)
30
32
  configured_teams: dict[str, dict | None] = field(default_factory=dict)
@@ -34,6 +36,16 @@ class GHorg: # pylint: disable=too-many-instance-attributes
34
36
  archived_repos: list[Repository] = field(default_factory=list)
35
37
  unconfigured_team_repo_permissions: dict[str, dict[str, str]] = field(default_factory=dict)
36
38
 
39
+ # Re-usable Constants
40
+ TEAM_CONFIG_FIELDS: dict[str, dict[str, str | None]] = field( # pylint: disable=invalid-name
41
+ default_factory=lambda: {
42
+ "parent": {"fallback_value": None},
43
+ "privacy": {"fallback_value": "<keep-current>"},
44
+ "description": {"fallback_value": "<keep-current>"},
45
+ "notification_setting": {"fallback_value": "<keep-current>"},
46
+ }
47
+ )
48
+
37
49
  # --------------------------------------------------------------------------
38
50
  # Helper functions
39
51
  # --------------------------------------------------------------------------
@@ -43,11 +55,13 @@ class GHorg: # pylint: disable=too-many-instance-attributes
43
55
  # supported, or multiple spaces etc.
44
56
  return team.replace(" ", "-")
45
57
 
46
- def login(self, orgname: str, token: str):
58
+ def login(self, orgname: str, token: str) -> None:
47
59
  """Login to GH, gather org data"""
48
60
  self.gh_token = get_github_token(token)
49
61
  self.gh = Github(self.gh_token)
62
+ logging.debug("Logged in as %s", self.gh.get_user().login)
50
63
  self.org = self.gh.get_organization(orgname)
64
+ logging.debug("Gathered data from organization '%s' (%s)", self.org.login, self.org.name)
51
65
 
52
66
  def ratelimit(self):
53
67
  """Get current rate limit"""
@@ -56,9 +70,8 @@ class GHorg: # pylint: disable=too-many-instance-attributes
56
70
  "Current rate limit: %s/%s (reset: %s)", core.remaining, core.limit, core.reset
57
71
  )
58
72
 
59
- def df2json(self) -> str:
60
- """Convert the dataclass to a JSON string"""
61
- d = asdict(self)
73
+ def pretty_print_dict(self, dictionary: dict) -> str:
74
+ """Convert a dict to a pretty-printed output"""
62
75
 
63
76
  # Censor sensible fields
64
77
  def censor_half_string(string: str) -> str:
@@ -69,7 +82,8 @@ class GHorg: # pylint: disable=too-many-instance-attributes
69
82
 
70
83
  sensible_keys = ["gh_token"]
71
84
  for key in sensible_keys:
72
- d[key] = censor_half_string(d.get(key, ""))
85
+ if value := dictionary.get(key, ""):
86
+ dictionary[key] = censor_half_string(value)
73
87
 
74
88
  # Print dict nicely
75
89
  def pretty(d, indent=0):
@@ -83,7 +97,105 @@ class GHorg: # pylint: disable=too-many-instance-attributes
83
97
 
84
98
  return string
85
99
 
86
- return pretty(d)
100
+ return pretty(dictionary)
101
+
102
+ def pretty_print_dataclass(self) -> str:
103
+ """Convert this dataclass to a pretty-printed output"""
104
+ return self.pretty_print_dict(asdict(self))
105
+
106
+ def compare_two_lists(self, list1: list[str], list2: list[str]):
107
+ """
108
+ Compares two lists of strings and returns a tuple containing elements
109
+ missing in each list and common elements.
110
+
111
+ Args:
112
+ list1 (list of str): The first list of strings.
113
+ list2 (list of str): The second list of strings.
114
+
115
+ Returns:
116
+ tuple: A tuple containing three lists:
117
+ 1. The first list contains elements in `list2` that are missing in `list1`.
118
+ 2. The second list contains elements that are present in both `list1` and `list2`.
119
+ 3. The third list contains elements in `list1` that are missing in `list2`.
120
+
121
+ Example:
122
+ >>> list1 = ["apple", "banana", "cherry"]
123
+ >>> list2 = ["banana", "cherry", "date", "fig"]
124
+ >>> compare_lists(list1, list2)
125
+ (['date', 'fig'], ['banana', 'cherry'], ['apple'])
126
+ """
127
+ # Convert lists to sets for easier comparison
128
+ set1, set2 = set(list1), set(list2)
129
+
130
+ # Elements in list2 that are missing in list1
131
+ missing_in_list1 = list(set2 - set1)
132
+
133
+ # Elements present in both lists
134
+ common_elements = list(set1 & set2)
135
+
136
+ # Elements in list1 that are missing in list2
137
+ missing_in_list2 = list(set1 - set2)
138
+
139
+ # Return the result as a tuple
140
+ return (missing_in_list1, common_elements, missing_in_list2)
141
+
142
+ def compare_two_dicts(self, dict1: dict, dict2: dict) -> dict[str, dict[str, str | int | None]]:
143
+ """Compares two dictionaries. Assume that the keys are the same. Output
144
+ a dict with keys that have differing values"""
145
+ # Create an empty dictionary to store differences
146
+ differences = {}
147
+
148
+ # Iterate through the keys (assuming both dictionaries have the same keys)
149
+ for key in dict1:
150
+ # Compare the values for each key
151
+ if dict1[key] != dict2[key]:
152
+ differences[key] = {"dict1": dict1[key], "dict2": dict2[key]}
153
+
154
+ return differences
155
+
156
+ def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser | None:
157
+ """Turn a username into a proper GitHub user object"""
158
+ try:
159
+ gh_user: NamedUser = self.gh.get_user(username) # type: ignore
160
+ except UnknownObjectException:
161
+ logging.error(
162
+ "The user '%s' configured as member of team '%s' does not "
163
+ "exist on GitHub. Spelling error or did they rename themselves?",
164
+ username,
165
+ teamname,
166
+ )
167
+ return None
168
+
169
+ return gh_user
170
+
171
+ # --------------------------------------------------------------------------
172
+ # Configuration
173
+ # --------------------------------------------------------------------------
174
+ def consolidate_team_config(self, default_team_configs: dict[str, str]) -> None:
175
+ """Complete teams configuration with default teams configs"""
176
+ for team_name, team_config in self.configured_teams.items():
177
+ # Handle none team configs
178
+ if team_config is None:
179
+ team_config = {}
180
+
181
+ # Iterate through configurable team settings. Take team config, fall
182
+ # back to default org-wide value. If no config can be found, either
183
+ # add a fallback value or do not add this setting altogether.
184
+ for cfg_item, cfg_value in self.TEAM_CONFIG_FIELDS.items():
185
+ # Case 1: setting in team config
186
+ if tcfg := team_config.get(cfg_item):
187
+ team_config[cfg_item] = tcfg
188
+ # Case 2: setting in default org team config
189
+ elif dcfg := default_team_configs.get(cfg_item):
190
+ team_config[cfg_item] = dcfg
191
+ # Case 3: setting defined nowhere, take hardcoded default
192
+ else:
193
+ # Look which fallback value/action shall be taken
194
+ fallback_value = cfg_value["fallback_value"]
195
+ if fallback_value != "<keep-current>":
196
+ team_config[cfg_item] = fallback_value
197
+
198
+ logging.debug("Configuration for team '%s' consolidated to: %s", team_name, team_config)
87
199
 
88
200
  # --------------------------------------------------------------------------
89
201
  # Teams
@@ -108,13 +220,25 @@ class GHorg: # pylint: disable=too-many-instance-attributes
108
220
  parent_id = self.org.get_team_by_slug(self._sluggify_teamname(parent)).id
109
221
 
110
222
  logging.info("Creating team '%s' with parent ID '%s'", team, parent_id)
223
+ # NOTE: We do not specify any team settings (description etc)
224
+ # here, this will happen later
111
225
  if not dry:
112
- self.org.create_team(team, parent_team_id=parent_id)
226
+ self.org.create_team(
227
+ team,
228
+ parent_team_id=parent_id,
229
+ # Hardcode privacy as "secret" is not possible in child teams
230
+ privacy="closed",
231
+ )
113
232
 
114
233
  else:
115
234
  logging.info("Creating team '%s' without parent", team)
116
235
  if not dry:
117
- self.org.create_team(team, privacy="closed")
236
+ self.org.create_team(
237
+ team,
238
+ # Hardcode privacy as "secret" is not possible in
239
+ # parent teams, which is the API's default
240
+ privacy="closed",
241
+ )
118
242
 
119
243
  else:
120
244
  logging.debug("Team '%s' already exists", team)
@@ -122,13 +246,215 @@ class GHorg: # pylint: disable=too-many-instance-attributes
122
246
  # Re-scan current teams as new ones may have been created
123
247
  self._get_current_teams()
124
248
 
249
+ def _prepare_team_config_for_sync(
250
+ self, team_config: dict[str, str | int | Team | None]
251
+ ) -> dict[str, str | int | None]:
252
+ """Turn parent values into IDs, and sort the config dictionary for better comparison"""
253
+ if parent := team_config["parent"]:
254
+ # team coming from API request (current)
255
+ if isinstance(parent, Team):
256
+ team_config["parent_team_id"] = parent.id
257
+ # team coming from config, and valid string
258
+ elif isinstance(parent, str) and parent:
259
+ team_config["parent_team_id"] = self.org.get_team_by_slug(
260
+ self._sluggify_teamname(parent)
261
+ ).id
262
+ # empty from string, so probably default value
263
+ elif isinstance(parent, str) and not parent:
264
+ team_config["parent_team_id"] = None
265
+ else:
266
+ team_config["parent_team_id"] = None
267
+
268
+ # Remove parent key
269
+ team_config.pop("parent", None)
270
+
271
+ # Sort dict and return
272
+ # Ensure the dictionary has only comparable types before sorting
273
+ filtered_team_config = {
274
+ k: v for k, v in team_config.items() if isinstance(v, (str, int, type(None)))
275
+ }
276
+ return dict(sorted(filtered_team_config.items()))
277
+
278
+ def sync_current_teams_settings(self, dry: bool = False) -> None:
279
+ """Sync settings for the existing teams: description, visibility etc."""
280
+ for team in self.current_teams:
281
+ # Skip unconfigured teams
282
+ if team.name not in self.configured_teams:
283
+ logging.debug(
284
+ "Will not sync settings of team '%s' as not configured locally", team.name
285
+ )
286
+ continue
287
+
288
+ # Use dictionary comprehensions to build the dictionaries with the
289
+ # relevant team settings for comparison
290
+ configured_team_configs = {
291
+ key: self.configured_teams[team.name].get(key) # type: ignore
292
+ for key in self.TEAM_CONFIG_FIELDS
293
+ # Only add keys that are actually in the configuration. Deals
294
+ # with settings that should be changed, as they are neither
295
+ # defined in the default or team config, and marked as
296
+ # <keep-current>
297
+ if key in self.configured_teams[team.name] # type: ignore
298
+ }
299
+ current_team_configs = {
300
+ key: getattr(team, key)
301
+ for key in self.TEAM_CONFIG_FIELDS
302
+ # Only compare current team settings with keys that are defined
303
+ # as the configured team settings. Taking out settings that
304
+ # shall not be changed
305
+ if key in self.configured_teams[team.name] # type: ignore
306
+ }
307
+
308
+ # Resolve parent team id from parent Team object or team string, and sort
309
+ configured_team_configs = self._prepare_team_config_for_sync(configured_team_configs)
310
+ current_team_configs = self._prepare_team_config_for_sync(current_team_configs)
311
+
312
+ # Log the comparison result
313
+ logging.debug(
314
+ "Comparing team '%s' settings: Configured '%s' vs. Current '%s'",
315
+ team.name,
316
+ configured_team_configs,
317
+ current_team_configs,
318
+ )
319
+
320
+ # Compare settings and update if necessary
321
+ if differences := self.compare_two_dicts(configured_team_configs, current_team_configs):
322
+ # Log differences
323
+ logging.info(
324
+ "Team settings for '%s' differ from the configuration. Updating them:",
325
+ team.name,
326
+ )
327
+ for setting, diff in differences.items():
328
+ logging.info(
329
+ "Setting '%s': '%s' --> '%s'", setting, diff["dict2"], diff["dict1"]
330
+ )
331
+ # Execute team setting changes
332
+ if not dry:
333
+ try:
334
+ team.edit(name=team.name, **configured_team_configs) # type: ignore
335
+ except GithubException as exc:
336
+ logging.critical(
337
+ "Team '%s' settings could not be edited. Error: \n%s",
338
+ team.name,
339
+ self.pretty_print_dict(exc.data),
340
+ )
341
+ sys.exit(1)
342
+ else:
343
+ logging.info("Team '%s' settings are in sync, no changes", team.name)
344
+
125
345
  # --------------------------------------------------------------------------
126
- # Members
346
+ # Owners
127
347
  # --------------------------------------------------------------------------
128
- def _get_org_members(self):
348
+ def _get_current_org_owners(self) -> None:
129
349
  """Get all owners of the org"""
350
+ # Reset the user list, then build up new list
351
+ self.current_org_owners = []
130
352
  for member in self.org.get_members(role="admin"):
131
- self.org_owners.append(member)
353
+ self.current_org_owners.append(member)
354
+
355
+ def _check_configured_org_owners(self) -> bool:
356
+ """Check configured owners and make them lower-case for better
357
+ comparison. Returns True if owners are well configured."""
358
+ # Add configured owners if they are a list
359
+ if isinstance(self.configured_org_owners, list):
360
+ # Make all configured users lower-case
361
+ self.configured_org_owners = [user.lower() for user in self.configured_org_owners]
362
+ else:
363
+ logging.warning(
364
+ "The organisation owners are not configured as a proper list. Will not handle them."
365
+ )
366
+ self.configured_org_owners = []
367
+
368
+ if not self.configured_org_owners:
369
+ logging.warning(
370
+ "No owners for your GitHub organisation configured. Will not make any "
371
+ "change regarding the ownership, and continue with the current owners: %s",
372
+ ", ".join([user.login for user in self.current_org_owners]),
373
+ )
374
+ return False
375
+
376
+ return True
377
+
378
+ def _is_user_authenticated_user(self, user: NamedUser) -> bool:
379
+ """Check if a given NamedUser is the authenticated user"""
380
+ if user.login == self.gh.get_user().login:
381
+ return True
382
+ return False
383
+
384
+ def sync_org_owners(self, dry: bool = False, force: bool = False) -> None:
385
+ """Synchronise the organization owners"""
386
+ # Get current and configured owners
387
+ self._get_current_org_owners()
388
+
389
+ # Abort owner synchronisation if no owners are configured, or badly
390
+ if not self._check_configured_org_owners():
391
+ return
392
+
393
+ # Get differences between the current and configured owners
394
+ owners_remove, owners_ok, owners_add = self.compare_two_lists(
395
+ self.configured_org_owners, [user.login for user in self.current_org_owners]
396
+ )
397
+ # Compare configured (lower-cased) owners with lower-cased list of current owners
398
+ if not owners_remove and not owners_add:
399
+ logging.info("Organization owners are in sync, no changes")
400
+ return
401
+
402
+ logging.debug(
403
+ "Organization owners are not in sync. Config: '%s' vs. Current: '%s'",
404
+ self.configured_org_owners,
405
+ self.current_org_owners,
406
+ )
407
+ logging.debug(
408
+ "Will remove %s, will not change %s, will add %s", owners_remove, owners_ok, owners_add
409
+ )
410
+
411
+ # Add the missing owners
412
+ for user in owners_add:
413
+ if gh_user := self._resolve_gh_username(user, "<org owners>"):
414
+ logging.info("Adding user '%s' as organization owner", gh_user.login)
415
+ if not dry:
416
+ self.org.add_to_members(gh_user, "admin")
417
+
418
+ # Remove the surplus owners
419
+ for user in owners_remove:
420
+ if gh_user := self._resolve_gh_username(user, "<org owners>"):
421
+ logging.info(
422
+ "User '%s' is not configured as organization owners. "
423
+ "Will make them a normal member",
424
+ gh_user.login,
425
+ )
426
+ # Handle authenticated user being the same as the one you want to degrade
427
+ if self._is_user_authenticated_user(gh_user):
428
+ logging.warning(
429
+ "The user '%s' you want to remove from owners is the one you "
430
+ "authenticated with. This may disrupt all further operations. "
431
+ "Unless you run the program with --force, "
432
+ "this operation will not be executed.",
433
+ gh_user.login,
434
+ )
435
+ # Check if user forced this operation
436
+ if force:
437
+ logging.info(
438
+ "You called the program with --force, "
439
+ "so it will remove yourself from the owners"
440
+ )
441
+ else:
442
+ continue
443
+
444
+ # Execute the degradation of the owner
445
+ if not dry:
446
+ self.org.add_to_members(gh_user, "member")
447
+
448
+ # Update the current organisation owners
449
+ self._get_current_org_owners()
450
+
451
+ # --------------------------------------------------------------------------
452
+ # Members
453
+ # --------------------------------------------------------------------------
454
+ def _get_current_org_members(self):
455
+ """Get all ordinary members of the org"""
456
+ # Reset the user list, then build up new list
457
+ self.org_members = []
132
458
  for member in self.org.get_members(role="member"):
133
459
  self.org_members.append(member)
134
460
 
@@ -159,39 +485,24 @@ class GHorg: # pylint: disable=too-many-instance-attributes
159
485
 
160
486
  return current_users
161
487
 
162
- def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser | None:
163
- """Turn a username into a proper GitHub user object"""
164
- try:
165
- gh_user: NamedUser = self.gh.get_user(username) # type: ignore
166
- except UnknownObjectException:
167
- logging.error(
168
- "The user '%s' configured as member of team '%s' does not "
169
- "exist on GitHub. Spelling error or did they rename themselves?",
170
- username,
171
- teamname,
172
- )
173
- return None
174
-
175
- return gh_user
176
-
177
488
  def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-many-branches
178
489
  """Check the configured members of each team, add missing ones and delete unconfigured"""
179
490
  logging.debug("Starting to sync team members")
180
491
 
181
- # Gather all members and owners of the organisation
182
- self._get_org_members()
492
+ # Gather all ordinary members of the organisation
493
+ self._get_current_org_members()
183
494
 
184
495
  # Get open invitations
185
- open_invitations = [user.login for user in self.org.invitations()]
496
+ open_invitations = [user.login.lower() for user in self.org.invitations()]
186
497
 
187
498
  for team, team_attrs in self.current_teams.items():
188
499
  # Update current team members with dict[NamedUser, str (role)]
189
500
  team_attrs["members"] = self._get_current_team_members(team)
190
501
 
191
502
  # For the rest of the function however, we use just the login name
192
- # for each current user
503
+ # for each current user. All lower-case
193
504
  current_team_members = {
194
- user.login: role for user, role in team_attrs["members"].items()
505
+ user.login.lower(): role for user, role in team_attrs["members"].items()
195
506
  }
196
507
 
197
508
  # Handle the team not being configured locally
@@ -209,29 +520,30 @@ class GHorg: # pylint: disable=too-many-instance-attributes
209
520
  else:
210
521
  team_configuration = {}
211
522
 
212
- # Analog to team_attrs["members"], add members and maintainers to shared
213
- # dict with respective role, while maintainer role dominates
523
+ # Analog to team_attrs["members"], add members and maintainers to
524
+ # shared dict with respective role, while maintainer role dominates.
525
+ # All user names shall be lower-case to ease comparison
214
526
  configured_users: dict[str, str] = {}
215
527
  for config_role in ("member", "maintainer"):
216
528
  team_members = self._get_configured_team_members(
217
529
  team_configuration, team.name, config_role
218
530
  )
219
531
  for team_member in team_members:
220
- # Add user with role to dict
221
- configured_users.update({team_member: config_role})
532
+ # Add user with role to dict, in lower-case
533
+ configured_users.update({team_member.lower(): config_role})
222
534
 
223
535
  # Consider all GitHub organisation team maintainers if they are member of the team
224
536
  # This is because GitHub API returns them as maintainers even if they are just members
225
- for user in self.org_owners:
537
+ for user in self.current_org_owners:
226
538
  if user.login in configured_users:
227
539
  logging.debug(
228
540
  "Overriding role of organisation owner '%s' to maintainer", user.login
229
541
  )
230
- configured_users[user.login] = "maintainer"
542
+ configured_users[user.login.lower()] = "maintainer"
231
543
 
232
544
  # Only make edits to the team membership if the current state differs from config
233
545
  if configured_users == current_team_members:
234
- logging.info("Team '%s' configuration is in sync, no changes", team.name)
546
+ logging.info("Team '%s' memberships are in sync, no changes", team.name)
235
547
  continue
236
548
 
237
549
  # Loop through the configured users, add / update them if necessary
@@ -278,8 +590,11 @@ class GHorg: # pylint: disable=too-many-instance-attributes
278
590
  # Loop through all current members. Remove them if they are not configured
279
591
  for current_user in current_team_members:
280
592
  if current_user not in configured_users:
593
+ logging.debug("User '%s' not found within configured users", current_user)
281
594
  # Turn user to GitHub object, trying to find them
282
595
  if not (gh_user := self._resolve_gh_username(current_user, team.name)):
596
+ # If the user cannot be found for some reason, log an
597
+ # error and skip this loop
283
598
  continue
284
599
  if team.has_in_members(gh_user):
285
600
  logging.info(
@@ -300,7 +615,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
300
615
  def get_members_without_team(self) -> None:
301
616
  """Get all organisation members without any team membership"""
302
617
  # Combine org owners and org members
303
- all_org_members = set(self.org_members + self.org_owners)
618
+ all_org_members = set(self.org_members + self.current_org_owners)
304
619
 
305
620
  # Get all members of all teams
306
621
  all_team_members_lst = []
@@ -471,7 +786,13 @@ class GHorg: # pylint: disable=too-many-instance-attributes
471
786
  """Combine multiple lists into one while removing duplicates"""
472
787
  complete = []
473
788
  for single_list in lists:
474
- complete.extend(single_list)
789
+ if single_list is not None:
790
+ complete.extend(single_list)
791
+ else:
792
+ logging.debug(
793
+ "A list that we attempted to extend to another was None. "
794
+ "This probably happened because a 'member:' or 'maintainer:' key was left empty"
795
+ )
475
796
 
476
797
  return list(set(complete))
477
798
 
@@ -561,7 +882,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
561
882
  )
562
883
  )
563
884
  else:
564
- self.configured_repos_collaborators[repo][team_member] = perm
885
+ self.configured_repos_collaborators[repo][team_member.lower()] = perm
565
886
 
566
887
  def _convert_graphql_perm_to_rest(self, permission: str) -> str:
567
888
  """Convert a repo permission coming from the GraphQL API to the ones
@@ -627,12 +948,12 @@ class GHorg: # pylint: disable=too-many-instance-attributes
627
948
 
628
949
  # Extract relevant data
629
950
  for collaborator in collaborators:
630
- login = collaborator["node"]["login"]
951
+ login: str = collaborator["node"]["login"]
631
952
  # Skip entry if collaborator is org owner, which is "admin" anyway
632
- if login in [user.login for user in self.org_owners]:
953
+ if login.lower() in [user.login.lower() for user in self.current_org_owners]:
633
954
  continue
634
955
  permission = self._convert_graphql_perm_to_rest(collaborator["permission"])
635
- self.current_repos_collaborators[repo][login] = permission
956
+ self.current_repos_collaborators[repo][login.lower()] = permission
636
957
 
637
958
  def _get_current_repos_and_user_perms(self):
638
959
  """Get all repos, their current collaborators and their permissions"""
@@ -47,6 +47,12 @@ parser_sync.add_argument(
47
47
  action="store_true",
48
48
  help="Do not take any action in ignored repositories",
49
49
  )
50
+ parser_sync.add_argument(
51
+ "-f",
52
+ "--force",
53
+ action="store_true",
54
+ help="Execute potentially dangerous actions which you will be warned about without this flag",
55
+ )
50
56
 
51
57
  # Setup Team
52
58
  parser_create_team = subparsers.add_parser(
@@ -74,12 +80,6 @@ parser_create_team_file.add_argument(
74
80
  "--file",
75
81
  help="Path to the file in which the team shall be added",
76
82
  )
77
- # parser_create_team.add_argument(
78
- # "-a",
79
- # "--file-exists-action",
80
- # help="Define which action shall be taken when the requested output file already exists",
81
- # choices=["override", "extend", "skip"]
82
- # )
83
83
 
84
84
 
85
85
  def main():
@@ -94,6 +94,8 @@ def main():
94
94
  if args.command == "sync":
95
95
  if args.dry:
96
96
  logging.info("Dry-run mode activated, will not make any changes at GitHub")
97
+ if args.force:
98
+ logging.info("Force mode activated, will make potentially dangerous actions")
97
99
 
98
100
  org = GHorg()
99
101
 
@@ -104,6 +106,10 @@ def main():
104
106
  "No GitHub organisation name configured in organisation settings. Cannot continue"
105
107
  )
106
108
  sys.exit(1)
109
+ org.configured_org_owners = cfg_org.get("org_owners", [])
110
+ org.consolidate_team_config(
111
+ default_team_configs=cfg_org.get("defaults", {}).get("team", {})
112
+ )
107
113
 
108
114
  # Login to GitHub with token, get GitHub organisation
109
115
  org.login(cfg_org.get("org_name", ""), cfg_app.get("github_token", ""))
@@ -112,6 +118,10 @@ def main():
112
118
 
113
119
  # Create teams that aren't present at Github yet
114
120
  org.create_missing_teams(dry=args.dry)
121
+ # Configure general settings of teams
122
+ org.sync_current_teams_settings(dry=args.dry)
123
+ # Synchronise organisation owners
124
+ org.sync_org_owners(dry=args.dry, force=args.force)
115
125
  # Synchronise the team memberships
116
126
  org.sync_teams_members(dry=args.dry)
117
127
  # Report about organisation members that do not belong to any team
@@ -123,7 +133,7 @@ def main():
123
133
  org.sync_repo_collaborator_permissions(dry=args.dry)
124
134
 
125
135
  # Debug output
126
- logging.debug("Final dataclass:\n%s", org.df2json())
136
+ logging.debug("Final dataclass:\n%s", org.pretty_print_dataclass())
127
137
  org.ratelimit()
128
138
 
129
139
  # Setup Team command
@@ -4,7 +4,7 @@
4
4
 
5
5
  [tool.poetry]
6
6
  name = "github-org-manager"
7
- version = "0.4.0"
7
+ version = "0.5.0"
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
- bump2version = "^1.0.1"
42
+ bump-my-version = "^0.26.0"
43
43
 
44
44
  [build-system]
45
45
  requires = ["poetry-core"]
@@ -57,6 +57,15 @@ line-length = 100
57
57
  [tool.mypy]
58
58
  files = ["gh_org_mgr/*.py"]
59
59
 
60
- # Pylint
61
- [tool.pylint.'MESSAGES CONTROL']
62
- disable = "fixme"
60
+ # Bump-My-Version
61
+ [tool.bumpversion]
62
+ commit = true
63
+ tag = true
64
+ allow_dirty = false
65
+ tag_name = "v{new_version}"
66
+
67
+ [[tool.bumpversion.files]]
68
+ filename = "pyproject.toml"
69
+ regex = true
70
+ search = "^version = \"{current_version}\""
71
+ replace = "version = \"{new_version}\""