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