Skip to content
73 changes: 73 additions & 0 deletions src/clusterfuzz/_internal/cron/oss_fuzz_cc_groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Cron to sync OSS-Fuzz projects groups used as CC in the issue tracker."""

from clusterfuzz._internal.base import utils
from clusterfuzz._internal.cron import project_setup
from clusterfuzz._internal.google_cloud_utils import google_groups
from clusterfuzz._internal.metrics import logs

_CC_GROUP_SUFFIX = '-ccs@oss-fuzz.com'
_CC_GROUP_DESC = 'External CCs in OSS-Fuzz issue tracker for project'


def sync_project_cc_group(project_name, info):
"""Sync the project's google group used for CCing in the issue tracker."""
group_name = f'{project_name}{_CC_GROUP_SUFFIX}'

group_id = google_groups.get_group_id(group_name)
# Create the group and bail out since the CIG API might delay to create a
# new group. Add members will be done in the next project-setup run.
if not group_id:
group_description = f'{_CC_GROUP_DESC}: {project_name}'
created = google_groups.create_google_group(
group_name, group_description=group_description)
if not created:
logs.info('Failed to create or retrieve the issue tracker CC group '
f'for {project_name}')
return
logs.info(f'Created issue tracker CC group for {project_name}. '
'Skipping adding members as group may still not exist.')
return

group_memberships = google_groups.get_google_group_memberships(group_id)
if group_memberships is None:
logs.info(
f'Failed to get list of group members for {project_name}. Skipping.')
return

ccs = set(project_setup.ccs_from_info(info))

to_add = ccs - group_memberships.keys()
for member in to_add:
google_groups.add_member_to_group(group_id, member)

to_delete = group_memberships.keys() - ccs
for member in to_delete:
# Ignore the SA that created the group from members to delete.
if utils.is_service_account(member):
continue
memebership_name = group_memberships[member]
google_groups.delete_google_group_membership(group_id, member,
memebership_name)


def main():
"""Sync OSS-Fuzz projects groups used to CC owners in the issue tracker."""
projects = project_setup.get_oss_fuzz_projects()
for project, info in projects:
sync_project_cc_group(project, info)

logs.info('OSS-Fuzz CC groups sync succeeded.')
return True
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for oss_fuzz_cc_groups cron."""

import unittest

from clusterfuzz._internal.cron import oss_fuzz_cc_groups
from clusterfuzz._internal.tests.test_libs import helpers as test_helpers


class OssFuzzCcGroupsTest(unittest.TestCase):
"""Tests for oss_fuzz_cc_groups."""

def setUp(self):
test_helpers.patch(self, [
'clusterfuzz._internal.cron.project_setup.get_oss_fuzz_projects',
'clusterfuzz._internal.cron.project_setup.ccs_from_info',
'clusterfuzz._internal.google_cloud_utils.google_groups.get_group_id',
'clusterfuzz._internal.google_cloud_utils.google_groups.create_google_group',
'clusterfuzz._internal.google_cloud_utils.google_groups.get_google_group_memberships',
'clusterfuzz._internal.google_cloud_utils.google_groups.add_member_to_group',
'clusterfuzz._internal.google_cloud_utils.google_groups.delete_google_group_membership',
'clusterfuzz._internal.base.utils.is_service_account',
])

def test_main(self):
"""Test main execution for creating groups and syncing project ccs."""
self.mock.get_oss_fuzz_projects.return_value = [
('project1', {
'info': 1
}),
('project2', {
'info': 2
}),
]

# project1 group does not exist, so create it.
# project2 group exists, only sync members.
self.mock.get_group_id.side_effect = [None, 'group2_id']
self.mock.create_google_group.return_value = True

self.mock.get_google_group_memberships.return_value = {
'member1@example.com': 'membership1',
'member2@example.com': 'membership2',
}
self.mock.ccs_from_info.return_value = [
'member2@example.com',
'member3@example.com',
]
self.mock.is_service_account.return_value = False

self.assertTrue(oss_fuzz_cc_groups.main())

# project1 check
self.mock.create_google_group.assert_called_with(
'project1-ccs@oss-fuzz.com',
group_description=(
'External CCs in OSS-Fuzz issue tracker for project: project1'))

# project2 check
self.mock.add_member_to_group.assert_called_with('group2_id',
'member3@example.com')
self.mock.delete_google_group_membership.assert_called_with(
'group2_id', 'member1@example.com', 'membership1')

def test_create_fail(self):
"""Test group creation failure."""
self.mock.get_oss_fuzz_projects.return_value = [('project1', {})]
self.mock.get_group_id.return_value = None
self.mock.create_google_group.return_value = False

self.assertTrue(oss_fuzz_cc_groups.main())
self.mock.get_google_group_memberships.assert_not_called()

def test_get_memberships_fail(self):
"""Test get memberships failure."""
self.mock.get_oss_fuzz_projects.return_value = [('project1', {})]
self.mock.get_group_id.return_value = 'group1_id'
self.mock.get_google_group_memberships.return_value = None

self.assertTrue(oss_fuzz_cc_groups.main())
self.mock.ccs_from_info.assert_not_called()

def test_skip_sa_deletion(self):
"""Test that service accounts are not deleted from group."""
self.mock.get_oss_fuzz_projects.return_value = [('project1', {})]
self.mock.get_group_id.return_value = 'group1_id'
self.mock.get_google_group_memberships.return_value = {
'sa@serviceaccount.com': 'membership_sa',
}
self.mock.ccs_from_info.return_value = []
self.mock.is_service_account.return_value = True

self.assertTrue(oss_fuzz_cc_groups.main())
self.mock.delete_google_group_membership.assert_not_called()
Loading