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