Skip to content

Commit 7f577b6

Browse files
authored
Merge pull request #35 from OpenRailAssociation/template
Add command to help with bootstrapping new team config files
2 parents dbd1b15 + bff704b commit 7f577b6

File tree

5 files changed

+251
-43
lines changed

5 files changed

+251
-43
lines changed

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,16 @@ You may also be interested in the [live configuration of the OpenRail Associatio
5050

5151
You can execute the program using the command `gh-org-mgr`. `gh-org-mgr --help` shows all available arguments and options.
5252

53-
Some examples:
53+
Synchronisation examples:
5454

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.
5863

5964
## License
6065

gh_org_mgr/_setup_team.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

gh_org_mgr/manage.py

Lines changed: 93 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,75 @@
1111
from . import __version__, configure_logger
1212
from ._config import parse_config_files
1313
from ._gh_org import GHorg
14+
from ._setup_team import setup_team
1415

16+
# Main parser with root-level flags
1517
parser = argparse.ArgumentParser(
1618
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
1719
)
1820
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(
1938
"-c",
2039
"--config",
2140
required=True,
2241
help="Path to the directory in which the configuration of an GitHub organisation is located",
2342
)
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(
43+
parser_sync.add_argument("--dry", action="store_true", help="Do not make any changes at GitHub")
44+
parser_sync.add_argument(
2745
"-A",
2846
"--ignore-archived",
2947
action="store_true",
3048
help="Do not take any action in ignored repositories",
3149
)
32-
parser.add_argument("--version", action="version", version="GitHub Team Manager " + __version__)
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+
# )
3383

3484

3585
def main():
@@ -40,36 +90,42 @@ def main():
4090

4191
configure_logger(args.debug)
4292

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()
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)

poetry.lock

Lines changed: 30 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ python = "^3.10"
3030
pygithub = "^2.3.0"
3131
pyyaml = "^6.0.1"
3232
requests = "^2.32.3"
33+
python-slugify = "^8.0.4"
3334

3435
[tool.poetry.group.dev.dependencies]
3536
black = "^24.3.0"

0 commit comments

Comments
 (0)