| #!/usr/bin/env python3 |
| |
| # Manage maintainer teams on the forge |
| |
| # Copyright (C) 2026 Free Software Foundation, Inc. |
| # |
| # This file is part of GCC. |
| # |
| # GCC is free software; you can redistribute it and/or modify |
| # it under the terms of the GNU General Public License as published by |
| # the Free Software Foundation; either version 3, or (at your option) |
| # any later version. |
| # |
| # GCC is distributed in the hope that it will be useful, |
| # but WITHOUT ANY WARRANTY; without even the implied warranty of |
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| # GNU General Public License for more details. |
| # |
| # You should have received a copy of the GNU General Public License |
| # along with GCC; see the file COPYING. If not, write to |
| # the Free Software Foundation, 51 Franklin Street, Fifth Floor, |
| # Boston, MA 02110-1301, USA. |
| |
| # This script is used to syncronize the data in the MAINTAINERS.yml |
| # file with the teams used by the forge for notifications. |
| |
| # The script is set up with all defaults for the sourceware forge |
| # except for an API token. The token's minimum permissions are |
| # write:organization |
| # but only owners of the 'gcc' organization will be able to generate |
| # useable write tokens. |
| # You can enter the API token either via the environment variable |
| # APIKEY, or when requested. |
| |
| |
| import getpass |
| import json |
| import math |
| import os |
| import pprint |
| import sys |
| |
| import urllib.parse |
| import urllib.request |
| import http.client |
| |
| from optparse import OptionParser |
| |
| import maintainer_utils as maintutils |
| |
| defaults = { |
| "PROJECT": "gcc", |
| "REPO": "gcc", |
| "FORGE": "https://forge.sourceware.org/api/v1", |
| } |
| |
| # These are teams in the forge that this script will not attempt to manage |
| # because the relevant data is not part of MAINTAINERS.yml |
| unmanaged_teams = [ |
| 'Automation', |
| 'Collaborators', |
| 'Maintainers.Global', # Remove once fully implemented |
| 'Owners', |
| 'Release.Managers', |
| 'Reviewers', # Remove once fully implemented |
| 'Write.After.Approval', # Remove once fully implemented |
| ] |
| |
| opts = None |
| error_count = 0 |
| |
| |
| def verbose(msg) -> None: |
| if opts.verbose: |
| print(msg) |
| return |
| |
| |
| class Forge: |
| def __init__(self): |
| self._setup() |
| |
| def _setup(self) -> None: |
| self.project = os.getenv("PROJECT", defaults['PROJECT']) |
| self.repo = os.getenv("REPO", defaults['REPO']) |
| self.forge = os.getenv("FORGE", defaults['FORGE']) |
| self.apikey = os.getenv("APIKEY") or getpass.getpass( |
| prompt="API key: " |
| ) |
| verbose(f"Accessing {self.forge}/ for org {self.project}.") |
| return |
| |
| def _send_request( |
| self, |
| url, |
| method='GET', |
| ) -> http.client.HTTPResponse: |
| # For safety while developing. |
| if method == 'GET' or not opts.dry_run: |
| headers = { |
| 'Authorization': f"token {self.apikey}", |
| 'accept': 'application/json', |
| 'Content-Type': 'application/json', |
| } |
| request = urllib.request.Request( |
| url, |
| headers=headers, |
| method=method, |
| ) |
| return urllib.request.urlopen(request) |
| return None |
| |
| # The rest API does not guarantee to return all the data with a |
| # single request. We support fetching by pages of up to limit. |
| # Note that the server may return less than this if increased too |
| # far. 50 seems a reasonable size; we don't expect there to be |
| # more than a couple of pages at that size. |
| def _get_teams_page(self, page=1, limit=50) -> tuple[ |
| list, |
| int, |
| ]: |
| fetch = urllib.parse.urlencode( |
| { |
| "page": page, |
| "limit": limit, |
| } |
| ) |
| url = f"{self.forge}/orgs/{self.project}/teams?{fetch}" |
| verbose(f"Requesting {url}") |
| reply = self._send_request(url) |
| item_count = int(reply.headers.get("X-Total-Count", -1)) |
| pages = math.ceil(item_count / limit) if item_count > 0 else 1 |
| return json.load(reply), pages |
| |
| def _get_team_members_page(self, id, page=1, limit=50) -> tuple[ |
| list, |
| int, |
| ]: |
| fetch = urllib.parse.urlencode( |
| { |
| "page": page, |
| "limit": limit, |
| } |
| ) |
| url = f"{self.forge}/teams/{id}/members?{fetch}" |
| verbose(f"GET {url}") |
| reply = self._send_request(url) |
| item_count = int(reply.headers.get("X-Total-Count", -1)) |
| pages = math.ceil(item_count / limit) if item_count > 0 else 1 |
| return json.load(reply), pages |
| |
| def fetch_teams(self) -> list: |
| teams, pages = self._get_teams_page() |
| if pages == 1: |
| return teams |
| for page in range(2, pages + 1): |
| teams_page, _ = self._get_teams_page(page=page) |
| teams.extend(teams_page) |
| return teams |
| |
| def fetch_team_members(self, id) -> list: |
| members, pages = self._get_team_members_page(id) |
| if pages == 1: |
| return members |
| for page in range(2, pages + 1): |
| members_page, _ = self._get_team_members_page(id, page=page) |
| members.extend(members_page) |
| return members |
| |
| def manage_team_member(self, id, login, remove=False) -> None: |
| url = f"{self.forge}/teams/{id}/members/{login}" |
| action = 'DELETE' if remove else 'PUT' |
| verbose(f"{action} {url}") |
| reply = self._send_request(url, method=action) |
| |
| |
| class DesiredTeam: |
| def __init__( |
| self, |
| teamname, |
| role_filter, |
| user_data, |
| subsystem=None, |
| ) -> None: |
| self.name = teamname |
| self.members = [] |
| self.accounts = set() |
| self.role_filter = role_filter |
| self.add_users(subsystem, user_data) |
| |
| @staticmethod |
| def subsystem_filter(subsystem_name, roles) -> bool: |
| return any( |
| isinstance(role, dict) |
| and ( |
| role.get('Maintainer') == subsystem_name |
| or role.get('Reviewer') == subsystem_name |
| ) |
| for role in roles |
| ) |
| |
| @staticmethod |
| def maintainers_filter(subsystem_name, roles) -> bool: |
| return any( |
| role == 'Global' |
| or (isinstance(role, dict) and 'Maintainer' in role) |
| for role in roles |
| ) |
| |
| @staticmethod |
| def global_reviewers_filter(subsystem_name, roles) -> bool: |
| return any( |
| role == 'Global' |
| for role in roles |
| ) |
| |
| @staticmethod |
| def reviewers_filter(subsystem_name, roles) -> bool: |
| return any( |
| isinstance(role, dict) and 'Reviewer' in role |
| for role in roles |
| ) |
| |
| @staticmethod |
| def write_after_filter(subsystem_name, roles) -> bool: |
| return any( |
| role == "WriteAfter" |
| for role in roles |
| ) |
| |
| def add_users(self, subsystem_name, user_data) -> None: |
| new_members = [ |
| u |
| for u in user_data |
| if (forgeid := u.get("forgeid")) is not None |
| and forgeid not in self.accounts |
| and self.role_filter(subsystem_name, u['roles']) |
| ] |
| self.members.extend(new_members) |
| self.accounts.update(u["forgeid"] for u in new_members) |
| |
| |
| class DesiredTeamList: |
| def __init__(self, data) -> None: |
| # Start with the static teams that are not based on specific |
| # subsytems. |
| self.teams = { |
| 'Maintainers.Global': DesiredTeam( |
| "Maintainers.Global", |
| DesiredTeam.global_reviewers_filter, |
| data['users'], |
| ), |
| 'Maintainers': DesiredTeam( |
| "Maintainers", DesiredTeam.maintainers_filter, data['users'] |
| ), |
| 'Reviewers': DesiredTeam( |
| "Reviewers", DesiredTeam.reviewers_filter, data['users'] |
| ), |
| 'Write.After.Approval': DesiredTeam( |
| "Write.After.Approval", |
| DesiredTeam.write_after_filter, |
| data['users'], |
| ), |
| } |
| # Now add teams for subsystems where a forge team has been |
| # set up. This is a many<->many mapping: some subsystems |
| # will impact multiple teams and some teams my cover multiple |
| # subsystems and there may be overlaps in team membership. |
| for subsystem in filter( |
| lambda s: 'teams' in s, |
| data['subsystems'] |
| ): |
| for t in subsystem['teams']: |
| if t in self.teams: |
| self.teams[t].add_users( |
| subsystem['name'], |
| data['users'], |
| ) |
| else: |
| self.teams[t] = DesiredTeam( |
| t, |
| DesiredTeam.subsystem_filter, |
| data['users'], |
| subsystem=subsystem['name'], |
| ) |
| |
| def get_by_name(self, name) -> DesiredTeam: |
| return self.teams.get(name) |
| |
| |
| class ExistingTeam: |
| def __init__(self, forge, team) -> None: |
| self._forge = forge |
| self._team = team |
| self._members = { |
| m['login']: m |
| for m in forge.fetch_team_members(team['id']) |
| } |
| |
| def get_member_names(self) -> list: |
| return self._members.keys() |
| |
| def has_member(self, name) -> bool: |
| return self._members.get(name) != None |
| |
| def add_member(self, name) -> None: |
| self._forge.manage_team_member(self._team['id'], name) |
| |
| def remove_member(self, name) -> None: |
| self._forge.manage_team_member(self._team['id'], name, remove=True) |
| |
| |
| class ExistingMaintainers: |
| def __init__(self, forge): |
| self._forge = forge |
| self._teams = { |
| t['name']: ExistingTeam(forge, t) |
| for t in forge.fetch_teams() |
| if not t['name'] in unmanaged_teams |
| } |
| |
| def get_by_name(self, name) -> ExistingTeam: |
| return self._teams.get(name) |
| |
| def get_team_names(self) -> list: |
| return self._teams.keys() |
| |
| |
| def update_teams(forge, needed_teams, existing_teams) -> None: |
| global error_count |
| for name, team in needed_teams.teams.items(): |
| # Skip any teams we don't manage |
| if name in unmanaged_teams: |
| continue |
| existing = existing_teams.get_by_name(name) |
| if existing: |
| verbose(f"Checking membership of {name}") |
| # First add any missing members; while doing this, build up |
| # a set of forgeids in the team so that we can quickly search |
| # the team list for pass 2 |
| member_names = set() |
| for member in team.members: |
| member_names.add(member['forgeid']) |
| if not existing.has_member(member['forgeid']): |
| # Add new member |
| verbose( |
| f" missing: {member['cn']} ({member['forgeid']})" |
| ) |
| existing.add_member(member['forgeid']) |
| else: |
| verbose(f" OK: {member['cn']} ({member['forgeid']})") |
| # Secondly, remove any excess members |
| for member in existing.get_member_names(): |
| if not member in member_names: |
| verbose(f" remove: {member}") |
| existing.remove_member(member) |
| else: |
| error_count += 1 |
| print(f"Team '{name}' does not exist on the forge") |
| # Scan the existing teams and report any that do not exist in |
| # either MAINTAINERS.yml or in the ignore list. |
| for existing in existing_teams.get_team_names(): |
| if ( |
| not needed_teams.get_by_name(existing) |
| and existing not in unmanaged_teams |
| ): |
| error_count += 1 |
| if len(existing_teams.get_by_name(existing).get_member_names()): |
| print(f"Unexpected team '{existing}' found on forge") |
| else: |
| print( |
| f"Forge team '{existing}' has no members and can be removed" |
| ) |
| |
| |
| def main() -> int: |
| global opts |
| optp = OptionParser("Usage: %prog [<options>] <maintainers.yml>") |
| optp.add_option( |
| "-v", "--verbose", |
| action="store_true", |
| dest="verbose", |
| default=False, |
| ) |
| optp.add_option( |
| "-n", "--dry-run", |
| action="store_true", |
| dest="dry_run", |
| default=False, |
| ) |
| opts, args = optp.parse_args() |
| if len(args) != 1: |
| optp.print_help() |
| return 1 |
| |
| maint_data = maintutils.load(args[0]) |
| maintutils.validate(maint_data) |
| needed_teams = DesiredTeamList(maint_data) |
| forge = Forge() |
| existing_teams = ExistingMaintainers(forge) |
| update_teams(forge, needed_teams, existing_teams) |
| if opts.verbose: |
| print("Existing managed teams and members:") |
| for team in existing_teams.get_team_names(): |
| print(team) |
| for m in existing_teams.get_by_name(team).get_member_names(): |
| print(f" {m}") |
| print("\nDesired teams and members:") |
| for name, team in needed_teams.teams.items(): |
| unmanaged = " (unmanaged)" if name in unmanaged_teams else "" |
| print(f"{name}{unmanaged}") |
| for m in team.members: |
| print(f" {m['forgeid']}") |
| return 0 if not error_count else 1 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |