Merge "Update attention set behaviour when commenting on a thread." into stable-3.11
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index bc47adc..2803c0e 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Sets.SetView;
@@ -25,6 +27,8 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -48,15 +52,17 @@
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
@@ -76,6 +82,7 @@
   private final ServiceUserClassifier serviceUserClassifier;
   private final CommentsUtil commentsUtil;
   private final DraftCommentsReader draftCommentsReader;
+  private final ProjectCache projectCache;
 
   @Inject
   ReplyAttentionSetUpdates(
@@ -86,7 +93,8 @@
       AccountResolver accountResolver,
       ServiceUserClassifier serviceUserClassifier,
       CommentsUtil commentsUtil,
-      DraftCommentsReader draftCommentsReader) {
+      DraftCommentsReader draftCommentsReader,
+      ProjectCache projectCache) {
     this.permissionBackend = permissionBackend;
     this.addToAttentionSetOpFactory = addToAttentionSetOpFactory;
     this.removeFromAttentionSetOpFactory = removeFromAttentionSetOpFactory;
@@ -95,6 +103,7 @@
     this.serviceUserClassifier = serviceUserClassifier;
     this.commentsUtil = commentsUtil;
     this.draftCommentsReader = draftCommentsReader;
+    this.projectCache = projectCache;
   }
 
   /** Adjusts the attention set but only based on the automatic rules. */
@@ -235,7 +244,7 @@
 
     addOwnerAndUploaderToAttentionSetIfSomeoneElseReplied(
         bu, postReviewOp, changeNotes, currentUser, readyForReview, allNewComments);
-    addAllAuthorsOfCommentThreads(bu, changeNotes, allNewComments);
+    addAllAuthorsOfCommentThreads(bu, changeNotes, allNewComments, currentUser);
   }
 
   /**
@@ -329,17 +338,71 @@
 
   /** Adds all authors of all comment threads that received a reply during this update */
   private void addAllAuthorsOfCommentThreads(
-      BatchUpdate bu, ChangeNotes changeNotes, ImmutableSet<HumanComment> allNewComments) {
-    List<HumanComment> publishedComments = commentsUtil.publishedHumanCommentsByChange(changeNotes);
-    ImmutableSet<CommentThread<HumanComment>> repliedToCommentThreads =
-        CommentThreads.forComments(publishedComments).getThreadsForChildren(allNewComments);
+      BatchUpdate bu,
+      ChangeNotes changeNotes,
+      ImmutableSet<HumanComment> allNewComments,
+      CurrentUser currentUser) {
+    boolean isOwnerOrUploader =
+        currentUser.getAccountId().equals(changeNotes.getChange().getOwner())
+            || currentUser.getAccountId().equals(changeNotes.getCurrentPatchSet().uploader());
 
-    ImmutableSet<Account.Id> repliedToUsers =
-        repliedToCommentThreads.stream()
-            .map(CommentThread::comments)
-            .flatMap(Collection::stream)
-            .map(comment -> comment.author.getId())
-            .collect(toImmutableSet());
+    boolean noCRLabel = false;
+    Optional<LabelValue> maxCRValue =
+        projectCache
+            .get(changeNotes.getChange().getProject())
+            .orElseThrow(
+                () ->
+                    new IllegalStateException(
+                        String.format(
+                            "Couldn't find project \"%s\" for a change \"%s\"",
+                            changeNotes.getChange().getProject(), changeNotes.getChangeId())))
+            .getLabelTypes(changeNotes)
+            .byLabel(LabelId.CODE_REVIEW)
+            .map(l -> l.getMax());
+
+    ImmutableSet<Account.Id> maxCrApprovers;
+    if (maxCRValue.isPresent()) {
+      maxCrApprovers =
+          changeNotes.getApprovals().all().get(changeNotes.getCurrentPatchSet().id()).stream()
+              .filter(
+                  a ->
+                      a.label().equals(LabelId.CODE_REVIEW)
+                          && a.value() == maxCRValue.get().getValue())
+              .map(a -> a.accountId())
+              .collect(toImmutableSet());
+    } else {
+      noCRLabel = true;
+      maxCrApprovers = ImmutableSet.of();
+    }
+
+    // Include newly published comments, when building threads.
+    ImmutableList<HumanComment> relevantComments =
+        Stream.concat(
+                commentsUtil.publishedHumanCommentsByChange(changeNotes).stream(),
+                allNewComments.stream())
+            .collect(toImmutableList());
+    ImmutableSet<CommentThread<HumanComment>> repliedToCommentThreads =
+        CommentThreads.forComments(relevantComments).getThreadsForChildren(allNewComments);
+
+    LinkedHashSet<Account.Id> repliedToUsers = new LinkedHashSet<>();
+    for (CommentThread<HumanComment> thread : repliedToCommentThreads) {
+      // If thread is resolved, we only bring back the commenters who have not yet left max
+      // Code-Review vote.
+      // If Owner replied but didn't resolve, we assume clarification was asked add everyone on the
+      // thread to attention set.
+      boolean ignoreVoteCheck = noCRLabel || (thread.unresolved() && isOwnerOrUploader);
+      if (thread.unresolved() && !isOwnerOrUploader) {
+        // Reviewer replied. Owner is still the one to act. No need to add commenters.
+        continue;
+      }
+      thread.comments().stream()
+          .map(comment -> comment.author.getId())
+          .filter(
+              a ->
+                  !a.equals(currentUser.getAccountId())
+                      && (ignoreVoteCheck || !maxCrApprovers.contains(a)))
+          .forEach(repliedToUsers::add);
+    }
     ImmutableSet<Account.Id> possibleUsersToAdd = approvalsUtil.getReviewers(changeNotes).all();
     SetView<Account.Id> usersToAdd = Sets.intersection(possibleUsersToAdd, repliedToUsers);
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 92da64e..0f245bd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -130,6 +130,11 @@
   @Before
   public void setUp() {
     TimeUtil.setCurrentMillisSupplier(fakeClock);
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .update();
   }
 
   @Test
@@ -1402,10 +1407,51 @@
   }
 
   @Test
