github-org-manager 0.4.1__tar.gz → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: github-org-manager
3
- Version: 0.4.1
3
+ Version: 0.5.1
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,23 +18,35 @@ 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)
33
+ newly_added_users: list[NamedUser] = field(default_factory=list)
31
34
  current_repos_teams: dict[Repository, dict[Team, str]] = field(default_factory=dict)
32
35
  current_repos_collaborators: dict[Repository, dict[str, str]] = field(default_factory=dict)
33
36
  configured_repos_collaborators: dict[str, dict[str, str]] = field(default_factory=dict)
34
37
  archived_repos: list[Repository] = field(default_factory=list)
35
38
  unconfigured_team_repo_permissions: dict[str, dict[str, str]] = field(default_factory=dict)
36
39
 
40
+ # Re-usable Constants
41
+ TEAM_CONFIG_FIELDS: dict[str, dict[str, str | None]] = field( # pylint: disable=invalid-name
42
+ default_factory=lambda: {
43
+ "parent": {"fallback_value": None},
44
+ "privacy": {"fallback_value": "<keep-current>"},
45
+ "description": {"fallback_value": "<keep-current>"},
46
+ "notification_setting": {"fallback_value": "<keep-current>"},
47
+ }
48
+ )
49
+
37
50
  # --------------------------------------------------------------------------
38
51
  # Helper functions
39
52
  # --------------------------------------------------------------------------
@@ -43,11 +56,13 @@ class GHorg: # pylint: disable=too-many-instance-attributes
43
56
  # supported, or multiple spaces etc.
44
57
  return team.replace(" ", "-")
45
58
 
46
- def login(self, orgname: str, token: str):
59
+ def login(self, orgname: str, token: str) -> None:
47
60
  """Login to GH, gather org data"""
48
61
  self.gh_token = get_github_token(token)
49
62
  self.gh = Github(self.gh_token)
63
+ logging.debug("Logged in as %s", self.gh.get_user().login)
50
64
  self.org = self.gh.get_organization(orgname)
65
+ logging.debug("Gathered data from organization '%s' (%s)", self.org.login, self.org.name)
51
66
 
52
67
  def ratelimit(self):
53
68
  """Get current rate limit"""
@@ -56,9 +71,8 @@ class GHorg: # pylint: disable=too-many-instance-attributes
56
71
  "Current rate limit: %s/%s (reset: %s)", core.remaining, core.limit, core.reset
57
72
  )
58
73
 
59
- def df2json(self) -> str:
60
- """Convert the dataclass to a JSON string"""
61
- d = asdict(self)
74
+ def pretty_print_dict(self, dictionary: dict) -> str:
75
+ """Convert a dict to a pretty-printed output"""
62
76
 
63
77
  # Censor sensible fields
64
78
  def censor_half_string(string: str) -> str:
@@ -69,7 +83,8 @@ class GHorg: # pylint: disable=too-many-instance-attributes
69
83
 
70
84
  sensible_keys = ["gh_token"]
71
85
  for key in sensible_keys:
72
- d[key] = censor_half_string(d.get(key, ""))
86
+ if value := dictionary.get(key, ""):
87
+ dictionary[key] = censor_half_string(value)
73
88
 
74
89
  # Print dict nicely
75
90
  def pretty(d, indent=0):
@@ -83,7 +98,211 @@ class GHorg: # pylint: disable=too-many-instance-attributes
83
98
 
84
99
  return string
85
100
 
