--- /dev/null
+/*
+ * 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;
+ }
+ }
+
+}
--- /dev/null
+/*
+ * 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);
+ }
+ }
+}
--- /dev/null
+/*
+ * 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();
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+ }
+}
--- /dev/null
+/*
+ * 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
+ }
+ }
+}
--- /dev/null
+/*
+ * 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;
--- /dev/null
+/*
+ * 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();
+
+}
--- /dev/null
+/*
+ * 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();
+ }
+}
--- /dev/null
+/*
+ * 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;
--- /dev/null
+/*
+ * 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);
+ }
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+
+}
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;
ImportBitbucketServerProjectAction.class,
ListBitbucketServerProjectsAction.class,
SearchBitbucketServerReposAction.class,
+ GetGithubClientIdAction.class,
+ ImportGithubProjectAction.class,
+ ListGithubOrganizationsAction.class,
+ ListGithubRepositoriesAction.class,
ImportGitLabProjectAction.class,
SearchGitlabReposAction.class,
ImportAzureProjectAction.class,
--- /dev/null
+/*
+ * 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;
--- /dev/null
+/*
+ * 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();
+ }
+ }
+}
--- /dev/null
+/*
+ * 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();
+ }
+}
--- /dev/null
+/*
+ * 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();
+ }
+ }
+}
--- /dev/null
+/*
+ * 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();
+ }
+ }
+}
--- /dev/null
+/*
+ * 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;
--- /dev/null
+/*
+ * 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());
+ }
+}
--- /dev/null
+/*
+ * 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"));
+ }
+}
--- /dev/null
+/*
+ * 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"));
+ }
+}
--- /dev/null
+/*
+ * 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"));
+ }
+}
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;
// ALM integrations
TimeoutConfigurationImpl.class,
ImportHelper.class,
+ GithubApplicationClientImpl.class,
+ GithubApplicationHttpClientImpl.class,
BitbucketServerRestClient.class,
GitlabHttpClient.class,
AzureDevOpsHttpClient.class,