-  public void reviewAddsAllUsersInCommentThread() throws Exception {
+  public void ownerReplyResolvedAddsNonVotedInCommentThread() throws Exception {
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
-    change(r).current().review(reviewWithComment());
+    ReviewInput ri = reviewWithComment();
+    ri.label("Code-Review", 2);
+    change(r).current().review(ri);
+
+    TestAccount user2 = accountCreator.user2();
+
+    requestScopeOperations.setApiUser(user2.id());
+    change(r)
+        .current()
+        .review(
+            reviewInReplyToComment(
+                Iterables.getOnlyElement(
+                        gApi.changes().id(r.getChangeId()).current().commentsAsList())
+                    .id));
+
+    change(r).attention(user.email()).remove(new AttentionSetInput("removal"));
+    requestScopeOperations.setApiUser(admin.id());
+    ri =
+        reviewInReplyToComment(
+            gApi.changes().id(r.getChangeId()).current().commentsAsList().get(1).id);
+    ri.comments.get(Patch.COMMIT_MSG).get(0).unresolved = false;
+    change(r).current().review(ri);
+
+    // First user already voted, no need to bring them back.
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user2));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user2.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet)
+        .hasReasonThat()
+        .isEqualTo("Someone else replied on a comment you posted");
+  }
+
+  @Test
+  public void ownerReplyUnresolvedAddsAllUsersInCommentThread() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput ri = reviewWithComment();
+    ri.label("Code-Review", 2);
+    change(r).current().review(ri);
 
     TestAccount user2 = accountCreator.user2();
 
@@ -1443,6 +1489,71 @@
   }
 
   @Test
+  public void reviewerReplyUnresolvedAddsOnlyOwner() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    change(r).current().review(reviewWithComment());
+
+    TestAccount user2 = accountCreator.user2();
+    change(r).attention(admin.email()).remove(new AttentionSetInput("removal"));
+    requestScopeOperations.setApiUser(user2.id());
+    change(r)
+        .current()
+        .review(
+            reviewInReplyToComment(
+                Iterables.getOnlyElement(
+                        gApi.changes().id(r.getChangeId()).current().commentsAsList())
+                    .id));
+
+    // First user is not needed, owner haven't addressed their comment yet.
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+  }
+
+  @Test
+  public void reviewerReplyResolvedAddsNonVotedInCommentThread() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput ri = reviewWithComment();
+    ri.label("Code-Review", 2);
+    change(r).current().review(ri);
+
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+    change(r)
+        .current()
+        .review(
+            reviewInReplyToComment(
+                Iterables.getOnlyElement(
+                        gApi.changes().id(r.getChangeId()).current().commentsAsList())
+                    .id));
+
+    TestAccount user3 = accountCreator.create("user3", "user3@example.com", "User3", null);
+    requestScopeOperations.setApiUser(user3.id());
+    ri =
+        reviewInReplyToComment(
+            gApi.changes().id(r.getChangeId()).current().commentsAsList().get(1).id);
+    ri.comments.get(Patch.COMMIT_MSG).get(0).unresolved = false;
+    change(r).current().review(ri);
+
+    // First user already voted, no need to bring them back.
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user2));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user2.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet)
+        .hasReasonThat()
+        .isEqualTo("Someone else replied on a comment you posted");
+  }
+
+  @Test
   public void reviewAddsAllUsersInCommentThreadWhenOriginalCommentIsARobotComment()
       throws Exception {
     PushOneCommit.Result result = createChange();
@@ -3278,6 +3389,7 @@
     comment.message = "comment";
     comment.setUpdated(TimeUtil.now());
     comment.inReplyTo = id;
+    comment.unresolved = true;
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
     return reviewInput;