]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-21119 Provide method to get groups for GitLab & refactored GithubPaginatedHttpC...
authorAurelien Poscia <aurelien.poscia@sonarsource.com>
Thu, 30 Nov 2023 08:58:20 +0000 (09:58 +0100)
committersonartech <sonartech@sonarsource.com>
Fri, 22 Dec 2023 20:03:01 +0000 (20:03 +0000)
42 files changed:
server/sonar-alm-client/build.gradle
server/sonar-alm-client/src/main/java/org/sonar/alm/client/ApplicationHttpClient.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/DevopsPlatformHeaders.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/GenericApplicationHttpClient.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/GenericPaginatedHttpClient.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/PaginatedHttpClient.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/RatioBasedRateLimitChecker.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/ApplicationHttpClient.java [deleted file]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/DevopsPlatformHeaders.java [deleted file]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GenericApplicationHttpClient.java [deleted file]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationHttpClient.java
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubHeaders.java
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubPaginatedHttpClient.java
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/PaginatedHttpClient.java [deleted file]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/RatioBasedRateLimitChecker.java [deleted file]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabApplicationClient.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabApplicationHttpClient.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabGlobalSettingsValidator.java
server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHeaders.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java [deleted file]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabPaginatedHttpClient.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabToken.java [new file with mode: 0644]
server/sonar-alm-client/src/test/java/org/sonar/alm/client/GenericPaginatedHttpClientImplTest.java [new file with mode: 0644]
server/sonar-alm-client/src/test/java/org/sonar/alm/client/RatioBasedRateLimitCheckerTest.java [new file with mode: 0644]
server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GenericApplicationHttpClientTest.java
server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java
server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubPaginatedHttpClientImplTest.java [deleted file]
server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/RatioBasedRateLimitCheckerTest.java [deleted file]
server/sonar-alm-client/src/test/java/org/sonar/alm/client/gitlab/GitlabApplicationClientTest.java [new file with mode: 0644]
server/sonar-alm-client/src/test/java/org/sonar/alm/client/gitlab/GitlabGlobalSettingsValidatorTest.java
server/sonar-alm-client/src/test/java/org/sonar/alm/client/gitlab/GitlabHttpClientTest.java [deleted file]
server/sonar-alm-client/src/test/resources/org/sonar/alm/client/gitlab/groups-full-response.json [new file with mode: 0644]
server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GsonGroup.java
server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GsonGroupTest.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/almintegration/ws/CheckPatActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/almintegration/ws/gitlab/ImportGitLabProjectActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/almintegration/ws/gitlab/SearchGitlabReposActionIT.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/CheckPatAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/ImportGitLabProjectAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/gitlab/SearchGitlabReposAction.java
server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java

index 7f77befefcabc3f2490b32a2f8a6a195ec601626..6c180f5605e8770a5ea038bfa70c6e0fba8886f0 100644 (file)
@@ -13,6 +13,7 @@ dependencies {
     api 'org.bouncycastle:bcpkix-jdk18on:1.76'
     api 'org.sonarsource.api.plugin:sonar-plugin-api'
     api project(':server:sonar-auth-github')
+    api project(':server:sonar-auth-gitlab')
 
     testImplementation project(':sonar-plugin-api-impl')
 
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/ApplicationHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/ApplicationHttpClient.java
new file mode 100644 (file)
index 0000000..933af09
--- /dev/null
@@ -0,0 +1,93 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.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 ApplicationHttpClient {
+  /**
+   * 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}.
+   * No log if there is an issue during the call.
+   */
+  GetResponse getSilent(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;
+
+  record RateLimit(int remaining, int limit, long reset) {
+  }
+  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();
+
+    RateLimit getRateLimit();
+  }
+
+  interface GetResponse extends Response {
+    Optional<String> getNextEndPoint();
+  }
+
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/DevopsPlatformHeaders.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/DevopsPlatformHeaders.java
new file mode 100644 (file)
index 0000000..210a562
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.Optional;
+
+public interface DevopsPlatformHeaders {
+  Optional<String> getApiVersionHeader();
+
+  Optional<String> getApiVersion();
+
+  String getRateLimitRemainingHeader();
+
+  String getRateLimitLimitHeader();
+
+  String getRateLimitResetHeader();
+
+  String getAuthorizationHeader();
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/GenericApplicationHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/GenericApplicationHttpClient.java
new file mode 100644 (file)
index 0000000..fc375f9
--- /dev/null
@@ -0,0 +1,297 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Optional;
+import java.util.function.Function;
+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.apache.commons.lang.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.sonar.alm.client.github.security.AccessToken;
+import org.sonarqube.ws.client.OkHttpClientBuilder;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.net.HttpURLConnection.HTTP_ACCEPTED;
+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 abstract class GenericApplicationHttpClient implements ApplicationHttpClient {
+
+  private static final Logger LOG = LoggerFactory.getLogger(GenericApplicationHttpClient.class);
+  private static final Pattern NEXT_LINK_PATTERN = Pattern.compile("<([^<]+)>; rel=\"next\"");
+
+  private final DevopsPlatformHeaders devopsPlatformHeaders;
+  private final OkHttpClient client;
+
+  protected GenericApplicationHttpClient(DevopsPlatformHeaders devopsPlatformHeaders, TimeoutConfiguration timeoutConfiguration) {
+    this.devopsPlatformHeaders = devopsPlatformHeaders;
+    client = new OkHttpClientBuilder()
+      .setConnectTimeoutMs(timeoutConfiguration.getConnectTimeout())
+      .setReadTimeoutMs(timeoutConfiguration.getReadTimeout())
+      .setFollowRedirects(false)
+      .build();
+  }
+
+  @Override
+  public GetResponse get(String appUrl, AccessToken token, String endPoint) throws IOException {
+    return get(appUrl, token, endPoint, true);
+  }
+
+  @Override
+  public GetResponse getSilent(String appUrl, AccessToken token, String endPoint) throws IOException {
+    return get(appUrl, token, endPoint, false);
+  }
+
+  private GetResponse get(String appUrl, AccessToken token, String endPoint, boolean withLog) throws IOException {
+    validateEndPoint(endPoint);
+    try (okhttp3.Response response = client.newCall(newGetRequest(appUrl, token, endPoint)).execute()) {
+      int responseCode = response.code();
+      RateLimit rateLimit = readRateLimit(response);
+      if (responseCode != HTTP_OK) {
+        String content = StringUtils.trimToNull(attemptReadContent(response));
+        if (withLog) {
+          LOG.warn("GET response did not have expected HTTP code (was {}): {}", responseCode, content);
+        }
+        return new GetResponseImpl(responseCode, content, null, rateLimit);
+      }
+      return new GetResponseImpl(responseCode, readContent(response.body()).orElse(null), readNextEndPoint(response), rateLimit);
+    }
+  }
+
+  private static void validateEndPoint(String endPoint) {
+    checkArgument(endPoint.startsWith("/") || endPoint.startsWith("http") || endPoint.isEmpty(),
+      "endpoint must start with '/' or 'http'");
+  }
+
+  private 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();
+      RateLimit rateLimit = readRateLimit(response);
+      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, rateLimit);
+      }
+      return new ResponseImpl(responseCode, null, rateLimit);
+    }
+  }
+
+  private 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();
+      RateLimit rateLimit = readRateLimit(response);
+      if (responseCode == HTTP_OK || responseCode == HTTP_CREATED || responseCode == HTTP_ACCEPTED) {
+        return new ResponseImpl(responseCode, readContent(response.body()).orElse(null), rateLimit);
+      } else if (responseCode == HTTP_NO_CONTENT) {
+        return new ResponseImpl(responseCode, null, rateLimit);
+      }
+      String content = attemptReadContent(response);
+      LOG.warn("POST response did not have expected HTTP code (was {}): {}", responseCode, content);
+      return new ResponseImpl(responseCode, content, rateLimit);
+    }
+  }
+
+  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();
+      RateLimit rateLimit = readRateLimit(response);
+      if (responseCode == HTTP_OK) {
+        return new ResponseImpl(responseCode, readContent(response.body()).orElse(null), rateLimit);
+      } else if (responseCode == HTTP_NO_CONTENT) {
+        return new ResponseImpl(responseCode, null, rateLimit);
+      }
+      String content = attemptReadContent(response);
+      LOG.warn("PATCH response did not have expected HTTP code (was {}): {}", responseCode, content);
+      return new ResponseImpl(responseCode, content, rateLimit);
+    }
+  }
+
+  private Request newPostRequest(String appUrl, @Nullable AccessToken token, String endPoint, RequestBody body) {
+    return newRequestBuilder(appUrl, token, endPoint).post(body).build();
+  }
+
+  private Request newPatchRequest(AccessToken token, String appUrl, String endPoint, RequestBody body) {
+    return newRequestBuilder(appUrl, token, endPoint).patch(body).build();
+  }
+
+  private 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(devopsPlatformHeaders.getAuthorizationHeader(), token.getAuthorizationHeaderPrefix() + " " + token.getValue());
+      devopsPlatformHeaders.getApiVersion().ifPresent(apiVersion ->
+        url.addHeader(devopsPlatformHeaders.getApiVersionHeader().orElseThrow(), apiVersion)
+      );
+    }
+    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 = Optional.ofNullable(response.headers().get("link")).orElse("");
+    Matcher nextLinkMatcher = NEXT_LINK_PATTERN.matcher(links);
+    if (!nextLinkMatcher.find()) {
+      return null;
+    }
+    String nextUrl = nextLinkMatcher.group(1);
+    if (response.request().url().toString().equals(nextUrl)) {
+      return null;
+    }
+    return nextUrl;
+  }
+
+  @CheckForNull
+  private RateLimit readRateLimit(okhttp3.Response response) {
+    Integer remaining = headerValueOrNull(response, devopsPlatformHeaders.getRateLimitRemainingHeader(), Integer::valueOf);
+    Integer limit = headerValueOrNull(response, devopsPlatformHeaders.getRateLimitLimitHeader(), Integer::valueOf);
+    Long reset = headerValueOrNull(response, devopsPlatformHeaders.getRateLimitResetHeader(), Long::valueOf);
+    if (remaining == null || limit == null || reset == null) {
+      return null;
+    }
+    return new RateLimit(remaining, limit, reset);
+  }
+
+  @CheckForNull
+  private static <T> T headerValueOrNull(okhttp3.Response response, String header, Function<String, T> mapper) {
+    return ofNullable(response.headers().get(header)).map(mapper::apply).orElse(null);
+  }
+
+  private static class ResponseImpl implements Response {
+    private final int code;
+    private final String content;
+
+    private final RateLimit rateLimit;
+
+    private ResponseImpl(int code, @Nullable String content, @Nullable RateLimit rateLimit) {
+      this.code = code;
+      this.content = content;
+      this.rateLimit = rateLimit;
+    }
+
+    @Override
+    public int getCode() {
+      return code;
+    }
+
+    @Override
+    public Optional<String> getContent() {
+      return ofNullable(content);
+    }
+
+    @Override
+    @CheckForNull
+    public RateLimit getRateLimit() {
+      return rateLimit;
+    }
+
+  }
+
+  private static final class GetResponseImpl extends ResponseImpl implements GetResponse {
+    private final String nextEndPoint;
+
+    private GetResponseImpl(int code, @Nullable String content, @Nullable String nextEndPoint, @Nullable RateLimit rateLimit) {
+      super(code, content, rateLimit);
+      this.nextEndPoint = nextEndPoint;
+    }
+
+    @Override
+    public Optional<String> getNextEndPoint() {
+      return ofNullable(nextEndPoint);
+    }
+  }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/GenericPaginatedHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/GenericPaginatedHttpClient.java
new file mode 100644 (file)
index 0000000..51e9483
--- /dev/null
@@ -0,0 +1,98 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.ArrayList;
+import java.util.List;
+import java.util.function.Function;
+import javax.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.sonar.alm.client.ApplicationHttpClient.GetResponse;
+import org.sonar.alm.client.github.security.AccessToken;
+
+import static java.lang.String.format;
+
+public abstract class GenericPaginatedHttpClient implements PaginatedHttpClient {
+
+  private static final Logger LOG = LoggerFactory.getLogger(GenericPaginatedHttpClient.class);
+  private final ApplicationHttpClient appHttpClient;
+  private final RatioBasedRateLimitChecker rateLimitChecker;
+
+  protected GenericPaginatedHttpClient(ApplicationHttpClient appHttpClient, RatioBasedRateLimitChecker rateLimitChecker) {
+    this.appHttpClient = appHttpClient;
+    this.rateLimitChecker = rateLimitChecker;
+  }
+
+  @Override
+  public <E> List<E> get(String appUrl, AccessToken token, String query, Function<String, List<E>> responseDeserializer) {
+    List<E> results = new ArrayList<>();
+    String nextEndpoint = query + "?per_page=100";
+    if (query.contains("?")) {
+      nextEndpoint = query + "&per_page=100";
+    }
+    ApplicationHttpClient.RateLimit rateLimit = null;
+    while (nextEndpoint != null) {
+      checkRateLimit(rateLimit);
+      GetResponse response = executeCall(appUrl, token, nextEndpoint);
+      response.getContent()
+        .ifPresent(content -> results.addAll(responseDeserializer.apply(content)));
+      nextEndpoint = response.getNextEndPoint().orElse(null);
+      rateLimit = response.getRateLimit();
+    }
+    return results;
+  }
+
+  private void checkRateLimit(@Nullable ApplicationHttpClient.RateLimit rateLimit) {
+    if (rateLimit == null) {
+      return;
+    }
+    try {
+      rateLimitChecker.checkRateLimit(rateLimit);
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      LOG.warn(format("Thread interrupted: %s", e.getMessage()), e);
+    }
+  }
+
+  private GetResponse executeCall(String appUrl, AccessToken token, String endpoint) {
+    try {
+      GetResponse response = appHttpClient.get(appUrl, token, endpoint);
+      if (response.getCode() < 200 || response.getCode() >= 300) {
+        throw new IllegalStateException(
+          format("Error while executing a call to %s. Return code %s. Error message: %s.", appUrl, response.getCode(), response.getContent().orElse("")));
+      }
+      return response;
+    } catch (Exception e) {
+      String errorMessage = format("SonarQube was not able to retrieve resources from external system. Error while executing a paginated call to %s, endpoint:%s.",
+        appUrl, endpoint);
+      logException(errorMessage, e);
+      throw new IllegalStateException(errorMessage + " " + e.getMessage());
+    }
+  }
+
+  private static void logException(String message, Exception e) {
+    if (LOG.isDebugEnabled()) {
+      LOG.warn(message, e);
+    } else {
+      LOG.warn(message, e.getMessage());
+    }
+  }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/PaginatedHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/PaginatedHttpClient.java
new file mode 100644 (file)
index 0000000..5e746e5
--- /dev/null
@@ -0,0 +1,28 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.List;
+import java.util.function.Function;
+import org.sonar.alm.client.github.security.AccessToken;
+
+public interface PaginatedHttpClient {
+  <E> List<E> get(String appUrl, AccessToken token, String query, Function<String, List<E>> responseDeserializer);
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/RatioBasedRateLimitChecker.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/RatioBasedRateLimitChecker.java
new file mode 100644 (file)
index 0000000..01beeac
--- /dev/null
@@ -0,0 +1,57 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.google.common.annotations.VisibleForTesting;
+import org.kohsuke.github.GHRateLimit;
+import org.kohsuke.github.RateLimitChecker;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.sonar.api.ce.ComputeEngineSide;
+import org.sonar.api.server.ServerSide;
+
+@ComputeEngineSide
+@ServerSide
+public class RatioBasedRateLimitChecker extends RateLimitChecker {
+  private static final Logger LOGGER = LoggerFactory.getLogger(RatioBasedRateLimitChecker.class);
+
+  @VisibleForTesting
+  static final String RATE_RATIO_EXCEEDED_MESSAGE = "The external system API rate limit is almost reached. Pausing GitHub provisioning until the next rate limit reset. "
+                                                    + "{} out of {} calls were used.";
+
+  private static final int MAX_PERCENTAGE_OF_CALLS_FOR_PROVISIONING = 90;
+
+  public boolean checkRateLimit(ApplicationHttpClient.RateLimit rateLimitRecord) throws InterruptedException {
+    int limit = rateLimitRecord.limit();
+    int apiCallsUsed = limit - rateLimitRecord.remaining();
+    double percentageOfCallsUsed = computePercentageOfCallsUsed(apiCallsUsed, limit);
+    LOGGER.debug("{} external system API calls used of {}", apiCallsUsed, limit);
+    if (percentageOfCallsUsed >= MAX_PERCENTAGE_OF_CALLS_FOR_PROVISIONING) {
+      LOGGER.warn(RATE_RATIO_EXCEEDED_MESSAGE, apiCallsUsed, limit);
+      GHRateLimit.Record rateLimit = new GHRateLimit.Record(rateLimitRecord.limit(), rateLimitRecord.remaining(), rateLimitRecord.reset());
+      return sleepUntilReset(rateLimit);
+    }
+    return false;
+  }
+
+  private static double computePercentageOfCallsUsed(int used, int limit) {
+    return (double) used * 100 / limit;
+  }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/ApplicationHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/ApplicationHttpClient.java
deleted file mode 100644 (file)
index 75c512e..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 ApplicationHttpClient {
-  /**
-   * 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}.
-   * No log if there is an issue during the call.
-   */
-  GetResponse getSilent(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;
-
-  record RateLimit(int remaining, int limit, long reset) {
-  }
-  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();
-
-    RateLimit getRateLimit();
-  }
-
-  interface GetResponse extends Response {
-    Optional<String> getNextEndPoint();
-  }
-
-}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/DevopsPlatformHeaders.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/DevopsPlatformHeaders.java
deleted file mode 100644 (file)
index 38e8fe0..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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.util.Optional;
-
-public interface DevopsPlatformHeaders {
-  Optional<String> getApiVersionHeader();
-
-  Optional<String> getApiVersion();
-
-  String getRateLimitRemainingHeader();
-
-  String getRateLimitLimitHeader();
-
-  String getRateLimitResetHeader();
-
-  String getAuthorizationHeader();
-}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GenericApplicationHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GenericApplicationHttpClient.java
deleted file mode 100644 (file)
index d67224c..0000000
+++ /dev/null
@@ -1,299 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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.function.Function;
-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.apache.commons.lang.StringUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.sonar.alm.client.TimeoutConfiguration;
-import org.sonar.alm.client.github.security.AccessToken;
-import org.sonarqube.ws.client.OkHttpClientBuilder;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static java.net.HttpURLConnection.HTTP_ACCEPTED;
-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 abstract class GenericApplicationHttpClient implements ApplicationHttpClient {
-
-  private static final Logger LOG = LoggerFactory.getLogger(GenericApplicationHttpClient.class);
-  private static final Pattern NEXT_LINK_PATTERN = Pattern.compile("<([^<]+)>; rel=\"next\"");
-
-  private final DevopsPlatformHeaders devopsPlatformHeaders;
-  private final OkHttpClient client;
-
-  public GenericApplicationHttpClient(DevopsPlatformHeaders devopsPlatformHeaders, TimeoutConfiguration timeoutConfiguration) {
-    this.devopsPlatformHeaders = devopsPlatformHeaders;
-    client = new OkHttpClientBuilder()
-      .setConnectTimeoutMs(timeoutConfiguration.getConnectTimeout())
-      .setReadTimeoutMs(timeoutConfiguration.getReadTimeout())
-      .setFollowRedirects(false)
-      .build();
-  }
-
-  @Override
-  public GetResponse get(String appUrl, AccessToken token, String endPoint) throws IOException {
-    return get(appUrl, token, endPoint, true);
-  }
-
-  @Override
-  public GetResponse getSilent(String appUrl, AccessToken token, String endPoint) throws IOException {
-    return get(appUrl, token, endPoint, false);
-  }
-
-  private GetResponse get(String appUrl, AccessToken token, String endPoint, boolean withLog) throws IOException {
-    validateEndPoint(endPoint);
-    try (okhttp3.Response response = client.newCall(newGetRequest(appUrl, token, endPoint)).execute()) {
-      int responseCode = response.code();
-      RateLimit rateLimit = readRateLimit(response);
-      if (responseCode != HTTP_OK) {
-        String content = StringUtils.trimToNull(attemptReadContent(response));
-        if (withLog) {
-          LOG.warn("GET response did not have expected HTTP code (was {}): {}", responseCode, content);
-        }
-        return new GetResponseImpl(responseCode, content, null, rateLimit);
-      }
-      return new GetResponseImpl(responseCode, readContent(response.body()).orElse(null), readNextEndPoint(response), rateLimit);
-    }
-  }
-
-  private static void validateEndPoint(String endPoint) {
-    checkArgument(endPoint.startsWith("/") || endPoint.startsWith("http") || endPoint.isEmpty(),
-      "endpoint must start with '/' or 'http'");
-  }
-
-  private 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();
-      RateLimit rateLimit = readRateLimit(response);
-      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, rateLimit);
-      }
-      return new ResponseImpl(responseCode, null, rateLimit);
-    }
-  }
-
-  private 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();
-      RateLimit rateLimit = readRateLimit(response);
-      if (responseCode == HTTP_OK || responseCode == HTTP_CREATED || responseCode == HTTP_ACCEPTED) {
-        return new ResponseImpl(responseCode, readContent(response.body()).orElse(null), rateLimit);
-      } else if (responseCode == HTTP_NO_CONTENT) {
-        return new ResponseImpl(responseCode, null, rateLimit);
-      }
-      String content = attemptReadContent(response);
-      LOG.warn("POST response did not have expected HTTP code (was {}): {}", responseCode, content);
-      return new ResponseImpl(responseCode, content, rateLimit);
-    }
-  }
-
-  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();
-      RateLimit rateLimit = readRateLimit(response);
-      if (responseCode == HTTP_OK) {
-        return new ResponseImpl(responseCode, readContent(response.body()).orElse(null), rateLimit);
-      } else if (responseCode == HTTP_NO_CONTENT) {
-        return new ResponseImpl(responseCode, null, rateLimit);
-      }
-      String content = attemptReadContent(response);
-      LOG.warn("PATCH response did not have expected HTTP code (was {}): {}", responseCode, content);
-      return new ResponseImpl(responseCode, content, rateLimit);
-    }
-  }
-
-  private Request newPostRequest(String appUrl, @Nullable AccessToken token, String endPoint, RequestBody body) {
-    return newRequestBuilder(appUrl, token, endPoint).post(body).build();
-  }
-
-  private Request newPatchRequest(AccessToken token, String appUrl, String endPoint, RequestBody body) {
-    return newRequestBuilder(appUrl, token, endPoint).patch(body).build();
-  }
-
-  private 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(devopsPlatformHeaders.getAuthorizationHeader(), token.getAuthorizationHeaderPrefix() + " " + token);
-      devopsPlatformHeaders.getApiVersion().ifPresent(apiVersion ->
-        url.addHeader(devopsPlatformHeaders.getApiVersionHeader().orElseThrow(), apiVersion)
-      );
-    }
-    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.headers().get("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);
-  }
-
-  @CheckForNull
-  private RateLimit readRateLimit(okhttp3.Response response) {
-    Integer remaining = headerValueOrNull(response, devopsPlatformHeaders.getRateLimitRemainingHeader(), Integer::valueOf);
-    Integer limit = headerValueOrNull(response, devopsPlatformHeaders.getRateLimitLimitHeader(), Integer::valueOf);
-    Long reset = headerValueOrNull(response, devopsPlatformHeaders.getRateLimitResetHeader(), Long::valueOf);
-    if (remaining == null || limit == null || reset == null) {
-      return null;
-    }
-    return new RateLimit(remaining, limit, reset);
-  }
-
-  @CheckForNull
-  private static <T> T headerValueOrNull(okhttp3.Response response, String header, Function<String, T> mapper) {
-    return ofNullable(response.header(header)).map(mapper::apply).orElse(null);
-  }
-
-  private static class ResponseImpl implements Response {
-    private final int code;
-    private final String content;
-
-    private final RateLimit rateLimit;
-
-    private ResponseImpl(int code, @Nullable String content, @Nullable RateLimit rateLimit) {
-      this.code = code;
-      this.content = content;
-      this.rateLimit = rateLimit;
-    }
-
-    @Override
-    public int getCode() {
-      return code;
-    }
-
-    @Override
-    public Optional<String> getContent() {
-      return ofNullable(content);
-    }
-
-    @Override
-    @CheckForNull
-    public RateLimit getRateLimit() {
-      return rateLimit;
-    }
-
-  }
-
-  private static final class GetResponseImpl extends ResponseImpl implements GetResponse {
-    private final String nextEndPoint;
-
-    private GetResponseImpl(int code, @Nullable String content, @Nullable String nextEndPoint, @Nullable RateLimit rateLimit) {
-      super(code, content, rateLimit);
-      this.nextEndPoint = nextEndPoint;
-    }
-
-    @Override
-    public Optional<String> getNextEndPoint() {
-      return ofNullable(nextEndPoint);
-    }
-  }
-}
index 2e657f7f3f4ef4865d2a9b915b6b486f1f4e2edb..d079dc6fb27d3ebb9f89d931030d774a75fdf5a4 100644 (file)
@@ -37,7 +37,8 @@ import java.util.stream.Collectors;
 import javax.annotation.Nullable;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.sonar.alm.client.github.ApplicationHttpClient.GetResponse;
