--- /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.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();
+ }
+}
--- /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.developers.ws;
+
+import org.sonar.server.ws.WsAction;
+
+interface DevelopersWsAction extends WsAction {
+ // marker interface
+}
--- /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.developers.ws;
+
+import org.sonar.core.platform.Module;
+
+public class DevelopersWsModule extends Module {
+ @Override
+ protected void configureModule() {
+ add(
+ DevelopersWs.class,
+ SearchEventsAction.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.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);
+ }
+ }
+}
--- /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.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;
+ }
+}
--- /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.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()));
+ }
+}
--- /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.developers.ws;
+
+import javax.annotation.ParametersAreNonnullByDefault;
+
--- /dev/null
+{
+ "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"
+ }
+ ]
+}
--- /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.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();
+ }
+}
--- /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.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)];
+ }
+}
--- /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.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;
+ }
+}
--- /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.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)];
+ }
+}
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;
LiveMeasureModule.class,
ComponentViewerJsonWriter.class,
+ DevelopersWsModule.class,
+
FavoriteModule.class,
FavoriteWsModule.class,
.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();
}
}
--- /dev/null
+// 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;
+ }
+}