]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14306 - Moved SearchEvent WS to community edition
authorMark Rekveld <mark.rekveld@sonarsource.com>
Wed, 6 Jan 2021 15:44:11 +0000 (16:44 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 14 Jan 2021 20:30:31 +0000 (20:30 +0000)
15 files changed:
server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/DevelopersWs.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/DevelopersWsAction.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/DevelopersWsModule.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/SearchEventsAction.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/UuidFromPair.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/UuidFromPairs.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/resources/org/sonar/server/developers/ws/search_events-example.json [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/DevelopersWsTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/SearchEventsActionNewIssuesTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/SearchEventsActionQualityGateTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/SearchEventsActionTest.java [new file with mode: 0644]
server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java
sonar-ws/src/main/java/org/sonarqube/ws/client/developers/DevelopersService.java
sonar-ws/src/main/protobuf/ws-developers.proto [new file with mode: 0644]

diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/DevelopersWs.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/DevelopersWs.java
new file mode 100644 (file)
index 0000000..d54497f
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * 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.developers.ws;
+
+import java.util.Arrays;
+import java.util.List;
+import org.sonar.api.server.ws.WebService;
+
+public class DevelopersWs implements WebService {
+
+  private final List<DevelopersWsAction> actions;
+
+  public DevelopersWs(DevelopersWsAction... actions) {
+    this.actions = Arrays.asList(actions);
+  }
+
+  @Override
+  public void define(Context context) {
+    NewController controller = context.createController("api/developers")
+      .setDescription("Return data needed by SonarLint.")
+      .setSince("1.0");
+
+    actions.forEach(action -> action.define(controller));
+
+    controller.done();
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/DevelopersWsAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/DevelopersWsAction.java
new file mode 100644 (file)
index 0000000..5aac99a
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * 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.developers.ws;
+
+import org.sonar.server.ws.WsAction;
+
+interface DevelopersWsAction extends WsAction {
+  // marker interface
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/DevelopersWsModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/DevelopersWsModule.java
new file mode 100644 (file)
index 0000000..cc46692
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * 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.developers.ws;
+
+import org.sonar.core.platform.Module;
+
+public class DevelopersWsModule extends Module {
+  @Override
+  protected void configureModule() {
+    add(
+      DevelopersWs.class,
+      SearchEventsAction.class
+    );
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/SearchEventsAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/SearchEventsAction.java
new file mode 100644 (file)
index 0000000..c484ac9
--- /dev/null
@@ -0,0 +1,253 @@
+/*
+ * 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.developers.ws;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import org.sonar.api.platform.Server;
+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.web.UserRole;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.component.BranchDto;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.component.SnapshotDto;
+import org.sonar.db.event.EventDto;
+import org.sonar.server.issue.index.IssueIndex;
+import org.sonar.server.issue.index.IssueIndexSyncProgressChecker;
+import org.sonar.server.issue.index.ProjectStatistics;
+import org.sonar.server.projectanalysis.ws.EventCategory;
+import org.sonar.server.user.UserSession;
+import org.sonar.server.ws.KeyExamples;
+import org.sonarqube.ws.Developers.SearchEventsWsResponse;
+import org.sonarqube.ws.Developers.SearchEventsWsResponse.Event;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static java.lang.String.format;
+import static java.lang.String.join;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.comparing;
+import static org.sonar.api.utils.DateUtils.formatDateTime;
+import static org.sonar.api.utils.DateUtils.parseDateTimeQuietly;
+import static org.sonar.core.util.stream.MoreCollectors.toList;
+import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex;
+import static org.sonar.db.component.BranchType.BRANCH;
+import static org.sonar.db.component.BranchType.PULL_REQUEST;
+import static org.sonar.server.developers.ws.UuidFromPairs.componentUuids;
+import static org.sonar.server.developers.ws.UuidFromPairs.fromDates;
+import static org.sonar.server.exceptions.BadRequestException.checkRequest;
+import static org.sonar.server.ws.WsUtils.writeProtobuf;
+
+public class SearchEventsAction implements DevelopersWsAction {
+
+  public static final String PARAM_PROJECTS = "projects";
+  public static final String PARAM_FROM = "from";
+
+  private final DbClient dbClient;
+  private final UserSession userSession;
+  private final Server server;
+  private final IssueIndex issueIndex;
+  private final IssueIndexSyncProgressChecker issueIndexSyncProgressChecker;
+
+  public SearchEventsAction(DbClient dbClient, UserSession userSession, Server server, IssueIndex issueIndex,
+    IssueIndexSyncProgressChecker issueIndexSyncProgressChecker) {
+    this.dbClient = dbClient;
+    this.userSession = userSession;
+    this.server = server;
+    this.issueIndex = issueIndex;
+    this.issueIndexSyncProgressChecker = issueIndexSyncProgressChecker;
+  }
+
+  @Override
+  public void define(WebService.NewController controller) {
+    WebService.NewAction action = controller.createAction("search_events")
+      .setDescription("Search for events.<br/>" +
+        "Requires authentication."
+        + "<br/>When issue indexation is in progress returns 503 service unavailable HTTP code.")
+      .setSince("1.0")
+      .setInternal(true)
+      .setHandler(this)
+      .setResponseExample(SearchEventsAction.class.getResource("search_events-example.json"));
+
+    action.createParam(PARAM_PROJECTS)
+      .setRequired(true)
+      .setDescription("Comma-separated list of project keys to search notifications for")
+      .setExampleValue(join(",", KeyExamples.KEY_PROJECT_EXAMPLE_001, KeyExamples.KEY_PROJECT_EXAMPLE_002));
+
+    action.createParam(PARAM_FROM)
+      .setRequired(true)
+      .setDescription("Comma-separated list of datetimes. Filter events created after the given date (exclusive).")
+      .setExampleValue("2017-10-19T13:00:00+0200");
+  }
+
+  @Override
+  public void handle(Request request, Response response) throws Exception {
+    userSession.checkLoggedIn();
+    checkIfNeedIssueSync(request.mandatoryParamAsStrings(PARAM_PROJECTS));
+    SearchEventsWsResponse.Builder message = SearchEventsWsResponse.newBuilder();
+    computeEvents(request).forEach(message::addEvents);
+    writeProtobuf(message.build(), request, response);
+  }
+
+  private void checkIfNeedIssueSync(List<String> projectKeys) {
+    try (DbSession dbSession = dbClient.openSession(false)) {
+      issueIndexSyncProgressChecker.checkIfAnyComponentsNeedIssueSync(dbSession, projectKeys);
+    }
+  }
+
+  private Stream<Event> computeEvents(Request request) {
+    List<String> projectKeys = request.mandatoryParamAsStrings(PARAM_PROJECTS);
+    List<Long> fromDates = mandatoryParamAsDateTimes(request, PARAM_FROM);
+
+    if (projectKeys.isEmpty()) {
+      return Stream.empty();
+    }
+
+    try (DbSession dbSession = dbClient.openSession(false)) {
+      List<ComponentDto> authorizedProjects = searchProjects(dbSession, projectKeys);
+      Map<String, ComponentDto> componentsByUuid = authorizedProjects.stream().collect(uniqueIndex(ComponentDto::uuid));
+      List<UuidFromPair> uuidFromPairs = componentUuidFromPairs(fromDates, projectKeys, authorizedProjects);
+      List<SnapshotDto> analyses = dbClient.snapshotDao().selectFinishedByComponentUuidsAndFromDates(dbSession, componentUuids(uuidFromPairs), fromDates(uuidFromPairs));
+
+      if (analyses.isEmpty()) {
+        return Stream.empty();
+      }
+
+      List<String> projectUuids = analyses.stream().map(SnapshotDto::getComponentUuid).collect(toList());
+      Map<String, BranchDto> branchesByUuids = dbClient.branchDao().selectByUuids(dbSession, projectUuids).stream().collect(uniqueIndex(BranchDto::getUuid));
+
+      return Stream.concat(
+        computeQualityGateChangeEvents(dbSession, componentsByUuid, branchesByUuids, analyses),
+        computeNewIssuesEvents(componentsByUuid, branchesByUuids, uuidFromPairs));
+    }
+  }
+
+  private Stream<Event> computeQualityGateChangeEvents(DbSession dbSession, Map<String, ComponentDto> projectsByUuid,
+    Map<String, BranchDto> branchesByUuids,
+    List<SnapshotDto> analyses) {
+    Map<String, EventDto> eventsByComponentUuid = new HashMap<>();
+    dbClient.eventDao().selectByAnalysisUuids(dbSession, analyses.stream().map(SnapshotDto::getUuid).collect(toList(analyses.size())))
+      .stream()
+      .sorted(comparing(EventDto::getDate))
+      .filter(e -> EventCategory.QUALITY_GATE.getLabel().equals(e.getCategory()))
+      .forEach(e -> eventsByComponentUuid.put(e.getComponentUuid(), e));
+
+    Predicate<EventDto> branchPredicate = e -> branchesByUuids.get(e.getComponentUuid()).getBranchType() == BRANCH;
+    return eventsByComponentUuid.values()
+      .stream()
+      .sorted(comparing(EventDto::getDate))
+      .filter(branchPredicate)
+      .map(e -> {
+        BranchDto branch = branchesByUuids.get(e.getComponentUuid());
+        ComponentDto project = projectsByUuid.get(branch.getProjectUuid());
+        checkState(project != null, "Found event '%s', for a component that we did not search for", e.getUuid());
+        return Event.newBuilder()
+          .setCategory(EventCategory.fromLabel(e.getCategory()).name())
+          .setProject(project.getKey())
+          .setMessage(branch.isMain() ? format("Quality Gate status of project '%s' changed to '%s'", project.name(), e.getName())
+            : format("Quality Gate status of project '%s' on branch '%s' changed to '%s'", project.name(), branch.getKey(), e.getName()))
+          .setLink(computeDashboardLink(project, branch))
+          .setDate(formatDateTime(e.getDate()))
+          .build();
+      });
+  }
+
+  private Stream<Event> computeNewIssuesEvents(Map<String, ComponentDto> projectsByUuid, Map<String, BranchDto> branchesByUuids,
+    List<UuidFromPair> uuidFromPairs) {
+    Map<String, Long> fromsByProjectUuid = uuidFromPairs.stream().collect(Collectors.toMap(
+      UuidFromPair::getComponentUuid,
+      UuidFromPair::getFrom));
+    List<ProjectStatistics> projectStatistics = issueIndex.searchProjectStatistics(componentUuids(uuidFromPairs), fromDates(uuidFromPairs), userSession.getUuid());
+    return projectStatistics
+      .stream()
+      .map(e -> {
+        BranchDto branch = branchesByUuids.get(e.getProjectUuid());
+        ComponentDto project = projectsByUuid.get(branch.getProjectUuid());
+        long issueCount = e.getIssueCount();
+        long lastIssueDate = e.getLastIssueDate();
+        String branchType = branch.getBranchType().equals(PULL_REQUEST) ? "pull request" : "branch";
+        return Event.newBuilder()
+          .setCategory("NEW_ISSUES")
+          .setMessage(format("You have %s new %s on project '%s'", issueCount, issueCount == 1 ? "issue" : "issues",
+            project.name()) + (branch.isMain() ? "" : format(" on %s '%s'", branchType, branch.getKey())))
+          .setLink(computeIssuesSearchLink(project, branch, fromsByProjectUuid.get(project.uuid()), userSession.getLogin()))
+          .setProject(project.getKey())
+          .setDate(formatDateTime(lastIssueDate))
+          .build();
+      });
+  }
+
+  private List<ComponentDto> searchProjects(DbSession dbSession, List<String> projectKeys) {
+    List<ComponentDto> projects = dbClient.componentDao().selectByKeys(dbSession, projectKeys);
+    return userSession.keepAuthorizedComponents(UserRole.USER, projects);
+  }
+
+  private String computeIssuesSearchLink(ComponentDto component, BranchDto branch, long functionalFromDate, String login) {
+    String branchParam = branch.getBranchType().equals(PULL_REQUEST) ? "pullRequest" : "branch";
+    String link = format("%s/project/issues?id=%s&createdAfter=%s&assignees=%s&resolved=false",
+      server.getPublicRootUrl(), encode(component.getKey()), encode(formatDateTime(functionalFromDate)), encode(login));
+    link += branch.isMain() ? "" : format("&%s=%s", branchParam, encode(branch.getKey()));
+    return link;
+  }
+
+  private String computeDashboardLink(ComponentDto component, BranchDto branch) {
+    String link = server.getPublicRootUrl() + "/dashboard?id=" + encode(component.getKey());
+    link += branch.isMain() ? "" : format("&branch=%s", encode(branch.getKey()));
+    return link;
+  }
+
+  private static List<UuidFromPair> componentUuidFromPairs(List<Long> fromDates, List<String> projectKeys, List<ComponentDto> authorizedProjects) {
+    checkRequest(projectKeys.size() == fromDates.size(), "The number of components (%s) and from dates (%s) must be the same.", projectKeys.size(), fromDates.size());
+    Map<String, Long> fromDatesByProjectKey = IntStream.range(0, projectKeys.size()).boxed()
+      .collect(uniqueIndex(projectKeys::get, fromDates::get));
+    return authorizedProjects.stream()
+      .map(dto -> new UuidFromPair(dto.uuid(), fromDatesByProjectKey.get(dto.getDbKey())))
+      .collect(toList(authorizedProjects.size()));
+  }
+
+  private static List<Long> mandatoryParamAsDateTimes(Request request, String param) {
+    return request.mandatoryParamAsStrings(param).stream()
+      .map(stringDate -> {
+        Date date = parseDateTimeQuietly(stringDate);
+        checkArgument(date != null, "'%s' cannot be parsed as either a date or date+time", stringDate);
+        return date.getTime() + 1_000L;
+      })
+      .collect(toList());
+  }
+
+  private static String encode(String text) {
+    try {
+      return URLEncoder.encode(text, UTF_8.name());
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException(format("Cannot encode %s", text), e);
+    }
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/UuidFromPair.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/UuidFromPair.java
new file mode 100644 (file)
index 0000000..e0ac4e7
--- /dev/null
@@ -0,0 +1,38 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.developers.ws;
+
+class UuidFromPair {
+  private final String componentUuid;
+  private final long from;
+
+  public UuidFromPair(String componentUuid, long from) {
+    this.componentUuid = componentUuid;
+    this.from = from;
+  }
+
+  public String getComponentUuid() {
+    return componentUuid;
+  }
+
+  public long getFrom() {
+    return from;
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/UuidFromPairs.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/UuidFromPairs.java
new file mode 100644 (file)
index 0000000..adc16d0
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * 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.developers.ws;
+
+import java.util.List;
+import org.sonar.core.util.stream.MoreCollectors;
+
+public class UuidFromPairs {
+  private UuidFromPairs() {
+    // prevent instantiation
+  }
+
+  public static List<String> componentUuids(List<UuidFromPair> pairs) {
+    return pairs.stream().map(UuidFromPair::getComponentUuid).collect(MoreCollectors.toList(pairs.size()));
+  }
+
+  public static List<Long> fromDates(List<UuidFromPair> pairs) {
+    return pairs.stream().map(UuidFromPair::getFrom).collect(MoreCollectors.toList(pairs.size()));
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/package-info.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/package-info.java
new file mode 100644 (file)
index 0000000..056fa9d
--- /dev/null
@@ -0,0 +1,24 @@
+/*
+ * 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.developers.ws;
+
+import javax.annotation.ParametersAreNonnullByDefault;
+
diff --git a/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/developers/ws/search_events-example.json b/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/developers/ws/search_events-example.json
new file mode 100644 (file)
index 0000000..522b5c4
--- /dev/null
@@ -0,0 +1,16 @@
+{
+  "events": [
+    {
+      "category": "QUALITY_GATE",
+      "message": "Quality Gate status of project 'My Project' changed to 'Failed'",
+      "link": "https://sonarcloud.io/dashboard?id=my_project",
+      "project": "my_project"
+    },
+    {
+      "category": "NEW_ISSUES",
+      "message": "You have 15 new issues on project 'My Project'",
+      "link": "https://sonarcloud.io/project/issues?id=my_project&createdAfter=2017-03-01T00%3A00%3A00%2B0100&assignees=me%40github&resolved=false",
+      "project": "my_project"
+    }
+  ]
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/DevelopersWsTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/DevelopersWsTest.java
new file mode 100644 (file)
index 0000000..f5cbc8d
--- /dev/null
@@ -0,0 +1,43 @@
+/*
+ * 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.developers.ws;
+
+import org.junit.Test;
+import org.sonar.api.server.ws.WebService;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class DevelopersWsTest {
+
+  private DevelopersWs underTest = new DevelopersWs(new SearchEventsAction(null, null, null, null,
+    null));
+
+  @Test
+  public void definition() {
+    WebService.Context context = new WebService.Context();
+    underTest.define(context);
+    WebService.Controller controller = context.controller("api/developers");
+
+    assertThat(controller).isNotNull();
+    assertThat(controller.description()).isNotEmpty();
+    assertThat(controller.since()).isEqualTo("1.0");
+    assertThat(controller.actions()).extracting(WebService.Action::key).isNotEmpty();
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/SearchEventsActionNewIssuesTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/SearchEventsActionNewIssuesTest.java
new file mode 100644 (file)
index 0000000..2d74b75
--- /dev/null
@@ -0,0 +1,302 @@
+/*
+ * 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.developers.ws;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.Date;
+import java.util.stream.Stream;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.api.platform.Server;
+import org.sonar.api.rules.RuleType;
+import org.sonar.db.DbTester;
+import org.sonar.db.ce.CeActivityDto;
+import org.sonar.db.ce.CeQueueDto;
+import org.sonar.db.ce.CeTaskTypes;
+import org.sonar.db.component.BranchType;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.component.SnapshotDto;
+import org.sonar.db.rule.RuleDefinitionDto;
+import org.sonar.server.es.EsTester;
+import org.sonar.server.issue.index.IssueIndex;
+import org.sonar.server.issue.index.IssueIndexSyncProgressChecker;
+import org.sonar.server.issue.index.IssueIndexer;
+import org.sonar.server.issue.index.IssueIteratorFactory;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.WsActionTester;
+import org.sonarqube.ws.Developers.SearchEventsWsResponse;
+import org.sonarqube.ws.Developers.SearchEventsWsResponse.Event;
+
+import static java.lang.String.format;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
+import static org.apache.commons.lang.math.RandomUtils.nextInt;
+import static org.apache.commons.lang.math.RandomUtils.nextLong;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.sonar.api.utils.DateUtils.formatDateTime;
+import static org.sonar.db.component.BranchType.BRANCH;
+import static org.sonar.server.developers.ws.SearchEventsAction.PARAM_FROM;
+import static org.sonar.server.developers.ws.SearchEventsAction.PARAM_PROJECTS;
+
+public class SearchEventsActionNewIssuesTest {
+
+  private static final RuleType[] RULE_TYPES_EXCEPT_HOTSPOT = Stream.of(RuleType.values())
+    .filter(r -> r != RuleType.SECURITY_HOTSPOT)
+    .toArray(RuleType[]::new);
+
+  @Rule
+  public DbTester db = DbTester.create();
+  @Rule
+  public EsTester es = EsTester.create();
+  @Rule
+  public UserSessionRule userSession = UserSessionRule.standalone();
+
+  private Server server = mock(Server.class);
+
+  private IssueIndex issueIndex = new IssueIndex(es.client(), null, null, null);
+  private IssueIndexer issueIndexer = new IssueIndexer(es.client(), db.getDbClient(), new IssueIteratorFactory(db.getDbClient()), null);
+  private IssueIndexSyncProgressChecker issueIndexSyncProgressChecker = mock(IssueIndexSyncProgressChecker.class);
+  private WsActionTester ws = new WsActionTester(new SearchEventsAction(db.getDbClient(), userSession, server, issueIndex,
+    issueIndexSyncProgressChecker));
+
+  @Test
+  public void issue_event() {
+    userSession.logIn().setRoot();
+    when(server.getPublicRootUrl()).thenReturn("https://sonarcloud.io");
+    ComponentDto project = db.components().insertPrivateProject();
+    SnapshotDto analysis = insertAnalysis(project, 1_500_000_000_000L);
+    insertIssue(project, analysis);
+    insertIssue(project, analysis);
+    // will be ignored
+    insertSecurityHotspot(project, analysis);
+    issueIndexer.indexAllIssues();
+
+    long from = analysis.getCreatedAt() - 1_000_000L;
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(from))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList())
+      .extracting(Event::getCategory, Event::getProject, Event::getMessage, Event::getLink, Event::getDate)
+      .containsOnly(
+        tuple("NEW_ISSUES", project.getKey(), format("You have 2 new issues on project '%s'", project.name()),
+          format("https://sonarcloud.io/project/issues?id=%s&createdAfter=%s&assignees=%s&resolved=false", project.getKey(), encode(formatDateTime(from + 1_000L)),
+            userSession.getLogin()),
+          formatDateTime(analysis.getCreatedAt())));
+  }
+
+  @Test
+  public void many_issues_events() {
+    userSession.logIn().setRoot();
+    long from = 1_500_000_000_000L;
+    ComponentDto project = db.components().insertPrivateProject(p -> p.setName("SonarQube"));
+    SnapshotDto analysis = insertAnalysis(project, from);
+    insertIssue(project, analysis);
+    insertIssue(project, analysis);
+    issueIndexer.indexAllIssues();
+    String fromDate = formatDateTime(from - 1_000L);
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, fromDate)
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList()).extracting(Event::getCategory, Event::getMessage, Event::getProject, Event::getDate)
+      .containsExactly(tuple("NEW_ISSUES", "You have 2 new issues on project 'SonarQube'", project.getKey(),
+        formatDateTime(from)));
+  }
+
+  @Test
+  public void does_not_return_old_issue() {
+    userSession.logIn().setRoot();
+    ComponentDto project = db.components().insertPrivateProject();
+    SnapshotDto analysis = insertAnalysis(project, 1_500_000_000_000L);
+    db.issues().insert(db.rules().insert(), project, project, i -> i.setIssueCreationDate(new Date(analysis.getCreatedAt() - 10_000L)));
+    issueIndexer.indexAllIssues();
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(analysis.getCreatedAt() - 1_000L))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList()).isEmpty();
+  }
+
+  @Test
+  public void return_link_to_issue_search_for_new_issues_event() {
+    userSession.logIn("my_login").setRoot();
+    ComponentDto project = db.components().insertPrivateProject(p -> p.setDbKey("my_project"));
+    SnapshotDto analysis = insertAnalysis(project, 1_400_000_000_000L);
+    insertIssue(project, analysis);
+    issueIndexer.indexAllIssues();
+    when(server.getPublicRootUrl()).thenReturn("https://sonarcloud.io");
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(analysis.getCreatedAt() - 1_000L))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList()).extracting(Event::getLink)
+      .containsExactly("https://sonarcloud.io/project/issues?id=my_project&createdAfter=" + encode(formatDateTime(analysis.getCreatedAt())) + "&assignees=my_login&resolved=false");
+  }
+
+  @Test
+  public void branch_issues_events() {
+    userSession.logIn().setRoot();
+    when(server.getPublicRootUrl()).thenReturn("https://sonarcloud.io");
+    ComponentDto project = db.components().insertPrivateProject();
+    ComponentDto branch1 = db.components().insertProjectBranch(project, b -> b.setBranchType(BRANCH).setKey("branch1"));
+    SnapshotDto branch1Analysis = insertAnalysis(branch1, 1_500_000_000_000L);
+    insertIssue(branch1, branch1Analysis);
+    insertIssue(branch1, branch1Analysis);
+    ComponentDto branch2 = db.components().insertProjectBranch(project, b -> b.setBranchType(BranchType.BRANCH).setKey("branch"));
+    SnapshotDto branch2Analysis = insertAnalysis(branch2, 1_300_000_000_000L);
+    insertIssue(branch2, branch2Analysis);
+    issueIndexer.indexAllIssues();
+
+    long from = 1_000_000_000_000L;
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(from))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList())
+      .extracting(Event::getCategory, Event::getProject, Event::getMessage, Event::getLink, Event::getDate)
+      .containsOnly(
+        tuple("NEW_ISSUES", project.getKey(), format("You have 2 new issues on project '%s' on branch '%s'", project.name(), branch1.getBranch()),
+          format("https://sonarcloud.io/project/issues?id=%s&createdAfter=%s&assignees=%s&resolved=false&branch=%s", branch1.getKey(), encode(formatDateTime(from + 1_000L)),
+            userSession.getLogin(), branch1.getBranch()),
+          formatDateTime(branch1Analysis.getCreatedAt())),
+        tuple("NEW_ISSUES", project.getKey(), format("You have 1 new issue on project '%s' on branch '%s'", project.name(), branch2.getBranch()),
+          format("https://sonarcloud.io/project/issues?id=%s&createdAfter=%s&assignees=%s&resolved=false&branch=%s", branch2.getKey(), encode(formatDateTime(from + 1_000L)),
+            userSession.getLogin(), branch2.getBranch()),
+          formatDateTime(branch2Analysis.getCreatedAt())));
+  }
+
+  @Test
+  public void pull_request_issues_events() {
+    userSession.logIn().setRoot();
+    when(server.getPublicRootUrl()).thenReturn("https://sonarcloud.io");
+    ComponentDto project = db.components().insertPrivateProject();
+    ComponentDto nonMainBranch = db.components().insertProjectBranch(project, b -> b.setBranchType(BRANCH).setKey("nonMain"));
+    SnapshotDto nonMainBranchAnalysis = insertAnalysis(nonMainBranch, 1_500_000_000_000L);
+    insertIssue(nonMainBranch, nonMainBranchAnalysis);
+    insertIssue(nonMainBranch, nonMainBranchAnalysis);
+    ComponentDto pullRequest = db.components().insertProjectBranch(project, b -> b.setBranchType(BranchType.PULL_REQUEST).setKey("42"));
+    SnapshotDto pullRequestAnalysis = insertAnalysis(pullRequest, 1_300_000_000_000L);
+    insertIssue(pullRequest, pullRequestAnalysis);
+    issueIndexer.indexAllIssues();
+
+    long from = 1_000_000_000_000L;
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(from))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList())
+      .extracting(Event::getCategory, Event::getProject, Event::getMessage, Event::getLink, Event::getDate)
+      .containsOnly(
+        tuple("NEW_ISSUES", project.getKey(), format("You have 2 new issues on project '%s' on branch '%s'", project.name(), nonMainBranch.getBranch()),
+          format("https://sonarcloud.io/project/issues?id=%s&createdAfter=%s&assignees=%s&resolved=false&branch=%s", nonMainBranch.getKey(), encode(formatDateTime(from + 1_000L)),
+            userSession.getLogin(), nonMainBranch.getBranch()),
+          formatDateTime(nonMainBranchAnalysis.getCreatedAt())),
+        tuple("NEW_ISSUES", project.getKey(), format("You have 1 new issue on project '%s' on pull request '%s'", project.name(), pullRequest.getPullRequest()),
+          format("https://sonarcloud.io/project/issues?id=%s&createdAfter=%s&assignees=%s&resolved=false&pullRequest=%s", pullRequest.getKey(),
+            encode(formatDateTime(from + 1_000L)),
+            userSession.getLogin(), pullRequest.getPullRequest()),
+          formatDateTime(pullRequestAnalysis.getCreatedAt())));
+  }
+
+  @Test
+  public void encode_link() {
+    userSession.logIn("rÃ¥gnar").setRoot();
+    long from = 1_500_000_000_000L;
+    ComponentDto project = db.components().insertPrivateProject(p -> p.setDbKey("M&M's"));
+    SnapshotDto analysis = insertAnalysis(project, from);
+    insertIssue(project, analysis);
+    issueIndexer.indexAllIssues();
+    when(server.getPublicRootUrl()).thenReturn("http://sonarcloud.io");
+
+    String fromDate = formatDateTime(from - 1_000L);
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, fromDate)
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList()).extracting(Event::getLink)
+      .containsExactly("http://sonarcloud.io/project/issues?id=M%26M%27s&createdAfter=" + encode(formatDateTime(from)) + "&assignees=r%C3%A5gnar&resolved=false");
+  }
+
+  private String encode(String text) {
+    try {
+      return URLEncoder.encode(text, UTF_8.name());
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException(format("Cannot encode %s", text), e);
+    }
+  }
+
+  private void insertIssue(ComponentDto component, SnapshotDto analysis) {
+    RuleDefinitionDto rule = db.rules().insert(r -> r.setType(randomRuleTypeExceptHotspot()));
+    db.issues().insert(rule, component, component,
+      i -> i.setIssueCreationDate(new Date(analysis.getCreatedAt()))
+        .setAssigneeUuid(userSession.getUuid())
+        .setType(randomRuleTypeExceptHotspot()));
+  }
+
+  private void insertSecurityHotspot(ComponentDto component, SnapshotDto analysis) {
+    RuleDefinitionDto rule = db.rules().insert(r -> r.setType(RuleType.SECURITY_HOTSPOT));
+    db.issues().insert(rule, component, component,
+      i -> i.setIssueCreationDate(new Date(analysis.getCreatedAt()))
+        .setAssigneeUuid(userSession.getUuid())
+        .setType(RuleType.SECURITY_HOTSPOT));
+  }
+
+  private SnapshotDto insertAnalysis(ComponentDto project, long analysisDate) {
+    SnapshotDto analysis = db.components().insertSnapshot(project, s -> s.setCreatedAt(analysisDate));
+    insertActivity(project, analysis, CeActivityDto.Status.SUCCESS);
+    return analysis;
+  }
+
+  private CeActivityDto insertActivity(ComponentDto project, SnapshotDto analysis, CeActivityDto.Status status) {
+    CeQueueDto queueDto = new CeQueueDto();
+    queueDto.setTaskType(CeTaskTypes.REPORT);
+    String mainBranchProjectUuid = project.getMainBranchProjectUuid();
+    queueDto.setComponentUuid(mainBranchProjectUuid == null ? project.uuid() : mainBranchProjectUuid);
+    queueDto.setUuid(randomAlphanumeric(40));
+    queueDto.setCreatedAt(nextLong());
+    CeActivityDto activityDto = new CeActivityDto(queueDto);
+    activityDto.setStatus(status);
+    activityDto.setExecutionTimeMs(nextLong());
+    activityDto.setExecutedAt(nextLong());
+    activityDto.setAnalysisUuid(analysis.getUuid());
+    db.getDbClient().ceActivityDao().insert(db.getSession(), activityDto);
+    db.commit();
+    return activityDto;
+  }
+
+  private RuleType randomRuleTypeExceptHotspot() {
+    return RULE_TYPES_EXCEPT_HOTSPOT[nextInt(RULE_TYPES_EXCEPT_HOTSPOT.length)];
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/SearchEventsActionQualityGateTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/SearchEventsActionQualityGateTest.java
new file mode 100644 (file)
index 0000000..c230304
--- /dev/null
@@ -0,0 +1,288 @@
+/*
+ * 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.developers.ws;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.api.platform.Server;
+import org.sonar.db.DbTester;
+import org.sonar.db.ce.CeActivityDto;
+import org.sonar.db.ce.CeQueueDto;
+import org.sonar.db.ce.CeTaskTypes;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.component.SnapshotDto;
+import org.sonar.db.event.EventDto;
+import org.sonar.server.es.EsTester;
+import org.sonar.server.issue.index.IssueIndex;
+import org.sonar.server.issue.index.IssueIndexSyncProgressChecker;
+import org.sonar.server.projectanalysis.ws.EventCategory;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.WsActionTester;
+import org.sonarqube.ws.Developers.SearchEventsWsResponse;
+import org.sonarqube.ws.Developers.SearchEventsWsResponse.Event;
+
+import static java.lang.String.format;
+import static java.lang.String.join;
+import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
+import static org.apache.commons.lang.math.RandomUtils.nextLong;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.sonar.api.utils.DateUtils.formatDateTime;
+import static org.sonar.db.component.BranchType.BRANCH;
+import static org.sonar.db.component.BranchType.PULL_REQUEST;
+import static org.sonar.db.event.EventTesting.newEvent;
+import static org.sonar.server.developers.ws.SearchEventsAction.PARAM_FROM;
+import static org.sonar.server.developers.ws.SearchEventsAction.PARAM_PROJECTS;
+
+public class SearchEventsActionQualityGateTest {
+
+  @Rule
+  public DbTester db = DbTester.create();
+  @Rule
+  public EsTester es = EsTester.create();
+  @Rule
+  public UserSessionRule userSession = UserSessionRule.standalone();
+
+  private Server server = mock(Server.class);
+  private IssueIndex issueIndex = new IssueIndex(es.client(), null, null, null);
+  private IssueIndexSyncProgressChecker issueIndexSyncProgressChecker = mock(IssueIndexSyncProgressChecker.class);
+  private WsActionTester ws = new WsActionTester(new SearchEventsAction(db.getDbClient(), userSession, server, issueIndex,
+    issueIndexSyncProgressChecker));
+
+  @Test
+  public void quality_gate_events() {
+    userSession.logIn().setRoot();
+    when(server.getPublicRootUrl()).thenReturn("https://sonarcloud.io");
+    ComponentDto project = db.components().insertPrivateProject();
+    SnapshotDto projectAnalysis = insertSuccessfulActivity(project, 1_500_000_000_000L);
+    db.events().insertEvent(newQualityGateEvent(projectAnalysis).setDate(projectAnalysis.getCreatedAt()).setName("Failed"));
+
+    long from = 1_000_000_000_000L;
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(from))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList())
+      .extracting(Event::getCategory, Event::getProject, Event::getMessage, Event::getLink, Event::getDate)
+      .containsOnly(
+        tuple("QUALITY_GATE", project.getKey(),
+          format("Quality Gate status of project '%s' changed to 'Failed'", project.name()),
+          format("https://sonarcloud.io/dashboard?id=%s", project.getKey()),
+          formatDateTime(projectAnalysis.getCreatedAt()))
+      );
+  }
+
+  @Test
+  public void branch_quality_gate_events() {
+    userSession.logIn().setRoot();
+    when(server.getPublicRootUrl()).thenReturn("https://sonarcloud.io");
+    ComponentDto project = db.components().insertPrivateProject();
+    ComponentDto branch = db.components().insertProjectBranch(project, b -> b.setBranchType(BRANCH));
+    SnapshotDto projectAnalysis = insertSuccessfulActivity(project, 1_500_000_000_000L);
+    SnapshotDto branchAnalysis = insertSuccessfulActivity(branch, 1_500_000_000_000L);
+    insertActivity(branch, branchAnalysis, CeActivityDto.Status.SUCCESS);
+    db.events().insertEvent(newQualityGateEvent(branchAnalysis).setDate(branchAnalysis.getCreatedAt()).setName("Failed"));
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(branchAnalysis.getCreatedAt() - 1_000L))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList())
+      .extracting(Event::getCategory, Event::getProject, Event::getMessage, Event::getLink)
+      .containsOnly(
+        tuple("QUALITY_GATE", project.getKey(),
+          format("Quality Gate status of project '%s' on branch '%s' changed to 'Failed'", project.name(), branch.getBranch()),
+          format("https://sonarcloud.io/dashboard?id=%s&branch=%s", project.getKey(), branch.getBranch()))
+      );
+  }
+
+  @Test
+  public void does_not_return_quality_gate_events_on_pull_request() {
+    userSession.logIn().setRoot();
+    when(server.getPublicRootUrl()).thenReturn("https://sonarcloud.io");
+    ComponentDto project = db.components().insertPrivateProject();
+    ComponentDto pr = db.components().insertProjectBranch(project, b -> b.setBranchType(PULL_REQUEST));
+    SnapshotDto prAnalysis = insertSuccessfulActivity(pr, 1_500_000_000_000L);
+    insertActivity(pr, prAnalysis, CeActivityDto.Status.SUCCESS);
+    db.events().insertEvent(newQualityGateEvent(prAnalysis).setDate(prAnalysis.getCreatedAt()).setName("Failed"));
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(prAnalysis.getCreatedAt() - 1_000L))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList()).isEmpty();
+  }
+
+  @Test
+  public void return_only_latest_quality_gate_event() {
+    userSession.logIn().setRoot();
+    ComponentDto project = db.components().insertPrivateProject(p -> p.setName("My Project"));
+    SnapshotDto a1 = insertSuccessfulActivity(project, 1_500_000_000_000L);
+    EventDto e1 = db.events().insertEvent(newQualityGateEvent(a1).setName("Failed").setDate(a1.getCreatedAt()));
+    SnapshotDto a2 = insertSuccessfulActivity(project, 1_500_000_000_001L);
+    EventDto e2 = db.events().insertEvent(newQualityGateEvent(a2).setName("Passed").setDate(a2.getCreatedAt() + 1L));
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(a1.getCreatedAt() - 1_000L))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList()).extracting(Event::getMessage)
+      .containsExactly("Quality Gate status of project 'My Project' changed to 'Passed'");
+  }
+
+  @Test
+  public void return_link_to_dashboard_for_quality_gate_event() {
+    userSession.logIn().setRoot();
+    ComponentDto project = db.components().insertPrivateProject();
+    SnapshotDto analysis = insertSuccessfulActivity(project, 1_500_000_000_000L);
+    EventDto e1 = db.events().insertEvent(newQualityGateEvent(analysis).setName("Failed").setDate(analysis.getCreatedAt()));
+    when(server.getPublicRootUrl()).thenReturn("https://sonarcloud.io");
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(analysis.getCreatedAt() - 1_000L))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList()).extracting(Event::getLink)
+      .containsExactly("https://sonarcloud.io/dashboard?id=" + project.getKey());
+  }
+
+  @Test
+  public void encode_link() {
+    userSession.logIn().setRoot();
+    ComponentDto project = db.components().insertPrivateProject(p -> p.setDbKey("M&M's"));
+    SnapshotDto analysis = insertSuccessfulActivity(project, 1_500_000_000_000L);
+    EventDto event = db.events().insertEvent(newQualityGateEvent(analysis).setName("Failed").setDate(analysis.getCreatedAt()));
+    when(server.getPublicRootUrl()).thenReturn("http://sonarcloud.io");
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(analysis.getCreatedAt() - 1_000L))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList()).extracting(Event::getLink)
+      .containsExactly("http://sonarcloud.io/dashboard?id=M%26M%27s");
+  }
+
+  @Test
+  public void filter_quality_gate_event() {
+    userSession.logIn().setRoot();
+    ComponentDto project = db.components().insertPrivateProject();
+    SnapshotDto analysis = insertSuccessfulActivity(project, 1_500_000_000_000L);
+    EventDto qualityGateEvent = db.events().insertEvent(newQualityGateEvent(analysis).setDate(analysis.getCreatedAt()));
+    EventDto versionEvent = db.events().insertEvent(newEvent(analysis).setCategory(EventCategory.VERSION.getLabel()).setDate(analysis.getCreatedAt()));
+    EventDto qualityProfileEvent = db.events().insertEvent(newEvent(analysis).setCategory(EventCategory.QUALITY_PROFILE.getLabel()).setDate(analysis.getCreatedAt()));
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(analysis.getCreatedAt() - 1_000L))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList()).extracting(Event::getCategory)
+      .containsExactly("QUALITY_GATE");
+  }
+
+  @Test
+  public void filter_by_from_date_inclusive() {
+    userSession.logIn().setRoot();
+    ComponentDto project1 = db.components().insertPrivateProject();
+    ComponentDto project2 = db.components().insertPrivateProject();
+    ComponentDto project3 = db.components().insertPrivateProject();
+    long from1 = 1_500_000_000_000L;
+    long from2 = 1_400_000_000_000L;
+    long from3 = 1_300_000_000_000L;
+    SnapshotDto a1 = insertSuccessfulActivity(project1, from1 - 1L);
+    db.events().insertEvent(newQualityGateEvent(a1).setDate(a1.getCreatedAt()));
+    SnapshotDto a2 = insertSuccessfulActivity(project2, from2);
+    db.events().insertEvent(newQualityGateEvent(a2).setDate(from2));
+    SnapshotDto a3 = insertSuccessfulActivity(project3, from3 + 1L);
+    db.events().insertEvent(newQualityGateEvent(a3).setDate(from3 + 1L));
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, join(",", project1.getKey(), project2.getKey(), project3.getKey()))
+      .setParam(PARAM_FROM, join(",", formatDateTime(from1 - 1_000L), formatDateTime(from2 - 1_000L), formatDateTime(from3 - 1_000L)))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList())
+      .extracting(Event::getProject)
+      .containsExactlyInAnyOrder(project2.getKey(), project3.getKey());
+  }
+
+  @Test
+  public void return_one_quality_gate_change_per_project() {
+    userSession.logIn().setRoot();
+    ComponentDto project1 = db.components().insertPrivateProject(p -> p.setName("p1"));
+    ComponentDto project2 = db.components().insertPrivateProject(p -> p.setName("p2"));
+    long from = 1_500_000_000_000L;
+    SnapshotDto a11 = insertSuccessfulActivity(project1, from);
+    SnapshotDto a12 = insertSuccessfulActivity(project1, from + 1L);
+    SnapshotDto a21 = insertSuccessfulActivity(project2, from);
+    SnapshotDto a22 = insertSuccessfulActivity(project2, from + 1L);
+    EventDto e11 = db.events().insertEvent(newQualityGateEvent(a11).setName("e11").setDate(from));
+    EventDto e12 = db.events().insertEvent(newQualityGateEvent(a12).setName("e12").setDate(from + 1L));
+    EventDto e21 = db.events().insertEvent(newQualityGateEvent(a21).setName("e21").setDate(from));
+    EventDto e22 = db.events().insertEvent(newQualityGateEvent(a22).setName("e22").setDate(from + 1L));
+    String fromDate = formatDateTime(from - 1_000L);
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, join(",", project1.getKey(), project2.getKey()))
+      .setParam(PARAM_FROM, join(",", fromDate, fromDate))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList())
+      .extracting(Event::getProject, Event::getMessage)
+      .containsExactlyInAnyOrder(
+        tuple(project1.getKey(), "Quality Gate status of project 'p1' changed to 'e12'"),
+        tuple(project2.getKey(), "Quality Gate status of project 'p2' changed to 'e22'"));
+  }
+
+  private static EventDto newQualityGateEvent(SnapshotDto analysis) {
+    return newEvent(analysis).setCategory(EventCategory.QUALITY_GATE.getLabel());
+  }
+
+  private SnapshotDto insertSuccessfulActivity(ComponentDto project, long analysisDate) {
+    SnapshotDto analysis = db.components().insertSnapshot(project, s -> s.setCreatedAt(analysisDate));
+    insertActivity(project, analysis, CeActivityDto.Status.SUCCESS);
+    return analysis;
+  }
+
+  private CeActivityDto insertActivity(ComponentDto project, SnapshotDto analysis, CeActivityDto.Status status) {
+    CeQueueDto queueDto = new CeQueueDto();
+    queueDto.setTaskType(CeTaskTypes.REPORT);
+    String mainBranchProjectUuid = project.getMainBranchProjectUuid();
+    queueDto.setComponentUuid(mainBranchProjectUuid == null ? project.uuid() : mainBranchProjectUuid);
+    queueDto.setUuid(randomAlphanumeric(40));
+    queueDto.setCreatedAt(nextLong());
+    CeActivityDto activityDto = new CeActivityDto(queueDto);
+    activityDto.setStatus(status);
+    activityDto.setExecutionTimeMs(nextLong());
+    activityDto.setExecutedAt(nextLong());
+    activityDto.setAnalysisUuid(analysis.getUuid());
+    db.getDbClient().ceActivityDao().insert(db.getSession(), activityDto);
+    db.commit();
+    return activityDto;
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/SearchEventsActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/SearchEventsActionTest.java
new file mode 100644 (file)
index 0000000..f707f0f
--- /dev/null
@@ -0,0 +1,301 @@
+/*
+ * 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.developers.ws;
+
+import java.util.Date;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.sonar.api.platform.Server;
+import org.sonar.api.rules.RuleType;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.api.server.ws.WebService.Param;
+import org.sonar.api.web.UserRole;
+import org.sonar.db.DbTester;
+import org.sonar.db.ce.CeActivityDto;
+import org.sonar.db.ce.CeQueueDto;
+import org.sonar.db.ce.CeTaskTypes;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.component.SnapshotDto;
+import org.sonar.db.event.EventDto;
+import org.sonar.server.es.EsTester;
+import org.sonar.server.exceptions.UnauthorizedException;
+import org.sonar.server.issue.index.IssueIndex;
+import org.sonar.server.issue.index.IssueIndexSyncProgressChecker;
+import org.sonar.server.issue.index.IssueIndexer;
+import org.sonar.server.issue.index.IssueIteratorFactory;
+import org.sonar.server.projectanalysis.ws.EventCategory;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.KeyExamples;
+import org.sonar.server.ws.WsActionTester;
+import org.sonarqube.ws.Developers.SearchEventsWsResponse;
+import org.sonarqube.ws.Developers.SearchEventsWsResponse.Event;
+
+import static java.lang.String.format;
+import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
+import static org.apache.commons.lang.math.RandomUtils.nextInt;
+import static org.apache.commons.lang.math.RandomUtils.nextLong;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.sonar.api.utils.DateUtils.formatDateTime;
+import static org.sonar.db.event.EventTesting.newEvent;
+import static org.sonar.server.developers.ws.SearchEventsAction.PARAM_FROM;
+import static org.sonar.server.developers.ws.SearchEventsAction.PARAM_PROJECTS;
+import static org.sonar.test.JsonAssert.assertJson;
+
+public class SearchEventsActionTest {
+
+  private static final RuleType[] RULE_TYPES_EXCEPT_HOTSPOT = Stream.of(RuleType.values())
+    .filter(r -> r != RuleType.SECURITY_HOTSPOT)
+    .toArray(RuleType[]::new);
+
+  @Rule
+  public DbTester db = DbTester.create();
+  @Rule
+  public EsTester es = EsTester.create();
+  @Rule
+  public UserSessionRule userSession = UserSessionRule.standalone();
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+  private Server server = mock(Server.class);
+  private IssueIndex issueIndex = new IssueIndex(es.client(), null, null, null);
+  private IssueIndexSyncProgressChecker issueIndexSyncProgressChecker = mock(IssueIndexSyncProgressChecker.class);
+  private IssueIndexer issueIndexer = new IssueIndexer(es.client(), db.getDbClient(), new IssueIteratorFactory(db.getDbClient()), null);
+  private WsActionTester ws = new WsActionTester(new SearchEventsAction(db.getDbClient(), userSession, server, issueIndex,
+    issueIndexSyncProgressChecker));
+
+  @Test
+  public void definition() {
+    WebService.Action definition = ws.getDef();
+
+    assertThat(definition.key()).isEqualTo("search_events");
+    assertThat(definition.description()).isNotEmpty();
+    assertThat(definition.isPost()).isFalse();
+    assertThat(definition.isInternal()).isTrue();
+    assertThat(definition.since()).isEqualTo("1.0");
+    assertThat(definition.description()).isNotEmpty();
+    assertThat(definition.responseExampleAsString()).isNotEmpty();
+    assertThat(definition.params()).extracting(Param::key).containsOnly("projects", "from");
+    Param projects = definition.param("projects");
+    assertThat(projects.isRequired()).isTrue();
+    assertThat(projects.exampleValue()).isEqualTo("my_project,another_project");
+    assertThat(definition.param("from").isRequired()).isTrue();
+  }
+
+  @Test
+  public void json_example() {
+    userSession.logIn().setRoot();
+    ComponentDto project = db.components().insertPrivateProject(p -> p.setName("My Project").setDbKey(KeyExamples.KEY_PROJECT_EXAMPLE_001));
+    SnapshotDto analysis = insertAnalysis(project, 1_500_000_000_000L);
+    EventDto e1 = db.events().insertEvent(newQualityGateEvent(analysis).setName("Failed").setDate(analysis.getCreatedAt()));
+    IntStream.range(0, 15).forEach(x -> insertIssue(project, analysis));
+    issueIndexer.indexAllIssues();
+    when(server.getPublicRootUrl()).thenReturn("https://sonarcloud.io");
+
+    String result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(analysis.getCreatedAt() - 1_000L))
+      .execute().getInput();
+
+    assertJson(result).ignoreFields("date", "link").isSimilarTo(ws.getDef().responseExampleAsString());
+  }
+
+  @Test
+  public void events() {
+    userSession.logIn().setRoot();
+    when(server.getPublicRootUrl()).thenReturn("https://sonarcloud.io");
+    ComponentDto project = db.components().insertPrivateProject();
+    ComponentDto branch = db.components().insertProjectBranch(project);
+    SnapshotDto projectAnalysis = insertAnalysis(project, 1_500_000_000_000L);
+    db.events().insertEvent(newQualityGateEvent(projectAnalysis).setDate(projectAnalysis.getCreatedAt()).setName("Passed"));
+    insertIssue(project, projectAnalysis);
+    insertIssue(project, projectAnalysis);
+    SnapshotDto branchAnalysis = insertAnalysis(branch, 1_501_000_000_000L);
+    db.events().insertEvent(newQualityGateEvent(branchAnalysis).setDate(branchAnalysis.getCreatedAt()).setName("Failed"));
+    insertIssue(branch, branchAnalysis);
+    issueIndexer.indexAllIssues();
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(1_499_000_000_000L))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList())
+      .extracting(Event::getCategory, Event::getProject, Event::getMessage)
+      .containsOnly(
+        tuple("QUALITY_GATE", project.getKey(), format("Quality Gate status of project '%s' changed to 'Passed'", project.name())),
+        tuple("QUALITY_GATE", project.getKey(), format("Quality Gate status of project '%s' on branch '%s' changed to 'Failed'", project.name(), branch.getBranch())),
+        tuple("NEW_ISSUES", project.getKey(), format("You have 2 new issues on project '%s'", project.name())),
+        tuple("NEW_ISSUES", project.getKey(), format("You have 1 new issue on project '%s' on branch '%s'", project.name(), branch.getBranch())));
+    verify(issueIndexSyncProgressChecker).checkIfAnyComponentsNeedIssueSync(any(), argThat(arg -> arg.contains(project.getKey())));
+  }
+
+  @Test
+  public void does_not_return_old_events() {
+    userSession.logIn().setRoot();
+    ComponentDto project = db.components().insertPrivateProject();
+    SnapshotDto analysis = insertAnalysis(project, 1_500_000_000_000L);
+    insertIssue(project, analysis);
+    db.events().insertEvent(newQualityGateEvent(analysis).setDate(analysis.getCreatedAt()).setName("Passed"));
+    SnapshotDto oldAnalysis = insertAnalysis(project, 1_400_000_000_000L);
+    insertIssue(project, oldAnalysis);
+    db.events().insertEvent(newQualityGateEvent(oldAnalysis).setDate(oldAnalysis.getCreatedAt()).setName("Failed"));
+    issueIndexer.indexAllIssues();
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(analysis.getCreatedAt() - 1450_000_000_000L))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList())
+      .extracting(Event::getCategory, Event::getDate)
+      .containsOnly(
+        tuple("NEW_ISSUES", formatDateTime(analysis.getCreatedAt())),
+        tuple("QUALITY_GATE", formatDateTime(analysis.getCreatedAt())));
+  }
+
+  @Test
+  public void empty_response_for_empty_list_of_projects() {
+    userSession.logIn().setRoot();
+
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, "")
+      .setParam(PARAM_FROM, "")
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList()).isEmpty();
+  }
+
+  @Test
+  public void does_not_return_events_of_project_for_which_the_current_user_has_no_browse_permission() {
+    userSession.logIn();
+
+    ComponentDto project1 = db.components().insertPrivateProject();
+    userSession.addProjectPermission(UserRole.CODEVIEWER, project1);
+    userSession.addProjectPermission(UserRole.ISSUE_ADMIN, project1);
+
+    ComponentDto project2 = db.components().insertPrivateProject();
+    userSession.addProjectPermission(UserRole.USER, project2);
+
+    SnapshotDto a1 = insertAnalysis(project1, 1_500_000_000_000L);
+    EventDto e1 = db.events().insertEvent(newQualityGateEvent(a1).setDate(a1.getCreatedAt()));
+    insertIssue(project1, a1);
+    SnapshotDto a2 = insertAnalysis(project2, 1_500_000_000_000L);
+    EventDto e2 = db.events().insertEvent(newQualityGateEvent(a2).setDate(a2.getCreatedAt()));
+    insertIssue(project2, a2);
+    issueIndexer.indexAllIssues();
+
+    String stringFrom = formatDateTime(a1.getCreatedAt() - 1_000L);
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, String.join(",", project1.getKey(), project2.getKey()))
+      .setParam(PARAM_FROM, String.join(",", stringFrom, stringFrom))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList())
+      .extracting(Event::getCategory, Event::getProject)
+      .containsOnly(
+        tuple("NEW_ISSUES", project2.getKey()),
+        tuple(EventCategory.QUALITY_GATE.name(), project2.getKey()));
+  }
+
+  @Test
+  public void empty_response_if_project_key_is_unknown() {
+    userSession.logIn().setRoot();
+
+    long from = 1_500_000_000_000L;
+    SearchEventsWsResponse result = ws.newRequest()
+      .setParam(PARAM_PROJECTS, "unknown")
+      .setParam(PARAM_FROM, formatDateTime(from - 1_000L))
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+    assertThat(result.getEventsList()).isEmpty();
+  }
+
+  @Test
+  public void fail_when_not_loggued() {
+    userSession.anonymous();
+    ComponentDto project = db.components().insertPrivateProject();
+
+    expectedException.expect(UnauthorizedException.class);
+
+    ws.newRequest()
+      .setParam(PARAM_PROJECTS, project.getKey())
+      .setParam(PARAM_FROM, formatDateTime(1_000L))
+      .execute();
+  }
+
+  @Test
+  public void fail_if_date_format_is_not_valid() {
+    userSession.logIn().setRoot();
+
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("'wat' cannot be parsed as either a date or date+time");
+    ws.newRequest()
+      .setParam(PARAM_PROJECTS, "foo")
+      .setParam(PARAM_FROM, "wat")
+      .executeProtobuf(SearchEventsWsResponse.class);
+
+  }
+
+  private static EventDto newQualityGateEvent(SnapshotDto analysis) {
+    return newEvent(analysis).setCategory(EventCategory.QUALITY_GATE.getLabel());
+  }
+
+  private CeActivityDto insertActivity(ComponentDto project, SnapshotDto analysis, CeActivityDto.Status status) {
+    CeQueueDto queueDto = new CeQueueDto();
+    queueDto.setTaskType(CeTaskTypes.REPORT);
+    String mainBranchProjectUuid = project.getMainBranchProjectUuid();
+    queueDto.setComponentUuid(mainBranchProjectUuid == null ? project.uuid() : mainBranchProjectUuid);
+    queueDto.setUuid(randomAlphanumeric(40));
+    queueDto.setCreatedAt(nextLong());
+    CeActivityDto activityDto = new CeActivityDto(queueDto);
+    activityDto.setStatus(status);
+    activityDto.setExecutionTimeMs(nextLong());
+    activityDto.setExecutedAt(nextLong());
+    activityDto.setAnalysisUuid(analysis.getUuid());
+    db.getDbClient().ceActivityDao().insert(db.getSession(), activityDto);
+    db.commit();
+    return activityDto;
+  }
+
+  private void insertIssue(ComponentDto component, SnapshotDto analysis) {
+    db.issues().insert(db.rules().insert(), component, component,
+      i -> i.setIssueCreationDate(new Date(analysis.getCreatedAt()))
+        .setAssigneeUuid(userSession.getUuid())
+        .setType(randomRuleTypeExceptHotspot()));
+  }
+
+  private SnapshotDto insertAnalysis(ComponentDto project, long analysisDate) {
+    SnapshotDto analysis = db.components().insertSnapshot(project, s -> s.setCreatedAt(analysisDate));
+    insertActivity(project, analysis, CeActivityDto.Status.SUCCESS);
+    return analysis;
+  }
+
+  private RuleType randomRuleTypeExceptHotspot() {
+    return RULE_TYPES_EXCEPT_HOTSPOT[nextInt(RULE_TYPES_EXCEPT_HOTSPOT.length)];
+  }
+}
index 90b28a037e014773cf138d5577c7b9b640a74e4e..6c384bd979c3bbc61f893ef6346bab604f2aec4c 100644 (file)
@@ -72,6 +72,7 @@ import org.sonar.server.es.RecoveryIndexer;
 import org.sonar.server.es.metadata.EsDbCompatibilityImpl;
 import org.sonar.server.es.metadata.MetadataIndexDefinition;
 import org.sonar.server.es.metadata.MetadataIndexImpl;
+import org.sonar.server.developers.ws.DevelopersWsModule;
 import org.sonar.server.extension.CoreExtensionBootstraper;
 import org.sonar.server.extension.CoreExtensionStopper;
 import org.sonar.server.favorite.FavoriteModule;
@@ -388,6 +389,8 @@ public class PlatformLevel4 extends PlatformLevel {
       LiveMeasureModule.class,
       ComponentViewerJsonWriter.class,
 
+      DevelopersWsModule.class,
+
       FavoriteModule.class,
       FavoriteWsModule.class,
 
index 58cf6677b1145a0bc9d54cfba9817874f8563ad8..e41538fb157b068b9923bc3e46a9115a30181c5e 100644 (file)
@@ -49,6 +49,6 @@ public class DevelopersService extends BaseService {
         .setParam("from", request.getFrom() == null ? null : request.getFrom().stream().collect(Collectors.joining(",")))
         .setParam("projects", request.getProjects() == null ? null : request.getProjects().stream().collect(Collectors.joining(",")))
         .setMediaType(MediaTypes.JSON)
-      ).content();
+    ).content();
   }
 }
diff --git a/sonar-ws/src/main/protobuf/ws-developers.proto b/sonar-ws/src/main/protobuf/ws-developers.proto
new file mode 100644 (file)
index 0000000..a59cb7a
--- /dev/null
@@ -0,0 +1,24 @@
+// Copyright (C) 2017-2017 SonarSource SA
+// All rights reserved
+// mailto:info AT sonarsource DOT com
+
+syntax = "proto2";
+
+package sonarqube.ws.component;
+
+option java_package = "org.sonarqube.ws";
+option java_outer_classname = "Developers";
+option optimize_for = SPEED;
+
+// WS api/developers/search_events
+message SearchEventsWsResponse {
+  repeated Event events = 1;
+
+  message Event {
+    optional string category = 1;
+    optional string message = 2;
+    optional string link = 3;
+    optional string project = 4;
+    optional string date = 5;
+  }
+}