github-org-manager 0.3.0__tar.gz → 0.4.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.3.0 → github_org_manager-0.4.0}/PKG-INFO +11 -5
- {github_org_manager-0.3.0 → github_org_manager-0.4.0}/README.md +9 -4
- {github_org_manager-0.3.0 → github_org_manager-0.4.0}/gh_org_mgr/_gh_org.py +118 -28
- github_org_manager-0.4.0/gh_org_mgr/_setup_team.py +118 -0
- github_org_manager-0.4.0/gh_org_mgr/manage.py +131 -0
- {github_org_manager-0.3.0 → github_org_manager-0.4.0}/pyproject.toml +3 -1
- github_org_manager-0.3.0/gh_org_mgr/manage.py +0 -75
- {github_org_manager-0.3.0 → github_org_manager-0.4.0}/LICENSE.txt +0 -0
- {github_org_manager-0.3.0 → github_org_manager-0.4.0}/LICENSES/Apache-2.0.txt +0 -0
- {github_org_manager-0.3.0 → github_org_manager-0.4.0}/LICENSES/CC-BY-4.0.txt +0 -0
- {github_org_manager-0.3.0 → github_org_manager-0.4.0}/LICENSES/CC0-1.0.txt +0 -0
- {github_org_manager-0.3.0 → github_org_manager-0.4.0}/LICENSES/MIT.txt +0 -0
- {github_org_manager-0.3.0 → github_org_manager-0.4.0}/gh_org_mgr/__init__.py +0 -0
- {github_org_manager-0.3.0 → github_org_manager-0.4.0}/gh_org_mgr/_config.py +0 -0
- {github_org_manager-0.3.0 → github_org_manager-0.4.0}/gh_org_mgr/_gh_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: github-org-manager
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Manage a GitHub Organization, its teams, repository permissions, and more
|
|
5
5
|
Home-page: https://github.com/OpenRailAssociation/github-org-manager
|
|
6
6
|
License: Apache-2.0
|
|
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
20
20
|
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
21
21
|
Classifier: Topic :: Utilities
|
|
22
22
|
Requires-Dist: pygithub (>=2.3.0,<3.0.0)
|
|
23
|
+
Requires-Dist: python-slugify (>=8.0.4,<9.0.0)
|
|
23
24
|
Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
|
|
24
25
|
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
25
26
|
Project-URL: Repository, https://github.com/OpenRailAssociation/github-org-manager
|
|
@@ -77,11 +78,16 @@ You may also be interested in the [live configuration of the OpenRail Associatio
|
|
|
77
78
|
|
|
78
79
|
You can execute the program using the command `gh-org-mgr`. `gh-org-mgr --help` shows all available arguments and options.
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
Synchronisation examples:
|
|
81
82
|
|
|
82
|
-
* `gh-org-mgr -c myorgconf`: synchronize the settings of the GitHub organization with your local configuration in the given configuration path (`myorgconf`). This may create new teams, remove/add members, and change permissions.
|
|
83
|
-
* `gh-org-mgr -c myorgconf --dry`: as above, but do not make any modification. Perfect for testing your local configuration and see its potential effects.
|
|
84
|
-
* `gh-org-mgr -c myorgconf --debug`: the first example, but show full debugging information.
|
|
83
|
+
* `gh-org-mgr sync -c myorgconf`: synchronize the settings of the GitHub organization with your local configuration in the given configuration path (`myorgconf`). This may create new teams, remove/add members, and change permissions.
|
|
84
|
+
* `gh-org-mgr sync -c myorgconf --dry`: as above, but do not make any modification. Perfect for testing your local configuration and see its potential effects.
|
|
85
|
+
* `gh-org-mgr sync -c myorgconf --debug`: the first example, but show full debugging information.
|
|
86
|
+
|
|
87
|
+
Setup team examples:
|
|
88
|
+
|
|
89
|
+
* `gh-org-mgr setup-team -n "My Team Name" -c myorgconf`: Bootstrap a team configuration for this team name. Will create a file `myorgconf/teams/my-team-name.yaml`, or provide options if this file already exists.
|
|
90
|
+
* `gh-org-mgr setup-team -n "My Team Name" -f path/to/myteam.yaml`: Bootstrap a team configuration for this team name and will force to write it in the given file. If the file already exists, offer some options.
|
|
85
91
|
|
|
86
92
|
## License
|
|
87
93
|
|
|
@@ -50,11 +50,16 @@ You may also be interested in the [live configuration of the OpenRail Associatio
|
|
|
50
50
|
|
|
51
51
|
You can execute the program using the command `gh-org-mgr`. `gh-org-mgr --help` shows all available arguments and options.
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
Synchronisation examples:
|
|
54
54
|
|
|
55
|
-
* `gh-org-mgr -c myorgconf`: synchronize the settings of the GitHub organization with your local configuration in the given configuration path (`myorgconf`). This may create new teams, remove/add members, and change permissions.
|
|
56
|
-
* `gh-org-mgr -c myorgconf --dry`: as above, but do not make any modification. Perfect for testing your local configuration and see its potential effects.
|
|
57
|
-
* `gh-org-mgr -c myorgconf --debug`: the first example, but show full debugging information.
|
|
55
|
+
* `gh-org-mgr sync -c myorgconf`: synchronize the settings of the GitHub organization with your local configuration in the given configuration path (`myorgconf`). This may create new teams, remove/add members, and change permissions.
|
|
56
|
+
* `gh-org-mgr sync -c myorgconf --dry`: as above, but do not make any modification. Perfect for testing your local configuration and see its potential effects.
|
|
57
|
+
* `gh-org-mgr sync -c myorgconf --debug`: the first example, but show full debugging information.
|
|
58
|
+
|
|
59
|
+
Setup team examples:
|
|
60
|
+
|
|
61
|
+
* `gh-org-mgr setup-team -n "My Team Name" -c myorgconf`: Bootstrap a team configuration for this team name. Will create a file `myorgconf/teams/my-team-name.yaml`, or provide options if this file already exists.
|
|
62
|
+
* `gh-org-mgr setup-team -n "My Team Name" -f path/to/myteam.yaml`: Bootstrap a team configuration for this team name and will force to write it in the given file. If the file already exists, offer some options.
|
|
58
63
|
|
|
59
64
|
## License
|
|
60
65
|
|
|
@@ -23,6 +23,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
23
23
|
gh: Github = None # type: ignore
|
|
24
24
|
org: Organization = None # type: ignore
|
|
25
25
|
gh_token: str = ""
|
|
26
|
+
default_repository_permission: str = ""
|
|
26
27
|
org_owners: list[NamedUser] = field(default_factory=list)
|
|
27
28
|
org_members: list[NamedUser] = field(default_factory=list)
|
|
28
29
|
current_teams: dict[Team, dict] = field(default_factory=dict)
|
|
@@ -59,6 +60,18 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
59
60
|
"""Convert the dataclass to a JSON string"""
|
|
60
61
|
d = asdict(self)
|
|
61
62
|
|
|
63
|
+
# Censor sensible fields
|
|
64
|
+
def censor_half_string(string: str) -> str:
|
|
65
|
+
"""Censor 50% of a string (rounded up)"""
|
|
66
|
+
half1 = int(len(string) / 2)
|
|
67
|
+
half2 = len(string) - half1
|
|
68
|
+
return string[:half1] + "*" * (half2)
|
|
69
|
+
|
|
70
|
+
sensible_keys = ["gh_token"]
|
|
71
|
+
for key in sensible_keys:
|
|
72
|
+
d[key] = censor_half_string(d.get(key, ""))
|
|
73
|
+
|
|
74
|
+
# Print dict nicely
|
|
62
75
|
def pretty(d, indent=0):
|
|
63
76
|
string = ""
|
|
64
77
|
for key, value in d.items():
|
|
@@ -75,9 +88,8 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
75
88
|
# --------------------------------------------------------------------------
|
|
76
89
|
# Teams
|
|
77
90
|
# --------------------------------------------------------------------------
|
|
78
|
-
def
|
|
91
|
+
def _get_current_teams(self):
|
|
79
92
|
"""Get teams of the existing organisation"""
|
|
80
|
-
|
|
81
93
|
for team in list(self.org.get_teams()):
|
|
82
94
|
self.current_teams[team] = {"members": {}, "repos": {}}
|
|
83
95
|
|
|
@@ -85,7 +97,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
85
97
|
"""Find out which teams are configured but not part of the org yet"""
|
|
86
98
|
|
|
87
99
|
# Get list of current teams
|
|
88
|
-
self.
|
|
100
|
+
self._get_current_teams()
|
|
89
101
|
|
|
90
102
|
# Get the names of the existing teams
|
|
91
103
|
existent_team_names = [team.name for team in self.current_teams]
|
|
@@ -108,7 +120,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
108
120
|
logging.debug("Team '%s' already exists", team)
|
|
109
121
|
|
|
110
122
|
# Re-scan current teams as new ones may have been created
|
|
111
|
-
self.
|
|
123
|
+
self._get_current_teams()
|
|
112
124
|
|
|
113
125
|
# --------------------------------------------------------------------------
|
|
114
126
|
# Members
|
|
@@ -474,13 +486,55 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
474
486
|
|
|
475
487
|
return ""
|
|
476
488
|
|
|
489
|
+
def _get_direct_repo_permissions_of_team(self, team_dict: dict) -> tuple[dict[str, str], str]:
|
|
490
|
+
"""Get a list of directly configured repo permissions for a team, and
|
|
491
|
+
whether the team has a parent"""
|
|
492
|
+
repo_perms: dict[str, str] = {}
|
|
493
|
+
# Direct permissions
|
|
494
|
+
for repo, perm in team_dict.get("repos", {}).items():
|
|
495
|
+
repo_perms[repo] = perm
|
|
496
|
+
|
|
497
|
+
# Parent team
|
|
498
|
+
parent = team_dict.get("parent", "")
|
|
499
|
+
|
|
500
|
+
return repo_perms, parent
|
|
501
|
+
|
|
502
|
+
def _get_all_repo_permissions_for_team_and_parents(self, team_name: str, team_dict: dict):
|
|
503
|
+
"""Get a list of all configured repo permissions for a team, also those
|
|
504
|
+
inherited by parent teams"""
|
|
505
|
+
all_repo_perms, parent = self._get_direct_repo_permissions_of_team(team_dict=team_dict)
|
|
506
|
+
# If parents have been found, iterate and merge them
|
|
507
|
+
while parent:
|
|
508
|
+
logging.debug(
|
|
509
|
+
"Checking for repository permissions of %s's parent team %s", team_name, parent
|
|
510
|
+
)
|
|
511
|
+
parent_team_dict = self.configured_teams[parent]
|
|
512
|
+
|
|
513
|
+
# Handle empty parent dict
|
|
514
|
+
if not parent_team_dict:
|
|
515
|
+
break
|
|
516
|
+
|
|
517
|
+
# Get repo permissions and potential parent, and add it
|
|
518
|
+
repo_perm, parent = self._get_direct_repo_permissions_of_team(
|
|
519
|
+
team_dict=parent_team_dict
|
|
520
|
+
)
|
|
521
|
+
for repo, perm in repo_perm.items():
|
|
522
|
+
# Add (highest) repo permission
|
|
523
|
+
all_repo_perms[repo] = self._get_highest_permission(
|
|
524
|
+
perm, all_repo_perms.get(repo, "")
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
return all_repo_perms
|
|
528
|
+
|
|
477
529
|
def _get_configured_repos_and_user_perms(self):
|
|
478
530
|
"""
|
|
479
531
|
Get a list of repos with a list of individuals and their permissions,
|
|
480
532
|
based on their team memberships
|
|
481
533
|
"""
|
|
482
|
-
for
|
|
483
|
-
|
|
534
|
+
for team_name, team_attrs in self.configured_teams.items():
|
|
535
|
+
logging.debug("Getting configured repository permissions for team %s", team_name)
|
|
536
|
+
repo_perms = self._get_all_repo_permissions_for_team_and_parents(team_name, team_attrs)
|
|
537
|
+
for repo, perm in repo_perms.items():
|
|
484
538
|
# Create repo if non-exist
|
|
485
539
|
if repo not in self.configured_repos_collaborators:
|
|
486
540
|
self.configured_repos_collaborators[repo] = {}
|
|
@@ -513,14 +567,15 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
513
567
|
"""Convert a repo permission coming from the GraphQL API to the ones
|
|
514
568
|
coming from the REST API"""
|
|
515
569
|
perm_conversion = {
|
|
516
|
-
"
|
|
517
|
-
"
|
|
518
|
-
"
|
|
519
|
-
"
|
|
520
|
-
"
|
|
570
|
+
"none": "",
|
|
571
|
+
"read": "pull",
|
|
572
|
+
"triage": "triage",
|
|
573
|
+
"write": "push",
|
|
574
|
+
"maintain": "maintain",
|
|
575
|
+
"admin": "admin",
|
|
521
576
|
}
|
|
522
|
-
if permission in perm_conversion:
|
|
523
|
-
replacement = perm_conversion.get(permission, "")
|
|
577
|
+
if permission.lower() in perm_conversion:
|
|
578
|
+
replacement = perm_conversion.get(permission.lower(), "")
|
|
524
579
|
return replacement
|
|
525
580
|
|
|
526
581
|
return permission
|
|
@@ -528,20 +583,22 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
528
583
|
def _fetch_collaborators_of_repo(self, repo: Repository):
|
|
529
584
|
"""Get all collaborators (individuals) of a GitHub repo with their
|
|
530
585
|
permissions using the GraphQL API"""
|
|
586
|
+
# TODO: Consider doing this for all repositories at once, but calculate
|
|
587
|
+
# costs beforehand
|
|
531
588
|
query = """
|
|
532
589
|
query($owner: String!, $name: String!, $cursor: String) {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
590
|
+
repository(owner: $owner, name: $name) {
|
|
591
|
+
collaborators(first: 100, after: $cursor) {
|
|
592
|
+
edges {
|
|
593
|
+
node {
|
|
594
|
+
login
|
|
595
|
+
}
|
|
596
|
+
permission
|
|
597
|
+
}
|
|
598
|
+
pageInfo {
|
|
599
|
+
endCursor
|
|
600
|
+
hasNextPage
|
|
601
|
+
}
|
|
545
602
|
}
|
|
546
603
|
}
|
|
547
604
|
}
|
|
@@ -554,6 +611,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
554
611
|
has_next_page = True
|
|
555
612
|
|
|
556
613
|
while has_next_page:
|
|
614
|
+
logging.debug("Requesting collaborators for %s", repo.name)
|
|
557
615
|
result = run_graphql_query(query, variables, self.gh_token)
|
|
558
616
|
try:
|
|
559
617
|
collaborators.extend(result["data"]["repository"]["collaborators"]["edges"])
|
|
@@ -586,6 +644,27 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
586
644
|
# Get users for this repo
|
|
587
645
|
self._fetch_collaborators_of_repo(repo)
|
|
588
646
|
|
|
647
|
+
def _get_default_repository_permission(self):
|
|
648
|
+
"""Get the default repository permission for all users. Convert to
|
|
649
|
+
admin/maintain/push/triage/pull scheme that the REST API provides"""
|
|
650
|
+
self.default_repository_permission = self._convert_graphql_perm_to_rest(
|
|
651
|
+
self.org.default_repository_permission
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
def _permission1_higher_than_permission2(self, permission1: str, permission2: str) -> bool:
|
|
655
|
+
"""Check whether permission 1 is higher than permission 2"""
|
|
656
|
+
perms_ranking = ["admin", "maintain", "push", "triage", "pull", ""]
|
|
657
|
+
|
|
658
|
+
def get_rank(permission):
|
|
659
|
+
return perms_ranking.index(permission) if permission in perms_ranking else 99
|
|
660
|
+
|
|
661
|
+
rank_permission1 = get_rank(permission1)
|
|
662
|
+
rank_permission2 = get_rank(permission2)
|
|
663
|
+
|
|
664
|
+
# The lower the index, the higher the permission. If lower than
|
|
665
|
+
# permission2, return True
|
|
666
|
+
return rank_permission1 < rank_permission2
|
|
667
|
+
|
|
589
668
|
def sync_repo_collaborator_permissions(self, dry: bool = False):
|
|
590
669
|
"""Compare the configured with the current repo permissions for all
|
|
591
670
|
repositories' collaborators"""
|
|
@@ -595,9 +674,13 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
595
674
|
# The resulting structure is:
|
|
596
675
|
# - configured_repos_collaborators: dict[Repository, dict[username, permission]]
|
|
597
676
|
# - current_repos_collaborators: dict[Repository, dict[username, permission]]
|
|
677
|
+
logging.debug("Starting to sync collaborator/individual permissions")
|
|
598
678
|
self._get_configured_repos_and_user_perms()
|
|
599
679
|
self._get_current_repos_and_user_perms()
|
|
600
680
|
|
|
681
|
+
# Get and convert the default permission for all members so we can check for it
|
|
682
|
+
self._get_default_repository_permission()
|
|
683
|
+
|
|
601
684
|
# Loop over all factually existing repositories. This will be a one-way
|
|
602
685
|
# sync. Team permissions have been set before, we are now removing
|
|
603
686
|
# surplus permissions. As no individual permissions are allowed, these
|
|
@@ -607,17 +690,21 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
607
690
|
# Get configured user permissions for this repo
|
|
608
691
|
try:
|
|
609
692
|
config_perm = self.configured_repos_collaborators[repo.name][username]
|
|
610
|
-
# There is no configured permission for this user in this repo
|
|
693
|
+
# There is no configured permission for this user in this repo,
|
|
694
|
+
# so we assume the default permission
|
|
611
695
|
except KeyError:
|
|
612
|
-
config_perm =
|
|
696
|
+
config_perm = self.default_repository_permission
|
|
613
697
|
|
|
614
|
-
|
|
698
|
+
# Evaluate whether current permission is higher than configured
|
|
699
|
+
# permission
|
|
700
|
+
if self._permission1_higher_than_permission2(current_perm, config_perm):
|
|
615
701
|
# Find out whether user has these unconfigured permissions
|
|
616
702
|
# due to being member of an unconfigured team. Check whether
|
|
617
703
|
# these are the same permissions as the team would get them.
|
|
618
704
|
unconfigured_team_repo_permission = self.unconfigured_team_repo_permissions.get(
|
|
619
705
|
repo.name, {}
|
|
620
706
|
).get(username, "")
|
|
707
|
+
|
|
621
708
|
if unconfigured_team_repo_permission:
|
|
622
709
|
if current_perm == unconfigured_team_repo_permission:
|
|
623
710
|
logging.info(
|
|
@@ -640,6 +727,9 @@ class GHorg: # pylint: disable=too-many-instance-attributes
|
|
|
640
727
|
current_perm,
|
|
641
728
|
)
|
|
642
729
|
|
|
730
|
+
# Remove person from repo, but only if their repository also
|
|
731
|
+
# diverges from the default repository permission given by
|
|
732
|
+
# the organization
|
|
643
733
|
logging.info(
|
|
644
734
|
"Remove %s from %s. They have '%s' there but should only have '%s'.",
|
|
645
735
|
username,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 DB Systel GmbH
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Functions to help with setting up new team"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from os.path import isfile, join
|
|
9
|
+
from string import Template
|
|
10
|
+
|
|
11
|
+
from slugify import slugify
|
|
12
|
+
|
|
13
|
+
TEAM_TEMPLATE = """
|
|
14
|
+
${team_name}:
|
|
15
|
+
# parent:
|
|
16
|
+
# repos:
|
|
17
|
+
# maintainer:
|
|
18
|
+
member:
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _sanitize_two_exclusive_options(option1: str | None, option2: str | None) -> bool:
|
|
23
|
+
"""Only of of these two options must be provided (not None, empty string is
|
|
24
|
+
OK). Returns True if no error ocourred"""
|
|
25
|
+
# There must not be a file_path and config_path provided at the same time
|
|
26
|
+
if option1 is not None and option2 is not None:
|
|
27
|
+
logging.critical("The two options must not be provided at the same time. Choose only one.")
|
|
28
|
+
return False
|
|
29
|
+
# There must at least be config_path or file_path configured
|
|
30
|
+
if option1 is None and option2 is None:
|
|
31
|
+
logging.critical("One of the two options must be provided")
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fill_template(template: str, **fillers) -> str:
|
|
38
|
+
"""Fill a template using a dicts with keys and their values. The function
|
|
39
|
+
looks for the keys starting with '$' characters"""
|
|
40
|
+
return Template(template).substitute(fillers).lstrip()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _ask_user_action(question: str, *options: str) -> str:
|
|
44
|
+
"""Ask the user a question and for an action. Return the chosen action."""
|
|
45
|
+
option_dict: dict[str, str] = {}
|
|
46
|
+
option_questions: list[str] = []
|
|
47
|
+
for option in options:
|
|
48
|
+
# Get first unique characters from the option
|
|
49
|
+
short = ""
|
|
50
|
+
i = 0
|
|
51
|
+
while not short:
|
|
52
|
+
i += 1
|
|
53
|
+
short_try = option[:i]
|
|
54
|
+
if short_try not in option_dict:
|
|
55
|
+
short = short_try
|
|
56
|
+
option_questions.append(option.replace(short, f"[{short}]", 1))
|
|
57
|
+
option_dict[option] = option
|
|
58
|
+
option_dict[short] = option
|
|
59
|
+
|
|
60
|
+
response = ""
|
|
61
|
+
while response not in option_dict:
|
|
62
|
+
response = input(f"{question} ({'/'.join(option_questions)}): ")
|
|
63
|
+
|
|
64
|
+
return option_dict[response]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_file(file: str, content: str, append: bool = False) -> None:
|
|
68
|
+
"""Write to a file. Overrides by default, but can also append"""
|
|
69
|
+
mode = "a" if append else "w"
|
|
70
|
+
try:
|
|
71
|
+
with open(file, mode=mode, encoding="UTF-8") as writer:
|
|
72
|
+
# Add linebreak if using append mode
|
|
73
|
+
if mode == "a":
|
|
74
|
+
writer.write("\n")
|
|
75
|
+
|
|
76
|
+
# Add content to file
|
|
77
|
+
writer.write(content)
|
|
78
|
+
except FileNotFoundError as exc:
|
|
79
|
+
logging.critical("File %s could not be written: %s", file, exc)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def setup_team(
|
|
83
|
+
team_name: str, config_path: str | None = None, file_path: str | None = None
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Set up a new team inside the config dir with a given name"""
|
|
86
|
+
_sanitize_two_exclusive_options(config_path, file_path)
|
|
87
|
+
|
|
88
|
+
# Come up with file name based on team name in the given config directory
|
|
89
|
+
if not file_path:
|
|
90
|
+
# Combine config dir and file name
|
|
91
|
+
file_path = join(config_path, "teams", slugify(team_name) + ".yaml") # type: ignore
|
|
92
|
+
logging.debug("Derived file path: %s", file_path)
|
|
93
|
+
|
|
94
|
+
# Fill template
|
|
95
|
+
yaml_content = _fill_template(TEAM_TEMPLATE, team_name=team_name)
|
|
96
|
+
|
|
97
|
+
# If file already exists, ask if file should be extended or overridden, or abort
|
|
98
|
+
if isfile(file_path):
|
|
99
|
+
options = ("override", "append", "print", "skip")
|
|
100
|
+
action = _ask_user_action(
|
|
101
|
+
f"The file {file_path} exists, what would you like to do?", *options
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
logging.debug("Chosen action: %s", action)
|
|
105
|
+
|
|
106
|
+
if action == "skip":
|
|
107
|
+
print("No action taken")
|
|
108
|
+
elif action == "print":
|
|
109
|
+
print()
|
|
110
|
+
print(yaml_content)
|
|
111
|
+
elif action in ("override", "append"):
|
|
112
|
+
append = action == "append"
|
|
113
|
+
write_file(file=file_path, content=yaml_content, append=append)
|
|
114
|
+
|
|
115
|
+
# File does not exist, write file
|
|
116
|
+
else:
|
|
117
|
+
print(f"Writing team configuration into {file_path}")
|
|
118
|
+
write_file(file=file_path, content=yaml_content)
|
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
from ._setup_team import setup_team
|
|
15
|
+
|
|
16
|
+
# Main parser with root-level flags
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--version", action="version", version="GitHub Organization Manager " + __version__
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Initiate first-level subcommands
|
|
25
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands", required=True)
|
|
26
|
+
|
|
27
|
+
# Common flags, usable for all effective subcommands
|
|
28
|
+
common_flags = argparse.ArgumentParser(add_help=False) # No automatic help to avoid duplication
|
|
29
|
+
common_flags.add_argument("--debug", action="store_true", help="Get verbose logging output")
|
|
30
|
+
|
|
31
|
+
# Sync commands
|
|
32
|
+
parser_sync = subparsers.add_parser(
|
|
33
|
+
"sync",
|
|
34
|
+
help="Synchronise GitHub organization settings and teams",
|
|
35
|
+
parents=[common_flags],
|
|
36
|
+
)
|
|
37
|
+
parser_sync.add_argument(
|
|
38
|
+
"-c",
|
|
39
|
+
"--config",
|
|
40
|
+
required=True,
|
|
41
|
+
help="Path to the directory in which the configuration of an GitHub organisation is located",
|
|
42
|
+
)
|
|
43
|
+
parser_sync.add_argument("--dry", action="store_true", help="Do not make any changes at GitHub")
|
|
44
|
+
parser_sync.add_argument(
|
|
45
|
+
"-A",
|
|
46
|
+
"--ignore-archived",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="Do not take any action in ignored repositories",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Setup Team
|
|
52
|
+
parser_create_team = subparsers.add_parser(
|
|
53
|
+
"setup-team",
|
|
54
|
+
help="Helps with setting up a new team using a base template",
|
|
55
|
+
parents=[common_flags],
|
|
56
|
+
)
|
|
57
|
+
parser_create_team.add_argument(
|
|
58
|
+
"-n",
|
|
59
|
+
"--name",
|
|
60
|
+
required=True,
|
|
61
|
+
help="Name of the team that shall be created",
|
|
62
|
+
)
|
|
63
|
+
parser_create_team_file = parser_create_team.add_mutually_exclusive_group(required=True)
|
|
64
|
+
parser_create_team_file.add_argument(
|
|
65
|
+
"-c",
|
|
66
|
+
"--config",
|
|
67
|
+
help=(
|
|
68
|
+
"Path to the directory in which the configuration of an GitHub organisation is located. "
|
|
69
|
+
"If this option is used, the tool will automatically come up with a file name"
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
parser_create_team_file.add_argument(
|
|
73
|
+
"-f",
|
|
74
|
+
"--file",
|
|
75
|
+
help="Path to the file in which the team shall be added",
|
|
76
|
+
)
|
|
77
|
+
# parser_create_team.add_argument(
|
|
78
|
+
# "-a",
|
|
79
|
+
# "--file-exists-action",
|
|
80
|
+
# help="Define which action shall be taken when the requested output file already exists",
|
|
81
|
+
# choices=["override", "extend", "skip"]
|
|
82
|
+
# )
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main():
|
|
86
|
+
"""Main function"""
|
|
87
|
+
|
|
88
|
+
# Process arguments
|
|
89
|
+
args = parser.parse_args()
|
|
90
|
+
|
|
91
|
+
configure_logger(args.debug)
|
|
92
|
+
|
|
93
|
+
# Sync command
|
|
94
|
+
if args.command == "sync":
|
|
95
|
+
if args.dry:
|
|
96
|
+
logging.info("Dry-run mode activated, will not make any changes at GitHub")
|
|
97
|
+
|
|
98
|
+
org = GHorg()
|
|
99
|
+
|
|
100
|
+
# Parse configuration folder, and do sanity check
|
|
101
|
+
cfg_org, cfg_app, org.configured_teams = parse_config_files(args.config)
|
|
102
|
+
if not cfg_org.get("org_name"):
|
|
103
|
+
logging.critical(
|
|
104
|
+
"No GitHub organisation name configured in organisation settings. Cannot continue"
|
|
105
|
+
)
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
# Login to GitHub with token, get GitHub organisation
|
|
109
|
+
org.login(cfg_org.get("org_name", ""), cfg_app.get("github_token", ""))
|
|
110
|
+
# Get current rate limit
|
|
111
|
+
org.ratelimit()
|
|
112
|
+
|
|
113
|
+
# Create teams that aren't present at Github yet
|
|
114
|
+
org.create_missing_teams(dry=args.dry)
|
|
115
|
+
# Synchronise the team memberships
|
|
116
|
+
org.sync_teams_members(dry=args.dry)
|
|
117
|
+
# Report about organisation members that do not belong to any team
|
|
118
|
+
org.get_members_without_team()
|
|
119
|
+
# Synchronise the permissions of teams for all repositories
|
|
120
|
+
org.sync_repo_permissions(dry=args.dry, ignore_archived=args.ignore_archived)
|
|
121
|
+
# Remove individual collaborator permissions if they are higher than the one
|
|
122
|
+
# from team membership (or if they are in no configured team at all)
|
|
123
|
+
org.sync_repo_collaborator_permissions(dry=args.dry)
|
|
124
|
+
|
|
125
|
+
# Debug output
|
|
126
|
+
logging.debug("Final dataclass:\n%s", org.df2json())
|
|
127
|
+
org.ratelimit()
|
|
128
|
+
|
|
129
|
+
# Setup Team command
|
|
130
|
+
elif args.command == "setup-team":
|
|
131
|
+
setup_team(team_name=args.name, config_path=args.config, file_path=args.file)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "github-org-manager"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.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"
|
|
@@ -30,6 +30,7 @@ python = "^3.10"
|
|
|
30
30
|
pygithub = "^2.3.0"
|
|
31
31
|
pyyaml = "^6.0.1"
|
|
32
32
|
requests = "^2.32.3"
|
|
33
|
+
python-slugify = "^8.0.4"
|
|
33
34
|
|
|
34
35
|
[tool.poetry.group.dev.dependencies]
|
|
35
36
|
black = "^24.3.0"
|
|
@@ -38,6 +39,7 @@ mypy = "^1.9.0"
|
|
|
38
39
|
pylint = "^3.1.0"
|
|
39
40
|
types-pyyaml = "^6.0.12.20240311"
|
|
40
41
|
types-requests = "^2.32.0.20240712"
|
|
42
|
+
bump2version = "^1.0.1"
|
|
41
43
|
|
|
42
44
|
[build-system]
|
|
43
45
|
requires = ["poetry-core"]
|
|
@@ -1,75 +0,0 @@
|
|
|
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(
|
|
27
|
-
"-A",
|
|
28
|
-
"--ignore-archived",
|
|
29
|
-
action="store_true",
|
|
30
|
-
help="Do not take any action in ignored repositories",
|
|
31
|
-
)
|
|
32
|
-
parser.add_argument("--version", action="version", version="GitHub Team Manager " + __version__)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def main():
|
|
36
|
-
"""Main function"""
|
|
37
|
-
|
|
38
|
-
# Process arguments
|
|
39
|
-
args = parser.parse_args()
|
|
40
|
-
|
|
41
|
-
configure_logger(args.debug)
|
|
42
|
-
|
|
43
|
-
if args.dry:
|
|
44
|
-
logging.info("Dry-run mode activated, will not make any changes at GitHub")
|
|
45
|
-
|
|
46
|
-
org = GHorg()
|
|
47
|
-
|
|
48
|
-
# Parse configuration folder, and do sanity check
|
|
49
|
-
cfg_org, cfg_app, org.configured_teams = parse_config_files(args.config)
|
|
50
|
-
if not cfg_org.get("org_name"):
|
|
51
|
-
logging.critical(
|
|
52
|
-
"No GitHub organisation name configured in organisation settings. Cannot continue"
|
|
53
|
-
)
|
|
54
|
-
sys.exit(1)
|
|
55
|
-
|
|
56
|
-
# Login to GitHub with token, get GitHub organisation
|
|
57
|
-
org.login(cfg_org.get("org_name", ""), cfg_app.get("github_token", ""))
|
|
58
|
-
# Get current rate limit
|
|
59
|
-
org.ratelimit()
|
|
60
|
-
|
|
61
|
-
# Create teams that aren't present at Github yet
|
|
62
|
-
org.create_missing_teams(dry=args.dry)
|
|
63
|
-
# Synchronise the team memberships
|
|
64
|
-
org.sync_teams_members(dry=args.dry)
|
|
65
|
-
# Report about organisation members that do not belong to any team
|
|
66
|
-
org.get_members_without_team()
|
|
67
|
-
# Synchronise the permissions of teams for all repositories
|
|
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)
|
|
72
|
-
|
|
73
|
-
# Debug output
|
|
74
|
-
logging.debug("Final dataclass:\n%s", org.df2json())
|
|
75
|
-
org.ratelimit()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|