github-org-manager 0.4.1__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.
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/PKG-INFO +11 -3
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/README.md +10 -2
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/gh_org_mgr/_config.py +3 -2
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/gh_org_mgr/_gh_org.py +346 -35
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/gh_org_mgr/manage.py +17 -7
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/pyproject.toml +1 -5
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/LICENSE.txt +0 -0
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/LICENSES/Apache-2.0.txt +0 -0
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/LICENSES/CC-BY-4.0.txt +0 -0
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/LICENSES/CC0-1.0.txt +0 -0
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/LICENSES/MIT.txt +0 -0
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/gh_org_mgr/__init__.py +0 -0
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/gh_org_mgr/_gh_api.py +0 -0
- {github_org_manager-0.4.1 → github_org_manager-0.5.0}/gh_org_mgr/_setup_team.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: github-org-manager
|
|
3
|
-
Version: 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
|
|
49
|
-
*
|
|
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
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
|
60
|
-
"""Convert
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
#
|
|
346
|
+
# Owners
|
|
127
347
|
# --------------------------------------------------------------------------
|
|
128
|
-
def
|
|
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.
|
|
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,27 +485,12 @@ 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
|
|
182
|
-
self.
|
|
492
|
+
# Gather all ordinary members of the organisation
|
|
493
|
+
self._get_current_org_members()
|
|
183
494
|
|
|
184
495
|
# Get open invitations
|
|
185
496
|
open_invitations = [user.login.lower() for user in self.org.invitations()]
|
|
@@ -223,7 +534,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
223
534
|
|
|
224
535
|
# Consider all GitHub organisation team maintainers if they are member of the team
|
|
225
536
|
# This is because GitHub API returns them as maintainers even if they are just members
|
|
226
|
-
for user in self.
|
|
537
|
+
for user in self.current_org_owners:
|
|
227
538
|
if user.login in configured_users:
|
|
228
539
|
logging.debug(
|
|
229
540
|
"Overriding role of organisation owner '%s' to maintainer", user.login
|
|
@@ -232,7 +543,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
232
543
|
|
|
233
544
|
# Only make edits to the team membership if the current state differs from config
|
|
234
545
|
if configured_users == current_team_members:
|
|
235
|
-
logging.info("Team '%s'
|
|
546
|
+
logging.info("Team '%s' memberships are in sync, no changes", team.name)
|
|
236
547
|
continue
|
|
237
548
|
|
|
238
549
|
# Loop through the configured users, add / update them if necessary
|
|
@@ -304,7 +615,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
304
615
|
def get_members_without_team(self) -> None:
|
|
305
616
|
"""Get all organisation members without any team membership"""
|
|
306
617
|
# Combine org owners and org members
|
|
307
|
-
all_org_members = set(self.org_members + self.
|
|
618
|
+
all_org_members = set(self.org_members + self.current_org_owners)
|
|
308
619
|
|
|
309
620
|
# Get all members of all teams
|
|
310
621
|
all_team_members_lst = []
|
|
@@ -639,7 +950,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
639
950
|
for collaborator in collaborators:
|
|
640
951
|
login: str = collaborator["node"]["login"]
|
|
641
952
|
# Skip entry if collaborator is org owner, which is "admin" anyway
|
|
642
|
-
if login.lower() in [user.login.lower() for user in self.
|
|
953
|
+
if login.lower() in [user.login.lower() for user in self.current_org_owners]:
|
|
643
954
|
continue
|
|
644
955
|
permission = self._convert_graphql_perm_to_rest(collaborator["permission"])
|
|
645
956
|
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,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.
|
|
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.
|
|
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"
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|