You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

IssueChangesEmailTemplate.java 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2020 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. package org.sonar.server.issue.notification;
  21. import com.google.common.collect.ImmutableList;
  22. import com.google.common.collect.ImmutableSortedSet;
  23. import com.google.common.collect.ListMultimap;
  24. import com.google.common.collect.Lists;
  25. import com.google.common.collect.SetMultimap;
  26. import java.io.UnsupportedEncodingException;
  27. import java.util.Collection;
  28. import java.util.Comparator;
  29. import java.util.Iterator;
  30. import java.util.List;
  31. import java.util.Locale;
  32. import java.util.Optional;
  33. import java.util.Set;
  34. import java.util.SortedSet;
  35. import java.util.function.BiConsumer;
  36. import java.util.function.Consumer;
  37. import javax.annotation.Nullable;
  38. import org.sonar.api.config.EmailSettings;
  39. import org.sonar.api.rules.RuleType;
  40. import org.sonar.core.i18n.I18n;
  41. import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue;
  42. import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project;
  43. import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule;
  44. import static java.net.URLEncoder.encode;
  45. import static java.nio.charset.StandardCharsets.UTF_8;
  46. import static java.util.function.Function.identity;
  47. import static org.sonar.core.util.stream.MoreCollectors.index;
  48. import static org.sonar.server.issue.notification.RuleGroup.ISSUES;
  49. import static org.sonar.server.issue.notification.RuleGroup.SECURITY_HOTSPOTS;
  50. import static org.sonar.server.issue.notification.RuleGroup.formatIssueOrHotspot;
  51. import static org.sonar.server.issue.notification.RuleGroup.formatIssuesOrHotspots;
  52. import static org.sonar.server.issue.notification.RuleGroup.resolveGroup;
  53. public abstract class IssueChangesEmailTemplate implements EmailTemplate {
  54. private static final Comparator<Rule> RULE_COMPARATOR = Comparator.comparing(r -> r.getKey().toString());
  55. private static final Comparator<Project> PROJECT_COMPARATOR = Comparator.comparing(Project::getProjectName)
  56. .thenComparing(t -> t.getBranchName().orElse(""));
  57. private static final Comparator<ChangedIssue> CHANGED_ISSUE_KEY_COMPARATOR = Comparator.comparing(ChangedIssue::getKey, Comparator.naturalOrder());
  58. /**
  59. * Assuming:
  60. * <ul>
  61. * <li>UUID length of 40 chars</li>
  62. * <li>a max URL length of 2083 chars</li>
  63. * </ul>
  64. * This leaves ~850 chars for the rest of the URL (including other parameters such as the project key and the branch),
  65. * which is reasonable to stay safe from the max URL length supported by some browsers and network devices.
  66. */
  67. private static final int MAX_ISSUES_BY_LINK = 40;
  68. private static final String URL_ENCODED_COMMA = urlEncode(",");
  69. private final I18n i18n;
  70. private final EmailSettings settings;
  71. protected IssueChangesEmailTemplate(I18n i18n, EmailSettings settings) {
  72. this.i18n = i18n;
  73. this.settings = settings;
  74. }
  75. /**
  76. * Adds "projectName" or "projectName, branchName" if branchName is non null
  77. */
  78. protected static void toString(StringBuilder sb, Project project) {
  79. Optional<String> branchName = project.getBranchName();
  80. if (branchName.isPresent()) {
  81. sb.append(project.getProjectName()).append(", ").append(branchName.get());
  82. } else {
  83. sb.append(project.getProjectName());
  84. }
  85. }
  86. static String toUrlParams(Project project) {
  87. return "id=" + urlEncode(project.getKey()) +
  88. project.getBranchName().map(branchName -> "&branch=" + urlEncode(branchName)).orElse("");
  89. }
  90. void addIssuesByProjectThenRule(StringBuilder sb, SetMultimap<Project, ChangedIssue> issuesByProject) {
  91. issuesByProject.keySet().stream()
  92. .sorted(PROJECT_COMPARATOR)
  93. .forEach(project -> {
  94. String encodedProjectParams = toUrlParams(project);
  95. paragraph(sb, s -> toString(s, project));
  96. addIssuesByRule(sb, issuesByProject.get(project), projectIssuePageHref(encodedProjectParams));
  97. });
  98. }
  99. void addIssuesAndHotspotsByProjectThenRule(StringBuilder sb, SetMultimap<Project, ChangedIssue> issuesByProject) {
  100. issuesByProject.keySet().stream()
  101. .sorted(PROJECT_COMPARATOR)
  102. .forEach(project -> {
  103. String encodedProjectParams = toUrlParams(project);
  104. paragraph(sb, s -> toString(s, project));
  105. Set<ChangedIssue> changedIssues = issuesByProject.get(project);
  106. ListMultimap<RuleGroup, ChangedIssue> issuesAndHotspots = changedIssues.stream()
  107. .collect(index(changedIssue -> resolveGroup(changedIssue.getRule().getRuleType()), identity()));
  108. List<ChangedIssue> issues = issuesAndHotspots.get(ISSUES);
  109. List<ChangedIssue> hotspots = issuesAndHotspots.get(SECURITY_HOTSPOTS);
  110. boolean hasSecurityHotspots = !hotspots.isEmpty();
  111. boolean hasOtherIssues = !issues.isEmpty();
  112. if (hasOtherIssues) {
  113. addIssuesByRule(sb, issues, projectIssuePageHref(encodedProjectParams));
  114. }
  115. if (hasSecurityHotspots && hasOtherIssues) {
  116. paragraph(sb, stringBuilder -> {
  117. });
  118. }
  119. if (hasSecurityHotspots) {
  120. addIssuesByRule(sb, hotspots, securityHotspotPageHref(encodedProjectParams));
  121. }
  122. });
  123. }
  124. void addIssuesByRule(StringBuilder sb, Collection<ChangedIssue> changedIssues, BiConsumer<StringBuilder, Collection<ChangedIssue>> issuePageHref) {
  125. ListMultimap<Rule, ChangedIssue> issuesByRule = changedIssues.stream()
  126. .collect(index(ChangedIssue::getRule, t -> t));
  127. Iterator<Rule> rules = issuesByRule.keySet().stream()
  128. .sorted(RULE_COMPARATOR)
  129. .iterator();
  130. if (!rules.hasNext()) {
  131. return;
  132. }
  133. sb.append("<ul>");
  134. while (rules.hasNext()) {
  135. Rule rule = rules.next();
  136. Collection<ChangedIssue> issues = issuesByRule.get(rule);
  137. sb.append("<li>").append("Rule ").append(" <em>").append(rule.getName()).append("</em> - ");
  138. appendIssueLinks(sb, issuePageHref, issues, rule.getRuleType());
  139. sb.append("</li>");
  140. }
  141. sb.append("</ul>");
  142. }
  143. private static void appendIssueLinks(StringBuilder sb, BiConsumer<StringBuilder, Collection<ChangedIssue>> issuePageHref, Collection<ChangedIssue> issues,
  144. @Nullable RuleType ruleType) {
  145. SortedSet<ChangedIssue> sortedIssues = ImmutableSortedSet.copyOf(CHANGED_ISSUE_KEY_COMPARATOR, issues);
  146. int issueCount = issues.size();
  147. if (issueCount == 1) {
  148. link(sb, s -> issuePageHref.accept(s, sortedIssues), s -> s.append("See the single ").append(formatIssueOrHotspot(ruleType)));
  149. } else if (issueCount <= MAX_ISSUES_BY_LINK) {
  150. link(sb, s -> issuePageHref.accept(s, sortedIssues), s -> s.append("See all ").append(issueCount).append(" ").append(formatIssuesOrHotspots(ruleType)));
  151. } else {
  152. sb.append("See ").append(formatIssuesOrHotspots(ruleType));
  153. List<List<ChangedIssue>> issueGroups = Lists.partition(ImmutableList.copyOf(sortedIssues), MAX_ISSUES_BY_LINK);
  154. Iterator<List<ChangedIssue>> issueGroupsIterator = issueGroups.iterator();
  155. int[] groupIndex = new int[] {0};
  156. while (issueGroupsIterator.hasNext()) {
  157. List<ChangedIssue> issueGroup = issueGroupsIterator.next();
  158. sb.append(' ');
  159. link(sb, s -> issuePageHref.accept(s, issueGroup), issueGroupLabel(sb, groupIndex, issueGroup));
  160. groupIndex[0]++;
  161. }
  162. }
  163. }
  164. BiConsumer<StringBuilder, Collection<ChangedIssue>> projectIssuePageHref(String projectParams) {
  165. return (s, issues) -> {
  166. s.append(settings.getServerBaseURL()).append("/project/issues?").append(projectParams)
  167. .append("&issues=");
  168. Iterator<ChangedIssue> issueIterator = issues.iterator();
  169. while (issueIterator.hasNext()) {
  170. s.append(urlEncode(issueIterator.next().getKey()));
  171. if (issueIterator.hasNext()) {
  172. s.append(URL_ENCODED_COMMA);
  173. }
  174. }
  175. if (issues.size() == 1) {
  176. s.append("&open=").append(urlEncode(issues.iterator().next().getKey()));
  177. }
  178. };
  179. }
  180. BiConsumer<StringBuilder, Collection<ChangedIssue>> securityHotspotPageHref(String projectParams) {
  181. return (s, issues) -> {
  182. s.append(settings.getServerBaseURL()).append("/security_hotspots?").append(projectParams)
  183. .append("&hotspots=");
  184. Iterator<ChangedIssue> issueIterator = issues.iterator();
  185. while (issueIterator.hasNext()) {
  186. s.append(urlEncode(issueIterator.next().getKey()));
  187. if (issueIterator.hasNext()) {
  188. s.append(URL_ENCODED_COMMA);
  189. }
  190. }
  191. };
  192. }
  193. private static Consumer<StringBuilder> issueGroupLabel(StringBuilder sb, int[] groupIndex, List<ChangedIssue> issueGroup) {
  194. return s -> {
  195. int firstIssueNumber = (groupIndex[0] * MAX_ISSUES_BY_LINK) + 1;
  196. if (issueGroup.size() == 1) {
  197. sb.append(firstIssueNumber);
  198. } else {
  199. sb.append(firstIssueNumber).append("-").append(firstIssueNumber + issueGroup.size() - 1);
  200. }
  201. };
  202. }
  203. void addFooter(StringBuilder sb, String notificationI18nKey) {
  204. paragraph(sb, s -> s.append("&nbsp;"));
  205. paragraph(sb, s -> {
  206. s.append("<small>");
  207. s.append("You received this email because you are subscribed to ")
  208. .append('"').append(i18n.message(Locale.ENGLISH, notificationI18nKey, notificationI18nKey)).append('"')
  209. .append(" notifications from SonarQube.");
  210. s.append(" Click ");
  211. link(s, s1 -> s1.append(settings.getServerBaseURL()).append("/account/notifications"), s1 -> s1.append("here"));
  212. s.append(" to edit your email preferences.");
  213. s.append("</small>");
  214. });
  215. }
  216. protected static void paragraph(StringBuilder sb, Consumer<StringBuilder> content) {
  217. sb.append("<p>");
  218. content.accept(sb);
  219. sb.append("</p>");
  220. }
  221. protected static void link(StringBuilder sb, Consumer<StringBuilder> link, Consumer<StringBuilder> content) {
  222. sb.append("<a href=\"");
  223. link.accept(sb);
  224. sb.append("\">");
  225. content.accept(sb);
  226. sb.append("</a>");
  227. }
  228. private static String urlEncode(String str) {
  229. try {
  230. return encode(str, UTF_8.name());
  231. } catch (UnsupportedEncodingException e) {
  232. throw new IllegalStateException(e);
  233. }
  234. }
  235. protected static String issueOrIssues(Collection<?> collection) {
  236. if (collection.size() > 1) {
  237. return "issues";
  238. }
  239. return "issue";
  240. }
  241. }