github-org-manager 0.7.3__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.3
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 "
@@ -8,14 +8,12 @@ import logging
8
8
  import sys
9
9
  from dataclasses import asdict, dataclass, field
10
10
 
11
- from github import (
12
- Auth,
13
- Github,
11
+ from github import Auth, Github, GithubIntegration
12
+ from github.GithubException import (
13
+ BadCredentialsException,
14
14
  GithubException,
15
- GithubIntegration,
16
15
  UnknownObjectException,
17
16
  )
18
- from github.GithubException import BadCredentialsException
19
17
  from github.NamedUser import NamedUser
20
18
  from github.Organization import Organization
21
19
  from github.Repository import Repository
@@ -47,6 +45,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
47
45
  configured_org_owners: list[str] = field(default_factory=list)
48
46
  org_members: list[NamedUser] = field(default_factory=list)
49
47
  current_teams: dict[Team, dict] = field(default_factory=dict)
48
+ current_teams_str: list[str] = field(default_factory=list)
50
49
  configured_teams: dict[str, dict | None] = field(default_factory=dict)
51
50
  newly_added_users: list[NamedUser] = field(default_factory=list)
52
51
  current_repos_teams: dict[Repository, dict[Team, str]] = field(default_factory=dict)
@@ -54,6 +53,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
54
53
  current_repos_collaborators: dict[Repository, dict[str, str]] = field(default_factory=dict)
55
54
  configured_repos_collaborators: dict[str, dict[str, str]] = field(default_factory=dict)
56
55
  archived_repos: list[Repository] = field(default_factory=list)
56
+ unconfigured_teams: list[Team] = field(default_factory=list)
57
57
  unconfigured_team_repo_permissions: dict[str, dict[str, str]] = field(default_factory=dict)
58
58
  stats: OrgChanges = field(default_factory=OrgChanges)
59
59
 
@@ -84,8 +84,9 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
84
84
 
85
85
  # Decide how to login. If app set, prefer this
86
86
  if self.gh_app_id and self.gh_app_private_key:
87
- logging.debug("Logging in via app %s", self.gh_app_id)
88
- auth = Auth.AppAuth(app_id=self.gh_app_id, private_key=self.gh_app_private_key)
87
+ gh_app_id_sanitized = str(self.gh_app_id).strip("'").strip('"')
88
+ logging.debug("Logging in via app %s", gh_app_id_sanitized)
89
+ auth = Auth.AppAuth(app_id=gh_app_id_sanitized, private_key=self.gh_app_private_key)
89
90
  app = GithubIntegration(auth=auth)
90
91
  try:
91
92
  installation = app.get_org_installation(org=orgname)
@@ -202,9 +203,16 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
202
203
  return True
203
204
 
204
205
  def _is_user_authenticated_user(self, user: NamedUser) -> bool:
205
- """Check if a given NamedUser is the authenticated user"""
206
- if user.login == self.gh.get_user().login:
207
- return True
206
+ """Check if a given NamedUser is the authenticated user. If logging in via App, this will
207
+ always return False, as the authenticated user is the App itself"""
208
+ try:
209
+ if user.login == self.gh.get_user().login:
210
+ return True
211
+ except GithubException as e:
212
+ if e.status == 403 and "Resource not accessible by integration" in str(e):
213
+ logging.debug("Cannot check if user is authenticated, as this is an App login")
214
+ return False
215
+ raise
208
216
  return False
209
217
 
210
218
  def sync_org_owners(self, dry: bool = False, force: bool = False) -> None:
@@ -283,20 +291,78 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
283
291
  """Get teams of the existing organisation"""
284
292
  for team in list(self.org.get_teams()):
285
293
  self.current_teams[team] = {"members": {}, "repos": {}}
294
+ self.current_teams_str = [team.name for team in self.current_teams]
286
295
 
287
- def create_missing_teams(self, dry: bool = False):
288
- """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"""
289
299
 
290
300
  # Get list of current teams
291
301
  self._get_current_teams()
292
302
 
293
- # Get the names of the existing teams
294
- 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"""
295
349
 
296
350
  for team, attributes in self.configured_teams.items():
297
- 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
298
353
  if parent := attributes.get("parent"): # type: ignore
299
- parent_id = self.org.get_team_by_slug(sluggify_teamname(parent)).id
354
+ try:
355
+ parent_id = self.org.get_team_by_slug(sluggify_teamname(parent)).id
356
+ except UnknownObjectException:
357
+ if dry:
358
+ logging.debug(
359
+ "For team %s, the configured parent team's ('%s') ID wasn't found, "
360
+ "probably because it should be created but it's a dry-run. "
361
+ "We set a default ID of 424242",
362
+ team,
363
+ parent,
364
+ )
365
+ parent_id = 424242
300
366
 
301
367
  logging.info("Creating team '%s' with parent ID '%s'", team, parent_id)
302
368
  self.stats.create_team(team)
@@ -310,6 +376,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
310
376
  privacy="closed",
311
377
  )
312
378
 
379
+ # No parent team configured
313
380
  else:
314
381
  logging.info("Creating team '%s' without parent", team)
315
382
  self.stats.create_team(team)
@@ -594,31 +661,38 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines
594
661
  team.name,
595
662
  )
596
663
 
597
- def get_unconfigured_teams(
664
+ def get_and_delete_unconfigured_teams(
598
665
  self, dry: bool = False, delete_unconfigured_teams: bool = False
599
666
  ) -> None:
600
667
  """Get all teams that are not configured locally and optionally remove them"""
601
668
  # Get all teams that are not configured locally
602
- unconfigured_teams: list[Team] = []
603
669
  for team in self.current_teams:
604
670
  if team.name not in self.configured_teams:
605
- unconfigured_teams.append(team)
671
+ self.unconfigured_teams.append(team)
606
672
 
607
- if unconfigured_teams:
673
+ if self.unconfigured_teams:
608
674
  if delete_unconfigured_teams:
609
- for team in unconfigured_teams:
675
+ for team in self.unconfigured_teams:
610
676
  logging.info("Deleting team '%s' as it is not configured locally", team.name)
611
677
  self.stats.delete_team(team=team.name, deleted=True)
612
678
  if not dry:
613
- team.delete()
679
+ try:
680
+ team.delete()
681
+ except UnknownObjectException as e:
682
+ logging.info(
683
+ "Team '%s' could not be deleted, probably because it was already "
684
+ "deleted as part of a parent team. "
685
+ "Error: %s",
686
+ team.name,
687
+ e,
688
+ )
614
689
  else:
615
- unconfigured_teams_str = [team.name for team in unconfigured_teams]
616
690
  logging.warning(
617
691
  "The following teams of your GitHub organisation are not "
618
692
  "configured locally: %s. Taking no action about these teams.",
619
- ", ".join(unconfigured_teams_str),
693
+ ", ".join([team.name for team in self.unconfigured_teams]),
620
694
  )
621
- for team in unconfigured_teams:
695
+ for team in self.unconfigured_teams:
622
696
  self.stats.delete_team(team=team.name, deleted=False)
623
697
 
624
698
  def get_members_without_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)
@@ -145,7 +148,7 @@ def main():
145
148
  org.sync_teams_members(dry=args.dry)
146
149
  # Report and act on teams that are not configured locally
147
150
  log_progress("Checking for unconfigured teams...")
148
- org.get_unconfigured_teams(
151
+ org.get_and_delete_unconfigured_teams(
149
152
  dry=args.dry,
150
153
  delete_unconfigured_teams=cfg_app.get("delete_unconfigured_teams", False),
151
154
  )
@@ -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.3"
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"]