]> source.dussan.org Git - sonarqube.git/blob
14beb1e818949a3517a7dbba882d491488f22e9c
[sonarqube.git] /
1 /*
2  * SonarQube
3  * Copyright (C) 2009-2022 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
22 import com.google.common.collect.ImmutableList;
23 import com.google.common.collect.ImmutableSortedSet;
24 import com.google.common.collect.ListMultimap;
25 import com.google.common.collect.Lists;
26 import com.google.common.collect.SetMultimap;
27 import java.io.UnsupportedEncodingException;
28 import java.util.Collection;
29 import java.util.Comparator;
30 import java.util.Iterator;
31 import java.util.List;
32 import java.util.Locale;
33 import java.util.Optional;
34 import java.util.Set;
35 import java.util.SortedSet;
36 import java.util.function.BiConsumer;
37 import java.util.function.Consumer;
38 import javax.annotation.Nullable;
39 import org.sonar.api.config.EmailSettings;
40 import org.sonar.api.rules.RuleType;
41 import org.sonar.core.i18n.I18n;
42 import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue;
43 import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project;
44 import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule;
45
46 import static java.net.URLEncoder.encode;
47 import static java.nio.charset.StandardCharsets.UTF_8;
48 import static java.util.function.Function.identity;
49 import static org.sonar.core.util.stream.MoreCollectors.index;
50 import static org.sonar.server.issue.notification.RuleGroup.ISSUES;
51 import static org.sonar.server.issue.notification.RuleGroup.SECURITY_HOTSPOTS;
52 import static org.sonar.server.issue.notification.RuleGroup.formatIssueOrHotspot;
53 import static org.sonar.server.issue.notification.RuleGroup.formatIssuesOrHotspots;
54 import static org.sonar.server.issue.notification.RuleGroup.resolveGroup;
55
56 public abstract class IssueChangesEmailTemplate implements EmailTemplate {
57
58   private static final Comparator<Rule> RULE_COMPARATOR = Comparator.comparing(r -> r.getKey().toString());
59   private static final Comparator<Project> PROJECT_COMPARATOR = Comparator.comparing(Project::getProjectName)
60     .thenComparing(t -> t.getBranchName().orElse(""));
61   private static final Comparator<ChangedIssue> CHANGED_ISSUE_KEY_COMPARATOR = Comparator.comparing(ChangedIssue::getKey, Comparator.naturalOrder());
62
63   /**
64    * Assuming:
65    * <ul>
66    *   <li>UUID length of 40 chars</li>
67    *   <li>a max URL length of 2083 chars</li>
68    * </ul>
69    * This leaves ~850 chars for the rest of the URL (including other parameters such as the project key and the branch),
70    * which is reasonable to stay safe from the max URL length supported by some browsers and network devices.
71    */
72   private static final int MAX_ISSUES_BY_LINK = 40;
73   private static final String URL_ENCODED_COMMA = urlEncode(",");
74
75   private final I18n i18n;
76   private final EmailSettings settings;
77
78   protected IssueChangesEmailTemplate(I18n i18n, EmailSettings settings) {
79     this.i18n = i18n;
80     this.settings = settings;
81   }
82
83   /**
84    * Adds "projectName" or "projectName, branchName" if branchName is non null
85    */
86   protected static void toString(StringBuilder sb, Project project) {
87     Optional<String> branchName = project.getBranchName();
88     if (branchName.isPresent()) {
89       sb.append(project.getProjectName()).append(", ").append(branchName.get());
90     } else {
91       sb.append(project.getProjectName());
92     }
93   }
94
95   static String toUrlParams(Project project) {
96     return "id=" + urlEncode(project.getKey()) +
97       project.getBranchName().map(branchName -> "&branch=" + urlEncode(branchName)).orElse("");
98   }
99
100   void addIssuesByProjectThenRule(StringBuilder sb, SetMultimap<Project, ChangedIssue> issuesByProject) {
101     issuesByProject.keySet().stream()
102       .sorted(PROJECT_COMPARATOR)
103       .forEach(project -> {
104         String encodedProjectParams = toUrlParams(project);
105         paragraph(sb, s -> toString(s, project));
106         addIssuesByRule(sb, issuesByProject.get(project), projectIssuePageHref(encodedProjectParams));
107       });
108   }
109
110   void addIssuesAndHotspotsByProjectThenRule(StringBuilder sb, SetMultimap<Project, ChangedIssue> issuesByProject) {
111     issuesByProject.keySet().stream()
112       .sorted(PROJECT_COMPARATOR)
113       .forEach(project -> {
114         String encodedProjectParams = toUrlParams(project);
115         paragraph(sb, s -> toString(s, project));
116
117         Set<ChangedIssue> changedIssues = issuesByProject.get(project);
118         ListMultimap<RuleGroup, ChangedIssue> issuesAndHotspots = changedIssues.stream()
119           .collect(index(changedIssue -> resolveGroup(changedIssue.getRule().getRuleType()), identity()));
120
121         List<ChangedIssue> issues = issuesAndHotspots.get(ISSUES);
122         List<ChangedIssue> hotspots = issuesAndHotspots.get(SECURITY_HOTSPOTS);
123
124         boolean hasSecurityHotspots = !hotspots.isEmpty();
125         boolean hasOtherIssues = !issues.isEmpty();
126
127         if (hasOtherIssues) {
128           addIssuesByRule(sb, issues, projectIssuePageHref(encodedProjectParams));
129         }
130
131         if (hasSecurityHotspots && hasOtherIssues) {
132           paragraph(sb, stringBuilder -> {
133           });
134         }
135
136         if (hasSecurityHotspots) {
137           addIssuesByRule(sb, hotspots, securityHotspotPageHref(encodedProjectParams));
138         }
139       });
140   }
141
142   void addIssuesByRule(StringBuilder sb, Collection<ChangedIssue> changedIssues, BiConsumer<StringBuilder, Collection<ChangedIssue>> issuePageHref) {
143     ListMultimap<Rule, ChangedIssue> issuesByRule = changedIssues.stream()
144       .collect(index(ChangedIssue::getRule, t -> t));
145
146     Iterator<Rule> rules = issuesByRule.keySet().stream()
147       .sorted(RULE_COMPARATOR)
148       .iterator();
149     if (!rules.hasNext()) {
150       return;
151     }
152
153     sb.append("<ul>");
154     while (rules.hasNext()) {
155       Rule rule = rules.next();
156       Collection<ChangedIssue> issues = issuesByRule.get(rule);
157
158       sb.append("<li>").append("Rule ").append(" <em>").append(rule.getName()).append("</em> - ");
159       appendIssueLinks(sb, issuePageHref, issues, rule.getRuleType());
160       sb.append("</li>");
161     }
162     sb.append("</ul>");
163   }
164
165   private static void appendIssueLinks(StringBuilder sb, BiConsumer<StringBuilder, Collection<ChangedIssue>> issuePageHref, Collection<ChangedIssue> issues,
166     @Nullable RuleType ruleType) {
167     SortedSet<ChangedIssue> sortedIssues = ImmutableSortedSet.copyOf(CHANGED_ISSUE_KEY_COMPARATOR, issues);
168     int issueCount = issues.size();
169     if (issueCount == 1) {
170       link(sb, s -> issuePageHref.accept(s, sortedIssues), s -> s.append("See the single ").append(formatIssueOrHotspot(ruleType)));
171     } else if (issueCount <= MAX_ISSUES_BY_LINK) {
172       link(sb, s -> issuePageHref.accept(s, sortedIssues), s -> s.append("See all ").append(issueCount).append(" ").append(formatIssuesOrHotspots(ruleType)));
173     } else {
174       sb.append("See ").append(formatIssuesOrHotspots(ruleType));
175       List<List<ChangedIssue>> issueGroups = Lists.partition(ImmutableList.copyOf(sortedIssues), MAX_ISSUES_BY_LINK);
176       Iterator<List<ChangedIssue>> issueGroupsIterator = issueGroups.iterator();
177       int[] groupIndex = new int[] {0};
178       while (issueGroupsIterator.hasNext()) {
179         List<ChangedIssue> issueGroup = issueGroupsIterator.next();
180         sb.append(' ');
181         link(sb, s -> issuePageHref.accept(s, issueGroup), issueGroupLabel(sb, groupIndex, issueGroup));
182         groupIndex[0]++;
183       }
184     }
185   }
186
187   BiConsumer<StringBuilder, Collection<ChangedIssue>> projectIssuePageHref(String projectParams) {
188     return (s, issues) -> {
189       s.append(settings.getServerBaseURL()).append("/project/issues?").append(projectParams)
190         .append("&issues=");
191
192       Iterator<ChangedIssue> issueIterator = issues.iterator();
193       while (issueIterator.hasNext()) {
194         s.append(urlEncode(issueIterator.next().getKey()));
195         if (issueIterator.hasNext()) {
196           s.append(URL_ENCODED_COMMA);
197         }
198       }
199
200       if (issues.size() == 1) {
201         s.append("&open=").append(urlEncode(issues.iterator().next().getKey()));
202       }
203     };
204   }
205
206   BiConsumer<StringBuilder, Collection<ChangedIssue>> securityHotspotPageHref(String projectParams) {
207     return (s, issues) -> {
208       s.append(settings.getServerBaseURL()).append("/security_hotspots?").append(projectParams)
209         .append("&hotspots=");
210
211       Iterator<ChangedIssue> issueIterator = issues.iterator();
212       while (issueIterator.hasNext()) {
213         s.append(urlEncode(issueIterator.next().getKey()));
214         if (issueIterator.hasNext()) {
215           s.append(URL_ENCODED_COMMA);
216         }
217       }
218     };
219   }
220
221   private static Consumer<StringBuilder> issueGroupLabel(StringBuilder sb, int[] groupIndex, List<ChangedIssue> issueGroup) {
222     return s -> {
223       int firstIssueNumber = (groupIndex[0] * MAX_ISSUES_BY_LINK) + 1;
224       if (issueGroup.size() == 1) {
225         sb.append(firstIssueNumber);
226       } else {
227         sb.append(firstIssueNumber).append("-").append(firstIssueNumber + issueGroup.size() - 1);
228       }
229     };
230   }
231
232   void addFooter(StringBuilder sb, String notificationI18nKey) {
233     paragraph(sb, s -> s.append("&nbsp;"));
234     paragraph(sb, s -> {
235       s.append("<small>");
236       s.append("You received this email because you are subscribed to ")
237         .append('"').append(i18n.message(Locale.ENGLISH, notificationI18nKey, notificationI18nKey)).append('"')
238         .append(" notifications from SonarQube.");
239       s.append(" Click ");
240       link(s, s1 -> s1.append(settings.getServerBaseURL()).append("/account/notifications"), s1 -> s1.append("here"));
241       s.append(" to edit your email preferences.");
242       s.append("</small>");
243     });
244   }
245
246   protected static void paragraph(StringBuilder sb, Consumer<StringBuilder> content) {
247     sb.append("<p>");
248     content.accept(sb);
249     sb.append("</p>");
250   }
251
252   protected static void link(StringBuilder sb, Consumer<StringBuilder> link, Consumer<StringBuilder> content) {
253     sb.append("<a href=\"");
254     link.accept(sb);
255     sb.append("\">");
256     content.accept(sb);
257     sb.append("</a>");
258   }
259
260   private static String urlEncode(String str) {
261     try {
262       return encode(str, UTF_8.name());
263     } catch (UnsupportedEncodingException e) {
264       throw new IllegalStateException(e);
265     }
266   }
267
268   protected static String issueOrIssues(Collection<?> collection) {
269     if (collection.size() > 1) {
270       return "issues";
271     }
272     return "issue";
273   }
274
275 }