86
- return pretty(d)
101
+ return pretty(dictionary)
102
+
103
+ def pretty_print_dataclass(self) -> str:
104
+ """Convert this dataclass to a pretty-printed output"""
105
+ return self.pretty_print_dict(asdict(self))
106
+
107
+ def compare_two_lists(self, list1: list[str], list2: list[str]):
108
+ """
109
+ Compares two lists of strings and returns a tuple containing elements
110
+ missing in each list and common elements.
111
+
112
+ Args:
113
+ list1 (list of str): The first list of strings.
114
+ list2 (list of str): The second list of strings.
115
+
116
+ Returns:
117
+ tuple: A tuple containing three lists:
118
+ 1. The first list contains elements in `list2` that are missing in `list1`.
119
+ 2. The second list contains elements that are present in both `list1` and `list2`.
120
+ 3. The third list contains elements in `list1` that are missing in `list2`.
121
+
122
+ Example:
123
+ >>> list1 = ["apple", "banana", "cherry"]
124
+ >>> list2 = ["banana", "cherry", "date", "fig"]
125
+ >>> compare_lists(list1, list2)
126
+ (['date', 'fig'], ['banana', 'cherry'], ['apple'])
127
+ """
128
+ # Convert lists to sets for easier comparison
129
+ set1, set2 = set(list1), set(list2)
130
+
131
+ # Elements in list2 that are missing in list1
132
+ missing_in_list1 = list(set2 - set1)
133
+
134
+ # Elements present in both lists
135
+ common_elements = list(set1 & set2)
136
+
137
+ # Elements in list1 that are missing in list2
138
+ missing_in_list2 = list(set1 - set2)
139
+
140
+ # Return the result as a tuple
141
+ return (missing_in_list1, common_elements, missing_in_list2)
142
+
143
+ def compare_two_dicts(self, dict1: dict, dict2: dict) -> dict[str, dict[str, str | int | None]]:
144
+ """Compares two dictionaries. Assume that the keys are the same. Output
145
+ a dict with keys that have differing values"""
146
+ # Create an empty dictionary to store differences
147
+ differences = {}
148
+
149
+ # Iterate through the keys (assuming both dictionaries have the same keys)
150
+ for key in dict1:
151
+ # Compare the values for each key
152
+ if dict1[key] != dict2[key]:
153
+ differences[key] = {"dict1": dict1[key], "dict2": dict2[key]}
154
+
155
+ return differences
156
+
157
+ def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser | None:
158
+ """Turn a username into a proper GitHub user object"""
159
+ try:
160
+ gh_user: NamedUser = self.gh.get_user(username) # type: ignore
161
+ except UnknownObjectException:
162
+ logging.error(
163
+ "The user '%s' configured as member of team '%s' does not "
164
+ "exist on GitHub. Spelling error or did they rename themselves?",
165
+ username,
166
+ teamname,
167
+ )
168
+ return None
169
+
170
+ return gh_user
171
+
172
+ # --------------------------------------------------------------------------
173
+ # Configuration
174
+ # --------------------------------------------------------------------------
175
+ def consolidate_team_config(self, default_team_configs: dict[str, str]) -> None:
176
+ """Complete teams configuration with default teams configs"""
177
+ for team_name, team_config in self.configured_teams.items():
178
+ # Handle none team configs
179
+ if team_config is None:
180
+ team_config = {}
181
+
182
+ # Iterate through configurable team settings. Take team config, fall
183
+ # back to default org-wide value. If no config can be found, either
184
+ # add a fallback value or do not add this setting altogether.
185
+ for cfg_item, cfg_value in self.TEAM_CONFIG_FIELDS.items():
186
+ # Case 1: setting in team config
187
+ if tcfg := team_config.get(cfg_item):
188
+ team_config[cfg_item] = tcfg
189
+ # Case 2: setting in default org team config
190
+ elif dcfg := default_team_configs.get(cfg_item):
191
+ team_config[cfg_item] = dcfg
192
+ # Case 3: setting defined nowhere, take hardcoded default
193
+ else:
194
+ # Look which fallback value/action shall be taken
195
+ fallback_value = cfg_value["fallback_value"]
196
+ if fallback_value != "<keep-current>":
197
+ team_config[cfg_item] = fallback_value
198
+
199
+ logging.debug("Configuration for team '%s' consolidated to: %s", team_name, team_config)
200
+
201
+ # --------------------------------------------------------------------------
202
+ # Owners
203
+ # --------------------------------------------------------------------------
204
+ def _get_current_org_owners(self) -> None:
205
+ """Get all owners of the org"""
206
+ # Reset the user list, then build up new list
207
+ self.current_org_owners = []
208
+ for member in self.org.get_members(role="admin"):
209
+ self.current_org_owners.append(member)
210
+
211
+ def _check_configured_org_owners(self) -> bool:
212
+ """Check configured owners and make them lower-case for better
213
+ comparison. Returns True if owners are well configured."""
214
+ # Add configured owners if they are a list
215
+ if isinstance(self.configured_org_owners, list):
216
+ # Make all configured users lower-case
217
+ self.configured_org_owners = [user.lower() for user in self.configured_org_owners]
218
+ else:
219
+ logging.warning(
220
+ "The organisation owners are not configured as a proper list. Will not handle them."
221
+ )
222
+ self.configured_org_owners = []
223
+
224
+ if not self.configured_org_owners:
225
+ logging.warning(
226
+ "No owners for your GitHub organisation configured. Will not make any "
227
+ "change regarding the ownership, and continue with the current owners: %s",
228
+ ", ".join([user.login for user in self.current_org_owners]),
229
+ )
230
+ return False
231
+
232
+ return True
233
+
234
+ def _is_user_authenticated_user(self, user: NamedUser) -> bool:
235
+ """Check if a given NamedUser is the authenticated user"""
236
+ if user.login == self.gh.get_user().login:
237
+ return True
238
+ return False
239
+
240
+ def sync_org_owners(self, dry: bool = False, force: bool = False) -> None:
241
+ """Synchronise the organization owners"""
242
+ # Get current and configured owners
243
+ self._get_current_org_owners()
244
+
245
+ # Abort owner synchronisation if no owners are configured, or badly
246
+ if not self._check_configured_org_owners():
247
+ return
248
+
249
+ # Get differences between the current and configured owners
250
+ owners_remove, owners_ok, owners_add = self.compare_two_lists(
251
+ self.configured_org_owners, [user.login for user in self.current_org_owners]
252
+ )
253
+ # Compare configured (lower-cased) owners with lower-cased list of current owners
254
+ if not owners_remove and not owners_add:
255
+ logging.info("Organization owners are in sync, no changes")
256
+ return
257
+
258
+ logging.debug(
259
+ "Organization owners are not in sync. Config: '%s' vs. Current: '%s'",
260
+ self.configured_org_owners,
261
+ self.current_org_owners,
262
+ )
263
+ logging.debug(
264
+ "Will remove %s, will not change %s, will add %s", owners_remove, owners_ok, owners_add
265
+ )
266
+
267
+ # Add the missing owners
268
+ for user in owners_add:
269
+ if gh_user := self._resolve_gh_username(user, "<org owners>"):
270
+ logging.info("Adding user '%s' as organization owner", gh_user.login)
271
+ if not dry:
272
+ self.org.add_to_members(gh_user, "admin")
273
+
274
+ # Remove the surplus owners
275
+ for user in owners_remove:
276
+ if gh_user := self._resolve_gh_username(user, "<org owners>"):
277
+ logging.info(
278
+ "User '%s' is not configured as organization owners. "
279
+ "Will make them a normal member",
280
+ gh_user.login,
281
+ )
282
+ # Handle authenticated user being the same as the one you want to degrade
283
+ if self._is_user_authenticated_user(gh_user):
284
+ logging.warning(
285
+ "The user '%s' you want to remove from owners is the one you "
286
+ "authenticated with. This may disrupt all further operations. "
287
+ "Unless you run the program with --force, "
288
+ "this operation will not be executed.",
289
+ gh_user.login,
290
+ )
291
+ # Check if user forced this operation
292
+ if force:
293
+ logging.info(
294
+ "You called the program with --force, "
295
+ "so it will remove yourself from the owners"
296
+ )
297
+ else:
298
+ continue
299
+
300
+ # Execute the degradation of the owner
301
+ if not dry:
302
+ self.org.add_to_members(gh_user, "member")
303
+
304
+ # Update the current organisation owners
305
+ self._get_current_org_owners()
87
306
 