+import org.sonar.alm.client.ApplicationHttpClient;
+import org.sonar.alm.client.ApplicationHttpClient.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;
@@ -69,21 +70,17 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
   protected static final String WRITE_PERMISSION_NAME = "write";
   protected static final String READ_PERMISSION_NAME = "read";
   protected static final String FAILED_TO_REQUEST_BEGIN_MSG = "Failed to request ";
-
-  private static final String EXCEPTION_MESSAGE = "SonarQube was not able to retrieve resources from GitHub. "
-                                                  + "This is likely due to a connectivity problem or a temporary network outage";
-
   private static final Type REPOSITORY_TEAM_LIST_TYPE = TypeToken.getParameterized(List.class, GsonRepositoryTeam.class).getType();
   private static final Type REPOSITORY_COLLABORATORS_LIST_TYPE = TypeToken.getParameterized(List.class, GsonRepositoryCollaborator.class).getType();
   private static final Type ORGANIZATION_LIST_TYPE = TypeToken.getParameterized(List.class, GithubBinding.GsonInstallation.class).getType();
-  protected final ApplicationHttpClient appHttpClient;
+  protected final GithubApplicationHttpClient githubApplicationHttpClient;
   protected final GithubAppSecurity appSecurity;
   private final GitHubSettings gitHubSettings;
-  private final PaginatedHttpClient githubPaginatedHttpClient;
+  private final GithubPaginatedHttpClient githubPaginatedHttpClient;
 
-  public GithubApplicationClientImpl(ApplicationHttpClient appHttpClient, GithubAppSecurity appSecurity, GitHubSettings gitHubSettings,
-    PaginatedHttpClient githubPaginatedHttpClient) {
-    this.appHttpClient = appHttpClient;
+  public GithubApplicationClientImpl(GithubApplicationHttpClient githubApplicationHttpClient, GithubAppSecurity appSecurity, GitHubSettings gitHubSettings,
+    GithubPaginatedHttpClient githubPaginatedHttpClient) {
+    this.githubApplicationHttpClient = githubApplicationHttpClient;
     this.appSecurity = appSecurity;
     this.gitHubSettings = gitHubSettings;
     this.githubPaginatedHttpClient = githubPaginatedHttpClient;
@@ -106,7 +103,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
 
   private <T> Optional<T> post(String baseUrl, AccessToken token, String endPoint, Class<T> gsonClass) {
     try {
-      ApplicationHttpClient.Response response = appHttpClient.post(baseUrl, token, endPoint);
+      ApplicationHttpClient.Response response = githubApplicationHttpClient.post(baseUrl, token, endPoint);
       return handleResponse(response, endPoint, gsonClass);
     } catch (Exception e) {
       LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + endPoint, e);
@@ -146,7 +143,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
     String endPoint = "/app";
     GetResponse response;
     try {
-      response = appHttpClient.get(githubAppConfiguration.getApiEndpoint(), appToken, endPoint);
+      response = githubApplicationHttpClient.get(githubAppConfiguration.getApiEndpoint(), appToken, endPoint);
     } catch (IOException e) {
       LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + githubAppConfiguration.getApiEndpoint() + endPoint, e);
       throw new IllegalArgumentException("Failed to validate configuration, check URL and Private Key");
@@ -189,7 +186,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
 
     try {
       Organizations organizations = new Organizations();
-      GetResponse response = appHttpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", page, pageSize));
+      GetResponse response = githubApplicationHttpClient.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()) {
@@ -247,7 +244,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
 
   protected <T> Optional<T> get(String baseUrl, AccessToken token, String endPoint, Class<T> gsonClass) {
     try {
-      GetResponse response = appHttpClient.get(baseUrl, token, endPoint);
+      GetResponse response = githubApplicationHttpClient.get(baseUrl, token, endPoint);
       return handleResponse(response, endPoint, gsonClass);
     } catch (Exception e) {
       LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + endPoint, e);
@@ -264,7 +261,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
     }
     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));
+      GetResponse response = githubApplicationHttpClient.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;
@@ -288,7 +285,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
   @Override
   public Optional<Repository> getRepository(String appUrl, AccessToken accessToken, String organizationAndRepository) {
     try {
-      GetResponse response = appHttpClient.get(appUrl, accessToken, String.format("/repos/%s", organizationAndRepository));
+      GetResponse response = githubApplicationHttpClient.get(appUrl, accessToken, String.format("/repos/%s", organizationAndRepository));
       return Optional.of(response)
         .filter(r -> r.getCode() == HTTP_OK)
         .flatMap(ApplicationHttpClient.Response::getContent)
@@ -315,7 +312,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
         baseAppUrl = appUrl;
       }
 
-      ApplicationHttpClient.Response response = appHttpClient.post(baseAppUrl, null, endpoint);
+      ApplicationHttpClient.Response response = githubApplicationHttpClient.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(""));
@@ -333,7 +330,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
       }
 
       // 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);
+      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);
@@ -349,7 +346,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
 
   private <T> T getOrThrowIfNotHttpOk(String baseUrl, AccessToken token, String endPoint, Class<T> gsonClass) {
     try {
-      GetResponse response = appHttpClient.get(baseUrl, token, endPoint);
+      GetResponse response = githubApplicationHttpClient.get(baseUrl, token, endPoint);
       if (response.getCode() != HTTP_OK) {
         throw new HttpException(baseUrl + endPoint, response.getCode(), response.getContent().orElse(""));
       }
@@ -386,23 +383,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
   }
 
   private <E> List<E> executePaginatedQuery(String appUrl, AccessToken token, String query, Function<String, List<E>> responseDeserializer) {
-    try {
-      return githubPaginatedHttpClient.get(appUrl, token, query, responseDeserializer);
-    } catch (IOException ioException) {
-      throw logAndCreateException(ioException, format("Error while executing a paginated call to GitHub - appUrl: %s, path: %s.", appUrl, query));
-    }
+    return githubPaginatedHttpClient.get(appUrl, token, query, responseDeserializer);
   }
 
-  private static IllegalStateException logAndCreateException(IOException ioException, String errorMessage) {
-    log(errorMessage, ioException);
-    return new IllegalStateException(EXCEPTION_MESSAGE + ": " + errorMessage + " " + ioException.getMessage());
-  }
-
-  private static void log(String message, Exception e) {
-    if (LOG.isDebugEnabled()) {
-      LOG.warn(message, e);
-    } else {
-      LOG.warn(message);
-    }
-  }
 }
index 49406cea9b04709d18a115419c1f56548ea91ce8..24556e3da72aebf639d0bcc86c37198100d2c303 100644 (file)
@@ -19,6 +19,7 @@
  */
 package org.sonar.alm.client.github;
 
+import org.sonar.alm.client.GenericApplicationHttpClient;
 import org.sonar.alm.client.TimeoutConfiguration;
 import org.sonar.api.ce.ComputeEngineSide;
 import org.sonar.api.server.ServerSide;
index 9496f0b2bcf199ba74e6e5c45b6f309aea393e0b..847cf537507fbd3d2e9fccdd899aea02e3971109 100644 (file)
@@ -20,6 +20,7 @@
 package org.sonar.alm.client.github;
 
 import java.util.Optional;
+import org.sonar.alm.client.DevopsPlatformHeaders;
 import org.sonar.api.ce.ComputeEngineSide;
 import org.sonar.api.server.ServerSide;
 
index 9c8d336e1928bc1acf238c6c1fae15ab1876581f..a68f0cf5d54376d17bc9034d9621bdb0da3d08ee 100644 (file)
  */
 package org.sonar.alm.client.github;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.function.Function;
-import javax.annotation.Nullable;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.sonar.alm.client.github.security.AccessToken;
+import org.sonar.alm.client.GenericPaginatedHttpClient;
+import org.sonar.alm.client.RatioBasedRateLimitChecker;
 import org.sonar.api.ce.ComputeEngineSide;
 import org.sonar.api.server.ServerSide;
 
-import static java.lang.String.format;
-
 @ServerSide
 @ComputeEngineSide
