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.mlbridge;
 24 
 25 import org.openjdk.skara.bot.WorkItem;
 26 import org.openjdk.skara.email.*;
 27 import org.openjdk.skara.forge.PullRequest;
 28 import org.openjdk.skara.host.*;
 29 import org.openjdk.skara.issuetracker.Comment;
 30 import org.openjdk.skara.mailinglist.*;
 31 import org.openjdk.skara.vcs.Repository;
 32 
 33 import java.io.*;
 34 import java.net.URI;
 35 import java.nio.file.Path;
 36 import java.time.Duration;
 37 import java.util.*;
 38 import java.util.function.*;
 39 import java.util.logging.Logger;
 40 import java.util.regex.Pattern;
 41 import java.util.stream.Collectors;
 42 
 43 class ArchiveWorkItem implements WorkItem {
 44     private final PullRequest pr;
 45     private final MailingListBridgeBot bot;
 46     private final Consumer<RuntimeException> exceptionConsumer;
 47     private final Logger log = Logger.getLogger("org.openjdk.skara.bots.mlbridge");
 48 
 49     ArchiveWorkItem(PullRequest pr, MailingListBridgeBot bot, Consumer<RuntimeException> exceptionConsumer) {
 50         this.pr = pr;
 51         this.bot = bot;
 52         this.exceptionConsumer = exceptionConsumer;
 53     }
 54 
 55     @Override
 56     public String toString() {
 57         return "ArchiveWorkItem@" + bot.codeRepo().name() + "#" + pr.id();
 58     }
 59 
 60     @Override
 61     public boolean concurrentWith(WorkItem other) {
 62         if (!(other instanceof ArchiveWorkItem)) {
 63             return true;
 64         }
 65         ArchiveWorkItem otherItem = (ArchiveWorkItem)other;
 66         if (!pr.id().equals(otherItem.pr.id())) {
 67             return true;
 68         }
 69         if (!bot.codeRepo().name().equals(otherItem.bot.codeRepo().name())) {
 70             return true;
 71         }
 72         return false;
 73     }
 74 
 75     private void pushMbox(Repository localRepo, String message) {
 76         try {
 77             localRepo.add(localRepo.root().resolve("."));
 78             var hash = localRepo.commit(message, bot.emailAddress().fullName().orElseThrow(), bot.emailAddress().address());
 79             localRepo.push(hash, bot.archiveRepo().url(), "master");
 80         } catch (IOException e) {
 81             throw new UncheckedIOException(e);
 82         }
 83     }
 84 
 85     private static final Pattern replyToPattern = Pattern.compile("^\\s*@([-A-Za-z0-9]+)");
 86 
 87     private Optional<Comment> getParentPost(Comment post, List<Comment> all) {
 88         var matcher = replyToPattern.matcher(post.body());
 89         if (matcher.find()) {
 90             var replyToName = matcher.group(1);
 91             var replyToNamePattern = Pattern.compile("^" + replyToName + "$");
 92 
 93             var postIterator = all.listIterator();
 94             while (postIterator.hasNext()) {
 95                 var cur = postIterator.next();
 96                 if (cur == post) {
 97                     break;
 98                 }
 99             }
100 
101             while (postIterator.hasPrevious()) {
102                 var cur = postIterator.previous();
103                 var userMatcher = replyToNamePattern.matcher(cur.author().userName());
104                 if (userMatcher.matches()) {
105                     return Optional.of(cur);
106                 }
107             }
108         }
109 
110         return Optional.empty();
111     }
112 
113     private Repository materializeArchive(Path scratchPath) {
114         try {
115             return Repository.materialize(scratchPath, bot.archiveRepo().url(),
116                                           "+" + bot.archiveRef() + ":mlbridge_archive");
117         } catch (IOException e) {
118             throw new UncheckedIOException(e);
119         }
120     }
121 
122     private final static Pattern commandPattern = Pattern.compile("^/.*$");
123 
124     private boolean ignoreComment(HostUser author, String body) {
125         if (pr.repository().forge().currentUser().equals(author)) {
126             return true;
127         }
128         if (bot.ignoredUsers().contains(author.userName())) {
129             return true;
130         }
131         var commandMatcher = commandPattern.matcher(body);
132         if (commandMatcher.matches()) {
133             return true;
134         }
135         for (var ignoredCommentPattern : bot.ignoredComments()) {
136             var ignoredCommentMatcher = ignoredCommentPattern.matcher(body);
137             if (ignoredCommentMatcher.find()) {
138                 return true;
139             }
140         }
141         return false;
142     }
143 
144     private static final String webrevCommentMarker = "<!-- mlbridge webrev comment -->";
145     private static final String webrevHeaderMarker = "<!-- mlbridge webrev header -->";
146     private static final String webrevListMarker = "<!-- mlbridge webrev list -->";
147 
148     private void updateWebrevComment(List<Comment> comments, int index, URI fullWebrev, URI incWebrev) {
149         var existing = comments.stream()
150                                .filter(comment -> comment.author().equals(pr.repository().forge().currentUser()))
151                                .filter(comment -> comment.body().contains(webrevCommentMarker))
152                                .findAny();
153         var comment = webrevCommentMarker + "\n";
154         comment += webrevHeaderMarker + "\n";
155         comment += "### Webrevs" + "\n";
156         comment += webrevListMarker + "\n";
157         comment += " * " + String.format("%02d", index) + ": [Full](" + fullWebrev.toString() + ")";
158         if (incWebrev != null) {
159             comment += " - [Incremental](" + incWebrev.toString() + ")";
160         }
161         comment += " (" + pr.headHash() + ")\n";
162 
163         if (existing.isPresent()) {
164             if (existing.get().body().contains(fullWebrev.toString())) {
165                 log.fine("Webrev link already posted - skipping update");
166                 return;
167             }
168             var previousListStart = existing.get().body().indexOf(webrevListMarker) + webrevListMarker.length() + 1;
169             var previousList = existing.get().body().substring(previousListStart);
170             comment += previousList;
171             pr.updateComment(existing.get().id(), comment);
172         } else {
173             pr.addComment(comment);
174         }
175     }
176 
177     private List<Email> parseArchive(MailingList archive) {
178         var conversations = archive.conversations(Duration.ofDays(365));
179 
180         if (conversations.size() == 0) {
181             return new ArrayList<>();
182         } else if (conversations.size() == 1) {
183             var conversation = conversations.get(0);
184             return conversation.allMessages();
185         } else {
186             throw new RuntimeException("Something is wrong with the mbox");
187         }
188     }
189 
190     @Override
191     public void run(Path scratchPath) {
192         var path = scratchPath.resolve("mlbridge");
193         var archiveRepo = materializeArchive(path);
194         var mboxBasePath = path.resolve(bot.codeRepo().name());
195         var mbox = MailingListServerFactory.createMboxFileServer(mboxBasePath);
196         var reviewArchiveList = mbox.getList(pr.id());
197         var sentMails = parseArchive(reviewArchiveList);
198 
199         // First determine if this PR should be inspected further or not
200         if (sentMails.isEmpty()) {
201             var labels = new HashSet<>(pr.labels());
202             for (var readyLabel : bot.readyLabels()) {
203                 if (!labels.contains(readyLabel)) {
204                     log.fine("PR is not yet ready - missing label '" + readyLabel + "'");
205                     return;
206                 }
207             }
208         }
209 
210         // Also inspect comments before making the first post
211         var comments = pr.comments();
212         if (sentMails.isEmpty()) {
213             for (var readyComment : bot.readyComments().entrySet()) {
214                 var commentFound = false;
215                 for (var comment : comments) {
216                     if (comment.author().userName().equals(readyComment.getKey())) {
217                         var matcher = readyComment.getValue().matcher(comment.body());
218                         if (matcher.find()) {
219                             commentFound = true;
220                             break;
221                         }
222                     }
223                 }
224                 if (!commentFound) {
225                     log.fine("PR is not yet ready - missing ready comment from '" + readyComment.getKey() +
226                                      "containing '" + readyComment.getValue().pattern() + "'");
227                     return;
228                 }
229             }
230         }
231 
232         var census = CensusInstance.create(bot.censusRepo(), bot.censusRef(), scratchPath.resolve("census"), pr);
233         var jbs = census.configuration().general().jbs();
234         if (jbs == null) {
235             jbs = census.configuration().general().project();
236         }
237         var prInstance = new PullRequestInstance(scratchPath.resolve("mlbridge-mergebase"), pr, bot.issueTracker(),
238                                                  jbs.toUpperCase());
239         var reviewArchive = new ReviewArchive(bot.emailAddress(), prInstance, census, sentMails);
240         var webrevPath = scratchPath.resolve("mlbridge-webrevs");
241         var listServer = MailingListServerFactory.createMailmanServer(bot.listArchive(), bot.smtpServer(), bot.sendInterval());
242         var list = listServer.getList(bot.listAddress().address());
243 
244         // First post
245         if (sentMails.isEmpty()) {
246             log.fine("Creating new PR review archive");
247             var webrev = bot.webrevStorage().createAndArchive(prInstance, webrevPath, prInstance.baseHash(),
248                                                               prInstance.headHash(), "00");
249             reviewArchive.create(webrev);
250             updateWebrevComment(comments, 0, webrev, null);
251         } else {
252             var latestHead = reviewArchive.latestHead();
253 
254             // Check if the head has changed
255             if (!pr.headHash().equals(latestHead)) {
256                 log.fine("Head hash change detected: current: " + pr.headHash() + " - last: " + latestHead);
257 
258                 var latestBase = reviewArchive.latestBase();
259                 if (!prInstance.baseHash().equals(latestBase)) {
260                     // FIXME: Could try harder to make an incremental
261                     var fullWebrev = bot.webrevStorage().createAndArchive(prInstance, webrevPath, prInstance.baseHash(),
262                                                                           prInstance.headHash(), String.format("%02d", reviewArchive.revisionCount()));
263                     reviewArchive.addFull(fullWebrev);
264                     updateWebrevComment(comments, reviewArchive.revisionCount(), fullWebrev, null);
265                 } else {
266                     var index = reviewArchive.revisionCount();
267                     var fullWebrev = bot.webrevStorage().createAndArchive(prInstance, webrevPath, prInstance.baseHash(),
268                                                                           prInstance.headHash(), String.format("%02d", index));
269                     var incrementalWebrev = bot.webrevStorage().createAndArchive(prInstance, webrevPath, latestHead,
270                                                                                  prInstance.headHash(), String.format("%02d-%02d", index - 1, index));
271                     reviewArchive.addIncremental(fullWebrev, incrementalWebrev);
272                     updateWebrevComment(comments, index, fullWebrev, incrementalWebrev);
273                 }
274             }
275         }
276 
277         // Regular comments
278         for (var comment : comments) {
279             if (ignoreComment(comment.author(), comment.body())) {
280                 continue;
281             }
282             reviewArchive.addComment(comment);
283         }
284 
285         // Review comments
286         var reviews = pr.reviews();
287         for (var review : reviews) {
288             if (ignoreComment(review.reviewer(), review.body().orElse(""))) {
289                 continue;
290             }
291             reviewArchive.addReview(review);
292         }
293 
294         // File specific comments
295         var reviewComments = pr.reviewComments();
296         for (var reviewComment : reviewComments) {
297             if (ignoreComment(reviewComment.author(), reviewComment.body())) {
298                 continue;
299             }
300             reviewArchive.addReviewComment(reviewComment);
301         }
302 
303         // Review verdict comments
304         for (var review : reviews) {
305             if (ignoreComment(review.reviewer(), review.body().orElse(""))) {
306                 continue;
307             }
308             reviewArchive.addReviewVerdict(review);
309         }
310 
311         var newMails = reviewArchive.generatedEmails();
312         if (newMails.isEmpty()) {
313             return;
314         }
315 
316         // Push all new mails to the archive repository
317         newMails.forEach(reviewArchiveList::post);
318         pushMbox(archiveRepo, "Adding comments for PR " + bot.codeRepo().name() + "/" + pr.id());
319 
320         // Finally post all new mails to the actual list
321         for (var newMail : newMails) {
322             var filteredHeaders = newMail.headers().stream()
323                                          .filter(header -> !header.startsWith("PR-"))
324                                          .collect(Collectors.toMap(Function.identity(),
325                                                                    newMail::headerValue));
326             var filteredEmail = Email.from(newMail)
327                                      .replaceHeaders(filteredHeaders)
328                                      .headers(bot.headers())
329                                      .build();
330             list.post(filteredEmail);
331         }
332     }
333 
334     @Override
335     public void handleRuntimeException(RuntimeException e) {
336         exceptionConsumer.accept(e);
337     }
338 }