88
307
  # --------------------------------------------------------------------------
89
308
  # Teams
@@ -108,13 +327,25 @@ class GHorg: # pylint: disable=too-many-instance-attributes
108
327
  parent_id = self.org.get_team_by_slug(self._sluggify_teamname(parent)).id
109
328
 
110
329
  logging.info("Creating team '%s' with parent ID '%s'", team, parent_id)
330
+ # NOTE: We do not specify any team settings (description etc)
331
+ # here, this will happen later
111
332
  if not dry:
112
- self.org.create_team(team, parent_team_id=parent_id)
333
+ self.org.create_team(
334
+ team,
335
+ parent_team_id=parent_id,
336
+ # Hardcode privacy as "secret" is not possible in child teams
337
+ privacy="closed",
338
+ )
113
339
 
114
340
  else:
115
341
  logging.info("Creating team '%s' without parent", team)
116
342
  if not dry:
117
- self.org.create_team(team, privacy="closed")
343
+ self.org.create_team(
344
+ team,
345
+ # Hardcode privacy as "secret" is not possible in
346
+ # parent teams, which is the API's default
347
+ privacy="closed",
348
+ )
118
349
 
119
350
  else:
120
351
  logging.debug("Team '%s' already exists", team)
@@ -122,13 +353,109 @@ class GHorg: # pylint: disable=too-many-instance-attributes
122
353
  # Re-scan current teams as new ones may have been created
