From: Sébastien Lesaint Date: Tue, 16 Apr 2019 13:19:27 +0000 (+0200) Subject: SONAR-11757 single notification for FPs and changes on my issues X-Git-Tag: 7.8~382 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=58bb4b37da6e32a113870b0fc98d5494379641b6;p=sonarqube.git SONAR-11757 single notification for FPs and changes on my issues --- diff --git a/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/container/ProjectAnalysisTaskContainerPopulator.java b/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/container/ProjectAnalysisTaskContainerPopulator.java index 7afdefb8eeb..3298ce5abc0 100644 --- a/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/container/ProjectAnalysisTaskContainerPopulator.java +++ b/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/container/ProjectAnalysisTaskContainerPopulator.java @@ -99,7 +99,7 @@ import org.sonar.ce.task.projectanalysis.measure.MeasureComputersVisitor; import org.sonar.ce.task.projectanalysis.measure.MeasureRepositoryImpl; import org.sonar.ce.task.projectanalysis.measure.MeasureToMeasureDto; import org.sonar.ce.task.projectanalysis.metric.MetricModule; -import org.sonar.ce.task.projectanalysis.notification.NewIssuesNotificationFactory; +import org.sonar.ce.task.projectanalysis.notification.NotificationFactory; import org.sonar.ce.task.projectanalysis.organization.DefaultOrganizationLoader; import org.sonar.ce.task.projectanalysis.period.PeriodHolderImpl; import org.sonar.ce.task.projectanalysis.qualitygate.EvaluationResultTextConverterImpl; @@ -306,7 +306,7 @@ public final class ProjectAnalysisTaskContainerPopulator implements ContainerPop WebhookPostTask.class, // notifications - NewIssuesNotificationFactory.class); + NotificationFactory.class); } } diff --git a/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactory.java b/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactory.java deleted file mode 100644 index 60b2629c612..00000000000 --- a/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactory.java +++ /dev/null @@ -1,112 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 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.ce.task.projectanalysis.notification; - -import com.google.common.collect.ImmutableMap; -import java.util.Map; -import java.util.Optional; -import org.sonar.api.ce.ComputeEngineSide; -import org.sonar.api.rule.RuleKey; -import org.sonar.api.utils.Durations; -import org.sonar.ce.task.projectanalysis.component.Component; -import org.sonar.ce.task.projectanalysis.component.DepthTraversalTypeAwareCrawler; -import org.sonar.ce.task.projectanalysis.component.TreeRootHolder; -import org.sonar.ce.task.projectanalysis.component.TypeAwareVisitorAdapter; -import org.sonar.ce.task.projectanalysis.issue.RuleRepository; -import org.sonar.db.user.UserDto; -import org.sonar.server.issue.notification.MyNewIssuesNotification; -import org.sonar.server.issue.notification.NewIssuesNotification; -import org.sonar.server.issue.notification.NewIssuesNotification.DetailsSupplier; -import org.sonar.server.issue.notification.NewIssuesNotification.RuleDefinition; - -import static java.util.Objects.requireNonNull; -import static org.sonar.ce.task.projectanalysis.component.ComponentVisitor.Order.PRE_ORDER; -import static org.sonar.ce.task.projectanalysis.component.CrawlerDepthLimit.FILE; - -@ComputeEngineSide -public class NewIssuesNotificationFactory { - private final TreeRootHolder treeRootHolder; - private final RuleRepository ruleRepository; - private final Durations durations; - private Map componentsByUuid; - - public NewIssuesNotificationFactory(TreeRootHolder treeRootHolder, RuleRepository ruleRepository, Durations durations) { - this.treeRootHolder = treeRootHolder; - this.ruleRepository = ruleRepository; - this.durations = durations; - } - - public MyNewIssuesNotification newMyNewIssuesNotification(Map assigneesByUuid) { - verifyAssigneesByUuid(assigneesByUuid); - return new MyNewIssuesNotification(durations, new DetailsSupplierImpl(assigneesByUuid)); - } - - public NewIssuesNotification newNewIssuesNotification(Map assigneesByUuid) { - verifyAssigneesByUuid(assigneesByUuid); - return new NewIssuesNotification(durations, new DetailsSupplierImpl(assigneesByUuid)); - } - - private static void verifyAssigneesByUuid(Map assigneesByUuid) { - requireNonNull(assigneesByUuid, "assigneesByUuid can't be null"); - } - - private class DetailsSupplierImpl implements DetailsSupplier { - private final Map assigneesByUuid; - - private DetailsSupplierImpl(Map assigneesByUuid) { - this.assigneesByUuid = assigneesByUuid; - } - - @Override - public Optional getRuleDefinitionByRuleKey(RuleKey ruleKey) { - requireNonNull(ruleKey, "ruleKey can't be null"); - return ruleRepository.findByKey(ruleKey) - .map(t -> new RuleDefinition(t.getName(), t.getLanguage())); - } - - @Override - public Optional getComponentNameByUuid(String uuid) { - requireNonNull(uuid, "uuid can't be null"); - return Optional.ofNullable(lazyLoadComponentsByUuid().get(uuid)) - .map(t -> t.getType() == Component.Type.FILE || t.getType() == Component.Type.DIRECTORY ? t.getShortName() : t.getName()); - } - - private Map lazyLoadComponentsByUuid() { - if (componentsByUuid == null) { - ImmutableMap.Builder builder = ImmutableMap.builder(); - new DepthTraversalTypeAwareCrawler(new TypeAwareVisitorAdapter(FILE, PRE_ORDER) { - @Override - public void visitAny(Component any) { - builder.put(any.getUuid(), any); - } - }).visit(treeRootHolder.getRoot()); - componentsByUuid = builder.build(); - } - return componentsByUuid; - } - - @Override - public Optional getUserNameByUuid(String uuid) { - requireNonNull(uuid, "uuid can't be null"); - return Optional.ofNullable(assigneesByUuid.get(uuid)) - .map(UserDto::getName); - } - } -} diff --git a/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactory.java b/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactory.java new file mode 100644 index 00000000000..1bf255cd626 --- /dev/null +++ b/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactory.java @@ -0,0 +1,176 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.ce.task.projectanalysis.notification; + +import com.google.common.collect.ImmutableMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; +import org.sonar.api.ce.ComputeEngineSide; +import org.sonar.api.rule.RuleKey; +import org.sonar.api.utils.Durations; +import org.sonar.ce.task.projectanalysis.analysis.AnalysisMetadataHolder; +import org.sonar.ce.task.projectanalysis.analysis.Branch; +import org.sonar.ce.task.projectanalysis.component.Component; +import org.sonar.ce.task.projectanalysis.component.DepthTraversalTypeAwareCrawler; +import org.sonar.ce.task.projectanalysis.component.TreeRootHolder; +import org.sonar.ce.task.projectanalysis.component.TypeAwareVisitorAdapter; +import org.sonar.ce.task.projectanalysis.issue.RuleRepository; +import org.sonar.core.issue.DefaultIssue; +import org.sonar.core.util.stream.MoreCollectors; +import org.sonar.db.user.UserDto; +import org.sonar.server.issue.notification.IssuesChangesNotification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer; +import org.sonar.server.issue.notification.MyNewIssuesNotification; +import org.sonar.server.issue.notification.NewIssuesNotification; +import org.sonar.server.issue.notification.NewIssuesNotification.DetailsSupplier; +import org.sonar.server.issue.notification.NewIssuesNotification.RuleDefinition; + +import static com.google.common.base.Preconditions.checkState; +import static java.util.Objects.requireNonNull; +import static org.sonar.ce.task.projectanalysis.component.ComponentVisitor.Order.PRE_ORDER; +import static org.sonar.ce.task.projectanalysis.component.CrawlerDepthLimit.FILE; +import static org.sonar.db.component.BranchType.PULL_REQUEST; + +@ComputeEngineSide +public class NotificationFactory { + private final TreeRootHolder treeRootHolder; + private final AnalysisMetadataHolder analysisMetadataHolder; + private final RuleRepository ruleRepository; + private final Durations durations; + private final IssuesChangesNotificationSerializer issuesChangesSerializer; + private Map componentsByUuid; + + public NotificationFactory(TreeRootHolder treeRootHolder, AnalysisMetadataHolder analysisMetadataHolder, + RuleRepository ruleRepository, Durations durations, IssuesChangesNotificationSerializer issuesChangesSerializer) { + this.treeRootHolder = treeRootHolder; + this.analysisMetadataHolder = analysisMetadataHolder; + this.ruleRepository = ruleRepository; + this.durations = durations; + this.issuesChangesSerializer = issuesChangesSerializer; + } + + public MyNewIssuesNotification newMyNewIssuesNotification(Map assigneesByUuid) { + verifyAssigneesByUuid(assigneesByUuid); + return new MyNewIssuesNotification(durations, new DetailsSupplierImpl(assigneesByUuid)); + } + + public NewIssuesNotification newNewIssuesNotification(Map assigneesByUuid) { + verifyAssigneesByUuid(assigneesByUuid); + return new NewIssuesNotification(durations, new DetailsSupplierImpl(assigneesByUuid)); + } + + public IssuesChangesNotification newIssuesChangesNotification(Set issues, Map assigneesByUuid) { + AnalysisChange change = new AnalysisChange(analysisMetadataHolder.getAnalysisDate()); + Set changedIssues = issues.stream() + .map(issue -> new ChangedIssue.Builder(issue.key()) + .setAssignee(getAssignee(issue.assignee(), assigneesByUuid)) + .setNewResolution(issue.resolution()) + .setNewStatus(issue.status()) + .setRule(getRuleByRuleKey(issue.ruleKey())) + .setProject(getProject()) + .build()) + .collect(MoreCollectors.toSet(issues.size())); + + return issuesChangesSerializer.serialize(new IssuesChangesNotificationBuilder(changedIssues, change)); + } + + @CheckForNull + public User getAssignee(@Nullable String assigneeUuid, Map assigneesByUuid) { + if (assigneeUuid == null) { + return null; + } + UserDto dto = assigneesByUuid.get(assigneeUuid); + checkState(dto != null, "Can not find DTO for assignee uuid %s", assigneeUuid); + return new User(dto.getUuid(), dto.getLogin(), dto.getName()); + } + + private IssuesChangesNotificationBuilder.Rule getRuleByRuleKey(RuleKey ruleKey) { + return ruleRepository.findByKey(ruleKey) + .map(t -> new IssuesChangesNotificationBuilder.Rule(ruleKey, t.getName())) + .orElseThrow(() -> new IllegalStateException("Can not find rule " + ruleKey + " in RuleRepository")); + } + + private Project getProject() { + Component project = treeRootHolder.getRoot(); + Branch branch = analysisMetadataHolder.getBranch(); + Project.Builder builder = new Project.Builder(project.getUuid()) + .setKey(project.getKey()) + .setProjectName(project.getName()); + if (!branch.isLegacyFeature() && branch.getType() != PULL_REQUEST && !branch.isMain()) { + builder.setBranchName(branch.getName()); + } + return builder.build(); + } + + private static void verifyAssigneesByUuid(Map assigneesByUuid) { + requireNonNull(assigneesByUuid, "assigneesByUuid can't be null"); + } + + private class DetailsSupplierImpl implements DetailsSupplier { + private final Map assigneesByUuid; + + private DetailsSupplierImpl(Map assigneesByUuid) { + this.assigneesByUuid = assigneesByUuid; + } + + @Override + public Optional getRuleDefinitionByRuleKey(RuleKey ruleKey) { + requireNonNull(ruleKey, "ruleKey can't be null"); + return ruleRepository.findByKey(ruleKey) + .map(t -> new RuleDefinition(t.getName(), t.getLanguage())); + } + + @Override + public Optional getComponentNameByUuid(String uuid) { + requireNonNull(uuid, "uuid can't be null"); + return Optional.ofNullable(lazyLoadComponentsByUuid().get(uuid)) + .map(t -> t.getType() == Component.Type.FILE || t.getType() == Component.Type.DIRECTORY ? t.getShortName() : t.getName()); + } + + private Map lazyLoadComponentsByUuid() { + if (componentsByUuid == null) { + ImmutableMap.Builder builder = ImmutableMap.builder(); + new DepthTraversalTypeAwareCrawler(new TypeAwareVisitorAdapter(FILE, PRE_ORDER) { + @Override + public void visitAny(Component any) { + builder.put(any.getUuid(), any); + } + }).visit(treeRootHolder.getRoot()); + componentsByUuid = builder.build(); + } + return componentsByUuid; + } + + @Override + public Optional getUserNameByUuid(String uuid) { + requireNonNull(uuid, "uuid can't be null"); + return Optional.ofNullable(assigneesByUuid.get(uuid)) + .map(UserDto::getName); + } + } +} diff --git a/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/ReportAnalysisFailureNotificationEmailTemplate.java b/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/ReportAnalysisFailureNotificationEmailTemplate.java index ef0ff26bd4b..280fdd3fd11 100644 --- a/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/ReportAnalysisFailureNotificationEmailTemplate.java +++ b/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/ReportAnalysisFailureNotificationEmailTemplate.java @@ -21,6 +21,7 @@ package org.sonar.ce.task.projectanalysis.notification; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import javax.annotation.CheckForNull; import org.sonar.api.config.EmailSettings; import org.sonar.api.notifications.Notification; import org.sonar.server.issue.notification.EmailMessage; @@ -41,6 +42,7 @@ public class ReportAnalysisFailureNotificationEmailTemplate implements EmailTemp } @Override + @CheckForNull public EmailMessage format(Notification notification) { if (!(notification instanceof ReportAnalysisFailureNotification)) { return null; @@ -53,7 +55,7 @@ public class ReportAnalysisFailureNotificationEmailTemplate implements EmailTemp return new EmailMessage() .setMessageId(notification.getType() + "/" + projectUuid) .setSubject(subject(projectFullName)) - .setMessage(message(projectFullName, taskFailureNotification)); + .setPlainTextMessage(message(projectFullName, taskFailureNotification)); } private static String computeProjectFullName(ReportAnalysisFailureNotificationBuilder.Project project) { diff --git a/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStep.java b/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStep.java index 0e98642046e..8393918369b 100644 --- a/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStep.java +++ b/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStep.java @@ -19,19 +19,17 @@ */ package org.sonar.ce.task.projectanalysis.step; -import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Collection; import java.util.Date; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.Set; import java.util.function.Predicate; +import java.util.stream.Collectors; import javax.annotation.CheckForNull; import org.sonar.api.issue.Issue; import org.sonar.api.notifications.Notification; @@ -40,22 +38,17 @@ import org.sonar.api.utils.Duration; import org.sonar.ce.task.projectanalysis.analysis.AnalysisMetadataHolder; import org.sonar.ce.task.projectanalysis.analysis.Branch; import org.sonar.ce.task.projectanalysis.component.Component; -import org.sonar.ce.task.projectanalysis.component.CrawlerDepthLimit; -import org.sonar.ce.task.projectanalysis.component.DepthTraversalTypeAwareCrawler; import org.sonar.ce.task.projectanalysis.component.TreeRootHolder; -import org.sonar.ce.task.projectanalysis.component.TypeAwareVisitorAdapter; import org.sonar.ce.task.projectanalysis.issue.IssueCache; -import org.sonar.ce.task.projectanalysis.issue.RuleRepository; -import org.sonar.ce.task.projectanalysis.notification.NewIssuesNotificationFactory; +import org.sonar.ce.task.projectanalysis.notification.NotificationFactory; import org.sonar.ce.task.step.ComputationStep; import org.sonar.core.issue.DefaultIssue; import org.sonar.core.util.CloseableIterator; -import org.sonar.core.util.stream.MoreCollectors; import org.sonar.db.DbClient; import org.sonar.db.DbSession; import org.sonar.db.component.BranchType; import org.sonar.db.user.UserDto; -import org.sonar.server.issue.notification.IssueChangeNotification; +import org.sonar.server.issue.notification.IssuesChangesNotification; import org.sonar.server.issue.notification.MyNewIssuesNotification; import org.sonar.server.issue.notification.NewIssuesNotification; import org.sonar.server.issue.notification.NewIssuesStatistics; @@ -64,9 +57,8 @@ import org.sonar.server.notification.NotificationService; import static java.util.Collections.singleton; import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toMap; -import static java.util.stream.Collectors.toSet; import static java.util.stream.StreamSupport.stream; -import static org.sonar.ce.task.projectanalysis.component.ComponentVisitor.Order.POST_ORDER; +import static org.sonar.core.util.stream.MoreCollectors.toSet; import static org.sonar.db.component.BranchType.PULL_REQUEST; import static org.sonar.db.component.BranchType.SHORT; @@ -79,27 +71,25 @@ public class SendIssueNotificationsStep implements ComputationStep { /** * Types of the notifications sent by this step */ - static final Set> NOTIF_TYPES = ImmutableSet.of(NewIssuesNotification.class, MyNewIssuesNotification.class, IssueChangeNotification.class); + static final Set> NOTIF_TYPES = ImmutableSet.of(NewIssuesNotification.class, MyNewIssuesNotification.class, IssuesChangesNotification.class); private final IssueCache issueCache; - private final RuleRepository rules; private final TreeRootHolder treeRootHolder; private final NotificationService service; private final AnalysisMetadataHolder analysisMetadataHolder; - private final NewIssuesNotificationFactory newIssuesNotificationFactory; + private final NotificationFactory notificationFactory; private final DbClient dbClient; private Map componentsByDbKey; - public SendIssueNotificationsStep(IssueCache issueCache, RuleRepository rules, TreeRootHolder treeRootHolder, + public SendIssueNotificationsStep(IssueCache issueCache, TreeRootHolder treeRootHolder, NotificationService service, AnalysisMetadataHolder analysisMetadataHolder, - NewIssuesNotificationFactory newIssuesNotificationFactory, DbClient dbClient) { + NotificationFactory notificationFactory, DbClient dbClient) { this.issueCache = issueCache; - this.rules = rules; this.treeRootHolder = treeRootHolder; this.service = service; this.analysisMetadataHolder = analysisMetadataHolder; - this.newIssuesNotificationFactory = newIssuesNotificationFactory; + this.notificationFactory = notificationFactory; this.dbClient = dbClient; } @@ -125,12 +115,12 @@ public class SendIssueNotificationsStep implements ComputationStep { Map assigneesByUuid; try (DbSession dbSession = dbClient.openSession(false)) { Iterable iterable = issueCache::traverse; - Set assigneeUuids = stream(iterable.spliterator(), false).map(DefaultIssue::assignee).filter(Objects::nonNull).collect(toSet()); + Set assigneeUuids = stream(iterable.spliterator(), false).map(DefaultIssue::assignee).filter(Objects::nonNull).collect(Collectors.toSet()); assigneesByUuid = dbClient.userDao().selectByUuids(dbSession, assigneeUuids).stream().collect(toMap(UserDto::getUuid, dto -> dto)); } try (CloseableIterator issues = issueCache.traverse()) { - processIssues(newIssuesStats, issues, project, assigneesByUuid, notificationStatistics); + processIssues(newIssuesStats, issues, assigneesByUuid, notificationStatistics); } if (newIssuesStats.hasIssuesOnLeak()) { sendNewIssuesNotification(newIssuesStats, project, assigneesByUuid, analysisDate, notificationStatistics); @@ -148,56 +138,45 @@ public class SendIssueNotificationsStep implements ComputationStep { return Date.from(instant).getTime(); } - private void processIssues(NewIssuesStatistics newIssuesStats, CloseableIterator issues, Component project, Map usersDtoByUuids, - NotificationStatistics notificationStatistics) { + private void processIssues(NewIssuesStatistics newIssuesStats, CloseableIterator issues, + Map assigneesByUuid, NotificationStatistics notificationStatistics) { int batchSize = 1000; - List loadedIssues = new ArrayList<>(batchSize); + Set changedIssuesToNotify = new HashSet<>(batchSize); while (issues.hasNext()) { DefaultIssue issue = issues.next(); if (issue.type() != RuleType.SECURITY_HOTSPOT) { if (issue.isNew() && issue.resolution() == null) { newIssuesStats.add(issue); } else if (issue.isChanged() && issue.mustSendNotifications()) { - loadedIssues.add(issue); + changedIssuesToNotify.add(issue); } } - if (loadedIssues.size() >= batchSize) { - sendIssueChangeNotification(loadedIssues, project, usersDtoByUuids, notificationStatistics); - loadedIssues.clear(); + if (changedIssuesToNotify.size() >= batchSize) { + sendIssuesChangesNotification(changedIssuesToNotify, assigneesByUuid, notificationStatistics); + changedIssuesToNotify.clear(); } } - if (!loadedIssues.isEmpty()) { - sendIssueChangeNotification(loadedIssues, project, usersDtoByUuids, notificationStatistics); + if (!changedIssuesToNotify.isEmpty()) { + sendIssuesChangesNotification(changedIssuesToNotify, assigneesByUuid, notificationStatistics); } } - private void sendIssueChangeNotification(Collection issues, Component project, Map usersDtoByUuids, - NotificationStatistics notificationStatistics) { - Set notifications = issues.stream() - .map(issue -> { - IssueChangeNotification notification = new IssueChangeNotification(); - notification.setRuleName(rules.getByKey(issue.ruleKey()).getName()); - notification.setIssue(issue); - notification.setAssignee(usersDtoByUuids.get(issue.assignee())); - notification.setProject(project.getKey(), project.getName(), getBranchName(), getPullRequest()); - getComponentKey(issue).ifPresent(c -> notification.setComponent(c.getKey(), c.getName())); - return notification; - }) - .collect(MoreCollectors.toSet(issues.size())); + private void sendIssuesChangesNotification(Set issues, Map assigneesByUuid, NotificationStatistics notificationStatistics) { + IssuesChangesNotification notification = notificationFactory.newIssuesChangesNotification(issues, assigneesByUuid); - notificationStatistics.issueChangesDeliveries += service.deliverEmails(notifications); + notificationStatistics.issueChangesDeliveries += service.deliverEmails(singleton(notification)); notificationStatistics.issueChanges++; // compatibility with old API - notifications.forEach(notification -> notificationStatistics.issueChangesDeliveries += service.deliver(notification)); + notificationStatistics.issueChangesDeliveries += service.deliver(notification); } private void sendNewIssuesNotification(NewIssuesStatistics statistics, Component project, Map assigneesByUuid, long analysisDate, NotificationStatistics notificationStatistics) { NewIssuesStatistics.Stats globalStatistics = statistics.globalStatistics(); - NewIssuesNotification notification = newIssuesNotificationFactory + NewIssuesNotification notification = notificationFactory .newNewIssuesNotification(assigneesByUuid) .setProject(project.getKey(), project.getName(), getBranchName(), getPullRequest()) .setProjectVersion(project.getProjectAttributes().getProjectVersion()) @@ -220,7 +199,7 @@ public class SendIssueNotificationsStep implements ComputationStep { .map(e -> { String assigneeUuid = e.getKey(); NewIssuesStatistics.Stats assigneeStatistics = e.getValue(); - MyNewIssuesNotification myNewIssuesNotification = newIssuesNotificationFactory + MyNewIssuesNotification myNewIssuesNotification = notificationFactory .newMyNewIssuesNotification(assigneesByUuid) .setAssignee(userDtoByUuid.get(assigneeUuid)); myNewIssuesNotification @@ -232,7 +211,7 @@ public class SendIssueNotificationsStep implements ComputationStep { return myNewIssuesNotification; }) - .collect(MoreCollectors.toSet(statistics.getAssigneesStatistics().size())); + .collect(toSet(statistics.getAssigneesStatistics().size())); notificationStatistics.myNewIssuesDeliveries += service.deliverEmails(myNewIssuesNotifications); notificationStatistics.myNewIssues += myNewIssuesNotifications.size(); @@ -251,21 +230,6 @@ public class SendIssueNotificationsStep implements ComputationStep { } } - private Optional getComponentKey(DefaultIssue issue) { - if (componentsByDbKey == null) { - final ImmutableMap.Builder builder = ImmutableMap.builder(); - new DepthTraversalTypeAwareCrawler( - new TypeAwareVisitorAdapter(CrawlerDepthLimit.LEAVES, POST_ORDER) { - @Override - public void visitAny(Component component) { - builder.put(component.getDbKey(), component); - } - }).visit(this.treeRootHolder.getRoot()); - this.componentsByDbKey = builder.build(); - } - return Optional.ofNullable(componentsByDbKey.get(issue.componentKey())); - } - @Override public String getDescription() { return "Send issue notifications"; diff --git a/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/issue/DumbRule.java b/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/issue/DumbRule.java index 9861faf598f..95e0bb75104 100644 --- a/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/issue/DumbRule.java +++ b/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/issue/DumbRule.java @@ -46,6 +46,7 @@ public class DumbRule implements Rule { public DumbRule(RuleKey key) { this.key = key; this.id = key.hashCode(); + this.name = "name_" + key; } @Override diff --git a/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactoryTest.java b/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactoryTest.java deleted file mode 100644 index 37a9210253f..00000000000 --- a/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactoryTest.java +++ /dev/null @@ -1,419 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 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.ce.task.projectanalysis.notification; - -import com.google.common.collect.ImmutableMap; -import java.lang.reflect.Field; -import java.util.Random; -import java.util.Set; -import java.util.stream.Collectors; -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.rule.RuleKey; -import org.sonar.api.utils.Durations; -import org.sonar.ce.task.projectanalysis.component.ReportComponent; -import org.sonar.ce.task.projectanalysis.component.TreeRootHolderRule; -import org.sonar.ce.task.projectanalysis.issue.DumbRule; -import org.sonar.ce.task.projectanalysis.issue.RuleRepositoryRule; -import org.sonar.db.user.UserDto; -import org.sonar.db.user.UserTesting; -import org.sonar.server.issue.notification.MyNewIssuesNotification; -import org.sonar.server.issue.notification.NewIssuesNotification; -import org.sonar.server.issue.notification.NewIssuesNotification.DetailsSupplier; -import org.sonar.server.issue.notification.NewIssuesNotification.RuleDefinition; - -import static java.util.Collections.emptyMap; -import static org.assertj.core.api.Assertions.assertThat; -import static org.sonar.ce.task.projectanalysis.component.Component.Type.DIRECTORY; -import static org.sonar.ce.task.projectanalysis.component.Component.Type.FILE; -import static org.sonar.ce.task.projectanalysis.component.Component.Type.PROJECT; -import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex; - -public class NewIssuesNotificationFactoryTest { - @Rule - public TreeRootHolderRule treeRootHolder = new TreeRootHolderRule(); - @Rule - public RuleRepositoryRule ruleRepository = new RuleRepositoryRule(); - @Rule - public ExpectedException expectedException = ExpectedException.none(); - - private Durations durations = new Durations(); - private NewIssuesNotificationFactory underTest = new NewIssuesNotificationFactory(treeRootHolder, ruleRepository, durations); - - @Test - public void newMyNewIssuesNotification_throws_NPE_if_assigneesByUuid_is_null() { - expectedException.expect(NullPointerException.class); - expectedException.expectMessage("assigneesByUuid can't be null"); - - underTest.newMyNewIssuesNotification(null); - } - - @Test - public void newNewIssuesNotification_throws_NPE_if_assigneesByUuid_is_null() { - expectedException.expect(NullPointerException.class); - expectedException.expectMessage("assigneesByUuid can't be null"); - - underTest.newNewIssuesNotification(null); - } - - @Test - public void newMyNewIssuesNotification_returns_MyNewIssuesNotification_object_with_the_constructor_Durations() { - MyNewIssuesNotification notification = underTest.newMyNewIssuesNotification(emptyMap()); - - assertThat(readDurationsField(notification)).isSameAs(durations); - } - - @Test - public void newNewIssuesNotification_returns_NewIssuesNotification_object_with_the_constructor_Durations() { - NewIssuesNotification notification = underTest.newNewIssuesNotification(emptyMap()); - - assertThat(readDurationsField(notification)).isSameAs(durations); - } - - @Test - public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_fails_with_NPE_if_uuid_is_null() { - MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - expectedException.expect(NullPointerException.class); - expectedException.expectMessage("uuid can't be null"); - - detailsSupplier.getUserNameByUuid(null); - } - - @Test - public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_always_returns_empty_if_map_argument_is_empty() { - MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty(); - } - - @Test - public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_name_of_user_from_map_argument() { - Set users = IntStream.range(0, 1 + new Random().nextInt(10)) - .mapToObj(i -> UserTesting.newUserDto().setLogin("user" + i)) - .collect(Collectors.toSet()); - - MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification( - users.stream().collect(uniqueIndex(UserDto::getUuid))); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty(); - users - .forEach(user -> assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).contains(user.getName())); - } - - @Test - public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_empty_if_user_has_null_name() { - UserDto user = UserTesting.newUserDto().setLogin("user_noname").setName(null); - - MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(ImmutableMap.of(user.getUuid(), user)); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).isEmpty(); - } - - @Test - public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_fails_with_NPE_if_uuid_is_null() { - NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - expectedException.expect(NullPointerException.class); - expectedException.expectMessage("uuid can't be null"); - - detailsSupplier.getUserNameByUuid(null); - } - - @Test - public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_always_returns_empty_if_map_argument_is_empty() { - NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty(); - } - - @Test - public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_name_of_user_from_map_argument() { - Set users = IntStream.range(0, 1 + new Random().nextInt(10)) - .mapToObj(i -> UserTesting.newUserDto().setLogin("user" + i)) - .collect(Collectors.toSet()); - - NewIssuesNotification underTest = this.underTest.newNewIssuesNotification( - users.stream().collect(uniqueIndex(UserDto::getUuid))); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty(); - users - .forEach(user -> assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).contains(user.getName())); - } - - @Test - public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_empty_if_user_has_null_name() { - UserDto user = UserTesting.newUserDto().setLogin("user_noname").setName(null); - - NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(ImmutableMap.of(user.getUuid(), user)); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).isEmpty(); - } - - @Test - public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_ISE_if_TreeRootHolder_is_not_initialized() { - MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - expectedException.expect(IllegalStateException.class); - expectedException.expectMessage("Holder has not been initialized yet"); - - detailsSupplier.getComponentNameByUuid("foo"); - } - - @Test - public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_NPE_if_uuid_is_null() { - treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build()); - - MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - expectedException.expect(NullPointerException.class); - expectedException.expectMessage("uuid can't be null"); - - detailsSupplier.getComponentNameByUuid(null); - } - - @Test - public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_name_of_project_in_TreeRootHolder() { - treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build()); - - MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - assertThat(detailsSupplier.getComponentNameByUuid("rootUuid")).contains("root"); - assertThat(detailsSupplier.getComponentNameByUuid("foo")).isEmpty(); - } - - @Test - public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_shortName_of_dir_and_file_in_TreeRootHolder() { - treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root") - .addChildren(ReportComponent.builder(DIRECTORY, 2).setUuid("dir1Uuid").setName("dir1").setShortName("dir1_short") - .addChildren(ReportComponent.builder(FILE, 21).setUuid("file21Uuid").setName("file21").setShortName("file21_short").build()) - .build()) - .addChildren(ReportComponent.builder(DIRECTORY, 3).setUuid("dir2Uuid").setName("dir2").setShortName("dir2_short") - .addChildren(ReportComponent.builder(FILE, 31).setUuid("file31Uuid").setName("file31").setShortName("file31_short").build()) - .addChildren(ReportComponent.builder(FILE, 32).setUuid("file32Uuid").setName("file32").setShortName("file32_short").build()) - .build()) - .addChildren(ReportComponent.builder(FILE, 11).setUuid("file11Uuid").setName("file11").setShortName("file11_short").build()) - .build()); - MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - Stream.of("dir1", "dir2", "file11", "file21", "file31", "file32") - .forEach(name -> { - assertThat(detailsSupplier.getComponentNameByUuid(name + "Uuid")).contains(name + "_short"); - assertThat(detailsSupplier.getComponentNameByUuid(name)).isEmpty(); - }); - } - - @Test - public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_ISE_if_TreeRootHolder_is_not_initialized() { - NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - expectedException.expect(IllegalStateException.class); - expectedException.expectMessage("Holder has not been initialized yet"); - - detailsSupplier.getComponentNameByUuid("foo"); - } - - @Test - public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_NPE_if_uuid_is_null() { - treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build()); - NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - expectedException.expect(NullPointerException.class); - expectedException.expectMessage("uuid can't be null"); - - detailsSupplier.getComponentNameByUuid(null); - } - - @Test - public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_name_of_project_in_TreeRootHolder() { - treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build()); - - NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - assertThat(detailsSupplier.getComponentNameByUuid("rootUuid")).contains("root"); - assertThat(detailsSupplier.getComponentNameByUuid("foo")).isEmpty(); - } - - @Test - public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_shortName_of_dir_and_file_in_TreeRootHolder() { - treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root") - .addChildren(ReportComponent.builder(DIRECTORY, 2).setUuid("dir1Uuid").setName("dir1").setShortName("dir1_short") - .addChildren(ReportComponent.builder(FILE, 21).setUuid("file21Uuid").setName("file21").setShortName("file21_short").build()) - .build()) - .addChildren(ReportComponent.builder(DIRECTORY, 3).setUuid("dir2Uuid").setName("dir2").setShortName("dir2_short") - .addChildren(ReportComponent.builder(FILE, 31).setUuid("file31Uuid").setName("file31").setShortName("file31_short").build()) - .addChildren(ReportComponent.builder(FILE, 32).setUuid("file32Uuid").setName("file32").setShortName("file32_short").build()) - .build()) - .addChildren(ReportComponent.builder(FILE, 11).setUuid("file11Uuid").setName("file11").setShortName("file11_short").build()) - .build()); - - NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - Stream.of("dir1", "dir2", "file11", "file21", "file31", "file32") - .forEach(name -> { - assertThat(detailsSupplier.getComponentNameByUuid(name + "Uuid")).contains(name + "_short"); - assertThat(detailsSupplier.getComponentNameByUuid(name)).isEmpty(); - }); - } - - @Test - public void newMyNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_fails_with_NPE_if_ruleKey_is_null() { - MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - expectedException.expect(NullPointerException.class); - expectedException.expectMessage("ruleKey can't be null"); - - detailsSupplier.getRuleDefinitionByRuleKey(null); - } - - @Test - public void newMyNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_always_returns_empty_if_RuleRepository_is_empty() { - MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("foo", "bar"))).isEmpty(); - assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("bar", "foo"))).isEmpty(); - } - - @Test - public void newMyNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_returns_name_and_language_from_RuleRepository() { - RuleKey rulekey1 = RuleKey.of("foo", "bar"); - RuleKey rulekey2 = RuleKey.of("foo", "donut"); - RuleKey rulekey3 = RuleKey.of("no", "language"); - DumbRule rule1 = ruleRepository.add(rulekey1).setName("rule1").setLanguage("lang1"); - DumbRule rule2 = ruleRepository.add(rulekey2).setName("rule2").setLanguage("lang2"); - DumbRule rule3 = ruleRepository.add(rulekey3).setName("rule3"); - - MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey1)) - .contains(new RuleDefinition(rule1.getName(), rule1.getLanguage())); - assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey2)) - .contains(new RuleDefinition(rule2.getName(), rule2.getLanguage())); - assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey3)) - .contains(new RuleDefinition(rule3.getName(), null)); - assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("donut", "foo"))) - .isEmpty(); - } - - @Test - public void newNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_fails_with_NPE_if_ruleKey_is_null() { - NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - expectedException.expect(NullPointerException.class); - expectedException.expectMessage("ruleKey can't be null"); - - detailsSupplier.getRuleDefinitionByRuleKey(null); - } - - @Test - public void newNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_always_returns_empty_if_RuleRepository_is_empty() { - NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("foo", "bar"))).isEmpty(); - assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("bar", "foo"))).isEmpty(); - } - - @Test - public void newNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_returns_name_and_language_from_RuleRepository() { - RuleKey rulekey1 = RuleKey.of("foo", "bar"); - RuleKey rulekey2 = RuleKey.of("foo", "donut"); - RuleKey rulekey3 = RuleKey.of("no", "language"); - DumbRule rule1 = ruleRepository.add(rulekey1).setName("rule1").setLanguage("lang1"); - DumbRule rule2 = ruleRepository.add(rulekey2).setName("rule2").setLanguage("lang2"); - DumbRule rule3 = ruleRepository.add(rulekey3).setName("rule3"); - - NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); - - DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); - - assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey1)) - .contains(new RuleDefinition(rule1.getName(), rule1.getLanguage())); - assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey2)) - .contains(new RuleDefinition(rule2.getName(), rule2.getLanguage())); - assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey3)) - .contains(new RuleDefinition(rule3.getName(), null)); - assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("donut", "foo"))) - .isEmpty(); - } - - private static Durations readDurationsField(NewIssuesNotification notification) { - return readField(notification, "durations"); - } - - private static Durations readField(NewIssuesNotification notification, String fieldName) { - try { - Field durationsField = NewIssuesNotification.class.getDeclaredField(fieldName); - durationsField.setAccessible(true); - Object o = durationsField.get(notification); - return (Durations) o; - } catch (IllegalAccessException | NoSuchFieldException e) { - throw new RuntimeException(e); - } - } - - private static DetailsSupplier readDetailsSupplier(NewIssuesNotification notification) { - try { - Field durationsField = NewIssuesNotification.class.getDeclaredField("detailsSupplier"); - durationsField.setAccessible(true); - return (DetailsSupplier) durationsField.get(notification); - } catch (IllegalAccessException | NoSuchFieldException e) { - throw new RuntimeException(e); - } - } -} diff --git a/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactoryTest.java b/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactoryTest.java new file mode 100644 index 00000000000..fe61a0238f3 --- /dev/null +++ b/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactoryTest.java @@ -0,0 +1,816 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.ce.task.projectanalysis.notification; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; +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.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.sonar.api.rule.RuleKey; +import org.sonar.api.utils.Durations; +import org.sonar.ce.task.projectanalysis.analysis.AnalysisMetadataHolderRule; +import org.sonar.ce.task.projectanalysis.analysis.Branch; +import org.sonar.ce.task.projectanalysis.component.ReportComponent; +import org.sonar.ce.task.projectanalysis.component.TreeRootHolderRule; +import org.sonar.ce.task.projectanalysis.issue.DumbRule; +import org.sonar.ce.task.projectanalysis.issue.RuleRepositoryRule; +import org.sonar.core.issue.DefaultIssue; +import org.sonar.db.component.BranchType; +import org.sonar.db.user.UserDto; +import org.sonar.db.user.UserTesting; +import org.sonar.server.issue.notification.IssuesChangesNotification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer; +import org.sonar.server.issue.notification.MyNewIssuesNotification; +import org.sonar.server.issue.notification.NewIssuesNotification; +import org.sonar.server.issue.notification.NewIssuesNotification.DetailsSupplier; +import org.sonar.server.issue.notification.NewIssuesNotification.RuleDefinition; + +import static java.util.Collections.emptyMap; +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.sonar.api.issue.Issue.STATUS_OPEN; +import static org.sonar.ce.task.projectanalysis.component.Component.Type.DIRECTORY; +import static org.sonar.ce.task.projectanalysis.component.Component.Type.FILE; +import static org.sonar.ce.task.projectanalysis.component.Component.Type.PROJECT; +import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex; + +@RunWith(DataProviderRunner.class) +public class NotificationFactoryTest { + @Rule + public TreeRootHolderRule treeRootHolder = new TreeRootHolderRule(); + @Rule + public RuleRepositoryRule ruleRepository = new RuleRepositoryRule(); + @Rule + public AnalysisMetadataHolderRule analysisMetadata = new AnalysisMetadataHolderRule(); + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + private Durations durations = new Durations(); + private IssuesChangesNotificationSerializer issuesChangesSerializer = mock(IssuesChangesNotificationSerializer.class); + private NotificationFactory underTest = new NotificationFactory(treeRootHolder, analysisMetadata, ruleRepository, durations, issuesChangesSerializer); + + @Test + public void newMyNewIssuesNotification_throws_NPE_if_assigneesByUuid_is_null() { + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("assigneesByUuid can't be null"); + + underTest.newMyNewIssuesNotification(null); + } + + @Test + public void newNewIssuesNotification_throws_NPE_if_assigneesByUuid_is_null() { + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("assigneesByUuid can't be null"); + + underTest.newNewIssuesNotification(null); + } + + @Test + public void newMyNewIssuesNotification_returns_MyNewIssuesNotification_object_with_the_constructor_Durations() { + MyNewIssuesNotification notification = underTest.newMyNewIssuesNotification(emptyMap()); + + assertThat(readDurationsField(notification)).isSameAs(durations); + } + + @Test + public void newNewIssuesNotification_returns_NewIssuesNotification_object_with_the_constructor_Durations() { + NewIssuesNotification notification = underTest.newNewIssuesNotification(emptyMap()); + + assertThat(readDurationsField(notification)).isSameAs(durations); + } + + @Test + public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_fails_with_NPE_if_uuid_is_null() { + MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("uuid can't be null"); + + detailsSupplier.getUserNameByUuid(null); + } + + @Test + public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_always_returns_empty_if_map_argument_is_empty() { + MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty(); + } + + @Test + public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_name_of_user_from_map_argument() { + Set users = IntStream.range(0, 1 + new Random().nextInt(10)) + .mapToObj(i -> UserTesting.newUserDto().setLogin("user" + i)) + .collect(Collectors.toSet()); + + MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification( + users.stream().collect(uniqueIndex(UserDto::getUuid))); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty(); + users + .forEach(user -> assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).contains(user.getName())); + } + + @Test + public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_empty_if_user_has_null_name() { + UserDto user = UserTesting.newUserDto().setLogin("user_noname").setName(null); + + MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(ImmutableMap.of(user.getUuid(), user)); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).isEmpty(); + } + + @Test + public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_fails_with_NPE_if_uuid_is_null() { + NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("uuid can't be null"); + + detailsSupplier.getUserNameByUuid(null); + } + + @Test + public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_always_returns_empty_if_map_argument_is_empty() { + NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty(); + } + + @Test + public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_name_of_user_from_map_argument() { + Set users = IntStream.range(0, 1 + new Random().nextInt(10)) + .mapToObj(i -> UserTesting.newUserDto().setLogin("user" + i)) + .collect(Collectors.toSet()); + + NewIssuesNotification underTest = this.underTest.newNewIssuesNotification( + users.stream().collect(uniqueIndex(UserDto::getUuid))); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty(); + users + .forEach(user -> assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).contains(user.getName())); + } + + @Test + public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_empty_if_user_has_null_name() { + UserDto user = UserTesting.newUserDto().setLogin("user_noname").setName(null); + + NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(ImmutableMap.of(user.getUuid(), user)); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).isEmpty(); + } + + @Test + public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_ISE_if_TreeRootHolder_is_not_initialized() { + MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage("Holder has not been initialized yet"); + + detailsSupplier.getComponentNameByUuid("foo"); + } + + @Test + public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_NPE_if_uuid_is_null() { + treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build()); + + MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("uuid can't be null"); + + detailsSupplier.getComponentNameByUuid(null); + } + + @Test + public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_name_of_project_in_TreeRootHolder() { + treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build()); + + MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + assertThat(detailsSupplier.getComponentNameByUuid("rootUuid")).contains("root"); + assertThat(detailsSupplier.getComponentNameByUuid("foo")).isEmpty(); + } + + @Test + public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_shortName_of_dir_and_file_in_TreeRootHolder() { + treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root") + .addChildren(ReportComponent.builder(DIRECTORY, 2).setUuid("dir1Uuid").setName("dir1").setShortName("dir1_short") + .addChildren(ReportComponent.builder(FILE, 21).setUuid("file21Uuid").setName("file21").setShortName("file21_short").build()) + .build()) + .addChildren(ReportComponent.builder(DIRECTORY, 3).setUuid("dir2Uuid").setName("dir2").setShortName("dir2_short") + .addChildren(ReportComponent.builder(FILE, 31).setUuid("file31Uuid").setName("file31").setShortName("file31_short").build()) + .addChildren(ReportComponent.builder(FILE, 32).setUuid("file32Uuid").setName("file32").setShortName("file32_short").build()) + .build()) + .addChildren(ReportComponent.builder(FILE, 11).setUuid("file11Uuid").setName("file11").setShortName("file11_short").build()) + .build()); + MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + Stream.of("dir1", "dir2", "file11", "file21", "file31", "file32") + .forEach(name -> { + assertThat(detailsSupplier.getComponentNameByUuid(name + "Uuid")).contains(name + "_short"); + assertThat(detailsSupplier.getComponentNameByUuid(name)).isEmpty(); + }); + } + + @Test + public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_ISE_if_TreeRootHolder_is_not_initialized() { + NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage("Holder has not been initialized yet"); + + detailsSupplier.getComponentNameByUuid("foo"); + } + + @Test + public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_NPE_if_uuid_is_null() { + treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build()); + NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("uuid can't be null"); + + detailsSupplier.getComponentNameByUuid(null); + } + + @Test + public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_name_of_project_in_TreeRootHolder() { + treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build()); + + NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + assertThat(detailsSupplier.getComponentNameByUuid("rootUuid")).contains("root"); + assertThat(detailsSupplier.getComponentNameByUuid("foo")).isEmpty(); + } + + @Test + public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_shortName_of_dir_and_file_in_TreeRootHolder() { + treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root") + .addChildren(ReportComponent.builder(DIRECTORY, 2).setUuid("dir1Uuid").setName("dir1").setShortName("dir1_short") + .addChildren(ReportComponent.builder(FILE, 21).setUuid("file21Uuid").setName("file21").setShortName("file21_short").build()) + .build()) + .addChildren(ReportComponent.builder(DIRECTORY, 3).setUuid("dir2Uuid").setName("dir2").setShortName("dir2_short") + .addChildren(ReportComponent.builder(FILE, 31).setUuid("file31Uuid").setName("file31").setShortName("file31_short").build()) + .addChildren(ReportComponent.builder(FILE, 32).setUuid("file32Uuid").setName("file32").setShortName("file32_short").build()) + .build()) + .addChildren(ReportComponent.builder(FILE, 11).setUuid("file11Uuid").setName("file11").setShortName("file11_short").build()) + .build()); + + NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + Stream.of("dir1", "dir2", "file11", "file21", "file31", "file32") + .forEach(name -> { + assertThat(detailsSupplier.getComponentNameByUuid(name + "Uuid")).contains(name + "_short"); + assertThat(detailsSupplier.getComponentNameByUuid(name)).isEmpty(); + }); + } + + @Test + public void newMyNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_fails_with_NPE_if_ruleKey_is_null() { + MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("ruleKey can't be null"); + + detailsSupplier.getRuleDefinitionByRuleKey(null); + } + + @Test + public void newMyNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_always_returns_empty_if_RuleRepository_is_empty() { + MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("foo", "bar"))).isEmpty(); + assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("bar", "foo"))).isEmpty(); + } + + @Test + public void newMyNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_returns_name_and_language_from_RuleRepository() { + RuleKey rulekey1 = RuleKey.of("foo", "bar"); + RuleKey rulekey2 = RuleKey.of("foo", "donut"); + RuleKey rulekey3 = RuleKey.of("no", "language"); + DumbRule rule1 = ruleRepository.add(rulekey1).setName("rule1").setLanguage("lang1"); + DumbRule rule2 = ruleRepository.add(rulekey2).setName("rule2").setLanguage("lang2"); + DumbRule rule3 = ruleRepository.add(rulekey3).setName("rule3"); + + MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey1)) + .contains(new RuleDefinition(rule1.getName(), rule1.getLanguage())); + assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey2)) + .contains(new RuleDefinition(rule2.getName(), rule2.getLanguage())); + assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey3)) + .contains(new RuleDefinition(rule3.getName(), null)); + assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("donut", "foo"))) + .isEmpty(); + } + + @Test + public void newNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_fails_with_NPE_if_ruleKey_is_null() { + NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("ruleKey can't be null"); + + detailsSupplier.getRuleDefinitionByRuleKey(null); + } + + @Test + public void newNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_always_returns_empty_if_RuleRepository_is_empty() { + NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("foo", "bar"))).isEmpty(); + assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("bar", "foo"))).isEmpty(); + } + + @Test + public void newNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_returns_name_and_language_from_RuleRepository() { + RuleKey rulekey1 = RuleKey.of("foo", "bar"); + RuleKey rulekey2 = RuleKey.of("foo", "donut"); + RuleKey rulekey3 = RuleKey.of("no", "language"); + DumbRule rule1 = ruleRepository.add(rulekey1).setName("rule1").setLanguage("lang1"); + DumbRule rule2 = ruleRepository.add(rulekey2).setName("rule2").setLanguage("lang2"); + DumbRule rule3 = ruleRepository.add(rulekey3).setName("rule3"); + + NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap()); + + DetailsSupplier detailsSupplier = readDetailsSupplier(underTest); + + assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey1)) + .contains(new RuleDefinition(rule1.getName(), rule1.getLanguage())); + assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey2)) + .contains(new RuleDefinition(rule2.getName(), rule2.getLanguage())); + assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey3)) + .contains(new RuleDefinition(rule3.getName(), null)); + assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("donut", "foo"))) + .isEmpty(); + } + + @Test + public void newIssuesChangesNotification_fails_with_ISE_if_analysis_date_has_not_been_set() { + Set issues = IntStream.range(0, 1 + new Random().nextInt(2)) + .mapToObj(i -> new DefaultIssue()) + .collect(Collectors.toSet()); + Map assigneesByUuid = nonEmptyAssigneesByUuid(); + + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage("Analysis date has not been set"); + + underTest.newIssuesChangesNotification(issues, assigneesByUuid); + } + + @Test + public void newIssuesChangesNotification_fails_with_IAE_if_issues_is_empty() { + analysisMetadata.setAnalysisDate(new Random().nextLong()); + Map assigneesByUuid = nonEmptyAssigneesByUuid(); + + expectedException.expect(IllegalArgumentException.class); + expectedException.expectMessage("issues can't be empty"); + + underTest.newIssuesChangesNotification(Collections.emptySet(), assigneesByUuid); + } + + @Test + public void newIssuesChangesNotification_fails_with_NPE_if_issue_has_no_rule() { + DefaultIssue issue = new DefaultIssue(); + Map assigneesByUuid = nonEmptyAssigneesByUuid(); + analysisMetadata.setAnalysisDate(new Random().nextLong()); + + expectedException.expect(NullPointerException.class); + + underTest.newIssuesChangesNotification(ImmutableSet.of(issue), assigneesByUuid); + } + + @Test + public void newIssuesChangesNotification_fails_with_ISE_if_rule_of_issue_does_not_exist_in_repository() { + RuleKey ruleKey = RuleKey.of("foo", "bar"); + DefaultIssue issue = new DefaultIssue() + .setRuleKey(ruleKey); + Map assigneesByUuid = nonEmptyAssigneesByUuid(); + analysisMetadata.setAnalysisDate(new Random().nextLong()); + + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage("Can not find rule " + ruleKey + " in RuleRepository"); + + underTest.newIssuesChangesNotification(ImmutableSet.of(issue), assigneesByUuid); + } + + @Test + public void newIssuesChangesNotification_fails_with_ISE_if_treeRootHolder_is_empty() { + RuleKey ruleKey = RuleKey.of("foo", "bar"); + DefaultIssue issue = new DefaultIssue() + .setRuleKey(ruleKey); + Map assigneesByUuid = nonEmptyAssigneesByUuid(); + ruleRepository.add(ruleKey); + analysisMetadata.setAnalysisDate(new Random().nextLong()); + + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage("Holder has not been initialized yet"); + + underTest.newIssuesChangesNotification(ImmutableSet.of(issue), assigneesByUuid); + } + + @Test + public void newIssuesChangesNotification_fails_with_ISE_if_branch_has_not_been_set() { + RuleKey ruleKey = RuleKey.of("foo", "bar"); + DefaultIssue issue = new DefaultIssue() + .setRuleKey(ruleKey); + Map assigneesByUuid = nonEmptyAssigneesByUuid(); + ruleRepository.add(ruleKey); + analysisMetadata.setAnalysisDate(new Random().nextLong()); + treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).build()); + + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage("Branch has not been set"); + + underTest.newIssuesChangesNotification(ImmutableSet.of(issue), assigneesByUuid); + } + + @Test + public void newIssuesChangesNotification_fails_with_NPE_if_issue_has_no_key() { + RuleKey ruleKey = RuleKey.of("foo", "bar"); + DefaultIssue issue = new DefaultIssue() + .setRuleKey(ruleKey); + Map assigneesByUuid = nonEmptyAssigneesByUuid(); + ruleRepository.add(ruleKey); + treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).build()); + analysisMetadata.setAnalysisDate(new Random().nextLong()); + analysisMetadata.setBranch(mock(Branch.class)); + + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("key can't be null"); + + underTest.newIssuesChangesNotification(ImmutableSet.of(issue), assigneesByUuid); + } + + @Test + public void newIssuesChangesNotification_fails_with_NPE_if_issue_has_no_status() { + RuleKey ruleKey = RuleKey.of("foo", "bar"); + DefaultIssue issue = new DefaultIssue() + .setRuleKey(ruleKey) + .setKey("issueKey"); + Map assigneesByUuid = nonEmptyAssigneesByUuid(); + ruleRepository.add(ruleKey); + treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).build()); + analysisMetadata.setAnalysisDate(new Random().nextLong()); + analysisMetadata.setBranch(mock(Branch.class)); + + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("newStatus can't be null"); + + underTest.newIssuesChangesNotification(ImmutableSet.of(issue), assigneesByUuid); + } + + @Test + @UseDataProvider("noBranchNameBranches") + public void newIssuesChangesNotification_creates_project_from_TreeRootHolder_and_branch_name_only_on_long_non_main_branches(Branch branch) { + RuleKey ruleKey = RuleKey.of("foo", "bar"); + DefaultIssue issue = new DefaultIssue() + .setRuleKey(ruleKey) + .setKey("issueKey") + .setStatus(STATUS_OPEN); + Map assigneesByUuid = nonEmptyAssigneesByUuid(); + ReportComponent project = ReportComponent.builder(PROJECT, 1).build(); + ruleRepository.add(ruleKey); + treeRootHolder.setRoot(project); + analysisMetadata.setAnalysisDate(new Random().nextLong()); + analysisMetadata.setBranch(branch); + IssuesChangesNotification expected = mock(IssuesChangesNotification.class); + when(issuesChangesSerializer.serialize(any(IssuesChangesNotificationBuilder.class))).thenReturn(expected); + + IssuesChangesNotification notification = underTest.newIssuesChangesNotification(ImmutableSet.of(issue), assigneesByUuid); + + assertThat(notification).isSameAs(expected); + + IssuesChangesNotificationBuilder builder = verifyAndCaptureIssueChangeNotificationBuilder(); + assertThat(builder.getIssues()).hasSize(1); + ChangedIssue changeIssue = builder.getIssues().iterator().next(); + assertThat(changeIssue.getProject().getUuid()).isEqualTo(project.getUuid()); + assertThat(changeIssue.getProject().getKey()).isEqualTo(project.getKey()); + assertThat(changeIssue.getProject().getProjectName()).isEqualTo(project.getName()); + assertThat(changeIssue.getProject().getBranchName()).isEmpty(); + } + + @DataProvider + public static Object[][] noBranchNameBranches() { + Branch mainBranch = mock(Branch.class); + when(mainBranch.isMain()).thenReturn(true); + when(mainBranch.isLegacyFeature()).thenReturn(false); + when(mainBranch.getType()).thenReturn(BranchType.LONG); + Branch legacyBranch = mock(Branch.class); + when(legacyBranch.isLegacyFeature()).thenReturn(true); + Branch shortBranch = mock(Branch.class); + when(shortBranch.isLegacyFeature()).thenReturn(false); + when(shortBranch.isMain()).thenReturn(false); + when(shortBranch.getType()).thenReturn(BranchType.SHORT); + Branch pr = mock(Branch.class); + when(pr.isLegacyFeature()).thenReturn(false); + when(pr.isMain()).thenReturn(false); + when(pr.getType()).thenReturn(BranchType.PULL_REQUEST); + return new Object[][] { + {mainBranch}, + {legacyBranch}, + {shortBranch}, + {pr} + }; + } + + @Test + public void newIssuesChangesNotification_creates_project_from_TreeRootHolder_and_branch_name_from_long_branch() { + RuleKey ruleKey = RuleKey.of("foo", "bar"); + DefaultIssue issue = new DefaultIssue() + .setRuleKey(ruleKey) + .setKey("issueKey") + .setStatus(STATUS_OPEN); + Map assigneesByUuid = nonEmptyAssigneesByUuid(); + ReportComponent project = ReportComponent.builder(PROJECT, 1).build(); + String branchName = randomAlphabetic(12); + ruleRepository.add(ruleKey); + treeRootHolder.setRoot(project); + analysisMetadata.setAnalysisDate(new Random().nextLong()); + analysisMetadata.setBranch(newBranch(BranchType.LONG, branchName)); + IssuesChangesNotification expected = mock(IssuesChangesNotification.class); + when(issuesChangesSerializer.serialize(any(IssuesChangesNotificationBuilder.class))).thenReturn(expected); + + IssuesChangesNotification notification = underTest.newIssuesChangesNotification(ImmutableSet.of(issue), assigneesByUuid); + + assertThat(notification).isSameAs(expected); + + IssuesChangesNotificationBuilder builder = verifyAndCaptureIssueChangeNotificationBuilder(); + assertThat(builder.getIssues()).hasSize(1); + ChangedIssue changeIssue = builder.getIssues().iterator().next(); + assertThat(changeIssue.getProject().getUuid()).isEqualTo(project.getUuid()); + assertThat(changeIssue.getProject().getKey()).isEqualTo(project.getKey()); + assertThat(changeIssue.getProject().getProjectName()).isEqualTo(project.getName()); + assertThat(changeIssue.getProject().getBranchName()).contains(branchName); + } + + @Test + public void newIssuesChangesNotification_creates_rule_from_RuleRepository() { + RuleKey ruleKey = RuleKey.of("foo", "bar"); + DefaultIssue issue = new DefaultIssue() + .setRuleKey(ruleKey) + .setKey("issueKey") + .setStatus(STATUS_OPEN); + Map assigneesByUuid = nonEmptyAssigneesByUuid(); + ReportComponent project = ReportComponent.builder(PROJECT, 1).build(); + String branchName = randomAlphabetic(12); + ruleRepository.add(ruleKey); + treeRootHolder.setRoot(project); + analysisMetadata.setAnalysisDate(new Random().nextLong()); + analysisMetadata.setBranch(newBranch(BranchType.LONG, branchName)); + IssuesChangesNotification expected = mock(IssuesChangesNotification.class); + when(issuesChangesSerializer.serialize(any(IssuesChangesNotificationBuilder.class))).thenReturn(expected); + + IssuesChangesNotification notification = underTest.newIssuesChangesNotification(ImmutableSet.of(issue), assigneesByUuid); + + assertThat(notification).isSameAs(expected); + IssuesChangesNotificationBuilder builder = verifyAndCaptureIssueChangeNotificationBuilder(); + assertThat(builder.getIssues()).hasSize(1); + ChangedIssue changeIssue = builder.getIssues().iterator().next(); + assertThat(changeIssue.getRule().getKey()).isEqualTo(ruleKey); + assertThat(changeIssue.getRule().getName()).isEqualTo(ruleRepository.getByKey(ruleKey).getName()); + } + + @Test + public void newIssuesChangesNotification_fails_with_ISE_if_issue_has_assignee_not_in_assigneesByUuid() { + RuleKey ruleKey = RuleKey.of("foo", "bar"); + String assigneeUuid = randomAlphabetic(40); + DefaultIssue issue = new DefaultIssue() + .setRuleKey(ruleKey) + .setKey("issueKey") + .setStatus(STATUS_OPEN) + .setAssigneeUuid(assigneeUuid); + Map assigneesByUuid = Collections.emptyMap(); + ReportComponent project = ReportComponent.builder(PROJECT, 1).build(); + ruleRepository.add(ruleKey); + treeRootHolder.setRoot(project); + analysisMetadata.setAnalysisDate(new Random().nextLong()); + analysisMetadata.setBranch(newBranch(BranchType.LONG, randomAlphabetic(12))); + + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage("Can not find DTO for assignee uuid " + assigneeUuid); + + underTest.newIssuesChangesNotification(ImmutableSet.of(issue), assigneesByUuid); + } + + @Test + public void newIssuesChangesNotification_creates_assignee_from_UserDto() { + RuleKey ruleKey = RuleKey.of("foo", "bar"); + String assigneeUuid = randomAlphabetic(40); + DefaultIssue issue = new DefaultIssue() + .setRuleKey(ruleKey) + .setKey("issueKey") + .setStatus(STATUS_OPEN) + .setAssigneeUuid(assigneeUuid); + UserDto userDto = UserTesting.newUserDto(); + Map assigneesByUuid = ImmutableMap.of(assigneeUuid, userDto); + ReportComponent project = ReportComponent.builder(PROJECT, 1).build(); + ruleRepository.add(ruleKey); + treeRootHolder.setRoot(project); + analysisMetadata.setAnalysisDate(new Random().nextLong()); + analysisMetadata.setBranch(newBranch(BranchType.LONG, randomAlphabetic(12))); + IssuesChangesNotification expected = mock(IssuesChangesNotification.class); + when(issuesChangesSerializer.serialize(any(IssuesChangesNotificationBuilder.class))).thenReturn(expected); + + IssuesChangesNotification notification = underTest.newIssuesChangesNotification(ImmutableSet.of(issue), assigneesByUuid); + + assertThat(notification).isSameAs(expected); + IssuesChangesNotificationBuilder builder = verifyAndCaptureIssueChangeNotificationBuilder(); + assertThat(builder.getIssues()).hasSize(1); + ChangedIssue changeIssue = builder.getIssues().iterator().next(); + assertThat(changeIssue.getAssignee()).isPresent(); + IssuesChangesNotificationBuilder.User assignee = changeIssue.getAssignee().get(); + assertThat(assignee.getUuid()).isEqualTo(userDto.getUuid()); + assertThat(assignee.getName()).contains(userDto.getName()); + assertThat(assignee.getLogin()).isEqualTo(userDto.getLogin()); + } + + @Test + public void newIssuesChangesNotification_creates_AnalysisChange_with_analysis_date() { + RuleKey ruleKey = RuleKey.of("foo", "bar"); + DefaultIssue issue = new DefaultIssue() + .setRuleKey(ruleKey) + .setKey("issueKey") + .setStatus(STATUS_OPEN); + Map assigneesByUuid = nonEmptyAssigneesByUuid(); + ReportComponent project = ReportComponent.builder(PROJECT, 1).build(); + long analysisDate = new Random().nextLong(); + ruleRepository.add(ruleKey); + treeRootHolder.setRoot(project); + analysisMetadata.setAnalysisDate(analysisDate); + analysisMetadata.setBranch(newBranch(BranchType.LONG, randomAlphabetic(12))); + IssuesChangesNotification expected = mock(IssuesChangesNotification.class); + when(issuesChangesSerializer.serialize(any(IssuesChangesNotificationBuilder.class))).thenReturn(expected); + + IssuesChangesNotification notification = underTest.newIssuesChangesNotification(ImmutableSet.of(issue), assigneesByUuid); + + assertThat(notification).isSameAs(expected); + IssuesChangesNotificationBuilder builder = verifyAndCaptureIssueChangeNotificationBuilder(); + assertThat(builder.getIssues()).hasSize(1); + assertThat(builder.getChange()) + .isInstanceOf(AnalysisChange.class) + .extracting(IssuesChangesNotificationBuilder.Change::getDate) + .containsOnly(analysisDate); + } + + @Test + public void newIssuesChangesNotification_maps_all_issues() { + Set issues = IntStream.range(0, 3 + new Random().nextInt(5)) + .mapToObj(i -> new DefaultIssue() + .setRuleKey(RuleKey.of("repo_" + i, "rule_" + i)) + .setKey("issue_key_" + i) + .setStatus("status_" + i)) + .collect(Collectors.toSet()); + ReportComponent project = ReportComponent.builder(PROJECT, 1).build(); + long analysisDate = new Random().nextLong(); + issues.stream() + .map(DefaultIssue::ruleKey) + .forEach(ruleKey -> ruleRepository.add(ruleKey)); + treeRootHolder.setRoot(project); + analysisMetadata.setAnalysisDate(analysisDate); + analysisMetadata.setBranch(newBranch(BranchType.LONG, randomAlphabetic(12))); + IssuesChangesNotification expected = mock(IssuesChangesNotification.class); + when(issuesChangesSerializer.serialize(any(IssuesChangesNotificationBuilder.class))).thenReturn(expected); + + IssuesChangesNotification notification = underTest.newIssuesChangesNotification(issues, emptyMap()); + + assertThat(notification).isSameAs(expected); + IssuesChangesNotificationBuilder builder = verifyAndCaptureIssueChangeNotificationBuilder(); + assertThat(builder.getIssues()).hasSize(issues.size()); + Map changedIssuesByKey = builder.getIssues().stream() + .collect(uniqueIndex(ChangedIssue::getKey)); + issues.forEach( + issue -> { + ChangedIssue changedIssue = changedIssuesByKey.get(issue.key()); + assertThat(changedIssue.getNewStatus()).isEqualTo(issue.status()); + assertThat(changedIssue.getNewResolution()).isEmpty(); + assertThat(changedIssue.getAssignee()).isEmpty(); + assertThat(changedIssue.getRule().getKey()).isEqualTo(issue.ruleKey()); + assertThat(changedIssue.getRule().getName()).isEqualTo(ruleRepository.getByKey(issue.ruleKey()).getName()); + } + ); + } + + private static Map nonEmptyAssigneesByUuid() { + return IntStream.range(0, 1 + new Random().nextInt(3)) + .boxed() + .collect(uniqueIndex(i -> "uuid_" + i, i -> new UserDto())); + } + + private IssuesChangesNotificationBuilder verifyAndCaptureIssueChangeNotificationBuilder() { + ArgumentCaptor builderCaptor = ArgumentCaptor.forClass(IssuesChangesNotificationBuilder.class); + verify(issuesChangesSerializer).serialize(builderCaptor.capture()); + verifyNoMoreInteractions(issuesChangesSerializer); + + return builderCaptor.getValue(); + } + + private static Branch newBranch(BranchType branchType, String branchName) { + Branch longBranch = mock(Branch.class); + when(longBranch.isLegacyFeature()).thenReturn(false); + when(longBranch.isMain()).thenReturn(false); + when(longBranch.getType()).thenReturn(branchType); + when(longBranch.getName()).thenReturn(branchName); + return longBranch; + } + + private static Durations readDurationsField(NewIssuesNotification notification) { + return readField(notification, "durations"); + } + + private static Durations readField(NewIssuesNotification notification, String fieldName) { + try { + Field durationsField = NewIssuesNotification.class.getDeclaredField(fieldName); + durationsField.setAccessible(true); + Object o = durationsField.get(notification); + return (Durations) o; + } catch (IllegalAccessException | NoSuchFieldException e) { + throw new RuntimeException(e); + } + } + + private static DetailsSupplier readDetailsSupplier(NewIssuesNotification notification) { + try { + Field durationsField = NewIssuesNotification.class.getDeclaredField("detailsSupplier"); + durationsField.setAccessible(true); + return (DetailsSupplier) durationsField.get(notification); + } catch (IllegalAccessException | NoSuchFieldException e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStepTest.java b/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStepTest.java index 22dc5ac1ede..6d077e53d34 100644 --- a/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStepTest.java +++ b/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStepTest.java @@ -19,14 +19,18 @@ */ package org.sonar.ce.task.projectanalysis.step; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Random; +import java.util.Set; +import java.util.function.Supplier; import java.util.stream.IntStream; import java.util.stream.Stream; import org.assertj.core.groups.Tuple; @@ -35,6 +39,8 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.mockito.ArgumentCaptor; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; import org.sonar.api.notifications.Notification; import org.sonar.api.rules.RuleType; import org.sonar.api.utils.Duration; @@ -45,8 +51,7 @@ import org.sonar.ce.task.projectanalysis.component.Component; import org.sonar.ce.task.projectanalysis.component.DefaultBranchImpl; import org.sonar.ce.task.projectanalysis.component.TreeRootHolderRule; import org.sonar.ce.task.projectanalysis.issue.IssueCache; -import org.sonar.ce.task.projectanalysis.issue.RuleRepositoryRule; -import org.sonar.ce.task.projectanalysis.notification.NewIssuesNotificationFactory; +import org.sonar.ce.task.projectanalysis.notification.NotificationFactory; import org.sonar.ce.task.projectanalysis.util.cache.DiskCache; import org.sonar.ce.task.step.ComputationStep; import org.sonar.ce.task.step.TestComputationStepContext; @@ -57,7 +62,7 @@ import org.sonar.db.component.ComponentDto; import org.sonar.db.rule.RuleDefinitionDto; import org.sonar.db.user.UserDto; import org.sonar.server.issue.notification.DistributedMetricStatsInt; -import org.sonar.server.issue.notification.IssueChangeNotification; +import org.sonar.server.issue.notification.IssuesChangesNotification; import org.sonar.server.issue.notification.MyNewIssuesNotification; import org.sonar.server.issue.notification.NewIssuesNotification; import org.sonar.server.issue.notification.NewIssuesStatistics; @@ -67,6 +72,7 @@ import org.sonar.server.project.Project; import static java.util.Arrays.stream; import static java.util.Collections.emptyList; import static java.util.Collections.shuffle; +import static java.util.Collections.singleton; import static java.util.stream.Collectors.toList; import static java.util.stream.Stream.concat; import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric; @@ -75,6 +81,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.groups.Tuple.tuple; import static org.mockito.ArgumentCaptor.forClass; import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.any; import static org.mockito.Mockito.mock; @@ -121,8 +129,6 @@ public class SendIssueNotificationsStepTest extends BaseStepTest { .setBranch(new DefaultBranchImpl()) .setAnalysisDate(new Date(ANALYSE_DATE)); @Rule - public RuleRepositoryRule ruleRepository = new RuleRepositoryRule(); - @Rule public TemporaryFolder temp = new TemporaryFolder(); @Rule public DbTester db = DbTester.create(System2.INSTANCE); @@ -132,9 +138,15 @@ public class SendIssueNotificationsStepTest extends BaseStepTest { private final RuleType randomRuleType = RULE_TYPES_EXCEPT_HOTSPOTS[random.nextInt(RULE_TYPES_EXCEPT_HOTSPOTS.length)]; @SuppressWarnings("unchecked") private Class> assigneeCacheType = (Class>) (Object) Map.class; + @SuppressWarnings("unchecked") + private Class> setType = (Class>) (Class) Set.class; + @SuppressWarnings("unchecked") + private Class> mapType = (Class>) (Class) Map.class; private ArgumentCaptor> assigneeCacheCaptor = ArgumentCaptor.forClass(assigneeCacheType); + private ArgumentCaptor> issuesSetCaptor = forClass(setType); + private ArgumentCaptor> assigneeByUuidCaptor = forClass(mapType); private NotificationService notificationService = mock(NotificationService.class); - private NewIssuesNotificationFactory newIssuesNotificationFactory = mock(NewIssuesNotificationFactory.class); + private NotificationFactory notificationFactory = mock(NotificationFactory.class); private NewIssuesNotification newIssuesNotificationMock = createNewIssuesNotificationMock(); private MyNewIssuesNotification myNewIssuesNotificationMock = createMyNewIssuesNotificationMock(); @@ -144,10 +156,10 @@ public class SendIssueNotificationsStepTest extends BaseStepTest { @Before public void setUp() throws Exception { issueCache = new IssueCache(temp.newFile(), System2.INSTANCE); - underTest = new SendIssueNotificationsStep(issueCache, ruleRepository, treeRootHolder, notificationService, analysisMetadataHolder, - newIssuesNotificationFactory, db.getDbClient()); - when(newIssuesNotificationFactory.newNewIssuesNotification(any(assigneeCacheType))).thenReturn(newIssuesNotificationMock); - when(newIssuesNotificationFactory.newMyNewIssuesNotification(any(assigneeCacheType))).thenReturn(myNewIssuesNotificationMock); + underTest = new SendIssueNotificationsStep(issueCache, treeRootHolder, notificationService, analysisMetadataHolder, + notificationFactory, db.getDbClient()); + when(notificationFactory.newNewIssuesNotification(any(assigneeCacheType))).thenReturn(newIssuesNotificationMock); + when(notificationFactory.newMyNewIssuesNotification(any(assigneeCacheType))).thenReturn(myNewIssuesNotificationMock); } @Test @@ -360,19 +372,19 @@ public class SendIssueNotificationsStepTest extends BaseStepTest { analysisMetadataHolder.setProject(new Project(PROJECT.getUuid(), PROJECT.getKey(), PROJECT.getName(), null, emptyList())); when(notificationService.hasProjectSubscribersForTypes(PROJECT.getUuid(), NOTIF_TYPES)).thenReturn(true); - NewIssuesNotificationFactory newIssuesNotificationFactory = mock(NewIssuesNotificationFactory.class); + NotificationFactory notificationFactory = mock(NotificationFactory.class); NewIssuesNotification newIssuesNotificationMock = createNewIssuesNotificationMock(); - when(newIssuesNotificationFactory.newNewIssuesNotification(assigneeCacheCaptor.capture())) + when(notificationFactory.newNewIssuesNotification(assigneeCacheCaptor.capture())) .thenReturn(newIssuesNotificationMock); MyNewIssuesNotification myNewIssuesNotificationMock1 = createMyNewIssuesNotificationMock(); MyNewIssuesNotification myNewIssuesNotificationMock2 = createMyNewIssuesNotificationMock(); - when(newIssuesNotificationFactory.newMyNewIssuesNotification(any(assigneeCacheType))) + when(notificationFactory.newMyNewIssuesNotification(any(assigneeCacheType))) .thenReturn(myNewIssuesNotificationMock1) .thenReturn(myNewIssuesNotificationMock2); TestComputationStepContext context = new TestComputationStepContext(); - new SendIssueNotificationsStep(issueCache, ruleRepository, treeRootHolder, notificationService, analysisMetadataHolder, newIssuesNotificationFactory, db.getDbClient()) + new SendIssueNotificationsStep(issueCache, treeRootHolder, notificationService, analysisMetadataHolder, notificationFactory, db.getDbClient()) .execute(context); verify(notificationService).deliverEmails(ImmutableSet.of(myNewIssuesNotificationMock1, myNewIssuesNotificationMock2)); @@ -380,9 +392,9 @@ public class SendIssueNotificationsStepTest extends BaseStepTest { verify(notificationService).deliver(myNewIssuesNotificationMock1); verify(notificationService).deliver(myNewIssuesNotificationMock2); - verify(newIssuesNotificationFactory).newNewIssuesNotification(assigneeCacheCaptor.capture()); - verify(newIssuesNotificationFactory, times(2)).newMyNewIssuesNotification(assigneeCacheCaptor.capture()); - verifyNoMoreInteractions(newIssuesNotificationFactory); + verify(notificationFactory).newNewIssuesNotification(assigneeCacheCaptor.capture()); + verify(notificationFactory, times(2)).newMyNewIssuesNotification(assigneeCacheCaptor.capture()); + verifyNoMoreInteractions(notificationFactory); verifyAssigneeCache(assigneeCacheCaptor, perceval, arthur); Map myNewIssuesNotificationMocksByUsersName = new HashMap<>(); @@ -439,10 +451,9 @@ public class SendIssueNotificationsStepTest extends BaseStepTest { // old API compatibility verify(notificationService).deliver(myNewIssuesNotificationMock); - - verify(newIssuesNotificationFactory).newNewIssuesNotification(assigneeCacheCaptor.capture()); - verify(newIssuesNotificationFactory).newMyNewIssuesNotification(assigneeCacheCaptor.capture()); - verifyNoMoreInteractions(newIssuesNotificationFactory); + verify(notificationFactory).newNewIssuesNotification(assigneeCacheCaptor.capture()); + verify(notificationFactory).newMyNewIssuesNotification(assigneeCacheCaptor.capture()); + verifyNoMoreInteractions(notificationFactory); verifyAssigneeCache(assigneeCacheCaptor, user); verify(myNewIssuesNotificationMock).setAssignee(any(UserDto.class)); @@ -499,7 +510,7 @@ public class SendIssueNotificationsStepTest extends BaseStepTest { ComponentDto project = newPrivateProjectDto(newOrganizationDto()).setDbKey(PROJECT.getDbKey()).setLongName(PROJECT.getName()); ComponentDto file = newFileDto(project).setDbKey(FILE.getDbKey()).setLongName(FILE.getName()); RuleDefinitionDto ruleDefinitionDto = newRule(); - DefaultIssue issue = prepareIssue(ANALYSE_DATE, user, project, file, ruleDefinitionDto, RuleType.SECURITY_HOTSPOT); + prepareIssue(ANALYSE_DATE, user, project, file, ruleDefinitionDto, RuleType.SECURITY_HOTSPOT); analysisMetadataHolder.setProject(new Project(PROJECT.getUuid(), PROJECT.getKey(), PROJECT.getName(), null, emptyList())); when(notificationService.hasProjectSubscribersForTypes(PROJECT.getUuid(), NOTIF_TYPES)).thenReturn(true); @@ -521,29 +532,33 @@ public class SendIssueNotificationsStepTest extends BaseStepTest { ComponentDto project = newPrivateProjectDto(newOrganizationDto()).setDbKey(PROJECT.getDbKey()).setLongName(PROJECT.getName()); analysisMetadataHolder.setProject(Project.from(project)); ComponentDto file = newFileDto(project).setDbKey(FILE.getDbKey()).setLongName(FILE.getName()); + treeRootHolder.setRoot(builder(Type.PROJECT, 2).setKey(project.getDbKey()).setPublicKey(project.getKey()).setName(project.longName()).setUuid(project.uuid()) + .addChildren( + builder(Type.FILE, 11).setKey(file.getDbKey()).setPublicKey(file.getKey()).setName(file.longName()).build()) + .build()); RuleDefinitionDto ruleDefinitionDto = newRule(); RuleType randomTypeExceptHotspot = RuleType.values()[nextInt(RuleType.values().length - 1)]; DefaultIssue issue = prepareIssue(issueCreatedAt, user, project, file, ruleDefinitionDto, randomTypeExceptHotspot); + IssuesChangesNotification issuesChangesNotification = mock(IssuesChangesNotification.class); + when(notificationService.hasProjectSubscribersForTypes(project.uuid(), NOTIF_TYPES)).thenReturn(true); + when(notificationFactory.newIssuesChangesNotification(anySet(), anyMap())).thenReturn(issuesChangesNotification); underTest.execute(new TestComputationStepContext()); - ArgumentCaptor issueChangeNotificationCaptor = forClass(IssueChangeNotification.class); - verify(notificationService).deliver(issueChangeNotificationCaptor.capture()); - IssueChangeNotification issueChangeNotification = issueChangeNotificationCaptor.getValue(); - assertThat(issueChangeNotification.getFieldValue("key")).isEqualTo(issue.key()); - assertThat(issueChangeNotification.getFieldValue("message")).isEqualTo(issue.message()); - assertThat(issueChangeNotification.getFieldValue("ruleName")).isEqualTo(ruleDefinitionDto.getName()); - assertThat(issueChangeNotification.getFieldValue("projectName")).isEqualTo(project.longName()); - assertThat(issueChangeNotification.getFieldValue("projectKey")).isEqualTo(project.getKey()); - assertThat(issueChangeNotification.getFieldValue("componentKey")).isEqualTo(file.getKey()); - assertThat(issueChangeNotification.getFieldValue("componentName")).isEqualTo(file.longName()); - assertThat(issueChangeNotification.getFieldValue("assignee")).isEqualTo(user.getLogin()); + verify(notificationFactory).newIssuesChangesNotification(issuesSetCaptor.capture(), assigneeByUuidCaptor.capture()); + assertThat(issuesSetCaptor.getValue()).hasSize(1); + assertThat(issuesSetCaptor.getValue().iterator().next()).isEqualTo(issue); + assertThat(assigneeByUuidCaptor.getValue()).hasSize(1); + assertThat(assigneeByUuidCaptor.getValue().get(user.getUuid())).isNotNull(); + verify(notificationService).hasProjectSubscribersForTypes(project.uuid(), NOTIF_TYPES); + verify(notificationService).deliverEmails(singleton(issuesChangesNotification)); + verify(notificationService).deliver(issuesChangesNotification); + verifyNoMoreInteractions(notificationService); } private DefaultIssue prepareIssue(long issueCreatedAt, UserDto user, ComponentDto project, ComponentDto file, RuleDefinitionDto ruleDefinitionDto, RuleType type) { DefaultIssue issue = newIssue(ruleDefinitionDto, project, file).setType(type).toDefaultIssue() .setNew(false).setChanged(true).setSendNotifications(true).setCreationDate(new Date(issueCreatedAt)).setAssigneeUuid(user.getUuid()); - ruleRepository.add(ruleDefinitionDto.getKey()).setName(ruleDefinitionDto.getName()); issueCache.newAppender().append(issue).close(); when(notificationService.hasProjectSubscribersForTypes(project.projectUuid(), NOTIF_TYPES)).thenReturn(true); return issue; @@ -573,48 +588,85 @@ public class SendIssueNotificationsStepTest extends BaseStepTest { .setChanged(true) .setSendNotifications(true) .setCreationDate(new Date(issueCreatedAt)); - ruleRepository.add(ruleDefinitionDto.getKey()).setName(ruleDefinitionDto.getName()); issueCache.newAppender().append(issue).close(); when(notificationService.hasProjectSubscribersForTypes(project.uuid(), NOTIF_TYPES)).thenReturn(true); + IssuesChangesNotification issuesChangesNotification = mock(IssuesChangesNotification.class); + when(notificationFactory.newIssuesChangesNotification(anySet(), anyMap())).thenReturn(issuesChangesNotification); analysisMetadataHolder.setBranch(newBranch(BranchType.LONG)); underTest.execute(new TestComputationStepContext()); - ArgumentCaptor issueChangeNotificationCaptor = forClass(IssueChangeNotification.class); - verify(notificationService).deliver(issueChangeNotificationCaptor.capture()); - IssueChangeNotification issueChangeNotification = issueChangeNotificationCaptor.getValue(); - assertThat(issueChangeNotification.getFieldValue("projectName")).isEqualTo(branch.longName()); - assertThat(issueChangeNotification.getFieldValue("projectKey")).isEqualTo(branch.getKey()); - assertThat(issueChangeNotification.getFieldValue("branch")).isEqualTo(BRANCH_NAME); - assertThat(issueChangeNotification.getFieldValue("componentKey")).isEqualTo(file.getKey()); - assertThat(issueChangeNotification.getFieldValue("componentName")).isEqualTo(file.longName()); + verify(notificationFactory).newIssuesChangesNotification(issuesSetCaptor.capture(), assigneeByUuidCaptor.capture()); + assertThat(issuesSetCaptor.getValue()).hasSize(1); + assertThat(issuesSetCaptor.getValue().iterator().next()).isEqualTo(issue); + assertThat(assigneeByUuidCaptor.getValue()).isEmpty(); + verify(notificationService).hasProjectSubscribersForTypes(project.uuid(), NOTIF_TYPES); + verify(notificationService).deliverEmails(singleton(issuesChangesNotification)); + verify(notificationService).deliver(issuesChangesNotification); + verifyNoMoreInteractions(notificationService); } @Test - public void send_issue_change_notification_in_bulks_of_1000() { + public void sends_one_issue_change_notification_every_1000_issues() { UserDto user = db.users().insertUser(); ComponentDto project = newPrivateProjectDto(newOrganizationDto()).setDbKey(PROJECT.getDbKey()).setLongName(PROJECT.getName()); ComponentDto file = newFileDto(project).setDbKey(FILE.getDbKey()).setLongName(FILE.getName()); RuleDefinitionDto ruleDefinitionDto = newRule(); - ruleRepository.add(ruleDefinitionDto.getKey()).setName(ruleDefinitionDto.getName()); RuleType randomTypeExceptHotspot = RuleType.values()[nextInt(RuleType.values().length - 1)]; - List issues = IntStream.range(0, 1001 + new Random().nextInt(10)) - .mapToObj(i -> newIssue(ruleDefinitionDto, project, file).setType(randomTypeExceptHotspot).toDefaultIssue() + List issues = IntStream.range(0, 2001 + new Random().nextInt(10)) + .mapToObj(i -> newIssue(ruleDefinitionDto, project, file).setKee("uuid_" + i).setType(randomTypeExceptHotspot).toDefaultIssue() .setNew(false).setChanged(true).setSendNotifications(true).setAssigneeUuid(user.getUuid())) .collect(toList()); DiskCache.DiskAppender diskAppender = issueCache.newAppender(); issues.forEach(diskAppender::append); diskAppender.close(); analysisMetadataHolder.setProject(Project.from(project)); + NewIssuesFactoryCaptor newIssuesFactoryCaptor = new NewIssuesFactoryCaptor(() -> mock(IssuesChangesNotification.class)); + when(notificationFactory.newIssuesChangesNotification(anySet(), anyMap())).thenAnswer(newIssuesFactoryCaptor); + when(notificationService.hasProjectSubscribersForTypes(PROJECT.getUuid(), NOTIF_TYPES)).thenReturn(true); when(notificationService.hasProjectSubscribersForTypes(project.uuid(), NOTIF_TYPES)).thenReturn(true); underTest.execute(new TestComputationStepContext()); + verify(notificationFactory, times(3)).newIssuesChangesNotification(anySet(), anyMap()); + assertThat(newIssuesFactoryCaptor.issuesSetCaptor).hasSize(3); + assertThat(newIssuesFactoryCaptor.issuesSetCaptor.get(0)).hasSize(1000); + assertThat(newIssuesFactoryCaptor.issuesSetCaptor.get(1)).hasSize(1000); + assertThat(newIssuesFactoryCaptor.issuesSetCaptor.get(2)).hasSize(issues.size() - 2000); + assertThat(newIssuesFactoryCaptor.assigneeCacheCaptor).hasSize(3); + assertThat(newIssuesFactoryCaptor.assigneeCacheCaptor).containsOnly(newIssuesFactoryCaptor.assigneeCacheCaptor.iterator().next()); ArgumentCaptor collectionCaptor = forClass(Collection.class); - verify(notificationService, times(2)).deliverEmails(collectionCaptor.capture()); - verify(notificationService, times(issues.size())).deliver(any(IssueChangeNotification.class)); - assertThat(collectionCaptor.getAllValues().get(0)).hasSize(1000); - assertThat(collectionCaptor.getAllValues().get(1)).hasSize(issues.size() - 1000); + verify(notificationService, times(3)).deliverEmails(collectionCaptor.capture()); + assertThat(collectionCaptor.getAllValues()).hasSize(3); + assertThat(collectionCaptor.getAllValues().get(0)).hasSize(1); + assertThat(collectionCaptor.getAllValues().get(1)).hasSize(1); + assertThat(collectionCaptor.getAllValues().get(2)).hasSize(1); + verify(notificationService, times(3)).deliver(any(IssuesChangesNotification.class)); + } + + /** + * Since the very same Set object is passed to {@link NotificationFactory#newIssuesChangesNotification(Set, Map)} and + * reset between each call. We must make a copy of each argument to capture what's been passed to the factory. + * This is of course not supported by Mockito's {@link ArgumentCaptor} and we implement this ourselves with a + * {@link Answer}. + */ + private static class NewIssuesFactoryCaptor implements Answer { + private final Supplier delegate; + private final List> issuesSetCaptor = new ArrayList<>(); + private final List> assigneeCacheCaptor = new ArrayList<>(); + + private NewIssuesFactoryCaptor(Supplier delegate) { + this.delegate = delegate; + } + + @Override + public Object answer(InvocationOnMock t) { + Set issuesSet = t.getArgument(0); + Map assigneeCatch = t.getArgument(1); + issuesSetCaptor.add(ImmutableSet.copyOf(issuesSet)); + assigneeCacheCaptor.add(ImmutableMap.copyOf(assigneeCatch)); + return delegate.get(); + } } private NewIssuesNotification createNewIssuesNotificationMock() { diff --git a/server/sonar-ce/src/main/java/org/sonar/ce/container/ComputeEngineContainerImpl.java b/server/sonar-ce/src/main/java/org/sonar/ce/container/ComputeEngineContainerImpl.java index 2f43b9b4ad3..cc7445e259b 100644 --- a/server/sonar-ce/src/main/java/org/sonar/ce/container/ComputeEngineContainerImpl.java +++ b/server/sonar-ce/src/main/java/org/sonar/ce/container/ComputeEngineContainerImpl.java @@ -98,9 +98,7 @@ import org.sonar.server.issue.IssueFieldsSetter; import org.sonar.server.issue.IssueStorage; import org.sonar.server.issue.index.IssueIndexer; import org.sonar.server.issue.index.IssueIteratorFactory; -import org.sonar.server.issue.notification.ChangesOnMyIssueNotificationHandler; -import org.sonar.server.issue.notification.DoNotFixNotificationHandler; -import org.sonar.server.issue.notification.IssueChangesEmailTemplate; +import org.sonar.server.issue.notification.IssuesChangesNotificationModule; import org.sonar.server.issue.notification.MyNewIssuesEmailTemplate; import org.sonar.server.issue.notification.MyNewIssuesNotificationHandler; import org.sonar.server.issue.notification.NewIssuesEmailTemplate; @@ -402,15 +400,11 @@ public class ComputeEngineContainerImpl implements ComputeEngineContainer { IssueWorkflow.class, // used in Web Services and CE's DebtCalculator NewIssuesEmailTemplate.class, MyNewIssuesEmailTemplate.class, - IssueChangesEmailTemplate.class, - ChangesOnMyIssueNotificationHandler.class, - ChangesOnMyIssueNotificationHandler.newMetadata(), NewIssuesNotificationHandler.class, NewIssuesNotificationHandler.newMetadata(), MyNewIssuesNotificationHandler.class, MyNewIssuesNotificationHandler.newMetadata(), - DoNotFixNotificationHandler.class, - DoNotFixNotificationHandler.newMetadata(), + IssuesChangesNotificationModule.class, // Notifications QGChangeEmailTemplate.class, diff --git a/server/sonar-ce/src/test/java/org/sonar/ce/container/ComputeEngineContainerImplTest.java b/server/sonar-ce/src/test/java/org/sonar/ce/container/ComputeEngineContainerImplTest.java index 6af124eb4be..0003b7c7cf1 100644 --- a/server/sonar-ce/src/test/java/org/sonar/ce/container/ComputeEngineContainerImplTest.java +++ b/server/sonar-ce/src/test/java/org/sonar/ce/container/ComputeEngineContainerImplTest.java @@ -97,7 +97,8 @@ public class ComputeEngineContainerImplTest { assertThat(picoContainer.getComponentAdapters()) .hasSize( CONTAINER_ITSELF - + 67 // level 4 + + 63 // level 4 + + 7 // content of IssuesChangesNotificationModule + 6 // content of CeConfigurationModule + 4 // content of CeQueueModule + 3 // content of CeHttpModule diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/AbstractNewIssuesEmailTemplate.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/AbstractNewIssuesEmailTemplate.java index e2c9f1be79c..3ab0fee2c86 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/AbstractNewIssuesEmailTemplate.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/AbstractNewIssuesEmailTemplate.java @@ -25,6 +25,7 @@ import java.util.Arrays; import java.util.Date; import java.util.Iterator; import java.util.Locale; +import javax.annotation.CheckForNull; import javax.annotation.Nullable; import org.sonar.api.config.EmailSettings; import org.sonar.api.i18n.I18n; @@ -71,6 +72,7 @@ public abstract class AbstractNewIssuesEmailTemplate implements EmailTemplate { } @Override + @CheckForNull public EmailMessage format(Notification notification) { if (shouldNotFormat(notification)) { return null; @@ -102,7 +104,7 @@ public abstract class AbstractNewIssuesEmailTemplate implements EmailTemplate { return new EmailMessage() .setMessageId(notification.getType() + "/" + notification.getFieldValue(FIELD_PROJECT_KEY)) .setSubject(subject(notification, computeFullProjectName(projectName, branchName))) - .setMessage(message.toString()); + .setPlainTextMessage(message.toString()); } private static String computeFullProjectName(String projectName, @Nullable String branchName) { diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandler.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandler.java index 7d5e5243d74..23e7d6f9249 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandler.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandler.java @@ -20,15 +20,17 @@ package org.sonar.server.issue.notification; import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Multimap; +import com.google.common.collect.SetMultimap; import java.util.Collection; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; -import java.util.stream.Stream; import javax.annotation.CheckForNull; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; import org.sonar.server.notification.EmailNotificationHandler; import org.sonar.server.notification.NotificationDispatcherMetadata; import org.sonar.server.notification.NotificationManager; @@ -36,12 +38,12 @@ import org.sonar.server.notification.NotificationManager.EmailRecipient; import org.sonar.server.notification.email.EmailNotificationChannel; import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest; -import static org.sonar.core.util.stream.MoreCollectors.index; import static org.sonar.core.util.stream.MoreCollectors.toSet; -import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex; +import static org.sonar.core.util.stream.MoreCollectors.unorderedFlattenIndex; +import static org.sonar.core.util.stream.MoreCollectors.unorderedIndex; import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER; -public class ChangesOnMyIssueNotificationHandler extends EmailNotificationHandler { +public class ChangesOnMyIssueNotificationHandler extends EmailNotificationHandler { private static final String KEY = "ChangesOnMyIssue"; private static final NotificationDispatcherMetadata METADATA = NotificationDispatcherMetadata.create(KEY) @@ -49,10 +51,13 @@ public class ChangesOnMyIssueNotificationHandler extends EmailNotificationHandle .setProperty(NotificationDispatcherMetadata.PER_PROJECT_NOTIFICATION, String.valueOf(true)); private final NotificationManager notificationManager; + private final IssuesChangesNotificationSerializer serializer; - public ChangesOnMyIssueNotificationHandler(NotificationManager notificationManager, EmailNotificationChannel emailNotificationChannel) { + public ChangesOnMyIssueNotificationHandler(NotificationManager notificationManager, + EmailNotificationChannel emailNotificationChannel, IssuesChangesNotificationSerializer serializer) { super(emailNotificationChannel); this.notificationManager = notificationManager; + this.serializer = serializer; } @Override @@ -65,52 +70,110 @@ public class ChangesOnMyIssueNotificationHandler extends EmailNotificationHandle } @Override - public Class getNotificationClass() { - return IssueChangeNotification.class; + public Class getNotificationClass() { + return IssuesChangesNotification.class; } @Override - public Set toEmailDeliveryRequests(Collection notifications) { - Multimap notificationsByProjectKey = notifications.stream() - // ignore inconsistent data - .filter(t -> t.getProjectKey() != null) - // ignore notification on which we can't identify who should be notified - .filter(t -> t.getAssignee() != null) - // do not notify users of the changes they made themselves (changeAuthor is null when change comes from an analysis) - .filter(t -> !Objects.equals(t.getAssignee(), t.getChangeAuthor())) - .collect(index(IssueChangeNotification::getProjectKey)); - if (notificationsByProjectKey.isEmpty()) { + public Set toEmailDeliveryRequests(Collection notifications) { + Set notificationsWithPeerChangedIssues = notifications.stream() + .map(serializer::from) + // ignore notification of which the changeAuthor is the assignee of all changed issues + .filter(t -> t.getIssues().stream().anyMatch(issue -> issue.getAssignee().isPresent() && isPeerChanged(t.getChange(), issue))) + .map(NotificationWithProjectKeys::new) + .collect(Collectors.toSet()); + if (notificationsWithPeerChangedIssues.isEmpty()) { return ImmutableSet.of(); } - return notificationsByProjectKey.asMap().entrySet() + Set projectKeys = notificationsWithPeerChangedIssues.stream() + .flatMap(t -> t.getProjectKeys().stream()) + .collect(Collectors.toSet()); + + // shortcut to save from building unnecessary data structures when all changed issues in notifications belong to + // the same project + if (projectKeys.size() == 1) { + Set assigneesOfPeerChangedIssues = notificationsWithPeerChangedIssues.stream() + .flatMap(t -> t.getIssues().stream().filter(issue -> isPeerChanged(t.getChange(), issue))) + .map(ChangedIssue::getAssignee) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toSet()); + Set subscribedAssignees = notificationManager.findSubscribedEmailRecipients( + KEY, + projectKeys.iterator().next(), + assigneesOfPeerChangedIssues.stream().map(User::getLogin).collect(Collectors.toSet()), + ALL_MUST_HAVE_ROLE_USER); + + return subscribedAssignees.stream() + .flatMap(recipient -> notificationsWithPeerChangedIssues.stream() + // do not notify users of the changes they made themselves + .filter(notification -> !notification.getChange().isAuthorLogin(recipient.getLogin())) + .map(notification -> toEmailDeliveryRequest(notification, recipient, projectKeys))) + .filter(Objects::nonNull) + .collect(toSet(notificationsWithPeerChangedIssues.size())); + } + + SetMultimap assigneeLoginsOfPeerChangedIssuesByProjectKey = notificationsWithPeerChangedIssues.stream() + .flatMap(notification -> notification.getIssues().stream() + .filter(issue -> issue.getAssignee().isPresent()) + .filter(issue -> isPeerChanged(notification.getChange(), issue))) + .collect(unorderedIndex(t -> t.getProject().getKey(), t -> t.getAssignee().get().getLogin())); + + SetMultimap authorizedAssigneeLoginsByProjectKey = assigneeLoginsOfPeerChangedIssuesByProjectKey.asMap().entrySet() .stream() - .flatMap(e -> toEmailDeliveryRequests(e.getKey(), e.getValue())) - .collect(toSet(notifications.size())); - } + .collect(unorderedFlattenIndex( + Map.Entry::getKey, + entry -> { + String projectKey = entry.getKey(); + Set assigneeLogins = (Set) entry.getValue(); + return notificationManager.findSubscribedEmailRecipients(KEY, projectKey, assigneeLogins, ALL_MUST_HAVE_ROLE_USER).stream(); + })); - private Stream toEmailDeliveryRequests(String projectKey, Collection notifications) { - Set assignees = notifications.stream() - .map(IssueChangeNotification::getAssignee) - .collect(Collectors.toSet()); - Map recipientsByLogin = notificationManager - .findSubscribedEmailRecipients(KEY, projectKey, assignees, ALL_MUST_HAVE_ROLE_USER) + SetMultimap projectKeyByRecipient = authorizedAssigneeLoginsByProjectKey.entries().stream() + .collect(unorderedIndex(Map.Entry::getValue, Map.Entry::getKey)); + + return projectKeyByRecipient.asMap().entrySet() .stream() - .collect(uniqueIndex(EmailRecipient::getLogin)); - return notifications.stream() - .map(notification -> toEmailDeliveryRequest(recipientsByLogin, notification)) - .filter(Objects::nonNull); + .flatMap(entry -> { + EmailRecipient recipient = entry.getKey(); + Set subscribedProjectKeys = (Set) entry.getValue(); + return notificationsWithPeerChangedIssues.stream() + // do not notify users of the changes they made themselves + .filter(notification -> !notification.getChange().isAuthorLogin(recipient.getLogin())) + .map(notification -> toEmailDeliveryRequest(notification, recipient, subscribedProjectKeys)) + .filter(Objects::nonNull); + }) + .collect(toSet(notificationsWithPeerChangedIssues.size())); } + /** + * Creates the {@link EmailDeliveryRequest} for the specified {@code recipient} with issues from the + * specified {@code notification} it is the assignee of. + * + * @return {@code null} when the recipient is the assignee of no issue in {@code notification}. + */ @CheckForNull - private static EmailNotificationChannel.EmailDeliveryRequest toEmailDeliveryRequest(Map recipientsByLogin, - IssueChangeNotification notification) { - String assignee = notification.getAssignee(); - - EmailRecipient emailRecipient = recipientsByLogin.get(assignee); - if (emailRecipient != null) { - return new EmailNotificationChannel.EmailDeliveryRequest(emailRecipient.getEmail(), notification); + private static EmailDeliveryRequest toEmailDeliveryRequest(NotificationWithProjectKeys notification, EmailRecipient recipient, Set subscribedProjectKeys) { + Set recipientIssuesByProject = notification.getIssues().stream() + .filter(issue -> issue.getAssignee().filter(assignee -> recipient.getLogin().equals(assignee.getLogin())).isPresent()) + .filter(issue -> subscribedProjectKeys.contains(issue.getProject().getKey())) + .collect(toSet(notification.getIssues().size())); + if (recipientIssuesByProject.isEmpty()) { + return null; } - return null; + return new EmailDeliveryRequest( + recipient.getEmail(), + new ChangesOnMyIssuesNotification(notification.getChange(), recipientIssuesByProject)); } + + /** + * Is the author of the change the assignee of the specified issue? + * If not, it means the issue has been changed by a peer of the author of the change. + */ + private static boolean isPeerChanged(Change change, ChangedIssue issue) { + Optional assignee = issue.getAssignee(); + return !assignee.isPresent() || !change.isAuthorLogin(assignee.get().getLogin()); + } + } diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplate.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplate.java new file mode 100644 index 00000000000..27346327def --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplate.java @@ -0,0 +1,146 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import com.google.common.collect.ListMultimap; +import com.google.common.collect.SetMultimap; +import java.util.Collection; +import java.util.List; +import javax.annotation.CheckForNull; +import org.sonar.api.config.EmailSettings; +import org.sonar.api.i18n.I18n; +import org.sonar.api.notifications.Notification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; + +import static com.google.common.base.Preconditions.checkState; +import static org.sonar.api.issue.Issue.STATUS_CLOSED; +import static org.sonar.api.issue.Issue.STATUS_OPEN; +import static org.sonar.core.util.stream.MoreCollectors.index; + +/** + * Creates email message for notification "Changes on my issues". + */ +public class ChangesOnMyIssuesEmailTemplate extends IssueChangesEmailTemplate { + private static final String NOTIFICATION_NAME_I18N_KEY = "notification.dispatcher.ChangesOnMyIssue"; + + public ChangesOnMyIssuesEmailTemplate(I18n i18n, EmailSettings settings) { + super(i18n, settings); + } + + @Override + @CheckForNull + public EmailMessage format(Notification notif) { + if (!(notif instanceof ChangesOnMyIssuesNotification)) { + return null; + } + + ChangesOnMyIssuesNotification notification = (ChangesOnMyIssuesNotification) notif; + + if (notification.getChange() instanceof AnalysisChange) { + checkState(!notification.getChangedIssues().isEmpty(), "changedIssues can't be empty"); + return formatAnalysisNotification(notification.getChangedIssues().keySet().iterator().next(), notification); + } + return formatMultiProject(notification); + } + + private EmailMessage formatAnalysisNotification(Project project, ChangesOnMyIssuesNotification notification) { + return new EmailMessage() + .setMessageId("changes-on-my-issues/" + project.getKey()) + .setSubject(buildAnalysisSubject(project)) + .setHtmlMessage(buildAnalysisMessage(project, notification)); + } + + private static String buildAnalysisSubject(Project project) { + StringBuilder res = new StringBuilder("Analysis has changed some of your issues in "); + toString(res, project); + return res.toString(); + } + + private String buildAnalysisMessage(Project project, ChangesOnMyIssuesNotification notification) { + String projectParams = toUrlParams(project); + + StringBuilder sb = new StringBuilder(); + paragraph(sb, s -> s.append("Hi,")); + paragraph(sb, s -> s.append("An analysis has updated ").append(issuesOrAnIssue(notification.getChangedIssues())) + .append(" assigned to you:")); + + ListMultimap issuesByNewStatus = notification.getChangedIssues().values().stream() + .collect(index(changedIssue -> STATUS_CLOSED.equals(changedIssue.getNewStatus()) ? STATUS_CLOSED : STATUS_OPEN, t -> t)); + + List closedIssues = issuesByNewStatus.get(STATUS_CLOSED); + if (!closedIssues.isEmpty()) { + paragraph(sb, s -> s.append("Closed ").append(issueOrIssues(closedIssues)).append(":")); + addIssuesByRule(sb, closedIssues, projectIssuePageHref(projectParams)); + } + List openIssues = issuesByNewStatus.get(STATUS_OPEN); + if (!openIssues.isEmpty()) { + paragraph(sb, s -> s.append("Open ").append(issueOrIssues(openIssues)).append(":")); + addIssuesByRule(sb, openIssues, projectIssuePageHref(projectParams)); + } + + addFooter(sb, NOTIFICATION_NAME_I18N_KEY); + + return sb.toString(); + } + + private EmailMessage formatMultiProject(ChangesOnMyIssuesNotification notification) { + User user = ((UserChange) notification.getChange()).getUser(); + return new EmailMessage() + .setFrom(user.getName().orElse(user.getLogin())) + .setMessageId("changes-on-my-issues") + .setSubject("A manual update has changed some of your issues") + .setHtmlMessage(buildMultiProjectMessage(notification)); + } + + private String buildMultiProjectMessage(ChangesOnMyIssuesNotification notification) { + StringBuilder sb = new StringBuilder(); + paragraph(sb, s -> s.append("Hi,")); + paragraph(sb, s -> { + SetMultimap changedIssues = notification.getChangedIssues(); + s.append("A manual change has updated ").append(issuesOrAnIssue(changedIssues)) + .append(" assigned to you:"); + }); + + addIssuesByProjectThenRule(sb, notification.getChangedIssues()); + + addFooter(sb, NOTIFICATION_NAME_I18N_KEY); + + return sb.toString(); + } + + private static String issueOrIssues(Collection collection) { + if (collection.size() > 1) { + return "issues"; + } + return "issue"; + } + + private static String issuesOrAnIssue(SetMultimap changedIssues) { + if (changedIssues.size() > 1) { + return "issues"; + } + return "an issue"; + } + +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotification.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotification.java new file mode 100644 index 00000000000..aba506cbce9 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotification.java @@ -0,0 +1,74 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import com.google.common.collect.SetMultimap; +import java.util.Collection; +import java.util.Objects; +import org.sonar.api.notifications.Notification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; + +import static org.sonar.core.util.stream.MoreCollectors.unorderedIndex; + +/** + * This notification is never serialized to DB. + *

+ * It is derived from {@link IssuesChangesNotification} by + * {@link FPOrWontFixNotificationHandler} and extends {@link Notification} only to comply with + * {@link org.sonar.server.issue.notification.EmailTemplate#format(Notification)} API. + */ +class ChangesOnMyIssuesNotification extends Notification { + private final Change change; + private final SetMultimap changedIssues; + + public ChangesOnMyIssuesNotification(Change change, Collection changedIssues) { + super("ChangesOnMyIssues"); + this.change = change; + this.changedIssues = changedIssues.stream().collect(unorderedIndex(ChangedIssue::getProject, t -> t)); + } + + public Change getChange() { + return change; + } + + public SetMultimap getChangedIssues() { + return changedIssues; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ChangesOnMyIssuesNotification that = (ChangesOnMyIssuesNotification) o; + return Objects.equals(change, that.change) && + Objects.equals(changedIssues, that.changedIssues); + } + + @Override + public int hashCode() { + return Objects.hash(change, changedIssues); + } +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/DoNotFixNotificationHandler.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/DoNotFixNotificationHandler.java deleted file mode 100644 index fc86ef73da2..00000000000 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/DoNotFixNotificationHandler.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 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.issue.notification; - -import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Multimap; -import java.util.Collection; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Stream; -import org.sonar.api.issue.Issue; -import org.sonar.server.notification.EmailNotificationHandler; -import org.sonar.server.notification.NotificationDispatcherMetadata; -import org.sonar.server.notification.NotificationManager; -import org.sonar.server.notification.NotificationManager.EmailRecipient; -import org.sonar.server.notification.email.EmailNotificationChannel; -import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest; - -import static java.util.Collections.emptySet; -import static java.util.Optional.of; -import static org.sonar.core.util.stream.MoreCollectors.index; -import static org.sonar.core.util.stream.MoreCollectors.toSet; -import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER; - -public class DoNotFixNotificationHandler extends EmailNotificationHandler { - - public static final String KEY = "NewFalsePositiveIssue"; - private static final NotificationDispatcherMetadata METADATA = NotificationDispatcherMetadata.create(KEY) - .setProperty(NotificationDispatcherMetadata.GLOBAL_NOTIFICATION, String.valueOf(false)) - .setProperty(NotificationDispatcherMetadata.PER_PROJECT_NOTIFICATION, String.valueOf(true)); - - private static final Set SUPPORTED_NEW_RESOLUTIONS = ImmutableSet.of(Issue.RESOLUTION_FALSE_POSITIVE, Issue.RESOLUTION_WONT_FIX); - - private final NotificationManager notificationManager; - - public DoNotFixNotificationHandler(NotificationManager notificationManager, EmailNotificationChannel emailNotificationChannel) { - super(emailNotificationChannel); - this.notificationManager = notificationManager; - } - - @Override - public Optional getMetadata() { - return of(METADATA); - } - - public static NotificationDispatcherMetadata newMetadata() { - return METADATA; - } - - @Override - public Class getNotificationClass() { - return IssueChangeNotification.class; - } - - @Override - public Set toEmailDeliveryRequests(Collection notifications) { - Multimap notificationsByProjectKey = notifications.stream() - // ignore inconsistent data - .filter(t -> t.getProjectKey() != null) - // ignore notification on which we can't identify who should not be notified - // (and anyway, it should not be null as an analysis can not resolve an issue as FP or Won't fix) - .filter(t -> t.getChangeAuthor() != null) - // ignore changes which did not lead to a FP or Won't Fix resolution - .filter(t -> SUPPORTED_NEW_RESOLUTIONS.contains(t.getNewResolution())) - .collect(index(IssueChangeNotification::getProjectKey)); - if (notificationsByProjectKey.isEmpty()) { - return emptySet(); - } - - return notificationsByProjectKey.asMap().entrySet() - .stream() - .flatMap(e -> toEmailDeliveryRequests(e.getKey(), e.getValue())) - .collect(toSet(notifications.size())); - } - - private Stream toEmailDeliveryRequests(String projectKey, Collection notifications) { - Set recipients = notificationManager - .findSubscribedEmailRecipients(KEY, projectKey, ALL_MUST_HAVE_ROLE_USER); - return notifications.stream() - .flatMap(notification -> recipients.stream() - // do not notify author of the change - .filter(t -> !Objects.equals(t.getLogin(), notification.getChangeAuthor())) - .map(t -> new EmailDeliveryRequest(t.getEmail(), notification))); - } - -} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailMessage.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailMessage.java index 2bbc4f5b3b7..e2cc3304060 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailMessage.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailMessage.java @@ -23,11 +23,12 @@ import org.apache.commons.lang.builder.ToStringBuilder; public class EmailMessage { - private String from; - private String to; - private String subject; - private String message; - private String messageId; + private String from = null; + private String to = null; + private String subject = null; + private String message = null; + private boolean html = false; + private String messageId = null; /** * @param from full name of user, who initiated this message or null, if message was initiated by Sonar @@ -77,13 +78,25 @@ public class EmailMessage { /** * @param message message body */ - public EmailMessage setMessage(String message) { + public EmailMessage setPlainTextMessage(String message) { this.message = message; + this.html = false; return this; } /** - * @see #setMessage(String) + * @param message HTML message body + */ + public EmailMessage setHtmlMessage(String message) { + this.message = message; + this.html = true; + return this; + } + + /** + * Either plain text or HTML. + * @see #setPlainTextMessage(String) (String) + * @see #setHtmlMessage(String) (String) (String) */ public String getMessage() { return message; @@ -104,6 +117,10 @@ public class EmailMessage { return messageId; } + public boolean isHtml() { + return html; + } + @Override public String toString() { return ToStringBuilder.reflectionToString(this); diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailTemplate.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailTemplate.java index eaa7af95bdb..79dd3036d4b 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailTemplate.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailTemplate.java @@ -19,6 +19,7 @@ */ package org.sonar.server.issue.notification; +import javax.annotation.CheckForNull; import org.sonar.api.ExtensionPoint; import org.sonar.api.server.ServerSide; import org.sonar.api.notifications.Notification; @@ -27,6 +28,7 @@ import org.sonar.api.notifications.Notification; @ExtensionPoint public interface EmailTemplate { + @CheckForNull EmailMessage format(Notification notification); } diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotification.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotification.java new file mode 100644 index 00000000000..d15b18ba8d6 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotification.java @@ -0,0 +1,96 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import com.google.common.collect.SetMultimap; +import java.util.Collection; +import java.util.Objects; +import org.sonar.api.notifications.Notification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; + +import static org.sonar.core.util.stream.MoreCollectors.unorderedIndex; + +/** + * This notification is never serialized to DB. + *

+ * It is derived from {@link IssuesChangesNotification} by + * {@link FPOrWontFixNotificationHandler} and extends {@link Notification} only to comply with + * {@link org.sonar.server.issue.notification.EmailTemplate#format(Notification)} API. + */ +class FPOrWontFixNotification extends Notification { + private static final String KEY = "FPorWontFix"; + + public enum FpOrWontFix { + FP, WONT_FIX + } + + private final Change change; + private final SetMultimap changedIssues; + private final FpOrWontFix resolution; + + public FPOrWontFixNotification(Change change, Collection changedIssues, FpOrWontFix resolution) { + super(KEY); + this.changedIssues = changedIssues.stream().collect(unorderedIndex(ChangedIssue::getProject, t -> t)); + this.change = change; + this.resolution = resolution; + } + + public Change getChange() { + return change; + } + + public SetMultimap getChangedIssues() { + return changedIssues; + } + + public FpOrWontFix getResolution() { + return resolution; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FPOrWontFixNotification that = (FPOrWontFixNotification) o; + return Objects.equals(changedIssues, that.changedIssues) && + Objects.equals(change, that.change) && + resolution == that.resolution; + } + + @Override + public int hashCode() { + return Objects.hash(changedIssues, change, resolution); + } + + @Override + public String toString() { + return "FPOrWontFixNotification{" + + "changedIssues=" + changedIssues + + ", change=" + change + + ", resolution=" + resolution + + '}'; + } +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandler.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandler.java new file mode 100644 index 00000000000..7b56c140d58 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandler.java @@ -0,0 +1,172 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Multimap; +import com.google.common.collect.SetMultimap; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.sonar.api.issue.Issue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.notification.EmailNotificationHandler; +import org.sonar.server.notification.NotificationDispatcherMetadata; +import org.sonar.server.notification.NotificationManager; +import org.sonar.server.notification.NotificationManager.EmailRecipient; +import org.sonar.server.notification.email.EmailNotificationChannel; +import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest; + +import static com.google.common.collect.Sets.intersection; +import static java.util.Collections.emptySet; +import static java.util.Optional.of; +import static java.util.Optional.ofNullable; +import static org.sonar.core.util.stream.MoreCollectors.toSet; +import static org.sonar.core.util.stream.MoreCollectors.unorderedFlattenIndex; +import static org.sonar.core.util.stream.MoreCollectors.unorderedIndex; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.FP; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.WONT_FIX; +import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER; + +public class FPOrWontFixNotificationHandler extends EmailNotificationHandler { + + public static final String KEY = "NewFalsePositiveIssue"; + private static final NotificationDispatcherMetadata METADATA = NotificationDispatcherMetadata.create(KEY) + .setProperty(NotificationDispatcherMetadata.GLOBAL_NOTIFICATION, String.valueOf(false)) + .setProperty(NotificationDispatcherMetadata.PER_PROJECT_NOTIFICATION, String.valueOf(true)); + + private static final Set FP_OR_WONTFIX_RESOLUTIONS = ImmutableSet.of(Issue.RESOLUTION_FALSE_POSITIVE, Issue.RESOLUTION_WONT_FIX); + + private final NotificationManager notificationManager; + private final IssuesChangesNotificationSerializer serializer; + + public FPOrWontFixNotificationHandler(NotificationManager notificationManager, + EmailNotificationChannel emailNotificationChannel, IssuesChangesNotificationSerializer serializer) { + super(emailNotificationChannel); + this.notificationManager = notificationManager; + this.serializer = serializer; + } + + @Override + public Optional getMetadata() { + return of(METADATA); + } + + public static NotificationDispatcherMetadata newMetadata() { + return METADATA; + } + + @Override + public Class getNotificationClass() { + return IssuesChangesNotification.class; + } + + @Override + public Set toEmailDeliveryRequests(Collection notifications) { + Set changeNotificationsWithFpOrWontFix = notifications.stream() + .map(serializer::from) + // ignore notifications which contain no issue changed to a FP or Won't Fix resolution + .filter(t -> t.getIssues().stream() + .filter(issue -> issue.getNewResolution().isPresent()) + .anyMatch(issue -> FP_OR_WONTFIX_RESOLUTIONS.contains(issue.getNewResolution().get()))) + .map(NotificationWithProjectKeys::new) + .collect(Collectors.toSet()); + if (changeNotificationsWithFpOrWontFix.isEmpty()) { + return emptySet(); + } + Set projectKeys = changeNotificationsWithFpOrWontFix.stream() + .flatMap(t -> t.getProjectKeys().stream()) + .collect(Collectors.toSet()); + + // shortcut to save from building unnecessary data structures when all changed issues in notifications belong to + // the same project + if (projectKeys.size() == 1) { + Set recipients = notificationManager.findSubscribedEmailRecipients(KEY, projectKeys.iterator().next(), ALL_MUST_HAVE_ROLE_USER); + return changeNotificationsWithFpOrWontFix.stream() + .flatMap(notification -> toRequests(notification, projectKeys, recipients)) + .collect(toSet(changeNotificationsWithFpOrWontFix.size())); + } + + Set recipientsByProjectKey = projectKeys.stream() + .flatMap(projectKey -> notificationManager.findSubscribedEmailRecipients(KEY, projectKey, ALL_MUST_HAVE_ROLE_USER).stream() + .map(emailRecipient -> new EmailRecipientAndProject(emailRecipient, projectKey))) + .collect(Collectors.toSet()); + + // builds sets of projectKeys for which a given recipient has subscribed to + SetMultimap projectKeysByRecipient = recipientsByProjectKey.stream() + .collect(unorderedIndex(t -> t.recipient, t -> t.projectKey)); + // builds sets of recipients who subscribed to the same subset of projects + Multimap, EmailRecipient> recipientsBySubscribedProjects = projectKeysByRecipient.asMap() + .entrySet().stream() + .collect(unorderedIndex(t -> (Set) t.getValue(), Map.Entry::getKey)); + + return changeNotificationsWithFpOrWontFix.stream() + .flatMap(notification -> { + // builds sets of recipients for each sub group of the notification's projectKeys necessary + SetMultimap, EmailRecipient> recipientsByProjectKeys = recipientsBySubscribedProjects.asMap().entrySet() + .stream() + .collect(unorderedFlattenIndex(t -> intersection(t.getKey(), notification.getProjectKeys()).immutableCopy(), t -> t.getValue().stream())); + return recipientsByProjectKeys.asMap().entrySet().stream() + .flatMap(entry -> toRequests(notification, entry.getKey(), entry.getValue())); + }) + .collect(toSet(changeNotificationsWithFpOrWontFix.size())); + } + + private static Stream toRequests(NotificationWithProjectKeys notification, Set projectKeys, Collection recipients) { + return recipients.stream() + // do not notify author of the change + .filter(recipient -> !notification.getChange().isAuthorLogin(recipient.getLogin())) + .flatMap(recipient -> { + SetMultimap issuesByNewResolution = notification.getIssues().stream() + // ignore issues not changed to a FP or Won't Fix resolution + .filter(issue -> issue.getNewResolution().filter(FP_OR_WONTFIX_RESOLUTIONS::contains).isPresent()) + // ignore issues belonging to projects the recipients have not subscribed to + .filter(issue -> projectKeys.contains(issue.getProject().getKey())) + .collect(unorderedIndex(t -> t.getNewResolution().get(), issue -> issue)); + + return Stream.of( + ofNullable(issuesByNewResolution.get(Issue.RESOLUTION_FALSE_POSITIVE)) + .filter(t -> !t.isEmpty()) + .map(fpIssues -> new FPOrWontFixNotification(notification.getChange(), fpIssues, FP)) + .orElse(null), + ofNullable(issuesByNewResolution.get(Issue.RESOLUTION_WONT_FIX)) + .filter(t -> !t.isEmpty()) + .map(wontFixIssues -> new FPOrWontFixNotification(notification.getChange(), wontFixIssues, WONT_FIX)) + .orElse(null)) + .filter(Objects::nonNull) + .map(fpOrWontFixNotification -> new EmailDeliveryRequest(recipient.getEmail(), fpOrWontFixNotification)); + }); + } + + private static final class EmailRecipientAndProject { + private final EmailRecipient recipient; + private final String projectKey; + + private EmailRecipientAndProject(EmailRecipient recipient, String projectKey) { + this.recipient = recipient; + this.projectKey = projectKey; + } + } + +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplate.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplate.java new file mode 100644 index 00000000000..1c368981df9 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplate.java @@ -0,0 +1,101 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import javax.annotation.CheckForNull; +import org.sonar.api.config.EmailSettings; +import org.sonar.api.i18n.I18n; +import org.sonar.api.notifications.Notification; +import org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; + +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.FP; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.WONT_FIX; + +/** + * Creates email message for notification "issue-changes". + */ +public class FpOrWontFixEmailTemplate extends IssueChangesEmailTemplate { + + private static final String NOTIFICATION_NAME_I18N_KEY = "notification.dispatcher.NewFalsePositiveIssue"; + + public FpOrWontFixEmailTemplate(I18n i18n, EmailSettings settings) { + super(i18n, settings); + } + + @Override + @CheckForNull + public EmailMessage format(Notification notif) { + if (!(notif instanceof FPOrWontFixNotification)) { + return null; + } + + FPOrWontFixNotification notification = (FPOrWontFixNotification) notif; + + EmailMessage emailMessage = new EmailMessage() + .setMessageId(getMessageId(notification.getResolution())) + .setSubject(buildSubject(notification)) + .setHtmlMessage(buildMessage(notification)); + if (notification.getChange() instanceof UserChange) { + User user = ((UserChange) notification.getChange()).getUser(); + emailMessage.setFrom(user.getName().orElse(user.getLogin())); + } + return emailMessage; + } + + private static String getMessageId(FpOrWontFix resolution) { + if (resolution == WONT_FIX) { + return "wontfix-issue-changes"; + } + if (resolution == FP) { + return "fp-issue-changes"; + } + throw new IllegalArgumentException("Unsupported resolution " + resolution); + } + + private static String buildSubject(FPOrWontFixNotification notification) { + return "Issues marked as " + resolutionLabel(notification.getResolution()); + } + + private String buildMessage(FPOrWontFixNotification notification) { + StringBuilder sb = new StringBuilder(); + paragraph(sb, s -> s.append("Hi,")); + paragraph(sb, s -> s.append("A manual change has resolved ").append(notification.getChangedIssues().size() > 1 ? "issues" : "an issue") + .append(" as ").append(resolutionLabel(notification.getResolution())).append(":")); + + addIssuesByProjectThenRule(sb, notification.getChangedIssues()); + + addFooter(sb, NOTIFICATION_NAME_I18N_KEY); + + return sb.toString(); + } + + private static String resolutionLabel(FpOrWontFix resolution) { + if (resolution == WONT_FIX) { + return "Won't Fix"; + } + if (resolution == FP) { + return "False Positive"; + } + throw new IllegalArgumentException("Unsupported resolution " + resolution); + } + +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangeNotification.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangeNotification.java deleted file mode 100644 index e7c1163b3ac..00000000000 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangeNotification.java +++ /dev/null @@ -1,142 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 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.issue.notification; - -import com.google.common.base.Strings; -import java.io.Serializable; -import java.util.Map; -import javax.annotation.CheckForNull; -import javax.annotation.Nullable; -import org.sonar.api.notifications.Notification; -import org.sonar.core.issue.DefaultIssue; -import org.sonar.core.issue.FieldDiffs; -import org.sonar.db.component.ComponentDto; -import org.sonar.db.user.UserDto; - -import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_BRANCH; -import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_PROJECT_KEY; -import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_PROJECT_NAME; -import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_PULL_REQUEST; - -public class IssueChangeNotification extends Notification { - - public static final String TYPE = "issue-changes"; - private static final String FIELD_CHANGE_AUTHOR = "changeAuthor"; - private static final String FIELD_ASSIGNEE = "assignee"; - - public IssueChangeNotification() { - super(TYPE); - } - - public IssueChangeNotification setIssue(DefaultIssue issue) { - setFieldValue("key", issue.key()); - setFieldValue("message", issue.message()); - FieldDiffs currentChange = issue.currentChange(); - if (currentChange != null) { - for (Map.Entry entry : currentChange.diffs().entrySet()) { - String type = entry.getKey(); - FieldDiffs.Diff diff = entry.getValue(); - setFieldValue("old." + type, neverEmptySerializableToString(diff.oldValue())); - setFieldValue("new." + type, neverEmptySerializableToString(diff.newValue())); - } - } - return this; - } - - @CheckForNull - public String getNewResolution() { - return getFieldValue("new.resolution"); - } - - public IssueChangeNotification setProject(ComponentDto project) { - return setProject(project.getKey(), project.name(), project.getBranch(), project.getPullRequest()); - } - - public IssueChangeNotification setProject(String projectKey, String projectName, @Nullable String branch, @Nullable String pullRequest) { - setFieldValue(FIELD_PROJECT_NAME, projectName); - setFieldValue(FIELD_PROJECT_KEY, projectKey); - if (branch != null) { - setFieldValue(FIELD_BRANCH, branch); - } - if (pullRequest != null) { - setFieldValue(FIELD_PULL_REQUEST, pullRequest); - } - return this; - } - - @CheckForNull - public String getProjectKey() { - return getFieldValue(FIELD_PROJECT_KEY); - } - - public IssueChangeNotification setComponent(ComponentDto component) { - return setComponent(component.getKey(), component.longName()); - } - - public IssueChangeNotification setComponent(String componentKey, String componentName) { - setFieldValue("componentName", componentName); - setFieldValue("componentKey", componentKey); - return this; - } - - public IssueChangeNotification setChangeAuthor(@Nullable UserDto author) { - if (author == null) { - return this; - } - setFieldValue(FIELD_CHANGE_AUTHOR, author.getLogin()); - return this; - } - - @CheckForNull - public String getChangeAuthor() { - return getFieldValue(FIELD_CHANGE_AUTHOR); - } - - public IssueChangeNotification setRuleName(@Nullable String s) { - if (s != null) { - setFieldValue("ruleName", s); - } - return this; - } - - public IssueChangeNotification setComment(@Nullable String s) { - if (s != null) { - setFieldValue("comment", s); - } - return this; - } - - @CheckForNull - private static String neverEmptySerializableToString(@Nullable Serializable s) { - return s != null ? Strings.emptyToNull(s.toString()) : null; - } - - public IssueChangeNotification setAssignee(@Nullable UserDto assignee) { - if (assignee != null) { - setFieldValue(FIELD_ASSIGNEE, assignee.getLogin()); - } - return this; - } - - @CheckForNull - public String getAssignee() { - return getFieldValue(FIELD_ASSIGNEE); - } -} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangesEmailTemplate.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangesEmailTemplate.java index 9e8a7f6a06c..3266d2cf051 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangesEmailTemplate.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangesEmailTemplate.java @@ -19,162 +19,190 @@ */ package org.sonar.server.issue.notification; -import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSortedSet; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Lists; +import com.google.common.collect.SetMultimap; import java.io.UnsupportedEncodingException; -import javax.annotation.CheckForNull; -import javax.annotation.Nullable; -import org.apache.commons.lang.StringUtils; +import java.util.Collection; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.SortedSet; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import org.sonar.api.config.EmailSettings; -import org.sonar.api.notifications.Notification; -import org.sonar.db.DbClient; -import org.sonar.db.DbSession; -import org.sonar.db.user.UserDto; +import org.sonar.api.i18n.I18n; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; import static java.net.URLEncoder.encode; -import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_BRANCH; -import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_PULL_REQUEST; - -/** - * Creates email message for notification "issue-changes". - */ -public class IssueChangesEmailTemplate implements EmailTemplate { - - private static final char NEW_LINE = '\n'; - private final DbClient dbClient; +import static org.sonar.core.util.stream.MoreCollectors.index; + +public abstract class IssueChangesEmailTemplate implements EmailTemplate { + + private static final Comparator RULE_COMPARATOR = Comparator.comparing(r -> r.getKey().toString()); + private static final Comparator PROJECT_COMPARATOR = Comparator.comparing(Project::getProjectName) + .thenComparing(t -> t.getBranchName().orElse("")); + private static final Comparator CHANGED_ISSUE_KEY_COMPARATOR = Comparator.comparing(ChangedIssue::getKey, Comparator.naturalOrder()); + /** + * Assuming: + *

    + *
  • UUID length of 40 chars
  • + *
  • a max URL length of 2083 chars
  • + *
+ * This leaves ~850 chars for the rest of the URL (including other parameters such as the project key and the branch), + * which is reasonable to stay safe from the max URL length supported by some browsers and network devices. + */ + private static final int MAX_ISSUES_BY_LINK = 40; + private static final String URL_ENCODED_COMMA = urlEncode(","); + + private final I18n i18n; private final EmailSettings settings; - public IssueChangesEmailTemplate(DbClient dbClient, EmailSettings settings) { - this.dbClient = dbClient; + protected IssueChangesEmailTemplate(I18n i18n, EmailSettings settings) { + this.i18n = i18n; this.settings = settings; } - @Override - public EmailMessage format(Notification notif) { - if (!IssueChangeNotification.TYPE.equals(notif.getType())) { - return null; - } - - StringBuilder sb = new StringBuilder(); - appendHeader(notif, sb); - sb.append(NEW_LINE); - appendChanges(notif, sb); - sb.append(NEW_LINE); - appendFooter(sb, notif); - - String projectName = notif.getFieldValue("projectName"); - String issueKey = notif.getFieldValue("key"); - String author = notif.getFieldValue("changeAuthor"); - - EmailMessage message = new EmailMessage() - .setMessageId("issue-changes/" + issueKey) - .setSubject(projectName + ", change on issue #" + issueKey) - .setMessage(sb.toString()); - if (author != null) { - message.setFrom(getUserFullName(author)); + /** + * Adds "projectName" or "projectName, branchName" if branchName is non null + */ + protected static void toString(StringBuilder sb, Project project) { + Optional branchName = project.getBranchName(); + if (branchName.isPresent()) { + sb.append(project.getProjectName()).append(", ").append(branchName.get()); + } else { + sb.append(project.getProjectName()); } - return message; } - private static void appendChanges(Notification notif, StringBuilder sb) { - appendField(sb, "Comment", null, notif.getFieldValue("comment")); - appendFieldWithoutHistory(sb, "Assignee", notif.getFieldValue("old.assignee"), notif.getFieldValue("new.assignee")); - appendField(sb, "Severity", notif.getFieldValue("old.severity"), notif.getFieldValue("new.severity")); - appendField(sb, "Type", notif.getFieldValue("old.type"), notif.getFieldValue("new.type")); - appendField(sb, "Resolution", notif.getFieldValue("old.resolution"), notif.getFieldValue("new.resolution")); - appendField(sb, "Status", notif.getFieldValue("old.status"), notif.getFieldValue("new.status")); - appendField(sb, "Message", notif.getFieldValue("old.message"), notif.getFieldValue("new.message")); - appendField(sb, "Author", notif.getFieldValue("old.author"), notif.getFieldValue("new.author")); - appendFieldWithoutHistory(sb, "Action Plan", notif.getFieldValue("old.actionPlan"), notif.getFieldValue("new.actionPlan")); - appendField(sb, "Tags", formatTagChange(notif.getFieldValue("old.tags")), formatTagChange(notif.getFieldValue("new.tags"))); + static String toUrlParams(Project project) { + return "id=" + urlEncode(project.getKey()) + + project.getBranchName().map(branchName -> "&branch=" + urlEncode(branchName)).orElse(""); } - @CheckForNull - private static String formatTagChange(@Nullable String tags) { - if (tags == null) { - return null; - } else { - return "[" + tags + "]"; - } + void addIssuesByProjectThenRule(StringBuilder sb, SetMultimap issuesByProject) { + issuesByProject.keySet().stream() + .sorted(PROJECT_COMPARATOR) + .forEach(project -> { + String encodedProjectParams = toUrlParams(project); + paragraph(sb, s -> toString(s, project)); + addIssuesByRule(sb, issuesByProject.get(project), projectIssuePageHref(encodedProjectParams)); + }); } - private static void appendHeader(Notification notif, StringBuilder sb) { - appendLine(sb, StringUtils.defaultString(notif.getFieldValue("componentName"), notif.getFieldValue("componentKey"))); - String branchName = notif.getFieldValue(FIELD_BRANCH); - if (branchName != null) { - appendField(sb, "Branch", null, branchName); - } - String pullRequest = notif.getFieldValue(FIELD_PULL_REQUEST); - if (pullRequest != null) { - appendField(sb, "Pull request", null, pullRequest); + void addIssuesByRule(StringBuilder sb, Collection changedIssues, BiConsumer> issuePageHref) { + ListMultimap issuesByRule = changedIssues.stream() + .collect(index(ChangedIssue::getRule, t -> t)); + + Iterator rules = issuesByRule.keySet().stream() + .sorted(RULE_COMPARATOR) + .iterator(); + if (!rules.hasNext()) { + return; } - appendField(sb, "Rule", null, notif.getFieldValue("ruleName")); - appendField(sb, "Message", null, notif.getFieldValue("message")); - } - private void appendFooter(StringBuilder sb, Notification notification) { - String issueKey = notification.getFieldValue("key"); - try { - sb.append("More details at: ").append(settings.getServerBaseURL()) - .append("/project/issues?id=").append(encode(notification.getFieldValue("projectKey"), "UTF-8")) - .append("&issues=").append(issueKey) - .append("&open=").append(issueKey); - String branchName = notification.getFieldValue(FIELD_BRANCH); - if (branchName != null) { - sb.append("&branch=").append(branchName); - } - String pullRequest = notification.getFieldValue(FIELD_PULL_REQUEST); - if (pullRequest != null) { - sb.append("&pullRequest=").append(pullRequest); - } - sb.append(NEW_LINE); - } catch (UnsupportedEncodingException e) { - throw new IllegalStateException("Encoding not supported", e); + sb.append("
    "); + while (rules.hasNext()) { + Rule rule = rules.next(); + Collection issues = issuesByRule.get(rule); + + sb.append("
  • ").append("Rule ").append(" ").append(rule.getName()).append(" - "); + appendIssueLinks(sb, issuePageHref, issues); + sb.append("
  • "); } + sb.append("
"); } - private static void appendLine(StringBuilder sb, @Nullable String line) { - if (!Strings.isNullOrEmpty(line)) { - sb.append(line).append(NEW_LINE); + private static void appendIssueLinks(StringBuilder sb, BiConsumer> issuePageHref, Collection issues) { + SortedSet sortedIssues = ImmutableSortedSet.copyOf(CHANGED_ISSUE_KEY_COMPARATOR, issues); + int issueCount = issues.size(); + if (issueCount == 1) { + link(sb, s -> issuePageHref.accept(s, sortedIssues), s -> s.append("See the single issue")); + } else if (issueCount <= MAX_ISSUES_BY_LINK) { + link(sb, s -> issuePageHref.accept(s, sortedIssues), s -> s.append("See all ").append(issueCount).append(" issues")); + } else { + sb.append("See issues"); + List> issueGroups = Lists.partition(ImmutableList.copyOf(sortedIssues), MAX_ISSUES_BY_LINK); + Iterator> issueGroupsIterator = issueGroups.iterator(); + int[] groupIndex = new int[] {0}; + while (issueGroupsIterator.hasNext()) { + List issueGroup = issueGroupsIterator.next(); + sb.append(' '); + link(sb, s -> issuePageHref.accept(s, issueGroup), issueGroupLabel(sb, groupIndex, issueGroup)); + groupIndex[0]++; + } } } - private static void appendField(StringBuilder sb, String name, @Nullable String oldValue, @Nullable String newValue) { - if (oldValue != null || newValue != null) { - sb.append(name).append(": "); - if (newValue != null) { - sb.append(newValue); + BiConsumer> projectIssuePageHref(String projectParams) { + return (s, issues) -> { + s.append(settings.getServerBaseURL()).append("/project/issues?").append(projectParams) + .append("&issues="); + + Iterator issueIterator = issues.iterator(); + while (issueIterator.hasNext()) { + s.append(urlEncode(issueIterator.next().getKey())); + if (issueIterator.hasNext()) { + s.append(URL_ENCODED_COMMA); + } } - if (oldValue != null) { - sb.append(" (was ").append(oldValue).append(")"); + + if (issues.size() == 1) { + s.append("&open=").append(urlEncode(issues.iterator().next().getKey())); } - sb.append(NEW_LINE); - } + }; } - private static void appendFieldWithoutHistory(StringBuilder sb, String name, @Nullable String oldValue, @Nullable String newValue) { - if (oldValue != null || newValue != null) { - sb.append(name); - if (newValue != null) { - sb.append(" changed to "); - sb.append(newValue); + private static Consumer issueGroupLabel(StringBuilder sb, int[] groupIndex, List issueGroup) { + return s -> { + int firstIssueNumber = (groupIndex[0] * MAX_ISSUES_BY_LINK) + 1; + if (issueGroup.size() == 1) { + sb.append(firstIssueNumber); } else { - sb.append(" removed"); + sb.append(firstIssueNumber).append("-").append(firstIssueNumber + issueGroup.size() - 1); } - sb.append(NEW_LINE); - } + }; } - private String getUserFullName(@Nullable String login) { - if (login == null) { - return null; - } - try (DbSession dbSession = dbClient.openSession(false)) { - UserDto userDto = dbClient.userDao().selectByLogin(dbSession, login); - if (userDto == null || !userDto.isActive()) { - // most probably user was deleted - return login; - } - return StringUtils.defaultIfBlank(userDto.getName(), login); + void addFooter(StringBuilder sb, String notificationI18nKey) { + paragraph(sb, s -> s.append(" ")); + paragraph(sb, s -> { + s.append(""); + s.append("You received this email because you are subscribed to ") + .append('"').append(i18n.message(Locale.ENGLISH, notificationI18nKey, notificationI18nKey)).append('"') + .append(" notifications from ").append(settings.getInstanceName()).append("."); + s.append(" Click "); + link(s, s1 -> s1.append(settings.getServerBaseURL()).append("/account/notifications"), s1 -> s1.append("here")); + s.append(" to edit your email preferences."); + s.append(""); + }); + } + + protected static void paragraph(StringBuilder sb, Consumer content) { + sb.append("

"); + content.accept(sb); + sb.append("

"); + } + + protected static void link(StringBuilder sb, Consumer link, Consumer content) { + sb.append(""); + content.accept(sb); + sb.append(""); + } + + private static String urlEncode(String str) { + try { + return encode(str, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); } } diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotification.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotification.java new file mode 100644 index 00000000000..2a7060d58a1 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotification.java @@ -0,0 +1,32 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import org.sonar.api.notifications.Notification; + +public class IssuesChangesNotification extends Notification { + + public static final String TYPE = "issues-changes"; + + public IssuesChangesNotification() { + super(TYPE); + } + +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilder.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilder.java new file mode 100644 index 00000000000..a0fadc8fd92 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilder.java @@ -0,0 +1,461 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import com.google.common.collect.ImmutableSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; +import org.sonar.api.rule.RuleKey; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; +import static java.util.Optional.ofNullable; + +@Immutable +public class IssuesChangesNotificationBuilder { + + private static final String KEY_CANT_BE_NULL_MESSAGE = "key can't be null"; + private final Set issues; + private final Change change; + + public IssuesChangesNotificationBuilder(Set issues, Change change) { + checkArgument(!issues.isEmpty(), "issues can't be empty"); + + this.issues = ImmutableSet.copyOf(issues); + this.change = requireNonNull(change, "change can't be null"); + } + + public Set getIssues() { + return issues; + } + + public Change getChange() { + return change; + } + + @Immutable + public static final class ChangedIssue { + private final String key; + private final String newStatus; + @CheckForNull + private final String newResolution; + @CheckForNull + private final User assignee; + private final Rule rule; + private final Project project; + + public ChangedIssue(Builder builder) { + this.key = requireNonNull(builder.key, KEY_CANT_BE_NULL_MESSAGE); + this.newStatus = requireNonNull(builder.newStatus, "newStatus can't be null"); + this.newResolution = builder.newResolution; + this.assignee = builder.assignee; + this.rule = requireNonNull(builder.rule, "rule can't be null"); + this.project = requireNonNull(builder.project, "project can't be null"); + } + + public String getKey() { + return key; + } + + public String getNewStatus() { + return newStatus; + } + + public Optional getNewResolution() { + return ofNullable(newResolution); + } + + public Optional getAssignee() { + return ofNullable(assignee); + } + + public Rule getRule() { + return rule; + } + + public Project getProject() { + return project; + } + + public static class Builder { + private final String key; + private String newStatus; + @CheckForNull + private String newResolution; + @CheckForNull + private User assignee; + private Rule rule; + private Project project; + + public Builder(String key) { + this.key = key; + } + + public Builder setNewStatus(String newStatus) { + this.newStatus = newStatus; + return this; + } + + public Builder setNewResolution(@Nullable String newResolution) { + this.newResolution = newResolution; + return this; + } + + public Builder setAssignee(@Nullable User assignee) { + this.assignee = assignee; + return this; + } + + public Builder setRule(Rule rule) { + this.rule = rule; + return this; + } + + public Builder setProject(Project project) { + this.project = project; + return this; + } + + public ChangedIssue build() { + return new ChangedIssue(this); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ChangedIssue that = (ChangedIssue) o; + return key.equals(that.key) && + newStatus.equals(that.newStatus) && + Objects.equals(newResolution, that.newResolution) && + Objects.equals(assignee, that.assignee) && + rule.equals(that.rule) && + project.equals(that.project); + } + + @Override + public int hashCode() { + return Objects.hash(key, newStatus, newResolution, assignee, rule, project); + } + + @Override + public String toString() { + return "ChangedIssue{" + + "key='" + key + '\'' + + ", newStatus='" + newStatus + '\'' + + ", newResolution='" + newResolution + '\'' + + ", assignee=" + assignee + + ", rule=" + rule + + ", project=" + project + + '}'; + } + } + + public static final class User { + private final String uuid; + private final String login; + @CheckForNull + private final String name; + + public User(String uuid, String login, @Nullable String name) { + this.uuid = requireNonNull(uuid, "uuid can't be null"); + this.login = requireNonNull(login, "login can't be null"); + this.name = name; + } + + public String getUuid() { + return uuid; + } + + public String getLogin() { + return login; + } + + public Optional getName() { + return ofNullable(name); + } + + @Override + public String toString() { + return "User{" + + "uuid='" + uuid + '\'' + + ", login='" + login + '\'' + + ", name='" + name + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + User user = (User) o; + return uuid.equals(user.uuid) && + login.equals(user.login) && + Objects.equals(name, user.name); + } + + @Override + public int hashCode() { + return Objects.hash(uuid, login, name); + } + } + + @Immutable + public static final class Rule { + private final RuleKey key; + private final String name; + + public Rule(RuleKey key, String name) { + this.key = requireNonNull(key, KEY_CANT_BE_NULL_MESSAGE); + this.name = requireNonNull(name, "name can't be null"); + } + + public RuleKey getKey() { + return key; + } + + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Rule that = (Rule) o; + return key.equals(that.key) && name.equals(that.name); + } + + @Override + public int hashCode() { + return Objects.hash(key, name); + } + + @Override + public String toString() { + return "Rule{" + + "key=" + key + + ", name='" + name + '\'' + + '}'; + } + } + + @Immutable + public static final class Project { + private final String uuid; + private final String key; + private final String projectName; + @Nullable + private final String branchName; + + public Project(Builder builder) { + this.uuid = requireNonNull(builder.uuid, "uuid can't be null"); + this.key = requireNonNull(builder.key, KEY_CANT_BE_NULL_MESSAGE); + this.projectName = requireNonNull(builder.projectName, "projectName can't be null"); + this.branchName = builder.branchName; + } + + public String getUuid() { + return uuid; + } + + public String getKey() { + return key; + } + + public String getProjectName() { + return projectName; + } + + public Optional getBranchName() { + return ofNullable(branchName); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Project project = (Project) o; + return uuid.equals(project.uuid) && + key.equals(project.key) && + projectName.equals(project.projectName) && + Objects.equals(branchName, project.branchName); + } + + @Override + public int hashCode() { + return Objects.hash(uuid, key, projectName, branchName); + } + + @Override + public String toString() { + return "Project{" + + "uuid='" + uuid + '\'' + + ", key='" + key + '\'' + + ", projectName='" + projectName + '\'' + + ", branchName='" + branchName + '\'' + + '}'; + } + + public static class Builder { + private final String uuid; + private String key; + private String projectName; + @CheckForNull + private String branchName; + + public Builder(String uuid) { + this.uuid = uuid; + } + + public Builder setKey(String key) { + this.key = key; + return this; + } + + public Builder setProjectName(String projectName) { + this.projectName = projectName; + return this; + } + + public Builder setBranchName(@Nullable String branchName) { + this.branchName = branchName; + return this; + } + + public Project build() { + return new Project(this); + } + } + } + + public abstract static class Change { + protected final long date; + + private Change(long date) { + this.date = requireNonNull(date, "date can't be null"); + } + + public long getDate() { + return date; + } + + public abstract boolean isAuthorLogin(String login); + } + + @Immutable + public static final class AnalysisChange extends Change { + public AnalysisChange(long date) { + super(date); + } + + @Override + public boolean isAuthorLogin(String login) { + return false; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Change change = (Change) o; + return date == change.date; + } + + @Override + public int hashCode() { + return Objects.hash(date); + } + + @Override + public String toString() { + return "AnalysisChange{" + date + '}'; + } + } + + @Immutable + public static final class UserChange extends Change { + private final User user; + + public UserChange(long date, User user) { + super(date); + this.user = requireNonNull(user, "user can't be null"); + } + + public User getUser() { + return user; + } + + @Override + public boolean isAuthorLogin(String login) { + return this.user.login.equals(login); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + UserChange that = (UserChange) o; + return date == that.date && user.equals(that.user); + } + + @Override + public int hashCode() { + return Objects.hash(user, date); + } + + @Override + public String toString() { + return "UserChange{" + + "date=" + date + + ", user=" + user + + '}'; + } + } +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationModule.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationModule.java new file mode 100644 index 00000000000..5d1efab8650 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationModule.java @@ -0,0 +1,37 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import org.sonar.core.platform.Module; + +public class IssuesChangesNotificationModule extends Module { + @Override + protected void configureModule() { + add( + ChangesOnMyIssueNotificationHandler.class, + ChangesOnMyIssueNotificationHandler.newMetadata(), + ChangesOnMyIssuesEmailTemplate.class, + FPOrWontFixNotificationHandler.class, + FPOrWontFixNotificationHandler.newMetadata(), + IssuesChangesNotificationSerializer.class, + FpOrWontFixEmailTemplate.class + ); + } +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationSerializer.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationSerializer.java new file mode 100644 index 00000000000..42982326eb9 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationSerializer.java @@ -0,0 +1,307 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; +import org.sonar.api.rule.RuleKey; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static org.sonar.core.util.stream.MoreCollectors.toSet; +import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex; + +public class IssuesChangesNotificationSerializer { + private static final String FIELD_ISSUES_COUNT = "issues.count"; + private static final String FIELD_CHANGE_DATE = "change.date"; + private static final String FIELD_CHANGE_AUTHOR_UUID = "change.author.uuid"; + private static final String FIELD_CHANGE_AUTHOR_LOGIN = "change.author.login"; + private static final String FIELD_CHANGE_AUTHOR_NAME = "change.author.name"; + + public IssuesChangesNotification serialize(IssuesChangesNotificationBuilder builder) { + IssuesChangesNotification res = new IssuesChangesNotification(); + serializeIssueSize(res, builder.getIssues()); + serializeChange(res, builder.getChange()); + serializeIssues(res, builder.getIssues()); + serializeRules(res, builder.getIssues()); + serializeProjects(res, builder.getIssues()); + + return res; + } + + /** + * @throws IllegalArgumentException if {@code notification} misses any field or of any has unsupported value + */ + public IssuesChangesNotificationBuilder from(IssuesChangesNotification notification) { + int issueCount = readIssueCount(notification); + IssuesChangesNotificationBuilder.Change change = readChange(notification); + List issues = readIssues(notification, issueCount); + Map projects = readProjects(notification, issues); + Map rules = readRules(notification, issues); + + return new IssuesChangesNotificationBuilder(buildChangedIssues(issues, projects, rules), change); + } + + private static void serializeIssueSize(IssuesChangesNotification res, Set issues) { + res.setFieldValue(FIELD_ISSUES_COUNT, String.valueOf(issues.size())); + } + + private static int readIssueCount(IssuesChangesNotification notification) { + String fieldValue = notification.getFieldValue(FIELD_ISSUES_COUNT); + checkArgument(fieldValue != null, "missing field %s", FIELD_ISSUES_COUNT); + int issueCount = Integer.parseInt(fieldValue); + checkArgument(issueCount > 0, "issue count must be >= 1"); + return issueCount; + } + + private static Set buildChangedIssues(List issues, Map projects, + Map rules) { + return issues.stream() + .map(issue -> new ChangedIssue.Builder(issue.key) + .setNewStatus(issue.newStatus) + .setNewResolution(issue.newResolution) + .setAssignee(issue.assignee) + .setRule(rules.get(issue.ruleKey)) + .setProject(projects.get(issue.projectUuid)) + .build()) + .collect(toSet(issues.size())); + } + + private static void serializeIssues(IssuesChangesNotification res, Set issues) { + int index = 0; + for (ChangedIssue issue : issues) { + serializeIssue(res, index, issue); + index++; + } + } + + private static List readIssues(IssuesChangesNotification notification, int issueCount) { + List res = new ArrayList<>(issueCount); + for (int i = 0; i < issueCount; i++) { + res.add(readIssue(notification, i)); + } + return res; + } + + private static void serializeIssue(IssuesChangesNotification notification, int index, ChangedIssue issue) { + String issuePropertyPrefix = "issues." + index; + notification.setFieldValue(issuePropertyPrefix + ".key", issue.getKey()); + issue.getAssignee() + .ifPresent(assignee -> { + notification.setFieldValue(issuePropertyPrefix + ".assignee.uuid", assignee.getUuid()); + notification.setFieldValue(issuePropertyPrefix + ".assignee.login", assignee.getLogin()); + assignee.getName() + .ifPresent(name -> notification.setFieldValue(issuePropertyPrefix + ".assignee.name", name)); + }); + issue.getNewResolution() + .ifPresent(newResolution -> notification.setFieldValue(issuePropertyPrefix + ".newResolution", newResolution)); + notification.setFieldValue(issuePropertyPrefix + ".newStatus", issue.getNewStatus()); + notification.setFieldValue(issuePropertyPrefix + ".ruleKey", issue.getRule().getKey().toString()); + notification.setFieldValue(issuePropertyPrefix + ".projectUuid", issue.getProject().getUuid()); + } + + private static Issue readIssue(IssuesChangesNotification notification, int index) { + String issuePropertyPrefix = "issues." + index; + User assignee = readAssignee(notification, issuePropertyPrefix, index); + return new Issue.Builder() + .setKey(getIssueFieldValue(notification, issuePropertyPrefix + ".key", index)) + .setNewStatus(getIssueFieldValue(notification, issuePropertyPrefix + ".newStatus", index)) + .setNewResolution(notification.getFieldValue(issuePropertyPrefix + ".newResolution")) + .setAssignee(assignee) + .setRuleKey(getIssueFieldValue(notification, issuePropertyPrefix + ".ruleKey", index)) + .setProjectUuid(getIssueFieldValue(notification, issuePropertyPrefix + ".projectUuid", index)) + .build(); + } + + @CheckForNull + private static User readAssignee(IssuesChangesNotification notification, String issuePropertyPrefix, int index) { + String uuid = notification.getFieldValue(issuePropertyPrefix + ".assignee.uuid"); + if (uuid == null) { + return null; + } + String login = getIssueFieldValue(notification, issuePropertyPrefix + ".assignee.login", index); + return new User(uuid, login, notification.getFieldValue(issuePropertyPrefix + ".assignee.name")); + } + + private static String getIssueFieldValue(IssuesChangesNotification notification, String fieldName, int index) { + String fieldValue = notification.getFieldValue(fieldName); + checkState(fieldValue != null, "Can not find field %s for issue with index %s", fieldName, index); + return fieldValue; + } + + private static void serializeRules(IssuesChangesNotification res, Set issues) { + issues.stream() + .map(ChangedIssue::getRule) + .collect(Collectors.toSet()) + .forEach(rule -> res.setFieldValue("rules." + rule.getKey(), rule.getName())); + } + + private static Map readRules(IssuesChangesNotification notification, List issues) { + return issues.stream() + .map(issue -> issue.ruleKey) + .collect(Collectors.toSet()) + .stream() + .map(ruleKey -> readRule(notification, ruleKey)) + .collect(uniqueIndex(Rule::getKey, t -> t)); + } + + private static Rule readRule(IssuesChangesNotification notification, RuleKey ruleKey) { + String fieldName = "rules." + ruleKey; + String ruleName = notification.getFieldValue(fieldName); + checkState(ruleName != null, "can not find field %s", ruleKey); + return new Rule(ruleKey, ruleName); + } + + private static void serializeProjects(IssuesChangesNotification res, Set issues) { + issues.stream() + .map(ChangedIssue::getProject) + .collect(Collectors.toSet()) + .forEach(project -> { + String projectPropertyPrefix = "projects." + project.getUuid(); + res.setFieldValue(projectPropertyPrefix + ".key", project.getKey()); + res.setFieldValue(projectPropertyPrefix + ".projectName", project.getProjectName()); + project.getBranchName() + .ifPresent(branchName -> res.setFieldValue(projectPropertyPrefix + ".branchName", branchName)); + }); + } + + private static Map readProjects(IssuesChangesNotification notification, List issues) { + return issues.stream() + .map(issue -> issue.projectUuid) + .collect(Collectors.toSet()) + .stream() + .map(projectUuid -> { + String projectPropertyPrefix = "projects." + projectUuid; + return new Project.Builder(projectUuid) + .setKey(getProjectFieldValue(notification, projectPropertyPrefix + ".key", projectUuid)) + .setProjectName(getProjectFieldValue(notification, projectPropertyPrefix + ".projectName", projectUuid)) + .setBranchName(notification.getFieldValue(projectPropertyPrefix + ".branchName")) + .build(); + }) + .collect(uniqueIndex(Project::getUuid, t -> t)); + } + + private static String getProjectFieldValue(IssuesChangesNotification notification, String fieldName, String uuid) { + String fieldValue = notification.getFieldValue(fieldName); + checkState(fieldValue != null, "Can not find field %s for project with uuid %s", fieldName, uuid); + return fieldValue; + } + + private static void serializeChange(IssuesChangesNotification notification, IssuesChangesNotificationBuilder.Change change) { + notification.setFieldValue(FIELD_CHANGE_DATE, String.valueOf(change.date)); + if (change instanceof IssuesChangesNotificationBuilder.UserChange) { + IssuesChangesNotificationBuilder.UserChange userChange = (IssuesChangesNotificationBuilder.UserChange) change; + User user = userChange.getUser(); + notification.setFieldValue(FIELD_CHANGE_AUTHOR_UUID, user.getUuid()); + notification.setFieldValue(FIELD_CHANGE_AUTHOR_LOGIN, user.getLogin()); + user.getName().ifPresent(name -> notification.setFieldValue(FIELD_CHANGE_AUTHOR_NAME, name)); + } + } + + private static IssuesChangesNotificationBuilder.Change readChange(IssuesChangesNotification notification) { + String dateFieldValue = notification.getFieldValue(FIELD_CHANGE_DATE); + checkState(dateFieldValue != null, "Can not find field %s", FIELD_CHANGE_DATE); + long date = Long.parseLong(dateFieldValue); + + String uuid = notification.getFieldValue(FIELD_CHANGE_AUTHOR_UUID); + if (uuid == null) { + return new IssuesChangesNotificationBuilder.AnalysisChange(date); + } + String login = notification.getFieldValue(FIELD_CHANGE_AUTHOR_LOGIN); + checkState(login != null, "Can not find field %s", FIELD_CHANGE_AUTHOR_LOGIN); + return new IssuesChangesNotificationBuilder.UserChange(date, new User(uuid, login, notification.getFieldValue(FIELD_CHANGE_AUTHOR_NAME))); + } + + @Immutable + private static final class Issue { + private final String key; + private final String newStatus; + @CheckForNull + private final String newResolution; + @CheckForNull + private final User assignee; + private final RuleKey ruleKey; + private final String projectUuid; + + private Issue(Builder builder) { + this.key = builder.key; + this.newResolution = builder.newResolution; + this.newStatus = builder.newStatus; + this.assignee = builder.assignee; + this.ruleKey = RuleKey.parse(builder.ruleKey); + this.projectUuid = builder.projectUuid; + } + + static class Builder { + private String key = null; + private String newStatus = null; + @CheckForNull + private String newResolution = null; + @CheckForNull + private User assignee = null; + private String ruleKey = null; + private String projectUuid = null; + + public Builder setKey(String key) { + this.key = key; + return this; + } + + public Builder setNewStatus(String newStatus) { + this.newStatus = newStatus; + return this; + } + + public Builder setNewResolution(@Nullable String newResolution) { + this.newResolution = newResolution; + return this; + } + + public Builder setAssignee(@Nullable User assignee) { + this.assignee = assignee; + return this; + } + + public Builder setRuleKey(String ruleKey) { + this.ruleKey = ruleKey; + return this; + } + + public Builder setProjectUuid(String projectUuid) { + this.projectUuid = projectUuid; + return this; + } + + public Issue build() { + return new Issue(this); + } + } + } +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/NotificationWithProjectKeys.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/NotificationWithProjectKeys.java new file mode 100644 index 00000000000..ad004aa127a --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/NotificationWithProjectKeys.java @@ -0,0 +1,47 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import java.util.Set; +import java.util.stream.Collectors; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; + +final class NotificationWithProjectKeys { + private final IssuesChangesNotificationBuilder builder; + private final Set projectKeys; + + protected NotificationWithProjectKeys(IssuesChangesNotificationBuilder builder) { + this.builder = builder; + this.projectKeys = builder.getIssues().stream().map(t -> t.getProject().getKey()).collect(Collectors.toSet()); + } + + public Set getIssues() { + return builder.getIssues(); + } + + public Change getChange() { + return builder.getChange(); + } + + public Set getProjectKeys() { + return projectKeys; + } +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java b/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java index 224164d68d0..0f4090daba3 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java @@ -26,7 +26,9 @@ import java.util.Set; import javax.annotation.CheckForNull; import javax.annotation.concurrent.Immutable; import org.apache.commons.lang.StringUtils; +import org.apache.commons.mail.Email; import org.apache.commons.mail.EmailException; +import org.apache.commons.mail.HtmlEmail; import org.apache.commons.mail.SimpleEmail; import org.sonar.api.config.EmailSettings; import org.sonar.api.notifications.Notification; @@ -227,46 +229,13 @@ public class EmailNotificationChannel extends NotificationChannel { try { LOG.trace("Sending email: {}", emailMessage); - String host = null; - try { - host = new URL(configuration.getServerBaseURL()).getHost(); - } catch (MalformedURLException e) { - // ignore - } + String host = resolveHost(); - SimpleEmail email = new SimpleEmail(); - if (StringUtils.isNotBlank(host)) { - /* - * Set headers for proper threading: GMail will not group messages, even if they have same subject, but don't have "In-Reply-To" and - * "References" headers. TODO investigate threading in other clients like KMail, Thunderbird, Outlook - */ - if (StringUtils.isNotEmpty(emailMessage.getMessageId())) { - String messageId = "<" + emailMessage.getMessageId() + "@" + host + ">"; - email.addHeader(IN_REPLY_TO_HEADER, messageId); - email.addHeader(REFERENCES_HEADER, messageId); - } - // Set headers for proper filtering - email.addHeader(LIST_ID_HEADER, "SonarQube "); - email.addHeader(LIST_ARCHIVE_HEADER, configuration.getServerBaseURL()); - } - // Set general information - email.setCharset("UTF-8"); - String fromName = configuration.getFromName(); - String from = StringUtils.isBlank(emailMessage.getFrom()) ? fromName : (emailMessage.getFrom() + " (" + fromName + ")"); - email.setFrom(configuration.getFrom(), from); - email.addTo(emailMessage.getTo(), " "); - String subject = StringUtils.defaultIfBlank(StringUtils.trimToEmpty(configuration.getPrefix()) + " ", "") - + StringUtils.defaultString(emailMessage.getSubject(), SUBJECT_DEFAULT); - email.setSubject(subject); - email.setMsg(emailMessage.getMessage()); - // Send - email.setHostName(configuration.getSmtpHost()); - configureSecureConnection(email); - if (StringUtils.isNotBlank(configuration.getSmtpUsername()) || StringUtils.isNotBlank(configuration.getSmtpPassword())) { - email.setAuthentication(configuration.getSmtpUsername(), configuration.getSmtpPassword()); - } - email.setSocketConnectionTimeout(SOCKET_TIMEOUT); - email.setSocketTimeout(SOCKET_TIMEOUT); + Email email = createEmailWithMessage(emailMessage); + setHeaders(email, emailMessage, host); + setConnectionDetails(email); + setToAndFrom(email, emailMessage); + setSubject(email, emailMessage); email.send(); } finally { @@ -274,7 +243,66 @@ public class EmailNotificationChannel extends NotificationChannel { } } - private void configureSecureConnection(SimpleEmail email) { + private static Email createEmailWithMessage(EmailMessage emailMessage) throws EmailException { + if (emailMessage.isHtml()) { + return new HtmlEmail().setHtmlMsg(emailMessage.getMessage()); + } + return new SimpleEmail().setMsg(emailMessage.getMessage()); + } + + private void setSubject(Email email, EmailMessage emailMessage) { + String subject = StringUtils.defaultIfBlank(StringUtils.trimToEmpty(configuration.getPrefix()) + " ", "") + + StringUtils.defaultString(emailMessage.getSubject(), SUBJECT_DEFAULT); + email.setSubject(subject); + } + + private void setToAndFrom(Email email, EmailMessage emailMessage) throws EmailException { + String fromName = configuration.getFromName(); + String from = StringUtils.isBlank(emailMessage.getFrom()) ? fromName : (emailMessage.getFrom() + " (" + fromName + ")"); + email.setFrom(configuration.getFrom(), from); + email.addTo(emailMessage.getTo(), " "); + } + + @CheckForNull + private String resolveHost() { + try { + return new URL(configuration.getServerBaseURL()).getHost(); + } catch (MalformedURLException e) { + // ignore + return null; + } + } + + private void setHeaders(Email email, EmailMessage emailMessage, @CheckForNull String host) { + // Set general information + email.setCharset("UTF-8"); + if (StringUtils.isNotBlank(host)) { + /* + * Set headers for proper threading: GMail will not group messages, even if they have same subject, but don't have "In-Reply-To" and + * "References" headers. TODO investigate threading in other clients like KMail, Thunderbird, Outlook + */ + if (StringUtils.isNotEmpty(emailMessage.getMessageId())) { + String messageId = "<" + emailMessage.getMessageId() + "@" + host + ">"; + email.addHeader(IN_REPLY_TO_HEADER, messageId); + email.addHeader(REFERENCES_HEADER, messageId); + } + // Set headers for proper filtering + email.addHeader(LIST_ID_HEADER, "SonarQube "); + email.addHeader(LIST_ARCHIVE_HEADER, configuration.getServerBaseURL()); + } + } + + private void setConnectionDetails(Email email) { + email.setHostName(configuration.getSmtpHost()); + configureSecureConnection(email); + if (StringUtils.isNotBlank(configuration.getSmtpUsername()) || StringUtils.isNotBlank(configuration.getSmtpPassword())) { + email.setAuthentication(configuration.getSmtpUsername(), configuration.getSmtpPassword()); + } + email.setSocketConnectionTimeout(SOCKET_TIMEOUT); + email.setSocketTimeout(SOCKET_TIMEOUT); + } + + private void configureSecureConnection(Email email) { if (StringUtils.equalsIgnoreCase(configuration.getSecureConnection(), "ssl")) { email.setSSLOnConnect(true); email.setSSLCheckServerIdentity(true); @@ -305,7 +333,7 @@ public class EmailNotificationChannel extends NotificationChannel { EmailMessage emailMessage = new EmailMessage(); emailMessage.setTo(toAddress); emailMessage.setSubject(subject); - emailMessage.setMessage(message); + emailMessage.setPlainTextMessage(message); send(emailMessage); } catch (EmailException e) { LOG.debug("Fail to send test email to {}: {}", toAddress, e); diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/qualitygate/notification/QGChangeEmailTemplate.java b/server/sonar-server-common/src/main/java/org/sonar/server/qualitygate/notification/QGChangeEmailTemplate.java index d5a883e1d66..9da89355546 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/qualitygate/notification/QGChangeEmailTemplate.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/qualitygate/notification/QGChangeEmailTemplate.java @@ -19,6 +19,7 @@ */ package org.sonar.server.qualitygate.notification; +import javax.annotation.CheckForNull; import javax.annotation.Nullable; import org.apache.commons.lang.StringUtils; import org.sonar.api.config.EmailSettings; @@ -41,6 +42,7 @@ public class QGChangeEmailTemplate implements EmailTemplate { } @Override + @CheckForNull public EmailMessage format(Notification notification) { if (!"alerts".equals(notification.getType())) { return null; @@ -66,7 +68,7 @@ public class QGChangeEmailTemplate implements EmailTemplate { return new EmailMessage() .setMessageId("alerts/" + projectId) .setSubject(subject) - .setMessage(messageBody); + .setPlainTextMessage(messageBody); } private static String computeFullProjectName(String projectName, @Nullable String branchName) { diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandlerTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandlerTest.java index b6fae117865..9f51b5b233b 100644 --- a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandlerTest.java +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandlerTest.java @@ -20,10 +20,12 @@ package org.sonar.server.issue.notification; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ListMultimap; import com.tngtech.java.junit.dataprovider.DataProvider; import com.tngtech.java.junit.dataprovider.DataProviderRunner; import com.tngtech.java.junit.dataprovider.UseDataProvider; import java.util.Collections; +import java.util.List; import java.util.Random; import java.util.Set; import java.util.stream.Collectors; @@ -32,22 +34,33 @@ import java.util.stream.Stream; import javax.annotation.Nullable; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; +import org.sonar.api.rule.RuleKey; +import org.sonar.core.util.stream.MoreCollectors; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; import org.sonar.server.notification.NotificationDispatcherMetadata; import org.sonar.server.notification.NotificationManager; import org.sonar.server.notification.email.EmailNotificationChannel; import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest; -import static java.util.Collections.singleton; import static java.util.stream.Collectors.toSet; import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; +import static org.sonar.core.util.stream.MoreCollectors.index; +import static org.sonar.core.util.stream.MoreCollectors.unorderedIndex; import static org.sonar.server.notification.NotificationDispatcherMetadata.GLOBAL_NOTIFICATION; import static org.sonar.server.notification.NotificationDispatcherMetadata.PER_PROJECT_NOTIFICATION; import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER; @@ -59,7 +72,12 @@ public class ChangesOnMyIssueNotificationHandlerTest { private NotificationManager notificationManager = mock(NotificationManager.class); private EmailNotificationChannel emailNotificationChannel = mock(EmailNotificationChannel.class); - private ChangesOnMyIssueNotificationHandler underTest = new ChangesOnMyIssueNotificationHandler(notificationManager, emailNotificationChannel); + private IssuesChangesNotificationSerializer serializer = new IssuesChangesNotificationSerializer(); + private ChangesOnMyIssueNotificationHandler underTest = new ChangesOnMyIssueNotificationHandler( + notificationManager, emailNotificationChannel, serializer); + + private Class> emailDeliveryRequestSetType = (Class>) (Object) Set.class; + private ArgumentCaptor> emailDeliveryRequestSetCaptor = ArgumentCaptor.forClass(emailDeliveryRequestSetType); @Test public void getMetadata_returns_same_instance_as_static_method() { @@ -89,7 +107,7 @@ public class ChangesOnMyIssueNotificationHandlerTest { @Test public void getNotificationClass_is_IssueChangeNotification() { - assertThat(underTest.getNotificationClass()).isEqualTo(IssueChangeNotification.class); + assertThat(underTest.getNotificationClass()).isEqualTo(IssuesChangesNotification.class); } @Test @@ -104,8 +122,8 @@ public class ChangesOnMyIssueNotificationHandlerTest { @Test public void deliver_has_no_effect_if_emailNotificationChannel_is_disabled() { when(emailNotificationChannel.isActivated()).thenReturn(false); - Set notifications = IntStream.range(0, 1 + new Random().nextInt(10)) - .mapToObj(i -> mock(IssueChangeNotification.class)) + Set notifications = IntStream.range(0, 1 + new Random().nextInt(10)) + .mapToObj(i -> mock(IssuesChangesNotification.class)) .collect(toSet()); int deliver = underTest.deliver(notifications); @@ -118,29 +136,47 @@ public class ChangesOnMyIssueNotificationHandlerTest { } @Test - public void deliver_has_no_effect_if_no_notification_has_projectKey() { + public void deliver_has_no_effect_if_no_notification_has_assignee() { when(emailNotificationChannel.isActivated()).thenReturn(true); - Set notifications = IntStream.range(0, 1 + new Random().nextInt(10)) - .mapToObj(i -> newNotification(null, null, null)) + Set issues = IntStream.range(0, 1 + new Random().nextInt(2)) + .mapToObj(i -> new ChangedIssue.Builder("issue_key_" + i) + .setNewStatus("foo") + .setAssignee(null) + .setRule(newRule()) + .setProject(newProject(i + "")) + .build()) .collect(toSet()); + IssuesChangesNotificationBuilder builder = new IssuesChangesNotificationBuilder(issues, new UserChange(new Random().nextLong(), new User("user_uuid", "user_login", null))); - int deliver = underTest.deliver(notifications); + int deliver = underTest.deliver(ImmutableSet.of(serializer.serialize(builder))); assertThat(deliver).isZero(); verifyZeroInteractions(notificationManager); verify(emailNotificationChannel).isActivated(); verifyNoMoreInteractions(emailNotificationChannel); - notifications.forEach(notification -> { - verify(notification).getProjectKey(); - verifyNoMoreInteractions(notification); - }); } @Test - public void deliver_has_no_effect_if_no_notification_has_assignee() { + public void deliver_has_no_effect_if_all_issues_are_assigned_to_the_changeAuthor() { when(emailNotificationChannel.isActivated()).thenReturn(true); - Set notifications = IntStream.range(0, 1 + new Random().nextInt(10)) - .mapToObj(i -> newNotification(randomAlphabetic(5 + i), null, NO_CHANGE_AUTHOR)) + Set userChanges = IntStream.range(0, 1 + new Random().nextInt(3)) + .mapToObj(i -> new UserChange(new Random().nextLong(), new User("user_uuid_" + i, "user_login_" + i, null))) + .collect(toSet()); + Set notificationBuilders = userChanges.stream() + .map(userChange -> { + Set issues = IntStream.range(0, 1 + new Random().nextInt(2)) + .mapToObj(i -> new ChangedIssue.Builder("issue_key_" + i + userChange.getUser().getUuid()) + .setNewStatus("foo") + .setAssignee(userChange.getUser()) + .setRule(newRule()) + .setProject(newProject(i + "")) + .build()) + .collect(toSet()); + return new IssuesChangesNotificationBuilder(issues, userChange); + }) + .collect(toSet()); + Set notifications = notificationBuilders.stream() + .map(t -> serializer.serialize(t)) .collect(toSet()); int deliver = underTest.deliver(notifications); @@ -149,150 +185,269 @@ public class ChangesOnMyIssueNotificationHandlerTest { verifyZeroInteractions(notificationManager); verify(emailNotificationChannel).isActivated(); verifyNoMoreInteractions(emailNotificationChannel); - notifications.forEach(notification -> { - verify(notification).getProjectKey(); - verify(notification).getAssignee(); - verifyNoMoreInteractions(notification); - }); } @Test - public void deliver_has_no_effect_if_no_notification_has_change_author_different_from_assignee() { + public void deliver_checks_by_projectKey_if_notifications_have_subscribed_assignee_to_ChangesOnMyIssues_notifications() { when(emailNotificationChannel.isActivated()).thenReturn(true); - Set notifications = IntStream.range(0, 1 + new Random().nextInt(10)) - .mapToObj(i -> { - String assignee = randomAlphabetic(4 + i); - return newNotification(randomAlphabetic(5 + i), assignee, assignee); - }) + Project project = newProject(); + Set issues = IntStream.range(0, 1 + new Random().nextInt(2)) + .mapToObj(i -> new ChangedIssue.Builder("issue_key_" + i) + .setNewStatus("foo") + .setAssignee(newUser("assignee_" + i)) + .setRule(newRule()) + .setProject(project) + .build()) .collect(toSet()); + IssuesChangesNotificationBuilder builder = new IssuesChangesNotificationBuilder(issues, new UserChange(new Random().nextLong(), new User("user_uuid", "user_login", null))); - int deliver = underTest.deliver(notifications); + int deliver = underTest.deliver(ImmutableSet.of(serializer.serialize(builder))); assertThat(deliver).isZero(); - verifyZeroInteractions(notificationManager); + Set assigneeLogins = issues.stream().map(i -> i.getAssignee().get().getLogin()).collect(toSet()); + verify(notificationManager).findSubscribedEmailRecipients(CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, project.getKey(), assigneeLogins, ALL_MUST_HAVE_ROLE_USER); + verifyNoMoreInteractions(notificationManager); verify(emailNotificationChannel).isActivated(); verifyNoMoreInteractions(emailNotificationChannel); - notifications.forEach(notification -> { - verify(notification).getProjectKey(); - verify(notification, times(2)).getAssignee(); - verify(notification).getChangeAuthor(); - verifyNoMoreInteractions(notification); - }); } @Test - @UseDataProvider("noOrDifferentChangeAuthor") - public void deliver_checks_by_projectKey_if_notifications_have_subscribed_assignee_to_ChangesOnMyIssues_notifications(@Nullable String noOrDifferentChangeAuthor) { - String projectKey1 = randomAlphabetic(10); - String assignee1 = randomAlphabetic(11); - String projectKey2 = randomAlphabetic(12); - String assignee2 = randomAlphabetic(13); - Set notifications1 = randomSetOfNotifications(projectKey1, assignee1, noOrDifferentChangeAuthor); - Set notifications2 = randomSetOfNotifications(projectKey2, assignee2, noOrDifferentChangeAuthor); + public void deliver_checks_by_projectKeys_if_notifications_have_subscribed_assignee_to_ChangesOnMyIssues_notifications() { when(emailNotificationChannel.isActivated()).thenReturn(true); + Set issues = IntStream.range(0, 1 + new Random().nextInt(2)) + .mapToObj(i -> new ChangedIssue.Builder("issue_key_" + i) + .setNewStatus("foo") + .setAssignee(newUser("" + i)) + .setRule(newRule()) + .setProject(newProject(i + "")) + .build()) + .collect(toSet()); + IssuesChangesNotificationBuilder builder = new IssuesChangesNotificationBuilder(issues, new UserChange(new Random().nextLong(), new User("user_uuid", "user_login", null))); - int deliver = underTest.deliver(Stream.concat(notifications1.stream(), notifications2.stream()).collect(toSet())); + int deliver = underTest.deliver(ImmutableSet.of(serializer.serialize(builder))); assertThat(deliver).isZero(); - verify(notificationManager).findSubscribedEmailRecipients(CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, projectKey1, singleton(assignee1), ALL_MUST_HAVE_ROLE_USER); - verify(notificationManager).findSubscribedEmailRecipients(CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, projectKey2, singleton(assignee2), ALL_MUST_HAVE_ROLE_USER); + issues.stream() + .collect(MoreCollectors.index(ChangedIssue::getProject)) + .asMap() + .forEach((key, value) -> { + String projectKey = key.getKey(); + Set assigneeLogins = value.stream().map(i -> i.getAssignee().get().getLogin()).collect(toSet()); + verify(notificationManager).findSubscribedEmailRecipients(CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, projectKey, assigneeLogins, ALL_MUST_HAVE_ROLE_USER); + }); verifyNoMoreInteractions(notificationManager); verify(emailNotificationChannel).isActivated(); verifyNoMoreInteractions(emailNotificationChannel); } @Test - @UseDataProvider("noOrDifferentChangeAuthor") - public void deliver_ignores_notifications_which_assignee_has_not_subscribed_to_ChangesOnMyIssues_notifications(@Nullable String noOrDifferentChangeAuthor) { - String projectKey = randomAlphabetic(5); - String assignee1 = randomAlphabetic(6); - String assignee2 = randomAlphabetic(7); - // assignee1 is not authorized - Set assignee1Notifications = randomSetOfNotifications(projectKey, assignee1, noOrDifferentChangeAuthor); - // assignee2 is authorized - Set assignee2Notifications = randomSetOfNotifications(projectKey, assignee2, noOrDifferentChangeAuthor); + @UseDataProvider("userOrAnalysisChange") + public void deliver_creates_a_notification_per_assignee_with_only_his_issues_on_the_single_project(Change userOrAnalysisChange) { when(emailNotificationChannel.isActivated()).thenReturn(true); - when(notificationManager.findSubscribedEmailRecipients(CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, projectKey, ImmutableSet.of(assignee1, assignee2), ALL_MUST_HAVE_ROLE_USER)) - .thenReturn(ImmutableSet.of(emailRecipientOf(assignee2))); - Set expectedRequests = assignee2Notifications.stream() - .map(t -> new EmailDeliveryRequest(emailOf(t.getAssignee()), t)) + Project project = newProject(); + User assignee1 = newUser("assignee_1"); + User assignee2 = newUser("assignee_2"); + Set assignee1Issues = IntStream.range(0, 10) + .mapToObj(i -> newChangedIssue("1_issue_key_" + i, assignee1, project)) .collect(toSet()); - int deliveredCount = new Random().nextInt(expectedRequests.size()); - when(emailNotificationChannel.deliverAll(expectedRequests)).thenReturn(deliveredCount); + Set assignee2Issues = IntStream.range(0, 10) + .mapToObj(i -> newChangedIssue("2_issue_key_" + i, assignee2, project)) + .collect(toSet()); + Set notifications = Stream.of( + // notification with only assignee1 5 notifications + new IssuesChangesNotificationBuilder(assignee1Issues.stream().limit(5).collect(toSet()), userOrAnalysisChange), + // notification with only assignee2 6 notifications + new IssuesChangesNotificationBuilder(assignee2Issues.stream().limit(6).collect(toSet()), userOrAnalysisChange), + // notification with 4 assignee1 and 3 assignee2 notifications + new IssuesChangesNotificationBuilder( + Stream.concat(assignee1Issues.stream().skip(6), assignee2Issues.stream().skip(7)).collect(toSet()), + userOrAnalysisChange)) + .map(t -> serializer.serialize(t)) + .collect(toSet()); + when(notificationManager.findSubscribedEmailRecipients(CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, project.getKey(), ImmutableSet.of(assignee1.getLogin(), assignee2.getLogin()), + ALL_MUST_HAVE_ROLE_USER)) + .thenReturn(ImmutableSet.of(emailRecipientOf(assignee1.getLogin()), emailRecipientOf(assignee2.getLogin()))); + int deliveredCount = new Random().nextInt(100); + when(emailNotificationChannel.deliverAll(anySet())).thenReturn(deliveredCount); - int deliver = underTest.deliver(Stream.concat(assignee1Notifications.stream(), assignee2Notifications.stream()).collect(toSet())); + int deliver = underTest.deliver(notifications); assertThat(deliver).isEqualTo(deliveredCount); - verify(notificationManager).findSubscribedEmailRecipients(CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, projectKey, ImmutableSet.of(assignee1, assignee2), ALL_MUST_HAVE_ROLE_USER); + verify(notificationManager).findSubscribedEmailRecipients(CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, + project.getKey(), ImmutableSet.of(assignee1.getLogin(), assignee2.getLogin()), ALL_MUST_HAVE_ROLE_USER); verifyNoMoreInteractions(notificationManager); verify(emailNotificationChannel).isActivated(); - verify(emailNotificationChannel).deliverAll(expectedRequests); + verify(emailNotificationChannel).deliverAll(emailDeliveryRequestSetCaptor.capture()); verifyNoMoreInteractions(emailNotificationChannel); + + Set emailDeliveryRequests = emailDeliveryRequestSetCaptor.getValue(); + assertThat(emailDeliveryRequests).hasSize(4); + ListMultimap emailDeliveryRequestByEmail = emailDeliveryRequests.stream() + .collect(index(EmailDeliveryRequest::getRecipientEmail)); + List assignee1Requests = emailDeliveryRequestByEmail.get(emailOf(assignee1.getLogin())); + assertThat(assignee1Requests) + .hasSize(2) + .extracting(t -> (ChangesOnMyIssuesNotification) t.getNotification()) + .extracting(ChangesOnMyIssuesNotification::getChange) + .containsOnly(userOrAnalysisChange); + assertThat(assignee1Requests) + .extracting(t -> (ChangesOnMyIssuesNotification) t.getNotification()) + .extracting(ChangesOnMyIssuesNotification::getChangedIssues) + .containsOnly( + assignee1Issues.stream().limit(5).collect(unorderedIndex(t -> project, t -> t)), + assignee1Issues.stream().skip(6).collect(unorderedIndex(t -> project, t -> t))); + + List assignee2Requests = emailDeliveryRequestByEmail.get(emailOf(assignee2.getLogin())); + assertThat(assignee2Requests) + .hasSize(2) + .extracting(t -> (ChangesOnMyIssuesNotification) t.getNotification()) + .extracting(ChangesOnMyIssuesNotification::getChange) + .containsOnly(userOrAnalysisChange); + assertThat(assignee2Requests) + .extracting(t -> (ChangesOnMyIssuesNotification) t.getNotification()) + .extracting(ChangesOnMyIssuesNotification::getChangedIssues) + .containsOnly( + assignee2Issues.stream().limit(6).collect(unorderedIndex(t -> project, t -> t)), + assignee2Issues.stream().skip(7).collect(unorderedIndex(t -> project, t -> t))); } @Test - public void deliver_ignores_notifications_which_assignee_is_the_changeAuthor() { - String projectKey = randomAlphabetic(5); - String assignee1 = randomAlphabetic(6); - String assignee2 = randomAlphabetic(7); - String assignee3 = randomAlphabetic(8); - // assignee1 is the changeAuthor of every notification he's the assignee of - Set assignee1ChangeAuthor = randomSetOfNotifications(projectKey, assignee1, assignee1); - // assignee2 is the changeAuthor of some notification he's the assignee of - Set assignee2ChangeAuthor = randomSetOfNotifications(projectKey, assignee2, assignee2); - Set assignee2NotChangeAuthor = randomSetOfNotifications(projectKey, assignee2, randomAlphabetic(10)); - Set assignee2NoChangeAuthor = randomSetOfNotifications(projectKey, assignee2, NO_CHANGE_AUTHOR); - // assignee3 is never the changeAuthor of the notification he's the assignee of - Set assignee3NotChangeAuthor = randomSetOfNotifications(projectKey, assignee3, randomAlphabetic(11)); - Set assignee3NoChangeAuthor = randomSetOfNotifications(projectKey, assignee3, NO_CHANGE_AUTHOR); + @UseDataProvider("userOrAnalysisChange") + public void deliver_ignores_issues_which_assignee_is_the_changeAuthor(Change userOrAnalysisChange) { when(emailNotificationChannel.isActivated()).thenReturn(true); - // assignees which are not changeAuthor have subscribed - Set assigneesChangeAuthor = ImmutableSet.of(assignee2, assignee3); - when(notificationManager.findSubscribedEmailRecipients(CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, projectKey, assigneesChangeAuthor, ALL_MUST_HAVE_ROLE_USER)) - .thenReturn(ImmutableSet.of(emailRecipientOf(assignee2), emailRecipientOf(assignee3))); - Set expectedRequests = Stream.of( - assignee2NotChangeAuthor.stream(), assignee2NoChangeAuthor.stream(), - assignee3NotChangeAuthor.stream(), assignee3NoChangeAuthor.stream()) - .flatMap(t -> t) - .map(t -> new EmailDeliveryRequest(emailOf(t.getAssignee()), t)) + Project project1 = newProject(); + Project project2 = newProject(); + User assignee1 = newUser("assignee_1"); + User assignee2 = newUser("assignee_2"); + Set assignee1Issues = IntStream.range(0, 10) + .mapToObj(i -> newChangedIssue("1_issue_key_" + i, assignee1, project1)) + .collect(toSet()); + Set assignee2Issues = IntStream.range(0, 10) + .mapToObj(i -> newChangedIssue("2_issue_key_" + i, assignee2, project2)) .collect(toSet()); - int deliveredCount = new Random().nextInt(expectedRequests.size()); - when(emailNotificationChannel.deliverAll(expectedRequests)).thenReturn(deliveredCount); - Set notifications = Stream.of( - assignee1ChangeAuthor.stream(), - assignee2ChangeAuthor.stream(), assignee2NotChangeAuthor.stream(), assignee2NoChangeAuthor.stream(), - assignee3NotChangeAuthor.stream(), assignee3NoChangeAuthor.stream()).flatMap(t -> t) + UserChange assignee2Change1 = new UserChange(new Random().nextLong(), assignee2); + Set notifications = Stream.of( + // notification from assignee1 with issues from assignee1 only + new IssuesChangesNotificationBuilder( + assignee1Issues.stream().limit(4).collect(toSet()), + new UserChange(new Random().nextLong(), assignee1)), + // notification from assignee2 with issues from assignee1 and assignee2 + new IssuesChangesNotificationBuilder( + Stream.concat( + assignee1Issues.stream().skip(4).limit(2), + assignee2Issues.stream().limit(4)) + .collect(toSet()), + assignee2Change1), + // notification from assignee2 with issues from assignee2 only + new IssuesChangesNotificationBuilder( + assignee2Issues.stream().skip(4).limit(3).collect(toSet()), + new UserChange(new Random().nextLong(), assignee2)), + // notification from other change with issues from assignee1 and assignee2) + new IssuesChangesNotificationBuilder( + Stream.concat( + assignee1Issues.stream().skip(6), + assignee2Issues.stream().skip(7)) + .collect(toSet()), + userOrAnalysisChange)) + .map(t -> serializer.serialize(t)) .collect(toSet()); + when(notificationManager.findSubscribedEmailRecipients( + CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, project1.getKey(), ImmutableSet.of(assignee1.getLogin()), ALL_MUST_HAVE_ROLE_USER)) + .thenReturn(ImmutableSet.of(emailRecipientOf(assignee1.getLogin()))); + when(notificationManager.findSubscribedEmailRecipients( + CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, project2.getKey(), ImmutableSet.of(assignee2.getLogin()), ALL_MUST_HAVE_ROLE_USER)) + .thenReturn(ImmutableSet.of(emailRecipientOf(assignee2.getLogin()))); + int deliveredCount = new Random().nextInt(100); + when(emailNotificationChannel.deliverAll(anySet())).thenReturn(deliveredCount); + int deliver = underTest.deliver(notifications); assertThat(deliver).isEqualTo(deliveredCount); - verify(notificationManager).findSubscribedEmailRecipients(CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, projectKey, assigneesChangeAuthor, ALL_MUST_HAVE_ROLE_USER); + verify(notificationManager).findSubscribedEmailRecipients(CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, + project1.getKey(), ImmutableSet.of(assignee1.getLogin()), ALL_MUST_HAVE_ROLE_USER); + verify(notificationManager).findSubscribedEmailRecipients(CHANGE_ON_MY_ISSUES_DISPATCHER_KEY, + project2.getKey(), ImmutableSet.of(assignee2.getLogin()), ALL_MUST_HAVE_ROLE_USER); verifyNoMoreInteractions(notificationManager); verify(emailNotificationChannel).isActivated(); - verify(emailNotificationChannel).deliverAll(expectedRequests); + verify(emailNotificationChannel).deliverAll(emailDeliveryRequestSetCaptor.capture()); verifyNoMoreInteractions(emailNotificationChannel); + + Set emailDeliveryRequests = emailDeliveryRequestSetCaptor.getValue(); + assertThat(emailDeliveryRequests).hasSize(3); + ListMultimap emailDeliveryRequestByEmail = emailDeliveryRequests.stream() + .collect(index(EmailDeliveryRequest::getRecipientEmail)); + List assignee1Requests = emailDeliveryRequestByEmail.get(emailOf(assignee1.getLogin())); + assertThat(assignee1Requests) + .hasSize(2) + .extracting(t -> (ChangesOnMyIssuesNotification) t.getNotification()) + .extracting(ChangesOnMyIssuesNotification::getChange) + .containsOnly(userOrAnalysisChange, assignee2Change1); + assertThat(assignee1Requests) + .extracting(t -> (ChangesOnMyIssuesNotification) t.getNotification()) + .extracting(ChangesOnMyIssuesNotification::getChangedIssues) + .containsOnly( + assignee1Issues.stream().skip(4).limit(2).collect(unorderedIndex(t -> project1, t -> t)), + assignee1Issues.stream().skip(6).collect(unorderedIndex(t -> project1, t -> t))); + + List assignee2Requests = emailDeliveryRequestByEmail.get(emailOf(assignee2.getLogin())); + assertThat(assignee2Requests) + .hasSize(1) + .extracting(t -> (ChangesOnMyIssuesNotification) t.getNotification()) + .extracting(ChangesOnMyIssuesNotification::getChange) + .containsOnly(userOrAnalysisChange); + assertThat(assignee2Requests) + .extracting(t -> (ChangesOnMyIssuesNotification) t.getNotification()) + .extracting(ChangesOnMyIssuesNotification::getChangedIssues) + .containsOnly(assignee2Issues.stream().skip(7).collect(unorderedIndex(t -> project2, t -> t))); } @DataProvider - public static Object[][] noOrDifferentChangeAuthor() { + public static Object[][] userOrAnalysisChange() { + User changeAuthor = new User(randomAlphabetic(12), randomAlphabetic(10), randomAlphabetic(11)); return new Object[][] { - {NO_CHANGE_AUTHOR}, - {randomAlphabetic(15)} + {new AnalysisChange(new Random().nextLong())}, + {new UserChange(new Random().nextLong(), changeAuthor)}, }; } - private static Set randomSetOfNotifications(@Nullable String projectKey, @Nullable String assignee, @Nullable String changeAuthor) { + private static Project newProject() { + String base = randomAlphabetic(6); + return newProject(base); + } + + private static Project newProject(String base) { + return new Project.Builder("prj_uuid_" + base) + .setKey("prj_key_" + base) + .setProjectName("prj_name_" + base) + .build(); + } + + private static User newUser(String name) { + return new User(name + "_uuid", name + "login", name); + } + + private static ChangedIssue newChangedIssue(String key, User assignee1, Project project) { + return new ChangedIssue.Builder(key) + .setNewStatus("foo") + .setAssignee(assignee1) + .setRule(newRule()) + .setProject(project) + .build(); + } + + private static Rule newRule() { + return new Rule(RuleKey.of(randomAlphabetic(3), randomAlphabetic(4)), randomAlphabetic(5)); + } + + private static Set randomSetOfNotifications(@Nullable String projectKey, @Nullable String assignee, @Nullable String changeAuthor) { return IntStream.range(0, 1 + new Random().nextInt(5)) .mapToObj(i -> newNotification(projectKey, assignee, changeAuthor)) .collect(Collectors.toSet()); } - private static IssueChangeNotification newNotification(@Nullable String projectKey, @Nullable String assignee, @Nullable String changeAuthor) { - IssueChangeNotification notification = mock(IssueChangeNotification.class); - when(notification.getProjectKey()).thenReturn(projectKey); - when(notification.getAssignee()).thenReturn(assignee); - when(notification.getChangeAuthor()).thenReturn(changeAuthor); + private static IssuesChangesNotification newNotification(@Nullable String projectKey, @Nullable String assignee, @Nullable String changeAuthor) { + IssuesChangesNotification notification = mock(IssuesChangesNotification.class); return notification; } diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplateTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplateTest.java new file mode 100644 index 00000000000..0a2e097a379 --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplateTest.java @@ -0,0 +1,721 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import com.google.common.collect.ImmutableSet; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Random; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.sonar.api.config.EmailSettings; +import org.sonar.api.i18n.I18n; +import org.sonar.api.notifications.Notification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; +import org.sonar.test.html.HtmlFragmentAssert; +import org.sonar.test.html.HtmlListAssert; +import org.sonar.test.html.HtmlParagraphAssert; + +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toSet; +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.api.issue.Issue.STATUS_CLOSED; +import static org.sonar.api.issue.Issue.STATUS_CONFIRMED; +import static org.sonar.api.issue.Issue.STATUS_OPEN; +import static org.sonar.api.issue.Issue.STATUS_REOPENED; +import static org.sonar.api.issue.Issue.STATUS_RESOLVED; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newBranch; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newChangedIssue; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newProject; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newRule; + +@RunWith(DataProviderRunner.class) +public class ChangesOnMyIssuesEmailTemplateTest { + private static final String[] ISSUE_STATUSES = {STATUS_OPEN, STATUS_RESOLVED, STATUS_CONFIRMED, STATUS_REOPENED, STATUS_CLOSED}; + @org.junit.Rule + public ExpectedException expectedException = ExpectedException.none(); + + private I18n i18n = mock(I18n.class); + private EmailSettings emailSettings = mock(EmailSettings.class); + private ChangesOnMyIssuesEmailTemplate underTest = new ChangesOnMyIssuesEmailTemplate(i18n, emailSettings); + + @Test + public void format_returns_null_on_Notification() { + EmailMessage emailMessage = underTest.format(mock(Notification.class)); + + assertThat(emailMessage).isNull(); + } + + @Test + public void formats_fails_with_ISE_if_change_from_Analysis_and_no_issue() { + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage("changedIssues can't be empty"); + + underTest.format(new ChangesOnMyIssuesNotification(analysisChange, Collections.emptySet())); + } + + @Test + public void format_sets_message_id_with_project_key_of_first_issue_in_set_when_change_from_Analysis() { + Set changedIssues = IntStream.range(0, 2 + new Random().nextInt(4)) + .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newRule("rule_" + i))) + .collect(toSet()); + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, changedIssues)); + + assertThat(emailMessage.getMessageId()).isEqualTo("changes-on-my-issues/" + changedIssues.iterator().next().getProject().getKey()); + } + + @Test + public void format_sets_subject_with_project_name_of_first_issue_in_set_when_change_from_Analysis() { + Set changedIssues = IntStream.range(0, 2 + new Random().nextInt(4)) + .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newRule("rule_" + i))) + .collect(toSet()); + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, changedIssues)); + + Project project = changedIssues.iterator().next().getProject(); + assertThat(emailMessage.getSubject()).isEqualTo("Analysis has changed some of your issues in " + project.getProjectName()); + } + + @Test + public void format_sets_subject_with_project_name_and_branch_name_of_first_issue_in_set_when_change_from_Analysis() { + Set changedIssues = IntStream.range(0, 2 + new Random().nextInt(4)) + .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newBranch("prj_" + i, "br_" + i), newRule("rule_" + i))) + .collect(toSet()); + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, changedIssues)); + + Project project = changedIssues.iterator().next().getProject(); + assertThat(emailMessage.getSubject()).isEqualTo("Analysis has changed some of your issues in " + project.getProjectName() + ", " + project.getBranchName().get()); + } + + @Test + public void format_set_html_message_with_header_dealing_with_plural_when_change_from_Analysis() { + Set changedIssues = IntStream.range(0, 2 + new Random().nextInt(4)) + .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newRule("rule_" + i))) + .collect(toSet()); + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + + EmailMessage singleIssueMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, changedIssues.stream().limit(1).collect(toSet()))); + EmailMessage multiIssueMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, changedIssues)); + + HtmlFragmentAssert.assertThat(singleIssueMessage.getMessage()) + .hasParagraph("Hi,") + .hasParagraph("An analysis has updated an issue assigned to you:"); + HtmlFragmentAssert.assertThat(multiIssueMessage.getMessage()) + .hasParagraph("Hi,") + .hasParagraph("An analysis has updated issues assigned to you:"); + } + + @Test + public void format_sets_static_message_id_when_change_from_User() { + Set changedIssues = IntStream.range(0, 2 + new Random().nextInt(4)) + .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newRule("rule_" + i))) + .collect(toSet()); + UserChange userChange = IssuesChangesNotificationBuilderTesting.newUserChange(); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, changedIssues)); + + assertThat(emailMessage.getMessageId()).isEqualTo("changes-on-my-issues"); + } + + @Test + public void format_sets_static_subject_when_change_from_User() { + Set changedIssues = IntStream.range(0, 2 + new Random().nextInt(4)) + .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newRule("rule_" + i))) + .collect(toSet()); + UserChange userChange = IssuesChangesNotificationBuilderTesting.newUserChange(); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, changedIssues)); + + assertThat(emailMessage.getSubject()).isEqualTo("A manual update has changed some of your issues"); + } + + @Test + public void format_set_html_message_with_header_dealing_with_plural_when_change_from_User() { + Set changedIssues = IntStream.range(0, 2 + new Random().nextInt(4)) + .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newRule("rule_" + i))) + .collect(toSet()); + UserChange userChange = IssuesChangesNotificationBuilderTesting.newUserChange(); + + EmailMessage singleIssueMessage = underTest.format(new ChangesOnMyIssuesNotification( + userChange, changedIssues.stream().limit(1).collect(toSet()))); + EmailMessage multiIssueMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, changedIssues)); + + HtmlFragmentAssert.assertThat(singleIssueMessage.getMessage()) + .hasParagraph("Hi,") + .withoutLink() + .hasParagraph("A manual change has updated an issue assigned to you:") + .withoutLink(); + HtmlFragmentAssert.assertThat(multiIssueMessage.getMessage()) + .hasParagraph("Hi,") + .withoutLink() + .hasParagraph("A manual change has updated issues assigned to you:") + .withoutLink(); + } + + @Test + @UseDataProvider("issueStatuses") + public void format_set_html_message_with_footer_when_change_from_user(String issueStatus) { + UserChange userChange = IssuesChangesNotificationBuilderTesting.newUserChange(); + format_set_html_message_with_footer(userChange, issueStatus, c -> c + // skip content + .hasParagraph() // open/closed issue + .hasList() // rule list + ); + } + + @Test + @UseDataProvider("issueStatuses") + public void format_set_html_message_with_footer_when_change_from_analysis(String issueStatus) { + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + format_set_html_message_with_footer(analysisChange, issueStatus, c -> c + // skip content + .hasParagraph() // status + .hasList() // rule list + ); + } + + @DataProvider + public static Object[][] issueStatuses() { + return Arrays.stream(ISSUE_STATUSES) + .map(t -> new Object[] {t}) + .toArray(Object[][]::new); + } + + private void format_set_html_message_with_footer(Change change, String issueStatus, Function skipContent) { + String wordingNotification = randomAlphabetic(20); + String host = randomAlphabetic(15); + String instance = randomAlphabetic(17); + when(i18n.message(Locale.ENGLISH, "notification.dispatcher.ChangesOnMyIssue", "notification.dispatcher.ChangesOnMyIssue")) + .thenReturn(wordingNotification); + when(emailSettings.getServerBaseURL()).thenReturn(host); + when(emailSettings.getInstanceName()).thenReturn(instance); + Project project = newProject("foo"); + Rule rule = newRule("bar"); + Set changedIssues = IntStream.range(0, 2 + new Random().nextInt(4)) + .mapToObj(i -> newChangedIssue(i + "", issueStatus, project, rule)) + .collect(toSet()); + + EmailMessage singleIssueMessage = underTest.format(new ChangesOnMyIssuesNotification( + change, changedIssues.stream().limit(1).collect(toSet()))); + EmailMessage multiIssueMessage = underTest.format(new ChangesOnMyIssuesNotification(change, changedIssues)); + + Stream.of(singleIssueMessage, multiIssueMessage) + .forEach(issueMessage -> { + HtmlParagraphAssert htmlAssert = HtmlFragmentAssert.assertThat(issueMessage.getMessage()) + .hasParagraph().hasParagraph(); // skip header + // skip content + HtmlListAssert htmlListAssert = skipContent.apply(htmlAssert); + + String footerText = "You received this email because you are subscribed to \"" + wordingNotification + "\" notifications from " + instance + "." + + " Click here to edit your email preferences."; + htmlListAssert.hasEmptyParagraph() + .hasParagraph(footerText) + .withSmallOn(footerText) + .withLink("here", host + "/account/notifications") + .noMoreBlock(); + }); + } + + @Test + public void format_set_html_message_with_issues_grouped_by_status_closed_or_any_other_when_change_from_analysis() { + Project project = newProject("foo"); + Rule rule = newRule("bar"); + Set changedIssues = Arrays.stream(ISSUE_STATUSES) + .map(status -> newChangedIssue(status + "", status, project, rule)) + .collect(toSet()); + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, changedIssues)); + + HtmlListAssert htmlListAssert = HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph("Closed issue:") + .withoutLink() + .hasList("Rule " + rule.getName() + " - See the single issue") + .withLinkOn("See the single issue") + .hasParagraph("Open issues:") + .withoutLink() + .hasList("Rule " + rule.getName() + " - See all " + (ISSUE_STATUSES.length - 1) + " issues") + .withLinkOn("See all " + (ISSUE_STATUSES.length - 1) + " issues"); + verifyEnd(htmlListAssert); + } + + @Test + public void format_set_html_message_with_status_title_handles_plural_when_change_from_analysis() { + Project project = newProject("foo"); + Rule rule = newRule("bar"); + Set closedIssues = IntStream.range(0, 2 + new Random().nextInt(5)) + .mapToObj(status -> newChangedIssue(status + "", STATUS_CLOSED, project, rule)) + .collect(toSet()); + Set openIssues = IntStream.range(0, 2 + new Random().nextInt(5)) + .mapToObj(status -> newChangedIssue(status + "", STATUS_OPEN, project, rule)) + .collect(toSet()); + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + + EmailMessage closedIssuesMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, closedIssues)); + EmailMessage openIssuesMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, openIssues)); + + HtmlListAssert htmlListAssert = HtmlFragmentAssert.assertThat(closedIssuesMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph("Closed issues:") + .hasList(); + verifyEnd(htmlListAssert); + htmlListAssert = HtmlFragmentAssert.assertThat(openIssuesMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph("Open issues:") + .hasList(); + verifyEnd(htmlListAssert); + } + + @Test + public void formats_returns_html_message_for_single_issue_on_master_when_analysis_change() { + Project project = newProject("1"); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + ChangedIssue changedIssue = newChangedIssue("key", randomValidStatus(), project, ruleName); + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue))); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph()// skip title based on status + .hasList("Rule " + ruleName + " - See the single issue") + .withLink("See the single issue", host + "/project/issues?id=" + project.getKey() + "&issues=" + changedIssue.getKey() + "&open=" + changedIssue.getKey()) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + public void formats_returns_html_message_for_single_issue_on_master_when_user_change() { + Project project = newProject("1"); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + ChangedIssue changedIssue = newChangedIssue("key", randomValidStatus(), project, ruleName); + UserChange userChange = IssuesChangesNotificationBuilderTesting.newUserChange(); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.of(changedIssue))); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project.getProjectName()) + .hasList("Rule " + ruleName + " - See the single issue") + .withLink("See the single issue", host + "/project/issues?id=" + project.getKey() + "&issues=" + changedIssue.getKey() + "&open=" + changedIssue.getKey()) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + public void formats_returns_html_message_for_single_issue_on_branch_when_analysis_change() { + String branchName = randomAlphabetic(6); + Project project = newBranch("1", branchName); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + String key = "key"; + ChangedIssue changedIssue = newChangedIssue(key, randomValidStatus(), project, ruleName); + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue))); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph()// skip title based on status + .hasList("Rule " + ruleName + " - See the single issue") + .withLink("See the single issue", + host + "/project/issues?id=" + project.getKey() + "&branch=" + branchName + "&issues=" + changedIssue.getKey() + "&open=" + changedIssue.getKey()) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + public void formats_returns_html_message_for_single_issue_on_branch_when_user_change() { + String branchName = randomAlphabetic(6); + Project project = newBranch("1", branchName); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + String key = "key"; + ChangedIssue changedIssue = newChangedIssue(key, randomValidStatus(), project, ruleName); + UserChange userChange = IssuesChangesNotificationBuilderTesting.newUserChange(); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.of(changedIssue))); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project.getProjectName() + ", " + branchName) + .hasList("Rule " + ruleName + " - See the single issue") + .withLink("See the single issue", + host + "/project/issues?id=" + project.getKey() + "&branch=" + branchName + "&issues=" + changedIssue.getKey() + "&open=" + changedIssue.getKey()) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + public void formats_returns_html_message_for_multiple_issues_of_same_rule_on_same_project_on_master_when_analysis_change() { + Project project = newProject("1"); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + Rule rule = newRule(ruleName); + String issueStatus = randomValidStatus(); + List changedIssues = IntStream.range(0, 2 + new Random().nextInt(5)) + .mapToObj(i -> newChangedIssue("issue_" + i, issueStatus, project, rule)) + .collect(toList()); + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.copyOf(changedIssues))); + + String expectedHref = host + "/project/issues?id=" + project.getKey() + + "&issues=" + changedIssues.stream().map(ChangedIssue::getKey).collect(joining("%2C")); + String expectedLinkText = "See all " + changedIssues.size() + " issues"; + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph() // skip title based on status + .hasList("Rule " + ruleName + " - " + expectedLinkText) + .withLink(expectedLinkText, expectedHref) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + public void formats_returns_html_message_for_multiple_issues_of_same_rule_on_same_project_on_master_when_user_change() { + Project project = newProject("1"); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + Rule rule = newRule(ruleName); + List changedIssues = IntStream.range(0, 2 + new Random().nextInt(5)) + .mapToObj(i -> newChangedIssue("issue_" + i, randomValidStatus(), project, rule)) + .collect(toList()); + UserChange userChange = IssuesChangesNotificationBuilderTesting.newUserChange(); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.copyOf(changedIssues))); + + String expectedHref = host + "/project/issues?id=" + project.getKey() + + "&issues=" + changedIssues.stream().map(ChangedIssue::getKey).collect(joining("%2C")); + String expectedLinkText = "See all " + changedIssues.size() + " issues"; + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project.getProjectName()) + .hasList("Rule " + ruleName + " - " + expectedLinkText) + .withLink(expectedLinkText, expectedHref) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + public void formats_returns_html_message_for_multiple_issues_of_same_rule_on_same_project_on_branch_when_analysis_change() { + String branchName = randomAlphabetic(19); + Project project = newBranch("1", branchName); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + Rule rule = newRule(ruleName); + String status = randomValidStatus(); + List changedIssues = IntStream.range(0, 2 + new Random().nextInt(5)) + .mapToObj(i -> newChangedIssue("issue_" + i, status, project, rule)) + .collect(toList()); + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.copyOf(changedIssues))); + + String expectedHref = host + "/project/issues?id=" + project.getKey() + "&branch=" + branchName + + "&issues=" + changedIssues.stream().map(ChangedIssue::getKey).collect(joining("%2C")); + String expectedLinkText = "See all " + changedIssues.size() + " issues"; + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph()// skip title based on status + .hasList("Rule " + ruleName + " - " + expectedLinkText) + .withLink(expectedLinkText, expectedHref) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + public void formats_returns_html_message_for_multiple_issues_of_same_rule_on_same_project_on_branch_when_user_change() { + String branchName = randomAlphabetic(19); + Project project = newBranch("1", branchName); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + Rule rule = newRule(ruleName); + List changedIssues = IntStream.range(0, 2 + new Random().nextInt(5)) + .mapToObj(i -> newChangedIssue("issue_" + i, randomValidStatus(), project, rule)) + .collect(toList()); + UserChange userChange = IssuesChangesNotificationBuilderTesting.newUserChange(); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.copyOf(changedIssues))); + + String expectedHref = host + "/project/issues?id=" + project.getKey() + "&branch=" + branchName + + "&issues=" + changedIssues.stream().map(ChangedIssue::getKey).collect(joining("%2C")); + String expectedLinkText = "See all " + changedIssues.size() + " issues"; + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project.getProjectName() + ", " + branchName) + .hasList("Rule " + ruleName + " - " + expectedLinkText) + .withLink(expectedLinkText, expectedHref) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + public void formats_returns_html_message_with_projects_ordered_by_name_when_user_change() { + Project project1 = newProject("1"); + Project project1Branch1 = newBranch("1", "a"); + Project project1Branch2 = newBranch("1", "b"); + Project project2 = newProject("B"); + Project project2Branch1 = newBranch("B", "a"); + Project project3 = newProject("C"); + String host = randomAlphabetic(15); + List changedIssues = Stream.of(project1, project1Branch1, project1Branch2, project2, project2Branch1, project3) + .map(project -> newChangedIssue("issue_" + project.getUuid(), randomValidStatus(), project, newRule(randomAlphabetic(2)))) + .collect(toList()); + Collections.shuffle(changedIssues); + UserChange userChange = IssuesChangesNotificationBuilderTesting.newUserChange(); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.copyOf(changedIssues))); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project1.getProjectName()) + .hasList() + .hasParagraph(project1Branch1.getProjectName() + ", " + project1Branch1.getBranchName().get()) + .hasList() + .hasParagraph(project1Branch2.getProjectName() + ", " + project1Branch2.getBranchName().get()) + .hasList() + .hasParagraph(project2.getProjectName()) + .hasList() + .hasParagraph(project2Branch1.getProjectName() + ", " + project2Branch1.getBranchName().get()) + .hasList() + .hasParagraph(project3.getProjectName()) + .hasList() + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + public void formats_returns_html_message_with_rules_ordered_by_name_when_analysis_change() { + Project project = newProject("1"); + Rule rule1 = newRule("1"); + Rule rule2 = newRule("a"); + Rule rule3 = newRule("b"); + Rule rule4 = newRule("X"); + String host = randomAlphabetic(15); + String issueStatus = randomValidStatus(); + List changedIssues = Stream.of(rule1, rule2, rule3, rule4) + .map(rule -> newChangedIssue("issue_" + rule.getName(), issueStatus, project, rule)) + .collect(toList()); + Collections.shuffle(changedIssues); + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.copyOf(changedIssues))); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph()// skip title based on status + .hasList( + "Rule " + rule1.getName() + " - See the single issue", + "Rule " + rule2.getName() + " - See the single issue", + "Rule " + rule3.getName() + " - See the single issue", + "Rule " + rule4.getName() + " - See the single issue") + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + public void formats_returns_html_message_with_rules_ordered_by_name_when_analysis_change_when_user_analysis() { + Project project = newProject("1"); + Rule rule1 = newRule("1"); + Rule rule2 = newRule("a"); + Rule rule3 = newRule("b"); + Rule rule4 = newRule("X"); + String host = randomAlphabetic(15); + List changedIssues = Stream.of(rule1, rule2, rule3, rule4) + .map(rule -> newChangedIssue("issue_" + rule.getName(), randomValidStatus(), project, rule)) + .collect(toList()); + Collections.shuffle(changedIssues); + UserChange userChange = IssuesChangesNotificationBuilderTesting.newUserChange(); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.copyOf(changedIssues))); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project.getProjectName()) + .hasList( + "Rule " + rule1.getName() + " - See the single issue", + "Rule " + rule2.getName() + " - See the single issue", + "Rule " + rule3.getName() + " - See the single issue", + "Rule " + rule4.getName() + " - See the single issue") + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + public void formats_returns_html_message_with_multiple_links_by_rule_of_groups_of_up_to_40_issues_when_analysis_change() { + Project project1 = newProject("1"); + Rule rule1 = newRule("1"); + Rule rule2 = newRule("a"); + String host = randomAlphabetic(15); + String issueStatusClosed = STATUS_CLOSED; + String otherIssueStatus = STATUS_RESOLVED; + List changedIssues = Stream.of( + IntStream.range(0, 39).mapToObj(i -> newChangedIssue("39_" + i, issueStatusClosed, project1, rule1)), + IntStream.range(0, 40).mapToObj(i -> newChangedIssue("40_" + i, issueStatusClosed, project1, rule2)), + IntStream.range(0, 81).mapToObj(i -> newChangedIssue("1-40_41-80_1_" + i, otherIssueStatus, project1, rule2)), + IntStream.range(0, 6).mapToObj(i -> newChangedIssue("6_" + i, otherIssueStatus, project1, rule1))) + .flatMap(t -> t) + .collect(toList()); + Collections.shuffle(changedIssues); + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.copyOf(changedIssues))); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph("Closed issues:") // skip title based on status + .hasList( + "Rule " + rule1.getName() + " - See all 39 issues", + "Rule " + rule2.getName() + " - See all 40 issues") + .withLink("See all 39 issues", + host + "/project/issues?id=" + project1.getKey() + + "&issues=" + IntStream.range(0, 39).mapToObj(i -> "39_" + i).sorted().collect(joining("%2C"))) + .withLink("See all 40 issues", + host + "/project/issues?id=" + project1.getKey() + + "&issues=" + IntStream.range(0, 40).mapToObj(i -> "40_" + i).sorted().collect(joining("%2C"))) + .hasParagraph("Open issues:") + .hasList( + "Rule " + rule2.getName() + " - See issues 1-40 41-80 81", + "Rule " + rule1.getName() + " - See all 6 issues") + .withLink("1-40", + host + "/project/issues?id=" + project1.getKey() + + "&issues=" + IntStream.range(0, 81).mapToObj(i -> "1-40_41-80_1_" + i).sorted().limit(40).collect(joining("%2C"))) + .withLink("41-80", + host + "/project/issues?id=" + project1.getKey() + + "&issues=" + IntStream.range(0, 81).mapToObj(i -> "1-40_41-80_1_" + i).sorted().skip(40).limit(40).collect(joining("%2C"))) + .withLink("81", + host + "/project/issues?id=" + project1.getKey() + + "&issues=" + "1-40_41-80_1_9" + "&open=" + "1-40_41-80_1_9") + .withLink("See all 6 issues", + host + "/project/issues?id=" + project1.getKey() + + "&issues=" + IntStream.range(0, 6).mapToObj(i -> "6_" + i).sorted().collect(joining("%2C"))) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + public void formats_returns_html_message_with_multiple_links_by_rule_of_groups_of_up_to_40_issues_when_user_change() { + Project project1 = newProject("1"); + Project project2 = newProject("V"); + Project project2Branch = newBranch("V", "AB"); + Rule rule1 = newRule("1"); + Rule rule2 = newRule("a"); + String status = randomValidStatus(); + String host = randomAlphabetic(15); + List changedIssues = Stream.of( + IntStream.range(0, 39).mapToObj(i -> newChangedIssue("39_" + i, status, project1, rule1)), + IntStream.range(0, 40).mapToObj(i -> newChangedIssue("40_" + i, status, project1, rule2)), + IntStream.range(0, 81).mapToObj(i -> newChangedIssue("1-40_41-80_1_" + i, status, project2, rule2)), + IntStream.range(0, 6).mapToObj(i -> newChangedIssue("6_" + i, status, project2Branch, rule1))) + .flatMap(t -> t) + .collect(toList()); + Collections.shuffle(changedIssues); + UserChange userChange = IssuesChangesNotificationBuilderTesting.newUserChange(); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.copyOf(changedIssues))); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project1.getProjectName()) + .hasList() + .withItemTexts( + "Rule " + rule1.getName() + " - See all 39 issues", + "Rule " + rule2.getName() + " - See all 40 issues") + .withLink("See all 39 issues", + host + "/project/issues?id=" + project1.getKey() + + "&issues=" + IntStream.range(0, 39).mapToObj(i -> "39_" + i).sorted().collect(joining("%2C"))) + .withLink("See all 40 issues", + host + "/project/issues?id=" + project1.getKey() + + "&issues=" + IntStream.range(0, 40).mapToObj(i -> "40_" + i).sorted().collect(joining("%2C"))) + .hasParagraph(project2.getProjectName()) + .hasList("Rule " + rule2.getName() + " - See issues 1-40 41-80 81") + .withLink("1-40", + host + "/project/issues?id=" + project2.getKey() + + "&issues=" + IntStream.range(0, 81).mapToObj(i -> "1-40_41-80_1_" + i).sorted().limit(40).collect(joining("%2C"))) + .withLink("41-80", + host + "/project/issues?id=" + project2.getKey() + + "&issues=" + IntStream.range(0, 81).mapToObj(i -> "1-40_41-80_1_" + i).sorted().skip(40).limit(40).collect(joining("%2C"))) + .withLink("81", + host + "/project/issues?id=" + project2.getKey() + + "&issues=" + "1-40_41-80_1_9" + "&open=" + "1-40_41-80_1_9") + .hasParagraph(project2Branch.getProjectName() + ", " + project2Branch.getBranchName().get()) + .hasList("Rule " + rule1.getName() + " - See all 6 issues") + .withLink("See all 6 issues", + host + "/project/issues?id=" + project2Branch.getKey() + "&branch=" + project2Branch.getBranchName().get() + + "&issues=" + IntStream.range(0, 6).mapToObj(i -> "6_" + i).sorted().collect(joining("%2C"))) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + private static String randomValidStatus() { + return ISSUE_STATUSES[new Random().nextInt(ISSUE_STATUSES.length)]; + } + + private void verifyEnd(HtmlListAssert htmlListAssert) { + htmlListAssert + .hasEmptyParagraph() + .hasParagraph() + .noMoreBlock(); + } + +} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotificationTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotificationTest.java new file mode 100644 index 00000000000..d3bb3fb19f6 --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotificationTest.java @@ -0,0 +1,73 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import com.google.common.collect.ImmutableSet; +import java.util.Random; +import org.junit.Test; +import org.sonar.api.notifications.Notification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; + +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +public class ChangesOnMyIssuesNotificationTest { + @Test + public void key_is_ChangesOnMyIssues() { + ChangesOnMyIssuesNotification underTest = new ChangesOnMyIssuesNotification( + new UserChange(new Random().nextLong(), new User(randomAlphabetic(2), randomAlphabetic(3), randomAlphabetic(4))), + ImmutableSet.of()); + + assertThat(underTest.getType()).isEqualTo("ChangesOnMyIssues"); + } + + @Test + public void equals_is_based_on_change_and_issues() { + AnalysisChange analysisChange = new AnalysisChange(new Random().nextLong()); + ChangedIssue changedIssue = IssuesChangesNotificationBuilderTesting.newChangedIssue("doo", IssuesChangesNotificationBuilderTesting.newProject("prj"), IssuesChangesNotificationBuilderTesting.newRule("rul")); + ChangesOnMyIssuesNotification underTest = new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue)); + + assertThat(underTest) + .isEqualTo(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue))) + .isNotEqualTo(mock(Notification.class)) + .isNotEqualTo(null) + .isNotEqualTo(new ChangesOnMyIssuesNotification(new AnalysisChange(analysisChange.getDate() + 10), ImmutableSet.of(changedIssue))) + .isNotEqualTo(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of())); + } + + @Test + public void hashcode_is_based_on_change_and_issues() { + AnalysisChange analysisChange = new AnalysisChange(new Random().nextLong()); + ChangedIssue changedIssue = IssuesChangesNotificationBuilderTesting.newChangedIssue("doo", IssuesChangesNotificationBuilderTesting.newProject("prj"), IssuesChangesNotificationBuilderTesting.newRule("rul")); + ChangesOnMyIssuesNotification underTest = new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue)); + + assertThat(underTest.hashCode()) + .isEqualTo(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue)).hashCode()) + .isNotEqualTo(mock(Notification.class).hashCode()) + .isNotEqualTo(null) + .isNotEqualTo(new ChangesOnMyIssuesNotification(new AnalysisChange(analysisChange.getDate() + 10), ImmutableSet.of(changedIssue)).hashCode()) + .isNotEqualTo(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of())).hashCode(); + } + +} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/DoNotFixNotificationHandlerTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/DoNotFixNotificationHandlerTest.java deleted file mode 100644 index c7990f33d7b..00000000000 --- a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/DoNotFixNotificationHandlerTest.java +++ /dev/null @@ -1,293 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 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.issue.notification; - -import com.google.common.collect.ImmutableSet; -import com.tngtech.java.junit.dataprovider.DataProvider; -import com.tngtech.java.junit.dataprovider.DataProviderRunner; -import com.tngtech.java.junit.dataprovider.UseDataProvider; -import java.util.Random; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.IntStream; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mockito; -import org.sonar.api.issue.Issue; -import org.sonar.server.notification.NotificationDispatcherMetadata; -import org.sonar.server.notification.NotificationManager; -import org.sonar.server.notification.email.EmailNotificationChannel; -import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest; - -import static java.util.stream.Collectors.toSet; -import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.verifyZeroInteractions; -import static org.mockito.Mockito.when; -import static org.sonar.server.notification.NotificationDispatcherMetadata.GLOBAL_NOTIFICATION; -import static org.sonar.server.notification.NotificationDispatcherMetadata.PER_PROJECT_NOTIFICATION; -import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER; - -@RunWith(DataProviderRunner.class) -public class DoNotFixNotificationHandlerTest { - private static final String DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY = "NewFalsePositiveIssue"; - private NotificationManager notificationManager = mock(NotificationManager.class); - private EmailNotificationChannel emailNotificationChannel = mock(EmailNotificationChannel.class); - private DoNotFixNotificationHandler underTest = new DoNotFixNotificationHandler(notificationManager, emailNotificationChannel); - - @Test - public void getMetadata_returns_same_instance_as_static_method() { - assertThat(underTest.getMetadata().get()).isSameAs(DoNotFixNotificationHandler.newMetadata()); - } - - @Test - public void verify_changeOnMyIssues_notification_dispatcher_key() { - NotificationDispatcherMetadata metadata = DoNotFixNotificationHandler.newMetadata(); - - assertThat(metadata.getDispatcherKey()).isEqualTo(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY); - } - - @Test - public void changeOnMyIssues_notification_is_disabled_at_global_level() { - NotificationDispatcherMetadata metadata = DoNotFixNotificationHandler.newMetadata(); - - assertThat(metadata.getProperty(GLOBAL_NOTIFICATION)).isEqualTo("false"); - } - - @Test - public void changeOnMyIssues_notification_is_enable_at_project_level() { - NotificationDispatcherMetadata metadata = DoNotFixNotificationHandler.newMetadata(); - - assertThat(metadata.getProperty(PER_PROJECT_NOTIFICATION)).isEqualTo("true"); - } - - @Test - public void getNotificationClass_is_IssueChangeNotification() { - assertThat(underTest.getNotificationClass()).isEqualTo(IssueChangeNotification.class); - } - - @Test - public void deliver_has_no_effect_if_emailNotificationChannel_is_disabled() { - when(emailNotificationChannel.isActivated()).thenReturn(false); - Set notifications = IntStream.range(0, 1 + new Random().nextInt(10)) - .mapToObj(i -> mock(IssueChangeNotification.class)) - .collect(toSet()); - - int deliver = underTest.deliver(notifications); - - assertThat(deliver).isZero(); - verifyZeroInteractions(notificationManager); - verify(emailNotificationChannel).isActivated(); - verifyNoMoreInteractions(emailNotificationChannel); - notifications.forEach(Mockito::verifyZeroInteractions); - } - - @Test - public void deliver_has_no_effect_if_no_notification_has_projectKey() { - when(emailNotificationChannel.isActivated()).thenReturn(true); - Set notifications = IntStream.range(0, 1 + new Random().nextInt(10)) - .mapToObj(i -> newNotification(null, null, null)) - .collect(toSet()); - - int deliver = underTest.deliver(notifications); - - assertThat(deliver).isZero(); - verifyZeroInteractions(notificationManager); - verify(emailNotificationChannel).isActivated(); - verifyNoMoreInteractions(emailNotificationChannel); - notifications.forEach(notification -> { - verify(notification).getProjectKey(); - verifyNoMoreInteractions(notification); - }); - } - - @Test - public void deliver_has_no_effect_if_no_notification_has_change_author() { - when(emailNotificationChannel.isActivated()).thenReturn(true); - Set notifications = IntStream.range(0, 1 + new Random().nextInt(10)) - .mapToObj(i -> newNotification(randomAlphabetic(5 + i), null, null)) - .collect(toSet()); - - int deliver = underTest.deliver(notifications); - - assertThat(deliver).isZero(); - verifyZeroInteractions(notificationManager); - verify(emailNotificationChannel).isActivated(); - verifyNoMoreInteractions(emailNotificationChannel); - notifications.forEach(notification -> { - verify(notification).getProjectKey(); - verify(notification).getChangeAuthor(); - verifyNoMoreInteractions(notification); - }); - } - - @Test - public void deliver_has_no_effect_if_no_notification_has_new_resolution() { - when(emailNotificationChannel.isActivated()).thenReturn(true); - Set notifications = IntStream.range(0, 1 + new Random().nextInt(10)) - .mapToObj(i -> newNotification(randomAlphabetic(5 + i), randomAlphabetic(4 + i), null)) - .collect(toSet()); - - int deliver = underTest.deliver(notifications); - - assertThat(deliver).isZero(); - verifyZeroInteractions(notificationManager); - verify(emailNotificationChannel).isActivated(); - verifyNoMoreInteractions(emailNotificationChannel); - notifications.forEach(notification -> { - verify(notification).getProjectKey(); - verify(notification).getChangeAuthor(); - verify(notification).getNewResolution(); - verifyNoMoreInteractions(notification); - }); - } - - @Test - @UseDataProvider("notFPorWontFixResolution") - public void deliver_has_no_effect_if_no_notification_has_FP_or_wont_fix_resolution(String newResolution) { - when(emailNotificationChannel.isActivated()).thenReturn(true); - Set notifications = IntStream.range(0, 1 + new Random().nextInt(10)) - .mapToObj(i -> newNotification(randomAlphabetic(5 + i), randomAlphabetic(4 + i), newResolution)) - .collect(toSet()); - - int deliver = underTest.deliver(notifications); - - assertThat(deliver).isZero(); - verifyZeroInteractions(notificationManager); - verify(emailNotificationChannel).isActivated(); - verifyNoMoreInteractions(emailNotificationChannel); - notifications.forEach(notification -> { - verify(notification).getProjectKey(); - verify(notification).getChangeAuthor(); - verify(notification).getNewResolution(); - verifyNoMoreInteractions(notification); - }); - } - - @DataProvider - public static Object[][] notFPorWontFixResolution() { - return new Object[][] { - {""}, - {randomAlphabetic(9)}, - {Issue.RESOLUTION_FIXED}, - {Issue.RESOLUTION_REMOVED} - }; - } - - @Test - @UseDataProvider("FPorWontFixResolution") - public void deliver_checks_by_projectKey_if_notifications_have_subscribed_assignee_to_FPorWontFix_notifications(String newResolution) { - String projectKey1 = randomAlphabetic(10); - String changeAuthor1 = randomAlphabetic(11); - String projectKey2 = randomAlphabetic(12); - String changeAuthor2 = randomAlphabetic(13); - Set notifications1 = randomSetOfNotifications(projectKey1, changeAuthor1, newResolution); - Set notifications2 = randomSetOfNotifications(projectKey2, changeAuthor2, newResolution); - when(emailNotificationChannel.isActivated()).thenReturn(true); - - int deliver = underTest.deliver(Stream.concat(notifications1.stream(), notifications2.stream()).collect(toSet())); - - assertThat(deliver).isZero(); - verify(notificationManager).findSubscribedEmailRecipients(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY, projectKey1, ALL_MUST_HAVE_ROLE_USER); - verify(notificationManager).findSubscribedEmailRecipients(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY, projectKey2, ALL_MUST_HAVE_ROLE_USER); - verifyNoMoreInteractions(notificationManager); - verify(emailNotificationChannel).isActivated(); - verifyNoMoreInteractions(emailNotificationChannel); - } - - @Test - @UseDataProvider("FPorWontFixResolution") - public void deliver_does_not_send_email_request_for_notifications_a_subscriber_is_the_changeAuthor_of(String newResolution) { - String projectKey = randomAlphabetic(5); - String subscriber1 = randomAlphabetic(6); - String subscriber2 = randomAlphabetic(7); - String subscriber3 = randomAlphabetic(8); - String otherChangeAuthor = randomAlphabetic(9); - // subscriber1 is the changeAuthor of some notifications - Set subscriber1Notifications = randomSetOfNotifications(projectKey, subscriber1, newResolution); - // subscriber2 is the changeAuthor of some notifications - Set subscriber2Notifications = randomSetOfNotifications(projectKey, subscriber2, newResolution); - // subscriber3 has no notification - Set otherChangeAuthorNotifications = randomSetOfNotifications(projectKey, otherChangeAuthor, newResolution); - when(emailNotificationChannel.isActivated()).thenReturn(true); - Set subscribers = ImmutableSet.of(subscriber1, subscriber2, subscriber3); - when(notificationManager.findSubscribedEmailRecipients(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY, projectKey, ALL_MUST_HAVE_ROLE_USER)) - .thenReturn(subscribers.stream().map(DoNotFixNotificationHandlerTest::emailRecipientOf).collect(toSet())); - Set expectedRequests = Stream.of( - subscriber1Notifications.stream().flatMap(notif -> Stream.of(subscriber2, subscriber3).map(login -> new EmailDeliveryRequest(emailOf(login), notif))), - subscriber2Notifications.stream().flatMap(notif -> Stream.of(subscriber1, subscriber3).map(login -> new EmailDeliveryRequest(emailOf(login), notif))), - otherChangeAuthorNotifications.stream().flatMap(notif -> Stream.of(subscriber1, subscriber2, subscriber3).map(login -> new EmailDeliveryRequest(emailOf(login), notif)))) - .flatMap(t -> t) - .collect(toSet()); - int deliveredCount = new Random().nextInt(expectedRequests.size()); - when(emailNotificationChannel.deliverAll(expectedRequests)).thenReturn(deliveredCount); - - Set notifications = Stream.of( - subscriber1Notifications.stream(), - subscriber2Notifications.stream(), - otherChangeAuthorNotifications.stream()) - .flatMap(t -> t) - .collect(toSet()); - int deliver = underTest.deliver(notifications); - - assertThat(deliver).isEqualTo(deliveredCount); - verify(notificationManager).findSubscribedEmailRecipients(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY, projectKey, ALL_MUST_HAVE_ROLE_USER); - verifyNoMoreInteractions(notificationManager); - verify(emailNotificationChannel).isActivated(); - verify(emailNotificationChannel).deliverAll(expectedRequests); - verifyNoMoreInteractions(emailNotificationChannel); - } - - @DataProvider - public static Object[][] FPorWontFixResolution() { - return new Object[][] { - {Issue.RESOLUTION_FALSE_POSITIVE}, - {Issue.RESOLUTION_WONT_FIX} - }; - } - - private static Set randomSetOfNotifications(@Nullable String projectKey, @Nullable String changeAuthor, @Nullable String newResolution) { - return IntStream.range(0, 1 + new Random().nextInt(5)) - .mapToObj(i -> newNotification(projectKey, changeAuthor, newResolution)) - .collect(Collectors.toSet()); - } - - private static IssueChangeNotification newNotification(@Nullable String projectKey, @Nullable String changeAuthor, @Nullable String newResolution) { - IssueChangeNotification notification = mock(IssueChangeNotification.class); - when(notification.getProjectKey()).thenReturn(projectKey); - when(notification.getChangeAuthor()).thenReturn(changeAuthor); - when(notification.getNewResolution()).thenReturn(newResolution); - return notification; - } - - private static NotificationManager.EmailRecipient emailRecipientOf(String assignee1) { - return new NotificationManager.EmailRecipient(assignee1, emailOf(assignee1)); - } - - private static String emailOf(String assignee1) { - return assignee1 + "@baffe"; - } - -} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/EmailMessageTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/EmailMessageTest.java new file mode 100644 index 00000000000..d2758efd297 --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/EmailMessageTest.java @@ -0,0 +1,49 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import org.junit.Test; + +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; +import static org.assertj.core.api.Assertions.assertThat; + +public class EmailMessageTest { + private EmailMessage underTest = new EmailMessage(); + + @Test + public void setHtmlMessage_sets_message_and_html_to_true() { + String message = randomAlphabetic(12); + + underTest.setHtmlMessage(message); + + assertThat(underTest.getMessage()).isEqualTo(message); + assertThat(underTest.isHtml()).isTrue(); + } + + @Test + public void setPlainTextMessage_sets_message_and_html_to_false() { + String message = randomAlphabetic(12); + + underTest.setPlainTextMessage(message); + + assertThat(underTest.getMessage()).isEqualTo(message); + assertThat(underTest.isHtml()).isFalse(); + } +} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandlerTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandlerTest.java new file mode 100644 index 00000000000..b4775a439dc --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandlerTest.java @@ -0,0 +1,498 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ListMultimap; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import java.util.Random; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.sonar.api.issue.Issue; +import org.sonar.api.rule.RuleKey; +import org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; +import org.sonar.server.notification.NotificationDispatcherMetadata; +import org.sonar.server.notification.NotificationManager; +import org.sonar.server.notification.email.EmailNotificationChannel; +import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest; + +import static java.util.Collections.singleton; +import static java.util.stream.Collectors.toSet; +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; +import static org.sonar.api.issue.Issue.RESOLUTION_FALSE_POSITIVE; +import static org.sonar.api.issue.Issue.RESOLUTION_WONT_FIX; +import static org.sonar.core.util.stream.MoreCollectors.index; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newProject; +import static org.sonar.server.notification.NotificationDispatcherMetadata.GLOBAL_NOTIFICATION; +import static org.sonar.server.notification.NotificationDispatcherMetadata.PER_PROJECT_NOTIFICATION; +import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER; + +@RunWith(DataProviderRunner.class) +public class FPOrWontFixNotificationHandlerTest { + private static final String DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY = "NewFalsePositiveIssue"; + private NotificationManager notificationManager = mock(NotificationManager.class); + private EmailNotificationChannel emailNotificationChannel = mock(EmailNotificationChannel.class); + private IssuesChangesNotificationSerializer serializerMock = mock(IssuesChangesNotificationSerializer.class); + private IssuesChangesNotificationSerializer serializer = spy(new IssuesChangesNotificationSerializer()); + private Class> requestSetType = (Class>) (Class) Set.class; + private FPOrWontFixNotificationHandler underTest = new FPOrWontFixNotificationHandler(notificationManager, emailNotificationChannel, serializer); + + @Test + public void getMetadata_returns_same_instance_as_static_method() { + assertThat(underTest.getMetadata().get()).isSameAs(FPOrWontFixNotificationHandler.newMetadata()); + } + + @Test + public void verify_fpOrWontFixIssues_notification_dispatcher_key() { + NotificationDispatcherMetadata metadata = FPOrWontFixNotificationHandler.newMetadata(); + + assertThat(metadata.getDispatcherKey()).isEqualTo(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY); + } + + @Test + public void fpOrWontFixIssues_notification_is_disabled_at_global_level() { + NotificationDispatcherMetadata metadata = FPOrWontFixNotificationHandler.newMetadata(); + + assertThat(metadata.getProperty(GLOBAL_NOTIFICATION)).isEqualTo("false"); + } + + @Test + public void fpOrWontFixIssues_notification_is_enable_at_project_level() { + NotificationDispatcherMetadata metadata = FPOrWontFixNotificationHandler.newMetadata(); + + assertThat(metadata.getProperty(PER_PROJECT_NOTIFICATION)).isEqualTo("true"); + } + + @Test + public void getNotificationClass_is_IssueChangeNotification() { + assertThat(underTest.getNotificationClass()).isEqualTo(IssuesChangesNotification.class); + } + + @Test + public void deliver_has_no_effect_if_emailNotificationChannel_is_disabled() { + when(emailNotificationChannel.isActivated()).thenReturn(false); + Set notifications = IntStream.range(0, 1 + new Random().nextInt(10)) + .mapToObj(i -> mock(IssuesChangesNotification.class)) + .collect(toSet()); + + int deliver = underTest.deliver(notifications); + + assertThat(deliver).isZero(); + verifyZeroInteractions(notificationManager); + verify(emailNotificationChannel).isActivated(); + verifyNoMoreInteractions(emailNotificationChannel); + notifications.forEach(Mockito::verifyZeroInteractions); + } + + @Test + public void deliver_parses_every_notification_in_order() { + Set notifications = IntStream.range(0, 5 + new Random().nextInt(10)) + .mapToObj(i -> mock(IssuesChangesNotification.class)) + .collect(toSet()); + when(emailNotificationChannel.isActivated()).thenReturn(true); + when(serializerMock.from(any(IssuesChangesNotification.class))).thenReturn(mock(IssuesChangesNotificationBuilder.class)); + FPOrWontFixNotificationHandler underTest = new FPOrWontFixNotificationHandler(notificationManager, emailNotificationChannel, serializerMock); + + underTest.deliver(notifications); + + notifications.forEach(notification -> verify(serializerMock).from(notification)); + } + + @Test + public void deliver_fails_with_IAE_if_serializer_throws_IAE() { + Set notifications = IntStream.range(0, 3 + new Random().nextInt(10)) + .mapToObj(i -> mock(IssuesChangesNotification.class)) + .collect(toSet()); + when(emailNotificationChannel.isActivated()).thenReturn(true); + IllegalArgumentException expected = new IllegalArgumentException("faking serializer#from throwing a IllegalArgumentException"); + when(serializerMock.from(any(IssuesChangesNotification.class))) + .thenReturn(mock(IssuesChangesNotificationBuilder.class)) + .thenReturn(mock(IssuesChangesNotificationBuilder.class)) + .thenThrow(expected); + FPOrWontFixNotificationHandler underTest = new FPOrWontFixNotificationHandler(notificationManager, emailNotificationChannel, serializerMock); + + try { + underTest.deliver(notifications); + fail("should have throws IAE"); + } catch (IllegalArgumentException e) { + verify(serializerMock, times(3)).from(any(IssuesChangesNotification.class)); + assertThat(e).isSameAs(expected); + } + } + + @Test + public void deliver_has_no_effect_if_no_issue_has_new_resolution() { + when(emailNotificationChannel.isActivated()).thenReturn(true); + Change changeMock = mock(Change.class); + Set notifications = IntStream.range(0, 2 + new Random().nextInt(5)) + .mapToObj(j -> new IssuesChangesNotificationBuilder(randomIssues(t -> t.setNewResolution(null)).collect(toSet()), changeMock)) + .map(serializer::serialize) + .collect(toSet()); + reset(serializer); + + int deliver = underTest.deliver(notifications); + + assertThat(deliver).isZero(); + verify(serializer, times(notifications.size())).from(any(IssuesChangesNotification.class)); + verifyZeroInteractions(changeMock); + verifyNoMoreInteractions(serializer); + verifyZeroInteractions(notificationManager); + verify(emailNotificationChannel).isActivated(); + verifyNoMoreInteractions(emailNotificationChannel); + } + + @Test + @UseDataProvider("notFPorWontFixResolution") + public void deliver_has_no_effect_if_no_issue_has_FP_or_wontfix_resolution(String newResolution) { + when(emailNotificationChannel.isActivated()).thenReturn(true); + Change changeMock = mock(Change.class); + Set notifications = IntStream.range(0, 2 + new Random().nextInt(5)) + .mapToObj(j -> new IssuesChangesNotificationBuilder(randomIssues(t -> t.setNewResolution(newResolution)).collect(toSet()), changeMock)) + .map(serializer::serialize) + .collect(toSet()); + reset(serializer); + + int deliver = underTest.deliver(notifications); + + assertThat(deliver).isZero(); + verify(serializer, times(notifications.size())).from(any(IssuesChangesNotification.class)); + verifyZeroInteractions(changeMock); + verifyNoMoreInteractions(serializer); + verifyZeroInteractions(notificationManager); + verify(emailNotificationChannel).isActivated(); + verifyNoMoreInteractions(emailNotificationChannel); + } + + @DataProvider + public static Object[][] notFPorWontFixResolution() { + return new Object[][] { + {""}, + {randomAlphabetic(9)}, + {Issue.RESOLUTION_FIXED}, + {Issue.RESOLUTION_REMOVED} + }; + } + + @Test + @UseDataProvider("FPorWontFixResolution") + public void deliver_checks_by_projectKey_if_notifications_have_subscribed_assignee_to_FPorWontFix_notifications(String newResolution) { + Project projectKey1 = newProject(randomAlphabetic(4)); + Project projectKey2 = newProject(randomAlphabetic(5)); + Project projectKey3 = newProject(randomAlphabetic(6)); + Project projectKey4 = newProject(randomAlphabetic(7)); + Change changeMock = mock(Change.class); + // some notifications with some issues on project1 + Stream project1Notifications = IntStream.range(0, 1 + new Random().nextInt(2)) + .mapToObj(j -> new IssuesChangesNotificationBuilder( + randomIssues(t -> t.setProject(projectKey1).setNewResolution(newResolution)).collect(toSet()), + changeMock)); + // some notifications with some issues on project2 + Stream project2Notifications = IntStream.range(0, 1 + new Random().nextInt(2)) + .mapToObj(j -> new IssuesChangesNotificationBuilder( + randomIssues(t -> t.setProject(projectKey2).setNewResolution(newResolution)).collect(toSet()), + changeMock)); + // some notifications with some issues on project3 and project 4 + Stream project3And4Notifications = IntStream.range(0, 1 + new Random().nextInt(2)) + .mapToObj(j -> new IssuesChangesNotificationBuilder( + Stream.concat( + randomIssues(t -> t.setProject(projectKey3).setNewResolution(newResolution)), + randomIssues(t -> t.setProject(projectKey4).setNewResolution(newResolution))) + .collect(toSet()), + changeMock)); + when(emailNotificationChannel.isActivated()).thenReturn(true); + + Set notifications = Stream.of(project1Notifications, project2Notifications, project3And4Notifications) + .flatMap(t -> t) + .map(serializer::serialize) + .collect(toSet()); + int deliver = underTest.deliver(notifications); + + assertThat(deliver).isZero(); + verify(notificationManager).findSubscribedEmailRecipients(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY, projectKey1.getKey(), ALL_MUST_HAVE_ROLE_USER); + verify(notificationManager).findSubscribedEmailRecipients(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY, projectKey2.getKey(), ALL_MUST_HAVE_ROLE_USER); + verify(notificationManager).findSubscribedEmailRecipients(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY, projectKey3.getKey(), ALL_MUST_HAVE_ROLE_USER); + verify(notificationManager).findSubscribedEmailRecipients(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY, projectKey4.getKey(), ALL_MUST_HAVE_ROLE_USER); + verifyNoMoreInteractions(notificationManager); + verify(emailNotificationChannel).isActivated(); + verifyNoMoreInteractions(emailNotificationChannel); + verifyZeroInteractions(changeMock); + } + + @Test + @UseDataProvider("FPorWontFixResolution") + public void deliver_does_not_send_email_request_for_notifications_a_subscriber_is_the_changeAuthor_of(String newResolution) { + Project project = newProject(randomAlphabetic(5)); + User subscriber1 = newUser("subscriber1"); + User subscriber2 = newUser("subscriber2"); + User subscriber3 = newUser("subscriber3"); + User otherChangeAuthor = newUser("otherChangeAuthor"); + + // subscriber1 is the changeAuthor of some notifications with issues assigned to subscriber1 only + Set subscriber1Notifications = IntStream.range(0, 1 + new Random().nextInt(2)) + .mapToObj(j -> new IssuesChangesNotificationBuilder( + randomIssues(t -> t.setProject(project).setNewResolution(newResolution).setAssignee(subscriber2)).collect(toSet()), + newUserChange(subscriber1))) + .collect(toSet()); + // subscriber1 is the changeAuthor of some notifications with issues assigned to subscriber1 and subscriber2 + Set subscriber1and2Notifications = IntStream.range(0, 1 + new Random().nextInt(2)) + .mapToObj(j -> new IssuesChangesNotificationBuilder( + Stream.concat( + randomIssues(t -> t.setProject(project).setNewResolution(newResolution).setAssignee(subscriber2)), + randomIssues(t -> t.setProject(project).setNewResolution(newResolution).setAssignee(subscriber1))) + .collect(toSet()), + newUserChange(subscriber1))) + .collect(toSet()); + // subscriber2 is the changeAuthor of some notifications with issues assigned to subscriber2 only + Set subscriber2Notifications = IntStream.range(0, 1 + new Random().nextInt(2)) + .mapToObj(j -> new IssuesChangesNotificationBuilder( + randomIssues(t -> t.setProject(project).setNewResolution(newResolution).setAssignee(subscriber2)).collect(toSet()), + newUserChange(subscriber2))) + .collect(toSet()); + // subscriber2 is the changeAuthor of some notifications with issues assigned to subscriber2 and subscriber 3 + Set subscriber2And3Notifications = IntStream.range(0, 1 + new Random().nextInt(2)) + .mapToObj(j -> new IssuesChangesNotificationBuilder( + Stream.concat( + randomIssues(t -> t.setProject(project).setNewResolution(newResolution).setAssignee(subscriber2)), + randomIssues(t -> t.setProject(project).setNewResolution(newResolution).setAssignee(subscriber3))) + .collect(toSet()), + newUserChange(subscriber2))) + .collect(toSet()); + // subscriber3 is the changeAuthor of no notification + // otherChangeAuthor has some notifications + Set otherChangeAuthorNotifications = IntStream.range(0, 1 + new Random().nextInt(2)) + .mapToObj(j -> new IssuesChangesNotificationBuilder(randomIssues(t -> t.setProject(project).setNewResolution(newResolution)).collect(toSet()), + newUserChange(otherChangeAuthor))) + .collect(toSet()); + when(emailNotificationChannel.isActivated()).thenReturn(true); + + Set subscriberLogins = ImmutableSet.of(subscriber1.getLogin(), subscriber2.getLogin(), subscriber3.getLogin()); + when(notificationManager.findSubscribedEmailRecipients(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY, project.getKey(), ALL_MUST_HAVE_ROLE_USER)) + .thenReturn(subscriberLogins.stream().map(FPOrWontFixNotificationHandlerTest::emailRecipientOf).collect(toSet())); + + int deliveredCount = new Random().nextInt(200); + when(emailNotificationChannel.deliverAll(anySet())) + .thenReturn(deliveredCount) + .thenThrow(new IllegalStateException("deliver should be called only once")); + + Set notifications = Stream.of( + subscriber1Notifications.stream(), + subscriber1and2Notifications.stream(), + subscriber2Notifications.stream(), + subscriber2And3Notifications.stream(), + otherChangeAuthorNotifications.stream()) + .flatMap(t -> t) + .map(serializer::serialize) + .collect(toSet()); + reset(serializer); + + int deliver = underTest.deliver(notifications); + + assertThat(deliver).isEqualTo(deliveredCount); + verify(notificationManager).findSubscribedEmailRecipients(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY, project.getKey(), ALL_MUST_HAVE_ROLE_USER); + verifyNoMoreInteractions(notificationManager); + verify(emailNotificationChannel).isActivated(); + ArgumentCaptor> captor = ArgumentCaptor.forClass(requestSetType); + verify(emailNotificationChannel).deliverAll(captor.capture()); + verifyNoMoreInteractions(emailNotificationChannel); + ListMultimap requestsByRecipientEmail = captor.getValue().stream() + .collect(index(EmailDeliveryRequest::getRecipientEmail)); + assertThat(requestsByRecipientEmail.get(emailOf(subscriber1.getLogin()))) + .containsOnly( + Stream.of( + subscriber2Notifications.stream() + .map(notif -> newEmailDeliveryRequest(notif, subscriber1, toFpOrWontFix(newResolution))), + subscriber2And3Notifications.stream() + .map(notif -> newEmailDeliveryRequest(notif, subscriber1, toFpOrWontFix(newResolution))), + otherChangeAuthorNotifications.stream() + .map(notif -> newEmailDeliveryRequest(notif, subscriber1, toFpOrWontFix(newResolution)))) + .flatMap(t -> t) + .toArray(EmailDeliveryRequest[]::new)); + assertThat(requestsByRecipientEmail.get(emailOf(subscriber2.getLogin()))) + .containsOnly( + Stream.of( + subscriber1Notifications.stream() + .map(notif -> newEmailDeliveryRequest(notif, subscriber2, toFpOrWontFix(newResolution))), + subscriber1and2Notifications.stream() + .map(notif -> newEmailDeliveryRequest(notif, subscriber2, toFpOrWontFix(newResolution))), + otherChangeAuthorNotifications.stream() + .map(notif -> newEmailDeliveryRequest(notif, subscriber2, toFpOrWontFix(newResolution)))) + .flatMap(t -> t) + .toArray(EmailDeliveryRequest[]::new)); + assertThat(requestsByRecipientEmail.get(emailOf(subscriber3.getLogin()))) + .containsOnly( + Stream.of( + subscriber1Notifications.stream() + .map(notif -> newEmailDeliveryRequest(notif, subscriber3, toFpOrWontFix(newResolution))), + subscriber1and2Notifications.stream() + .map(notif -> newEmailDeliveryRequest(notif, subscriber3, toFpOrWontFix(newResolution))), + subscriber2Notifications.stream() + .map(notif -> newEmailDeliveryRequest(notif, subscriber3, toFpOrWontFix(newResolution))), + subscriber2And3Notifications.stream() + .map(notif -> newEmailDeliveryRequest(notif, subscriber3, toFpOrWontFix(newResolution))), + otherChangeAuthorNotifications.stream() + .map(notif -> newEmailDeliveryRequest(notif, subscriber3, toFpOrWontFix(newResolution)))) + .flatMap(t -> t) + .toArray(EmailDeliveryRequest[]::new)); + assertThat(requestsByRecipientEmail.get(emailOf(otherChangeAuthor.getLogin()))) + .isEmpty(); + } + + @Test + @UseDataProvider("oneOrMoreProjectCounts") + public void deliver_send_a_separated_email_request_for_FPs_and_Wont_Fix_issues(int projectCount) { + Set projects = IntStream.range(0, projectCount).mapToObj(i -> newProject("prk_key_" + i)).collect(toSet()); + User subscriber1 = newUser("subscriber1"); + User changeAuthor = newUser("changeAuthor"); + + Set fpIssues = projects.stream() + .flatMap(project -> randomIssues(t -> t.setProject(project).setNewResolution(RESOLUTION_FALSE_POSITIVE).setAssignee(subscriber1))) + .collect(toSet()); + Set wontFixIssues = projects.stream() + .flatMap(project -> randomIssues(t -> t.setProject(project).setNewResolution(RESOLUTION_WONT_FIX).setAssignee(subscriber1))) + .collect(toSet()); + UserChange userChange = newUserChange(changeAuthor); + IssuesChangesNotificationBuilder fpAndWontFixNotifications = new IssuesChangesNotificationBuilder( + Stream.concat(fpIssues.stream(), wontFixIssues.stream()).collect(toSet()), + userChange); + when(emailNotificationChannel.isActivated()).thenReturn(true); + projects.forEach(project -> when(notificationManager.findSubscribedEmailRecipients(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY, project.getKey(), ALL_MUST_HAVE_ROLE_USER)) + .thenReturn(singleton(emailRecipientOf(subscriber1.getLogin())))); + + int deliveredCount = new Random().nextInt(200); + when(emailNotificationChannel.deliverAll(anySet())) + .thenReturn(deliveredCount) + .thenThrow(new IllegalStateException("deliver should be called only once")); + Set notifications = singleton(serializer.serialize(fpAndWontFixNotifications)); + reset(serializer); + + int deliver = underTest.deliver(notifications); + + assertThat(deliver).isEqualTo(deliveredCount); + projects + .forEach(project -> verify(notificationManager).findSubscribedEmailRecipients(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY, project.getKey(), ALL_MUST_HAVE_ROLE_USER)); + verifyNoMoreInteractions(notificationManager); + verify(emailNotificationChannel).isActivated(); + ArgumentCaptor> captor = ArgumentCaptor.forClass(requestSetType); + verify(emailNotificationChannel).deliverAll(captor.capture()); + verifyNoMoreInteractions(emailNotificationChannel); + ListMultimap requestsByRecipientEmail = captor.getValue().stream() + .collect(index(EmailDeliveryRequest::getRecipientEmail)); + assertThat(requestsByRecipientEmail.get(emailOf(subscriber1.getLogin()))) + .containsOnly( + new EmailDeliveryRequest(emailOf(subscriber1.getLogin()), new FPOrWontFixNotification( + userChange, wontFixIssues, FpOrWontFix.WONT_FIX)), + new EmailDeliveryRequest(emailOf(subscriber1.getLogin()), new FPOrWontFixNotification( + userChange, fpIssues, FpOrWontFix.FP))); + } + + @DataProvider + public static Object[][] oneOrMoreProjectCounts() { + return new Object[][] { + {1}, + {2 + new Random().nextInt(3)}, + }; + } + + private static EmailDeliveryRequest newEmailDeliveryRequest(IssuesChangesNotificationBuilder notif, User user, FpOrWontFix resolution) { + return new EmailDeliveryRequest( + emailOf(user.getLogin()), + new FPOrWontFixNotification(notif.getChange(), notif.getIssues(), resolution)); + } + + private static FpOrWontFix toFpOrWontFix(String newResolution) { + if (newResolution.equals(Issue.RESOLUTION_WONT_FIX)) { + return FpOrWontFix.WONT_FIX; + } + if (newResolution.equals(RESOLUTION_FALSE_POSITIVE)) { + return FpOrWontFix.FP; + } + throw new IllegalArgumentException("unsupported resolution " + newResolution); + } + + private static long counter = 233_343; + + private static UserChange newUserChange(User subscriber1) { + return new UserChange(counter += 100, subscriber1); + } + + public User newUser(String subscriber1) { + return new User(subscriber1, subscriber1 + "_login", subscriber1 + "_name"); + } + + @DataProvider + public static Object[][] FPorWontFixResolution() { + return new Object[][] { + {RESOLUTION_FALSE_POSITIVE}, + {Issue.RESOLUTION_WONT_FIX} + }; + } + + private static Stream randomIssues(Consumer consumer) { + return IntStream.range(0, 1 + new Random().nextInt(5)) + .mapToObj(i -> { + ChangedIssue.Builder builder = new ChangedIssue.Builder("key_" + i) + .setAssignee(new User(randomAlphabetic(3), randomAlphabetic(4), randomAlphabetic(5))) + .setNewStatus(randomAlphabetic(12)) + .setNewResolution(randomAlphabetic(13)) + .setRule(new Rule(RuleKey.of(randomAlphabetic(6), randomAlphabetic(7)), randomAlphabetic(8))) + .setProject(new Project.Builder(randomAlphabetic(9)) + .setKey(randomAlphabetic(10)) + .setProjectName(randomAlphabetic(11)) + .build()); + consumer.accept(builder); + return builder.build(); + }); + } + + private static NotificationManager.EmailRecipient emailRecipientOf(String assignee1) { + return new NotificationManager.EmailRecipient(assignee1, emailOf(assignee1)); + } + + private static String emailOf(String assignee1) { + return assignee1 + "@baffe"; + } + +} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationTest.java new file mode 100644 index 00000000000..cd9145704a3 --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationTest.java @@ -0,0 +1,92 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import com.google.common.collect.ImmutableSet; +import java.util.Collections; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.Test; +import org.sonar.api.rule.RuleKey; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.FP; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.WONT_FIX; + +public class FPOrWontFixNotificationTest { + @Test + public void equals_is_based_on_issues_change_and_resolution() { + Rule rule = new Rule(RuleKey.of("repo", "rule_key"), "rule_name"); + Project project = new Project.Builder("prj_uuid").setKey("prj_key").setProjectName("prj_name").build(); + Set changedIssues = IntStream.range(0, 2 + new Random().nextInt(5)) + .mapToObj(i -> new ChangedIssue.Builder("key_" + i) + .setNewStatus("status") + .setRule(rule) + .setProject(project) + .build()) + .collect(Collectors.toSet()); + AnalysisChange change = new AnalysisChange(12); + User user = new User("uuid", "login", null); + FPOrWontFixNotification underTest = new FPOrWontFixNotification(change, changedIssues, WONT_FIX); + + assertThat(underTest) + .isEqualTo(new FPOrWontFixNotification(change, changedIssues, WONT_FIX)) + .isEqualTo(new FPOrWontFixNotification(change, ImmutableSet.copyOf(changedIssues), WONT_FIX)) + .isNotEqualTo(new Object()) + .isNotEqualTo(null) + .isNotEqualTo(new FPOrWontFixNotification(change, Collections.emptySet(), WONT_FIX)) + .isNotEqualTo(new FPOrWontFixNotification(change, ImmutableSet.of(changedIssues.iterator().next()), WONT_FIX)) + .isNotEqualTo(new FPOrWontFixNotification(new AnalysisChange(14), changedIssues, WONT_FIX)) + .isNotEqualTo(new FPOrWontFixNotification(new IssuesChangesNotificationBuilder.UserChange(12, user), changedIssues, WONT_FIX)) + .isNotEqualTo(new FPOrWontFixNotification(change, changedIssues, FP)); + } + @Test + public void hashcode_is_based_on_issues_change_and_resolution() { + Rule rule = new Rule(RuleKey.of("repo", "rule_key"), "rule_name"); + Project project = new Project.Builder("prj_uuid").setKey("prj_key").setProjectName("prj_name").build(); + Set changedIssues = IntStream.range(0, 2 + new Random().nextInt(5)) + .mapToObj(i -> new ChangedIssue.Builder("key_" + i) + .setNewStatus("status") + .setRule(rule) + .setProject(project) + .build()) + .collect(Collectors.toSet()); + AnalysisChange change = new AnalysisChange(12); + User user = new User("uuid", "login", null); + FPOrWontFixNotification underTest = new FPOrWontFixNotification(change, changedIssues, WONT_FIX); + + assertThat(underTest.hashCode()) + .isEqualTo(new FPOrWontFixNotification(change, changedIssues, WONT_FIX).hashCode()) + .isEqualTo(new FPOrWontFixNotification(change, ImmutableSet.copyOf(changedIssues), WONT_FIX).hashCode()) + .isNotEqualTo(new Object().hashCode()) + .isNotEqualTo(new FPOrWontFixNotification(change, Collections.emptySet(), WONT_FIX).hashCode()) + .isNotEqualTo(new FPOrWontFixNotification(change, ImmutableSet.of(changedIssues.iterator().next()), WONT_FIX).hashCode()) + .isNotEqualTo(new FPOrWontFixNotification(new AnalysisChange(14), changedIssues, WONT_FIX).hashCode()) + .isNotEqualTo(new FPOrWontFixNotification(new IssuesChangesNotificationBuilder.UserChange(12, user), changedIssues, WONT_FIX).hashCode()) + .isNotEqualTo(new FPOrWontFixNotification(change, changedIssues, FP)).hashCode(); + } +} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplateTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplateTest.java new file mode 100644 index 00000000000..038a65d7c6c --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplateTest.java @@ -0,0 +1,421 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import com.google.common.collect.ImmutableSet; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Random; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.sonar.api.config.EmailSettings; +import org.sonar.api.i18n.I18n; +import org.sonar.api.notifications.Notification; +import org.sonar.api.rule.RuleKey; +import org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; +import org.sonar.test.html.HtmlFragmentAssert; + +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toList; +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.FP; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.WONT_FIX; + +@RunWith(DataProviderRunner.class) +public class FpOrWontFixEmailTemplateTest { + private I18n i18n = mock(I18n.class); + private EmailSettings emailSettings = mock(EmailSettings.class); + private FpOrWontFixEmailTemplate underTest = new FpOrWontFixEmailTemplate(i18n, emailSettings); + + @Test + public void format_returns_null_on_Notification() { + EmailMessage emailMessage = underTest.format(mock(Notification.class)); + + assertThat(emailMessage).isNull(); + } + + @Test + public void format_sets_message_id_specific_to_fp() { + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(mock(Change.class), Collections.emptySet(), FP)); + + assertThat(emailMessage.getMessageId()).isEqualTo("fp-issue-changes"); + } + + @Test + public void format_sets_message_id_specific_to_wont_fix() { + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(mock(Change.class), Collections.emptySet(), WONT_FIX)); + + assertThat(emailMessage.getMessageId()).isEqualTo("wontfix-issue-changes"); + } + + @Test + public void format_sets_subject_specific_to_fp() { + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(mock(Change.class), Collections.emptySet(), FP)); + + assertThat(emailMessage.getSubject()).isEqualTo("Issues marked as False Positive"); + } + + @Test + public void format_sets_subject_specific_to_wont_fix() { + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(mock(Change.class), Collections.emptySet(), WONT_FIX)); + + assertThat(emailMessage.getSubject()).isEqualTo("Issues marked as Won't Fix"); + } + + @Test + public void format_sets_from_to_name_of_author_change_when_available() { + UserChange change = new UserChange(new Random().nextLong(), new User(randomAlphabetic(5), randomAlphabetic(6), randomAlphabetic(7))); + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, Collections.emptySet(), WONT_FIX)); + + assertThat(emailMessage.getFrom()).isEqualTo(change.getUser().getName().get()); + } + + @Test + public void format_sets_from_to_login_of_author_change_when_name_is_not_available() { + UserChange change = new UserChange(new Random().nextLong(), new User(randomAlphabetic(5), randomAlphabetic(6), null)); + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, Collections.emptySet(), WONT_FIX)); + + assertThat(emailMessage.getFrom()).isEqualTo(change.getUser().getLogin()); + } + + @Test + public void format_sets_from_to_null_when_analysisChange() { + AnalysisChange change = new AnalysisChange(new Random().nextLong()); + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, Collections.emptySet(), WONT_FIX)); + + assertThat(emailMessage.getFrom()).isNull(); + } + + @Test + @UseDataProvider("userOrAnalysisChange") + public void formats_returns_html_message_with_only_footer_and_header_when_no_issue_for_FPs(Change change) { + formats_returns_html_message_with_only_footer_and_header_when_no_issue(change, FP, "False Positive"); + } + + @Test + @UseDataProvider("userOrAnalysisChange") + public void formats_returns_html_message_with_only_footer_and_header_when_no_issue_for_Wont_fixs(Change change) { + formats_returns_html_message_with_only_footer_and_header_when_no_issue(change, WONT_FIX, "Won't Fix"); + } + + public void formats_returns_html_message_with_only_footer_and_header_when_no_issue(Change change, FpOrWontFix fpOrWontFix, String fpOrWontFixLabel) { + String wordingNotification = randomAlphabetic(20); + String host = randomAlphabetic(15); + String instance = randomAlphabetic(17); + when(i18n.message(Locale.ENGLISH, "notification.dispatcher.NewFalsePositiveIssue", "notification.dispatcher.NewFalsePositiveIssue")) + .thenReturn(wordingNotification); + when(emailSettings.getServerBaseURL()).thenReturn(host); + when(emailSettings.getInstanceName()).thenReturn(instance); + + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, Collections.emptySet(), fpOrWontFix)); + + String footerText = "You received this email because you are subscribed to \"" + wordingNotification + "\" notifications from " + instance + "." + + " Click here to edit your email preferences."; + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph("Hi,") + .withoutLink() + .hasParagraph("A manual change has resolved an issue as " + fpOrWontFixLabel + ":") + .withoutLink() + .hasEmptyParagraph() + .hasParagraph(footerText) + .withSmallOn(footerText) + .withLink("here", host + "/account/notifications") + .noMoreBlock(); + } + + @Test + @UseDataProvider("fpOrWontFixValuesByUserOrAnalysisChange") + public void formats_returns_html_message_for_single_issue_on_master(Change change, FpOrWontFix fpOrWontFix) { + Project project = newProject("1"); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + ChangedIssue changedIssue = newChangedIssue("key", project, ruleName); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, ImmutableSet.of(changedIssue), fpOrWontFix)); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project.getProjectName()) + .hasList("Rule " + ruleName + " - See the single issue") + .withLink("See the single issue", host + "/project/issues?id=" + project.getKey() + "&issues=" + changedIssue.getKey() + "&open=" + changedIssue.getKey()) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + @UseDataProvider("fpOrWontFixValuesByUserOrAnalysisChange") + public void formats_returns_html_message_for_single_issue_on_branch(Change change, FpOrWontFix fpOrWontFix) { + String branchName = randomAlphabetic(6); + Project project = newBranch("1", branchName); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + String key = "key"; + ChangedIssue changedIssue = newChangedIssue(key, project, ruleName); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, ImmutableSet.of(changedIssue), fpOrWontFix)); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project.getProjectName() + ", " + branchName) + .hasList("Rule " + ruleName + " - See the single issue") + .withLink("See the single issue", + host + "/project/issues?id=" + project.getKey() + "&branch=" + branchName + "&issues=" + changedIssue.getKey() + "&open=" + changedIssue.getKey()) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + @UseDataProvider("fpOrWontFixValuesByUserOrAnalysisChange") + public void formats_returns_html_message_for_multiple_issues_of_same_rule_on_same_project_on_master(Change change, FpOrWontFix fpOrWontFix) { + Project project = newProject("1"); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + Rule rule = newRule(ruleName); + List changedIssues = IntStream.range(0, 2 + new Random().nextInt(5)) + .mapToObj(i -> newChangedIssue("issue_" + i, project, rule)) + .collect(toList()); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, ImmutableSet.copyOf(changedIssues), fpOrWontFix)); + + String expectedHref = host + "/project/issues?id=" + project.getKey() + + "&issues=" + changedIssues.stream().map(ChangedIssue::getKey).collect(joining("%2C")); + String expectedLinkText = "See all " + changedIssues.size() + " issues"; + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project.getProjectName()) + .hasList("Rule " + ruleName + " - " + expectedLinkText) + .withLink(expectedLinkText, expectedHref) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + @UseDataProvider("fpOrWontFixValuesByUserOrAnalysisChange") + public void formats_returns_html_message_for_multiple_issues_of_same_rule_on_same_project_on_branch(Change change, FpOrWontFix fpOrWontFix) { + String branchName = randomAlphabetic(19); + Project project = newBranch("1", branchName); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + Rule rule = newRule(ruleName); + List changedIssues = IntStream.range(0, 2 + new Random().nextInt(5)) + .mapToObj(i -> newChangedIssue("issue_" + i, project, rule)) + .collect(toList()); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, ImmutableSet.copyOf(changedIssues), fpOrWontFix)); + + String expectedHref = host + "/project/issues?id=" + project.getKey() + "&branch=" + branchName + + "&issues=" + changedIssues.stream().map(ChangedIssue::getKey).collect(joining("%2C")); + String expectedLinkText = "See all " + changedIssues.size() + " issues"; + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project.getProjectName() + ", " + branchName) + .hasList("Rule " + ruleName + " - " + expectedLinkText) + .withLink(expectedLinkText, expectedHref) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + @UseDataProvider("fpOrWontFixValuesByUserOrAnalysisChange") + public void formats_returns_html_message_with_projects_ordered_by_name(Change change, FpOrWontFix fpOrWontFix) { + Project project1 = newProject("1"); + Project project1Branch1 = newBranch("1", "a"); + Project project1Branch2 = newBranch("1", "b"); + Project project2 = newProject("B"); + Project project2Branch1 = newBranch("B", "a"); + Project project3 = newProject("C"); + String host = randomAlphabetic(15); + List changedIssues = Stream.of(project1, project1Branch1, project1Branch2, project2, project2Branch1, project3) + .map(project -> newChangedIssue("issue_" + project.getUuid(), project, newRule(randomAlphabetic(2)))) + .collect(toList()); + Collections.shuffle(changedIssues); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, ImmutableSet.copyOf(changedIssues), fpOrWontFix)); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project1.getProjectName()) + .hasList() + .hasParagraph(project1Branch1.getProjectName() + ", " + project1Branch1.getBranchName().get()) + .hasList() + .hasParagraph(project1Branch2.getProjectName() + ", " + project1Branch2.getBranchName().get()) + .hasList() + .hasParagraph(project2.getProjectName()) + .hasList() + .hasParagraph(project2Branch1.getProjectName() + ", " + project2Branch1.getBranchName().get()) + .hasList() + .hasParagraph(project3.getProjectName()) + .hasList() + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + @UseDataProvider("fpOrWontFixValuesByUserOrAnalysisChange") + public void formats_returns_html_message_with_rules_ordered_by_name(Change change, FpOrWontFix fpOrWontFix) { + Project project = newProject("1"); + Rule rule1 = newRule("1"); + Rule rule2 = newRule("a"); + Rule rule3 = newRule("b"); + Rule rule4 = newRule("X"); + String host = randomAlphabetic(15); + List changedIssues = Stream.of(rule1, rule2, rule3, rule4) + .map(rule -> newChangedIssue("issue_" + rule.getName(), project, rule)) + .collect(toList()); + Collections.shuffle(changedIssues); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, ImmutableSet.copyOf(changedIssues), fpOrWontFix)); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project.getProjectName()) + .hasList( + "Rule " + rule1.getName() + " - See the single issue", + "Rule " + rule2.getName() + " - See the single issue", + "Rule " + rule3.getName() + " - See the single issue", + "Rule " + rule4.getName() + " - See the single issue") + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + @UseDataProvider("fpOrWontFixValuesByUserOrAnalysisChange") + public void formats_returns_html_message_with_multiple_links_by_rule_of_groups_of_up_to_40_issues(Change change, FpOrWontFix fpOrWontFix) { + Project project1 = newProject("1"); + Project project2 = newProject("V"); + Project project2Branch = newBranch("V", "AB"); + Rule rule1 = newRule("1"); + Rule rule2 = newRule("a"); + String host = randomAlphabetic(15); + List changedIssues = Stream.of( + IntStream.range(0, 39).mapToObj(i -> newChangedIssue("39_" + i, project1, rule1)), + IntStream.range(0, 40).mapToObj(i -> newChangedIssue("40_" + i, project1, rule2)), + IntStream.range(0, 81).mapToObj(i -> newChangedIssue("1-40_41-80_1_" + i, project2, rule2)), + IntStream.range(0, 6).mapToObj(i -> newChangedIssue("6_" + i, project2Branch, rule1))) + .flatMap(t -> t) + .collect(toList()); + Collections.shuffle(changedIssues); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, ImmutableSet.copyOf(changedIssues), fpOrWontFix)); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project1.getProjectName()) + .hasList() + .withItemTexts( + "Rule " + rule1.getName() + " - See all 39 issues", + "Rule " + rule2.getName() + " - See all 40 issues") + .withLink("See all 39 issues", + host + "/project/issues?id=" + project1.getKey() + + "&issues=" + IntStream.range(0, 39).mapToObj(i -> "39_" + i).sorted().collect(joining("%2C"))) + .withLink("See all 40 issues", + host + "/project/issues?id=" + project1.getKey() + + "&issues=" + IntStream.range(0, 40).mapToObj(i -> "40_" + i).sorted().collect(joining("%2C"))) + .hasParagraph(project2.getProjectName()) + .hasList("Rule " + rule2.getName() + " - See issues 1-40 41-80 81") + .withLink("1-40", + host + "/project/issues?id=" + project2.getKey() + + "&issues=" + IntStream.range(0, 81).mapToObj(i -> "1-40_41-80_1_" + i).sorted().limit(40).collect(joining("%2C"))) + .withLink("41-80", + host + "/project/issues?id=" + project2.getKey() + + "&issues=" + IntStream.range(0, 81).mapToObj(i -> "1-40_41-80_1_" + i).sorted().skip(40).limit(40).collect(joining("%2C"))) + .withLink("81", + host + "/project/issues?id=" + project2.getKey() + + "&issues=" + "1-40_41-80_1_9" + "&open=" + "1-40_41-80_1_9") + .hasParagraph(project2Branch.getProjectName() + ", " + project2Branch.getBranchName().get()) + .hasList("Rule " + rule1.getName() + " - See all 6 issues") + .withLink("See all 6 issues", + host + "/project/issues?id=" + project2Branch.getKey() + "&branch=" + project2Branch.getBranchName().get() + + "&issues=" + IntStream.range(0, 6).mapToObj(i -> "6_" + i).sorted().collect(joining("%2C"))) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @DataProvider + public static Object[][] userOrAnalysisChange() { + AnalysisChange analysisChange = new AnalysisChange(new Random().nextLong()); + UserChange userChange = new UserChange(new Random().nextLong(), new User(randomAlphabetic(5), randomAlphabetic(6), + new Random().nextBoolean() ? null : randomAlphabetic(7))); + return new Object[][] { + {analysisChange}, + {userChange} + }; + } + + @DataProvider + public static Object[][] fpOrWontFixValuesByUserOrAnalysisChange() { + AnalysisChange analysisChange = new AnalysisChange(new Random().nextLong()); + UserChange userChange = new UserChange(new Random().nextLong(), new User(randomAlphabetic(5), randomAlphabetic(6), + new Random().nextBoolean() ? null : randomAlphabetic(7))); + return new Object[][] { + {analysisChange, FP}, + {analysisChange, WONT_FIX}, + {userChange, FP}, + {userChange, WONT_FIX} + }; + } + + private static ChangedIssue newChangedIssue(String key, Project project, String ruleName) { + return newChangedIssue(key, project, newRule(ruleName)); + } + + private static ChangedIssue newChangedIssue(String key, Project project, Rule rule) { + return new ChangedIssue.Builder(key) + .setNewStatus(randomAlphabetic(19)) + .setProject(project) + .setRule(rule) + .build(); + } + + private static Rule newRule(String ruleName) { + return new Rule(RuleKey.of(randomAlphabetic(6), randomAlphabetic(7)), ruleName); + } + + private static Project newProject(String uuid) { + return new Project.Builder(uuid).setProjectName(uuid + "_name").setKey(uuid + "_key").build(); + } + + private static Project newBranch(String uuid, String branchName) { + return new Project.Builder(uuid).setProjectName(uuid + "_name").setKey(uuid + "_key").setBranchName(branchName).build(); + } +} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangeNotificationTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangeNotificationTest.java deleted file mode 100644 index 5ac3675aa3e..00000000000 --- a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangeNotificationTest.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 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.issue.notification; - -import org.junit.Test; -import org.sonar.core.issue.DefaultIssue; -import org.sonar.core.issue.FieldDiffs; -import org.sonar.db.component.ComponentDto; -import org.sonar.db.user.UserDto; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.sonar.db.user.UserTesting.newUserDto; - -public class IssueChangeNotificationTest { - - private IssueChangeNotification notification = new IssueChangeNotification(); - - @Test - public void getProjectKey_returns_null_when_project_is_not_set() { - assertThat(notification.getProjectKey()).isNull(); - } - - @Test - public void getChangeAuthor_returns_null_when_issue_is_not_set() { - assertThat(notification.getChangeAuthor()).isNull(); - } - - @Test - public void getNewResolution_returns_null_when_issue_is_not_set() { - assertThat(notification.getNewResolution()).isNull(); - } - - @Test - public void set_issue() { - UserDto assignee = newUserDto(); - - DefaultIssue issue = new DefaultIssue() - .setKey("ABCD") - .setAssigneeUuid(assignee.getUuid()) - .setMessage("Remove this useless method") - .setComponentKey("MyService") - .setCurrentChange(new FieldDiffs().setDiff("resolution", "FALSE-POSITIVE", "FIXED")); - - IssueChangeNotification result = notification.setIssue(issue).setAssignee(assignee); - - assertThat(result.getFieldValue("key")).isEqualTo("ABCD"); - assertThat(result.getFieldValue("message")).isEqualTo("Remove this useless method"); - assertThat(result.getFieldValue("old.resolution")).isEqualTo("FALSE-POSITIVE"); - assertThat(result.getFieldValue("new.resolution")) - .isEqualTo("FIXED") - .isEqualTo(result.getNewResolution()); - assertThat(result.getFieldValue("assignee")).isEqualTo(assignee.getLogin()); - } - - @Test - public void set_issue_with_current_change_having_no_old_value() { - DefaultIssue issue = new DefaultIssue() - .setKey("ABCD") - .setAssigneeUuid("simon") - .setMessage("Remove this useless method") - .setComponentKey("MyService"); - - IssueChangeNotification result = notification.setIssue(issue.setCurrentChange(new FieldDiffs().setDiff("resolution", null, "FIXED"))); - assertThat(result.getFieldValue("old.resolution")).isNull(); - assertThat(result.getFieldValue("new.resolution")) - .isEqualTo("FIXED") - .isEqualTo(result.getNewResolution()); - - result = notification.setIssue(issue.setCurrentChange(new FieldDiffs().setDiff("resolution", "", "FIXED"))); - assertThat(result.getFieldValue("old.resolution")).isNull(); - assertThat(result.getFieldValue("new.resolution")) - .isEqualTo("FIXED") - .isEqualTo(result.getNewResolution()); - } - - @Test - public void set_issue_with_current_change_having_no_new_value() { - DefaultIssue issue = new DefaultIssue() - .setKey("ABCD") - .setAssigneeUuid("simon") - .setMessage("Remove this useless method") - .setComponentKey("MyService"); - - IssueChangeNotification result = notification.setIssue(issue.setCurrentChange(new FieldDiffs().setDiff("assignee", "john", null))); - assertThat(result.getFieldValue("old.assignee")).isEqualTo("john"); - assertThat(result.getFieldValue("new.assignee")).isNull(); - assertThat(result.getNewResolution()).isNull(); - - result = notification.setIssue(issue.setCurrentChange(new FieldDiffs().setDiff("assignee", "john", ""))); - assertThat(result.getFieldValue("old.assignee")).isEqualTo("john"); - assertThat(result.getFieldValue("new.assignee")).isNull(); - assertThat(result.getNewResolution()).isNull(); - } - - @Test - public void set_project_without_branch() { - IssueChangeNotification result = notification.setProject("MyService", "My Service", null, null); - assertThat(result.getFieldValue("projectKey")) - .isEqualTo("MyService") - .isEqualTo(result.getProjectKey()); - assertThat(result.getFieldValue("projectName")).isEqualTo("My Service"); - assertThat(result.getFieldValue("branch")).isNull(); - } - - @Test - public void set_project_with_branch() { - IssueChangeNotification result = notification.setProject("MyService", "My Service", "feature1", null); - assertThat(result.getFieldValue("projectKey")) - .isEqualTo("MyService") - .isEqualTo(result.getProjectKey()); - assertThat(result.getFieldValue("projectName")).isEqualTo("My Service"); - assertThat(result.getFieldValue("branch")).isEqualTo("feature1"); - } - - @Test - public void set_project_with_pull_request() { - IssueChangeNotification result = notification.setProject("MyService", "My Service", null, "pr-123"); - assertThat(result.getFieldValue("projectKey")) - .isEqualTo("MyService") - .isEqualTo(result.getProjectKey()); - assertThat(result.getFieldValue("projectName")).isEqualTo("My Service"); - assertThat(result.getFieldValue("pullRequest")).isEqualTo("pr-123"); - } - - @Test - public void set_component() { - IssueChangeNotification result = notification.setComponent(new ComponentDto().setDbKey("MyService").setLongName("My Service")); - assertThat(result.getFieldValue("componentName")).isEqualTo("My Service"); - assertThat(result.getFieldValue("componentKey")).isEqualTo("MyService"); - } - - @Test - public void set_change_author_login() { - UserDto user = newUserDto(); - IssueChangeNotification result = notification.setChangeAuthor(user); - assertThat(result.getFieldValue("changeAuthor")) - .isEqualTo(user.getLogin()) - .isEqualTo(result.getChangeAuthor()); - } - - @Test - public void set_rule_name() { - IssueChangeNotification result = notification.setRuleName("Xoo Rule"); - assertThat(result.getFieldValue("ruleName")).isEqualTo("Xoo Rule"); - } - - @Test - public void setComment() { - IssueChangeNotification result = notification.setComment("My comment"); - assertThat(result.getFieldValue("comment")).isEqualTo("My comment"); - } -} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangesEmailTemplateTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangesEmailTemplateTest.java deleted file mode 100644 index da6ed26a453..00000000000 --- a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangesEmailTemplateTest.java +++ /dev/null @@ -1,200 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 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.issue.notification; - -import com.google.common.io.Resources; -import java.nio.charset.StandardCharsets; -import org.apache.commons.lang.StringUtils; -import org.junit.Rule; -import org.junit.Test; -import org.sonar.api.config.EmailSettings; -import org.sonar.api.config.internal.MapSettings; -import org.sonar.api.notifications.Notification; -import org.sonar.db.DbTester; -import org.sonar.db.user.UserDto; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.sonar.api.CoreProperties.SERVER_BASE_URL; - -public class IssueChangesEmailTemplateTest { - - @Rule - public DbTester db = DbTester.create(); - - private MapSettings settings = new MapSettings().setProperty(SERVER_BASE_URL, "http://nemo.sonarsource.org"); - - private IssueChangesEmailTemplate underTest = new IssueChangesEmailTemplate(db.getDbClient(), new EmailSettings(settings.asConfig())); - - @Test - public void should_ignore_non_issue_changes() { - Notification notification = new Notification("other"); - EmailMessage message = underTest.format(notification); - assertThat(message).isNull(); - } - - @Test - public void email_should_display_assignee_change() throws Exception { - Notification notification = generateNotification() - .setFieldValue("old.assignee", "simon") - .setFieldValue("new.assignee", "louis"); - - EmailMessage email = underTest.format(notification); - assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE"); - assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE"); - - String message = email.getMessage(); - String expected = Resources.toString(Resources.getResource( - "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_with_assignee_change.txt"), - StandardCharsets.UTF_8); - expected = StringUtils.remove(expected, '\r'); - assertThat(message).isEqualTo(expected); - assertThat(email.getFrom()).isNull(); - } - - @Test - public void email_should_display_plan_change() throws Exception { - Notification notification = generateNotification() - .setFieldValue("old.actionPlan", null) - .setFieldValue("new.actionPlan", "ABC 1.0"); - - EmailMessage email = underTest.format(notification); - assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE"); - assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE"); - - String message = email.getMessage(); - String expected = Resources.toString(Resources.getResource( - "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_with_action_plan_change.txt"), - StandardCharsets.UTF_8); - expected = StringUtils.remove(expected, '\r'); - assertThat(message).isEqualTo(expected); - assertThat(email.getFrom()).isNull(); - } - - @Test - public void email_should_display_resolution_change() throws Exception { - Notification notification = generateNotification() - .setFieldValue("old.resolution", "FALSE-POSITIVE") - .setFieldValue("new.resolution", "FIXED"); - - EmailMessage email = underTest.format(notification); - assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE"); - assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE"); - - String message = email.getMessage(); - String expected = Resources.toString(Resources.getResource( - "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_should_display_resolution_change.txt"), - StandardCharsets.UTF_8); - expected = StringUtils.remove(expected, '\r'); - assertThat(message).isEqualTo(expected); - assertThat(email.getFrom()).isNull(); - } - - @Test - public void display_component_key_if_no_component_name() throws Exception { - Notification notification = generateNotification() - .setFieldValue("componentName", null); - - EmailMessage email = underTest.format(notification); - assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE"); - assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE"); - - String message = email.getMessage(); - String expected = Resources.toString(Resources.getResource( - "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/display_component_key_if_no_component_name.txt"), - StandardCharsets.UTF_8); - expected = StringUtils.remove(expected, '\r'); - assertThat(message).isEqualTo(expected); - } - - @Test - public void test_email_with_multiple_changes() throws Exception { - Notification notification = generateNotification() - .setFieldValue("comment", "How to fix it?") - .setFieldValue("old.assignee", "simon") - .setFieldValue("new.assignee", "louis") - .setFieldValue("new.resolution", "FALSE-POSITIVE") - .setFieldValue("new.status", "RESOLVED") - .setFieldValue("new.type", "BUG") - .setFieldValue("new.tags", "bug performance"); - - EmailMessage email = underTest.format(notification); - assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE"); - assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE"); - - String message = email.getMessage(); - String expected = Resources.toString(Resources.getResource( - "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_with_multiple_changes.txt"), StandardCharsets.UTF_8); - expected = StringUtils.remove(expected, '\r'); - assertThat(message).isEqualTo(expected); - assertThat(email.getFrom()).isNull(); - } - - @Test - public void test_email_with_issue_on_branch() throws Exception { - Notification notification = generateNotification() - .setFieldValue("branch", "feature1"); - - EmailMessage email = underTest.format(notification); - assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE"); - assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE"); - - String message = email.getMessage(); - String expected = Resources.toString(Resources.getResource( - "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_with_issue_on_branch.txt"), - StandardCharsets.UTF_8); - expected = StringUtils.remove(expected, '\r'); - assertThat(message).isEqualTo(expected); - } - - @Test - public void notification_sender_should_be_the_author_of_change() { - UserDto user = db.users().insertUser(); - - Notification notification = new IssueChangeNotification() - .setChangeAuthor(user) - .setProject("Struts", "org.apache:struts", null, null); - - EmailMessage message = underTest.format(notification); - assertThat(message.getFrom()).isEqualTo(user.getName()); - } - - @Test - public void notification_contains_user_login_when_user_is_removed() { - UserDto user = db.users().insertDisabledUser(); - - Notification notification = new IssueChangeNotification() - .setChangeAuthor(user) - .setProject("Struts", "org.apache:struts", null, null); - - EmailMessage message = underTest.format(notification); - assertThat(message.getFrom()).isEqualTo(user.getLogin()); - } - - private static Notification generateNotification() { - return new IssueChangeNotification() - .setFieldValue("projectName", "Struts") - .setFieldValue("projectKey", "org.apache:struts") - .setFieldValue("componentName", "Action") - .setFieldValue("componentKey", "org.apache.struts.Action") - .setFieldValue("key", "ABCDE") - .setFieldValue("ruleName", "Avoid Cycles") - .setFieldValue("message", "Has 3 cycles"); - } -} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilderTesting.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilderTesting.java new file mode 100644 index 00000000000..43f5cbfd5a2 --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilderTesting.java @@ -0,0 +1,110 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import java.util.Random; +import org.sonar.api.rule.RuleKey; +import org.sonar.db.DbTester; +import org.sonar.db.component.BranchDto; +import org.sonar.db.component.ComponentDto; +import org.sonar.db.rule.RuleDefinitionDto; +import org.sonar.db.rule.RuleDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; + +import static com.google.common.base.Preconditions.checkArgument; +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; + +public class IssuesChangesNotificationBuilderTesting { + + public static Rule ruleOf(RuleDto rule) { + return new Rule(rule.getKey(), rule.getName()); + } + + public static Rule ruleOf(RuleDefinitionDto rule) { + return new Rule(rule.getKey(), rule.getName()); + } + + public static User userOf(UserDto changeAuthor) { + return new User(changeAuthor.getUuid(), changeAuthor.getLogin(), changeAuthor.getName()); + } + + public static Project projectBranchOf(DbTester db, ComponentDto branch) { + BranchDto branchDto = db.getDbClient().branchDao().selectByUuid(db.getSession(), branch.uuid()).get(); + checkArgument(!branchDto.isMain(), "should be a branch"); + return new Project.Builder(branch.uuid()) + .setKey(branch.getKey()) + .setProjectName(branch.name()) + .setBranchName(branchDto.getKey()) + .build(); + } + + public static Project projectOf(ComponentDto project) { + return new Project.Builder(project.uuid()) + .setKey(project.getKey()) + .setProjectName(project.name()) + .build(); + } + + static ChangedIssue newChangedIssue(String key, Project project, Rule rule) { + return new ChangedIssue.Builder(key) + .setNewStatus(randomAlphabetic(19)) + .setProject(project) + .setRule(rule) + .build(); + } + + static ChangedIssue newChangedIssue(String key, String status, Project project, String ruleName) { + return newChangedIssue(key, status, project, newRule(ruleName)); + } + + static ChangedIssue newChangedIssue(String key, String status, Project project, Rule rule) { + return new ChangedIssue.Builder(key) + .setNewStatus(status) + .setProject(project) + .setRule(rule) + .build(); + } + + static Rule newRule(String ruleName) { + return new Rule(RuleKey.of(randomAlphabetic(6), randomAlphabetic(7)), ruleName); + } + + static Project newProject(String uuid) { + return new Project.Builder(uuid).setProjectName(uuid + "_name").setKey(uuid + "_key").build(); + } + + static Project newBranch(String uuid, String branchName) { + return new Project.Builder(uuid).setProjectName(uuid + "_name").setKey(uuid + "_key").setBranchName(branchName).build(); + } + + static UserChange newUserChange() { + return new UserChange(new Random().nextLong(), new User(randomAlphabetic(4), randomAlphabetic(5), randomAlphabetic(6))); + } + + static AnalysisChange newAnalysisChange() { + return new AnalysisChange(new Random().nextLong()); + } +} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationModuleTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationModuleTest.java new file mode 100644 index 00000000000..2e724dd857a --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationModuleTest.java @@ -0,0 +1,37 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import org.junit.Test; +import org.sonar.core.platform.ComponentContainer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.sonar.core.platform.ComponentContainer.COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER; + +public class IssuesChangesNotificationModuleTest { + @Test + public void verify_count_of_added_components() { + ComponentContainer container = new ComponentContainer(); + new IssuesChangesNotificationModule().configure(container); + assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 7); + } + + +} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationTest.java new file mode 100644 index 00000000000..bbdb2f77446 --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationTest.java @@ -0,0 +1,35 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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.issue.notification; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class IssuesChangesNotificationTest { + + private IssuesChangesNotification notification = new IssuesChangesNotification(); + + @Test + public void verify_type() { + assertThat(notification.getType()).isEqualTo("issues-changes"); + } + +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/issue/IssueUpdater.java b/server/sonar-server/src/main/java/org/sonar/server/issue/IssueUpdater.java index 4146385e9c4..5f88b73eab8 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/issue/IssueUpdater.java +++ b/server/sonar-server/src/main/java/org/sonar/server/issue/IssueUpdater.java @@ -35,7 +35,13 @@ import org.sonar.db.component.ComponentDto; import org.sonar.db.issue.IssueDto; import org.sonar.db.rule.RuleDefinitionDto; import org.sonar.db.user.UserDto; -import org.sonar.server.issue.notification.IssueChangeNotification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer; import org.sonar.server.issue.ws.SearchResponseData; import org.sonar.server.notification.NotificationManager; @@ -51,27 +57,25 @@ public class IssueUpdater { private final WebIssueStorage issueStorage; private final NotificationManager notificationService; private final IssueChangePostProcessor issueChangePostProcessor; + private final IssuesChangesNotificationSerializer notificationSerializer; public IssueUpdater(DbClient dbClient, WebIssueStorage issueStorage, NotificationManager notificationService, - IssueChangePostProcessor issueChangePostProcessor) { + IssueChangePostProcessor issueChangePostProcessor, IssuesChangesNotificationSerializer notificationSerializer) { this.dbClient = dbClient; this.issueStorage = issueStorage; this.notificationService = notificationService; this.issueChangePostProcessor = issueChangePostProcessor; + this.notificationSerializer = notificationSerializer; } - /** - * Same as {@link #saveIssue(DbSession, DefaultIssue, IssueChangeContext, String)} but populates the specified - * {@link SearchResponseData} with the DTOs (rule and components) retrieved from DB to save the issue. - */ - public SearchResponseData saveIssueAndPreloadSearchResponseData(DbSession dbSession, DefaultIssue issue, IssueChangeContext context, - @Nullable String comment, boolean refreshMeasures) { + public SearchResponseData saveIssueAndPreloadSearchResponseData(DbSession dbSession, DefaultIssue issue, + IssueChangeContext context, boolean refreshMeasures) { Optional rule = getRuleByKey(dbSession, issue.getRuleKey()); ComponentDto project = dbClient.componentDao().selectOrFailByUuid(dbSession, issue.projectUuid()); BranchDto branch = getBranch(dbSession, issue, issue.projectUuid()); ComponentDto component = getComponent(dbSession, issue, issue.componentUuid()); - IssueDto issueDto = doSaveIssue(dbSession, issue, context, comment, rule, project, branch, component); + IssueDto issueDto = doSaveIssue(dbSession, issue, context, rule, project, branch); SearchResponseData result = new SearchResponseData(issueDto); rule.ifPresent(r -> result.addRules(singletonList(r))); @@ -86,31 +90,38 @@ public class IssueUpdater { return result; } - public IssueDto saveIssue(DbSession session, DefaultIssue issue, IssueChangeContext context, @Nullable String comment) { - Optional rule = getRuleByKey(session, issue.getRuleKey()); - ComponentDto project = getComponent(session, issue, issue.projectUuid()); - BranchDto branch = getBranch(session, issue, issue.projectUuid()); - ComponentDto component = getComponent(session, issue, issue.componentUuid()); - return doSaveIssue(session, issue, context, comment, rule, project, branch, component); - } - - private IssueDto doSaveIssue(DbSession session, DefaultIssue issue, IssueChangeContext context, @Nullable String comment, - Optional rule, ComponentDto project, BranchDto branch, ComponentDto component) { + private IssueDto doSaveIssue(DbSession session, DefaultIssue issue, IssueChangeContext context, + Optional rule, ComponentDto project, BranchDto branchDto) { IssueDto issueDto = issueStorage.save(session, singletonList(issue)).iterator().next(); - if (issue.type() != RuleType.SECURITY_HOTSPOT && hasNotificationSupport(branch)) { - String assigneeUuid = issue.assignee(); - UserDto assignee = assigneeUuid == null ? null : dbClient.userDao().selectByUuid(session, assigneeUuid); - String authorUuid = context.userUuid(); - UserDto author = authorUuid == null ? null : dbClient.userDao().selectByUuid(session, authorUuid); - notificationService.scheduleForSending(new IssueChangeNotification() - .setIssue(issue) - .setAssignee(assignee) - .setChangeAuthor(author) - .setRuleName(rule.map(RuleDefinitionDto::getName).orElse(null)) - .setProject(project) - .setComponent(component) - .setComment(comment)); + if (issue.type() == RuleType.SECURITY_HOTSPOT + // since this method is called after an update of the issue, date should never be null + || issue.updateDate() == null + // name of rule is displayed in notification, rule must therefor be present + || !rule.isPresent() + // notification are not supported on PRs and short lived branches + || !hasNotificationSupport(branchDto)) { + return issueDto; } + + Optional assignee = Optional.ofNullable(issue.assignee()) + .map(assigneeUuid -> dbClient.userDao().selectByUuid(session, assigneeUuid)); + UserDto author = Optional.ofNullable(context.userUuid()) + .map(authorUuid -> dbClient.userDao().selectByUuid(session, authorUuid)) + .orElseThrow(() -> new IllegalStateException("Can not find dto for change author " + context.userUuid())); + IssuesChangesNotificationBuilder notificationBuilder = new IssuesChangesNotificationBuilder(singleton( + new ChangedIssue.Builder(issue.key()) + .setNewResolution(issue.resolution()) + .setNewStatus(issue.status()) + .setAssignee(assignee.map(assigneeDto -> new User(assigneeDto.getUuid(), assigneeDto.getLogin(), assigneeDto.getName())).orElse(null)) + .setRule(rule.map(r -> new Rule(r.getKey(), r.getName())).get()) + .setProject(new Project.Builder(project.uuid()) + .setKey(project.getKey()) + .setProjectName(project.name()) + .setBranchName(branchDto.isMain() ? null : branchDto.getKey()) + .build()) + .build()), + new UserChange(issue.updateDate().getTime(), new User(author.getUuid(), author.getLogin(), author.getName()))); + notificationService.scheduleForSending(notificationSerializer.serialize(notificationBuilder)); return issueDto; } diff --git a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/AddCommentAction.java b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/AddCommentAction.java index cc0fc176403..2acae7cb3f2 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/AddCommentAction.java +++ b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/AddCommentAction.java @@ -98,7 +98,7 @@ public class AddCommentAction implements IssuesWsAction { IssueChangeContext context = IssueChangeContext.createUser(new Date(system2.now()), userSession.getUuid()); DefaultIssue defaultIssue = issueDto.toDefaultIssue(); issueFieldsSetter.addComment(defaultIssue, wsRequest.getText(), context); - SearchResponseData preloadedSearchResponseData = issueUpdater.saveIssueAndPreloadSearchResponseData(dbSession, defaultIssue, context, wsRequest.getText(), false); + SearchResponseData preloadedSearchResponseData = issueUpdater.saveIssueAndPreloadSearchResponseData(dbSession, defaultIssue, context, false); responseWriter.write(defaultIssue.key(), preloadedSearchResponseData, request, response); } } diff --git a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/AssignAction.java b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/AssignAction.java index 07074faed34..a5ea16f11f2 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/AssignAction.java +++ b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/AssignAction.java @@ -116,7 +116,7 @@ public class AssignAction implements IssuesWsAction { } IssueChangeContext context = IssueChangeContext.createUser(new Date(system2.now()), userSession.getUuid()); if (issueFieldsSetter.assign(issue, user, context)) { - return issueUpdater.saveIssueAndPreloadSearchResponseData(dbSession, issue, context, null, false); + return issueUpdater.saveIssueAndPreloadSearchResponseData(dbSession, issue, context, false); } return new SearchResponseData(issueDto); } diff --git a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/BulkChangeAction.java b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/BulkChangeAction.java index 4f49fb5abe5..4b4bba89e88 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/BulkChangeAction.java +++ b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/BulkChangeAction.java @@ -30,6 +30,8 @@ import java.util.Optional; import java.util.Set; import java.util.function.Consumer; import java.util.function.Predicate; +import java.util.stream.Collectors; +import javax.annotation.CheckForNull; import javax.annotation.Nullable; import org.sonar.api.issue.DefaultTransitions; import org.sonar.api.rule.RuleKey; @@ -60,7 +62,12 @@ import org.sonar.server.issue.AssignAction; import org.sonar.server.issue.IssueChangePostProcessor; import org.sonar.server.issue.RemoveTagsAction; import org.sonar.server.issue.WebIssueStorage; -import org.sonar.server.issue.notification.IssueChangeNotification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer; import org.sonar.server.notification.NotificationManager; import org.sonar.server.user.UserSession; import org.sonarqube.ws.Issues; @@ -72,12 +79,12 @@ import static java.lang.String.format; import static java.util.Objects.requireNonNull; import static java.util.function.Function.identity; import static java.util.stream.Collectors.toMap; -import static java.util.stream.Collectors.toSet; import static org.sonar.api.issue.DefaultTransitions.REOPEN; import static org.sonar.api.rule.Severity.BLOCKER; import static org.sonar.api.rules.RuleType.BUG; import static org.sonar.core.util.Uuids.UUID_EXAMPLE_01; import static org.sonar.core.util.Uuids.UUID_EXAMPLE_02; +import static org.sonar.core.util.stream.MoreCollectors.toSet; import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex; import static org.sonar.server.es.SearchOptions.MAX_LIMIT; import static org.sonar.server.issue.AbstractChangeTagsAction.TAGS_PARAMETER; @@ -113,10 +120,11 @@ public class BulkChangeAction implements IssuesWsAction { private final NotificationManager notificationService; private final List actions; private final IssueChangePostProcessor issueChangePostProcessor; + private final IssuesChangesNotificationSerializer notificationSerializer; public BulkChangeAction(System2 system2, UserSession userSession, DbClient dbClient, WebIssueStorage issueStorage, NotificationManager notificationService, List actions, - IssueChangePostProcessor issueChangePostProcessor) { + IssueChangePostProcessor issueChangePostProcessor, IssuesChangesNotificationSerializer notificationSerializer) { this.system2 = system2; this.userSession = userSession; this.dbClient = dbClient; @@ -124,6 +132,7 @@ public class BulkChangeAction implements IssuesWsAction { this.notificationService = notificationService; this.actions = actions; this.issueChangePostProcessor = issueChangePostProcessor; + this.notificationSerializer = notificationSerializer; } @Override @@ -200,12 +209,12 @@ public class BulkChangeAction implements IssuesWsAction { refreshLiveMeasures(dbSession, bulkChangeData, result); - Set assigneeUuids = items.stream().map(DefaultIssue::assignee).filter(Objects::nonNull).collect(toSet()); + Set assigneeUuids = items.stream().map(DefaultIssue::assignee).filter(Objects::nonNull).collect(Collectors.toSet()); Map userDtoByUuid = dbClient.userDao().selectByUuids(dbSession, assigneeUuids).stream().collect(toMap(UserDto::getUuid, u -> u)); String authorUuid = requireNonNull(userSession.getUuid(), "User uuid cannot be null"); UserDto author = dbClient.userDao().selectByUuid(dbSession, authorUuid); checkState(author != null, "User with uuid '%s' does not exist"); - items.forEach(sendNotification(bulkChangeData, userDtoByUuid, author)); + sendNotification(items, bulkChangeData, userDtoByUuid, author); return result; } @@ -216,7 +225,7 @@ public class BulkChangeAction implements IssuesWsAction { } Set touchedComponentUuids = result.success.stream() .map(DefaultIssue::componentUuid) - .collect(toSet()); + .collect(Collectors.toSet()); List touchedComponents = touchedComponentUuids.stream() .map(data.componentsByUuid::get) .collect(MoreCollectors.toList(touchedComponentUuids.size())); @@ -253,27 +262,70 @@ public class BulkChangeAction implements IssuesWsAction { bulkChangeData.getCommentAction().ifPresent(action -> action.execute(bulkChangeData.getProperties(action.key()), actionContext)); } - private Consumer sendNotification(BulkChangeData bulkChangeData, Map userDtoByUuid, UserDto author) { - return issue -> { - if (bulkChangeData.sendNotification && issue.type() != RuleType.SECURITY_HOTSPOT) { - BranchDto branch = bulkChangeData.branchesByProjectUuid.get(issue.projectUuid()); - if (hasNotificationSupport(branch)) { - notificationService.scheduleForSending(new IssueChangeNotification() - .setIssue(issue) - .setAssignee(userDtoByUuid.get(issue.assignee())) - .setChangeAuthor(author) - .setRuleName(bulkChangeData.rulesByKey.get(issue.ruleKey()).getName()) - .setProject(bulkChangeData.projectsByUuid.get(issue.projectUuid())) - .setComponent(bulkChangeData.componentsByUuid.get(issue.componentUuid()))); - } - } - }; + private void sendNotification(Collection issues, BulkChangeData bulkChangeData, Map userDtoByUuid, UserDto author) { + if (!bulkChangeData.sendNotification) { + return; + } + Set changedIssues = issues.stream() + .filter(issue -> issue.type() != RuleType.SECURITY_HOTSPOT) + // should not happen but filter it out anyway to avoid NPE in oldestUpdateDate call below + .filter(issue -> issue.updateDate() != null) + .map(issue -> toNotification(bulkChangeData, userDtoByUuid, issue)) + .filter(Objects::nonNull) + .collect(toSet(issues.size())); + + if (changedIssues.isEmpty()) { + return; + } + + IssuesChangesNotificationBuilder builder = new IssuesChangesNotificationBuilder( + changedIssues, + new UserChange(oldestUpdateDate(issues), new User(author.getUuid(), author.getLogin(), author.getName()))); + notificationService.scheduleForSending(notificationSerializer.serialize(builder)); + } + + @CheckForNull + private ChangedIssue toNotification(BulkChangeData bulkChangeData, Map userDtoByUuid, DefaultIssue issue) { + BranchDto branchDto = bulkChangeData.branchesByProjectUuid.get(issue.projectUuid()); + if (!hasNotificationSupport(branchDto)) { + return null; + } + + RuleDefinitionDto ruleDefinitionDto = bulkChangeData.rulesByKey.get(issue.ruleKey()); + ComponentDto projectDto = bulkChangeData.projectsByUuid.get(issue.projectUuid()); + if (ruleDefinitionDto == null || projectDto == null) { + return null; + } + + Optional assignee = Optional.ofNullable(issue.assignee()).map(userDtoByUuid::get); + return new ChangedIssue.Builder(issue.key()) + .setNewStatus(issue.status()) + .setNewResolution(issue.resolution()) + .setAssignee(assignee.map(u -> new User(u.getUuid(), u.getLogin(), u.getName())).orElse(null)) + .setRule(new IssuesChangesNotificationBuilder.Rule(ruleDefinitionDto.getKey(), ruleDefinitionDto.getName())) + .setProject(new Project.Builder(projectDto.uuid()) + .setKey(projectDto.getKey()) + .setProjectName(projectDto.name()) + .setBranchName(branchDto.isMain() ? null : branchDto.getKey()) + .build()) + .build(); } private static boolean hasNotificationSupport(@Nullable BranchDto branch) { return branch != null && branch.getBranchType() != BranchType.PULL_REQUEST && branch.getBranchType() != BranchType.SHORT; } + private static long oldestUpdateDate(Collection issues) { + long res = Long.MAX_VALUE; + for (DefaultIssue issue : issues) { + long issueUpdateDate = issue.updateDate().getTime(); + if (issueUpdateDate < res) { + res = issueUpdateDate; + } + } + return res; + } + private static Issues.BulkChangeWsResponse toWsResponse(BulkChangeResult result) { return Issues.BulkChangeWsResponse.newBuilder() .setTotal(result.countTotal()) diff --git a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/DoTransitionAction.java b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/DoTransitionAction.java index 4782f37141b..dd3cfb81bd9 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/DoTransitionAction.java +++ b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/DoTransitionAction.java @@ -104,7 +104,7 @@ public class DoTransitionAction implements IssuesWsAction { IssueChangeContext context = IssueChangeContext.createUser(new Date(system2.now()), userSession.getUuid()); transitionService.checkTransitionPermission(transitionKey, defaultIssue); if (transitionService.doTransition(defaultIssue, context, transitionKey)) { - return issueUpdater.saveIssueAndPreloadSearchResponseData(session, defaultIssue, context, null, true); + return issueUpdater.saveIssueAndPreloadSearchResponseData(session, defaultIssue, context, true); } return new SearchResponseData(issueDto); } diff --git a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetSeverityAction.java b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetSeverityAction.java index e7afa901ae8..0493ae97110 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetSeverityAction.java +++ b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetSeverityAction.java @@ -107,7 +107,7 @@ public class SetSeverityAction implements IssuesWsAction { IssueChangeContext context = IssueChangeContext.createUser(new Date(), userSession.getUuid()); if (issueFieldsSetter.setManualSeverity(issue, severity, context)) { - return issueUpdater.saveIssueAndPreloadSearchResponseData(session, issue, context, null, true); + return issueUpdater.saveIssueAndPreloadSearchResponseData(session, issue, context, true); } return new SearchResponseData(issueDto); } diff --git a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTagsAction.java b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTagsAction.java index 600738c6ce9..2d5bcc0d814 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTagsAction.java +++ b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTagsAction.java @@ -105,7 +105,7 @@ public class SetTagsAction implements IssuesWsAction { DefaultIssue issue = issueDto.toDefaultIssue(); IssueChangeContext context = IssueChangeContext.createUser(new Date(), userSession.getUuid()); if (issueFieldsSetter.setTags(issue, tags, context)) { - return issueUpdater.saveIssueAndPreloadSearchResponseData(session, issue, context, null, false); + return issueUpdater.saveIssueAndPreloadSearchResponseData(session, issue, context, false); } return new SearchResponseData(issueDto); } diff --git a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTypeAction.java b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTypeAction.java index 3df77bd08b9..b02c18ea26d 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTypeAction.java +++ b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTypeAction.java @@ -113,7 +113,7 @@ public class SetTypeAction implements IssuesWsAction { IssueChangeContext context = IssueChangeContext.createUser(new Date(system2.now()), userSession.getUuid()); if (issueFieldsSetter.setType(issue, ruleType, context)) { - return issueUpdater.saveIssueAndPreloadSearchResponseData(session, issue, context, null, true); + return issueUpdater.saveIssueAndPreloadSearchResponseData(session, issue, context, true); } return new SearchResponseData(issueDto); } diff --git a/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java b/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java index f0949b09986..a2e039daa3b 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java +++ b/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java @@ -79,9 +79,7 @@ import org.sonar.server.issue.TransitionAction; import org.sonar.server.issue.index.IssueIndexDefinition; import org.sonar.server.issue.index.IssueIndexer; import org.sonar.server.issue.index.IssueIteratorFactory; -import org.sonar.server.issue.notification.ChangesOnMyIssueNotificationHandler; -import org.sonar.server.issue.notification.DoNotFixNotificationHandler; -import org.sonar.server.issue.notification.IssueChangesEmailTemplate; +import org.sonar.server.issue.notification.IssuesChangesNotificationModule; import org.sonar.server.issue.notification.MyNewIssuesEmailTemplate; import org.sonar.server.issue.notification.MyNewIssuesNotificationHandler; import org.sonar.server.issue.notification.NewIssuesEmailTemplate; @@ -151,10 +149,10 @@ import org.sonar.server.property.InternalPropertiesImpl; import org.sonar.server.property.ws.PropertiesWs; import org.sonar.server.qualitygate.QualityGateModule; import org.sonar.server.qualitygate.notification.QGChangeNotificationHandler; -import org.sonar.server.qualityprofile.BuiltInQProfileDefinitionsBridge; -import org.sonar.server.qualityprofile.BuiltInQProfileRepositoryImpl; import org.sonar.server.qualityprofile.BuiltInQPChangeNotificationHandler; import org.sonar.server.qualityprofile.BuiltInQPChangeNotificationTemplate; +import org.sonar.server.qualityprofile.BuiltInQProfileDefinitionsBridge; +import org.sonar.server.qualityprofile.BuiltInQProfileRepositoryImpl; import org.sonar.server.qualityprofile.QProfileBackuperImpl; import org.sonar.server.qualityprofile.QProfileComparison; import org.sonar.server.qualityprofile.QProfileCopier; @@ -408,15 +406,11 @@ public class PlatformLevel4 extends PlatformLevel { IssueWsModule.class, NewIssuesEmailTemplate.class, MyNewIssuesEmailTemplate.class, - IssueChangesEmailTemplate.class, - ChangesOnMyIssueNotificationHandler.class, - ChangesOnMyIssueNotificationHandler.newMetadata(), + IssuesChangesNotificationModule.class, NewIssuesNotificationHandler.class, NewIssuesNotificationHandler.newMetadata(), MyNewIssuesNotificationHandler.class, MyNewIssuesNotificationHandler.newMetadata(), - DoNotFixNotificationHandler.class, - DoNotFixNotificationHandler.newMetadata(), // Security reports SecurityReportsWsModule.class, diff --git a/server/sonar-server/src/main/java/org/sonar/server/qualityprofile/BuiltInQPChangeNotificationTemplate.java b/server/sonar-server/src/main/java/org/sonar/server/qualityprofile/BuiltInQPChangeNotificationTemplate.java index 41fd750f45d..ccc644b02a1 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/qualityprofile/BuiltInQPChangeNotificationTemplate.java +++ b/server/sonar-server/src/main/java/org/sonar/server/qualityprofile/BuiltInQPChangeNotificationTemplate.java @@ -23,6 +23,7 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.Comparator; import java.util.Date; +import javax.annotation.CheckForNull; import org.sonar.api.notifications.Notification; import org.sonar.api.platform.Server; import org.sonar.server.issue.notification.EmailMessage; @@ -42,6 +43,7 @@ public class BuiltInQPChangeNotificationTemplate implements EmailTemplate { } @Override + @CheckForNull public EmailMessage format(Notification notification) { if (!BuiltInQPChangeNotification.TYPE.equals(notification.getType())) { return null; @@ -94,7 +96,7 @@ public class BuiltInQPChangeNotificationTemplate implements EmailTemplate { return new EmailMessage() .setMessageId(BuiltInQPChangeNotification.TYPE) .setSubject("Built-in quality profiles have been updated") - .setMessage(message.toString()); + .setPlainTextMessage(message.toString()); } private static String plural(int count) { diff --git a/server/sonar-server/src/test/java/org/sonar/server/issue/IssueUpdaterTest.java b/server/sonar-server/src/test/java/org/sonar/server/issue/IssueUpdaterTest.java index e7b02a808d2..1a86962ef86 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/issue/IssueUpdaterTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/issue/IssueUpdaterTest.java @@ -41,7 +41,11 @@ import org.sonar.db.user.UserDto; import org.sonar.server.es.EsTester; import org.sonar.server.issue.index.IssueIndexer; import org.sonar.server.issue.index.IssueIteratorFactory; -import org.sonar.server.issue.notification.IssueChangeNotification; +import org.sonar.server.issue.notification.IssuesChangesNotification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer; import org.sonar.server.issue.ws.SearchResponseData; import org.sonar.server.notification.NotificationManager; import org.sonar.server.organization.DefaultOrganizationProvider; @@ -55,10 +59,15 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyZeroInteractions; +import static org.sonar.api.issue.Issue.RESOLUTION_FIXED; import static org.sonar.api.rule.Severity.BLOCKER; import static org.sonar.api.rule.Severity.MAJOR; import static org.sonar.db.component.BranchType.LONG; import static org.sonar.db.component.ComponentTesting.newFileDto; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.projectBranchOf; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.projectOf; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.ruleOf; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.userOf; public class IssueUpdaterTest { @@ -78,27 +87,29 @@ public class IssueUpdaterTest { private IssueFieldsSetter issueFieldsSetter = new IssueFieldsSetter(); private NotificationManager notificationManager = mock(NotificationManager.class); - private ArgumentCaptor notificationArgumentCaptor = ArgumentCaptor.forClass(IssueChangeNotification.class); + private ArgumentCaptor notificationArgumentCaptor = ArgumentCaptor.forClass(IssuesChangesNotification.class); private IssueIndexer issueIndexer = new IssueIndexer(es.client(), dbClient, new IssueIteratorFactory(dbClient)); private TestIssueChangePostProcessor issueChangePostProcessor = new TestIssueChangePostProcessor(); + private IssuesChangesNotificationSerializer issuesChangesSerializer = new IssuesChangesNotificationSerializer(); private IssueUpdater underTest = new IssueUpdater(dbClient, - new WebIssueStorage(system2, dbClient, new DefaultRuleFinder(dbClient, defaultOrganizationProvider), issueIndexer), notificationManager, issueChangePostProcessor); + new WebIssueStorage(system2, dbClient, new DefaultRuleFinder(dbClient, defaultOrganizationProvider), issueIndexer), notificationManager, issueChangePostProcessor, issuesChangesSerializer); @Test public void update_issue() { DefaultIssue issue = db.issues().insertIssue(i -> i.setSeverity(MAJOR)).toDefaultIssue(); - IssueChangeContext context = IssueChangeContext.createUser(new Date(), "user_uuid"); + UserDto user = db.users().insertUser(); + IssueChangeContext context = IssueChangeContext.createUser(new Date(), user.getUuid()); issueFieldsSetter.setSeverity(issue, BLOCKER, context); - underTest.saveIssue(db.getSession(), issue, context, null); + underTest.saveIssueAndPreloadSearchResponseData(db.getSession(), issue, context, false); IssueDto issueReloaded = dbClient.issueDao().selectByKey(db.getSession(), issue.key()).get(); assertThat(issueReloaded.getSeverity()).isEqualTo(BLOCKER); } @Test - public void verify_notification() { + public void verify_notification_without_resolution() { UserDto assignee = db.users().insertUser(); RuleDto rule = db.rules().insertRule(); ComponentDto project = db.components().insertMainBranch(); @@ -113,21 +124,52 @@ public class IssueUpdaterTest { IssueChangeContext context = IssueChangeContext.createUser(new Date(), changeAuthor.getUuid()); issueFieldsSetter.setSeverity(issue, BLOCKER, context); - underTest.saveIssue(db.getSession(), issue, context, "increase severity"); + underTest.saveIssueAndPreloadSearchResponseData(db.getSession(), issue, context, false); verify(notificationManager).scheduleForSending(notificationArgumentCaptor.capture()); - IssueChangeNotification issueChangeNotification = notificationArgumentCaptor.getValue(); - assertThat(issueChangeNotification.getFieldValue("key")).isEqualTo(issue.key()); - assertThat(issueChangeNotification.getFieldValue("old.severity")).isEqualTo(MAJOR); - assertThat(issueChangeNotification.getFieldValue("new.severity")).isEqualTo(BLOCKER); - assertThat(issueChangeNotification.getFieldValue("componentKey")).isEqualTo(file.getDbKey()); - assertThat(issueChangeNotification.getFieldValue("componentName")).isEqualTo(file.longName()); - assertThat(issueChangeNotification.getFieldValue("projectKey")).isEqualTo(project.getDbKey()); - assertThat(issueChangeNotification.getFieldValue("projectName")).isEqualTo(project.name()); - assertThat(issueChangeNotification.getFieldValue("ruleName")).isEqualTo(rule.getName()); - assertThat(issueChangeNotification.getFieldValue("changeAuthor")).isEqualTo(changeAuthor.getLogin()); - assertThat(issueChangeNotification.getFieldValue("comment")).isEqualTo("increase severity"); - assertThat(issueChangeNotification.getFieldValue("assignee")).isEqualTo(assignee.getLogin()); + IssuesChangesNotification issueChangeNotification = notificationArgumentCaptor.getValue(); + IssuesChangesNotificationBuilder builder = issuesChangesSerializer.from(issueChangeNotification); + assertThat(builder.getIssues()).hasSize(1); + ChangedIssue changedIssue = builder.getIssues().iterator().next(); + assertThat(changedIssue.getKey()).isEqualTo(issue.key()); + assertThat(changedIssue.getNewStatus()).isEqualTo(issue.status()); + assertThat(changedIssue.getNewResolution()).isEmpty(); + assertThat(changedIssue.getAssignee()).contains(userOf(assignee)); + assertThat(changedIssue.getRule()).isEqualTo(ruleOf(rule)); + assertThat(changedIssue.getProject()).isEqualTo(projectOf(project)); + assertThat(builder.getChange()).isEqualTo(new UserChange(issue.updateDate().getTime(), userOf(changeAuthor))); + } + + @Test + public void verify_notification_with_resolution() { + UserDto assignee = db.users().insertUser(); + RuleDto rule = db.rules().insertRule(); + ComponentDto project = db.components().insertMainBranch(); + ComponentDto file = db.components().insertComponent(newFileDto(project)); + RuleType randomTypeExceptHotspot = RuleType.values()[nextInt(RuleType.values().length - 1)]; + DefaultIssue issue = db.issues().insertIssue(IssueTesting.newIssue(rule.getDefinition(), project, file) + .setType(randomTypeExceptHotspot)) + .setSeverity(MAJOR) + .setAssigneeUuid(assignee.getUuid()) + .toDefaultIssue(); + UserDto changeAuthor = db.users().insertUser(); + IssueChangeContext context = IssueChangeContext.createUser(new Date(), changeAuthor.getUuid()); + issueFieldsSetter.setResolution(issue, RESOLUTION_FIXED, context); + + underTest.saveIssueAndPreloadSearchResponseData(db.getSession(), issue, context, false); + + verify(notificationManager).scheduleForSending(notificationArgumentCaptor.capture()); + IssuesChangesNotification issueChangeNotification = notificationArgumentCaptor.getValue(); + IssuesChangesNotificationBuilder builder = issuesChangesSerializer.from(issueChangeNotification); + assertThat(builder.getIssues()).hasSize(1); + ChangedIssue changedIssue = builder.getIssues().iterator().next(); + assertThat(changedIssue.getKey()).isEqualTo(issue.key()); + assertThat(changedIssue.getNewStatus()).isEqualTo(issue.status()); + assertThat(changedIssue.getNewResolution()).contains(RESOLUTION_FIXED); + assertThat(changedIssue.getAssignee()).contains(userOf(assignee)); + assertThat(changedIssue.getRule()).isEqualTo(ruleOf(rule)); + assertThat(changedIssue.getProject()).isEqualTo(projectOf(project)); + assertThat(builder.getChange()).isEqualTo(new UserChange(issue.updateDate().getTime(), userOf(changeAuthor))); } @Test @@ -145,7 +187,7 @@ public class IssueUpdaterTest { IssueChangeContext context = IssueChangeContext.createUser(new Date(), changeAuthor.getUuid()); issueFieldsSetter.setSeverity(issue, BLOCKER, context); - underTest.saveIssue(db.getSession(), issue, context, "increase severity"); + underTest.saveIssueAndPreloadSearchResponseData(db.getSession(), issue, context, false); verify(notificationManager, never()).scheduleForSending(any()); } @@ -159,17 +201,24 @@ public class IssueUpdaterTest { RuleType randomTypeExceptHotspot = RuleType.values()[nextInt(RuleType.values().length - 1)]; DefaultIssue issue = db.issues().insertIssue(IssueTesting.newIssue(rule.getDefinition(), branch, file) .setType(randomTypeExceptHotspot)).setSeverity(MAJOR).toDefaultIssue(); - IssueChangeContext context = IssueChangeContext.createUser(new Date(), "user_uuid"); + UserDto changeAuthor = db.users().insertUser(); + IssueChangeContext context = IssueChangeContext.createUser(new Date(), changeAuthor.getUuid()); issueFieldsSetter.setSeverity(issue, BLOCKER, context); - underTest.saveIssue(db.getSession(), issue, context, "increase severity"); + underTest.saveIssueAndPreloadSearchResponseData(db.getSession(), issue, context, false); verify(notificationManager).scheduleForSending(notificationArgumentCaptor.capture()); - IssueChangeNotification issueChangeNotification = notificationArgumentCaptor.getValue(); - assertThat(issueChangeNotification.getFieldValue("key")).isEqualTo(issue.key()); - assertThat(issueChangeNotification.getFieldValue("projectKey")).isEqualTo(project.getDbKey()); - assertThat(issueChangeNotification.getFieldValue("projectName")).isEqualTo(project.name()); - assertThat(issueChangeNotification.getFieldValue("branch")).isEqualTo(branch.getBranch()); + IssuesChangesNotification issueChangeNotification = notificationArgumentCaptor.getValue(); + IssuesChangesNotificationBuilder builder = issuesChangesSerializer.from(issueChangeNotification); + assertThat(builder.getIssues()).hasSize(1); + ChangedIssue changedIssue = builder.getIssues().iterator().next(); + assertThat(changedIssue.getKey()).isEqualTo(issue.key()); + assertThat(changedIssue.getNewStatus()).isEqualTo(issue.status()); + assertThat(changedIssue.getNewResolution()).isEmpty(); + assertThat(changedIssue.getAssignee()).isEmpty(); + assertThat(changedIssue.getRule()).isEqualTo(ruleOf(rule)); + assertThat(changedIssue.getProject()).isEqualTo(projectBranchOf(db, branch)); + assertThat(builder.getChange()).isEqualTo(new UserChange(issue.updateDate().getTime(), userOf(changeAuthor))); } @Test @@ -184,7 +233,7 @@ public class IssueUpdaterTest { IssueChangeContext context = IssueChangeContext.createUser(new Date(), "user_uuid"); issueFieldsSetter.setSeverity(issue, BLOCKER, context); - underTest.saveIssue(db.getSession(), issue, context, "increase severity"); + underTest.saveIssueAndPreloadSearchResponseData(db.getSession(), issue, context, false); verifyZeroInteractions(notificationManager); } @@ -201,7 +250,7 @@ public class IssueUpdaterTest { IssueChangeContext context = IssueChangeContext.createUser(new Date(), "user_uuid"); issueFieldsSetter.setSeverity(issue, BLOCKER, context); - underTest.saveIssue(db.getSession(), issue, context, "increase severity"); + underTest.saveIssueAndPreloadSearchResponseData(db.getSession(), issue, context, false); verifyZeroInteractions(notificationManager); } @@ -213,14 +262,13 @@ public class IssueUpdaterTest { ComponentDto file = db.components().insertComponent(newFileDto(project)); RuleType randomTypeExceptHotspot = RuleType.values()[nextInt(RuleType.values().length - 1)]; DefaultIssue issue = db.issues().insertIssue(IssueTesting.newIssue(rule.getDefinition(), project, file) - .setType(randomTypeExceptHotspot)).setSeverity(MAJOR).toDefaultIssue(); + .setType(randomTypeExceptHotspot)).setSeverity(MAJOR).toDefaultIssue(); IssueChangeContext context = IssueChangeContext.createUser(new Date(), "user_uuid"); issueFieldsSetter.setSeverity(issue, BLOCKER, context); - underTest.saveIssue(db.getSession(), issue, context, null); + underTest.saveIssueAndPreloadSearchResponseData(db.getSession(), issue, context, false); - verify(notificationManager).scheduleForSending(notificationArgumentCaptor.capture()); - assertThat(notificationArgumentCaptor.getValue().getFieldValue("ruleName")).isNull(); + verifyZeroInteractions(notificationManager); } @Test @@ -231,7 +279,7 @@ public class IssueUpdaterTest { ComponentDto file = db.components().insertComponent(newFileDto(project)); RuleType randomTypeExceptHotspot = RuleType.values()[nextInt(RuleType.values().length - 1)]; DefaultIssue issue = db.issues().insertIssue(IssueTesting.newIssue(rule.getDefinition(), project, file) - .setType(randomTypeExceptHotspot)) + .setType(randomTypeExceptHotspot)) .setAssigneeUuid(oldAssignee.getUuid()) .toDefaultIssue(); UserDto changeAuthor = db.users().insertUser(); @@ -239,14 +287,20 @@ public class IssueUpdaterTest { UserDto newAssignee = db.users().insertUser(); issueFieldsSetter.assign(issue, newAssignee, context); - underTest.saveIssue(db.getSession(), issue, context, null); + underTest.saveIssueAndPreloadSearchResponseData(db.getSession(), issue, context, false); verify(notificationManager).scheduleForSending(notificationArgumentCaptor.capture()); - IssueChangeNotification issueChangeNotification = notificationArgumentCaptor.getValue(); - assertThat(issueChangeNotification.getFieldValue("key")).isEqualTo(issue.key()); - assertThat(issueChangeNotification.getFieldValue("new.assignee")).isEqualTo(newAssignee.getName()); - assertThat(issueChangeNotification.getFieldValue("old.assignee")).isNull(); - assertThat(issueChangeNotification.getFieldValue("assignee")).isEqualTo(newAssignee.getLogin()); + IssuesChangesNotification issueChangeNotification = notificationArgumentCaptor.getValue(); + IssuesChangesNotificationBuilder builder = issuesChangesSerializer.from(issueChangeNotification); + assertThat(builder.getIssues()).hasSize(1); + ChangedIssue changedIssue = builder.getIssues().iterator().next(); + assertThat(changedIssue.getKey()).isEqualTo(issue.key()); + assertThat(changedIssue.getNewStatus()).isEqualTo(issue.status()); + assertThat(changedIssue.getNewResolution()).isEmpty(); + assertThat(changedIssue.getAssignee()).contains(userOf(newAssignee)); + assertThat(changedIssue.getRule()).isEqualTo(ruleOf(rule)); + assertThat(changedIssue.getProject()).isEqualTo(projectOf(project)); + assertThat(builder.getChange()).isEqualTo(new UserChange(issue.updateDate().getTime(), userOf(changeAuthor))); } @Test @@ -256,10 +310,11 @@ public class IssueUpdaterTest { ComponentDto file = db.components().insertComponent(newFileDto(project)); IssueDto issueDto = IssueTesting.newIssue(rule.getDefinition(), project, file); DefaultIssue issue = db.issues().insertIssue(issueDto).setSeverity(MAJOR).toDefaultIssue(); - IssueChangeContext context = IssueChangeContext.createUser(new Date(), "user_uuid"); + UserDto changeAuthor = db.users().insertUser(); + IssueChangeContext context = IssueChangeContext.createUser(new Date(), changeAuthor.getUuid()); issueFieldsSetter.setSeverity(issue, BLOCKER, context); - SearchResponseData preloadedSearchResponseData = underTest.saveIssueAndPreloadSearchResponseData(db.getSession(), issue, context, null, true); + SearchResponseData preloadedSearchResponseData = underTest.saveIssueAndPreloadSearchResponseData(db.getSession(), issue, context, true); assertThat(preloadedSearchResponseData.getIssues()) .hasSize(1); @@ -284,7 +339,7 @@ public class IssueUpdaterTest { IssueChangeContext context = IssueChangeContext.createUser(new Date(), "user_uuid"); issueFieldsSetter.setSeverity(issue, BLOCKER, context); - SearchResponseData preloadedSearchResponseData = underTest.saveIssueAndPreloadSearchResponseData(db.getSession(), issue, context, null, false); + SearchResponseData preloadedSearchResponseData = underTest.saveIssueAndPreloadSearchResponseData(db.getSession(), issue, context, false); assertThat(preloadedSearchResponseData.getIssues()) .hasSize(1); diff --git a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/AddCommentActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/AddCommentActionTest.java index d11c0048f36..5bcfb594eb0 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/AddCommentActionTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/AddCommentActionTest.java @@ -36,6 +36,7 @@ import org.sonar.db.issue.IssueChangeDto; import org.sonar.db.issue.IssueDbTester; import org.sonar.db.issue.IssueDto; import org.sonar.db.rule.RuleDefinitionDto; +import org.sonar.db.user.UserDto; import org.sonar.server.es.EsTester; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.NotFoundException; @@ -47,6 +48,7 @@ import org.sonar.server.issue.IssueUpdater; import org.sonar.server.issue.TestIssueChangePostProcessor; import org.sonar.server.issue.index.IssueIndexer; import org.sonar.server.issue.index.IssueIteratorFactory; +import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer; import org.sonar.server.notification.NotificationManager; import org.sonar.server.organization.DefaultOrganizationProvider; import org.sonar.server.organization.TestDefaultOrganizationProvider; @@ -94,7 +96,7 @@ public class AddCommentActionTest { private IssueIndexer issueIndexer = new IssueIndexer(es.client(), dbClient, new IssueIteratorFactory(dbClient)); private WebIssueStorage serverIssueStorage = new WebIssueStorage(system2, dbClient, new DefaultRuleFinder(dbClient, defaultOrganizationProvider), issueIndexer); private TestIssueChangePostProcessor issueChangePostProcessor = new TestIssueChangePostProcessor(); - private IssueUpdater issueUpdater = new IssueUpdater(dbClient, serverIssueStorage, mock(NotificationManager.class), issueChangePostProcessor); + private IssueUpdater issueUpdater = new IssueUpdater(dbClient, serverIssueStorage, mock(NotificationManager.class), issueChangePostProcessor, new IssuesChangesNotificationSerializer()); private OperationResponseWriter responseWriter = mock(OperationResponseWriter.class); private ArgumentCaptor preloadedSearchResponseDataCaptor = ArgumentCaptor.forClass(SearchResponseData.class); @@ -210,7 +212,9 @@ public class AddCommentActionTest { } private void loginWithBrowsePermission(IssueDto issueDto, String permission) { - userSession.logIn("john").addProjectPermission(permission, + UserDto user = dbTester.users().insertUser("john"); + userSession.logIn(user) + .addProjectPermission(permission, dbClient.componentDao().selectByUuid(dbTester.getSession(), issueDto.getProjectUuid()).get(), dbClient.componentDao().selectByUuid(dbTester.getSession(), issueDto.getComponentUuid()).get()); } diff --git a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/AssignActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/AssignActionTest.java index 7943f9e5f20..92d52382ea0 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/AssignActionTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/AssignActionTest.java @@ -43,6 +43,7 @@ import org.sonar.server.issue.TestIssueChangePostProcessor; import org.sonar.server.issue.WebIssueStorage; import org.sonar.server.issue.index.IssueIndexer; import org.sonar.server.issue.index.IssueIteratorFactory; +import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer; import org.sonar.server.notification.NotificationManager; import org.sonar.server.organization.DefaultOrganizationProvider; import org.sonar.server.organization.TestDefaultOrganizationProvider; @@ -83,10 +84,11 @@ public class AssignActionTest { private IssueIndexer issueIndexer = new IssueIndexer(es.client(), dbClient, new IssueIteratorFactory(dbClient)); private OperationResponseWriter responseWriter = mock(OperationResponseWriter.class); private TestIssueChangePostProcessor issueChangePostProcessor = new TestIssueChangePostProcessor(); + private IssuesChangesNotificationSerializer issuesChangesSerializer = new IssuesChangesNotificationSerializer(); private AssignAction underTest = new AssignAction(system2, userSession, dbClient, new IssueFinder(dbClient, userSession), new IssueFieldsSetter(), new IssueUpdater(dbClient, new WebIssueStorage(system2, dbClient, new DefaultRuleFinder(dbClient, defaultOrganizationProvider), issueIndexer), - mock(NotificationManager.class), issueChangePostProcessor), + mock(NotificationManager.class), issueChangePostProcessor, issuesChangesSerializer), responseWriter); private WsActionTester ws = new WsActionTester(underTest); @@ -282,8 +284,8 @@ public class AssignActionTest { } private void setUserWithPermission(IssueDto issue, String permission) { - insertUser(CURRENT_USER_LOGIN); - userSession.logIn(CURRENT_USER_LOGIN) + UserDto user = insertUser(CURRENT_USER_LOGIN); + userSession.logIn(user) .addProjectPermission(permission, dbClient.componentDao().selectByUuid(db.getSession(), issue.getProjectUuid()).get(), dbClient.componentDao().selectByUuid(db.getSession(), issue.getComponentUuid()).get()); diff --git a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/BulkChangeActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/BulkChangeActionTest.java index 2ac7faf5bac..c3d00899d96 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/BulkChangeActionTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/BulkChangeActionTest.java @@ -51,7 +51,11 @@ import org.sonar.server.issue.TransitionService; import org.sonar.server.issue.WebIssueStorage; import org.sonar.server.issue.index.IssueIndexer; import org.sonar.server.issue.index.IssueIteratorFactory; -import org.sonar.server.issue.notification.IssueChangeNotification; +import org.sonar.server.issue.notification.IssuesChangesNotification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer; import org.sonar.server.issue.workflow.FunctionExecutor; import org.sonar.server.issue.workflow.IssueWorkflow; import org.sonar.server.notification.NotificationManager; @@ -77,6 +81,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyZeroInteractions; import static org.sonar.api.issue.Issue.RESOLUTION_FIXED; import static org.sonar.api.issue.Issue.STATUS_CLOSED; +import static org.sonar.api.issue.Issue.STATUS_CONFIRMED; import static org.sonar.api.issue.Issue.STATUS_OPEN; import static org.sonar.api.rule.Severity.MAJOR; import static org.sonar.api.rule.Severity.MINOR; @@ -87,6 +92,10 @@ import static org.sonar.api.web.UserRole.ISSUE_ADMIN; import static org.sonar.api.web.UserRole.USER; import static org.sonar.db.component.ComponentTesting.newFileDto; import static org.sonar.db.issue.IssueChangeDto.TYPE_COMMENT; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.projectBranchOf; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.projectOf; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.ruleOf; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.userOf; public class BulkChangeActionTest { @@ -112,9 +121,12 @@ public class BulkChangeActionTest { new IssueIndexer(es.client(), dbClient, new IssueIteratorFactory(dbClient))); private NotificationManager notificationManager = mock(NotificationManager.class); private TestIssueChangePostProcessor issueChangePostProcessor = new TestIssueChangePostProcessor(); + private IssuesChangesNotificationSerializer issuesChangesSerializer = new IssuesChangesNotificationSerializer(); + private ArgumentCaptor issueChangeNotificationCaptor = ArgumentCaptor.forClass(IssuesChangesNotification.class); private List actions = new ArrayList<>(); - private WsActionTester tester = new WsActionTester(new BulkChangeAction(system2, userSession, dbClient, issueStorage, notificationManager, actions, issueChangePostProcessor)); + private WsActionTester tester = new WsActionTester(new BulkChangeAction(system2, userSession, dbClient, issueStorage, notificationManager, actions, + issueChangePostProcessor, issuesChangesSerializer)); @Before public void setUp() { @@ -300,22 +312,30 @@ public class BulkChangeActionTest { .build()); checkResponse(response, 1, 1, 0, 0); - ArgumentCaptor issueChangeNotificationCaptor = ArgumentCaptor.forClass(IssueChangeNotification.class); verify(notificationManager).scheduleForSending(issueChangeNotificationCaptor.capture()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("key")).isEqualTo(issue.getKey()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("componentName")).isEqualTo(file.longName()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("projectName")).isEqualTo(project.name()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("projectKey")).isEqualTo(project.getDbKey()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("ruleName")).isEqualTo(rule.getName()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("changeAuthor")).isEqualTo(user.getLogin()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("branch")).isNull(); + IssuesChangesNotificationBuilder builder = issuesChangesSerializer.from(issueChangeNotificationCaptor.getValue()); + assertThat(builder.getIssues()).hasSize(1); + ChangedIssue changedIssue = builder.getIssues().iterator().next(); + assertThat(changedIssue.getKey()).isEqualTo(issue.getKey()); + assertThat(changedIssue.getProject().getUuid()).isEqualTo(project.uuid()); + assertThat(changedIssue.getProject().getKey()).isEqualTo(project.getKey()); + assertThat(changedIssue.getProject().getProjectName()).isEqualTo(project.name()); + assertThat(changedIssue.getProject().getBranchName()).isEmpty(); + assertThat(changedIssue.getRule().getKey()).isEqualTo(rule.getKey()); + assertThat(changedIssue.getRule().getName()).isEqualTo(rule.getName()); + assertThat(builder.getChange().getDate()).isEqualTo(NOW); + assertThat(builder.getChange()).isInstanceOf(UserChange.class); + UserChange userChange = (UserChange) builder.getChange(); + assertThat(userChange.getUser().getUuid()).isEqualTo(user.getUuid()); + assertThat(userChange.getUser().getLogin()).isEqualTo(user.getLogin()); + assertThat(userChange.getUser().getName()).contains(user.getName()); } @Test public void hotspots_are_ignored_and_no_notification_is_sent() { UserDto user = db.users().insertUser(); userSession.logIn(user); - ComponentDto project = db.components().insertPrivateProject(); + ComponentDto project = db.components().insertMainBranch(); ComponentDto file = db.components().insertComponent(newFileDto(project)); addUserProjectPermissions(user, project, USER, ISSUE_ADMIN); RuleDefinitionDto rule = db.rules().insert(); @@ -351,22 +371,23 @@ public class BulkChangeActionTest { .build()); checkResponse(response, 1, 1, 0, 0); - ArgumentCaptor issueChangeNotificationCaptor = ArgumentCaptor.forClass(IssueChangeNotification.class); verify(notificationManager).scheduleForSending(issueChangeNotificationCaptor.capture()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("key")).isEqualTo(issue.getKey()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("componentName")).isEqualTo(fileOnBranch.longName()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("projectName")).isEqualTo(project.name()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("projectKey")).isEqualTo(project.getDbKey()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("ruleName")).isEqualTo(rule.getName()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("changeAuthor")).isEqualTo(user.getLogin()); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("branch")).isEqualTo("feature"); + IssuesChangesNotificationBuilder builder = issuesChangesSerializer.from(issueChangeNotificationCaptor.getValue()); + assertThat(builder.getIssues()).hasSize(1); + ChangedIssue changedIssue = builder.getIssues().iterator().next(); + assertThat(changedIssue.getKey()).isEqualTo(issue.getKey()); + assertThat(changedIssue.getNewStatus()).isEqualTo(STATUS_CONFIRMED); + assertThat(changedIssue.getNewResolution()).isEmpty(); + assertThat(changedIssue.getAssignee()).isEmpty(); + assertThat(changedIssue.getRule()).isEqualTo(ruleOf(rule)); + assertThat(changedIssue.getProject()).isEqualTo(projectBranchOf(db, branch)); + assertThat(builder.getChange()).isEqualTo(new UserChange(NOW, userOf(user))); verifyPostProcessorCalled(fileOnBranch); } @Test - public void send_notification_on_short_branch() { - BranchType branchType = BranchType.SHORT; - verifySendNoNotification(branchType); + public void send_no_notification_on_short_branch() { + verifySendNoNotification(BranchType.SHORT); } @Test @@ -418,11 +439,18 @@ public class BulkChangeActionTest { .build()); checkResponse(response, 3, 1, 2, 0); - ArgumentCaptor issueChangeNotificationCaptor = ArgumentCaptor.forClass(IssueChangeNotification.class); verify(notificationManager).scheduleForSending(issueChangeNotificationCaptor.capture()); assertThat(issueChangeNotificationCaptor.getAllValues()).hasSize(1); - assertThat(issueChangeNotificationCaptor.getValue().getFieldValue("key")).isEqualTo(issue3.getKey()); - verifyPostProcessorCalled(file); + IssuesChangesNotificationBuilder builder = issuesChangesSerializer.from(issueChangeNotificationCaptor.getValue()); + assertThat(builder.getIssues()).hasSize(1); + ChangedIssue changedIssue = builder.getIssues().iterator().next(); + assertThat(changedIssue.getKey()).isEqualTo(issue3.getKey()); + assertThat(changedIssue.getNewStatus()).isEqualTo(STATUS_OPEN); + assertThat(changedIssue.getNewResolution()).isEmpty(); + assertThat(changedIssue.getAssignee()).isEmpty(); + assertThat(changedIssue.getRule()).isEqualTo(ruleOf(rule)); + assertThat(changedIssue.getProject()).isEqualTo(projectOf(project)); + assertThat(builder.getChange()).isEqualTo(new UserChange(NOW, userOf(user))); } @Test diff --git a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/DoTransitionActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/DoTransitionActionTest.java index 2ed99bd9a8b..128ca322d8e 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/DoTransitionActionTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/DoTransitionActionTest.java @@ -46,6 +46,7 @@ import org.sonar.server.issue.TransitionService; import org.sonar.server.issue.WebIssueStorage; import org.sonar.server.issue.index.IssueIndexer; import org.sonar.server.issue.index.IssueIteratorFactory; +import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer; import org.sonar.server.issue.workflow.FunctionExecutor; import org.sonar.server.issue.workflow.IssueWorkflow; import org.sonar.server.notification.NotificationManager; @@ -96,9 +97,10 @@ public class DoTransitionActionTest { private OperationResponseWriter responseWriter = mock(OperationResponseWriter.class); private IssueIndexer issueIndexer = new IssueIndexer(es.client(), dbClient, new IssueIteratorFactory(dbClient)); private TestIssueChangePostProcessor issueChangePostProcessor = new TestIssueChangePostProcessor(); + private IssuesChangesNotificationSerializer issuesChangesSerializer = new IssuesChangesNotificationSerializer(); private IssueUpdater issueUpdater = new IssueUpdater(dbClient, new WebIssueStorage(system2, dbClient, new DefaultRuleFinder(dbClient, defaultOrganizationProvider), issueIndexer), mock(NotificationManager.class), - issueChangePostProcessor); + issueChangePostProcessor, issuesChangesSerializer); private ArgumentCaptor preloadedSearchResponseDataCaptor = ArgumentCaptor.forClass(SearchResponseData.class); private WsAction underTest = new DoTransitionAction(dbClient, userSession, new IssueFinder(dbClient, userSession), issueUpdater, transitionService, responseWriter, system2); @@ -115,7 +117,7 @@ public class DoTransitionActionTest { ComponentDto file = db.components().insertComponent(newFileDto(project)); RuleDefinitionDto rule = db.rules().insert(); IssueDto issue = db.issues().insert(rule, project, file, i -> i.setStatus(STATUS_OPEN).setResolution(null)); - userSession.logIn().addProjectPermission(USER, project, file); + userSession.logIn(db.users().insertUser()).addProjectPermission(USER, project, file); call(issue.getKey(), "confirm"); diff --git a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetSeverityActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetSeverityActionTest.java index 94a489526e6..b7b4afa7f06 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetSeverityActionTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetSeverityActionTest.java @@ -37,6 +37,7 @@ import org.sonar.db.issue.IssueDbTester; import org.sonar.db.issue.IssueDto; import org.sonar.db.rule.RuleDefinitionDto; import org.sonar.db.rule.RuleDto; +import org.sonar.db.user.UserDto; import org.sonar.server.es.EsTester; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.UnauthorizedException; @@ -47,6 +48,7 @@ import org.sonar.server.issue.IssueUpdater; import org.sonar.server.issue.TestIssueChangePostProcessor; import org.sonar.server.issue.index.IssueIndexer; import org.sonar.server.issue.index.IssueIteratorFactory; +import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer; import org.sonar.server.notification.NotificationManager; import org.sonar.server.organization.DefaultOrganizationProvider; import org.sonar.server.organization.TestDefaultOrganizationProvider; @@ -91,9 +93,10 @@ public class SetSeverityActionTest { private IssueIndexer issueIndexer = new IssueIndexer(es.client(), dbClient, new IssueIteratorFactory(dbClient)); private TestIssueChangePostProcessor issueChangePostProcessor = new TestIssueChangePostProcessor(); + private IssuesChangesNotificationSerializer issuesChangesSerializer = new IssuesChangesNotificationSerializer(); private WsActionTester tester = new WsActionTester(new SetSeverityAction(userSession, dbClient, new IssueFinder(dbClient, userSession), new IssueFieldsSetter(), new IssueUpdater(dbClient, - new WebIssueStorage(system2, dbClient, new DefaultRuleFinder(dbClient, defaultOrganizationProvider), issueIndexer), mock(NotificationManager.class), issueChangePostProcessor), + new WebIssueStorage(system2, dbClient, new DefaultRuleFinder(dbClient, defaultOrganizationProvider), issueIndexer), mock(NotificationManager.class), issueChangePostProcessor, issuesChangesSerializer), responseWriter)); @Test @@ -187,12 +190,15 @@ public class SetSeverityActionTest { } private void logInAndAddProjectPermission(IssueDto issueDto, String permission) { - userSession.logIn("john").addProjectPermission(permission, dbClient.componentDao().selectByUuid(dbTester.getSession(), issueDto.getProjectUuid()).get()); + UserDto user = dbTester.users().insertUser("john"); + userSession.logIn(user) + .addProjectPermission(permission, dbClient.componentDao().selectByUuid(dbTester.getSession(), issueDto.getProjectUuid()).get()); } private void setUserWithBrowseAndAdministerIssuePermission(IssueDto issueDto) { ComponentDto project = dbClient.componentDao().selectByUuid(dbTester.getSession(), issueDto.getProjectUuid()).get(); - userSession.logIn("john") + UserDto user = dbTester.users().insertUser("john"); + userSession.logIn(user) .addProjectPermission(ISSUE_ADMIN, project) .addProjectPermission(USER, project); } diff --git a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTagsActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTagsActionTest.java index bff4568b616..a9cfab721e7 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTagsActionTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTagsActionTest.java @@ -39,6 +39,7 @@ import org.sonar.db.component.ComponentDto; import org.sonar.db.issue.IssueDto; import org.sonar.db.issue.IssueTesting; import org.sonar.db.rule.RuleDefinitionDto; +import org.sonar.db.user.UserDto; import org.sonar.server.es.EsTester; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.UnauthorizedException; @@ -49,6 +50,7 @@ import org.sonar.server.issue.TestIssueChangePostProcessor; import org.sonar.server.issue.WebIssueStorage; import org.sonar.server.issue.index.IssueIndexer; import org.sonar.server.issue.index.IssueIteratorFactory; +import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer; import org.sonar.server.notification.NotificationManager; import org.sonar.server.organization.DefaultOrganizationProvider; import org.sonar.server.organization.TestDefaultOrganizationProvider; @@ -88,10 +90,12 @@ public class SetTagsActionTest { private IssueIndexer issueIndexer = new IssueIndexer(es.client(), dbClient, new IssueIteratorFactory(dbClient)); private ArgumentCaptor preloadedSearchResponseDataCaptor = ArgumentCaptor.forClass(SearchResponseData.class); private TestIssueChangePostProcessor issueChangePostProcessor = new TestIssueChangePostProcessor(); + private IssuesChangesNotificationSerializer issuesChangesSerializer = new IssuesChangesNotificationSerializer(); private WsActionTester ws = new WsActionTester(new SetTagsAction(userSession, dbClient, new IssueFinder(dbClient, userSession), new IssueFieldsSetter(), new IssueUpdater(dbClient, - new WebIssueStorage(system2, dbClient, new DefaultRuleFinder(dbClient, defaultOrganizationProvider), issueIndexer), mock(NotificationManager.class), issueChangePostProcessor), + new WebIssueStorage(system2, dbClient, new DefaultRuleFinder(dbClient, defaultOrganizationProvider), issueIndexer), mock(NotificationManager.class), + issueChangePostProcessor, issuesChangesSerializer), responseWriter)); @Test @@ -243,13 +247,17 @@ public class SetTagsActionTest { } private void logIn(IssueDto issueDto) { - userSession.logIn("john").registerComponents( - dbClient.componentDao().selectByUuid(db.getSession(), issueDto.getProjectUuid()).get(), - dbClient.componentDao().selectByUuid(db.getSession(), issueDto.getComponentUuid()).get()); + UserDto user = db.users().insertUser("john"); + userSession.logIn(user) + .registerComponents( + dbClient.componentDao().selectByUuid(db.getSession(), issueDto.getProjectUuid()).get(), + dbClient.componentDao().selectByUuid(db.getSession(), issueDto.getComponentUuid()).get()); } private void logInAndAddProjectPermission(IssueDto issueDto, String permission) { - userSession.logIn("john").addProjectPermission(permission, dbClient.componentDao().selectByUuid(db.getSession(), issueDto.getProjectUuid()).get()); + UserDto user = db.users().insertUser("john"); + userSession.logIn(user) + .addProjectPermission(permission, dbClient.componentDao().selectByUuid(db.getSession(), issueDto.getProjectUuid()).get()); } private void verifyContentOfPreloadedSearchResponseData(IssueDto issue) { diff --git a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTypeActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTypeActionTest.java index 700f241ef0c..57326fdd725 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTypeActionTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTypeActionTest.java @@ -47,6 +47,7 @@ import org.sonar.server.issue.IssueUpdater; import org.sonar.server.issue.TestIssueChangePostProcessor; import org.sonar.server.issue.index.IssueIndexer; import org.sonar.server.issue.index.IssueIteratorFactory; +import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer; import org.sonar.server.notification.NotificationManager; import org.sonar.server.organization.DefaultOrganizationProvider; import org.sonar.server.organization.TestDefaultOrganizationProvider; @@ -94,10 +95,11 @@ public class SetTypeActionTest { private IssueIndexer issueIndexer = new IssueIndexer(es.client(), dbClient, new IssueIteratorFactory(dbClient)); private TestIssueChangePostProcessor issueChangePostProcessor = new TestIssueChangePostProcessor(); + private IssuesChangesNotificationSerializer issuesChangesSerializer = new IssuesChangesNotificationSerializer(); private WsActionTester tester = new WsActionTester(new SetTypeAction(userSession, dbClient, new IssueFinder(dbClient, userSession), new IssueFieldsSetter(), new IssueUpdater(dbClient, new WebIssueStorage(system2, dbClient, new DefaultRuleFinder(dbClient, defaultOrganizationProvider), issueIndexer), mock(NotificationManager.class), - issueChangePostProcessor), + issueChangePostProcessor, issuesChangesSerializer), responseWriter, system2)); @Test @@ -207,7 +209,7 @@ public class SetTypeActionTest { private void setUserWithBrowseAndAdministerIssuePermission(IssueDto issueDto) { ComponentDto project = dbClient.componentDao().selectByUuid(dbTester.getSession(), issueDto.getProjectUuid()).get(); - userSession.logIn("john") + userSession.logIn(dbTester.users().insertUser("john")) .addProjectPermission(ISSUE_ADMIN, project) .addProjectPermission(USER, project); } diff --git a/server/sonar-server/src/test/java/org/sonar/server/notification/email/EmailNotificationChannelTest.java b/server/sonar-server/src/test/java/org/sonar/server/notification/email/EmailNotificationChannelTest.java index deed583b73e..7b57c9af448 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/notification/email/EmailNotificationChannelTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/notification/email/EmailNotificationChannelTest.java @@ -139,7 +139,7 @@ public class EmailNotificationChannelTest { EmailMessage emailMessage = new EmailMessage() .setTo("user@nowhere") .setSubject("Foo") - .setMessage("Bar"); + .setPlainTextMessage("Bar"); boolean delivered = underTest.deliver(emailMessage); assertThat(smtpServer.getMessages()).isEmpty(); assertThat(delivered).isFalse(); @@ -153,7 +153,7 @@ public class EmailNotificationChannelTest { .setFrom("Full Username") .setTo("user@nowhere") .setSubject("Review #3") - .setMessage("I'll take care of this violation."); + .setPlainTextMessage("I'll take care of this violation."); boolean delivered = underTest.deliver(emailMessage); List messages = smtpServer.getMessages(); @@ -182,7 +182,7 @@ public class EmailNotificationChannelTest { EmailMessage emailMessage = new EmailMessage() .setTo("user@nowhere") .setSubject("Foo") - .setMessage("Bar"); + .setPlainTextMessage("Bar"); boolean delivered = underTest.deliver(emailMessage); List messages = smtpServer.getMessages(); @@ -213,7 +213,7 @@ public class EmailNotificationChannelTest { EmailMessage emailMessage = new EmailMessage() .setTo("user@nowhere") .setSubject("Foo") - .setMessage("Bar"); + .setPlainTextMessage("Bar"); boolean delivered = underTest.deliver(emailMessage); assertThat(delivered).isFalse(); @@ -291,8 +291,8 @@ public class EmailNotificationChannelTest { Notification notification3 = mock(Notification.class); EmailTemplate template1 = mock(EmailTemplate.class); EmailTemplate template3 = mock(EmailTemplate.class); - EmailMessage emailMessage1 = new EmailMessage().setTo(recipientEmail).setSubject("sub11").setMessage("msg11"); - EmailMessage emailMessage3 = new EmailMessage().setTo(recipientEmail).setSubject("sub3").setMessage("msg3"); + EmailMessage emailMessage1 = new EmailMessage().setTo(recipientEmail).setSubject("sub11").setPlainTextMessage("msg11"); + EmailMessage emailMessage3 = new EmailMessage().setTo(recipientEmail).setSubject("sub3").setPlainTextMessage("msg3"); when(template1.format(notification1)).thenReturn(emailMessage1); when(template3.format(notification3)).thenReturn(emailMessage3); Set requests = Stream.of(notification1, notification2, notification3) @@ -333,8 +333,8 @@ public class EmailNotificationChannelTest { Notification notification1 = mock(Notification.class); EmailTemplate template11 = mock(EmailTemplate.class); EmailTemplate template12 = mock(EmailTemplate.class); - EmailMessage emailMessage11 = new EmailMessage().setTo(recipientEmail).setSubject("sub11").setMessage("msg11"); - EmailMessage emailMessage12 = new EmailMessage().setTo(recipientEmail).setSubject("sub12").setMessage("msg12"); + EmailMessage emailMessage11 = new EmailMessage().setTo(recipientEmail).setSubject("sub11").setPlainTextMessage("msg11"); + EmailMessage emailMessage12 = new EmailMessage().setTo(recipientEmail).setSubject("sub12").setPlainTextMessage("msg12"); when(template11.format(notification1)).thenReturn(emailMessage11); when(template12.format(notification1)).thenReturn(emailMessage12); EmailDeliveryRequest request = new EmailDeliveryRequest(recipientEmail, notification1); diff --git a/server/sonar-server/src/test/java/org/sonar/server/notification/ws/DispatchersImplTest.java b/server/sonar-server/src/test/java/org/sonar/server/notification/ws/DispatchersImplTest.java index cc46c9db775..a6354115e83 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/notification/ws/DispatchersImplTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/notification/ws/DispatchersImplTest.java @@ -22,7 +22,7 @@ package org.sonar.server.notification.ws; import org.junit.Test; import org.sonar.api.config.internal.MapSettings; import org.sonar.api.notifications.NotificationChannel; -import org.sonar.server.issue.notification.DoNotFixNotificationHandler; +import org.sonar.server.issue.notification.FPOrWontFixNotificationHandler; import org.sonar.server.issue.notification.MyNewIssuesNotificationHandler; import org.sonar.server.issue.notification.NewIssuesNotificationHandler; import org.sonar.server.notification.NotificationCenter; @@ -45,7 +45,7 @@ public class DispatchersImplTest { NotificationDispatcherMetadata.create(QGChangeNotificationHandler.KEY) .setProperty(GLOBAL_NOTIFICATION, "true") .setProperty(PER_PROJECT_NOTIFICATION, "true"), - NotificationDispatcherMetadata.create(DoNotFixNotificationHandler.KEY) + NotificationDispatcherMetadata.create(FPOrWontFixNotificationHandler.KEY) .setProperty(GLOBAL_NOTIFICATION, "false") .setProperty(PER_PROJECT_NOTIFICATION, "true") }, @@ -77,7 +77,7 @@ public class DispatchersImplTest { underTest.start(); assertThat(underTest.getProjectDispatchers()).containsExactly( - QGChangeNotificationHandler.KEY, DoNotFixNotificationHandler.KEY, MyNewIssuesNotificationHandler.KEY); + QGChangeNotificationHandler.KEY, FPOrWontFixNotificationHandler.KEY, MyNewIssuesNotificationHandler.KEY); } @Test @@ -87,6 +87,6 @@ public class DispatchersImplTest { underTest.start(); assertThat(underTest.getProjectDispatchers()).containsOnly( - MyNewIssuesNotificationHandler.KEY, QGChangeNotificationHandler.KEY, DoNotFixNotificationHandler.KEY); + MyNewIssuesNotificationHandler.KEY, QGChangeNotificationHandler.KEY, FPOrWontFixNotificationHandler.KEY); } } diff --git a/server/sonar-server/src/test/java/org/sonar/server/tester/UserSessionRule.java b/server/sonar-server/src/test/java/org/sonar/server/tester/UserSessionRule.java index 052c4fe2db4..13f05371271 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/tester/UserSessionRule.java +++ b/server/sonar-server/src/test/java/org/sonar/server/tester/UserSessionRule.java @@ -375,5 +375,4 @@ public class UserSessionRule implements TestRule, UserSession { ensureAbstractMockUserSession().addOrganizationMembership(organization); return this; } - } diff --git a/sonar-core/src/main/java/org/sonar/core/util/stream/MoreCollectors.java b/sonar-core/src/main/java/org/sonar/core/util/stream/MoreCollectors.java index 9da92dc857c..3fac4dd4df2 100644 --- a/sonar-core/src/main/java/org/sonar/core/util/stream/MoreCollectors.java +++ b/sonar-core/src/main/java/org/sonar/core/util/stream/MoreCollectors.java @@ -24,6 +24,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSetMultimap; import java.util.ArrayList; import java.util.EnumSet; import java.util.HashMap; @@ -37,12 +38,15 @@ import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collector; import java.util.stream.Collectors; +import java.util.stream.Stream; import static java.util.Objects.requireNonNull; public final class MoreCollectors { private static final int DEFAULT_HASHMAP_CAPACITY = 0; + private static final String KEY_FUNCTION_CANT_RETURN_NULL_MESSAGE = "Key function can't return null"; + private static final String VALUE_FUNCTION_CANT_RETURN_NULL_MESSAGE = "Value function can't return null"; private MoreCollectors() { // prevents instantiation @@ -247,11 +251,11 @@ public final class MoreCollectors { */ public static Collector, ImmutableMap> uniqueIndex(Function keyFunction, Function valueFunction, int expectedSize) { - requireNonNull(keyFunction, "Key function can't be null"); - requireNonNull(valueFunction, "Value function can't be null"); + verifyKeyAndValueFunctions(keyFunction, valueFunction); + BiConsumer, E> accumulator = (map, element) -> { - K key = requireNonNull(keyFunction.apply(element), "Key function can't return null"); - V value = requireNonNull(valueFunction.apply(element), "Value function can't return null"); + K key = requireNonNull(keyFunction.apply(element), KEY_FUNCTION_CANT_RETURN_NULL_MESSAGE); + V value = requireNonNull(valueFunction.apply(element), VALUE_FUNCTION_CANT_RETURN_NULL_MESSAGE); putAndFailOnDuplicateKey(map, key, value); }; @@ -328,11 +332,11 @@ public final class MoreCollectors { */ public static Collector, ImmutableListMultimap> index(Function keyFunction, Function valueFunction) { - requireNonNull(keyFunction, "Key function can't be null"); - requireNonNull(valueFunction, "Value function can't be null"); + verifyKeyAndValueFunctions(keyFunction, valueFunction); + BiConsumer, E> accumulator = (map, element) -> { - K key = requireNonNull(keyFunction.apply(element), "Key function can't return null"); - V value = requireNonNull(valueFunction.apply(element), "Value function can't return null"); + K key = requireNonNull(keyFunction.apply(element), KEY_FUNCTION_CANT_RETURN_NULL_MESSAGE); + V value = requireNonNull(valueFunction.apply(element), VALUE_FUNCTION_CANT_RETURN_NULL_MESSAGE); map.put(key, value); }; @@ -349,6 +353,93 @@ public final class MoreCollectors { ImmutableListMultimap.Builder::build); } + /** + * Creates an {@link com.google.common.collect.ImmutableSetMultimap} from the stream where the values are the values + * in the stream and the keys are the result of the provided {@link Function keyFunction} applied to each value in the + * stream. + * + *

+ * Neither {@link Function keyFunction} nor {@link Function valueFunction} can return {@code null}, otherwise a + * {@link NullPointerException} will be thrown. + *

+ * + * @throws NullPointerException if {@code keyFunction} or {@code valueFunction} is {@code null}. + * @throws NullPointerException if result of {@code keyFunction} or {@code valueFunction} is {@code null}. + */ + public static Collector, ImmutableSetMultimap> unorderedIndex(Function keyFunction) { + return unorderedIndex(keyFunction, Function.identity()); + } + + /** + * Creates an {@link com.google.common.collect.ImmutableSetMultimap} from the stream where the values are the result + * of {@link Function valueFunction} applied to the values in the stream and the keys are the result of the provided + * {@link Function keyFunction} applied to each value in the stream. + * + *

+ * Neither {@link Function keyFunction} nor {@link Function valueFunction} can return {@code null}, otherwise a + * {@link NullPointerException} will be thrown. + *

+ * + * @throws NullPointerException if {@code keyFunction} or {@code valueFunction} is {@code null}. + * @throws NullPointerException if result of {@code keyFunction} or {@code valueFunction} is {@code null}. + */ + public static Collector, ImmutableSetMultimap> unorderedIndex(Function keyFunction, + Function valueFunction) { + verifyKeyAndValueFunctions(keyFunction, valueFunction); + + BiConsumer, E> accumulator = (map, element) -> { + K key = requireNonNull(keyFunction.apply(element), KEY_FUNCTION_CANT_RETURN_NULL_MESSAGE); + V value = requireNonNull(valueFunction.apply(element), VALUE_FUNCTION_CANT_RETURN_NULL_MESSAGE); + + map.put(key, value); + }; + BinaryOperator> merger = (m1, m2) -> { + for (Map.Entry entry : m2.build().entries()) { + m1.put(entry.getKey(), entry.getValue()); + } + return m1; + }; + return Collector.of( + ImmutableSetMultimap::builder, + accumulator, + merger, + ImmutableSetMultimap.Builder::build); + } + + /** + * A Collector similar to {@link #unorderedIndex(Function, Function)} except that it expects the {@code valueFunction} + * to return a {@link Stream} which content will be flatten into the returned {@link ImmutableSetMultimap}. + * + * @see #unorderedIndex(Function, Function) + */ + public static Collector, ImmutableSetMultimap> unorderedFlattenIndex( + Function keyFunction, Function> valueFunction) { + verifyKeyAndValueFunctions(keyFunction, valueFunction); + + BiConsumer, E> accumulator = (map, element) -> { + K key = requireNonNull(keyFunction.apply(element), KEY_FUNCTION_CANT_RETURN_NULL_MESSAGE); + Stream valueStream = requireNonNull(valueFunction.apply(element), VALUE_FUNCTION_CANT_RETURN_NULL_MESSAGE); + + valueStream.forEach(value -> map.put(key, value)); + }; + BinaryOperator> merger = (m1, m2) -> { + for (Map.Entry entry : m2.build().entries()) { + m1.put(entry.getKey(), entry.getValue()); + } + return m1; + }; + return Collector.of( + ImmutableSetMultimap::builder, + accumulator, + merger, + ImmutableSetMultimap.Builder::build); + } + + private static void verifyKeyAndValueFunctions(Function keyFunction, Function valueFunction) { + requireNonNull(keyFunction, "Key function can't be null"); + requireNonNull(valueFunction, "Value function can't be null"); + } + /** * Applies the specified {@link Joiner} to the current stream. * diff --git a/sonar-core/src/test/java/org/sonar/core/util/stream/MoreCollectorsTest.java b/sonar-core/src/test/java/org/sonar/core/util/stream/MoreCollectorsTest.java index 1de9bca52fd..713c33f217a 100644 --- a/sonar-core/src/test/java/org/sonar/core/util/stream/MoreCollectorsTest.java +++ b/sonar-core/src/test/java/org/sonar/core/util/stream/MoreCollectorsTest.java @@ -22,7 +22,8 @@ package org.sonar.core.util.stream; import com.google.common.base.Joiner; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Multimap; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.SetMultimap; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -32,6 +33,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; import org.junit.Rule; @@ -48,6 +50,8 @@ import static org.sonar.core.util.stream.MoreCollectors.toHashSet; import static org.sonar.core.util.stream.MoreCollectors.toList; import static org.sonar.core.util.stream.MoreCollectors.toSet; import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex; +import static org.sonar.core.util.stream.MoreCollectors.unorderedFlattenIndex; +import static org.sonar.core.util.stream.MoreCollectors.unorderedIndex; public class MoreCollectorsTest { @@ -57,9 +61,16 @@ public class MoreCollectorsTest { private static final MyObj MY_OBJ_1_C = new MyObj(1, "C"); private static final MyObj MY_OBJ_2_B = new MyObj(2, "B"); private static final MyObj MY_OBJ_3_C = new MyObj(3, "C"); + private static final MyObj2 MY_OBJ2_1_A_X = new MyObj2(1, "A", "X"); + private static final MyObj2 MY_OBJ2_1_C = new MyObj2(1, "C"); + private static final MyObj2 MY_OBJ2_2_B = new MyObj2(2, "B"); + private static final MyObj2 MY_OBJ2_3_C = new MyObj2(3, "C"); private static final List SINGLE_ELEMENT_LIST = Arrays.asList(MY_OBJ_1_A); + private static final List SINGLE_ELEMENT2_LIST = Arrays.asList(MY_OBJ2_1_A_X); private static final List LIST_WITH_DUPLICATE_ID = Arrays.asList(MY_OBJ_1_A, MY_OBJ_2_B, MY_OBJ_1_C); + private static final List LIST2_WITH_DUPLICATE_ID = Arrays.asList(MY_OBJ2_1_A_X, MY_OBJ2_2_B, MY_OBJ2_1_C); private static final List LIST = Arrays.asList(MY_OBJ_1_A, MY_OBJ_2_B, MY_OBJ_3_C); + private static final List LIST2 = Arrays.asList(MY_OBJ2_1_A_X, MY_OBJ2_2_B, MY_OBJ2_3_C); @Rule public ExpectedException expectedException = ExpectedException.none(); @@ -356,6 +367,15 @@ public class MoreCollectorsTest { assertThat(map.values()).containsExactlyElementsOf(HUGE_SET); } + @Test + public void uniqueIndex_supports_duplicate_keys() { + ListMultimap multimap = LIST_WITH_DUPLICATE_ID.stream().collect(index(MyObj::getId, MyObj::getText)); + + assertThat(multimap.keySet()).containsOnly(1, 2); + assertThat(multimap.get(1)).containsOnly("A", "C"); + assertThat(multimap.get(2)).containsOnly("B"); + } + @Test public void index_empty_stream_returns_empty_map() { assertThat(Collections.emptyList().stream().collect(index(MyObj::getId)).size()).isEqualTo(0); @@ -409,7 +429,7 @@ public class MoreCollectorsTest { @Test public void index_supports_duplicate_keys() { - Multimap multimap = LIST_WITH_DUPLICATE_ID.stream().collect(index(MyObj::getId)); + ListMultimap multimap = LIST_WITH_DUPLICATE_ID.stream().collect(index(MyObj::getId)); assertThat(multimap.keySet()).containsOnly(1, 2); assertThat(multimap.get(1)).containsOnly(MY_OBJ_1_A, MY_OBJ_1_C); @@ -417,17 +437,104 @@ public class MoreCollectorsTest { } @Test - public void uniqueIndex_supports_duplicate_keys() { - Multimap multimap = LIST_WITH_DUPLICATE_ID.stream().collect(index(MyObj::getId, MyObj::getText)); + public void index_returns_ListMultimap() { + ListMultimap multimap = LIST.stream().collect(index(MyObj::getId)); + + assertThat(multimap.size()).isEqualTo(3); + Map> map = multimap.asMap(); + assertThat(map.get(1)).containsOnly(MY_OBJ_1_A); + assertThat(map.get(2)).containsOnly(MY_OBJ_2_B); + assertThat(map.get(3)).containsOnly(MY_OBJ_3_C); + } + + @Test + public void index_with_valueFunction_returns_ListMultimap() { + ListMultimap multimap = LIST.stream().collect(index(MyObj::getId, MyObj::getText)); + + assertThat(multimap.size()).isEqualTo(3); + Map> map = multimap.asMap(); + assertThat(map.get(1)).containsOnly("A"); + assertThat(map.get(2)).containsOnly("B"); + assertThat(map.get(3)).containsOnly("C"); + } + + @Test + public void index_parallel_stream() { + ListMultimap multimap = HUGE_LIST.parallelStream().collect(index(identity())); + + assertThat(multimap.keySet()).isEqualTo(HUGE_SET); + } + + @Test + public void index_with_valueFunction_parallel_stream() { + ListMultimap multimap = HUGE_LIST.parallelStream().collect(index(identity(), identity())); + + assertThat(multimap.keySet()).isEqualTo(HUGE_SET); + } + + @Test + public void unorderedIndex_empty_stream_returns_empty_map() { + assertThat(Collections.emptyList().stream().collect(unorderedIndex(MyObj::getId)).size()).isEqualTo(0); + assertThat(Collections.emptyList().stream().collect(unorderedIndex(MyObj::getId, MyObj::getText)).size()).isEqualTo(0); + } + + @Test + public void unorderedIndex_fails_if_key_function_is_null() { + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("Key function can't be null"); + + unorderedIndex(null); + } + + @Test + public void unorderedIndex_with_valueFunction_fails_if_key_function_is_null() { + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("Key function can't be null"); + + unorderedIndex(null, MyObj::getText); + } + + @Test + public void unorderedIndex_with_valueFunction_fails_if_value_function_is_null() { + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("Value function can't be null"); + + unorderedIndex(MyObj::getId, null); + } + + @Test + public void unorderedIndex_fails_if_key_function_returns_null() { + expectKeyFunctionCantReturnNullNPE(); + + SINGLE_ELEMENT_LIST.stream().collect(unorderedIndex(s -> null)); + } + + @Test + public void unorderedIndex_with_valueFunction_fails_if_key_function_returns_null() { + expectKeyFunctionCantReturnNullNPE(); + + SINGLE_ELEMENT_LIST.stream().collect(unorderedIndex(s -> null, MyObj::getText)); + } + + @Test + public void unorderedIndex_with_valueFunction_fails_if_value_function_returns_null() { + expectValueFunctionCantReturnNullNPE(); + + SINGLE_ELEMENT_LIST.stream().collect(unorderedIndex(MyObj::getId, s -> null)); + } + + @Test + public void unorderedIndex_supports_duplicate_keys() { + SetMultimap multimap = LIST_WITH_DUPLICATE_ID.stream().collect(unorderedIndex(MyObj::getId)); assertThat(multimap.keySet()).containsOnly(1, 2); - assertThat(multimap.get(1)).containsOnly("A", "C"); - assertThat(multimap.get(2)).containsOnly("B"); + assertThat(multimap.get(1)).containsOnly(MY_OBJ_1_A, MY_OBJ_1_C); + assertThat(multimap.get(2)).containsOnly(MY_OBJ_2_B); } @Test - public void index_returns_multimap() { - Multimap multimap = LIST.stream().collect(index(MyObj::getId)); + public void unorderedIndex_returns_SetMultimap() { + SetMultimap multimap = LIST.stream().collect(unorderedIndex(MyObj::getId)); assertThat(multimap.size()).isEqualTo(3); Map> map = multimap.asMap(); @@ -437,8 +544,8 @@ public class MoreCollectorsTest { } @Test - public void index_with_valueFunction_returns_multimap() { - Multimap multimap = LIST.stream().collect(index(MyObj::getId, MyObj::getText)); + public void unorderedIndex_with_valueFunction_returns_SetMultimap() { + SetMultimap multimap = LIST.stream().collect(unorderedIndex(MyObj::getId, MyObj::getText)); assertThat(multimap.size()).isEqualTo(3); Map> map = multimap.asMap(); @@ -448,19 +555,113 @@ public class MoreCollectorsTest { } @Test - public void index_parallel_stream() { - Multimap multimap = HUGE_LIST.parallelStream().collect(index(identity())); + public void unorderedIndex_parallel_stream() { + SetMultimap multimap = HUGE_LIST.parallelStream().collect(unorderedIndex(identity())); assertThat(multimap.keySet()).isEqualTo(HUGE_SET); } @Test - public void index_with_valueFunction_parallel_stream() { - Multimap multimap = HUGE_LIST.parallelStream().collect(index(identity(), identity())); + public void unorderedIndex_with_valueFunction_parallel_stream() { + SetMultimap multimap = HUGE_LIST.parallelStream().collect(unorderedIndex(identity(), identity())); + + assertThat(multimap.keySet()).isEqualTo(HUGE_SET); + } + + + + + + + @Test + public void unorderedFlattenIndex_empty_stream_returns_empty_map() { + assertThat(Collections.emptyList().stream() + .collect(unorderedFlattenIndex(MyObj2::getId, MyObj2::getTexts)) + .size()).isEqualTo(0); + } + + @Test + public void unorderedFlattenIndex_with_valueFunction_fails_if_key_function_is_null() { + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("Key function can't be null"); + + unorderedFlattenIndex(null, MyObj2::getTexts); + } + + @Test + public void unorderedFlattenIndex_with_valueFunction_fails_if_value_function_is_null() { + expectedException.expect(NullPointerException.class); + expectedException.expectMessage("Value function can't be null"); + + unorderedFlattenIndex(MyObj2::getId, null); + } + + @Test + public void unorderedFlattenIndex_with_valueFunction_fails_if_key_function_returns_null() { + expectKeyFunctionCantReturnNullNPE(); + + SINGLE_ELEMENT2_LIST.stream().collect(unorderedFlattenIndex(s -> null, MyObj2::getTexts)); + } + + @Test + public void unorderedFlattenIndex_with_valueFunction_fails_if_value_function_returns_null() { + expectValueFunctionCantReturnNullNPE(); + + SINGLE_ELEMENT2_LIST.stream().collect(unorderedFlattenIndex(MyObj2::getId, s -> null)); + } + + @Test + public void unorderedFlattenIndex_supports_duplicate_keys() { + SetMultimap multimap = LIST2_WITH_DUPLICATE_ID.stream() + .collect(unorderedFlattenIndex(MyObj2::getId, MyObj2::getTexts)); + + assertThat(multimap.keySet()).containsOnly(1, 2); + assertThat(multimap.get(1)).containsOnly("A", "X", "C"); + assertThat(multimap.get(2)).containsOnly("B"); + } + + @Test + public void unorderedFlattenIndex_with_valueFunction_returns_SetMultimap() { + SetMultimap multimap = LIST2.stream() + .collect(unorderedFlattenIndex(MyObj2::getId, MyObj2::getTexts)); + + assertThat(multimap.size()).isEqualTo(4); + Map> map = multimap.asMap(); + assertThat(map.get(1)).containsOnly("A", "X"); + assertThat(map.get(2)).containsOnly("B"); + assertThat(map.get(3)).containsOnly("C"); + } + + @Test + public void unorderedFlattenIndex_with_valueFunction_parallel_stream() { + SetMultimap multimap = HUGE_LIST.parallelStream().collect(unorderedFlattenIndex(identity(), Stream::of)); assertThat(multimap.keySet()).isEqualTo(HUGE_SET); } + + + + + + + + + + + + + + + + + + + + + + + @Test public void join_on_empty_stream_returns_empty_string() { assertThat(Collections.emptyList().stream().collect(join(Joiner.on(",")))).isEmpty(); @@ -532,6 +733,24 @@ public class MoreCollectorsTest { } } + private static final class MyObj2 { + private final int id; + private final List texts; + + public MyObj2(int id, String... texts) { + this.id = id; + this.texts = Arrays.stream(texts).collect(Collectors.toList()); + } + + public int getId() { + return id; + } + + public Stream getTexts() { + return texts.stream(); + } + } + private enum MyEnum { ONE, TWO, THREE } diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/config/EmailSettings.java b/sonar-plugin-api/src/main/java/org/sonar/api/config/EmailSettings.java index 2a785123b6d..d35642a7904 100644 --- a/sonar-plugin-api/src/main/java/org/sonar/api/config/EmailSettings.java +++ b/sonar-plugin-api/src/main/java/org/sonar/api/config/EmailSettings.java @@ -100,6 +100,10 @@ public class EmailSettings { .orElse(SERVER_BASE_URL_DEFAULT_VALUE); } + public String getInstanceName() { + return config.getBoolean("sonar.sonarcloud.enabled").orElse(false) ? "SonarCloud" : "SonarQube"; + } + private String get(String key, String defaultValue) { return config.get(key).orElse(defaultValue); } diff --git a/sonar-plugin-api/src/test/java/org/sonar/api/config/EmailSettingsTest.java b/sonar-plugin-api/src/test/java/org/sonar/api/config/EmailSettingsTest.java index af8963c085d..72c2fea4607 100644 --- a/sonar-plugin-api/src/test/java/org/sonar/api/config/EmailSettingsTest.java +++ b/sonar-plugin-api/src/test/java/org/sonar/api/config/EmailSettingsTest.java @@ -60,6 +60,18 @@ public class EmailSettingsTest { assertThat(underTest.getServerBaseURL()).isEqualTo("http://www.acme.com"); } + @Test + public void getInstanceName_returns_sonarqube_when_not_on_SonarCloud() { + assertThat(underTest.getInstanceName()).isEqualTo("SonarQube"); + } + + @Test + public void getInstanceName_returns_sonarcloud_on_SonarCloud() { + settings.setProperty("sonar.sonarcloud.enabled", true); + + assertThat(underTest.getInstanceName()).isEqualTo("SonarCloud"); + } + @Test public void return_definitions() { assertThat(EmailSettings.definitions()).hasSize(8);