]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-11757 single notification for FPs and changes on my issues
authorSébastien Lesaint <sebastien.lesaint@sonarsource.com>
Tue, 16 Apr 2019 13:19:27 +0000 (15:19 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 23 Apr 2019 08:37:57 +0000 (10:37 +0200)
68 files changed:
server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/container/ProjectAnalysisTaskContainerPopulator.java
server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactory.java [deleted file]
server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactory.java [new file with mode: 0644]
server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/ReportAnalysisFailureNotificationEmailTemplate.java
server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStep.java
server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/issue/DumbRule.java
server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactoryTest.java [deleted file]
server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactoryTest.java [new file with mode: 0644]
server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/step/SendIssueNotificationsStepTest.java
server/sonar-ce/src/main/java/org/sonar/ce/container/ComputeEngineContainerImpl.java
server/sonar-ce/src/test/java/org/sonar/ce/container/ComputeEngineContainerImplTest.java
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/AbstractNewIssuesEmailTemplate.java
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandler.java
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplate.java [new file with mode: 0644]
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotification.java [new file with mode: 0644]
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/DoNotFixNotificationHandler.java [deleted file]
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailMessage.java
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailTemplate.java
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotification.java [new file with mode: 0644]
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandler.java [new file with mode: 0644]
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplate.java [new file with mode: 0644]
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangeNotification.java [deleted file]
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangesEmailTemplate.java
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotification.java [new file with mode: 0644]
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilder.java [new file with mode: 0644]
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationModule.java [new file with mode: 0644]
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationSerializer.java [new file with mode: 0644]
server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/NotificationWithProjectKeys.java [new file with mode: 0644]
server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java
server/sonar-server-common/src/main/java/org/sonar/server/qualitygate/notification/QGChangeEmailTemplate.java
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandlerTest.java
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplateTest.java [new file with mode: 0644]
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotificationTest.java [new file with mode: 0644]
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/DoNotFixNotificationHandlerTest.java [deleted file]
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/EmailMessageTest.java [new file with mode: 0644]
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandlerTest.java [new file with mode: 0644]
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationTest.java [new file with mode: 0644]
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplateTest.java [new file with mode: 0644]
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangeNotificationTest.java [deleted file]
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangesEmailTemplateTest.java [deleted file]
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilderTesting.java [new file with mode: 0644]
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationModuleTest.java [new file with mode: 0644]
server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationTest.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/issue/IssueUpdater.java
server/sonar-server/src/main/java/org/sonar/server/issue/ws/AddCommentAction.java
server/sonar-server/src/main/java/org/sonar/server/issue/ws/AssignAction.java
server/sonar-server/src/main/java/org/sonar/server/issue/ws/BulkChangeAction.java
server/sonar-server/src/main/java/org/sonar/server/issue/ws/DoTransitionAction.java
server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetSeverityAction.java
server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTagsAction.java
server/sonar-server/src/main/java/org/sonar/server/issue/ws/SetTypeAction.java
server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java
server/sonar-server/src/main/java/org/sonar/server/qualityprofile/BuiltInQPChangeNotificationTemplate.java
server/sonar-server/src/test/java/org/sonar/server/issue/IssueUpdaterTest.java
server/sonar-server/src/test/java/org/sonar/server/issue/ws/AddCommentActionTest.java
server/sonar-server/src/test/java/org/sonar/server/issue/ws/AssignActionTest.java
server/sonar-server/src/test/java/org/sonar/server/issue/ws/BulkChangeActionTest.java
server/sonar-server/src/test/java/org/sonar/server/issue/ws/DoTransitionActionTest.java
server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetSeverityActionTest.java
server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTagsActionTest.java
server/sonar-server/src/test/java/org/sonar/server/issue/ws/SetTypeActionTest.java
server/sonar-server/src/test/java/org/sonar/server/notification/email/EmailNotificationChannelTest.java
server/sonar-server/src/test/java/org/sonar/server/notification/ws/DispatchersImplTest.java
server/sonar-server/src/test/java/org/sonar/server/tester/UserSessionRule.java
sonar-core/src/main/java/org/sonar/core/util/stream/MoreCollectors.java
sonar-core/src/test/java/org/sonar/core/util/stream/MoreCollectorsTest.java
sonar-plugin-api/src/main/java/org/sonar/api/config/EmailSettings.java
sonar-plugin-api/src/test/java/org/sonar/api/config/EmailSettingsTest.java

index 7afdefb8eeb3330649350de933878d3bfaa6da5e..3298ce5abc0b1554fe1bfbb2f908bd2b662a2d57 100644 (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);
   }
 
 }
