@@ -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; |
@@ -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); | |||
} | |||
} | |||
} |
@@ -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) { | |||
} |
@@ -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) { | |||
} |
@@ -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; |
@@ -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()); |
@@ -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 | |||
} | |||
} | |||
] |
@@ -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 | |||
} | |||
} | |||
] |