github-org-manager 0.1.0__py3-none-any.whl
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.
- gh_org_mgr/__init__.py +26 -0
- gh_org_mgr/_config.py +117 -0
- gh_org_mgr/_gh_api.py +27 -0
- gh_org_mgr/_gh_org.py +405 -0
- gh_org_mgr/manage.py +66 -0
- github_org_manager-0.1.0.dist-info/LICENSE.txt +202 -0
- github_org_manager-0.1.0.dist-info/LICENSES/Apache-2.0.txt +202 -0
- github_org_manager-0.1.0.dist-info/LICENSES/CC-BY-4.0.txt +156 -0
- github_org_manager-0.1.0.dist-info/LICENSES/CC0-1.0.txt +121 -0
- github_org_manager-0.1.0.dist-info/LICENSES/MIT.txt +9 -0
- github_org_manager-0.1.0.dist-info/METADATA +79 -0
- github_org_manager-0.1.0.dist-info/RECORD +14 -0
- github_org_manager-0.1.0.dist-info/WHEEL +4 -0
- github_org_manager-0.1.0.dist-info/entry_points.txt +3 -0
gh_org_mgr/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 DB Systel GmbH
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Global init file"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
|
|
10
|
+
__version__ = version("github-org-manager")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def configure_logger(debug: bool = False) -> logging.Logger:
|
|
14
|
+
"""Set logging options"""
|
|
15
|
+
log = logging.getLogger()
|
|
16
|
+
logging.basicConfig(
|
|
17
|
+
encoding="utf-8",
|
|
18
|
+
format="[%(asctime)s] %(levelname)s: %(message)s",
|
|
19
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
20
|
+
)
|
|
21
|
+
if debug:
|
|
22
|
+
log.setLevel(logging.DEBUG)
|
|
23
|
+
else:
|
|
24
|
+
log.setLevel(logging.INFO)
|
|
25
|
+
|
|
26
|
+
return log
|
gh_org_mgr/_config.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 DB Systel GmbH
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Handling the private and organisation configuration"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
# Global files with settings for the app and org, e.g. GitHub token and org name
|
|
16
|
+
ORG_CONFIG_FILE = r"org\.ya?ml"
|
|
17
|
+
APP_CONFIG_FILE = r"app\.ya?ml"
|
|
18
|
+
TEAM_CONFIG_DIR = "teams"
|
|
19
|
+
TEAM_CONFIG_FILES = r".+\.ya?ml"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_matching_files(directory: str, pattern: str, only_one: bool = False) -> list[str]:
|
|
23
|
+
"""
|
|
24
|
+
Get all files in a directory matching a regex pattern.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
- directory: Path to the directory
|
|
28
|
+
- pattern: Regular expression pattern to match filenames
|
|
29
|
+
- only_one: Whether only the first match shall be returned.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
- List of filenames matching the pattern
|
|
33
|
+
"""
|
|
34
|
+
matching_files: list[str] = []
|
|
35
|
+
|
|
36
|
+
# Validate directory existence
|
|
37
|
+
if not os.path.isdir(directory):
|
|
38
|
+
logging.error("'%s' is not a valid directory", directory)
|
|
39
|
+
|
|
40
|
+
else:
|
|
41
|
+
# Compile the regex pattern
|
|
42
|
+
regex_pattern = re.compile(pattern + "$")
|
|
43
|
+
|
|
44
|
+
# Traverse the directory and find matching files
|
|
45
|
+
for file_name in os.listdir(directory):
|
|
46
|
+
if regex_pattern.match(file_name):
|
|
47
|
+
file_path = os.path.join(directory, file_name)
|
|
48
|
+
if os.path.isfile(file_path):
|
|
49
|
+
matching_files.append(file_path)
|
|
50
|
+
else:
|
|
51
|
+
logging.warning(
|
|
52
|
+
"'%s' looks like a file we searched for, but it's not. "
|
|
53
|
+
"Will not consider its contents",
|
|
54
|
+
file_path,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if only_one and len(matching_files) > 1:
|
|
58
|
+
matching_files = [matching_files[0]]
|
|
59
|
+
logging.warning(
|
|
60
|
+
"More than one configuration file for the pattern '%s' found. "
|
|
61
|
+
"Reducing to the first match as wished: %s",
|
|
62
|
+
pattern,
|
|
63
|
+
matching_files[0],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if not matching_files:
|
|
67
|
+
logging.error(
|
|
68
|
+
"No configuration file found for '%s' in '%s'. The program might not work as expected!",
|
|
69
|
+
pattern,
|
|
70
|
+
directory
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return matching_files
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _read_config_file(file: str) -> dict:
|
|
77
|
+
"""Return dict of a YAML file"""
|
|
78
|
+
logging.debug("Attempting to parse YAML file %s", file)
|
|
79
|
+
with open(file, encoding="UTF-8") as yamlfile:
|
|
80
|
+
config: dict = yaml.safe_load(yamlfile)
|
|
81
|
+
|
|
82
|
+
if not config:
|
|
83
|
+
config = {}
|
|
84
|
+
|
|
85
|
+
return config
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_config_files(path: str) -> tuple[dict, dict, dict]:
|
|
89
|
+
"""Parse all relevant files in the configuration directory"""
|
|
90
|
+
# Find the relevant config files for app, org, and teams
|
|
91
|
+
cfg_app_files = _find_matching_files(path, APP_CONFIG_FILE, only_one=True)
|
|
92
|
+
cfg_org_files = _find_matching_files(path, ORG_CONFIG_FILE, only_one=True)
|
|
93
|
+
cfg_teams_files = _find_matching_files(os.path.join(path, TEAM_CONFIG_DIR), TEAM_CONFIG_FILES)
|
|
94
|
+
|
|
95
|
+
# Read and parse config files for app and org
|
|
96
|
+
cfg_app = _read_config_file(cfg_app_files[0])
|
|
97
|
+
cfg_org = _read_config_file(cfg_org_files[0])
|
|
98
|
+
|
|
99
|
+
# For the teams config files, we parse and combine them as there may be multiple
|
|
100
|
+
cfg_teams: dict[str, Any] = {}
|
|
101
|
+
# For this, merge the resulting dicts of the previously read files, and the current file
|
|
102
|
+
# Compare their keys (team names). They must not be defined multiple times!
|
|
103
|
+
for cfg_team_file in cfg_teams_files:
|
|
104
|
+
cfg = _read_config_file(cfg_team_file)
|
|
105
|
+
if overlap := set(cfg_teams.keys()) & set(cfg.keys()):
|
|
106
|
+
logging.critical(
|
|
107
|
+
"The config file '%s' contains keys that are also defined in "
|
|
108
|
+
"other config files. This is disallowed. Affected keys: %s",
|
|
109
|
+
cfg_team_file,
|
|
110
|
+
", ".join(overlap),
|
|
111
|
+
)
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
else:
|
|
114
|
+
# Merge the dicts into one
|
|
115
|
+
cfg_teams = cfg_teams | cfg
|
|
116
|
+
|
|
117
|
+
return cfg_org, cfg_app, cfg_teams
|
gh_org_mgr/_gh_api.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 DB Systel GmbH
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Functions for interacting with the GitHub API
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_github_token(token: str = "") -> str:
|
|
15
|
+
"""Get the GitHub token from config or environment, while environment overrides"""
|
|
16
|
+
if "GITHUB_TOKEN" in os.environ and os.environ["GITHUB_TOKEN"]:
|
|
17
|
+
logging.debug("GitHub Token taken from environment variable GITHUB_TOKEN")
|
|
18
|
+
token = os.environ["GITHUB_TOKEN"]
|
|
19
|
+
elif token:
|
|
20
|
+
logging.debug("GitHub Token taken from app configuration file")
|
|
21
|
+
else:
|
|
22
|
+
sys.exit(
|
|
23
|
+
"No token set for GitHub authentication! Set it in config/app_config.yaml "
|
|
24
|
+
"or via environment variable GITHUB_TOKEN"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return token
|
gh_org_mgr/_gh_org.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 DB Systel GmbH
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Class for the GitHub organization which contains most of the logic"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
|
|
10
|
+
from github import (
|
|
11
|
+
Github,
|
|
12
|
+
NamedUser,
|
|
13
|
+
Organization,
|
|
14
|
+
Repository,
|
|
15
|
+
Team,
|
|
16
|
+
UnknownObjectException,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from ._gh_api import get_github_token
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class GHorg: # pylint: disable=too-many-instance-attributes
|
|
24
|
+
"""Dataclass holding GH organization data and functions"""
|
|
25
|
+
|
|
26
|
+
gh: Github = None # type: ignore
|
|
27
|
+
org: Organization.Organization = None # type: ignore
|
|
28
|
+
org_owners: list[NamedUser.NamedUser] = field(default_factory=list)
|
|
29
|
+
org_members: list[NamedUser.NamedUser] = field(default_factory=list)
|
|
30
|
+
current_teams: dict[Team.Team, dict] = field(default_factory=dict)
|
|
31
|
+
configured_teams: dict[str, dict | None] = field(default_factory=dict)
|
|
32
|
+
current_repos: dict[Repository.Repository, dict[Team.Team, str]] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------------
|
|
35
|
+
# Helper functions
|
|
36
|
+
# --------------------------------------------------------------------------
|
|
37
|
+
def _sluggify_teamname(self, team: str) -> str:
|
|
38
|
+
"""Slugify a GitHub team name"""
|
|
39
|
+
# TODO: this is very naive, no other special chars are
|
|
40
|
+
# supported, or multiple spaces etc.
|
|
41
|
+
return team.replace(" ", "-")
|
|
42
|
+
|
|
43
|
+
def login(self, orgname: str, token: str):
|
|
44
|
+
"""Login to GH, gather org data"""
|
|
45
|
+
self.gh = Github(get_github_token(token))
|
|
46
|
+
self.org = self.gh.get_organization(orgname)
|
|
47
|
+
|
|
48
|
+
def ratelimit(self):
|
|
49
|
+
"""Get current rate limit"""
|
|
50
|
+
core = self.gh.get_rate_limit().core
|
|
51
|
+
logging.debug(
|
|
52
|
+
"Current rate limit: %s/%s (reset: %s)", core.remaining, core.limit, core.reset
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def df2json(self) -> str:
|
|
56
|
+
"""Convert the dataclass to a JSON string"""
|
|
57
|
+
d = asdict(self)
|
|
58
|
+
|
|
59
|
+
def pretty(d, indent=0):
|
|
60
|
+
string = ""
|
|
61
|
+
for key, value in d.items():
|
|
62
|
+
string += " " * indent + str(key) + ":\n"
|
|
63
|
+
if isinstance(value, dict):
|
|
64
|
+
string += pretty(value, indent + 1)
|
|
65
|
+
else:
|
|
66
|
+
string += " " * (indent + 1) + str(value) + "\n"
|
|
67
|
+
|
|
68
|
+
return string
|
|
69
|
+
|
|
70
|
+
return pretty(d)
|
|
71
|
+
|
|
72
|
+
# --------------------------------------------------------------------------
|
|
73
|
+
# Teams
|
|
74
|
+
# --------------------------------------------------------------------------
|
|
75
|
+
def get_current_teams(self):
|
|
76
|
+
"""Get teams of the existing organisation"""
|
|
77
|
+
|
|
78
|
+
for team in list(self.org.get_teams()):
|
|
79
|
+
self.current_teams[team] = {"members": {}, "repos": {}}
|
|
80
|
+
|
|
81
|
+
def create_missing_teams(self, dry: bool = False):
|
|
82
|
+
"""Find out which teams are configured but not part of the org yet"""
|
|
83
|
+
|
|
84
|
+
# Get list of current teams
|
|
85
|
+
self.get_current_teams()
|
|
86
|
+
|
|
87
|
+
# Get the names of the existing teams
|
|
88
|
+
existent_team_names = [team.name for team in self.current_teams]
|
|
89
|
+
|
|
90
|
+
for team, attributes in self.configured_teams.items():
|
|
91
|
+
if team not in existent_team_names:
|
|
92
|
+
if parent := attributes.get("parent"): # type: ignore
|
|
93
|
+
parent_id = self.org.get_team_by_slug(self._sluggify_teamname(parent)).id
|
|
94
|
+
|
|
95
|
+
logging.info("Creating team '%s' with parent ID '%s'", team, parent_id)
|
|
96
|
+
if not dry:
|
|
97
|
+
self.org.create_team(team, parent_team_id=parent_id)
|
|
98
|
+
|
|
99
|
+
else:
|
|
100
|
+
logging.info("Creating team '%s' without parent", team)
|
|
101
|
+
if not dry:
|
|
102
|
+
self.org.create_team(team, privacy="closed")
|
|
103
|
+
|
|
104
|
+
else:
|
|
105
|
+
logging.debug("Team '%s' already exists", team)
|
|
106
|
+
|
|
107
|
+
# Re-scan current teams as new ones may have been created
|
|
108
|
+
self.get_current_teams()
|
|
109
|
+
|
|
110
|
+
# --------------------------------------------------------------------------
|
|
111
|
+
# Members
|
|
112
|
+
# --------------------------------------------------------------------------
|
|
113
|
+
def _get_org_members(self):
|
|
114
|
+
"""Get all owners of the org"""
|
|
115
|
+
for member in self.org.get_members(role="admin"):
|
|
116
|
+
self.org_owners.append(member)
|
|
117
|
+
for member in self.org.get_members(role="member"):
|
|
118
|
+
self.org_members.append(member)
|
|
119
|
+
|
|
120
|
+
def _get_configured_team_members(
|
|
121
|
+
self, team_config: dict, team_name: str, role: str
|
|
122
|
+
) -> list[str]:
|
|
123
|
+
"""Read configured members/maintainers from the configuration"""
|
|
124
|
+
|
|
125
|
+
if isinstance(team_config, dict) and team_config.get(role):
|
|
126
|
+
configured_team_members = []
|
|
127
|
+
for user in team_config.get(role, []):
|
|
128
|
+
configured_team_members.append(user)
|
|
129
|
+
|
|
130
|
+
return configured_team_members
|
|
131
|
+
|
|
132
|
+
logging.debug("Team '%s' has no configured %ss", team_name, role)
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
def _get_current_team_members(self, team: Team.Team) -> dict[NamedUser.NamedUser, str]:
|
|
136
|
+
"""Return dict of current users with their respective roles. Also
|
|
137
|
+
contains members of child teams"""
|
|
138
|
+
current_users: dict[NamedUser.NamedUser, str] = {}
|
|
139
|
+
for role in ("member", "maintainer"):
|
|
140
|
+
# Make a two-step check whether person is actually in team, as
|
|
141
|
+
# get_members() also return child-team members
|
|
142
|
+
for user in list(team.get_members(role=role)):
|
|
143
|
+
current_users.update({user: role})
|
|
144
|
+
|
|
145
|
+
return current_users
|
|
146
|
+
|
|
147
|
+
def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser.NamedUser | None:
|
|
148
|
+
"""Turn a username into a proper GitHub user object"""
|
|
149
|
+
try:
|
|
150
|
+
gh_user: NamedUser.NamedUser = self.gh.get_user(username) # type: ignore
|
|
151
|
+
except UnknownObjectException:
|
|
152
|
+
logging.error(
|
|
153
|
+
"The user '%s' configured as member of team '%s' does not "
|
|
154
|
+
"exist on GitHub. Spelling error or did they rename themselves?",
|
|
155
|
+
username,
|
|
156
|
+
teamname,
|
|
157
|
+
)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
return gh_user
|
|
161
|
+
|
|
162
|
+
def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too-many-branches
|
|
163
|
+
"""Check the configured members of each team, add missing ones and delete unconfigured"""
|
|
164
|
+
logging.debug("Starting to sync team members")
|
|
165
|
+
|
|
166
|
+
# Gather all members and owners of the organisation
|
|
167
|
+
self._get_org_members()
|
|
168
|
+
|
|
169
|
+
# Get open invitations
|
|
170
|
+
open_invitations = [user.login for user in self.org.invitations()]
|
|
171
|
+
|
|
172
|
+
for team, team_attrs in self.current_teams.items():
|
|
173
|
+
# Update current team members with dict[NamedUser, str (role)]
|
|
174
|
+
team_attrs["members"] = self._get_current_team_members(team)
|
|
175
|
+
|
|
176
|
+
# For the rest of the function however, we use just the login name
|
|
177
|
+
# for each current user
|
|
178
|
+
current_team_members = {
|
|
179
|
+
user.login: role for user, role in team_attrs["members"].items()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Handle the team not being configured locally
|
|
183
|
+
if team.name not in self.configured_teams:
|
|
184
|
+
logging.warning(
|
|
185
|
+
"Team '%s' does not seem to be configured locally. "
|
|
186
|
+
"Taking no action about this team at all",
|
|
187
|
+
team.name,
|
|
188
|
+
)
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
# Get configuration from current team
|
|
192
|
+
if team_configuration := self.configured_teams.get(team.name):
|
|
193
|
+
pass
|
|
194
|
+
else:
|
|
195
|
+
team_configuration = {}
|
|
196
|
+
|
|
197
|
+
# Analog to team_attrs["members"], add members and maintainers to shared
|
|
198
|
+
# dict with respective role, while maintainer role dominates
|
|
199
|
+
configured_users: dict[str, str] = {}
|
|
200
|
+
for config_role in ("member", "maintainer"):
|
|
201
|
+
team_members = self._get_configured_team_members(
|
|
202
|
+
team_configuration, team.name, config_role
|
|
203
|
+
)
|
|
204
|
+
for team_member in team_members:
|
|
205
|
+
# Add user with role to dict
|
|
206
|
+
configured_users.update({team_member: config_role})
|
|
207
|
+
|
|
208
|
+
# Consider all GitHub organisation team maintainers if they are member of the team
|
|
209
|
+
# This is because GitHub API returns them as maintainers even if they are just members
|
|
210
|
+
for user in self.org_owners:
|
|
211
|
+
if user.login in configured_users:
|
|
212
|
+
logging.debug(
|
|
213
|
+
"Overriding role of organisation owner '%s' to maintainer", user.login
|
|
214
|
+
)
|
|
215
|
+
configured_users[user.login] = "maintainer"
|
|
216
|
+
|
|
217
|
+
# Only make edits to the team membership if the current state differs from config
|
|
218
|
+
if configured_users == current_team_members:
|
|
219
|
+
logging.info("Team '%s' configuration is in sync, no changes", team.name)
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# Loop through the configured users, add / update them if necessary
|
|
223
|
+
for config_user, config_role in configured_users.items():
|
|
224
|
+
# Add user if they haven't been in the team yet
|
|
225
|
+
if config_user not in current_team_members:
|
|
226
|
+
# Turn user to GitHub object, trying to find them
|
|
227
|
+
if not (gh_user := self._resolve_gh_username(config_user, team.name)):
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
# Do not reinvite user if their invitation is already pending
|
|
231
|
+
if config_user in open_invitations:
|
|
232
|
+
logging.info(
|
|
233
|
+
"User '%s' shall be added to team '%s' as %s, invitation is pending",
|
|
234
|
+
gh_user.login,
|
|
235
|
+
team.name,
|
|
236
|
+
config_role,
|
|
237
|
+
)
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
logging.info(
|
|
241
|
+
"Adding user '%s' to team '%s' as %s",
|
|
242
|
+
gh_user.login,
|
|
243
|
+
team.name,
|
|
244
|
+
config_role,
|
|
245
|
+
)
|
|
246
|
+
if not dry:
|
|
247
|
+
team.add_membership(member=gh_user, role=config_role)
|
|
248
|
+
|
|
249
|
+
# Update roles if they differ from old role
|
|
250
|
+
elif config_role != current_team_members.get(config_user, ""):
|
|
251
|
+
# Turn user to GitHub object, trying to find them
|
|
252
|
+
if not (gh_user := self._resolve_gh_username(config_user, team.name)):
|
|
253
|
+
continue
|
|
254
|
+
logging.info(
|
|
255
|
+
"Updating role of '%s' in team '%s' to %s",
|
|
256
|
+
config_user,
|
|
257
|
+
team.name,
|
|
258
|
+
config_role,
|
|
259
|
+
)
|
|
260
|
+
if not dry:
|
|
261
|
+
team.add_membership(member=gh_user, role=config_role)
|
|
262
|
+
|
|
263
|
+
# Loop through all current members. Remove them if they are not configured
|
|
264
|
+
for current_user in current_team_members:
|
|
265
|
+
if current_user not in configured_users:
|
|
266
|
+
# Turn user to GitHub object, trying to find them
|
|
267
|
+
if not (gh_user := self._resolve_gh_username(current_user, team.name)):
|
|
268
|
+
continue
|
|
269
|
+
if team.has_in_members(gh_user):
|
|
270
|
+
logging.info(
|
|
271
|
+
"Removing '%s' from team '%s' as they are not configured",
|
|
272
|
+
gh_user.login,
|
|
273
|
+
team.name,
|
|
274
|
+
)
|
|
275
|
+
if not dry:
|
|
276
|
+
team.remove_membership(gh_user)
|
|
277
|
+
else:
|
|
278
|
+
logging.debug(
|
|
279
|
+
"User '%s' does not need to be removed from team '%s' "
|
|
280
|
+
"as they are just member of a child-team",
|
|
281
|
+
gh_user.login,
|
|
282
|
+
team.name,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def get_members_without_team(self) -> None:
|
|
286
|
+
"""Get all organisation members without any team membership"""
|
|
287
|
+
# Combine org owners and org members
|
|
288
|
+
all_org_members = set(self.org_members + self.org_owners)
|
|
289
|
+
|
|
290
|
+
# Get all members of all teams
|
|
291
|
+
all_team_members_lst = []
|
|
292
|
+
for _, team_attrs in self.current_teams.items():
|
|
293
|
+
for member in team_attrs.get("members", {}):
|
|
294
|
+
all_team_members_lst.append(member)
|
|
295
|
+
all_team_members = set(all_team_members_lst)
|
|
296
|
+
|
|
297
|
+
# Find members that are in org_members but not team_members
|
|
298
|
+
members_without_team = all_org_members.difference(all_team_members)
|
|
299
|
+
|
|
300
|
+
if members_without_team:
|
|
301
|
+
members_without_team_str = [user.login for user in members_without_team]
|
|
302
|
+
logging.warning(
|
|
303
|
+
"The following members of your GitHub organisation are not member of any team: %s",
|
|
304
|
+
", ".join(members_without_team_str),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# --------------------------------------------------------------------------
|
|
308
|
+
# Repos
|
|
309
|
+
# --------------------------------------------------------------------------
|
|
310
|
+
def _get_current_repos_and_perms(self) -> None:
|
|
311
|
+
"""Get all repos, their current teams and their permissions"""
|
|
312
|
+
for repo in list(self.org.get_repos()):
|
|
313
|
+
self.current_repos[repo] = {}
|
|
314
|
+
for team in list(repo.get_teams()):
|
|
315
|
+
self.current_repos[repo][team] = team.permission
|
|
316
|
+
|
|
317
|
+
def _create_perms_changelist_for_teams(
|
|
318
|
+
self,
|
|
319
|
+
) -> dict[Team.Team, dict[Repository.Repository, str]]:
|
|
320
|
+
"""Create a permission/repo changelist from the perspective of configured teams"""
|
|
321
|
+
team_changelist: dict[Team.Team, dict[Repository.Repository, str]] = {}
|
|
322
|
+
for team_name, team_attrs in self.configured_teams.items():
|
|
323
|
+
# Handle unset configured attributes
|
|
324
|
+
if team_attrs is None:
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
# Convert team name to Team object
|
|
328
|
+
team = self.org.get_team_by_slug(self._sluggify_teamname(team_name))
|
|
329
|
+
|
|
330
|
+
# Get configured repo permissions
|
|
331
|
+
for repo, perm in team_attrs.get("repos", {}).items():
|
|
332
|
+
# Convert repo to Repo object
|
|
333
|
+
try:
|
|
334
|
+
repo = self.org.get_repo(repo)
|
|
335
|
+
except UnknownObjectException:
|
|
336
|
+
logging.warning(
|
|
337
|
+
"Configured repository '%s' for team '%s' has not been "
|
|
338
|
+
"found in the organisation",
|
|
339
|
+
repo,
|
|
340
|
+
team.name,
|
|
341
|
+
)
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
if perm != self.current_repos[repo].get(team):
|
|
345
|
+
# Add the changeset to the changelist
|
|
346
|
+
if team not in team_changelist:
|
|
347
|
+
team_changelist[team] = {}
|
|
348
|
+
team_changelist[team][repo] = perm
|
|
349
|
+
|
|
350
|
+
return team_changelist
|
|
351
|
+
|
|
352
|
+
def sync_repo_permissions(self, dry: bool = False) -> None:
|
|
353
|
+
"""Synchronise the repository permissions of all teams"""
|
|
354
|
+
logging.debug("Starting to sync repo/team permissions")
|
|
355
|
+
|
|
356
|
+
# Get all repos and their current permissions from GitHub
|
|
357
|
+
self._get_current_repos_and_perms()
|
|
358
|
+
|
|
359
|
+
# Find differences between configured permissions for a team's repo and the current state
|
|
360
|
+
for team, repos in self._create_perms_changelist_for_teams().items():
|
|
361
|
+
for repo, perm in repos.items():
|
|
362
|
+
logging.info(
|
|
363
|
+
"Changing permission of repository '%s' for team '%s' to '%s'",
|
|
364
|
+
repo.name,
|
|
365
|
+
team.name,
|
|
366
|
+
perm,
|
|
367
|
+
)
|
|
368
|
+
if not dry:
|
|
369
|
+
# Update permissions or newly add a team to a repo
|
|
370
|
+
team.update_team_repository(repo, perm)
|
|
371
|
+
|
|
372
|
+
# Find out whether repos' permissions contain *configured* teams that
|
|
373
|
+
# should not have permissions
|
|
374
|
+
for repo, teams in self.current_repos.items():
|
|
375
|
+
for team in teams:
|
|
376
|
+
# Get configured repos for this team, finding out whether repo
|
|
377
|
+
# is configured for this team
|
|
378
|
+
remove = False
|
|
379
|
+
# Handle: Team is not configured at all
|
|
380
|
+
if team.name not in self.configured_teams:
|
|
381
|
+
logging.warning(
|
|
382
|
+
"Team '%s' has permissions on repository '%s', but this team "
|
|
383
|
+
"is not configured locally",
|
|
384
|
+
team.name,
|
|
385
|
+
repo.name,
|
|
386
|
+
)
|
|
387
|
+
continue
|
|
388
|
+
# Handle: Team is configured, but contains no config
|
|
389
|
+
if self.configured_teams[team.name] is None:
|
|
390
|
+
remove = True
|
|
391
|
+
# Handle: Team is configured, contains config
|
|
392
|
+
elif repos := self.configured_teams[team.name].get("repos", []): # type: ignore
|
|
393
|
+
# If this repo has not been found in the configured repos
|
|
394
|
+
# for the team, remove all permissions
|
|
395
|
+
if repo.name not in repos:
|
|
396
|
+
remove = True
|
|
397
|
+
# Handle: Team is configured, contains config, but no "repos" key
|
|
398
|
+
else:
|
|
399
|
+
remove = True
|
|
400
|
+
|
|
401
|
+
# Remove if any mismatch has been found
|
|
402
|
+
if remove:
|
|
403
|
+
logging.info("Removing team '%s' from repository '%s'", team.name, repo.name)
|
|
404
|
+
if not dry:
|
|
405
|
+
team.remove_from_repos(repo)
|
gh_org_mgr/manage.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 DB Systel GmbH
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Manage a GitHub Organization, its teams, repository permissions, and more"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import __version__, configure_logger
|
|
12
|
+
from ._config import parse_config_files
|
|
13
|
+
from ._gh_org import GHorg
|
|
14
|
+
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"-c",
|
|
20
|
+
"--config",
|
|
21
|
+
required=True,
|
|
22
|
+
help="Path to the directory in which the configuration of an GitHub organisation is located",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument("--debug", action="store_true", help="Get verbose logging output")
|
|
25
|
+
parser.add_argument("--dry", action="store_true", help="Do not make any changes at GitHub")
|
|
26
|
+
parser.add_argument("--version", action="version", version="GitHub Team Manager " + __version__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main():
|
|
30
|
+
"""Main function"""
|
|
31
|
+
|
|
32
|
+
# Process arguments
|
|
33
|
+
args = parser.parse_args()
|
|
34
|
+
|
|
35
|
+
configure_logger(args.debug)
|
|
36
|
+
|
|
37
|
+
if args.dry:
|
|
38
|
+
logging.info("Dry-run mode activated, will not make any changes at GitHub")
|
|
39
|
+
|
|
40
|
+
org = GHorg()
|
|
41
|
+
|
|
42
|
+
# Parse configuration folder, and do sanity check
|
|
43
|
+
cfg_org, cfg_app, org.configured_teams = parse_config_files(args.config)
|
|
44
|
+
if not cfg_org.get("org_name"):
|
|
45
|
+
logging.critical(
|
|
46
|
+
"No GitHub organisation name configured in organisation settings. Cannot continue"
|
|
47
|
+
)
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
# Login to GitHub with token, get GitHub organisation
|
|
51
|
+
org.login(cfg_org.get("org_name", ""), cfg_app.get("github_token", ""))
|
|
52
|
+
# Get current rate limit
|
|
53
|
+
org.ratelimit()
|
|
54
|
+
|
|
55
|
+
# Create teams that aren't present at Github yet
|
|
56
|
+
org.create_missing_teams(dry=args.dry)
|
|
57
|
+
# Synchronise the team memberships
|
|
58
|
+
org.sync_teams_members(dry=args.dry)
|
|
59
|
+
# Report about organisation members that do not belong to any team
|
|
60
|
+
org.get_members_without_team()
|
|
61
|
+
# Synchronise the permissions of teams for all repositories
|
|
62
|
+
org.sync_repo_permissions(dry=args.dry)
|
|
63
|
+
|
|
64
|
+
# Debug output
|
|
65
|
+
logging.debug("Final dataclass:\n%s", org.df2json())
|
|
66
|
+
org.ratelimit()
|