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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: github-org-manager
3
- Version: 0.3.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
- 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
 
@@ -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 get_current_teams(self):
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.get_current_teams()
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.get_current_teams()
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 _, team_attrs in self.configured_teams.items():
483
- for repo, perm in team_attrs.get("repos", {}).items():
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
- "READ": "pull",
517
- "TRIAGE": "triage",
518
- "WRITE": "push",
519
- "MAINTAIN": "maintain",
520
- "ADMIN": "admin",
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
- 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
- }
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
- if current_perm != config_perm:
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.3.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()