Merge "NoteDb: Support comments on multiple patch sets with same base"
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
index b7a6b93..69cb05b 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -383,6 +383,10 @@
         newDraft(FILE_NAME, Side.REVISION, 1, "join lines"));
     addDraft(r2.getChangeId(), r2.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 2, "typo: content"));
+    addDraft(r2.getChangeId(), r2.getCommit().getName(),
+        newDraft(FILE_NAME, Side.PARENT, 1, "comment 1 on base"));
+    addDraft(r2.getChangeId(), r2.getCommit().getName(),
+        newDraft(FILE_NAME, Side.PARENT, 2, "comment 2 on base"));
 
     PushOneCommit.Result other = createChange();
     // Drafts on other changes aren't returned.
@@ -431,9 +435,11 @@
         .comments();
     assertThat(ps2Map.keySet()).containsExactly(FILE_NAME);
     List<CommentInfo> ps2List = ps2Map.get(FILE_NAME);
-    assertThat(ps2List).hasSize(2);
-    assertThat(ps2List.get(0).message).isEqualTo("join lines");
-    assertThat(ps2List.get(1).message).isEqualTo("typo: content");
+    assertThat(ps2List).hasSize(4);
+    assertThat(ps2List.get(0).message).isEqualTo("comment 1 on base");
+    assertThat(ps2List.get(1).message).isEqualTo("comment 2 on base");
+    assertThat(ps2List.get(2).message).isEqualTo("join lines");
+    assertThat(ps2List.get(3).message).isEqualTo("typo: content");
 
     ImmutableList<Message> messages =
         email.getMessages(r2.getChangeId(), "comment");
@@ -443,7 +449,7 @@
     assertThat(extractComments(messages.get(0).body())).isEqualTo(
         "Patch Set 2:\n"
         + "\n"
-        + "(4 comments)\n"
+        + "(6 comments)\n"
         + "\n"
         + "comments\n"
         + "\n"
@@ -461,6 +467,14 @@
         + url + "#/c/" + c + "/2/a.txt\n"
         + "File a.txt:\n"
         + "\n"
+        + "PS2, Line 1: \n"
+        + "comment 1 on base\n"
+        + "\n"
+        + "\n"
+        + "PS2, Line 2: \n"
+        + "comment 2 on base\n"
+        + "\n"
+        + "\n"
         + "PS2, Line 1: ew\n"
         + "join lines\n"
         + "\n"
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 7d3f363..8b112a3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -15,12 +15,14 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.PatchLineCommentsUtil.PLC_ORDER;
 import static com.google.gerrit.server.notedb.ChangeNotes.parseException;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -31,8 +33,8 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerId;
@@ -48,7 +50,6 @@
 import org.eclipse.jgit.util.QuotedString;
 import org.eclipse.jgit.util.RawParseUtils;
 
-import java.io.ByteArrayOutputStream;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
@@ -151,6 +152,11 @@
         serverId, email);
   }
 
