com.google.gerrit.server.restapi.change.ReplyAttentionSetUpdates Maven / Gradle / Ivy
// 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.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;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
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;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.DraftCommentsReader;
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.approval.ApprovalsUtil;
import com.google.gerrit.server.change.AddToAttentionSetOp;
import com.google.gerrit.server.change.AttentionSetUnchangedOp;
import com.google.gerrit.server.change.AttentionSetUpdateCondition;
import com.google.gerrit.server.change.CommentThread;
import com.google.gerrit.server.change.CommentThreads;
import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
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.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;
import org.eclipse.jgit.errors.ConfigInvalidException;
/**
 * This class is used to update the attention set when performing a review or replying on a change.
 */
public class ReplyAttentionSetUpdates {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
  private final PermissionBackend permissionBackend;
  private final AddToAttentionSetOp.Factory addToAttentionSetOpFactory;
  private final RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory;
  private final ApprovalsUtil approvalsUtil;
  private final AccountResolver accountResolver;
  private final ServiceUserClassifier serviceUserClassifier;
  private final CommentsUtil commentsUtil;
  private final DraftCommentsReader draftCommentsReader;
  private final ProjectCache projectCache;
  @Inject
  ReplyAttentionSetUpdates(
      PermissionBackend permissionBackend,
      AddToAttentionSetOp.Factory addToAttentionSetOpFactory,
      RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory,
      ApprovalsUtil approvalsUtil,
      AccountResolver accountResolver,
      ServiceUserClassifier serviceUserClassifier,
      CommentsUtil commentsUtil,
      DraftCommentsReader draftCommentsReader,
      ProjectCache projectCache) {
    this.permissionBackend = permissionBackend;
    this.addToAttentionSetOpFactory = addToAttentionSetOpFactory;
    this.removeFromAttentionSetOpFactory = removeFromAttentionSetOpFactory;
    this.approvalsUtil = approvalsUtil;
    this.accountResolver = accountResolver;
    this.serviceUserClassifier = serviceUserClassifier;
    this.commentsUtil = commentsUtil;
    this.draftCommentsReader = draftCommentsReader;
    this.projectCache = projectCache;
  }
  /** Adjusts the attention set but only based on the automatic rules. */
  public void processAutomaticAttentionSetRulesOnReply(
      BatchUpdate bu,
      ChangeNotes changeNotes,
      boolean readyForReview,
      CurrentUser currentUser,
      List commentsToBePublished) {
    if (serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
      return;
    }
    processRules(
        bu,
        /* postReviewOp= */ null,
        changeNotes,
        readyForReview,
        currentUser,
        commentsToBePublished.stream().collect(toImmutableSet()));
  }
  /**
   * Adjusts the attention set when a review is posted.
   *
   * If the same user should be added and removed or added/removed twice, the user will only be
   * added/removed once, based on first addition/removal.
   *
   * @param postReviewOp the {@link PostReviewOp} that is being executed before the attention set
   *     updates
   */
  public void updateAttentionSetOnPostReview(
      BatchUpdate bu,
      PostReviewOp postReviewOp,
      ChangeNotes changeNotes,
      ReviewInput input,
      CurrentUser currentUser)
      throws BadRequestException,
          IOException,
          PermissionBackendException,
          UnprocessableEntityException,
          ConfigInvalidException {
    requireNonNull(postReviewOp, "postReviewOp must not be null");
    processManualUpdates(bu, changeNotes, input);
    if (input.ignoreAutomaticAttentionSetRules) {
      // If we ignore automatic attention set rules it means we need to pass this information to
      // ChangeUpdate. Also, we should stop all other attention set updates that are part of
      // this method and happen in PostReview.
      bu.addOp(changeNotes.getChangeId(), new AttentionSetUnchangedOp());
      return;
    }
    boolean isReadyForReview = isReadyForReview(changeNotes, input);
    if (isReadyForReview && serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
      botsWithNegativeLabelsAddOwnerAndUploader(bu, postReviewOp, changeNotes);
      return;
    }
    processRules(
        bu,
        postReviewOp,
        changeNotes,
        isReadyForReview,
        currentUser,
        getAllNewComments(changeNotes, input, currentUser));
  }
  private ImmutableSet getAllNewComments(
      ChangeNotes changeNotes, ReviewInput input, CurrentUser currentUser) {
    Set newComments = new HashSet<>();
    if (input.comments != null) {
      for (ReviewInput.CommentInput commentInput :
          input.comments.values().stream().flatMap(x -> x.stream()).collect(Collectors.toList())) {
        newComments.add(
            commentsUtil.newHumanComment(
                changeNotes,
                currentUser,
                TimeUtil.now(),
                commentInput.path,
                commentInput.patchSet == null
                    ? changeNotes.getChange().currentPatchSetId()
                    : PatchSet.id(changeNotes.getChange().getId(), commentInput.patchSet),
                commentInput.side(),
                commentInput.message,
                commentInput.unresolved,
                commentInput.inReplyTo,
                CommentsUtil.createFixSuggestionsFromInput(commentInput.fixSuggestions)));
      }
    }
    List drafts = new ArrayList<>();
    if (input.drafts == ReviewInput.DraftHandling.PUBLISH) {
      drafts =
          draftCommentsReader.getDraftsByPatchSetAndDraftAuthor(
              changeNotes, changeNotes.getChange().currentPatchSetId(), currentUser.getAccountId());
    }
    if (input.drafts == ReviewInput.DraftHandling.PUBLISH_ALL_REVISIONS) {
      drafts =
          draftCommentsReader.getDraftsByChangeAndDraftAuthor(
              changeNotes, currentUser.getAccountId());
    }
    return Stream.concat(newComments.stream(), drafts.stream()).collect(toImmutableSet());
  }
  /**
   * Process the automatic rules of the attention set.
   *
   * All of the automatic rules except adding/removing reviewers and entering/exiting WIP state
   * are done here, and the rest are done in {@link ChangeUpdate}.
   *
   * @param postReviewOp {@link PostReviewOp} that is being executed before the attention set
   *     updates, may be {@code null}
   */
  private void processRules(
      BatchUpdate bu,
      @Nullable PostReviewOp postReviewOp,
      ChangeNotes changeNotes,
      boolean readyForReview,
      CurrentUser currentUser,
      ImmutableSet allNewComments) {
    updateAttentionSetForCurrentUser(bu, postReviewOp, changeNotes, currentUser);
    // The rest of the conditions only apply if the change is open.
    if (changeNotes.getChange().getStatus().isClosed()) {
      // We still add the owner if a new comment thread was created, on closed changes.
      if (allNewComments.stream().anyMatch(c -> c.parentUuid == null)) {
        addToAttentionSet(
            bu,
            changeNotes,
            changeNotes.getChange().getOwner(),
            "A new comment thread was created",
            false);
      }
      return;
    }
    // The rest of the conditions only apply if the change is ready for review and reply is not
    // posted by a bot.
    if (!readyForReview || serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
      return;
    }
    addOwnerAndUploaderToAttentionSetIfSomeoneElseReplied(
        bu, postReviewOp, changeNotes, currentUser, readyForReview, allNewComments);
    addAllAuthorsOfCommentThreads(bu, changeNotes, allNewComments, currentUser);
  }
  /**
   * Updates the attention set for the current user.
   *
   * Removes the current user from the attention set (since they replied) unless they voted on an
   * outdated patch set and some of the votes were not copied to the current patch set (in this case
   * they should be in the attention set to re-apply their votes).
   *
   * 
If the current user voted on an outdated patch set and some of the votes were not copied to
   * the current patch set:
   *
   * 
   *   - the current user is added to the attention set (if they are not in the attention set yet)
   *       or
   *   
 - the reason for the current user to be in the attention set is updated (if they are
   *       already in the attention set).
   * 
 
   */
  private void updateAttentionSetForCurrentUser(
      BatchUpdate bu,
      @Nullable PostReviewOp postReviewOp,
      ChangeNotes changeNotes,
      CurrentUser currentUser) {
    if (postReviewOp == null) {
      // Replying removes the current user from the attention set.
      removeFromAttentionSet(
          bu, changeNotes, currentUser.getAccountId(), "removed on reply", false);
    } else {
      // If the current user voted on an outdated patch set and some of the votes were not copied to
      // the current patch set the current user should stay in the attention set, or be added to the
      // attention set. In case the user stays in the attention set, this updates the reason for
      // being in the attention set.
      AttentionSetUpdateCondition addOrKeepCondition =
          () ->
              postReviewOp
                  .getResult()
                  .appliedVotesOnOutdatedPatchSetThatWereNotCopiedToCurrentPatchSet();
      maybeAddToAttentionSet(
          bu,
          addOrKeepCondition,
          changeNotes,
          currentUser.getAccountId(),
          "Some votes were not copied to the current patch set",
          false);
      // Otherwise replying removes the current user from the attention set.
      AttentionSetUpdateCondition removeCondition = () -> !addOrKeepCondition.check();
      maybeRemoveFromAttentionSet(
          bu, removeCondition, changeNotes, currentUser.getAccountId(), "removed on reply", false);
    }
  }
  /**
   * Adds the owner and uploader to the attention set if someone else replied.
   *
   * Replying means they either updated the votes on the current patch set (either directly on
   * the current patch set or the votes were copied to the current patch set), they posted a change
   * message, they marked the change as ready or they posted new comments.
   */
  private void addOwnerAndUploaderToAttentionSetIfSomeoneElseReplied(
      BatchUpdate bu,
      @Nullable PostReviewOp postReviewOp,
      ChangeNotes changeNotes,
      CurrentUser currentUser,
      boolean readyForReview,
      ImmutableSet allNewComments) {
    AttentionSetUpdateCondition condition =
        postReviewOp != null
            ? () ->
                postReviewOp.getResult().updatedAnyVoteOnCurrentPatchSet()
                    || postReviewOp.getResult().postedChangeMessage()
                    || (changeNotes.getChange().isWorkInProgress() && readyForReview)
                    || !allNewComments.isEmpty()
            : () ->
                (changeNotes.getChange().isWorkInProgress() && readyForReview)
                    || !allNewComments.isEmpty();
    Account.Id owner = changeNotes.getChange().getOwner();
    if (!currentUser.getAccountId().equals(owner)) {
      maybeAddToAttentionSet(
          bu, condition, changeNotes, owner, "Someone else replied on the change", false);
    }
    Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
    if (!owner.equals(uploader) && !currentUser.getAccountId().equals(uploader)) {
      maybeAddToAttentionSet(
          bu, condition, changeNotes, uploader, "Someone else replied on the change", false);
    }
  }
  /** Adds all authors of all comment threads that received a reply during this update */
  private void addAllAuthorsOfCommentThreads(
      BatchUpdate bu,
      ChangeNotes changeNotes,
      ImmutableSet allNewComments,
      CurrentUser currentUser) {
    boolean isOwnerOrUploader =
        currentUser.getAccountId().equals(changeNotes.getChange().getOwner())
            || currentUser.getAccountId().equals(changeNotes.getCurrentPatchSet().uploader());
    boolean noCRLabel = false;
    Optional 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 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 relevantComments =
        Stream.concat(
                commentsUtil.publishedHumanCommentsByChange(changeNotes).stream(),
                allNewComments.stream())
            .collect(toImmutableList());
    ImmutableSet> repliedToCommentThreads =
        CommentThreads.forComments(relevantComments).getThreadsForChildren(allNewComments);
    LinkedHashSet repliedToUsers = new LinkedHashSet<>();
    for (CommentThread 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 possibleUsersToAdd = approvalsUtil.getReviewers(changeNotes).all();
    SetView usersToAdd = Sets.intersection(possibleUsersToAdd, repliedToUsers);
    for (Account.Id user : usersToAdd) {
      addToAttentionSet(
          bu, changeNotes, user, "Someone else replied on a comment you posted", false);
    }
  }
  /** Process the manual updates of the attention set. */
  private void processManualUpdates(BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input)
      throws BadRequestException,
          IOException,
          PermissionBackendException,
          UnprocessableEntityException,
          ConfigInvalidException {
    Set accountsChangedInCommit = new HashSet<>();
    // If we specify a user to remove, and the user is in the attention set, we remove it.
    if (input.removeFromAttentionSet != null) {
      for (AttentionSetInput remove : input.removeFromAttentionSet) {
        removeFromAttentionSet(bu, changeNotes, remove, accountsChangedInCommit);
      }
    }
    // If we don't specify a user to remove, but we specify addition for that user, the user will be
    // added if they are not in the attention set yet.
    if (input.addToAttentionSet != null) {
      for (AttentionSetInput add : input.addToAttentionSet) {
        addToAttentionSet(bu, changeNotes, add, accountsChangedInCommit);
      }
    }
  }
  /**
   * Bots don't process automatic rules, the only attention set change they do is this rule: Add
   * owner and uploader when a bot votes negatively on the current patch set, but only if the change
   * is open.
   */
  private void botsWithNegativeLabelsAddOwnerAndUploader(
      BatchUpdate bu, PostReviewOp postReviewOp, ChangeNotes changeNotes) {
    if (changeNotes.getChange().isClosed()) {
      return;
    }
    AttentionSetUpdateCondition condition =
        () -> postReviewOp.getResult().updatedAnyNegativeVoteOnCurrentPatchSet();
    Account.Id owner = changeNotes.getChange().getOwner();
    maybeAddToAttentionSet(
        bu, condition, changeNotes, owner, "A robot voted negatively on a label", false);
    Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
    if (!owner.equals(uploader)) {
      maybeAddToAttentionSet(
          bu, condition, changeNotes, uploader, "A robot voted negatively on a label", false);
    }
  }
  /**
   * Adds the user to the attention set
   *
   * @param bu BatchUpdate to perform the updates to the attention set
   * @param changeNotes current change
   * @param user user to add to the attention set
   * @param reason reason for adding
   * @param notify whether or not to notify about this addition
   */
  private void addToAttentionSet(
      BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
    AddToAttentionSetOp addToAttentionSet = addToAttentionSetOpFactory.create(user, reason, notify);
    bu.addOp(changeNotes.getChangeId(), addToAttentionSet);
  }
  /**
   * Adds the user to the attention set if the given condition is true.
   *
   * @param bu BatchUpdate to perform the updates to the attention set
   * @param condition condition that decides whether the attention set update should be performed
   * @param changeNotes current change
   * @param user user to add to the attention set
   * @param reason reason for adding
   * @param notify whether or not to notify about this addition
   */
  private void maybeAddToAttentionSet(
      BatchUpdate bu,
      AttentionSetUpdateCondition condition,
      ChangeNotes changeNotes,
      Account.Id user,
      String reason,
      boolean notify) {
    AddToAttentionSetOp addToAttentionSet =
        addToAttentionSetOpFactory.create(user, reason, notify).setCondition(condition);
    bu.addOp(changeNotes.getChangeId(), addToAttentionSet);
  }
  /**
   * Removes the user from the attention set
   *
   * @param bu BatchUpdate to perform the updates to the attention set.
   * @param changeNotes current change.
   * @param user user to add remove from the attention set.
   * @param reason reason for removing.
   * @param notify whether or not to notify about this removal.
   */
  private void removeFromAttentionSet(
      BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
    RemoveFromAttentionSetOp removeFromAttentionSetOp =
        removeFromAttentionSetOpFactory.create(user, reason, notify);
    bu.addOp(changeNotes.getChangeId(), removeFromAttentionSetOp);
  }
  /**
   * Removes the user from the attention set if the given condition is true.
   *
   * @param bu BatchUpdate to perform the updates to the attention set.
   * @param condition condition that decides whether the attention set update should be performed
   * @param changeNotes current change.
   * @param user user to add remove from the attention set.
   * @param reason reason for removing.
   * @param notify whether or not to notify about this removal.
   */
  private void maybeRemoveFromAttentionSet(
      BatchUpdate bu,
      AttentionSetUpdateCondition condition,
      ChangeNotes changeNotes,
      Account.Id user,
      String reason,
      boolean notify) {
    RemoveFromAttentionSetOp removeFromAttentionSetOp =
        removeFromAttentionSetOpFactory.create(user, reason, notify).setCondition(condition);
    bu.addOp(changeNotes.getChangeId(), removeFromAttentionSetOp);
  }
  private static boolean isReadyForReview(ChangeNotes changeNotes, ReviewInput input) {
    return (!changeNotes.getChange().isWorkInProgress() && !input.workInProgress) || input.ready;
  }
  private void addToAttentionSet(
      BatchUpdate bu,
      ChangeNotes changeNotes,
      AttentionSetInput add,
      Set accountsChangedInCommit)
      throws BadRequestException,
          IOException,
          PermissionBackendException,
          UnprocessableEntityException,
          ConfigInvalidException {
    AttentionSetUtil.validateInput(add);
    try {
      Account.Id attentionUserId =
          getAccountIdAndValidateUser(
              changeNotes, add.user, accountsChangedInCommit, AttentionSetUpdate.Operation.ADD);
      addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
    } catch (AccountResolver.UnresolvableAccountException ex) {
      // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
      // message here, then it would be possible to probe whether an account exists.
    } catch (AuthException ex) {
      // adding users without permission to the attention set should fail silently.
      logger.atFine().log("%s", ex.getMessage());
    }
  }
  private void removeFromAttentionSet(
      BatchUpdate bu,
      ChangeNotes changeNotes,
      AttentionSetInput remove,
      Set accountsChangedInCommit)
      throws BadRequestException,
          IOException,
          PermissionBackendException,
          UnprocessableEntityException,
          ConfigInvalidException {
    AttentionSetUtil.validateInput(remove);
    try {
      Account.Id attentionUserId =
          getAccountIdAndValidateUser(
              changeNotes,
              remove.user,
              accountsChangedInCommit,
              AttentionSetUpdate.Operation.REMOVE);
      removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
    } catch (AccountResolver.UnresolvableAccountException ex) {
      // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
      // message here, then it would be possible to probe whether an account exists.
    } catch (AuthException ex) {
      // this should never happen since removing users with permissions should work.
      logger.atSevere().log("%s", ex.getMessage());
    }
  }
  private Account.Id getAccountId(
      ChangeNotes changeNotes, String user, AttentionSetUpdate.Operation operation)
      throws ConfigInvalidException,
          IOException,
          UnprocessableEntityException,
          PermissionBackendException,
          AuthException {
    Account.Id attentionUserId = accountResolver.resolve(user).asUnique().account().id();
    try {
      permissionBackend
          .absentUser(attentionUserId)
          .change(changeNotes)
          .check(ChangePermission.READ);
    } catch (AuthException e) {
      // If the change is private, it is okay to add the user to the attention set since that
      // person will be granted visibility when a reviewer.
      if (!changeNotes.getChange().isPrivate()) {
        // Removing users without access is allowed, adding is not allowed
        if (operation == AttentionSetUpdate.Operation.ADD) {
          throw new AuthException(
              "Can't modify attention set: Read not permitted for " + attentionUserId, e);
        }
      }
    }
    return attentionUserId;
  }
  private Account.Id getAccountIdAndValidateUser(
      ChangeNotes changeNotes,
      String user,
      Set accountsChangedInCommit,
      AttentionSetUpdate.Operation operation)
      throws ConfigInvalidException,
          IOException,
          PermissionBackendException,
          UnprocessableEntityException,
          BadRequestException,
          AuthException {
    try {
      Account.Id attentionUserId = getAccountId(changeNotes, user, operation);
      if (accountsChangedInCommit.contains(attentionUserId)) {
        throw new BadRequestException(
            String.format(
                "%s can not be added/removed twice, and can not be added and "
                    + "removed at the same time",
                user));
      }
      accountsChangedInCommit.add(attentionUserId);
      return attentionUserId;
    } catch (AccountResolver.UnresolvableAccountException ex) {
      // This can only happen if this user can't see the account or the account doesn't exist.
      // Silently modify the account's attention set anyway, if the account exists.
      return accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
    }
  }
}