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')
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.alm.client.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();
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.alm.client.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;
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.alm.client.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;
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.alm.client.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 +
+ '}';
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.alm.client.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;
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.alm.client.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;
+ }
+
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.alm.client.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();
+ }
+}
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;
SearchBitbucketServerReposAction.class,
ImportGitLabProjectAction.class,
SearchGitlabReposAction.class,
+ ImportAzureProjectAction.class,
+ ListAzureProjectsAction.class,
+ SearchAzureReposAction.class,
AlmIntegrationsWs.class);
}
}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+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(" ", "_");
+ }
+
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.almintegration.ws.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();
+ }
+
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.almintegration.ws.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);
+ }
+ }
+
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.server.almintegration.ws.azure;
+
+import javax.annotation.ParametersAreNonnullByDefault;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.almintegration.ws.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"));
+ }
+
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.almintegration.ws.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;
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.almintegration.ws.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;
+ }
+}
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;
ImportHelper.class,
BitbucketServerRestClient.class,
GitlabHttpClient.class,
+ AzureDevOpsHttpClient.class,
AlmIntegrationsWSModule.class,
// Branch