blob: ad699b88808b71ca96e35e8ed4c4aab2d34a11ff [file]
#!/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())