Merge "Skip migrating inline comments on missing patch set parents" into stable-2.15
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
index 6f5a7a7..68bd350 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -24,6 +24,7 @@
 import static java.util.concurrent.TimeUnit.DAYS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.junit.Assert.fail;
@@ -54,6 +55,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ChangeUtil;
@@ -73,6 +75,8 @@
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
 import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.project.Util;
@@ -97,6 +101,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
 import org.apache.http.Header;
 import org.apache.http.message.BasicHeader;
 import org.eclipse.jgit.junit.TestRepository;
@@ -149,6 +154,8 @@
 
   @Inject private PatchSetInfoFactory patchSetInfoFactory;
 
+  @Inject private PatchListCache patchListCache;
+
   @Before
   public void setUp() throws Exception {
     assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
@@ -1378,6 +1385,67 @@
     assertChangeUpToDate(true, id);
   }
 
+  @Test
+  public void missingPatchSetCommitOkForCommentsNotOnParentSide() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    putDraft(user, id, 1, "draft comment", null, Side.REVISION);
+    putComment(user, id, 1, "published comment", null, Side.REVISION);
+
+    ReviewDb db = getUnwrappedDb();
+    PatchSet ps = db.patchSets().get(new PatchSet.Id(id, 1));
+    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    db.patchSets().update(Collections.singleton(ps));
+
+    try {
+      patchListCache.getOldId(db.changes().get(id), ps, null);
+      assert_().fail("Expected PatchListNotAvailableException");
+    } catch (PatchListNotAvailableException e) {
+      // Expected.
+    }
+
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void missingPatchSetCommitOmitsCommentsOnParentSide() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    CommentInfo draftInfo = putDraft(user, id, 1, "draft comment", null, Side.PARENT);
+    putComment(user, id, 1, "published comment", null, Side.PARENT);
+    CommentInfo commentInfo =
+        gApi.changes()
+            .id(id.get())
+            .comments()
+            .values()
+            .stream()
+            .flatMap(List::stream)
+            .findFirst()
+            .get();
+
+    ReviewDb db = getUnwrappedDb();
+    PatchSet ps = db.patchSets().get(new PatchSet.Id(id, 1));
+    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    db.patchSets().update(Collections.singleton(ps));
+
+    try {
+      patchListCache.getOldId(db.changes().get(id), ps, null);
+      assert_().fail("Expected PatchListNotAvailableException");
+    } catch (PatchListNotAvailableException e) {
+      // Expected.
+    }
+
+    checker.rebuildAndCheckChange(
+        id,
+        Stream.of(draftInfo.id, commentInfo.id)
+            .sorted()
+            .map(c -> id + ",1," + PushOneCommit.FILE_NAME + "," + c)
+            .collect(
+                joining(", ", "PatchLineComment.Key sets differ: [", "] only in A; [] only in B")));
+  }
+
   private void assertChangesReadOnly(RestApiException e) throws Exception {
     Throwable cause = e.getCause();
     assertThat(cause).isInstanceOf(UpdateException.class);
@@ -1427,16 +1495,24 @@
     }
   }
 
