Browse Source

SONAR-14826 - BBC Authenticate through SetPatAction and CheckPatAction

tags/9.0.0.45539
Belen Pruvost 3 years ago
parent
commit
5333756be2

+ 28
- 6
server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucket/bitbucketcloud/BitbucketCloudRestClient.java View File

@@ -46,6 +46,7 @@ import static org.sonar.api.internal.apachecommons.lang.StringUtils.removeEnd;
@ServerSide
public class BitbucketCloudRestClient {
private static final Logger LOG = Loggers.get(BitbucketCloudRestClient.class);
private static final String AUTHORIZATION = "Authorization";
private static final String GET = "GET";
private static final String ENDPOINT = "https://api.bitbucket.org";
private static final String ACCESS_TOKEN_ENDPOINT = "https://bitbucket.org/site/oauth2/access_token";
@@ -88,6 +89,17 @@ public class BitbucketCloudRestClient {
}
}

/**
* Validate parameters provided.
*/
public void validateAppPassword(String encodedCredentials, String workspace) {
try {
doGetWithBasicAuth(encodedCredentials, buildUrl("/repositories/" + workspace), r -> null);
} catch (NotFoundException | IllegalStateException e) {
throw new IllegalArgumentException(e.getMessage());
}
}

private Token validateAccessToken(String clientId, String clientSecret) {
Request request = createAccessTokenRequest(clientId, clientSecret);
try (Response response = client.newCall(request).execute()) {
@@ -133,11 +145,7 @@ public class BitbucketCloudRestClient {
.build();
HttpUrl url = HttpUrl.parse(accessTokenEndpoint);
String credential = Credentials.basic(clientId, clientSecret);
return new Request.Builder()
.method("POST", body)
.url(url)
.header("Authorization", credential)
.build();
return prepareRequestWithBasicAuthCredentials(credential, "POST", url, body);
}

protected HttpUrl buildUrl(String relativeUrl) {
@@ -149,6 +157,11 @@ public class BitbucketCloudRestClient {
return doCall(request, handler);
}

protected <G> G doGetWithBasicAuth(String encodedCredentials, HttpUrl url, Function<Response, G> handler) {
Request request = prepareRequestWithBasicAuthCredentials("Basic " + encodedCredentials, GET, url, null);
return doCall(request, handler);
}

protected <G> G doCall(Request request, Function<Response, G> handler) {
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) {
@@ -232,7 +245,16 @@ public class BitbucketCloudRestClient {
return new Request.Builder()
.method(method, body)
.url(url)
.header("Authorization", "Bearer " + accessToken)
.header(AUTHORIZATION, "Bearer " + accessToken)
.build();
}

protected static Request prepareRequestWithBasicAuthCredentials(String encodedCredentials, String method,
HttpUrl url, @Nullable RequestBody body) {
return new Request.Builder()
.method(method, body)
.url(url)
.header(AUTHORIZATION, encodedCredentials)
.build();
}


+ 27
- 0
server/sonar-alm-client/src/test/java/org/sonar/alm/client/bitbucket/bitbucketcloud/BitbucketCloudRestClientTest.java View File

@@ -143,6 +143,33 @@ public class BitbucketCloudRestClientTest {
.withMessage("Error returned by Bitbucket Cloud: Your credentials lack one or more required privilege scopes.");
}

@Test
public void validate_app_password_success() throws Exception {
String reposResponse = "{\"pagelen\": 10,\n" +
"\"values\": [],\n" +
"\"page\": 1,\n" +
"\"size\": 0\n" +
"}";

server.enqueue(new MockResponse().setBody(reposResponse));
server.enqueue(new MockResponse().setBody("OK"));

underTest.validateAppPassword("user:password", "workspace");

RecordedRequest request = server.takeRequest();
assertThat(request.getPath()).isEqualTo("/2.0/repositories/workspace");
assertThat(request.getHeader("Authorization")).isNotNull();
}

@Test
public void validate_app_password_with_invalid_credentials() {
server.enqueue(new MockResponse().setResponseCode(401).setHeader("Content-Type", JSON_MEDIA_TYPE));

assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> underTest.validateAppPassword("wrong:wrong", "workspace"))
.withMessage("Unable to contact Bitbucket Cloud servers");
}

@Test
public void nullErrorBodyIsSupported() throws IOException {
OkHttpClient clientMock = mock(OkHttpClient.class);

+ 14
- 1
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/CheckPatAction.java View File

@@ -20,8 +20,10 @@
package org.sonar.server.almintegration.ws;

import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
import org.sonar.alm.client.bitbucket.bitbucketcloud.BitbucketCloudRestClient;
import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient;
import org.sonar.alm.client.gitlab.GitlabHttpClient;
import org.sonar.api.server.ws.Change;
import org.sonar.api.server.ws.Request;
import org.sonar.api.server.ws.Response;
import org.sonar.api.server.ws.WebService;
@@ -38,22 +40,27 @@ import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS;
public class CheckPatAction implements AlmIntegrationsWsAction {

private static final String PARAM_ALM_SETTING = "almSetting";
private static final String APP_PASSWORD_CANNOT_BE_NULL = "App Password and Username cannot be null";
private static final String PAT_CANNOT_BE_NULL = "PAT cannot be null";
private static final String URL_CANNOT_BE_NULL = "URL cannot be null";
private static final String WORKSPACE_CANNOT_BE_NULL = "Workspace cannot be null";

private final DbClient dbClient;
private final UserSession userSession;
private final AzureDevOpsHttpClient azureDevOpsHttpClient;
private final BitbucketCloudRestClient bitbucketCloudRestClient;
private final BitbucketServerRestClient bitbucketServerRestClient;
private final GitlabHttpClient gitlabHttpClient;

public CheckPatAction(DbClient dbClient, UserSession userSession,
AzureDevOpsHttpClient azureDevOpsHttpClient,
BitbucketCloudRestClient bitbucketCloudRestClient,
BitbucketServerRestClient bitbucketServerRestClient,
GitlabHttpClient gitlabHttpClient) {
this.dbClient = dbClient;
this.userSession = userSession;
this.azureDevOpsHttpClient = azureDevOpsHttpClient;
this.bitbucketCloudRestClient = bitbucketCloudRestClient;
this.bitbucketServerRestClient = bitbucketServerRestClient;
this.gitlabHttpClient = gitlabHttpClient;
}
@@ -66,7 +73,8 @@ public class CheckPatAction implements AlmIntegrationsWsAction {
.setPost(false)
.setInternal(true)
.setSince("8.2")
.setHandler(this);
.setHandler(this)
.setChangelog(new Change("9.0", "Bitbucket Cloud support was added"));

action.createParam(PARAM_ALM_SETTING)
.setRequired(true)
@@ -110,6 +118,11 @@ public class CheckPatAction implements AlmIntegrationsWsAction {
requireNonNull(almPatDto.getPersonalAccessToken(), PAT_CANNOT_BE_NULL),
null, 1, 10);
break;
case BITBUCKET_CLOUD:
bitbucketCloudRestClient.validateAppPassword(
requireNonNull(almPatDto.getPersonalAccessToken(), APP_PASSWORD_CANNOT_BE_NULL),
requireNonNull(almSettingDto.getAppId(), WORKSPACE_CANNOT_BE_NULL));
break;
case GITHUB:
default:
throw new IllegalArgumentException(String.format("unsupported ALM %s", almSettingDto.getAlm()));

+ 41
- 0
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/CredentialsEncoderHelper.java View File

@@ -0,0 +1,41 @@
/*
* SonarQube
* Copyright (C) 2009-2021 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.server.almintegration.ws;

import java.util.Base64;
import javax.annotation.Nullable;
import org.sonar.db.alm.setting.ALM;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.sonar.db.alm.setting.ALM.BITBUCKET_CLOUD;

public class CredentialsEncoderHelper {
private CredentialsEncoderHelper() {

}

public static String encodeCredentials(ALM alm, String pat, @Nullable String username) {
if (!alm.equals(BITBUCKET_CLOUD)) {
return pat;
}

return Base64.getEncoder().encodeToString((username + ":" + pat).getBytes(UTF_8));
}
}

+ 22
- 7
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/SetPatAction.java View File

@@ -19,8 +19,10 @@
*/
package org.sonar.server.almintegration.ws;

import com.google.common.base.Strings;
import java.util.Arrays;
import java.util.Optional;
import org.sonar.api.server.ws.Change;
import org.sonar.api.server.ws.Request;
import org.sonar.api.server.ws.Response;
import org.sonar.api.server.ws.WebService;
@@ -36,6 +38,7 @@ import static java.lang.String.format;
import static java.util.Objects.requireNonNull;
import static org.sonar.db.alm.setting.ALM.AZURE_DEVOPS;
import static org.sonar.db.alm.setting.ALM.BITBUCKET;
import static org.sonar.db.alm.setting.ALM.BITBUCKET_CLOUD;
import static org.sonar.db.alm.setting.ALM.GITLAB;
import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS;

@@ -43,6 +46,7 @@ public class SetPatAction implements AlmIntegrationsWsAction {

private static final String PARAM_ALM_SETTING = "almSetting";
private static final String PARAM_PAT = "pat";
private static final String PARAM_USERNAME = "username";

private final DbClient dbClient;
private final UserSession userSession;
@@ -56,11 +60,12 @@ public class SetPatAction implements AlmIntegrationsWsAction {
public void define(WebService.NewController context) {
WebService.NewAction action = context.createAction("set_pat")
.setDescription("Set a Personal Access Token for the given ALM setting<br/>" +
"Only valid for Azure DevOps, Bitbucket Server & GitLab Alm Setting<br/>" +
"Only valid for Azure DevOps, Bitbucket Server, GitLab Alm and Bitbucket Cloud Setting<br/>" +
"Requires the 'Create Projects' permission")
.setPost(true)
.setSince("8.2")
.setHandler(this);
.setHandler(this)
.setChangelog(new Change("9.0", "Bitbucket Cloud support and optional Username parameter were added"));

action.createParam(PARAM_ALM_SETTING)
.setRequired(true)
@@ -69,6 +74,10 @@ public class SetPatAction implements AlmIntegrationsWsAction {
.setRequired(true)
.setMaximumLength(2000)
.setDescription("Personal Access Token");
action.createParam(PARAM_USERNAME)
.setRequired(false)
.setMaximumLength(2000)
.setDescription("Username");
}

@Override
@@ -83,22 +92,29 @@ public class SetPatAction implements AlmIntegrationsWsAction {

String pat = request.mandatoryParam(PARAM_PAT);
String almSettingKey = request.mandatoryParam(PARAM_ALM_SETTING);
String username = request.param(PARAM_USERNAME);

String userUuid = requireNonNull(userSession.getUuid(), "User UUID cannot be null");
AlmSettingDto almSetting = dbClient.almSettingDao().selectByKey(dbSession, almSettingKey)
.orElseThrow(() -> new NotFoundException(format("ALM Setting '%s' not found", almSettingKey)));

Preconditions.checkArgument(Arrays.asList(AZURE_DEVOPS, BITBUCKET, GITLAB)
.contains(almSetting.getAlm()), "Only Azure DevOps, Bibucket Server and GitLab ALM Settings are supported.");
Preconditions.checkArgument(Arrays.asList(AZURE_DEVOPS, BITBUCKET, GITLAB, BITBUCKET_CLOUD)
.contains(almSetting.getAlm()), "Only Azure DevOps, Bitbucket Server, GitLab ALM and Bitbucket Cloud Settings are supported.");

if(almSetting.getAlm().equals(BITBUCKET_CLOUD)) {
Preconditions.checkArgument(!Strings.isNullOrEmpty(username), "Username cannot be null for Bitbucket Cloud");
}

String resultingPat = CredentialsEncoderHelper.encodeCredentials(almSetting.getAlm(), pat, username);

Optional<AlmPatDto> almPatDto = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSetting);
if (almPatDto.isPresent()) {
AlmPatDto almPat = almPatDto.get();
almPat.setPersonalAccessToken(pat);
almPat.setPersonalAccessToken(resultingPat);
dbClient.almPatDao().update(dbSession, almPat);
} else {
AlmPatDto almPat = new AlmPatDto()
.setPersonalAccessToken(pat)
.setPersonalAccessToken(resultingPat)
.setAlmSettingUuid(almSetting.getUuid())
.setUserUuid(userUuid);
dbClient.almPatDao().insert(dbSession, almPat);
@@ -106,5 +122,4 @@ public class SetPatAction implements AlmIntegrationsWsAction {
dbSession.commit();
}
}

}

+ 44
- 1
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/CheckPatActionTest.java View File

@@ -22,6 +22,7 @@ package org.sonar.server.almintegration.ws;
import org.junit.Rule;
import org.junit.Test;
import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
import org.sonar.alm.client.bitbucket.bitbucketcloud.BitbucketCloudRestClient;
import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient;
import org.sonar.alm.client.gitlab.GitlabHttpClient;
import org.sonar.api.server.ws.WebService;
@@ -41,6 +42,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -56,9 +59,11 @@ public class CheckPatActionTest {
public DbTester db = DbTester.create();

private final AzureDevOpsHttpClient azureDevOpsPrHttpClient = mock(AzureDevOpsHttpClient.class);
private final BitbucketCloudRestClient bitbucketCloudRestClient = mock(BitbucketCloudRestClient.class);
private final BitbucketServerRestClient bitbucketServerRestClient = mock(BitbucketServerRestClient.class);
private final GitlabHttpClient gitlabPrHttpClient = mock(GitlabHttpClient.class);
private final WsActionTester ws = new WsActionTester(new CheckPatAction(db.getDbClient(), userSession, azureDevOpsPrHttpClient, bitbucketServerRestClient, gitlabPrHttpClient));
private final WsActionTester ws = new WsActionTester(new CheckPatAction(db.getDbClient(), userSession, azureDevOpsPrHttpClient,
bitbucketCloudRestClient, bitbucketServerRestClient, gitlabPrHttpClient));

@Test
public void check_pat_for_github() {
@@ -133,6 +138,25 @@ public class CheckPatActionTest {
verify(gitlabPrHttpClient).searchProjects(almSetting.getUrl(), PAT_SECRET, null, 1, 10);
}

@Test
public void check_pat_for_bitbucketcloud() {
UserDto user = db.users().insertUser();
userSession.logIn(user).addPermission(PROVISION_PROJECTS);
AlmSettingDto almSetting = db.almSettings().insertBitbucketCloudAlmSetting();
db.almPats().insert(dto -> {
dto.setAlmSettingUuid(almSetting.getUuid());
dto.setUserUuid(user.getUuid());
dto.setPersonalAccessToken(PAT_SECRET);
});

ws.newRequest()
.setParam("almSetting", almSetting.getKey())
.execute();

assertThat(almSetting.getAppId()).isNotNull();
verify(bitbucketCloudRestClient).validateAppPassword(PAT_SECRET, almSetting.getAppId());
}

@Test
public void fail_when_personal_access_token_is_invalid_for_bitbucket() {
when(bitbucketServerRestClient.getRecentRepo(any(), any())).thenThrow(new IllegalArgumentException("Invalid personal access token"));
@@ -168,6 +192,25 @@ public class CheckPatActionTest {
.hasMessage("Invalid personal access token");
}

@Test
public void fail_when_personal_access_token_is_invalid_for_bitbucketcloud() {
doThrow(new IllegalArgumentException("Invalid personal access token"))
.when(bitbucketCloudRestClient).validateAppPassword(anyString(), anyString());

UserDto user = db.users().insertUser();
userSession.logIn(user).addPermission(PROVISION_PROJECTS);
AlmSettingDto almSetting = db.almSettings().insertBitbucketCloudAlmSetting();
db.almPats().insert(dto -> {
dto.setAlmSettingUuid(almSetting.getUuid());
dto.setUserUuid(user.getUuid());
});

TestRequest request = ws.newRequest().setParam("almSetting", almSetting.getKey());
assertThatThrownBy(request::execute)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Invalid personal access token");
}

@Test
public void fail_when_not_logged_in() {
TestRequest request = ws.newRequest().setParam("almSetting", "anyvalue");

+ 50
- 0
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/CredentialsEncoderHelperTest.java View File

@@ -0,0 +1,50 @@
/*
* SonarQube
* Copyright (C) 2009-2021 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.server.almintegration.ws;

import java.util.Base64;
import org.junit.Test;
import org.sonar.db.alm.setting.ALM;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;

public class CredentialsEncoderHelperTest {
private static final String PAT = "pat";
private static final String USERNAME = "user";

@Test
public void encodes_credential_returns_just_pat_for_non_bitbucketcloud() {
assertThat(CredentialsEncoderHelper.encodeCredentials(ALM.GITHUB, PAT, null))
.isEqualTo("pat");
}

@Test
public void encodes_credential_returns_username_and_encoded_pat_for_bitbucketcloud() {
String encodedPat = Base64.getEncoder().encodeToString((USERNAME + ":" + PAT).getBytes(UTF_8));

String encodedCredential = CredentialsEncoderHelper.encodeCredentials(ALM.BITBUCKET_CLOUD, PAT, USERNAME);
assertThat(encodedCredential)
.doesNotContain(USERNAME)
.doesNotContain(PAT)
.contains(encodedPat);
}

}

+ 40
- 2
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/SetPatActionTest.java View File

@@ -26,6 +26,7 @@ import org.junit.rules.ExpectedException;
import org.sonar.api.server.ws.WebService;
import org.sonar.db.DbTester;
import org.sonar.db.alm.pat.AlmPatDto;
import org.sonar.db.alm.setting.ALM;
import org.sonar.db.alm.setting.AlmSettingDto;
import org.sonar.db.user.UserDto;
import org.sonar.server.exceptions.ForbiddenException;
@@ -85,6 +86,28 @@ public class SetPatActionTest {
assertThat(actualAlmPat.get().getAlmSettingUuid()).isEqualTo(almSetting.getUuid());
}

@Test
public void set_new_bitbucketcloud_pat() {
UserDto user = db.users().insertUser();
AlmSettingDto almSetting = db.almSettings().insertBitbucketCloudAlmSetting();
userSession.logIn(user).addPermission(PROVISION_PROJECTS);

String pat = "12345678987654321";
String username = "test-user";

ws.newRequest()
.setParam("almSetting", almSetting.getKey())
.setParam("pat", pat)
.setParam("username", username)
.execute();

Optional<AlmPatDto> actualAlmPat = db.getDbClient().almPatDao().selectByUserAndAlmSetting(db.getSession(), user.getUuid(), almSetting);
assertThat(actualAlmPat).isPresent();
assertThat(actualAlmPat.get().getPersonalAccessToken()).isEqualTo(CredentialsEncoderHelper.encodeCredentials(ALM.BITBUCKET_CLOUD, pat, username));
assertThat(actualAlmPat.get().getUserUuid()).isEqualTo(user.getUuid());
assertThat(actualAlmPat.get().getAlmSettingUuid()).isEqualTo(almSetting.getUuid());
}

@Test
public void set_new_gitlab_pat() {
UserDto user = db.users().insertUser();
@@ -122,6 +145,21 @@ public class SetPatActionTest {
assertThat(actualAlmPat.get().getAlmSettingUuid()).isEqualTo(almSetting.getUuid());
}

@Test
public void fail_when_bitbucketcloud_without_username() {
UserDto user = db.users().insertUser();
AlmSettingDto almSetting = db.almSettings().insertBitbucketCloudAlmSetting();
userSession.logIn(user).addPermission(PROVISION_PROJECTS);

expectedException.expect(IllegalArgumentException.class);
expectedException.expectMessage("Username cannot be null for Bitbucket Cloud");

ws.newRequest()
.setParam("almSetting", almSetting.getKey())
.setParam("pat", "12345678987654321")
.execute();
}

@Test
public void fail_when_alm_setting_unknow() {
UserDto user = db.users().insertUser();
@@ -143,7 +181,7 @@ public class SetPatActionTest {
userSession.logIn(user).addPermission(PROVISION_PROJECTS);

expectedException.expect(IllegalArgumentException.class);
expectedException.expectMessage("Only Azure DevOps, Bibucket Server and GitLab ALM Settings are supported.");
expectedException.expectMessage("Only Azure DevOps, Bitbucket Server, GitLab ALM and Bitbucket Cloud Settings are supported.");

ws.newRequest()
.setParam("almSetting", almSetting.getKey())
@@ -177,7 +215,7 @@ public class SetPatActionTest {
assertThat(def.isPost()).isTrue();
assertThat(def.params())
.extracting(WebService.Param::key, WebService.Param::isRequired)
.containsExactlyInAnyOrder(tuple("almSetting", true), tuple("pat", true));
.containsExactlyInAnyOrder(tuple("almSetting", true), tuple("pat", true), tuple("username", false));
}

}

+ 2
- 0
server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java View File

@@ -48,6 +48,7 @@ import org.sonar.core.extension.CoreExtensionsInstaller;
import org.sonar.core.platform.ComponentContainer;
import org.sonar.core.platform.PlatformEditionProvider;
import org.sonar.server.almintegration.ws.AlmIntegrationsWSModule;
import org.sonar.server.almintegration.ws.CredentialsEncoderHelper;
import org.sonar.server.almintegration.ws.ImportHelper;
import org.sonar.server.almsettings.MultipleAlmFeatureProvider;
import org.sonar.server.almsettings.ws.AlmSettingsWsModule;
@@ -500,6 +501,7 @@ public class PlatformLevel4 extends PlatformLevel {

// ALM integrations
TimeoutConfigurationImpl.class,
CredentialsEncoderHelper.class,
ImportHelper.class,
GithubAppSecurityImpl.class,
GithubApplicationClientImpl.class,

Loading…
Cancel
Save