diff options
author | Sébastien Lesaint <sebastien.lesaint@sonarsource.com> | 2019-04-16 15:19:27 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2019-04-23 10:37:57 +0200 |
commit | 58bb4b37da6e32a113870b0fc98d5494379641b6 (patch) | |
tree | 027172a0a7945ea6b2f3dae5d917e1fce1a66c81 /server/sonar-server-common | |
parent | d9e7cb020409491b45199ab8762eb22746e3543d (diff) | |
download | sonarqube-58bb4b37da6e32a113870b0fc98d5494379641b6.tar.gz sonarqube-58bb4b37da6e32a113870b0fc98d5494379641b6.zip |
SONAR-11757 single notification for FPs and changes on my issues
Diffstat (limited to 'server/sonar-server-common')
32 files changed, 4125 insertions, 1227 deletions
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/AbstractNewIssuesEmailTemplate.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/AbstractNewIssuesEmailTemplate.java index e2c9f1be79c..3ab0fee2c86 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/AbstractNewIssuesEmailTemplate.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/AbstractNewIssuesEmailTemplate.java @@ -25,6 +25,7 @@ import java.util.Arrays; import java.util.Date; import java.util.Iterator; import java.util.Locale; +import javax.annotation.CheckForNull; import javax.annotation.Nullable; import org.sonar.api.config.EmailSettings; import org.sonar.api.i18n.I18n; @@ -71,6 +72,7 @@ public abstract class AbstractNewIssuesEmailTemplate implements EmailTemplate { } @Override + @CheckForNull public EmailMessage format(Notification notification) { if (shouldNotFormat(notification)) { return null; @@ -102,7 +104,7 @@ public abstract class AbstractNewIssuesEmailTemplate implements EmailTemplate { return new EmailMessage() .setMessageId(notification.getType() + "/" + notification.getFieldValue(FIELD_PROJECT_KEY)) .setSubject(subject(notification, computeFullProjectName(projectName, branchName))) - .setMessage(message.toString()); + .setPlainTextMessage(message.toString()); } private static String computeFullProjectName(String projectName, @Nullable String branchName) { diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandler.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandler.java index 7d5e5243d74..23e7d6f9249 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandler.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandler.java @@ -20,15 +20,17 @@ package org.sonar.server.issue.notification; import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Multimap; +import com.google.common.collect.SetMultimap; import java.util.Collection; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; -import java.util.stream.Stream; import javax.annotation.CheckForNull; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; import org.sonar.server.notification.EmailNotificationHandler; import org.sonar.server.notification.NotificationDispatcherMetadata; import org.sonar.server.notification.NotificationManager; @@ -36,12 +38,12 @@ import org.sonar.server.notification.NotificationManager.EmailRecipient; import org.sonar.server.notification.email.EmailNotificationChannel; import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest; -import static org.sonar.core.util.stream.MoreCollectors.index; import static org.sonar.core.util.stream.MoreCollectors.toSet; -import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex; +import static org.sonar.core.util.stream.MoreCollectors.unorderedFlattenIndex; +import static org.sonar.core.util.stream.MoreCollectors.unorderedIndex; import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER; -public class ChangesOnMyIssueNotificationHandler extends EmailNotificationHandler<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 index 00000000000..27346327def --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplate.java @@ -0,0 +1,146 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import com.google.common.collect.ListMultimap; +import com.google.common.collect.SetMultimap; +import java.util.Collection; +import java.util.List; +import javax.annotation.CheckForNull; +import org.sonar.api.config.EmailSettings; +import org.sonar.api.i18n.I18n; +import org.sonar.api.notifications.Notification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; + +import static com.google.common.base.Preconditions.checkState; +import static org.sonar.api.issue.Issue.STATUS_CLOSED; +import static org.sonar.api.issue.Issue.STATUS_OPEN; +import static org.sonar.core.util.stream.MoreCollectors.index; + +/** + * Creates email message for notification "Changes on my issues". + */ +public class ChangesOnMyIssuesEmailTemplate extends IssueChangesEmailTemplate { + private static final String NOTIFICATION_NAME_I18N_KEY = "notification.dispatcher.ChangesOnMyIssue"; + + public ChangesOnMyIssuesEmailTemplate(I18n i18n, EmailSettings settings) { + super(i18n, settings); + } + + @Override + @CheckForNull + public EmailMessage format(Notification notif) { + if (!(notif instanceof ChangesOnMyIssuesNotification)) { + return null; + } + + ChangesOnMyIssuesNotification notification = (ChangesOnMyIssuesNotification) notif; + + if (notification.getChange() instanceof AnalysisChange) { + checkState(!notification.getChangedIssues().isEmpty(), "changedIssues can't be empty"); + return formatAnalysisNotification(notification.getChangedIssues().keySet().iterator().next(), notification); + } + return formatMultiProject(notification); + } + + private EmailMessage formatAnalysisNotification(Project project, ChangesOnMyIssuesNotification notification) { + return new EmailMessage() + .setMessageId("changes-on-my-issues/" + project.getKey()) + .setSubject(buildAnalysisSubject(project)) + .setHtmlMessage(buildAnalysisMessage(project, notification)); + } + + private static String buildAnalysisSubject(Project project) { + StringBuilder res = new StringBuilder("Analysis has changed some of your issues in "); + toString(res, project); + return res.toString(); + } + + private String buildAnalysisMessage(Project project, ChangesOnMyIssuesNotification notification) { + String projectParams = toUrlParams(project); + + StringBuilder sb = new StringBuilder(); + paragraph(sb, s -> s.append("Hi,")); + paragraph(sb, s -> s.append("An analysis has updated ").append(issuesOrAnIssue(notification.getChangedIssues())) + .append(" assigned to you:")); + + ListMultimap<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 index 00000000000..aba506cbce9 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotification.java @@ -0,0 +1,74 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import com.google.common.collect.SetMultimap; +import java.util.Collection; +import java.util.Objects; +import org.sonar.api.notifications.Notification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; + +import static org.sonar.core.util.stream.MoreCollectors.unorderedIndex; + +/** + * This notification is never serialized to DB. + * <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 index fc86ef73da2..00000000000 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/DoNotFixNotificationHandler.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.server.issue.notification; - -import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Multimap; -import java.util.Collection; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Stream; -import org.sonar.api.issue.Issue; -import org.sonar.server.notification.EmailNotificationHandler; -import org.sonar.server.notification.NotificationDispatcherMetadata; -import org.sonar.server.notification.NotificationManager; -import org.sonar.server.notification.NotificationManager.EmailRecipient; -import org.sonar.server.notification.email.EmailNotificationChannel; -import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest; - -import static java.util.Collections.emptySet; -import static java.util.Optional.of; -import static org.sonar.core.util.stream.MoreCollectors.index; -import static org.sonar.core.util.stream.MoreCollectors.toSet; -import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER; - -public class DoNotFixNotificationHandler extends EmailNotificationHandler<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))); - } - -} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailMessage.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailMessage.java index 2bbc4f5b3b7..e2cc3304060 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailMessage.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailMessage.java @@ -23,11 +23,12 @@ import org.apache.commons.lang.builder.ToStringBuilder; public class EmailMessage { - private String from; - private String to; - private String subject; - private String message; - private String messageId; + private String from = null; + private String to = null; + private String subject = null; + private String message = null; + private boolean html = false; + private String messageId = null; /** * @param from full name of user, who initiated this message or null, if message was initiated by Sonar @@ -77,13 +78,25 @@ public class EmailMessage { /** * @param message message body */ - public EmailMessage setMessage(String message) { + public EmailMessage setPlainTextMessage(String message) { this.message = message; + this.html = false; return this; } /** - * @see #setMessage(String) + * @param message HTML message body + */ + public EmailMessage setHtmlMessage(String message) { + this.message = message; + this.html = true; + return this; + } + + /** + * Either plain text or HTML. + * @see #setPlainTextMessage(String) (String) + * @see #setHtmlMessage(String) (String) (String) */ public String getMessage() { return message; @@ -104,6 +117,10 @@ public class EmailMessage { return messageId; } + public boolean isHtml() { + return html; + } + @Override public String toString() { return ToStringBuilder.reflectionToString(this); diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailTemplate.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailTemplate.java index eaa7af95bdb..79dd3036d4b 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailTemplate.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/EmailTemplate.java @@ -19,6 +19,7 @@ */ package org.sonar.server.issue.notification; +import javax.annotation.CheckForNull; import org.sonar.api.ExtensionPoint; import org.sonar.api.server.ServerSide; import org.sonar.api.notifications.Notification; @@ -27,6 +28,7 @@ import org.sonar.api.notifications.Notification; @ExtensionPoint public interface EmailTemplate { + @CheckForNull EmailMessage format(Notification notification); } diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotification.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotification.java new file mode 100644 index 00000000000..d15b18ba8d6 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotification.java @@ -0,0 +1,96 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import com.google.common.collect.SetMultimap; +import java.util.Collection; +import java.util.Objects; +import org.sonar.api.notifications.Notification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; + +import static org.sonar.core.util.stream.MoreCollectors.unorderedIndex; + +/** + * This notification is never serialized to DB. + * <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 index 00000000000..7b56c140d58 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandler.java @@ -0,0 +1,172 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Multimap; +import com.google.common.collect.SetMultimap; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.sonar.api.issue.Issue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.notification.EmailNotificationHandler; +import org.sonar.server.notification.NotificationDispatcherMetadata; +import org.sonar.server.notification.NotificationManager; +import org.sonar.server.notification.NotificationManager.EmailRecipient; +import org.sonar.server.notification.email.EmailNotificationChannel; +import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest; + +import static com.google.common.collect.Sets.intersection; +import static java.util.Collections.emptySet; +import static java.util.Optional.of; +import static java.util.Optional.ofNullable; +import static org.sonar.core.util.stream.MoreCollectors.toSet; +import static org.sonar.core.util.stream.MoreCollectors.unorderedFlattenIndex; +import static org.sonar.core.util.stream.MoreCollectors.unorderedIndex; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.FP; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.WONT_FIX; +import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER; + +public class FPOrWontFixNotificationHandler extends EmailNotificationHandler<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 index 00000000000..1c368981df9 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplate.java @@ -0,0 +1,101 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import javax.annotation.CheckForNull; +import org.sonar.api.config.EmailSettings; +import org.sonar.api.i18n.I18n; +import org.sonar.api.notifications.Notification; +import org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; + +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.FP; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.WONT_FIX; + +/** + * Creates email message for notification "issue-changes". + */ +public class FpOrWontFixEmailTemplate extends IssueChangesEmailTemplate { + + private static final String NOTIFICATION_NAME_I18N_KEY = "notification.dispatcher.NewFalsePositiveIssue"; + + public FpOrWontFixEmailTemplate(I18n i18n, EmailSettings settings) { + super(i18n, settings); + } + + @Override + @CheckForNull + public EmailMessage format(Notification notif) { + if (!(notif instanceof FPOrWontFixNotification)) { + return null; + } + + FPOrWontFixNotification notification = (FPOrWontFixNotification) notif; + + EmailMessage emailMessage = new EmailMessage() + .setMessageId(getMessageId(notification.getResolution())) + .setSubject(buildSubject(notification)) + .setHtmlMessage(buildMessage(notification)); + if (notification.getChange() instanceof UserChange) { + User user = ((UserChange) notification.getChange()).getUser(); + emailMessage.setFrom(user.getName().orElse(user.getLogin())); + } + return emailMessage; + } + + private static String getMessageId(FpOrWontFix resolution) { + if (resolution == WONT_FIX) { + return "wontfix-issue-changes"; + } + if (resolution == FP) { + return "fp-issue-changes"; + } + throw new IllegalArgumentException("Unsupported resolution " + resolution); + } + + private static String buildSubject(FPOrWontFixNotification notification) { + return "Issues marked as " + resolutionLabel(notification.getResolution()); + } + + private String buildMessage(FPOrWontFixNotification notification) { + StringBuilder sb = new StringBuilder(); + paragraph(sb, s -> s.append("Hi,")); + paragraph(sb, s -> s.append("A manual change has resolved ").append(notification.getChangedIssues().size() > 1 ? "issues" : "an issue") + .append(" as ").append(resolutionLabel(notification.getResolution())).append(":")); + + addIssuesByProjectThenRule(sb, notification.getChangedIssues()); + + addFooter(sb, NOTIFICATION_NAME_I18N_KEY); + + return sb.toString(); + } + + private static String resolutionLabel(FpOrWontFix resolution) { + if (resolution == WONT_FIX) { + return "Won't Fix"; + } + if (resolution == FP) { + return "False Positive"; + } + throw new IllegalArgumentException("Unsupported resolution " + resolution); + } + +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangeNotification.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangeNotification.java deleted file mode 100644 index e7c1163b3ac..00000000000 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangeNotification.java +++ /dev/null @@ -1,142 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.server.issue.notification; - -import com.google.common.base.Strings; -import java.io.Serializable; -import java.util.Map; -import javax.annotation.CheckForNull; -import javax.annotation.Nullable; -import org.sonar.api.notifications.Notification; -import org.sonar.core.issue.DefaultIssue; -import org.sonar.core.issue.FieldDiffs; -import org.sonar.db.component.ComponentDto; -import org.sonar.db.user.UserDto; - -import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_BRANCH; -import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_PROJECT_KEY; -import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_PROJECT_NAME; -import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_PULL_REQUEST; - -public class IssueChangeNotification extends Notification { - - public static final String TYPE = "issue-changes"; - private static final String FIELD_CHANGE_AUTHOR = "changeAuthor"; - private static final String FIELD_ASSIGNEE = "assignee"; - - public IssueChangeNotification() { - super(TYPE); - } - - public IssueChangeNotification setIssue(DefaultIssue issue) { - setFieldValue("key", issue.key()); - setFieldValue("message", issue.message()); - FieldDiffs currentChange = issue.currentChange(); - if (currentChange != null) { - for (Map.Entry<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); - } -} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangesEmailTemplate.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangesEmailTemplate.java index 9e8a7f6a06c..3266d2cf051 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangesEmailTemplate.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssueChangesEmailTemplate.java @@ -19,162 +19,190 @@ */ package org.sonar.server.issue.notification; -import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSortedSet; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Lists; +import com.google.common.collect.SetMultimap; import java.io.UnsupportedEncodingException; -import javax.annotation.CheckForNull; -import javax.annotation.Nullable; -import org.apache.commons.lang.StringUtils; +import java.util.Collection; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.SortedSet; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import org.sonar.api.config.EmailSettings; -import org.sonar.api.notifications.Notification; -import org.sonar.db.DbClient; -import org.sonar.db.DbSession; -import org.sonar.db.user.UserDto; +import org.sonar.api.i18n.I18n; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; import static java.net.URLEncoder.encode; -import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_BRANCH; -import static org.sonar.server.issue.notification.AbstractNewIssuesEmailTemplate.FIELD_PULL_REQUEST; - -/** - * Creates email message for notification "issue-changes". - */ -public class IssueChangesEmailTemplate implements EmailTemplate { - - private static final char NEW_LINE = '\n'; - private final DbClient dbClient; +import static org.sonar.core.util.stream.MoreCollectors.index; + +public abstract class IssueChangesEmailTemplate implements EmailTemplate { + + private static final Comparator<Rule> 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(" ")); + 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 index 00000000000..2a7060d58a1 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotification.java @@ -0,0 +1,32 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import org.sonar.api.notifications.Notification; + +public class IssuesChangesNotification extends Notification { + + public static final String TYPE = "issues-changes"; + + public IssuesChangesNotification() { + super(TYPE); + } + +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilder.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilder.java new file mode 100644 index 00000000000..a0fadc8fd92 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilder.java @@ -0,0 +1,461 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import com.google.common.collect.ImmutableSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; +import org.sonar.api.rule.RuleKey; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; +import static java.util.Optional.ofNullable; + +@Immutable +public class IssuesChangesNotificationBuilder { + + private static final String KEY_CANT_BE_NULL_MESSAGE = "key can't be null"; + private final Set<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 index 00000000000..5d1efab8650 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationModule.java @@ -0,0 +1,37 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import org.sonar.core.platform.Module; + +public class IssuesChangesNotificationModule extends Module { + @Override + protected void configureModule() { + add( + ChangesOnMyIssueNotificationHandler.class, + ChangesOnMyIssueNotificationHandler.newMetadata(), + ChangesOnMyIssuesEmailTemplate.class, + FPOrWontFixNotificationHandler.class, + FPOrWontFixNotificationHandler.newMetadata(), + IssuesChangesNotificationSerializer.class, + FpOrWontFixEmailTemplate.class + ); + } +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationSerializer.java b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationSerializer.java new file mode 100644 index 00000000000..42982326eb9 --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/IssuesChangesNotificationSerializer.java @@ -0,0 +1,307 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; +import org.sonar.api.rule.RuleKey; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static org.sonar.core.util.stream.MoreCollectors.toSet; +import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex; + +public class IssuesChangesNotificationSerializer { + private static final String FIELD_ISSUES_COUNT = "issues.count"; + private static final String FIELD_CHANGE_DATE = "change.date"; + private static final String FIELD_CHANGE_AUTHOR_UUID = "change.author.uuid"; + private static final String FIELD_CHANGE_AUTHOR_LOGIN = "change.author.login"; + private static final String FIELD_CHANGE_AUTHOR_NAME = "change.author.name"; + + public IssuesChangesNotification serialize(IssuesChangesNotificationBuilder builder) { + IssuesChangesNotification res = new IssuesChangesNotification(); + serializeIssueSize(res, builder.getIssues()); + serializeChange(res, builder.getChange()); + serializeIssues(res, builder.getIssues()); + serializeRules(res, builder.getIssues()); + serializeProjects(res, builder.getIssues()); + + return res; + } + + /** + * @throws IllegalArgumentException if {@code notification} misses any field or of any has unsupported value + */ + public IssuesChangesNotificationBuilder from(IssuesChangesNotification notification) { + int issueCount = readIssueCount(notification); + IssuesChangesNotificationBuilder.Change change = readChange(notification); + List<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 index 00000000000..ad004aa127a --- /dev/null +++ b/server/sonar-server-common/src/main/java/org/sonar/server/issue/notification/NotificationWithProjectKeys.java @@ -0,0 +1,47 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import java.util.Set; +import java.util.stream.Collectors; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; + +final class NotificationWithProjectKeys { + private final IssuesChangesNotificationBuilder builder; + private final Set<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; + } +} diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java b/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java index 224164d68d0..0f4090daba3 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/notification/email/EmailNotificationChannel.java @@ -26,7 +26,9 @@ import java.util.Set; import javax.annotation.CheckForNull; import javax.annotation.concurrent.Immutable; import org.apache.commons.lang.StringUtils; +import org.apache.commons.mail.Email; import org.apache.commons.mail.EmailException; +import org.apache.commons.mail.HtmlEmail; import org.apache.commons.mail.SimpleEmail; import org.sonar.api.config.EmailSettings; import org.sonar.api.notifications.Notification; @@ -227,46 +229,13 @@ public class EmailNotificationChannel extends NotificationChannel { try { LOG.trace("Sending email: {}", emailMessage); - String host = null; - try { - host = new URL(configuration.getServerBaseURL()).getHost(); - } catch (MalformedURLException e) { - // ignore - } + String host = resolveHost(); - SimpleEmail email = new SimpleEmail(); - if (StringUtils.isNotBlank(host)) { - /* - * Set headers for proper threading: GMail will not group messages, even if they have same subject, but don't have "In-Reply-To" and - * "References" headers. TODO investigate threading in other clients like KMail, Thunderbird, Outlook - */ - if (StringUtils.isNotEmpty(emailMessage.getMessageId())) { - String messageId = "<" + emailMessage.getMessageId() + "@" + host + ">"; - email.addHeader(IN_REPLY_TO_HEADER, messageId); - email.addHeader(REFERENCES_HEADER, messageId); - } - // Set headers for proper filtering - email.addHeader(LIST_ID_HEADER, "SonarQube <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); diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/qualitygate/notification/QGChangeEmailTemplate.java b/server/sonar-server-common/src/main/java/org/sonar/server/qualitygate/notification/QGChangeEmailTemplate.java index d5a883e1d66..9da89355546 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/qualitygate/notification/QGChangeEmailTemplate.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/qualitygate/notification/QGChangeEmailTemplate.java @@ -19,6 +19,7 @@ */ package org.sonar.server.qualitygate.notification; +import javax.annotation.CheckForNull; import javax.annotation.Nullable; import org.apache.commons.lang.StringUtils; import org.sonar.api.config.EmailSettings; @@ -41,6 +42,7 @@ public class QGChangeEmailTemplate implements EmailTemplate { } @Override + @CheckForNull public EmailMessage format(Notification notification) { if (!"alerts".equals(notification.getType())) { return null; @@ -66,7 +68,7 @@ public class QGChangeEmailTemplate implements EmailTemplate { return new EmailMessage() .setMessageId("alerts/" + projectId) .setSubject(subject) - .setMessage(messageBody); + .setPlainTextMessage(messageBody); } private static String computeFullProjectName(String projectName, @Nullable String branchName) { diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandlerTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandlerTest.java index b6fae117865..9f51b5b233b 100644 --- a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandlerTest.java +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssueNotificationHandlerTest.java @@ -20,10 +20,12 @@ package org.sonar.server.issue.notification; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ListMultimap; import com.tngtech.java.junit.dataprovider.DataProvider; import com.tngtech.java.junit.dataprovider.DataProviderRunner; import com.tngtech.java.junit.dataprovider.UseDataProvider; import java.util.Collections; +import java.util.List; import java.util.Random; import java.util.Set; import java.util.stream.Collectors; @@ -32,22 +34,33 @@ import java.util.stream.Stream; import javax.annotation.Nullable; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; +import org.sonar.api.rule.RuleKey; +import org.sonar.core.util.stream.MoreCollectors; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; import org.sonar.server.notification.NotificationDispatcherMetadata; import org.sonar.server.notification.NotificationManager; import org.sonar.server.notification.email.EmailNotificationChannel; import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest; -import static java.util.Collections.singleton; import static java.util.stream.Collectors.toSet; import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; +import static org.sonar.core.util.stream.MoreCollectors.index; +import static org.sonar.core.util.stream.MoreCollectors.unorderedIndex; import static org.sonar.server.notification.NotificationDispatcherMetadata.GLOBAL_NOTIFICATION; import static org.sonar.server.notification.NotificationDispatcherMetadata.PER_PROJECT_NOTIFICATION; import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER; @@ -59,7 +72,12 @@ public class ChangesOnMyIssueNotificationHandlerTest { private NotificationManager notificationManager = mock(NotificationManager.class); private EmailNotificationChannel emailNotificationChannel = mock(EmailNotificationChannel.class); - private ChangesOnMyIssueNotificationHandler underTest = new ChangesOnMyIssueNotificationHandler(notificationManager, emailNotificationChannel); + private IssuesChangesNotificationSerializer serializer = new IssuesChangesNotificationSerializer(); + private ChangesOnMyIssueNotificationHandler underTest = new ChangesOnMyIssueNotificationHandler( + notificationManager, emailNotificationChannel, serializer); + + private Class<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 index 00000000000..0a2e097a379 --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesEmailTemplateTest.java @@ -0,0 +1,721 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import com.google.common.collect.ImmutableSet; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Random; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.sonar.api.config.EmailSettings; +import org.sonar.api.i18n.I18n; +import org.sonar.api.notifications.Notification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; +import org.sonar.test.html.HtmlFragmentAssert; +import org.sonar.test.html.HtmlListAssert; +import org.sonar.test.html.HtmlParagraphAssert; + +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toSet; +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.api.issue.Issue.STATUS_CLOSED; +import static org.sonar.api.issue.Issue.STATUS_CONFIRMED; +import static org.sonar.api.issue.Issue.STATUS_OPEN; +import static org.sonar.api.issue.Issue.STATUS_REOPENED; +import static org.sonar.api.issue.Issue.STATUS_RESOLVED; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newBranch; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newChangedIssue; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newProject; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newRule; + +@RunWith(DataProviderRunner.class) +public class ChangesOnMyIssuesEmailTemplateTest { + private static final String[] ISSUE_STATUSES = {STATUS_OPEN, STATUS_RESOLVED, STATUS_CONFIRMED, STATUS_REOPENED, STATUS_CLOSED}; + @org.junit.Rule + public ExpectedException expectedException = ExpectedException.none(); + + private I18n i18n = mock(I18n.class); + private EmailSettings emailSettings = mock(EmailSettings.class); + private ChangesOnMyIssuesEmailTemplate underTest = new ChangesOnMyIssuesEmailTemplate(i18n, emailSettings); + + @Test + public void format_returns_null_on_Notification() { + EmailMessage emailMessage = underTest.format(mock(Notification.class)); + + assertThat(emailMessage).isNull(); + } + + @Test + public void formats_fails_with_ISE_if_change_from_Analysis_and_no_issue() { + AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange(); + + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage("changedIssues can't be empty"); + + underTest.format(new ChangesOnMyIssuesNotification(analysisChange, Collections.emptySet())); + } + + @Test + public void format_sets_message_id_with_project_key_of_first_issue_in_set_when_change_from_Analysis() { + Set<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 index 00000000000..d3bb3fb19f6 --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/ChangesOnMyIssuesNotificationTest.java @@ -0,0 +1,73 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import com.google.common.collect.ImmutableSet; +import java.util.Random; +import org.junit.Test; +import org.sonar.api.notifications.Notification; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; + +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +public class ChangesOnMyIssuesNotificationTest { + @Test + public void key_is_ChangesOnMyIssues() { + ChangesOnMyIssuesNotification underTest = new ChangesOnMyIssuesNotification( + new UserChange(new Random().nextLong(), new User(randomAlphabetic(2), randomAlphabetic(3), randomAlphabetic(4))), + ImmutableSet.of()); + + assertThat(underTest.getType()).isEqualTo("ChangesOnMyIssues"); + } + + @Test + public void equals_is_based_on_change_and_issues() { + AnalysisChange analysisChange = new AnalysisChange(new Random().nextLong()); + ChangedIssue changedIssue = IssuesChangesNotificationBuilderTesting.newChangedIssue("doo", IssuesChangesNotificationBuilderTesting.newProject("prj"), IssuesChangesNotificationBuilderTesting.newRule("rul")); + ChangesOnMyIssuesNotification underTest = new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue)); + + assertThat(underTest) + .isEqualTo(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue))) + .isNotEqualTo(mock(Notification.class)) + .isNotEqualTo(null) + .isNotEqualTo(new ChangesOnMyIssuesNotification(new AnalysisChange(analysisChange.getDate() + 10), ImmutableSet.of(changedIssue))) + .isNotEqualTo(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of())); + } + + @Test + public void hashcode_is_based_on_change_and_issues() { + AnalysisChange analysisChange = new AnalysisChange(new Random().nextLong()); + ChangedIssue changedIssue = IssuesChangesNotificationBuilderTesting.newChangedIssue("doo", IssuesChangesNotificationBuilderTesting.newProject("prj"), IssuesChangesNotificationBuilderTesting.newRule("rul")); + ChangesOnMyIssuesNotification underTest = new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue)); + + assertThat(underTest.hashCode()) + .isEqualTo(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue)).hashCode()) + .isNotEqualTo(mock(Notification.class).hashCode()) + .isNotEqualTo(null) + .isNotEqualTo(new ChangesOnMyIssuesNotification(new AnalysisChange(analysisChange.getDate() + 10), ImmutableSet.of(changedIssue)).hashCode()) + .isNotEqualTo(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of())).hashCode(); + } + +} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/DoNotFixNotificationHandlerTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/DoNotFixNotificationHandlerTest.java deleted file mode 100644 index c7990f33d7b..00000000000 --- a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/DoNotFixNotificationHandlerTest.java +++ /dev/null @@ -1,293 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.server.issue.notification; - -import com.google.common.collect.ImmutableSet; -import com.tngtech.java.junit.dataprovider.DataProvider; -import com.tngtech.java.junit.dataprovider.DataProviderRunner; -import com.tngtech.java.junit.dataprovider.UseDataProvider; -import java.util.Random; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.IntStream; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mockito; -import org.sonar.api.issue.Issue; -import org.sonar.server.notification.NotificationDispatcherMetadata; -import org.sonar.server.notification.NotificationManager; -import org.sonar.server.notification.email.EmailNotificationChannel; -import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest; - -import static java.util.stream.Collectors.toSet; -import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.verifyZeroInteractions; -import static org.mockito.Mockito.when; -import static org.sonar.server.notification.NotificationDispatcherMetadata.GLOBAL_NOTIFICATION; -import static org.sonar.server.notification.NotificationDispatcherMetadata.PER_PROJECT_NOTIFICATION; -import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER; - -@RunWith(DataProviderRunner.class) -public class DoNotFixNotificationHandlerTest { - private static final String DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY = "NewFalsePositiveIssue"; - private NotificationManager notificationManager = mock(NotificationManager.class); - private EmailNotificationChannel emailNotificationChannel = mock(EmailNotificationChannel.class); - private DoNotFixNotificationHandler underTest = new DoNotFixNotificationHandler(notificationManager, emailNotificationChannel); - - @Test - public void getMetadata_returns_same_instance_as_static_method() { - assertThat(underTest.getMetadata().get()).isSameAs(DoNotFixNotificationHandler.newMetadata()); - } - - @Test - public void verify_changeOnMyIssues_notification_dispatcher_key() { - NotificationDispatcherMetadata metadata = DoNotFixNotificationHandler.newMetadata(); - - assertThat(metadata.getDispatcherKey()).isEqualTo(DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY); - } - - @Test - public void changeOnMyIssues_notification_is_disabled_at_global_level() { - NotificationDispatcherMetadata metadata = DoNotFixNotificationHandler.newMetadata(); - - assertThat(metadata.getProperty(GLOBAL_NOTIFICATION)).isEqualTo("false"); - } - - @Test - public void changeOnMyIssues_notification_is_enable_at_project_level() { - NotificationDispatcherMetadata metadata = DoNotFixNotificationHandler.newMetadata(); - - assertThat(metadata.getProperty(PER_PROJECT_NOTIFICATION)).isEqualTo("true"); - } - - @Test - public void getNotificationClass_is_IssueChangeNotification() { - assertThat(underTest.getNotificationClass()).isEqualTo(IssueChangeNotification.class); - } - - @Test - public void deliver_has_no_effect_if_emailNotificationChannel_is_disabled() { - when(emailNotificationChannel.isActivated()).thenReturn(false); - Set<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 index 00000000000..d2758efd297 --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/EmailMessageTest.java @@ -0,0 +1,49 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import org.junit.Test; + +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; +import static org.assertj.core.api.Assertions.assertThat; + +public class EmailMessageTest { + private EmailMessage underTest = new EmailMessage(); + + @Test + public void setHtmlMessage_sets_message_and_html_to_true() { + String message = randomAlphabetic(12); + + underTest.setHtmlMessage(message); + + assertThat(underTest.getMessage()).isEqualTo(message); + assertThat(underTest.isHtml()).isTrue(); + } + + @Test + public void setPlainTextMessage_sets_message_and_html_to_false() { + String message = randomAlphabetic(12); + + underTest.setPlainTextMessage(message); + + assertThat(underTest.getMessage()).isEqualTo(message); + assertThat(underTest.isHtml()).isFalse(); + } +} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandlerTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandlerTest.java new file mode 100644 index 00000000000..b4775a439dc --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationHandlerTest.java @@ -0,0 +1,498 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ListMultimap; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import java.util.Random; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.sonar.api.issue.Issue; +import org.sonar.api.rule.RuleKey; +import org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; +import org.sonar.server.notification.NotificationDispatcherMetadata; +import org.sonar.server.notification.NotificationManager; +import org.sonar.server.notification.email.EmailNotificationChannel; +import org.sonar.server.notification.email.EmailNotificationChannel.EmailDeliveryRequest; + +import static java.util.Collections.singleton; +import static java.util.stream.Collectors.toSet; +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; +import static org.sonar.api.issue.Issue.RESOLUTION_FALSE_POSITIVE; +import static org.sonar.api.issue.Issue.RESOLUTION_WONT_FIX; +import static org.sonar.core.util.stream.MoreCollectors.index; +import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newProject; +import static org.sonar.server.notification.NotificationDispatcherMetadata.GLOBAL_NOTIFICATION; +import static org.sonar.server.notification.NotificationDispatcherMetadata.PER_PROJECT_NOTIFICATION; +import static org.sonar.server.notification.NotificationManager.SubscriberPermissionsOnProject.ALL_MUST_HAVE_ROLE_USER; + +@RunWith(DataProviderRunner.class) +public class FPOrWontFixNotificationHandlerTest { + private static final String DO_NOT_FIX_ISSUE_CHANGE_DISPATCHER_KEY = "NewFalsePositiveIssue"; + private NotificationManager notificationManager = mock(NotificationManager.class); + private EmailNotificationChannel emailNotificationChannel = mock(EmailNotificationChannel.class); + private IssuesChangesNotificationSerializer serializerMock = mock(IssuesChangesNotificationSerializer.class); + private IssuesChangesNotificationSerializer serializer = spy(new IssuesChangesNotificationSerializer()); + private Class<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 index 00000000000..cd9145704a3 --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FPOrWontFixNotificationTest.java @@ -0,0 +1,92 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import com.google.common.collect.ImmutableSet; +import java.util.Collections; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.Test; +import org.sonar.api.rule.RuleKey; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.FP; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.WONT_FIX; + +public class FPOrWontFixNotificationTest { + @Test + public void equals_is_based_on_issues_change_and_resolution() { + Rule rule = new Rule(RuleKey.of("repo", "rule_key"), "rule_name"); + Project project = new Project.Builder("prj_uuid").setKey("prj_key").setProjectName("prj_name").build(); + Set<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 index 00000000000..038a65d7c6c --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/FpOrWontFixEmailTemplateTest.java @@ -0,0 +1,421 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import com.google.common.collect.ImmutableSet; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Random; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.sonar.api.config.EmailSettings; +import org.sonar.api.i18n.I18n; +import org.sonar.api.notifications.Notification; +import org.sonar.api.rule.RuleKey; +import org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; +import org.sonar.test.html.HtmlFragmentAssert; + +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toList; +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.FP; +import static org.sonar.server.issue.notification.FPOrWontFixNotification.FpOrWontFix.WONT_FIX; + +@RunWith(DataProviderRunner.class) +public class FpOrWontFixEmailTemplateTest { + private I18n i18n = mock(I18n.class); + private EmailSettings emailSettings = mock(EmailSettings.class); + private FpOrWontFixEmailTemplate underTest = new FpOrWontFixEmailTemplate(i18n, emailSettings); + + @Test + public void format_returns_null_on_Notification() { + EmailMessage emailMessage = underTest.format(mock(Notification.class)); + + assertThat(emailMessage).isNull(); + } + + @Test + public void format_sets_message_id_specific_to_fp() { + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(mock(Change.class), Collections.emptySet(), FP)); + + assertThat(emailMessage.getMessageId()).isEqualTo("fp-issue-changes"); + } + + @Test + public void format_sets_message_id_specific_to_wont_fix() { + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(mock(Change.class), Collections.emptySet(), WONT_FIX)); + + assertThat(emailMessage.getMessageId()).isEqualTo("wontfix-issue-changes"); + } + + @Test + public void format_sets_subject_specific_to_fp() { + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(mock(Change.class), Collections.emptySet(), FP)); + + assertThat(emailMessage.getSubject()).isEqualTo("Issues marked as False Positive"); + } + + @Test + public void format_sets_subject_specific_to_wont_fix() { + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(mock(Change.class), Collections.emptySet(), WONT_FIX)); + + assertThat(emailMessage.getSubject()).isEqualTo("Issues marked as Won't Fix"); + } + + @Test + public void format_sets_from_to_name_of_author_change_when_available() { + UserChange change = new UserChange(new Random().nextLong(), new User(randomAlphabetic(5), randomAlphabetic(6), randomAlphabetic(7))); + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, Collections.emptySet(), WONT_FIX)); + + assertThat(emailMessage.getFrom()).isEqualTo(change.getUser().getName().get()); + } + + @Test + public void format_sets_from_to_login_of_author_change_when_name_is_not_available() { + UserChange change = new UserChange(new Random().nextLong(), new User(randomAlphabetic(5), randomAlphabetic(6), null)); + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, Collections.emptySet(), WONT_FIX)); + + assertThat(emailMessage.getFrom()).isEqualTo(change.getUser().getLogin()); + } + + @Test + public void format_sets_from_to_null_when_analysisChange() { + AnalysisChange change = new AnalysisChange(new Random().nextLong()); + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, Collections.emptySet(), WONT_FIX)); + + assertThat(emailMessage.getFrom()).isNull(); + } + + @Test + @UseDataProvider("userOrAnalysisChange") + public void formats_returns_html_message_with_only_footer_and_header_when_no_issue_for_FPs(Change change) { + formats_returns_html_message_with_only_footer_and_header_when_no_issue(change, FP, "False Positive"); + } + + @Test + @UseDataProvider("userOrAnalysisChange") + public void formats_returns_html_message_with_only_footer_and_header_when_no_issue_for_Wont_fixs(Change change) { + formats_returns_html_message_with_only_footer_and_header_when_no_issue(change, WONT_FIX, "Won't Fix"); + } + + public void formats_returns_html_message_with_only_footer_and_header_when_no_issue(Change change, FpOrWontFix fpOrWontFix, String fpOrWontFixLabel) { + String wordingNotification = randomAlphabetic(20); + String host = randomAlphabetic(15); + String instance = randomAlphabetic(17); + when(i18n.message(Locale.ENGLISH, "notification.dispatcher.NewFalsePositiveIssue", "notification.dispatcher.NewFalsePositiveIssue")) + .thenReturn(wordingNotification); + when(emailSettings.getServerBaseURL()).thenReturn(host); + when(emailSettings.getInstanceName()).thenReturn(instance); + + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, Collections.emptySet(), fpOrWontFix)); + + String footerText = "You received this email because you are subscribed to \"" + wordingNotification + "\" notifications from " + instance + "." + + " Click here to edit your email preferences."; + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph("Hi,") + .withoutLink() + .hasParagraph("A manual change has resolved an issue as " + fpOrWontFixLabel + ":") + .withoutLink() + .hasEmptyParagraph() + .hasParagraph(footerText) + .withSmallOn(footerText) + .withLink("here", host + "/account/notifications") + .noMoreBlock(); + } + + @Test + @UseDataProvider("fpOrWontFixValuesByUserOrAnalysisChange") + public void formats_returns_html_message_for_single_issue_on_master(Change change, FpOrWontFix fpOrWontFix) { + Project project = newProject("1"); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + ChangedIssue changedIssue = newChangedIssue("key", project, ruleName); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, ImmutableSet.of(changedIssue), fpOrWontFix)); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project.getProjectName()) + .hasList("Rule " + ruleName + " - See the single issue") + .withLink("See the single issue", host + "/project/issues?id=" + project.getKey() + "&issues=" + changedIssue.getKey() + "&open=" + changedIssue.getKey()) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + @UseDataProvider("fpOrWontFixValuesByUserOrAnalysisChange") + public void formats_returns_html_message_for_single_issue_on_branch(Change change, FpOrWontFix fpOrWontFix) { + String branchName = randomAlphabetic(6); + Project project = newBranch("1", branchName); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + String key = "key"; + ChangedIssue changedIssue = newChangedIssue(key, project, ruleName); + when(emailSettings.getServerBaseURL()).thenReturn(host); + + EmailMessage emailMessage = underTest.format(new FPOrWontFixNotification(change, ImmutableSet.of(changedIssue), fpOrWontFix)); + + HtmlFragmentAssert.assertThat(emailMessage.getMessage()) + .hasParagraph().hasParagraph() // skip header + .hasParagraph(project.getProjectName() + ", " + branchName) + .hasList("Rule " + ruleName + " - See the single issue") + .withLink("See the single issue", + host + "/project/issues?id=" + project.getKey() + "&branch=" + branchName + "&issues=" + changedIssue.getKey() + "&open=" + changedIssue.getKey()) + .hasParagraph().hasParagraph() // skip footer + .noMoreBlock(); + } + + @Test + @UseDataProvider("fpOrWontFixValuesByUserOrAnalysisChange") + public void formats_returns_html_message_for_multiple_issues_of_same_rule_on_same_project_on_master(Change change, FpOrWontFix fpOrWontFix) { + Project project = newProject("1"); + String ruleName = randomAlphabetic(8); + String host = randomAlphabetic(15); + Rule rule = newRule(ruleName); + List<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 index 5ac3675aa3e..00000000000 --- a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangeNotificationTest.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.server.issue.notification; - -import org.junit.Test; -import org.sonar.core.issue.DefaultIssue; -import org.sonar.core.issue.FieldDiffs; -import org.sonar.db.component.ComponentDto; -import org.sonar.db.user.UserDto; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.sonar.db.user.UserTesting.newUserDto; - -public class IssueChangeNotificationTest { - - private IssueChangeNotification notification = new IssueChangeNotification(); - - @Test - public void getProjectKey_returns_null_when_project_is_not_set() { - assertThat(notification.getProjectKey()).isNull(); - } - - @Test - public void getChangeAuthor_returns_null_when_issue_is_not_set() { - assertThat(notification.getChangeAuthor()).isNull(); - } - - @Test - public void getNewResolution_returns_null_when_issue_is_not_set() { - assertThat(notification.getNewResolution()).isNull(); - } - - @Test - public void set_issue() { - UserDto assignee = newUserDto(); - - DefaultIssue issue = new DefaultIssue() - .setKey("ABCD") - .setAssigneeUuid(assignee.getUuid()) - .setMessage("Remove this useless method") - .setComponentKey("MyService") - .setCurrentChange(new FieldDiffs().setDiff("resolution", "FALSE-POSITIVE", "FIXED")); - - IssueChangeNotification result = notification.setIssue(issue).setAssignee(assignee); - - assertThat(result.getFieldValue("key")).isEqualTo("ABCD"); - assertThat(result.getFieldValue("message")).isEqualTo("Remove this useless method"); - assertThat(result.getFieldValue("old.resolution")).isEqualTo("FALSE-POSITIVE"); - assertThat(result.getFieldValue("new.resolution")) - .isEqualTo("FIXED") - .isEqualTo(result.getNewResolution()); - assertThat(result.getFieldValue("assignee")).isEqualTo(assignee.getLogin()); - } - - @Test - public void set_issue_with_current_change_having_no_old_value() { - DefaultIssue issue = new DefaultIssue() - .setKey("ABCD") - .setAssigneeUuid("simon") - .setMessage("Remove this useless method") - .setComponentKey("MyService"); - - IssueChangeNotification result = notification.setIssue(issue.setCurrentChange(new FieldDiffs().setDiff("resolution", null, "FIXED"))); - assertThat(result.getFieldValue("old.resolution")).isNull(); - assertThat(result.getFieldValue("new.resolution")) - .isEqualTo("FIXED") - .isEqualTo(result.getNewResolution()); - - result = notification.setIssue(issue.setCurrentChange(new FieldDiffs().setDiff("resolution", "", "FIXED"))); - assertThat(result.getFieldValue("old.resolution")).isNull(); - assertThat(result.getFieldValue("new.resolution")) - .isEqualTo("FIXED") - .isEqualTo(result.getNewResolution()); - } - - @Test - public void set_issue_with_current_change_having_no_new_value() { - DefaultIssue issue = new DefaultIssue() - .setKey("ABCD") - .setAssigneeUuid("simon") - .setMessage("Remove this useless method") - .setComponentKey("MyService"); - - IssueChangeNotification result = notification.setIssue(issue.setCurrentChange(new FieldDiffs().setDiff("assignee", "john", null))); - assertThat(result.getFieldValue("old.assignee")).isEqualTo("john"); - assertThat(result.getFieldValue("new.assignee")).isNull(); - assertThat(result.getNewResolution()).isNull(); - - result = notification.setIssue(issue.setCurrentChange(new FieldDiffs().setDiff("assignee", "john", ""))); - assertThat(result.getFieldValue("old.assignee")).isEqualTo("john"); - assertThat(result.getFieldValue("new.assignee")).isNull(); - assertThat(result.getNewResolution()).isNull(); - } - - @Test - public void set_project_without_branch() { - IssueChangeNotification result = notification.setProject("MyService", "My Service", null, null); - assertThat(result.getFieldValue("projectKey")) - .isEqualTo("MyService") - .isEqualTo(result.getProjectKey()); - assertThat(result.getFieldValue("projectName")).isEqualTo("My Service"); - assertThat(result.getFieldValue("branch")).isNull(); - } - - @Test - public void set_project_with_branch() { - IssueChangeNotification result = notification.setProject("MyService", "My Service", "feature1", null); - assertThat(result.getFieldValue("projectKey")) - .isEqualTo("MyService") - .isEqualTo(result.getProjectKey()); - assertThat(result.getFieldValue("projectName")).isEqualTo("My Service"); - assertThat(result.getFieldValue("branch")).isEqualTo("feature1"); - } - - @Test - public void set_project_with_pull_request() { - IssueChangeNotification result = notification.setProject("MyService", "My Service", null, "pr-123"); - assertThat(result.getFieldValue("projectKey")) - .isEqualTo("MyService") - .isEqualTo(result.getProjectKey()); - assertThat(result.getFieldValue("projectName")).isEqualTo("My Service"); - assertThat(result.getFieldValue("pullRequest")).isEqualTo("pr-123"); - } - - @Test - public void set_component() { - IssueChangeNotification result = notification.setComponent(new ComponentDto().setDbKey("MyService").setLongName("My Service")); - assertThat(result.getFieldValue("componentName")).isEqualTo("My Service"); - assertThat(result.getFieldValue("componentKey")).isEqualTo("MyService"); - } - - @Test - public void set_change_author_login() { - UserDto user = newUserDto(); - IssueChangeNotification result = notification.setChangeAuthor(user); - assertThat(result.getFieldValue("changeAuthor")) - .isEqualTo(user.getLogin()) - .isEqualTo(result.getChangeAuthor()); - } - - @Test - public void set_rule_name() { - IssueChangeNotification result = notification.setRuleName("Xoo Rule"); - assertThat(result.getFieldValue("ruleName")).isEqualTo("Xoo Rule"); - } - - @Test - public void setComment() { - IssueChangeNotification result = notification.setComment("My comment"); - assertThat(result.getFieldValue("comment")).isEqualTo("My comment"); - } -} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangesEmailTemplateTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangesEmailTemplateTest.java deleted file mode 100644 index da6ed26a453..00000000000 --- a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssueChangesEmailTemplateTest.java +++ /dev/null @@ -1,200 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -package org.sonar.server.issue.notification; - -import com.google.common.io.Resources; -import java.nio.charset.StandardCharsets; -import org.apache.commons.lang.StringUtils; -import org.junit.Rule; -import org.junit.Test; -import org.sonar.api.config.EmailSettings; -import org.sonar.api.config.internal.MapSettings; -import org.sonar.api.notifications.Notification; -import org.sonar.db.DbTester; -import org.sonar.db.user.UserDto; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.sonar.api.CoreProperties.SERVER_BASE_URL; - -public class IssueChangesEmailTemplateTest { - - @Rule - public DbTester db = DbTester.create(); - - private MapSettings settings = new MapSettings().setProperty(SERVER_BASE_URL, "http://nemo.sonarsource.org"); - - private IssueChangesEmailTemplate underTest = new IssueChangesEmailTemplate(db.getDbClient(), new EmailSettings(settings.asConfig())); - - @Test - public void should_ignore_non_issue_changes() { - Notification notification = new Notification("other"); - EmailMessage message = underTest.format(notification); - assertThat(message).isNull(); - } - - @Test - public void email_should_display_assignee_change() throws Exception { - Notification notification = generateNotification() - .setFieldValue("old.assignee", "simon") - .setFieldValue("new.assignee", "louis"); - - EmailMessage email = underTest.format(notification); - assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE"); - assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE"); - - String message = email.getMessage(); - String expected = Resources.toString(Resources.getResource( - "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_with_assignee_change.txt"), - StandardCharsets.UTF_8); - expected = StringUtils.remove(expected, '\r'); - assertThat(message).isEqualTo(expected); - assertThat(email.getFrom()).isNull(); - } - - @Test - public void email_should_display_plan_change() throws Exception { - Notification notification = generateNotification() - .setFieldValue("old.actionPlan", null) - .setFieldValue("new.actionPlan", "ABC 1.0"); - - EmailMessage email = underTest.format(notification); - assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE"); - assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE"); - - String message = email.getMessage(); - String expected = Resources.toString(Resources.getResource( - "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_with_action_plan_change.txt"), - StandardCharsets.UTF_8); - expected = StringUtils.remove(expected, '\r'); - assertThat(message).isEqualTo(expected); - assertThat(email.getFrom()).isNull(); - } - - @Test - public void email_should_display_resolution_change() throws Exception { - Notification notification = generateNotification() - .setFieldValue("old.resolution", "FALSE-POSITIVE") - .setFieldValue("new.resolution", "FIXED"); - - EmailMessage email = underTest.format(notification); - assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE"); - assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE"); - - String message = email.getMessage(); - String expected = Resources.toString(Resources.getResource( - "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_should_display_resolution_change.txt"), - StandardCharsets.UTF_8); - expected = StringUtils.remove(expected, '\r'); - assertThat(message).isEqualTo(expected); - assertThat(email.getFrom()).isNull(); - } - - @Test - public void display_component_key_if_no_component_name() throws Exception { - Notification notification = generateNotification() - .setFieldValue("componentName", null); - - EmailMessage email = underTest.format(notification); - assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE"); - assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE"); - - String message = email.getMessage(); - String expected = Resources.toString(Resources.getResource( - "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/display_component_key_if_no_component_name.txt"), - StandardCharsets.UTF_8); - expected = StringUtils.remove(expected, '\r'); - assertThat(message).isEqualTo(expected); - } - - @Test - public void test_email_with_multiple_changes() throws Exception { - Notification notification = generateNotification() - .setFieldValue("comment", "How to fix it?") - .setFieldValue("old.assignee", "simon") - .setFieldValue("new.assignee", "louis") - .setFieldValue("new.resolution", "FALSE-POSITIVE") - .setFieldValue("new.status", "RESOLVED") - .setFieldValue("new.type", "BUG") - .setFieldValue("new.tags", "bug performance"); - - EmailMessage email = underTest.format(notification); - assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE"); - assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE"); - - String message = email.getMessage(); - String expected = Resources.toString(Resources.getResource( - "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_with_multiple_changes.txt"), StandardCharsets.UTF_8); - expected = StringUtils.remove(expected, '\r'); - assertThat(message).isEqualTo(expected); - assertThat(email.getFrom()).isNull(); - } - - @Test - public void test_email_with_issue_on_branch() throws Exception { - Notification notification = generateNotification() - .setFieldValue("branch", "feature1"); - - EmailMessage email = underTest.format(notification); - assertThat(email.getMessageId()).isEqualTo("issue-changes/ABCDE"); - assertThat(email.getSubject()).isEqualTo("Struts, change on issue #ABCDE"); - - String message = email.getMessage(); - String expected = Resources.toString(Resources.getResource( - "org/sonar/server/issue/notification/IssueChangesEmailTemplateTest/email_with_issue_on_branch.txt"), - StandardCharsets.UTF_8); - expected = StringUtils.remove(expected, '\r'); - assertThat(message).isEqualTo(expected); - } - - @Test - public void notification_sender_should_be_the_author_of_change() { - UserDto user = db.users().insertUser(); - - Notification notification = new IssueChangeNotification() - .setChangeAuthor(user) - .setProject("Struts", "org.apache:struts", null, null); - - EmailMessage message = underTest.format(notification); - assertThat(message.getFrom()).isEqualTo(user.getName()); - } - - @Test - public void notification_contains_user_login_when_user_is_removed() { - UserDto user = db.users().insertDisabledUser(); - - Notification notification = new IssueChangeNotification() - .setChangeAuthor(user) - .setProject("Struts", "org.apache:struts", null, null); - - EmailMessage message = underTest.format(notification); - assertThat(message.getFrom()).isEqualTo(user.getLogin()); - } - - private static Notification generateNotification() { - return new IssueChangeNotification() - .setFieldValue("projectName", "Struts") - .setFieldValue("projectKey", "org.apache:struts") - .setFieldValue("componentName", "Action") - .setFieldValue("componentKey", "org.apache.struts.Action") - .setFieldValue("key", "ABCDE") - .setFieldValue("ruleName", "Avoid Cycles") - .setFieldValue("message", "Has 3 cycles"); - } -} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilderTesting.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilderTesting.java new file mode 100644 index 00000000000..43f5cbfd5a2 --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationBuilderTesting.java @@ -0,0 +1,110 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import java.util.Random; +import org.sonar.api.rule.RuleKey; +import org.sonar.db.DbTester; +import org.sonar.db.component.BranchDto; +import org.sonar.db.component.ComponentDto; +import org.sonar.db.rule.RuleDefinitionDto; +import org.sonar.db.rule.RuleDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.User; +import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange; + +import static com.google.common.base.Preconditions.checkArgument; +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; + +public class IssuesChangesNotificationBuilderTesting { + + public static Rule ruleOf(RuleDto rule) { + return new Rule(rule.getKey(), rule.getName()); + } + + public static Rule ruleOf(RuleDefinitionDto rule) { + return new Rule(rule.getKey(), rule.getName()); + } + + public static User userOf(UserDto changeAuthor) { + return new User(changeAuthor.getUuid(), changeAuthor.getLogin(), changeAuthor.getName()); + } + + public static Project projectBranchOf(DbTester db, ComponentDto branch) { + BranchDto branchDto = db.getDbClient().branchDao().selectByUuid(db.getSession(), branch.uuid()).get(); + checkArgument(!branchDto.isMain(), "should be a branch"); + return new Project.Builder(branch.uuid()) + .setKey(branch.getKey()) + .setProjectName(branch.name()) + .setBranchName(branchDto.getKey()) + .build(); + } + + public static Project projectOf(ComponentDto project) { + return new Project.Builder(project.uuid()) + .setKey(project.getKey()) + .setProjectName(project.name()) + .build(); + } + + static ChangedIssue newChangedIssue(String key, Project project, Rule rule) { + return new ChangedIssue.Builder(key) + .setNewStatus(randomAlphabetic(19)) + .setProject(project) + .setRule(rule) + .build(); + } + + static ChangedIssue newChangedIssue(String key, String status, Project project, String ruleName) { + return newChangedIssue(key, status, project, newRule(ruleName)); + } + + static ChangedIssue newChangedIssue(String key, String status, Project project, Rule rule) { + return new ChangedIssue.Builder(key) + .setNewStatus(status) + .setProject(project) + .setRule(rule) + .build(); + } + + static Rule newRule(String ruleName) { + return new Rule(RuleKey.of(randomAlphabetic(6), randomAlphabetic(7)), ruleName); + } + + static Project newProject(String uuid) { + return new Project.Builder(uuid).setProjectName(uuid + "_name").setKey(uuid + "_key").build(); + } + + static Project newBranch(String uuid, String branchName) { + return new Project.Builder(uuid).setProjectName(uuid + "_name").setKey(uuid + "_key").setBranchName(branchName).build(); + } + + static UserChange newUserChange() { + return new UserChange(new Random().nextLong(), new User(randomAlphabetic(4), randomAlphabetic(5), randomAlphabetic(6))); + } + + static AnalysisChange newAnalysisChange() { + return new AnalysisChange(new Random().nextLong()); + } +} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationModuleTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationModuleTest.java new file mode 100644 index 00000000000..2e724dd857a --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationModuleTest.java @@ -0,0 +1,37 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import org.junit.Test; +import org.sonar.core.platform.ComponentContainer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.sonar.core.platform.ComponentContainer.COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER; + +public class IssuesChangesNotificationModuleTest { + @Test + public void verify_count_of_added_components() { + ComponentContainer container = new ComponentContainer(); + new IssuesChangesNotificationModule().configure(container); + assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 7); + } + + +} diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationTest.java new file mode 100644 index 00000000000..bbdb2f77446 --- /dev/null +++ b/server/sonar-server-common/src/test/java/org/sonar/server/issue/notification/IssuesChangesNotificationTest.java @@ -0,0 +1,35 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.issue.notification; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class IssuesChangesNotificationTest { + + private IssuesChangesNotification notification = new IssuesChangesNotification(); + + @Test + public void verify_type() { + assertThat(notification.getType()).isEqualTo("issues-changes"); + } + +} |