diff --git a/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactory.java b/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactory.java
deleted file mode 100644 (file)
index 60b2629..0000000
+++ /dev/null
@@ -1,112 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2019 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-package org.sonar.ce.task.projectanalysis.notification;
-
-import com.google.common.collect.ImmutableMap;
-import java.util.Map;
-import java.util.Optional;
-import org.sonar.api.ce.ComputeEngineSide;
-import org.sonar.api.rule.RuleKey;
-import org.sonar.api.utils.Durations;
-import org.sonar.ce.task.projectanalysis.component.Component;
-import org.sonar.ce.task.projectanalysis.component.DepthTraversalTypeAwareCrawler;
-import org.sonar.ce.task.projectanalysis.component.TreeRootHolder;
-import org.sonar.ce.task.projectanalysis.component.TypeAwareVisitorAdapter;
-import org.sonar.ce.task.projectanalysis.issue.RuleRepository;
-import org.sonar.db.user.UserDto;
-import org.sonar.server.issue.notification.MyNewIssuesNotification;
-import org.sonar.server.issue.notification.NewIssuesNotification;
-import org.sonar.server.issue.notification.NewIssuesNotification.DetailsSupplier;
-import org.sonar.server.issue.notification.NewIssuesNotification.RuleDefinition;
-
-import static java.util.Objects.requireNonNull;
-import static org.sonar.ce.task.projectanalysis.component.ComponentVisitor.Order.PRE_ORDER;
-import static org.sonar.ce.task.projectanalysis.component.CrawlerDepthLimit.FILE;
-
-@ComputeEngineSide
-public class NewIssuesNotificationFactory {
-  private final TreeRootHolder treeRootHolder;
-  private final RuleRepository ruleRepository;
-  private final Durations durations;
-  private Map<String, Component> componentsByUuid;
-
-  public NewIssuesNotificationFactory(TreeRootHolder treeRootHolder, RuleRepository ruleRepository, Durations durations) {
-    this.treeRootHolder = treeRootHolder;
-    this.ruleRepository = ruleRepository;
-    this.durations = durations;
-  }
-
-  public MyNewIssuesNotification newMyNewIssuesNotification(Map<String, UserDto> assigneesByUuid) {
-    verifyAssigneesByUuid(assigneesByUuid);
-    return new MyNewIssuesNotification(durations, new DetailsSupplierImpl(assigneesByUuid));
-  }
-
-  public NewIssuesNotification newNewIssuesNotification(Map<String, UserDto> assigneesByUuid) {
-    verifyAssigneesByUuid(assigneesByUuid);
-    return new NewIssuesNotification(durations, new DetailsSupplierImpl(assigneesByUuid));
-  }
-
-  private static void verifyAssigneesByUuid(Map<String, UserDto> assigneesByUuid) {
-    requireNonNull(assigneesByUuid, "assigneesByUuid can't be null");
-  }
-
-  private class DetailsSupplierImpl implements DetailsSupplier {
-    private final Map<String, UserDto> assigneesByUuid;
-
-    private DetailsSupplierImpl(Map<String, UserDto> assigneesByUuid) {
-      this.assigneesByUuid = assigneesByUuid;
-    }
-
-    @Override
-    public Optional<RuleDefinition> getRuleDefinitionByRuleKey(RuleKey ruleKey) {
-      requireNonNull(ruleKey, "ruleKey can't be null");
-      return ruleRepository.findByKey(ruleKey)
-        .map(t -> new RuleDefinition(t.getName(), t.getLanguage()));
-    }
-
-    @Override
-    public Optional<String> getComponentNameByUuid(String uuid) {
-      requireNonNull(uuid, "uuid can't be null");
-      return Optional.ofNullable(lazyLoadComponentsByUuid().get(uuid))
-        .map(t -> t.getType() == Component.Type.FILE || t.getType() == Component.Type.DIRECTORY ? t.getShortName() : t.getName());
-    }
-
-    private Map<String, Component> lazyLoadComponentsByUuid() {
-      if (componentsByUuid == null) {
-        ImmutableMap.Builder<String, Component> builder = ImmutableMap.builder();
-        new DepthTraversalTypeAwareCrawler(new TypeAwareVisitorAdapter(FILE, PRE_ORDER) {
-          @Override
-          public void visitAny(Component any) {
-            builder.put(any.getUuid(), any);
-          }
-        }).visit(treeRootHolder.getRoot());
-        componentsByUuid = builder.build();
-      }
-      return componentsByUuid;
-    }
-
-    @Override
-    public Optional<String> getUserNameByUuid(String uuid) {
-      requireNonNull(uuid, "uuid can't be null");
-      return Optional.ofNullable(assigneesByUuid.get(uuid))
-        .map(UserDto::getName);
-    }
-  }
-}
diff --git a/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactory.java b/server/sonar-ce-task-projectanalysis/src/main/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactory.java
new file mode 100644 (file)
index 0000000..1bf255c
--- /dev/null
@@ -0,0 +1,176 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.ce.task.projectanalysis.notification;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+import org.sonar.api.ce.ComputeEngineSide;
+import org.sonar.api.rule.RuleKey;
+import org.sonar.api.utils.Durations;
+import org.sonar.ce.task.projectanalysis.analysis.AnalysisMetadataHolder;
+import org.sonar.ce.task.projectanalysis.analysis.Branch;
+import org.sonar.ce.task.projectanalysis.component.Component;
+import org.sonar.ce.task.projectanalysis.component.DepthTraversalTypeAwareCrawler;
+import org.sonar.ce.task.projectanalysis.component.TreeRootHolder;
+import org.sonar.ce.task.projectanalysis.component.TypeAwareVisitorAdapter;
+import org.sonar.ce.task.projectanalysis.issue.RuleRepository;
+import org.sonar.core.issue.DefaultIssue;
+import org.sonar.core.util.stream.MoreCollectors;
+import org.sonar.db.user.UserDto;
+import org.sonar.server.issue.notification.IssuesChangesNotification;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User;
+import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer;
+import org.sonar.server.issue.notification.MyNewIssuesNotification;
+import org.sonar.server.issue.notification.NewIssuesNotification;
+import org.sonar.server.issue.notification.NewIssuesNotification.DetailsSupplier;
+import org.sonar.server.issue.notification.NewIssuesNotification.RuleDefinition;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+import static org.sonar.ce.task.projectanalysis.component.ComponentVisitor.Order.PRE_ORDER;
+import static org.sonar.ce.task.projectanalysis.component.CrawlerDepthLimit.FILE;
+import static org.sonar.db.component.BranchType.PULL_REQUEST;
+
+@ComputeEngineSide
+public class NotificationFactory {
+  private final TreeRootHolder treeRootHolder;
+  private final AnalysisMetadataHolder analysisMetadataHolder;
+  private final RuleRepository ruleRepository;
+  private final Durations durations;
+  private final IssuesChangesNotificationSerializer issuesChangesSerializer;
+  private Map<String, Component> componentsByUuid;
+
+  public NotificationFactory(TreeRootHolder treeRootHolder, AnalysisMetadataHolder analysisMetadataHolder,
+    RuleRepository ruleRepository, Durations durations, IssuesChangesNotificationSerializer issuesChangesSerializer) {
+    this.treeRootHolder = treeRootHolder;
+    this.analysisMetadataHolder = analysisMetadataHolder;
+    this.ruleRepository = ruleRepository;
+    this.durations = durations;
+    this.issuesChangesSerializer = issuesChangesSerializer;
+  }
+
+  public MyNewIssuesNotification newMyNewIssuesNotification(Map<String, UserDto> assigneesByUuid) {
+    verifyAssigneesByUuid(assigneesByUuid);
+    return new MyNewIssuesNotification(durations, new DetailsSupplierImpl(assigneesByUuid));
+  }
+
+  public NewIssuesNotification newNewIssuesNotification(Map<String, UserDto> assigneesByUuid) {
+    verifyAssigneesByUuid(assigneesByUuid);
+    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");
+  }
+
+  private class DetailsSupplierImpl implements DetailsSupplier {
+    private final Map<String, UserDto> assigneesByUuid;
+
+    private DetailsSupplierImpl(Map<String, UserDto> assigneesByUuid) {
+      this.assigneesByUuid = assigneesByUuid;
+    }
+
+    @Override
+    public Optional<RuleDefinition> getRuleDefinitionByRuleKey(RuleKey ruleKey) {
+      requireNonNull(ruleKey, "ruleKey can't be null");
+      return ruleRepository.findByKey(ruleKey)
+        .map(t -> new RuleDefinition(t.getName(), t.getLanguage()));
+    }
+
+    @Override
+    public Optional<String> getComponentNameByUuid(String uuid) {
+      requireNonNull(uuid, "uuid can't be null");
+      return Optional.ofNullable(lazyLoadComponentsByUuid().get(uuid))
+        .map(t -> t.getType() == Component.Type.FILE || t.getType() == Component.Type.DIRECTORY ? t.getShortName() : t.getName());
+    }
+
+    private Map<String, Component> lazyLoadComponentsByUuid() {
+      if (componentsByUuid == null) {
+        ImmutableMap.Builder<String, Component> builder = ImmutableMap.builder();
+        new DepthTraversalTypeAwareCrawler(new TypeAwareVisitorAdapter(FILE, PRE_ORDER) {
+          @Override
+          public void visitAny(Component any) {
+            builder.put(any.getUuid(), any);
+          }
+        }).visit(treeRootHolder.getRoot());
+        componentsByUuid = builder.build();
+      }
+      return componentsByUuid;
+    }
+
+    @Override
+    public Optional<String> getUserNameByUuid(String uuid) {
+      requireNonNull(uuid, "uuid can't be null");
+      return Optional.ofNullable(assigneesByUuid.get(uuid))
+        .map(UserDto::getName);
+    }
+  }
+}
index ef0ff26bd4be938baadbfef119a086d5e62ae01e..280fdd3fd11aa6c900efa14ae58595a12fefc843 100644 (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) {
index 0e98642046e44a9bf39a8a588bdaf750d5f670ff..8393918369b617551fd8fe344fe2f2ea5dba53e6 100644 (file)
  */
 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";
index 9861faf598f6753c69bdffac396cb6e7ffb29c07..95e0bb751048fe22c53002e1893a1ac62c8af300 100644 (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
diff --git a/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactoryTest.java b/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NewIssuesNotificationFactoryTest.java
deleted file mode 100644 (file)
index 37a9210..0000000
+++ /dev/null
@@ -1,419 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2019 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-package org.sonar.ce.task.projectanalysis.notification;
-
-import com.google.common.collect.ImmutableMap;
-import java.lang.reflect.Field;
-import java.util.Random;
-import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.IntStream;
-import java.util.stream.Stream;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.ExpectedException;
-import org.sonar.api.rule.RuleKey;
-import org.sonar.api.utils.Durations;
-import org.sonar.ce.task.projectanalysis.component.ReportComponent;
-import org.sonar.ce.task.projectanalysis.component.TreeRootHolderRule;
-import org.sonar.ce.task.projectanalysis.issue.DumbRule;
-import org.sonar.ce.task.projectanalysis.issue.RuleRepositoryRule;
-import org.sonar.db.user.UserDto;
-import org.sonar.db.user.UserTesting;
-import org.sonar.server.issue.notification.MyNewIssuesNotification;
-import org.sonar.server.issue.notification.NewIssuesNotification;
-import org.sonar.server.issue.notification.NewIssuesNotification.DetailsSupplier;
-import org.sonar.server.issue.notification.NewIssuesNotification.RuleDefinition;
-
-import static java.util.Collections.emptyMap;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.sonar.ce.task.projectanalysis.component.Component.Type.DIRECTORY;
-import static org.sonar.ce.task.projectanalysis.component.Component.Type.FILE;
-import static org.sonar.ce.task.projectanalysis.component.Component.Type.PROJECT;
-import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex;
-
-public class NewIssuesNotificationFactoryTest {
-  @Rule
-  public TreeRootHolderRule treeRootHolder = new TreeRootHolderRule();
-  @Rule
-  public RuleRepositoryRule ruleRepository = new RuleRepositoryRule();
-  @Rule
-  public ExpectedException expectedException = ExpectedException.none();
-
-  private Durations durations = new Durations();
-  private NewIssuesNotificationFactory underTest = new NewIssuesNotificationFactory(treeRootHolder, ruleRepository, durations);
-
-  @Test
-  public void newMyNewIssuesNotification_throws_NPE_if_assigneesByUuid_is_null() {
-    expectedException.expect(NullPointerException.class);
-    expectedException.expectMessage("assigneesByUuid can't be null");
-
-    underTest.newMyNewIssuesNotification(null);
-  }
-
-  @Test
-  public void newNewIssuesNotification_throws_NPE_if_assigneesByUuid_is_null() {
-    expectedException.expect(NullPointerException.class);
-    expectedException.expectMessage("assigneesByUuid can't be null");
-
-    underTest.newNewIssuesNotification(null);
-  }
-
-  @Test
-  public void newMyNewIssuesNotification_returns_MyNewIssuesNotification_object_with_the_constructor_Durations() {
-    MyNewIssuesNotification notification = underTest.newMyNewIssuesNotification(emptyMap());
-
-    assertThat(readDurationsField(notification)).isSameAs(durations);
-  }
-
-  @Test
-  public void newNewIssuesNotification_returns_NewIssuesNotification_object_with_the_constructor_Durations() {
-    NewIssuesNotification notification = underTest.newNewIssuesNotification(emptyMap());
-
-    assertThat(readDurationsField(notification)).isSameAs(durations);
-  }
-
-  @Test
-  public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_fails_with_NPE_if_uuid_is_null() {
-    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    expectedException.expect(NullPointerException.class);
-    expectedException.expectMessage("uuid can't be null");
-
-    detailsSupplier.getUserNameByUuid(null);
-  }
-
-  @Test
-  public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_always_returns_empty_if_map_argument_is_empty() {
-    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-    assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty();
-  }
-
-  @Test
-  public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_name_of_user_from_map_argument() {
-    Set<UserDto> users = IntStream.range(0, 1 + new Random().nextInt(10))
-      .mapToObj(i -> UserTesting.newUserDto().setLogin("user" + i))
-      .collect(Collectors.toSet());
-
-    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(
-      users.stream().collect(uniqueIndex(UserDto::getUuid)));
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-    assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty();
-    users
-      .forEach(user -> assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).contains(user.getName()));
-  }
-
-  @Test
-  public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_empty_if_user_has_null_name() {
-    UserDto user = UserTesting.newUserDto().setLogin("user_noname").setName(null);
-
-    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(ImmutableMap.of(user.getUuid(), user));
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-    assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).isEmpty();
-  }
-
-  @Test
-  public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_fails_with_NPE_if_uuid_is_null() {
-    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    expectedException.expect(NullPointerException.class);
-    expectedException.expectMessage("uuid can't be null");
-
-    detailsSupplier.getUserNameByUuid(null);
-  }
-
-  @Test
-  public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_always_returns_empty_if_map_argument_is_empty() {
-    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-    assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty();
-  }
-
-  @Test
-  public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_name_of_user_from_map_argument() {
-    Set<UserDto> users = IntStream.range(0, 1 + new Random().nextInt(10))
-      .mapToObj(i -> UserTesting.newUserDto().setLogin("user" + i))
-      .collect(Collectors.toSet());
-
-    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(
-      users.stream().collect(uniqueIndex(UserDto::getUuid)));
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-    assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty();
-    users
-      .forEach(user -> assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).contains(user.getName()));
-  }
-
-  @Test
-  public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_empty_if_user_has_null_name() {
-    UserDto user = UserTesting.newUserDto().setLogin("user_noname").setName(null);
-
-    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(ImmutableMap.of(user.getUuid(), user));
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-    assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).isEmpty();
-  }
-
-  @Test
-  public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_ISE_if_TreeRootHolder_is_not_initialized() {
-    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    expectedException.expect(IllegalStateException.class);
-    expectedException.expectMessage("Holder has not been initialized yet");
-
-    detailsSupplier.getComponentNameByUuid("foo");
-  }
-
-  @Test
-  public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_NPE_if_uuid_is_null() {
-    treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build());
-
-    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    expectedException.expect(NullPointerException.class);
-    expectedException.expectMessage("uuid can't be null");
-
-    detailsSupplier.getComponentNameByUuid(null);
-  }
-
-  @Test
-  public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_name_of_project_in_TreeRootHolder() {
-    treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build());
-
-    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    assertThat(detailsSupplier.getComponentNameByUuid("rootUuid")).contains("root");
-    assertThat(detailsSupplier.getComponentNameByUuid("foo")).isEmpty();
-  }
-
-  @Test
-  public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_shortName_of_dir_and_file_in_TreeRootHolder() {
-    treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root")
-      .addChildren(ReportComponent.builder(DIRECTORY, 2).setUuid("dir1Uuid").setName("dir1").setShortName("dir1_short")
-        .addChildren(ReportComponent.builder(FILE, 21).setUuid("file21Uuid").setName("file21").setShortName("file21_short").build())
-        .build())
-      .addChildren(ReportComponent.builder(DIRECTORY, 3).setUuid("dir2Uuid").setName("dir2").setShortName("dir2_short")
-        .addChildren(ReportComponent.builder(FILE, 31).setUuid("file31Uuid").setName("file31").setShortName("file31_short").build())
-        .addChildren(ReportComponent.builder(FILE, 32).setUuid("file32Uuid").setName("file32").setShortName("file32_short").build())
-        .build())
-      .addChildren(ReportComponent.builder(FILE, 11).setUuid("file11Uuid").setName("file11").setShortName("file11_short").build())
-      .build());
-    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    Stream.of("dir1", "dir2", "file11", "file21", "file31", "file32")
-      .forEach(name -> {
-        assertThat(detailsSupplier.getComponentNameByUuid(name + "Uuid")).contains(name + "_short");
-        assertThat(detailsSupplier.getComponentNameByUuid(name)).isEmpty();
-      });
-  }
-
-  @Test
-  public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_ISE_if_TreeRootHolder_is_not_initialized() {
-    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    expectedException.expect(IllegalStateException.class);
-    expectedException.expectMessage("Holder has not been initialized yet");
-
-    detailsSupplier.getComponentNameByUuid("foo");
-  }
-
-  @Test
-  public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_NPE_if_uuid_is_null() {
-    treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build());
-    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    expectedException.expect(NullPointerException.class);
-    expectedException.expectMessage("uuid can't be null");
-
-    detailsSupplier.getComponentNameByUuid(null);
-  }
-
-  @Test
-  public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_name_of_project_in_TreeRootHolder() {
-    treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build());
-
-    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    assertThat(detailsSupplier.getComponentNameByUuid("rootUuid")).contains("root");
-    assertThat(detailsSupplier.getComponentNameByUuid("foo")).isEmpty();
-  }
-
-  @Test
-  public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_shortName_of_dir_and_file_in_TreeRootHolder() {
-    treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root")
-      .addChildren(ReportComponent.builder(DIRECTORY, 2).setUuid("dir1Uuid").setName("dir1").setShortName("dir1_short")
-        .addChildren(ReportComponent.builder(FILE, 21).setUuid("file21Uuid").setName("file21").setShortName("file21_short").build())
-        .build())
-      .addChildren(ReportComponent.builder(DIRECTORY, 3).setUuid("dir2Uuid").setName("dir2").setShortName("dir2_short")
-        .addChildren(ReportComponent.builder(FILE, 31).setUuid("file31Uuid").setName("file31").setShortName("file31_short").build())
-        .addChildren(ReportComponent.builder(FILE, 32).setUuid("file32Uuid").setName("file32").setShortName("file32_short").build())
-        .build())
-      .addChildren(ReportComponent.builder(FILE, 11).setUuid("file11Uuid").setName("file11").setShortName("file11_short").build())
-      .build());
-
-    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    Stream.of("dir1", "dir2", "file11", "file21", "file31", "file32")
-      .forEach(name -> {
-        assertThat(detailsSupplier.getComponentNameByUuid(name + "Uuid")).contains(name + "_short");
-        assertThat(detailsSupplier.getComponentNameByUuid(name)).isEmpty();
-      });
-  }
-
-  @Test
-  public void newMyNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_fails_with_NPE_if_ruleKey_is_null() {
-    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    expectedException.expect(NullPointerException.class);
-    expectedException.expectMessage("ruleKey can't be null");
-
-    detailsSupplier.getRuleDefinitionByRuleKey(null);
-  }
-
-  @Test
-  public void newMyNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_always_returns_empty_if_RuleRepository_is_empty() {
-    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("foo", "bar"))).isEmpty();
-    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("bar", "foo"))).isEmpty();
-  }
-
-  @Test
-  public void newMyNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_returns_name_and_language_from_RuleRepository() {
-    RuleKey rulekey1 = RuleKey.of("foo", "bar");
-    RuleKey rulekey2 = RuleKey.of("foo", "donut");
-    RuleKey rulekey3 = RuleKey.of("no", "language");
-    DumbRule rule1 = ruleRepository.add(rulekey1).setName("rule1").setLanguage("lang1");
-    DumbRule rule2 = ruleRepository.add(rulekey2).setName("rule2").setLanguage("lang2");
-    DumbRule rule3 = ruleRepository.add(rulekey3).setName("rule3");
-
-    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey1))
-      .contains(new RuleDefinition(rule1.getName(), rule1.getLanguage()));
-    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey2))
-      .contains(new RuleDefinition(rule2.getName(), rule2.getLanguage()));
-    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey3))
-      .contains(new RuleDefinition(rule3.getName(), null));
-    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("donut", "foo")))
-      .isEmpty();
-  }
-
-  @Test
-  public void newNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_fails_with_NPE_if_ruleKey_is_null() {
-    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    expectedException.expect(NullPointerException.class);
-    expectedException.expectMessage("ruleKey can't be null");
-
-    detailsSupplier.getRuleDefinitionByRuleKey(null);
-  }
-
-  @Test
-  public void newNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_always_returns_empty_if_RuleRepository_is_empty() {
-    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("foo", "bar"))).isEmpty();
-    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("bar", "foo"))).isEmpty();
-  }
-
-  @Test
-  public void newNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_returns_name_and_language_from_RuleRepository() {
-    RuleKey rulekey1 = RuleKey.of("foo", "bar");
-    RuleKey rulekey2 = RuleKey.of("foo", "donut");
-    RuleKey rulekey3 = RuleKey.of("no", "language");
-    DumbRule rule1 = ruleRepository.add(rulekey1).setName("rule1").setLanguage("lang1");
-    DumbRule rule2 = ruleRepository.add(rulekey2).setName("rule2").setLanguage("lang2");
-    DumbRule rule3 = ruleRepository.add(rulekey3).setName("rule3");
-
-    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
-
-    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
-
-    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey1))
-      .contains(new RuleDefinition(rule1.getName(), rule1.getLanguage()));
-    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey2))
-      .contains(new RuleDefinition(rule2.getName(), rule2.getLanguage()));
-    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey3))
-      .contains(new RuleDefinition(rule3.getName(), null));
-    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("donut", "foo")))
-      .isEmpty();
-  }
-
-  private static Durations readDurationsField(NewIssuesNotification notification) {
-    return readField(notification, "durations");
-  }
-
-  private static Durations readField(NewIssuesNotification notification, String fieldName) {
-    try {
-      Field durationsField = NewIssuesNotification.class.getDeclaredField(fieldName);
-      durationsField.setAccessible(true);
-      Object o = durationsField.get(notification);
-      return (Durations) o;
-    } catch (IllegalAccessException | NoSuchFieldException e) {
-      throw new RuntimeException(e);
-    }
-  }
-
-  private static DetailsSupplier readDetailsSupplier(NewIssuesNotification notification) {
-    try {
-      Field durationsField = NewIssuesNotification.class.getDeclaredField("detailsSupplier");
-      durationsField.setAccessible(true);
-      return (DetailsSupplier) durationsField.get(notification);
-    } catch (IllegalAccessException | NoSuchFieldException e) {
-      throw new RuntimeException(e);
-    }
-  }
-}
diff --git a/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactoryTest.java b/server/sonar-ce-task-projectanalysis/src/test/java/org/sonar/ce/task/projectanalysis/notification/NotificationFactoryTest.java
new file mode 100644 (file)
index 0000000..fe61a02
--- /dev/null
@@ -0,0 +1,816 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.ce.task.projectanalysis.notification;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.tngtech.java.junit.dataprovider.DataProvider;
+import com.tngtech.java.junit.dataprovider.DataProviderRunner;
+import com.tngtech.java.junit.dataprovider.UseDataProvider;
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.sonar.api.rule.RuleKey;
+import org.sonar.api.utils.Durations;
+import org.sonar.ce.task.projectanalysis.analysis.AnalysisMetadataHolderRule;
+import org.sonar.ce.task.projectanalysis.analysis.Branch;
+import org.sonar.ce.task.projectanalysis.component.ReportComponent;
+import org.sonar.ce.task.projectanalysis.component.TreeRootHolderRule;
+import org.sonar.ce.task.projectanalysis.issue.DumbRule;
+import org.sonar.ce.task.projectanalysis.issue.RuleRepositoryRule;
+import org.sonar.core.issue.DefaultIssue;
+import org.sonar.db.component.BranchType;
+import org.sonar.db.user.UserDto;
+import org.sonar.db.user.UserTesting;
+import org.sonar.server.issue.notification.IssuesChangesNotification;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue;
+import org.sonar.server.issue.notification.IssuesChangesNotificationSerializer;
+import org.sonar.server.issue.notification.MyNewIssuesNotification;
+import org.sonar.server.issue.notification.NewIssuesNotification;
+import org.sonar.server.issue.notification.NewIssuesNotification.DetailsSupplier;
+import org.sonar.server.issue.notification.NewIssuesNotification.RuleDefinition;
+
+import static java.util.Collections.emptyMap;
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+import static org.sonar.api.issue.Issue.STATUS_OPEN;
+import static org.sonar.ce.task.projectanalysis.component.Component.Type.DIRECTORY;
+import static org.sonar.ce.task.projectanalysis.component.Component.Type.FILE;
+import static org.sonar.ce.task.projectanalysis.component.Component.Type.PROJECT;
+import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex;
+
+@RunWith(DataProviderRunner.class)
+public class NotificationFactoryTest {
+  @Rule
+  public TreeRootHolderRule treeRootHolder = new TreeRootHolderRule();
+  @Rule
+  public RuleRepositoryRule ruleRepository = new RuleRepositoryRule();
+  @Rule
+  public AnalysisMetadataHolderRule analysisMetadata = new AnalysisMetadataHolderRule();
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+
+  private Durations durations = new Durations();
+  private IssuesChangesNotificationSerializer issuesChangesSerializer = mock(IssuesChangesNotificationSerializer.class);
+  private NotificationFactory underTest = new NotificationFactory(treeRootHolder, analysisMetadata, ruleRepository, durations, issuesChangesSerializer);
+
+  @Test
+  public void newMyNewIssuesNotification_throws_NPE_if_assigneesByUuid_is_null() {
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("assigneesByUuid can't be null");
+
+    underTest.newMyNewIssuesNotification(null);
+  }
+
+  @Test
+  public void newNewIssuesNotification_throws_NPE_if_assigneesByUuid_is_null() {
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("assigneesByUuid can't be null");
+
+    underTest.newNewIssuesNotification(null);
+  }
+
+  @Test
+  public void newMyNewIssuesNotification_returns_MyNewIssuesNotification_object_with_the_constructor_Durations() {
+    MyNewIssuesNotification notification = underTest.newMyNewIssuesNotification(emptyMap());
+
+    assertThat(readDurationsField(notification)).isSameAs(durations);
+  }
+
+  @Test
+  public void newNewIssuesNotification_returns_NewIssuesNotification_object_with_the_constructor_Durations() {
+    NewIssuesNotification notification = underTest.newNewIssuesNotification(emptyMap());
+
+    assertThat(readDurationsField(notification)).isSameAs(durations);
+  }
+
+  @Test
+  public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_fails_with_NPE_if_uuid_is_null() {
+    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("uuid can't be null");
+
+    detailsSupplier.getUserNameByUuid(null);
+  }
+
+  @Test
+  public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_always_returns_empty_if_map_argument_is_empty() {
+    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+    assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty();
+  }
+
+  @Test
+  public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_name_of_user_from_map_argument() {
+    Set<UserDto> users = IntStream.range(0, 1 + new Random().nextInt(10))
+      .mapToObj(i -> UserTesting.newUserDto().setLogin("user" + i))
+      .collect(Collectors.toSet());
+
+    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(
+      users.stream().collect(uniqueIndex(UserDto::getUuid)));
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+    assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty();
+    users
+      .forEach(user -> assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).contains(user.getName()));
+  }
+
+  @Test
+  public void newMyNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_empty_if_user_has_null_name() {
+    UserDto user = UserTesting.newUserDto().setLogin("user_noname").setName(null);
+
+    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(ImmutableMap.of(user.getUuid(), user));
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+    assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).isEmpty();
+  }
+
+  @Test
+  public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_fails_with_NPE_if_uuid_is_null() {
+    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("uuid can't be null");
+
+    detailsSupplier.getUserNameByUuid(null);
+  }
+
+  @Test
+  public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_always_returns_empty_if_map_argument_is_empty() {
+    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+    assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty();
+  }
+
+  @Test
+  public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_name_of_user_from_map_argument() {
+    Set<UserDto> users = IntStream.range(0, 1 + new Random().nextInt(10))
+      .mapToObj(i -> UserTesting.newUserDto().setLogin("user" + i))
+      .collect(Collectors.toSet());
+
+    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(
+      users.stream().collect(uniqueIndex(UserDto::getUuid)));
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+    assertThat(detailsSupplier.getUserNameByUuid("foo")).isEmpty();
+    users
+      .forEach(user -> assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).contains(user.getName()));
+  }
+
+  @Test
+  public void newNewIssuesNotification_DetailsSupplier_getUserNameByUuid_returns_empty_if_user_has_null_name() {
+    UserDto user = UserTesting.newUserDto().setLogin("user_noname").setName(null);
+
+    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(ImmutableMap.of(user.getUuid(), user));
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+    assertThat(detailsSupplier.getUserNameByUuid(user.getUuid())).isEmpty();
+  }
+
+  @Test
+  public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_ISE_if_TreeRootHolder_is_not_initialized() {
+    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    expectedException.expect(IllegalStateException.class);
+    expectedException.expectMessage("Holder has not been initialized yet");
+
+    detailsSupplier.getComponentNameByUuid("foo");
+  }
+
+  @Test
+  public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_NPE_if_uuid_is_null() {
+    treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build());
+
+    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("uuid can't be null");
+
+    detailsSupplier.getComponentNameByUuid(null);
+  }
+
+  @Test
+  public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_name_of_project_in_TreeRootHolder() {
+    treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build());
+
+    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    assertThat(detailsSupplier.getComponentNameByUuid("rootUuid")).contains("root");
+    assertThat(detailsSupplier.getComponentNameByUuid("foo")).isEmpty();
+  }
+
+  @Test
+  public void newMyNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_shortName_of_dir_and_file_in_TreeRootHolder() {
+    treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root")
+      .addChildren(ReportComponent.builder(DIRECTORY, 2).setUuid("dir1Uuid").setName("dir1").setShortName("dir1_short")
+        .addChildren(ReportComponent.builder(FILE, 21).setUuid("file21Uuid").setName("file21").setShortName("file21_short").build())
+        .build())
+      .addChildren(ReportComponent.builder(DIRECTORY, 3).setUuid("dir2Uuid").setName("dir2").setShortName("dir2_short")
+        .addChildren(ReportComponent.builder(FILE, 31).setUuid("file31Uuid").setName("file31").setShortName("file31_short").build())
+        .addChildren(ReportComponent.builder(FILE, 32).setUuid("file32Uuid").setName("file32").setShortName("file32_short").build())
+        .build())
+      .addChildren(ReportComponent.builder(FILE, 11).setUuid("file11Uuid").setName("file11").setShortName("file11_short").build())
+      .build());
+    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    Stream.of("dir1", "dir2", "file11", "file21", "file31", "file32")
+      .forEach(name -> {
+        assertThat(detailsSupplier.getComponentNameByUuid(name + "Uuid")).contains(name + "_short");
+        assertThat(detailsSupplier.getComponentNameByUuid(name)).isEmpty();
+      });
+  }
+
+  @Test
+  public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_ISE_if_TreeRootHolder_is_not_initialized() {
+    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    expectedException.expect(IllegalStateException.class);
+    expectedException.expectMessage("Holder has not been initialized yet");
+
+    detailsSupplier.getComponentNameByUuid("foo");
+  }
+
+  @Test
+  public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_fails_with_NPE_if_uuid_is_null() {
+    treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build());
+    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("uuid can't be null");
+
+    detailsSupplier.getComponentNameByUuid(null);
+  }
+
+  @Test
+  public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_name_of_project_in_TreeRootHolder() {
+    treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root").build());
+
+    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    assertThat(detailsSupplier.getComponentNameByUuid("rootUuid")).contains("root");
+    assertThat(detailsSupplier.getComponentNameByUuid("foo")).isEmpty();
+  }
+
+  @Test
+  public void newNewIssuesNotification_DetailsSupplier_getComponentNameByUuid_returns_shortName_of_dir_and_file_in_TreeRootHolder() {
+    treeRootHolder.setRoot(ReportComponent.builder(PROJECT, 1).setUuid("rootUuid").setName("root")
+      .addChildren(ReportComponent.builder(DIRECTORY, 2).setUuid("dir1Uuid").setName("dir1").setShortName("dir1_short")
+        .addChildren(ReportComponent.builder(FILE, 21).setUuid("file21Uuid").setName("file21").setShortName("file21_short").build())
+        .build())
+      .addChildren(ReportComponent.builder(DIRECTORY, 3).setUuid("dir2Uuid").setName("dir2").setShortName("dir2_short")
+        .addChildren(ReportComponent.builder(FILE, 31).setUuid("file31Uuid").setName("file31").setShortName("file31_short").build())
+        .addChildren(ReportComponent.builder(FILE, 32).setUuid("file32Uuid").setName("file32").setShortName("file32_short").build())
+        .build())
+      .addChildren(ReportComponent.builder(FILE, 11).setUuid("file11Uuid").setName("file11").setShortName("file11_short").build())
+      .build());
+
+    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    Stream.of("dir1", "dir2", "file11", "file21", "file31", "file32")
+      .forEach(name -> {
+        assertThat(detailsSupplier.getComponentNameByUuid(name + "Uuid")).contains(name + "_short");
+        assertThat(detailsSupplier.getComponentNameByUuid(name)).isEmpty();
+      });
+  }
+
+  @Test
+  public void newMyNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_fails_with_NPE_if_ruleKey_is_null() {
+    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("ruleKey can't be null");
+
+    detailsSupplier.getRuleDefinitionByRuleKey(null);
+  }
+
+  @Test
+  public void newMyNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_always_returns_empty_if_RuleRepository_is_empty() {
+    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("foo", "bar"))).isEmpty();
+    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("bar", "foo"))).isEmpty();
+  }
+
+  @Test
+  public void newMyNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_returns_name_and_language_from_RuleRepository() {
+    RuleKey rulekey1 = RuleKey.of("foo", "bar");
+    RuleKey rulekey2 = RuleKey.of("foo", "donut");
+    RuleKey rulekey3 = RuleKey.of("no", "language");
+    DumbRule rule1 = ruleRepository.add(rulekey1).setName("rule1").setLanguage("lang1");
+    DumbRule rule2 = ruleRepository.add(rulekey2).setName("rule2").setLanguage("lang2");
+    DumbRule rule3 = ruleRepository.add(rulekey3).setName("rule3");
+
+    MyNewIssuesNotification underTest = this.underTest.newMyNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey1))
+      .contains(new RuleDefinition(rule1.getName(), rule1.getLanguage()));
+    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey2))
+      .contains(new RuleDefinition(rule2.getName(), rule2.getLanguage()));
+    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey3))
+      .contains(new RuleDefinition(rule3.getName(), null));
+    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("donut", "foo")))
+      .isEmpty();
+  }
+
+  @Test
+  public void newNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_fails_with_NPE_if_ruleKey_is_null() {
+    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    expectedException.expect(NullPointerException.class);
+    expectedException.expectMessage("ruleKey can't be null");
+
+    detailsSupplier.getRuleDefinitionByRuleKey(null);
+  }
+
+  @Test
+  public void newNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_always_returns_empty_if_RuleRepository_is_empty() {
+    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("foo", "bar"))).isEmpty();
+    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("bar", "foo"))).isEmpty();
+  }
+
+  @Test
+  public void newNewIssuesNotification_DetailsSupplier_getRuleDefinitionByRuleKey_returns_name_and_language_from_RuleRepository() {
+    RuleKey rulekey1 = RuleKey.of("foo", "bar");
+    RuleKey rulekey2 = RuleKey.of("foo", "donut");
+    RuleKey rulekey3 = RuleKey.of("no", "language");
+    DumbRule rule1 = ruleRepository.add(rulekey1).setName("rule1").setLanguage("lang1");
+    DumbRule rule2 = ruleRepository.add(rulekey2).setName("rule2").setLanguage("lang2");
+    DumbRule rule3 = ruleRepository.add(rulekey3).setName("rule3");
+
+    NewIssuesNotification underTest = this.underTest.newNewIssuesNotification(emptyMap());
+
+    DetailsSupplier detailsSupplier = readDetailsSupplier(underTest);
+
+    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey1))
+      .contains(new RuleDefinition(rule1.getName(), rule1.getLanguage()));
+    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey2))
+      .contains(new RuleDefinition(rule2.getName(), rule2.getLanguage()));
+    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(rulekey3))
+      .contains(new RuleDefinition(rule3.getName(), null));
+    assertThat(detailsSupplier.getRuleDefinitionByRuleKey(RuleKey.of("donut", "foo")))
+      .isEmpty();
+  }
+
+  @Test
+  public void newIssuesChangesNotification_fails_with_ISE_if_analysis_date_has_not_been_set() {
+    Set<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");
+  }
+
+  private static Durations readField(NewIssuesNotification notification, String fieldName) {
+    try {
+      Field durationsField = NewIssuesNotification.class.getDeclaredField(fieldName);
+      durationsField.setAccessible(true);
+      Object o = durationsField.get(notification);
+      return (Durations) o;
+    } catch (IllegalAccessException | NoSuchFieldException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private static DetailsSupplier readDetailsSupplier(NewIssuesNotification notification) {
+    try {
+      Field durationsField = NewIssuesNotification.class.getDeclaredField("detailsSupplier");
+      durationsField.setAccessible(true);
+      return (DetailsSupplier) durationsField.get(notification);
+    } catch (IllegalAccessException | NoSuchFieldException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}
index 22dc5ac1ede70975f50e7676c9846dfa3c711738..6d077e53d34c3e7e75d5ca5ba4bc65129834e580 100644 (file)
  */
 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() {
index 2f43b9b4ad347e5843c60be77aca445e9cc1290d..cc7445e259b301d9a35c1d9ddd891fb2c13002c5 100644 (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,
index 6af124eb4be8f28d30b0f4e8f738f25517b3c931..0003b7c7cf1ca547b2d4015793ffa14e0ba48434 100644 (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
index e2c9f1be79cfcb48a946d52f1645671471acd752..3ab0fee2c866580274478f5d45a5323425033b55 100644 (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) {
index 7d5e5243d74f67a88a89c2dfb60896dd6e203cca..23e7d6f9249e82e2ef4adc25ae5b59186db7e82a 100644 (file)
 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());
+  }
+
 }
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplate.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplate.java
new file mode 100644 (file)
index 0000000..2734632
--- /dev/null
@@ -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";
+  }
+
+}
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotification.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotification.java
new file mode 100644 (file)
index 0000000..aba506c
--- /dev/null
@@ -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);
+  }
+}
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/DoNotFixNotificationHandler.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/DoNotFixNotificationHandler.java
deleted file mode 100644 (file)
index fc86ef7..0000000
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2019 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-package org.sonar.server.issue.notification;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Multimap;
-import java.util.Collection;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import java.util.stream.Stream;
-import org.sonar.api.issue.Issue;
-import org.sonar.server.notification.EmailNotificationHandler;
-import org.sonar.server.notification.NotificationDispatcherMetadata;
-import org.sonar.server.notification.NotificationManager;
-import org.sonar.server.notification.NotificationManager.EmailRecipient;
-import org.sonar.server.notification.email.EmailNotificationChannel;
-import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest;
-
-import static java.util.Collections.emptySet;
-import static java.util.Optional.of;
-import static org.sonar.core.util.stream.MoreCollectors.index;
-import static org.sonar.core.util.stream.MoreCollectors.toSet;
-import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER;
-
-public class DoNotFixNotificationHandler extends EmailNotificationHandler<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)));
-  }
-
-}
index 2bbc4f5b3b7ca8a6b3d7fa8452fe200302dd4576..e2cc3304060f0984915a94571ab0bc3ce42be62f 100644 (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);
index eaa7af95bdb2356b631a1a655e44ac5966108ce9..79dd3036d4b549dbf61a84e371c92646753fc027 100644 (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);
 
 }
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotification.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotification.java
new file mode 100644 (file)
index 0000000..d15b18b
--- /dev/null
@@ -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 +
+      '}';
+  }
+}
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandler.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandler.java
new file mode 100644 (file)
index 0000000..7b56c14
--- /dev/null
@@ -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;
+    }
+  }
+
+}
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplate.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplate.java
new file mode 100644 (file)
index 0000000..1c36898
--- /dev/null
@@ -0,0 +1,101 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.issue.notification;
+
+import javax.annotation.CheckForNull;
+import org.sonar.api.config.EmailSettings;
+import org.sonar.api.i18n.I18n;
+import org.sonar.api.notifications.Notification;
+import org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange;
+
+import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.FP;
+import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.WONT_FIX;
+
+/**
+ * Creates email message for notification "issue-changes".
+ */
+public class FpOrWontFixEmailTemplate extends IssueChangesEmailTemplate {
+
+  private static final String NOTIFICATION_NAME_I18N_KEY = "notification.dispatcher.NewFalsePositiveIssue";
+
+  public FpOrWontFixEmailTemplate(I18n i18n, EmailSettings settings) {
+    super(i18n, settings);
+  }
+
+  @Override
+  @CheckForNull
+  public EmailMessage format(Notification notif) {
+    if (!(notif instanceof FPOrWontFixNotification)) {
+      return null;
+    }
+
+    FPOrWontFixNotification notification = (FPOrWontFixNotification) notif;
+
+    EmailMessage emailMessage = new EmailMessage()
+      .setMessageId(getMessageId(notification.getResolution()))
+      .setSubject(buildSubject(notification))
+      .setHtmlMessage(buildMessage(notification));
+    if (notification.getChange() instanceof UserChange) {
+      User user = ((UserChange) notification.getChange()).getUser();
+      emailMessage.setFrom(user.getName().orElse(user.getLogin()));
+    }
+    return emailMessage;
+  }
+
+  private static String getMessageId(FpOrWontFix resolution) {
+    if (resolution == WONT_FIX) {
+      return "wontfix-issue-changes";
+    }
+    if (resolution == FP) {
+      return "fp-issue-changes";
+    }
+    throw new IllegalArgumentException("Unsupported resolution " + resolution);
+  }
+
+  private static String buildSubject(FPOrWontFixNotification notification) {
+    return "Issues marked as " + resolutionLabel(notification.getResolution());
+  }
+
+  private String buildMessage(FPOrWontFixNotification notification) {
+    StringBuilder sb = new StringBuilder();
+    paragraph(sb, s -> s.append("Hi,"));
+    paragraph(sb, s -> s.append("A manual change has resolved ").append(notification.getChangedIssues().size() > 1 ? "issues" : "an issue")
+      .append(" as ").append(resolutionLabel(notification.getResolution())).append(":"));
+
+    addIssuesByProjectThenRule(sb, notification.getChangedIssues());
+
+    addFooter(sb, NOTIFICATION_NAME_I18N_KEY);
+
+    return sb.toString();
+  }
+
+  private static String resolutionLabel(FpOrWontFix resolution) {
+    if (resolution == WONT_FIX) {
+      return "Won't Fix";
+    }
+    if (resolution == FP) {
+      return "False Positive";
+    }
+    throw new IllegalArgumentException("Unsupported resolution " + resolution);
+  }
+
+}
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangeNotification.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangeNotification.java
deleted file mode 100644 (file)
index e7c1163..0000000
+++ /dev/null
@@ -1,142 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2019 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-package org.sonar.server.issue.notification;
-
-import com.google.common.base.Strings;
-import java.io.Serializable;
-import java.util.Map;
-import javax.annotation.CheckForNull;
-import javax.annotation.Nullable;
-import org.sonar.api.notifications.Notification;
-import org.sonar.core.issue.DefaultIssue;
-import org.sonar.core.issue.FieldDiffs;
-import org.sonar.db.component.ComponentDto;
-import org.sonar.db.user.UserDto;
-
-import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_BRANCH;
-import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_PROJECT_KEY;
-import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_PROJECT_NAME;
-import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_PULL_REQUEST;
-
-public class IssueChangeNotification extends Notification {
-
-  public static final String TYPE = "issue-changes";
-  private static final String FIELD_CHANGE_AUTHOR = "changeAuthor";
-  private static final String FIELD_ASSIGNEE = "assignee";
-
-  public IssueChangeNotification() {
-    super(TYPE);
-  }
-
-  public IssueChangeNotification setIssue(DefaultIssue issue) {
-    setFieldValue("key", issue.key());
-    setFieldValue("message", issue.message());
-    FieldDiffs currentChange = issue.currentChange();
-    if (currentChange != null) {
-      for (Map.Entry<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);
-  }
-}
index 9e8a7f6a06c172ef3d83512e6d38fb1abb32d38d..3266d2cf0513e69de62108b8fa1286ce7936ddb7 100644 (file)
  */
 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);
     }
   }
 
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotification.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotification.java
new file mode 100644 (file)
index 0000000..2a7060d
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.issue.notification;
+
+import org.sonar.api.notifications.Notification;
+
+public class IssuesChangesNotification extends Notification {
+
+  public static final String TYPE = "issues-changes";
+
+  public IssuesChangesNotification() {
+    super(TYPE);
+  }
+
+}
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilder.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilder.java
new file mode 100644 (file)
index 0000000..a0fadc8
--- /dev/null
@@ -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 +
+        '}';
+    }
+  }
+}
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationModule.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationModule.java
new file mode 100644 (file)
index 0000000..5d1efab
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.issue.notification;
+
+import org.sonar.core.platform.Module;
+
+public class IssuesChangesNotificationModule extends Module {
+  @Override
+  protected void configureModule() {
+    add(
+      ChangesOnMyIssueNotificationHandler.class,
+      ChangesOnMyIssueNotificationHandler.newMetadata(),
+      ChangesOnMyIssuesEmailTemplate.class,
+      FPOrWontFixNotificationHandler.class,
+      FPOrWontFixNotificationHandler.newMetadata(),
+      IssuesChangesNotificationSerializer.class,
+      FpOrWontFixEmailTemplate.class
+    );
+  }
+}
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationSerializer.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationSerializer.java
new file mode 100644 (file)
index 0000000..4298232
--- /dev/null
@@ -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);
+      }
+    }
+  }
+}
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/NotificationWithProjectKeys.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/NotificationWithProjectKeys.java
new file mode 100644 (file)
index 0000000..ad004aa
--- /dev/null
@@ -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;
+  }
+}
index 224164d68d038ababadc41b46e9a64951d9c42ee..0f4090daba35eb5b9815e3e27170632dc3125eb8 100644 (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);
index d5a883e1d66c534777ca31bc3003a4a1e7e2e3e6..9da89355546475eb38e14a98beb13f5a37fc080d 100644 (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) {
index b6fae117865f647ad019522ec96f9164df398a54..9f51b5b233be75a1f05f7bcf5e200193855da16c 100644 (file)
 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;
   }
 
diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplateTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplateTest.java
new file mode 100644 (file)
index 0000000..0a2e097
--- /dev/null
@@ -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();
+  }
+
+}
diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotificationTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotificationTest.java
new file mode 100644 (file)
index 0000000..d3bb3fb
--- /dev/null
@@ -0,0 +1,73 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.issue.notification;
+
+import com.google.common.collect.ImmutableSet;
+import java.util.Random;
+import org.junit.Test;
+import org.sonar.api.notifications.Notification;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange;
+
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+public class ChangesOnMyIssuesNotificationTest {
+  @Test
+  public void key_is_ChangesOnMyIssues() {
+    ChangesOnMyIssuesNotification underTest = new ChangesOnMyIssuesNotification(
+      new UserChange(new Random().nextLong(), new User(randomAlphabetic(2), randomAlphabetic(3), randomAlphabetic(4))),
+      ImmutableSet.of());
+
+    assertThat(underTest.getType()).isEqualTo("ChangesOnMyIssues");
+  }
+
+  @Test
+  public void equals_is_based_on_change_and_issues() {
+    AnalysisChange analysisChange = new AnalysisChange(new Random().nextLong());
+    ChangedIssue changedIssue = IssuesChangesNotificationBuilderTesting.newChangedIssue("doo", IssuesChangesNotificationBuilderTesting.newProject("prj"), IssuesChangesNotificationBuilderTesting.newRule("rul"));
+    ChangesOnMyIssuesNotification underTest = new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue));
+
+    assertThat(underTest)
+      .isEqualTo(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue)))
+      .isNotEqualTo(mock(Notification.class))
+      .isNotEqualTo(null)
+      .isNotEqualTo(new ChangesOnMyIssuesNotification(new AnalysisChange(analysisChange.getDate() + 10), ImmutableSet.of(changedIssue)))
+      .isNotEqualTo(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of()));
+  }
+
+  @Test
+  public void hashcode_is_based_on_change_and_issues() {
+    AnalysisChange analysisChange = new AnalysisChange(new Random().nextLong());
+    ChangedIssue changedIssue = IssuesChangesNotificationBuilderTesting.newChangedIssue("doo", IssuesChangesNotificationBuilderTesting.newProject("prj"), IssuesChangesNotificationBuilderTesting.newRule("rul"));
+    ChangesOnMyIssuesNotification underTest = new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue));
+
+    assertThat(underTest.hashCode())
+      .isEqualTo(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue)).hashCode())
+      .isNotEqualTo(mock(Notification.class).hashCode())
+      .isNotEqualTo(null)
+      .isNotEqualTo(new ChangesOnMyIssuesNotification(new AnalysisChange(analysisChange.getDate() + 10), ImmutableSet.of(changedIssue)).hashCode())
+      .isNotEqualTo(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of())).hashCode();
+  }
+
+}
diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/DoNotFixNotificationHandlerTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/DoNotFixNotificationHandlerTest.java
deleted file mode 100644 (file)
index c7990f3..0000000
+++ /dev/null
@@ -1,293 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2019 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-package org.sonar.server.issue.notification;
-
-import com.google.common.collect.ImmutableSet;
-import com.tngtech.java.junit.dataprovider.DataProvider;
-import com.tngtech.java.junit.dataprovider.DataProviderRunner;
-import com.tngtech.java.junit.dataprovider.UseDataProvider;
-import java.util.Random;
-import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.IntStream;
-import java.util.stream.Stream;
-import javax.annotation.Nullable;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mockito;
-import org.sonar.api.issue.Issue;
-import org.sonar.server.notification.NotificationDispatcherMetadata;
-import org.sonar.server.notification.NotificationManager;
-import org.sonar.server.notification.email.EmailNotificationChannel;
-import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest;
-
-import static java.util.stream.Collectors.toSet;
-import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoMoreInteractions;
-import static org.mockito.Mockito.verifyZeroInteractions;
-import static org.mockito.Mockito.when;
-import static org.sonar.server.notification.NotificationDispatcherMetadata.GLOBAL_NOTIFICATION;
-import static org.sonar.server.notification.NotificationDispatcherMetadata.PER_PROJECT_NOTIFICATION;
-import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER;
-
-@RunWith(DataProviderRunner.class)
-public class DoNotFixNotificationHandlerTest {
-  private static final String DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY = "NewFalsePositiveIssue";
-  private NotificationManager notificationManager = mock(NotificationManager.class);
-  private EmailNotificationChannel emailNotificationChannel = mock(EmailNotificationChannel.class);
-  private DoNotFixNotificationHandler underTest = new DoNotFixNotificationHandler(notificationManager, emailNotificationChannel);
-
-  @Test
-  public void getMetadata_returns_same_instance_as_static_method() {
-    assertThat(underTest.getMetadata().get()).isSameAs(DoNotFixNotificationHandler.newMetadata());
-  }
-
-  @Test
-  public void verify_changeOnMyIssues_notification_dispatcher_key() {
-    NotificationDispatcherMetadata metadata = DoNotFixNotificationHandler.newMetadata();
-
-    assertThat(metadata.getDispatcherKey()).isEqualTo(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY);
-  }
-
-  @Test
-  public void changeOnMyIssues_notification_is_disabled_at_global_level() {
-    NotificationDispatcherMetadata metadata = DoNotFixNotificationHandler.newMetadata();
-
-    assertThat(metadata.getProperty(GLOBAL_NOTIFICATION)).isEqualTo("false");
-  }
-
-  @Test
-  public void changeOnMyIssues_notification_is_enable_at_project_level() {
-    NotificationDispatcherMetadata metadata = DoNotFixNotificationHandler.newMetadata();
-
-    assertThat(metadata.getProperty(PER_PROJECT_NOTIFICATION)).isEqualTo("true");
-  }
-
-  @Test
-  public void getNotificationClass_is_IssueChangeNotification() {
-    assertThat(underTest.getNotificationClass()).isEqualTo(IssueChangeNotification.class);
-  }
-
-  @Test
-  public void deliver_has_no_effect_if_emailNotificationChannel_is_disabled() {
-    when(emailNotificationChannel.isActivated()).thenReturn(false);
-    Set<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";
-  }
-
-}
diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/EmailMessageTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/EmailMessageTest.java
new file mode 100644 (file)
index 0000000..d2758ef
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.issue.notification;
+
+import org.junit.Test;
+
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class EmailMessageTest {
+  private EmailMessage underTest = new EmailMessage();
+
+  @Test
+  public void setHtmlMessage_sets_message_and_html_to_true() {
+    String message = randomAlphabetic(12);
+
+    underTest.setHtmlMessage(message);
+
+    assertThat(underTest.getMessage()).isEqualTo(message);
+    assertThat(underTest.isHtml()).isTrue();
+  }
+
+  @Test
+  public void setPlainTextMessage_sets_message_and_html_to_false() {
+    String message = randomAlphabetic(12);
+
+    underTest.setPlainTextMessage(message);
+
+    assertThat(underTest.getMessage()).isEqualTo(message);
+    assertThat(underTest.isHtml()).isFalse();
+  }
+}
diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandlerTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandlerTest.java
new file mode 100644 (file)
index 0000000..b4775a4
--- /dev/null
@@ -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";
+  }
+
+}
diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationTest.java
new file mode 100644 (file)
index 0000000..cd91457
--- /dev/null
@@ -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();
+  }
+}
diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplateTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplateTest.java
new file mode 100644 (file)
index 0000000..038a65d
--- /dev/null
@@ -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();
+  }
+}
diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangeNotificationTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangeNotificationTest.java
deleted file mode 100644 (file)
index 5ac3675..0000000
+++ /dev/null
@@ -1,169 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2019 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-package org.sonar.server.issue.notification;
-
-import org.junit.Test;
-import org.sonar.core.issue.DefaultIssue;
-import org.sonar.core.issue.FieldDiffs;
-import org.sonar.db.component.ComponentDto;
-import org.sonar.db.user.UserDto;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.sonar.db.user.UserTesting.newUserDto;
-
-public class IssueChangeNotificationTest {
-
-  private IssueChangeNotification notification = new IssueChangeNotification();
-
-  @Test
-  public void getProjectKey_returns_null_when_project_is_not_set() {
-    assertThat(notification.getProjectKey()).isNull();
-  }
-
-  @Test
-  public void getChangeAuthor_returns_null_when_issue_is_not_set() {
-    assertThat(notification.getChangeAuthor()).isNull();
-  }
-
-  @Test
-  public void getNewResolution_returns_null_when_issue_is_not_set() {
-    assertThat(notification.getNewResolution()).isNull();
-  }
-
-  @Test
-  public void set_issue() {
-    UserDto assignee = newUserDto();
-
-    DefaultIssue issue = new DefaultIssue()
-      .setKey("ABCD")
-      .setAssigneeUuid(assignee.getUuid())
-      .setMessage("Remove this useless method")
-      .setComponentKey("MyService")
-      .setCurrentChange(new FieldDiffs().setDiff("resolution", "FALSE-POSITIVE", "FIXED"));
-
-    IssueChangeNotification result = notification.setIssue(issue).setAssignee(assignee);
-
-    assertThat(result.getFieldValue("key")).isEqualTo("ABCD");
-    assertThat(result.getFieldValue("message")).isEqualTo("Remove this useless method");
-    assertThat(result.getFieldValue("old.resolution")).isEqualTo("FALSE-POSITIVE");
-    assertThat(result.getFieldValue("new.resolution"))
-      .isEqualTo("FIXED")
-      .isEqualTo(result.getNewResolution());
-    assertThat(result.getFieldValue("assignee")).isEqualTo(assignee.getLogin());
-  }
-
-  @Test
-  public void set_issue_with_current_change_having_no_old_value() {
-    DefaultIssue issue = new DefaultIssue()
-      .setKey("ABCD")
-      .setAssigneeUuid("simon")
-      .setMessage("Remove this useless method")
-      .setComponentKey("MyService");
-
-    IssueChangeNotification result = notification.setIssue(issue.setCurrentChange(new FieldDiffs().setDiff("resolution", null, "FIXED")));
-    assertThat(result.getFieldValue("old.resolution")).isNull();
-    assertThat(result.getFieldValue("new.resolution"))
-      .isEqualTo("FIXED")
-      .isEqualTo(result.getNewResolution());
-
-    result = notification.setIssue(issue.setCurrentChange(new FieldDiffs().setDiff("resolution", "", "FIXED")));
-    assertThat(result.getFieldValue("old.resolution")).isNull();
-    assertThat(result.getFieldValue("new.resolution"))
-      .isEqualTo("FIXED")
-      .isEqualTo(result.getNewResolution());
-  }
-
-  @Test
-  public void set_issue_with_current_change_having_no_new_value() {
-    DefaultIssue issue = new DefaultIssue()
-      .setKey("ABCD")
-      .setAssigneeUuid("simon")
-      .setMessage("Remove this useless method")
-      .setComponentKey("MyService");
-
-    IssueChangeNotification result = notification.setIssue(issue.setCurrentChange(new FieldDiffs().setDiff("assignee", "john", null)));
-    assertThat(result.getFieldValue("old.assignee")).isEqualTo("john");
-    assertThat(result.getFieldValue("new.assignee")).isNull();
-    assertThat(result.getNewResolution()).isNull();
-
-    result = notification.setIssue(issue.setCurrentChange(new FieldDiffs().setDiff("assignee", "john", "")));
-    assertThat(result.getFieldValue("old.assignee")).isEqualTo("john");
-    assertThat(result.getFieldValue("new.assignee")).isNull();
-    assertThat(result.getNewResolution()).isNull();
-  }
-
-  @Test
-  public void set_project_without_branch() {
-    IssueChangeNotification result = notification.setProject("MyService", "My Service", null, null);
-    assertThat(result.getFieldValue("projectKey"))
-      .isEqualTo("MyService")
-      .isEqualTo(result.getProjectKey());
-    assertThat(result.getFieldValue("projectName")).isEqualTo("My Service");
-    assertThat(result.getFieldValue("branch")).isNull();
-  }
-
-  @Test
-  public void set_project_with_branch() {
-    IssueChangeNotification result = notification.setProject("MyService", "My Service", "feature1", null);
-    assertThat(result.getFieldValue("projectKey"))
-      .isEqualTo("MyService")
-      .isEqualTo(result.getProjectKey());
-    assertThat(result.getFieldValue("projectName")).isEqualTo("My Service");
-    assertThat(result.getFieldValue("branch")).isEqualTo("feature1");
-  }
-
-  @Test
-  public void set_project_with_pull_request() {
-    IssueChangeNotification result = notification.setProject("MyService", "My Service", null, "pr-123");
-    assertThat(result.getFieldValue("projectKey"))
-      .isEqualTo("MyService")
-      .isEqualTo(result.getProjectKey());
-    assertThat(result.getFieldValue("projectName")).isEqualTo("My Service");
-    assertThat(result.getFieldValue("pullRequest")).isEqualTo("pr-123");
-  }
-
-  @Test
-  public void set_component() {
-    IssueChangeNotification result = notification.setComponent(new ComponentDto().setDbKey("MyService").setLongName("My Service"));
-    assertThat(result.getFieldValue("componentName")).isEqualTo("My Service");
-    assertThat(result.getFieldValue("componentKey")).isEqualTo("MyService");
-  }
-
-  @Test
-  public void set_change_author_login() {
-    UserDto user = newUserDto();
-    IssueChangeNotification result = notification.setChangeAuthor(user);
-    assertThat(result.getFieldValue("changeAuthor"))
-      .isEqualTo(user.getLogin())
-      .isEqualTo(result.getChangeAuthor());
-  }
-
-  @Test
-  public void set_rule_name() {
-    IssueChangeNotification result = notification.setRuleName("Xoo Rule");
-    assertThat(result.getFieldValue("ruleName")).isEqualTo("Xoo Rule");
-  }
-
-  @Test
-  public void setComment() {
-    IssueChangeNotification result = notification.setComment("My comment");
-    assertThat(result.getFieldValue("comment")).isEqualTo("My comment");
-  }
-}
diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangesEmailTemplateTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangesEmailTemplateTest.java
deleted file mode 100644 (file)
index da6ed26..0000000
+++ /dev/null
@@ -1,200 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2019 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-package org.sonar.server.issue.notification;
-
-import com.google.common.io.Resources;
-import java.nio.charset.StandardCharsets;
-import org.apache.commons.lang.StringUtils;
-import org.junit.Rule;
-import org.junit.Test;
-import org.sonar.api.config.EmailSettings;
-import org.sonar.api.config.internal.MapSettings;
-import org.sonar.api.notifications.Notification;
-import org.sonar.db.DbTester;
-import org.sonar.db.user.UserDto;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.sonar.api.CoreProperties.SERVER_BASE_URL;
-
-public class IssueChangesEmailTemplateTest {
-
-  @Rule
-  public DbTester db = DbTester.create();
-
-  private MapSettings settings = new MapSettings().setProperty(SERVER_BASE_URL, "http://nemo.sonarsource.org");
-
-  private IssueChangesEmailTemplate underTest = new IssueChangesEmailTemplate(db.getDbClient(), new EmailSettings(settings.asConfig()));
-
-  @Test
-  public void should_ignore_non_issue_changes() {
-    Notification notification = new Notification("other");
-    EmailMessage message = underTest.format(notification);
-    assertThat(message).isNull();
-  }
-
-  @Test
-  public void email_should_display_assignee_change() throws Exception {
-    Notification notification = generateNotification()
-      .setFieldValue("old.assignee", "simon")
-      .setFieldValue("new.assignee", "louis");
-
-    EmailMessage email = underTest.format(notification);
-    assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE");
-    assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE");
-
-    String message = email.getMessage();
-    String expected = Resources.toString(Resources.getResource(
-      "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_with_assignee_change.txt"),
-      StandardCharsets.UTF_8);
-    expected = StringUtils.remove(expected, '\r');
-    assertThat(message).isEqualTo(expected);
-    assertThat(email.getFrom()).isNull();
-  }
-
-  @Test
-  public void email_should_display_plan_change() throws Exception {
-    Notification notification = generateNotification()
-      .setFieldValue("old.actionPlan", null)
-      .setFieldValue("new.actionPlan", "ABC 1.0");
-
-    EmailMessage email = underTest.format(notification);
-    assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE");
-    assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE");
-
-    String message = email.getMessage();
-    String expected = Resources.toString(Resources.getResource(
-      "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_with_action_plan_change.txt"),
-      StandardCharsets.UTF_8);
-    expected = StringUtils.remove(expected, '\r');
-    assertThat(message).isEqualTo(expected);
-    assertThat(email.getFrom()).isNull();
-  }
-
-  @Test
-  public void email_should_display_resolution_change() throws Exception {
-    Notification notification = generateNotification()
-      .setFieldValue("old.resolution", "FALSE-POSITIVE")
-      .setFieldValue("new.resolution", "FIXED");
-
-    EmailMessage email = underTest.format(notification);
-    assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE");
-    assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE");
-
-    String message = email.getMessage();
-    String expected = Resources.toString(Resources.getResource(
-      "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_should_display_resolution_change.txt"),
-      StandardCharsets.UTF_8);
-    expected = StringUtils.remove(expected, '\r');
-    assertThat(message).isEqualTo(expected);
-    assertThat(email.getFrom()).isNull();
-  }
-
-  @Test
-  public void display_component_key_if_no_component_name() throws Exception {
-    Notification notification = generateNotification()
-      .setFieldValue("componentName", null);
-
-    EmailMessage email = underTest.format(notification);
-    assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE");
-    assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE");
-
-    String message = email.getMessage();
-    String expected = Resources.toString(Resources.getResource(
-      "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/display_component_key_if_no_component_name.txt"),
-      StandardCharsets.UTF_8);
-    expected = StringUtils.remove(expected, '\r');
-    assertThat(message).isEqualTo(expected);
-  }
-
-  @Test
-  public void test_email_with_multiple_changes() throws Exception {
-    Notification notification = generateNotification()
-      .setFieldValue("comment", "How to fix it?")
-      .setFieldValue("old.assignee", "simon")
-      .setFieldValue("new.assignee", "louis")
-      .setFieldValue("new.resolution", "FALSE-POSITIVE")
-      .setFieldValue("new.status", "RESOLVED")
-      .setFieldValue("new.type", "BUG")
-      .setFieldValue("new.tags", "bug performance");
-
-    EmailMessage email = underTest.format(notification);
-    assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE");
-    assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE");
-
-    String message = email.getMessage();
-    String expected = Resources.toString(Resources.getResource(
-      "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_with_multiple_changes.txt"), StandardCharsets.UTF_8);
-    expected = StringUtils.remove(expected, '\r');
-    assertThat(message).isEqualTo(expected);
-    assertThat(email.getFrom()).isNull();
-  }
-
-  @Test
-  public void test_email_with_issue_on_branch() throws Exception {
-    Notification notification = generateNotification()
-      .setFieldValue("branch", "feature1");
-
-    EmailMessage email = underTest.format(notification);
-    assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE");
-    assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE");
-
-    String message = email.getMessage();
-    String expected = Resources.toString(Resources.getResource(
-      "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_with_issue_on_branch.txt"),
-      StandardCharsets.UTF_8);
-    expected = StringUtils.remove(expected, '\r');
-    assertThat(message).isEqualTo(expected);
-  }
-
-  @Test
-  public void notification_sender_should_be_the_author_of_change() {
-    UserDto user = db.users().insertUser();
-
-    Notification notification = new IssueChangeNotification()
-      .setChangeAuthor(user)
-      .setProject("Struts", "org.apache:struts", null, null);
-
-    EmailMessage message = underTest.format(notification);
-    assertThat(message.getFrom()).isEqualTo(user.getName());
-  }
-
-  @Test
-  public void notification_contains_user_login_when_user_is_removed() {
-    UserDto user = db.users().insertDisabledUser();
-
-    Notification notification = new IssueChangeNotification()
-      .setChangeAuthor(user)
-      .setProject("Struts", "org.apache:struts", null, null);
-
-    EmailMessage message = underTest.format(notification);
-    assertThat(message.getFrom()).isEqualTo(user.getLogin());
-  }
-
-  private static Notification generateNotification() {
-    return new IssueChangeNotification()
-      .setFieldValue("projectName", "Struts")
-      .setFieldValue("projectKey", "org.apache:struts")
-      .setFieldValue("componentName", "Action")
-      .setFieldValue("componentKey", "org.apache.struts.Action")
-      .setFieldValue("key", "ABCDE")
-      .setFieldValue("ruleName", "Avoid Cycles")
-      .setFieldValue("message", "Has 3 cycles");
-  }
-}
diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilderTesting.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilderTesting.java
new file mode 100644 (file)
index 0000000..43f5cbf
--- /dev/null
@@ -0,0 +1,110 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.issue.notification;
+
+import java.util.Random;
+import org.sonar.api.rule.RuleKey;
+import org.sonar.db.DbTester;
+import org.sonar.db.component.BranchDto;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.rule.RuleDefinitionDto;
+import org.sonar.db.rule.RuleDto;
+import org.sonar.db.user.UserDto;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User;
+import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
+
+public class IssuesChangesNotificationBuilderTesting {
+
+  public static Rule ruleOf(RuleDto rule) {
+    return new Rule(rule.getKey(), rule.getName());
+  }
+
+  public static Rule ruleOf(RuleDefinitionDto rule) {
+    return new Rule(rule.getKey(), rule.getName());
+  }
+
+  public static User userOf(UserDto changeAuthor) {
+    return new User(changeAuthor.getUuid(), changeAuthor.getLogin(), changeAuthor.getName());
+  }
+
+  public static Project projectBranchOf(DbTester db, ComponentDto branch) {
+    BranchDto branchDto = db.getDbClient().branchDao().selectByUuid(db.getSession(), branch.uuid()).get();
+    checkArgument(!branchDto.isMain(), "should be a branch");
+    return new Project.Builder(branch.uuid())
+      .setKey(branch.getKey())
+      .setProjectName(branch.name())
+      .setBranchName(branchDto.getKey())
+      .build();
+  }
+
+  public static Project projectOf(ComponentDto project) {
+    return new Project.Builder(project.uuid())
+      .setKey(project.getKey())
+      .setProjectName(project.name())
+      .build();
+  }
+
+  static ChangedIssue newChangedIssue(String key, Project project, Rule rule) {
+    return new ChangedIssue.Builder(key)
+      .setNewStatus(randomAlphabetic(19))
+      .setProject(project)
+      .setRule(rule)
+      .build();
+  }
+
+  static ChangedIssue newChangedIssue(String key, String status, Project project, String ruleName) {
+    return newChangedIssue(key, status, project, newRule(ruleName));
+  }
+
+  static ChangedIssue newChangedIssue(String key, String status, Project project, Rule rule) {
+    return new ChangedIssue.Builder(key)
+      .setNewStatus(status)
+      .setProject(project)
+      .setRule(rule)
+      .build();
+  }
+
+  static Rule newRule(String ruleName) {
+    return new Rule(RuleKey.of(randomAlphabetic(6), randomAlphabetic(7)), ruleName);
+  }
+
+  static Project newProject(String uuid) {
+    return new Project.Builder(uuid).setProjectName(uuid + "_name").setKey(uuid + "_key").build();
+  }
+
+  static Project newBranch(String uuid, String branchName) {
+    return new Project.Builder(uuid).setProjectName(uuid + "_name").setKey(uuid + "_key").setBranchName(branchName).build();
+  }
+
+  static UserChange newUserChange() {
+    return new UserChange(new Random().nextLong(), new User(randomAlphabetic(4), randomAlphabetic(5), randomAlphabetic(6)));
+  }
+
+  static AnalysisChange newAnalysisChange() {
+    return new AnalysisChange(new Random().nextLong());
+  }
+}
diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationModuleTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationModuleTest.java
new file mode 100644 (file)
index 0000000..2e724dd
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.issue.notification;
+
+import org.junit.Test;
+import org.sonar.core.platform.ComponentContainer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.sonar.core.platform.ComponentContainer.COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER;
+
+public class IssuesChangesNotificationModuleTest {
+  @Test
+  public void verify_count_of_added_components() {
+    ComponentContainer container = new ComponentContainer();
+    new IssuesChangesNotificationModule().configure(container);
+    assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 7);
+  }
+
+
+}
diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationTest.java
new file mode 100644 (file)
index 0000000..bbdb2f7
--- /dev/null
@@ -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");
+  }
+
+}
index 4146385e9c43fe52c3142334dfedd7fbc078be4d..5f88b73eab8f08345a5afc5826db7229c89eff5f 100644 (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;
   }
 
