github-org-manager 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: github-org-manager
3
- Version: 0.3.1
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
- Some examples:
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
- Some examples:
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
 
@@ -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.3.1"
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()