github-org-manager 0.2.0__tar.gz → 0.3.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.2.0 → github_org_manager-0.3.0}/PKG-INFO +13 -1
- {github_org_manager-0.2.0 → github_org_manager-0.3.0}/gh_org_mgr/_gh_api.py +18 -0
- {github_org_manager-0.2.0 → github_org_manager-0.3.0}/gh_org_mgr/_gh_org.py +276 -28
- {github_org_manager-0.2.0 → github_org_manager-0.3.0}/gh_org_mgr/manage.py +10 -1
- {github_org_manager-0.2.0 → github_org_manager-0.3.0}/pyproject.toml +16 -2
- {github_org_manager-0.2.0 → github_org_manager-0.3.0}/LICENSE.txt +0 -0
- {github_org_manager-0.2.0 → github_org_manager-0.3.0}/LICENSES/Apache-2.0.txt +0 -0
- {github_org_manager-0.2.0 → github_org_manager-0.3.0}/LICENSES/CC-BY-4.0.txt +0 -0
- {github_org_manager-0.2.0 → github_org_manager-0.3.0}/LICENSES/CC0-1.0.txt +0 -0
- {github_org_manager-0.2.0 → github_org_manager-0.3.0}/LICENSES/MIT.txt +0 -0
- {github_org_manager-0.2.0 → github_org_manager-0.3.0}/README.md +0 -0
- {github_org_manager-0.2.0 → github_org_manager-0.3.0}/gh_org_mgr/__init__.py +0 -0
- {github_org_manager-0.2.0 → github_org_manager-0.3.0}/gh_org_mgr/_config.py +0 -0
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: github-org-manager
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Manage a GitHub Organization, its teams, repository permissions, and more
|
|
5
|
+
Home-page: https://github.com/OpenRailAssociation/github-org-manager
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: github,github-management,permissions,access-control
|
|
5
8
|
Author: Max Mehl
|
|
6
9
|
Author-email: max.mehl@deutschebahn.com
|
|
7
10
|
Requires-Python: >=3.10,<4.0
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
8
16
|
Classifier: Programming Language :: Python :: 3
|
|
9
17
|
Classifier: Programming Language :: Python :: 3.10
|
|
10
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
11
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
12
22
|
Requires-Dist: pygithub (>=2.3.0,<3.0.0)
|
|
13
23
|
Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
|
|
24
|
+
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
25
|
+
Project-URL: Repository, https://github.com/OpenRailAssociation/github-org-manager
|
|
14
26
|
Description-Content-Type: text/markdown
|
|
15
27
|
|
|
16
28
|
<!--
|
|
@@ -10,6 +10,8 @@ import logging
|
|
|
10
10
|
import os
|
|
11
11
|
import sys
|
|
12
12
|
|
|
13
|
+
import requests
|
|
14
|
+
|
|
13
15
|
|
|
14
16
|
def get_github_token(token: str = "") -> str:
|
|
15
17
|
"""Get the GitHub token from config or environment, while environment overrides"""
|
|
@@ -25,3 +27,19 @@ def get_github_token(token: str = "") -> str:
|
|
|
25
27
|
)
|
|
26
28
|
|
|
27
29
|
return token
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Function to execute GraphQL query
|
|
33
|
+
def run_graphql_query(query, variables, token):
|
|
34
|
+
"""Run a query against the GitHub GraphQL API"""
|
|
35
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
36
|
+
request = requests.post(
|
|
37
|
+
"https://api.github.com/graphql",
|
|
38
|
+
json={"query": query, "variables": variables},
|
|
39
|
+
headers=headers,
|
|
40
|
+
timeout=10,
|
|
41
|
+
)
|
|
42
|
+
if request.status_code == 200:
|
|
43
|
+
return request.json()
|
|
44
|
+
|
|
45
|
+
sys.exit(f"Query failed to run by returning code of {query}: {request.status_code}")
|
|
@@ -7,16 +7,13 @@
|
|
|
7
7
|
import logging
|
|
8
8
|
from dataclasses import asdict, dataclass, field
|
|
9
9
|
|
|
10
|
-
from github import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Team,
|
|
16
|
-
UnknownObjectException,
|
|
17
|
-
)
|
|
10
|
+
from github import Github, UnknownObjectException
|
|
11
|
+
from github.NamedUser import NamedUser
|
|
12
|
+
from github.Organization import Organization
|
|
13
|
+
from github.Repository import Repository
|
|
14
|
+
from github.Team import Team
|
|
18
15
|
|
|
19
|
-
from ._gh_api import get_github_token
|
|
16
|
+
from ._gh_api import get_github_token, run_graphql_query
|
|
20
17
|
|
|
21
18
|
|
|
22
19
|
@dataclass
|
|
@@ -24,12 +21,17 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
24
21
|
"""Dataclass holding GH organization data and functions"""
|
|
25
22
|
|
|
26
23
|
gh: Github = None # type: ignore
|
|
27
|
-
org: Organization
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
org: Organization = None # type: ignore
|
|
25
|
+
gh_token: str = ""
|
|
26
|
+
org_owners: list[NamedUser] = field(default_factory=list)
|
|
27
|
+
org_members: list[NamedUser] = field(default_factory=list)
|
|
28
|
+
current_teams: dict[Team, dict] = field(default_factory=dict)
|
|
31
29
|
configured_teams: dict[str, dict | None] = field(default_factory=dict)
|
|
32
|
-
|
|
30
|
+
current_repos_teams: dict[Repository, dict[Team, str]] = field(default_factory=dict)
|
|
31
|
+
current_repos_collaborators: dict[Repository, dict[str, str]] = field(default_factory=dict)
|
|
32
|
+
configured_repos_collaborators: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
33
|
+
archived_repos: list[Repository] = field(default_factory=list)
|
|
34
|
+
unconfigured_team_repo_permissions: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
33
35
|
|
|
34
36
|
# --------------------------------------------------------------------------
|
|
35
37
|
# Helper functions
|
|
@@ -42,7 +44,8 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
42
44
|
|
|
43
45
|
def login(self, orgname: str, token: str):
|
|
44
46
|
"""Login to GH, gather org data"""
|
|
45
|
-
self.
|
|
47
|
+
self.gh_token = get_github_token(token)
|
|
48
|
+
self.gh = Github(self.gh_token)
|
|
46
49
|
self.org = self.gh.get_organization(orgname)
|
|
47
50
|
|
|
48
51
|
def ratelimit(self):
|
|
@@ -132,10 +135,10 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
132
135
|
logging.debug("Team '%s' has no configured %ss", team_name, role)
|
|
133
136
|
return []
|
|
134
137
|
|
|
135
|
-
def _get_current_team_members(self, team: Team
|
|
138
|
+
def _get_current_team_members(self, team: Team) -> dict[NamedUser, str]:
|
|
136
139
|
"""Return dict of current users with their respective roles. Also
|
|
137
140
|
contains members of child teams"""
|
|
138
|
-
current_users: dict[NamedUser
|
|
141
|
+
current_users: dict[NamedUser, str] = {}
|
|
139
142
|
for role in ("member", "maintainer"):
|
|
140
143
|
# Make a two-step check whether person is actually in team, as
|
|
141
144
|
# get_members() also return child-team members
|
|
@@ -144,10 +147,10 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
144
147
|
|
|
145
148
|
return current_users
|
|
146
149
|
|
|
147
|
-
def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser
|
|
150
|
+
def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser | None:
|
|
148
151
|
"""Turn a username into a proper GitHub user object"""
|
|
149
152
|
try:
|
|
150
|
-
gh_user: NamedUser
|
|
153
|
+
gh_user: NamedUser = self.gh.get_user(username) # type: ignore
|
|
151
154
|
except UnknownObjectException:
|
|
152
155
|
logging.error(
|
|
153
156
|
"The user '%s' configured as member of team '%s' does not "
|
|
@@ -307,18 +310,27 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
307
310
|
# --------------------------------------------------------------------------
|
|
308
311
|
# Repos
|
|
309
312
|
# --------------------------------------------------------------------------
|
|
310
|
-
def
|
|
313
|
+
def _get_current_repos_and_team_perms(self, ignore_archived: bool) -> None:
|
|
311
314
|
"""Get all repos, their current teams and their permissions"""
|
|
312
315
|
for repo in list(self.org.get_repos()):
|
|
313
|
-
|
|
316
|
+
# Check if repo is archived. If so, ignore it, if user requested so
|
|
317
|
+
if ignore_archived and repo.archived:
|
|
318
|
+
logging.debug(
|
|
319
|
+
"Ignoring %s as it is archived and user requested to ignore such repos",
|
|
320
|
+
repo.name,
|
|
321
|
+
)
|
|
322
|
+
self.archived_repos.append(repo)
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
self.current_repos_teams[repo] = {}
|
|
314
326
|
for team in list(repo.get_teams()):
|
|
315
|
-
self.
|
|
327
|
+
self.current_repos_teams[repo][team] = team.permission
|
|
316
328
|
|
|
317
329
|
def _create_perms_changelist_for_teams(
|
|
318
330
|
self,
|
|
319
|
-
) -> dict[Team
|
|
331
|
+
) -> dict[Team, dict[Repository, str]]:
|
|
320
332
|
"""Create a permission/repo changelist from the perspective of configured teams"""
|
|
321
|
-
team_changelist: dict[Team
|
|
333
|
+
team_changelist: dict[Team, dict[Repository, str]] = {}
|
|
322
334
|
for team_name, team_attrs in self.configured_teams.items():
|
|
323
335
|
# Handle unset configured attributes
|
|
324
336
|
if team_attrs is None:
|
|
@@ -341,7 +353,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
341
353
|
)
|
|
342
354
|
continue
|
|
343
355
|
|
|
344
|
-
if perm != self.
|
|
356
|
+
if perm != self.current_repos_teams[repo].get(team):
|
|
345
357
|
# Add the changeset to the changelist
|
|
346
358
|
if team not in team_changelist:
|
|
347
359
|
team_changelist[team] = {}
|
|
@@ -349,12 +361,41 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
349
361
|
|
|
350
362
|
return team_changelist
|
|
351
363
|
|
|
352
|
-
def
|
|
364
|
+
def _document_unconfigured_team_repo_permissions(
|
|
365
|
+
self, team: Team, team_permission: str, repo_name: str
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Create a record of all members of a team and their permissions on a
|
|
368
|
+
repo due to being member of an unconfigured team"""
|
|
369
|
+
users_of_unconfigured_team: dict[NamedUser, str] = self.current_teams[team].get(
|
|
370
|
+
"members"
|
|
371
|
+
) # type: ignore
|
|
372
|
+
# Initiate this repo in the dict as dict if not present
|
|
373
|
+
if repo_name not in self.unconfigured_team_repo_permissions:
|
|
374
|
+
self.unconfigured_team_repo_permissions[repo_name] = {}
|
|
375
|
+
# Add actual permission for each user of this unconfigured team
|
|
376
|
+
for user in users_of_unconfigured_team:
|
|
377
|
+
# Handle if another, potentially higher permission is already set by
|
|
378
|
+
# membership in another team
|
|
379
|
+
if exist_perm := self.unconfigured_team_repo_permissions[repo_name].get(user.login, ""):
|
|
380
|
+
logging.debug(
|
|
381
|
+
"Permissions for %s on %s already exist: %s. "
|
|
382
|
+
"Checking whether new permission is higher.",
|
|
383
|
+
user.login,
|
|
384
|
+
repo_name,
|
|
385
|
+
exist_perm,
|
|
386
|
+
)
|
|
387
|
+
self.unconfigured_team_repo_permissions[repo_name][user.login] = (
|
|
388
|
+
self._get_highest_permission(exist_perm, team_permission)
|
|
389
|
+
)
|
|
390
|
+
else:
|
|
391
|
+
self.unconfigured_team_repo_permissions[repo_name][user.login] = team_permission
|
|
392
|
+
|
|
393
|
+
def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False) -> None:
|
|
353
394
|
"""Synchronise the repository permissions of all teams"""
|
|
354
395
|
logging.debug("Starting to sync repo/team permissions")
|
|
355
396
|
|
|
356
397
|
# Get all repos and their current permissions from GitHub
|
|
357
|
-
self.
|
|
398
|
+
self._get_current_repos_and_team_perms(ignore_archived)
|
|
358
399
|
|
|
359
400
|
# Find differences between configured permissions for a team's repo and the current state
|
|
360
401
|
for team, repos in self._create_perms_changelist_for_teams().items():
|
|
@@ -371,7 +412,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
371
412
|
|
|
372
413
|
# Find out whether repos' permissions contain *configured* teams that
|
|
373
414
|
# should not have permissions
|
|
374
|
-
for repo, teams in self.
|
|
415
|
+
for repo, teams in self.current_repos_teams.items():
|
|
375
416
|
for team in teams:
|
|
376
417
|
# Get configured repos for this team, finding out whether repo
|
|
377
418
|
# is configured for this team
|
|
@@ -384,6 +425,13 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
384
425
|
team.name,
|
|
385
426
|
repo.name,
|
|
386
427
|
)
|
|
428
|
+
# Store information about these team members and their
|
|
429
|
+
# permissions on the repo. We will use it later in the
|
|
430
|
+
# collaborators step
|
|
431
|
+
self._document_unconfigured_team_repo_permissions(
|
|
432
|
+
team=team, team_permission=teams[team], repo_name=repo.name
|
|
433
|
+
)
|
|
434
|
+
# Abort handling the repo sync as we don't touch unconfigured teams
|
|
387
435
|
continue
|
|
388
436
|
# Handle: Team is configured, but contains no config
|
|
389
437
|
if self.configured_teams[team.name] is None:
|
|
@@ -403,3 +451,203 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
403
451
|
logging.info("Removing team '%s' from repository '%s'", team.name, repo.name)
|
|
404
452
|
if not dry:
|
|
405
453
|
team.remove_from_repos(repo)
|
|
454
|
+
|
|
455
|
+
# --------------------------------------------------------------------------
|
|
456
|
+
# Collaborators
|
|
457
|
+
# --------------------------------------------------------------------------
|
|
458
|
+
def _aggregate_lists(self, *lists: list[str | int]) -> list[str | int]:
|
|
459
|
+
"""Combine multiple lists into one while removing duplicates"""
|
|
460
|
+
complete = []
|
|
461
|
+
for single_list in lists:
|
|
462
|
+
complete.extend(single_list)
|
|
463
|
+
|
|
464
|
+
return list(set(complete))
|
|
465
|
+
|
|
466
|
+
def _get_highest_permission(self, *permissions: str) -> str:
|
|
467
|
+
"""Get the highest GitHub repo permissions out of multiple permissions"""
|
|
468
|
+
perms_ranking = ["admin", "maintain", "push", "triage", "pull"]
|
|
469
|
+
for perm in perms_ranking:
|
|
470
|
+
# If e.g. "maintain" matches one of the two permissions
|
|
471
|
+
if perm in permissions:
|
|
472
|
+
logging.debug("%s is the highest permission", perm)
|
|
473
|
+
return perm
|
|
474
|
+
|
|
475
|
+
return ""
|
|
476
|
+
|
|
477
|
+
def _get_configured_repos_and_user_perms(self):
|
|
478
|
+
"""
|
|
479
|
+
Get a list of repos with a list of individuals and their permissions,
|
|
480
|
+
based on their team memberships
|
|
481
|
+
"""
|
|
482
|
+
for _, team_attrs in self.configured_teams.items():
|
|
483
|
+
for repo, perm in team_attrs.get("repos", {}).items():
|
|
484
|
+
# Create repo if non-exist
|
|
485
|
+
if repo not in self.configured_repos_collaborators:
|
|
486
|
+
self.configured_repos_collaborators[repo] = {}
|
|
487
|
+
|
|
488
|
+
# Get team maintainers and members
|
|
489
|
+
team_members = self._aggregate_lists(
|
|
490
|
+
team_attrs.get("maintainer", []), team_attrs.get("member", [])
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# Add team member to repo with their repo permissions
|
|
494
|
+
for team_member in team_members:
|
|
495
|
+
# Check if permissions already exist
|
|
496
|
+
if self.configured_repos_collaborators[repo].get(team_member, {}):
|
|
497
|
+
logging.debug(
|
|
498
|
+
"Permissions for %s on %s already exist: %s. "
|
|
499
|
+
"Checking whether new permission is higher.",
|
|
500
|
+
team_member,
|
|
501
|
+
repo,
|
|
502
|
+
self.configured_repos_collaborators[repo][team_member],
|
|
503
|
+
)
|
|
504
|
+
self.configured_repos_collaborators[repo][team_member] = (
|
|
505
|
+
self._get_highest_permission(
|
|
506
|
+
perm, self.configured_repos_collaborators[repo][team_member]
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
else:
|
|
510
|
+
self.configured_repos_collaborators[repo][team_member] = perm
|
|
511
|
+
|
|
512
|
+
def _convert_graphql_perm_to_rest(self, permission: str) -> str:
|
|
513
|
+
"""Convert a repo permission coming from the GraphQL API to the ones
|
|
514
|
+
coming from the REST API"""
|
|
515
|
+
perm_conversion = {
|
|
516
|
+
"READ": "pull",
|
|
517
|
+
"TRIAGE": "triage",
|
|
518
|
+
"WRITE": "push",
|
|
519
|
+
"MAINTAIN": "maintain",
|
|
520
|
+
"ADMIN": "admin",
|
|
521
|
+
}
|
|
522
|
+
if permission in perm_conversion:
|
|
523
|
+
replacement = perm_conversion.get(permission, "")
|
|
524
|
+
return replacement
|
|
525
|
+
|
|
526
|
+
return permission
|
|
527
|
+
|
|
528
|
+
def _fetch_collaborators_of_repo(self, repo: Repository):
|
|
529
|
+
"""Get all collaborators (individuals) of a GitHub repo with their
|
|
530
|
+
permissions using the GraphQL API"""
|
|
531
|
+
query = """
|
|
532
|
+
query($owner: String!, $name: String!, $cursor: String) {
|
|
533
|
+
repository(owner: $owner, name: $name) {
|
|
534
|
+
collaborators(first: 20, after: $cursor) {
|
|
535
|
+
edges {
|
|
536
|
+
node {
|
|
537
|
+
login
|
|
538
|
+
}
|
|
539
|
+
permission
|
|
540
|
+
}
|
|
541
|
+
pageInfo {
|
|
542
|
+
endCursor
|
|
543
|
+
hasNextPage
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
# Initial query parameters
|
|
551
|
+
variables = {"owner": self.org.login, "name": repo.name, "cursor": None}
|
|
552
|
+
|
|
553
|
+
collaborators = []
|
|
554
|
+
has_next_page = True
|
|
555
|
+
|
|
556
|
+
while has_next_page:
|
|
557
|
+
result = run_graphql_query(query, variables, self.gh_token)
|
|
558
|
+
try:
|
|
559
|
+
collaborators.extend(result["data"]["repository"]["collaborators"]["edges"])
|
|
560
|
+
has_next_page = result["data"]["repository"]["collaborators"]["pageInfo"][
|
|
561
|
+
"hasNextPage"
|
|
562
|
+
]
|
|
563
|
+
variables["cursor"] = result["data"]["repository"]["collaborators"]["pageInfo"][
|
|
564
|
+
"endCursor"
|
|
565
|
+
]
|
|
566
|
+
except (TypeError, KeyError):
|
|
567
|
+
logging.debug("Repo %s does not seem to have any collaborators", repo.name)
|
|
568
|
+
continue
|
|
569
|
+
|
|
570
|
+
# Extract relevant data
|
|
571
|
+
for collaborator in collaborators:
|
|
572
|
+
login = collaborator["node"]["login"]
|
|
573
|
+
# Skip entry if collaborator is org owner, which is "admin" anyway
|
|
574
|
+
if login in [user.login for user in self.org_owners]:
|
|
575
|
+
continue
|
|
576
|
+
permission = self._convert_graphql_perm_to_rest(collaborator["permission"])
|
|
577
|
+
self.current_repos_collaborators[repo][login] = permission
|
|
578
|
+
|
|
579
|
+
def _get_current_repos_and_user_perms(self):
|
|
580
|
+
"""Get all repos, their current collaborators and their permissions"""
|
|
581
|
+
# We copy the list of repos from self.current_repos_teams
|
|
582
|
+
for repo in self.current_repos_teams:
|
|
583
|
+
self.current_repos_collaborators[repo] = {}
|
|
584
|
+
|
|
585
|
+
for repo in self.current_repos_collaborators:
|
|
586
|
+
# Get users for this repo
|
|
587
|
+
self._fetch_collaborators_of_repo(repo)
|
|
588
|
+
|
|
589
|
+
def sync_repo_collaborator_permissions(self, dry: bool = False):
|
|
590
|
+
"""Compare the configured with the current repo permissions for all
|
|
591
|
+
repositories' collaborators"""
|
|
592
|
+
# Collect info about all repos, their configured collaborators (through
|
|
593
|
+
# team membership) and the current state (either through team membership
|
|
594
|
+
# or individual).
|
|
595
|
+
# The resulting structure is:
|
|
596
|
+
# - configured_repos_collaborators: dict[Repository, dict[username, permission]]
|
|
597
|
+
# - current_repos_collaborators: dict[Repository, dict[username, permission]]
|
|
598
|
+
self._get_configured_repos_and_user_perms()
|
|
599
|
+
self._get_current_repos_and_user_perms()
|
|
600
|
+
|
|
601
|
+
# Loop over all factually existing repositories. This will be a one-way
|
|
602
|
+
# sync. Team permissions have been set before, we are now removing
|
|
603
|
+
# surplus permissions. As no individual permissions are allowed, these
|
|
604
|
+
# will be fully revoked.
|
|
605
|
+
for repo, current_repo_perms in self.current_repos_collaborators.items():
|
|
606
|
+
for username, current_perm in current_repo_perms.items():
|
|
607
|
+
# Get configured user permissions for this repo
|
|
608
|
+
try:
|
|
609
|
+
config_perm = self.configured_repos_collaborators[repo.name][username]
|
|
610
|
+
# There is no configured permission for this user in this repo
|
|
611
|
+
except KeyError:
|
|
612
|
+
config_perm = ""
|
|
613
|
+
|
|
614
|
+
if current_perm != config_perm:
|
|
615
|
+
# Find out whether user has these unconfigured permissions
|
|
616
|
+
# due to being member of an unconfigured team. Check whether
|
|
617
|
+
# these are the same permissions as the team would get them.
|
|
618
|
+
unconfigured_team_repo_permission = self.unconfigured_team_repo_permissions.get(
|
|
619
|
+
repo.name, {}
|
|
620
|
+
).get(username, "")
|
|
621
|
+
if unconfigured_team_repo_permission:
|
|
622
|
+
if current_perm == unconfigured_team_repo_permission:
|
|
623
|
+
logging.info(
|
|
624
|
+
"User %s has '%s' permission on repo '%s' due to being member of "
|
|
625
|
+
"an unconfigured team, and this matches their current permission. "
|
|
626
|
+
"Will not make any changes therefore.",
|
|
627
|
+
username,
|
|
628
|
+
current_perm,
|
|
629
|
+
repo.name,
|
|
630
|
+
)
|
|
631
|
+
continue
|
|
632
|
+
|
|
633
|
+
logging.info(
|
|
634
|
+
"User %s should have '%s' permissions on repo '%s' due to being member "
|
|
635
|
+
"of an unconfigured team, but their current permission on the "
|
|
636
|
+
"repo is '%s'. Removing them from collaborators therefore.",
|
|
637
|
+
username,
|
|
638
|
+
unconfigured_team_repo_permission,
|
|
639
|
+
repo.name,
|
|
640
|
+
current_perm,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
logging.info(
|
|
644
|
+
"Remove %s from %s. They have '%s' there but should only have '%s'.",
|
|
645
|
+
username,
|
|
646
|
+
repo.name,
|
|
647
|
+
current_perm,
|
|
648
|
+
config_perm,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
# Remove collaborator
|
|
652
|
+
if not dry:
|
|
653
|
+
repo.remove_from_collaborators(username)
|
|
@@ -23,6 +23,12 @@ parser.add_argument(
|
|
|
23
23
|
)
|
|
24
24
|
parser.add_argument("--debug", action="store_true", help="Get verbose logging output")
|
|
25
25
|
parser.add_argument("--dry", action="store_true", help="Do not make any changes at GitHub")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"-A",
|
|
28
|
+
"--ignore-archived",
|
|
29
|
+
action="store_true",
|
|
30
|
+
help="Do not take any action in ignored repositories",
|
|
31
|
+
)
|
|
26
32
|
parser.add_argument("--version", action="version", version="GitHub Team Manager " + __version__)
|
|
27
33
|
|
|
28
34
|
|
|
@@ -59,7 +65,10 @@ def main():
|
|
|
59
65
|
# Report about organisation members that do not belong to any team
|
|
60
66
|
org.get_members_without_team()
|
|
61
67
|
# Synchronise the permissions of teams for all repositories
|
|
62
|
-
org.sync_repo_permissions(dry=args.dry)
|
|
68
|
+
org.sync_repo_permissions(dry=args.dry, ignore_archived=args.ignore_archived)
|
|
69
|
+
# Remove individual collaborator permissions if they are higher than the one
|
|
70
|
+
# from team membership (or if they are in no configured team at all)
|
|
71
|
+
org.sync_repo_collaborator_permissions(dry=args.dry)
|
|
63
72
|
|
|
64
73
|
# Debug output
|
|
65
74
|
logging.debug("Final dataclass:\n%s", org.df2json())
|
|
@@ -4,11 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "github-org-manager"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.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"
|
|
11
|
-
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
repository = "https://github.com/OpenRailAssociation/github-org-manager"
|
|
13
|
+
keywords = ["github", "github-management", "permissions", "access-control"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
18
|
+
"Topic :: Utilities",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Intended Audience :: System Administrators",
|
|
21
|
+
"License :: OSI Approved :: Apache Software License",
|
|
22
|
+
]
|
|
23
|
+
packages = [{ include = "gh_org_mgr" }]
|
|
12
24
|
|
|
13
25
|
[tool.poetry.scripts]
|
|
14
26
|
gh-org-mgr = 'gh_org_mgr.manage:main'
|
|
@@ -17,6 +29,7 @@ gh-org-mgr = 'gh_org_mgr.manage:main'
|
|
|
17
29
|
python = "^3.10"
|
|
18
30
|
pygithub = "^2.3.0"
|
|
19
31
|
pyyaml = "^6.0.1"
|
|
32
|
+
requests = "^2.32.3"
|
|
20
33
|
|
|
21
34
|
[tool.poetry.group.dev.dependencies]
|
|
22
35
|
black = "^24.3.0"
|
|
@@ -24,6 +37,7 @@ isort = "^5.13.2"
|
|
|
24
37
|
mypy = "^1.9.0"
|
|
25
38
|
pylint = "^3.1.0"
|
|
26
39
|
types-pyyaml = "^6.0.12.20240311"
|
|
40
|
+
types-requests = "^2.32.0.20240712"
|
|
27
41
|
|
|
28
42
|
[build-system]
|
|
29
43
|
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
|