]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14371 move Azure Devops client and WS actions to CE
authorZipeng WU <zipeng.wu@sonarsource.com>
Tue, 26 Jan 2021 17:22:53 +0000 (18:22 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 4 Feb 2021 20:07:07 +0000 (20:07 +0000)
17 files changed:
server/sonar-alm-client/build.gradle
server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/AzureDevOpsHttpClient.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureError.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProject.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProjectList.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepo.java [new file with mode: 0644]
server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepoList.java [new file with mode: 0644]
server/sonar-alm-client/src/test/java/org/sonar/alm/client/azure/AzureDevOpsHttpClientTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/AlmIntegrationsWSModule.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectAction.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsAction.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposAction.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectActionTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsActionTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposActionTest.java [new file with mode: 0644]
server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java

index afe80fc3278e35ed91518aa15889347524e5d50c..f546848d6309f8b010823f1aeae166e1eb7c2481 100644 (file)
@@ -6,6 +6,7 @@ dependencies {
     compile 'com.google.code.gson:gson'
     compile 'com.google.guava:guava'
     compile 'com.squareup.okhttp3:okhttp'
+    compile 'commons-codec:commons-codec'
 
     testCompile project(':sonar-plugin-api-impl')
 
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/AzureDevOpsHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/AzureDevOpsHttpClient.java
new file mode 100644 (file)
index 0000000..c4f2f4f
--- /dev/null
@@ -0,0 +1,158 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.alm.client.azure;
+
+import com.google.common.base.Strings;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonSyntaxException;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.nio.charset.StandardCharsets;
+import java.util.function.Function;
+import javax.annotation.Nullable;
+
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.apache.commons.codec.binary.Base64;
+import org.sonar.alm.client.TimeoutConfiguration;
+import org.sonar.api.server.ServerSide;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+import org.sonarqube.ws.client.OkHttpClientBuilder;
+
+import static org.sonar.api.internal.apachecommons.lang.StringUtils.isBlank;
+import static org.sonar.api.internal.apachecommons.lang.StringUtils.substringBeforeLast;
+
+@ServerSide
+public class AzureDevOpsHttpClient {
+  private static final Logger LOG = Loggers.get(AzureDevOpsHttpClient.class);
+
+  protected static final String GET = "GET";
+  protected static final String UNABLE_TO_CONTACT_AZURE_SERVER = "Unable to contact Azure DevOps server";
+
+  protected final OkHttpClient client;
+
+  public AzureDevOpsHttpClient(TimeoutConfiguration timeoutConfiguration) {
+    client = new OkHttpClientBuilder()
+      .setConnectTimeoutMs(timeoutConfiguration.getConnectTimeout())
+      .setReadTimeoutMs(timeoutConfiguration.getReadTimeout())
+      .build();
+  }
+
+  public GsonAzureProjectList getProjects(String serverUrl, String token) {
+    String url = String.format("%s/_apis/projects", getTrimmedUrl(serverUrl));
+    LOG.debug(String.format("get projects : [%s]", url));
+    return doGet(token, url, r -> buildGson().fromJson(r.body().charStream(), GsonAzureProjectList.class));
+  }
+
+
+  public GsonAzureRepoList getRepos(String serverUrl, String token, @Nullable String projectName) {
+    String url;
+    if (projectName != null && !projectName.isEmpty()) {
+      url = String.format("%s/%s/_apis/git/repositories", getTrimmedUrl(serverUrl), projectName);
+    } else {
+      url = String.format("%s/_apis/git/repositories", getTrimmedUrl(serverUrl));
+    }
+    LOG.debug(String.format("get repos : [%s]", url));
+    return doGet(token, url, r -> buildGson().fromJson(r.body().charStream(), GsonAzureRepoList.class));
+  }
+
+  public GsonAzureRepo getRepo(String serverUrl, String token, String projectName, String repositoryName) {
+    String url = String.format("%s/%s/_apis/git/repositories/%s", getTrimmedUrl(serverUrl), projectName, repositoryName);
+    LOG.debug(String.format("get repo : [%s]", url));
+    return doGet(token, url, r -> buildGson().fromJson(r.body().charStream(), GsonAzureRepo.class));
+  }
+
+  protected  <G> G doGet(String token, String url, Function<Response, G> handler) {
+    Request request = prepareRequestWithToken(token, GET, url, null);
+    return doCall(request, handler);
+  }
+
+  protected  <G> G doCall(Request request, Function<Response, G> handler) {
+    try (Response response = client.newCall(request).execute()) {
+      checkResponseIsSuccessful(response);
+      return handler.apply(response);
+    } catch (JsonSyntaxException e) {
+      throw new IllegalArgumentException(UNABLE_TO_CONTACT_AZURE_SERVER + ", got an unexpected response", e);
+    } catch (IOException e) {
+      throw new IllegalArgumentException(UNABLE_TO_CONTACT_AZURE_SERVER, e);
+    }
+  }
+
+  protected static Request prepareRequestWithToken(String token, String method, String url, @Nullable RequestBody body) {
+    return new Request.Builder()
+      .method(method, body)
+      .url(url)
+      .addHeader("Authorization", encodeToken("accessToken:" + token))
+      .build();
+  }
+
+  protected static void checkResponseIsSuccessful(Response response) throws IOException {
+    if (!response.isSuccessful()) {
+      LOG.debug(UNABLE_TO_CONTACT_AZURE_SERVER + ": {} {}", response.request().url().toString(), response.code());
+      if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) {
+        throw new IllegalArgumentException("Invalid personal access token");
+      }
+      ResponseBody responseBody = response.body();
+      String body = responseBody == null ? "" : responseBody.string();
+      String errorMessage = generateErrorMessage(body, UNABLE_TO_CONTACT_AZURE_SERVER);
+      LOG.info(String.format("Azure API call to [%s] failed with %s http code. Azure response content : [%s]", response.request().url().toString(), response.code(), body));
+      throw new IllegalArgumentException(errorMessage);
+    }
+  }
+
+  protected static String generateErrorMessage(String body, String defaultMessage) {
+    GsonAzureError gsonAzureError = null;
+    try {
+      gsonAzureError = buildGson().fromJson(body, GsonAzureError.class);
+    } catch (JsonSyntaxException e) {
+      // not a json payload, ignore the error
+    }
+    if (gsonAzureError != null && !Strings.isNullOrEmpty(gsonAzureError.message())) {
+      return defaultMessage + " : " + gsonAzureError.message();
+    } else {
+      return defaultMessage;
+    }
+  }
+
+  protected static String getTrimmedUrl(String rawUrl) {
+    if (isBlank(rawUrl)) {
+      return rawUrl;
+    }
+    if (rawUrl.endsWith("/")) {
+      return substringBeforeLast(rawUrl, "/");
+    }
+    return rawUrl;
+  }
+
+  protected static String encodeToken(String token) {
+    return String.format("BASIC %s", Base64.encodeBase64String(token.getBytes(StandardCharsets.UTF_8)));
+  }
+
+  protected static Gson buildGson() {
+    return new GsonBuilder()
+      .create();
+  }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureError.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureError.java
new file mode 100644 (file)
index 0000000..e4e9179
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.alm.client.azure;
+
+import com.google.gson.annotations.SerializedName;
+import javax.annotation.Nullable;
+
+public class GsonAzureError {
+  @SerializedName("message")
+  private final String message;
+
+  public GsonAzureError(@Nullable String message) {
+    this.message = message;
+  }
+
+  public GsonAzureError() {
+    // http://stackoverflow.com/a/18645370/229031
+    this(null);
+  }
+
+  public String message() {
+    return message;
+  }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProject.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProject.java
new file mode 100644 (file)
index 0000000..c206f02
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.alm.client.azure;
+
+import com.google.gson.annotations.SerializedName;
+
+public class GsonAzureProject {
+
+  @SerializedName("name")
+  private String name;
+
+  @SerializedName("description")
+  private String description;
+
+  public GsonAzureProject() {
+    // http://stackoverflow.com/a/18645370/229031
+  }
+
+  public GsonAzureProject(String name, String description) {
+    this.name = name;
+    this.description = description;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public String getDescription() {
+    return description;
+  }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProjectList.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureProjectList.java
new file mode 100644 (file)
index 0000000..e594bbb
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.alm.client.azure;
+
+import com.google.gson.annotations.SerializedName;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class GsonAzureProjectList {
+
+  @SerializedName("value")
+  private List<GsonAzureProject> values;
+
+  public GsonAzureProjectList() {
+    // http://stackoverflow.com/a/18645370/229031
+    this(new ArrayList<>());
+  }
+
+  public GsonAzureProjectList(List<GsonAzureProject> values) {
+    this.values = values;
+  }
+
+  public List<GsonAzureProject> getValues() {
+    return values;
+  }
+
+  public GsonAzureProjectList setValues(List<GsonAzureProject> values) {
+    this.values = values;
+    return this;
+  }
+
+  @Override
+  public String toString() {
+    return "{" +
+      "values=" + values +
+      '}';
+  }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepo.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepo.java
new file mode 100644 (file)
index 0000000..60327fc
--- /dev/null
@@ -0,0 +1,63 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.alm.client.azure;
+
+import com.google.gson.annotations.SerializedName;
+
+public class GsonAzureRepo {
+  @SerializedName("id")
+  private String id;
+
+  @SerializedName("name")
+  private String name;
+
+  @SerializedName("url")
+  private String url;
+
+  @SerializedName("project")
+  private GsonAzureProject project;
+
+  public GsonAzureRepo() {
+    // http://stackoverflow.com/a/18645370/229031
+  }
+
+  public GsonAzureRepo(String id, String name, String url, GsonAzureProject project) {
+    this.id = id;
+    this.name = name;
+    this.url = url;
+    this.project = project;
+  }
+
+  public String getId() {
+    return id;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public String getUrl() {
+    return url;
+  }
+
+  public GsonAzureProject getProject() {
+    return project;
+  }
+}
diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepoList.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/azure/GsonAzureRepoList.java
new file mode 100644 (file)
index 0000000..eaeffc8
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.alm.client.azure;
+
+import com.google.gson.Gson;
+import com.google.gson.annotations.SerializedName;
+import java.util.ArrayList;
+import java.util.List;
+
+public class GsonAzureRepoList {
+
+  @SerializedName("value")
+  private List<GsonAzureRepo> values;
+
+  public GsonAzureRepoList() {
+    // http://stackoverflow.com/a/18645370/229031
+    this(new ArrayList<>());
+  }
+
+  public GsonAzureRepoList(List<GsonAzureRepo> values) {
+    this.values = values;
+  }
+
+  static GsonAzureRepoList parse(String json) {
+    return new Gson().fromJson(json, GsonAzureRepoList.class);
+  }
+
+  public List<GsonAzureRepo> getValues() {
+    return values;
+  }
+
+}
diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/azure/AzureDevOpsHttpClientTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/azure/AzureDevOpsHttpClientTest.java
new file mode 100644 (file)
index 0000000..c296f79
--- /dev/null
@@ -0,0 +1,273 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.alm.client.azure;
+
+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.sonar.alm.client.ConstantTimeoutConfiguration;
+import org.sonar.alm.client.TimeoutConfiguration;
+import org.sonar.api.utils.log.LogTester;
+import org.sonar.api.utils.log.LoggerLevel;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.tuple;
+import javax.annotation.Nullable;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+public class AzureDevOpsHttpClientTest {
+  public static final String UNABLE_TO_CONTACT_AZURE = "Unable to contact Azure DevOps server, got an unexpected response";
+  @Rule
+  public LogTester logTester = new LogTester();
+
+  private static final String NON_JSON_PAYLOAD = "non json payload";
+  private final MockWebServer server = new MockWebServer();
+  private AzureDevOpsHttpClient underTest;
+
+  @Before
+  public void prepare() throws IOException {
+    server.start();
+
+    TimeoutConfiguration timeoutConfiguration = new ConstantTimeoutConfiguration(10_000);
+    underTest = new AzureDevOpsHttpClient(timeoutConfiguration);
+  }
+
+  @After
+  public void stopServer() throws IOException {
+    server.shutdown();
+  }
+
+  @Test
+  public void get_projects() throws InterruptedException {
+    enqueueResponse(200, " { \"count\": 2,\n" +
+      "  \"value\": [\n" +
+      "    {\n" +
+      "      \"id\": \"3311cd05-3f00-4a5e-b47f-df94a9982b6e\",\n" +
+      "      \"name\": \"Project 1\",\n" +
+      "      \"description\": \"Project Description\",\n" +
+      "      \"url\": \"https://ado.sonarqube.com/DefaultCollection/_apis/projects/3311cd05-3f00-4a5e-b47f-df94a9982b6e\",\n" +
+      "      \"state\": \"wellFormed\",\n" +
+      "      \"revision\": 63,\n" +
+      "      \"visibility\": \"private\"\n" +
+      "    }," +
+      "{\n" +
+      "      \"id\": \"3be0f34d-c931-4ff8-8d37-18a83663bd3c\",\n" +
+      "      \"name\": \"Project 2\",\n" +
+      "      \"url\": \"https://ado.sonarqube.com/DefaultCollection/_apis/projects/3be0f34d-c931-4ff8-8d37-18a83663bd3c\",\n" +
+      "      \"state\": \"wellFormed\",\n" +
+      "      \"revision\": 52,\n" +
+      "      \"visibility\": \"private\"\n" +
+      "    }]}");
+
+    GsonAzureProjectList projects = underTest.getProjects(server.url("").toString(), "token");
+
+    RecordedRequest request = server.takeRequest(10, TimeUnit.SECONDS);
+    String azureDevOpsUrlCall = request.getRequestUrl().toString();
+    assertThat(azureDevOpsUrlCall).isEqualTo(server.url("") + "_apis/projects");
+    assertThat(request.getMethod()).isEqualTo("GET");
+
+    assertThat(logTester.logs(LoggerLevel.DEBUG)).hasSize(1);
+    assertThat(logTester.logs(LoggerLevel.DEBUG))
+      .contains("get projects : [" + server.url("").toString() + "_apis/projects]");
+    assertThat(projects.getValues()).hasSize(2);
+    assertThat(projects.getValues())
+      .extracting(GsonAzureProject::getName, GsonAzureProject::getDescription)
+      .containsExactly(tuple("Project 1", "Project Description"), tuple("Project 2", null));
+  }
+
+  @Test
+  public void get_projects_non_json_payload() {
+    enqueueResponse(200, NON_JSON_PAYLOAD);
+
+    assertThatThrownBy(() -> underTest.getProjects(server.url("").toString(), "token"))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage(UNABLE_TO_CONTACT_AZURE);
+  }
+
+  @Test
+  public void get_projects_with_invalid_pat() {
+    enqueueResponse(401);
+
+    assertThatThrownBy(() -> underTest.getProjects(server.url("").toString(), "invalid-token"))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("Invalid personal access token");
+  }
+
+  @Test
+  public void get_projects_with_server_error() {
+    enqueueResponse(500);
+
+    assertThatThrownBy(() -> underTest.getProjects(server.url("").toString(), "token"))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("Unable to contact Azure DevOps server");
+  }
+
+  @Test
+  public void get_repos_with_project_name() throws InterruptedException {
+    enqueueResponse(200, "{\n" +
+      "  \"value\": [\n" +
+      "    {\n" +
+      "      \"id\": \"741248a4-285e-4a6d-af52-1a49d8070638\",\n" +
+      "      \"name\": \"Repository 1\",\n" +
+      "      \"url\": \"https://ado.sonarqube.com/repositories/\",\n" +
+      "      \"project\": {\n" +
+      "        \"id\": \"c88ddb32-ced8-420d-ab34-764133038b34\",\n" +
+      "        \"name\": \"projectName\",\n" +
+      "        \"url\": \"https://ado.sonarqube.com/DefaultCollection/_apis/projects/c88ddb32-ced8-420d-ab34-764133038b34\",\n" +
+      "        \"state\": \"wellFormed\",\n" +
+      "        \"revision\": 29,\n" +
+      "        \"visibility\": \"private\",\n" +
+      "        \"lastUpdateTime\": \"2020-11-11T09:38:03.3Z\"\n" +
+      "      },\n" +
+      "      \"size\": 0\n" +
+      "    }\n" +
+      "  ],\n" +
+      "  \"count\": 1\n" +
+      "}");
+
+    GsonAzureRepoList repos = underTest.getRepos(server.url("").toString(), "token", "projectName");
+
+    RecordedRequest request = server.takeRequest(10, TimeUnit.SECONDS);
+    String azureDevOpsUrlCall = request.getRequestUrl().toString();
+    assertThat(azureDevOpsUrlCall).isEqualTo(server.url("") + "projectName/_apis/git/repositories");
+    assertThat(request.getMethod()).isEqualTo("GET");
+
+    assertThat(logTester.logs(LoggerLevel.DEBUG)).hasSize(1);
+    assertThat(logTester.logs(LoggerLevel.DEBUG))
+      .contains("get repos : [" + server.url("").toString() + "projectName/_apis/git/repositories]");
+    assertThat(repos.getValues()).hasSize(1);
+    assertThat(repos.getValues())
+      .extracting(GsonAzureRepo::getName, GsonAzureRepo::getUrl, r -> r.getProject().getName())
+      .containsExactly(tuple("Repository 1", "https://ado.sonarqube.com/repositories/", "projectName"));
+  }
+
+  @Test
+  public void get_repos_without_project_name() throws InterruptedException {
+    enqueueResponse(200, "{  \"value\": [],  \"count\": 0 }");
+
+    GsonAzureRepoList repos = underTest.getRepos(server.url("").toString(), "token", null);
+
+    RecordedRequest request = server.takeRequest(10, TimeUnit.SECONDS);
+    String azureDevOpsUrlCall = request.getRequestUrl().toString();
+    assertThat(azureDevOpsUrlCall).isEqualTo(server.url("") + "_apis/git/repositories");
+    assertThat(request.getMethod()).isEqualTo("GET");
+    assertThat(repos.getValues()).isEmpty();
+  }
+
+  @Test
+  public void get_repos_non_json_payload() {
+    enqueueResponse(200, NON_JSON_PAYLOAD);
+
+    assertThatThrownBy(() -> underTest.getRepos(server.url("").toString(), "token", null))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage(UNABLE_TO_CONTACT_AZURE);
+  }
+
+  @Test
+  public void get_repo() throws InterruptedException {
+    enqueueResponse(200, "{ " +
+      "  \"id\": \"Repo-Id-1\",\n" +
+      "  \"name\": \"Repo-Name-1\",\n" +
+      "  \"url\": \"https://ado.sonarqube.com/DefaultCollection/Repo-Id-1\",\n" +
+      "  \"project\": {\n" +
+      "    \"id\": \"84ea9d51-0c8a-44ad-be92-b2af7fe2c299\",\n" +
+      "    \"name\": \"Project-Name\",\n" +
+      "    \"description\": \"Project's description\" \n" +
+      "  },\n" +
+      "  \"size\": 0" +
+      "}");
+
+    GsonAzureRepo repo = underTest.getRepo(server.url("").toString(), "token", "Project-Name", "Repo-Name-1");
+
+    RecordedRequest request = server.takeRequest(10, TimeUnit.SECONDS);
+    String azureDevOpsUrlCall = request.getRequestUrl().toString();
+    assertThat(azureDevOpsUrlCall).isEqualTo(server.url("") + "Project-Name/_apis/git/repositories/Repo-Name-1");
+    assertThat(request.getMethod()).isEqualTo("GET");
+
+    assertThat(logTester.logs(LoggerLevel.DEBUG)).hasSize(1);
+    assertThat(logTester.logs(LoggerLevel.DEBUG))
+      .contains("get repo : [" + server.url("").toString() + "Project-Name/_apis/git/repositories/Repo-Name-1]");
+    assertThat(repo.getId()).isEqualTo("Repo-Id-1");
+    assertThat(repo.getName()).isEqualTo("Repo-Name-1");
+    assertThat(repo.getUrl()).isEqualTo("https://ado.sonarqube.com/DefaultCollection/Repo-Id-1");
+    assertThat(repo.getProject().getName()).isEqualTo("Project-Name");
+  }
+
+  @Test
+  public void get_repo_non_json_payload() {
+    enqueueResponse(200, NON_JSON_PAYLOAD);
+
+    assertThatThrownBy(() -> underTest.getRepo(server.url("").toString(), "token", "projectName", "repoName"))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage(UNABLE_TO_CONTACT_AZURE);
+  }
+
+  @Test
+  public void get_repo_json_error_payload() {
+    enqueueResponse(400,
+      "{'message':'TF200016: The following project does not exist: projectName. Verify that the name of the project is correct and that the project exists on the specified Azure DevOps Server.'}");
+
+    assertThatThrownBy(() -> underTest.getRepo(server.url("").toString(), "token", "projectName", "repoName"))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage(
+        "Unable to contact Azure DevOps server : TF200016: The following project does not exist: projectName. Verify that the name of the project is correct and that the project exists on the specified Azure DevOps Server.");
+  }
+
+  private void enqueueResponse(int responseCode) {
+    enqueueResponse(responseCode, "");
+  }
+
+  private void enqueueResponse(int responseCode, @Nullable String body) {
+    server.enqueue(new MockResponse()
+      .setHeader("Content-Type", "application/json;charset=UTF-8")
+      .setResponseCode(responseCode)
+      .setBody(body));
+  }
+
+  @Test
+  public void trim_url() {
+    assertThat(AzureDevOpsHttpClient.getTrimmedUrl("http://localhost:4564/"))
+      .isEqualTo("http://localhost:4564");
+  }
+
+  @Test
+  public void trim_url_without_ending_slash() {
+    assertThat(AzureDevOpsHttpClient.getTrimmedUrl("http://localhost:4564"))
+      .isEqualTo("http://localhost:4564");
+  }
+
+  @Test
+  public void trim_null_url() {
+    assertThat(AzureDevOpsHttpClient.getTrimmedUrl(null))
+      .isNull();
+  }
+
+  @Test
+  public void trim_empty_url() {
+    assertThat(AzureDevOpsHttpClient.getTrimmedUrl(""))
+      .isEmpty();
+  }
+}
index 65b65cac982d1702a1556bc9ef99c03d47e5806a..0496dae204c24bbd8602a890770f1242747a8a36 100644 (file)
@@ -20,6 +20,9 @@
 package org.sonar.server.almintegration.ws;
 
 import org.sonar.core.platform.Module;
+import org.sonar.server.almintegration.ws.azure.ImportAzureProjectAction;
+import org.sonar.server.almintegration.ws.azure.ListAzureProjectsAction;
+import org.sonar.server.almintegration.ws.azure.SearchAzureReposAction;
 import org.sonar.server.almintegration.ws.bitbucketserver.ImportBitbucketServerProjectAction;
 import org.sonar.server.almintegration.ws.bitbucketserver.ListBitbucketServerProjectsAction;
 import org.sonar.server.almintegration.ws.bitbucketserver.SearchBitbucketServerReposAction;
@@ -35,6 +38,9 @@ public class AlmIntegrationsWSModule extends Module {
       SearchBitbucketServerReposAction.class,
       ImportGitLabProjectAction.class,
       SearchGitlabReposAction.class,
+      ImportAzureProjectAction.class,
+      ListAzureProjectsAction.class,
+      SearchAzureReposAction.class,
       AlmIntegrationsWs.class);
   }
 }
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectAction.java
new file mode 100644 (file)
index 0000000..de7a2a6
--- /dev/null
@@ -0,0 +1,163 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.almintegration.ws.azure;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.Optional;
+
+import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
+import org.sonar.alm.client.azure.GsonAzureRepo;
+import org.sonar.api.server.ws.Request;
+import org.sonar.api.server.ws.Response;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.alm.pat.AlmPatDto;
+import org.sonar.db.alm.setting.AlmSettingDto;
+import org.sonar.db.alm.setting.ProjectAlmSettingDto;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.server.almintegration.ws.AlmIntegrationsWsAction;
+import org.sonar.server.almintegration.ws.ImportHelper;
+import org.sonar.server.component.ComponentUpdater;
+import org.sonar.server.project.ProjectDefaultVisibility;
+import org.sonar.server.user.UserSession;
+import org.sonarqube.ws.Projects.CreateWsResponse;
+
+import static java.util.Objects.requireNonNull;
+import static org.sonar.api.resources.Qualifiers.PROJECT;
+import static org.sonar.server.almintegration.ws.ImportHelper.PARAM_ALM_SETTING;
+import static org.sonar.server.almintegration.ws.ImportHelper.toCreateResponse;
+import static org.sonar.server.component.NewComponent.newComponentBuilder;
+import static org.sonar.server.ws.WsUtils.writeProtobuf;
+
+public class ImportAzureProjectAction implements AlmIntegrationsWsAction {
+
+  private static final String PARAM_REPOSITORY_NAME = "repositoryName";
+  private static final String PARAM_PROJECT_NAME = "projectName";
+
+  private final DbClient dbClient;
+  private final UserSession userSession;
+  private final AzureDevOpsHttpClient azureDevOpsHttpClient;
+  private final ProjectDefaultVisibility projectDefaultVisibility;
+  private final ComponentUpdater componentUpdater;
+  private final ImportHelper importHelper;
+
+  public ImportAzureProjectAction(DbClient dbClient, UserSession userSession, AzureDevOpsHttpClient azureDevOpsHttpClient,
+    ProjectDefaultVisibility projectDefaultVisibility, ComponentUpdater componentUpdater,
+    ImportHelper importHelper) {
+    this.dbClient = dbClient;
+    this.userSession = userSession;
+    this.azureDevOpsHttpClient = azureDevOpsHttpClient;
+    this.projectDefaultVisibility = projectDefaultVisibility;
+    this.componentUpdater = componentUpdater;
+    this.importHelper = importHelper;
+  }
+
+  @Override
+  public void define(WebService.NewController context) {
+    WebService.NewAction action = context.createAction("import_azure_project")
+      .setDescription("Create a SonarQube project with the information from the provided Azure DevOps project.<br/>" +
+        "Autoconfigure pull request decoration mechanism.<br/>" +
+        "Requires the 'Create Projects' permission")
+      .setPost(true)
+      .setInternal(true)
+      .setSince("8.6")
+      .setHandler(this);
+
+    action.createParam(PARAM_ALM_SETTING)
+      .setRequired(true)
+      .setMaximumLength(200)
+      .setDescription("ALM setting key");
+
+    action.createParam(PARAM_PROJECT_NAME)
+      .setRequired(true)
+      .setMaximumLength(200)
+      .setDescription("Azure project name");
+
+    action.createParam(PARAM_REPOSITORY_NAME)
+      .setRequired(true)
+      .setMaximumLength(200)
+      .setDescription("Azure repository name");
+  }
+
+  @Override
+  public void handle(Request request, Response response) {
+    CreateWsResponse createResponse = doHandle(request);
+    writeProtobuf(createResponse, request, response);
+  }
+
+  private CreateWsResponse doHandle(Request request) {
+    importHelper.checkProvisionProjectPermission();
+    AlmSettingDto almSettingDto = importHelper.getAlmSetting(request);
+    String userUuid = importHelper.getUserUuid();
+    try (DbSession dbSession = dbClient.openSession(false)) {
+
+      Optional<AlmPatDto> almPatDto = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto);
+      String pat = almPatDto.map(AlmPatDto::getPersonalAccessToken)
+        .orElseThrow(() -> new IllegalArgumentException(String.format("personal access token for '%s' is missing", almSettingDto.getKey())));
+
+      String projectName = request.mandatoryParam(PARAM_PROJECT_NAME);
+      String repositoryName = request.mandatoryParam(PARAM_REPOSITORY_NAME);
+
+      String url = requireNonNull(almSettingDto.getUrl(), "ALM url cannot be null");
+      GsonAzureRepo repo = azureDevOpsHttpClient.getRepo(url, pat, projectName, repositoryName);
+
+      ComponentDto componentDto = createProject(dbSession, repo);
+      populatePRSetting(dbSession, repo, componentDto, almSettingDto);
+
+      return toCreateResponse(componentDto);
+    }
+  }
+
+  private ComponentDto createProject(DbSession dbSession, GsonAzureRepo repo) {
+    boolean visibility = projectDefaultVisibility.get(dbSession).isPrivate();
+    return componentUpdater.create(dbSession, newComponentBuilder()
+      .setKey(generateProjectKey(repo.getProject().getName(), repo.getName()))
+      .setName(repo.getName())
+      .setPrivate(visibility)
+      .setQualifier(PROJECT)
+      .build(),
+      userSession.isLoggedIn() ? userSession.getUuid() : null);
+  }
+
+  private void populatePRSetting(DbSession dbSession, GsonAzureRepo repo, ComponentDto componentDto, AlmSettingDto almSettingDto) {
+    ProjectAlmSettingDto projectAlmSettingDto = new ProjectAlmSettingDto()
+      .setAlmSettingUuid(almSettingDto.getUuid())
+      .setAlmRepo(repo.getName())
+      .setAlmSlug(repo.getProject().getName())
+      .setProjectUuid(componentDto.uuid())
+      .setMonorepo(false);
+    dbClient.projectAlmSettingDao().insertOrUpdate(dbSession, projectAlmSettingDto);
+    dbSession.commit();
+  }
+
+  @VisibleForTesting
+  String generateProjectKey(String projectName, String repoName) {
+    String sqProjectKey = projectName + "_" + repoName;
+
+    if (sqProjectKey.length() > 250) {
+      sqProjectKey = sqProjectKey.substring(sqProjectKey.length() - 250);
+    }
+
+    return sqProjectKey.replace(" ", "_");
+  }
+
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsAction.java
new file mode 100644 (file)
index 0000000..7bd8a3f
--- /dev/null
@@ -0,0 +1,115 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.almintegration.ws.azure;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
+import org.sonar.alm.client.azure.GsonAzureProject;
+import org.sonar.alm.client.azure.GsonAzureProjectList;
+import org.sonar.api.server.ws.Request;
+import org.sonar.api.server.ws.Response;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.alm.pat.AlmPatDto;
+import org.sonar.db.alm.setting.AlmSettingDto;
+import org.sonar.server.almintegration.ws.AlmIntegrationsWsAction;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.user.UserSession;
+import org.sonarqube.ws.AlmIntegrations.AzureProject;
+import org.sonarqube.ws.AlmIntegrations.ListAzureProjectsWsResponse;
+
+import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
+import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS;
+import static org.sonar.server.ws.WsUtils.writeProtobuf;
+
+public class ListAzureProjectsAction implements AlmIntegrationsWsAction {
+
+  private static final String PARAM_ALM_SETTING = "almSetting";
+
+  private final DbClient dbClient;
+  private final UserSession userSession;
+  private final AzureDevOpsHttpClient azureDevOpsHttpClient;
+
+  public ListAzureProjectsAction(DbClient dbClient, UserSession userSession, AzureDevOpsHttpClient azureDevOpsHttpClient) {
+    this.dbClient = dbClient;
+    this.userSession = userSession;
+    this.azureDevOpsHttpClient = azureDevOpsHttpClient;
+  }
+
+  @Override
+  public void define(WebService.NewController context) {
+    WebService.NewAction action = context.createAction("list_azure_projects")
+      .setDescription("List Azure projects<br/>" +
+        "Requires the 'Create Projects' permission")
+      .setPost(false)
+      .setSince("8.6")
+      .setHandler(this);
+
+    action.createParam(PARAM_ALM_SETTING)
+      .setRequired(true)
+      .setMaximumLength(200)
+      .setDescription("ALM setting key");
+  }
+
+  @Override
+  public void handle(Request request, Response response) {
+
+    ListAzureProjectsWsResponse wsResponse = doHandle(request);
+    writeProtobuf(wsResponse, request, response);
+  }
+
+  private ListAzureProjectsWsResponse doHandle(Request request) {
+
+    try (DbSession dbSession = dbClient.openSession(false)) {
+      userSession.checkLoggedIn().checkPermission(PROVISION_PROJECTS);
+
+      String almSettingKey = request.mandatoryParam(PARAM_ALM_SETTING);
+      String userUuid = requireNonNull(userSession.getUuid(), "User UUID is not null");
+      AlmSettingDto almSettingDto = dbClient.almSettingDao().selectByKey(dbSession, almSettingKey)
+        .orElseThrow(() -> new NotFoundException(String.format("ALM Setting '%s' not found", almSettingKey)));
+      Optional<AlmPatDto> almPatDto = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto);
+      String pat = almPatDto.map(AlmPatDto::getPersonalAccessToken).orElseThrow(() -> new IllegalArgumentException("No personal access token found"));
+
+      String url = requireNonNull(almSettingDto.getUrl(), "URL cannot be null");
+      GsonAzureProjectList projectList = azureDevOpsHttpClient.getProjects(url, pat);
+
+      List<AzureProject> values = projectList.getValues().stream()
+        .map(ListAzureProjectsAction::toAzureProject)
+        .sorted(comparing(AzureProject::getName, String::compareToIgnoreCase))
+        .collect(Collectors.toList());
+      ListAzureProjectsWsResponse.Builder builder = ListAzureProjectsWsResponse.newBuilder()
+        .addAllProjects(values);
+      return builder.build();
+    }
+  }
+
+  private static AzureProject toAzureProject(GsonAzureProject project) {
+    return AzureProject.newBuilder()
+      .setName(project.getName())
+      .setDescription(Optional.ofNullable(project.getDescription()).orElse(""))
+      .build();
+  }
+
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposAction.java
new file mode 100644 (file)
index 0000000..33a3825
--- /dev/null
@@ -0,0 +1,235 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.almintegration.ws.azure;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.BinaryOperator;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+
+import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
+import org.sonar.alm.client.azure.GsonAzureRepo;
+import org.sonar.alm.client.azure.GsonAzureRepoList;
+import org.sonar.api.server.ws.Request;
+import org.sonar.api.server.ws.Response;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.alm.pat.AlmPatDto;
+import org.sonar.db.alm.setting.AlmSettingDto;
+import org.sonar.db.alm.setting.ProjectAlmSettingDto;
+import org.sonar.db.project.ProjectDto;
+import org.sonar.server.almintegration.ws.AlmIntegrationsWsAction;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.user.UserSession;
+import org.sonarqube.ws.AlmIntegrations.AzureRepo;
+import org.sonarqube.ws.AlmIntegrations.SearchAzureReposWsResponse;
+
+import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toMap;
+import static java.util.stream.Collectors.toSet;
+import static org.apache.commons.lang.StringUtils.containsIgnoreCase;
+import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS;
+import static org.sonar.server.ws.WsUtils.writeProtobuf;
+
+public class SearchAzureReposAction implements AlmIntegrationsWsAction {
+
+  private static final Logger LOG = Loggers.get(SearchAzureReposAction.class);
+
+  private static final String PARAM_ALM_SETTING = "almSetting";
+  private static final String PARAM_PROJECT_NAME = "projectName";
+  private static final String PARAM_SEARCH_QUERY = "searchQuery";
+
+  private final DbClient dbClient;
+  private final UserSession userSession;
+  private final AzureDevOpsHttpClient azureDevOpsHttpClient;
+
+  public SearchAzureReposAction(DbClient dbClient, UserSession userSession,
+    AzureDevOpsHttpClient azureDevOpsHttpClient) {
+    this.dbClient = dbClient;
+    this.userSession = userSession;
+    this.azureDevOpsHttpClient = azureDevOpsHttpClient;
+  }
+
+  @Override
+  public void define(WebService.NewController context) {
+    WebService.NewAction action = context.createAction("search_azure_repos")
+      .setDescription("Search the Azure repositories<br/>" +
+        "Requires the 'Create Projects' permission")
+      .setPost(false)
+      .setSince("8.6")
+      .setHandler(this);
+
+    action.createParam(PARAM_ALM_SETTING)
+      .setRequired(true)
+      .setMaximumLength(200)
+      .setDescription("ALM setting key");
+    action.createParam(PARAM_PROJECT_NAME)
+      .setRequired(false)
+      .setMaximumLength(200)
+      .setDescription("Project name filter");
+    action.createParam(PARAM_SEARCH_QUERY)
+      .setRequired(false)
+      .setMaximumLength(200)
+      .setDescription("Search query filter");
+  }
+
+  @Override
+  public void handle(Request request, Response response) {
+
+    SearchAzureReposWsResponse wsResponse = doHandle(request);
+    writeProtobuf(wsResponse, request, response);
+
+  }
+
+  private SearchAzureReposWsResponse doHandle(Request request) {
+
+    try (DbSession dbSession = dbClient.openSession(false)) {
+      userSession.checkLoggedIn().checkPermission(PROVISION_PROJECTS);
+
+      String almSettingKey = request.mandatoryParam(PARAM_ALM_SETTING);
+      String userUuid = requireNonNull(userSession.getUuid(), "User UUID cannot be null");
+      AlmSettingDto almSettingDto = dbClient.almSettingDao().selectByKey(dbSession, almSettingKey)
+        .orElseThrow(() -> new NotFoundException(String.format("ALM Setting '%s' not found", almSettingKey)));
+      Optional<AlmPatDto> almPatDto = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto);
+
+      String projectKey = request.param(PARAM_PROJECT_NAME);
+      String searchQuery = request.param(PARAM_SEARCH_QUERY);
+      String pat = almPatDto.map(AlmPatDto::getPersonalAccessToken).orElseThrow(() -> new IllegalArgumentException("No personal access token found"));
+      String url = requireNonNull(almSettingDto.getUrl(), "ALM url cannot be null");
+
+      GsonAzureRepoList gsonAzureRepoList = azureDevOpsHttpClient.getRepos(url, pat, projectKey);
+
+      Map<ProjectKeyName, ProjectDto> sqProjectsKeyByAzureKey = getSqProjectsKeyByCustomKey(dbSession, almSettingDto, gsonAzureRepoList);
+
+      List<AzureRepo> repositories = gsonAzureRepoList.getValues()
+        .stream()
+        .filter(r -> isSearchOnlyByProjectName(searchQuery) || doesSearchCriteriaMatchProjectOrRepo(r, searchQuery))
+        .map(repo -> toAzureRepo(repo, sqProjectsKeyByAzureKey))
+        .sorted(comparing(AzureRepo::getName, String::compareToIgnoreCase))
+        .collect(toList());
+
+      LOG.debug(repositories.toString());
+
+      return SearchAzureReposWsResponse.newBuilder()
+        .addAllRepositories(repositories)
+        .build();
+    }
+  }
+
+  private Map<ProjectKeyName, ProjectDto> getSqProjectsKeyByCustomKey(DbSession dbSession, AlmSettingDto almSettingDto,
+    GsonAzureRepoList azureProjectList) {
+    Set<String> projectNames = azureProjectList.getValues().stream().map(r -> r.getProject().getName()).collect(toSet());
+    Set<ProjectKeyName> azureProjectsAndRepos = azureProjectList.getValues().stream().map(ProjectKeyName::from).collect(toSet());
+
+    List<ProjectAlmSettingDto> projectAlmSettingDtos = dbClient.projectAlmSettingDao()
+      .selectByAlmSettingAndSlugs(dbSession, almSettingDto, projectNames);
+
+    Map<String, ProjectAlmSettingDto> filteredProjectsByUuid = projectAlmSettingDtos
+      .stream()
+      .filter(p -> azureProjectsAndRepos.contains(ProjectKeyName.from(p)))
+      .collect(toMap(ProjectAlmSettingDto::getProjectUuid, Function.identity()));
+
+    Set<String> projectUuids = filteredProjectsByUuid.values().stream().map(ProjectAlmSettingDto::getProjectUuid).collect(toSet());
+
+    return dbClient.projectDao().selectByUuids(dbSession, projectUuids)
+      .stream()
+      .collect(Collectors.toMap(
+        projectDto -> ProjectKeyName.from(filteredProjectsByUuid.get(projectDto.getUuid())),
+        p -> p,
+        resolveNameCollisionOperatorByNaturalOrder()));
+  }
+
+  private static boolean isSearchOnlyByProjectName(@Nullable String criteria) {
+    return criteria == null || criteria.isEmpty();
+  }
+
+  private static boolean doesSearchCriteriaMatchProjectOrRepo(GsonAzureRepo repo, String criteria) {
+    boolean matchProject = containsIgnoreCase(repo.getProject().getName(), criteria);
+    boolean matchRepo = containsIgnoreCase(repo.getName(), criteria);
+    return matchProject || matchRepo;
+  }
+
+  private static AzureRepo toAzureRepo(GsonAzureRepo azureRepo, Map<ProjectKeyName, ProjectDto> sqProjectsKeyByAzureKey) {
+    AzureRepo.Builder builder = AzureRepo.newBuilder()
+      .setName(azureRepo.getName())
+      .setProjectName(azureRepo.getProject().getName());
+
+    ProjectDto projectDto = sqProjectsKeyByAzureKey.get(ProjectKeyName.from(azureRepo));
+    if (projectDto != null) {
+      builder.setSqProjectName(projectDto.getName());
+      builder.setSqProjectKey(projectDto.getKey());
+    }
+
+    return builder.build();
+  }
+
+  private static BinaryOperator<ProjectDto> resolveNameCollisionOperatorByNaturalOrder() {
+    return (a, b) -> b.getKey().compareTo(a.getKey()) > 0 ? a : b;
+  }
+
+  static class ProjectKeyName {
+    final String projectName;
+    final String repoName;
+
+    ProjectKeyName(String projectName, String repoName) {
+      this.projectName = projectName;
+      this.repoName = repoName;
+    }
+
+    public static ProjectKeyName from(ProjectAlmSettingDto project) {
+      return new ProjectKeyName(project.getAlmSlug(), project.getAlmRepo());
+    }
+
+    public static ProjectKeyName from(GsonAzureRepo gsonAzureRepo) {
+      return new ProjectKeyName(gsonAzureRepo.getProject().getName(), gsonAzureRepo.getName());
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+
+      if (o == null || getClass() != o.getClass()) {
+        return false;
+      }
+
+      ProjectKeyName that = (ProjectKeyName) o;
+      return Objects.equals(projectName, that.projectName) &&
+        Objects.equals(repoName, that.repoName);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(projectName, repoName);
+    }
+  }
+
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/package-info.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almintegration/ws/azure/package-info.java
new file mode 100644 (file)
index 0000000..23ca978
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.server.almintegration.ws.azure;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ImportAzureProjectActionTest.java
new file mode 100644 (file)
index 0000000..22b2439
--- /dev/null
@@ -0,0 +1,238 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.almintegration.ws.azure;
+
+import java.util.Optional;
+import java.util.stream.IntStream;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
+import org.sonar.alm.client.azure.GsonAzureProject;
+import org.sonar.alm.client.azure.GsonAzureRepo;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.api.utils.System2;
+import org.sonar.core.i18n.I18n;
+import org.sonar.core.util.SequenceUuidFactory;
+import org.sonar.db.DbTester;
+import org.sonar.db.alm.pat.AlmPatDto;
+import org.sonar.db.alm.setting.AlmSettingDto;
+import org.sonar.db.alm.setting.ProjectAlmSettingDto;
+import org.sonar.db.project.ProjectDto;
+import org.sonar.db.user.UserDto;
+import org.sonar.server.almintegration.ws.ImportHelper;
+import org.sonar.server.component.ComponentUpdater;
+import org.sonar.server.es.TestProjectIndexers;
+import org.sonar.server.exceptions.BadRequestException;
+import org.sonar.server.exceptions.ForbiddenException;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.exceptions.UnauthorizedException;
+import org.sonar.server.favorite.FavoriteUpdater;
+import org.sonar.server.permission.PermissionTemplateService;
+import org.sonar.server.project.ProjectDefaultVisibility;
+import org.sonar.server.project.Visibility;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.TestRequest;
+import org.sonar.server.ws.WsActionTester;
+import org.sonarqube.ws.Projects;
+
+import static java.util.stream.Collectors.joining;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.sonar.db.alm.integration.pat.AlmPatsTesting.newAlmPatDto;
+import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS;
+import static org.sonar.db.permission.GlobalPermission.SCAN;
+
+public class ImportAzureProjectActionTest {
+
+  @Rule
+  public UserSessionRule userSession = UserSessionRule.standalone();
+  @Rule
+  public DbTester db = DbTester.create();
+
+  private final AzureDevOpsHttpClient azureDevOpsHttpClient = mock(AzureDevOpsHttpClient.class);
+
+  private final ComponentUpdater componentUpdater = new ComponentUpdater(db.getDbClient(), mock(I18n.class), System2.INSTANCE,
+    mock(PermissionTemplateService.class), new FavoriteUpdater(db.getDbClient()), new TestProjectIndexers(), new SequenceUuidFactory());
+
+  private final ImportHelper importHelper = new ImportHelper(db.getDbClient(), userSession);
+  private final ProjectDefaultVisibility projectDefaultVisibility = mock(ProjectDefaultVisibility.class);
+  private final ImportAzureProjectAction importAzureProjectAction = new ImportAzureProjectAction(db.getDbClient(), userSession,
+    azureDevOpsHttpClient, projectDefaultVisibility, componentUpdater, importHelper);
+  private final WsActionTester ws = new WsActionTester(importAzureProjectAction);
+
+  @Before
+  public void before() {
+    when(projectDefaultVisibility.get(any())).thenReturn(Visibility.PRIVATE);
+  }
+
+  @Test
+  public void import_project() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user).addPermission(PROVISION_PROJECTS);
+    AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting();
+    db.almPats().insert(dto -> {
+      dto.setAlmSettingUuid(almSetting.getUuid());
+      dto.setPersonalAccessToken(almSetting.getPersonalAccessToken());
+      dto.setUserUuid(user.getUuid());
+    });
+    GsonAzureRepo repo = getGsonAzureRepo();
+    when(azureDevOpsHttpClient.getRepo(almSetting.getUrl(), almSetting.getPersonalAccessToken(), "project-name", "repo-name"))
+      .thenReturn(repo);
+
+    Projects.CreateWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .setParam("projectName", "project-name")
+      .setParam("repositoryName", "repo-name")
+      .executeProtobuf(Projects.CreateWsResponse.class);
+
+    Projects.CreateWsResponse.Project result = response.getProject();
+    assertThat(result.getKey()).isEqualTo(repo.getProject().getName() + "_" + repo.getName());
+    assertThat(result.getName()).isEqualTo(repo.getName());
+
+    Optional<ProjectDto> projectDto = db.getDbClient().projectDao().selectProjectByKey(db.getSession(), result.getKey());
+    assertThat(projectDto).isPresent();
+    Optional<ProjectAlmSettingDto> projectAlmSettingDto = db.getDbClient().projectAlmSettingDao().selectByProject(db.getSession(), projectDto.get());
+    assertThat(projectAlmSettingDto.get().getAlmRepo()).isEqualTo("repo-name");
+    assertThat(projectAlmSettingDto.get().getAlmSettingUuid()).isEqualTo(almSetting.getUuid());
+    assertThat(projectAlmSettingDto.get().getAlmSlug()).isEqualTo("project-name");
+  }
+
+  @Test
+  public void fail_when_not_logged_in() {
+    TestRequest request = ws.newRequest()
+      .setParam("almSetting", "azure")
+      .setParam("projectName", "project-name")
+      .setParam("repositoryName", "repo-name");
+
+    assertThatThrownBy(() -> request.execute())
+      .isInstanceOf(UnauthorizedException.class);
+  }
+
+  @Test
+  public void fail_when_missing_project_creator_permission() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user).addPermission(SCAN);
+
+    TestRequest request = ws.newRequest()
+      .setParam("almSetting", "azure")
+      .setParam("projectName", "project-name")
+      .setParam("repositoryName", "repo-name");
+
+    assertThatThrownBy(() -> request.execute())
+      .isInstanceOf(ForbiddenException.class)
+      .hasMessage("Insufficient privileges");
+  }
+
+  @Test
+  public void check_pat_is_missing() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user).addPermission(PROVISION_PROJECTS);
+    AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting();
+
+    TestRequest request = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .setParam("projectName", "project-name")
+      .setParam("repositoryName", "repo-name");
+
+    assertThatThrownBy(() -> request.execute())
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("personal access token for '" + almSetting.getKey() + "' is missing");
+  }
+
+  @Test
+  public void fail_check_alm_setting_not_found() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user).addPermission(PROVISION_PROJECTS);
+    AlmPatDto almPatDto = newAlmPatDto();
+    db.getDbClient().almPatDao().insert(db.getSession(), almPatDto);
+
+    TestRequest request = ws.newRequest()
+      .setParam("almSetting", "testKey");
+
+    assertThatThrownBy(() -> request.execute())
+      .isInstanceOf(NotFoundException.class)
+      .hasMessage("ALM Setting 'testKey' not found");
+  }
+
+  @Test
+  public void fail_project_already_exists() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user).addPermission(PROVISION_PROJECTS);
+    AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting();
+    db.almPats().insert(dto -> {
+      dto.setAlmSettingUuid(almSetting.getUuid());
+      dto.setPersonalAccessToken(almSetting.getPersonalAccessToken());
+      dto.setUserUuid(user.getUuid());
+    });
+    GsonAzureRepo repo = getGsonAzureRepo();
+    String projectKey = repo.getProject().getName() + "_" + repo.getName();
+    db.components().insertPublicProject(p -> p.setDbKey(projectKey));
+
+    when(azureDevOpsHttpClient.getRepo(almSetting.getUrl(), almSetting.getPersonalAccessToken(), "project-name", "repo-name")).thenReturn(repo);
+    TestRequest request = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .setParam("projectName", "project-name")
+      .setParam("repositoryName", "repo-name");
+
+    assertThatThrownBy(() -> request.execute())
+      .isInstanceOf(BadRequestException.class)
+      .hasMessage("Could not create null, key already exists: " + projectKey);
+  }
+
+  @Test
+  public void sanitize_project_and_repo_names_with_invalid_characters() {
+    assertThat(importAzureProjectAction.generateProjectKey("project name", "repo name"))
+      .isEqualTo("project_name_repo_name");
+  }
+
+  @Test
+  public void sanitize_long_project_and_repo_names() {
+    String projectName = IntStream.range(0, 260).mapToObj(i -> "a").collect(joining());
+
+    assertThat(importAzureProjectAction.generateProjectKey(projectName, "repo name"))
+      .hasSize(250);
+  }
+
+  @Test
+  public void define() {
+    WebService.Action def = ws.getDef();
+
+    assertThat(def.since()).isEqualTo("8.6");
+    assertThat(def.isPost()).isTrue();
+    assertThat(def.params())
+      .extracting(WebService.Param::key, WebService.Param::isRequired)
+      .containsExactlyInAnyOrder(
+        tuple("almSetting", true),
+        tuple("projectName", true),
+        tuple("repositoryName", true));
+  }
+
+  private GsonAzureRepo getGsonAzureRepo() {
+    return new GsonAzureRepo("repo-id", "repo-name", "repo-url",
+      new GsonAzureProject("project-name", "project-description"));
+  }
+
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/ListAzureProjectsActionTest.java
new file mode 100644 (file)
index 0000000..aca6f3e
--- /dev/null
@@ -0,0 +1,187 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.almintegration.ws.azure;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.List;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
+import org.sonar.alm.client.azure.GsonAzureProject;
+import org.sonar.alm.client.azure.GsonAzureProjectList;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.db.DbTester;
+import org.sonar.db.alm.pat.AlmPatDto;
+import org.sonar.db.alm.setting.AlmSettingDto;
+import org.sonar.db.user.UserDto;
+import org.sonar.server.exceptions.ForbiddenException;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.exceptions.UnauthorizedException;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.TestRequest;
+import org.sonar.server.ws.WsActionTester;
+import org.sonarqube.ws.AlmIntegrations.AzureProject;
+import org.sonarqube.ws.AlmIntegrations.ListAzureProjectsWsResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.sonar.db.alm.integration.pat.AlmPatsTesting.newAlmPatDto;
+import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS;
+
+public class ListAzureProjectsActionTest {
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+  @Rule
+  public UserSessionRule userSession = UserSessionRule.standalone();
+  @Rule
+  public DbTester db = DbTester.create();
+
+  private final AzureDevOpsHttpClient azureDevOpsHttpClient = mock(AzureDevOpsHttpClient.class);
+  private final WsActionTester ws = new WsActionTester(new ListAzureProjectsAction(db.getDbClient(), userSession, azureDevOpsHttpClient));
+
+  @Before
+  public void before() {
+    mockClient(ImmutableList.of(new GsonAzureProject("name", "description"),
+      new GsonAzureProject("name", null)));
+  }
+
+  private void mockClient(List<GsonAzureProject> projects) {
+    GsonAzureProjectList projectList = new GsonAzureProjectList();
+    projectList.setValues(projects);
+    when(azureDevOpsHttpClient.getProjects(anyString(), anyString())).thenReturn(projectList);
+  }
+
+  @Test
+  public void list_projects() {
+    AlmSettingDto almSetting = insertAlmSetting();
+
+    ListAzureProjectsWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .executeProtobuf(ListAzureProjectsWsResponse.class);
+
+    assertThat(response.getProjectsCount()).isEqualTo(2);
+    assertThat(response.getProjectsList())
+      .extracting(AzureProject::getName, AzureProject::getDescription)
+      .containsExactly(tuple("name", "description"), tuple("name", ""));
+  }
+
+  @Test
+  public void list_projects_alphabetically_sorted() {
+    mockClient(ImmutableList.of(new GsonAzureProject("BBB project", "BBB project description"),
+      new GsonAzureProject("AAA project 1", "AAA project description"),
+      new GsonAzureProject("zzz project", "zzz project description"),
+      new GsonAzureProject("aaa project", "aaa project description")));
+    AlmSettingDto almSetting = insertAlmSetting();
+
+    ListAzureProjectsWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .executeProtobuf(ListAzureProjectsWsResponse.class);
+
+    assertThat(response.getProjectsCount()).isEqualTo(4);
+    assertThat(response.getProjectsList())
+      .extracting(AzureProject::getName, AzureProject::getDescription)
+      .containsExactly(tuple("aaa project", "aaa project description"), tuple("AAA project 1", "AAA project description"),
+        tuple("BBB project", "BBB project description"), tuple("zzz project", "zzz project description"));
+  }
+
+  @Test
+  public void check_pat_is_missing() {
+    insertUser();
+    AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting();
+
+    TestRequest request = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey());
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("No personal access token found");
+  }
+
+  @Test
+  public void fail_check_alm_setting_not_found() {
+    insertUser();
+    AlmPatDto almPatDto = newAlmPatDto();
+    db.getDbClient().almPatDao().insert(db.getSession(), almPatDto);
+
+    TestRequest request = ws.newRequest()
+      .setParam("almSetting", "testKey");
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(NotFoundException.class)
+      .hasMessage("ALM Setting 'testKey' not found");
+  }
+
+  @Test
+  public void fail_when_not_logged_in() {
+    TestRequest request = ws.newRequest()
+      .setParam("almSetting", "anyvalue");
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(UnauthorizedException.class);
+  }
+
+  @Test
+  public void fail_when_no_creation_project_permission() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user);
+
+    TestRequest request = ws.newRequest()
+      .setParam("almSetting", "anyvalue");
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(ForbiddenException.class)
+      .hasMessage("Insufficient privileges");
+  }
+
+  @Test
+  public void definition() {
+    WebService.Action def = ws.getDef();
+
+    assertThat(def.since()).isEqualTo("8.6");
+    assertThat(def.isPost()).isFalse();
+    assertThat(def.params())
+      .extracting(WebService.Param::key, WebService.Param::isRequired)
+      .containsExactlyInAnyOrder(tuple("almSetting", true));
+  }
+
+  private UserDto insertUser() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user).addPermission(PROVISION_PROJECTS);
+    return user;
+  }
+
+  private AlmSettingDto insertAlmSetting() {
+    UserDto user = insertUser();
+    AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting();
+    db.almPats().insert(dto -> {
+      dto.setAlmSettingUuid(almSetting.getUuid());
+      dto.setUserUuid(user.getUuid());
+    });
+    return almSetting;
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almintegration/ws/azure/SearchAzureReposActionTest.java
new file mode 100644 (file)
index 0000000..ed73655
--- /dev/null
@@ -0,0 +1,367 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.almintegration.ws.azure;
+
+import com.google.common.collect.ImmutableList;
+import org.jetbrains.annotations.NotNull;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
+import org.sonar.alm.client.azure.GsonAzureProject;
+import org.sonar.alm.client.azure.GsonAzureRepo;
+import org.sonar.alm.client.azure.GsonAzureRepoList;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.db.DbTester;
+import org.sonar.db.alm.pat.AlmPatDto;
+import org.sonar.db.alm.setting.AlmSettingDto;
+import org.sonar.db.project.ProjectDto;
+import org.sonar.db.user.UserDto;
+import org.sonar.server.exceptions.ForbiddenException;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.exceptions.UnauthorizedException;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.TestRequest;
+import org.sonar.server.ws.WsActionTester;
+
+import static java.util.Collections.emptyList;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.sonar.db.alm.integration.pat.AlmPatsTesting.newAlmPatDto;
+import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS;
+import static org.sonarqube.ws.AlmIntegrations.AzureRepo;
+import static org.sonarqube.ws.AlmIntegrations.SearchAzureReposWsResponse;
+
+public class SearchAzureReposActionTest {
+
+  @Rule
+  public UserSessionRule userSession = UserSessionRule.standalone();
+  @Rule
+  public DbTester db = DbTester.create();
+
+  private AzureDevOpsHttpClient azureDevOpsHttpClient = mock(AzureDevOpsHttpClient.class);
+  private WsActionTester ws = new WsActionTester(new SearchAzureReposAction(db.getDbClient(), userSession, azureDevOpsHttpClient));
+
+  @Before
+  public void before() {
+    mockClient(new GsonAzureRepoList(ImmutableList.of(getGsonAzureRepo("project-1", "repoName-1"),
+      getGsonAzureRepo("project-2", "repoName-2"))));
+  }
+
+  @Test
+  public void define() {
+    WebService.Action def = ws.getDef();
+
+    assertThat(def.since()).isEqualTo("8.6");
+    assertThat(def.isPost()).isFalse();
+    assertThat(def.params())
+      .extracting(WebService.Param::key, WebService.Param::isRequired)
+      .containsExactlyInAnyOrder(
+        tuple("almSetting", true),
+        tuple("projectName", false),
+        tuple("searchQuery", false));
+  }
+
+  @Test
+  public void search_repos() {
+    AlmSettingDto almSetting = insertAlmSetting();
+
+    SearchAzureReposWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .executeProtobuf(SearchAzureReposWsResponse.class);
+
+    assertThat(response.getRepositoriesList())
+      .extracting(AzureRepo::getName, AzureRepo::getProjectName)
+      .containsExactlyInAnyOrder(
+        tuple("repoName-1", "project-1"), tuple("repoName-2", "project-2"));
+  }
+
+  @Test
+  public void search_repos_alphabetically_sorted() {
+    mockClient(new GsonAzureRepoList(ImmutableList.of(getGsonAzureRepo("project-1", "Z-repo"),
+      getGsonAzureRepo("project-1", "A-repo-1"), getGsonAzureRepo("project-1", "a-repo"),
+      getGsonAzureRepo("project-1", "b-repo"))));
+
+    AlmSettingDto almSetting = insertAlmSetting();
+
+    SearchAzureReposWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .executeProtobuf(SearchAzureReposWsResponse.class);
+
+    assertThat(response.getRepositoriesList())
+      .extracting(AzureRepo::getName, AzureRepo::getProjectName)
+      .containsExactly(
+        tuple("a-repo", "project-1"), tuple("A-repo-1", "project-1"),
+        tuple("b-repo", "project-1"), tuple("Z-repo", "project-1"));
+  }
+
+  @Test
+  public void search_repos_with_project_already_set_up() {
+    AlmSettingDto almSetting = insertAlmSetting();
+
+    ProjectDto projectDto2 = insertProject(almSetting, "repoName-2", "project-2");
+
+    SearchAzureReposWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .executeProtobuf(SearchAzureReposWsResponse.class);
+
+    assertThat(response.getRepositoriesCount()).isEqualTo(2);
+
+    assertThat(response.getRepositoriesList())
+      .extracting(AzureRepo::getName, AzureRepo::getProjectName,
+        AzureRepo::getSqProjectKey, AzureRepo::getSqProjectName)
+      .containsExactlyInAnyOrder(
+        tuple("repoName-1", "project-1", "", ""),
+        tuple("repoName-2", "project-2", projectDto2.getKey(), projectDto2.getName()));
+  }
+
+  @Test
+  public void search_repos_with_project_already_set_u_and_collision_is_handled() {
+    AlmSettingDto almSetting = insertAlmSetting();
+
+    ProjectDto projectDto2 = insertProject(almSetting, "repoName-2", "project-2");
+    insertProject(almSetting, "repoName-2", "project-2");
+
+    SearchAzureReposWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .executeProtobuf(SearchAzureReposWsResponse.class);
+
+    assertThat(response.getRepositoriesCount()).isEqualTo(2);
+
+    assertThat(response.getRepositoriesList())
+      .extracting(AzureRepo::getName, AzureRepo::getProjectName,
+        AzureRepo::getSqProjectKey, AzureRepo::getSqProjectName)
+      .containsExactlyInAnyOrder(
+        tuple("repoName-1", "project-1", "", ""),
+        tuple("repoName-2", "project-2", projectDto2.getKey(), projectDto2.getName()));
+  }
+
+  @Test
+  public void search_repos_with_projects_already_set_up_and_no_collision() {
+    mockClient(new GsonAzureRepoList(ImmutableList.of(getGsonAzureRepo("project-1", "repoName-1"),
+      getGsonAzureRepo("project", "1-repoName-1"))));
+    AlmSettingDto almSetting = insertAlmSetting();
+
+    ProjectDto projectDto1 = insertProject(almSetting, "repoName-1", "project-1");
+    ProjectDto projectDto2 = insertProject(almSetting, "1-repoName-1", "project");
+
+    SearchAzureReposWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .executeProtobuf(SearchAzureReposWsResponse.class);
+
+    assertThat(response.getRepositoriesCount()).isEqualTo(2);
+
+    assertThat(response.getRepositoriesList())
+      .extracting(AzureRepo::getName, AzureRepo::getProjectName,
+        AzureRepo::getSqProjectKey, AzureRepo::getSqProjectName)
+      .containsExactlyInAnyOrder(
+        tuple("repoName-1", "project-1", projectDto1.getKey(), projectDto1.getName()),
+        tuple("1-repoName-1", "project", projectDto2.getKey(), projectDto2.getName()));
+  }
+
+  @Test
+  public void search_repos_with_same_name_and_different_project() {
+    mockClient(new GsonAzureRepoList(ImmutableList.of(getGsonAzureRepo("project-1", "repoName-1"),
+      getGsonAzureRepo("project-2", "repoName-1"))));
+    AlmSettingDto almSetting = insertAlmSetting();
+
+    ProjectDto projectDto1 = insertProject(almSetting, "repoName-1", "project-1");
+    ProjectDto projectDto2 = insertProject(almSetting, "repoName-1", "project-2");
+
+    SearchAzureReposWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .executeProtobuf(SearchAzureReposWsResponse.class);
+
+    assertThat(response.getRepositoriesCount()).isEqualTo(2);
+
+    assertThat(response.getRepositoriesList())
+      .extracting(AzureRepo::getName, AzureRepo::getProjectName,
+        AzureRepo::getSqProjectKey, AzureRepo::getSqProjectName)
+      .containsExactlyInAnyOrder(
+        tuple("repoName-1", "project-1", projectDto1.getKey(), projectDto1.getName()),
+        tuple("repoName-1", "project-2", projectDto2.getKey(), projectDto2.getName()));
+  }
+
+  @Test
+  public void search_repos_with_project_name() {
+    AlmSettingDto almSetting = insertAlmSetting();
+
+    SearchAzureReposWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .setParam("projectName", "project-1")
+      .executeProtobuf(SearchAzureReposWsResponse.class);
+
+    assertThat(response.getRepositoriesList())
+      .extracting(AzureRepo::getName, AzureRepo::getProjectName)
+      .containsExactlyInAnyOrder(
+        tuple("repoName-1", "project-1"), tuple("repoName-2", "project-2"));
+  }
+
+  @Test
+  public void search_repos_with_project_name_and_empty_criteria() {
+    AlmSettingDto almSetting = insertAlmSetting();
+
+    SearchAzureReposWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .setParam("projectName", "project-1")
+      .setParam("searchQuery", "")
+      .executeProtobuf(SearchAzureReposWsResponse.class);
+
+    assertThat(response.getRepositoriesList())
+      .extracting(AzureRepo::getName, AzureRepo::getProjectName)
+      .containsExactlyInAnyOrder(
+        tuple("repoName-1", "project-1"), tuple("repoName-2", "project-2"));
+  }
+
+  @Test
+  public void search_and_filter_repos_with_repo_name() {
+    AlmSettingDto almSetting = insertAlmSetting();
+
+    SearchAzureReposWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .setParam("searchQuery", "repoName-2")
+      .executeProtobuf(SearchAzureReposWsResponse.class);
+
+    assertThat(response.getRepositoriesList())
+      .extracting(AzureRepo::getName, AzureRepo::getProjectName)
+      .containsExactlyInAnyOrder(tuple("repoName-2", "project-2"));
+  }
+
+  @Test
+  public void search_and_filter_repos_with_matching_repo_and_project_name() {
+    mockClient(new GsonAzureRepoList(ImmutableList.of(getGsonAzureRepo("big-project", "repo-1"),
+      getGsonAzureRepo("big-project", "repo-2"),
+      getGsonAzureRepo("big-project", "big-repo"),
+      getGsonAzureRepo("project", "big-repo"),
+      getGsonAzureRepo("project", "small-repo"))));
+    AlmSettingDto almSetting = insertAlmSetting();
+
+    SearchAzureReposWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .setParam("searchQuery", "big")
+      .executeProtobuf(SearchAzureReposWsResponse.class);
+
+    assertThat(response.getRepositoriesList())
+      .extracting(AzureRepo::getName, AzureRepo::getProjectName)
+      .containsExactlyInAnyOrder(tuple("repo-1", "big-project"), tuple("repo-2", "big-project"),
+        tuple("big-repo", "big-project"), tuple("big-repo", "project"));
+  }
+
+  @Test
+  public void return_empty_list_when_there_are_no_azure_repos() {
+    when(azureDevOpsHttpClient.getRepos(any(), any(), any())).thenReturn(new GsonAzureRepoList(emptyList()));
+
+    AlmSettingDto almSetting = insertAlmSetting();
+
+    SearchAzureReposWsResponse response = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey())
+      .executeProtobuf(SearchAzureReposWsResponse.class);
+
+    assertThat(response.getRepositoriesList()).isEmpty();
+  }
+
+  @Test
+  public void check_pat_is_missing() {
+    insertUser();
+    AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting();
+
+    TestRequest request = ws.newRequest()
+      .setParam("almSetting", almSetting.getKey());
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("No personal access token found");
+  }
+
+  @Test
+  public void fail_check_pat_alm_setting_not_found() {
+    insertUser();
+    AlmPatDto almPatDto = newAlmPatDto();
+    db.getDbClient().almPatDao().insert(db.getSession(), almPatDto);
+
+    TestRequest request = ws.newRequest()
+      .setParam("almSetting", "testKey");
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(NotFoundException.class)
+      .hasMessage("ALM Setting 'testKey' not found");
+  }
+
+  @Test
+  public void fail_when_not_logged_in() {
+    TestRequest request = ws.newRequest()
+      .setParam("almSetting", "anyvalue");
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(UnauthorizedException.class);
+  }
+
+  @Test
+  public void fail_when_no_creation_project_permission() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user);
+
+    TestRequest request = ws.newRequest()
+      .setParam("almSetting", "anyvalue");
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(ForbiddenException.class)
+      .hasMessage("Insufficient privileges");
+  }
+
+  private ProjectDto insertProject(AlmSettingDto almSetting, String repoName, String projectName) {
+    ProjectDto projectDto1 = db.components().insertPrivateProjectDto();
+    db.almSettings().insertAzureProjectAlmSetting(almSetting, projectDto1, projectAlmSettingDto -> projectAlmSettingDto.setAlmRepo(repoName),
+      projectAlmSettingDto -> projectAlmSettingDto.setAlmSlug(projectName));
+    return projectDto1;
+  }
+
+  private void mockClient(GsonAzureRepoList repoList) {
+    when(azureDevOpsHttpClient.getRepos(any(), any(), any())).thenReturn(repoList);
+  }
+
+  private AlmSettingDto insertAlmSetting() {
+    UserDto user = insertUser();
+    AlmSettingDto almSetting = db.almSettings().insertAzureAlmSetting();
+    db.almPats().insert(dto -> {
+      dto.setAlmSettingUuid(almSetting.getUuid());
+      dto.setUserUuid(user.getUuid());
+      dto.setPersonalAccessToken(almSetting.getPersonalAccessToken());
+    });
+    return almSetting;
+  }
+
+  @NotNull
+  private UserDto insertUser() {
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user).addPermission(PROVISION_PROJECTS);
+    return user;
+  }
+
+  private GsonAzureRepo getGsonAzureRepo(String projectName, String repoName) {
+    GsonAzureProject project = new GsonAzureProject(projectName, "the best project ever");
+    GsonAzureRepo gsonAzureRepo = new GsonAzureRepo("repo-id", repoName, "url", project);
+    return gsonAzureRepo;
+  }
+}
index d6761042cc37050045ae53423ca60f6cd130cd7b..b7deaf622b6821e2f298d56b73024a579983b2f0 100644 (file)
@@ -21,6 +21,7 @@ package org.sonar.server.platform.platformlevel;
 
 import java.util.List;
 import org.sonar.alm.client.TimeoutConfigurationImpl;
+import org.sonar.alm.client.azure.AzureDevOpsHttpClient;
 import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient;
 import org.sonar.alm.client.gitlab.GitlabHttpClient;
 import org.sonar.api.profiles.AnnotationProfileParser;
@@ -494,6 +495,7 @@ public class PlatformLevel4 extends PlatformLevel {
       ImportHelper.class,
       BitbucketServerRestClient.class,
       GitlabHttpClient.class,
+      AzureDevOpsHttpClient.class,
       AlmIntegrationsWSModule.class,
 
       // Branch