123
354
  self._get_current_teams()
124
355
 
356
+ def _prepare_team_config_for_sync(
357
+ self, team_config: dict[str, str | int | Team | None]
358
+ ) -> dict[str, str | int | None]:
359
+ """Turn parent values into IDs, and sort the config dictionary for better comparison"""
360
+ if parent := team_config["parent"]:
361
+ # team coming from API request (current)
362
+ if isinstance(parent, Team):
363
+ team_config["parent_team_id"] = parent.id
364
+ # team coming from config, and valid string
365
+ elif isinstance(parent, str) and parent:
366
+ team_config["parent_team_id"] = self.org.get_team_by_slug(
367
+ self._sluggify_teamname(parent)
368
+ ).id
369
+ # empty from string, so probably default value
370
+ elif isinstance(parent, str) and not parent:
371
+ team_config["parent_team_id"] = None
372
+ else:
373
+ team_config["parent_team_id"] = None
374
+
375
+ # Remove parent key
376
+ team_config.pop("parent", None)
377
+
378
+ # Sort dict and return
379
+ # Ensure the dictionary has only comparable types before sorting
380
+ filtered_team_config = {
381
+ k: v for k, v in team_config.items() if isinstance(v, (str, int, type(None)))
382
+ }
383
+ return dict(sorted(filtered_team_config.items()))
384
+
385
+ def sync_current_teams_settings(self, dry: bool = False) -> None:
386
+ """Sync settings for the existing teams: description, visibility etc."""
387
+ for team in self.current_teams:
388
+ # Skip unconfigured teams
389
+ if team.name not in self.configured_teams:
390
+ logging.debug(
391
+ "Will not sync settings of team '%s' as not configured locally", team.name
392
+ )
393
+ continue
394
+
395
+ # Use dictionary comprehensions to build the dictionaries with the
396
+ # relevant team settings for comparison
397
+ configured_team_configs = {
398
+ key: self.configured_teams[team.name].get(key) # type: ignore
399
+ for key in self.TEAM_CONFIG_FIELDS
400
+ # Only add keys that are actually in the configuration. Deals
401
+ # with settings that should be changed, as they are neither
402
+ # defined in the default or team config, and marked as
403
+ # <keep-current>
404
+ if key in self.configured_teams[team.name] # type: ignore
405
+ }
406
+ current_team_configs = {
407
+ key: getattr(team, key)
408
+ for key in self.TEAM_CONFIG_FIELDS
409
+ # Only compare current team settings with keys that are defined
410
+ # as the configured team settings. Taking out settings that
411
+ # shall not be changed
412
+ if key in self.configured_teams[team.name] # type: ignore
413
+ }
414
+
415
+ # Resolve parent team id from parent Team object or team string, and sort
416
+ configured_team_configs = self._prepare_team_config_for_sync(configured_team_configs)
417
+ current_team_configs = self._prepare_team_config_for_sync(current_team_configs)
418
+
419
+ # Log the comparison result
420
+ logging.debug(
421
+ "Comparing team '%s' settings: Configured '%s' vs. Current '%s'",
422
+ team.name,
423
+ configured_team_configs,
424
+ current_team_configs,
425
+ )
426
+
427
+ # Compare settings and update if necessary
428
+ if differences := self.compare_two_dicts(configured_team_configs, current_team_configs):
429
+ # Log differences
430
+ logging.info(
431
+ "Team settings for '%s' differ from the configuration. Updating them:",
432
+ team.name,
433
+ )
434
+ for setting, diff in differences.items():
435
+ logging.info(
436
+ "Setting '%s': '%s' --> '%s'", setting, diff["dict2"], diff["dict1"]
437
+ )
438
+ # Execute team setting changes
439
+ if not dry:
440
+ try:
441
+ team.edit(name=team.name, **configured_team_configs) # type: ignore
442
+ except GithubException as exc:
443
+ logging.critical(
444
+ "Team '%s' settings could not be edited. Error: \n%s",
445
+ team.name,
446
+ self.pretty_print_dict(exc.data),
447
+ )
448
+ sys.exit(1)
449
+ else:
450
+ logging.info("Team '%s' settings are in sync, no changes", team.name)
451
+
125
452
  # --------------------------------------------------------------------------
