aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-alm-client
diff options
context:
space:
mode:
authorJacek <jacek.poreda@sonarsource.com>2021-01-27 14:09:11 +0100
committersonartech <sonartech@sonarsource.com>2021-02-04 20:07:07 +0000
commit53fb0b91e5da3762990255dc8ec4caa67ed63e30 (patch)
tree13a41ad0db5c2179e1fe00859dc14817ef6ee8ca /server/sonar-alm-client
parentc364b859dd4b1610dd7537b77f1152e620c5525a (diff)
downloadsonarqube-53fb0b91e5da3762990255dc8ec4caa67ed63e30.tar.gz
sonarqube-53fb0b91e5da3762990255dc8ec4caa67ed63e30.zip
SONAR-14371 Move Github http client to CE
Diffstat (limited to 'server/sonar-alm-client')
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java304
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java164
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationHttpClient.java81
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationHttpClientImpl.java254
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubBinding.java148
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/package-info.java23
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/AccessToken.java35
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/UserAccessToken.java44
-rw-r--r--server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/package-info.java23
-rw-r--r--server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java703
-rw-r--r--server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationHttpClientImplTest.java373
11 files changed, 2152 insertions, 0 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
new file mode 100644
index 00000000000..eb98cdcafbc
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java
@@ -0,0 +1,304 @@
+/*
+ * 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.alm.client.github;
+
+import com.google.gson.annotations.SerializedName;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+import org.sonar.alm.client.github.security.AccessToken;
+import org.sonar.alm.client.github.security.UserAccessToken;
+import org.sonar.api.server.ServerSide;
+
+@ServerSide
+public interface GithubApplicationClient {
+
+ /**
+ * Create a user access token for the enterprise app installation.
+ *
+ * See https://developer.github.com/enterprise/2.20/apps/building-github-apps/identifying-and-authorizing-users-for-github-apps/#identifying-users-on-your-site
+ *
+ * @throws IllegalStateException if an internal error occured: network issue, invalid response, etc
+ * @throws IllegalArgumentException if the request failed due to one of the parameters being invalid.
+ */
+ UserAccessToken createUserAccessToken(String appUrl, String clientId, String clientSecret, String code);
+
+ /**
+ * Lists all the organizations accessible to the access token provided.
+ */
+ Organizations listOrganizations(String appUrl, AccessToken accessToken, int page, int pageSize);
+
+ /**
+ * Lists all the repositories of the provided organization accessible to the access token provided.
+ */
+ Repositories listRepositories(String appUrl, AccessToken accessToken, String organization, @Nullable String query, int page, int pageSize);
+
+ /**
+ * Returns the repository identified by the repositoryKey owned by the provided organization.
+ */
+ Optional<Repository> getRepository(String appUrl, AccessToken accessToken, String organization, String repositoryKey);
+
+ class Repositories {
+ private int total;
+ private List<Repository> repositories;
+
+ public Repositories() {
+ //nothing to do
+ }
+
+ public int getTotal() {
+ return total;
+ }
+
+ public Repositories setTotal(int total) {
+ this.total = total;
+ return this;
+ }
+
+ @CheckForNull
+ public List<Repository> getRepositories() {
+ return repositories;
+ }
+
+ public Repositories setRepositories(List<Repository> repositories) {
+ this.repositories = repositories;
+ return this;
+ }
+ }
+
+ @Immutable
+ final class Repository {
+ private final long id;
+ private final String name;
+ private final boolean isPrivate;
+ private final String fullName;
+ private final String url;
+
+ public Repository(long id, String name, boolean isPrivate, String fullName, String url) {
+ this.id = id;
+ this.name = name;
+ this.isPrivate = isPrivate;
+ this.fullName = fullName;
+ this.url = url;
+ }
+
+ public long getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public boolean isPrivate() {
+ return isPrivate;
+ }
+
+ public String getFullName() {
+ return fullName;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ @Override
+ public String toString() {
+ return "Repository{" +
+ "id=" + id +
+ ", name='" + name + '\'' +
+ ", isPrivate='" + isPrivate + '\'' +
+ ", fullName='" + fullName + '\'' +
+ ", url='" + url + '\'' +
+ '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Repository that = (Repository) o;
+ return id == that.id;
+ }
+
+ @Override
+ public int hashCode() {
+ return Long.hashCode(id);
+ }
+ }
+
+ @Immutable
+ final class RepositoryDetails {
+ private final Repository repository;
+ private final String description;
+ private final String mainBranchName;
+ private final String url;
+
+ public RepositoryDetails(Repository repository, String description, String mainBranchName, String url) {
+ this.repository = repository;
+ this.description = description;
+ this.mainBranchName = mainBranchName;
+ this.url = url;
+ }
+
+ public Repository getRepository() {
+ return repository;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public String getMainBranchName() {
+ return mainBranchName;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ RepositoryDetails that = (RepositoryDetails) o;
+ return Objects.equals(repository, that.repository) &&
+ Objects.equals(description, that.description) &&
+ Objects.equals(mainBranchName, that.mainBranchName) &&
+ Objects.equals(url, that.url);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(repository, description, mainBranchName, url);
+ }
+
+ @Override
+ public String toString() {
+ return "RepositoryDetails{" +
+ "repository=" + repository +
+ ", description='" + description + '\'' +
+ ", mainBranchName='" + mainBranchName + '\'' +
+ ", url='" + url + '\'' +
+ '}';
+ }
+ }
+
+ class Organizations {
+ private int total;
+ private List<Organization> organizations;
+
+ public Organizations() {
+ //nothing to do
+ }
+
+ public int getTotal() {
+ return total;
+ }
+
+ public Organizations setTotal(int total) {
+ this.total = total;
+ return this;
+ }
+
+ @CheckForNull
+ public List<Organization> getOrganizations() {
+ return organizations;
+ }
+
+ public Organizations setOrganizations(List<Organization> organizations) {
+ this.organizations = organizations;
+ return this;
+ }
+ }
+
+ class Organization {
+ private final long id;
+ private final String login;
+ private final String name;
+ private final String bio;
+ private final String blog;
+ @SerializedName("html_url")
+ private final String htmlUrl;
+ @SerializedName("avatar_url")
+ private final String avatarUrl;
+ private final String type;
+
+ public Organization(long id, String login, @Nullable String name, @Nullable String bio, @Nullable String blog, @Nullable String htmlUrl, @Nullable String avatarUrl,
+ String type) {
+ this.id = id;
+ this.login = login;
+ this.name = name;
+ this.bio = bio;
+ this.blog = blog;
+ this.htmlUrl = htmlUrl;
+ this.avatarUrl = avatarUrl;
+ this.type = type;
+ }
+
+ public long getId() {
+ return id;
+ }
+
+ public String getLogin() {
+ return login;
+ }
+
+ @CheckForNull
+ public String getName() {
+ return name;
+ }
+
+ @CheckForNull
+ public String getBio() {
+ return bio;
+ }
+
+ @CheckForNull
+ public String getBlog() {
+ return blog;
+ }
+
+ public String getHtmlUrl() {
+ return htmlUrl;
+ }
+
+ @CheckForNull
+ public String getAvatarUrl() {
+ return avatarUrl;
+ }
+
+ public String getType() {
+ return type;
+ }
+ }
+
+}
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
new file mode 100644
index 00000000000..361d206a6a1
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java
@@ -0,0 +1,164 @@
+/*
+ * 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.alm.client.github;
+
+import com.google.gson.Gson;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+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.security.AccessToken;
+import org.sonar.alm.client.github.security.UserAccessToken;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.lang.String.format;
+import static java.net.HttpURLConnection.HTTP_OK;
+
+public class GithubApplicationClientImpl implements GithubApplicationClient {
+ private static final Logger LOG = Loggers.get(GithubApplicationClientImpl.class);
+ private static final Gson GSON = new Gson();
+
+ private final GithubApplicationHttpClient appHttpClient;
+
+ public GithubApplicationClientImpl(GithubApplicationHttpClient appHttpClient) {
+ this.appHttpClient = appHttpClient;
+ }
+
+ private static void checkPageArgs(int page, int pageSize) {
+ checkArgument(page > 0, "'page' must be larger than 0.");
+ checkArgument(pageSize > 0 && pageSize <= 100, "'pageSize' must be a value larger than 0 and smaller or equal to 100.");
+ }
+
+ @Override
+ public Organizations listOrganizations(String appUrl, AccessToken accessToken, int page, int pageSize) {
+ checkPageArgs(page, pageSize);
+
+ try {
+ Organizations organizations = new Organizations();
+ GetResponse response = appHttpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", page, pageSize));
+ Optional<GsonInstallations> gsonInstallations = response.getContent().map(content -> GSON.fromJson(content, GsonInstallations.class));
+
+ if (!gsonInstallations.isPresent()) {
+ return organizations;
+ }
+
+ organizations.setTotal(gsonInstallations.get().totalCount);
+ if (gsonInstallations.get().installations != null) {
+ organizations.setOrganizations(gsonInstallations.get().installations.stream()
+ .map(gsonInstallation -> new Organization(gsonInstallation.account.id, gsonInstallation.account.login, null, null, null, null, null,
+ gsonInstallation.targetType))
+ .collect(Collectors.toList()));
+ }
+
+ return organizations;
+ } catch (IOException e) {
+ throw new IllegalStateException(format("Failed to list all organizations accessible by user access token on %s", appUrl), e);
+ }
+ }
+
+ @Override
+ public Repositories listRepositories(String appUrl, AccessToken accessToken, String organization, @Nullable String query, int page, int pageSize) {
+ checkPageArgs(page, pageSize);
+ String searchQuery = "org:" + organization;
+ if (query != null) {
+ searchQuery = query.replace(" ", "+") + "+" + searchQuery;
+ }
+ try {
+ Repositories repositories = new Repositories();
+ GetResponse response = appHttpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", searchQuery, page, pageSize));
+ Optional<GsonRepositorySearch> gsonRepositories = response.getContent().map(content -> GSON.fromJson(content, GsonRepositorySearch.class));
+ if (!gsonRepositories.isPresent()) {
+ return repositories;
+ }
+
+ repositories.setTotal(gsonRepositories.get().totalCount);
+
+ if (gsonRepositories.get().items != null) {
+ repositories.setRepositories(gsonRepositories.get().items.stream()
+ .map(gsonRepository -> new Repository(gsonRepository.id, gsonRepository.name, gsonRepository.isPrivate, gsonRepository.fullName, gsonRepository.url))
+ .collect(Collectors.toList()));
+ }
+
+ return repositories;
+ } catch (Exception e) {
+ throw new IllegalStateException(format("Failed to list all repositories of '%s' accessible by user access token on '%s' using query '%s'", organization, appUrl, searchQuery),
+ e);
+ }
+ }
+
+ @Override
+ public Optional<Repository> getRepository(String appUrl, AccessToken accessToken, String organization, String repositoryKey) {
+ try {
+ GetResponse response = appHttpClient.get(appUrl, accessToken, String.format("/repos/%s", repositoryKey));
+ return response.getContent()
+ .map(content -> GSON.fromJson(content, GsonGithubRepository.class))
+ .map(repository -> new Repository(repository.id, repository.name, repository.isPrivate, repository.fullName, repository.url));
+ } catch (Exception e) {
+ throw new IllegalStateException(format("Failed to get repository '%s' of '%s' accessible by user access token on '%s'", repositoryKey, organization, appUrl), e);
+ }
+ }
+
+ @Override
+ public UserAccessToken createUserAccessToken(String appUrl, String clientId, String clientSecret, String code) {
+ try {
+ String endpoint = "/login/oauth/access_token?client_id=" + clientId + "&client_secret=" + clientSecret + "&code=" + code;
+
+ String baseAppUrl;
+ int apiIndex = appUrl.indexOf("/api/v3");
+ if (apiIndex > 0) {
+ baseAppUrl = appUrl.substring(0, apiIndex);
+ } else if (appUrl.startsWith("https://api.github.com")) {
+ baseAppUrl = "https://github.com";
+ } else {
+ baseAppUrl = appUrl;
+ }
+
+ GithubApplicationHttpClient.Response response = appHttpClient.post(baseAppUrl, null, endpoint);
+
+ if (response.getCode() != HTTP_OK) {
+ throw new IllegalStateException("Failed to create GitHub's user access token. GitHub returned code " + code + ". " + response.getContent().orElse(""));
+ }
+
+ Optional<String> content = response.getContent();
+ Optional<UserAccessToken> accessToken = content.flatMap(c -> Arrays.stream(c.split("&"))
+ .filter(t -> t.startsWith("access_token="))
+ .map(t -> t.split("=")[1])
+ .findAny())
+ .map(UserAccessToken::new);
+
+ if (accessToken.isPresent()) {
+ return accessToken.get();
+ }
+
+ // If token is not in the 200's body, it's because the client ID or client secret are incorrect
+ LOG.error("Failed to create GitHub's user access token. GitHub's response: " + content);
+ throw new IllegalArgumentException();
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to create GitHub's user access token", e);
+ }
+ }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationHttpClient.java
new file mode 100644
index 00000000000..59f554cb518
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationHttpClient.java
@@ -0,0 +1,81 @@
+/*
+ * 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.alm.client.github;
+
+import java.io.IOException;
+import java.util.Optional;
+import org.sonar.alm.client.github.security.AccessToken;
+import org.sonar.api.ce.ComputeEngineSide;
+import org.sonar.api.server.ServerSide;
+
+@ServerSide
+@ComputeEngineSide
+public interface GithubApplicationHttpClient {
+ /**
+ * Content of the response is populated if response's HTTP code is {@link java.net.HttpURLConnection#HTTP_OK OK}.
+ */
+ GetResponse get(String appUrl, AccessToken token, String endPoint) throws IOException;
+
+ /**
+ * Content of the response is populated if response's HTTP code is {@link java.net.HttpURLConnection#HTTP_OK OK} or
+ * {@link java.net.HttpURLConnection#HTTP_CREATED CREATED}.
+ */
+ Response post(String appUrl, AccessToken token, String endPoint) throws IOException;
+
+ /**
+ * Content of the response is populated if response's HTTP code is {@link java.net.HttpURLConnection#HTTP_OK OK} or
+ * {@link java.net.HttpURLConnection#HTTP_CREATED CREATED}.
+ *
+ * Content type will be application/json; charset=utf-8
+ */
+ Response post(String appUrl, AccessToken token, String endPoint, String json) throws IOException;
+
+ /**
+ * Content of the response is populated if response's HTTP code is {@link java.net.HttpURLConnection#HTTP_OK OK}.
+ *
+ * Content type will be application/json; charset=utf-8
+ */
+ Response patch(String appUrl, AccessToken token, String endPoint, String json) throws IOException;
+
+ /**
+ * Content of the response is populated if response's HTTP code is {@link java.net.HttpURLConnection#HTTP_OK OK}.
+ *
+ * Content type will be application/json; charset=utf-8
+ *
+ */
+ Response delete(String appUrl, AccessToken token, String endPoint) throws IOException;
+
+ interface Response {
+ /**
+ * @return the HTTP code of the response.
+ */
+ int getCode();
+
+ /**
+ * @return the content of the response if the response had an HTTP code for which we expect a content for the current
+ * HTTP method (see {@link #get(String, AccessToken, String)} and {@link #post(String, AccessToken, String)}).
+ */
+ Optional<String> getContent();
+ }
+
+ interface GetResponse extends Response {
+ Optional<String> getNextEndPoint();
+ }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationHttpClientImpl.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationHttpClientImpl.java
new file mode 100644
index 00000000000..d5655e3b048
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationHttpClientImpl.java
@@ -0,0 +1,254 @@
+/*
+ * 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.alm.client.github;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+import okhttp3.FormBody;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.ResponseBody;
+import org.sonar.alm.client.TimeoutConfiguration;
+import org.sonar.alm.client.github.security.AccessToken;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+import org.sonarqube.ws.client.OkHttpClientBuilder;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.net.HttpURLConnection.HTTP_CREATED;
+import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
+import static java.net.HttpURLConnection.HTTP_OK;
+import static java.util.Optional.empty;
+import static java.util.Optional.of;
+import static java.util.Optional.ofNullable;
+
+public class GithubApplicationHttpClientImpl implements GithubApplicationHttpClient {
+
+ private static final Logger LOG = Loggers.get(GithubApplicationHttpClientImpl.class);
+ private static final Pattern NEXT_LINK_PATTERN = Pattern.compile(".*<(.*)>; rel=\"next\"");
+ private static final String MACHINE_MAN_PREVIEW_JSON = "application/vnd.github.machine-man-preview+json";
+ private static final String ANTIOPE_PREVIEW_JSON = "application/vnd.github.antiope-preview+json";
+
+ private final OkHttpClient client;
+
+ public GithubApplicationHttpClientImpl(TimeoutConfiguration timeoutConfiguration) {
+ client = new OkHttpClientBuilder()
+ .setConnectTimeoutMs(timeoutConfiguration.getConnectTimeout())
+ .setReadTimeoutMs(timeoutConfiguration.getReadTimeout())
+ .build();
+ }
+
+ @Override
+ public GetResponse get(String appUrl, AccessToken token, String endPoint) throws IOException {
+ validateEndPoint(endPoint);
+
+ try (okhttp3.Response response = client.newCall(newGetRequest(appUrl, token, endPoint)).execute()) {
+ int responseCode = response.code();
+ if (responseCode != HTTP_OK) {
+ LOG.warn("GET response did not have expected HTTP code (was {}): {}", responseCode, attemptReadContent(response));
+ return new GetResponseImpl(responseCode, null, null);
+ }
+ return new GetResponseImpl(responseCode, readContent(response.body()).orElse(null), readNextEndPoint(response));
+ }
+ }
+
+ private static void validateEndPoint(String endPoint) {
+ checkArgument(endPoint.startsWith("/") || endPoint.startsWith("http"), "endpoint must start with '/' or 'http'");
+ }
+
+ private static Request newGetRequest(String appUrl, AccessToken token, String endPoint) {
+ return newRequestBuilder(appUrl, token, endPoint).get().build();
+ }
+
+ @Override
+ public Response post(String appUrl, AccessToken token, String endPoint) throws IOException {
+ return doPost(appUrl, token, endPoint, new FormBody.Builder().build());
+ }
+
+ @Override
+ public Response post(String appUrl, AccessToken token, String endPoint, String json) throws IOException {
+ RequestBody body = RequestBody.create(json, MediaType.parse("application/json; charset=utf-8"));
+ return doPost(appUrl, token, endPoint, body);
+ }
+
+ @Override
+ public Response patch(String appUrl, AccessToken token, String endPoint, String json) throws IOException {
+ RequestBody body = RequestBody.create(json, MediaType.parse("application/json; charset=utf-8"));
+ return doPatch(appUrl, token, endPoint, body);
+ }
+
+ @Override
+ public Response delete(String appUrl, AccessToken token, String endPoint) throws IOException {
+ validateEndPoint(endPoint);
+
+ try (okhttp3.Response response = client.newCall(newDeleteRequest(appUrl, token, endPoint)).execute()) {
+ int responseCode = response.code();
+ if (responseCode != HTTP_NO_CONTENT) {
+ String content = attemptReadContent(response);
+ LOG.warn("DELETE response did not have expected HTTP code (was {}): {}", responseCode, content);
+ return new ResponseImpl(responseCode, content);
+ }
+ return new ResponseImpl(responseCode, null);
+ }
+ }
+
+ private static Request newDeleteRequest(String appUrl, AccessToken token, String endPoint) {
+ return newRequestBuilder(appUrl, token, endPoint).delete().build();
+ }
+
+ private Response doPost(String appUrl, @Nullable AccessToken token, String endPoint, RequestBody body) throws IOException {
+ validateEndPoint(endPoint);
+
+ try (okhttp3.Response response = client.newCall(newPostRequest(appUrl, token, endPoint, body)).execute()) {
+ int responseCode = response.code();
+ if (responseCode == HTTP_OK || responseCode == HTTP_CREATED) {
+ return new ResponseImpl(responseCode, readContent(response.body()).orElse(null));
+ } else if (responseCode == HTTP_NO_CONTENT) {
+ return new ResponseImpl(responseCode, null);
+ }
+ String content = attemptReadContent(response);
+ LOG.warn("POST response did not have expected HTTP code (was {}): {}", responseCode, content);
+ return new ResponseImpl(responseCode, content);
+ }
+ }
+
+ private Response doPatch(String appUrl, AccessToken token, String endPoint, RequestBody body) throws IOException {
+ validateEndPoint(endPoint);
+
+ try (okhttp3.Response response = client.newCall(newPatchRequest(token, appUrl, endPoint, body)).execute()) {
+ int responseCode = response.code();
+ if (responseCode == HTTP_OK) {
+ return new ResponseImpl(responseCode, readContent(response.body()).orElse(null));
+ } else if (responseCode == HTTP_NO_CONTENT) {
+ return new ResponseImpl(responseCode, null);
+ }
+ String content = attemptReadContent(response);
+ LOG.warn("PATCH response did not have expected HTTP code (was {}): {}", responseCode, content);
+ return new ResponseImpl(responseCode, content);
+ }
+ }
+
+ private static Request newPostRequest(String appUrl, @Nullable AccessToken token, String endPoint, RequestBody body) {
+ return newRequestBuilder(appUrl, token, endPoint).post(body).build();
+ }
+
+ private static Request newPatchRequest(AccessToken token, String appUrl, String endPoint, RequestBody body) {
+ return newRequestBuilder(appUrl, token, endPoint).patch(body).build();
+ }
+
+ private static Request.Builder newRequestBuilder(String appUrl, @Nullable AccessToken token, String endPoint) {
+ Request.Builder url = new Request.Builder()
+ .url(toAbsoluteEndPoint(appUrl, endPoint));
+ if (token != null) {
+ url
+ .addHeader("Authorization", token.getAuthorizationHeaderPrefix() + " " + token)
+ // TODO: Remove when CheckAPI is no longer in beta
+ .addHeader("Accept", ANTIOPE_PREVIEW_JSON + ", " + MACHINE_MAN_PREVIEW_JSON);
+ }
+ return url;
+ }
+
+ private static String toAbsoluteEndPoint(String host, String endPoint) {
+ if (endPoint.startsWith("http")) {
+ return endPoint;
+ }
+ try {
+ return new URL(host + endPoint).toExternalForm();
+ } catch (MalformedURLException e) {
+ throw new IllegalArgumentException(String.format("%s is not a valid url", host + endPoint));
+ }
+ }
+
+ private static String attemptReadContent(okhttp3.Response response) {
+ try {
+ return readContent(response.body()).orElse(null);
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ private static Optional<String> readContent(@Nullable ResponseBody body) throws IOException {
+ if (body == null) {
+ return empty();
+ }
+ try {
+ return of(body.string());
+ } finally {
+ body.close();
+ }
+ }
+
+ @CheckForNull
+ private static String readNextEndPoint(okhttp3.Response response) {
+ String links = response.header("link");
+ if (links == null || links.isEmpty() || !links.contains("rel=\"next\"")) {
+ return null;
+ }
+
+ Matcher nextLinkMatcher = NEXT_LINK_PATTERN.matcher(links);
+ if (!nextLinkMatcher.find()) {
+ return null;
+ }
+
+ return nextLinkMatcher.group(1);
+ }
+
+ private static class ResponseImpl implements Response {
+ private final int code;
+ private final String content;
+
+ private ResponseImpl(int code, @Nullable String content) {
+ this.code = code;
+ this.content = content;
+ }
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public Optional<String> getContent() {
+ return ofNullable(content);
+ }
+ }
+
+ private static final class GetResponseImpl extends ResponseImpl implements GetResponse {
+ private final String nextEndPoint;
+
+ private GetResponseImpl(int code, @Nullable String content, @Nullable String nextEndPoint) {
+ super(code, content);
+ this.nextEndPoint = nextEndPoint;
+ }
+
+ @Override
+ public Optional<String> getNextEndPoint() {
+ return ofNullable(nextEndPoint);
+ }
+ }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubBinding.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubBinding.java
new file mode 100644
index 00000000000..83a6a08e77a
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubBinding.java
@@ -0,0 +1,148 @@
+/*
+ * 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.alm.client.github;
+
+import com.google.gson.annotations.SerializedName;
+import java.util.List;
+
+public class GithubBinding {
+
+ private GithubBinding() {
+ //nothing to do
+ }
+
+ public static class GsonInstallations {
+ @SerializedName("total_count")
+ int totalCount;
+ @SerializedName("installations")
+ List<GsonInstallation> installations;
+
+ public GsonInstallations() {
+ // even if empty constructor is not required for Gson, it is strongly
+ // recommended:
+ // http://stackoverflow.com/a/18645370/229031
+ }
+ }
+
+ public static class GsonInstallation {
+ @SerializedName("id")
+ long id;
+ @SerializedName("target_type")
+ String targetType;
+ @SerializedName("permissions")
+ Permissions permissions;
+
+ @SerializedName("account")
+ GsonAccount account;
+
+ public GsonInstallation(long id, String targetType, Permissions permissions, GsonAccount account) {
+ this.id = id;
+ this.targetType = targetType;
+ this.permissions = permissions;
+ this.account = account;
+ }
+
+ public GsonInstallation() {
+ // even if empty constructor is not required for Gson, it is strongly
+ // recommended:
+ // http://stackoverflow.com/a/18645370/229031
+ }
+
+ public long getId() {
+ return id;
+ }
+
+ public String getTargetType() {
+ return targetType;
+ }
+
+ public Permissions getPermissions() {
+ return permissions;
+ }
+
+ public GsonAccount getAccount() {
+ return account;
+ }
+
+ public static class Permissions {
+ @SerializedName("checks")
+ String checks;
+
+ public Permissions(String checks) {
+ this.checks = checks;
+ }
+
+ public Permissions() {
+ // even if empty constructor is not required for Gson, it is strongly
+ // recommended:
+ // http://stackoverflow.com/a/18645370/229031
+ }
+
+ public String getChecks() {
+ return checks;
+ }
+ }
+
+ public static class GsonAccount {
+ @SerializedName("id")
+ long id;
+ @SerializedName("login")
+ String login;
+
+ public GsonAccount() {
+ // even if empty constructor is not required for Gson, it is strongly
+ // recommended:
+ // http://stackoverflow.com/a/18645370/229031
+ }
+ }
+ }
+
+ public static class GsonRepositorySearch {
+ @SerializedName("total_count")
+ int totalCount;
+ @SerializedName("items")
+ List<GsonGithubRepository> items;
+
+ public GsonRepositorySearch() {
+ // even if empty constructor is not required for Gson, it is strongly
+ // recommended:
+ // http://stackoverflow.com/a/18645370/229031
+ }
+ }
+
+ public static class GsonGithubRepository {
+ @SerializedName("id")
+ long id;
+ @SerializedName("name")
+ String name;
+ @SerializedName("full_name")
+ String fullName;
+ @SerializedName("private")
+ boolean isPrivate;
+ @SerializedName("url")
+ String url;
+
+ public GsonGithubRepository() {
+ // even if empty constructor is not required for Gson, it is strongly
+ // recommended:
+ // http://stackoverflow.com/a/18645370/229031
+ }
+ }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/package-info.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/package-info.java
new file mode 100644
index 00000000000..928da543c28
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.alm.client.github;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/AccessToken.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/AccessToken.java
new file mode 100644
index 00000000000..33f43e23c17
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/AccessToken.java
@@ -0,0 +1,35 @@
+/*
+ * 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.alm.client.github.security;
+
+/**
+ * Token used to authenticate requests to Github API
+ *
+ */
+public interface AccessToken {
+
+ String getValue();
+
+ /**
+ * Value of the HTTP header "Authorization"
+ */
+ String getAuthorizationHeaderPrefix();
+
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/UserAccessToken.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/UserAccessToken.java
new file mode 100644
index 00000000000..72106294212
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/UserAccessToken.java
@@ -0,0 +1,44 @@
+/*
+ * 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.alm.client.github.security;
+
+public class UserAccessToken implements AccessToken {
+
+ private final String token;
+
+ public UserAccessToken(String token) {
+ this.token = token;
+ }
+
+ @Override
+ public String getValue() {
+ return token;
+ }
+
+ @Override
+ public String getAuthorizationHeaderPrefix() {
+ return "token";
+ }
+
+ @Override
+ public String toString() {
+ return getValue();
+ }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/package-info.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/package-info.java
new file mode 100644
index 00000000000..1ce88881a81
--- /dev/null
+++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.alm.client.github.security;
+
+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
new file mode 100644
index 00000000000..75d800ae4d4
--- /dev/null
+++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java
@@ -0,0 +1,703 @@
+/*
+ * 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.alm.client.github;
+
+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.util.Optional;
+import javax.annotation.Nullable;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.sonar.alm.client.github.security.AccessToken;
+import org.sonar.alm.client.github.security.UserAccessToken;
+import org.sonar.api.utils.log.LogTester;
+import org.sonar.api.utils.log.LoggerLevel;
+
+import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.groups.Tuple.tuple;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@RunWith(DataProviderRunner.class)
+public class GithubApplicationClientImplTest {
+
+ @ClassRule
+ public static LogTester logTester = new LogTester().setLevel(LoggerLevel.WARN);
+
+ private GithubApplicationHttpClientImpl httpClient = mock(GithubApplicationHttpClientImpl.class);
+ private GithubApplicationClient underTest;
+
+ private String appUrl = "Any URL";
+
+ @Before
+ public void setup() {
+ underTest = new GithubApplicationClientImpl(httpClient);
+ logTester.clear();
+ }
+
+ @Test
+ @UseDataProvider("githubServers")
+ public void createUserAccessToken_returns_empty_if_access_token_cant_be_created(String apiUrl, String appUrl) throws IOException {
+ when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
+ .thenReturn(new Response(400, null));
+
+ assertThatThrownBy(() -> underTest.createUserAccessToken(appUrl, "clientId", "clientSecret", "code"))
+ .isInstanceOf(IllegalStateException.class);
+ verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
+ }
+
+ @Test
+ @UseDataProvider("githubServers")
+ public void createUserAccessToken_fail_if_access_token_request_fails(String apiUrl, String appUrl) throws IOException {
+ when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
+ .thenThrow(new IOException("OOPS"));
+
+ assertThatThrownBy(() -> underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code"))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("Failed to create GitHub's user access token");
+
+ verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
+ }
+
+ @Test
+ @UseDataProvider("githubServers")
+ public void createUserAccessToken_throws_illegal_argument_exception_if_access_token_code_is_expired(String apiUrl, String appUrl) throws IOException {
+ when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
+ .thenReturn(new OkGetResponse("error_code=100&error=expired_or_invalid"));
+
+ assertThatThrownBy(() -> underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code"))
+ .isInstanceOf(IllegalArgumentException.class);
+
+ verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
+ }
+
+ @Test
+ @UseDataProvider("githubServers")
+ public void createUserAccessToken_from_authorization_code_returns_access_token(String apiUrl, String appUrl) throws IOException {
+ String token = randomAlphanumeric(10);
+ when(httpClient.post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code"))
+ .thenReturn(new OkGetResponse("access_token=" + token + "&status="));
+
+ UserAccessToken userAccessToken = underTest.createUserAccessToken(apiUrl, "clientId", "clientSecret", "code");
+
+ assertThat(userAccessToken)
+ .extracting(UserAccessToken::getValue, UserAccessToken::getAuthorizationHeaderPrefix)
+ .containsOnly(token, "token");
+ verify(httpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
+ }
+
+ @DataProvider
+ public static Object[][] githubServers() {
+ return new Object[][] {
+ {"https://github.sonarsource.com/api/v3", "https://github.sonarsource.com"},
+ {"https://api.github.com", "https://github.com"},
+ {"https://github.sonarsource.com/api/v3/", "https://github.sonarsource.com"},
+ {"https://api.github.com/", "https://github.com"},
+ };
+ }
+
+ @Test
+ public void listOrganizations_fail_on_failure() throws IOException {
+ String appUrl = "https://github.sonarsource.com";
+ AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
+
+ when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
+ .thenThrow(new IOException("OOPS"));
+
+ assertThatThrownBy(() -> underTest.listOrganizations(appUrl, accessToken, 1, 100))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("Failed to list all organizations accessible by user access token on %s", appUrl);
+ }
+
+ @Test
+ public void listOrganizations_fail_if_pageIndex_out_of_bounds() {
+ UserAccessToken token = new UserAccessToken("token");
+ assertThatThrownBy(() -> underTest.listOrganizations(appUrl, token, 0, 100))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("'page' must be larger than 0.");
+ }
+
+ @Test
+ public void listOrganizations_fail_if_pageSize_out_of_bounds() {
+ UserAccessToken token = new UserAccessToken("token");
+ assertThatThrownBy(() -> underTest.listOrganizations(appUrl, token, 1, 0))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
+ assertThatThrownBy(() -> underTest.listOrganizations("", token, 1, 101))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
+ }
+
+ @Test
+ public void listOrganizations_returns_no_installations() throws IOException {
+ String appUrl = "https://github.sonarsource.com";
+ AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
+ String responseJson = "{\n"
+ + " \"total_count\": 0\n"
+ + "} ";
+
+ when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
+ .thenReturn(new OkGetResponse(responseJson));
+
+ GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100);
+
+ assertThat(organizations.getTotal()).isZero();
+ assertThat(organizations.getOrganizations()).isNull();
+ }
+
+ @Test
+ public void listOrganizations_returns_pages_results() throws IOException {
+ String appUrl = "https://github.sonarsource.com";
+ AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
+ String responseJson = "{\n"
+ + " \"total_count\": 2,\n"
+ + " \"installations\": [\n"
+ + " {\n"
+ + " \"id\": 1,\n"
+ + " \"account\": {\n"
+ + " \"login\": \"github\",\n"
+ + " \"id\": 1,\n"
+ + " \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjE=\",\n"
+ + " \"url\": \"https://github.sonarsource.com/api/v3/orgs/github\",\n"
+ + " \"repos_url\": \"https://github.sonarsource.com/api/v3/orgs/github/repos\",\n"
+ + " \"events_url\": \"https://github.sonarsource.com/api/v3/orgs/github/events\",\n"
+ + " \"hooks_url\": \"https://github.sonarsource.com/api/v3/orgs/github/hooks\",\n"
+ + " \"issues_url\": \"https://github.sonarsource.com/api/v3/orgs/github/issues\",\n"
+ + " \"members_url\": \"https://github.sonarsource.com/api/v3/orgs/github/members{/member}\",\n"
+ + " \"public_members_url\": \"https://github.sonarsource.com/api/v3/orgs/github/public_members{/member}\",\n"
+ + " \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
+ + " \"description\": \"A great organization\"\n"
+ + " },\n"
+ + " \"access_tokens_url\": \"https://github.sonarsource.com/api/v3/app/installations/1/access_tokens\",\n"
+ + " \"repositories_url\": \"https://github.sonarsource.com/api/v3/installation/repositories\",\n"
+ + " \"html_url\": \"https://github.com/organizations/github/settings/installations/1\",\n"
+ + " \"app_id\": 1,\n"
+ + " \"target_id\": 1,\n"
+ + " \"target_type\": \"Organization\",\n"
+ + " \"permissions\": {\n"
+ + " \"checks\": \"write\",\n"
+ + " \"metadata\": \"read\",\n"
+ + " \"contents\": \"read\"\n"
+ + " },\n"
+ + " \"events\": [\n"
+ + " \"push\",\n"
+ + " \"pull_request\"\n"
+ + " ],\n"
+ + " \"single_file_name\": \"config.yml\"\n"
+ + " },\n"
+ + " {\n"
+ + " \"id\": 3,\n"
+ + " \"account\": {\n"
+ + " \"login\": \"octocat\",\n"
+ + " \"id\": 2,\n"
+ + " \"node_id\": \"MDQ6VXNlcjE=\",\n"
+ + " \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
+ + " \"gravatar_id\": \"\",\n"
+ + " \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
+ + " \"html_url\": \"https://github.com/octocat\",\n"
+ + " \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
+ + " \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
+ + " \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
+ + " \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
+ + " \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
+ + " \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
+ + " \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
+ + " \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
+ + " \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
+ + " \"type\": \"User\",\n"
+ + " \"site_admin\": false\n"
+ + " },\n"
+ + " \"access_tokens_url\": \"https://github.sonarsource.com/api/v3/app/installations/1/access_tokens\",\n"
+ + " \"repositories_url\": \"https://github.sonarsource.com/api/v3/installation/repositories\",\n"
+ + " \"html_url\": \"https://github.com/organizations/github/settings/installations/1\",\n"
+ + " \"app_id\": 1,\n"
+ + " \"target_id\": 1,\n"
+ + " \"target_type\": \"Organization\",\n"
+ + " \"permissions\": {\n"
+ + " \"checks\": \"write\",\n"
+ + " \"metadata\": \"read\",\n"
+ + " \"contents\": \"read\"\n"
+ + " },\n"
+ + " \"events\": [\n"
+ + " \"push\",\n"
+ + " \"pull_request\"\n"
+ + " ],\n"
+ + " \"single_file_name\": \"config.yml\"\n"
+ + " }\n"
+ + " ]\n"
+ + "} ";
+
+ when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
+ .thenReturn(new OkGetResponse(responseJson));
+
+ GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100);
+
+ assertThat(organizations.getTotal()).isEqualTo(2);
+ assertThat(organizations.getOrganizations()).extracting(GithubApplicationClient.Organization::getLogin).containsOnly("github", "octocat");
+ }
+
+ @Test
+ public void listRepositories_fail_on_failure() throws IOException {
+ String appUrl = "https://github.sonarsource.com";
+ AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
+
+ when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "org:test", 1, 100)))
+ .thenThrow(new IOException("OOPS"));
+
+ assertThatThrownBy(() -> underTest.listRepositories(appUrl, accessToken, "test", null, 1, 100))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("Failed to list all repositories of 'test' accessible by user access token on 'https://github.sonarsource.com' using query 'org:test'");
+ }
+
+ @Test
+ public void listRepositories_fail_if_pageIndex_out_of_bounds() {
+ UserAccessToken token = new UserAccessToken("token");
+ assertThatThrownBy(() -> underTest.listRepositories(appUrl, token, "test", null, 0, 100))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("'page' must be larger than 0.");
+ }
+
+ @Test
+ public void listRepositories_fail_if_pageSize_out_of_bounds() {
+ UserAccessToken token = new UserAccessToken("token");
+ assertThatThrownBy(() -> underTest.listRepositories(appUrl, token, "test", null, 1, 0))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
+ assertThatThrownBy(() -> underTest.listRepositories("", token, "test", null, 1, 101))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("'pageSize' must be a value larger than 0 and smaller or equal to 100.");
+ }
+
+ @Test
+ public void listRepositories_returns_empty_results() throws IOException {
+ String appUrl = "https://github.sonarsource.com";
+ AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
+ String responseJson = "{\n"
+ + " \"total_count\": 0\n"
+ + "}";
+
+ when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "org:github", 1, 100)))
+ .thenReturn(new OkGetResponse(responseJson));
+
+ GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100);
+
+ assertThat(repositories.getTotal()).isZero();
+ assertThat(repositories.getRepositories()).isNull();
+ }
+
+ @Test
+ public void listRepositories_returns_pages_results() throws IOException {
+ String appUrl = "https://github.sonarsource.com";
+ AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
+ String responseJson = "{\n"
+ + " \"total_count\": 2,\n"
+ + " \"incomplete_results\": false,\n"
+ + " \"items\": [\n"
+ + " {\n"
+ + " \"id\": 3081286,\n"
+ + " \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
+ + " \"name\": \"HelloWorld\",\n"
+ + " \"full_name\": \"github/HelloWorld\",\n"
+ + " \"owner\": {\n"
+ + " \"login\": \"github\",\n"
+ + " \"id\": 872147,\n"
+ + " \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
+ + " \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
+ + " \"gravatar_id\": \"\",\n"
+ + " \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
+ + " \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
+ + " \"type\": \"User\"\n"
+ + " },\n"
+ + " \"private\": false,\n"
+ + " \"html_url\": \"https://github.com/github/HelloWorld\",\n"
+ + " \"description\": \"A C implementation of HelloWorld\",\n"
+ + " \"fork\": false,\n"
+ + " \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloWorld\",\n"
+ + " \"created_at\": \"2012-01-01T00:31:50Z\",\n"
+ + " \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
+ + " \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
+ + " \"homepage\": \"\",\n"
+ + " \"size\": 524,\n"
+ + " \"stargazers_count\": 1,\n"
+ + " \"watchers_count\": 1,\n"
+ + " \"language\": \"Assembly\",\n"
+ + " \"forks_count\": 0,\n"
+ + " \"open_issues_count\": 0,\n"
+ + " \"master_branch\": \"master\",\n"
+ + " \"default_branch\": \"master\",\n"
+ + " \"score\": 1.0\n"
+ + " },\n"
+ + " {\n"
+ + " \"id\": 3081286,\n"
+ + " \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
+ + " \"name\": \"HelloUniverse\",\n"
+ + " \"full_name\": \"github/HelloUniverse\",\n"
+ + " \"owner\": {\n"
+ + " \"login\": \"github\",\n"
+ + " \"id\": 872147,\n"
+ + " \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
+ + " \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
+ + " \"gravatar_id\": \"\",\n"
+ + " \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
+ + " \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
+ + " \"type\": \"User\"\n"
+ + " },\n"
+ + " \"private\": false,\n"
+ + " \"html_url\": \"https://github.com/github/HelloUniverse\",\n"
+ + " \"description\": \"A C implementation of HelloUniverse\",\n"
+ + " \"fork\": false,\n"
+ + " \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloUniverse\",\n"
+ + " \"created_at\": \"2012-01-01T00:31:50Z\",\n"
+ + " \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
+ + " \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
+ + " \"homepage\": \"\",\n"
+ + " \"size\": 524,\n"
+ + " \"stargazers_count\": 1,\n"
+ + " \"watchers_count\": 1,\n"
+ + " \"language\": \"Assembly\",\n"
+ + " \"forks_count\": 0,\n"
+ + " \"open_issues_count\": 0,\n"
+ + " \"master_branch\": \"master\",\n"
+ + " \"default_branch\": \"master\",\n"
+ + " \"score\": 1.0\n"
+ + " }\n"
+ + " ]\n"
+ + "}";
+
+ when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "org:github", 1, 100)))
+ .thenReturn(new OkGetResponse(responseJson));
+ GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100);
+
+ assertThat(repositories.getTotal()).isEqualTo(2);
+ assertThat(repositories.getRepositories())
+ .extracting(GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName)
+ .containsOnly(tuple("HelloWorld", "github/HelloWorld"), tuple("HelloUniverse", "github/HelloUniverse"));
+ }
+
+ @Test
+ public void listRepositories_returns_search_results() throws IOException {
+ String appUrl = "https://github.sonarsource.com";
+ AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
+ String responseJson = "{\n"
+ + " \"total_count\": 2,\n"
+ + " \"incomplete_results\": false,\n"
+ + " \"items\": [\n"
+ + " {\n"
+ + " \"id\": 3081286,\n"
+ + " \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
+ + " \"name\": \"HelloWorld\",\n"
+ + " \"full_name\": \"github/HelloWorld\",\n"
+ + " \"owner\": {\n"
+ + " \"login\": \"github\",\n"
+ + " \"id\": 872147,\n"
+ + " \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
+ + " \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
+ + " \"gravatar_id\": \"\",\n"
+ + " \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
+ + " \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
+ + " \"type\": \"User\"\n"
+ + " },\n"
+ + " \"private\": false,\n"
+ + " \"html_url\": \"https://github.com/github/HelloWorld\",\n"
+ + " \"description\": \"A C implementation of HelloWorld\",\n"
+ + " \"fork\": false,\n"
+ + " \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloWorld\",\n"
+ + " \"created_at\": \"2012-01-01T00:31:50Z\",\n"
+ + " \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
+ + " \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
+ + " \"homepage\": \"\",\n"
+ + " \"size\": 524,\n"
+ + " \"stargazers_count\": 1,\n"
+ + " \"watchers_count\": 1,\n"
+ + " \"language\": \"Assembly\",\n"
+ + " \"forks_count\": 0,\n"
+ + " \"open_issues_count\": 0,\n"
+ + " \"master_branch\": \"master\",\n"
+ + " \"default_branch\": \"master\",\n"
+ + " \"score\": 1.0\n"
+ + " }\n"
+ + " ]\n"
+ + "}";
+
+ when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "world+org:github", 1, 100)))
+ .thenReturn(new GithubApplicationHttpClient.GetResponse() {
+ @Override
+ public Optional<String> getNextEndPoint() {
+ return Optional.empty();
+ }
+
+ @Override
+ public int getCode() {
+ return 200;
+ }
+
+ @Override
+ public Optional<String> getContent() {
+ return Optional.of(responseJson);
+ }
+ });
+
+ GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", "world", 1, 100);
+
+ assertThat(repositories.getTotal()).isEqualTo(2);
+ assertThat(repositories.getRepositories())
+ .extracting(GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName)
+ .containsOnly(tuple("HelloWorld", "github/HelloWorld"));
+ }
+
+ @Test
+ public void getRepository_returns_empty_when_repository_doesnt_exist() throws IOException {
+ when(httpClient.get(any(), any(), any()))
+ .thenReturn(new Response(404, null));
+
+ Optional<GithubApplicationClient.Repository> repository = underTest.getRepository(appUrl, new UserAccessToken("temp"), "octocat", "octocat/Hello-World");
+
+ assertThat(repository).isEmpty();
+ }
+
+ @Test
+ public void getRepository_fails_on_failure() throws IOException {
+ String repositoryKey = "octocat/Hello-World";
+ String organization = "octocat";
+
+ when(httpClient.get(any(), any(), any()))
+ .thenThrow(new IOException("OOPS"));
+
+ UserAccessToken token = new UserAccessToken("temp");
+ assertThatThrownBy(() -> underTest.getRepository(appUrl, token, organization, repositoryKey))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("Failed to get repository '%s' of '%s' accessible by user access token on '%s'", repositoryKey, organization, appUrl);
+ }
+
+ @Test
+ public void getRepository_returns_repository() throws IOException {
+ String appUrl = "https://github.sonarsource.com";
+ AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
+ String responseJson = "{\n"
+ + " \"id\": 1296269,\n"
+ + " \"node_id\": \"MDEwOlJlcG9zaXRvcnkxMjk2MjY5\",\n"
+ + " \"name\": \"Hello-World\",\n"
+ + " \"full_name\": \"octocat/Hello-World\",\n"
+ + " \"owner\": {\n"
+ + " \"login\": \"octocat\",\n"
+ + " \"id\": 1,\n"
+ + " \"node_id\": \"MDQ6VXNlcjE=\",\n"
+ + " \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
+ + " \"gravatar_id\": \"\",\n"
+ + " \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
+ + " \"html_url\": \"https://github.com/octocat\",\n"
+ + " \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
+ + " \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
+ + " \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
+ + " \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
+ + " \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
+ + " \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
+ + " \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
+ + " \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
+ + " \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
+ + " \"type\": \"User\",\n"
+ + " \"site_admin\": false\n"
+ + " },\n"
+ + " \"private\": false,\n"
+ + " \"html_url\": \"https://github.com/octocat/Hello-World\",\n"
+ + " \"description\": \"This your first repo!\",\n"
+ + " \"fork\": false,\n"
+ + " \"url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World\",\n"
+ + " \"archive_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/{archive_format}{/ref}\",\n"
+ + " \"assignees_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/assignees{/user}\",\n"
+ + " \"blobs_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/blobs{/sha}\",\n"
+ + " \"branches_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/branches{/branch}\",\n"
+ + " \"collaborators_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/collaborators{/collaborator}\",\n"
+ + " \"comments_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/comments{/number}\",\n"
+ + " \"commits_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/commits{/sha}\",\n"
+ + " \"compare_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/compare/{base}...{head}\",\n"
+ + " \"contents_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contents/{+path}\",\n"
+ + " \"contributors_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contributors\",\n"
+ + " \"deployments_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/deployments\",\n"
+ + " \"downloads_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/downloads\",\n"
+ + " \"events_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/events\",\n"
+ + " \"forks_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/forks\",\n"
+ + " \"git_commits_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/commits{/sha}\",\n"
+ + " \"git_refs_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/refs{/sha}\",\n"
+ + " \"git_tags_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/tags{/sha}\",\n"
+ + " \"git_url\": \"git:github.com/octocat/Hello-World.git\",\n"
+ + " \"issue_comment_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/comments{/number}\",\n"
+ + " \"issue_events_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/events{/number}\",\n"
+ + " \"issues_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues{/number}\",\n"
+ + " \"keys_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/keys{/key_id}\",\n"
+ + " \"labels_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/labels{/name}\",\n"
+ + " \"languages_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/languages\",\n"
+ + " \"merges_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/merges\",\n"
+ + " \"milestones_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/milestones{/number}\",\n"
+ + " \"notifications_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/notifications{?since,all,participating}\",\n"
+ + " \"pulls_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/pulls{/number}\",\n"
+ + " \"releases_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/releases{/id}\",\n"
+ + " \"ssh_url\": \"git@github.com:octocat/Hello-World.git\",\n"
+ + " \"stargazers_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/stargazers\",\n"
+ + " \"statuses_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/statuses/{sha}\",\n"
+ + " \"subscribers_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscribers\",\n"
+ + " \"subscription_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscription\",\n"
+ + " \"tags_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/tags\",\n"
+ + " \"teams_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/teams\",\n"
+ + " \"trees_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/trees{/sha}\",\n"
+ + " \"clone_url\": \"https://github.com/octocat/Hello-World.git\",\n"
+ + " \"mirror_url\": \"git:git.example.com/octocat/Hello-World\",\n"
+ + " \"hooks_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/hooks\",\n"
+ + " \"svn_url\": \"https://svn.github.com/octocat/Hello-World\",\n"
+ + " \"homepage\": \"https://github.com\",\n"
+ + " \"language\": null,\n"
+ + " \"forks_count\": 9,\n"
+ + " \"stargazers_count\": 80,\n"
+ + " \"watchers_count\": 80,\n"
+ + " \"size\": 108,\n"
+ + " \"default_branch\": \"master\",\n"
+ + " \"open_issues_count\": 0,\n"
+ + " \"is_template\": true,\n"
+ + " \"topics\": [\n"
+ + " \"octocat\",\n"
+ + " \"atom\",\n"
+ + " \"electron\",\n"
+ + " \"api\"\n"
+ + " ],\n"
+ + " \"has_issues\": true,\n"
+ + " \"has_projects\": true,\n"
+ + " \"has_wiki\": true,\n"
+ + " \"has_pages\": false,\n"
+ + " \"has_downloads\": true,\n"
+ + " \"archived\": false,\n"
+ + " \"disabled\": false,\n"
+ + " \"visibility\": \"public\",\n"
+ + " \"pushed_at\": \"2011-01-26T19:06:43Z\",\n"
+ + " \"created_at\": \"2011-01-26T19:01:12Z\",\n"
+ + " \"updated_at\": \"2011-01-26T19:14:43Z\",\n"
+ + " \"permissions\": {\n"
+ + " \"admin\": false,\n"
+ + " \"push\": false,\n"
+ + " \"pull\": true\n"
+ + " },\n"
+ + " \"allow_rebase_merge\": true,\n"
+ + " \"template_repository\": null,\n"
+ + " \"allow_squash_merge\": true,\n"
+ + " \"allow_merge_commit\": true,\n"
+ + " \"subscribers_count\": 42,\n"
+ + " \"network_count\": 0,\n"
+ + " \"anonymous_access_enabled\": false,\n"
+ + " \"license\": {\n"
+ + " \"key\": \"mit\",\n"
+ + " \"name\": \"MIT License\",\n"
+ + " \"spdx_id\": \"MIT\",\n"
+ + " \"url\": \"https://github.sonarsource.com/api/v3/licenses/mit\",\n"
+ + " \"node_id\": \"MDc6TGljZW5zZW1pdA==\"\n"
+ + " },\n"
+ + " \"organization\": {\n"
+ + " \"login\": \"octocat\",\n"
+ + " \"id\": 1,\n"
+ + " \"node_id\": \"MDQ6VXNlcjE=\",\n"
+ + " \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
+ + " \"gravatar_id\": \"\",\n"
+ + " \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
+ + " \"html_url\": \"https://github.com/octocat\",\n"
+ + " \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
+ + " \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
+ + " \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
+ + " \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
+ + " \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
+ + " \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
+ + " \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
+ + " \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
+ + " \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
+ + " \"type\": \"Organization\",\n"
+ + " \"site_admin\": false\n"
+ + " }"
+ + "}";
+
+ when(httpClient.get(appUrl, accessToken, "/repos/octocat/Hello-World"))
+ .thenReturn(new GithubApplicationHttpClient.GetResponse() {
+ @Override
+ public Optional<String> getNextEndPoint() {
+ return Optional.empty();
+ }
+
+ @Override
+ public int getCode() {
+ return 200;
+ }
+
+ @Override
+ public Optional<String> getContent() {
+ return Optional.of(responseJson);
+ }
+ });
+
+ Optional<GithubApplicationClient.Repository> repository = underTest.getRepository(appUrl, accessToken, "octocat", "octocat/Hello-World");
+
+ assertThat(repository)
+ .isPresent()
+ .get()
+ .extracting(GithubApplicationClient.Repository::getId, GithubApplicationClient.Repository::getName, GithubApplicationClient.Repository::getFullName,
+ GithubApplicationClient.Repository::getUrl, GithubApplicationClient.Repository::isPrivate)
+ .containsOnly(1296269L, "Hello-World", "octocat/Hello-World", "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World", false);
+ }
+
+ private static class OkGetResponse extends Response {
+ private OkGetResponse(String content) {
+ super(200, content);
+ }
+ }
+
+ private static class Response implements GithubApplicationHttpClient.GetResponse {
+ private final int code;
+ private final String content;
+ private final String nextEndPoint;
+
+ private Response(int code, @Nullable String content) {
+ this(code, content, null);
+ }
+
+ private Response(int code, @Nullable String content, @Nullable String nextEndPoint) {
+ this.code = code;
+ this.content = content;
+ this.nextEndPoint = nextEndPoint;
+ }
+
+ @Override
+ public int getCode() {
+ return code;
+ }
+
+ @Override
+ public Optional<String> getContent() {
+ return Optional.ofNullable(content);
+ }
+
+ @Override
+ public Optional<String> getNextEndPoint() {
+ return Optional.ofNullable(nextEndPoint);
+ }
+ }
+}
diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationHttpClientImplTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationHttpClientImplTest.java
new file mode 100644
index 00000000000..44516fbaeaf
--- /dev/null
+++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationHttpClientImplTest.java
@@ -0,0 +1,373 @@
+/*
+ * 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.alm.client.github;
+
+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.net.SocketTimeoutException;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import okhttp3.mockwebserver.SocketPolicy;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.sonar.alm.client.ConstantTimeoutConfiguration;
+import org.sonar.alm.client.github.GithubApplicationHttpClient.GetResponse;
+import org.sonar.alm.client.github.GithubApplicationHttpClient.Response;
+import org.sonar.alm.client.github.security.AccessToken;
+import org.sonar.alm.client.github.security.UserAccessToken;
+
+import static java.lang.String.format;
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.Assert.fail;
+
+@RunWith(DataProviderRunner.class)
+public class GithubApplicationHttpClientImplTest {
+ private static final String BETA_API_HEADER = "application/vnd.github.antiope-preview+json, application/vnd.github.machine-man-preview+json";
+ @Rule
+ public MockWebServer server = new MockWebServer();
+
+ private GithubApplicationHttpClientImpl underTest;
+
+ private final AccessToken accessToken = new UserAccessToken(randomAlphabetic(10));
+ private final String randomEndPoint = "/" + randomAlphabetic(10);
+ private final String randomBody = randomAlphabetic(40);
+ private String appUrl;
+
+ @Before
+ public void setUp() {
+ this.appUrl = format("http://%s:%s", server.getHostName(), server.getPort());
+ this.underTest = new GithubApplicationHttpClientImpl(new ConstantTimeoutConfiguration(500));
+ }
+
+ @Test
+ public void get_fails_if_endpoint_does_not_start_with_slash() throws IOException {
+ assertThatThrownBy(() -> underTest.get(appUrl, accessToken, "api/foo/bar"))
+ .hasMessage("endpoint must start with '/' or 'http'")
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ public void get_fails_if_endpoint_does_not_start_with_http() throws IOException {
+ assertThatThrownBy(() -> underTest.get(appUrl, accessToken, "ttp://api/foo/bar"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("endpoint must start with '/' or 'http'");
+ }
+
+ @Test
+ public void get_fails_if_github_endpoint_is_invalid() throws IOException {
+ assertThatThrownBy(() -> underTest.get("invalidUrl", accessToken, "/endpoint"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("invalidUrl/endpoint is not a valid url");
+ }
+
+ @Test
+ public void get_adds_authentication_header_with_Bearer_type_and_Accept_header() throws IOException, InterruptedException {
+ server.enqueue(new MockResponse());
+
+ GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
+
+ assertThat(response).isNotNull();
+ RecordedRequest recordedRequest = server.takeRequest();
+ assertThat(recordedRequest.getMethod()).isEqualTo("GET");
+ assertThat(recordedRequest.getPath()).isEqualTo(randomEndPoint);
+ assertThat(recordedRequest.getHeader("Authorization")).isEqualTo("token " + accessToken.getValue());
+ assertThat(recordedRequest.getHeader("Accept")).isEqualTo(BETA_API_HEADER);
+ }
+
+ @Test
+ public void get_returns_body_as_response_if_code_is_200() throws IOException {
+ server.enqueue(new MockResponse().setResponseCode(200).setBody(randomBody));
+
+ GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
+
+ assertThat(response.getContent()).contains(randomBody);
+ }
+
+ @Test
+ public void get_timeout() {
+ server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE));
+
+ try {
+ underTest.get(appUrl, accessToken, randomEndPoint);
+ fail("Expected timeout");
+ } catch (Exception e) {
+ assertThat(e).isInstanceOf(SocketTimeoutException.class);
+ }
+ }
+
+ @Test
+ @UseDataProvider("someHttpCodesWithContentBut200")
+ public void get_empty_response_if_code_is_not_200(int code) throws IOException {
+ server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
+
+ GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
+
+ assertThat(response.getContent()).isEmpty();
+ }
+
+ @Test
+ public void get_returns_empty_endPoint_when_no_link_header() throws IOException {
+ server.enqueue(new MockResponse().setBody(randomBody));
+
+ GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
+
+ assertThat(response.getNextEndPoint()).isEmpty();
+ }
+
+ @Test
+ public void get_returns_empty_endPoint_when_link_header_does_not_have_next_rel() throws IOException {
+ server.enqueue(new MockResponse().setBody(randomBody)
+ .setHeader("link", "<https://api.github.com/installation/repositories?per_page=5&page=4>; rel=\"prev\", " +
+ "<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\""));
+
+ GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
+
+ assertThat(response.getNextEndPoint()).isEmpty();
+ }
+
+ @Test
+ @UseDataProvider("linkHeadersWithNextRel")
+ public void get_returns_endPoint_when_link_header_has_next_rel(String linkHeader) throws IOException {
+ server.enqueue(new MockResponse().setBody(randomBody)
+ .setHeader("link", linkHeader));
+
+ GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
+
+ assertThat(response.getNextEndPoint()).contains("https://api.github.com/installation/repositories?per_page=5&page=2");
+ }
+
+ @DataProvider
+ public static Object[][] linkHeadersWithNextRel() {
+ String expected = "https://api.github.com/installation/repositories?per_page=5&page=2";
+ return new Object[][] {
+ {"<" + expected + ">; rel=\"next\""},
+ {"<" + expected + ">; rel=\"next\", " +
+ "<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\""},
+ {"<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\", " +
+ "<" + expected + ">; rel=\"next\""},
+ {"<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\", " +
+ "<" + expected + ">; rel=\"next\", " +
+ "<https://api.github.com/installation/repositories?per_page=5&page=5>; rel=\"last\""},
+ };
+ }
+
+ @DataProvider
+ public static Object[][] someHttpCodesWithContentBut200() {
+ return new Object[][] {
+ {201},
+ {202},
+ {203},
+ {404},
+ {500}
+ };
+ }
+
+ @Test
+ public void post_fails_if_endpoint_does_not_start_with_slash() throws IOException {
+ assertThatThrownBy(() -> underTest.post(appUrl, accessToken, "api/foo/bar"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("endpoint must start with '/' or 'http'");
+ }
+
+ @Test
+ public void post_fails_if_endpoint_does_not_start_with_http() throws IOException {
+ assertThatThrownBy(() -> underTest.post(appUrl, accessToken, "ttp://api/foo/bar"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("endpoint must start with '/' or 'http'");
+ }
+
+ @Test
+ public void post_fails_if_github_endpoint_is_invalid() throws IOException {
+ assertThatThrownBy(() -> underTest.post("invalidUrl", accessToken, "/endpoint"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("invalidUrl/endpoint is not a valid url");
+ }
+
+ @Test
+ public void post_adds_authentication_header_with_Bearer_type_and_Accept_header() throws IOException, InterruptedException {
+ server.enqueue(new MockResponse());
+
+ Response response = underTest.post(appUrl, accessToken, randomEndPoint);
+
+ assertThat(response).isNotNull();
+ RecordedRequest recordedRequest = server.takeRequest();
+ assertThat(recordedRequest.getMethod()).isEqualTo("POST");
+ assertThat(recordedRequest.getPath()).isEqualTo(randomEndPoint);
+ assertThat(recordedRequest.getHeader("Authorization")).isEqualTo("token " + accessToken.getValue());
+ assertThat(recordedRequest.getHeader("Accept")).isEqualTo(BETA_API_HEADER);
+ }
+
+ @Test
+ public void post_returns_body_as_response_if_code_is_200() throws IOException {
+ server.enqueue(new MockResponse().setResponseCode(200).setBody(randomBody));
+
+ Response response = underTest.post(appUrl, accessToken, randomEndPoint);
+
+ assertThat(response.getContent()).contains(randomBody);
+ }
+
+ @Test
+ public void post_returns_body_as_response_if_code_is_201() throws IOException {
+ server.enqueue(new MockResponse().setResponseCode(201).setBody(randomBody));
+
+ Response response = underTest.post(appUrl, accessToken, randomEndPoint);
+
+ assertThat(response.getContent()).contains(randomBody);
+ }
+
+ @Test
+ public void post_returns_empty_response_if_code_is_204() throws IOException {
+ server.enqueue(new MockResponse().setResponseCode(204));
+
+ Response response = underTest.post(appUrl, accessToken, randomEndPoint);
+
+ assertThat(response.getContent()).isEmpty();
+ }
+
+ @Test
+ @UseDataProvider("httpCodesBut200_201And204")
+ public void post_has_json_error_in_body_if_code_is_neither_200_201_nor_204(int code) throws IOException {
+ server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
+
+ Response response = underTest.post(appUrl, accessToken, randomEndPoint);
+
+ assertThat(response.getContent()).contains(randomBody);
+ }
+
+ @DataProvider
+ public static Object[][] httpCodesBut200_201And204() {
+ return new Object[][] {
+ {202},
+ {203},
+ {400},
+ {401},
+ {403},
+ {404},
+ {500}
+ };
+ }
+
+ @Test
+ public void post_with_json_body_adds_json_to_body_request() throws IOException, InterruptedException {
+ server.enqueue(new MockResponse());
+ String jsonBody = "{\"foo\": \"bar\"}";
+ Response response = underTest.post(appUrl, accessToken, randomEndPoint, jsonBody);
+
+ assertThat(response).isNotNull();
+ RecordedRequest recordedRequest = server.takeRequest();
+ assertThat(recordedRequest.getBody().readUtf8()).isEqualTo(jsonBody);
+ }
+
+ @Test
+ public void patch_with_json_body_adds_json_to_body_request() throws IOException, InterruptedException {
+ server.enqueue(new MockResponse());
+ String jsonBody = "{\"foo\": \"bar\"}";
+
+ Response response = underTest.patch(appUrl, accessToken, randomEndPoint, jsonBody);
+
+ assertThat(response).isNotNull();
+ RecordedRequest recordedRequest = server.takeRequest();
+ assertThat(recordedRequest.getBody().readUtf8()).isEqualTo(jsonBody);
+ }
+
+ @Test
+ public void patch_returns_body_as_response_if_code_is_200() throws IOException {
+ server.enqueue(new MockResponse().setResponseCode(200).setBody(randomBody));
+
+ Response response = underTest.patch(appUrl, accessToken, randomEndPoint, "{}");
+
+ assertThat(response.getContent()).contains(randomBody);
+ }
+
+ @Test
+ public void patch_returns_empty_response_if_code_is_204() throws IOException {
+ server.enqueue(new MockResponse().setResponseCode(204));
+
+ Response response = underTest.patch(appUrl, accessToken, randomEndPoint, "{}");
+
+ assertThat(response.getContent()).isEmpty();
+ }
+
+ @Test
+ public void delete_returns_empty_response_if_code_is_204() throws IOException {
+ server.enqueue(new MockResponse().setResponseCode(204));
+
+ Response response = underTest.delete(appUrl, accessToken, randomEndPoint);
+
+ assertThat(response.getContent()).isEmpty();
+ }
+
+ @DataProvider
+ public static Object[][] httpCodesBut204() {
+ return new Object[][] {
+ {200},
+ {201},
+ {202},
+ {203},
+ {400},
+ {401},
+ {403},
+ {404},
+ {500}
+ };
+ }
+
+ @Test
+ @UseDataProvider("httpCodesBut204")
+ public void delete_returns_response_if_code_is_not_204(int code) throws IOException {
+ server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
+
+ Response response = underTest.delete(appUrl, accessToken, randomEndPoint);
+
+ assertThat(response.getContent()).hasValue(randomBody);
+ }
+
+ @DataProvider
+ public static Object[][] httpCodesBut200And204() {
+ return new Object[][] {
+ {201},
+ {202},
+ {203},
+ {400},
+ {401},
+ {403},
+ {404},
+ {500}
+ };
+ }
+
+ @Test
+ @UseDataProvider("httpCodesBut200And204")
+ public void patch_has_json_error_in_body_if_code_is_neither_200_nor_204(int code) throws IOException {
+ server.enqueue(new MockResponse().setResponseCode(code).setBody(randomBody));
+
+ Response response = underTest.patch(appUrl, accessToken, randomEndPoint, "{}");
+
+ assertThat(response.getContent()).contains(randomBody);
+ }
+
+}