github-org-manager 0.7.4__tar.gz → 0.7.5__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.7.4 → github_org_manager-0.7.5}/PKG-INFO +12 -5
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/gh_org_mgr/_config.py +93 -0
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/gh_org_mgr/_gh_org.py +54 -5
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/gh_org_mgr/_helpers.py +1 -1
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/gh_org_mgr/manage.py +3 -1
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/pyproject.toml +12 -10
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/LICENSE.txt +0 -0
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/LICENSES/Apache-2.0.txt +0 -0
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/LICENSES/CC-BY-4.0.txt +0 -0
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/LICENSES/CC0-1.0.txt +0 -0
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/LICENSES/MIT.txt +0 -0
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/README.md +0 -0
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/gh_org_mgr/__init__.py +0 -0
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/gh_org_mgr/_gh_api.py +0 -0
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/gh_org_mgr/_setup_team.py +0 -0
- {github_org_manager-0.7.4 → github_org_manager-0.7.5}/gh_org_mgr/_stats.py +0 -0
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: github-org-manager
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.5
|
|
4
4
|
Summary: Manage a GitHub Organization, its teams, repository permissions, and more
|
|
5
5
|
License: Apache-2.0
|
|
6
|
+
License-File: LICENSE.txt
|
|
7
|
+
License-File: LICENSES/Apache-2.0.txt
|
|
8
|
+
License-File: LICENSES/CC-BY-4.0.txt
|
|
9
|
+
License-File: LICENSES/CC0-1.0.txt
|
|
10
|
+
License-File: LICENSES/MIT.txt
|
|
6
11
|
Keywords: github,github-management,permissions,access-control
|
|
7
12
|
Author: Max Mehl
|
|
8
13
|
Author-email: max.mehl@deutschebahn.com
|
|
@@ -17,12 +22,14 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
17
22
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
23
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
24
|
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
26
|
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
21
27
|
Classifier: Topic :: Utilities
|
|
22
|
-
Requires-Dist:
|
|
28
|
+
Requires-Dist: jsonschema (>=4.25.1,<5.0.0)
|
|
29
|
+
Requires-Dist: pygithub (>=2.8.1,<3.0.0)
|
|
23
30
|
Requires-Dist: python-slugify (>=8.0.4,<9.0.0)
|
|
24
|
-
Requires-Dist: pyyaml (>=6.0.
|
|
25
|
-
Requires-Dist: requests (>=2.32.
|
|
31
|
+
Requires-Dist: pyyaml (>=6.0.3,<7.0.0)
|
|
32
|
+
Requires-Dist: requests (>=2.32.5,<3.0.0)
|
|
26
33
|
Project-URL: Repository, https://github.com/OpenRailAssociation/github-org-manager
|
|
27
34
|
Description-Content-Type: text/markdown
|
|
28
35
|
|
|
@@ -11,6 +11,8 @@ import sys
|
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
13
|
import yaml
|
|
14
|
+
from jsonschema import FormatChecker, validate
|
|
15
|
+
from jsonschema.exceptions import ValidationError
|
|
14
16
|
|
|
15
17
|
# Global files with settings for the app and org, e.g. GitHub token and org name
|
|
16
18
|
ORG_CONFIG_FILE = r"org\.ya?ml"
|
|
@@ -18,6 +20,84 @@ APP_CONFIG_FILE = r"app\.ya?ml"
|
|
|
18
20
|
TEAM_CONFIG_DIR = "teams"
|
|
19
21
|
TEAM_CONFIG_FILES = r".+\.ya?ml"
|
|
20
22
|
|
|
23
|
+
# Schemas for config validation
|
|
24
|
+
APP_CONFIG_SCHEMA = {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"github_token": {"type": "string"},
|
|
28
|
+
"github_app_id": {"type": "integer"},
|
|
29
|
+
"github_app_private_key": {"type": "string"},
|
|
30
|
+
"remove_members_without_team": {"type": "boolean"},
|
|
31
|
+
"delete_unconfigured_teams": {"type": "boolean"},
|
|
32
|
+
},
|
|
33
|
+
"additionalProperties": False,
|
|
34
|
+
}
|
|
35
|
+
ORG_CONFIG_SCHEMA = {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"org_name": {"type": "string"},
|
|
39
|
+
"org_owners": {
|
|
40
|
+
"type": "array",
|
|
41
|
+
"items": {"type": "string"},
|
|
42
|
+
"minItems": 1,
|
|
43
|
+
},
|
|
44
|
+
"defaults": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"team": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"properties": {
|
|
50
|
+
"description": {"type": "string"},
|
|
51
|
+
"privacy": {"type": "string", "enum": ["secret", "closed"]},
|
|
52
|
+
"notification_setting": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"enum": ["notifications_enabled", "notifications_disabled"],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
"additionalProperties": False,
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"additionalProperties": False,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
"additionalProperties": False,
|
|
64
|
+
"required": ["org_name", "org_owners"],
|
|
65
|
+
}
|
|
66
|
+
TEAM_CONFIG_SCHEMA = {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"patternProperties": {
|
|
69
|
+
"^[a-zA-Z0-9 _\\-]+$": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {
|
|
72
|
+
"description": {"type": "string"},
|
|
73
|
+
"privacy": {"type": "string", "enum": ["secret", "closed"]},
|
|
74
|
+
"notification_setting": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"enum": ["notifications_enabled", "notifications_disabled"],
|
|
77
|
+
},
|
|
78
|
+
"maintainer": {
|
|
79
|
+
"oneOf": [{"type": "null"}, {"type": "array", "items": {"type": "string"}}]
|
|
80
|
+
},
|
|
81
|
+
"member": {
|
|
82
|
+
"oneOf": [{"type": "null"}, {"type": "array", "items": {"type": "string"}}]
|
|
83
|
+
},
|
|
84
|
+
"parent": {"type": "string"},
|
|
85
|
+
"repos": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"propertyNames": {"type": "string"},
|
|
88
|
+
"additionalProperties": {
|
|
89
|
+
"type": "string",
|
|
90
|
+
"enum": ["pull", "triage", "push", "maintain", "admin"],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
"additionalProperties": False,
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"additionalProperties": False,
|
|
98
|
+
"required": [],
|
|
99
|
+
}
|
|
100
|
+
|
|
21
101
|
|
|
22
102
|
def _find_matching_files(directory: str, pattern: str, only_one: bool = False) -> list[str]:
|
|
23
103
|
"""
|
|
@@ -85,6 +165,16 @@ def _read_config_file(file: str) -> dict:
|
|
|
85
165
|
return config
|
|
86
166
|
|
|
87
167
|
|
|
168
|
+
def _validate_config_schema(file: str, cfg: dict, schema: dict) -> None:
|
|
169
|
+
"""Validate the config against a JSON schema"""
|
|
170
|
+
try:
|
|
171
|
+
validate(instance=cfg, schema=schema, format_checker=FormatChecker())
|
|
172
|
+
except ValidationError as e:
|
|
173
|
+
logging.critical("Config validation of file %s failed: %s", file, e.message)
|
|
174
|
+
raise ValueError(e) from None
|
|
175
|
+
logging.debug("Config in file %s validated successfully against schema.", file)
|
|
176
|
+
|
|
177
|
+
|
|
88
178
|
def parse_config_files(path: str) -> tuple[dict[str, str | dict[str, str]], dict, dict]:
|
|
89
179
|
"""Parse all relevant files in the configuration directory. Returns a tuple
|
|
90
180
|
of org config, app config, and merged teams config"""
|
|
@@ -95,7 +185,9 @@ def parse_config_files(path: str) -> tuple[dict[str, str | dict[str, str]], dict
|
|
|
95
185
|
|
|
96
186
|
# Read and parse config files for app and org
|
|
97
187
|
cfg_app = _read_config_file(cfg_app_files[0])
|
|
188
|
+
_validate_config_schema(file=cfg_app_files[0], cfg=cfg_app, schema=APP_CONFIG_SCHEMA)
|
|
98
189
|
cfg_org = _read_config_file(cfg_org_files[0])
|
|
190
|
+
_validate_config_schema(file=cfg_org_files[0], cfg=cfg_org, schema=ORG_CONFIG_SCHEMA)
|
|
99
191
|
|
|
100
192
|
# For the teams config files, we parse and combine them as there may be multiple
|
|
101
193
|
cfg_teams: dict[str, Any] = {}
|
|
@@ -103,6 +195,7 @@ def parse_config_files(path: str) -> tuple[dict[str, str | dict[str, str]], dict
|
|
|
103
195
|
# Compare their keys (team names). They must not be defined multiple times!
|
|
104
196
|
for cfg_team_file in cfg_teams_files:
|
|
105
197
|
cfg = _read_config_file(cfg_team_file)
|
|
198
|
+
_validate_config_schema(file=cfg_team_file, cfg=cfg, schema=TEAM_CONFIG_SCHEMA)
|
|
106
199
|
if overlap := set(cfg_teams.keys()) & set(cfg.keys()):
|
|
107
200
|
logging.critical(
|
|
108
201
|
"The config file '%s' contains keys that are also defined in "
|
|
@@ -45,6 +45,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
|
|
|
45
45
|
configured_org_owners: list[str] = field(default_factory=list)
|
|
46
46
|
org_members: list[NamedUser] = field(default_factory=list)
|
|
47
47
|
current_teams: dict[Team, dict] = field(default_factory=dict)
|
|
48
|
+
current_teams_str: list[str] = field(default_factory=list)
|
|
48
49
|
configured_teams: dict[str, dict | None] = field(default_factory=dict)
|
|
49
50
|
newly_added_users: list[NamedUser] = field(default_factory=list)
|
|
50
51
|
current_repos_teams: dict[Repository, dict[Team, str]] = field(default_factory=dict)
|
|
@@ -290,18 +291,65 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
|
|
|
290
291
|
"""Get teams of the existing organisation"""
|
|
291
292
|
for team in list(self.org.get_teams()):
|
|
292
293
|
self.current_teams[team] = {"members": {}, "repos": {}}
|
|
294
|
+
self.current_teams_str = [team.name for team in self.current_teams]
|
|
293
295
|
|
|
294
|
-
def
|
|
295
|
-
"""
|
|
296
|
+
def ensure_team_hierarchy(self) -> None:
|
|
297
|
+
"""Check if all configured parent teams make sense: either they exist already or will be
|
|
298
|
+
created during this run"""
|
|
296
299
|
|
|
297
300
|
# Get list of current teams
|
|
298
301
|
self._get_current_teams()
|
|
299
302
|
|
|
300
|
-
#
|
|
301
|
-
|
|
303
|
+
# First, check whether all configured parent teams exist or will be created
|
|
304
|
+
for team, attributes in self.configured_teams.items():
|
|
305
|
+
if parent := attributes.get("parent"): # type: ignore
|
|
306
|
+
if parent not in self.configured_teams:
|
|
307
|
+
if parent not in self.current_teams_str:
|
|
308
|
+
logging.critical(
|
|
309
|
+
"The team '%s' is configured with parent team '%s', but this parent "
|
|
310
|
+
"team does not exist and is not configured to be created. "
|
|
311
|
+
"Cannot continue.",
|
|
312
|
+
team,
|
|
313
|
+
parent,
|
|
314
|
+
)
|
|
315
|
+
sys.exit(1)
|
|
316
|
+
else:
|
|
317
|
+
logging.debug(
|
|
318
|
+
"The team '%s' is configured with parent team '%s', "
|
|
319
|
+
"which already exists",
|
|
320
|
+
team,
|
|
321
|
+
parent,
|
|
322
|
+
)
|
|
323
|
+
else:
|
|
324
|
+
logging.debug(
|
|
325
|
+
"The team '%s' is configured with parent team '%s', "
|
|
326
|
+
"which will be created during this run",
|
|
327
|
+
team,
|
|
328
|
+
parent,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# Second, order the teams in a way that parent teams are created before child teams
|
|
332
|
+
ordered_teams: dict[str, dict | None] = {}
|
|
333
|
+
while len(ordered_teams) < len(self.configured_teams):
|
|
334
|
+
for team, attributes in self.configured_teams.items():
|
|
335
|
+
# Team already ordered
|
|
336
|
+
if team in ordered_teams:
|
|
337
|
+
continue
|
|
338
|
+
# Team has parent, but parent not ordered yet
|
|
339
|
+
if parent := attributes.get("parent"): # type: ignore
|
|
340
|
+
if parent not in ordered_teams:
|
|
341
|
+
continue
|
|
342
|
+
# Team has no parent, or parent already ordered
|
|
343
|
+
ordered_teams[team] = attributes
|
|
344
|
+
# Overwrite configured teams with ordered ones
|
|
345
|
+
self.configured_teams = ordered_teams
|
|
346
|
+
|
|
347
|
+
def create_missing_teams(self, dry: bool = False) -> None:
|
|
348
|
+
"""Find out which teams are configured but not part of the org yet"""
|
|
302
349
|
|
|
303
350
|
for team, attributes in self.configured_teams.items():
|
|
304
|
-
if team not in
|
|
351
|
+
if team not in self.current_teams_str:
|
|
352
|
+
# If a parent team is configured, try to get its ID
|
|
305
353
|
if parent := attributes.get("parent"): # type: ignore
|
|
306
354
|
try:
|
|
307
355
|
parent_id = self.org.get_team_by_slug(sluggify_teamname(parent)).id
|
|
@@ -328,6 +376,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
|
|
|
328
376
|
privacy="closed",
|
|
329
377
|
)
|
|
330
378
|
|
|
379
|
+
# No parent team configured
|
|
331
380
|
else:
|
|
332
381
|
logging.info("Creating team '%s' without parent", team)
|
|
333
382
|
self.stats.create_team(team)
|
|
@@ -134,6 +134,9 @@ def main():
|
|
|
134
134
|
# Synchronise organisation owners
|
|
135
135
|
log_progress("Synchronising organisation owners...")
|
|
136
136
|
org.sync_org_owners(dry=args.dry, force=args.force)
|
|
137
|
+
# Validate parent/child team relationships
|
|
138
|
+
log_progress("Validating team hierarchy...")
|
|
139
|
+
org.ensure_team_hierarchy()
|
|
137
140
|
# Create teams that aren't present at Github yet
|
|
138
141
|
log_progress("Creating missing teams...")
|
|
139
142
|
org.create_missing_teams(dry=args.dry)
|
|
@@ -164,7 +167,6 @@ def main():
|
|
|
164
167
|
org.sync_repo_collaborator_permissions(dry=args.dry)
|
|
165
168
|
|
|
166
169
|
# Debug output
|
|
167
|
-
log_progress("") # clear progress
|
|
168
170
|
logging.debug("Final dataclass:\n%s", org.pretty_print_dataclass())
|
|
169
171
|
org.ratelimit()
|
|
170
172
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "github-org-manager"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.5"
|
|
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"
|
|
@@ -27,19 +27,21 @@ gh-org-mgr = 'gh_org_mgr.manage:main'
|
|
|
27
27
|
|
|
28
28
|
[tool.poetry.dependencies]
|
|
29
29
|
python = "^3.10"
|
|
30
|
-
pygithub = "^2.
|
|
31
|
-
pyyaml = "^6.0.
|
|
32
|
-
requests = "^2.32.
|
|
30
|
+
pygithub = "^2.8.1"
|
|
31
|
+
pyyaml = "^6.0.3"
|
|
32
|
+
requests = "^2.32.5"
|
|
33
33
|
python-slugify = "^8.0.4"
|
|
34
|
+
jsonschema = "^4.25.1"
|
|
34
35
|
|
|
35
36
|
[tool.poetry.group.dev.dependencies]
|
|
36
|
-
black = "^25.
|
|
37
|
+
black = "^25.9.0"
|
|
37
38
|
isort = ">=5.13.2,<7.0.0"
|
|
38
|
-
mypy = "^1.
|
|
39
|
-
pylint = "^3.
|
|
40
|
-
types-pyyaml = "^6.0.12.
|
|
41
|
-
types-requests = "^2.32.
|
|
42
|
-
bump-my-version = "^1.
|
|
39
|
+
mypy = "^1.18.2"
|
|
40
|
+
pylint = "^3.3.8"
|
|
41
|
+
types-pyyaml = "^6.0.12.20250915"
|
|
42
|
+
types-requests = "^2.32.4.20250913"
|
|
43
|
+
bump-my-version = "^1.2.3"
|
|
44
|
+
types-jsonschema = "^4.25.1.20250822"
|
|
43
45
|
|
|
44
46
|
[build-system]
|
|
45
47
|
requires = ["poetry-core"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|