Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/dryrun-manage-github-repositories.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,10 @@
run: |
pipenv run ansible-playbook playbook.yaml -e api_token=$API_TOKEN --check --diff
env:
API_TOKEN: ${{ secrets[format('GHP_{0}', github.actor)] }}

Check warning

Code scanning / CodeQL

Excessive Secrets Exposure Medium

All organization and repository secrets are passed to the workflow runner in
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)] }}
6 changes: 6 additions & 0 deletions .github/workflows/manage-github-repositories.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,10 @@
run: |
pipenv run python3 manage.py --keep_labels TRUE
env:
API_TOKEN: ${{ secrets[format('GHP_{0}', github.actor)] }}

Check warning

Code scanning / CodeQL

Excessive Secrets Exposure Medium

All organization and repository secrets are passed to the workflow runner in
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)] }}
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://github.com/opentelekomcloud/ansible-collection-gitcontrol> and
We're working on upstream improvements: <https://github.com/opentelekomcloud/ansible-collection-gitcontrol> and
<https://github.com/opentelekomcloud-infra/gitstyring>

## Github Actions
Expand Down
119 changes: 119 additions & 0 deletions remove_members.py
Original file line number Diff line number Diff line change
@@ -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()
Loading