+  private static boolean match(byte[] note, MutableInteger p, byte[] expected) {
+    int m = RawParseUtils.match(note, p.value, expected);
+    return m == p.value + expected.length;
+  }
+
   public List<PatchLineComment> parseNote(byte[] note, MutableInteger p,
       Change.Id changeId, Status status) throws ConfigInvalidException {
     if (p.value >= note.length) {
@@ -159,21 +165,33 @@
     Set<PatchLineComment.Key> seen = new HashSet<>();
     List<PatchLineComment> result = Lists.newArrayList();
     int sizeOfNote = note.length;
+    byte[] psb = PATCH_SET.getBytes(UTF_8);
+    byte[] bpsb = BASE_PATCH_SET.getBytes(UTF_8);
 
-    boolean isForBase =
-        (RawParseUtils.match(note, p.value, PATCH_SET.getBytes(UTF_8))) < 0;
+    RevId revId = new RevId(parseStringField(note, p, changeId, REVISION));
+    String fileName = null;
+    PatchSet.Id psId = null;
+    boolean isForBase = false;
 
-    PatchSet.Id psId = parsePsId(note, p, changeId, isForBase ? BASE_PATCH_SET : PATCH_SET);
-
-    RevId revId =
-        new RevId(parseStringField(note, p, changeId, REVISION));
-
-    PatchLineComment c = null;
     while (p.value < sizeOfNote) {
-      String previousFileName = c == null ?
-          null : c.getKey().getParentKey().getFileName();
-      c = parseComment(note, p, previousFileName, psId, revId,
-          isForBase, status);
+      boolean matchPs = match(note, p, psb);
+      boolean matchBase = match(note, p, bpsb);
+      if (matchPs) {
+        fileName = null;
+        psId = parsePsId(note, p, changeId, PATCH_SET);
+        isForBase = false;
+      } else if (matchBase) {
+        fileName = null;
+        psId = parsePsId(note, p, changeId, BASE_PATCH_SET);
+        isForBase = true;
+      } else if (psId == null) {
+        throw parseException(changeId, "missing %s or %s header",
+            PATCH_SET, BASE_PATCH_SET);
+      }
+
+      PatchLineComment c =
+          parseComment(note, p, fileName, psId, revId, isForBase, status);
+      fileName = c.getKey().getParentKey().getFileName();
       if (!seen.add(c.getKey())) {
         throw parseException(
             changeId, "multiple comments for %s in note", c.getKey());
@@ -434,105 +452,113 @@
     }
   }
 
-  public byte[] buildNote(List<PatchLineComment> comments) {
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    buildNote(comments, out);
-    return out.toByteArray();
-  }
-
   /**
    * Build a note that contains the metadata for and the contents of all of the
-   * comments in the given list of comments.
+   * comments in the given comments.
    *
-   * @param comments A list of the comments to be written to the
-   *            output stream. All of the comments in this list must have the
-   *            same side and must share the same patch set ID.
+   * @param comments Comments to be written to the output stream, keyed by patch
+   *     set ID; multiple patch sets are allowed since base revisions may be
+   *     shared across patch sets. All of the comments must share the same
+   *     RevId, and all the comments for a given patch set must have the same
+   *     side.
    * @param out output stream to write to.
    */
-  void buildNote(List<PatchLineComment> comments, OutputStream out) {
+  void buildNote(Multimap<PatchSet.Id, PatchLineComment> comments,
+      OutputStream out) {
     if (comments.isEmpty()) {
       return;
     }
+
+    List<PatchSet.Id> psIds =
+        ReviewDbUtil.intKeyOrdering().sortedCopy(comments.keySet());
+
     OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8);
     try (PrintWriter writer = new PrintWriter(streamWriter)) {
-      PatchLineComment first = comments.get(0);
+      RevId revId = comments.values().iterator().next().getRevId();
+      appendHeaderField(writer, REVISION, revId.get());
 
-      short side = first.getSide();
-      PatchSet.Id psId = PatchLineCommentsUtil.getCommentPsId(first);
-      appendHeaderField(writer, side == 0
-          ? BASE_PATCH_SET
-          : PATCH_SET,
-          Integer.toString(psId.get()));
-      appendHeaderField(writer, REVISION, first.getRevId().get());
+      for (PatchSet.Id psId : psIds) {
+        List<PatchLineComment> psComments =
+            PLC_ORDER.sortedCopy(comments.get(psId));
+        PatchLineComment first = psComments.get(0);
 
-      String currentFilename = null;
+        short side = first.getSide();
+        appendHeaderField(writer, side == 0
+            ? BASE_PATCH_SET
+            : PATCH_SET,
+            Integer.toString(psId.get()));
 
-      for (PatchLineComment c : comments) {
-        PatchSet.Id currentPsId = PatchLineCommentsUtil.getCommentPsId(c);
-        checkArgument(psId.equals(currentPsId),
-            "All comments being added must all have the same PatchSet.Id. The "
-            + "comment below does not have the same PatchSet.Id as the others "
-            + "(%s).\n%s", psId.toString(), c.toString());
-        checkArgument(side == c.getSide(),
-            "All comments being added must all have the same side. The "
-            + "comment below does not have the same side as the others "
-            + "(%s).\n%s", side, c.toString());
-        String commentFilename =
-            QuotedString.GIT_PATH.quote(c.getKey().getParentKey().getFileName());
+        String currentFilename = null;
 
-        if (!commentFilename.equals(currentFilename)) {
-          currentFilename = commentFilename;
-          writer.print("File: ");
-          writer.print(commentFilename);
-          writer.print("\n\n");
+        for (PatchLineComment c : psComments) {
+          checkArgument(revId.equals(c.getRevId()),
+              "All comments being added must have all the same RevId. The "
+              + "comment below does not have the same RevId as the others "
+              + "(%s).\n%s", revId, c);
+          checkArgument(side == c.getSide(),
+              "All comments being added must all have the same side. The "
+              + "comment below does not have the same side as the others "
+              + "(%s).\n%s", side, c);
+          String commentFilename = QuotedString.GIT_PATH.quote(
+              c.getKey().getParentKey().getFileName());
+
+          if (!commentFilename.equals(currentFilename)) {
+            currentFilename = commentFilename;
+            writer.print("File: ");
+            writer.print(commentFilename);
+            writer.print("\n\n");
+          }
+
+          appendOneComment(writer, c);
         }
-
-        // The CommentRange field for a comment is allowed to be null.
-        // If it is indeed null, then in the first line, we simply use the line
-        // number field for a comment instead. If it isn't null, we write the
-        // comment range itself.
-        CommentRange range = c.getRange();
-        if (range != null) {
-          writer.print(range.getStartLine());
-          writer.print(':');
-          writer.print(range.getStartCharacter());
-          writer.print('-');
-          writer.print(range.getEndLine());
-          writer.print(':');
-          writer.print(range.getEndCharacter());
-        } else {
-          writer.print(c.getLine());
-        }
-        writer.print("\n");
-
-        writer.print(formatTime(serverIdent, c.getWrittenOn()));
-        writer.print("\n");
-
-        PersonIdent ident = newIdent(
-            accountCache.get(c.getAuthor()).getAccount(),
-            c.getWrittenOn(), serverIdent, anonymousCowardName);
-        String nameString = ident.getName() + " <" + ident.getEmailAddress()
-            + ">";
-        appendHeaderField(writer, AUTHOR, nameString);
-
-        String parent = c.getParentUuid();
-        if (parent != null) {
-          appendHeaderField(writer, PARENT, parent);
-        }
-
-        appendHeaderField(writer, UUID, c.getKey().get());
-
-        if (c.getTag() != null) {
-          appendHeaderField(writer, TAG, c.getTag());
-        }
-
-        byte[] messageBytes = c.getMessage().getBytes(UTF_8);
-        appendHeaderField(writer, LENGTH,
-            Integer.toString(messageBytes.length));
-
-        writer.print(c.getMessage());
-        writer.print("\n\n");
       }
     }
   }
+
+  private void appendOneComment(PrintWriter writer, PatchLineComment c) {
+    // The CommentRange field for a comment is allowed to be null. If it is
+    // null, then in the first line, we simply use the line number field for a
+    // comment instead. If it isn't null, we write the comment range itself.
+    CommentRange range = c.getRange();
+    if (range != null) {
+      writer.print(range.getStartLine());
+      writer.print(':');
+      writer.print(range.getStartCharacter());
+      writer.print('-');
+      writer.print(range.getEndLine());
+      writer.print(':');
+      writer.print(range.getEndCharacter());
+    } else {
+      writer.print(c.getLine());
+    }
+    writer.print("\n");
+
+    writer.print(formatTime(serverIdent, c.getWrittenOn()));
+    writer.print("\n");
+
+    PersonIdent ident = newIdent(
+        accountCache.get(c.getAuthor()).getAccount(),
+        c.getWrittenOn(), serverIdent, anonymousCowardName);
+    String nameString = ident.getName() + " <" + ident.getEmailAddress()
+        + ">";
+    appendHeaderField(writer, AUTHOR, nameString);
+
+    String parent = c.getParentUuid();
+    if (parent != null) {
+      appendHeaderField(writer, PARENT, parent);
+    }
+
+    appendHeaderField(writer, UUID, c.getKey().get());
+
+    if (c.getTag() != null) {
+      appendHeaderField(writer, TAG, c.getTag());
+    }
+
+    byte[] messageBytes = c.getMessage().getBytes(UTF_8);
+    appendHeaderField(writer, LENGTH,
+        Integer.toString(messageBytes.length));
+
+    writer.print(c.getMessage());
+    writer.print("\n\n");
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index 4345e8b..c8364d3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -15,15 +15,16 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.PatchLineCommentsUtil.PLC_ORDER;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
 
 import java.io.ByteArrayOutputStream;
-import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -101,19 +102,17 @@
       out.write('\n');
     }
 
-    List<PatchLineComment> all =
-        new ArrayList<>(baseComments.size() + put.size());
+    Multimap<PatchSet.Id, PatchLineComment> all = ArrayListMultimap.create();
     for (PatchLineComment c : baseComments) {
       if (!delete.contains(c.getKey()) && !put.containsKey(c.getKey())) {
-        all.add(c);
+        all.put(c.getPatchSetId(), c);
       }
     }
     for (PatchLineComment c : put.values()) {
       if (!delete.contains(c.getKey())) {
-        all.add(c);
+        all.put(c.getPatchSetId(), c);
       }
     }
-    Collections.sort(all, PLC_ORDER);
     noteUtil.buildNote(all, out);
     return out.toByteArray();
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 7cb74cb..44fa6f5 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -932,8 +932,8 @@
     notes = newNotes(c);
     assertThat(readNote(notes, commit)).isEqualTo(
         pushCert
-        + "Patch-set: 2\n"
         + "Revision: " + commit.name() + "\n"
+        + "Patch-set: 2\n"
         + "File: a.txt\n"
         + "\n"
         + "1:2-3:4\n"
@@ -1314,8 +1314,9 @@
           walk.getObjectReader().open(
               note.getData(), Constants.OBJ_BLOB).getBytes();
       String noteString = new String(bytes, UTF_8);
-      assertThat(noteString).isEqualTo("Patch-set: 1\n"
-          + "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+      assertThat(noteString).isEqualTo(
+          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+          + "Patch-set: 1\n"
           + "File: file1\n"
           + "\n"
           + "1:1-2:1\n"
@@ -1384,8 +1385,9 @@
           walk.getObjectReader().open(
               note.getData(), Constants.OBJ_BLOB).getBytes();
       String noteString = new String(bytes, UTF_8);
-      assertThat(noteString).isEqualTo("Base-for-patch-set: 1\n"
-          + "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+      assertThat(noteString).isEqualTo(
+          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+          + "Base-for-patch-set: 1\n"
           + "File: file1\n"
           + "\n"
           + "1:1-2:1\n"
@@ -1406,6 +1408,91 @@
   }
 
   @Test
+  public void patchLineCommentNotesFormatMultiplePatchSetsSameRevId()
+      throws Exception {
+    Change c = newChange();
+    String uuid1 = "uuid1";
+    String uuid2 = "uuid2";
+    String uuid3 = "uuid3";
+    String message1 = "comment 1";
+    String message2 = "comment 2";
+    String message3 = "comment 3";
+    CommentRange range1 = new CommentRange(1, 1, 2, 1);
+    CommentRange range2 = new CommentRange(2, 1, 3, 1);
+    Timestamp time = TimeUtil.nowTs();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+
+    PatchSet.Id psId1 = c.currentPatchSetId();
+    PatchSet.Id psId2 = new PatchSet.Id(c.getId(), psId1.get() + 1);
+
+    PatchLineComment comment1 = newPublishedComment(psId1, "file1",
+        uuid1, range1, range1.getEndLine(), otherUser, null, time, message1,
+        (short) 0, revId.get());
+    PatchLineComment comment2 = newPublishedComment(psId1, "file1",
+        uuid2, range2, range2.getEndLine(), otherUser, null, time, message2,
+        (short) 0, revId.get());
+    PatchLineComment comment3 = newPublishedComment(psId2, "file1",
+        uuid3, range1, range1.getEndLine(), otherUser, null, time, message3,
+        (short) 0, revId.get());
+
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(psId2);
+    update.putComment(comment3);
+    update.putComment(comment2);
+    update.putComment(comment1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    try (RevWalk walk = new RevWalk(repo)) {
+      ArrayList<Note> notesInTree =
+          Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      Note note = Iterables.getOnlyElement(notesInTree);
+
+      byte[] bytes =
+          walk.getObjectReader().open(
+              note.getData(), Constants.OBJ_BLOB).getBytes();
+      String noteString = new String(bytes, UTF_8);
+      String timeStr = ChangeNoteUtil.formatTime(serverIdent, time);
+      assertThat(noteString).isEqualTo(
+          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+          + "Base-for-patch-set: 1\n"
+          + "File: file1\n"
+          + "\n"
+          + "1:1-2:1\n"
+          + timeStr + "\n"
+          + "Author: Other Account <2@gerrit>\n"
+          + "UUID: uuid1\n"
+          + "Bytes: 9\n"
+          + "comment 1\n"
+          + "\n"
+          + "2:1-3:1\n"
+          + timeStr + "\n"
+          + "Author: Other Account <2@gerrit>\n"
+          + "UUID: uuid2\n"
+          + "Bytes: 9\n"
+          + "comment 2\n"
+          + "\n"
+          + "Base-for-patch-set: 2\n"
+          + "File: file1\n"
+          + "\n"
+          + "1:1-2:1\n"
+          + timeStr + "\n"
+          + "Author: Other Account <2@gerrit>\n"
+          + "UUID: uuid3\n"
+          + "Bytes: 9\n"
+          + "comment 3\n"
+          + "\n");
+    }
+
+    assertThat(notes.getComments()).isEqualTo(
+        ImmutableMultimap.of(
+            revId, comment1,
+            revId, comment2,
+            revId, comment3));
+  }
+
+  @Test
   public void patchLineCommentMultipleOnePatchsetOneFileBothSides()
       throws Exception {
     Change c = newChange();