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 }