Browse Source

SONAR-20700 Move getRepositoryCollaborators/getRepositoryTeams from GithubUserClient to GithubApplicationClient (and make it accessible for commmunity edition)

tags/10.3.0.82913
Aurelien Poscia 8 months ago
parent
commit
8445ca39a9

+ 9
- 0
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java View File

@@ -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;

+ 50
- 8
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java View File

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

+ 29
- 0
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/api/GsonRepositoryCollaborator.java View File

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

+ 31
- 0
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/api/GsonRepositoryTeam.java View File

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

+ 23
- 0
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/api/package-info.java View File

@@ -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;

+ 98
- 3
server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java View File

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

+ 26
- 0
server/sonar-alm-client/src/test/resources/org/sonar/alm/client/github/repo-collaborators-full-response.json View File

@@ -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
}
}
]

+ 28
- 0
server/sonar-alm-client/src/test/resources/org/sonar/alm/client/github/repo-teams-full-response.json View File

@@ -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
}
}
]

Loading…
Cancel
Save