diff --git a/.github/workflows/teamsync_check_for_changes.yml b/.github/workflows/teamsync_check_for_changes.yml new file mode 100644 index 0000000000..b766164213 --- /dev/null +++ b/.github/workflows/teamsync_check_for_changes.yml @@ -0,0 +1,40 @@ +name: teamSync check for changes + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + check-permissions: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'adopt' + + - name: Build project + run: mvn clean install + + - name: Check for yml changes + id: files + run: | + FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} -- 'permissions/*.yml' | xargs echo) + if [[ -z "$FILES" ]]; then + echo "No changes detected in permissions files." + else + echo "Changes detected. Processing..." + java -jar target/github_team_sync.jar $FILES + fi + env: + GITHUB_OAUTH: ${{ secrets.TEMP_TEAMSYNC_PAT }} diff --git a/pom.xml b/pom.xml index d9bdd68eaf..69851178e2 100644 --- a/pom.xml +++ b/pom.xml @@ -67,10 +67,32 @@ package - src/assembly.xml + src/assembly/assembly.xml + + GitHub TeamSync + package + + single + + + + src/assembly/teamSync.xml + + + + io.jenkins.infra.repository_permissions_updater.github_team_sync.TeamSyncExecutor + + + + jar-with-dependencies + + github_team_sync + false + + @@ -234,6 +256,12 @@ gson 2.11.0 + + org.mockito + mockito-junit-jupiter + 5.7.0 + test + diff --git a/src/assembly.xml b/src/assembly.xml deleted file mode 100644 index b607329407..0000000000 --- a/src/assembly.xml +++ /dev/null @@ -1,12 +0,0 @@ - - bin - - dir - - false - - - runtime - - - diff --git a/src/assembly/assembly.xml b/src/assembly/assembly.xml new file mode 100644 index 0000000000..4f8472d44a --- /dev/null +++ b/src/assembly/assembly.xml @@ -0,0 +1,22 @@ + + bin + + dir + + false + + + target/classes/ + / + + io/jenkins/infra/repository_permissions_updater/github_team_sync/*.class + + + + + + + runtime + + + diff --git a/src/assembly/teamSync.xml b/src/assembly/teamSync.xml new file mode 100644 index 0000000000..7de51ea285 --- /dev/null +++ b/src/assembly/teamSync.xml @@ -0,0 +1,17 @@ + + GitHub TeamSync + + jar + + false + + + target/classes/ + / + + io/jenkins/infra/repository_permissions_updater/github_team_sync/*.class + + + + + diff --git a/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/AdditionalTeamDefinition.java b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/AdditionalTeamDefinition.java new file mode 100644 index 0000000000..6cc6c335c3 --- /dev/null +++ b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/AdditionalTeamDefinition.java @@ -0,0 +1,31 @@ +package io.jenkins.infra.repository_permissions_updater.github_team_sync; + +public class AdditionalTeamDefinition { + private String teamName; + private Role role; + + public AdditionalTeamDefinition(String teamName, String role) { + this.teamName = teamName; + this.role = validateRole(role); + } + + public String getName() { + return teamName; + } + + public Role getRole() { + return role; + } + + private Role validateRole(String role) { + if (role == null) { + return null; + } + try { + return Role.valueOf(role.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid team role: " + role); + } + } +} + diff --git a/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/GitHubService.java b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/GitHubService.java new file mode 100644 index 0000000000..4707308e0a --- /dev/null +++ b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/GitHubService.java @@ -0,0 +1,29 @@ +package io.jenkins.infra.repository_permissions_updater.github_team_sync; + +import java.io.IOException; +import java.util.Set; + +import org.kohsuke.github.GHOrganization; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GHTeam; + +public interface GitHubService { + + GHOrganization getOrganization(String name) throws IOException; + + void addDeveloperToTeam(GHTeam team, String developer) throws IOException; + + void removeDeveloperFromTeam(GHTeam team, String developer) throws IOException; + + Set getCurrentTeamMembers(GHTeam team) throws IOException; + + GHTeam createTeam(String orgName, String teamName, GHTeam.Privacy privacy) throws IOException; + + void updateTeamRole(GHRepository repo, GHTeam ghTeam, Role role) throws IOException; + + GHTeam getTeamFromRepo(String orgName, String repoName, String teamName) throws IOException; + + void removeTeamFromRepository(GHTeam team, GHRepository repo) throws IOException; + + Set getCurrentTeams(GHRepository repo, GHTeam repoTeam) throws IOException; +} \ No newline at end of file diff --git a/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/GitHubServiceImpl.java b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/GitHubServiceImpl.java new file mode 100644 index 0000000000..47d2b2a823 --- /dev/null +++ b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/GitHubServiceImpl.java @@ -0,0 +1,106 @@ +package io.jenkins.infra.repository_permissions_updater.github_team_sync; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.kohsuke.github.*; + + +public class GitHubServiceImpl implements GitHubService { + private GitHub github; + + private static final Map PERMISSIONS_MAP = Map.of( + Role.READ, GHOrganization.Permission.PULL, + Role.TRIAGE, GHOrganization.Permission.TRIAGE, + Role.WRITE, GHOrganization.Permission.PUSH, + Role.MAINTAIN, GHOrganization.Permission.MAINTAIN, + Role.ADMIN, GHOrganization.Permission.ADMIN + ); + + public GitHubServiceImpl(String oauthToken) { + try { + this.github = new GitHubBuilder().withOAuthToken(oauthToken).build(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Override + public GHTeam getTeamFromRepo( + String repoName, String orgName, String teamName) throws IOException { + GHOrganization org = github.getOrganization(orgName); + GHRepository repo = org.getRepository(repoName); + Set teams = ((GHRepository) repo).getTeams(); + + for (GHTeam team : teams) { + if (team.getName().equals(teamName)) { + return team; + } + } + return null; + } + + @Override + public GHOrganization getOrganization(String name) throws IOException { + return github.getOrganization(name); + } + + + @Override + public void addDeveloperToTeam(GHTeam team, String developer) throws IOException { + GHUser user = github.getUser(developer); + team.add(user); + } + + @Override + public void removeDeveloperFromTeam(GHTeam team, String developer) throws IOException { + GHUser user = github.getUser(developer); + team.remove(user); + } + + @Override + public Set getCurrentTeamMembers(GHTeam team) throws IOException { + Set members = new HashSet<>(); + for (GHUser member : team.listMembers()) { + members.add(member.getLogin()); + } + return members; + } + + @Override + public GHTeam createTeam(String orgName, String teamName, GHTeam.Privacy privacy) throws IOException { + GHOrganization org = github.getOrganization(orgName); + return org.createTeam(teamName).privacy(privacy).create(); + } + + @Override + public void updateTeamRole(GHRepository repo, GHTeam ghTeam, Role role) throws IOException { + GHOrganization.Permission permission = PERMISSIONS_MAP.get(role); + GHOrganization.RepositoryRole repoRole = GHOrganization.RepositoryRole.from(permission); + ghTeam.add(repo, repoRole); + } + + @Override + public void removeTeamFromRepository(GHTeam team, GHRepository repo) throws IOException { + team.remove(repo); + } + + /** + * Retrieves the names of all additional teams associated with the given GitHub repository, excluding the repo team. + * This method returns only team names because the current Java GitHub API does not support retrieving roles + * that teams hold within specific repositories. Therefore, role-related information is not available. + */ + @Override + public Set getCurrentTeams(GHRepository repo, GHTeam repoTeam) throws IOException { + Set allTeams = repo.getTeams(); + + return allTeams.stream() + .filter(team -> !team.equals(repoTeam)) + .map(GHTeam::getName) + .collect(Collectors.toSet()); + } + +} diff --git a/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/RepoTeamDefinition.java b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/RepoTeamDefinition.java new file mode 100644 index 0000000000..620616980e --- /dev/null +++ b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/RepoTeamDefinition.java @@ -0,0 +1,52 @@ +package io.jenkins.infra.repository_permissions_updater.github_team_sync; + +import java.util.Set; + +public class RepoTeamDefinition { + + private String repoName; + private String orgName; + private String teamName; + + private static final String DEFAULT_ORG_NAME = "jenkinsci"; + private final Role role = Role.ADMIN; + private Set developers; + private Set additionalTeams; + + public RepoTeamDefinition(String repoName, String orgName, String teamName, + Set developers, Set additionalTeams) { + this.repoName = repoName; + this.orgName = orgName != null ? orgName : DEFAULT_ORG_NAME; + this.teamName = teamName; + this.developers = developers; + this.additionalTeams = additionalTeams; + } + + public RepoTeamDefinition() { + } + + + public String getRepoName() { + return repoName; + } + + public String getOrgName() { + return orgName; + } + + public String getTeamName() { + return teamName; + } + + public Role getRole() { + return role; + } + + public Set getDevelopers() { + return developers; + } + + public Set getAdditionalTeams() { + return additionalTeams; + } +} \ No newline at end of file diff --git a/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/Role.java b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/Role.java new file mode 100644 index 0000000000..8f9b16a30c --- /dev/null +++ b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/Role.java @@ -0,0 +1,9 @@ +package io.jenkins.infra.repository_permissions_updater.github_team_sync; + +public enum Role { + READ, + TRIAGE, + WRITE, + MAINTAIN, + ADMIN +} diff --git a/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/SpecialTeamDefinition.java b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/SpecialTeamDefinition.java new file mode 100644 index 0000000000..9f2079f803 --- /dev/null +++ b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/SpecialTeamDefinition.java @@ -0,0 +1,32 @@ +package io.jenkins.infra.repository_permissions_updater.github_team_sync; + +import java.util.Set; + +public class SpecialTeamDefinition{ + private String orgName; + private String teamName; + private Set developers; + + private static final String DEFAULT_ORG_NAME = "jenkinsci"; + + public SpecialTeamDefinition(String orgName, String teamName, Set developers) { + this.orgName = orgName != null ? orgName : DEFAULT_ORG_NAME; + this.teamName = teamName; + this.developers = developers; + } + + public SpecialTeamDefinition() { + } + + public String getOrgName() { + return orgName; + } + + public String getTeamName() { + return teamName; + } + + public Set getDevelopers() { + return developers; + } +} diff --git a/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/TeamSyncExecutor.java b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/TeamSyncExecutor.java new file mode 100644 index 0000000000..f944892ed0 --- /dev/null +++ b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/TeamSyncExecutor.java @@ -0,0 +1,50 @@ +package io.jenkins.infra.repository_permissions_updater.github_team_sync; + + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TeamSyncExecutor { + private static final Logger logger = LoggerFactory.getLogger(TeamSyncExecutor.class); + private final TeamUpdater teamUpdater; + private final YamlTeamManager yamlTeamManager; + + public TeamSyncExecutor(TeamUpdater teamUpdater, YamlTeamManager yamlTeamManager) { + this.teamUpdater = teamUpdater; + this.yamlTeamManager = yamlTeamManager; + } + + public static void main(String[] args) throws IOException { + GitHubService gitHubService = new GitHubServiceImpl(System.getenv("GITHUB_OAUTH")); + TeamUpdater teamUpdater = new TeamUpdater(gitHubService); + YamlTeamManager yamlTeamManager = new YamlTeamManager(gitHubService, ""); + TeamSyncExecutor executor = new TeamSyncExecutor(teamUpdater, yamlTeamManager); + + executor.run(args); + } + + public void run(String[] args) { + if (args.length == 0) { + throw new IllegalArgumentException("No file path provided."); + } + + for (String yamlFilePath : args) { + try { + logger.info("Processing team configuration for file: " + yamlFilePath); + Object team = yamlTeamManager.loadTeam(yamlFilePath); + + if (team instanceof RepoTeamDefinition) { + teamUpdater.updateTeam((RepoTeamDefinition) team); + } else if (team instanceof SpecialTeamDefinition) { + teamUpdater.updateSpecialTeam((SpecialTeamDefinition) team); + } else { + throw new IllegalArgumentException("Unsupported team definition type."); + } + } catch (Exception e) { + logger.error("Failed to update team for file " + yamlFilePath + ": " + e.getMessage(), e); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/TeamUpdater.java b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/TeamUpdater.java new file mode 100644 index 0000000000..de0824b223 --- /dev/null +++ b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/TeamUpdater.java @@ -0,0 +1,135 @@ +package io.jenkins.infra.repository_permissions_updater.github_team_sync; + + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +import org.kohsuke.github.GHOrganization; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GHTeam; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +public class TeamUpdater { + private static final Logger logger = LoggerFactory.getLogger(TeamUpdater.class); + + private final GitHubService gitHubService; + + + public TeamUpdater(GitHubService gitHubService) { + this.gitHubService = gitHubService; + } + + public void updateTeam(RepoTeamDefinition team) { + try { + String orgName = team.getOrgName(); + String teamName = team.getTeamName(); + GHOrganization org = gitHubService.getOrganization(orgName); + String repoName = team.getRepoName(); + GHRepository repo = org.getRepository(repoName); + GHTeam ghTeam = org.getTeamByName(teamName); + Role teamRole = team.getRole(); + + + // Create team if it doesn't exist + if (ghTeam == null) { + ghTeam = gitHubService.createTeam(orgName, teamName, GHTeam.Privacy.CLOSED); + logger.info("Team '{}' created.", teamName); + gitHubService.updateTeamRole(repo, ghTeam, teamRole); + logger.info("Team role for '{}' updated to '{}' in repository '{}'.", teamName, teamRole, repo.getName()); + } + + // Remove team if team name and developers are all empty + if ((teamName == null || teamName.trim().isEmpty()) && team.getDevelopers().isEmpty()) { + String potentialTeamName = repoName + " Developers"; + GHTeam currTeam = gitHubService.getTeamFromRepo(orgName, repoName, potentialTeamName); + if (currTeam != null) { + gitHubService.removeTeamFromRepository(currTeam, repo); + } + } + + // Update team members if they are different from the yaml definition + updateTeamMembers(ghTeam, team.getDevelopers(), teamName); + // Update role of other teams in repository if it's different from the yaml definition + updateAdditionalTeam(org, repo, ghTeam, team.getAdditionalTeams()); + + } catch (IOException e) { + logger.error("Error updating team", e); + } + } + + public void updateSpecialTeam(SpecialTeamDefinition team) { + try { + String orgName = team.getOrgName(); + String teamName = team.getTeamName(); + GHOrganization org = gitHubService.getOrganization(orgName); + GHTeam ghTeam = org.getTeamByName(teamName); + + if (ghTeam == null) { + throw new IOException("Team not found: " + teamName); + } else { + updateTeamMembers(ghTeam, team.getDevelopers(), teamName); + } + + } catch (Exception e) { + logger.error("Error updating special team", e); + } + + } + + + private void updateTeamMembers(GHTeam ghTeam, Set developers, String teamName) throws IOException { + Set currentMembers = gitHubService.getCurrentTeamMembers(ghTeam); + // Add new developers from yaml file + for (String dev : developers) { + if (!currentMembers.contains(dev)) { + gitHubService.addDeveloperToTeam(ghTeam, dev); + logger.info("Developer: '" + dev + "' added to team: " + teamName); + } + } + // Remove developers not in yaml file + for (String member : currentMembers) { + if (!developers.contains(member)) { + gitHubService.removeDeveloperFromTeam(ghTeam, member); + logger.info("Developer: '" + member + "' removed from team: " + teamName); + } + } + } + + public void updateAdditionalTeam( + GHOrganization org, GHRepository repo, GHTeam repoTeam, + Set additionalTeams) throws IOException { + + Set currentTeamMap = gitHubService.getCurrentTeams(repo, repoTeam); + + Set additionalTeamNames = new HashSet<>(); + // update roles of additional teams from yaml file + for (AdditionalTeamDefinition additionalTeam : additionalTeams) { + String name = additionalTeam.getName(); + Role role = additionalTeam.getRole(); + GHTeam ghTeam = org.getTeamByName(name); + additionalTeamNames.add(name); + + if (ghTeam != null && role != null) { + gitHubService.updateTeamRole(repo, ghTeam, role); + } else if (ghTeam == null) { + logger.error("Additional team not found: " + name); + } + } + + // remove teams that are not in the yaml file + for (String currentTeam : currentTeamMap) { + GHTeam ghTeam = org.getTeamByName(currentTeam); + + if (!additionalTeamNames.contains(currentTeam)) { + // backfill team name if it's the first run + ghTeam.remove(repo); + } + } + } +} + + + diff --git a/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/YamlTeamManager.java b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/YamlTeamManager.java new file mode 100644 index 0000000000..a0d93c684d --- /dev/null +++ b/src/main/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/YamlTeamManager.java @@ -0,0 +1,148 @@ +package io.jenkins.infra.repository_permissions_updater.github_team_sync; + +import org.yaml.snakeyaml.Yaml; + +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +/** + * Loads and parses YAML configurations into RepoTeamDefinition objects with security validations. + */ + +public class YamlTeamManager { + private static final Logger logger = LoggerFactory.getLogger(YamlTeamManager.class); + + // Defines paths for permission and team YAML files + private static final Path PERMISSIONS_PATH = Paths.get("permissions").toAbsolutePath().normalize(); + private static final Path TEAMS_PATH = Paths.get("teams").toAbsolutePath().normalize(); + + private final Path resolvedPath; + private final Map teamConfig; + + public YamlTeamManager(GitHubService gitHubService, String filePath) throws IOException { + this.resolvedPath = resolveFilePath(filePath); + this.teamConfig = loadYamlConfiguration(this.resolvedPath); + } + + public Object loadTeam(String filePath) throws IOException { + Path resolvedPath = resolveFilePath(filePath); + Map teamConfig = loadYamlConfiguration(resolvedPath); + + if (filePath.startsWith("permissions/")) { + return parsePermissionsTeamDefinition(teamConfig); + } else if (filePath.startsWith("teams/")) { + return parseTeamsTeamDefinition(teamConfig); + } else { + throw new IllegalArgumentException("Unsupported file path: " + filePath); + } + } + + // Resolves and secures a YAML file path, protecting against path traversal attacks. + private Path resolveFilePath(String filePath) { + Path basePath = filePath.startsWith("permissions/") ? PERMISSIONS_PATH : TEAMS_PATH; + Path resolvedPath = basePath.resolve(filePath.replaceFirst("^(permissions/|teams/)", "")).normalize(); + + if (!resolvedPath.startsWith(basePath)) { + throw new SecurityException("Attempted path traversal out of allowed directory"); + } + if (!resolvedPath.toString().endsWith(".yml")) { + throw new SecurityException("Invalid file type"); + } + if (!Files.exists(resolvedPath)) { + throw new RuntimeException("File does not exist: " + resolvedPath); + } + return resolvedPath; + } + + + private static Map loadYamlConfiguration(Path path) { + try (FileInputStream inputStream = new FileInputStream(path.toFile())) { + Yaml yaml = new Yaml(); + return yaml.load(inputStream); + } catch (Exception e) { + logger.error("Failed to load YAML configuration: {}", path, e); + throw new RuntimeException("Failed to load YAML configuration: " + path, e); + } + } + + private static RepoTeamDefinition parsePermissionsTeamDefinition( + Map teamConfig) throws IOException { + + // Extract the repo name and org name from the GitHub key + // e.g. github: &GH "jenkinsci/commons-lang3-api-plugin" + // orgName = jenkinsci, repoName = commons-lang3-api-plugin + String repoPath = (String) teamConfig.getOrDefault("github", ""); + String[] parts = repoPath.split("/"); + String orgName = parts[0]; + String repoName = parts[1]; + + // Extract the team name and role + // Note: repository_team should always be admin + // e.g. repository_team: design-library-plugin-developers + // teamName = design-library-plugin-developers, teamRole = admin + String repoTeamName = (String) teamConfig.get("repository_team"); + + // Extract the developers, example: + // developers: + // - "user1" + // - "user2" + Set developers = extractDevelopers(teamConfig); + + // If developers is not empty, then team name is required + if ((repoTeamName == null || repoTeamName.trim().isEmpty()) && !developers.isEmpty()) { + throw new IllegalArgumentException("No valid team name found."); + } + + + // Extract the additional GitHub teams, which can range from zero to any number, example: + // additional_github_teams: + // - name: sig-ux + // role: admin + Set additionalTeams = new HashSet<>(); + Map additionalTeamsInfo = (Map) teamConfig.get("additional_github_teams"); + if (additionalTeamsInfo != null) { + List> groups = (List>) additionalTeamsInfo; + for (Map group : groups) { + String name = (String) group.get("name"); + String role = (String) group.get("role"); + try { + additionalTeams.add(new AdditionalTeamDefinition(name, role)); + } catch (IllegalArgumentException e) { + // if the role is invalid, log an error and skip adding this team + logger.error("Invalid role {} for team {}: {}", role, name, e.getMessage()); + } + } + } + return new RepoTeamDefinition(repoName, orgName, repoTeamName, developers, additionalTeams); + } + + private static SpecialTeamDefinition parseTeamsTeamDefinition(Map teamConfig) throws IOException { + String teamName = (String) teamConfig.getOrDefault("github_team", ""); + Set developers = extractDevelopers(teamConfig); + + if (teamName == null || teamName.trim().isEmpty()) { + // If developers is not empty, then team name is required + if (!developers.isEmpty()) { + throw new IllegalArgumentException("No valid team name found."); + } + } + + return new SpecialTeamDefinition(null, teamName, developers); + } + + private static Set extractDevelopers(Map teamConfig) { + List devsList = (List) teamConfig.getOrDefault( + "developers", new HashSet<>()); + return new HashSet<>(devsList); + } + + +} diff --git a/src/test/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/TeamUpdaterTest.java b/src/test/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/TeamUpdaterTest.java new file mode 100644 index 0000000000..83779ed2e2 --- /dev/null +++ b/src/test/java/io/jenkins/infra/repository_permissions_updater/github_team_sync/TeamUpdaterTest.java @@ -0,0 +1,92 @@ +package io.jenkins.infra.repository_permissions_updater.github_team_sync; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + + +import org.kohsuke.github.GHOrganization; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GHTeam; + +import java.io.IOException; + +import java.util.Set; + + +@ExtendWith(MockitoExtension.class) +public class TeamUpdaterTest { + @Mock + private GitHubService gitHubService; + + @Mock + private GHOrganization org; + + @Mock + private GHRepository repo; + + @Mock + private GHTeam ghTeam; + + private TeamUpdater teamUpdater; + + @BeforeEach + public void setUp() { + teamUpdater = new TeamUpdater(gitHubService); + } + + @Test + public void testUpdateTeamCreatesNewTeam() throws IOException { + Set developers = Set.of("dev1", "dev2"); // Example set of developers + Set additionalTeams = Set.of( + new AdditionalTeamDefinition("teamA", "maintain"), + new AdditionalTeamDefinition("teamB", "read") + ); + RepoTeamDefinition team = new RepoTeamDefinition("example-repo", "jenkins", "example-repo Developers", developers, additionalTeams); + when(gitHubService.getOrganization(anyString())).thenReturn(org); + when(org.getRepository(anyString())).thenReturn(repo); + when(org.getTeamByName(anyString())).thenReturn(null); // Team does not exist + when(gitHubService.createTeam(anyString(), anyString(), any())).thenReturn(ghTeam); + + teamUpdater.updateTeam(team); + + verify(gitHubService).createTeam(eq("jenkins"), eq("example-repo Developers"), eq(GHTeam.Privacy.CLOSED)); + verify(gitHubService).updateTeamRole(eq(repo), eq(ghTeam), any()); + } + + @Test + public void testUpdateExistingTeamMembers() throws IOException { + Set currentMembers = Set.of("dev1", "dev3"); + Set yamlMembers = Set.of("dev1", "dev2"); + when(gitHubService.getCurrentTeamMembers(ghTeam)).thenReturn(currentMembers); + when(gitHubService.getOrganization(anyString())).thenReturn(org); + when(org.getTeamByName("example-repo Developers")).thenReturn(ghTeam); + + teamUpdater.updateTeam(new RepoTeamDefinition("example-repo", "jenkins", "example-repo Developers", yamlMembers, Set.of())); + + verify(gitHubService).addDeveloperToTeam(ghTeam, "dev2"); + verify(gitHubService).removeDeveloperFromTeam(ghTeam, "dev3"); + } + + @Test + public void testUpdateAdditionalTeamRoles() throws IOException { + when(gitHubService.getCurrentTeams(repo, ghTeam)).thenReturn(Set.of("teamA", "teamC")); // teamC is not in the yaml + when(org.getTeamByName("teamA")).thenReturn(ghTeam); + when(org.getTeamByName("teamC")).thenReturn(ghTeam); + + Set additionalTeams = Set.of(new AdditionalTeamDefinition("teamA", "maintain")); + + teamUpdater.updateAdditionalTeam(org, repo, ghTeam, additionalTeams); + + verify(gitHubService).updateTeamRole(eq(repo), eq(ghTeam), any()); + verify(ghTeam).remove(eq(repo)); // teamC is removed + } + +}