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(), pr.targetRef());
116         } catch (IOException e) {
117             throw new UncheckedIOException(e);
118         }
119     }
120 
121     private final static Pattern commandPattern = Pattern.compile("^/.*$");
122 
123     private boolean ignoreComment(HostUser author, String body) {
124         if (pr.repository().forge().currentUser().equals(author)) {
125             return true;
126         }
127         if (bot.ignoredUsers().contains(author.userName())) {
128             return true;
129         }
130         var commandMatcher = commandPattern.matcher(body);
131         if (commandMatcher.matches()) {
132             return true;
133         }
134         for (var ignoredCommentPattern : bot.ignoredComments()) {
135             var ignoredCommentMatcher = ignoredCommentPattern.matcher(body);
136             if (ignoredCommentMatcher.find()) {
137                 return true;
138             }
139         }
140         return false;
141     }
142 
143     private static final String webrevCommentMarker = "<!-- mlbridge webrev comment -->";
144     private static final String webrevHeaderMarker = "<!-- mlbridge webrev header -->";
145     private static final String webrevListMarker = "<!-- mlbridge webrev list -->";
146 
147     private void updateWebrevComment(List<Comment> comments, int index, URI fullWebrev, URI incWebrev) {
148         var existing = comments.stream()
149                                .filter(comment -> comment.author().equals(pr.repository().forge().currentUser()))
150                                .filter(comment -> comment.body().contains(webrevCommentMarker))
151                                .findAny();
152         var comment = webrevCommentMarker + "\n";
153         comment += webrevHeaderMarker + "\n";
154         comment += "### Webrevs" + "\n";
155         comment += webrevListMarker + "\n";
156         comment += " * " + String.format("%02d", index) + ": [Full](" + fullWebrev.toString() + ")";
157         if (incWebrev != null) {
158             comment += " - [Incremental](" + incWebrev.toString() + ")";
159         }
160         comment += " (" + pr.headHash() + ")\n";
161 
162         if (existing.isPresent()) {
163             if (existing.get().body().contains(fullWebrev.toString())) {
164                 log.fine("Webrev link already posted - skipping update");
165                 return;
166             }
167             var previousListStart = existing.get().body().indexOf(webrevListMarker) + webrevListMarker.length() + 1;
168             var previousList = existing.get().body().substring(previousListStart);
169             comment += previousList;
170             pr.updateComment(existing.get().id(), comment);
171         } else {
172             pr.addComment(comment);
173         }
174     }
175 
176     private List<Email> parseArchive(MailingList archive) {
177         var conversations = archive.conversations(Duration.ofDays(365));
178 
179         if (conversations.size() == 0) {
180             return new ArrayList<>();
181         } else if (conversations.size() == 1) {
182             var conversation = conversations.get(0);
183             return conversation.allMessages();
184         } else {
185             throw new RuntimeException("Something is wrong with the mbox");
186         }
187     }
188 
189     @Override
190     public void run(Path scratchPath) {
191         var path = scratchPath.resolve("mlbridge");
192         var archiveRepo = materializeArchive(path);
193         var mboxBasePath = path.resolve(bot.codeRepo().name());
194         var mbox = MailingListServerFactory.createMboxFileServer(mboxBasePath);
195         var reviewArchiveList = mbox.getList(pr.id());
196         var sentMails = parseArchive(reviewArchiveList);
197 
198         // First determine if this PR should be inspected further or not
199         if (sentMails.isEmpty()) {
200             var labels = new HashSet<>(pr.labels());
201             for (var readyLabel : bot.readyLabels()) {
202                 if (!labels.contains(readyLabel)) {
203                     log.fine("PR is not yet ready - missing label '" + readyLabel + "'");
204                     return;
205                 }
206             }
207         }
208 
209         // Also inspect comments before making the first post
210         var comments = pr.comments();
211         if (sentMails.isEmpty()) {
212             for (var readyComment : bot.readyComments().entrySet()) {
213                 var commentFound = false;
214                 for (var comment : comments) {
215                     if (comment.author().userName().equals(readyComment.getKey())) {
216                         var matcher = readyComment.getValue().matcher(comment.body());
217                         if (matcher.find()) {
218                             commentFound = true;
219                             break;
220                         }
221                     }
222                 }
223                 if (!commentFound) {
224                     log.fine("PR is not yet ready - missing ready comment from '" + readyComment.getKey() +
225                                      "containing '" + readyComment.getValue().pattern() + "'");
226                     return;
227                 }
228             }
229         }
230 
231         var census = CensusInstance.create(bot.censusRepo(), bot.censusRef(), scratchPath.resolve("census"), pr);
232         var jbs = census.configuration().general().jbs();
233         if (jbs == null) {
234             jbs = census.configuration().general().project();
235         }
236         var prInstance = new PullRequestInstance(scratchPath.resolve("mlbridge-mergebase"), pr, bot.issueTracker(),
237                                                  jbs.toUpperCase());
238         var reviewArchive = new ReviewArchive(bot.emailAddress(), prInstance, census, sentMails);
239         var webrevPath = scratchPath.resolve("mlbridge-webrevs");
240         var listServer = MailingListServerFactory.createMailmanServer(bot.listArchive(), bot.smtpServer(), bot.sendInterval());
241         var list = listServer.getList(bot.listAddress().address());
242 
243         // First post
244         if (sentMails.isEmpty()) {
245             log.fine("Creating new PR review archive");
246             var webrev = bot.webrevStorage().createAndArchive(prInstance, webrevPath, prInstance.baseHash(),
247                                                               prInstance.headHash(), "00");
248             reviewArchive.create(webrev);
249             updateWebrevComment(comments, 0, webrev, null);
250         } else {
251             var latestHead = reviewArchive.latestHead();
252 
253             // Check if the head has changed
254             if (!pr.headHash().equals(latestHead)) {
255                 log.fine("Head hash change detected: current: " + pr.headHash() + " - last: " + latestHead);
256 
257                 var latestBase = reviewArchive.latestBase();
258                 if (!prInstance.baseHash().equals(latestBase)) {
259                     // FIXME: Could try harder to make an incremental
260                     var fullWebrev = bot.webrevStorage().createAndArchive(prInstance, webrevPath, prInstance.baseHash(),
261                                                                           prInstance.headHash(), String.format("%02d", reviewArchive.revisionCount()));
262                     reviewArchive.addFull(fullWebrev);
263                     updateWebrevComment(comments, reviewArchive.revisionCount(), fullWebrev, null);
264                 } else {
265                     var index = reviewArchive.revisionCount();
266                     var fullWebrev = bot.webrevStorage().createAndArchive(prInstance, webrevPath, prInstance.baseHash(),
267                                                                           prInstance.headHash(), String.format("%02d", index));
268                     var incrementalWebrev = bot.webrevStorage().createAndArchive(prInstance, webrevPath, latestHead,
269                                                                                  prInstance.headHash(), String.format("%02d-%02d", index - 1, index));
270                     reviewArchive.addIncremental(fullWebrev, incrementalWebrev);
271                     updateWebrevComment(comments, index, fullWebrev, incrementalWebrev);
272                 }
273             }
274         }
275 
276         // Regular comments
277         for (var comment : comments) {
278             if (ignoreComment(comment.author(), comment.body())) {
279                 continue;
280             }
281             reviewArchive.addComment(comment);
282         }
283 
284         // Review comments
285         var reviews = pr.reviews();
286         for (var review : reviews) {
287             if (ignoreComment(review.reviewer(), review.body().orElse(""))) {
288                 continue;
289             }
290             reviewArchive.addReview(review);
291         }
292 
293         // File specific comments
294         var reviewComments = pr.reviewComments();
295         for (var reviewComment : reviewComments) {
296             if (ignoreComment(reviewComment.author(), reviewComment.body())) {
297                 continue;
298             }
299             reviewArchive.addReviewComment(reviewComment);
300         }
301 
302         // Review verdict comments
303         for (var review : reviews) {
304             if (ignoreComment(review.reviewer(), review.body().orElse(""))) {
305                 continue;
306             }
307             reviewArchive.addReviewVerdict(review);
308         }
309 
310         var newMails = reviewArchive.generatedEmails();
311         if (newMails.isEmpty()) {
312             return;
313         }
314 
315         // Push all new mails to the archive repository
316         newMails.forEach(reviewArchiveList::post);
317         pushMbox(archiveRepo, "Adding comments for PR " + bot.codeRepo().name() + "/" + pr.id());
318 
319         // Finally post all new mails to the actual list
320         for (var newMail : newMails) {
321             var filteredHeaders = newMail.headers().stream()
322                                          .filter(header -> !header.startsWith("PR-"))
323                                          .collect(Collectors.toMap(Function.identity(),
324                                                                    newMail::headerValue));
325             var filteredEmail = Email.from(newMail)
326                                      .replaceHeaders(filteredHeaders)
327                                      .headers(bot.headers())
328                                      .build();
329             list.post(filteredEmail);
330         }
331     }
332 
333     @Override
334     public void handleRuntimeException(RuntimeException e) {
335         exceptionConsumer.accept(e);
336     }
337 }