diff options
author | Jacek <jacek.poreda@sonarsource.com> | 2021-01-27 14:09:11 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2021-02-04 20:07:07 +0000 |
commit | 53fb0b91e5da3762990255dc8ec4caa67ed63e30 (patch) | |
tree | 13a41ad0db5c2179e1fe00859dc14817ef6ee8ca | |
parent | c364b859dd4b1610dd7537b77f1152e620c5525a (diff) | |
download | sonarqube-53fb0b91e5da3762990255dc8ec4caa67ed63e30.tar.gz sonarqube-53fb0b91e5da3762990255dc8ec4caa67ed63e30.zip |
SONAR-14371 Move Github http client to CE
23 files changed, 3423 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); + } + +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java index 0496dae204c..fccfba4a7d2 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java @@ -26,6 +26,10 @@ import org.sonar.server.almintegration.ws.azure.SearchAzureReposAction; import org.sonar.server.almintegration.ws.bitbucketserver.ImportBitbucketServerProjectAction; import org.sonar.server.almintegration.ws.bitbucketserver.ListBitbucketServerProjectsAction; import org.sonar.server.almintegration.ws.bitbucketserver.SearchBitbucketServerReposAction; +import org.sonar.server.almintegration.ws.github.GetGithubClientIdAction; +import org.sonar.server.almintegration.ws.github.ImportGithubProjectAction; +import org.sonar.server.almintegration.ws.github.ListGithubOrganizationsAction; +import org.sonar.server.almintegration.ws.github.ListGithubRepositoriesAction; import org.sonar.server.almintegration.ws.gitlab.ImportGitLabProjectAction; import org.sonar.server.almintegration.ws.gitlab.SearchGitlabReposAction; @@ -36,6 +40,10 @@ public class AlmIntegrationsWSModule extends Module { ImportBitbucketServerProjectAction.class, ListBitbucketServerProjectsAction.class, SearchBitbucketServerReposAction.class, + GetGithubClientIdAction.class, + ImportGithubProjectAction.class, + ListGithubOrganizationsAction.class, + ListGithubRepositoriesAction.class, ImportGitLabProjectAction.class, SearchGitlabReposAction.class, ImportAzureProjectAction.class, diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/bitbucketserver/package-info.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/bitbucketserver/package-info.java new file mode 100644 index 00000000000..6ae1d0f9f77 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/bitbucketserver/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.server.almintegration.ws.bitbucketserver; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GetGithubClientIdAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GetGithubClientIdAction.java new file mode 100644 index 00000000000..a28b6352892 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/GetGithubClientIdAction.java @@ -0,0 +1,84 @@ +/* + * 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.github; + +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.server.almintegration.ws.AlmIntegrationsWsAction; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.user.UserSession; +import org.sonarqube.ws.AlmIntegrations; + +import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS; +import static org.sonar.server.ws.WsUtils.writeProtobuf; + +public class GetGithubClientIdAction implements AlmIntegrationsWsAction { + + public static final String PARAM_ALM_SETTING = "almSetting"; + + private final DbClient dbClient; + private final UserSession userSession; + + public GetGithubClientIdAction(DbClient dbClient, UserSession userSession) { + this.dbClient = dbClient; + this.userSession = userSession; + } + + @Override + public void define(WebService.NewController context) { + WebService.NewAction action = context.createAction("get_github_client_id") + .setDescription("Get the client id of a Github ALM Integration.") + .setInternal(true) + .setSince("8.4") + .setHandler(this); + + action.createParam(PARAM_ALM_SETTING) + .setRequired(true) + .setMaximumLength(200) + .setDescription("ALM setting key"); + } + + @Override + public void handle(Request request, Response response) { + AlmIntegrations.GithubClientIdWsResponse getResponse = doHandle(request); + writeProtobuf(getResponse, request, response); + } + + private AlmIntegrations.GithubClientIdWsResponse doHandle(Request request) { + try (DbSession dbSession = dbClient.openSession(false)) { + userSession.checkLoggedIn().checkPermission(PROVISION_PROJECTS); + + String almSettingKey = request.mandatoryParam(PARAM_ALM_SETTING); + AlmSettingDto almSetting = dbClient.almSettingDao().selectByKey(dbSession, almSettingKey) + .orElseThrow(() -> new NotFoundException(String.format("Github ALM Setting '%s' not found", almSettingKey))); + + if (almSetting.getClientId() == null) { + throw new NotFoundException(String.format("No client ID for setting with key '%s'", almSettingKey)); + } + return AlmIntegrations.GithubClientIdWsResponse.newBuilder() + .setClientId(almSetting.getClientId()) + .build(); + } + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/ImportGithubProjectAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/ImportGithubProjectAction.java new file mode 100644 index 00000000000..eb1f31a6180 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/ImportGithubProjectAction.java @@ -0,0 +1,157 @@ +/* + * 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.github; + +import org.sonar.alm.client.github.GithubApplicationClient; +import org.sonar.alm.client.github.GithubApplicationClient.Repository; +import org.sonar.alm.client.github.GithubApplicationClientImpl; +import org.sonar.alm.client.github.security.AccessToken; +import org.sonar.alm.client.github.security.UserAccessToken; +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.alm.setting.ProjectAlmSettingDto; +import org.sonar.db.component.ComponentDto; +import org.sonar.server.almintegration.ws.AlmIntegrationsWsAction; +import org.sonar.server.almintegration.ws.ImportHelper; +import org.sonar.server.component.ComponentUpdater; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.project.ProjectDefaultVisibility; +import org.sonar.server.user.UserSession; +import org.sonarqube.ws.Projects; + +import static java.util.Objects.requireNonNull; +import static org.sonar.api.resources.Qualifiers.PROJECT; +import static org.sonar.server.almintegration.ws.ImportHelper.PARAM_ALM_SETTING; +import static org.sonar.server.almintegration.ws.ImportHelper.toCreateResponse; +import static org.sonar.server.component.NewComponent.newComponentBuilder; +import static org.sonar.server.ws.WsUtils.writeProtobuf; + +public class ImportGithubProjectAction implements AlmIntegrationsWsAction { + + public static final String PARAM_ORGANIZATION = "organization"; + public static final String PARAM_REPOSITORY_KEY = "repositoryKey"; + + private final DbClient dbClient; + private final UserSession userSession; + private final ProjectDefaultVisibility projectDefaultVisibility; + private final GithubApplicationClient githubApplicationClient; + private final ComponentUpdater componentUpdater; + private final ImportHelper importHelper; + + public ImportGithubProjectAction(DbClient dbClient, UserSession userSession, ProjectDefaultVisibility projectDefaultVisibility, + GithubApplicationClientImpl githubApplicationClient, ComponentUpdater componentUpdater, ImportHelper importHelper) { + this.dbClient = dbClient; + this.userSession = userSession; + this.projectDefaultVisibility = projectDefaultVisibility; + this.githubApplicationClient = githubApplicationClient; + this.componentUpdater = componentUpdater; + this.importHelper = importHelper; + } + + @Override + public void define(WebService.NewController context) { + WebService.NewAction action = context.createAction("import_github_project") + .setDescription("Create a SonarQube project with the information from the provided GitHub repository.<br/>" + + "Autoconfigure pull request decoration mechanism.<br/>" + + "Requires the 'Create Projects' permission") + .setPost(true) + .setInternal(true) + .setSince("8.4") + .setHandler(this); + + action.createParam(PARAM_ALM_SETTING) + .setRequired(true) + .setMaximumLength(200) + .setDescription("ALM setting key"); + + action.createParam(PARAM_ORGANIZATION) + .setRequired(true) + .setMaximumLength(200) + .setDescription("GitHub organization"); + + action.createParam(PARAM_REPOSITORY_KEY) + .setRequired(true) + .setMaximumLength(256) + .setDescription("GitHub repository key"); + } + + @Override + public void handle(Request request, Response response) { + Projects.CreateWsResponse createResponse = doHandle(request); + writeProtobuf(createResponse, request, response); + } + + private Projects.CreateWsResponse doHandle(Request request) { + importHelper.checkProvisionProjectPermission(); + AlmSettingDto almSettingDto = importHelper.getAlmSetting(request); + String userUuid = importHelper.getUserUuid(); + try (DbSession dbSession = dbClient.openSession(false)) { + + AccessToken accessToken = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto) + .map(AlmPatDto::getPersonalAccessToken) + .map(UserAccessToken::new) + .orElseThrow(() -> new IllegalArgumentException("No personal access token found")); + + String githubOrganization = request.mandatoryParam(PARAM_ORGANIZATION); + String repositoryKey = request.mandatoryParam(PARAM_REPOSITORY_KEY); + + String url = requireNonNull(almSettingDto.getUrl(), "ALM url cannot be null"); + Repository repository = githubApplicationClient.getRepository(url, accessToken, githubOrganization, repositoryKey) + .orElseThrow(() -> new NotFoundException(String.format("GitHub repository '%s' not found", repositoryKey))); + + ComponentDto componentDto = createProject(dbSession, repository); + populatePRSetting(dbSession, repository, componentDto, almSettingDto); + + return toCreateResponse(componentDto); + } + } + + private ComponentDto createProject(DbSession dbSession, Repository repo) { + boolean visibility = projectDefaultVisibility.get(dbSession).isPrivate(); + return componentUpdater.create(dbSession, newComponentBuilder() + .setKey(getProjectKeyFromRepository(repo)) + .setName(repo.getName()) + .setPrivate(visibility) + .setQualifier(PROJECT) + .build(), + userSession.getUuid()); + } + + static String getProjectKeyFromRepository(Repository repo) { + return repo.getFullName().replace("/", "_"); + } + + private void populatePRSetting(DbSession dbSession, Repository repo, ComponentDto componentDto, AlmSettingDto almSettingDto) { + ProjectAlmSettingDto projectAlmSettingDto = new ProjectAlmSettingDto() + .setAlmSettingUuid(almSettingDto.getUuid()) + .setAlmRepo(repo.getFullName()) + .setAlmSlug(null) + .setProjectUuid(componentDto.uuid()) + .setSummaryCommentEnabled(true) + .setMonorepo(false); + dbClient.projectAlmSettingDao().insertOrUpdate(dbSession, projectAlmSettingDto); + dbSession.commit(); + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/ListGithubOrganizationsAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/ListGithubOrganizationsAction.java new file mode 100644 index 00000000000..f89ad82f338 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/ListGithubOrganizationsAction.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.server.almintegration.ws.github; + +import java.util.List; +import java.util.Optional; +import org.sonar.alm.client.github.GithubApplicationClient; +import org.sonar.alm.client.github.GithubApplicationClient.Organization; +import org.sonar.alm.client.github.GithubApplicationClientImpl; +import org.sonar.alm.client.github.security.AccessToken; +import org.sonar.alm.client.github.security.UserAccessToken; +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.server.almintegration.ws.AlmIntegrationsWsAction; +import org.sonar.server.exceptions.BadRequestException; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.user.UserSession; +import org.sonarqube.ws.AlmIntegrations; +import org.sonarqube.ws.AlmIntegrations.ListGithubOrganizationsWsResponse; +import org.sonarqube.ws.Common; + +import static java.util.Objects.requireNonNull; +import static org.sonar.api.server.ws.WebService.Param.PAGE; +import static org.sonar.api.server.ws.WebService.Param.PAGE_SIZE; +import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS; +import static org.sonar.server.ws.WsUtils.writeProtobuf; + +public class ListGithubOrganizationsAction implements AlmIntegrationsWsAction { + + public static final String PARAM_ALM_SETTING = "almSetting"; + public static final String PARAM_TOKEN = "token"; + + private final DbClient dbClient; + private final UserSession userSession; + private final GithubApplicationClient githubApplicationClient; + + public ListGithubOrganizationsAction(DbClient dbClient, UserSession userSession, GithubApplicationClientImpl githubApplicationClient) { + this.dbClient = dbClient; + this.userSession = userSession; + this.githubApplicationClient = githubApplicationClient; + } + + @Override + public void define(WebService.NewController context) { + WebService.NewAction action = context.createAction("list_github_organizations") + .setDescription("List GitHub organizations<br/>" + + "Requires the 'Create Projects' permission") + .setInternal(true) + .setSince("8.4") + .setHandler(this); + + action.createParam(PARAM_ALM_SETTING) + .setRequired(true) + .setMaximumLength(200) + .setDescription("ALM setting key"); + + action.createParam(PARAM_TOKEN) + .setMaximumLength(200) + .setDescription("Github authorization code"); + + action.createParam(PAGE) + .setDescription("Index of the page to display") + .setDefaultValue(1); + action.createParam(PAGE_SIZE) + .setDescription("Size for the paging to apply") + .setDefaultValue(100); + } + + @Override + public void handle(Request request, Response response) { + ListGithubOrganizationsWsResponse getResponse = doHandle(request); + writeProtobuf(getResponse, request, response); + } + + private ListGithubOrganizationsWsResponse doHandle(Request request) { + try (DbSession dbSession = dbClient.openSession(false)) { + userSession.checkLoggedIn().checkPermission(PROVISION_PROJECTS); + + String almSettingKey = request.mandatoryParam(PARAM_ALM_SETTING); + AlmSettingDto almSettingDto = dbClient.almSettingDao().selectByKey(dbSession, almSettingKey) + .orElseThrow(() -> new NotFoundException(String.format("GitHub ALM Setting '%s' not found", almSettingKey))); + + String userUuid = requireNonNull(userSession.getUuid(), "User UUID is not null"); + String url = requireNonNull(almSettingDto.getUrl(), String.format("No URL set for GitHub ALM '%s'", almSettingKey)); + + AccessToken accessToken; + if (request.hasParam(PARAM_TOKEN)) { + String code = request.mandatoryParam(PARAM_TOKEN); + String clientId = requireNonNull(almSettingDto.getClientId(), String.format("No clientId set for GitHub ALM '%s'", almSettingKey)); + String clientSecret = requireNonNull(almSettingDto.getClientSecret(), String.format("No clientSecret set for GitHub ALM '%s'", almSettingKey)); + + try { + accessToken = githubApplicationClient.createUserAccessToken(url, clientId, clientSecret, code); + } catch (IllegalArgumentException e) { + // it could also be that the code has expired! + throw BadRequestException.create("Unable to authenticate with GitHub. " + + "Check the GitHub App client ID and client secret configured in the Global Settings and try again."); + } + Optional<AlmPatDto> almPatDto = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto); + if (almPatDto.isPresent()) { + AlmPatDto almPat = almPatDto.get(); + almPat.setPersonalAccessToken(accessToken.getValue()); + dbClient.almPatDao().update(dbSession, almPat); + } else { + AlmPatDto almPat = new AlmPatDto() + .setPersonalAccessToken(accessToken.getValue()) + .setAlmSettingUuid(almSettingDto.getUuid()) + .setUserUuid(userUuid); + dbClient.almPatDao().insert(dbSession, almPat); + } + dbSession.commit(); + } else { + accessToken = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto) + .map(AlmPatDto::getPersonalAccessToken) + .map(UserAccessToken::new) + .orElseThrow(() -> new IllegalArgumentException("No personal access token found")); + } + + int page = request.hasParam(PAGE) ? request.mandatoryParamAsInt(PAGE) : 1; + int pageSize = request.hasParam(PAGE_SIZE) ? request.mandatoryParamAsInt(PAGE_SIZE) : 100; + GithubApplicationClient.Organizations githubOrganizations = githubApplicationClient.listOrganizations(url, accessToken, page, pageSize); + + ListGithubOrganizationsWsResponse.Builder response = ListGithubOrganizationsWsResponse.newBuilder() + .setPaging(Common.Paging.newBuilder() + .setPageIndex(page) + .setPageSize(pageSize) + .setTotal(githubOrganizations.getTotal()) + .build()); + + List<Organization> organizations = githubOrganizations.getOrganizations(); + if (organizations != null) { + organizations + .forEach(githubOrganization -> response.addOrganizations(AlmIntegrations.GithubOrganization.newBuilder() + .setKey(githubOrganization.getLogin()) + .setName(githubOrganization.getLogin()) + .build())); + } + + return response.build(); + } + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/ListGithubRepositoriesAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/ListGithubRepositoriesAction.java new file mode 100644 index 00000000000..a0872a6a4cb --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/ListGithubRepositoriesAction.java @@ -0,0 +1,150 @@ +/* + * 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.github; + +import java.util.List; +import java.util.Optional; +import org.sonar.alm.client.github.GithubApplicationClient; +import org.sonar.alm.client.github.GithubApplicationClient.Repository; +import org.sonar.alm.client.github.GithubApplicationClientImpl; +import org.sonar.alm.client.github.security.AccessToken; +import org.sonar.alm.client.github.security.UserAccessToken; +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.project.ProjectDto; +import org.sonar.server.almintegration.ws.AlmIntegrationsWsAction; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.user.UserSession; +import org.sonarqube.ws.AlmIntegrations; +import org.sonarqube.ws.Common; + +import static java.util.Objects.requireNonNull; +import static org.sonar.api.server.ws.WebService.Param.PAGE; +import static org.sonar.api.server.ws.WebService.Param.PAGE_SIZE; +import static org.sonar.api.server.ws.WebService.Param.TEXT_QUERY; +import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS; +import static org.sonar.server.almintegration.ws.github.ImportGithubProjectAction.getProjectKeyFromRepository; +import static org.sonar.server.ws.WsUtils.writeProtobuf; + +public class ListGithubRepositoriesAction implements AlmIntegrationsWsAction { + + public static final String PARAM_ALM_SETTING = "almSetting"; + public static final String PARAM_ORGANIZATION = "organization"; + + private final DbClient dbClient; + private final UserSession userSession; + private final GithubApplicationClient githubApplicationClient; + + public ListGithubRepositoriesAction(DbClient dbClient, UserSession userSession, GithubApplicationClientImpl githubApplicationClient) { + this.dbClient = dbClient; + this.userSession = userSession; + this.githubApplicationClient = githubApplicationClient; + } + + @Override + public void define(WebService.NewController context) { + WebService.NewAction action = context.createAction("list_github_repositories") + .setDescription("List the GitHub repositories for an organization<br/>" + + "Requires the 'Create Projects' permission") + .setInternal(true) + .setSince("8.4") + .setHandler(this); + + action.createParam(PARAM_ALM_SETTING) + .setRequired(true) + .setMaximumLength(200) + .setDescription("ALM setting key"); + + action.createParam(PARAM_ORGANIZATION) + .setRequired(true) + .setMaximumLength(200) + .setDescription("Github organization"); + + action.createParam(TEXT_QUERY) + .setDescription("Limit search to repositories that contain the supplied string") + .setExampleValue("Apache"); + + action.createParam(PAGE) + .setDescription("Index of the page to display") + .setDefaultValue(1); + action.createParam(PAGE_SIZE) + .setDescription("Size for the paging to apply") + .setDefaultValue(100); + } + + @Override + public void handle(Request request, Response response) { + AlmIntegrations.ListGithubRepositoriesWsResponse getResponse = doHandle(request); + writeProtobuf(getResponse, request, response); + } + + private AlmIntegrations.ListGithubRepositoriesWsResponse doHandle(Request request) { + try (DbSession dbSession = dbClient.openSession(false)) { + userSession.checkLoggedIn().checkPermission(PROVISION_PROJECTS); + + String almSettingKey = request.mandatoryParam(PARAM_ALM_SETTING); + AlmSettingDto almSettingDto = dbClient.almSettingDao().selectByKey(dbSession, almSettingKey) + .orElseThrow(() -> new NotFoundException(String.format("GitHub ALM Setting '%s' not found", almSettingKey))); + + String userUuid = requireNonNull(userSession.getUuid(), "User UUID is not null"); + String url = requireNonNull(almSettingDto.getUrl(), String.format("No URL set for GitHub ALM '%s'", almSettingKey)); + + AccessToken accessToken = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto) + .map(AlmPatDto::getPersonalAccessToken) + .map(UserAccessToken::new) + .orElseThrow(() -> new IllegalArgumentException("No personal access token found")); + + int pageIndex = request.hasParam(PAGE) ? request.mandatoryParamAsInt(PAGE) : 1; + int pageSize = request.hasParam(PAGE_SIZE) ? request.mandatoryParamAsInt(PAGE_SIZE) : 100; + + GithubApplicationClient.Repositories repositories = githubApplicationClient + .listRepositories(url, accessToken, request.mandatoryParam(PARAM_ORGANIZATION), request.param(TEXT_QUERY), pageIndex, pageSize); + + AlmIntegrations.ListGithubRepositoriesWsResponse.Builder response = AlmIntegrations.ListGithubRepositoriesWsResponse.newBuilder() + .setPaging(Common.Paging.newBuilder() + .setPageIndex(pageIndex) + .setPageSize(pageSize) + .setTotal(repositories.getTotal()) + .build()); + + List<Repository> repositoryList = repositories.getRepositories(); + if (repositoryList != null) { + repositoryList.forEach(repository -> { + Optional<String> sonarQubeKey = dbClient.projectDao().selectProjectByKey(dbSession, getProjectKeyFromRepository(repository)).map(ProjectDto::getKey); + response.addRepositories(AlmIntegrations.GithubRepository.newBuilder() + .setId(repository.getId()) + .setKey(repository.getFullName()) + .setName(repository.getName()) + .setUrl(repository.getUrl()) + .setSqProjectKey(sonarQubeKey.orElse("")) + .build()); + } + ); + } + + return response.build(); + } + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/package-info.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/github/package-info.java new file mode 100644 index 00000000000..023ef7fc352 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/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.server.almintegration.ws.github; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/GetGithubClientIdActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/GetGithubClientIdActionTest.java new file mode 100644 index 00000000000..082c330b8f8 --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/GetGithubClientIdActionTest.java @@ -0,0 +1,95 @@ +/* + * 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.github; + +import org.junit.Rule; +import org.junit.Test; +import org.sonar.api.utils.System2; +import org.sonar.db.DbTester; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.db.user.UserDto; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.exceptions.UnauthorizedException; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.ws.TestRequest; +import org.sonar.server.ws.WsActionTester; +import org.sonarqube.ws.AlmIntegrations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.sonar.server.tester.UserSessionRule.standalone; + +public class GetGithubClientIdActionTest { + + @Rule + public UserSessionRule userSession = standalone(); + + private final System2 system2 = mock(System2.class); + + @Rule + public DbTester db = DbTester.create(system2); + + private final WsActionTester ws = new WsActionTester(new GetGithubClientIdAction(db.getDbClient(), userSession)); + + @Test + public void get_client_id() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(GlobalPermission.PROVISION_PROJECTS); + AlmSettingDto githubAlmSetting = db.almSettings().insertGitHubAlmSetting(alm -> alm.setClientId("client_123").setClientSecret("client_secret_123")); + + AlmIntegrations.GithubClientIdWsResponse response = ws.newRequest().setParam(GetGithubClientIdAction.PARAM_ALM_SETTING, githubAlmSetting.getKey()) + .executeProtobuf(AlmIntegrations.GithubClientIdWsResponse.class); + + assertThat(response.getClientId()).isEqualTo(githubAlmSetting.getClientId()); + } + + @Test + public void fail_when_missing_create_project_permission() { + TestRequest request = ws.newRequest(); + assertThatThrownBy(request::execute) + .isInstanceOf(UnauthorizedException.class); + } + + @Test + public void fail_when_almSetting_does_not_exist() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(GlobalPermission.PROVISION_PROJECTS); + + TestRequest request = ws.newRequest().setParam(GetGithubClientIdAction.PARAM_ALM_SETTING, "unknown"); + assertThatThrownBy(request::execute) + .isInstanceOf(NotFoundException.class) + .hasMessage("Github ALM Setting 'unknown' not found"); + } + + @Test + public void fail_when_client_id_does_not_exist() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(GlobalPermission.PROVISION_PROJECTS); + AlmSettingDto githubAlmSetting = db.almSettings().insertGitHubAlmSetting(); + + TestRequest request = ws.newRequest() + .setParam(GetGithubClientIdAction.PARAM_ALM_SETTING, githubAlmSetting.getKey()); + assertThatThrownBy(request::execute) + .isInstanceOf(NotFoundException.class) + .hasMessage("No client ID for setting with key '%s'", githubAlmSetting.getKey()); + } +} diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/ImportGithubProjectActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/ImportGithubProjectActionTest.java new file mode 100644 index 00000000000..563e40b955a --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/ImportGithubProjectActionTest.java @@ -0,0 +1,198 @@ +/* + * 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.github; + +import java.util.Optional; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.alm.client.github.GithubApplicationClient; +import org.sonar.alm.client.github.GithubApplicationClientImpl; +import org.sonar.api.server.ws.WebService; +import org.sonar.api.utils.System2; +import org.sonar.core.i18n.I18n; +import org.sonar.core.util.SequenceUuidFactory; +import org.sonar.db.DbTester; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.db.project.ProjectDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.almintegration.ws.ImportHelper; +import org.sonar.server.component.ComponentUpdater; +import org.sonar.server.es.TestProjectIndexers; +import org.sonar.server.exceptions.BadRequestException; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.exceptions.UnauthorizedException; +import org.sonar.server.favorite.FavoriteUpdater; +import org.sonar.server.permission.PermissionTemplateService; +import org.sonar.server.project.ProjectDefaultVisibility; +import org.sonar.server.project.Visibility; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.ws.TestRequest; +import org.sonar.server.ws.WsActionTester; +import org.sonarqube.ws.Projects; + +import static org.assertj.core.api.Assertions.assertThat; +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.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.server.almintegration.ws.ImportHelper.PARAM_ALM_SETTING; +import static org.sonar.server.almintegration.ws.github.ImportGithubProjectAction.PARAM_ORGANIZATION; +import static org.sonar.server.almintegration.ws.github.ImportGithubProjectAction.PARAM_REPOSITORY_KEY; +import static org.sonar.server.tester.UserSessionRule.standalone; + +public class ImportGithubProjectActionTest { + + @Rule + public UserSessionRule userSession = standalone(); + + private final System2 system2 = mock(System2.class); + private final GithubApplicationClientImpl appClient = mock(GithubApplicationClientImpl.class); + + @Rule + public DbTester db = DbTester.create(system2); + + private final ComponentUpdater componentUpdater = new ComponentUpdater(db.getDbClient(), mock(I18n.class), System2.INSTANCE, + mock(PermissionTemplateService.class), new FavoriteUpdater(db.getDbClient()), new TestProjectIndexers(), new SequenceUuidFactory()); + + private final ImportHelper importHelper = new ImportHelper(db.getDbClient(), userSession); + private final ProjectDefaultVisibility projectDefaultVisibility = mock(ProjectDefaultVisibility.class); + private final WsActionTester ws = new WsActionTester(new ImportGithubProjectAction(db.getDbClient(), userSession, + projectDefaultVisibility, appClient, componentUpdater, importHelper)); + + @Before + public void before() { + when(projectDefaultVisibility.get(any())).thenReturn(Visibility.PRIVATE); + } + + @Test + public void import_project() { + AlmSettingDto githubAlmSetting = setupAlm(); + db.almPats().insert(p -> p.setAlmSettingUuid(githubAlmSetting.getUuid()).setUserUuid(userSession.getUuid())); + + GithubApplicationClient.Repository repository = new GithubApplicationClient.Repository(1L, "Hello-World", false, "octocat/Hello-World", + "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World"); + when(appClient.getRepository(any(), any(), any(), any())) + .thenReturn(Optional.of(repository)); + + Projects.CreateWsResponse response = ws.newRequest() + .setParam(PARAM_ALM_SETTING, githubAlmSetting.getKey()) + .setParam(PARAM_ORGANIZATION, "octocat") + .setParam(PARAM_REPOSITORY_KEY, "octocat/Hello-World") + .executeProtobuf(Projects.CreateWsResponse.class); + + Projects.CreateWsResponse.Project result = response.getProject(); + assertThat(result.getKey()).isEqualTo(repository.getFullName().replace("/", "_")); + assertThat(result.getName()).isEqualTo(repository.getName()); + + Optional<ProjectDto> projectDto = db.getDbClient().projectDao().selectProjectByKey(db.getSession(), result.getKey()); + assertThat(projectDto).isPresent(); + assertThat(db.getDbClient().projectAlmSettingDao().selectByProject(db.getSession(), projectDto.get())).isPresent(); + } + + @Test + public void fail_project_already_exist() { + AlmSettingDto githubAlmSetting = setupAlm(); + db.almPats().insert(p -> p.setAlmSettingUuid(githubAlmSetting.getUuid()).setUserUuid(userSession.getUuid())); + db.components().insertPublicProject(p -> p.setDbKey("octocat_Hello-World")); + + GithubApplicationClient.Repository repository = new GithubApplicationClient.Repository(1L, "Hello-World", false, "octocat/Hello-World", + "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World"); + when(appClient.getRepository(any(), any(), any(), any())) + .thenReturn(Optional.of(repository)); + + TestRequest request = ws.newRequest() + .setParam(PARAM_ALM_SETTING, githubAlmSetting.getKey()) + .setParam(PARAM_ORGANIZATION, "octocat") + .setParam(PARAM_REPOSITORY_KEY, "octocat/Hello-World"); + assertThatThrownBy(() -> request.execute()) + .isInstanceOf(BadRequestException.class) + .hasMessage("Could not create null, key already exists: octocat_Hello-World"); + } + + @Test + public void fail_when_not_logged_in() { + TestRequest request = ws.newRequest() + .setParam(PARAM_ALM_SETTING, "asdfghjkl") + .setParam(PARAM_ORGANIZATION, "test") + .setParam(PARAM_REPOSITORY_KEY, "test/repo"); + assertThatThrownBy(() -> request + .execute()) + .isInstanceOf(UnauthorizedException.class); + } + + @Test + public void fail_when_missing_create_project_permission() { + TestRequest request = ws.newRequest(); + assertThatThrownBy(() -> request.execute()) + .isInstanceOf(UnauthorizedException.class); + } + + @Test + public void fail_when_almSetting_does_not_exist() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(GlobalPermission.PROVISION_PROJECTS); + + TestRequest request = ws.newRequest() + .setParam(PARAM_ALM_SETTING, "unknown") + .setParam(PARAM_ORGANIZATION, "test") + .setParam(PARAM_REPOSITORY_KEY, "test/repo"); + assertThatThrownBy(() -> request + .execute()) + .isInstanceOf(NotFoundException.class) + .hasMessage("ALM Setting 'unknown' not found"); + } + + @Test + public void fail_when_personal_access_token_doesnt_exist() { + AlmSettingDto githubAlmSetting = setupAlm(); + + TestRequest request = ws.newRequest() + .setParam(PARAM_ALM_SETTING, githubAlmSetting.getKey()) + .setParam(PARAM_ORGANIZATION, "test") + .setParam(PARAM_REPOSITORY_KEY, "test/repo"); + assertThatThrownBy(() -> request.execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No personal access token found"); + } + + @Test + public void definition() { + WebService.Action def = ws.getDef(); + + assertThat(def.since()).isEqualTo("8.4"); + assertThat(def.isPost()).isTrue(); + assertThat(def.params()) + .extracting(WebService.Param::key, WebService.Param::isRequired) + .containsExactlyInAnyOrder( + tuple(PARAM_ALM_SETTING, true), + tuple(PARAM_ORGANIZATION, true), + tuple(PARAM_REPOSITORY_KEY, true)); + } + + private AlmSettingDto setupAlm() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(GlobalPermission.PROVISION_PROJECTS); + + return db.almSettings().insertGitHubAlmSetting(alm -> alm.setClientId("client_123").setClientSecret("client_secret_123")); + } +} diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/ListGithubOrganizationsActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/ListGithubOrganizationsActionTest.java new file mode 100644 index 00000000000..7bca0e3af5e --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/ListGithubOrganizationsActionTest.java @@ -0,0 +1,227 @@ +/* + * 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.github; + +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mockito; +import org.sonar.alm.client.github.GithubApplicationClient; +import org.sonar.alm.client.github.GithubApplicationClientImpl; +import org.sonar.alm.client.github.security.UserAccessToken; +import org.sonar.api.utils.System2; +import org.sonar.db.DbTester; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.db.user.UserDto; +import org.sonar.server.exceptions.BadRequestException; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.exceptions.UnauthorizedException; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.ws.TestRequest; +import org.sonar.server.ws.WsActionTester; +import org.sonarqube.ws.AlmIntegrations.GithubOrganization; +import org.sonarqube.ws.AlmIntegrations.ListGithubOrganizationsWsResponse; +import org.sonarqube.ws.Common; + +import static org.assertj.core.api.Assertions.assertThat; +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.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.sonar.server.almintegration.ws.github.ListGithubOrganizationsAction.PARAM_ALM_SETTING; +import static org.sonar.server.almintegration.ws.github.ListGithubOrganizationsAction.PARAM_TOKEN; +import static org.sonar.server.tester.UserSessionRule.standalone; + +public class ListGithubOrganizationsActionTest { + + @Rule + public UserSessionRule userSession = standalone(); + + private final System2 system2 = mock(System2.class); + private final GithubApplicationClientImpl appClient = mock(GithubApplicationClientImpl.class); + + @Rule + public DbTester db = DbTester.create(system2); + + private final WsActionTester ws = new WsActionTester(new ListGithubOrganizationsAction(db.getDbClient(), userSession, appClient)); + + @Test + public void fail_when_missing_create_project_permission() { + TestRequest request = ws.newRequest(); + + assertThatThrownBy(request::execute).isInstanceOf(UnauthorizedException.class); + } + + @Test + public void fail_when_almSetting_does_not_exist() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(GlobalPermission.PROVISION_PROJECTS); + TestRequest request = ws.newRequest().setParam(PARAM_ALM_SETTING, "unknown"); + + assertThatThrownBy(request::execute) + .isInstanceOf(NotFoundException.class) + .hasMessage("GitHub ALM Setting 'unknown' not found"); + } + + @Test + public void fail_when_unable_to_create_personal_access_token() { + AlmSettingDto githubAlmSetting = setupAlm(); + when(appClient.createUserAccessToken(githubAlmSetting.getUrl(), githubAlmSetting.getClientId(), githubAlmSetting.getClientSecret(), "abc")) + .thenThrow(IllegalStateException.class); + TestRequest request = ws.newRequest() + .setParam(PARAM_ALM_SETTING, githubAlmSetting.getKey()) + .setParam(PARAM_TOKEN, "abc"); + + assertThatThrownBy(request::execute) + .isInstanceOf(IllegalStateException.class) + .hasMessage(null); + } + + @Test + public void fail_create_personal_access_token_because_of_invalid_settings() { + AlmSettingDto githubAlmSetting = setupAlm(); + when(appClient.createUserAccessToken(githubAlmSetting.getUrl(), githubAlmSetting.getClientId(), githubAlmSetting.getClientSecret(), "abc")) + .thenThrow(IllegalArgumentException.class); + TestRequest request = ws.newRequest() + .setParam(PARAM_ALM_SETTING, githubAlmSetting.getKey()) + .setParam(PARAM_TOKEN, "abc"); + + assertThatThrownBy(request::execute) + .isInstanceOf(BadRequestException.class) + .hasMessage("Unable to authenticate with GitHub. Check the GitHub App client ID and client secret configured in the Global Settings and try again."); + } + + @Test + public void fail_when_personal_access_token_doesnt_exist() { + AlmSettingDto githubAlmSetting = setupAlm(); + TestRequest request = ws.newRequest().setParam(PARAM_ALM_SETTING, githubAlmSetting.getKey()); + + assertThatThrownBy(request::execute) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No personal access token found"); + } + + @Test + public void return_organizations_and_store_personal_access_token() { + UserAccessToken accessToken = new UserAccessToken("token_for_abc"); + AlmSettingDto githubAlmSettings = setupAlm(); + + when(appClient.createUserAccessToken(githubAlmSettings.getUrl(), githubAlmSettings.getClientId(), githubAlmSettings.getClientSecret(), "abc")) + .thenReturn(accessToken); + setupGhOrganizations(githubAlmSettings, accessToken.getValue()); + + ListGithubOrganizationsWsResponse response = ws.newRequest() + .setParam(PARAM_ALM_SETTING, githubAlmSettings.getKey()) + .setParam(PARAM_TOKEN, "abc") + .executeProtobuf(ListGithubOrganizationsWsResponse.class); + + assertThat(response.getPaging()) + .extracting(Common.Paging::getPageIndex, Common.Paging::getPageSize, Common.Paging::getTotal) + .containsOnly(1, 100, 2); + assertThat(response.getOrganizationsList()) + .extracting(GithubOrganization::getKey, GithubOrganization::getName) + .containsOnly(tuple("github", "github"), tuple("octacat", "octacat")); + + verify(appClient).createUserAccessToken(githubAlmSettings.getUrl(), githubAlmSettings.getClientId(), githubAlmSettings.getClientSecret(), "abc"); + verify(appClient).listOrganizations(githubAlmSettings.getUrl(), accessToken, 1, 100); + Mockito.verifyNoMoreInteractions(appClient); + assertThat(db.getDbClient().almPatDao().selectByUserAndAlmSetting(db.getSession(), userSession.getUuid(), githubAlmSettings).get().getPersonalAccessToken()) + .isEqualTo(accessToken.getValue()); + } + + @Test + public void return_organizations_overriding_existing_personal_access_token() { + + AlmSettingDto githubAlmSettings = setupAlm(); + // old pat + AlmPatDto pat = db.almPats().insert(p -> p.setAlmSettingUuid(githubAlmSettings.getUuid()).setUserUuid(userSession.getUuid())); + + // new pat + UserAccessToken accessToken = new UserAccessToken("token_for_abc"); + when(appClient.createUserAccessToken(githubAlmSettings.getUrl(), githubAlmSettings.getClientId(), githubAlmSettings.getClientSecret(), "abc")) + .thenReturn(accessToken); + setupGhOrganizations(githubAlmSettings, accessToken.getValue()); + + ListGithubOrganizationsWsResponse response = ws.newRequest() + .setParam(PARAM_ALM_SETTING, githubAlmSettings.getKey()) + .setParam(PARAM_TOKEN, "abc") + .executeProtobuf(ListGithubOrganizationsWsResponse.class); + + assertThat(response.getPaging()) + .extracting(Common.Paging::getPageIndex, Common.Paging::getPageSize, Common.Paging::getTotal) + .containsOnly(1, 100, 2); + assertThat(response.getOrganizationsList()) + .extracting(GithubOrganization::getKey, GithubOrganization::getName) + .containsOnly(tuple("github", "github"), tuple("octacat", "octacat")); + + verify(appClient).createUserAccessToken(githubAlmSettings.getUrl(), githubAlmSettings.getClientId(), githubAlmSettings.getClientSecret(), "abc"); + verify(appClient).listOrganizations(eq(githubAlmSettings.getUrl()), argThat(token -> token.getValue().equals(accessToken.getValue())), eq(1), eq(100)); + Mockito.verifyNoMoreInteractions(appClient); + assertThat(db.getDbClient().almPatDao().selectByUserAndAlmSetting(db.getSession(), userSession.getUuid(), githubAlmSettings).get().getPersonalAccessToken()) + .isEqualTo(accessToken.getValue()); + } + + @Test + public void return_organizations_using_existing_personal_access_token() { + AlmSettingDto githubAlmSettings = setupAlm(); + AlmPatDto pat = db.almPats().insert(p -> p.setAlmSettingUuid(githubAlmSettings.getUuid()).setUserUuid(userSession.getUuid())); + setupGhOrganizations(githubAlmSettings, pat.getPersonalAccessToken()); + + ListGithubOrganizationsWsResponse response = ws.newRequest() + .setParam(PARAM_ALM_SETTING, githubAlmSettings.getKey()) + .executeProtobuf(ListGithubOrganizationsWsResponse.class); + + assertThat(response.getPaging()) + .extracting(Common.Paging::getPageIndex, Common.Paging::getPageSize, Common.Paging::getTotal) + .containsOnly(1, 100, 2); + assertThat(response.getOrganizationsList()) + .extracting(GithubOrganization::getKey, GithubOrganization::getName) + .containsOnly(tuple("github", "github"), tuple("octacat", "octacat")); + + verify(appClient, never()).createUserAccessToken(any(), any(), any(), any()); + verify(appClient).listOrganizations(eq(githubAlmSettings.getUrl()), argThat(token -> token.getValue().equals(pat.getPersonalAccessToken())), eq(1), eq(100)); + Mockito.verifyNoMoreInteractions(appClient); + assertThat(db.getDbClient().almPatDao().selectByUserAndAlmSetting(db.getSession(), userSession.getUuid(), githubAlmSettings).get().getPersonalAccessToken()) + .isEqualTo(pat.getPersonalAccessToken()); + } + + private void setupGhOrganizations(AlmSettingDto almSetting, String pat) { + when(appClient.listOrganizations(eq(almSetting.getUrl()), argThat(token -> token.getValue().equals(pat)), eq(1), eq(100))) + .thenReturn(new GithubApplicationClient.Organizations() + .setTotal(2) + .setOrganizations(Stream.of("github", "octacat") + .map(login -> new GithubApplicationClient.Organization(login.length(), login, login, null, null, null, null, "Organization")) + .collect(Collectors.toList()))); + } + + private AlmSettingDto setupAlm() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(GlobalPermission.PROVISION_PROJECTS); + return db.almSettings().insertGitHubAlmSetting(alm -> alm.setClientId("client_123").setClientSecret("client_secret_123")); + } +} diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/ListGithubRepositoriesActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/ListGithubRepositoriesActionTest.java new file mode 100644 index 00000000000..42bba3926ce --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/github/ListGithubRepositoriesActionTest.java @@ -0,0 +1,138 @@ +/* + * 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.github; + +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.alm.client.github.GithubApplicationClient; +import org.sonar.alm.client.github.GithubApplicationClientImpl; +import org.sonar.api.utils.System2; +import org.sonar.db.DbTester; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.db.project.ProjectDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.exceptions.UnauthorizedException; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.ws.TestRequest; +import org.sonar.server.ws.WsActionTester; +import org.sonarqube.ws.Common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.sonar.server.tester.UserSessionRule.standalone; +import static org.sonarqube.ws.AlmIntegrations.GithubRepository; +import static org.sonarqube.ws.AlmIntegrations.ListGithubRepositoriesWsResponse; + +public class ListGithubRepositoriesActionTest { + + @Rule + public UserSessionRule userSession = standalone(); + + private final System2 system2 = mock(System2.class); + private final GithubApplicationClientImpl appClient = mock(GithubApplicationClientImpl.class); + + @Rule + public DbTester db = DbTester.create(system2); + + private final WsActionTester ws = new WsActionTester(new ListGithubRepositoriesAction(db.getDbClient(), userSession, appClient)); + + @Test + public void fail_when_missing_create_project_permission() { + TestRequest request = ws.newRequest(); + assertThatThrownBy(request::execute) + .isInstanceOf(UnauthorizedException.class); + } + + @Test + public void fail_when_almSetting_does_not_exist() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(GlobalPermission.PROVISION_PROJECTS); + + TestRequest request = ws.newRequest() + .setParam(ListGithubRepositoriesAction.PARAM_ALM_SETTING, "unknown") + .setParam(ListGithubRepositoriesAction.PARAM_ORGANIZATION, "test"); + assertThatThrownBy(request::execute) + .isInstanceOf(NotFoundException.class) + .hasMessage("GitHub ALM Setting 'unknown' not found"); + } + + @Test + public void fail_when_personal_access_token_doesnt_exist() { + AlmSettingDto githubAlmSetting = setupAlm(); + + TestRequest request = ws.newRequest() + .setParam(ListGithubRepositoriesAction.PARAM_ALM_SETTING, githubAlmSetting.getKey()) + .setParam(ListGithubRepositoriesAction.PARAM_ORGANIZATION, "test"); + assertThatThrownBy(request::execute) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No personal access token found"); + } + + @Test + public void return_repositories_using_existing_personal_access_token() { + AlmSettingDto githubAlmSettings = setupAlm(); + AlmPatDto pat = db.almPats().insert(p -> p.setAlmSettingUuid(githubAlmSettings.getUuid()).setUserUuid(userSession.getUuid())); + + when(appClient.listRepositories(eq(githubAlmSettings.getUrl()), argThat(token -> token.getValue().equals(pat.getPersonalAccessToken())), eq("github"), isNull(), eq(1), + eq(100))) + .thenReturn(new GithubApplicationClient.Repositories() + .setTotal(2) + .setRepositories(Stream.of("HelloWorld", "HelloUniverse") + .map(name -> new GithubApplicationClient.Repository(name.length(), name, false, "github/" + name, "https://github-enterprise.sonarqube.com/api/v3/github/HelloWorld")) + .collect(Collectors.toList()))); + + ProjectDto project = db.components().insertPrivateProjectDto(componentDto -> componentDto.setDbKey("github_HelloWorld")); + + ListGithubRepositoriesWsResponse response = ws.newRequest() + .setParam(ListGithubRepositoriesAction.PARAM_ALM_SETTING, githubAlmSettings.getKey()) + .setParam(ListGithubRepositoriesAction.PARAM_ORGANIZATION, "github") + .executeProtobuf(ListGithubRepositoriesWsResponse.class); + + assertThat(response.getPaging()) + .extracting(Common.Paging::getPageIndex, Common.Paging::getPageSize, Common.Paging::getTotal) + .containsOnly(1, 100, 2); + assertThat(response.getRepositoriesCount()).isEqualTo(2); + assertThat(response.getRepositoriesList()) + .extracting(GithubRepository::getKey, GithubRepository::getName, GithubRepository::getSqProjectKey) + .containsOnly(tuple("github/HelloWorld", "HelloWorld", project.getKey()), tuple("github/HelloUniverse", "HelloUniverse", "")); + + verify(appClient).listRepositories(eq(githubAlmSettings.getUrl()), argThat(token -> token.getValue().equals(pat.getPersonalAccessToken())), eq("github"), isNull(), eq(1), + eq(100)); + } + + private AlmSettingDto setupAlm() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(GlobalPermission.PROVISION_PROJECTS); + + return db.almSettings().insertGitHubAlmSetting(alm -> alm.setClientId("client_123").setClientSecret("client_secret_123")); + } +} diff --git a/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java b/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java index b7deaf622b6..f34110fe0c7 100644 --- a/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java +++ b/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java @@ -23,6 +23,8 @@ import java.util.List; import org.sonar.alm.client.TimeoutConfigurationImpl; import org.sonar.alm.client.azure.AzureDevOpsHttpClient; import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient; +import org.sonar.alm.client.github.GithubApplicationClientImpl; +import org.sonar.alm.client.github.GithubApplicationHttpClientImpl; import org.sonar.alm.client.gitlab.GitlabHttpClient; import org.sonar.api.profiles.AnnotationProfileParser; import org.sonar.api.profiles.XMLProfileParser; @@ -493,6 +495,8 @@ public class PlatformLevel4 extends PlatformLevel { // ALM integrations TimeoutConfigurationImpl.class, ImportHelper.class, + GithubApplicationClientImpl.class, + GithubApplicationHttpClientImpl.class, BitbucketServerRestClient.class, GitlabHttpClient.class, AzureDevOpsHttpClient.class, |