Browse Source

SONAR-11757 single notification for FPs and changes on my issues

tags/7.8
Sébastien Lesaint 5 years ago
parent
commit
58bb4b37da
66 changed files with 5412 additions and 1548 deletions
  1. 2
    2
      server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/container/ProjectAnalysisTaskContainerPopulator.java
  2. 66
    2
      server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactory.java
  3. 3
    1
      server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/ReportAnalysisFailureNotificationEmailTemplate.java
  4. 28
    64
      server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStep.java
  5. 1
    0
      server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/issue/DumbRule.java
  6. 399
    2
      server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactoryTest.java
  7. 103
    51
      server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStepTest.java
  8. 2
    8
      server/sonar-ce/src/main/java/org/sonar/ce/container/ComputeEngineContainerImpl.java
  9. 2
    1
      server/sonar-ce/src/test/java/org/sonar/ce/container/ComputeEngineContainerImplTest.java
  10. 3
    1
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/AbstractNewIssuesEmailTemplate.java
  11. 103
    40
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandler.java
  12. 146
    0
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplate.java
  13. 74
    0
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotification.java
  14. 0
    104
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/DoNotFixNotificationHandler.java
  15. 24
    7
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailMessage.java
  16. 2
    0
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailTemplate.java
  17. 96
    0
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotification.java
  18. 172
    0
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandler.java
  19. 101
    0
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplate.java
  20. 0
    142
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangeNotification.java
  21. 152
    124
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangesEmailTemplate.java
  22. 32
    0
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotification.java
  23. 461
    0
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilder.java
  24. 37
    0
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationModule.java
  25. 307
    0
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationSerializer.java
  26. 47
    0
      server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/NotificationWithProjectKeys.java
  27. 69
    41
      server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java
  28. 3
    1
      server/sonar-server-common/src/main/java/org/sonar/server/qualitygate/notification/QGChangeEmailTemplate.java
  29. 260
    105
      server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandlerTest.java
  30. 721
    0
      server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplateTest.java
  31. 73
    0
      server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotificationTest.java
  32. 0
    293
      server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/DoNotFixNotificationHandlerTest.java
  33. 49
    0
      server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/EmailMessageTest.java
  34. 498
    0
      server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandlerTest.java
  35. 92
    0
      server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationTest.java
  36. 421
    0
      server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplateTest.java
  37. 0
    169
      server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangeNotificationTest.java
  38. 0
    200
      server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangesEmailTemplateTest.java
  39. 110
    0
      server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilderTesting.java
  40. 37
    0
      server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationModuleTest.java
  41. 35
    0
      server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationTest.java
  42. 43
    32
      server/sonar-server/src/main/java/org/sonar/server/issue/IssueUpdater.java
  43. 1
    1
      server/sonar-server/src/main/java/org/sonar/server/issue/ws/AddCommentAction.java
  44. 1
    1
      server/sonar-server/src/main/java/org/sonar/server/issue/ws/AssignAction.java
  45. 73
    21
      server/sonar-server/src/main/java/org/sonar/server/issue/ws/BulkChangeAction.java
  46. 1
    1
      server/sonar-server/src/main/java/org/sonar/server/issue/ws/DoTransitionAction.java
  47. 1
    1
      server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetSeverityAction.java
  48. 1
    1
      server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTagsAction.java
  49. 1
    1
      server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTypeAction.java
  50. 4
    10
      server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java
  51. 3
    1
      server/sonar-server/src/main/java/org/sonar/server/qualityprofile/BuiltInQPChangeNotificationTemplate.java
  52. 98
    43
      server/sonar-server/src/test/java/org/sonar/server/issue/IssueUpdaterTest.java
  53. 6
    2
      server/sonar-server/src/test/java/org/sonar/server/issue/ws/AddCommentActionTest.java
  54. 5
    3
      server/sonar-server/src/test/java/org/sonar/server/issue/ws/AssignActionTest.java
  55. 53
    25
      server/sonar-server/src/test/java/org/sonar/server/issue/ws/BulkChangeActionTest.java
  56. 4
    2
      server/sonar-server/src/test/java/org/sonar/server/issue/ws/DoTransitionActionTest.java
  57. 9
    3
      server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetSeverityActionTest.java
  58. 13
    5
      server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTagsActionTest.java
  59. 4
    2
      server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTypeActionTest.java
  60. 8
    8
      server/sonar-server/src/test/java/org/sonar/server/notification/email/EmailNotificationChannelTest.java
  61. 4
    4
      server/sonar-server/src/test/java/org/sonar/server/notification/ws/DispatchersImplTest.java
  62. 0
    1
      server/sonar-server/src/test/java/org/sonar/server/tester/UserSessionRule.java
  63. 99
    8
      sonar-core/src/main/java/org/sonar/core/util/stream/MoreCollectors.java
  64. 233
    14
      sonar-core/src/test/java/org/sonar/core/util/stream/MoreCollectorsTest.java
  65. 4
    0
      sonar-plugin-api/src/main/java/org/sonar/api/config/EmailSettings.java
  66. 12
    0
      sonar-plugin-api/src/test/java/org/sonar/api/config/EmailSettingsTest.java

+ 2
- 2
server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/container/ProjectAnalysisTaskContainerPopulator.java View File

@@ -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);
}

}

server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactory.java → server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactory.java View File

