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.
@@ -1,8 +1,13 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: github-org-manager
3
- Version: 0.7.4
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: pygithub (>=2.7.0,<3.0.0)
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.2,<7.0.0)
25
- Requires-Dist: requests (>=2.32.4,<3.0.0)
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 create_missing_teams(self, dry: bool = False):
295
- """Find out which teams are configured but not part of the org yet"""
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
- # Get the names of the existing teams
301
- existent_team_names = [team.name for team in self.current_teams]
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 existent_team_names:
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)
@@ -34,7 +34,7 @@ def log_progress(message: str) -> None:
34
34
  sys.stderr.write("\r\033[K")
35
35
  sys.stderr.flush()
36
36
  else:
37
- sys.stderr.write(f"\r\033[K⏳ {message}")
37
+ sys.stderr.write(f"\r\033[K⏳ {message}\n")
38
38
  sys.stderr.flush()
39
39
 
40
40
 
@@ -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.4"
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.7.0"
31
- pyyaml = "^6.0.2"
32
- requests = "^2.32.4"
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.1.0"
37
+ black = "^25.9.0"
37
38
  isort = ">=5.13.2,<7.0.0"
38
- mypy = "^1.9.0"
39
- pylint = "^3.1.0"
40
- types-pyyaml = "^6.0.12.20240311"
41
- types-requests = "^2.32.0.20240712"
42
- bump-my-version = "^1.1.2"
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"]