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.email.EmailAddress;
  26 import org.openjdk.skara.host.*;
  27 import org.openjdk.skara.host.network.URIBuilder;
  28 import org.openjdk.skara.mailinglist.MailingListServerFactory;
  29 import org.openjdk.skara.test.*;
  30 import org.openjdk.skara.vcs.Repository;
  31 
  32 import org.junit.jupiter.api.*;
  33 
  34 import java.io.IOException;
  35 import java.nio.charset.StandardCharsets;
  36 import java.nio.file.*;
  37 import java.time.Duration;
  38 import java.util.*;
  39 import java.util.regex.Pattern;
  40 import java.util.stream.Collectors;
  41 
  42 import static org.junit.jupiter.api.Assertions.*;
  43 
  44 class MailingListBridgeBotTests {
  45     private boolean archiveContains(Path archive, String text) {
  46         return archiveContainsCount(archive, text) > 0;
  47     }
  48 
  49     private int archiveContainsCount(Path archive, String text) {
  50         try {
  51             var mbox = Files.find(archive, 50, (path, attrs) -> path.toString().endsWith(".mbox")).findAny();
  52             if (mbox.isEmpty()) {
  53                 return 0;
  54             }
  55             var lines = Files.readString(mbox.get(), StandardCharsets.UTF_8);
  56             var pattern = Pattern.compile(text);
  57             int count = 0;
  58             for (var line : lines.split("\\R")) {
  59                 var matcher = pattern.matcher(line);
  60                 if (matcher.find()) {
  61                     count++;
  62                 }
  63             }
  64             return count;
  65         } catch (IOException e) {
  66             return 0;
  67         }
  68     }
  69 
  70     private boolean webrevContains(Path webrev, String text) {
  71         try {
  72             var index = Files.find(webrev, 5, (path, attrs) -> path.toString().endsWith("index.html")).findAny();
  73             if (index.isEmpty()) {
  74                 return false;
  75             }
  76             var lines = Files.readString(index.get(), StandardCharsets.UTF_8);
  77             return lines.contains(text);
  78         } catch (IOException e) {
  79             return false;
  80         }
  81     }
  82 
  83     private long countSubstrings(String string, String substring) {
  84         return Pattern.compile(substring).matcher(string).results().count();
  85     }
  86 
  87     private String noreplyAddress(HostedRepository repository) {
  88         return repository.host().getCurrentUserDetails().id() + "+" +
  89                 repository.host().getCurrentUserDetails().userName() +
  90                 "@users.noreply.test";
  91     }
  92 
  93     @Test
  94     void simpleArchive(TestInfo testInfo) throws IOException {
  95         try (var credentials = new HostCredentials(testInfo);
  96              var tempFolder = new TemporaryDirectory();
  97              var archiveFolder = new TemporaryDirectory();
  98              var webrevFolder = new TemporaryDirectory();
  99              var listServer = new TestMailmanServer()) {
 100             var author = credentials.getHostedRepository();
 101             var archive = credentials.getHostedRepository();
 102             var ignored = credentials.getHostedRepository();
 103             var listAddress = EmailAddress.parse(listServer.createList("test"));
 104             var censusBuilder = credentials.getCensusBuilder()
 105                                            .addAuthor(author.host().getCurrentUserDetails().id());
 106             var from = EmailAddress.from("test", "test@test.mail");
 107             var mlBot = new MailingListBridgeBot(from, author, archive, censusBuilder.build(), "master", listAddress,
 108                                                  Set.of(ignored.host().getCurrentUserDetails().userName()),
 109                                                  Set.of(),
 110                                                  listServer.getArchive(), listServer.getSMTP(),
 111                                                  archive, "webrev", Path.of("test"),
 112                                                  URIBuilder.base("http://www.test.test/").build(),
 113                                                  Set.of("rfr"), Map.of(ignored.host().getCurrentUserDetails().userName(),
 114                                                                        Pattern.compile("ready")),
 115                                                  URIBuilder.base("http://issues.test/browse/").build());
 116 
 117             // Populate the projects repository
 118             var localRepo = CheckableRepository.init(tempFolder.path(), author.getRepositoryType());
 119             var masterHash = localRepo.resolve("master").orElseThrow();
 120             localRepo.push(masterHash, author.getUrl(), "master", true);
 121             localRepo.push(masterHash, archive.getUrl(), "webrev", true);
 122 
 123             // Make a change with a corresponding PR
 124             var editHash = CheckableRepository.appendAndCommit(localRepo, "A simple change",
 125                                                                "Change msg\n\nWith several lines");
 126             localRepo.push(editHash, author.getUrl(), "edit", true);
 127             var pr = credentials.createPullRequest(archive, "master", "edit", "1234: This is a pull request");
 128             pr.setBody("This should not be ready");
 129 
 130             // Run an archive pass
 131             TestBotRunner.runPeriodicItems(mlBot);
 132 
 133             // A PR that isn't ready for review should not be archived
 134             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 135             assertFalse(archiveContains(archiveFolder.path(), "This is a pull request"));
 136 
 137             // Flag it as ready for review
 138             pr.setBody("This should now be ready");
 139             pr.addLabel("rfr");
 140 
 141             // Run another archive pass
 142             TestBotRunner.runPeriodicItems(mlBot);
 143 
 144             // But it should still not be archived
 145             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 146             assertFalse(archiveContains(archiveFolder.path(), "This is a pull request"));
 147 
 148             // Now post a general comment - not a ready marker
 149             var ignoredPr = ignored.getPullRequest(pr.getId());
 150             ignoredPr.addComment("hello there");
 151 
 152             // Run another archive pass
 153             TestBotRunner.runPeriodicItems(mlBot);
 154 
 155             // It should still not be archived
 156             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 157             assertFalse(archiveContains(archiveFolder.path(), "This is a pull request"));
 158 
 159             // Now post a ready comment
 160             ignoredPr.addComment("ready");
 161 
 162             // Run another archive pass
 163             TestBotRunner.runPeriodicItems(mlBot);
 164 
 165             // The archive should now contain an entry
 166             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 167             assertTrue(archiveContains(archiveFolder.path(), "This is a pull request"));
 168             assertTrue(archiveContains(archiveFolder.path(), "This should now be ready"));
 169             assertTrue(archiveContains(archiveFolder.path(), "Patch:"));
 170             assertTrue(archiveContains(archiveFolder.path(), "Changes:"));
 171             assertTrue(archiveContains(archiveFolder.path(), "Webrev:"));
 172             assertTrue(archiveContains(archiveFolder.path(), "http://www.test.test/"));
 173             assertTrue(archiveContains(archiveFolder.path(), "webrev.00"));
 174             assertTrue(archiveContains(archiveFolder.path(), "Issue:"));
 175             assertTrue(archiveContains(archiveFolder.path(), "http://issues.test/browse/TSTPRJ-1234"));
 176             assertTrue(archiveContains(archiveFolder.path(), "Fetch:"));
 177             assertTrue(archiveContains(archiveFolder.path(), "^ - " + editHash.abbreviate() + ": Change msg"));
 178             assertFalse(archiveContains(archiveFolder.path(), "With several lines"));
 179 
 180             // The mailing list as well
 181             listServer.processIncoming();
 182             var mailmanServer = MailingListServerFactory.createMailmanServer(listServer.getArchive(), listServer.getSMTP());
 183             var mailmanList = mailmanServer.getList(listAddress.address());
 184             var conversations = mailmanList.conversations(Duration.ofDays(1));
 185             assertEquals(1, conversations.size());
 186             var mail = conversations.get(0).first();
 187             assertEquals("RFR: 1234: This is a pull request", mail.subject());
 188             assertEquals(pr.getAuthor().fullName(), mail.author().fullName().orElseThrow());
 189             assertEquals(noreplyAddress(archive), mail.author().address());
 190             assertEquals(from, mail.sender());
 191 
 192             // And there should be a webrev
 193             Repository.materialize(webrevFolder.path(), archive.getUrl(), "webrev");
 194             assertTrue(webrevContains(webrevFolder.path(), "1 lines changed"));
 195             var comments = pr.getComments();
 196             var webrevComments = comments.stream()
 197                                          .filter(comment -> comment.author().equals(author.host().getCurrentUserDetails()))
 198                                          .filter(comment -> comment.body().contains("webrev"))
 199                                          .filter(comment -> comment.body().contains(editHash.hex()))
 200                                          .collect(Collectors.toList());
 201             assertEquals(1, webrevComments.size());
 202 
 203             // Add a comment
 204             pr.addComment("This is a comment :smile:");
 205 
 206             // Add a comment from an ignored user as well
 207             ignoredPr.addComment("Don't mind me");
 208 
 209             // Run another archive pass
 210             TestBotRunner.runPeriodicItems(mlBot);
 211 
 212             // The archive should now contain the comment, but not the ignored one
 213             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 214             assertTrue(archiveContains(archiveFolder.path(), "This is a comment"));
 215             assertTrue(archiveContains(archiveFolder.path(), "> This should now be ready"));
 216             assertFalse(archiveContains(archiveFolder.path(), "Don't mind me"));
 217 
 218             listServer.processIncoming();
 219             conversations = mailmanList.conversations(Duration.ofDays(1));
 220             assertEquals(1, conversations.size());
 221             assertEquals(2, conversations.get(0).allMessages().size());
 222 
 223             // Remove the rfr flag and post another comment
 224             pr.addLabel("rfr");
 225             pr.addComment("This is another comment");
 226 
 227             // Run another archive pass
 228             TestBotRunner.runPeriodicItems(mlBot);
 229 
 230             // The archive should contain the additional comment
 231             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 232             assertTrue(archiveContains(archiveFolder.path(), "This is another comment"));
 233             assertTrue(archiveContains(archiveFolder.path(), ">> This should now be ready"));
 234 
 235             listServer.processIncoming();
 236             conversations = mailmanList.conversations(Duration.ofDays(1));
 237             assertEquals(1, conversations.size());
 238             assertEquals(3, conversations.get(0).allMessages().size());
 239             for (var newMail : conversations.get(0).allMessages()) {
 240                 assertEquals(noreplyAddress(archive), newMail.author().address());
 241                 assertEquals(from, newMail.sender());
 242             }
 243             assertTrue(conversations.get(0).allMessages().get(2).body().contains("This is a comment 😄"));
 244         }
 245     }
 246 
 247     @Test
 248     void reviewComment(TestInfo testInfo) throws IOException {
 249         try (var credentials = new HostCredentials(testInfo);
 250              var tempFolder = new TemporaryDirectory();
 251              var archiveFolder = new TemporaryDirectory();
 252              var listServer = new TestMailmanServer()) {
 253             var author = credentials.getHostedRepository();
 254             var archive = credentials.getHostedRepository();
 255             var ignored = credentials.getHostedRepository();
 256             var listAddress = EmailAddress.parse(listServer.createList("test"));
 257             var censusBuilder = credentials.getCensusBuilder()
 258                                            .addAuthor(author.host().getCurrentUserDetails().id());
 259             var from = EmailAddress.from("test", "test@test.mail");
 260             var mlBot = new MailingListBridgeBot(from, author, archive, censusBuilder.build(), "master", listAddress,
 261                                                  Set.of(ignored.host().getCurrentUserDetails().userName()),
 262                                                  Set.of(),
 263                                                  listServer.getArchive(), listServer.getSMTP(),
 264                                                  archive, "webrev", Path.of("test"),
 265                                                  URIBuilder.base("http://www.test.test/").build(),
 266                                                  Set.of(), Map.of(),
 267                                                  URIBuilder.base("http://issues.test/browse/").build());
 268 
 269             // Populate the projects repository
 270             var reviewFile = Path.of("reviewfile.txt");
 271             var localRepo = CheckableRepository.init(tempFolder.path(), author.getRepositoryType(), reviewFile);
 272             var masterHash = localRepo.resolve("master").orElseThrow();
 273             localRepo.push(masterHash, author.getUrl(), "master", true);
 274             localRepo.push(masterHash, archive.getUrl(), "webrev", true);
 275 
 276             // Make a change with a corresponding PR
 277             var editHash = CheckableRepository.appendAndCommit(localRepo);
 278             localRepo.push(editHash, author.getUrl(), "edit", true);
 279             var pr = credentials.createPullRequest(archive, "master", "edit", "This is a pull request");
 280             pr.setBody("This is now ready");
 281             TestBotRunner.runPeriodicItems(mlBot);
 282             listServer.processIncoming();
 283 
 284             // And make a file specific comment
 285             var currentMaster = localRepo.resolve("master").orElseThrow();
 286             var comment = pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, "Review comment");
 287 
 288             // Add one from an ignored user as well
 289             var ignoredPr = ignored.getPullRequest(pr.getId());
 290             ignoredPr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, "Don't mind me");
 291 
 292             // Process comments
 293             TestBotRunner.runPeriodicItems(mlBot);
 294             listServer.processIncoming();
 295 
 296             // The archive should now contain an entry
 297             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 298             assertTrue(archiveContains(archiveFolder.path(), "This is a pull request"));
 299             assertTrue(archiveContains(archiveFolder.path(), "This is now ready"));
 300             assertTrue(archiveContains(archiveFolder.path(), "Review comment"));
 301             assertTrue(archiveContains(archiveFolder.path(), "> This is now ready"));
 302             assertTrue(archiveContains(archiveFolder.path(), reviewFile.toString()));
 303             assertFalse(archiveContains(archiveFolder.path(), "Don't mind me"));
 304 
 305             // The mailing list as well
 306             var mailmanServer = MailingListServerFactory.createMailmanServer(listServer.getArchive(), listServer.getSMTP());
 307             var mailmanList = mailmanServer.getList(listAddress.address());
 308             var conversations = mailmanList.conversations(Duration.ofDays(1));
 309             assertEquals(1, conversations.size());
 310             var mail = conversations.get(0).first();
 311             assertEquals("RFR: This is a pull request", mail.subject());
 312 
 313             // Comment on the comment
 314             pr.addReviewCommentReply(comment, "This is a review reply");
 315             TestBotRunner.runPeriodicItems(mlBot);
 316             listServer.processIncoming();
 317 
 318             // The archive should contain the additional comment (but no quoted footers)
 319             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 320             assertTrue(archiveContains(archiveFolder.path(), "This is a review reply"));
 321             assertTrue(archiveContains(archiveFolder.path(), ">> This is now ready"));
 322             assertFalse(archiveContains(archiveFolder.path(), "^> PR:"));
 323 
 324             // As well as the mailing list
 325             conversations = mailmanList.conversations(Duration.ofDays(1));
 326             assertEquals(1, conversations.size());
 327             assertEquals(3, conversations.get(0).allMessages().size());
 328             for (var newMail : conversations.get(0).allMessages()) {
 329                 assertEquals(noreplyAddress(archive), newMail.author().address());
 330                 assertEquals(from, newMail.sender());
 331             }
 332         }
 333     }
 334 
 335     @Test
 336     void combineComments(TestInfo testInfo) throws IOException {
 337         try (var credentials = new HostCredentials(testInfo);
 338              var tempFolder = new TemporaryDirectory();
 339              var archiveFolder = new TemporaryDirectory();
 340              var listServer = new TestMailmanServer()) {
 341             var author = credentials.getHostedRepository();
 342             var archive = credentials.getHostedRepository();
 343             var listAddress = EmailAddress.parse(listServer.createList("test"));
 344             var censusBuilder = credentials.getCensusBuilder()
 345                                            .addAuthor(author.host().getCurrentUserDetails().id());
 346             var from = EmailAddress.from("test", "test@test.mail");
 347             var mlBot = new MailingListBridgeBot(from, author, archive, censusBuilder.build(), "master",
 348                                                  listAddress, Set.of(), Set.of(),
 349                                                  listServer.getArchive(),
 350                                                  listServer.getSMTP(),
 351                                                  archive, "webrev", Path.of("test"),
 352                                                  URIBuilder.base("http://www.test.test/").build(),
 353                                                  Set.of(), Map.of(),
 354                                                  URIBuilder.base("http://issues.test/browse/").build());
 355 
 356             // Populate the projects repository
 357             var reviewFile = Path.of("reviewfile.txt");
 358             var localRepo = CheckableRepository.init(tempFolder.path(), author.getRepositoryType(), reviewFile);
 359             var masterHash = localRepo.resolve("master").orElseThrow();
 360             localRepo.push(masterHash, author.getUrl(), "master", true);
 361             localRepo.push(masterHash, archive.getUrl(), "webrev", true);
 362 
 363             // Make a change with a corresponding PR
 364             var editHash = CheckableRepository.appendAndCommit(localRepo);
 365             localRepo.push(editHash, author.getUrl(), "edit", true);
 366             var pr = credentials.createPullRequest(archive, "master", "edit", "This is a pull request");
 367             pr.setBody("This is now ready");
 368             pr.addComment("Avoid combining");
 369 
 370             TestBotRunner.runPeriodicItems(mlBot);
 371             listServer.processIncoming();
 372             listServer.processIncoming();
 373 
 374             // Make two file specific comments
 375             pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, "Review comment");
 376             pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, "Another review comment");
 377             TestBotRunner.runPeriodicItems(mlBot);
 378             listServer.processIncoming();
 379 
 380             // The archive should contain a combined entry
 381             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 382             assertEquals(2, archiveContainsCount(archiveFolder.path(), "^On.*wrote:"));
 383 
 384             // As well as the mailing list
 385             var mailmanServer = MailingListServerFactory.createMailmanServer(listServer.getArchive(), listServer.getSMTP());
 386             var mailmanList = mailmanServer.getList(listAddress.address());
 387             var conversations = mailmanList.conversations(Duration.ofDays(1));
 388             assertEquals(1, conversations.size());
 389             var mail = conversations.get(0).first();
 390             assertEquals("RFR: This is a pull request", mail.subject());
 391             assertEquals(3, conversations.get(0).allMessages().size());
 392 
 393             var commentReply = conversations.get(0).replies(mail).get(0);
 394             assertEquals(2, commentReply.body().split("^On.*wrote:").length);
 395             assertTrue(commentReply.body().contains("Avoid combining\n\n"), commentReply.body());
 396 
 397             var reviewReply = conversations.get(0).replies(mail).get(1);
 398             assertEquals(2, reviewReply.body().split("^On.*wrote:").length);
 399             assertEquals(2, reviewReply.body().split("> This is now ready").length, reviewReply.body());
 400             assertEquals("Re: RFR: This is a pull request", reviewReply.subject());
 401             assertTrue(reviewReply.body().contains("Review comment\n\n"), reviewReply.body());
 402             assertTrue(reviewReply.body().contains("Another review comment"), reviewReply.body());
 403         }
 404     }
 405 
 406     @Test
 407     void commentThreading(TestInfo testInfo) throws IOException {
 408         try (var credentials = new HostCredentials(testInfo);
 409              var tempFolder = new TemporaryDirectory();
 410              var archiveFolder = new TemporaryDirectory();
 411              var listServer = new TestMailmanServer()) {
 412             var author = credentials.getHostedRepository();
 413             var reviewer = credentials.getHostedRepository();
 414             var archive = credentials.getHostedRepository();
 415             var listAddress = EmailAddress.parse(listServer.createList("test"));
 416             var censusBuilder = credentials.getCensusBuilder()
 417                                            .addReviewer(reviewer.host().getCurrentUserDetails().id())
 418                                            .addAuthor(author.host().getCurrentUserDetails().id());
 419             var from = EmailAddress.from("test", "test@test.mail");
 420             var mlBot = new MailingListBridgeBot(from, author, archive, censusBuilder.build(), "master",
 421                                                  listAddress, Set.of(), Set.of(),
 422                                                  listServer.getArchive(),
 423                                                  listServer.getSMTP(),
 424                                                  archive, "webrev", Path.of("test"),
 425                                                  URIBuilder.base("http://www.test.test/").build(),
 426                                                  Set.of(), Map.of(),
 427                                                  URIBuilder.base("http://issues.test/browse/").build());
 428 
 429             // Populate the projects repository
 430             var reviewFile = Path.of("reviewfile.txt");
 431             var localRepo = CheckableRepository.init(tempFolder.path(), author.getRepositoryType(), reviewFile);
 432             var masterHash = localRepo.resolve("master").orElseThrow();
 433             localRepo.push(masterHash, author.getUrl(), "master", true);
 434             localRepo.push(masterHash, archive.getUrl(), "webrev", true);
 435 
 436             // Make a change with a corresponding PR
 437             var editHash = CheckableRepository.appendAndCommit(localRepo);
 438             localRepo.push(editHash, author.getUrl(), "edit", true);
 439             var pr = credentials.createPullRequest(archive, "master", "edit", "This is a pull request");
 440             pr.setBody("This is now ready");
 441             TestBotRunner.runPeriodicItems(mlBot);
 442             listServer.processIncoming();
 443 
 444             // Make a file specific comment
 445             var reviewPr = reviewer.getPullRequest(pr.getId());
 446             var comment1 = reviewPr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, "Review comment");
 447             pr.addReviewCommentReply(comment1, "I agree");
 448             reviewPr.addReviewCommentReply(comment1, "Great");
 449             TestBotRunner.runPeriodicItems(mlBot);
 450             listServer.processIncoming();
 451             listServer.processIncoming();
 452             listServer.processIncoming();
 453 
 454             // And a second one by ourselves
 455             var comment2 = pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, "Another review comment");
 456             reviewPr.addReviewCommentReply(comment2, "Sounds good");
 457             pr.addReviewCommentReply(comment2, "Thanks");
 458             TestBotRunner.runPeriodicItems(mlBot);
 459             listServer.processIncoming();
 460             listServer.processIncoming();
 461             listServer.processIncoming();
 462 
 463             // Finally some approvals
 464             pr.addReview(Review.Verdict.APPROVED, "Nice");
 465             reviewPr.addReview(Review.Verdict.APPROVED, "Looks fine");
 466             TestBotRunner.runPeriodicItems(mlBot);
 467             listServer.processIncoming();
 468             listServer.processIncoming();
 469 
 470             // Sanity check the archive
 471             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 472             assertEquals(8, archiveContainsCount(archiveFolder.path(), "^On.*wrote:"));
 473 
 474             // Check the mailing list
 475             var mailmanServer = MailingListServerFactory.createMailmanServer(listServer.getArchive(), listServer.getSMTP());
 476             var mailmanList = mailmanServer.getList(listAddress.address());
 477             var conversations = mailmanList.conversations(Duration.ofDays(1));
 478             assertEquals(1, conversations.size());
 479             var mail = conversations.get(0).first();
 480             assertEquals("RFR: This is a pull request", mail.subject());
 481             assertEquals(9, conversations.get(0).allMessages().size());
 482 
 483             // There should be four separate threads
 484             var thread1 = conversations.get(0).replies(mail).get(0);
 485             assertEquals(2, thread1.body().split("^On.*wrote:").length);
 486             assertEquals(2, thread1.body().split("> This is now ready").length, thread1.body());
 487             assertEquals("Re: RFR: This is a pull request", thread1.subject());
 488             assertTrue(thread1.body().contains("Review comment\n\n"), thread1.body());
 489             assertFalse(thread1.body().contains("Another review comment"), thread1.body());
 490             var thread1reply1 = conversations.get(0).replies(thread1).get(0);
 491             assertTrue(thread1reply1.body().contains("I agree"));
 492             assertEquals(noreplyAddress(archive), thread1reply1.author().address());
 493             assertEquals(archive.host().getCurrentUserDetails().fullName(), thread1reply1.author().fullName().orElseThrow());
 494             var thread1reply2 = conversations.get(0).replies(thread1reply1).get(0);
 495             assertTrue(thread1reply2.body().contains("Great"));
 496             assertEquals("integrationreviewer1@openjdk.java.net", thread1reply2.author().address());
 497             assertEquals("Generated Reviewer 1", thread1reply2.author().fullName().orElseThrow());
 498 
 499             var thread2 = conversations.get(0).replies(mail).get(1);
 500             assertEquals(2, thread2.body().split("^On.*wrote:").length);
 501             assertEquals(2, thread2.body().split("> This is now ready").length, thread2.body());
 502             assertEquals("Re: RFR: This is a pull request", thread2.subject());
 503             assertFalse(thread2.body().contains("Review comment\n\n"), thread2.body());
 504             assertTrue(thread2.body().contains("Another review comment"), thread2.body());
 505             var thread2reply1 = conversations.get(0).replies(thread2).get(0);
 506             assertTrue(thread2reply1.body().contains("Sounds good"));
 507             var thread2reply2 = conversations.get(0).replies(thread2reply1).get(0);
 508             assertTrue(thread2reply2.body().contains("Thanks"));
 509 
 510             var thread3 = conversations.get(0).replies(mail).get(2);
 511             assertEquals("Re: RFR: This is a pull request", thread3.subject());
 512             var thread4 = conversations.get(0).replies(mail).get(3);
 513             assertEquals("Re: [Approved] RFR: This is a pull request", thread4.subject());
 514         }
 515     }
 516 
 517     @Test
 518     void reviewContext(TestInfo testInfo) throws IOException {
 519         try (var credentials = new HostCredentials(testInfo);
 520              var tempFolder = new TemporaryDirectory();
 521              var archiveFolder = new TemporaryDirectory();
 522              var listServer = new TestMailmanServer()) {
 523             var author = credentials.getHostedRepository();
 524             var archive = credentials.getHostedRepository();
 525             var listAddress = EmailAddress.parse(listServer.createList("test"));
 526             var censusBuilder = credentials.getCensusBuilder()
 527                                            .addAuthor(author.host().getCurrentUserDetails().id());
 528             var from = EmailAddress.from("test", "test@test.mail");
 529             var mlBot = new MailingListBridgeBot(from, author, archive, censusBuilder.build(), "master",
 530                                                  listAddress, Set.of(), Set.of(),
 531                                                  listServer.getArchive(),
 532                                                  listServer.getSMTP(),
 533                                                  archive, "webrev", Path.of("test"),
 534                                                  URIBuilder.base("http://www.test.test/").build(),
 535                                                  Set.of(), Map.of(),
 536                                                  URIBuilder.base("http://issues.test/browse/").build());
 537 
 538             // Populate the projects repository
 539             var reviewFile = Path.of("reviewfile.txt");
 540             var localRepo = CheckableRepository.init(tempFolder.path(), author.getRepositoryType(), reviewFile);
 541             var masterHash = localRepo.resolve("master").orElseThrow();
 542             localRepo.push(masterHash, author.getUrl(), "master", true);
 543             localRepo.push(masterHash, archive.getUrl(), "webrev", true);
 544 
 545             // Make a change with a corresponding PR
 546             var editHash = CheckableRepository.appendAndCommit(localRepo, "Line 1\nLine 2\nLine 3\nLine 4");
 547             localRepo.push(editHash, author.getUrl(), "edit", true);
 548             var pr = credentials.createPullRequest(archive, "master", "edit", "This is a pull request");
 549             pr.setBody("This is now ready");
 550             TestBotRunner.runPeriodicItems(mlBot);
 551             listServer.processIncoming();
 552 
 553             // Make a file specific comment
 554             pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, "Review comment");
 555 
 556             TestBotRunner.runPeriodicItems(mlBot);
 557             listServer.processIncoming();
 558 
 559             // The archive should only contain context around line 2
 560             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 561             assertTrue(archiveContains(archiveFolder.path(), "^> 2: Line 1$"));
 562             assertTrue(archiveContains(archiveFolder.path(), "^> 3: Line 2$"));
 563             assertFalse(archiveContains(archiveFolder.path(), "^> 4: Line 3$"));
 564         }
 565     }
 566 
 567     @Test
 568     void multipleReviewContexts(TestInfo testInfo) throws IOException {
 569         try (var credentials = new HostCredentials(testInfo);
 570              var tempFolder = new TemporaryDirectory();
 571              var archiveFolder = new TemporaryDirectory();
 572              var listServer = new TestMailmanServer()) {
 573             var author = credentials.getHostedRepository();
 574             var archive = credentials.getHostedRepository();
 575             var listAddress = EmailAddress.parse(listServer.createList("test"));
 576             var censusBuilder = credentials.getCensusBuilder()
 577                                            .addAuthor(author.host().getCurrentUserDetails().id());
 578             var from = EmailAddress.from("test", "test@test.mail");
 579             var mlBot = new MailingListBridgeBot(from, author, archive, censusBuilder.build(), "master",
 580                                                  listAddress, Set.of(), Set.of(),
 581                                                  listServer.getArchive(),
 582                                                  listServer.getSMTP(),
 583                                                  archive, "webrev", Path.of("test"),
 584                                                  URIBuilder.base("http://www.test.test/").build(),
 585                                                  Set.of(), Map.of(),
 586                                                  URIBuilder.base("http://issues.test/browse/").build());
 587 
 588             // Populate the projects repository
 589             var reviewFile = Path.of("reviewfile.txt");
 590             var localRepo = CheckableRepository.init(tempFolder.path(), author.getRepositoryType(), reviewFile);
 591             var masterHash = localRepo.resolve("master").orElseThrow();
 592             localRepo.push(masterHash, author.getUrl(), "master", true);
 593             localRepo.push(masterHash, archive.getUrl(), "webrev", true);
 594             var initialHash = CheckableRepository.appendAndCommit(localRepo,
 595                                                                   "Line 0.1\nLine 0.2\nLine 0.3\nLine 0.4\n" +
 596                                                                           "Line 1\nLine 2\nLine 3\nLine 4\n" +
 597                                                                           "Line 5\nLine 6\nLine 7\nLine 8\n" +
 598                                                                           "Line 8.1\nLine 8.2\nLine 8.3\nLine 8.4\n" +
 599                                                                           "Line 9\nLine 10\nLine 11\nLine 12\n" +
 600                                                                           "Line 13\nLine 14\nLine 15\nLine 16\n");
 601             localRepo.push(initialHash, author.getUrl(), "master");
 602 
 603             // Make a change with a corresponding PR
 604             var current = Files.readString(localRepo.root().resolve(reviewFile), StandardCharsets.UTF_8);
 605             var updated = current.replaceAll("Line 2", "Line 2 edit\nLine 2.5");
 606             updated = updated.replaceAll("Line 13", "Line 12.5\nLine 13 edit");
 607             Files.writeString(localRepo.root().resolve(reviewFile), updated, StandardCharsets.UTF_8);
 608             var editHash = CheckableRepository.appendAndCommit(localRepo);
 609             localRepo.push(editHash, author.getUrl(), "edit", true);
 610             var pr = credentials.createPullRequest(archive, "master", "edit", "This is a pull request");
 611             pr.setBody("This is now ready");
 612             TestBotRunner.runPeriodicItems(mlBot);
 613             listServer.processIncoming();
 614 
 615             // Make file specific comments
 616             pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 7, "Review comment");
 617             pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 24, "Another review comment");
 618 
 619             TestBotRunner.runPeriodicItems(mlBot);
 620             listServer.processIncoming();
 621 
 622             // The archive should only contain context around line 2 and 20
 623             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 624             assertTrue(archiveContains(archiveFolder.path(), "reviewfile.txt line 7"));
 625             assertTrue(archiveContains(archiveFolder.path(), "^> 6: Line 1$"));
 626             assertTrue(archiveContains(archiveFolder.path(), "^> 7: Line 2 edit$"));
 627             assertFalse(archiveContains(archiveFolder.path(), "Line 3"));
 628 
 629             assertTrue(archiveContains(archiveFolder.path(), "reviewfile.txt line 24"));
 630             assertTrue(archiveContains(archiveFolder.path(), "^> 23: Line 12.5$"));
 631             assertTrue(archiveContains(archiveFolder.path(), "^> 24: Line 13 edit$"));
 632             assertFalse(archiveContains(archiveFolder.path(), "^Line 15"));
 633         }
 634     }
 635 
 636     @Test
 637     void filterComments(TestInfo testInfo) throws IOException {
 638         try (var credentials = new HostCredentials(testInfo);
 639              var tempFolder = new TemporaryDirectory();
 640              var archiveFolder = new TemporaryDirectory();
 641              var listServer = new TestMailmanServer()) {
 642             var author = credentials.getHostedRepository();
 643             var archive = credentials.getHostedRepository();
 644             var listAddress = EmailAddress.parse(listServer.createList("test"));
 645             var censusBuilder = credentials.getCensusBuilder()
 646                                            .addAuthor(author.host().getCurrentUserDetails().id());
 647             var from = EmailAddress.from("test", "test@test.mail");
 648             var mlBot = new MailingListBridgeBot(from, author, archive, censusBuilder.build(), "master",
 649                                                  listAddress, Set.of(), Set.of(),
 650                                                  listServer.getArchive(), listServer.getSMTP(),
 651                                                  archive, "webrev", Path.of("test"),
 652                                                  URIBuilder.base("http://www.test.test/").build(),
 653                                                  Set.of(), Map.of(),
 654                                                  URIBuilder.base("http://issues.test/browse/").build());
 655 
 656             // Populate the projects repository
 657             var reviewFile = Path.of("reviewfile.txt");
 658             var localRepo = CheckableRepository.init(tempFolder.path(), author.getRepositoryType(), reviewFile);
 659             var masterHash = localRepo.resolve("master").orElseThrow();
 660             localRepo.push(masterHash, author.getUrl(), "master", true);
 661             localRepo.push(masterHash, archive.getUrl(), "webrev", true);
 662 
 663             // Make a change with a corresponding PR
 664             var editHash = CheckableRepository.appendAndCommit(localRepo);
 665             localRepo.push(editHash, author.getUrl(), "edit", true);
 666             var pr = credentials.createPullRequest(archive, "master", "edit", "This is a pull request");
 667             pr.setBody("This is now ready\n<!-- this is a comment -->\nAnd this is not\n" +
 668                                "<!-- Anything below this marker will be hidden -->\nStatus stuff");
 669 
 670             // Make a bunch of comments
 671             pr.addComment("Plain comment\n<!-- this is a comment -->");
 672             pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, "Review comment <!-- this is a comment -->\n");
 673             pr.addComment("/integrate stuff");
 674             TestBotRunner.runPeriodicItems(mlBot);
 675 
 676             // Run an archive pass
 677             TestBotRunner.runPeriodicItems(mlBot);
 678 
 679             // The archive should not contain the comment
 680             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 681             assertTrue(archiveContains(archiveFolder.path(), "This is now ready"));
 682             assertFalse(archiveContains(archiveFolder.path(), "this is a comment"));
 683             assertFalse(archiveContains(archiveFolder.path(), "Status stuff"));
 684             assertTrue(archiveContains(archiveFolder.path(), "And this is not"));
 685             assertFalse(archiveContains(archiveFolder.path(), "<!--"));
 686             assertFalse(archiveContains(archiveFolder.path(), "-->"));
 687             assertTrue(archiveContains(archiveFolder.path(), "Plain comment"));
 688             assertTrue(archiveContains(archiveFolder.path(), "Review comment"));
 689             assertFalse(archiveContains(archiveFolder.path(), "/integrate"));
 690         }
 691     }
 692 
 693     @Test
 694     void incrementalChanges(TestInfo testInfo) throws IOException {
 695         try (var credentials = new HostCredentials(testInfo);
 696              var tempFolder = new TemporaryDirectory();
 697              var archiveFolder = new TemporaryDirectory();
 698              var listServer = new TestMailmanServer()) {
 699             var author = credentials.getHostedRepository();
 700             var archive = credentials.getHostedRepository();
 701             var commenter = credentials.getHostedRepository();
 702             var listAddress = EmailAddress.parse(listServer.createList("test"));
 703             var censusBuilder = credentials.getCensusBuilder()
 704                                            .addAuthor(author.host().getCurrentUserDetails().id());
 705             var from = EmailAddress.from("test", "test@test.mail");
 706             var mlBot = new MailingListBridgeBot(from, author, archive, censusBuilder.build(), "master",
 707                                                  listAddress, Set.of(), Set.of(),
 708                                                  listServer.getArchive(), listServer.getSMTP(),
 709                                                  archive, "webrev", Path.of("test"),
 710                                                  URIBuilder.base("http://www.test.test/").build(),
 711                                                  Set.of(), Map.of(),
 712                                                  URIBuilder.base("http://issues.test/browse/").build());
 713 
 714             // Populate the projects repository
 715             var reviewFile = Path.of("reviewfile.txt");
 716             var localRepo = CheckableRepository.init(tempFolder.path(), author.getRepositoryType(), reviewFile);
 717             var masterHash = localRepo.resolve("master").orElseThrow();
 718             localRepo.push(masterHash, author.getUrl(), "master", true);
 719             localRepo.push(masterHash, archive.getUrl(), "webrev", true);
 720 
 721             // Make a change with a corresponding PR
 722             var editHash = CheckableRepository.appendAndCommit(localRepo);
 723             localRepo.push(editHash, author.getUrl(), "edit", true);
 724             var pr = credentials.createPullRequest(archive, "master", "edit", "This is a pull request");
 725             pr.setBody("This is now ready");
 726 
 727             // Run an archive pass
 728             TestBotRunner.runPeriodicItems(mlBot);
 729             listServer.processIncoming();
 730 
 731             var nextHash = CheckableRepository.appendAndCommit(localRepo, "Yet one more line", "Fixing");
 732             localRepo.push(nextHash, author.getUrl(), "edit");
 733 
 734             // Make sure that the push registered
 735             var lastHeadHash = pr.getHeadHash();
 736             var refreshCount = 0;
 737             do {
 738                 pr = author.getPullRequest(pr.getId());
 739                 if (refreshCount++ > 100) {
 740                     fail("The PR did not update after the new push");
 741                 }
 742             } while (pr.getHeadHash().equals(lastHeadHash));
 743 
 744             // Run another archive pass
 745             TestBotRunner.runPeriodicItems(mlBot);
 746             TestBotRunner.runPeriodicItems(mlBot);
 747             TestBotRunner.runPeriodicItems(mlBot);
 748             listServer.processIncoming();
 749 
 750             // The archive should reference the updated push
 751             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 752             assertTrue(archiveContains(archiveFolder.path(), "additional changes"));
 753             assertTrue(archiveContains(archiveFolder.path(), "full.*/" + pr.getId() + "/webrev.01"));
 754             assertTrue(archiveContains(archiveFolder.path(), "inc.*/" + pr.getId() + "/webrev.00-01"));
 755             assertTrue(archiveContains(archiveFolder.path(), "Patch"));
 756             assertTrue(archiveContains(archiveFolder.path(), "Fetch"));
 757             assertTrue(archiveContains(archiveFolder.path(), "Fixing"));
 758 
 759             // The webrev comment should be updated
 760             var comments = pr.getComments();
 761             var webrevComments = comments.stream()
 762                                          .filter(comment -> comment.author().equals(author.host().getCurrentUserDetails()))
 763                                          .filter(comment -> comment.body().contains("webrev"))
 764                                          .filter(comment -> comment.body().contains(nextHash.hex()))
 765                                          .filter(comment -> comment.body().contains(editHash.hex()))
 766                                          .collect(Collectors.toList());
 767             assertEquals(1, webrevComments.size());
 768 
 769             // Check that sender address is set properly
 770             var mailmanServer = MailingListServerFactory.createMailmanServer(listServer.getArchive(), listServer.getSMTP());
 771             var mailmanList = mailmanServer.getList(listAddress.address());
 772             var conversations = mailmanList.conversations(Duration.ofDays(1));
 773             assertEquals(1, conversations.size());
 774             for (var newMail : conversations.get(0).allMessages()) {
 775                 assertEquals(noreplyAddress(archive), newMail.author().address());
 776                 assertEquals(from, newMail.sender());
 777             }
 778 
 779             // Add a comment
 780             var commenterPr = commenter.getPullRequest(pr.getId());
 781             commenterPr.addReviewComment(masterHash, nextHash, reviewFile.toString(), 2, "Review comment");
 782             TestBotRunner.runPeriodicItems(mlBot);
 783             listServer.processIncoming();
 784 
 785             // Ensure that additional updates are only reported once
 786             for (int i = 0; i < 3; ++i) {
 787                 var anotherHash = CheckableRepository.appendAndCommit(localRepo, "Another line", "Fixing");
 788                 localRepo.push(anotherHash, author.getUrl(), "edit");
 789 
 790                 // Make sure that the push registered
 791                 lastHeadHash = pr.getHeadHash();
 792                 refreshCount = 0;
 793                 do {
 794                     pr = author.getPullRequest(pr.getId());
 795                     if (refreshCount++ > 100) {
 796                         fail("The PR did not update after the new push");
 797                     }
 798                 } while (pr.getHeadHash().equals(lastHeadHash));
 799 
 800                 TestBotRunner.runPeriodicItems(mlBot);
 801                 TestBotRunner.runPeriodicItems(mlBot);
 802                 listServer.processIncoming();
 803             }
 804             var updatedConversations = mailmanList.conversations(Duration.ofDays(1));
 805             assertEquals(1, updatedConversations.size());
 806             var conversation = updatedConversations.get(0);
 807             assertEquals(6, conversation.allMessages().size());
 808             assertEquals("Re: [Rev 01] RFR: This is a pull request", conversation.allMessages().get(1).subject());
 809             assertEquals("Re: [Rev 01] RFR: This is a pull request", conversation.allMessages().get(2).subject(), conversation.allMessages().get(2).toString());
 810             assertEquals("Re: [Rev 04] RFR: This is a pull request", conversation.allMessages().get(5).subject());
 811         }
 812     }
 813 
 814     @Test
 815     void rebased(TestInfo testInfo) throws IOException {
 816         try (var credentials = new HostCredentials(testInfo);
 817              var tempFolder = new TemporaryDirectory();
 818              var archiveFolder = new TemporaryDirectory();
 819              var listServer = new TestMailmanServer()) {
 820             var author = credentials.getHostedRepository();
 821             var archive = credentials.getHostedRepository();
 822             var listAddress = EmailAddress.parse(listServer.createList("test"));
 823             var censusBuilder = credentials.getCensusBuilder()
 824                                            .addAuthor(author.host().getCurrentUserDetails().id());
 825             var sender = EmailAddress.from("test", "test@test.mail");
 826             var mlBot = new MailingListBridgeBot(sender, author, archive, censusBuilder.build(), "master",
 827                                                  listAddress, Set.of(), Set.of(),
 828                                                  listServer.getArchive(), listServer.getSMTP(),
 829                                                  archive, "webrev", Path.of("test"),
 830                                                  URIBuilder.base("http://www.test.test/").build(),
 831                                                  Set.of(), Map.of(),
 832                                                  URIBuilder.base("http://issues.test/browse/").build());
 833 
 834             // Populate the projects repository
 835             var reviewFile = Path.of("reviewfile.txt");
 836             var localRepo = CheckableRepository.init(tempFolder.path().resolve("first"), author.getRepositoryType(), reviewFile);
 837             var masterHash = localRepo.resolve("master").orElseThrow();
 838             localRepo.push(masterHash, author.getUrl(), "master", true);
 839             localRepo.push(masterHash, archive.getUrl(), "webrev", true);
 840 
 841             // Make a change with a corresponding PR
 842             var editHash = CheckableRepository.appendAndCommit(localRepo, "A line", "Original msg");
 843             localRepo.push(editHash, author.getUrl(), "edit", true);
 844             var pr = credentials.createPullRequest(archive, "master", "edit", "This is a pull request");
 845             pr.setBody("This is now ready");
 846 
 847             // Run an archive pass
 848             TestBotRunner.runPeriodicItems(mlBot);
 849             listServer.processIncoming();
 850 
 851             var newLocalRepo = Repository.materialize(tempFolder.path().resolve("second"), author.getUrl(), "master");
 852             var newEditHash = CheckableRepository.appendAndCommit(newLocalRepo, "Another line", "Replaced msg");
 853             newLocalRepo.push(newEditHash, author.getUrl(), "edit", true);
 854 
 855             // Make sure that the push registered
 856             var lastHeadHash = pr.getHeadHash();
 857             var refreshCount = 0;
 858             do {
 859                 pr = author.getPullRequest(pr.getId());
 860                 if (refreshCount++ > 100) {
 861                     fail("The PR did not update after the new push");
 862                 }
 863             } while (pr.getHeadHash().equals(lastHeadHash));
 864 
 865             // Run another archive pass
 866             TestBotRunner.runPeriodicItems(mlBot);
 867             listServer.processIncoming();
 868 
 869             // The archive should reference the rebased push
 870             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 871             assertTrue(archiveContains(archiveFolder.path(), "complete new set of changes"));
 872             assertTrue(archiveContains(archiveFolder.path(), pr.getId() + "/webrev.01"));
 873             assertFalse(archiveContains(archiveFolder.path(), "Incremental"));
 874             assertTrue(archiveContains(archiveFolder.path(), "Patch"));
 875             assertTrue(archiveContains(archiveFolder.path(), "Fetch"));
 876             assertTrue(archiveContains(archiveFolder.path(), "Original msg"));
 877             assertTrue(archiveContains(archiveFolder.path(), "Replaced msg"));
 878 
 879             // The webrev comment should be updated
 880             var comments = pr.getComments();
 881             var webrevComments = comments.stream()
 882                                          .filter(comment -> comment.author().equals(author.host().getCurrentUserDetails()))
 883                                          .filter(comment -> comment.body().contains("webrev"))
 884                                          .filter(comment -> comment.body().contains(newEditHash.hex()))
 885                                          .collect(Collectors.toList());
 886             assertEquals(1, webrevComments.size());
 887 
 888             // Check that sender address is set properly
 889             var mailmanServer = MailingListServerFactory.createMailmanServer(listServer.getArchive(), listServer.getSMTP());
 890             var mailmanList = mailmanServer.getList(listAddress.address());
 891             var conversations = mailmanList.conversations(Duration.ofDays(1));
 892             assertEquals(1, conversations.size());
 893             for (var newMail : conversations.get(0).allMessages()) {
 894                 assertEquals(noreplyAddress(archive), newMail.author().address());
 895                 assertEquals(sender, newMail.sender());
 896                 assertFalse(newMail.hasHeader("PR-Head-Hash"));
 897             }
 898             assertEquals("Re: [Rev 01] RFR: This is a pull request", conversations.get(0).allMessages().get(1).subject());
 899         }
 900     }
 901 
 902     @Test
 903     void skipAddingExistingWebrev(TestInfo testInfo) throws IOException {
 904         try (var credentials = new HostCredentials(testInfo);
 905              var tempFolder = new TemporaryDirectory();
 906              var archiveFolder = new TemporaryDirectory();
 907              var webrevFolder = new TemporaryDirectory();
 908              var listServer = new TestMailmanServer()) {
 909             var author = credentials.getHostedRepository();
 910             var archive = credentials.getHostedRepository();
 911             var ignored = credentials.getHostedRepository();
 912             var listAddress = EmailAddress.parse(listServer.createList("test"));
 913             var censusBuilder = credentials.getCensusBuilder()
 914                                            .addAuthor(author.host().getCurrentUserDetails().id());
 915             var from = EmailAddress.from("test", "test@test.mail");
 916             var mlBot = new MailingListBridgeBot(from, author, archive, censusBuilder.build(), "master",
 917                                                  listAddress,
 918                                                  Set.of(ignored.host().getCurrentUserDetails().userName()),
 919                                                  Set.of(),
 920                                                  listServer.getArchive(), listServer.getSMTP(),
 921                                                  archive, "webrev", Path.of("test"),
 922                                                  URIBuilder.base("http://www.test.test/").build(),
 923                                                  Set.of(), Map.of(),
 924                                                  URIBuilder.base("http://issues.test/browse/").build());
 925 
 926             // Populate the projects repository
 927             var localRepo = CheckableRepository.init(tempFolder.path(), author.getRepositoryType());
 928             var masterHash = localRepo.resolve("master").orElseThrow();
 929             localRepo.push(masterHash, author.getUrl(), "master", true);
 930             localRepo.push(masterHash, archive.getUrl(), "webrev", true);
 931 
 932             // Make a change with a corresponding PR
 933             var editHash = CheckableRepository.appendAndCommit(localRepo, "A simple change",
 934                                                                "Change msg\n\nWith several lines");
 935             localRepo.push(editHash, author.getUrl(), "edit", true);
 936             var pr = credentials.createPullRequest(archive, "master", "edit", "This is a pull request");
 937 
 938             // Flag it as ready for review
 939             pr.setBody("This should now be ready");
 940 
 941             // Run an archive pass
 942             TestBotRunner.runPeriodicItems(mlBot);
 943 
 944             // The archive should now contain an entry
 945             var archiveRepo = Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
 946             assertTrue(archiveContains(archiveFolder.path(), editHash.abbreviate()));
 947 
 948             // And there should be a webrev comment
 949             var comments = pr.getComments();
 950             var webrevComments = comments.stream()
 951                                          .filter(comment -> comment.author().equals(author.host().getCurrentUserDetails()))
 952                                          .filter(comment -> comment.body().contains("webrev"))
 953                                          .filter(comment -> comment.body().contains(editHash.hex()))
 954                                          .collect(Collectors.toList());
 955             assertEquals(1, webrevComments.size());
 956             assertEquals(1, countSubstrings(webrevComments.get(0).body(), "webrev.00"));
 957 
 958             // Pretend the archive didn't work out
 959             archiveRepo.push(masterHash, archive.getUrl(), "master", true);
 960 
 961             // Run another archive pass
 962             TestBotRunner.runPeriodicItems(mlBot);
 963 
 964             // The webrev comment should not contain duplicate entries
 965             comments = pr.getComments();
 966             webrevComments = comments.stream()
 967                                          .filter(comment -> comment.author().equals(author.host().getCurrentUserDetails()))
 968                                          .filter(comment -> comment.body().contains("webrev"))
 969                                          .filter(comment -> comment.body().contains(editHash.hex()))
 970                                          .collect(Collectors.toList());
 971             assertEquals(1, webrevComments.size());
 972             assertEquals(1, countSubstrings(webrevComments.get(0).body(), "webrev.00"));
 973         }
 974     }
 975 
 976     @Test
 977     void notifyReviewVerdicts(TestInfo testInfo) throws IOException {
 978         try (var credentials = new HostCredentials(testInfo);
 979              var tempFolder = new TemporaryDirectory();
 980              var archiveFolder = new TemporaryDirectory();
 981              var listServer = new TestMailmanServer()) {
 982             var author = credentials.getHostedRepository();
 983             var archive = credentials.getHostedRepository();
 984             var reviewer = credentials.getHostedRepository();
 985             var listAddress = EmailAddress.parse(listServer.createList("test"));
 986             var from = EmailAddress.from("test", "test@test.mail");
 987             var censusBuilder = credentials.getCensusBuilder()
 988                                            .addReviewer(reviewer.host().getCurrentUserDetails().id())
 989                                            .addAuthor(author.host().getCurrentUserDetails().id());
 990             var mlBot = new MailingListBridgeBot(from, author, archive, censusBuilder.build(), "master",
 991                                                  listAddress, Set.of(), Set.of(),
 992                                                  listServer.getArchive(), listServer.getSMTP(),
 993                                                  archive, "webrev", Path.of("test"),
 994                                                  URIBuilder.base("http://www.test.test/").build(),
 995                                                  Set.of(), Map.of(),
 996                                                  URIBuilder.base("http://issues.test/browse/").build());
 997 
 998             // Populate the projects repository
 999             var reviewFile = Path.of("reviewfile.txt");
1000             var localRepo = CheckableRepository.init(tempFolder.path(), author.getRepositoryType(), reviewFile);
1001             var masterHash = localRepo.resolve("master").orElseThrow();
1002             localRepo.push(masterHash, author.getUrl(), "master", true);
1003             localRepo.push(masterHash, archive.getUrl(), "webrev", true);
1004 
1005             // Make a change with a corresponding PR
1006             var editHash = CheckableRepository.appendAndCommit(localRepo);
1007             localRepo.push(editHash, author.getUrl(), "edit", true);
1008             var pr = credentials.createPullRequest(archive, "master", "edit", "This is a pull request");
1009             pr.setBody("This is now ready");
1010 
1011             // Run an archive pass
1012             TestBotRunner.runPeriodicItems(mlBot);
1013 
1014             // First unapprove it
1015             var reviewedPr = reviewer.getPullRequest(pr.getId());
1016             reviewedPr.addReview(Review.Verdict.DISAPPROVED, "Reason 1");
1017             TestBotRunner.runPeriodicItems(mlBot);
1018             TestBotRunner.runPeriodicItems(mlBot);
1019             TestBotRunner.runPeriodicItems(mlBot);
1020 
1021             // The archive should contain a note
1022             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
1023             assertEquals(1, archiveContainsCount(archiveFolder.path(), "Disapproved by "));
1024             assertEquals(1, archiveContainsCount(archiveFolder.path(), " by integrationreviewer1"));
1025             if (author.host().supportsReviewBody()) {
1026                 assertEquals(1, archiveContainsCount(archiveFolder.path(), "Reason 1"));
1027             }
1028 
1029             // Then approve it
1030             reviewedPr.addReview(Review.Verdict.APPROVED, "Reason 2");
1031             TestBotRunner.runPeriodicItems(mlBot);
1032             TestBotRunner.runPeriodicItems(mlBot);
1033             TestBotRunner.runPeriodicItems(mlBot);
1034 
1035             // The archive should contain another note
1036             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
1037             assertEquals(1, archiveContainsCount(archiveFolder.path(), "Approved by "));
1038             if (author.host().supportsReviewBody()) {
1039                 assertEquals(1, archiveContainsCount(archiveFolder.path(), "Reason 2"));
1040             }
1041             assertEquals(1, archiveContainsCount(archiveFolder.path(), "Re: \\[Approved\\] RFR:"));
1042 
1043             // Yet another change
1044             reviewedPr.addReview(Review.Verdict.DISAPPROVED, "Reason 3");
1045             TestBotRunner.runPeriodicItems(mlBot);
1046             TestBotRunner.runPeriodicItems(mlBot);
1047             TestBotRunner.runPeriodicItems(mlBot);
1048 
1049             // The archive should contain another note
1050             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
1051             assertEquals(2, archiveContainsCount(archiveFolder.path(), "Disapproved by "));
1052             if (author.host().supportsReviewBody()) {
1053                 assertEquals(1, archiveContainsCount(archiveFolder.path(), "Reason 3"));
1054             }
1055         }
1056     }
1057 
1058     @Test
1059     void ignoreComments(TestInfo testInfo) throws IOException {
1060         try (var credentials = new HostCredentials(testInfo);
1061              var tempFolder = new TemporaryDirectory();
1062              var archiveFolder = new TemporaryDirectory();
1063              var listServer = new TestMailmanServer()) {
1064             var author = credentials.getHostedRepository();
1065             var ignored = credentials.getHostedRepository();
1066             var archive = credentials.getHostedRepository();
1067             var listAddress = EmailAddress.parse(listServer.createList("test"));
1068             var censusBuilder = credentials.getCensusBuilder()
1069                                            .addAuthor(author.host().getCurrentUserDetails().id());
1070             var from = EmailAddress.from("test", "test@test.mail");
1071             var mlBot = new MailingListBridgeBot(from, author, archive, censusBuilder.build(), "master",
1072                                                  listAddress,
1073                                                  Set.of(ignored.host().getCurrentUserDetails().userName()),
1074                                                  Set.of(Pattern.compile("ignore this comment", Pattern.MULTILINE | Pattern.DOTALL)),
1075                                                  listServer.getArchive(), listServer.getSMTP(),
1076                                                  archive, "webrev", Path.of("test"),
1077                                                  URIBuilder.base("http://www.test.test/").build(),
1078                                                  Set.of(), Map.of(),
1079                                                  URIBuilder.base("http://issues.test/browse/").build());
1080 
1081             // Populate the projects repository
1082             var reviewFile = Path.of("reviewfile.txt");
1083             var localRepo = CheckableRepository.init(tempFolder.path(), author.getRepositoryType(), reviewFile);
1084             var masterHash = localRepo.resolve("master").orElseThrow();
1085             localRepo.push(masterHash, author.getUrl(), "master", true);
1086             localRepo.push(masterHash, archive.getUrl(), "webrev", true);
1087 
1088             // Make a change with a corresponding PR
1089             var editHash = CheckableRepository.appendAndCommit(localRepo);
1090             localRepo.push(editHash, author.getUrl(), "edit", true);
1091             var pr = credentials.createPullRequest(archive, "master", "edit", "This is a pull request");
1092             pr.setBody("This is now ready");
1093 
1094             // Make a bunch of comments
1095             pr.addComment("Plain comment");
1096             pr.addComment("ignore this comment");
1097             pr.addComment("I think it is time to\nignore this comment!");
1098             pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, "Review ignore this comment");
1099 
1100             var ignoredPR = ignored.getPullRequest(pr.getId());
1101             ignoredPR.addComment("Don't mind me");
1102 
1103             TestBotRunner.runPeriodicItems(mlBot);
1104             TestBotRunner.runPeriodicItems(mlBot);
1105 
1106             // The archive should not contain the ignored comments
1107             Repository.materialize(archiveFolder.path(), archive.getUrl(), "master");
1108             assertTrue(archiveContains(archiveFolder.path(), "This is now ready"));
1109             assertFalse(archiveContains(archiveFolder.path(), "ignore this comment"));
1110             assertFalse(archiveContains(archiveFolder.path(), "it is time to"));
1111             assertFalse(archiveContains(archiveFolder.path(), "Don't mind me"));
1112             assertFalse(archiveContains(archiveFolder.path(), "Review ignore"));
1113         }
1114     }
1115 }