126
453
  # Members
127
454
  # --------------------------------------------------------------------------
128
- def _get_org_members(self):
129
- """Get all owners of the org"""
130
- for member in self.org.get_members(role="admin"):
131
- self.org_owners.append(member)
455
+ def _get_current_org_members(self):
456
+ """Get all ordinary members of the org"""
457
+ # Reset the user list, then build up new list
458
+ self.org_members = []
132
459
  for member in self.org.get_members(role="member"):
133
460
  self.org_members.append(member)
134
461
 
@@ -159,27 +486,19 @@ class GHorg: # pylint: disable=too-many-instance-attributes
159
486
 
160
487
  return current_users
161
488
 
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
489
+ def _add_or_update_user_in_team(self, team: Team, user: NamedUser, role: str):
490
+ """Add or update membership of a user in a team"""
491
+ team.add_membership(member=user, role=role)
492
+ # Document that the user has just been added to a team. Relevant when we
493
+ # will later find users without team membership
494
+ self.newly_added_users.append(user)
176
495
 
177
496
  def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-many-branches
178
497
  """Check the configured members of each team, add missing ones and delete unconfigured"""
179
498
  logging.debug("Starting to sync team members")
180
499
 
181
- # Gather all members and owners of the organisation
182
- self._get_org_members()
500
+ # Gather all ordinary members of the organisation
501
+ self._get_current_org_members()
183
502
 
184
503
  # Get open invitations
185
504
  open_invitations = [user.login.lower() for user in self.org.invitations()]
@@ -223,7 +542,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
223
542
 
224
543
  # Consider all GitHub organisation team maintainers if they are member of the team
225
544
  # This is because GitHub API returns them as maintainers even if they are just members
226
- for user in self.org_owners:
545
+ for user in self.current_org_owners:
227
546
  if user.login in configured_users:
228
547
  logging.debug(
229
548
  "Overriding role of organisation owner '%s' to maintainer", user.login
@@ -232,7 +551,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
232
551
 
233
552
  # Only make edits to the team membership if the current state differs from config
234
553
  if configured_users == current_team_members:
235
- logging.info("Team '%s' configuration is in sync, no changes", team.name)
554
+ logging.info("Team '%s' memberships are in sync, no changes", team.name)
236
555
  continue
237
556
 
238
557
  # Loop through the configured users, add / update them if necessary
@@ -260,7 +579,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
260
579
  config_role,
261
580
  )
