diff options
author | Mark Rekveld <mark.rekveld@sonarsource.com> | 2021-01-06 16:44:11 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2021-01-14 20:30:31 +0000 |
commit | b246cc9646aa695aec156282e81157b3c3544a71 (patch) | |
tree | 1e3477ddcd64e8b989bca7b5d73b8291749cbcc8 /server | |
parent | a42c39d23049b6aa12ad79ca87e8c4b231763f37 (diff) | |
download | sonarqube-b246cc9646aa695aec156282e81157b3c3544a71.tar.gz sonarqube-b246cc9646aa695aec156282e81157b3c3544a71.zip |
SONAR-14306 - Moved SearchEvent WS to community edition
Diffstat (limited to 'server')
13 files changed, 1407 insertions, 0 deletions
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 index 00000000000..d54497ff86f --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/DevelopersWs.java @@ -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 index 00000000000..5aac99a6639 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/DevelopersWsAction.java @@ -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 index 00000000000..cc46692d40c --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/DevelopersWsModule.java @@ -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 index 00000000000..c484ac99b48 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/SearchEventsAction.java @@ -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 index 00000000000..e0ac4e78f68 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/UuidFromPair.java @@ -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 index 00000000000..adc16d03c23 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/UuidFromPairs.java @@ -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 index 00000000000..056fa9d4639 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/developers/ws/package-info.java @@ -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 index 00000000000..522b5c4d3a9 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/developers/ws/search_events-example.json @@ -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 index 00000000000..f5cbc8d4036 --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/DevelopersWsTest.java @@ -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 index 00000000000..2d74b75ff5b --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/SearchEventsActionNewIssuesTest.java @@ -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 index 00000000000..c230304697b --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/SearchEventsActionQualityGateTest.java @@ -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 index 00000000000..f707f0f7623 --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/developers/ws/SearchEventsActionTest.java @@ -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)]; + } +} diff --git a/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java b/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java index 90b28a037e0..6c384bd979c 100644 --- a/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java +++ b/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java @@ -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, |