-public class GithubPaginatedHttpClient implements PaginatedHttpClient {
-
-  private static final Logger LOG = LoggerFactory.getLogger(GithubPaginatedHttpClient.class);
-  private final ApplicationHttpClient appHttpClient;
-  private final RatioBasedRateLimitChecker rateLimitChecker;
-
-  public GithubPaginatedHttpClient(ApplicationHttpClient appHttpClient, RatioBasedRateLimitChecker rateLimitChecker) {
-    this.appHttpClient = appHttpClient;
-    this.rateLimitChecker = rateLimitChecker;
-  }
+public class GithubPaginatedHttpClient extends GenericPaginatedHttpClient {
 
-  @Override
-  public <E> List<E> get(String appUrl, AccessToken token, String query, Function<String, List<E>> responseDeserializer) throws IOException {
-    List<E> results = new ArrayList<>();
-    String nextEndpoint = query + "?per_page=100";
-    if (query.contains("?")) {
-      nextEndpoint = query + "&per_page=100";
-    }
-    ApplicationHttpClient.RateLimit rateLimit = null;
-    while (nextEndpoint != null) {
-      checkRateLimit(rateLimit);
-      ApplicationHttpClient.GetResponse response = executeCall(appUrl, token, nextEndpoint);
-      response.getContent()
-        .ifPresent(content -> results.addAll(responseDeserializer.apply(content)));
-      nextEndpoint = response.getNextEndPoint().orElse(null);
-      rateLimit = response.getRateLimit();
-    }
-    return results;
+  public GithubPaginatedHttpClient(GithubApplicationHttpClient appHttpClient, RatioBasedRateLimitChecker rateLimitChecker) {
+    super(appHttpClient, rateLimitChecker);
   }
 
-  private void checkRateLimit(@Nullable ApplicationHttpClient.RateLimit rateLimit) {
-    if (rateLimit == null) {
-      return;
-    }
-    try {
-      rateLimitChecker.checkRateLimit(rateLimit);
-    } catch (InterruptedException e) {
-      Thread.currentThread().interrupt();
-      LOG.warn(format("Thread interrupted: %s", e.getMessage()), e);
-    }
-  }
-
-  private ApplicationHttpClient.GetResponse executeCall(String appUrl, AccessToken token, String endpoint) throws IOException {
-    ApplicationHttpClient.GetResponse response = appHttpClient.get(appUrl, token, endpoint);
-    if (response.getCode() < 200 || response.getCode() >= 300) {
-      throw new IllegalStateException(
-        format("Error while executing a call to GitHub. Return code %s. Error message: %s.", response.getCode(), response.getContent().orElse("")));
-    }
-    return response;
-  }
 }
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/PaginatedHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/PaginatedHttpClient.java
deleted file mode 100644 (file)
index 134e942..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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.List;
-import java.util.function.Function;
-import org.sonar.alm.client.github.security.AccessToken;
-
-public interface PaginatedHttpClient {
-
-  <E> List<E> get(String appUrl, AccessToken token, String query, Function<String, List<E>> responseDeserializer) throws IOException;
-}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/RatioBasedRateLimitChecker.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/RatioBasedRateLimitChecker.java
deleted file mode 100644 (file)
index 9eb6e46..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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.common.annotations.VisibleForTesting;
-import org.kohsuke.github.GHRateLimit;
-import org.kohsuke.github.RateLimitChecker;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.sonar.api.ce.ComputeEngineSide;
-import org.sonar.api.server.ServerSide;
-
-@ComputeEngineSide
-@ServerSide
-public class RatioBasedRateLimitChecker extends RateLimitChecker {
-  private static final Logger LOGGER = LoggerFactory.getLogger(RatioBasedRateLimitChecker.class);
-
-  @VisibleForTesting
-  static final String RATE_RATIO_EXCEEDED_MESSAGE = "The GitHub API rate limit is almost reached. Pausing GitHub provisioning until the next rate limit reset. "
-                                                    + "{} out of {} calls were used.";
-
-  private static final int MAX_PERCENTAGE_OF_CALLS_FOR_PROVISIONING = 90;
-
-  public boolean checkRateLimit(ApplicationHttpClient.RateLimit rateLimitRecord) throws InterruptedException {
-    int limit = rateLimitRecord.limit();
-    int apiCallsUsed = limit - rateLimitRecord.remaining();
-    double percentageOfCallsUsed = computePercentageOfCallsUsed(apiCallsUsed, limit);
-    LOGGER.debug("{} GitHub API calls used of {} available per hours", apiCallsUsed, limit);
-    if (percentageOfCallsUsed >= MAX_PERCENTAGE_OF_CALLS_FOR_PROVISIONING) {
-      LOGGER.warn(RATE_RATIO_EXCEEDED_MESSAGE, apiCallsUsed, limit);
-      GHRateLimit.Record rateLimit = new GHRateLimit.Record(rateLimitRecord.limit(), rateLimitRecord.remaining(), rateLimitRecord.reset());
-      return sleepUntilReset(rateLimit);
-    }
-    return false;
-  }
-
-  private static double computePercentageOfCallsUsed(int used, int limit) {
-    return (double) used * 100 / limit;
-  }
-}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabApplicationClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabApplicationClient.java
new file mode 100644 (file)
index 0000000..13088ae
--- /dev/null
@@ -0,0 +1,359 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.GsonBuilder;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.reflect.TypeToken;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.lang.reflect.Type;
+import java.net.URLEncoder;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import javax.annotation.Nullable;
+import okhttp3.Headers;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import org.apache.logging.log4j.util.Strings;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.sonar.alm.client.TimeoutConfiguration;
+import org.sonar.api.server.ServerSide;
+import org.sonar.auth.gitlab.GsonGroup;
+import org.sonarqube.ws.MediaTypes;
+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 GitlabApplicationClient {
+  private static final Logger LOG = LoggerFactory.getLogger(GitlabApplicationClient.class);
+  private static final Gson GSON = new Gson();
+  private static final Type GITLAB_GROUP = TypeToken.getParameterized(List.class, GsonGroup.class).getType();
+
+  protected static final String PRIVATE_TOKEN = "Private-Token";
+  protected final OkHttpClient client;
+
+  private final GitlabPaginatedHttpClient gitlabPaginatedHttpClient;
+
+  public GitlabApplicationClient(GitlabPaginatedHttpClient gitlabPaginatedHttpClient, TimeoutConfiguration timeoutConfiguration) {
+    this.gitlabPaginatedHttpClient = gitlabPaginatedHttpClient;
+    client = new OkHttpClientBuilder()
+      .setConnectTimeoutMs(timeoutConfiguration.getConnectTimeout())
+      .setReadTimeoutMs(timeoutConfiguration.getReadTimeout())
+      .setFollowRedirects(false)
+      .build();
+  }
+
+  public void checkReadPermission(@Nullable String gitlabUrl, @Nullable String personalAccessToken) {
+    checkProjectAccess(gitlabUrl, personalAccessToken, "Could not validate GitLab read permission. Got an unexpected answer.");
+  }
+
+  public void checkUrl(@Nullable String gitlabUrl) {
+    checkProjectAccess(gitlabUrl, null, "Could not validate GitLab url. Got an unexpected answer.");
+  }
+
+  private void checkProjectAccess(@Nullable String gitlabUrl, @Nullable String personalAccessToken, String errorMessage) {
+    String url = String.format("%s/projects", gitlabUrl);
+
+    LOG.debug(String.format("get projects : [%s]", url));
+    Request.Builder builder = new Request.Builder()
+      .url(url)
+      .get();
+
+    if (personalAccessToken != null) {
+      builder.addHeader(PRIVATE_TOKEN, personalAccessToken);
+    }
+
+    Request request = builder.build();
+
+    try (Response response = client.newCall(request).execute()) {
+      checkResponseIsSuccessful(response, errorMessage);
+      Project.parseJsonArray(response.body().string());
+    } catch (JsonSyntaxException e) {
+      throw new IllegalArgumentException("Could not parse GitLab answer to verify read permission. Got a non-json payload as result.");
+    } catch (IOException e) {
+      logException(url, e);
+      throw new IllegalArgumentException(errorMessage);
+    }
+  }
+
+  private static void logException(String url, IOException e) {
+    LOG.info(String.format("Gitlab API call to [%s] failed with error message : [%s]", url, e.getMessage()), e);
+  }
+
+  public void checkToken(String gitlabUrl, String personalAccessToken) {
+    String url = String.format("%s/user", gitlabUrl);
+
+    LOG.debug(String.format("get current user : [%s]", url));
+    Request.Builder builder = new Request.Builder()
+      .addHeader(PRIVATE_TOKEN, personalAccessToken)
+      .url(url)
+      .get();
+
+    Request request = builder.build();
+
+    String errorMessage = "Could not validate GitLab token. Got an unexpected answer.";
+    try (Response response = client.newCall(request).execute()) {
+      checkResponseIsSuccessful(response, errorMessage);
+      GsonId.parseOne(response.body().string());
+    } catch (JsonSyntaxException e) {
+      throw new IllegalArgumentException("Could not parse GitLab answer to verify token. Got a non-json payload as result.");
+    } catch (IOException e) {
+      logException(url, e);
+      throw new IllegalArgumentException(errorMessage);
+    }
+  }
+
+  public void checkWritePermission(String gitlabUrl, String personalAccessToken) {
+    String url = String.format("%s/markdown", gitlabUrl);
+
+    LOG.debug(String.format("verify write permission by formating some markdown : [%s]", url));
+    Request.Builder builder = new Request.Builder()
+      .url(url)
+      .addHeader(PRIVATE_TOKEN, personalAccessToken)
+      .addHeader("Content-Type", MediaTypes.JSON)
+      .post(RequestBody.create("{\"text\":\"validating write permission\"}".getBytes(UTF_8)));
+
+    Request request = builder.build();
+
+    String errorMessage = "Could not validate GitLab write permission. Got an unexpected answer.";
+    try (Response response = client.newCall(request).execute()) {
+      checkResponseIsSuccessful(response, errorMessage);
+      GsonMarkdown.parseOne(response.body().string());
+    } catch (JsonSyntaxException e) {
+      throw new IllegalArgumentException("Could not parse GitLab answer to verify write permission. Got a non-json payload as result.");
+    } catch (IOException e) {
+      logException(url, e);
+      throw new IllegalArgumentException(errorMessage);
+    }
+
+  }
+
+  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 GitlabServerException(response.code(), "Your GitLab token was revoked");
+      } else if (isTokenExpired(response, body)) {
+        throw new GitlabServerException(response.code(), "Your GitLab token is expired");
+      } else if (isInsufficientScope(response, body)) {
+        throw new GitlabServerException(response.code(), "Your GitLab token has insufficient scope");
+      } else if (response.code() == HTTP_UNAUTHORIZED) {
+        throw new GitlabServerException(response.code(), "Invalid personal access token");
+      } else if (response.isRedirect()) {
+        throw new GitlabServerException(response.code(), "Request was redirected, please provide the correct URL");
+      } else {
+        throw new GitlabServerException(response.code(), errorMessage);
+      }
+    }
+  }
+
+  private static boolean isTokenRevoked(Response response, String body) {
+    if (response.code() == HTTP_UNAUTHORIZED) {
+      try {
+        Optional<GsonError> 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<GsonError> 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<GsonError> 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) {
+      logException(url, e);
+      throw new IllegalStateException(e.getMessage(), e);
+    }
+  }
+
+  //
+  // This method is used to check if a user has REPORTER level access to the project, which is a requirement for PR decoration.
+  // As of June 9, 2021 there is no better way to do this check and still support GitLab 11.7.
+  //
+  public Optional<Project> getReporterLevelAccessProject(String gitlabUrl, String pat, Long gitlabProjectId) {
+    String url = String.format("%s/projects?min_access_level=20&id_after=%s&id_before=%s", gitlabUrl, gitlabProjectId - 1,
+      gitlabProjectId + 1);
+    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));
+
+      List<Project> projects = Project.parseJsonArray(body);
+      if (projects.isEmpty()) {
+        return Optional.empty();
+      } else {
+        return Optional.of(projects.get(0));
+      }
+    } 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) {
+      logException(url, e);
+      throw new IllegalStateException(e.getMessage(), e);
+    }
+  }
+
+  public List<GitLabBranch> getBranches(String gitlabUrl, String pat, Long gitlabProjectId) {
+    String url = String.format("%s/projects/%s/repository/branches", gitlabUrl, gitlabProjectId);
+    LOG.debug(String.format("get branches : [%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 branches payload result : [%s]", body));
+      return Arrays.asList(new GsonBuilder().create().fromJson(body, GitLabBranch[].class));
+    } catch (JsonSyntaxException e) {
+      throw new IllegalArgumentException("Could not parse GitLab answer to retrieve project branches. Got a non-json payload as result.");
+    } catch (IOException e) {
+      logException(url, e);
+      throw new IllegalStateException(e.getMessage(), e);
+    }
+  }
+
+  public ProjectList searchProjects(String gitlabUrl, String personalAccessToken, @Nullable String projectName,
+    @Nullable Integer pageNumber, @Nullable Integer pageSize) {
+    String url = String.format("%s/projects?archived=false&simple=true&membership=true&order_by=name&sort=asc&search=%s%s%s",
+      gitlabUrl,
+      projectName == null ? "" : urlEncode(projectName),
+      pageNumber == null ? "" : String.format("&page=%d", pageNumber),
+      pageSize == null ? "" : String.format("&per_page=%d", 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()) {
+      Headers headers = response.headers();
+      checkResponseIsSuccessful(response, "Could not get projects from GitLab instance");
+      List<Project> projectList = Project.parseJsonArray(response.body().string());
+      int returnedPageNumber = parseAndGetIntegerHeader(headers.get("X-Page"));
+      int returnedPageSize = parseAndGetIntegerHeader(headers.get("X-Per-Page"));
+      String xtotal = headers.get("X-Total");
+      Integer totalProjects = Strings.isEmpty(xtotal) ? null : parseAndGetIntegerHeader(xtotal);
+      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) {
+      logException(url, 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);
+      }
+    }
+  }
+
+  public Set<GsonGroup> getGroups(String gitlabUrl, String token) {
+    return Set.copyOf(executePaginatedQuery(gitlabUrl, token, "/groups", resp -> GSON.fromJson(resp, GITLAB_GROUP)));
+  }
+
+  private <E> List<E> executePaginatedQuery(String appUrl, String token, String query, Function<String, List<E>> responseDeserializer) {
+    GitlabToken gitlabToken = new GitlabToken(token);
+    return gitlabPaginatedHttpClient.get(appUrl, gitlabToken, query, responseDeserializer);
+  }
+
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabApplicationHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabApplicationHttpClient.java
new file mode 100644 (file)
index 0000000..ec9c15f
--- /dev/null
@@ -0,0 +1,33 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 org.sonar.alm.client.TimeoutConfiguration;
+import org.sonar.alm.client.GenericApplicationHttpClient;
+import org.sonar.api.ce.ComputeEngineSide;
+import org.sonar.api.server.ServerSide;
+
+@ServerSide
+@ComputeEngineSide
+public class GitlabApplicationHttpClient extends GenericApplicationHttpClient {
+  public GitlabApplicationHttpClient(GitlabHeaders gitlabHeaders, TimeoutConfiguration timeoutConfiguration) {
+    super(gitlabHeaders, timeoutConfiguration);
+  }
+}
index 2ca75b19e268d8dd9eb1b729ecad29b5e828cb48..c1f76a15274f4319c002d503b13f46eaa3271a7b 100644 (file)
@@ -28,11 +28,11 @@ import org.sonar.db.alm.setting.AlmSettingDto;
 public class GitlabGlobalSettingsValidator {
 
   private final Encryption encryption;
-  private final GitlabHttpClient gitlabHttpClient;
+  private final GitlabApplicationClient gitlabApplicationClient;
 
-  public GitlabGlobalSettingsValidator(GitlabHttpClient gitlabHttpClient, Settings settings) {
+  public GitlabGlobalSettingsValidator(GitlabApplicationClient gitlabApplicationClient, Settings settings) {
     this.encryption = settings.getEncryption();
-    this.gitlabHttpClient = gitlabHttpClient;
+    this.gitlabApplicationClient = gitlabApplicationClient;
   }
 
   public void validate(AlmSettingDto almSettingDto) {
@@ -43,10 +43,10 @@ public class GitlabGlobalSettingsValidator {
       throw new IllegalArgumentException("Your Gitlab global configuration is incomplete.");
     }
 
-    gitlabHttpClient.checkUrl(gitlabUrl);
-    gitlabHttpClient.checkToken(gitlabUrl, accessToken);
-    gitlabHttpClient.checkReadPermission(gitlabUrl, accessToken);
-    gitlabHttpClient.checkWritePermission(gitlabUrl, accessToken);
+    gitlabApplicationClient.checkUrl(gitlabUrl);
+    gitlabApplicationClient.checkToken(gitlabUrl, accessToken);
+    gitlabApplicationClient.checkReadPermission(gitlabUrl, accessToken);
+    gitlabApplicationClient.checkWritePermission(gitlabUrl, accessToken);
   }
 
 }
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHeaders.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHeaders.java
new file mode 100644 (file)
index 0000000..e09d744
--- /dev/null
@@ -0,0 +1,60 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.Optional;
+import org.sonar.alm.client.DevopsPlatformHeaders;
+import org.sonar.api.ce.ComputeEngineSide;
+import org.sonar.api.server.ServerSide;
+
+@ServerSide
+@ComputeEngineSide
+public class GitlabHeaders implements DevopsPlatformHeaders {
+
+  @Override
+  public Optional<String> getApiVersionHeader() {
+    return Optional.empty();
+  }
+
+  @Override
+  public Optional<String> getApiVersion() {
+    return Optional.empty();
+  }
+
+  @Override
+  public String getRateLimitRemainingHeader() {
+    return "ratelimit-remaining";
+  }
+
+  @Override
+  public String getRateLimitLimitHeader() {
+    return "ratelimit-limit";
+  }
+
+  @Override
+  public String getRateLimitResetHeader() {
+    return "ratelimit-reset";
+  }
+
+  @Override
+  public String getAuthorizationHeader() {
+    return "PRIVATE-TOKEN";
+  }
+}
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
deleted file mode 100644 (file)
index b93540c..0000000
+++ /dev/null
@@ -1,367 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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.Arrays;
-import java.util.List;
-import java.util.Optional;
-import javax.annotation.Nullable;
-import okhttp3.Headers;
-import okhttp3.OkHttpClient;
-import okhttp3.Request;
-import okhttp3.RequestBody;
-import okhttp3.Response;
-import org.apache.logging.log4j.util.Strings;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.sonar.alm.client.TimeoutConfiguration;
-import org.sonar.api.server.ServerSide;
-import org.sonarqube.ws.MediaTypes;
-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 = LoggerFactory.getLogger(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())
-      .setFollowRedirects(false)
-      .build();
-  }
-
-  public void checkReadPermission(@Nullable String gitlabUrl, @Nullable String personalAccessToken) {
-    checkProjectAccess(gitlabUrl, personalAccessToken, "Could not validate GitLab read permission. Got an unexpected answer.");
-  }
-
-  public void checkUrl(@Nullable String gitlabUrl) {
-    checkProjectAccess(gitlabUrl, null, "Could not validate GitLab url. Got an unexpected answer.");
-  }
-
-  private void checkProjectAccess(@Nullable String gitlabUrl, @Nullable String personalAccessToken, String errorMessage) {
-    String url = String.format("%s/projects", gitlabUrl);
-
-    LOG.debug(String.format("get projects : [%s]", url));
-    Request.Builder builder = new Request.Builder()
-      .url(url)
-      .get();
-
-    if (personalAccessToken != null) {
-      builder.addHeader(PRIVATE_TOKEN, personalAccessToken);
-    }
-
-    Request request = builder.build();
-
-    try (Response response = client.newCall(request).execute()) {
-      checkResponseIsSuccessful(response, errorMessage);
-      Project.parseJsonArray(response.body().string());
-    } catch (JsonSyntaxException e) {
-      throw new IllegalArgumentException("Could not parse GitLab answer to verify read permission. Got a non-json payload as result.");
-    } catch (IOException e) {
-      logException(url, e);
-      throw new IllegalArgumentException(errorMessage);
-    }
-  }
-
-  private static void logException(String url, IOException e) {
-    LOG.info(String.format("Gitlab API call to [%s] failed with error message : [%s]", url, e.getMessage()), e);
-  }
-
-  public void checkToken(String gitlabUrl, String personalAccessToken) {
-    String url = String.format("%s/user", gitlabUrl);
-
-    LOG.debug(String.format("get current user : [%s]", url));
-    Request.Builder builder = new Request.Builder()
-      .addHeader(PRIVATE_TOKEN, personalAccessToken)
-      .url(url)
-      .get();
-
-    Request request = builder.build();
-
-    String errorMessage = "Could not validate GitLab token. Got an unexpected answer.";
-    try (Response response = client.newCall(request).execute()) {
-      checkResponseIsSuccessful(response, errorMessage);
-      GsonId.parseOne(response.body().string());
-    } catch (JsonSyntaxException e) {
-      throw new IllegalArgumentException("Could not parse GitLab answer to verify token. Got a non-json payload as result.");
-    } catch (IOException e) {
-      logException(url, e);
-      throw new IllegalArgumentException(errorMessage);
-    }
-  }
-
-  public void checkWritePermission(String gitlabUrl, String personalAccessToken) {
-    String url = String.format("%s/markdown", gitlabUrl);
-
-    LOG.debug(String.format("verify write permission by formating some markdown : [%s]", url));
-    Request.Builder builder = new Request.Builder()
-      .url(url)
-      .addHeader(PRIVATE_TOKEN, personalAccessToken)
-      .addHeader("Content-Type", MediaTypes.JSON)
-      .post(RequestBody.create("{\"text\":\"validating write permission\"}".getBytes(UTF_8)));
-
-    Request request = builder.build();
-
-    String errorMessage = "Could not validate GitLab write permission. Got an unexpected answer.";
-    try (Response response = client.newCall(request).execute()) {
-      checkResponseIsSuccessful(response, errorMessage);
-      GsonMarkdown.parseOne(response.body().string());
-    } catch (JsonSyntaxException e) {
-      throw new IllegalArgumentException("Could not parse GitLab answer to verify write permission. Got a non-json payload as result.");
-    } catch (IOException e) {
-      logException(url, e);
-      throw new IllegalArgumentException(errorMessage);
-    }
-
-  }
-
-  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 GitlabServerException(response.code(), "Your GitLab token was revoked");
-      } else if (isTokenExpired(response, body)) {
-        throw new GitlabServerException(response.code(), "Your GitLab token is expired");
-      } else if (isInsufficientScope(response, body)) {
-        throw new GitlabServerException(response.code(), "Your GitLab token has insufficient scope");
-      } else if (response.code() == HTTP_UNAUTHORIZED) {
-        throw new GitlabServerException(response.code(), "Invalid personal access token");
-      } else if (response.isRedirect()) {
-        throw new GitlabServerException(response.code(), "Request was redirected, please provide the correct URL");
-      } else {
-        throw new GitlabServerException(response.code(), errorMessage);
-      }
-    }
-  }
-
-  private static boolean isTokenRevoked(Response response, String body) {
-    if (response.code() == HTTP_UNAUTHORIZED) {
-      try {
-        Optional<GsonError> 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<GsonError> 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<GsonError> 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) {
-      logException(url, e);
-      throw new IllegalStateException(e.getMessage(), e);
-    }
-  }
-
-  //
-  // This method is used to check if a user has REPORTER level access to the project, which is a requirement for PR decoration.
-  // As of June 9, 2021 there is no better way to do this check and still support GitLab 11.7.
-  //
-  public Optional<Project> getReporterLevelAccessProject(String gitlabUrl, String pat, Long gitlabProjectId) {
-    String url = String.format("%s/projects?min_access_level=20&id_after=%s&id_before=%s", gitlabUrl, gitlabProjectId - 1,
-      gitlabProjectId + 1);
-    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));
-
-      List<Project> projects = Project.parseJsonArray(body);
-      if (projects.isEmpty()) {
-        return Optional.empty();
-      } else {
-        return Optional.of(projects.get(0));
-      }
-    } 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) {
-      logException(url, e);
-      throw new IllegalStateException(e.getMessage(), e);
-    }
-  }
-
-  public List<GitLabBranch> getBranches(String gitlabUrl, String pat, Long gitlabProjectId) {
-    String url = String.format("%s/projects/%s/repository/branches", gitlabUrl, gitlabProjectId);
-    LOG.debug(String.format("get branches : [%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 branches payload result : [%s]", body));
-      return Arrays.asList(new GsonBuilder().create().fromJson(body, GitLabBranch[].class));
-    } catch (JsonSyntaxException e) {
-      throw new IllegalArgumentException("Could not parse GitLab answer to retrieve project branches. Got a non-json payload as result.");
-    } catch (IOException e) {
-      logException(url, e);
-      throw new IllegalStateException(e.getMessage(), e);
-    }
-  }
-
-  public ProjectList searchProjects(String gitlabUrl, String personalAccessToken, @Nullable String projectName,
-    @Nullable Integer pageNumber, @Nullable Integer pageSize) {
-    String url = String.format("%s/projects?archived=false&simple=true&membership=true&order_by=name&sort=asc&search=%s%s%s",
-      gitlabUrl,
-      projectName == null ? "" : urlEncode(projectName),
-      pageNumber == null ? "" : String.format("&page=%d", pageNumber),
-      pageSize == null ? "" : String.format("&per_page=%d", 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()) {
-      Headers headers = response.headers();
-      checkResponseIsSuccessful(response, "Could not get projects from GitLab instance");
-      List<Project> projectList = Project.parseJsonArray(response.body().string());
-      int returnedPageNumber = parseAndGetIntegerHeader(headers.get("X-Page"));
-      int returnedPageSize = parseAndGetIntegerHeader(headers.get("X-Per-Page"));
-      String xtotal = headers.get("X-Total");
-      Integer totalProjects = Strings.isEmpty(xtotal) ? null : parseAndGetIntegerHeader(xtotal);
-      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) {
-      logException(url, e);
-      throw new IllegalStateException(e.getMessage(), e);
-    }
-  }
-
-  /*public void getGroups(String gitlabUrl, String token) {
-    String url = String.format("%s/groups", gitlabUrl);
-    LOG.debug(String.format("get groups : [%s]", url));
-
-    Request request = new Request.Builder()
-      .addHeader(PRIVATE_TOKEN, token)
-      .url(url)
-      .get()
-      .build();
-
-
-    try (Response response = client.newCall(request).execute()) {
-      Headers headers = response.headers();
-      checkResponseIsSuccessful(response, "Could not get projects from GitLab instance");
-      List<Project> projectList = Project.parseJsonArray(response.body().string());
-      int returnedPageNumber = parseAndGetIntegerHeader(headers.get("X-Page"));
-      int returnedPageSize = parseAndGetIntegerHeader(headers.get("X-Per-Page"));
-      String xtotal = headers.get("X-Total");
-      Integer totalProjects = Strings.isEmpty(xtotal) ? null : parseAndGetIntegerHeader(xtotal);
-      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) {
-      logException(url, 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/GitlabPaginatedHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabPaginatedHttpClient.java
new file mode 100644 (file)
index 0000000..0e02c84
--- /dev/null
@@ -0,0 +1,35 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 org.sonar.alm.client.GenericPaginatedHttpClient;
+import org.sonar.alm.client.RatioBasedRateLimitChecker;
+import org.sonar.api.ce.ComputeEngineSide;
+import org.sonar.api.server.ServerSide;
+
+@ServerSide
+@ComputeEngineSide
+public class GitlabPaginatedHttpClient extends GenericPaginatedHttpClient {
+
+  public GitlabPaginatedHttpClient(GitlabApplicationHttpClient appHttpClient, RatioBasedRateLimitChecker rateLimitChecker) {
+    super(appHttpClient, rateLimitChecker);
+  }
+
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabToken.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabToken.java
new file mode 100644 (file)
index 0000000..c6c3c0a
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.Objects;
+import org.sonar.alm.client.github.security.AccessToken;
+
+public class GitlabToken implements AccessToken {
+  private final String token;
+
+  public GitlabToken(String token) {
+    this.token = token;
+  }
+
+  @Override
+  public String getValue() {
+    return token;
+  }
+
+  @Override
+  public String getAuthorizationHeaderPrefix() {
+    return "";
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    GitlabToken that = (GitlabToken) o;
+    return Objects.equals(token, that.token);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(token);
+  }
+}
diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/GenericPaginatedHttpClientImplTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/GenericPaginatedHttpClientImplTest.java
new file mode 100644 (file)
index 0000000..f958929
--- /dev/null
@@ -0,0 +1,193 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.util.List;
+import java.util.Optional;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.slf4j.event.Level;
+import org.sonar.alm.client.github.security.AccessToken;
+import org.sonar.api.testfixtures.log.LogTester;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.sonar.alm.client.ApplicationHttpClient.GetResponse;
+
+@RunWith(MockitoJUnitRunner.class)
+public class GenericPaginatedHttpClientImplTest {
+
+  private static final String APP_URL = "https://github.com/";
+
+  private static final String ENDPOINT = "/test-endpoint";
+
+  private static final Type STRING_LIST_TYPE = TypeToken.getParameterized(List.class, String.class).getType();
+
+  private Gson gson = new Gson();
+
+  @Rule
+  public LogTester logTester = new LogTester();
+
+  @Mock
+  private AccessToken accessToken;
+
+  @Mock
+  RatioBasedRateLimitChecker rateLimitChecker;
+
+  @Mock
+  ApplicationHttpClient appHttpClient;
+
+  @InjectMocks
+  private TestPaginatedHttpClient underTest;
+
+  private static class TestPaginatedHttpClient extends GenericPaginatedHttpClient {
+    protected TestPaginatedHttpClient(ApplicationHttpClient appHttpClient, RatioBasedRateLimitChecker rateLimitChecker) {
+      super(appHttpClient, rateLimitChecker);
+    }
+  }
+
+  @Test
+  public void get_whenNoPagination_ReturnsCorrectResponse() throws IOException {
+
+    GetResponse response = mockResponseWithoutPagination("[\"result1\", \"result2\"]");
+    when(appHttpClient.get(APP_URL, accessToken, ENDPOINT + "?per_page=100")).thenReturn(response);
+
+    List<String> results = underTest.get(APP_URL, accessToken, ENDPOINT, result -> gson.fromJson(result, STRING_LIST_TYPE));
+
+    assertThat(results)
+      .containsExactly("result1", "result2");
+  }
+
+  @Test
+  public void get_whenEndpointAlreadyContainsPathParameter_shouldAddANewParameter() throws IOException {
+    ArgumentCaptor<String> urlCaptor = ArgumentCaptor.forClass(String.class);
+
+    GetResponse response = mockResponseWithoutPagination("[\"result1\", \"result2\"]");
+    when(appHttpClient.get(eq(APP_URL), eq(accessToken), urlCaptor.capture())).thenReturn(response);
+
+    underTest.get(APP_URL, accessToken, ENDPOINT + "?alreadyExistingArg=2", result -> gson.fromJson(result, STRING_LIST_TYPE));
+
+    assertThat(urlCaptor.getValue()).isEqualTo(ENDPOINT + "?alreadyExistingArg=2&per_page=100");
+  }
+
+  private static GetResponse mockResponseWithoutPagination(String content) {
+    GetResponse response = mock(GetResponse.class);
+    when(response.getCode()).thenReturn(200);
+    when(response.getContent()).thenReturn(Optional.of(content));
+    return response;
+  }
+
+  @Test
+  public void get_whenPaginationAndRateLimiting_returnsResponseFromAllPages() throws IOException, InterruptedException {
+    GetResponse response1 = mockResponseWithPaginationAndRateLimit("[\"result1\", \"result2\"]", "/next-endpoint");
+    GetResponse response2 = mockResponseWithoutPagination("[\"result3\"]");
+    when(appHttpClient.get(APP_URL, accessToken, ENDPOINT + "?per_page=100")).thenReturn(response1);
+    when(appHttpClient.get(APP_URL, accessToken, "/next-endpoint")).thenReturn(response2);
+
+    List<String> results = underTest.get(APP_URL, accessToken, ENDPOINT, result -> gson.fromJson(result, STRING_LIST_TYPE));
+
+    assertThat(results)
+      .containsExactly("result1", "result2", "result3");
+
+    ArgumentCaptor<ApplicationHttpClient.RateLimit> rateLimitRecordCaptor = ArgumentCaptor.forClass(ApplicationHttpClient.RateLimit.class);
+    verify(rateLimitChecker).checkRateLimit(rateLimitRecordCaptor.capture());
+    ApplicationHttpClient.RateLimit rateLimitRecord = rateLimitRecordCaptor.getValue();
+    assertThat(rateLimitRecord.limit()).isEqualTo(10);
+    assertThat(rateLimitRecord.remaining()).isEqualTo(1);
+    assertThat(rateLimitRecord.reset()).isZero();
+  }
+
+  private static GetResponse mockResponseWithPaginationAndRateLimit(String content, String nextEndpoint) {
+    GetResponse response = mockResponseWithoutPagination(content);
+    when(response.getCode()).thenReturn(200);
+    when(response.getNextEndPoint()).thenReturn(Optional.of(nextEndpoint));
+    when(response.getRateLimit()).thenReturn(new ApplicationHttpClient.RateLimit(1, 10, 0L));
+    return response;
+  }
+
+  @Test
+  public void get_whenGitHubReturnsNonSuccessCode_shouldThrow() throws IOException {
+    GetResponse response1 = mockResponseWithPaginationAndRateLimit("[\"result1\", \"result2\"]", "/next-endpoint");
+    GetResponse response2 = mockFailedResponse("failed");
+    when(appHttpClient.get(APP_URL, accessToken, ENDPOINT + "?per_page=100")).thenReturn(response1);
+    when(appHttpClient.get(APP_URL, accessToken, "/next-endpoint")).thenReturn(response2);
+
+    assertThatIllegalStateException()
+      .isThrownBy(() -> underTest.get(APP_URL, accessToken, ENDPOINT, result -> gson.fromJson(result, STRING_LIST_TYPE)))
+      .withMessage("SonarQube was not able to retrieve resources from external system. Error while executing a paginated call to https://github.com/, endpoint:/next-endpoint. "
+                   + "Error while executing a call to https://github.com/. Return code 400. Error message: failed.");
+  }
+
+  private static GetResponse mockFailedResponse(String content) {
+    GetResponse response = mock(GetResponse.class);
+    when(response.getCode()).thenReturn(400);
+    when(response.getContent()).thenReturn(Optional.of(content));
+    return response;
+  }
+
+  @Test
+  public void getRepositoryTeams_whenRateLimitCheckerThrowsInterruptedException_shouldSucceed() throws IOException, InterruptedException {
+    GetResponse response1 = mockResponseWithPaginationAndRateLimit("[\"result1\", \"result2\"]", "/next-endpoint");
+    GetResponse response2 = mockResponseWithoutPagination("[\"result3\"]");
+    when(appHttpClient.get(APP_URL, accessToken, ENDPOINT + "?per_page=100")).thenReturn(response1);
+    when(appHttpClient.get(APP_URL, accessToken, "/next-endpoint")).thenReturn(response2);
+    doThrow(new InterruptedException("interrupted")).when(rateLimitChecker).checkRateLimit(any(ApplicationHttpClient.RateLimit.class));
+
+    assertThatNoException()
+      .isThrownBy(() -> underTest.get(APP_URL, accessToken, ENDPOINT, result -> gson.fromJson(result, STRING_LIST_TYPE)));
+
+    assertThat(logTester.logs()).hasSize(1);
+    assertThat(logTester.logs(Level.WARN))
+      .containsExactly("Thread interrupted: interrupted");
+  }
+
+  @Test
+  public void getRepositoryCollaborators_whenDevOpsPlatformCallThrowsIOException_shouldLogAndReThrow() throws IOException {
+    AccessToken accessToken = mock();
+    when(appHttpClient.get(APP_URL, accessToken, "query?per_page=100")).thenThrow(new IOException("error"));
+
+    assertThatIllegalStateException()
+      .isThrownBy(() -> underTest.get(APP_URL, accessToken, "query", mock()))
+      .isInstanceOf(IllegalStateException.class)
+      .withMessage("SonarQube was not able to retrieve resources from external system. Error while executing a paginated call to https://github.com/, "
+                   + "endpoint:query?per_page=100. error");
+
+    assertThat(logTester.logs()).hasSize(1);
+    assertThat(logTester.logs(Level.WARN))
+      .containsExactly("SonarQube was not able to retrieve resources from external system. "
+                       + "Error while executing a paginated call to https://github.com/, endpoint:query?per_page=100.");
+  }
+}
diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/RatioBasedRateLimitCheckerTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/RatioBasedRateLimitCheckerTest.java
new file mode 100644 (file)
index 0000000..d6d2bff
--- /dev/null
@@ -0,0 +1,83 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.slf4j.event.Level;
+import org.sonar.api.testfixtures.log.LogTester;
+
+import static java.lang.String.format;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.sonar.alm.client.RatioBasedRateLimitChecker.RATE_RATIO_EXCEEDED_MESSAGE;
+
+@RunWith(DataProviderRunner.class)
+public class RatioBasedRateLimitCheckerTest {
+
+  @Rule
+  public LogTester logTester = new LogTester();
+  private static final long MILLIS_BEFORE_RESET = 100L;
+  RatioBasedRateLimitChecker ratioBasedRateLimitChecker = new RatioBasedRateLimitChecker();
+
+  @DataProvider
+  public static Object[][] rates() {
+    return new Object[][] {
+      {10000, 100000, false},
+      {10000, 10000, false},
+      {10000, 9999, false},
+      {10000, 9900, false},
+      {10000, 1001, false},
+      {10000, 1000, true},
+      {10000, 500, true},
+      {10000, 0, true},
+    };
+  }
+
+  @Test
+  @UseDataProvider("rates")
+  public void checkRateLimit(int limit, int remaining, boolean rateLimitShouldBeExceeded) throws InterruptedException {
+    ApplicationHttpClient.RateLimit record = mock();
+    when(record.limit()).thenReturn(limit);
+    when(record.remaining()).thenReturn(remaining);
+    when(record.reset()).thenReturn(System.currentTimeMillis() / 1000 + 1);
+
+    long start = System.currentTimeMillis();
+    boolean result = ratioBasedRateLimitChecker.checkRateLimit(record);
+    long stop = System.currentTimeMillis();
+    long totalTime = stop - start;
+
+    if (rateLimitShouldBeExceeded) {
+      assertThat(result).isTrue();
+      assertThat(stop).isGreaterThanOrEqualTo(record.reset());
+      assertThat(logTester.logs(Level.WARN)).contains(
+        format(RATE_RATIO_EXCEEDED_MESSAGE.replaceAll("\\{\\}", "%s"), limit - remaining, limit));
+    } else {
+      assertThat(result).isFalse();
+      assertThat(totalTime).isLessThan(MILLIS_BEFORE_RESET);
+      assertThat(logTester.logs(Level.WARN)).isEmpty();
+    }
+  }
+}
index 53a6fcf08c94a462ee15c7b9d4ab1552de55c4af..dcd2fdab87cd3dadbe7f8d62670807391d9f9193 100644 (file)
@@ -36,9 +36,11 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.slf4j.event.Level;
 import org.sonar.alm.client.ConstantTimeoutConfiguration;
+import org.sonar.alm.client.DevopsPlatformHeaders;
+import org.sonar.alm.client.GenericApplicationHttpClient;
 import org.sonar.alm.client.TimeoutConfiguration;
-import org.sonar.alm.client.github.ApplicationHttpClient.GetResponse;
-import org.sonar.alm.client.github.ApplicationHttpClient.Response;
+import org.sonar.alm.client.ApplicationHttpClient.GetResponse;
+import org.sonar.alm.client.ApplicationHttpClient.Response;
 import org.sonar.alm.client.github.security.AccessToken;
 import org.sonar.alm.client.github.security.UserAccessToken;
 import org.sonar.api.testfixtures.log.LogTester;
@@ -49,7 +51,7 @@ 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;
-import static org.sonar.alm.client.github.ApplicationHttpClient.RateLimit;
+import static org.sonar.alm.client.ApplicationHttpClient.RateLimit;
 
 @RunWith(DataProviderRunner.class)
 public class GenericApplicationHttpClientTest {
@@ -76,7 +78,7 @@ public class GenericApplicationHttpClientTest {
     logTester.clear();
   }
 
-  private class TestApplicationHttpClient extends GenericApplicationHttpClient {
+  private static class TestApplicationHttpClient extends GenericApplicationHttpClient {
     public TestApplicationHttpClient(DevopsPlatformHeaders devopsPlatformHeaders, TimeoutConfiguration timeoutConfiguration) {
       super(devopsPlatformHeaders, timeoutConfiguration);
     }
@@ -183,7 +185,7 @@ public class GenericApplicationHttpClientTest {
   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\""));
+                         "<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\""));
 
     GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
 
@@ -212,18 +214,30 @@ public class GenericApplicationHttpClientTest {
     assertThat(response.getNextEndPoint()).contains("https://api.github.com/installation/repositories?per_page=5&page=2");
   }
 
+  @Test
+  public void get_returns_endPoint_when_link_header_is_from_gitlab() throws IOException {
+    String linkHeader = "<https://gitlab.com/api/v4/groups?all_available=false&order_by=name&owned=false&page=2&per_page=2&sort=asc&statistics=false&with_custom_attributes=false>; rel=\"next\", <https://gitlab.com/api/v4/groups?all_available=false&order_by=name&owned=false&page=1&per_page=2&sort=asc&statistics=false&with_custom_attributes=false>; rel=\"first\", <https://gitlab.com/api/v4/groups?all_available=false&order_by=name&owned=false&page=8&per_page=2&sort=asc&statistics=false&with_custom_attributes=false>; rel=\"last\"";
+    server.enqueue(new MockResponse().setBody(randomBody)
+      .setHeader("link", linkHeader));
+
+    GetResponse response = underTest.get(appUrl, accessToken, randomEndPoint);
+
+    assertThat(response.getNextEndPoint()).contains("https://gitlab.com/api/v4/groups?all_available=false"
+                                                    + "&order_by=name&owned=false&page=2&per_page=2&sort=asc&statistics=false&with_custom_attributes=false");
+  }
+
   @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\""},
       {"<https://api.github.com/installation/repositories?per_page=5&page=1>; rel=\"first\", " +
-        "<" + expected + ">; rel=\"next\""},
+       "<" + 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\""},
+       "<" + expected + ">; rel=\"next\", " +
+       "<https://api.github.com/installation/repositories?per_page=5&page=5>; rel=\"last\""},
     };
   }
 
@@ -416,14 +430,19 @@ public class GenericApplicationHttpClientTest {
 
   @Test
   public void get_whenRateLimitHeadersArePresent_returnsRateLimit() throws Exception {
-    testRateLimitHeader(() -> underTest.get(appUrl, accessToken, randomEndPoint));
+    testRateLimitHeader(() -> underTest.get(appUrl, accessToken, randomEndPoint), false);
+  }
+
+  @Test
+  public void get_whenRateLimitHeadersArePresentAndUppercased_returnsRateLimit() throws Exception {
+    testRateLimitHeader(() -> underTest.get(appUrl, accessToken, randomEndPoint), true);
   }
 
-  private void testRateLimitHeader(Callable<Response> request ) throws Exception {
+  private void testRateLimitHeader(Callable<Response> request, boolean uppercasedHeaders) throws Exception {
     server.enqueue(new MockResponse().setBody(randomBody)
-      .setHeader("x-ratelimit-remaining", "1")
-      .setHeader("x-ratelimit-limit", "10")
-      .setHeader("x-ratelimit-reset", "1000"));
+      .setHeader(uppercasedHeaders ? "x-ratelimit-remaining" : "x-ratelimit-REMAINING", "1")
+      .setHeader(uppercasedHeaders ? "x-ratelimit-limit" : "X-RATELIMIT-LIMIT", "10")
+      .setHeader(uppercasedHeaders ? "x-ratelimit-reset" : "X-ratelimit-reset", "1000"));
 
     Response response = request.call();
 
@@ -438,7 +457,7 @@ public class GenericApplicationHttpClientTest {
 
   }
 
-  private void testMissingRateLimitHeader(Callable<Response> request ) throws Exception {
+  private void testMissingRateLimitHeader(Callable<Response> request) throws Exception {
     server.enqueue(new MockResponse().setBody(randomBody));
 
     Response response = request.call();
@@ -448,7 +467,7 @@ public class GenericApplicationHttpClientTest {
 
   @Test
   public void delete_whenRateLimitHeadersArePresent_returnsRateLimit() throws Exception {
-    testRateLimitHeader(() -> underTest.delete(appUrl, accessToken, randomEndPoint));
+    testRateLimitHeader(() -> underTest.delete(appUrl, accessToken, randomEndPoint), false);
 
   }
 
@@ -460,7 +479,7 @@ public class GenericApplicationHttpClientTest {
 
   @Test
   public void patch_whenRateLimitHeadersArePresent_returnsRateLimit() throws Exception {
-    testRateLimitHeader(() -> underTest.patch(appUrl, accessToken, randomEndPoint, "body"));
+    testRateLimitHeader(() -> underTest.patch(appUrl, accessToken, randomEndPoint, "body"), false);
   }
 
   @Test
@@ -470,7 +489,7 @@ public class GenericApplicationHttpClientTest {
 
   @Test
   public void post_whenRateLimitHeadersArePresent_returnsRateLimit() throws Exception {
-    testRateLimitHeader(() -> underTest.post(appUrl, accessToken, randomEndPoint));
+    testRateLimitHeader(() -> underTest.post(appUrl, accessToken, randomEndPoint), false);
   }
 
   @Test
index 6cf2f71dfc9909ce8150368636b8dc45e668b664..a7a3d2203d24e5c301d03867556053bf8e9bda1e 100644 (file)
@@ -36,7 +36,7 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
 import org.slf4j.event.Level;
-import org.sonar.alm.client.github.ApplicationHttpClient.RateLimit;
+import org.sonar.alm.client.ApplicationHttpClient.RateLimit;
 import org.sonar.alm.client.github.api.GsonRepositoryCollaborator;
 import org.sonar.alm.client.github.api.GsonRepositoryTeam;
 import org.sonar.alm.client.github.config.GithubAppConfiguration;
@@ -68,7 +68,7 @@ import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
-import static org.sonar.alm.client.github.ApplicationHttpClient.GetResponse;
+import static org.sonar.alm.client.ApplicationHttpClient.GetResponse;
 
 @RunWith(DataProviderRunner.class)
 public class GithubApplicationClientImplTest {
@@ -114,12 +114,12 @@ public class GithubApplicationClientImplTest {
   @ClassRule
   public static LogTester logTester = new LogTester().setLevel(LoggerLevel.WARN);
 
-  private GenericApplicationHttpClient httpClient = mock();
+  private GithubApplicationHttpClient githubApplicationHttpClient = mock();
   private GithubAppSecurity appSecurity = mock();
   private GithubAppConfiguration githubAppConfiguration = mock();
   private GitHubSettings gitHubSettings = mock();
 
-  private PaginatedHttpClient githubPaginatedHttpClient = mock();
+  private GithubPaginatedHttpClient githubPaginatedHttpClient = mock();
   private AppInstallationToken appInstallationToken = mock();
   private GithubApplicationClient underTest;
 
@@ -129,7 +129,7 @@ public class GithubApplicationClientImplTest {
   @Before
   public void setup() {
     when(githubAppConfiguration.getApiEndpoint()).thenReturn(appUrl);
-    underTest = new GithubApplicationClientImpl(httpClient, appSecurity, gitHubSettings, githubPaginatedHttpClient);
+    underTest = new GithubApplicationClientImpl(githubApplicationHttpClient, appSecurity, gitHubSettings, githubPaginatedHttpClient);
     logTester.clear();
   }
 
@@ -179,7 +179,7 @@ public class GithubApplicationClientImplTest {
   public void checkAppPermissions_IOException() throws IOException {
     AppToken appToken = mockAppToken();
 
-    when(httpClient.get(appUrl, appToken, "/app")).thenThrow(new IOException("OOPS"));
+    when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenThrow(new IOException("OOPS"));
 
     assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
       .isInstanceOf(IllegalArgumentException.class)
@@ -191,7 +191,7 @@ public class GithubApplicationClientImplTest {
   public void checkAppPermissions_ErrorCodes(int errorCode, String expectedMessage) throws IOException {
     AppToken appToken = mockAppToken();
 
-    when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new ErrorGetResponse(errorCode, null));
+    when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new ErrorGetResponse(errorCode, null));
 
     assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
       .isInstanceOf(IllegalArgumentException.class)
@@ -211,7 +211,7 @@ public class GithubApplicationClientImplTest {
   public void checkAppPermissions_MissingPermissions() throws IOException {
     AppToken appToken = mockAppToken();
 
-    when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse("{}"));
+    when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse("{}"));
 
     assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
       .isInstanceOf(IllegalArgumentException.class)
@@ -230,7 +230,7 @@ public class GithubApplicationClientImplTest {
                   + "      }\n"
                   + "}";
 
-    when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
+    when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
 
     assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration))
       .isInstanceOf(IllegalArgumentException.class)
@@ -249,7 +249,7 @@ public class GithubApplicationClientImplTest {
                   + "      }\n"
                   + "}";
 
-    when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
+    when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
 
     assertThatCode(() -> underTest.checkAppPermissions(githubAppConfiguration)).isNull();
   }
@@ -258,7 +258,7 @@ public class GithubApplicationClientImplTest {
   public void getInstallationId_returns_installation_id_of_given_account() throws IOException {
     AppToken appToken = new AppToken(APP_JWT_TOKEN);
     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
-    when(httpClient.get(appUrl, appToken, "/repos/torvalds/linux/installation"))
+    when(githubApplicationHttpClient.get(appUrl, appToken, "/repos/torvalds/linux/installation"))
       .thenReturn(new OkGetResponse("{" +
         "  \"id\": 2," +
         "  \"account\": {" +
@@ -281,7 +281,7 @@ public class GithubApplicationClientImplTest {
   public void getInstallationId_return_empty_if_no_installation_found_for_githubAccount() throws IOException {
     AppToken appToken = new AppToken(APP_JWT_TOKEN);
     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
-    when(httpClient.get(appUrl, appToken, "/repos/torvalds/linux/installation"))
+    when(githubApplicationHttpClient.get(appUrl, appToken, "/repos/torvalds/linux/installation"))
       .thenReturn(new ErrorGetResponse(404, null));
 
     assertThat(underTest.getInstallationId(githubAppConfiguration, "torvalds")).isEmpty();
@@ -290,44 +290,44 @@ public class GithubApplicationClientImplTest {
   @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"))
+    when(githubApplicationHttpClient.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");
+    verify(githubApplicationHttpClient).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"))
+    when(githubApplicationHttpClient.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");
+    verify(githubApplicationHttpClient).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"))
+    when(githubApplicationHttpClient.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");
+    verify(githubApplicationHttpClient).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"))
+    when(githubApplicationHttpClient.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");
@@ -335,14 +335,14 @@ public class GithubApplicationClientImplTest {
     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");
+    verify(githubApplicationHttpClient).post(appUrl, null, "/login/oauth/access_token?client_id=clientId&client_secret=clientSecret&code=code");
   }
 
   @Test
   public void getApp_returns_id() throws IOException {
     AppToken appToken = new AppToken(APP_JWT_TOKEN);
     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
-    when(httpClient.get(appUrl, appToken, "/app"))
+    when(githubApplicationHttpClient.get(appUrl, appToken, "/app"))
       .thenReturn(new OkGetResponse("{\"installations_count\": 2}"));
 
     assertThat(underTest.getApp(githubAppConfiguration).getInstallationsCount()).isEqualTo(2L);
@@ -352,7 +352,7 @@ public class GithubApplicationClientImplTest {
   public void getApp_whenStatusCodeIsNotOk_shouldThrowHttpException() throws IOException {
     AppToken appToken = new AppToken(APP_JWT_TOKEN);
     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
-    when(httpClient.get(appUrl, appToken, "/app"))
+    when(githubApplicationHttpClient.get(appUrl, appToken, "/app"))
       .thenReturn(new ErrorGetResponse(418, "I'm a teapot"));
 
     assertThatThrownBy(() -> underTest.getApp(githubAppConfiguration))
@@ -378,7 +378,7 @@ public class GithubApplicationClientImplTest {
     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)))
+    when(githubApplicationHttpClient.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))
@@ -413,7 +413,7 @@ public class GithubApplicationClientImplTest {
                           + "  \"total_count\": 0\n"
                           + "} ";
 
-    when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
+    when(githubApplicationHttpClient.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);
@@ -504,7 +504,7 @@ public class GithubApplicationClientImplTest {
                           + "  ]\n"
                           + "} ";
 
-    when(httpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
+    when(githubApplicationHttpClient.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);
@@ -581,18 +581,14 @@ public class GithubApplicationClientImplTest {
   }
 
   @Test
-  public void getWhitelistedGithubAppInstallations_whenGithubReturnsError_shouldThrow() throws IOException {
+  public void getWhitelistedGithubAppInstallations_whenGithubReturnsError_shouldReThrow() {
     AppToken appToken = new AppToken(APP_JWT_TOKEN);
     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
-    when(githubPaginatedHttpClient.get(any(), any(), any(), any())).thenThrow(new IOException("io exception"));
+    when(githubPaginatedHttpClient.get(any(), any(), any(), any())).thenThrow(new IllegalStateException("exception"));
 
     assertThatThrownBy(() -> underTest.getWhitelistedGithubAppInstallations(githubAppConfiguration))
       .isInstanceOf(IllegalStateException.class)
-      .hasMessage(
-        "SonarQube was not able to retrieve resources from GitHub. "
-        + "This is likely due to a connectivity problem or a temporary network outage: "
-        + "Error while executing a paginated call to GitHub - appUrl: Any URL, path: /app/installations. io exception"
-        );
+      .hasMessage("exception");
   }
 
   @Test
@@ -600,7 +596,7 @@ public class GithubApplicationClientImplTest {
     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)))
+    when(githubApplicationHttpClient.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))
@@ -635,7 +631,7 @@ public class GithubApplicationClientImplTest {
                           + "  \"total_count\": 0\n"
                           + "}";
 
-    when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
+    when(githubApplicationHttpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
       .thenReturn(new OkGetResponse(responseJson));
 
     GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100);
@@ -723,7 +719,7 @@ public class GithubApplicationClientImplTest {
                           + "  ]\n"
                           + "}";
 
-    when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
+    when(githubApplicationHttpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
       .thenReturn(new OkGetResponse(responseJson));
     GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100);
 
@@ -778,7 +774,7 @@ public class GithubApplicationClientImplTest {
                           + "  ]\n"
                           + "}";
 
-    when(httpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "world+fork:true+org:github", 1, 100)))
+    when(githubApplicationHttpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "world+fork:true+org:github", 1, 100)))
       .thenReturn(new GetResponse() {
         @Override
         public Optional<String> getNextEndPoint() {
@@ -811,7 +807,7 @@ public class GithubApplicationClientImplTest {
 
   @Test
   public void getRepository_returns_empty_when_repository_doesnt_exist() throws IOException {
-    when(httpClient.get(any(), any(), any()))
+    when(githubApplicationHttpClient.get(any(), any(), any()))
       .thenReturn(new Response(404, null));
 
     Optional<GithubApplicationClient.Repository> repository = underTest.getRepository(appUrl, new UserAccessToken("temp"), "octocat/Hello-World");
@@ -823,7 +819,7 @@ public class GithubApplicationClientImplTest {
   public void getRepository_fails_on_failure() throws IOException {
     String repositoryKey = "octocat/Hello-World";
 
-    when(httpClient.get(any(), any(), any()))
+    when(githubApplicationHttpClient.get(any(), any(), any()))
       .thenThrow(new IOException("OOPS"));
 
     UserAccessToken token = new UserAccessToken("temp");
@@ -974,7 +970,7 @@ public class GithubApplicationClientImplTest {
                           + "  }"
                           + "}";
 
-    when(httpClient.get(appUrl, accessToken, "/repos/octocat/Hello-World"))
+    when(githubApplicationHttpClient.get(appUrl, accessToken, "/repos/octocat/Hello-World"))
       .thenReturn(new GetResponse() {
         @Override
         public Optional<String> getNextEndPoint() {
@@ -1022,7 +1018,7 @@ public class GithubApplicationClientImplTest {
   @Test
   public void createAppInstallationToken_returns_empty_if_post_throws_IOE() throws IOException {
     mockAppToken();
-    when(httpClient.post(anyString(), any(AccessToken.class), anyString())).thenThrow(IOException.class);
+    when(githubApplicationHttpClient.post(anyString(), any(AccessToken.class), anyString())).thenThrow(IOException.class);
     Optional<AppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
 
     assertThat(accessToken).isEmpty();
@@ -1037,7 +1033,7 @@ public class GithubApplicationClientImplTest {
     Optional<AppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
 
     assertThat(accessToken).isEmpty();
-    verify(httpClient).post(appUrl, appToken, "/app/installations/" + INSTALLATION_ID + "/access_tokens");
+    verify(githubApplicationHttpClient).post(appUrl, appToken, "/app/installations/" + INSTALLATION_ID + "/access_tokens");
   }
 
   @Test
@@ -1048,7 +1044,7 @@ public class GithubApplicationClientImplTest {
     Optional<AppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
 
     assertThat(accessToken).hasValue(installToken);
-    verify(httpClient).post(appUrl, appToken, "/app/installations/" + INSTALLATION_ID + "/access_tokens");
+    verify(githubApplicationHttpClient).post(appUrl, appToken, "/app/installations/" + INSTALLATION_ID + "/access_tokens");
   }
 
   @Test
@@ -1067,18 +1063,12 @@ public class GithubApplicationClientImplTest {
   }
 
   @Test
-  public void getRepositoryTeams_whenGitHubCallThrowsIOException_shouldLogAndThrow() throws IOException {
-    when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_TEAMS_ENDPOINT), any())).thenThrow(new IOException("error"));
+  public void getRepositoryTeams_whenGitHubCallThrowsException_shouldRethrow() {
+    when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_TEAMS_ENDPOINT), any())).thenThrow(new IllegalStateException("error"));
 
     assertThatIllegalStateException()
       .isThrownBy(() -> underTest.getRepositoryTeams(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME))
-      .isInstanceOf(IllegalStateException.class)
-      .withMessage(
-        "SonarQube was not able to retrieve resources from GitHub. This is likely due to a connectivity problem or a temporary network outage: Error while executing a paginated call to GitHub - appUrl: https://github.com/, path: /repos/ORG_NAME/repo1/teams. error");
-
-    assertThat(logTester.logs()).hasSize(1);
-    assertThat(logTester.logs(Level.WARN))
-      .containsExactly("Error while executing a paginated call to GitHub - appUrl: https://github.com/, path: /repos/ORG_NAME/repo1/teams.");
+      .withMessage("error");
   }
 
   private static List<GsonRepositoryTeam> expectedTeams() {
@@ -1104,19 +1094,12 @@ public class GithubApplicationClientImplTest {
   }
 
   @Test
-  public void getRepositoryCollaborators_whenGitHubCallThrowsIOException_shouldLogAndThrow() throws IOException {
-    when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_COLLABORATORS_ENDPOINT), any())).thenThrow(new IOException("error"));
+  public void getRepositoryCollaborators_whenGitHubCallThrowsException_shouldRethrow() {
+    when(githubPaginatedHttpClient.get(eq(APP_URL), eq(appInstallationToken), eq(REPO_COLLABORATORS_ENDPOINT), any())).thenThrow(new IllegalStateException("error"));
 
     assertThatIllegalStateException()
       .isThrownBy(() -> underTest.getRepositoryCollaborators(APP_URL, appInstallationToken, ORG_NAME, REPO_NAME))
-      .isInstanceOf(IllegalStateException.class)
-      .withMessage(
-        "SonarQube was not able to retrieve resources from GitHub. This is likely due to a connectivity problem or a temporary network outage: "
-        + "Error while executing a paginated call to GitHub - appUrl: https://github.com/, path: /repos/ORG_NAME/repo1/collaborators?affiliation=direct. error");
-
-    assertThat(logTester.logs()).hasSize(1);
-    assertThat(logTester.logs(Level.WARN))
-      .containsExactly("Error while executing a paginated call to GitHub - appUrl: https://github.com/, path: /repos/ORG_NAME/repo1/collaborators?affiliation=direct.");
+      .withMessage("error");
   }
 
   private static String getResponseContent(String path) throws IOException {
@@ -1133,7 +1116,7 @@ public class GithubApplicationClientImplTest {
     Response response = mock(Response.class);
     when(response.getContent()).thenReturn(Optional.empty());
     when(response.getCode()).thenReturn(HTTP_UNAUTHORIZED);
-    when(httpClient.post(eq(appUrl), any(AppToken.class), eq("/app/installations/" + INSTALLATION_ID + "/access_tokens"))).thenReturn(response);
+    when(githubApplicationHttpClient.post(eq(appUrl), any(AppToken.class), eq("/app/installations/" + INSTALLATION_ID + "/access_tokens"))).thenReturn(response);
   }
 
   private AppToken mockAppToken() {
@@ -1149,7 +1132,7 @@ public class GithubApplicationClientImplTest {
                                                        "  \"token\": \"" + token + "\"" +
                                                        "}"));
     when(response.getCode()).thenReturn(HTTP_CREATED);
-    when(httpClient.post(eq(appUrl), any(AppToken.class), eq("/app/installations/" + INSTALLATION_ID + "/access_tokens"))).thenReturn(response);
+    when(githubApplicationHttpClient.post(eq(appUrl), any(AppToken.class), eq("/app/installations/" + INSTALLATION_ID + "/access_tokens"))).thenReturn(response);
     return new AppInstallationToken(token);
   }
 
diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubPaginatedHttpClientImplTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubPaginatedHttpClientImplTest.java
deleted file mode 100644 (file)
index 5df514c..0000000
+++ /dev/null
@@ -1,169 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 com.google.gson.reflect.TypeToken;
-import java.io.IOException;
-import java.lang.reflect.Type;
-import java.util.List;
-import java.util.Optional;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.junit.MockitoJUnitRunner;
-import org.slf4j.event.Level;
-import org.sonar.alm.client.github.security.AccessToken;
-import org.sonar.api.testfixtures.log.LogTester;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
-import static org.assertj.core.api.Assertions.assertThatNoException;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.doThrow;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-import static org.sonar.alm.client.github.ApplicationHttpClient.GetResponse;
-
-@RunWith(MockitoJUnitRunner.class)
-public class GithubPaginatedHttpClientImplTest {
-
-  private static final String APP_URL = "https://github.com/";
-
-  private static final String ENDPOINT = "/test-endpoint";
-
-  private static final Type STRING_LIST_TYPE = TypeToken.getParameterized(List.class, String.class).getType();
-
-  private Gson gson = new Gson();
-
-  @Rule
-  public LogTester logTester = new LogTester();
-
-  @Mock
-  private AccessToken accessToken;
-
-  @Mock
-  RatioBasedRateLimitChecker rateLimitChecker;
-
-  @Mock
-  ApplicationHttpClient appHttpClient;
-
-  @InjectMocks
-  private GithubPaginatedHttpClient underTest;
-
-  @Test
-  public void get_whenNoPagination_ReturnsCorrectResponse() throws IOException {
-
-    GetResponse response = mockResponseWithoutPagination("[\"result1\", \"result2\"]");
-    when(appHttpClient.get(APP_URL, accessToken, ENDPOINT + "?per_page=100")).thenReturn(response);
-
-    List<String> results = underTest.get(APP_URL, accessToken, ENDPOINT, result -> gson.fromJson(result, STRING_LIST_TYPE));
-
-    assertThat(results)
-      .containsExactly("result1", "result2");
-  }
-
-  @Test
-  public void get_whenEndpointAlreadyContainsPathParameter_shouldAddANewParameter() throws IOException {
-    ArgumentCaptor<String> urlCaptor = ArgumentCaptor.forClass(String.class);
-
-    GetResponse response = mockResponseWithoutPagination("[\"result1\", \"result2\"]");
-    when(appHttpClient.get(eq(APP_URL), eq(accessToken), urlCaptor.capture())).thenReturn(response);
-
-    underTest.get(APP_URL, accessToken, ENDPOINT + "?alreadyExistingArg=2", result -> gson.fromJson(result, STRING_LIST_TYPE));
-
-    assertThat(urlCaptor.getValue()).isEqualTo(ENDPOINT + "?alreadyExistingArg=2&per_page=100");
-  }
-
-  private static GetResponse mockResponseWithoutPagination(String content) {
-    GetResponse response = mock(GetResponse.class);
-    when(response.getCode()).thenReturn(200);
-    when(response.getContent()).thenReturn(Optional.of(content));
-    return response;
-  }
-
-  @Test
-  public void get_whenPaginationAndRateLimiting_returnsResponseFromAllPages() throws IOException, InterruptedException {
-    GetResponse response1 = mockResponseWithPaginationAndRateLimit("[\"result1\", \"result2\"]", "/next-endpoint");
-    GetResponse response2 = mockResponseWithoutPagination("[\"result3\"]");
-    when(appHttpClient.get(APP_URL, accessToken, ENDPOINT + "?per_page=100")).thenReturn(response1);
-    when(appHttpClient.get(APP_URL, accessToken, "/next-endpoint")).thenReturn(response2);
-
-    List<String> results = underTest.get(APP_URL, accessToken, ENDPOINT, result -> gson.fromJson(result, STRING_LIST_TYPE));
-
-    assertThat(results)
-      .containsExactly("result1", "result2", "result3");
-
-    ArgumentCaptor<ApplicationHttpClient.RateLimit> rateLimitRecordCaptor = ArgumentCaptor.forClass(ApplicationHttpClient.RateLimit.class);
-    verify(rateLimitChecker).checkRateLimit(rateLimitRecordCaptor.capture());
-    ApplicationHttpClient.RateLimit rateLimitRecord = rateLimitRecordCaptor.getValue();
-    assertThat(rateLimitRecord.limit()).isEqualTo(10);
-    assertThat(rateLimitRecord.remaining()).isEqualTo(1);
-    assertThat(rateLimitRecord.reset()).isZero();
-  }
-
-  private static GetResponse mockResponseWithPaginationAndRateLimit(String content, String nextEndpoint) {
-    GetResponse response = mockResponseWithoutPagination(content);
-    when(response.getCode()).thenReturn(200);
-    when(response.getNextEndPoint()).thenReturn(Optional.of(nextEndpoint));
-    when(response.getRateLimit()).thenReturn(new ApplicationHttpClient.RateLimit(1, 10, 0L));
-    return response;
-  }
-
-  @Test
-  public void get_whenGitHubReturnsNonSuccessCode_shouldThrow() throws IOException {
-    GetResponse response1 = mockResponseWithPaginationAndRateLimit("[\"result1\", \"result2\"]", "/next-endpoint");
-    GetResponse response2 = mockFailedResponse("failed");
-    when(appHttpClient.get(APP_URL, accessToken, ENDPOINT + "?per_page=100")).thenReturn(response1);
-    when(appHttpClient.get(APP_URL, accessToken, "/next-endpoint")).thenReturn(response2);
-
-    assertThatIllegalStateException()
-      .isThrownBy(() -> underTest.get(APP_URL, accessToken, ENDPOINT, result -> gson.fromJson(result, STRING_LIST_TYPE)))
-      .withMessage("Error while executing a call to GitHub. Return code 400. Error message: failed.");
-  }
-
-  private static GetResponse mockFailedResponse(String content) {
-    GetResponse response = mock(GetResponse.class);
-    when(response.getCode()).thenReturn(400);
-    when(response.getContent()).thenReturn(Optional.of(content));
-    return response;
-  }
-
-  @Test
-  public void getRepositoryTeams_whenRateLimitCheckerThrowsInterruptedException_shouldSucceed() throws IOException, InterruptedException {
-    GetResponse response1 = mockResponseWithPaginationAndRateLimit("[\"result1\", \"result2\"]", "/next-endpoint");
-    GetResponse response2 = mockResponseWithoutPagination("[\"result3\"]");
-    when(appHttpClient.get(APP_URL, accessToken, ENDPOINT + "?per_page=100")).thenReturn(response1);
-    when(appHttpClient.get(APP_URL, accessToken, "/next-endpoint")).thenReturn(response2);
-    doThrow(new InterruptedException("interrupted")).when(rateLimitChecker).checkRateLimit(any(ApplicationHttpClient.RateLimit.class));
-
-    assertThatNoException()
-      .isThrownBy(() -> underTest.get(APP_URL, accessToken, ENDPOINT, result -> gson.fromJson(result, STRING_LIST_TYPE)));
-
-    assertThat(logTester.logs()).hasSize(1);
-    assertThat(logTester.logs(Level.WARN))
-      .containsExactly("Thread interrupted: interrupted");
-  }
-}
diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/RatioBasedRateLimitCheckerTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/RatioBasedRateLimitCheckerTest.java
deleted file mode 100644 (file)
index d106333..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.slf4j.event.Level;
-import org.sonar.api.testfixtures.log.LogTester;
-
-import static java.lang.String.format;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-import static org.sonar.alm.client.github.RatioBasedRateLimitChecker.RATE_RATIO_EXCEEDED_MESSAGE;
-
-@RunWith(DataProviderRunner.class)
-public class RatioBasedRateLimitCheckerTest {
-
-  @Rule
-  public LogTester logTester = new LogTester();
-  private static final long MILLIS_BEFORE_RESET = 100L;
-  RatioBasedRateLimitChecker ratioBasedRateLimitChecker = new RatioBasedRateLimitChecker();
-
-  @DataProvider
-  public static Object[][] rates() {
-    return new Object[][] {
-      {10000, 100000, false},
-      {10000, 10000, false},
-      {10000, 9999, false},
-      {10000, 9900, false},
-      {10000, 1001, false},
-      {10000, 1000, true},
-      {10000, 500, true},
-      {10000, 0, true},
-    };
-  }
-
-  @Test
-  @UseDataProvider("rates")
-  public void checkRateLimit(int limit, int remaining, boolean rateLimitShouldBeExceeded) throws InterruptedException {
-    ApplicationHttpClient.RateLimit record = mock();
-    when(record.limit()).thenReturn(limit);
-    when(record.remaining()).thenReturn(remaining);
-    when(record.reset()).thenReturn(System.currentTimeMillis() / 1000 + 1);
-
-    long start = System.currentTimeMillis();
-    boolean result = ratioBasedRateLimitChecker.checkRateLimit(record);
-    long stop = System.currentTimeMillis();
-    long totalTime = stop - start;
-
-    if (rateLimitShouldBeExceeded) {
-      assertThat(result).isTrue();
-      assertThat(stop).isGreaterThanOrEqualTo(record.reset());
-      assertThat(logTester.logs(Level.WARN)).contains(
-        format(RATE_RATIO_EXCEEDED_MESSAGE.replaceAll("\\{\\}", "%s"), limit - remaining, limit));
-    } else {
-      assertThat(result).isFalse();
-      assertThat(totalTime).isLessThan(MILLIS_BEFORE_RESET);
-      assertThat(logTester.logs(Level.WARN)).isEmpty();
-    }
-  }
-}
diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/gitlab/GitlabApplicationClientTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/gitlab/GitlabApplicationClientTest.java
new file mode 100644 (file)
index 0000000..4ec9f5d
--- /dev/null
@@ -0,0 +1,589 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.apache.commons.io.IOUtils;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.slf4j.event.Level;
+import org.sonar.alm.client.ConstantTimeoutConfiguration;
+import org.sonar.alm.client.TimeoutConfiguration;
+import org.sonar.api.testfixtures.log.LogTester;
+import org.sonar.auth.gitlab.GsonGroup;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+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.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class GitlabApplicationClientTest {
+
+  @Rule
+  public LogTester logTester = new LogTester();
+
+
+  private GitlabPaginatedHttpClient gitlabPaginatedHttpClient = mock();
+
+  private final MockWebServer server = new MockWebServer();
+  private GitlabApplicationClient 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 GitlabApplicationClient(gitlabPaginatedHttpClient, 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);
+
+    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);
+
+    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);
+
+    assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("Invalid personal access token");
+  }
+
+  @Test
+  public void should_throw_IllegalArgumentException_when_redirected() {
+    MockResponse response = new MockResponse()
+      .setResponseCode(308);
+    server.enqueue(response);
+
+    assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("Request was redirected, please provide the correct URL");
+  }
+
+  @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);
+
+    assertThatThrownBy(() -> underTest.getProject(gitlabUrl, "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 get_branches() {
+    MockResponse response = new MockResponse()
+      .setResponseCode(200)
+      .setBody("[{\n"
+        + "    \"name\": \"main\",\n"
+        + "    \"default\": true\n"
+        + "},{\n"
+        + "    \"name\": \"other\",\n"
+        + "    \"default\": false\n"
+        + "}]");
+    server.enqueue(response);
+
+    assertThat(underTest.getBranches(gitlabUrl, "pat", 12345L))
+      .extracting(GitLabBranch::getName, GitLabBranch::isDefault)
+      .containsExactly(
+        tuple("main", true),
+        tuple("other", false)
+      );
+  }
+
+  @Test
+  public void get_branches_fail_if_non_json_payload() {
+    MockResponse response = new MockResponse()
+      .setResponseCode(200)
+      .setBody("non json payload");
+    server.enqueue(response);
+
+    String instanceUrl = gitlabUrl;
+    assertThatThrownBy(() -> underTest.getBranches(instanceUrl, "pat", 12345L))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("Could not parse GitLab answer to retrieve project branches. Got a non-json payload as result.");
+  }
+
+  @Test
+  public void get_branches_fail_if_exception() throws IOException {
+    server.shutdown();
+
+    String instanceUrl = gitlabUrl;
+    assertThatThrownBy(() -> underTest.getBranches(instanceUrl, "pat", 12345L))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessageContaining("Failed to connect to");
+  }
+
+  @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()).isOne();
+    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_dont_fail_if_no_x_total() 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"
+        + "  }"
+        + "]");
+    projects.addHeader("X-Page", 1);
+    projects.addHeader("X-Per-Page", 10);
+    server.enqueue(projects);
+
+    ProjectList projectList = underTest.searchProjects(gitlabUrl, "pat", "example", 1, 10);
+
+    assertThat(projectList.getPageNumber()).isOne();
+    assertThat(projectList.getPageSize()).isEqualTo(10);
+    assertThat(projectList.getTotal()).isNull();
+
+    assertThat(projectList.getProjects()).hasSize(1);
+    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"));
+
+    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_with_case_insensitive_pagination_headers() throws InterruptedException {
+    MockResponse projects1 = 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"
+        + "  }"
+        + "]");
+    projects1.addHeader("x-page", 1);
+    projects1.addHeader("x-Per-page", 1);
+    projects1.addHeader("X-Total", 2);
+    server.enqueue(projects1);
+
+    ProjectList projectList = underTest.searchProjects(gitlabUrl, "pat", "example", 1, 10);
+
+    assertThat(projectList.getPageNumber()).isOne();
+    assertThat(projectList.getPageSize()).isOne();
+    assertThat(projectList.getTotal()).isEqualTo(2);
+
+    assertThat(projectList.getProjects()).hasSize(1);
+    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"));
+
+    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=<script>alert('nasty')</script>", 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 get_project_details() throws InterruptedException {
+    MockResponse projectResponse = new MockResponse()
+        .setResponseCode(200)
+        .setBody("{"
+            + "  \"id\": 1234,"
+            + "  \"name\": \"SonarQube example 2\","
+            + "  \"name_with_namespace\": \"SonarSource / SonarQube / SonarQube example 2\","
+            + "  \"path\": \"sonarqube-example-2\","
+            + "  \"path_with_namespace\": \"sonarsource/sonarqube/sonarqube-example-2\","
+            + "  \"web_url\": \"https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-2\""
+            + "}");
+
+    server.enqueue(projectResponse);
+
+    Project project = underTest.getProject(gitlabUrl, "pat", 1234L);
+
+    RecordedRequest projectGitlabRequest = server.takeRequest(10, TimeUnit.SECONDS);
+    String gitlabUrlCall = projectGitlabRequest.getRequestUrl().toString();
+
+    assertThat(project).isNotNull();
+
+    assertThat(gitlabUrlCall).isEqualTo(
+        server.url("") + "projects/1234");
+    assertThat(projectGitlabRequest.getMethod()).isEqualTo("GET");
+  }
+
+  @Test
+  public void get_reporter_level_access_project() throws InterruptedException {
+    MockResponse projectResponse = new MockResponse()
+        .setResponseCode(200)
+        .setBody("[{"
+            + "  \"id\": 1234,"
+            + "  \"name\": \"SonarQube example 2\","
+            + "  \"name_with_namespace\": \"SonarSource / SonarQube / SonarQube example 2\","
+            + "  \"path\": \"sonarqube-example-2\","
+            + "  \"path_with_namespace\": \"sonarsource/sonarqube/sonarqube-example-2\","
+            + "  \"web_url\": \"https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-2\""
+            + "}]");
+
+    server.enqueue(projectResponse);
+
+    Optional<Project> project = underTest.getReporterLevelAccessProject(gitlabUrl, "pat", 1234L);
+
+    RecordedRequest projectGitlabRequest = server.takeRequest(10, TimeUnit.SECONDS);
+    String gitlabUrlCall = projectGitlabRequest.getRequestUrl().toString();
+
+    assertThat(project).isNotNull();
+
+    assertThat(gitlabUrlCall).isEqualTo(
+        server.url("") + "projects?min_access_level=20&id_after=1233&id_before=1235");
+    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);
+
+    assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "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);
+
+    assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "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);
+
+    assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("Could not get projects from GitLab instance");
+  }
+
+  @Test
+  public void fail_check_read_permission_with_unexpected_io_exception_with_detailed_log() throws IOException {
+    server.shutdown();
+
+    assertThatThrownBy(() -> underTest.checkReadPermission(gitlabUrl, "token"))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("Could not validate GitLab read permission. Got an unexpected answer.");
+    assertThat(logTester.logs(Level.INFO).get(0))
+      .contains("Gitlab API call to [" + server.url("/projects") + "] " +
+        "failed with error message : [Failed to connect to " + server.getHostName());
+  }
+
+  @Test
+  public void fail_check_token_with_unexpected_io_exception_with_detailed_log() throws IOException {
+    server.shutdown();
+
+    assertThatThrownBy(() -> underTest.checkToken(gitlabUrl, "token"))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("Could not validate GitLab token. Got an unexpected answer.");
+    assertThat(logTester.logs(Level.INFO).get(0))
+      .contains("Gitlab API call to [" + server.url("user") + "] " +
+        "failed with error message : [Failed to connect to " + server.getHostName());
+  }
+
+  @Test
+  public void fail_check_write_permission_with_unexpected_io_exception_with_detailed_log() throws IOException {
+    server.shutdown();
+
+    assertThatThrownBy(() -> underTest.checkWritePermission(gitlabUrl, "token"))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("Could not validate GitLab write permission. Got an unexpected answer.");
+    assertThat(logTester.logs(Level.INFO).get(0))
+      .contains("Gitlab API call to [" + server.url("/markdown") + "] " +
+        "failed with error message : [Failed to connect to " + server.getHostName());
+  }
+
+  @Test
+  public void fail_get_project_with_unexpected_io_exception_with_detailed_log() throws IOException {
+    server.shutdown();
+
+    assertThatThrownBy(() -> underTest.getProject(gitlabUrl, "token", 0L))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessageContaining("Failed to connect to");
+    assertThat(logTester.logs(Level.INFO).get(0))
+      .contains("Gitlab API call to [" + server.url("/projects/0") + "] " +
+        "failed with error message : [Failed to connect to " + server.getHostName());
+  }
+
+  @Test
+  public void fail_get_branches_with_unexpected_io_exception_with_detailed_log() throws IOException {
+    server.shutdown();
+
+    assertThatThrownBy(() -> underTest.getBranches(gitlabUrl, "token", 0L))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessageContaining("Failed to connect to " + server.getHostName());
+    assertThat(logTester.logs(Level.INFO).get(0))
+      .contains("Gitlab API call to [" + server.url("/projects/0/repository/branches") + "] " +
+        "failed with error message : [Failed to connect to " + server.getHostName());
+  }
+
+  @Test
+  public void fail_search_projects_with_unexpected_io_exception_with_detailed_log() throws IOException {
+    server.shutdown();
+
+    assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "token", null, 1, 1))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessageContaining("Failed to connect to");
+    assertThat(logTester.logs(Level.INFO).get(0))
+      .contains(
+        "Gitlab API call to [" + server.url("/projects?archived=false&simple=true&membership=true&order_by=name&sort=asc&search=&page=1&per_page=1")
+          + "] " +
+          "failed with error message : [Failed to connect to " + server.getHostName());
+  }
+
+  @Test
+  public void getGroups_whenCallIsInError_rethrows() throws IOException {
+    String token = "token-toto";
+    GitlabToken gitlabToken = new GitlabToken(token);
+    when(gitlabPaginatedHttpClient.get(eq(gitlabUrl), eq(gitlabToken), eq("/groups"), any())).thenThrow(new IllegalStateException("exception"));
+
+    assertThatIllegalStateException()
+      .isThrownBy(() -> underTest.getGroups(gitlabUrl, token))
+      .withMessage("exception");
+  }
+
+  @Test
+  public void getGroups_whenCallIsSuccessful_deserializesAndReturnsCorrectlyGroups() throws IOException {
+    ArgumentCaptor<Function<String, List<GsonGroup>>> deserializerCaptor = ArgumentCaptor.forClass(Function.class);
+
+    String token = "token-toto";
+    GitlabToken gitlabToken = new GitlabToken(token);
+    List<GsonGroup> expectedGroups = expectedGroups();
+    when(gitlabPaginatedHttpClient.get(eq(gitlabUrl), eq(gitlabToken), eq("/groups"), deserializerCaptor.capture())).thenReturn(expectedGroups);
+
+    Set<GsonGroup> groups = underTest.getGroups(gitlabUrl, token);
+    assertThat(groups).containsExactlyInAnyOrderElementsOf(expectedGroups);
+
+    String responseContent = getResponseContent("groups-full-response.json");
+
+    List<GsonGroup> deserializedGroups = deserializerCaptor.getValue().apply(responseContent);
+    assertThat(deserializedGroups).usingRecursiveComparison().isEqualTo(expectedGroups);
+  }
+
+  private static List<GsonGroup> expectedGroups() {
+    GsonGroup gsonGroup = createGsonGroup("56232243", "sonarsource/cfamily", "this is a long description");
+    GsonGroup gsonGroup2 = createGsonGroup("78902256", "sonarsource/sonarqube/mmf-3052-ant1", "");
+    return List.of(gsonGroup, gsonGroup2);
+  }
+
+  private static GsonGroup createGsonGroup(String number, String fullPath, String description) {
+    GsonGroup gsonGroup = mock(GsonGroup.class);
+    when(gsonGroup.getId()).thenReturn(number);
+    when(gsonGroup.getFullPath()).thenReturn(fullPath);
+    when(gsonGroup.getDescription()).thenReturn(description);
+    return gsonGroup;
+  }
+
+  private static String getResponseContent(String path) throws IOException {
+    return IOUtils.toString(GitlabApplicationClientTest.class.getResourceAsStream(path), StandardCharsets.UTF_8);
+  }
+
+}
index 6582f503024d0d7f93cd607da68d1ffcaad589ea..13f1c4c9c4736de91f7d7b429b82e43db31ed2c4 100644 (file)
@@ -21,8 +21,6 @@ package org.sonar.alm.client.gitlab;
 
 import org.junit.BeforeClass;
 import org.junit.Test;
-import org.sonar.alm.client.gitlab.GitlabGlobalSettingsValidator;
-import org.sonar.alm.client.gitlab.GitlabHttpClient;
 import org.sonar.api.config.internal.Encryption;
 import org.sonar.api.config.internal.Settings;
 import org.sonar.db.alm.setting.AlmSettingDto;
@@ -37,7 +35,7 @@ public class GitlabGlobalSettingsValidatorTest {
   private static final Encryption encryption = mock(Encryption.class);
   private static final Settings settings = mock(Settings.class);
 
-  private final GitlabHttpClient gitlabHttpClient = mock(GitlabHttpClient.class);
+  private final GitlabApplicationClient gitlabHttpClient = mock(GitlabApplicationClient.class);
 
   private final GitlabGlobalSettingsValidator underTest = new GitlabGlobalSettingsValidator(gitlabHttpClient, settings);
 
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
deleted file mode 100644 (file)
index 48c0b2d..0000000
+++ /dev/null
@@ -1,526 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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.Optional;
-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.Rule;
-import org.junit.Test;
-import org.slf4j.event.Level;
-import org.sonar.alm.client.ConstantTimeoutConfiguration;
-import org.sonar.alm.client.TimeoutConfiguration;
-import org.sonar.api.testfixtures.log.LogTester;
-
-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 {
-
-  @Rule
-  public LogTester logTester = new LogTester();
-
-  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);
-
-    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);
-
-    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);
-
-    assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2))
-      .isInstanceOf(IllegalArgumentException.class)
-      .hasMessage("Invalid personal access token");
-  }
-
-  @Test
-  public void should_throw_IllegalArgumentException_when_redirected() {
-    MockResponse response = new MockResponse()
-      .setResponseCode(308);
-    server.enqueue(response);
-
-    assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2))
-      .isInstanceOf(IllegalArgumentException.class)
-      .hasMessage("Request was redirected, please provide the correct URL");
-  }
-
-  @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);
-
-    assertThatThrownBy(() -> underTest.getProject(gitlabUrl, "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 get_branches() {
-    MockResponse response = new MockResponse()
-      .setResponseCode(200)
-      .setBody("[{\n"
-        + "    \"name\": \"main\",\n"
-        + "    \"default\": true\n"
-        + "},{\n"
-        + "    \"name\": \"other\",\n"
-        + "    \"default\": false\n"
-        + "}]");
-    server.enqueue(response);
-
-    assertThat(underTest.getBranches(gitlabUrl, "pat", 12345L))
-      .extracting(GitLabBranch::getName, GitLabBranch::isDefault)
-      .containsExactly(
-        tuple("main", true),
-        tuple("other", false)
-      );
-  }
-
-  @Test
-  public void get_branches_fail_if_non_json_payload() {
-    MockResponse response = new MockResponse()
-      .setResponseCode(200)
-      .setBody("non json payload");
-    server.enqueue(response);
-
-    String instanceUrl = gitlabUrl;
-    assertThatThrownBy(() -> underTest.getBranches(instanceUrl, "pat", 12345L))
-      .isInstanceOf(IllegalArgumentException.class)
-      .hasMessage("Could not parse GitLab answer to retrieve project branches. Got a non-json payload as result.");
-  }
-
-  @Test
-  public void get_branches_fail_if_exception() throws IOException {
-    server.shutdown();
-
-    String instanceUrl = gitlabUrl;
-    assertThatThrownBy(() -> underTest.getBranches(instanceUrl, "pat", 12345L))
-      .isInstanceOf(IllegalStateException.class)
-      .hasMessageContaining("Failed to connect to");
-  }
-
-  @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()).isOne();
-    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_dont_fail_if_no_x_total() 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"
-        + "  }"
-        + "]");
-    projects.addHeader("X-Page", 1);
-    projects.addHeader("X-Per-Page", 10);
-    server.enqueue(projects);
-
-    ProjectList projectList = underTest.searchProjects(gitlabUrl, "pat", "example", 1, 10);
-
-    assertThat(projectList.getPageNumber()).isOne();
-    assertThat(projectList.getPageSize()).isEqualTo(10);
-    assertThat(projectList.getTotal()).isNull();
-
-    assertThat(projectList.getProjects()).hasSize(1);
-    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"));
-
-    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_with_case_insensitive_pagination_headers() throws InterruptedException {
-    MockResponse projects1 = 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"
-        + "  }"
-        + "]");
-    projects1.addHeader("x-page", 1);
-    projects1.addHeader("x-Per-page", 1);
-    projects1.addHeader("X-Total", 2);
-    server.enqueue(projects1);
-
-    ProjectList projectList = underTest.searchProjects(gitlabUrl, "pat", "example", 1, 10);
-
-    assertThat(projectList.getPageNumber()).isOne();
-    assertThat(projectList.getPageSize()).isOne();
-    assertThat(projectList.getTotal()).isEqualTo(2);
-
-    assertThat(projectList.getProjects()).hasSize(1);
-    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"));
-
-    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=<script>alert('nasty')</script>", 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 get_project_details() throws InterruptedException {
-    MockResponse projectResponse = new MockResponse()
-        .setResponseCode(200)
-        .setBody("{"
-            + "  \"id\": 1234,"
-            + "  \"name\": \"SonarQube example 2\","
-            + "  \"name_with_namespace\": \"SonarSource / SonarQube / SonarQube example 2\","
-            + "  \"path\": \"sonarqube-example-2\","
-            + "  \"path_with_namespace\": \"sonarsource/sonarqube/sonarqube-example-2\","
-            + "  \"web_url\": \"https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-2\""
-            + "}");
-
-    server.enqueue(projectResponse);
-
-    Project project = underTest.getProject(gitlabUrl, "pat", 1234L);
-
-    RecordedRequest projectGitlabRequest = server.takeRequest(10, TimeUnit.SECONDS);
-    String gitlabUrlCall = projectGitlabRequest.getRequestUrl().toString();
-
-    assertThat(project).isNotNull();
-
-    assertThat(gitlabUrlCall).isEqualTo(
-        server.url("") + "projects/1234");
-    assertThat(projectGitlabRequest.getMethod()).isEqualTo("GET");
-  }
-
-  @Test
-  public void get_reporter_level_access_project() throws InterruptedException {
-    MockResponse projectResponse = new MockResponse()
-        .setResponseCode(200)
-        .setBody("[{"
-            + "  \"id\": 1234,"
-            + "  \"name\": \"SonarQube example 2\","
-            + "  \"name_with_namespace\": \"SonarSource / SonarQube / SonarQube example 2\","
-            + "  \"path\": \"sonarqube-example-2\","
-            + "  \"path_with_namespace\": \"sonarsource/sonarqube/sonarqube-example-2\","
-            + "  \"web_url\": \"https://example.gitlab.com/sonarsource/sonarqube/sonarqube-example-2\""
-            + "}]");
-
-    server.enqueue(projectResponse);
-
-    Optional<Project> project = underTest.getReporterLevelAccessProject(gitlabUrl, "pat", 1234L);
-
-    RecordedRequest projectGitlabRequest = server.takeRequest(10, TimeUnit.SECONDS);
-    String gitlabUrlCall = projectGitlabRequest.getRequestUrl().toString();
-
-    assertThat(project).isNotNull();
-
-    assertThat(gitlabUrlCall).isEqualTo(
-        server.url("") + "projects?min_access_level=20&id_after=1233&id_before=1235");
-    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);
-
-    assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "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);
-
-    assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "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);
-
-    assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "pat", "example", 1, 2))
-      .isInstanceOf(IllegalArgumentException.class)
-      .hasMessage("Could not get projects from GitLab instance");
-  }
-
-  @Test
-  public void fail_check_read_permission_with_unexpected_io_exception_with_detailed_log() throws IOException {
-    server.shutdown();
-
-    assertThatThrownBy(() -> underTest.checkReadPermission(gitlabUrl, "token"))
-      .isInstanceOf(IllegalArgumentException.class)
-      .hasMessage("Could not validate GitLab read permission. Got an unexpected answer.");
-    assertThat(logTester.logs(Level.INFO).get(0))
-      .contains("Gitlab API call to [" + server.url("/projects") + "] " +
-        "failed with error message : [Failed to connect to " + server.getHostName());
-  }
-
-  @Test
-  public void fail_check_token_with_unexpected_io_exception_with_detailed_log() throws IOException {
-    server.shutdown();
-
-    assertThatThrownBy(() -> underTest.checkToken(gitlabUrl, "token"))
-      .isInstanceOf(IllegalArgumentException.class)
-      .hasMessage("Could not validate GitLab token. Got an unexpected answer.");
-    assertThat(logTester.logs(Level.INFO).get(0))
-      .contains("Gitlab API call to [" + server.url("user") + "] " +
-        "failed with error message : [Failed to connect to " + server.getHostName());
-  }
-
-  @Test
-  public void fail_check_write_permission_with_unexpected_io_exception_with_detailed_log() throws IOException {
-    server.shutdown();
-
-    assertThatThrownBy(() -> underTest.checkWritePermission(gitlabUrl, "token"))
-      .isInstanceOf(IllegalArgumentException.class)
-      .hasMessage("Could not validate GitLab write permission. Got an unexpected answer.");
-    assertThat(logTester.logs(Level.INFO).get(0))
-      .contains("Gitlab API call to [" + server.url("/markdown") + "] " +
-        "failed with error message : [Failed to connect to " + server.getHostName());
-  }
-
-  @Test
-  public void fail_get_project_with_unexpected_io_exception_with_detailed_log() throws IOException {
-    server.shutdown();
-
-    assertThatThrownBy(() -> underTest.getProject(gitlabUrl, "token", 0L))
-      .isInstanceOf(IllegalStateException.class)
-      .hasMessageContaining("Failed to connect to");
-    assertThat(logTester.logs(Level.INFO).get(0))
-      .contains("Gitlab API call to [" + server.url("/projects/0") + "] " +
-        "failed with error message : [Failed to connect to " + server.getHostName());
-  }
-
-  @Test
-  public void fail_get_branches_with_unexpected_io_exception_with_detailed_log() throws IOException {
-    server.shutdown();
-
-    assertThatThrownBy(() -> underTest.getBranches(gitlabUrl, "token", 0L))
-      .isInstanceOf(IllegalStateException.class)
-      .hasMessageContaining("Failed to connect to " + server.getHostName());
-    assertThat(logTester.logs(Level.INFO).get(0))
-      .contains("Gitlab API call to [" + server.url("/projects/0/repository/branches") + "] " +
-        "failed with error message : [Failed to connect to " + server.getHostName());
-  }
-
-  @Test
-  public void fail_search_projects_with_unexpected_io_exception_with_detailed_log() throws IOException {
-    server.shutdown();
-
-    assertThatThrownBy(() -> underTest.searchProjects(gitlabUrl, "token", null, 1, 1))
-      .isInstanceOf(IllegalStateException.class)
-      .hasMessageContaining("Failed to connect to");
-    assertThat(logTester.logs(Level.INFO).get(0))
-      .contains(
-        "Gitlab API call to [" + server.url("/projects?archived=false&simple=true&membership=true&order_by=name&sort=asc&search=&page=1&per_page=1")
-          + "] " +
-          "failed with error message : [Failed to connect to " + server.getHostName());
-  }
-}
diff --git a/server/sonar-alm-client/src/test/resources/org/sonar/alm/client/gitlab/groups-full-response.json b/server/sonar-alm-client/src/test/resources/org/sonar/alm/client/gitlab/groups-full-response.json
new file mode 100644 (file)
index 0000000..45861a2
--- /dev/null
@@ -0,0 +1,76 @@
+[
+  {
+    "id": 56232243,
+    "web_url": "https://gitlab.com/groups/sonarsource/cfamily",
+    "name": "CFamily",
+    "path": "cfamily",
+    "description": "this is a long description",
+    "visibility": "public",
+    "share_with_group_lock": false,
+    "require_two_factor_authentication": false,
+    "two_factor_grace_period": 48,
+    "project_creation_level": "maintainer",
+    "auto_devops_enabled": null,
+    "subgroup_creation_level": "owner",
+    "emails_disabled": false,
+    "emails_enabled": true,
+    "mentions_disabled": null,
+    "lfs_enabled": false,
+    "default_branch_protection": 2,
+    "default_branch_protection_defaults": {
+      "allowed_to_push": [
+        {
+          "access_level": 30
+        }
+      ],
+      "allow_force_push": true,
+      "allowed_to_merge": [
+        {
+          "access_level": 30
+        }
+      ]
+    },
+    "avatar_url": null,
+    "request_access_enabled": false,
+    "full_name": "SonarSource / CFamily",
+    "full_path": "sonarsource/cfamily",
+    "created_at": "2022-08-02T06:56:14.451Z",
+    "parent_id": 6164984,
+    "shared_runners_setting": "enabled",
+    "ldap_cn": null,
+    "ldap_access": null,
+    "marked_for_deletion_on": null,
+    "wiki_access_level": "enabled"
+  },
+  {
+    "id": 78902256,
+    "web_url": "https://gitlab.com/groups/sonarsource/sonarqube/mmf-3052-ant1",
+    "name": "MMF-3052-Ant1",
+    "path": "mmf-3052-ant1",
+    "description": "",
+    "visibility": "private",
+    "share_with_group_lock": true,
+    "require_two_factor_authentication": false,
+    "two_factor_grace_period": 48,
+    "project_creation_level": "developer",
+    "auto_devops_enabled": null,
+    "subgroup_creation_level": "maintainer",
+    "emails_disabled": false,
+    "emails_enabled": true,
+    "mentions_disabled": null,
+    "lfs_enabled": true,
+    "default_branch_protection": 2,
+    "default_branch_protection_defaults": {},
+    "avatar_url": null,
+    "request_access_enabled": true,
+    "full_name": "SonarSource / SonarQube / MMF-3052-Ant1",
+    "full_path": "sonarsource/sonarqube/mmf-3052-ant1",
+    "created_at": "2023-11-29T10:34:43.382Z",
+    "parent_id": 67918039,
+    "shared_runners_setting": "enabled",
+    "ldap_cn": null,
+    "ldap_access": null,
+    "marked_for_deletion_on": null,
+    "wiki_access_level": "enabled"
+  }
+]
index b4ebf1a666def87d2e7b9d1163a2477827441a6e..2fe325c2ab4de80c96c50472a989c797f1c6c523 100644 (file)
@@ -31,22 +31,36 @@ import java.util.List;
  */
 public class GsonGroup {
 
+  @SerializedName("id")
+  private String id;
   @SerializedName("full_path")
   private String fullPath;
+  @SerializedName("description")
+  private String description;
 
   public GsonGroup() {
     // http://stackoverflow.com/a/18645370/229031
-    this("");
+    this("", "", "");
   }
 
-  GsonGroup(String fullPath) {
+  private GsonGroup(String id, String fullPath, String description) {
+    this.id = id;
     this.fullPath = fullPath;
+    this.description = description;
   }
 
-  String getFullPath() {
+  public String getId() {
+    return id;
+  }
+
+  public String getFullPath() {
     return fullPath;
   }
 
+  public String getDescription() {
+    return description;
+  }
+
   static List<GsonGroup> parse(String json) {
     Type collectionType = new TypeToken<Collection<GsonGroup>>() {
     }.getType();
index 384fad4d84b39a785db3a5bc93736b7b6a416ee8..b72f834c16eb5c711d2049ed5f8a519700965ac6 100644 (file)
@@ -33,7 +33,7 @@ public class GsonGroupTest {
       "\"web_url\": \"https://gitlab.com/groups/my-awesome-group/my-project\",\n" +
       "\"name\": \"my-project\",\n" +
       "\"path\": \"my-project\",\n" +
-      "\"description\": \"\",\n" +
+      "\"description\": \"toto\",\n" +
       "\"visibility\": \"private\",\n" +
       "\"lfs_enabled\": true,\n" +
       "\"avatar_url\": null,\n" +
@@ -47,6 +47,8 @@ public class GsonGroupTest {
 
     assertThat(groups).isNotNull();
     assertThat(groups.size()).isOne();
+    assertThat(groups.get(0).getId()).isEqualTo("123456789");
     assertThat(groups.get(0).getFullPath()).isEqualTo("my-awesome-group/my-project");
+    assertThat(groups.get(0).getDescription()).isEqualTo("toto");
   }
 }
index 7bac4b067ce49402c5a2bb32dae2f6297d47b181..dc553ca9a5a8e5306ce9ac98f1335eb2439ef985 100644 (file)
@@ -24,7 +24,7 @@ import org.junit.Test;
 import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
 import org.sonar.alm.client.bitbucket.bitbucketcloud.BitbucketCloudRestClient;
 import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient;
-import org.sonar.alm.client.gitlab.GitlabHttpClient;
+import org.sonar.alm.client.gitlab.GitlabApplicationClient;
 import org.sonar.api.server.ws.WebService;
 import org.sonar.db.DbTester;
 import org.sonar.db.alm.pat.AlmPatDto;
@@ -60,9 +60,9 @@ public class CheckPatActionIT {
   private final AzureDevOpsHttpClient azureDevOpsPrHttpClient = mock(AzureDevOpsHttpClient.class);
   private final BitbucketCloudRestClient bitbucketCloudRestClient = mock(BitbucketCloudRestClient.class);
   private final BitbucketServerRestClient bitbucketServerRestClient = mock(BitbucketServerRestClient.class);
-  private final GitlabHttpClient gitlabPrHttpClient = mock(GitlabHttpClient.class);
+  private final GitlabApplicationClient gitlabApplicationClient = mock(GitlabApplicationClient.class);
   private final WsActionTester ws = new WsActionTester(new CheckPatAction(db.getDbClient(), userSession, azureDevOpsPrHttpClient,
-    bitbucketCloudRestClient, bitbucketServerRestClient, gitlabPrHttpClient));
+    bitbucketCloudRestClient, bitbucketServerRestClient, gitlabApplicationClient));
 
   @Test
   public void check_pat_for_github() {
@@ -134,7 +134,7 @@ public class CheckPatActionIT {
       .execute();
 
     assertThat(almSetting.getUrl()).isNotNull();
-    verify(gitlabPrHttpClient).searchProjects(almSetting.getUrl(), PAT_SECRET, null, null, null);
+    verify(gitlabApplicationClient).searchProjects(almSetting.getUrl(), PAT_SECRET, null, null, null);
   }
 
   @Test
@@ -175,7 +175,7 @@ public class CheckPatActionIT {
 
   @Test
   public void fail_when_personal_access_token_is_invalid_for_gitlab() {
-    when(gitlabPrHttpClient.searchProjects(any(), any(), any(), any(), any()))
+    when(gitlabApplicationClient.searchProjects(any(), any(), any(), any(), any()))
       .thenThrow(new IllegalArgumentException("Invalid personal access token"));
     UserDto user = db.users().insertUser();
     userSession.logIn(user).addPermission(PROVISION_PROJECTS);
index b1c8414e7db8480f0da8a8c7f810020e879ebe89..c38535314cd44a45beeab75512010b4e3acded85 100644 (file)
@@ -26,7 +26,7 @@ import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 import org.sonar.alm.client.gitlab.GitLabBranch;
-import org.sonar.alm.client.gitlab.GitlabHttpClient;
+import org.sonar.alm.client.gitlab.GitlabApplicationClient;
 import org.sonar.alm.client.gitlab.Project;
 import org.sonar.api.utils.System2;
 import org.sonar.core.i18n.I18n;
@@ -94,14 +94,14 @@ public class ImportGitLabProjectActionIT {
     mock(PermissionTemplateService.class), new FavoriteUpdater(db.getDbClient()), new TestIndexers(), new SequenceUuidFactory(),
     defaultBranchNameResolver, mock(PermissionUpdater.class), mock(PermissionService.class));
 
-  private final GitlabHttpClient gitlabHttpClient = mock(GitlabHttpClient.class);
+  private final GitlabApplicationClient gitlabApplicationClient = mock(GitlabApplicationClient.class);
   private final ImportHelper importHelper = new ImportHelper(db.getDbClient(), userSession);
   private final ProjectDefaultVisibility projectDefaultVisibility = mock(ProjectDefaultVisibility.class);
   private final ProjectKeyGenerator projectKeyGenerator = mock(ProjectKeyGenerator.class);
   private PlatformEditionProvider editionProvider = mock(PlatformEditionProvider.class);
   private NewCodeDefinitionResolver newCodeDefinitionResolver = new NewCodeDefinitionResolver(db.getDbClient(), editionProvider);
   private final ImportGitLabProjectAction importGitLabProjectAction = new ImportGitLabProjectAction(
-    db.getDbClient(), userSession, projectDefaultVisibility, gitlabHttpClient, componentUpdater, importHelper, projectKeyGenerator, newCodeDefinitionResolver,
+    db.getDbClient(), userSession, projectDefaultVisibility, gitlabApplicationClient, componentUpdater, importHelper, projectKeyGenerator, newCodeDefinitionResolver,
     defaultBranchNameResolver);
   private final WsActionTester ws = new WsActionTester(importGitLabProjectAction);
 
@@ -125,7 +125,7 @@ public class ImportGitLabProjectActionIT {
       .setParam(PARAM_NEW_CODE_DEFINITION_VALUE, "30")
       .executeProtobuf(Projects.CreateWsResponse.class);
 
-    verify(gitlabHttpClient).getProject(almSetting.getUrl(), "PAT", 12345L);
+    verify(gitlabApplicationClient).getProject(almSetting.getUrl(), "PAT", 12345L);
 
     Projects.CreateWsResponse.Project result = response.getProject();
     assertThat(result.getKey()).isEqualTo(PROJECT_KEY_NAME);
@@ -179,8 +179,8 @@ public class ImportGitLabProjectActionIT {
       .setParam("gitlabProjectId", "12345")
       .executeProtobuf(Projects.CreateWsResponse.class);
 
-    verify(gitlabHttpClient).getProject(almSetting.getUrl(), "PAT", 12345L);
-    verify(gitlabHttpClient).getBranches(almSetting.getUrl(), "PAT", 12345L);
+    verify(gitlabApplicationClient).getProject(almSetting.getUrl(), "PAT", 12345L);
+    verify(gitlabApplicationClient).getBranches(almSetting.getUrl(), "PAT", 12345L);
 
     Projects.CreateWsResponse.Project result = response.getProject();
     assertThat(result.getKey()).isEqualTo(PROJECT_KEY_NAME);
@@ -205,8 +205,8 @@ public class ImportGitLabProjectActionIT {
       .setParam("gitlabProjectId", "12345")
       .executeProtobuf(Projects.CreateWsResponse.class);
 
-    verify(gitlabHttpClient).getProject(almSetting.getUrl(), "PAT", 12345L);
-    verify(gitlabHttpClient).getBranches(almSetting.getUrl(), "PAT", 12345L);
+    verify(gitlabApplicationClient).getProject(almSetting.getUrl(), "PAT", 12345L);
+    verify(gitlabApplicationClient).getBranches(almSetting.getUrl(), "PAT", 12345L);
 
     Projects.CreateWsResponse.Project result = response.getProject();
     assertThat(result.getKey()).isEqualTo(PROJECT_KEY_NAME);
@@ -231,7 +231,7 @@ public class ImportGitLabProjectActionIT {
       .setParam("gitlabProjectId", "12345")
       .executeProtobuf(Projects.CreateWsResponse.class);
 
-    verify(gitlabHttpClient).getProject(almSetting.getUrl(), "PAT", 12345L);
+    verify(gitlabApplicationClient).getProject(almSetting.getUrl(), "PAT", 12345L);
 
     Projects.CreateWsResponse.Project result = response.getProject();
     assertThat(result.getKey()).isEqualTo(PROJECT_KEY_NAME);
@@ -344,8 +344,8 @@ public class ImportGitLabProjectActionIT {
 
   private Project mockGitlabProject(List<GitLabBranch> master) {
     Project project = new Project(randomAlphanumeric(5), randomAlphanumeric(5));
-    when(gitlabHttpClient.getProject(any(), any(), any())).thenReturn(project);
-    when(gitlabHttpClient.getBranches(any(), any(), any())).thenReturn(master);
+    when(gitlabApplicationClient.getProject(any(), any(), any())).thenReturn(project);
+    when(gitlabApplicationClient.getBranches(any(), any(), any())).thenReturn(master);
     when(projectKeyGenerator.generateUniqueProjectKey(project.getPathWithNamespace())).thenReturn(PROJECT_KEY_NAME);
     return project;
   }
index 95707c614b6d078994e465f2824ad391eccda98d..219d4208bbdf30725694faa3b46be4830267f596 100644 (file)
@@ -23,7 +23,7 @@ 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.GitlabApplicationClient;
 import org.sonar.alm.client.gitlab.Project;
 import org.sonar.alm.client.gitlab.ProjectList;
 import org.sonar.api.server.ws.WebService;
@@ -59,9 +59,9 @@ public class SearchGitlabReposActionIT {
   @Rule
   public DbTester db = DbTester.create();
 
-  private final GitlabHttpClient gitlabHttpClient = mock(GitlabHttpClient.class);
+  private final GitlabApplicationClient gitlabApplicationClient = mock(GitlabApplicationClient.class);
   private final WsActionTester ws = new WsActionTester(new SearchGitlabReposAction(db.getDbClient(), userSession,
-    gitlabHttpClient));
+    gitlabApplicationClient));
 
   @Test
   public void list_gitlab_repos() {
@@ -69,7 +69,7 @@ public class SearchGitlabReposActionIT {
     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()))
+    when(gitlabApplicationClient.searchProjects(any(), any(), any(), anyInt(), anyInt()))
       .thenReturn(
         new ProjectList(Arrays.asList(gitlabProject1, gitlabProject2, gitlabProject3, gitlabProject4), 1, 10, 4));
 
@@ -112,7 +112,7 @@ public class SearchGitlabReposActionIT {
     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()))
+    when(gitlabApplicationClient.searchProjects(any(), any(), any(), anyInt(), anyInt()))
       .thenReturn(
         new ProjectList(Arrays.asList(gitlabProject1, gitlabProject2, gitlabProject3, gitlabProject4), 1, 10, 4));
 
@@ -160,7 +160,7 @@ public class SearchGitlabReposActionIT {
 
   @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));
+    when(gitlabApplicationClient.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();
@@ -238,7 +238,7 @@ public class SearchGitlabReposActionIT {
       "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()))
+    when(gitlabApplicationClient.searchProjects(any(), any(), any(), anyInt(), anyInt()))
       .thenReturn(
         new ProjectList(Arrays.asList(gitlabProject1, gitlabProject2, gitlabProject3), 1, 3, 10));
 
index a9fc2ad8d6d96eac5e271a73fefb2e516d8635c0..212bf09c2c7f2ef062930767f86e1bf40be5831a 100644 (file)
@@ -22,7 +22,7 @@ package org.sonar.server.almintegration.ws;
 import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
 import org.sonar.alm.client.bitbucket.bitbucketcloud.BitbucketCloudRestClient;
 import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient;
-import org.sonar.alm.client.gitlab.GitlabHttpClient;
+import org.sonar.alm.client.gitlab.GitlabApplicationClient;
 import org.sonar.api.server.ws.Change;
 import org.sonar.api.server.ws.Request;
 import org.sonar.api.server.ws.Response;
@@ -50,19 +50,19 @@ public class CheckPatAction implements AlmIntegrationsWsAction {
   private final AzureDevOpsHttpClient azureDevOpsHttpClient;
   private final BitbucketCloudRestClient bitbucketCloudRestClient;
   private final BitbucketServerRestClient bitbucketServerRestClient;
-  private final GitlabHttpClient gitlabHttpClient;
+  private final GitlabApplicationClient gitlabApplicationClient;
 
   public CheckPatAction(DbClient dbClient, UserSession userSession,
     AzureDevOpsHttpClient azureDevOpsHttpClient,
     BitbucketCloudRestClient bitbucketCloudRestClient,
     BitbucketServerRestClient bitbucketServerRestClient,
-    GitlabHttpClient gitlabHttpClient) {
+    GitlabApplicationClient gitlabApplicationClient) {
     this.dbClient = dbClient;
     this.userSession = userSession;
     this.azureDevOpsHttpClient = azureDevOpsHttpClient;
     this.bitbucketCloudRestClient = bitbucketCloudRestClient;
     this.bitbucketServerRestClient = bitbucketServerRestClient;
-    this.gitlabHttpClient = gitlabHttpClient;
+    this.gitlabApplicationClient = gitlabApplicationClient;
   }
 
   @Override
@@ -113,7 +113,7 @@ public class CheckPatAction implements AlmIntegrationsWsAction {
             requireNonNull(almPatDto.getPersonalAccessToken(), PAT_CANNOT_BE_NULL));
           break;
         case GITLAB:
-          gitlabHttpClient.searchProjects(
+          gitlabApplicationClient.searchProjects(
             requireNonNull(almSettingDto.getUrl(), URL_CANNOT_BE_NULL),
             requireNonNull(almPatDto.getPersonalAccessToken(), PAT_CANNOT_BE_NULL),
             null, null, null);
index c532245f5e3f246f4135563afd2b5a358fb8940b..e5e0318427c14da5969dd5db949c9c904c1a45c5 100644 (file)
@@ -23,7 +23,7 @@ import java.util.Optional;
 import javax.annotation.Nullable;
 import javax.inject.Inject;
 import org.sonar.alm.client.gitlab.GitLabBranch;
-import org.sonar.alm.client.gitlab.GitlabHttpClient;
+import org.sonar.alm.client.gitlab.GitlabApplicationClient;
 import org.sonar.alm.client.gitlab.Project;
 import org.sonar.api.server.ws.Change;
 import org.sonar.api.server.ws.Request;
@@ -70,7 +70,7 @@ public class ImportGitLabProjectAction implements AlmIntegrationsWsAction {
   private final DbClient dbClient;
   private final UserSession userSession;
   private final ProjectDefaultVisibility projectDefaultVisibility;
-  private final GitlabHttpClient gitlabHttpClient;
+  private final GitlabApplicationClient gitlabApplicationClient;
   private final ComponentUpdater componentUpdater;
   private final ImportHelper importHelper;
   private final ProjectKeyGenerator projectKeyGenerator;
@@ -79,13 +79,13 @@ public class ImportGitLabProjectAction implements AlmIntegrationsWsAction {
 
   @Inject
   public ImportGitLabProjectAction(DbClient dbClient, UserSession userSession,
-    ProjectDefaultVisibility projectDefaultVisibility, GitlabHttpClient gitlabHttpClient,
+    ProjectDefaultVisibility projectDefaultVisibility, GitlabApplicationClient gitlabApplicationClient,
     ComponentUpdater componentUpdater, ImportHelper importHelper, ProjectKeyGenerator projectKeyGenerator, NewCodeDefinitionResolver newCodeDefinitionResolver,
     DefaultBranchNameResolver defaultBranchNameResolver) {
     this.dbClient = dbClient;
     this.userSession = userSession;
     this.projectDefaultVisibility = projectDefaultVisibility;
-    this.gitlabHttpClient = gitlabHttpClient;
+    this.gitlabApplicationClient = gitlabApplicationClient;
     this.componentUpdater = componentUpdater;
     this.importHelper = importHelper;
     this.projectKeyGenerator = projectKeyGenerator;
@@ -139,7 +139,7 @@ public class ImportGitLabProjectAction implements AlmIntegrationsWsAction {
       long gitlabProjectId = request.mandatoryParamAsLong(PARAM_GITLAB_PROJECT_ID);
 
       String gitlabUrl = requireNonNull(almSettingDto.getUrl(), "DevOps Platform gitlabUrl cannot be null");
-      Project gitlabProject = gitlabHttpClient.getProject(gitlabUrl, pat, gitlabProjectId);
+      Project gitlabProject = gitlabApplicationClient.getProject(gitlabUrl, pat, gitlabProjectId);
 
       Optional<String> almMainBranchName = getAlmDefaultBranch(pat, gitlabProjectId, gitlabUrl);
 
@@ -169,7 +169,7 @@ public class ImportGitLabProjectAction implements AlmIntegrationsWsAction {
   }
 
   private Optional<String> getAlmDefaultBranch(String pat, long gitlabProjectId, String gitlabUrl) {
-    Optional<GitLabBranch> almMainBranch = gitlabHttpClient.getBranches(gitlabUrl, pat, gitlabProjectId).stream().filter(GitLabBranch::isDefault).findFirst();
+    Optional<GitLabBranch> almMainBranch = gitlabApplicationClient.getBranches(gitlabUrl, pat, gitlabProjectId).stream().filter(GitLabBranch::isDefault).findFirst();
     return almMainBranch.map(GitLabBranch::getName);
   }
 
index a27f3280dc4a431a22ad7e58c09dd700f1c82949..223421dde73dc65a5dc121d0ba460f9c16dc3115 100644 (file)
@@ -26,7 +26,7 @@ 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.GitlabApplicationClient;
 import org.sonar.alm.client.gitlab.Project;
 import org.sonar.alm.client.gitlab.ProjectList;
 import org.sonar.api.server.ws.Request;
@@ -58,12 +58,12 @@ public class SearchGitlabReposAction implements AlmIntegrationsWsAction {
 
   private final DbClient dbClient;
   private final UserSession userSession;
-  private final GitlabHttpClient gitlabHttpClient;
+  private final GitlabApplicationClient gitlabApplicationClient;
 
-  public SearchGitlabReposAction(DbClient dbClient, UserSession userSession, GitlabHttpClient gitlabHttpClient) {
+  public SearchGitlabReposAction(DbClient dbClient, UserSession userSession, GitlabApplicationClient gitlabApplicationClient) {
     this.dbClient = dbClient;
     this.userSession = userSession;
-    this.gitlabHttpClient = gitlabHttpClient;
+    this.gitlabApplicationClient = gitlabApplicationClient;
   }
 
   @Override
@@ -113,7 +113,7 @@ public class SearchGitlabReposAction implements AlmIntegrationsWsAction {
       String personalAccessToken = almPatDto.map(AlmPatDto::getPersonalAccessToken).orElseThrow(() -> new IllegalArgumentException("No personal access token found"));
       String gitlabUrl = requireNonNull(almSettingDto.getUrl(), "DevOps Platform url cannot be null");
 
-      ProjectList gitlabProjectList = gitlabHttpClient
+      ProjectList gitlabProjectList = gitlabApplicationClient
         .searchProjects(gitlabUrl, personalAccessToken, projectName, pageNumber, pageSize);
 
       Map<String, ProjectKeyName> sqProjectsKeyByGitlabProjectId = getSqProjectsKeyByGitlabProjectId(dbSession, almSettingDto, gitlabProjectList);
index fc9b920c8609aea489773a7e1f7c0ecc0fb8eb80..a57327580b2945ba3915d4f30d1e1715aeafc261 100644 (file)
@@ -33,11 +33,14 @@ import org.sonar.alm.client.github.GithubGlobalSettingsValidator;
 import org.sonar.alm.client.github.GithubHeaders;
 import org.sonar.alm.client.github.GithubPaginatedHttpClient;
 import org.sonar.alm.client.github.GithubPermissionConverter;
-import org.sonar.alm.client.github.RatioBasedRateLimitChecker;
+import org.sonar.alm.client.RatioBasedRateLimitChecker;
 import org.sonar.alm.client.github.config.GithubProvisioningConfigValidator;
 import org.sonar.alm.client.github.security.GithubAppSecurityImpl;
+import org.sonar.alm.client.gitlab.GitlabApplicationHttpClient;
 import org.sonar.alm.client.gitlab.GitlabGlobalSettingsValidator;
-import org.sonar.alm.client.gitlab.GitlabHttpClient;
+import org.sonar.alm.client.gitlab.GitlabHeaders;
+import org.sonar.alm.client.gitlab.GitlabApplicationClient;
+import org.sonar.alm.client.gitlab.GitlabPaginatedHttpClient;
 import org.sonar.api.resources.ResourceTypes;
 import org.sonar.api.server.rule.RulesDefinitionXmlLoader;
 import org.sonar.auth.bitbucket.BitbucketModule;
@@ -555,22 +558,25 @@ public class PlatformLevel4 extends PlatformLevel {
       ProjectKeyGenerator.class,
       RatioBasedRateLimitChecker.class,
       GithubAppSecurityImpl.class,
-      GithubApplicationClientImpl.class,
-      GithubPaginatedHttpClient.class,
       GithubHeaders.class,
       GithubApplicationHttpClient.class,
+      GithubPaginatedHttpClient.class,
+      GithubApplicationClientImpl.class,
       GithubProvisioningConfigValidator.class,
       GithubProvisioningWs.class,
       GithubProjectCreatorFactory.class,
       GithubPermissionConverter.class,
       BitbucketCloudRestClientConfiguration.class,
       BitbucketServerRestClient.class,
-      GitlabHttpClient.class,
       AzureDevOpsHttpClient.class,
       new AlmIntegrationsWSModule(),
       BitbucketCloudValidator.class,
       BitbucketServerSettingsValidator.class,
       GithubGlobalSettingsValidator.class,
+      GitlabHeaders.class,
+      GitlabApplicationHttpClient.class,
+      GitlabPaginatedHttpClient.class,
+      GitlabApplicationClient.class,
       GitlabGlobalSettingsValidator.class,
       AzureDevOpsValidator.class,