@@ -22,35 +22,56 @@ 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 NewIssuesNotificationFactory {
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<String, Component> componentsByUuid;

public NewIssuesNotificationFactory(TreeRootHolder treeRootHolder, RuleRepository ruleRepository, Durations durations) {
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<String, UserDto> assigneesByUuid) {
@@ -63,6 +84,49 @@ public class NewIssuesNotificationFactory {
return new NewIssuesNotification(durations, new DetailsSupplierImpl(assigneesByUuid));
}

public IssuesChangesNotification newIssuesChangesNotification(Set<DefaultIssue> issues, Map<String, UserDto> assigneesByUuid) {
AnalysisChange change = new AnalysisChange(analysisMetadataHolder.getAnalysisDate());
Set<ChangedIssue> 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<String, UserDto> 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<String, UserDto> assigneesByUuid) {
requireNonNull(assigneesByUuid, "assigneesByUuid can't be null");
}

+ 3
- 1
server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/ReportAnalysisFailureNotificationEmailTemplate.java View File

@@ -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) {

+ 28
- 64
server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStep.java View File

@@ -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<Class<? extends Notification>> NOTIF_TYPES = ImmutableSet.of(NewIssuesNotification.class, MyNewIssuesNotification.class, IssueChangeNotification.class);
static final Set<Class<? extends Notification>> 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<String, Component> 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<String, UserDto> assigneesByUuid;
try (DbSession dbSession = dbClient.openSession(false)) {
Iterable<DefaultIssue> iterable = issueCache::traverse;
Set<String> assigneeUuids = stream(iterable.spliterator(), false).map(DefaultIssue::assignee).filter(Objects::nonNull).collect(toSet());
Set<String> 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<DefaultIssue> 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<DefaultIssue> issues, Component project, Map<String, UserDto> usersDtoByUuids,
NotificationStatistics notificationStatistics) {
private void processIssues(NewIssuesStatistics newIssuesStats, CloseableIterator<DefaultIssue> issues,
Map<String, UserDto> assigneesByUuid, NotificationStatistics notificationStatistics) {
int batchSize = 1000;
List<DefaultIssue> loadedIssues = new ArrayList<>(batchSize);
Set<DefaultIssue> 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<DefaultIssue> issues, Component project, Map<String, UserDto> usersDtoByUuids,
NotificationStatistics notificationStatistics) {
Set<IssueChangeNotification> 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<DefaultIssue> issues, Map<String, UserDto> 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<String, UserDto> 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<Component> getComponentKey(DefaultIssue issue) {
if (componentsByDbKey == null) {
final ImmutableMap.Builder<String, Component> 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";

+ 1
- 0
server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/issue/DumbRule.java View File

@@ -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

server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactoryTest.java → server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactoryTest.java View File

@@ -20,7 +20,13 @@
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;
@@ -29,36 +35,58 @@ 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;

public class NewIssuesNotificationFactoryTest {
@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 NewIssuesNotificationFactory underTest = new NewIssuesNotificationFactory(treeRootHolder, ruleRepository, 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() {
@@ -392,6 +420,375 @@ public class NewIssuesNotificationFactoryTest {
.isEmpty();
}

@Test
public void newIssuesChangesNotification_fails_with_ISE_if_analysis_date_has_not_been_set() {
Set<DefaultIssue> issues = IntStream.range(0, 1 + new Random().nextInt(2))
.mapToObj(i -> new DefaultIssue())
.collect(Collectors.toSet());
Map<String, UserDto> 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<String, UserDto> 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<String, UserDto> 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<String, UserDto> 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<String, UserDto> 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<String, UserDto> 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<String, UserDto> 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<String, UserDto> 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<String, UserDto> 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<String, UserDto> 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<String, UserDto> 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<String, UserDto> 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<String, UserDto> 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<String, UserDto> 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<DefaultIssue> 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<String, ChangedIssue> 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<String, UserDto> nonEmptyAssigneesByUuid() {
return IntStream.range(0, 1 + new Random().nextInt(3))
.boxed()
.collect(uniqueIndex(i -> "uuid_" + i, i -> new UserDto()));
}

private IssuesChangesNotificationBuilder verifyAndCaptureIssueChangeNotificationBuilder() {
ArgumentCaptor<IssuesChangesNotificationBuilder> 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");
}

+ 103
- 51
server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStepTest.java View File

@@ -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<Map<String, UserDto>> assigneeCacheType = (Class<Map<String, UserDto>>) (Object) Map.class;
@SuppressWarnings("unchecked")
private Class<Set<DefaultIssue>> setType = (Class<Set<DefaultIssue>>) (Class<?>) Set.class;
@SuppressWarnings("unchecked")
private Class<Map<String, UserDto>> mapType = (Class<Map<String, UserDto>>) (Class<?>) Map.class;
private ArgumentCaptor<Map<String, UserDto>> assigneeCacheCaptor = ArgumentCaptor.forClass(assigneeCacheType);
private ArgumentCaptor<Set<DefaultIssue>> issuesSetCaptor = forClass(setType);
private ArgumentCaptor<Map<String, UserDto>> 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<String, MyNewIssuesNotification> 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<IssueChangeNotification> 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<IssueChangeNotification> 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<DefaultIssue> issues = IntStream.range(0, 1001 + new Random().nextInt(10))
.mapToObj(i -> newIssue(ruleDefinitionDto, project, file).setType(randomTypeExceptHotspot).toDefaultIssue()
List<DefaultIssue> 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<DefaultIssue>.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<Collection> 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<Object> {
private final Supplier<IssuesChangesNotification> delegate;
private final List<Set<DefaultIssue>> issuesSetCaptor = new ArrayList<>();
private final List<Map<String, UserDto>> assigneeCacheCaptor = new ArrayList<>();

private NewIssuesFactoryCaptor(Supplier<IssuesChangesNotification> delegate) {
this.delegate = delegate;
}

@Override
public Object answer(InvocationOnMock t) {
Set<DefaultIssue> issuesSet = t.getArgument(0);
Map<String, UserDto> assigneeCatch = t.getArgument(1);
issuesSetCaptor.add(ImmutableSet.copyOf(issuesSet));
assigneeCacheCaptor.add(ImmutableMap.copyOf(assigneeCatch));
return delegate.get();
}
}

private NewIssuesNotification createNewIssuesNotificationMock() {

+ 2
- 8
server/sonar-ce/src/main/java/org/sonar/ce/container/ComputeEngineContainerImpl.java View File

@@ -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,

+ 2
- 1
server/sonar-ce/src/test/java/org/sonar/ce/container/ComputeEngineContainerImplTest.java View File

@@ -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

+ 3
- 1
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/AbstractNewIssuesEmailTemplate.java View File

@@ -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) {

+ 103
- 40
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandler.java View File

@@ -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<IssueChangeNotification> {
public class ChangesOnMyIssueNotificationHandler extends EmailNotificationHandler<IssuesChangesNotification> {

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<IssueChangeNotification> getNotificationClass() {
return IssueChangeNotification.class;
public Class<IssuesChangesNotification> getNotificationClass() {
return IssuesChangesNotification.class;
}

@Override
public Set<EmailDeliveryRequest> toEmailDeliveryRequests(Collection<IssueChangeNotification> notifications) {
Multimap<String, IssueChangeNotification> 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<EmailDeliveryRequest> toEmailDeliveryRequests(Collection<IssuesChangesNotification> notifications) {
Set<NotificationWithProjectKeys> 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<String> 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<User> 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<EmailRecipient> 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<String, String> 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<String, EmailRecipient> 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<String> assigneeLogins = (Set<String>) entry.getValue();
return notificationManager.findSubscribedEmailRecipients(KEY, projectKey, assigneeLogins, ALL_MUST_HAVE_ROLE_USER).stream();
}));

private Stream<? extends EmailDeliveryRequest> toEmailDeliveryRequests(String projectKey, Collection<IssueChangeNotification> notifications) {
Set<String> assignees = notifications.stream()
.map(IssueChangeNotification::getAssignee)
.collect(Collectors.toSet());
Map<String, EmailRecipient> recipientsByLogin = notificationManager
.findSubscribedEmailRecipients(KEY, projectKey, assignees, ALL_MUST_HAVE_ROLE_USER)
SetMultimap<EmailRecipient, String> 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<String> subscribedProjectKeys = (Set<String>) 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<String, EmailRecipient> 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<String> subscribedProjectKeys) {
Set<ChangedIssue> 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<User> assignee = issue.getAssignee();
return !assignee.isPresent() || !change.isAuthorLogin(assignee.get().getLogin());
}

}

+ 146
- 0
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplate.java View File

@@ -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<String, ChangedIssue> issuesByNewStatus = notification.getChangedIssues().values().stream()
.collect(index(changedIssue -> STATUS_CLOSED.equals(changedIssue.getNewStatus()) ? STATUS_CLOSED : STATUS_OPEN, t -> t));

List<ChangedIssue> closedIssues = issuesByNewStatus.get(STATUS_CLOSED);
if (!closedIssues.isEmpty()) {
paragraph(sb, s -> s.append("Closed ").append(issueOrIssues(closedIssues)).append(":"));
addIssuesByRule(sb, closedIssues, projectIssuePageHref(projectParams));
}
List<ChangedIssue> 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<Project, ChangedIssue> 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<Project, ChangedIssue> changedIssues) {
if (changedIssues.size() > 1) {
return "issues";
}
return "an issue";
}

}

+ 74
- 0
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotification.java View File

@@ -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.
* <p>
* 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<Project, ChangedIssue> changedIssues;

public ChangesOnMyIssuesNotification(Change change, Collection<ChangedIssue> changedIssues) {
super("ChangesOnMyIssues");
this.change = change;
this.changedIssues = changedIssues.stream().collect(unorderedIndex(ChangedIssue::getProject, t -> t));
}

public Change getChange() {
return change;
}

public SetMultimap<Project, ChangedIssue> 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);
}
}

+ 0
- 104
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/DoNotFixNotificationHandler.java View File

@@ -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<IssueChangeNotification> {

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<String> 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<NotificationDispatcherMetadata> getMetadata() {
return of(METADATA);
}

public static NotificationDispatcherMetadata newMetadata() {
return METADATA;
}

@Override
public Class<IssueChangeNotification> getNotificationClass() {
return IssueChangeNotification.class;
}

@Override
public Set<EmailDeliveryRequest> toEmailDeliveryRequests(Collection<IssueChangeNotification> notifications) {
Multimap<String, IssueChangeNotification> 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<? extends EmailDeliveryRequest> toEmailDeliveryRequests(String projectKey, Collection<IssueChangeNotification> notifications) {
Set<EmailRecipient> 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)));
}

}

+ 24
- 7
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailMessage.java View File

@@ -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);

+ 2
- 0
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailTemplate.java View File

@@ -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);

}

+ 96
- 0
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotification.java View File

@@ -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.
* <p>
* 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<Project, ChangedIssue> changedIssues;
private final FpOrWontFix resolution;

public FPOrWontFixNotification(Change change, Collection<ChangedIssue> 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<Project, ChangedIssue> 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 +
'}';
}
}

+ 172
- 0
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandler.java View File

@@ -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<IssuesChangesNotification> {

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<String> 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<NotificationDispatcherMetadata> getMetadata() {
return of(METADATA);
}

public static NotificationDispatcherMetadata newMetadata() {
return METADATA;
}

@Override
public Class<IssuesChangesNotification> getNotificationClass() {
return IssuesChangesNotification.class;
}

@Override
public Set<EmailDeliveryRequest> toEmailDeliveryRequests(Collection<IssuesChangesNotification> notifications) {
Set<NotificationWithProjectKeys> 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<String> 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<EmailRecipient> 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<EmailRecipientAndProject> 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<EmailRecipient, String> projectKeysByRecipient = recipientsByProjectKey.stream()
.collect(unorderedIndex(t -> t.recipient, t -> t.projectKey));
// builds sets of recipients who subscribed to the same subset of projects
Multimap<Set<String>, EmailRecipient> recipientsBySubscribedProjects = projectKeysByRecipient.asMap()
.entrySet().stream()
.collect(unorderedIndex(t -> (Set<String>) 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<Set<String>, 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<EmailDeliveryRequest> toRequests(NotificationWithProjectKeys notification, Set<String> projectKeys, Collection<EmailRecipient> recipients) {
return recipients.stream()
// do not notify author of the change
.filter(recipient -> !notification.getChange().isAuthorLogin(recipient.getLogin()))
.flatMap(recipient -> {
SetMultimap<String, ChangedIssue> 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;
}
}

}

+ 101
- 0
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplate.java View File

@@ -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);
}

}

+ 0
- 142
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangeNotification.java View File

@@ -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<String, FieldDiffs.Diff> 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);
}
}

