From 044247d48921aa5486c4cea1e2719207ff250fc9 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 26 Jan 2021 11:23:07 +0100 Subject: [PATCH] SONAR-14371 Move gitlab http client to CE --- server/sonar-alm-client/build.gradle | 15 +- .../alm/client/TimeoutConfiguration.java | 40 +++ .../alm/client/TimeoutConfigurationImpl.java | 64 ++++ .../alm/client/gitlab/GitlabHttpClient.java | 183 ++++++++++++ .../sonar/alm/client/gitlab/GsonError.java | 62 ++++ .../org/sonar/alm/client/gitlab/Project.java | 105 +++++++ .../sonar/alm/client/gitlab/ProjectList.java | 53 ++++ .../sonar/alm/client/gitlab/package-info.java | 23 ++ .../client/ConstantTimeoutConfiguration.java | 38 +++ .../client/TimeoutConfigurationImplTest.java | 87 ++++++ .../client/gitlab/GitlabHttpClientTest.java | 270 +++++++++++++++++ .../ws/AlmIntegrationsWSModule.java | 5 +- .../almintegration/ws/ImportHelper.java | 76 +++++ .../ws/gitlab/ImportGitLabProjectAction.java | 149 +++++++++ .../ws/gitlab/SearchGitlabReposAction.java | 197 ++++++++++++ .../ws/gitlab/package-info.java | 23 ++ .../almintegration/ws/package-info.java | 23 ++ .../ws/gitlab/search_gitlab_repos.json | 33 ++ .../ws/AlmIntegrationsWSModuleTest.java | 36 +++ .../ws/AlmIntegrationsWsTest.java | 61 ++++ .../gitlab/ImportGitLabProjectActionTest.java | 148 +++++++++ .../gitlab/SearchGitlabReposActionTest.java | 282 ++++++++++++++++++ .../platformlevel/PlatformLevel4.java | 6 + 23 files changed, 1977 insertions(+), 2 deletions(-) create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfiguration.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfigurationImpl.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonError.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/Project.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/ProjectList.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/package-info.java create mode 100644 server/sonar-alm-client/src/test/java/org/sonar/alm/client/ConstantTimeoutConfiguration.java create mode 100644 server/sonar-alm-client/src/test/java/org/sonar/alm/client/TimeoutConfigurationImplTest.java create mode 100644 server/sonar-alm-client/src/test/java/org/sonar/alm/client/gitlab/GitlabHttpClientTest.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/ImportHelper.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/ImportGitLabProjectAction.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/SearchGitlabReposAction.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/package-info.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/package-info.java create mode 100644 server/sonar-webserver-webapi/src/main/resources/org/sonar/server/almintegration/ws/gitlab/search_gitlab_repos.json create mode 100644 server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModuleTest.java create mode 100644 server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/AlmIntegrationsWsTest.java create mode 100644 server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/gitlab/ImportGitLabProjectActionTest.java create mode 100644 server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/gitlab/SearchGitlabReposActionTest.java diff --git a/server/sonar-alm-client/build.gradle b/server/sonar-alm-client/build.gradle index 7c9be696227..cdcae780e98 100644 --- a/server/sonar-alm-client/build.gradle +++ b/server/sonar-alm-client/build.gradle @@ -1,5 +1,18 @@ description = 'SonarQube :: ALM integrations :: Clients' dependencies { - testCompile group: 'junit', name: 'junit' + compile project(path: ':sonar-plugin-api', configuration: 'shadow') + compile project(':sonar-ws') + compile 'com.google.code.gson:gson' + compile 'com.google.guava:guava' + compile 'com.squareup.okhttp3:okhttp' + + testCompile project(':sonar-plugin-api-impl') + testCompile 'junit:junit' + testCompile 'com.tngtech.java:junit-dataprovider' + testCompile 'org.assertj:assertj-core' + testCompile 'org.assertj:assertj-guava' + testCompile 'org.mockito:mockito-core' + testCompile 'com.squareup.okhttp3:mockwebserver' + } diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfiguration.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfiguration.java new file mode 100644 index 00000000000..f6ea6e1ca32 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfiguration.java @@ -0,0 +1,40 @@ +/* + * 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; + +import org.sonar.api.ce.ComputeEngineSide; +import org.sonar.api.server.ServerSide; + +/** + * Holds the configuration of timeouts when connecting to ALMs. + */ +@ServerSide +@ComputeEngineSide +public interface TimeoutConfiguration { + /** + * @return connect timeout in milliseconds + */ + long getConnectTimeout(); + + /** + * @return read timeout in milliseconds + */ + long getReadTimeout(); +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfigurationImpl.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfigurationImpl.java new file mode 100644 index 00000000000..3e73abd5a8b --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/TimeoutConfigurationImpl.java @@ -0,0 +1,64 @@ +/* + * 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; + +import java.util.OptionalLong; +import org.sonar.api.config.Configuration; +import org.sonar.api.utils.log.Loggers; + +/** + * Implementation of {@link TimeoutConfiguration} reading values from configuration properties. + */ +public class TimeoutConfigurationImpl implements TimeoutConfiguration { + private static final String CONNECT_TIMEOUT_PROPERTY = "sonar.alm.timeout.connect"; + private static final String READ_TIMEOUT_PROPERTY = "sonar.alm.timeout.read"; + + private static final long DEFAULT_TIMEOUT = 30_000; + private final Configuration configuration; + + public TimeoutConfigurationImpl(Configuration configuration) { + this.configuration = configuration; + } + + @Override + public long getConnectTimeout() { + return safelyParseLongValue(CONNECT_TIMEOUT_PROPERTY).orElse(DEFAULT_TIMEOUT); + } + + private OptionalLong safelyParseLongValue(String property) { + return configuration.get(property) + .map(value -> { + try { + return OptionalLong.of(Long.parseLong(value)); + } catch (NumberFormatException e) { + Loggers.get(TimeoutConfigurationImpl.class) + .warn("Value of property {} can not be parsed to a long: {}", property, value); + return OptionalLong.empty(); + } + }) + .orElse(OptionalLong.empty()); + + } + + @Override + public long getReadTimeout() { + return safelyParseLongValue(READ_TIMEOUT_PROPERTY).orElse(DEFAULT_TIMEOUT); + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java new file mode 100644 index 00000000000..e0d453c56e2 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java @@ -0,0 +1,183 @@ +/* + * 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.gitlab; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSyntaxException; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.List; +import java.util.Optional; +import javax.annotation.Nullable; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.sonar.alm.client.TimeoutConfiguration; +import org.sonar.api.server.ServerSide; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; +import org.sonarqube.ws.client.OkHttpClientBuilder; + +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; +import static java.nio.charset.StandardCharsets.UTF_8; + +@ServerSide +public class GitlabHttpClient { + + private static final Logger LOG = Loggers.get(GitlabHttpClient.class); + protected static final String PRIVATE_TOKEN = "Private-Token"; + protected final OkHttpClient client; + + public GitlabHttpClient(TimeoutConfiguration timeoutConfiguration) { + client = new OkHttpClientBuilder() + .setConnectTimeoutMs(timeoutConfiguration.getConnectTimeout()) + .setReadTimeoutMs(timeoutConfiguration.getReadTimeout()) + .build(); + } + + private static String urlEncode(String value) { + try { + return URLEncoder.encode(value, UTF_8.toString()); + } catch (UnsupportedEncodingException ex) { + throw new IllegalStateException(ex.getCause()); + } + } + + protected static void checkResponseIsSuccessful(Response response) throws IOException { + checkResponseIsSuccessful(response, "GitLab Merge Request did not happen, please check your configuration"); + } + + protected static void checkResponseIsSuccessful(Response response, String errorMessage) throws IOException { + if (!response.isSuccessful()) { + String body = response.body().string(); + LOG.error(String.format("Gitlab API call to [%s] failed with %s http code. gitlab response content : [%s]", response.request().url().toString(), response.code(), body)); + if (isTokenRevoked(response, body)) { + throw new IllegalArgumentException("Your GitLab token was revoked"); + } else if (isTokenExpired(response, body)) { + throw new IllegalArgumentException("Your GitLab token is expired"); + } else if (isInsufficientScope(response, body)) { + throw new IllegalArgumentException("Your GitLab token has insufficient scope"); + } else if (response.code() == HTTP_UNAUTHORIZED) { + throw new IllegalArgumentException("Invalid personal access token"); + } else { + throw new IllegalArgumentException(errorMessage); + } + } + } + + private static boolean isTokenRevoked(Response response, String body) { + if (response.code() == HTTP_UNAUTHORIZED) { + try { + Optional gitlabError = GsonError.parseOne(body); + return gitlabError.map(GsonError::getErrorDescription).map(description -> description.contains("Token was revoked")).orElse(false); + } catch (JsonParseException e) { + // nothing to do + } + } + return false; + } + + private static boolean isTokenExpired(Response response, String body) { + if (response.code() == HTTP_UNAUTHORIZED) { + try { + Optional gitlabError = GsonError.parseOne(body); + return gitlabError.map(GsonError::getErrorDescription).map(description -> description.contains("Token is expired")).orElse(false); + } catch (JsonParseException e) { + // nothing to do + } + } + return false; + } + + private static boolean isInsufficientScope(Response response, String body) { + if (response.code() == HTTP_FORBIDDEN) { + try { + Optional gitlabError = GsonError.parseOne(body); + return gitlabError.map(GsonError::getError).map("insufficient_scope"::equals).orElse(false); + } catch (JsonParseException e) { + // nothing to do + } + } + return false; + } + + public Project getProject(String gitlabUrl, String pat, Long gitlabProjectId) { + String url = String.format("%s/projects/%s", gitlabUrl, gitlabProjectId); + LOG.debug(String.format("get project : [%s]", url)); + Request request = new Request.Builder() + .addHeader(PRIVATE_TOKEN, pat) + .get() + .url(url) + .build(); + + try (Response response = client.newCall(request).execute()) { + checkResponseIsSuccessful(response); + String body = response.body().string(); + LOG.trace(String.format("loading project payload result : [%s]", body)); + return new GsonBuilder().create().fromJson(body, Project.class); + } catch (JsonSyntaxException e) { + throw new IllegalArgumentException("Could not parse GitLab answer to retrieve a project. Got a non-json payload as result."); + } catch (IOException e) { + throw new IllegalStateException(e.getMessage(), e); + } + } + + public ProjectList searchProjects(String gitlabUrl, String personalAccessToken, @Nullable String projectName, + int pageNumber, int pageSize) { + String url = String.format("%s/projects?archived=false&simple=true&membership=true&order_by=name&sort=asc&search=%s&page=%d&per_page=%d", + gitlabUrl, projectName == null ? "" : urlEncode(projectName), pageNumber, pageSize); + + LOG.debug(String.format("get projects : [%s]", url)); + Request request = new Request.Builder() + .addHeader(PRIVATE_TOKEN, personalAccessToken) + .url(url) + .get() + .build(); + + try (Response response = client.newCall(request).execute()) { + checkResponseIsSuccessful(response, "Could not get projects from GitLab instance"); + List projectList = Project.parseJsonArray(response.body().string()); + int returnedPageNumber = parseAndGetIntegerHeader(response.header("X-Page")); + int returnedPageSize = parseAndGetIntegerHeader(response.header("X-Per-Page")); + int totalProjects = parseAndGetIntegerHeader(response.header("X-Total")); + return new ProjectList(projectList, returnedPageNumber, returnedPageSize, totalProjects); + } catch (JsonSyntaxException e) { + throw new IllegalArgumentException("Could not parse GitLab answer to search projects. Got a non-json payload as result."); + } catch (IOException e) { + throw new IllegalStateException(e.getMessage(), e); + } + } + + private static int parseAndGetIntegerHeader(@Nullable String header) { + if (header == null) { + throw new IllegalArgumentException("Pagination data from GitLab response is missing"); + } else { + try { + return Integer.parseInt(header); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Could not parse pagination number", e); + } + } + } + +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonError.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonError.java new file mode 100644 index 00000000000..8f3af6690bb --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonError.java @@ -0,0 +1,62 @@ +/* + * 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.gitlab; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import java.util.Optional; + +public class GsonError { + @SerializedName("error") + private final String error; + + @SerializedName("error_description") + private final String errorDescription; + + @SerializedName("message") + private final String message; + + public GsonError() { + this("", "", ""); + } + + public GsonError(String error, String errorDescription, String message) { + this.error = error; + this.errorDescription = errorDescription; + this.message = message; + } + + public static Optional parseOne(String json) { + Gson gson = new Gson(); + return Optional.ofNullable(gson.fromJson(json, GsonError.class)); + } + + public String getError() { + return error; + } + + public String getErrorDescription() { + return errorDescription; + } + + public String getMessage() { + return message; + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/Project.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/Project.java new file mode 100644 index 00000000000..f20c955c88d --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/Project.java @@ -0,0 +1,105 @@ +/* + * 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.gitlab; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; +import java.util.LinkedList; +import java.util.List; + +public class Project { + // https://docs.gitlab.com/ee/api/projects.html#get-single-project + // https://docs.gitlab.com/ee/api/projects.html#list-all-projects + @SerializedName("id") + private long id; + + @SerializedName("name") + private final String name; + + @SerializedName("name_with_namespace") + private String nameWithNamespace; + + @SerializedName("path") + private String path; + + @SerializedName("path_with_namespace") + private final String pathWithNamespace; + + @SerializedName("web_url") + private String webUrl; + + public Project(String name, String pathWithNamespace) { + this.name = name; + this.pathWithNamespace = pathWithNamespace; + } + + public Project() { + // http://stackoverflow.com/a/18645370/229031 + this(0, "", "", "", "", ""); + } + + public Project(long id, String name, String nameWithNamespace, String path, String pathWithNamespace, + String webUrl) { + this.id = id; + this.name = name; + this.nameWithNamespace = nameWithNamespace; + this.path = path; + this.pathWithNamespace = pathWithNamespace; + this.webUrl = webUrl; + } + + + public static Project parseJson(String json) { + Gson gson = new Gson(); + return gson.fromJson(json, Project.class); + } + + public static List parseJsonArray(String json) { + Gson gson = new Gson(); + return gson.fromJson(json, new TypeToken>() { + }.getType()); + } + + public long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getNameWithNamespace() { + return nameWithNamespace; + } + + public String getPath() { + return path; + } + + public String getPathWithNamespace() { + return pathWithNamespace; + } + + public String getWebUrl() { + return webUrl; + } + +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/ProjectList.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/ProjectList.java new file mode 100644 index 00000000000..6548e669922 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/ProjectList.java @@ -0,0 +1,53 @@ +/* + * 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.gitlab; + +import java.util.List; + +public class ProjectList { + + private final List projects; + private final int pageNumber; + private final int pageSize; + private final int total; + + public ProjectList(List projects, int pageNumber, int pageSize, int total) { + this.projects = projects; + this.pageNumber = pageNumber; + this.pageSize = pageSize; + this.total = total; + } + + public List getProjects() { + return projects; + } + + public int getPageNumber() { + return pageNumber; + } + + public int getPageSize() { + return pageSize; + } + + public int getTotal() { + return total; + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/package-info.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/package-info.java new file mode 100644 index 00000000000..54fa6284913 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.alm.client.gitlab; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/ConstantTimeoutConfiguration.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/ConstantTimeoutConfiguration.java new file mode 100644 index 00000000000..ac261ee5120 --- /dev/null +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/ConstantTimeoutConfiguration.java @@ -0,0 +1,38 @@ +/* + * 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; + +public class ConstantTimeoutConfiguration implements TimeoutConfiguration { + private final long timeout; + + public ConstantTimeoutConfiguration(long timeout) { + this.timeout = timeout; + } + + @Override + public long getConnectTimeout() { + return timeout; + } + + @Override + public long getReadTimeout() { + return timeout; + } +} diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/TimeoutConfigurationImplTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/TimeoutConfigurationImplTest.java new file mode 100644 index 00000000000..85f3f39d018 --- /dev/null +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/TimeoutConfigurationImplTest.java @@ -0,0 +1,87 @@ +/* + * 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; + +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import java.util.Random; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.sonar.api.config.internal.MapSettings; + +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(DataProviderRunner.class) +public class TimeoutConfigurationImplTest { + private final MapSettings settings = new MapSettings(); + private final TimeoutConfigurationImpl underTest = new TimeoutConfigurationImpl(settings.asConfig()); + + @Test + public void getConnectTimeout_returns_10000_when_property_is_not_defined() { + assertThat(underTest.getConnectTimeout()).isEqualTo(30_000L); + } + + @Test + @UseDataProvider("notALongPropertyValues") + public void getConnectTimeout_returns_10000_when_property_is_not_a_long(String notALong) { + settings.setProperty("sonar.alm.timeout.connect", notALong); + + assertThat(underTest.getConnectTimeout()).isEqualTo(30_000L); + } + + @Test + public void getConnectTimeout_returns_value_of_property() { + long expected = new Random().nextInt(9_456_789); + settings.setProperty("sonar.alm.timeout.connect", expected); + + assertThat(underTest.getConnectTimeout()).isEqualTo(expected); + } + + @Test + public void getReadTimeout_returns_10000_when_property_is_not_defined() { + assertThat(underTest.getReadTimeout()).isEqualTo(30_000L); + } + + @Test + @UseDataProvider("notALongPropertyValues") + public void getReadTimeout_returns_10000_when_property_is_not_a_long(String notALong) { + settings.setProperty("sonar.alm.timeout.read", notALong); + + assertThat(underTest.getReadTimeout()).isEqualTo(30_000L); + } + + @Test + public void getReadTimeout_returns_value_of_property() { + long expected = new Random().nextInt(9_456_789); + settings.setProperty("sonar.alm.timeout.read", expected); + + assertThat(underTest.getReadTimeout()).isEqualTo(expected); + } + + @DataProvider + public static Object[][] notALongPropertyValues() { + return new Object[][] { + {"foo"}, + {""}, + {"12.5"} + }; + } +} diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/gitlab/GitlabHttpClientTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/gitlab/GitlabHttpClientTest.java new file mode 100644 index 00000000000..a19ee334320 --- /dev/null +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/gitlab/GitlabHttpClientTest.java @@ -0,0 +1,270 @@ +/* + * 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.gitlab; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.sonar.alm.client.ConstantTimeoutConfiguration; +import org.sonar.alm.client.TimeoutConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; + +public class GitlabHttpClientTest { + + private final MockWebServer server = new MockWebServer(); + private GitlabHttpClient underTest; + private String gitlabUrl; + + @Before + public void prepare() throws IOException { + server.start(); + String urlWithEndingSlash = server.url("").toString(); + gitlabUrl = urlWithEndingSlash.substring(0, urlWithEndingSlash.length() - 1); + + TimeoutConfiguration timeoutConfiguration = new ConstantTimeoutConfiguration(10_000); + underTest = new GitlabHttpClient(timeoutConfiguration); + } + + @After + public void stopServer() throws IOException { + server.shutdown(); + } + + @Test + public void should_throw_IllegalArgumentException_when_token_is_revoked() { + MockResponse response = new MockResponse() + .setResponseCode(401) + .setBody("{\"error\":\"invalid_token\",\"error_description\":\"Token was revoked. You have to re-authorize from the user.\"}"); + server.enqueue(response); + + String gitlabUrl = this.gitlabUrl; + assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Your GitLab token was revoked"); + } + + @Test + public void should_throw_IllegalArgumentException_when_token_insufficient_scope() { + MockResponse response = new MockResponse() + .setResponseCode(403) + .setBody("{\"error\":\"insufficient_scope\"," + + "\"error_description\":\"The request requires higher privileges than provided by the access token.\"," + + "\"scope\":\"api read_api\"}"); + server.enqueue(response); + + String gitlabUrl = this.gitlabUrl; + assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Your GitLab token has insufficient scope"); + } + + @Test + public void should_throw_IllegalArgumentException_when_invalide_json_in_401_response() { + MockResponse response = new MockResponse() + .setResponseCode(401) + .setBody("error in pat"); + server.enqueue(response); + + String gitlabUrl = this.gitlabUrl; + assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid personal access token"); + } + + @Test + public void get_project() { + MockResponse response = new MockResponse() + .setResponseCode(200) + .setBody("{\n" + + " \"id\": 12345,\n" + + " \"name\": \"SonarQube example 1\",\n" + + " \"name_with_namespace\": \"SonarSource / SonarQube / SonarQube example 1\",\n" + + " \"path\": \"sonarqube-example-1\",\n" + + " \"path_with_namespace\": \"sonarsource/sonarqube/sonarqube-example-1\",\n" + + " \"web_url\": \"https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-1\"\n" + + " }"); + server.enqueue(response); + + assertThat(underTest.getProject(gitlabUrl, "pat", 12345L)) + .extracting(Project::getId, Project::getName) + .containsExactly(12345L, "SonarQube example 1"); + } + + @Test + public void get_project_fail_if_non_json_payload() { + MockResponse response = new MockResponse() + .setResponseCode(200) + .setBody("non json payload"); + server.enqueue(response); + + String instanceUrl = gitlabUrl; + assertThatThrownBy(() -> underTest.getProject(instanceUrl, "pat", 12345L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Could not parse GitLab answer to retrieve a project. Got a non-json payload as result."); + } + + @Test + public void search_projects() throws InterruptedException { + MockResponse projects = new MockResponse() + .setResponseCode(200) + .setBody("[\n" + + " {\n" + + " \"id\": 1,\n" + + " \"name\": \"SonarQube example 1\",\n" + + " \"name_with_namespace\": \"SonarSource / SonarQube / SonarQube example 1\",\n" + + " \"path\": \"sonarqube-example-1\",\n" + + " \"path_with_namespace\": \"sonarsource/sonarqube/sonarqube-example-1\",\n" + + " \"web_url\": \"https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-1\"\n" + + " },\n" + + " {\n" + + " \"id\": 2,\n" + + " \"name\": \"SonarQube example 2\",\n" + + " \"name_with_namespace\": \"SonarSource / SonarQube / SonarQube example 2\",\n" + + " \"path\": \"sonarqube-example-2\",\n" + + " \"path_with_namespace\": \"sonarsource/sonarqube/sonarqube-example-2\",\n" + + " \"web_url\": \"https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-2\"\n" + + " },\n" + + " {\n" + + " \"id\": 3,\n" + + " \"name\": \"SonarQube example 3\",\n" + + " \"name_with_namespace\": \"SonarSource / SonarQube / SonarQube example 3\",\n" + + " \"path\": \"sonarqube-example-3\",\n" + + " \"path_with_namespace\": \"sonarsource/sonarqube/sonarqube-example-3\",\n" + + " \"web_url\": \"https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-3\"\n" + + " }\n" + + "]"); + projects.addHeader("X-Page", 1); + projects.addHeader("X-Per-Page", 10); + projects.addHeader("X-Total", 3); + server.enqueue(projects); + + ProjectList projectList = underTest.searchProjects(gitlabUrl, "pat", "example", 1, 10); + + assertThat(projectList.getPageNumber()).isEqualTo(1); + assertThat(projectList.getPageSize()).isEqualTo(10); + assertThat(projectList.getTotal()).isEqualTo(3); + + assertThat(projectList.getProjects()).hasSize(3); + assertThat(projectList.getProjects()).extracting( + Project::getId, Project::getName, Project::getNameWithNamespace, Project::getPath, Project::getPathWithNamespace, Project::getWebUrl).containsExactly( + tuple(1L, "SonarQube example 1", "SonarSource / SonarQube / SonarQube example 1", "sonarqube-example-1", "sonarsource/sonarqube/sonarqube-example-1", + "https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-1"), + tuple(2L, "SonarQube example 2", "SonarSource / SonarQube / SonarQube example 2", "sonarqube-example-2", "sonarsource/sonarqube/sonarqube-example-2", + "https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-2"), + tuple(3L, "SonarQube example 3", "SonarSource / SonarQube / SonarQube example 3", "sonarqube-example-3", "sonarsource/sonarqube/sonarqube-example-3", + "https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-3")); + + RecordedRequest projectGitlabRequest = server.takeRequest(10, TimeUnit.SECONDS); + String gitlabUrlCall = projectGitlabRequest.getRequestUrl().toString(); + assertThat(gitlabUrlCall).isEqualTo(server.url("") + "projects?archived=false&simple=true&membership=true&order_by=name&sort=asc&search=example&page=1&per_page=10"); + assertThat(projectGitlabRequest.getMethod()).isEqualTo("GET"); + } + + @Test + public void search_projects_projectName_param_should_be_encoded() throws InterruptedException { + MockResponse projects = new MockResponse() + .setResponseCode(200) + .setBody("[]"); + projects.addHeader("X-Page", 1); + projects.addHeader("X-Per-Page", 10); + projects.addHeader("X-Total", 0); + server.enqueue(projects); + + ProjectList projectList = underTest.searchProjects(gitlabUrl, "pat", "&page=", 1, 10); + + RecordedRequest projectGitlabRequest = server.takeRequest(10, TimeUnit.SECONDS); + String gitlabUrlCall = projectGitlabRequest.getRequestUrl().toString(); + assertThat(projectList.getProjects()).isEmpty(); + assertThat(gitlabUrlCall).isEqualTo( + server.url("") + + "projects?archived=false&simple=true&membership=true&order_by=name&sort=asc&search=%26page%3D%3Cscript%3Ealert%28%27nasty%27%29%3C%2Fscript%3E&page=1&per_page=10"); + assertThat(projectGitlabRequest.getMethod()).isEqualTo("GET"); + } + + @Test + public void search_projects_projectName_param_null_should_pass_empty_string() throws InterruptedException { + MockResponse projects = new MockResponse() + .setResponseCode(200) + .setBody("[]"); + projects.addHeader("X-Page", 1); + projects.addHeader("X-Per-Page", 10); + projects.addHeader("X-Total", 0); + server.enqueue(projects); + + ProjectList projectList = underTest.searchProjects(gitlabUrl, "pat", null, 1, 10); + + RecordedRequest projectGitlabRequest = server.takeRequest(10, TimeUnit.SECONDS); + String gitlabUrlCall = projectGitlabRequest.getRequestUrl().toString(); + assertThat(projectList.getProjects()).isEmpty(); + assertThat(gitlabUrlCall).isEqualTo( + server.url("") + "projects?archived=false&simple=true&membership=true&order_by=name&sort=asc&search=&page=1&per_page=10"); + assertThat(projectGitlabRequest.getMethod()).isEqualTo("GET"); + } + + @Test + public void search_projects_fail_if_could_not_parse_pagination_number() { + MockResponse projects = new MockResponse() + .setResponseCode(200) + .setBody("[ ]"); + projects.addHeader("X-Page", "bad-page-number"); + projects.addHeader("X-Per-Page", "bad-per-page-number"); + projects.addHeader("X-Total", "bad-total-number"); + server.enqueue(projects); + + String gitlabInstanceUrl = gitlabUrl; + assertThatThrownBy(() -> underTest.searchProjects(gitlabInstanceUrl, "pat", "example", 1, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Could not parse pagination number"); + } + + @Test + public void search_projects_fail_if_pagination_data_not_returned() { + MockResponse projects = new MockResponse() + .setResponseCode(200) + .setBody("[ ]"); + server.enqueue(projects); + + String gitlabInstanceUrl = gitlabUrl; + assertThatThrownBy(() -> underTest.searchProjects(gitlabInstanceUrl, "pat", "example", 1, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Pagination data from GitLab response is missing"); + } + + @Test + public void throws_ISE_when_get_projects_not_http_200() { + MockResponse projects = new MockResponse() + .setResponseCode(500) + .setBody("test"); + server.enqueue(projects); + + String gitlabUrl = this.gitlabUrl; + assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Could not get projects from GitLab instance"); + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java index 8f15ca75fcf..5bcfb2009c4 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java @@ -20,12 +20,15 @@ package org.sonar.server.almintegration.ws; import org.sonar.core.platform.Module; +import org.sonar.server.almintegration.ws.gitlab.ImportGitLabProjectAction; +import org.sonar.server.almintegration.ws.gitlab.SearchGitlabReposAction; public class AlmIntegrationsWSModule extends Module { @Override protected void configureModule() { - // TODO:: move alm_integrations actions here add( + ImportGitLabProjectAction.class, + SearchGitlabReposAction.class, AlmIntegrationsWs.class); } } diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/ImportHelper.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/ImportHelper.java new file mode 100644 index 00000000000..7f6fb0c1bbb --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/ImportHelper.java @@ -0,0 +1,76 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.almintegration.ws; + +import org.sonar.api.server.ServerSide; +import org.sonar.api.server.ws.Request; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.component.ComponentDto; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.project.Visibility; +import org.sonar.server.user.UserSession; +import org.sonarqube.ws.Projects.CreateWsResponse.Project; + +import static java.util.Objects.requireNonNull; +import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS; +import static org.sonarqube.ws.Projects.CreateWsResponse; +import static org.sonarqube.ws.Projects.CreateWsResponse.newBuilder; + +@ServerSide +public class ImportHelper { + public static final String PARAM_ALM_SETTING = "almSetting"; + + private final DbClient dbClient; + private final UserSession userSession; + + public ImportHelper(DbClient dbClient, UserSession userSession) { + this.dbClient = dbClient; + this.userSession = userSession; + } + + public void checkProvisionProjectPermission() { + userSession.checkLoggedIn().checkPermission(PROVISION_PROJECTS); + } + + public AlmSettingDto getAlmSetting(Request request) { + String almSettingKey = request.mandatoryParam(PARAM_ALM_SETTING); + try (DbSession dbSession = dbClient.openSession(false)) { + return dbClient.almSettingDao().selectByKey(dbSession, almSettingKey) + .orElseThrow(() -> new NotFoundException(String.format("ALM Setting '%s' not found", almSettingKey))); + } + } + + public String getUserUuid() { + return requireNonNull(userSession.getUuid(), "User UUID cannot be null"); + } + + public static CreateWsResponse toCreateResponse(ComponentDto componentDto) { + return newBuilder() + .setProject(Project.newBuilder() + .setKey(componentDto.getDbKey()) + .setName(componentDto.name()) + .setQualifier(componentDto.qualifier()) + .setVisibility(Visibility.getLabel(componentDto.isPrivate()))) + .build(); + } + +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/ImportGitLabProjectAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/ImportGitLabProjectAction.java new file mode 100644 index 00000000000..b116ab2e222 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/ImportGitLabProjectAction.java @@ -0,0 +1,149 @@ +/* + * 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.gitlab; + +import com.google.common.annotations.VisibleForTesting; +import java.util.Optional; +import org.sonar.alm.client.gitlab.GitlabHttpClient; +import org.sonar.alm.client.gitlab.Project; +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService; +import org.sonar.core.util.UuidFactory; +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.project.ProjectDefaultVisibility; +import org.sonar.server.user.UserSession; +import org.sonarqube.ws.Projects.CreateWsResponse; + +import static java.util.Objects.requireNonNull; +import static org.sonar.api.resources.Qualifiers.PROJECT; +import static org.sonar.server.component.NewComponent.newComponentBuilder; +import static org.sonar.server.ws.WsUtils.writeProtobuf; + +public class ImportGitLabProjectAction implements AlmIntegrationsWsAction { + + public static final String PARAM_GITLAB_PROJECT_ID = "gitlabProjectId"; + + private final DbClient dbClient; + private final UserSession userSession; + private final ProjectDefaultVisibility projectDefaultVisibility; + private final GitlabHttpClient gitlabHttpClient; + private final ComponentUpdater componentUpdater; + private final UuidFactory uuidFactory; + private final ImportHelper importHelper; + + public ImportGitLabProjectAction(DbClient dbClient, UserSession userSession, + ProjectDefaultVisibility projectDefaultVisibility, GitlabHttpClient gitlabHttpClient, + ComponentUpdater componentUpdater, UuidFactory uuidFactory, ImportHelper importHelper) { + this.dbClient = dbClient; + this.userSession = userSession; + this.projectDefaultVisibility = projectDefaultVisibility; + this.gitlabHttpClient = gitlabHttpClient; + this.componentUpdater = componentUpdater; + this.uuidFactory = uuidFactory; + this.importHelper = importHelper; + } + + @Override + public void define(WebService.NewController context) { + WebService.NewAction action = context.createAction("import_gitlab_project") + .setDescription("Import a GitLab project to SonarQube, creating a new project and configuring MR decoration
" + + "Requires the 'Create Projects' permission") + .setPost(true) + .setSince("8.5") + .setHandler(this); + + action.createParam(ImportHelper.PARAM_ALM_SETTING) + .setRequired(true) + .setDescription("ALM setting key"); + action.createParam(PARAM_GITLAB_PROJECT_ID) + .setRequired(true) + .setDescription("GitLab project ID"); + } + + @Override + public void handle(Request request, Response response) throws Exception { + CreateWsResponse createResponse = doHandle(request); + writeProtobuf(createResponse, request, response); + } + + private CreateWsResponse doHandle(Request request) { + importHelper.checkProvisionProjectPermission(); + AlmSettingDto almSettingDto = importHelper.getAlmSetting(request); + String userUuid = importHelper.getUserUuid(); + try (DbSession dbSession = dbClient.openSession(false)) { + Optional almPatDto = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto); + String pat = almPatDto.map(AlmPatDto::getPersonalAccessToken) + .orElseThrow(() -> new IllegalArgumentException(String.format("personal access token for '%s' is missing", almSettingDto.getKey()))); + + long gitlabProjectId = request.mandatoryParamAsLong(PARAM_GITLAB_PROJECT_ID); + + String url = requireNonNull(almSettingDto.getUrl(), "ALM url cannot be null"); + Project gitlabProject = gitlabHttpClient.getProject(url, pat, gitlabProjectId); + + ComponentDto componentDto = createProject(dbSession, gitlabProject); + populateMRSetting(dbSession, gitlabProjectId, componentDto, almSettingDto); + + return ImportHelper.toCreateResponse(componentDto); + } + } + + private void populateMRSetting(DbSession dbSession, Long gitlabProjectId, ComponentDto componentDto, AlmSettingDto almSettingDto) { + dbClient.projectAlmSettingDao().insertOrUpdate(dbSession, new ProjectAlmSettingDto() + .setProjectUuid(componentDto.projectUuid()) + .setAlmSettingUuid(almSettingDto.getUuid()) + .setAlmRepo(gitlabProjectId.toString()) + .setAlmSlug(null) + .setMonorepo(false)); + dbSession.commit(); + } + + private ComponentDto createProject(DbSession dbSession, Project gitlabProject) { + boolean visibility = projectDefaultVisibility.get(dbSession).isPrivate(); + String sqProjectKey = generateProjectKey(gitlabProject.getPathWithNamespace(), uuidFactory.create()); + + return componentUpdater.create(dbSession, newComponentBuilder() + .setKey(sqProjectKey) + .setName(gitlabProject.getName()) + .setPrivate(visibility) + .setQualifier(PROJECT) + .build(), + userSession.getUuid()); + } + + @VisibleForTesting + String generateProjectKey(String pathWithNamespace, String uuid) { + String sqProjectKey = pathWithNamespace + "_" + uuid; + + if (sqProjectKey.length() > 250) { + sqProjectKey = sqProjectKey.substring(sqProjectKey.length() - 250); + } + + return sqProjectKey.replace("/", "_"); + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/SearchGitlabReposAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/SearchGitlabReposAction.java new file mode 100644 index 00000000000..b3214bbd458 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/SearchGitlabReposAction.java @@ -0,0 +1,197 @@ +/* + * 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.gitlab; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.sonar.alm.client.gitlab.GitlabHttpClient; +import org.sonar.alm.client.gitlab.Project; +import org.sonar.alm.client.gitlab.ProjectList; +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.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.AlmIntegrations.GitlabRepository; +import org.sonarqube.ws.Common.Paging; + +import static java.util.Objects.requireNonNull; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toSet; +import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS; +import static org.sonar.server.ws.WsUtils.writeProtobuf; + +public class SearchGitlabReposAction implements AlmIntegrationsWsAction { + + private static final String PARAM_ALM_SETTING = "almSetting"; + private static final String PARAM_PROJECT_NAME = "projectName"; + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 500; + + private final DbClient dbClient; + private final UserSession userSession; + private final GitlabHttpClient gitlabHttpClient; + + public SearchGitlabReposAction(DbClient dbClient, UserSession userSession, GitlabHttpClient gitlabHttpClient) { + this.dbClient = dbClient; + this.userSession = userSession; + this.gitlabHttpClient = gitlabHttpClient; + } + + @Override + public void define(WebService.NewController context) { + WebService.NewAction action = context.createAction("search_gitlab_repos") + .setDescription("Search the GitLab projects.
" + + "Requires the 'Create Projects' permission") + .setPost(false) + .setSince("8.5") + .setHandler(this); + + action.createParam(PARAM_ALM_SETTING) + .setRequired(true) + .setMaximumLength(200) + .setDescription("ALM setting key"); + action.createParam(PARAM_PROJECT_NAME) + .setRequired(false) + .setMaximumLength(200) + .setDescription("Project name filter"); + + action.addPagingParams(DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE); + + action.setResponseExample(getClass().getResource("search_gitlab_repos.json")); + } + + @Override + public void handle(Request request, Response response) { + AlmIntegrations.SearchGitlabReposWsResponse wsResponse = doHandle(request); + writeProtobuf(wsResponse, request, response); + } + + private AlmIntegrations.SearchGitlabReposWsResponse doHandle(Request request) { + String almSettingKey = request.mandatoryParam(PARAM_ALM_SETTING); + String projectName = request.param(PARAM_PROJECT_NAME); + + int pageNumber = request.mandatoryParamAsInt("p"); + int pageSize = request.mandatoryParamAsInt("ps"); + + try (DbSession dbSession = dbClient.openSession(false)) { + userSession.checkLoggedIn().checkPermission(PROVISION_PROJECTS); + + String userUuid = requireNonNull(userSession.getUuid(), "User UUID cannot be null"); + AlmSettingDto almSettingDto = dbClient.almSettingDao().selectByKey(dbSession, almSettingKey) + .orElseThrow(() -> new NotFoundException(String.format("ALM Setting '%s' not found", almSettingKey))); + Optional almPatDto = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto); + + String personalAccessToken = almPatDto.map(AlmPatDto::getPersonalAccessToken).orElseThrow(() -> new IllegalArgumentException("No personal access token found")); + String gitlabUrl = requireNonNull(almSettingDto.getUrl(), "ALM url cannot be null"); + + ProjectList gitlabProjectList = gitlabHttpClient + .searchProjects(gitlabUrl, personalAccessToken, projectName, pageNumber, pageSize); + + Map sqProjectsKeyByGitlabProjectId = getSqProjectsKeyByGitlabProjectId(dbSession, almSettingDto, gitlabProjectList); + + List gitlabRepositories = gitlabProjectList.getProjects().stream() + .map(project -> toGitlabRepository(project, sqProjectsKeyByGitlabProjectId)) + .collect(toList()); + + return AlmIntegrations.SearchGitlabReposWsResponse.newBuilder() + .addAllRepositories(gitlabRepositories) + .setPaging(Paging.newBuilder() + .setPageIndex(gitlabProjectList.getPageNumber()) + .setPageSize(gitlabProjectList.getPageSize()) + .setTotal(gitlabProjectList.getTotal()) + .build()) + .build(); + } + } + + private Map getSqProjectsKeyByGitlabProjectId(DbSession dbSession, AlmSettingDto almSettingDto, + ProjectList gitlabProjectList) { + Set gitlabProjectIds = gitlabProjectList.getProjects().stream().map(Project::getId).map(String::valueOf) + .collect(toSet()); + Map projectAlmSettingDtos = dbClient.projectAlmSettingDao() + .selectByAlmSettingAndRepos(dbSession, almSettingDto, gitlabProjectIds) + .stream().collect(Collectors.toMap(ProjectAlmSettingDto::getProjectUuid, Function.identity())); + + return dbClient.projectDao().selectByUuids(dbSession, projectAlmSettingDtos.keySet()) + .stream() + .collect(Collectors.toMap(projectDto -> projectAlmSettingDtos.get(projectDto.getUuid()).getAlmRepo(), + p -> new ProjectKeyName(p.getKey(), p.getName()), resolveNameCollisionOperatorByNaturalOrder())); + } + + private static BinaryOperator resolveNameCollisionOperatorByNaturalOrder() { + return (a, b) -> b.key.compareTo(a.key) > 0 ? a : b; + } + + private static GitlabRepository toGitlabRepository(Project project, Map sqProjectsKeyByGitlabProjectId) { + String name = project.getName(); + String pathName = removeLastOccurrenceOfString(project.getNameWithNamespace(), " / " + name); + + String slug = project.getPath(); + String pathSlug = removeLastOccurrenceOfString(project.getPathWithNamespace(), "/" + slug); + + GitlabRepository.Builder builder = GitlabRepository.newBuilder() + .setId(project.getId()) + .setName(name) + .setPathName(pathName) + .setSlug(slug) + .setPathSlug(pathSlug) + .setUrl(project.getWebUrl()); + + String projectIdAsString = String.valueOf(project.getId()); + Optional.ofNullable(sqProjectsKeyByGitlabProjectId.get(projectIdAsString)) + .ifPresent(p -> builder + .setSqProjectKey(p.key) + .setSqProjectName(p.name)); + + return builder.build(); + } + + private static String removeLastOccurrenceOfString(String string, String stringToRemove) { + StringBuilder resultString = new StringBuilder(string); + int index = resultString.lastIndexOf(stringToRemove); + if (index > -1) { + resultString.delete(index, string.length() + index); + } + return resultString.toString(); + } + + static class ProjectKeyName { + String key; + String name; + + ProjectKeyName(String key, String name) { + this.key = key; + this.name = name; + } + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/package-info.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/package-info.java new file mode 100644 index 00000000000..82dc3b7e8a4 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.almintegration.ws.gitlab; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/package-info.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/package-info.java new file mode 100644 index 00000000000..d2901036b39 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.almintegration.ws; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/almintegration/ws/gitlab/search_gitlab_repos.json b/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/almintegration/ws/gitlab/search_gitlab_repos.json new file mode 100644 index 00000000000..a1247e0ac99 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/almintegration/ws/gitlab/search_gitlab_repos.json @@ -0,0 +1,33 @@ +{ + "paging": { + "pageIndex": 1, + "pageSize": 3, + "total": 10 + }, + "repositories": [ + { + "id": 1, + "name": "Gitlab repo name 1", + "pathName": "Group", + "slug": "gitlab-repo-name-1", + "pathSlug": "group", + "url": "https://example.gitlab.com/group/gitlab-repo-name-1" + }, + { + "id": 2, + "name": "Gitlab repo name 2", + "pathName": "Group", + "slug": "gitlab-repo-name-2", + "pathSlug": "group", + "url": "https://example.gitlab.com/group/gitlab-repo-name-2" + }, + { + "id": 3, + "name": "Gitlab repo name 3", + "pathName": "Group", + "slug": "gitlab-repo-name-3", + "pathSlug": "group", + "url": "https://example.gitlab.com/group/gitlab-repo-name-3" + } + ] +} diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModuleTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModuleTest.java new file mode 100644 index 00000000000..555d0b1345f --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModuleTest.java @@ -0,0 +1,36 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.almintegration.ws; + +import org.junit.Test; +import org.sonar.core.platform.ComponentContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AlmIntegrationsWSModuleTest { + + @Test + public void verify_count_of_added_components() { + ComponentContainer container = new ComponentContainer(); + new AlmIntegrationsWSModule().configure(container); + assertThat(container.size()).isPositive(); + } + +} diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/AlmIntegrationsWsTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/AlmIntegrationsWsTest.java new file mode 100644 index 00000000000..382717c52c6 --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/AlmIntegrationsWsTest.java @@ -0,0 +1,61 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.almintegration.ws; + +import java.util.Collections; +import org.junit.Test; +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AlmIntegrationsWsTest { + private final AlmIntegrationsWs underTest = new AlmIntegrationsWs(Collections.singletonList(new AlmIntegrationsWsAction() { + @Override + public void handle(Request request, Response response) { + // nothing to do + } + + @Override + public void define(WebService.NewController controller) { + controller.createAction("foo") + .setHandler((request, response) -> { + throw new UnsupportedOperationException("not implemented"); + }); + } + })); + + @Test + public void define_ws() { + WebService.Context context = new WebService.Context(); + + underTest.define(context); + + WebService.Controller controller = context.controller("api/alm_integrations"); + assertThat(controller).isNotNull(); + assertThat(controller.description()).isNotEmpty(); + assertThat(controller.actions()).hasSize(1); + + WebService.Action fooAction = controller.action("foo"); + assertThat(fooAction).isNotNull(); + assertThat(fooAction.handler()).isNotNull(); + } +} diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/gitlab/ImportGitLabProjectActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/gitlab/ImportGitLabProjectActionTest.java new file mode 100644 index 00000000000..55c0d0e6ea6 --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/gitlab/ImportGitLabProjectActionTest.java @@ -0,0 +1,148 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.almintegration.ws.gitlab; + +import java.util.Optional; +import java.util.stream.IntStream; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.alm.client.gitlab.GitlabHttpClient; +import org.sonar.alm.client.gitlab.Project; +import org.sonar.api.utils.System2; +import org.sonar.core.i18n.I18n; +import org.sonar.core.util.SequenceUuidFactory; +import org.sonar.core.util.UuidFactory; +import org.sonar.db.DbTester; +import org.sonar.db.alm.setting.AlmSettingDto; +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.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.WsActionTester; +import org.sonarqube.ws.Projects; + +import static java.util.stream.Collectors.joining; +import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS; +import static org.sonar.server.tester.UserSessionRule.standalone; + +public class ImportGitLabProjectActionTest { + + private final System2 system2 = mock(System2.class); + + @Rule + public UserSessionRule userSession = standalone(); + + @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 GitlabHttpClient gitlabHttpClient = mock(GitlabHttpClient.class); + private final UuidFactory uuidFactory = mock(UuidFactory.class); + private final ImportHelper importHelper = new ImportHelper(db.getDbClient(), userSession); + private final ProjectDefaultVisibility projectDefaultVisibility = mock(ProjectDefaultVisibility.class); + private final ImportGitLabProjectAction importGitLabProjectAction = new ImportGitLabProjectAction( + db.getDbClient(), userSession, projectDefaultVisibility, gitlabHttpClient, componentUpdater, uuidFactory, importHelper); + private final WsActionTester ws = new WsActionTester(importGitLabProjectAction); + + @Before + public void before() { + when(projectDefaultVisibility.get(any())).thenReturn(Visibility.PRIVATE); + } + + @Test + public void import_project() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(PROVISION_PROJECTS); + AlmSettingDto almSetting = db.almSettings().insertGitlabAlmSetting(); + db.almPats().insert(dto -> { + dto.setAlmSettingUuid(almSetting.getUuid()); + dto.setUserUuid(user.getUuid()); + dto.setPersonalAccessToken("PAT"); + }); + Project project = getGitlabProject(); + when(gitlabHttpClient.getProject(any(), any(), any())).thenReturn(project); + when(uuidFactory.create()).thenReturn("uuid"); + + Projects.CreateWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .setParam("gitlabProjectId", "12345") + .executeProtobuf(Projects.CreateWsResponse.class); + + verify(gitlabHttpClient).getProject(almSetting.getUrl(), "PAT", 12345L); + + Projects.CreateWsResponse.Project result = response.getProject(); + assertThat(result.getKey()).isEqualTo(project.getPathWithNamespace() + "_uuid"); + assertThat(result.getName()).isEqualTo(project.getName()); + + Optional 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 generate_project_key_less_than_250() { + String name = "abcdeert"; + assertThat(importGitLabProjectAction.generateProjectKey(name, "uuid")).isEqualTo("abcdeert_uuid"); + } + + @Test + public void generate_project_key_equal_250() { + String name = IntStream.range(0, 245).mapToObj(i -> "a").collect(joining()); + String projectKey = importGitLabProjectAction.generateProjectKey(name, "uuid"); + assertThat(projectKey) + .hasSize(250) + .isEqualTo(name + "_uuid"); + + } + + @Test + public void generate_project_key_more_than_250() { + String name = IntStream.range(0, 250).mapToObj(i -> "a").collect(joining()); + String projectKey = importGitLabProjectAction.generateProjectKey(name, "uuid"); + assertThat(projectKey) + .hasSize(250) + .isEqualTo(name.substring(5) + "_uuid"); + } + + @Test + public void generate_project_key_containing_slash() { + String name = "a/b/c"; + assertThat(importGitLabProjectAction.generateProjectKey(name, "uuid")).isEqualTo("a_b_c_uuid"); + } + + private Project getGitlabProject() { + return new Project(randomAlphanumeric(5), randomAlphanumeric(5)); + } +} diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/gitlab/SearchGitlabReposActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/gitlab/SearchGitlabReposActionTest.java new file mode 100644 index 00000000000..d1d728efa65 --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/gitlab/SearchGitlabReposActionTest.java @@ -0,0 +1,282 @@ +/* + * 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.gitlab; + +import java.util.Arrays; +import java.util.LinkedList; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.alm.client.gitlab.GitlabHttpClient; +import org.sonar.alm.client.gitlab.Project; +import org.sonar.alm.client.gitlab.ProjectList; +import org.sonar.api.server.ws.WebService; +import org.sonar.db.DbTester; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.project.ProjectDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.exceptions.ForbiddenException; +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 org.sonarqube.ws.AlmIntegrations.GitlabRepository; +import org.sonarqube.ws.AlmIntegrations.SearchGitlabReposWsResponse; + +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.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.db.alm.integration.pat.AlmPatsTesting.newAlmPatDto; +import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS; + +public class SearchGitlabReposActionTest { + + @Rule + public UserSessionRule userSession = UserSessionRule.standalone(); + @Rule + public DbTester db = DbTester.create(); + + private final GitlabHttpClient gitlabHttpClient = mock(GitlabHttpClient.class); + private final WsActionTester ws = new WsActionTester(new SearchGitlabReposAction(db.getDbClient(), userSession, + gitlabHttpClient)); + + @Test + public void list_gitlab_repos() { + Project gitlabProject1 = new Project(1L, "repoName1", "repoNamePath1", "repo-slug-1", "repo-path-slug-1", "url-1"); + Project gitlabProject2 = new Project(2L, "repoName2", "path1 / repoName2", "repo-slug-2", "path-1/repo-slug-2", "url-2"); + Project gitlabProject3 = new Project(3L, "repoName3", "repoName3 / repoName3", "repo-slug-3", "repo-slug-3/repo-slug-3", "url-3"); + Project gitlabProject4 = new Project(4L, "repoName4", "repoName4 / repoName4 / repoName4", "repo-slug-4", "repo-slug-4/repo-slug-4/repo-slug-4", "url-4"); + when(gitlabHttpClient.searchProjects(any(), any(), any(), anyInt(), anyInt())) + .thenReturn( + new ProjectList(Arrays.asList(gitlabProject1, gitlabProject2, gitlabProject3, gitlabProject4), 1, 10, 4)); + + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(PROVISION_PROJECTS); + AlmSettingDto almSetting = db.almSettings().insertGitlabAlmSetting(); + db.almPats().insert(dto -> { + dto.setAlmSettingUuid(almSetting.getUuid()); + dto.setUserUuid(user.getUuid()); + dto.setPersonalAccessToken("some-pat"); + }); + ProjectDto projectDto = db.components().insertPrivateProjectDto(); + db.almSettings().insertGitlabProjectAlmSetting(almSetting, projectDto); + + AlmIntegrations.SearchGitlabReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .executeProtobuf(SearchGitlabReposWsResponse.class); + + assertThat(response.getRepositoriesCount()).isEqualTo(4); + + assertThat(response.getRepositoriesList()) + .extracting(GitlabRepository::getId, + GitlabRepository::getName, + GitlabRepository::getPathName, + GitlabRepository::getSlug, + GitlabRepository::getPathSlug, + GitlabRepository::getUrl, + GitlabRepository::hasSqProjectKey, + GitlabRepository::hasSqProjectName) + .containsExactlyInAnyOrder( + tuple(1L, "repoName1", "repoNamePath1", "repo-slug-1", "repo-path-slug-1", "url-1", false, false), + tuple(2L, "repoName2", "path1", "repo-slug-2", "path-1", "url-2", false, false), + tuple(3L, "repoName3", "repoName3", "repo-slug-3", "repo-slug-3", "url-3", false, false), + tuple(4L, "repoName4", "repoName4 / repoName4", "repo-slug-4", "repo-slug-4/repo-slug-4", "url-4", false, false)); + } + + @Test + public void list_gitlab_repos_some_projects_already_set_up() { + Project gitlabProject1 = new Project(1L, "repoName1", "repoNamePath1", "repo-slug-1", "repo-path-slug-1", "url-1"); + Project gitlabProject2 = new Project(2L, "repoName2", "path1 / repoName2", "repo-slug-2", "path-1/repo-slug-2", "url-2"); + Project gitlabProject3 = new Project(3L, "repoName3", "repoName3 / repoName3", "repo-slug-3", "repo-slug-3/repo-slug-3", "url-3"); + Project gitlabProject4 = new Project(4L, "repoName4", "repoName4 / repoName4 / repoName4", "repo-slug-4", "repo-slug-4/repo-slug-4/repo-slug-4", "url-4"); + when(gitlabHttpClient.searchProjects(any(), any(), any(), anyInt(), anyInt())) + .thenReturn( + new ProjectList(Arrays.asList(gitlabProject1, gitlabProject2, gitlabProject3, gitlabProject4), 1, 10, 4)); + + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(PROVISION_PROJECTS); + AlmSettingDto almSetting = db.almSettings().insertGitlabAlmSetting(); + db.almPats().insert(dto -> { + dto.setAlmSettingUuid(almSetting.getUuid()); + dto.setUserUuid(user.getUuid()); + dto.setPersonalAccessToken("some-pat"); + }); + ProjectDto projectDto1 = db.components().insertPrivateProjectDto(); + db.almSettings().insertGitlabProjectAlmSetting(almSetting, projectDto1); + + ProjectDto projectDto2 = db.components().insertPrivateProjectDto(); + db.almSettings().insertGitlabProjectAlmSetting(almSetting, projectDto2, projectAlmSettingDto -> projectAlmSettingDto.setAlmRepo("2")); + + ProjectDto projectDto3 = db.components().insertPrivateProjectDto(); + db.almSettings().insertGitlabProjectAlmSetting(almSetting, projectDto3, projectAlmSettingDto -> projectAlmSettingDto.setAlmRepo("3")); + + ProjectDto projectDto4 = db.components().insertPrivateProjectDto(); + db.almSettings().insertGitlabProjectAlmSetting(almSetting, projectDto4, projectAlmSettingDto -> projectAlmSettingDto.setAlmRepo("3")); + + AlmIntegrations.SearchGitlabReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .executeProtobuf(SearchGitlabReposWsResponse.class); + + assertThat(response.getRepositoriesCount()).isEqualTo(4); + + assertThat(response.getRepositoriesList()) + .extracting(GitlabRepository::getId, + GitlabRepository::getName, + GitlabRepository::getPathName, + GitlabRepository::getSlug, + GitlabRepository::getPathSlug, + GitlabRepository::getUrl, + GitlabRepository::getSqProjectKey, + GitlabRepository::getSqProjectName) + .containsExactlyInAnyOrder( + tuple(1L, "repoName1", "repoNamePath1", "repo-slug-1", "repo-path-slug-1", "url-1", "", ""), + tuple(2L, "repoName2", "path1", "repo-slug-2", "path-1", "url-2", projectDto2.getKey(), projectDto2.getName()), + tuple(3L, "repoName3", "repoName3", "repo-slug-3", "repo-slug-3", "url-3", projectDto3.getKey(), projectDto3.getName()), + tuple(4L, "repoName4", "repoName4 / repoName4", "repo-slug-4", "repo-slug-4/repo-slug-4", "url-4", "", "")); + } + + @Test + public void return_empty_list_when_no_gitlab_projects() { + when(gitlabHttpClient.searchProjects(any(), any(), any(), anyInt(), anyInt())).thenReturn(new ProjectList(new LinkedList<>(), 1, 10, 0)); + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(PROVISION_PROJECTS); + AlmSettingDto almSetting = db.almSettings().insertBitbucketAlmSetting(); + db.almPats().insert(dto -> { + dto.setAlmSettingUuid(almSetting.getUuid()); + dto.setUserUuid(user.getUuid()); + }); + ProjectDto projectDto = db.components().insertPrivateProjectDto(); + db.almSettings().insertGitlabProjectAlmSetting(almSetting, projectDto); + + AlmIntegrations.SearchGitlabReposWsResponse response = ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .executeProtobuf(SearchGitlabReposWsResponse.class); + + assertThat(response.getRepositoriesList()).isEmpty(); + } + + @Test + public void check_pat_is_missing() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(PROVISION_PROJECTS); + AlmSettingDto almSetting = db.almSettings().insertGitlabAlmSetting(); + + TestRequest request = ws.newRequest() + .setParam("almSetting", almSetting.getKey()); + + assertThatThrownBy(request::execute) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No personal access token found"); + } + + @Test + public void fail_when_alm_setting_not_found() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(PROVISION_PROJECTS); + AlmPatDto almPatDto = newAlmPatDto(); + db.getDbClient().almPatDao().insert(db.getSession(), almPatDto); + + TestRequest request = ws.newRequest() + .setParam("almSetting", "testKey"); + + assertThatThrownBy(request::execute) + .isInstanceOf(NotFoundException.class) + .hasMessage("ALM Setting 'testKey' not found"); + } + + @Test + public void fail_when_not_logged_in() { + TestRequest request = ws.newRequest() + .setParam("almSetting", "anyvalue"); + + assertThatThrownBy(request::execute) + .isInstanceOf(UnauthorizedException.class) + .hasMessage("Authentication is required"); + } + + @Test + public void fail_when_no_creation_project_permission() { + UserDto user = db.users().insertUser(); + userSession.logIn(user); + + TestRequest request = ws.newRequest() + .setParam("almSetting", "anyvalue"); + + assertThatThrownBy(request::execute) + .isInstanceOf(ForbiddenException.class) + .hasMessage("Insufficient privileges"); + } + + @Test + public void verify_response_example() { + Project gitlabProject1 = new Project(1L, "Gitlab repo name 1", "Group / Gitlab repo name 1", "gitlab-repo-name-1", "group/gitlab-repo-name-1", + "https://example.gitlab.com/group/gitlab-repo-name-1"); + Project gitlabProject2 = new Project(2L, "Gitlab repo name 2", "Group / Gitlab repo name 2", "gitlab-repo-name-2", "group/gitlab-repo-name-2", + "https://example.gitlab.com/group/gitlab-repo-name-2"); + Project gitlabProject3 = new Project(3L, "Gitlab repo name 3", "Group / Gitlab repo name 3", "gitlab-repo-name-3", "group/gitlab-repo-name-3", + "https://example.gitlab.com/group/gitlab-repo-name-3"); + when(gitlabHttpClient.searchProjects(any(), any(), any(), anyInt(), anyInt())) + .thenReturn( + new ProjectList(Arrays.asList(gitlabProject1, gitlabProject2, gitlabProject3), 1, 3, 10)); + + UserDto user = db.users().insertUser(); + userSession.logIn(user).addPermission(PROVISION_PROJECTS); + AlmSettingDto almSetting = db.almSettings().insertGitlabAlmSetting(); + db.almPats().insert(dto -> { + dto.setAlmSettingUuid(almSetting.getUuid()); + dto.setUserUuid(user.getUuid()); + dto.setPersonalAccessToken("some-pat"); + }); + ProjectDto projectDto = db.components().insertPrivateProjectDto(); + db.almSettings().insertGitlabProjectAlmSetting(almSetting, projectDto); + + WebService.Action def = ws.getDef(); + String responseExample = def.responseExampleAsString(); + + assertThat(responseExample).isNotBlank(); + + ws.newRequest() + .setParam("almSetting", almSetting.getKey()) + .execute().assertJson( + responseExample); + } + + @Test + public void definition() { + WebService.Action def = ws.getDef(); + + assertThat(def.since()).isEqualTo("8.5"); + assertThat(def.isPost()).isFalse(); + assertThat(def.params()) + .extracting(WebService.Param::key, WebService.Param::isRequired) + .containsExactlyInAnyOrder( + tuple("almSetting", true), + tuple("p", false), + tuple("ps", false), + tuple("projectName", false)); + } + +} diff --git a/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java b/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java index 962bafff35c..bed805ac5af 100644 --- a/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java +++ b/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java @@ -20,6 +20,8 @@ package org.sonar.server.platform.platformlevel; import java.util.List; +import org.sonar.alm.client.TimeoutConfigurationImpl; +import org.sonar.alm.client.gitlab.GitlabHttpClient; import org.sonar.api.profiles.AnnotationProfileParser; import org.sonar.api.profiles.XMLProfileParser; import org.sonar.api.profiles.XMLProfileSerializer; @@ -40,6 +42,7 @@ import org.sonar.core.extension.CoreExtensionsInstaller; import org.sonar.core.platform.ComponentContainer; import org.sonar.core.platform.PlatformEditionProvider; import org.sonar.server.almintegration.ws.AlmIntegrationsWSModule; +import org.sonar.server.almintegration.ws.ImportHelper; import org.sonar.server.almsettings.MultipleAlmFeatureProvider; import org.sonar.server.authentication.AuthenticationModule; import org.sonar.server.authentication.DefaultAdminCredentialsVerifierNotificationHandler; @@ -486,6 +489,9 @@ public class PlatformLevel4 extends PlatformLevel { PluginsWs.class, // ALM integrations + TimeoutConfigurationImpl.class, + ImportHelper.class, + GitlabHttpClient.class, AlmIntegrationsWSModule.class, // Branch -- 2.39.5