262
581
  if not dry:
263
- team.add_membership(member=gh_user, role=config_role)
582
+ self._add_or_update_user_in_team(team=team, user=gh_user, role=config_role)
264
583
 
265
584
  # Update roles if they differ from old role
266
585
  elif config_role != current_team_members.get(config_user, ""):
@@ -274,7 +593,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
274
593
  config_role,
275
594
  )
276
595
  if not dry:
277
- team.add_membership(member=gh_user, role=config_role)
596
+ self._add_or_update_user_in_team(team=team, user=gh_user, role=config_role)
278
597
 
279
598
  # Loop through all current members. Remove them if they are not configured
280
599
  for current_user in current_team_members:
@@ -304,14 +623,15 @@ class GHorg: # pylint: disable=too-many-instance-attributes
304
623
  def get_members_without_team(self) -> None:
305
624
  """Get all organisation members without any team membership"""
306
625
  # Combine org owners and org members
307
- all_org_members = set(self.org_members + self.org_owners)
626
+ all_org_members = set(self.org_members + self.current_org_owners)
308
627
 
309
628
  # Get all members of all teams
310
- all_team_members_lst = []
629
+ all_team_members_lst: list[NamedUser] = []
311
630
  for _, team_attrs in self.current_teams.items():
312
631
  for member in team_attrs.get("members", {}):
313
632
  all_team_members_lst.append(member)
314
- all_team_members = set(all_team_members_lst)
633
+ # Also add users that have just been added to a team, and unify them
634
+ all_team_members: set[NamedUser] = set(all_team_members_lst + self.newly_added_users)
315
635
 
316
636
  # Find members that are in org_members but not team_members
317
637
  members_without_team = all_org_members.difference(all_team_members)
@@ -556,6 +876,8 @@ class GHorg: # pylint: disable=too-many-instance-attributes
556
876
 
557
877
  # Add team member to repo with their repo permissions
558
878
  for team_member in team_members:
879
+ # Lower-case team member
880
+ team_member = team_member.lower()
559
881
  # Check if permissions already exist
560
882
  if self.configured_repos_collaborators[repo].get(team_member, {}):
561
883
  logging.debug(
@@ -571,7 +893,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
571
893
  )
572
894
  )
573
895
  else:
574
- self.configured_repos_collaborators[repo][team_member.lower()] = perm
896
+ self.configured_repos_collaborators[repo][team_member] = perm
575
897
 
576
898
  def _convert_graphql_perm_to_rest(self, permission: str) -> str:
577
899
  """Convert a repo permission coming from the GraphQL API to the ones
@@ -639,7 +961,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
639
961
  for collaborator in collaborators:
640
962
  login: str = collaborator["node"]["login"]
641
963
  # Skip entry if collaborator is org owner, which is "admin" anyway
642
- if login.lower() in [user.login.lower() for user in self.org_owners]:
964
+ if login.lower() in [user.login.lower() for user in self.current_org_owners]:
643
965
  continue
644
966
  permission = self._convert_graphql_perm_to_rest(collaborator["permission"])
645
967
  self.current_repos_collaborators[repo][login.lower()] = permission
@@ -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,14 +106,22 @@ 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", ""))
110
116
  # Get current rate limit
111
117
  org.ratelimit()
112
118
 
119
+ # Synchronise organisation owners
120
+ org.sync_org_owners(dry=args.dry, force=args.force)
113
121
  # Create teams that aren't present at Github yet
114
122
  org.create_missing_teams(dry=args.dry)
123
+ # Configure general settings of teams
124
+ org.sync_current_teams_settings(dry=args.dry)
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.1"
7
+ version = "0.5.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"
@@ -57,10 +57,6 @@ 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"
63
-
64
60
  # Bump-My-Version
65
61
  [tool.bumpversion]
66
62
  commit = true