index cc0fc176403b37fe95268c72bb4622f28203978b..2acae7cb3f2363c95276acb79afe5ae3cb05580a 100644 (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);
     }
   }
index 07074faed34157aae129f1e5ff94bb4ff580cdd3..a5ea16f11f261ade2a42ed346f5ad86b6f6e2aa2 100644 (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);
     }
index 4f49fb5abe5f8e58c403f7054eb43e0a025f4a6c..4b4bba89e88a9d74ae46d903524a31a256f3b72f 100644 (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())
index 4782f37141bf43ff8594e4ac3361e2996bf5876d..dd3cfb81bd99215072461f8d9a0cdb6ab4580f90 100644 (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);
   }
index e7afa901ae812f6b55cb51a4b4092c18684fa90a..0493ae9711027fca9676e8b5ce229ea8df12489f 100644 (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);
   }
index 600738c6ce97dd06a56966a5e5f36845b711788a..2d5bcc0d814153af6137b68a539695a7e04df847 100644 (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);
     }
index 3df77bd08b9c55f248bae17d40539f0377db3580..b02c18ea26d82f22da6a432ce513c68eecde05c9 100644 (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);
   }
index f0949b099866b97b0e3572199b7b884bd0b27211..a2e039daa3b09b5583ce8f5e55d24019138ac398 100644 (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,
index 41fd750f45d734faf44530e0e5df19d95003eba7..ccc644b02a17dab2d6b3e57086934718a2dc4f2f 100644 (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) {
index e7b02a808d2afefe4d2378d61f07847bc0ce12d1..1a86962ef86435e79f999abcdf668dc42540ec9b 100644 (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);
index d11c0048f36352a8b4f8d2b4989d59c617885026..5bcfb594eb0c4a67b412e41505cf5fcbf7d4c732 100644 (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());
   }
index 7943f9e5f20effc5d04b1794d15b48b9f3658e6b..92d52382ea012fbfcd3a10a7f6b46f75f618bb70 100644 (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());
index 2ac7faf5bac143a69b1df081b947cba4994449f9..c3d00899d966059df15edf2347894b113017dbb5 100644 (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
index 2ed99bd9a8b88ad5c1a7940ef15fe961ce3f87cf..128ca322d8ed632ff74f19cb49821073a71c5fe5 100644 (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");
 
index 94a489526e60118f6d0619a99d6b3a569ad2ccfb..b7b4afa7f0640083d976b13ef1c510e029767e16 100644 (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);
   }
index bff4568b616fc3d85790df331f5c8027ef0b790b..a9cfab721e7ece61c201f46e0d0a488829201831 100644 (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) {
index 700f241ef0cd25d1fe7dcc0baf03898e5c281519..57326fdd725874b8ff2a6ca642396c48fc4f4feb 100644 (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);
   }
index deed583b73e4564afd3aff821d5ab590f4a2d484..7b57c9af448aa76071e54c0efd1f1ad5d18d4d45 100644 (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);
index cc46c9db7757fbb68040653e1bf7d819ee6125b8..a6354115e838d649e5cdf799d1e79195f66ebbd5 100644 (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);
   }
 }
index 052c4fe2db43bce4ee848ba12cc7334bb9fc80dd..13f053712715f081d943c6c1fcb06756171dfd70 100644 (file)
@@ -375,5 +375,4 @@ public class UserSessionRule implements TestRule, UserSession {
     ensureAbstractMockUserSession().addOrganizationMembership(organization);
     return this;
   }
-
 }
index 9da92dc857cf8313936b3b1fb01853bf631512ff..3fac4dd4df2c810a41c1863d9740a3ed7267782e 100644 (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.
    *
index 1de9bca52fd8ccabdbbf2324ec84bd9efcf189b4..713c33f217ac266a161fe00662f979c76132f268 100644 (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
   }
index 2a785123b6def80e09e71cf22cb0d473e9dd49c3..d35642a790469246e5479c1882ea2fa0aff8c989 100644 (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);
   }
index af8963c085d1d2f5921b520e55c3ad014abdff74..72c2fea460760ccee72299397b27c7be64b13888 100644 (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);