1 /*
  2  * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.
  3  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
  4  *
  5  * This code is free software; you can redistribute it and/or modify it
  6  * under the terms of the GNU General Public License version 2 only, as
  7  * published by the Free Software Foundation.
  8  *
  9  * This code is distributed in the hope that it will be useful, but WITHOUT
 10  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 11  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 12  * version 2 for more details (a copy is included in the LICENSE file that
 13  * accompanied this code).
 14  *
 15  * You should have received a copy of the GNU General Public License version
 16  * 2 along with this work; if not, write to the Free Software Foundation,
 17  * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 18  *
 19  * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 20  * or visit www.oracle.com if you need additional information or have any
 21  * questions.
 22  */
 23 package org.openjdk.skara.bots.notify;
 24 
 25 import org.openjdk.skara.email.*;
 26 import org.openjdk.skara.forge.HostedRepository;
 27 import org.openjdk.skara.mailinglist.*;
 28 import org.openjdk.skara.vcs.*;
 29 import org.openjdk.skara.vcs.openjdk.OpenJDKTag;
 30 
 31 import java.io.*;
 32 import java.time.Duration;
 33 import java.time.format.DateTimeFormatter;
 34 import java.util.*;
 35 import java.util.logging.Logger;
 36 import java.util.regex.Pattern;
 37 import java.util.stream.Collectors;
 38 
 39 public class MailingListUpdater implements UpdateConsumer {
 40     private final MailingList list;
 41     private final EmailAddress recipient;
 42     private final EmailAddress sender;
 43     private final EmailAddress author;
 44     private final boolean includeBranch;
 45     private final Mode mode;
 46     private final Map<String, String> headers;
 47     private final Pattern allowedAuthorDomains;
 48     private final Logger log = Logger.getLogger("org.openjdk.skara.bots.notify");
 49 
 50     enum Mode {
 51         ALL,
 52         PR,
 53         PR_ONLY
 54     }
 55 
 56     MailingListUpdater(MailingList list, EmailAddress recipient, EmailAddress sender, EmailAddress author,
 57                        boolean includeBranch, Mode mode, Map<String, String> headers, Pattern allowedAuthorDomains) {
 58         this.list = list;
 59         this.recipient = recipient;
 60         this.sender = sender;
 61         this.author = author;
 62         this.includeBranch = includeBranch;
 63         this.mode = mode;
 64         this.headers = headers;
 65         this.allowedAuthorDomains = allowedAuthorDomains;
 66     }
 67 
 68     private String patchToText(Patch patch) {
 69         if (patch.status().isAdded()) {
 70             return "+ " + patch.target().path().orElseThrow();
 71         } else if (patch.status().isDeleted()) {
 72             return "- " + patch.source().path().orElseThrow();
 73         } else if (patch.status().isModified()) {
 74             return "! " + patch.target().path().orElseThrow();
 75         } else {
 76             return "= " + patch.target().path().orElseThrow();
 77         }
 78     }
 79 
 80     private String commitToTextBrief(HostedRepository repository, Commit commit) {
 81         var writer = new StringWriter();
 82         var printer = new PrintWriter(writer);
 83 
 84         printer.println("Changeset: " + commit.hash().abbreviate());
 85         printer.println("Author:    " + commit.author().name() + " <" + commit.author().email() + ">");
 86         if (!commit.author().equals(commit.committer())) {
 87             printer.println("Committer: " + commit.committer().name() + " <" + commit.committer().email() + ">");
 88         }
 89         printer.println("Date:      " + commit.date().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss +0000")));
 90         printer.println("URL:       " + repository.webUrl(commit.hash()));
 91 
 92         return writer.toString();
 93     }
 94 
 95     private String commitToText(HostedRepository repository, Commit commit) {
 96         var writer = new StringWriter();
 97         var printer = new PrintWriter(writer);
 98 
 99         printer.print(commitToTextBrief(repository, commit));
100         printer.println();
101         printer.println(String.join("\n", commit.message()));
102         printer.println();
103 
104         for (var diff : commit.parentDiffs()) {
105             for (var patch : diff.patches()) {
106                 printer.println(patchToText(patch));
107             }
108         }
109 
110         return writer.toString();
111     }
112 
113     private String tagAnnotationToText(HostedRepository repository, Tag.Annotated annotation) {
114         var writer = new StringWriter();
115         var printer = new PrintWriter(writer);
116 
117         printer.println("Tagged by: " + annotation.author().name() + " <" + annotation.author().email() + ">");
118         printer.println("Date:      " + annotation.date().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss +0000")));
119         printer.println();
120         printer.print(String.join("\n", annotation.message()));
121 
122         return writer.toString();
123     }
124 
125     private EmailAddress filteredAuthor(EmailAddress commitAddress) {
126         if (author != null) {
127             return author;
128         }
129         var allowedAuthorMatcher = allowedAuthorDomains.matcher(commitAddress.domain());
130         if (!allowedAuthorMatcher.matches()) {
131             return sender;
132         } else {
133             return commitAddress;
134         }
135     }
136 
137     private EmailAddress commitToAuthor(Commit commit) {
138         return filteredAuthor(EmailAddress.from(commit.committer().name(), commit.committer().email()));
139     }
140 
141     private EmailAddress annotationToAuthor(Tag.Annotated annotation) {
142         return filteredAuthor(EmailAddress.from(annotation.author().name(), annotation.author().email()));
143     }
144 
145     private String commitsToSubject(HostedRepository repository, List<Commit> commits, Branch branch) {
146         var subject = new StringBuilder();
147         subject.append(repository.repositoryType().shortName());
148         subject.append(": ");
149         subject.append(repository.name());
150         subject.append(": ");
151         if (includeBranch) {
152             subject.append(branch.name());
153             subject.append(": ");
154         }
155         if (commits.size() > 1) {
156             subject.append(commits.size());
157             subject.append(" new changesets");
158         } else {
159             subject.append(commits.get(0).message().get(0));
160         }
161         return subject.toString();
162     }
163 
164     private String tagToSubject(HostedRepository repository, Hash hash, Tag tag) {
165         return repository.repositoryType().shortName() +
166                 ": " +
167                 repository.name() +
168                 ": Added tag " +
169                 tag +
170                 " for changeset " +
171                 hash.abbreviate();
172     }
173 
174     private List<Commit> filterAndSendPrCommits(HostedRepository repository, List<Commit> commits) {
175         var ret = new ArrayList<Commit>();
176 
177         var rfrs = list.conversations(Duration.ofDays(365)).stream()
178                        .map(Conversation::first)
179                        .filter(email -> email.subject().startsWith("RFR: "))
180                        .collect(Collectors.toList());
181 
182         for (var commit : commits) {
183             var candidates = repository.findPullRequestsWithComment(null, "Pushed as commit " + commit.hash() + ".");
184             if (candidates.size() != 1) {
185                 log.warning("Commit " + commit.hash() + " matches " + candidates.size() + " pull requests - expected 1");
186                 ret.add(commit);
187                 continue;
188             }
189 
190             var candidate = candidates.get(0);
191             var prLink = candidate.webUrl();
192             var prLinkPattern = Pattern.compile("^(?:PR: )?" + Pattern.quote(prLink.toString()), Pattern.MULTILINE);
193 
194             var rfrCandidates = rfrs.stream()
195                                     .filter(email -> prLinkPattern.matcher(email.body()).find())
196                                     .collect(Collectors.toList());
197             if (rfrCandidates.size() != 1) {
198                 log.warning("Pull request " + prLink + " found in " + rfrCandidates.size() + " RFR threads - expected 1");
199                 ret.add(commit);
200                 continue;
201             }
202             var rfr = rfrCandidates.get(0);
203 
204             var body = commitToText(repository, commit);
205             var email = Email.reply(rfr, "Re: [Integrated] " + rfr.subject(), body)
206                              .sender(sender)
207                              .author(commitToAuthor(commit))
208                              .recipient(recipient)
209                              .headers(headers)
210                              .build();
211             list.post(email);
212         }
213 
214         return ret;
215     }
216 
217     private void sendCombinedCommits(HostedRepository repository, List<Commit> commits, Branch branch) {
218         if (commits.size() == 0) {
219             return;
220         }
221 
222         var writer = new StringWriter();
223         var printer = new PrintWriter(writer);
224 
225         for (var commit : commits) {
226             printer.println(commitToText(repository, commit));
227         }
228 
229         var subject = commitsToSubject(repository, commits, branch);
230         var lastCommit = commits.get(commits.size() - 1);
231         var commitAddress = filteredAuthor(EmailAddress.from(lastCommit.committer().name(), lastCommit.committer().email()));
232         var email = Email.create(subject, writer.toString())
233                          .sender(sender)
234                          .author(commitAddress)
235                          .recipient(recipient)
236                          .headers(headers)
237                          .build();
238 
239         list.post(email);
240     }
241 
242     @Override
243     public void handleCommits(HostedRepository repository, List<Commit> commits, Branch branch) {
244         switch (mode) {
245             case PR_ONLY:
246                 filterAndSendPrCommits(repository, commits);
247                 break;
248             case PR:
249                 commits = filterAndSendPrCommits(repository, commits);
250                 // fall-through
251             case ALL:
252                 sendCombinedCommits(repository, commits, branch);
253                 break;
254         }
255     }
256 
257     @Override
258     public void handleOpenJDKTagCommits(HostedRepository repository, List<Commit> commits, OpenJDKTag tag, Tag.Annotated annotation) {
259         if (mode == Mode.PR_ONLY) {
260             return;
261         }
262         var writer = new StringWriter();
263         var printer = new PrintWriter(writer);
264 
265         var taggedCommit = commits.get(commits.size() - 1);
266         if (annotation != null) {
267             printer.println(tagAnnotationToText(repository, annotation));
268         }
269         printer.println(commitToTextBrief(repository, taggedCommit));
270 
271         printer.println("The following commits are included in " + tag.tag());
272         printer.println("========================================================");
273         for (var commit : commits) {
274             printer.print(commit.hash().abbreviate());
275             if (commit.message().size() > 0) {
276                 printer.print(": " + commit.message().get(0));
277             }
278             printer.println();
279         }
280 
281         var subject = tagToSubject(repository, taggedCommit.hash(), tag.tag());
282         var email = Email.create(subject, writer.toString())
283                          .sender(sender)
284                          .recipient(recipient)
285                          .headers(headers);
286 
287         if (annotation != null) {
288             email.author(annotationToAuthor(annotation));
289         } else {
290             email.author(commitToAuthor(taggedCommit));
291         }
292 
293         list.post(email.build());
294     }
295 
296     @Override
297     public void handleTagCommit(HostedRepository repository, Commit commit, Tag tag, Tag.Annotated annotation) {
298         if (mode == Mode.PR_ONLY) {
299             return;
300         }
301         var writer = new StringWriter();
302         var printer = new PrintWriter(writer);
303 
304         if (annotation != null) {
305             printer.println(tagAnnotationToText(repository, annotation));
306         }
307         printer.println(commitToTextBrief(repository, commit));
308 
309         var subject = tagToSubject(repository, commit.hash(), tag);
310         var email = Email.create(subject, writer.toString())
311                          .sender(sender)
312                          .recipient(recipient)
313                          .headers(headers);
314 
315         if (annotation != null) {
316             email.author(annotationToAuthor(annotation));
317         } else {
318             email.author(commitToAuthor(commit));
319         }
320 
321         list.post(email.build());
322     }
323 
324     private String newBranchSubject(HostedRepository repository, List<Commit> commits, Branch parent, Branch branch) {
325         var subject = new StringBuilder();
326         subject.append(repository.repositoryType().shortName());
327         subject.append(": ");
328         subject.append(repository.name());
329         subject.append(": created branch ");
330         subject.append(branch);
331         subject.append(" based on the branch ");
332         subject.append(parent);
333         subject.append(" containing ");
334         subject.append(commits.size());
335         subject.append(" unique commit");
336         if (commits.size() != 1) {
337             subject.append("s");
338         }
339 
340         return subject.toString();
341     }
342 
343     @Override
344     public void handleNewBranch(HostedRepository repository, List<Commit> commits, Branch parent, Branch branch) {
345         var writer = new StringWriter();
346         var printer = new PrintWriter(writer);
347 
348         if (commits.size() > 0) {
349             printer.println("The following commits are unique to the " + branch.name() + " branch:");
350             printer.println("========================================================");
351             for (var commit : commits) {
352                 printer.print(commit.hash().abbreviate());
353                 if (commit.message().size() > 0) {
354                     printer.print(": " + commit.message().get(0));
355                 }
356                 printer.println();
357             }
358         } else {
359             printer.println("The new branch " + branch.name() + " is currently identical to the " + parent.name() + " branch.");
360         }
361 
362         var subject = newBranchSubject(repository, commits, parent, branch);
363         var finalAuthor = commits.size() > 0 ? commitToAuthor(commits.get(commits.size() - 1)) : sender;
364 
365         var email = Email.create(subject, writer.toString())
366                          .sender(sender)
367                          .author(finalAuthor)
368                          .recipient(recipient)
369                          .headers(headers)
370                          .build();
371         list.post(email);
372     }
373 }