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