-  private void putDraft(TestAccount account, Change.Id id, int line, String msg, Boolean unresolved)
+  private CommentInfo putDraft(
+      TestAccount account, Change.Id id, int line, String msg, Boolean unresolved)
+      throws Exception {
+    return putDraft(account, id, line, msg, unresolved, Side.REVISION);
+  }
+
+  private CommentInfo putDraft(
+      TestAccount account, Change.Id id, int line, String msg, Boolean unresolved, Side side)
       throws Exception {
     DraftInput in = new DraftInput();
+    in.side = side;
     in.line = line;
     in.message = msg;
     in.path = PushOneCommit.FILE_NAME;
     in.unresolved = unresolved;
     AcceptanceTestRequestScope.Context old = setApiUser(account);
     try {
-      gApi.changes().id(id.get()).current().createDraft(in);
+      return gApi.changes().id(id.get()).current().createDraft(in).get();
     } finally {
       atrScope.set(old);
     }
@@ -1444,7 +1520,14 @@
 
   private void putComment(TestAccount account, Change.Id id, int line, String msg, String inReplyTo)
       throws Exception {
+    putComment(account, id, line, msg, inReplyTo, Side.REVISION);
+  }
+
+  private void putComment(
+      TestAccount account, Change.Id id, int line, String msg, String inReplyTo, Side side)
+      throws Exception {
     CommentInput in = new CommentInput();
+    in.side = side;
     in.line = line;
     in.message = msg;
     in.inReplyTo = inReplyTo;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
index 1c71c70..56b1724 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
@@ -491,25 +491,21 @@
   }
 
   public static void setCommentRevId(Comment c, PatchListCache cache, Change change, PatchSet ps)
-      throws OrmException {
+      throws PatchListNotAvailableException {
     checkArgument(
         c.key.patchSetId == ps.getId().get(),
         "cannot set RevId for patch set %s on comment %s",
         ps.getId(),
         c);
     if (c.revId == null) {
-      try {
-        if (Side.fromShort(c.side) == Side.PARENT) {
-          if (c.side < 0) {
-            c.revId = ObjectId.toString(cache.getOldId(change, ps, -c.side));
-          } else {
-            c.revId = ObjectId.toString(cache.getOldId(change, ps, null));
-          }
+      if (Side.fromShort(c.side) == Side.PARENT) {
+        if (c.side < 0) {
+          c.revId = ObjectId.toString(cache.getOldId(change, ps, -c.side));
         } else {
-          c.revId = ps.getRevision().get();
+          c.revId = ObjectId.toString(cache.getOldId(change, ps, null));
         }
-      } catch (PatchListNotAvailableException e) {
-        throw new OrmException(e);
+      } else {
+        c.revId = ps.getRevision().get();
       }
     }
   }
@@ -576,7 +572,11 @@
       // Draft may have been created by a different real user; copy the current real user. (Only
       // applies to X-Gerrit-RunAs, since modifying drafts via on_behalf_of is not allowed.)
       ctx.getUser().updateRealAccountId(d::setRealAuthor);
-      setCommentRevId(d, patchListCache, notes.getChange(), ps);
+      try {
+        setCommentRevId(d, patchListCache, notes.getChange(), ps);
+      } catch (PatchListNotAvailableException e) {
+        throw new OrmException(e);
+      }
     }
     putComments(ctx.getDb(), ctx.getUpdate(psId), PUBLISHED, drafts);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
index 002c8b7..898f634 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -107,7 +108,8 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, OrmException, UnprocessableEntityException {
+        throws ResourceNotFoundException, OrmException, UnprocessableEntityException,
+            PatchListNotAvailableException {
       PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
index 68db189..aec233e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DeleteDraftComment.Input;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -87,7 +88,8 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException, OrmException {
+    public boolean updateChange(ChangeContext ctx)
+        throws ResourceNotFoundException, OrmException, PatchListNotAvailableException {
       Optional<Comment> maybeComment =
           commentsUtil.getDraft(ctx.getDb(), ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
index 0022656..8e8489f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
@@ -838,7 +838,8 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws OrmException, ResourceConflictException, UnprocessableEntityException, IOException {
+        throws OrmException, ResourceConflictException, UnprocessableEntityException, IOException,
+            PatchListNotAvailableException {
       user = ctx.getIdentifiedUser();
       notes = ctx.getNotes();
       ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
@@ -880,7 +881,7 @@
     }
 
     private boolean insertComments(ChangeContext ctx)
-        throws OrmException, UnprocessableEntityException {
+        throws OrmException, UnprocessableEntityException, PatchListNotAvailableException {
       Map<String, List<CommentInput>> map = in.comments;
       if (map == null) {
         map = Collections.emptyMap();
@@ -945,7 +946,8 @@
       return !toDel.isEmpty() || !toPublish.isEmpty();
     }
 
-    private boolean insertRobotComments(ChangeContext ctx) throws OrmException {
+    private boolean insertRobotComments(ChangeContext ctx)
+        throws OrmException, PatchListNotAvailableException {
       if (in.robotComments == null) {
         return false;
       }
@@ -956,7 +958,8 @@
       return !newRobotComments.isEmpty();
     }
 
-    private List<RobotComment> getNewRobotComments(ChangeContext ctx) throws OrmException {
+    private List<RobotComment> getNewRobotComments(ChangeContext ctx)
+        throws OrmException, PatchListNotAvailableException {
       List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
 
       Set<CommentSetEntry> existingIds =
@@ -976,7 +979,8 @@
     }
 
     private RobotComment createRobotCommentFromInput(
-        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) throws OrmException {
+        ChangeContext ctx, String path, RobotCommentInput robotCommentInput)
+        throws PatchListNotAvailableException {
       RobotComment robotComment =
           commentsUtil.newRobotComment(
               ctx,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
index c5693c6..ca170df 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -112,7 +113,8 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException, OrmException {
+    public boolean updateChange(ChangeContext ctx)
+        throws ResourceNotFoundException, OrmException, PatchListNotAvailableException {
       Optional<Comment> maybeComment =
           commentsUtil.getDraft(ctx.getDb(), ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 3e8734a..5fc660d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.server.mail.MailFilter;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -241,7 +242,7 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws OrmException, UnprocessableEntityException {
+        throws OrmException, UnprocessableEntityException, PatchListNotAvailableException {
       patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       notes = ctx.getNotes();
       if (patchSet == null) {
@@ -338,7 +339,7 @@
 
     private Comment persistentCommentFromMailComment(
         ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment)
-        throws OrmException, UnprocessableEntityException {
+        throws OrmException, UnprocessableEntityException, PatchListNotAvailableException {
       String fileName;
       // The patch set that this comment is based on is different if this
       // comment was sent in reply to a comment on a previous patch set.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
index 29ca6d0..18f4c61 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
@@ -530,8 +530,7 @@
   }
 
   private void flushEventsToDraftUpdate(
-      NoteDbUpdateManager manager, EventList<DraftCommentEvent> events, Change change)
-      throws OrmException {
+      NoteDbUpdateManager manager, EventList<DraftCommentEvent> events, Change change) {
     if (events.isEmpty()) {
       return;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
index c8a649e..611f32e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
@@ -24,9 +24,13 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 class CommentEvent extends Event {
+  private static final Logger log = LoggerFactory.getLogger(CommentEvent.class);
+
   public final Comment c;
   private final Change change;
   private final PatchSet ps;
@@ -57,10 +61,19 @@
   }
 
   @Override
-  void apply(ChangeUpdate update) throws OrmException {
+  void apply(ChangeUpdate update) {
     checkUpdate(update);
     if (c.revId == null) {
-      setCommentRevId(c, cache, change, ps);
+      try {
+        setCommentRevId(c, cache, change, ps);
+      } catch (PatchListNotAvailableException e) {
+        log.warn(
+            "Unable to determine parent commit of patch set {} ({}); omitting inline comment",
+            ps.getId(),
+            ps.getRevision(),
+            c);
+        return;
+      }
     }
     update.putComment(PatchLineComment.Status.PUBLISHED, c);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
index 914930c..3bc3a58 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
@@ -24,9 +24,13 @@
 import com.google.gerrit.server.notedb.ChangeDraftUpdate;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 class DraftCommentEvent extends Event {
+  private static final Logger log = LoggerFactory.getLogger(DraftCommentEvent.class);
+
   public final Comment c;
   private final Change change;
   private final PatchSet ps;
@@ -56,9 +60,18 @@
     throw new UnsupportedOperationException();
   }
 
-  void applyDraft(ChangeDraftUpdate draftUpdate) throws OrmException {
+  void applyDraft(ChangeDraftUpdate draftUpdate) {
     if (c.revId == null) {
-      setCommentRevId(c, cache, change, ps);
+      try {
+        setCommentRevId(c, cache, change, ps);
+      } catch (PatchListNotAvailableException e) {
+        log.warn(
+            "Unable to determine parent commit of patch set {} ({}); omitting draft inline comment",
+            ps.getId(),
+            ps.getRevision(),
+            c);
+        return;
+      }
     }
     draftUpdate.putComment(c);
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
index b5edb25..5ce0810 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
@@ -19,6 +19,8 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -78,14 +80,17 @@
   }
 
   public void rebuildAndCheckAllChanges() throws Exception {
-    rebuildAndCheckChanges(getUnwrappedDb().changes().all().toList().stream().map(Change::getId));
+    rebuildAndCheckChanges(
+        getUnwrappedDb().changes().all().toList().stream().map(Change::getId),
+        ImmutableListMultimap.of());
   }
 
   public void rebuildAndCheckChanges(Change.Id... changeIds) throws Exception {
-    rebuildAndCheckChanges(Arrays.stream(changeIds));
+    rebuildAndCheckChanges(Arrays.stream(changeIds), ImmutableListMultimap.of());
   }
 
-  private void rebuildAndCheckChanges(Stream<Change.Id> changeIds) throws Exception {
+  private void rebuildAndCheckChanges(
+      Stream<Change.Id> changeIds, ListMultimap<Change.Id, String> expectedDiffs) throws Exception {
     ReviewDb db = getUnwrappedDb();
 
     List<ChangeBundle> allExpected = readExpected(changeIds);
@@ -105,7 +110,7 @@
         }
       }
 
-      checkActual(allExpected, msgs);
+      checkActual(allExpected, expectedDiffs, msgs);
     } finally {
       notesMigration.setReadChanges(oldRead);
       notesMigration.setWriteChanges(oldWrite);
@@ -113,7 +118,14 @@
   }
 
   public void checkChanges(Change.Id... changeIds) throws Exception {
-    checkActual(readExpected(Arrays.stream(changeIds)), new ArrayList<>());
+    checkActual(
+        readExpected(Arrays.stream(changeIds)), ImmutableListMultimap.of(), new ArrayList<>());
+  }
+
+  public void rebuildAndCheckChange(Change.Id changeId, String... expectedDiff) throws Exception {
+    ImmutableListMultimap.Builder<Change.Id, String> b = ImmutableListMultimap.builder();
+    b.putAll(changeId, Arrays.asList(expectedDiff));
+    rebuildAndCheckChanges(Stream.of(changeId), b.build());
   }
 
   public void assertNoChangeRef(Project.NameKey project, Change.Id changeId) throws Exception {
@@ -160,7 +172,11 @@
     }
   }
 
-  private void checkActual(List<ChangeBundle> allExpected, List<String> msgs) throws Exception {
+  private void checkActual(
+      List<ChangeBundle> allExpected,
+      ListMultimap<Change.Id, String> expectedDiffs,
+      List<String> msgs)
+      throws Exception {
     ReviewDb db = getUnwrappedDb();
     boolean oldRead = notesMigration.readChanges();
     boolean oldWrite = notesMigration.rawWriteChangesSetting();
@@ -181,9 +197,14 @@
           continue;
         }
         List<String> diff = expected.differencesFrom(actual);
-        if (!diff.isEmpty()) {
+        List<String> expectedDiff = expectedDiffs.get(c.getId());
+        if (!diff.equals(expectedDiff)) {
           msgs.add("Differences between ReviewDb and NoteDb for " + c + ":");
           msgs.addAll(diff);
+          if (!expectedDiff.isEmpty()) {
+            msgs.add("Expected differences:");
+            msgs.addAll(expectedDiff);
+          }
           msgs.add("");
         } else {
           System.err.println("NoteDb conversion of change " + c.getId() + " successful");