+ 152
- 124
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangesEmailTemplate.java View File

@@ -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> RULE_COMPARATOR = Comparator.comparing(r -> r.getKey().toString());
private static final Comparator<Project> PROJECT_COMPARATOR = Comparator.comparing(Project::getProjectName)
.thenComparing(t -> t.getBranchName().orElse(""));
private static final Comparator<ChangedIssue> CHANGED_ISSUE_KEY_COMPARATOR = Comparator.comparing(ChangedIssue::getKey, Comparator.naturalOrder());
/**
* Assuming:
* <ul>
* <li>UUID length of 40 chars</li>
* <li>a max URL length of 2083 chars</li>
* </ul>
* 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<String> 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<Project, ChangedIssue> 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<ChangedIssue> changedIssues, BiConsumer<StringBuilder, Collection<ChangedIssue>> issuePageHref) {
ListMultimap<Rule, ChangedIssue> issuesByRule = changedIssues.stream()
.collect(index(ChangedIssue::getRule, t -> t));
Iterator<Rule> 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("<ul>");
while (rules.hasNext()) {
Rule rule = rules.next();
Collection<ChangedIssue> issues = issuesByRule.get(rule);

sb.append("<li>").append("Rule ").append(" <em>").append(rule.getName()).append("</em> - ");
appendIssueLinks(sb, issuePageHref, issues);
sb.append("</li>");
}
sb.append("</ul>");
}

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<StringBuilder, Collection<ChangedIssue>> issuePageHref, Collection<ChangedIssue> issues) {
SortedSet<ChangedIssue> 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<List<ChangedIssue>> issueGroups = Lists.partition(ImmutableList.copyOf(sortedIssues), MAX_ISSUES_BY_LINK);
Iterator<List<ChangedIssue>> issueGroupsIterator = issueGroups.iterator();
int[] groupIndex = new int[] {0};
while (issueGroupsIterator.hasNext()) {
List<ChangedIssue> 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<StringBuilder, Collection<ChangedIssue>> projectIssuePageHref(String projectParams) {
return (s, issues) -> {
s.append(settings.getServerBaseURL()).append("/project/issues?").append(projectParams)
.append("&issues=");

Iterator<ChangedIssue> 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<StringBuilder> issueGroupLabel(StringBuilder sb, int[] groupIndex, List<ChangedIssue> 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("&nbsp;"));
paragraph(sb, s -> {
s.append("<small>");
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("</small>");
});
}

protected static void paragraph(StringBuilder sb, Consumer<StringBuilder> content) {
sb.append("<p>");
content.accept(sb);
sb.append("</p>");
}

protected static void link(StringBuilder sb, Consumer<StringBuilder> link, Consumer<StringBuilder> content) {
sb.append("<a href=\"");
link.accept(sb);
sb.append("\">");
content.accept(sb);
sb.append("</a>");
}

private static String urlEncode(String str) {
try {
return encode(str, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException(e);
}
}


+ 32
- 0
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotification.java View File

@@ -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);
}

}

+ 461
- 0
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilder.java View File

@@ -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<ChangedIssue> issues;
private final Change change;

public IssuesChangesNotificationBuilder(Set<ChangedIssue> 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<ChangedIssue> 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<String> getNewResolution() {
return ofNullable(newResolution);
}

public Optional<User> 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<String> 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<String> 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 +
'}';
}
}
}

+ 37
- 0
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationModule.java View File

@@ -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
);
}
}

+ 307
- 0
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationSerializer.java View File

@@ -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<Issue> issues = readIssues(notification, issueCount);
Map<String, Project> projects = readProjects(notification, issues);
Map<RuleKey, Rule> rules = readRules(notification, issues);

return new IssuesChangesNotificationBuilder(buildChangedIssues(issues, projects, rules), change);
}

private static void serializeIssueSize(IssuesChangesNotification res, Set<ChangedIssue> 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<ChangedIssue> buildChangedIssues(List<Issue> issues, Map<String, Project> projects,
Map<RuleKey, Rule> 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<ChangedIssue> issues) {
int index = 0;
for (ChangedIssue issue : issues) {
serializeIssue(res, index, issue);
index++;
}
}

private static List<Issue> readIssues(IssuesChangesNotification notification, int issueCount) {
List<Issue> 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<ChangedIssue> issues) {
issues.stream()
.map(ChangedIssue::getRule)
.collect(Collectors.toSet())
.forEach(rule -> res.setFieldValue("rules." + rule.getKey(), rule.getName()));
}

private static Map<RuleKey, Rule> readRules(IssuesChangesNotification notification, List<Issue> 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<ChangedIssue> 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<String, Project> readProjects(IssuesChangesNotification notification, List<Issue> 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);
}
}
}
}

+ 47
- 0
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/NotificationWithProjectKeys.java View File

@@ -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<String> projectKeys;

protected NotificationWithProjectKeys(IssuesChangesNotificationBuilder builder) {
this.builder = builder;
this.projectKeys = builder.getIssues().stream().map(t -> t.getProject().getKey()).collect(Collectors.toSet());
}

public Set<ChangedIssue> getIssues() {
return builder.getIssues();
}

public Change getChange() {
return builder.getChange();
}

public Set<String> getProjectKeys() {
return projectKeys;
}
}

+ 69
- 41
server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java View File

@@ -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 <sonar." + host + ">");
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 <sonar." + host + ">");
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);

+ 3
- 1
server/sonar-server-common/src/main/java/org/sonar/server/qualitygate/notification/QGChangeEmailTemplate.java View File

@@ -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) {

+ 260
- 105
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandlerTest.java View File

@@ -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<Set<EmailDeliveryRequest>> emailDeliveryRequestSetType = (Class<Set<EmailDeliveryRequest>>) (Object) Set.class;
private ArgumentCaptor<Set<EmailDeliveryRequest>> 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<IssueChangeNotification> notifications = IntStream.range(0, 1 + new Random().nextInt(10))
.mapToObj(i -> mock(IssueChangeNotification.class))
Set<IssuesChangesNotification> 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<IssueChangeNotification> notifications = IntStream.range(0, 1 + new Random().nextInt(10))
.mapToObj(i -> newNotification(null, null, null))
Set<ChangedIssue> 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<IssueChangeNotification> notifications = IntStream.range(0, 1 + new Random().nextInt(10))
.mapToObj(i -> newNotification(randomAlphabetic(5 + i), null, NO_CHANGE_AUTHOR))
Set<UserChange> 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<IssuesChangesNotificationBuilder> notificationBuilders = userChanges.stream()
.map(userChange -> {
Set<ChangedIssue> 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<IssuesChangesNotification> 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<IssueChangeNotification> 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<ChangedIssue> 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<String> 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<IssueChangeNotification> notifications1 = randomSetOfNotifications(projectKey1, assignee1, noOrDifferentChangeAuthor);
Set<IssueChangeNotification> 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<ChangedIssue> 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<String> 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<IssueChangeNotification> assignee1Notifications = randomSetOfNotifications(projectKey, assignee1, noOrDifferentChangeAuthor);
// assignee2 is authorized
Set<IssueChangeNotification> 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<EmailDeliveryRequest> 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<ChangedIssue> 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<ChangedIssue> assignee2Issues = IntStream.range(0, 10)
.mapToObj(i -> newChangedIssue("2_issue_key_" + i, assignee2, project))
.collect(toSet());
Set<IssuesChangesNotification> 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<EmailDeliveryRequest> emailDeliveryRequests = emailDeliveryRequestSetCaptor.getValue();
assertThat(emailDeliveryRequests).hasSize(4);
ListMultimap<String, EmailDeliveryRequest> emailDeliveryRequestByEmail = emailDeliveryRequests.stream()
.collect(index(EmailDeliveryRequest::getRecipientEmail));
List<EmailDeliveryRequest> 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<EmailDeliveryRequest> 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<IssueChangeNotification> assignee1ChangeAuthor = randomSetOfNotifications(projectKey, assignee1, assignee1);
// assignee2 is the changeAuthor of some notification he's the assignee of
Set<IssueChangeNotification> assignee2ChangeAuthor = randomSetOfNotifications(projectKey, assignee2, assignee2);
Set<IssueChangeNotification> assignee2NotChangeAuthor = randomSetOfNotifications(projectKey, assignee2, randomAlphabetic(10));
Set<IssueChangeNotification> assignee2NoChangeAuthor = randomSetOfNotifications(projectKey, assignee2, NO_CHANGE_AUTHOR);
// assignee3 is never the changeAuthor of the notification he's the assignee of
Set<IssueChangeNotification> assignee3NotChangeAuthor = randomSetOfNotifications(projectKey, assignee3, randomAlphabetic(11));
Set<IssueChangeNotification> 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<String> 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<EmailDeliveryRequest> 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<ChangedIssue> assignee1Issues = IntStream.range(0, 10)
.mapToObj(i -> newChangedIssue("1_issue_key_" + i, assignee1, project1))
.collect(toSet());
Set<ChangedIssue> 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<IssueChangeNotification> 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<IssuesChangesNotification> 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<EmailDeliveryRequest> emailDeliveryRequests = emailDeliveryRequestSetCaptor.getValue();
assertThat(emailDeliveryRequests).hasSize(3);
ListMultimap<String, EmailDeliveryRequest> emailDeliveryRequestByEmail = emailDeliveryRequests.stream()
.collect(index(EmailDeliveryRequest::getRecipientEmail));
List<EmailDeliveryRequest> 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<EmailDeliveryRequest> 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<IssueChangeNotification> 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<IssuesChangesNotification> 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;
}


+ 721
- 0
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplateTest.java View File

@@ -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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<HtmlParagraphAssert, HtmlListAssert> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> closedIssues = IntStream.range(0, 2 + new Random().nextInt(5))
.mapToObj(status -> newChangedIssue(status + "", STATUS_CLOSED, project, rule))
.collect(toSet());
Set<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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();
}

}

+ 73
- 0
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotificationTest.java View File

@@ -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();
}

}

+ 0
- 293
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/DoNotFixNotificationHandlerTest.java View File

@@ -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<IssueChangeNotification> 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<IssueChangeNotification> 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<IssueChangeNotification> 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<IssueChangeNotification> 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<IssueChangeNotification> 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<IssueChangeNotification> notifications1 = randomSetOfNotifications(projectKey1, changeAuthor1, newResolution);
Set<IssueChangeNotification> 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<IssueChangeNotification> subscriber1Notifications = randomSetOfNotifications(projectKey, subscriber1, newResolution);
// subscriber2 is the changeAuthor of some notifications
Set<IssueChangeNotification> subscriber2Notifications = randomSetOfNotifications(projectKey, subscriber2, newResolution);
// subscriber3 has no notification
Set<IssueChangeNotification> otherChangeAuthorNotifications = randomSetOfNotifications(projectKey, otherChangeAuthor, newResolution);
when(emailNotificationChannel.isActivated()).thenReturn(true);
Set<String> 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<EmailDeliveryRequest> 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<IssueChangeNotification> 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<IssueChangeNotification> 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";
}

}

+ 49
- 0
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/EmailMessageTest.java View File

@@ -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();
}
}

+ 498
- 0
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandlerTest.java View File

@@ -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<Set<EmailDeliveryRequest>> requestSetType = (Class<Set<EmailDeliveryRequest>>) (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<IssuesChangesNotification> 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<IssuesChangesNotification> 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<IssuesChangesNotification> 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<IssuesChangesNotification> 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<IssuesChangesNotification> 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<IssuesChangesNotificationBuilder> 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<IssuesChangesNotificationBuilder> 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<IssuesChangesNotificationBuilder> 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<IssuesChangesNotification> 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<IssuesChangesNotificationBuilder> 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<IssuesChangesNotificationBuilder> 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<IssuesChangesNotificationBuilder> 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<IssuesChangesNotificationBuilder> 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<IssuesChangesNotificationBuilder> 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<String> 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<IssuesChangesNotification> 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<Set<EmailDeliveryRequest>> captor = ArgumentCaptor.forClass(requestSetType);
verify(emailNotificationChannel).deliverAll(captor.capture());
verifyNoMoreInteractions(emailNotificationChannel);
ListMultimap<String, EmailDeliveryRequest> 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<Project> projects = IntStream.range(0, projectCount).mapToObj(i -> newProject("prk_key_" + i)).collect(toSet());
User subscriber1 = newUser("subscriber1");
User changeAuthor = newUser("changeAuthor");

Set<ChangedIssue> fpIssues = projects.stream()
.flatMap(project -> randomIssues(t -> t.setProject(project).setNewResolution(RESOLUTION_FALSE_POSITIVE).setAssignee(subscriber1)))
.collect(toSet());
Set<ChangedIssue> 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<IssuesChangesNotification> 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<Set<EmailDeliveryRequest>> captor = ArgumentCaptor.forClass(requestSetType);
verify(emailNotificationChannel).deliverAll(captor.capture());
verifyNoMoreInteractions(emailNotificationChannel);
ListMultimap<String, EmailDeliveryRequest> 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<ChangedIssue> randomIssues(Consumer<ChangedIssue.Builder> 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";
}

}

+ 92
- 0
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationTest.java View File

@@ -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<ChangedIssue> 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<ChangedIssue> 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();
}
}

+ 421
- 0
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplateTest.java View File

@@ -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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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<ChangedIssue> 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();
}
}

+ 0
- 169
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangeNotificationTest.java View File

@@ -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");
}
}

+ 0
- 200
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangesEmailTemplateTest.java View File

@@ -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");
}
}

+ 110
- 0
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilderTesting.java View File

@@ -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());
}
}

+ 37
- 0
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationModuleTest.java View File

@@ -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);
}


}

+ 35
- 0
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationTest.java View File

@@ -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");
}

}

+ 43
- 32
server/sonar-server/src/main/java/org/sonar/server/issue/IssueUpdater.java View File

@@ -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<RuleDefinitionDto> 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<RuleDefinitionDto> 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<RuleDefinitionDto> rule, ComponentDto project, BranchDto branch, ComponentDto component) {
private IssueDto doSaveIssue(DbSession session, DefaultIssue issue, IssueChangeContext context,
Optional<RuleDefinitionDto> 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<UserDto> 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;
}


+ 1
- 1
server/sonar-server/src/main/java/org/sonar/server/issue/ws/AddCommentAction.java View File

@@ -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);
}
}

+ 1
- 1
server/sonar-server/src/main/java/org/sonar/server/issue/ws/AssignAction.java View File

@@ -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);
}

+ 73
- 21
server/sonar-server/src/main/java/org/sonar/server/issue/ws/BulkChangeAction.java View File

@@ -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<Action> actions;
private final IssueChangePostProcessor issueChangePostProcessor;
private final IssuesChangesNotificationSerializer notificationSerializer;

public BulkChangeAction(System2 system2, UserSession userSession, DbClient dbClient, WebIssueStorage issueStorage,
NotificationManager notificationService, List<Action> 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<String> assigneeUuids = items.stream().map(DefaultIssue::assignee).filter(Objects::nonNull).collect(toSet());
Set<String> assigneeUuids = items.stream().map(DefaultIssue::assignee).filter(Objects::nonNull).collect(Collectors.toSet());
Map<String, UserDto> 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<String> touchedComponentUuids = result.success.stream()
.map(DefaultIssue::componentUuid)
.collect(toSet());
.collect(Collectors.toSet());
List<ComponentDto> 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<DefaultIssue> sendNotification(BulkChangeData bulkChangeData, Map<String, UserDto> 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<DefaultIssue> issues, BulkChangeData bulkChangeData, Map<String, UserDto> userDtoByUuid, UserDto author) {
if (!bulkChangeData.sendNotification) {
return;
}
Set<ChangedIssue> 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<String, UserDto> 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<UserDto> 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<DefaultIssue> 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())

+ 1
- 1
server/sonar-server/src/main/java/org/sonar/server/issue/ws/DoTransitionAction.java View File

@@ -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);
}

+ 1
- 1
server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetSeverityAction.java View File

@@ -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);
}

+ 1
- 1
server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTagsAction.java View File

@@ -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);
}

+ 1
- 1
server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTypeAction.java View File

@@ -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);
}

+ 4
- 10
server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java View File

@@ -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,

+ 3
- 1
server/sonar-server/src/main/java/org/sonar/server/qualityprofile/BuiltInQPChangeNotificationTemplate.java View File

@@ -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) {

+ 98
- 43
server/sonar-server/src/test/java/org/sonar/server/issue/IssueUpdaterTest.java View File

@@ -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<IssueChangeNotification> notificationArgumentCaptor = ArgumentCaptor.forClass(IssueChangeNotification.class);
private ArgumentCaptor<IssuesChangesNotification> 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);

+ 6
- 2
server/sonar-server/src/test/java/org/sonar/server/issue/ws/AddCommentActionTest.java View File

@@ -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<SearchResponseData> 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());
}

+ 5
- 3
server/sonar-server/src/test/java/org/sonar/server/issue/ws/AssignActionTest.java View File

@@ -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());

+ 53
- 25
server/sonar-server/src/test/java/org/sonar/server/issue/ws/BulkChangeActionTest.java View File

@@ -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<IssuesChangesNotification> issueChangeNotificationCaptor = ArgumentCaptor.forClass(IssuesChangesNotification.class);
private List<Action> 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<IssueChangeNotification> 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<IssueChangeNotification> 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<IssueChangeNotification> 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

+ 4
- 2
server/sonar-server/src/test/java/org/sonar/server/issue/ws/DoTransitionActionTest.java View File

@@ -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<SearchResponseData> 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");


+ 9
- 3
server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetSeverityActionTest.java View File

@@ -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);
}

+ 13
- 5
server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTagsActionTest.java View File

@@ -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<SearchResponseData> 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) {

+ 4
- 2
server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTypeActionTest.java View File

@@ -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);
}

+ 8
- 8
server/sonar-server/src/test/java/org/sonar/server/notification/email/EmailNotificationChannelTest.java View File

@@ -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<WiserMessage> 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<WiserMessage> 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<EmailDeliveryRequest> 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);

+ 4
- 4
server/sonar-server/src/test/java/org/sonar/server/notification/ws/DispatchersImplTest.java View File

@@ -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);
}
}

+ 0
- 1
server/sonar-server/src/test/java/org/sonar/server/tester/UserSessionRule.java View File

@@ -375,5 +375,4 @@ public class UserSessionRule implements TestRule, UserSession {
ensureAbstractMockUserSession().addOrganizationMembership(organization);
return this;
}

}

+ 99
- 8
sonar-core/src/main/java/org/sonar/core/util/stream/MoreCollectors.java View File

@@ -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 <K, E, V> Collector<E, Map<K, V>, ImmutableMap<K, V>> uniqueIndex(Function<? super E, K> keyFunction,
Function<? super E, V> valueFunction, int expectedSize) {
requireNonNull(keyFunction, "Key function can't be null");
requireNonNull(valueFunction, "Value function can't be null");
verifyKeyAndValueFunctions(keyFunction, valueFunction);
BiConsumer<Map<K, V>, 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 <K, E, V> Collector<E, ImmutableListMultimap.Builder<K, V>, ImmutableListMultimap<K, V>> index(Function<? super E, K> keyFunction,
Function<? super E, V> valueFunction) {
requireNonNull(keyFunction, "Key function can't be null");
requireNonNull(valueFunction, "Value function can't be null");
verifyKeyAndValueFunctions(keyFunction, valueFunction);
BiConsumer<ImmutableListMultimap.Builder<K, V>, 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.
*
* <p>
* Neither {@link Function keyFunction} nor {@link Function valueFunction} can return {@code null}, otherwise a
* {@link NullPointerException} will be thrown.
* </p>
*
* @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 <K, E> Collector<E, ImmutableSetMultimap.Builder<K, E>, ImmutableSetMultimap<K, E>> unorderedIndex(Function<? super E, K> 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.
*
* <p>
* Neither {@link Function keyFunction} nor {@link Function valueFunction} can return {@code null}, otherwise a
* {@link NullPointerException} will be thrown.
* </p>
*
* @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 <K, E, V> Collector<E, ImmutableSetMultimap.Builder<K, V>, ImmutableSetMultimap<K, V>> unorderedIndex(Function<? super E, K> keyFunction,
Function<? super E, V> valueFunction) {
verifyKeyAndValueFunctions(keyFunction, valueFunction);

BiConsumer<ImmutableSetMultimap.Builder<K, V>, 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<ImmutableSetMultimap.Builder<K, V>> merger = (m1, m2) -> {
for (Map.Entry<K, V> 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 <K, E, V> Collector<E, ImmutableSetMultimap.Builder<K, V>, ImmutableSetMultimap<K, V>> unorderedFlattenIndex(
Function<? super E, K> keyFunction, Function<? super E, Stream<V>> valueFunction) {
verifyKeyAndValueFunctions(keyFunction, valueFunction);

BiConsumer<ImmutableSetMultimap.Builder<K, V>, E> accumulator = (map, element) -> {
K key = requireNonNull(keyFunction.apply(element), KEY_FUNCTION_CANT_RETURN_NULL_MESSAGE);
Stream<V> valueStream = requireNonNull(valueFunction.apply(element), VALUE_FUNCTION_CANT_RETURN_NULL_MESSAGE);

valueStream.forEach(value -> map.put(key, value));
};
BinaryOperator<ImmutableSetMultimap.Builder<K, V>> merger = (m1, m2) -> {
for (Map.Entry<K, V> 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.
*

+ 233
- 14
sonar-core/src/test/java/org/sonar/core/util/stream/MoreCollectorsTest.java View File

@@ -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<MyObj> SINGLE_ELEMENT_LIST = Arrays.asList(MY_OBJ_1_A);
private static final List<MyObj2> SINGLE_ELEMENT2_LIST = Arrays.asList(MY_OBJ2_1_A_X);
private static final List<MyObj> LIST_WITH_DUPLICATE_ID = Arrays.asList(MY_OBJ_1_A, MY_OBJ_2_B, MY_OBJ_1_C);
private static final List<MyObj2> LIST2_WITH_DUPLICATE_ID = Arrays.asList(MY_OBJ2_1_A_X, MY_OBJ2_2_B, MY_OBJ2_1_C);
private static final List<MyObj> LIST = Arrays.asList(MY_OBJ_1_A, MY_OBJ_2_B, MY_OBJ_3_C);
private static final List<MyObj2> 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<Integer, String> 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.<MyObj>emptyList().stream().collect(index(MyObj::getId)).size()).isEqualTo(0);
@@ -409,7 +429,7 @@ public class MoreCollectorsTest {

@Test
public void index_supports_duplicate_keys() {
Multimap<Integer, MyObj> multimap = LIST_WITH_DUPLICATE_ID.stream().collect(index(MyObj::getId));
ListMultimap<Integer, MyObj> 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<Integer, String> multimap = LIST_WITH_DUPLICATE_ID.stream().collect(index(MyObj::getId, MyObj::getText));
public void index_returns_ListMultimap() {
ListMultimap<Integer, MyObj> multimap = LIST.stream().collect(index(MyObj::getId));

assertThat(multimap.size()).isEqualTo(3);
Map<Integer, Collection<MyObj>> 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<Integer, String> multimap = LIST.stream().collect(index(MyObj::getId, MyObj::getText));

assertThat(multimap.size()).isEqualTo(3);
Map<Integer, Collection<String>> 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<String, String> multimap = HUGE_LIST.parallelStream().collect(index(identity()));

assertThat(multimap.keySet()).isEqualTo(HUGE_SET);
}

@Test
public void index_with_valueFunction_parallel_stream() {
ListMultimap<String, String> 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.<MyObj>emptyList().stream().collect(unorderedIndex(MyObj::getId)).size()).isEqualTo(0);
assertThat(Collections.<MyObj>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<Integer, MyObj> 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<Integer, MyObj> multimap = LIST.stream().collect(index(MyObj::getId));
public void unorderedIndex_returns_SetMultimap() {
SetMultimap<Integer, MyObj> multimap = LIST.stream().collect(unorderedIndex(MyObj::getId));

assertThat(multimap.size()).isEqualTo(3);
Map<Integer, Collection<MyObj>> map = multimap.asMap();
@@ -437,8 +544,8 @@ public class MoreCollectorsTest {
}

@Test
public void index_with_valueFunction_returns_multimap() {
Multimap<Integer, String> multimap = LIST.stream().collect(index(MyObj::getId, MyObj::getText));
public void unorderedIndex_with_valueFunction_returns_SetMultimap() {
SetMultimap<Integer, String> multimap = LIST.stream().collect(unorderedIndex(MyObj::getId, MyObj::getText));

assertThat(multimap.size()).isEqualTo(3);
Map<Integer, Collection<String>> map = multimap.asMap();
@@ -448,19 +555,113 @@ public class MoreCollectorsTest {
}

@Test
public void index_parallel_stream() {
Multimap<String, String> multimap = HUGE_LIST.parallelStream().collect(index(identity()));
public void unorderedIndex_parallel_stream() {
SetMultimap<String, String> multimap = HUGE_LIST.parallelStream().collect(unorderedIndex(identity()));

assertThat(multimap.keySet()).isEqualTo(HUGE_SET);
}

@Test
public void index_with_valueFunction_parallel_stream() {
Multimap<String, String> multimap = HUGE_LIST.parallelStream().collect(index(identity(), identity()));
public void unorderedIndex_with_valueFunction_parallel_stream() {
SetMultimap<String, String> 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.<MyObj2>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<Integer, String> 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<Integer, String> multimap = LIST2.stream()
.collect(unorderedFlattenIndex(MyObj2::getId, MyObj2::getTexts));

assertThat(multimap.size()).isEqualTo(4);
Map<Integer, Collection<String>> 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<String, String> 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<String> 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<String> getTexts() {
return texts.stream();
}
}

private enum MyEnum {
ONE, TWO, THREE
}

+ 4
- 0
sonar-plugin-api/src/main/java/org/sonar/api/config/EmailSettings.java View File

@@ -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);
}

+ 12
- 0
sonar-plugin-api/src/test/java/org/sonar/api/config/EmailSettingsTest.java View File

@@ -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);

Loading…
Cancel
Save