// Copyright (C) 2020 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.gerrit.acceptance.api.revision;

import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.Iterables.getOnlyElement;
import static com.google.common.collect.MoreCollectors.onlyElement;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThat;
import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThatList;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static com.google.gerrit.truth.MapSubject.assertThatMap;

import com.google.common.collect.ImmutableList;
import com.google.common.truth.Correspondence;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
import com.google.gerrit.acceptance.testsuite.change.TestCommentCreation;
import com.google.gerrit.acceptance.testsuite.change.TestPatchset;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.common.CommentInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.truth.NullAwareCorrespondence;
import com.google.inject.Inject;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.junit.Ignore;
import org.junit.Test;

public class PortedCommentsIT extends AbstractDaemonTest {

  @Inject private ChangeOperations changeOps;
  @Inject private AccountOperations accountOps;
  @Inject private RequestScopeOperations requestScopeOps;

  @Test
  public void onlyCommentsBeforeTargetPatchsetArePorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    PatchSet.Id patchset3Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    String comment1Uuid = newComment(patchset1Id).create();
    newComment(patchset2Id).create();
    newComment(patchset3Id).create();

    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));

    assertThatList(portedComments).comparingElementsUsing(hasUuid()).containsExactly(comment1Uuid);
  }

  @Test
  public void commentsOnAnyPatchsetBeforeTargetPatchsetArePorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    changeOps.change(changeId).newPatchset().create();
    PatchSet.Id patchset3Id = changeOps.change(changeId).newPatchset().create();
    PatchSet.Id patchset4Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    String comment1Uuid = newComment(patchset1Id).create();
    String comment3Uuid = newComment(patchset3Id).create();

    List<CommentInfo> portedComments = flatten(getPortedComments(patchset4Id));

    assertThat(portedComments)
        .comparingElementsUsing(hasUuid())
        .containsExactly(comment1Uuid, comment3Uuid);
  }

  @Test
  public void severalCommentsFromEarlierPatchsetArePorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    String comment1Uuid = newComment(patchset1Id).create();
    String comment2Uuid = newComment(patchset1Id).create();

    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));

    assertThat(portedComments)
        .comparingElementsUsing(hasUuid())
        .containsExactly(comment1Uuid, comment2Uuid);
  }

  @Test
  public void completeCommentThreadIsPorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    String rootCommentUuid = newComment(patchset1Id).create();
    String child1CommentUuid = newComment(patchset1Id).parentUuid(rootCommentUuid).create();
    String child2CommentUuid = newComment(patchset1Id).parentUuid(child1CommentUuid).create();

    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));

    assertThat(portedComments)
        .comparingElementsUsing(hasUuid())
        .containsExactly(rootCommentUuid, child1CommentUuid, child2CommentUuid);
  }

  @Test
  public void onlyUnresolvedPublishedCommentsArePorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    newComment(patchset1Id).resolved().create();
    String comment2Uuid = newComment(patchset1Id).unresolved().create();

    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));

    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(comment2Uuid);
  }

  @Test
  public void resolvedAndUnresolvedDraftCommentsArePorted() throws Exception {
    Account.Id accountId = accountOps.newAccount().create();
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    String comment1Uuid = newDraftComment(patchset1Id).author(accountId).resolved().create();
    String comment2Uuid = newDraftComment(patchset1Id).author(accountId).unresolved().create();

    List<CommentInfo> portedComments =
        flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));

    assertThat(portedComments)
        .comparingElementsUsing(hasUuid())
        .containsExactly(comment1Uuid, comment2Uuid);
  }

  @Test
  public void unresolvedStateOfLastCommentInThreadMatters() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    String rootComment1Uuid = newComment(patchset1Id).resolved().create();
    String childComment1Uuid =
        newComment(patchset1Id).parentUuid(rootComment1Uuid).unresolved().create();
    String rootComment2Uuid = newComment(patchset1Id).unresolved().create();
    newComment(patchset1Id).parentUuid(rootComment2Uuid).resolved().create();

    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));

    assertThat(portedComments)
        .comparingElementsUsing(hasUuid())
        .containsExactly(rootComment1Uuid, childComment1Uuid);
  }

  @Test
  public void unresolvedStateOfLastCommentByDateMattersForBranchedThreads() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comments. Comments should be more than 1 second apart as NoteDb only supports second
    // precision.
    LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
    String rootCommentUuid = newComment(patchset1Id).resolved().createdOn(now).create();
    String childComment1Uuid =
        newComment(patchset1Id)
            .parentUuid(rootCommentUuid)
            .resolved()
            .createdOn(now.plusSeconds(5))
            .create();
    String childComment2Uuid =
        newComment(patchset1Id)
            .parentUuid(rootCommentUuid)
            .unresolved()
            .createdOn(now.plusSeconds(10))
            .create();

    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));

    assertThat(portedComments)
        .comparingElementsUsing(hasUuid())
        .containsExactly(rootCommentUuid, childComment1Uuid, childComment2Uuid);
  }

  @Test
  public void unresolvedStateOfDraftCommentsIsIgnoredForPublishedComments() throws Exception {
    Account.Id accountId = accountOps.newAccount().create();
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    String rootComment1Uuid = newComment(patchset1Id).resolved().create();
    newDraftComment(patchset1Id)
        .author(accountId)
        .parentUuid(rootComment1Uuid)
        .unresolved()
        .create();
    String rootComment2Uuid = newComment(patchset1Id).unresolved().create();
    newDraftComment(patchset1Id).author(accountId).parentUuid(rootComment2Uuid).resolved().create();

    // Draft comments are only visible to their author.
    requestScopeOps.setApiUser(accountId);
    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));

    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(rootComment2Uuid);
  }

  @Test
  public void draftCommentsAreNotPortedViaApiForPublishedComments() throws Exception {
    Account.Id accountId = accountOps.newAccount().create();
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add draft comment.
    newDraftComment(patchset1Id).author(accountId).create();

    // Draft comments are only visible to their author.
    requestScopeOps.setApiUser(accountId);
    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));

    assertThatList(portedComments).isEmpty();
  }

  @Test
  public void publishedCommentsAreNotPortedViaApiForDraftComments() throws Exception {
    Account.Id accountId = accountOps.newAccount().create();
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    newComment(patchset1Id).author(accountId).create();

    List<CommentInfo> portedComments =
        flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));

    assertThatList(portedComments).isEmpty();
  }

  @Test
  public void draftCommentCanBePorted() throws Exception {
    Account.Id accountId = accountOps.newAccount().create();
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add draft comment.
    newComment(patchset1Id).author(accountId).create();

    List<CommentInfo> portedComments =
        flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));

    assertThatList(portedComments).isEmpty();
  }

  @Test
  public void portedDraftCommentOfOtherUserIsNotVisible() throws Exception {
    Account.Id userId = accountOps.newAccount().create();
    Account.Id otherUserId = accountOps.newAccount().create();
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add draft comment.
    newComment(patchset1Id).author(otherUserId).create();

    List<CommentInfo> portedComments = flatten(getPortedDraftCommentsOfUser(patchset2Id, userId));

    assertThatList(portedComments).isEmpty();
  }

  @Test
  public void publishedCommentsOfAllTypesArePorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\nLine 2\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    String rangeCommentUuid =
        newComment(patchset1Id)
            .message("Range comment")
            .fromLine(1)
            .charOffset(2)
            .toLine(2)
            .charOffset(1)
            .ofFile("myFile")
            .create();
    String lineCommentUuid =
        newComment(patchset1Id).message("Line comment").onLine(1).ofFile("myFile").create();
    String fileCommentUuid =
        newComment(patchset1Id).message("File comment").onFileLevelOf("myFile").create();
    String patchsetLevelCommentUuid =
        newComment(patchset1Id).message("Patchset-level comment").onPatchsetLevel().create();

    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));

    assertThat(portedComments)
        .comparingElementsUsing(hasUuid())
        .containsExactly(
            rangeCommentUuid, lineCommentUuid, fileCommentUuid, patchsetLevelCommentUuid);
  }

  @Test
  public void commentOnParentCommitIsPorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    String commentUuid = newComment(patchset1Id).onParentCommit().create();

    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));

    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
  }

  @Test
  public void commentOnInvalidParentIsPorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    String commentUuid = newComment(patchset1Id).onSecondParentCommit().create();

    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));

    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
  }

  @Test
  public void commentsOnInvalidPositionArePorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    String commentUuid1 = newComment(patchset1Id).onFileLevelOf("not-existing file").create();
    String commentUuid2 = newComment(patchset1Id).onLine(3).ofFile("myFile").create();

    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));

    assertThat(portedComments)
        .comparingElementsUsing(hasUuid())
        .containsExactly(commentUuid1, commentUuid2);
  }

  @Test
  public void commentsOnInvalidPositionKeepTheirInvalidPosition() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    newComment(patchset1Id).onFileLevelOf("not-existing file").create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);

    assertThatMap(portedComments).keys().containsExactly("not-existing file");
  }

  @Test
  public void portedCommentHasOriginalUuid() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).create();

    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));

    assertThatList(portedComments).onlyElement().uuid().isEqualTo(commentUuid);
  }

  @Test
  public void portedCommentHasOriginalPatchset() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).patchSet().isEqualTo(patchset1Id.get());
  }

  @Test
  public void portedDraftCommentHasPatchsetFilled() throws Exception {
    // Set up change and patchsets.
    Account.Id authorId = accountOps.newAccount().create();
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    String commentUuid = newDraftComment(patchset1Id).author(authorId).create();

    Map<String, List<CommentInfo>> portedComments =
        getPortedDraftCommentsOfUser(patchset2Id, authorId);
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);

    // We explicitly need to request that the patchset field is filled, which we could have missed
    // for drafts. -> Test that aspect. Don't verify the actual patchset number as that's already
    // covered by the previous test.
    assertThat(portedComment).patchSet().isNotNull();
  }

  @Test
  public void portedCommentHasOriginalPatchsetCommitId() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    String commentUuid = newComment(patchset1.patchsetId()).create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).commitId().isEqualTo(patchset1.commitId().name());
  }

  @Test
  public void portedCommentHasOriginalMessage() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    String commentUuid = newComment(patchset1.patchsetId()).message("My comment text").create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).message().isEqualTo("My comment text");
  }

  @Test
  public void portedReplyStillRefersToParentComment() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comments.
    String rootCommentUuid = newComment(patchset1.patchsetId()).create();
    String childCommentUuid =
        newComment(patchset1.patchsetId()).parentUuid(rootCommentUuid).create();

    CommentInfo portedComment = getPortedComment(patchset2Id, childCommentUuid);

    assertThat(portedComment).inReplyTo().isEqualTo(rootCommentUuid);
  }

  @Test
  public void portedPublishedCommentHasOriginalAuthor() throws Exception {
    // Set up change and patchsets.
    Account.Id authorId = accountOps.newAccount().create();
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).author(authorId).create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).author().id().isEqualTo(authorId.get());
  }

  @Test
  public void anonymousUsersGetAuthExceptionForPortedDrafts() throws Exception {
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchsetId = changeOps.change(changeId).currentPatchset().get().patchsetId();

    requestScopeOps.setApiUserAnonymous();
    AuthException thrown =
        assertThrows(
            AuthException.class,
            () ->
                gApi.changes()
                    .id(patchsetId.changeId().get())
                    .revision(patchsetId.get())
                    .portedDrafts());
    assertThat(thrown)
        .hasMessageThat()
        .contains("requires authentication; only authenticated users can have drafts");
  }

  @Test
  public void portedDraftCommentHasNoAuthor() throws Exception {
    // Set up change and patchsets.
    Account.Id authorId = accountOps.newAccount().create();
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    String commentUuid = newDraftComment(patchset1Id).author(authorId).create();

    Map<String, List<CommentInfo>> portedComments =
        getPortedDraftCommentsOfUser(patchset2Id, authorId);
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);

    // Authors of draft comments are never set.
    assertThat(portedComment).author().isNull();
  }

  @Test
  public void portedCommentHasOriginalTag() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    String commentUuid = newComment(patchset1.patchsetId()).tag("My comment tag").create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).tag().isEqualTo("My comment tag");
  }

  @Test
  public void portedCommentHasUpdatedTimestamp() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).updated().isNotNull();
  }

  @Test
  public void portedCommentDoesNotHaveChangeMessageId() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    // There's currently no use case for linking ported comments to specific change messages. Hence,
    // there's no reason to fill this field, which requires additional computations.
    // Besides, we also don't fill this field for the comments requested for a specific patchset.
    assertThat(portedComment).changeMessageId().isNull();
  }

  @Test
  public void pathOfPortedCommentIsOnlyIndicatedInMap() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);

    assertThatMap(portedComments).keys().containsExactly("myFile");
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
    assertThat(portedComment).path().isNull();
  }

  @Test
  public void portedRangeCommentCanHandleAddedLines() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(3)
            .charOffset(2)
            .toLine(4)
            .charOffset(5)
            .ofFile("myFile")
            .create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).range().startLine().isEqualTo(5);
    assertThat(portedComment).range().startCharacter().isEqualTo(2);
    assertThat(portedComment).range().endLine().isEqualTo(6);
    assertThat(portedComment).range().endCharacter().isEqualTo(5);
  }

  @Test
  public void portedRangeCommentCanHandleDeletedLines() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 2\nLine 3\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(3)
            .charOffset(2)
            .toLine(4)
            .charOffset(5)
            .ofFile("myFile")
            .create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).range().startLine().isEqualTo(2);
    assertThat(portedComment).range().startCharacter().isEqualTo(2);
    assertThat(portedComment).range().endLine().isEqualTo(3);
    assertThat(portedComment).range().endCharacter().isEqualTo(5);
  }

  @Test
  public void portedRangeCommentCanHandlePureRename() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(3)
            .charOffset(2)
            .toLine(4)
            .charOffset(5)
            .ofFile("myFile")
            .create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);

    assertThatMap(portedComments).keys().containsExactly("newFileName");
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
    assertThat(portedComment).range().startLine().isEqualTo(3);
    assertThat(portedComment).range().startCharacter().isEqualTo(2);
    assertThat(portedComment).range().endLine().isEqualTo(4);
    assertThat(portedComment).range().endCharacter().isEqualTo(5);
  }

  @Test
  public void portedRangeCommentCanHandleRenameWithLineShift() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .delete()
            .file("newFileName")
            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(3)
            .charOffset(2)
            .toLine(4)
            .charOffset(5)
            .ofFile("myFile")
            .create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);

    assertThatMap(portedComments).keys().containsExactly("newFileName");
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
    assertThat(portedComment).range().startLine().isEqualTo(5);
    assertThat(portedComment).range().startCharacter().isEqualTo(2);
    assertThat(portedComment).range().endLine().isEqualTo(6);
    assertThat(portedComment).range().endCharacter().isEqualTo(5);
  }

  @Test
  public void portedRangeCommentAdditionallyAppearsOnCopyAtIndependentPosition() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    // Gerrit currently only identifies a copy if a rename also happens at the same time. Modify the
    // renamed file slightly different than the copied file so that the end location of the comment
    // is different. Modify the renamed file less so that Gerrit/Git picks it as the renamed one.
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .delete()
            .file("renamedFiled")
            .content("Line 1\nLine 1.1\nLine 2\nLine 3\nLine 4\n")
            .file("copiedFile")
            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(3)
            .charOffset(2)
            .toLine(4)
            .charOffset(5)
            .ofFile("myFile")
            .create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);

    assertThatMap(portedComments).keys().containsExactly("renamedFiled", "copiedFile");
    CommentInfo portedCommentOnRename = getOnlyElement(portedComments.get("renamedFiled"));
    assertThat(portedCommentOnRename).uuid().isEqualTo(commentUuid);
    assertThat(portedCommentOnRename).range().startLine().isEqualTo(4);
    assertThat(portedCommentOnRename).range().startCharacter().isEqualTo(2);
    assertThat(portedCommentOnRename).range().endLine().isEqualTo(5);
    assertThat(portedCommentOnRename).range().endCharacter().isEqualTo(5);
    CommentInfo portedCommentOnCopy = getOnlyElement(portedComments.get("copiedFile"));
    assertThat(portedCommentOnCopy).uuid().isEqualTo(commentUuid);
    assertThat(portedCommentOnCopy).range().startLine().isEqualTo(5);
    assertThat(portedCommentOnCopy).range().startCharacter().isEqualTo(2);
    assertThat(portedCommentOnCopy).range().endLine().isEqualTo(6);
    assertThat(portedCommentOnCopy).range().endCharacter().isEqualTo(5);
  }

  @Test
  public void lineOfPortedRangeCommentFollowsContract() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(3)
            .charOffset(2)
            .toLine(4)
            .charOffset(5)
            .ofFile("myFile")
            .create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    // Line is equal to the end line, which is at 6 when ported.
    assertThat(portedComment).line().isEqualTo(6);
  }

  @Test
  public void portedRangeCommentBecomesFileCommentOnConflict() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine two\nLine three\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(2)
            .charOffset(2)
            .toLine(3)
            .charOffset(5)
            .ofFile("myFile")
            .create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);

    assertThatMap(portedComments).keys().containsExactly("myFile");
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
    assertThat(portedComment).range().isNull();
    assertThat(portedComment).line().isNull();
  }

  @Test
  public void portedRangeCommentEndingOnLineJustBeforeModificationCanBePorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 2\nLine three\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(1)
            .charOffset(2)
            .toLine(2)
            .charOffset(5)
            .ofFile("myFile")
            .create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).range().startLine().isEqualTo(1);
    assertThat(portedComment).range().startCharacter().isEqualTo(2);
    assertThat(portedComment).range().endLine().isEqualTo(2);
    assertThat(portedComment).range().endCharacter().isEqualTo(5);
  }

  @Test
  public void portedRangeCommentEndingAtStartOfModifiedLineCanBePorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 2\nLine three\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(1)
            .charOffset(2)
            .toLine(3)
            .charOffset(0)
            .ofFile("myFile")
            .create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).range().startLine().isEqualTo(1);
    assertThat(portedComment).range().startCharacter().isEqualTo(2);
    assertThat(portedComment).range().endLine().isEqualTo(3);
    assertThat(portedComment).range().endCharacter().isEqualTo(0);
  }

  @Test
  public void portedRangeCommentEndingWithinModifiedLineBecomesFileComment() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 2\nLine three\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(1)
            .charOffset(2)
            .toLine(3)
            .charOffset(4)
            .ofFile("myFile")
            .create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).range().isNull();
    assertThat(portedComment).line().isNull();
  }

  @Test
  public void portedRangeCommentWithinModifiedLineBecomesFileComment() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 2\nLine three\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(3)
            .charOffset(2)
            .toLine(3)
            .charOffset(5)
            .ofFile("myFile")
            .create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).range().isNull();
    assertThat(portedComment).line().isNull();
  }

  @Test
  public void portedRangeCommentStartingWithinLastModifiedLineBecomesFileComment()
      throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line one\nLine two\nLine 3\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(2)
            .charOffset(2)
            .toLine(4)
            .charOffset(5)
            .ofFile("myFile")
            .create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).range().isNull();
    assertThat(portedComment).line().isNull();
  }

  @Test
  public void portedRangeCommentStartingOnLineJustAfterModificationCanBePorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine two\nLine 3\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(3)
            .charOffset(2)
            .toLine(4)
            .charOffset(5)
            .ofFile("myFile")
            .create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).range().startLine().isEqualTo(3);
    assertThat(portedComment).range().startCharacter().isEqualTo(2);
    assertThat(portedComment).range().endLine().isEqualTo(4);
    assertThat(portedComment).range().endCharacter().isEqualTo(5);
  }

  // We could actually do better in such a situation but that involves some careful improvements
  // which would need to be covered with even more tests (e.g. several modifications could be within
  // the comment range; several comments could surround it; other modifications could have occurred
  // in the file so that start is shifted too but different than end). That's why we go for the
  // simple solution now (-> just map to file comment).
  @Test
  public void portedRangeCommentStartingBeforeButEndingAfterModifiedLineBecomesFileComment()
      throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 2\nLine three\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(2)
            .charOffset(2)
            .toLine(4)
            .charOffset(5)
            .ofFile("myFile")
            .create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).range().isNull();
    assertThat(portedComment).line().isNull();
  }

  @Test
  public void portedRangeCommentBecomesPatchsetLevelCommentOnFileDeletion() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps.change(changeId).newPatchset().file("myFile").delete().create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            .fromLine(3)
            .charOffset(2)
            .toLine(4)
            .charOffset(5)
            .ofFile("myFile")
            .create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
    assertThat(portedComment).range().isNull();
    assertThat(portedComment).line().isNull();
  }

  @Test
  public void overlappingRangeCommentsArePortedToNewPosition() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\nLine 2\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 1.1\nLine 2\n")
            .create();
    // Add comment.
    String commentUuid1 =
        newComment(patchset1Id)
            .fromLine(2)
            .charOffset(0)
            .toLine(2)
            .charOffset(3)
            .ofFile("myFile")
            .create();
    String commentUuid2 =
        newComment(patchset1Id)
            .fromLine(2)
            .charOffset(1)
            .toLine(2)
            .charOffset(4)
            .ofFile("myFile")
            .create();

    CommentInfo portedComment1 = getPortedComment(patchset2Id, commentUuid1);
    assertThat(portedComment1).range().startLine().isEqualTo(3);
    CommentInfo portedComment2 = getPortedComment(patchset2Id, commentUuid2);
    assertThat(portedComment2).range().startLine().isEqualTo(3);
  }

  @Test
  public void portedLineCommentCanHandleAddedLines() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).line().isEqualTo(5);
  }

  @Test
  public void portedLineCommentCanHandleDeletedLines() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 2\nLine 3\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).line().isEqualTo(2);
  }

  @Test
  public void portedLineCommentCanHandlePureRename() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);

    assertThatMap(portedComments).keys().containsExactly("newFileName");
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
    assertThat(portedComment).line().isEqualTo(3);
  }

  @Test
  public void portedLineCommentCanHandleRenameWithLineShift() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    changeOps
        .change(changeId)
        .newPatchset()
        .file("myFile")
        .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
        .create();
    PatchSet.Id patchset3Id =
        changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset3Id);

    assertThatMap(portedComments).keys().containsExactly("newFileName");
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
    assertThat(portedComment).line().isEqualTo(5);
  }

  @Test
  public void portedLineCommentAdditionallyAppearsOnCopyAtIndependentPosition() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    // Gerrit currently only identifies a copy if a rename also happens at the same time. Modify the
    // renamed file slightly different than the copied file so that the end location of the comment
    // is different. Modify the renamed file less so that Gerrit/Git picks it as the renamed one.
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .delete()
            .file("renamedFiled")
            .content("Line 1\nLine 1.1\nLine 2\nLine 3\nLine 4\n")
            .file("copiedFile")
            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);

    assertThatMap(portedComments).keys().containsExactly("renamedFiled", "copiedFile");
    CommentInfo portedCommentOnRename = getOnlyElement(portedComments.get("renamedFiled"));
    assertThat(portedCommentOnRename).uuid().isEqualTo(commentUuid);
    assertThat(portedCommentOnRename).line().isEqualTo(4);
    CommentInfo portedCommentOnCopy = getOnlyElement(portedComments.get("copiedFile"));
    assertThat(portedCommentOnCopy).uuid().isEqualTo(commentUuid);
    assertThat(portedCommentOnCopy).line().isEqualTo(5);
  }

  @Test
  public void portedLineCommentBecomesFileCommentOnConflict() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine two\nLine three\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onLine(2).ofFile("myFile").create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);

    assertThatMap(portedComments).keys().containsExactly("myFile");
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
    assertThat(portedComment).line().isNull();
  }

  @Test
  public void portedLineCommentOnLineJustBeforeModificationCanBePorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 2\nLine three\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onLine(2).ofFile("myFile").create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).line().isEqualTo(2);
  }

  @Test
  public void portedLineCommentOnStartLineOfModificationBecomesFileComment() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 2\nSome completely\ndifferent\ncontent\n")
            .create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).line().isNull();
  }

  @Test
  public void portedLineCommentOnLastLineOfModificationBecomesFileComment() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 2\nSome completely\ndifferent\ncontent\n")
            .create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onLine(4).ofFile("myFile").create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).line().isNull();
  }

  @Test
  public void portedLineCommentOnLineJustAfterModificationCanBePorted() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 2\nLine three\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onLine(4).ofFile("myFile").create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).line().isEqualTo(4);
  }

  @Test
  public void portedLineCommentBecomesPatchsetLevelCommentOnFileDeletion() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps.change(changeId).newPatchset().file("myFile").delete().create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
    assertThat(portedComment).range().isNull();
    assertThat(portedComment).line().isNull();
  }

  @Test
  public void overlappingLineCommentsArePortedToNewPosition() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\nLine 2\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 1.1\nLine 2\n")
            .create();
    // Add comment.
    String commentUuid1 = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
    String commentUuid2 = newComment(patchset1Id).onLine(2).ofFile("myFile").create();

    CommentInfo portedComment1 = getPortedComment(patchset2Id, commentUuid1);
    assertThat(portedComment1).line().isEqualTo(3);
    CommentInfo portedComment2 = getPortedComment(patchset2Id, commentUuid2);
    assertThat(portedComment2).line().isEqualTo(3);
  }

  @Test
  public void portedFileCommentIsObliviousToAdjustedFileContent() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
            .create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).line().isNull();
  }

  @Test
  public void portedFileCommentCanHandleRename() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);

    assertThatMap(portedComments).keys().containsExactly("newFileName");
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
    assertThat(portedComment).line().isNull();
  }

  @Test
  public void portedFileCommentAdditionallyAppearsOnCopy() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .renameTo("renamedFiled")
            .file("copiedFile")
            .content("Line 1\nLine 2\nLine 3\nLine 4\n")
            .create();
    // Add comment.
    newComment(patchset1Id).onFileLevelOf("myFile").create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);

    assertThatMap(portedComments).keys().containsExactly("renamedFiled", "copiedFile");
    CommentInfo portedCommentOnCopy = getOnlyElement(portedComments.get("copiedFile"));
    assertThat(portedCommentOnCopy).line().isNull();
  }

  @Test
  public void portedFileCommentBecomesPatchsetLevelCommentOnFileDeletion() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps.change(changeId).newPatchset().file("myFile").delete().create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
    assertThat(portedComment).range().isNull();
    assertThat(portedComment).line().isNull();
  }

  @Test
  public void portedPatchsetLevelCommentIsObliviousToAdjustedFileContent() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .file("myFile")
            .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
            .create();
    // Add comment.
    newComment(patchset1Id).onPatchsetLevel().create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);

    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
  }

  @Test
  public void portedPatchsetLevelCommentIsObliviousToRename() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
    // Add comment.
    newComment(patchset1Id).onPatchsetLevel().create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);

    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
  }

  @Test
  public void commentOnCommitMessageIsPortedToNewPosition() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId =
        changeOps.newChange().commitMessage("Summary line\n\nText 1\nText 2").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps
            .change(changeId)
            .newPatchset()
            .commitMessage("Summary line\n\nText 1\nText 1.1\nText 2")
            .create();
    // Add comment.
    String commentUuid =
        newComment(patchset1Id)
            // The /COMMIT_MSG file has a header of 6 lines, so the summary line is in line 7.
            // Place comment on 'Text 2' which is line 10.
            .onLine(10)
            .ofFile(Patch.COMMIT_MSG)
            .create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).line().isEqualTo(11);
  }

  @Test
  public void commentOnParentIsPortedToNewPosition() throws Exception {
    // Set up change and patchsets.
    Change.Id parentChangeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
    Change.Id childChangeId =
        changeOps
            .newChange()
            .childOf()
            .change(parentChangeId)
            .file("myFile")
            .content("Line one\n")
            .create();
    PatchSet.Id childPatchset1Id =
        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
    PatchSet.Id parentPatchset2Id =
        changeOps
            .change(parentChangeId)
            .newPatchset()
            .file("myFile")
            .content("Line 0\nLine 1\n")
            .create();
    PatchSet.Id childPatchset2Id =
        changeOps.change(childChangeId).newPatchset().parent().patchset(parentPatchset2Id).create();
    // Add comment.
    String commentUuid =
        newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("myFile").create();

    CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);

    assertThat(portedComment).line().isEqualTo(2);
  }

  @Test
  public void commentOnFirstParentIsPortedToNewPosition() throws Exception {
    // Set up change and patchsets.
    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
    Change.Id childChangeId =
        changeOps
            .newChange()
            .mergeOf()
            .change(parent1ChangeId)
            .and()
            .change(parent2ChangeId)
            .file("file1")
            .content("Line one\n")
            .create();
    PatchSet.Id childPatchset1Id =
        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
    PatchSet.Id parent1Patchset2Id =
        changeOps
            .change(parent1ChangeId)
            .newPatchset()
            .file("file1")
            .content("Line 0\nLine 1\n")
            .create();
    PatchSet.Id childPatchset2Id =
        changeOps
            .change(childChangeId)
            .newPatchset()
            .parents()
            .patchset(parent1Patchset2Id)
            .and()
            .change(parent2ChangeId)
            .create();
    // Add comment.
    String commentUuid =
        newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("file1").create();

    CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);

    assertThat(portedComment).line().isEqualTo(2);
  }

  @Test
  public void commentOnSecondParentIsPortedToNewPosition() throws Exception {
    // Set up change and patchsets.
    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
    Change.Id childChangeId =
        changeOps
            .newChange()
            .mergeOf()
            .change(parent1ChangeId)
            .and()
            .change(parent2ChangeId)
            .file("file2")
            .content("Line one\n")
            .create();
    PatchSet.Id childPatchset1Id =
        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
    PatchSet.Id parent2Patchset2Id =
        changeOps
            .change(parent1ChangeId)
            .newPatchset()
            .file("file2")
            .content("Line 0\nLine 1\n")
            .create();
    PatchSet.Id childPatchset2Id =
        changeOps
            .change(childChangeId)
            .newPatchset()
            .parents()
            .change(parent1ChangeId)
            .and()
            .patchset(parent2Patchset2Id)
            .create();
    // Add comment.
    String commentUuid =
        newComment(childPatchset1Id).onSecondParentCommit().onLine(1).ofFile("file2").create();

    CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);

    assertThat(portedComment).line().isEqualTo(2);
  }

  @Test
  public void commentOnAutoMergeCommitIsPortedToNewPosition() throws Exception {
    // Set up change and patchsets. Use the same file so that there's a meaningful auto-merge
    // commit/diff.
    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
    Change.Id parent2ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
    Change.Id childChangeId =
        changeOps
            .newChange()
            .mergeOf()
            .change(parent1ChangeId)
            .and()
            .change(parent2ChangeId)
            .create();
    PatchSet.Id childPatchset1Id =
        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
    PatchSet.Id parent1Patchset2Id =
        changeOps
            .change(parent1ChangeId)
            .newPatchset()
            .file("file1")
            .content("Line 0\nLine 1\n")
            .create();
    PatchSet.Id parent2Patchset2Id =
        changeOps
            .change(parent1ChangeId)
            .newPatchset()
            .file("file1")
            .content("Line zero\nLine 1\n")
            .create();
    PatchSet.Id childPatchset2Id =
        changeOps
            .change(childChangeId)
            .newPatchset()
            .parents()
            .patchset(parent1Patchset2Id)
            .and()
            .patchset(parent2Patchset2Id)
            .create();
    // Add comment.
    String commentUuid =
        newComment(childPatchset1Id).onAutoMergeCommit().onLine(1).ofFile("file1").create();

    CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);

    // Merging the parents creates a conflict in the file. -> Several lines are added due to
    // conflict markers in the auto-merge commit. We don't care about the exact number, just that
    // the comment moved down several lines (instead of just one in each parent) and that the
    // porting logic hence used the auto-merge commit for its computation.
    assertThat(portedComment).line().isGreaterThan(2);
  }

  @Test
  public void commentOnFirstParentIsPortedToSingleParentWhenPatchsetChangedToNonMergeCommit()
      throws Exception {
    // Set up change and patchsets.
    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
    Change.Id childChangeId =
        changeOps
            .newChange()
            .mergeOf()
            .change(parent1ChangeId)
            .and()
            .change(parent2ChangeId)
            .create();
    PatchSet.Id childPatchset1Id =
        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
    PatchSet.Id parent1PatchsetId2 =
        changeOps
            .change(parent1ChangeId)
            .newPatchset()
            .file("file1")
            .content("Line 0\nLine 1\n")
            .create();
    PatchSet.Id childPatchset2Id =
        changeOps
            .change(childChangeId)
            .newPatchset()
            .parent()
            .patchset(parent1PatchsetId2)
            .create();
    // Add comment.
    String commentUuid =
        newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("file1").create();

    CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);

    assertThat(portedComment).line().isEqualTo(2);
    assertThat(portedComment).side().isEqualTo(Side.PARENT);
    assertThat(portedComment).parent().isEqualTo(1);
  }

  @Test
  public void commentOnSecondParentBecomesPatchsetLevelCommentWhenPatchsetChangedToNonMergeCommit()
      throws Exception {
    // Set up change and patchsets.
    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
    Change.Id childChangeId =
        changeOps
            .newChange()
            .mergeOf()
            .change(parent1ChangeId)
            .and()
            .change(parent2ChangeId)
            .create();
    PatchSet.Id childPatchset1Id =
        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
    PatchSet.Id childPatchset2Id =
        changeOps.change(childChangeId).newPatchset().parent().change(parent1ChangeId).create();
    // Add comment.
    String commentUuid =
        newComment(childPatchset1Id).onSecondParentCommit().onLine(1).ofFile("file2").create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(childPatchset2Id);
    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
    assertThat(portedComment).line().isNull();
    assertThat(portedComment).side().isNull();
    assertThat(portedComment).parent().isNull();
  }

  @Test
  // TODO(ghareeb): Adjust implementation in CommentsUtil to use the new auto-merge code instead of
  // PatchListCache#getOldId which returns the wrong result if a change isn't a merge commit.
  @Ignore
  public void
      commentOnAutoMergeCommitBecomesPatchsetLevelCommentWhenPatchsetChangedToNonMergeCommit()
          throws Exception {
    // Set up change and patchsets.
    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
    Change.Id parent2ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
    Change.Id childChangeId =
        changeOps
            .newChange()
            .mergeOf()
            .change(parent1ChangeId)
            .and()
            .change(parent2ChangeId)
            .create();
    PatchSet.Id childPatchset1Id =
        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
    PatchSet.Id childPatchset2Id =
        changeOps.change(childChangeId).newPatchset().parent().change(parent1ChangeId).create();
    // Add comment.
    String commentUuid =
        newComment(childPatchset1Id).onAutoMergeCommit().onLine(1).ofFile("file1").create();

    Map<String, List<CommentInfo>> portedComments = getPortedComments(childPatchset2Id);
    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
    assertThat(portedComment).line().isNull();
    assertThat(portedComment).side().isNull();
    assertThat(portedComment).parent().isNull();
  }

  @Test
  public void whitespaceOnlyModificationsAreAlsoConsideredWhenPorting() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchset2Id =
        changeOps.change(changeId).newPatchset().file("myFile").content("\nLine 1\n").create();
    // Add comment.
    String commentUuid = newComment(patchset1Id).onLine(1).ofFile("myFile").create();

    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);

    assertThat(portedComment).line().isEqualTo(2);
  }

  @Test
  public void deletedCommentContentIsNotCachedInPortedComments() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchsetId1 = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchsetId2 = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    String commentUuid = newComment(patchsetId1).message("Confidential content").create();

    getPortedComment(patchsetId2, commentUuid);
    gApi.changes()
        .id(changeId.get())
        .revision(patchsetId1.get())
        .comment(commentUuid)
        .delete(new DeleteCommentInput());
    CommentInfo portedComment = getPortedComment(patchsetId2, commentUuid);

    assertThat(portedComment).message().doesNotContain("Confidential content");
  }

  @Test
  public void setOfPortedCommentsCanChangeOnRepeatedCalls() throws Exception {
    // Set up change and patchsets.
    Change.Id changeId = changeOps.newChange().create();
    PatchSet.Id patchsetId1 = changeOps.change(changeId).currentPatchset().get().patchsetId();
    PatchSet.Id patchsetId2 = changeOps.change(changeId).newPatchset().create();
    // Add comment.
    String commentUuid1 = newComment(patchsetId1).unresolved().create();

    ImmutableList<CommentInfo> pastPortedComments = flatten(getPortedComments(patchsetId2));
    // Set the existing comment thread to resolved, so it won't be ported anymore.
    newComment(patchsetId1).parentUuid(commentUuid1).resolved().create();
    // Create a new comment which should show up as ported comment.
    String commentUuid2 = newComment(patchsetId1).create();
    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchsetId2));

    // Ensure that results are not cached between calls. This should not be necessary as the diffs
    // are already cached. If we need to also cache the ported comments in the future, we'll need to
    // identify ALL situations when the set of ported comments changes.
    assertThat(portedComments).isNotEqualTo(pastPortedComments);
    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid2);
  }

  private TestCommentCreation.Builder newComment(PatchSet.Id patchsetId) {
    // Create unresolved comments by default as only those are ported. Tests get override the
    // unresolved state by explicitly setting it.
    return changeOps.change(patchsetId.changeId()).patchset(patchsetId).newComment().unresolved();
  }

  private TestCommentCreation.Builder newDraftComment(PatchSet.Id patchsetId) {
    // Create unresolved comments by default as only those are ported. Tests get override the
    // unresolved state by explicitly setting it.
    return changeOps
        .change(patchsetId.changeId())
        .patchset(patchsetId)
        .newDraftComment()
        .unresolved();
  }

  private CommentInfo getPortedComment(PatchSet.Id patchsetId, String commentUuid)
      throws RestApiException {
    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchsetId);
    return extractSpecificComment(portedComments, commentUuid);
  }

  private Map<String, List<CommentInfo>> getPortedComments(PatchSet.Id patchsetId)
      throws RestApiException {
    return gApi.changes()
        .id(patchsetId.changeId().get())
        .revision(patchsetId.get())
        .portedComments();
  }

  private Map<String, List<CommentInfo>> getPortedDraftCommentsOfUser(
      PatchSet.Id patchsetId, Account.Id accountId) throws RestApiException {
    // Draft comments are only visible to their author.
    requestScopeOps.setApiUser(accountId);
    return gApi.changes().id(patchsetId.changeId().get()).revision(patchsetId.get()).portedDrafts();
  }

  private static CommentInfo extractSpecificComment(
      Map<String, List<CommentInfo>> portedComments, String commentUuid) {
    return portedComments.values().stream()
        .flatMap(Collection::stream)
        .filter(comment -> comment.id.equals(commentUuid))
        .collect(onlyElement());
  }

  /**
   * Returns all comments in one list. The map keys (= file paths) are simply ignored. The returned
   * comments won't have the file path attribute set for them as they came from a map with that
   * attribute as key (= established Gerrit behavior).
   */
  private static ImmutableList<CommentInfo> flatten(
      Map<String, List<CommentInfo>> commentsPerFile) {
    return commentsPerFile.values().stream()
        .flatMap(Collection::stream)
        .collect((toImmutableList()));
  }

  // Unfortunately, we don't get an absolutely helpful error message when using this correspondence
  // as CommentInfo doesn't have a toString() implementation. Even if we added it, the string
  // representation would be quite unwieldy due to the huge number of comment attributes.
  // Interestingly, using Correspondence#formattingDiffsUsing didn't improve anything.
  private static Correspondence<CommentInfo, String> hasUuid() {
    return NullAwareCorrespondence.transforming(comment -> comment.id, "hasUuid");
  }
}
