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