diff --git a/.github/workflows/dryrun-manage-github-repositories.yml b/.github/workflows/dryrun-manage-github-repositories.yml index 0af1ee9..42485f5 100644 --- a/.github/workflows/dryrun-manage-github-repositories.yml +++ b/.github/workflows/dryrun-manage-github-repositories.yml @@ -51,3 +51,9 @@ jobs: pipenv run ansible-playbook playbook.yaml -e api_token=$API_TOKEN --check --diff env: API_TOKEN: ${{ secrets[format('GHP_{0}', github.actor)] }} + + - name: Dry-run removal of members no longer defined in data.yaml + run: | + pipenv run python3 remove_members.py --dry-run + env: + API_TOKEN: ${{ secrets[format('GHP_{0}', github.actor)] }} diff --git a/.github/workflows/manage-github-repositories.yml b/.github/workflows/manage-github-repositories.yml index 317417f..9602e7b 100644 --- a/.github/workflows/manage-github-repositories.yml +++ b/.github/workflows/manage-github-repositories.yml @@ -52,3 +52,9 @@ jobs: pipenv run python3 manage.py --keep_labels TRUE env: API_TOKEN: ${{ secrets[format('GHP_{0}', github.actor)] }} + + - name: Remove members no longer defined in data.yaml + run: | + pipenv run python3 remove_members.py + env: + API_TOKEN: ${{ secrets[format('GHP_{0}', github.actor)] }} diff --git a/README.md b/README.md index da0ea4c..2062fd1 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,23 @@ You can use the following procedure to test, debug or improve github manager on ``` pipenv run ./check_consistency.py ``` +* Remove members no longer in data.yaml (dry run first) + ```sh + pipenv run python3 remove_members.py --dry-run + pipenv run python3 remove_members.py + ``` ## Limitiations * It is not possible to add already created, but still empty, repositories here. Before this is possible, at least one commit must have been made on the main branch. -* It is not possible to remove members from the organization or any team. Please first delete the corresponding -lines in `data.yaml` here in this repository and delete the user afterwards via the GitHub UI. +* To remove a member from the organization or a team, delete the corresponding lines in `data.yaml` +and push to main. The `remove_members.py` script will automatically remove them from GitHub on the +next workflow run. `exclusive: true` must be set in `data.yaml` (it is set by default) for removals +to take effect. -We're working on these issues upstream: and +We're working on upstream improvements: and ## Github Actions diff --git a/remove_members.py b/remove_members.py new file mode 100644 index 0000000..00dde15 --- /dev/null +++ b/remove_members.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Remove org members and team members that are no longer defined in data.yaml. + +Uses data.yaml as the source of truth. When exclusive: true is set, +any member present on GitHub but absent from the YAML will be removed. +""" + +import logging +import os +import sys +from argparse import ArgumentParser + +import github +import yaml +from github import Github + +logging.basicConfig( + format="%(asctime)s - %(message)s", level=logging.INFO, datefmt="%Y-%m-%d %H:%M:%S" +) + +API_TOKEN = os.environ.get("API_TOKEN") +ORGANIZATION = os.environ.get("ORGANIZATION", "SovereignCloudStack") + + +def load_data(data_file: str) -> dict: + with open(data_file) as f: + return yaml.safe_load(f) + + +def remove_org_members(gh: Github, org_name: str, defined_logins: set, dry_run: bool) -> int: + """Remove org members not defined in data.yaml. Returns number of removals.""" + org = gh.get_organization(org_name) + removed = 0 + + for member in org.get_members(): + login = member.login.lower() + if login not in defined_logins: + logging.info(f"Removing org member: {member.login}") + if not dry_run: + org.remove_from_members(member) + removed += 1 + + return removed + + +def remove_team_members(gh: Github, org_name: str, yaml_teams: list, dry_run: bool) -> int: + """Remove team members/maintainers not defined in data.yaml for each team. Returns number of removals.""" + org = gh.get_organization(org_name) + removed = 0 + + defined_teams = {t["slug"]: t for t in yaml_teams} + + for gh_team in org.get_teams(): + slug = gh_team.slug + if slug not in defined_teams: + # Team not in YAML — skip (team creation/deletion is handled by Ansible) + continue + + yaml_team = defined_teams[slug] + yaml_members = {m.lower() for m in yaml_team.get("member", [])} + yaml_maintainers = {m.lower() for m in yaml_team.get("maintainer", [])} + yaml_all = yaml_members | yaml_maintainers + + for gh_member in gh_team.get_members(): + login = gh_member.login.lower() + if login not in yaml_all: + logging.info(f"Removing {gh_member.login} from team {slug}") + if not dry_run: + gh_team.remove_membership(gh_member) + removed += 1 + + return removed + + +def main(): + parser = ArgumentParser(description="Remove GitHub org/team members not defined in data.yaml") + parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help="Report what would be removed without making any changes", + ) + parser.add_argument( + "--data-file", + default=f"orgs/{ORGANIZATION}/data.yaml", + help="Path to the data.yaml file", + ) + args = parser.parse_args() + + if args.dry_run: + logging.info("DRY RUN — no changes will be made") + + data = load_data(args.data_file) + + if not data.get("exclusive", False): + logging.info("exclusive: false — skipping removal") + sys.exit(0) + + defined_logins = {m["login"].lower() for m in data.get("members", [])} + yaml_teams = data.get("teams", []) + + gh = Github(login_or_token=API_TOKEN) + + org_removals = remove_org_members(gh, ORGANIZATION, defined_logins, args.dry_run) + team_removals = remove_team_members(gh, ORGANIZATION, yaml_teams, args.dry_run) + + total = org_removals + team_removals + if total == 0: + logging.info("No members to remove — everything is in sync") + else: + action = "Would remove" if args.dry_run else "Removed" + logging.info(f"{action} {org_removals} org member(s) and {team_removals} team member(s)") + + sys.exit(0) + + +if __name__ == "__main__": + main()