diff options
author | Aurelien Poscia <aurelien.poscia@sonarsource.com> | 2023-10-16 11:09:16 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-10-20 20:02:40 +0000 |
commit | 8445ca39a9f46b228f693fbb259f623ba6d05414 (patch) | |
tree | fa2d714624e16a1254c931057d6f8d2386f3a8fc /server/sonar-alm-client | |
parent | f5f71fdbe1482d7b132a9923419672459d762aad (diff) | |
download | sonarqube-8445ca39a9f46b228f693fbb259f623ba6d05414.tar.gz sonarqube-8445ca39a9f46b228f693fbb259f623ba6d05414.zip |
SONAR-20700 Move getRepositoryCollaborators/getRepositoryTeams from GithubUserClient to GithubApplicationClient (and make it accessible for commmunity edition)
Diffstat (limited to 'server/sonar-alm-client')
8 files changed, 294 insertions, 11 deletions
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java index 9a7db826bb3..7ef05182cd0 100644 --- a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java @@ -22,9 +22,12 @@ package org.sonar.alm.client.github; import com.google.gson.annotations.SerializedName; import java.util.List; import java.util.Optional; +import java.util.Set; import javax.annotation.CheckForNull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; +import org.sonar.alm.client.github.api.GsonRepositoryCollaborator; +import org.sonar.alm.client.github.api.GsonRepositoryTeam; import org.sonar.alm.client.github.config.GithubAppConfiguration; import org.sonar.alm.client.github.config.GithubAppInstallation; import org.sonar.alm.client.github.security.AccessToken; @@ -93,6 +96,12 @@ public interface GithubApplicationClient { */ Optional<Repository> getRepository(String appUrl, AccessToken accessToken, String repositoryKey); + + + Set<GsonRepositoryTeam> getRepositoryTeams(String appUrl, AppInstallationToken accessToken, String orgName, String repoName); + + Set<GsonRepositoryCollaborator> getRepositoryCollaborators(String appUrl, AppInstallationToken accessToken, String orgName, String repoName); + class Repositories { private int total; private List<Repository> repositories; diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java index be6914fa8a5..9403377c59f 100644 --- a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java @@ -32,6 +32,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import javax.annotation.Nullable; import org.slf4j.Logger; @@ -40,6 +41,8 @@ import org.sonar.alm.client.github.GithubApplicationHttpClient.GetResponse; import org.sonar.alm.client.github.GithubBinding.GsonGithubRepository; import org.sonar.alm.client.github.GithubBinding.GsonInstallations; import org.sonar.alm.client.github.GithubBinding.GsonRepositorySearch; +import org.sonar.alm.client.github.api.GsonRepositoryCollaborator; +import org.sonar.alm.client.github.api.GsonRepositoryTeam; import org.sonar.alm.client.github.config.GithubAppConfiguration; import org.sonar.alm.client.github.config.GithubAppInstallation; import org.sonar.alm.client.github.security.AccessToken; @@ -66,6 +69,12 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { protected static final String WRITE_PERMISSION_NAME = "write"; protected static final String READ_PERMISSION_NAME = "read"; protected static final String FAILED_TO_REQUEST_BEGIN_MSG = "Failed to request "; + + private static final String EXCEPTION_MESSAGE = "SonarQube was not able to retrieve resources from GitHub. " + + "This is likely due to a connectivity problem or a temporary network outage"; + + private static final Type REPOSITORY_TEAM_LIST_TYPE = TypeToken.getParameterized(List.class, GsonRepositoryTeam.class).getType(); + private static final Type REPOSITORY_COLLABORATORS_LIST_TYPE = TypeToken.getParameterized(List.class, GsonRepositoryCollaborator.class).getType(); private static final Type ORGANIZATION_LIST_TYPE = TypeToken.getParameterized(List.class, GithubBinding.GsonInstallation.class).getType(); protected final GithubApplicationHttpClient appHttpClient; protected final GithubAppSecurity appSecurity; @@ -232,13 +241,8 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { private List<GithubBinding.GsonInstallation> fetchAppInstallationsFromGithub(GithubAppConfiguration githubAppConfiguration) { AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey()); String endpoint = "/app/installations"; - try { - return githubPaginatedHttpClient.get(githubAppConfiguration.getApiEndpoint(), appToken, endpoint, resp -> GSON.fromJson(resp, ORGANIZATION_LIST_TYPE)); - } catch (IOException e) { - LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + endpoint, e); - throw new IllegalStateException("An error occurred when retrieving your GitHup App installations. " - + "It might be related to your GitHub App configuration or a connectivity problem."); - } + + return executePaginatedQuery(githubAppConfiguration.getApiEndpoint(), appToken, endpoint, resp -> GSON.fromJson(resp, ORGANIZATION_LIST_TYPE)); } protected <T> Optional<T> get(String baseUrl, AccessToken token, String endPoint, Class<T> gsonClass) { @@ -292,7 +296,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { .map(GsonGithubRepository::toRepository); } catch (Exception e) { throw new IllegalStateException(format("Failed to get repository '%s' on '%s' (this might be related to the GitHub App installation scope)", - organizationAndRepository, appUrl), e); + organizationAndRepository, appUrl), e); } } @@ -363,4 +367,42 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { return Optional.empty(); } } + + @Override + public Set<GsonRepositoryTeam> getRepositoryTeams(String appUrl, AppInstallationToken accessToken, String orgName, String repoName) { + return Set + .copyOf(executePaginatedQuery(appUrl, accessToken, format("/repos/%s/%s/teams", orgName, repoName), resp -> GSON.fromJson(resp, REPOSITORY_TEAM_LIST_TYPE))); + } + + @Override + public Set<GsonRepositoryCollaborator> getRepositoryCollaborators(String appUrl, AppInstallationToken accessToken, String orgName, String repoName) { + return Set + .copyOf( + executePaginatedQuery( + appUrl, + accessToken, + format("/repos/%s/%s/collaborators?affiliation=direct", orgName, repoName), + resp -> GSON.fromJson(resp, REPOSITORY_COLLABORATORS_LIST_TYPE))); + } + + private <E> List<E> executePaginatedQuery(String appUrl, AccessToken token, String query, Function<String, List<E>> responseDeserializer) { + try { + return githubPaginatedHttpClient.get(appUrl, token, query, responseDeserializer); + } catch (IOException ioException) { + throw logAndCreateException(ioException, format("Error while executing a paginated call to GitHub - appUrl: %s, path: %s.", appUrl, query)); + } + } + + private static IllegalStateException logAndCreateException(IOException ioException, String errorMessage) { + log(errorMessage, ioException); + return new IllegalStateException(EXCEPTION_MESSAGE + ": " + errorMessage + " " + ioException.getMessage()); + } + + private static void log(String message, Exception e) { + if (LOG.isDebugEnabled()) { + LOG.warn(message, e); + } else { + LOG.warn(message); + } + } } diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/api/GsonRepositoryCollaborator.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/api/GsonRepositoryCollaborator.java new file mode 100644 index 00000000000..d7f43cc4e12 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/api/GsonRepositoryCollaborator.java @@ -0,0 +1,29 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.alm.client.github.api; + +import com.google.gson.annotations.SerializedName; +import org.sonar.auth.github.GsonRepositoryPermissions; + +public record GsonRepositoryCollaborator(@SerializedName("login") String name, + @SerializedName("id") Integer id, + @SerializedName("role_name") String roleName, + @SerializedName("permissions") GsonRepositoryPermissions permissions) { +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/api/GsonRepositoryTeam.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/api/GsonRepositoryTeam.java new file mode 100644 index 00000000000..806b5de9e27 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/api/GsonRepositoryTeam.java @@ -0,0 +1,31 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.alm.client.github.api; + +import com.google.gson.annotations.SerializedName; +import org.sonar.auth.github.GsonRepositoryPermissions; + +public record GsonRepositoryTeam( + @SerializedName("name") String name, + @SerializedName("id") Integer id, + @SerializedName("slug") String slug, + @SerializedName("permission") String permission, + @SerializedName("permissions") GsonRepositoryPermissions permissions) { +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/api/package-info.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/api/package-info.java new file mode 100644 index 00000000000..4c1a2f9b0a9 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/api/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.alm.client.github.api; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java index 0429beebdc5..cbe044b9bfe 100644 --- a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java @@ -23,17 +23,22 @@ import com.tngtech.java.junit.dataprovider.DataProvider; import com.tngtech.java.junit.dataprovider.DataProviderRunner; import com.tngtech.java.junit.dataprovider.UseDataProvider; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.function.Function; import javax.annotation.Nullable; +import org.apache.commons.io.IOUtils; import org.junit.Before; import org.junit.ClassRule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.slf4j.event.Level; import org.sonar.alm.client.github.GithubApplicationHttpClient.RateLimit; +import org.sonar.alm.client.github.api.GsonRepositoryCollaborator; +import org.sonar.alm.client.github.api.GsonRepositoryTeam; import org.sonar.alm.client.github.config.GithubAppConfiguration; import org.sonar.alm.client.github.config.GithubAppInstallation; import org.sonar.alm.client.github.security.AccessToken; @@ -44,6 +49,7 @@ import org.sonar.api.testfixtures.log.LogAndArguments; import org.sonar.api.testfixtures.log.LogTester; import org.sonar.api.utils.log.LoggerLevel; import org.sonar.auth.github.GitHubSettings; +import org.sonar.auth.github.GsonRepositoryPermissions; import org.sonarqube.ws.client.HttpException; import static java.net.HttpURLConnection.HTTP_CREATED; @@ -53,6 +59,7 @@ import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.groups.Tuple.tuple; import static org.mockito.ArgumentMatchers.any; @@ -65,7 +72,12 @@ import static org.sonar.alm.client.github.GithubApplicationHttpClient.GetRespons @RunWith(DataProviderRunner.class) public class GithubApplicationClientImplTest { - + private static final String ORG_NAME = "ORG_NAME"; + private static final String TEAM_NAME = "team1"; + private static final String REPO_NAME = "repo1"; + private static final String APP_URL = "https://github.com/"; + private static final String REPO_TEAMS_ENDPOINT = "/repos/ORG_NAME/repo1/teams"; + private static final String REPO_COLLABORATORS_ENDPOINT = "/repos/ORG_NAME/repo1/collaborators?affiliation=direct"; private static final int INSTALLATION_ID = 1; private static final String APP_JWT_TOKEN = "APP_TOKEN_JWT"; private static final String PAYLOAD_2_ORGS = """ @@ -108,8 +120,10 @@ public class GithubApplicationClientImplTest { private GitHubSettings gitHubSettings = mock(); private GithubPaginatedHttpClient githubPaginatedHttpClient = mock(); + private AppInstallationToken appInstallationToken = mock(); private GithubApplicationClient underTest; + private String appUrl = "Any URL"; @Before @@ -574,8 +588,11 @@ public class GithubApplicationClientImplTest { assertThatThrownBy(() -> underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration)) .isInstanceOf(IllegalStateException.class) - .hasMessage("An error occurred when retrieving your GitHup App installations. " - + "It might be related to your GitHub App configuration or a connectivity problem."); + .hasMessage( + "SonarQube was not able to retrieve resources from GitHub. " + + "This is likely due to a connectivity problem or a temporary network outage: " + + "Error while executing a paginated call to GitHub - appUrl: Any URL, path: /app/installations. io exception" + ); } @Test @@ -1034,6 +1051,84 @@ public class GithubApplicationClientImplTest { verify(httpClient).post(appUrl, appToken, "/app/installations/" + INSTALLATION_ID + "/access_tokens"); } + @Test + public void getRepositoryTeams_returnsRepositoryTeams() throws IOException { + ArgumentCaptor<Function<String, List<GsonRepositoryTeam>>> deserializerCaptor = ArgumentCaptor.forClass(Function.class); + + when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_TEAMS_ENDPOINT), deserializerCaptor.capture())).thenReturn(expectedTeams()); + + Set<GsonRepositoryTeam> repoTeams = underTest.getRepositoryTeams(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME); + + assertThat(repoTeams) + .containsExactlyInAnyOrderElementsOf(expectedTeams()); + + String responseContent = getResponseContent("repo-teams-full-response.json"); + assertThat(deserializerCaptor.getValue().apply(responseContent)).containsExactlyElementsOf(expectedTeams()); + } + + @Test + public void getRepositoryTeams_whenGitHubCallThrowsIOException_shouldLogAndThrow() throws IOException { + when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_TEAMS_ENDPOINT), any())).thenThrow(new IOException("error")); + + assertThatIllegalStateException() + .isThrownBy(() -> underTest.getRepositoryTeams(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME)) + .isInstanceOf(IllegalStateException.class) + .withMessage( + "SonarQube was not able to retrieve resources from GitHub. This is likely due to a connectivity problem or a temporary network outage: Error while executing a paginated call to GitHub - appUrl: https://github.com/, path: /repos/ORG_NAME/repo1/teams. error"); + + assertThat(logTester.logs()).hasSize(1); + assertThat(logTester.logs(Level.WARN)) + .containsExactly("Error while executing a paginated call to GitHub - appUrl: https://github.com/, path: /repos/ORG_NAME/repo1/teams."); + } + + private static List<GsonRepositoryTeam> expectedTeams() { + return List.of( + new GsonRepositoryTeam("team1", 1, "team1", "pull", new GsonRepositoryPermissions(true, true, true, true, true)), + new GsonRepositoryTeam("team2", 2, "team2", "push", new GsonRepositoryPermissions(false, false, true, true, true))); + } + + @Test + public void getRepositoryCollaborators_returnsCollaboratorsFromGithub() throws IOException { + ArgumentCaptor<Function<String, List<GsonRepositoryCollaborator>>> deserializerCaptor = ArgumentCaptor.forClass(Function.class); + + when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_COLLABORATORS_ENDPOINT), deserializerCaptor.capture())).thenReturn(expectedCollaborators()); + + Set<GsonRepositoryCollaborator> repoTeams = underTest.getRepositoryCollaborators(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME); + + assertThat(repoTeams) + .containsExactlyInAnyOrderElementsOf(expectedCollaborators()); + + String responseContent = getResponseContent("repo-collaborators-full-response.json"); + assertThat(deserializerCaptor.getValue().apply(responseContent)).containsExactlyElementsOf(expectedCollaborators()); + + } + + @Test + public void getRepositoryCollaborators_whenGitHubCallThrowsIOException_shouldLogAndThrow() throws IOException { + when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_COLLABORATORS_ENDPOINT), any())).thenThrow(new IOException("error")); + + assertThatIllegalStateException() + .isThrownBy(() -> underTest.getRepositoryCollaborators(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME)) + .isInstanceOf(IllegalStateException.class) + .withMessage( + "SonarQube was not able to retrieve resources from GitHub. This is likely due to a connectivity problem or a temporary network outage: " + + "Error while executing a paginated call to GitHub - appUrl: https://github.com/, path: /repos/ORG_NAME/repo1/collaborators?affiliation=direct. error"); + + assertThat(logTester.logs()).hasSize(1); + assertThat(logTester.logs(Level.WARN)) + .containsExactly("Error while executing a paginated call to GitHub - appUrl: https://github.com/, path: /repos/ORG_NAME/repo1/collaborators?affiliation=direct."); + } + + private static String getResponseContent(String path) throws IOException { + return IOUtils.toString(GithubApplicationClientImplTest.class.getResourceAsStream(path), StandardCharsets.UTF_8); + } + + private static List<GsonRepositoryCollaborator> expectedCollaborators() { + return List.of( + new GsonRepositoryCollaborator("jean-michel", 1, "role1", new GsonRepositoryPermissions(true, true, true, true, true)), + new GsonRepositoryCollaborator("jean-pierre", 2, "role2", new GsonRepositoryPermissions(false, false, true, true, true))); + } + private void mockAccessTokenCallingGithubFailure() throws IOException { Response response = mock(Response.class); when(response.getContent()).thenReturn(Optional.empty()); diff --git a/server/sonar-alm-client/src/test/resources/org/sonar/alm/client/github/repo-collaborators-full-response.json b/server/sonar-alm-client/src/test/resources/org/sonar/alm/client/github/repo-collaborators-full-response.json new file mode 100644 index 00000000000..644b9083d56 --- /dev/null +++ b/server/sonar-alm-client/src/test/resources/org/sonar/alm/client/github/repo-collaborators-full-response.json @@ -0,0 +1,26 @@ +[ + { + "login": "jean-michel", + "id": 1, + "role_name": "role1", + "permissions": { + "admin": true, + "maintain": true, + "push": true, + "triage": true, + "pull": true + } + }, + { + "login": "jean-pierre", + "id": 2, + "role_name": "role2", + "permissions": { + "admin": false, + "maintain": false, + "push": true, + "triage": true, + "pull": true + } + } +] diff --git a/server/sonar-alm-client/src/test/resources/org/sonar/alm/client/github/repo-teams-full-response.json b/server/sonar-alm-client/src/test/resources/org/sonar/alm/client/github/repo-teams-full-response.json new file mode 100644 index 00000000000..842eed2666e --- /dev/null +++ b/server/sonar-alm-client/src/test/resources/org/sonar/alm/client/github/repo-teams-full-response.json @@ -0,0 +1,28 @@ +[ + { + "name": "team1", + "id": 1, + "slug": "team1", + "permission": "pull", + "permissions": { + "admin": true, + "maintain": true, + "push": true, + "triage": true, + "pull": true + } + }, + { + "name": "team2", + "id": 2, + "slug": "team2", + "permission": "push", + "permissions": { + "admin": false, + "maintain": false, + "push": true, + "triage": true, + "pull": true + } + } +] |