com.google.gerrit.server.restapi.change.PostReviewOp Maven / Gradle / Ivy
// Copyright (C) 2022 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.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toSet;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableTable;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.SortedSetMultimap;
import com.google.common.collect.Streams;
import com.google.common.collect.Table.Cell;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.RobotComment;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.extensions.restapi.Url;
import com.google.gerrit.extensions.validators.CommentForValidation;
import com.google.gerrit.extensions.validators.CommentValidationContext;
import com.google.gerrit.extensions.validators.CommentValidationFailure;
import com.google.gerrit.extensions.validators.CommentValidator;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.DraftCommentsReader;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.PublishCommentUtil;
import com.google.gerrit.server.approval.ApprovalCopier;
import com.google.gerrit.server.approval.ApprovalsUtil;
import com.google.gerrit.server.change.EmailReviewComments;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.extensions.events.CommentAdded;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.plugincontext.PluginSetContext;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.restapi.change.PostReview.CommentSetEntry;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.CommentsRejectedException;
import com.google.gerrit.server.update.PostUpdateContext;
import com.google.gerrit.server.util.LabelVote;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jgit.lib.Config;
public class PostReviewOp implements BatchUpdateOp {
  public interface Factory {
    PostReviewOp create(
        ProjectState projectState, PatchSet.Id psId, ReviewInput in, Account.Id reviewerId);
  }
  /**
   * Update of a copied label that has been performed on a follow-up patch set after a vote has been
   * applied on an outdated patch set (follow-up patch sets = all patch sets that are newer than the
   * outdated patch set on which the user voted).
   */
  @AutoValue
  abstract static class CopiedLabelUpdate {
    /**
     * Type of the update that has been performed for a copied vote on a follow-up patch set.
     *
     * Whether the copied vote has been added
     *
     * 
     *   - added to
     *   
 - updated on
     *   
 - removed from
     * 
 
     *
     * a follow-up patch set.
     */
    enum Type {
      /** A copied vote was added. No copied vote existed for this label yet. */
      ADDED,
      /** An existing copied vote has been updated. */
      UPDATED,
      /** An existing copied vote has been removed. */
      REMOVED;
    }
    /** The ID of the (follow-up) patch set on which the copied label update has been performed. */
    abstract PatchSet.Id patchSetId();
    /**
     * The old copied label vote that has been updated or that has been removed.
     *
     * Not set if {@link #type()} is {@link Type#ADDED}.
     */
    abstract Optional oldLabelVote();
    /**
     * The type of the update that has been performed for the copied vote on the (follow-up) patch
     * set.
     */
    abstract Type type();
    /** Returns a string with the patch set number and if present the old label vote. */
    private String formatPatchSetWithOldLabelVote() {
      StringBuilder b = new StringBuilder();
      b.append(patchSetId().get());
      if (oldLabelVote().isPresent()) {
        b.append(" (was ").append(oldLabelVote().get().format()).append(")");
      }
      return b.toString();
    }
    private static CopiedLabelUpdate added(PatchSet.Id patchSetId) {
      return create(patchSetId, Optional.empty(), Type.ADDED);
    }
    private static CopiedLabelUpdate updated(PatchSet.Id patchSetId, LabelVote oldLabelVote) {
      return create(patchSetId, Optional.of(oldLabelVote), Type.UPDATED);
    }
    private static CopiedLabelUpdate removed(PatchSet.Id patchSetId, LabelVote oldLabelVote) {
      return create(patchSetId, Optional.of(oldLabelVote), Type.REMOVED);
    }
    private static CopiedLabelUpdate create(
        PatchSet.Id patchSetId, Optional oldLabelVote, Type type) {
      return new AutoValue_PostReviewOp_CopiedLabelUpdate(patchSetId, oldLabelVote, type);
    }
  }
  @AutoValue
  public abstract static class Result {
    /**
     * Whether this {@code PostReviewOp} updated any vote on the current patch set.
     *
     * @return returns {@code true} if a) ReviewInput contained votes and b) ReviewInput was applied
     *     on the current patch set or any votes got copied to the current patch set.
     */
    abstract boolean updatedAnyVoteOnCurrentPatchSet();
    /**
     * Whether this {@code PostReviewOp} applied any negative vote on the current patch set.
     *
     * @return returns {@code true} if a) ReviewInput contained negative votes and b) ReviewInput
     *     was applied on the current patch set or any of the negative votes got copied to the
     *     current patch set.
     */
    abstract boolean updatedAnyNegativeVoteOnCurrentPatchSet();
    /**
     * Whether this {@code PostReviewOp} applied votes on an outdated patch set that were not copied
     * to the current patch set.
     *
     * @return returns {@code true} if a) ReviewInput contained votes, b) ReviewInput was applied on
     *     an outdated patch set and c) not all of the votes got copied to the current patch set
     */
    abstract boolean appliedVotesOnOutdatedPatchSetThatWereNotCopiedToCurrentPatchSet();
    /**
     * Whether this {@code PostReviewOp} posted a change message.
     *
     * @return returns {@code true} if ReviewInput contained a message.
     */
    abstract boolean postedChangeMessage();
    static Result create(
        boolean updatedAnyVoteOnCurrentPatchSet,
        boolean updatedAnyNegativeVoteOnCurrentPatchSet,
        boolean appliedVotesOnOutdatedPatchSetThatWereNotCopiedToCurrentPatchSet,
        boolean postedChangeMessage) {
      return new AutoValue_PostReviewOp_Result(
          updatedAnyVoteOnCurrentPatchSet,
          updatedAnyNegativeVoteOnCurrentPatchSet,
          appliedVotesOnOutdatedPatchSetThatWereNotCopiedToCurrentPatchSet,
          postedChangeMessage);
    }
  }
  @VisibleForTesting
  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
  private final ApprovalCopier approvalCopier;
  private final ApprovalsUtil approvalsUtil;
  private final ChangeMessagesUtil cmUtil;
  private final CommentsUtil commentsUtil;
  private final DraftCommentsReader draftCommentsReader;
  private final PublishCommentUtil publishCommentUtil;
  private final PatchSetUtil psUtil;
  private final EmailReviewComments.Factory email;
  private final CommentAdded commentAdded;
  private final PluginSetContext commentValidators;
  private final PluginSetContext onPostReviews;
  private final ProjectState projectState;
  private final PatchSet.Id psId;
  private final ReviewInput in;
  private final Account.Id reviewerId;
  private final boolean publishPatchSetLevelComment;
  private IdentifiedUser user;
  private ChangeNotes notes;
  private PatchSet ps;
  private String mailMessage;
  private List comments = new ArrayList<>();
  private List labelDelta = new ArrayList<>();
  private SortedSetMultimap labelUpdatesOnFollowUpPatchSets =
      MultimapBuilder.hashKeys().treeSetValues(comparing(CopiedLabelUpdate::patchSetId)).build();
  private Map approvals = new HashMap<>();
  private Map oldApprovals = new HashMap<>();
  private Result result;
  @Inject
  PostReviewOp(
      @GerritServerConfig Config gerritConfig,
      ApprovalCopier approvalCopier,
      ApprovalsUtil approvalsUtil,
      ChangeMessagesUtil cmUtil,
      CommentsUtil commentsUtil,
      DraftCommentsReader draftCommentsReader,
      PublishCommentUtil publishCommentUtil,
      PatchSetUtil psUtil,
      EmailReviewComments.Factory email,
      CommentAdded commentAdded,
      PluginSetContext commentValidators,
      PluginSetContext onPostReviews,
      @Assisted ProjectState projectState,
      @Assisted PatchSet.Id psId,
      @Assisted ReviewInput in,
      @Assisted Account.Id reviewerId) {
    this.approvalCopier = approvalCopier;
    this.approvalsUtil = approvalsUtil;
    this.publishCommentUtil = publishCommentUtil;
    this.psUtil = psUtil;
    this.cmUtil = cmUtil;
    this.commentsUtil = commentsUtil;
    this.draftCommentsReader = draftCommentsReader;
    this.email = email;
    this.commentAdded = commentAdded;
    this.commentValidators = commentValidators;
    this.onPostReviews = onPostReviews;
    this.publishPatchSetLevelComment =
        gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
    this.projectState = projectState;
    this.psId = psId;
    this.in = in;
    this.reviewerId = reviewerId;
  }
  @Override
  public boolean updateChange(ChangeContext ctx)
      throws ResourceConflictException,
          UnprocessableEntityException,
          IOException,
          CommentsRejectedException {
    user = ctx.getIdentifiedUser();
    notes = ctx.getNotes();
    ps = psUtil.get(ctx.getNotes(), psId);
    List newRobotComments =
        in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
    boolean dirty = false;
    try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
      dirty |= insertComments(ctx, newRobotComments);
    }
    try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
      dirty |= insertRobotComments(ctx, newRobotComments);
    }
    try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
      dirty |= updateLabels(projectState, ctx);
    }
    try (TraceContext.TraceTimer ignored = newTimer("updateCopiedApprovals")) {
      dirty |= updateCopiedApprovalsOnFollowUpPatchSets(ctx);
    }
    try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
      dirty |= insertMessage(ctx);
    }
    result =
        Result.create(
            updatedAnyVoteOnCurrentPatchSet(),
            updatedAnyNegativeVoteOnCurrentPatchSet(),
            appliedVotesOnOutdatedPatchSetThatWereNotCopiedToCurrentPatchSet(),
            postedChangeMessage());
    return dirty;
  }
  @Override
  public void postUpdate(PostUpdateContext ctx) {
    if (mailMessage == null) {
      return;
    }
    email
        .create(ctx, ps, notes.getMetaId(), mailMessage, comments, in.message, labelDelta)
        .sendAsync();
    String comment = mailMessage;
    if (publishPatchSetLevelComment) {
      // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
      // added event. For backwards compatibility, patchset level comment has a higher priority
      // than change message and should be used as comment in comment added event.
      String patchSetLevelComment =
          comments.stream()
              .filter(c -> c.key.filename.equals(PATCHSET_LEVEL))
              .map(c -> Strings.nullToEmpty(c.message))
              .collect(Collectors.joining("\n"))
              .trim();
      if (!patchSetLevelComment.isEmpty()) {
        comment = String.format("Patch Set %s:\n\n%s", psId.get(), patchSetLevelComment);
      }
    }
    commentAdded.fire(
        ctx.getChangeData(notes),
        ps,
        user.state(),
        comment,
        approvals,
        oldApprovals,
        ctx.getWhen());
  }
  /**
   * Publishes draft and input comments. Input comments are those passed as input in the request
   * body.
   *
   * @param ctx context for performing the change update.
   * @param newRobotComments robot comments. Used only for validation in this method.
   * @return true if any input comments where published.
   */
  private boolean insertComments(ChangeContext ctx, List newRobotComments)
      throws CommentsRejectedException {
    Map> inputComments = in.comments;
    if (inputComments == null) {
      inputComments = Collections.emptyMap();
    }
    // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
    Map drafts = new HashMap<>();
    if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
      drafts =
          in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
              ? changeDrafts(ctx)
              : patchSetDrafts(ctx);
    }
    // Existing published comments
    Set existingComments =
        in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
    // Input comments should be deduplicated from existing drafts
    List inputCommentsToPublish =
        resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
    switch (in.drafts) {
      case PUBLISH:
      case PUBLISH_ALL_REVISIONS:
        Collection filteredDrafts =
            in.draftIdsToPublish == null
                ? drafts.values()
                : drafts.values().stream()
                    .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
                    .collect(Collectors.toList());
        validateComments(
            ctx,
            Streams.concat(
                drafts.values().stream(),
                inputCommentsToPublish.stream(),
                newRobotComments.stream()));
        publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
        comments.addAll(drafts.values());
        break;
      case KEEP:
        validateComments(
            ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
        break;
    }
    commentsUtil.putHumanComments(
        ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
    comments.addAll(inputCommentsToPublish);
    return !inputCommentsToPublish.isEmpty();
  }
  /**
   * Returns the subset of {@code inputComments} that should be added to the change.
   *
   * If the matching comment (with the same id) already exists in {@code existingComments} then
   * the comment is filtered out. This assumes that the comment has been already published earlier.
   *
   * 
If the matching comment is found in {@code drafts}, then it's removed from drafts and the
   * comment is kept in the output. This assumes that the comment in the input is the newer version
   * of the previously existing draft.
   *
   * @param inputComments new comments provided as {@link CommentInput} entries in the API.
   * @param existingComments existing published comments in the database.
   * @param drafts existing draft comments in the database. This map can be modified.
   */
  private List resolveInputCommentsAndDrafts(
      Map> inputComments,
      Set existingComments,
      Map drafts,
      ChangeContext ctx) {
    List inputCommentsToPublish = new ArrayList<>();
    for (Map.Entry> entry : inputComments.entrySet()) {
      String path = entry.getKey();
      for (CommentInput inputComment : entry.getValue()) {
        HumanComment comment = drafts.remove(Url.decode(inputComment.id));
        if (comment == null) {
          String parent = Url.decode(inputComment.inReplyTo);
          comment =
              commentsUtil.newHumanComment(
                  ctx.getNotes(),
                  ctx.getUser(),
                  ctx.getWhen(),
                  path,
                  psId,
                  inputComment.side(),
                  inputComment.message,
                  inputComment.unresolved,
                  parent,
                  CommentsUtil.createFixSuggestionsFromInput(inputComment.fixSuggestions));
        } else {
          // In ChangeUpdate#putDraftComment() the draft with the same ID will be deleted.
          comment.writtenOn = Timestamp.from(ctx.getWhen());
          comment.side = inputComment.side();
          comment.message = inputComment.message;
          comment.unresolved = inputComment.unresolved;
        }
        commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
        comment.setLineNbrAndRange(inputComment.line, inputComment.range);
        comment.tag = in.tag;
        if (existingComments.contains(CommentSetEntry.create(comment))) {
          continue;
        }
        inputCommentsToPublish.add(comment);
      }
    }
    return inputCommentsToPublish;
  }
  /**
   * Validates all comments and the change message in a single call to fulfill the interface
   * contract of {@link CommentValidator#validateComments(CommentValidationContext, ImmutableList)}.
   */
  private void validateComments(ChangeContext ctx, Stream extends Comment> comments)
      throws CommentsRejectedException {
    CommentValidationContext commentValidationCtx =
        CommentValidationContext.create(
            ctx.getChange().getChangeId(),
            ctx.getChange().getProject().get(),
            ctx.getChange().getDest().branch());
    String changeMessage = Strings.nullToEmpty(in.message).trim();
    ImmutableList draftsForValidation =
        Stream.concat(
                comments.map(
                    comment ->
                        CommentForValidation.create(
                            comment instanceof RobotComment
                                ? CommentForValidation.CommentSource.ROBOT
                                : CommentForValidation.CommentSource.HUMAN,
                            comment.lineNbr > 0
                                ? CommentForValidation.CommentType.INLINE_COMMENT
                                : CommentForValidation.CommentType.FILE_COMMENT,
                            comment.message,
                            comment.getApproximateSize())),
                Stream.of(
                    CommentForValidation.create(
                        CommentForValidation.CommentSource.HUMAN,
                        CommentForValidation.CommentType.CHANGE_MESSAGE,
                        changeMessage,
                        changeMessage.length())))
            .collect(toImmutableList());
    ImmutableList draftValidationFailures =
        PublishCommentUtil.findInvalidComments(
            commentValidationCtx, commentValidators, draftsForValidation);
    if (!draftValidationFailures.isEmpty()) {
      throw new CommentsRejectedException(draftValidationFailures);
    }
  }
  private boolean insertRobotComments(ChangeContext ctx, List newRobotComments) {
    if (in.robotComments == null) {
      return false;
    }
    commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
    comments.addAll(newRobotComments);
    return !newRobotComments.isEmpty();
  }
  private List getNewRobotComments(ChangeContext ctx) {
    List toAdd = new ArrayList<>(in.robotComments.size());
    Set existingIds =
        in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
    for (Map.Entry> ent : in.robotComments.entrySet()) {
      String path = ent.getKey();
      for (RobotCommentInput c : ent.getValue()) {
        RobotComment e = createRobotCommentFromInput(ctx, path, c);
        if (existingIds.contains(CommentSetEntry.create(e))) {
          continue;
        }
        toAdd.add(e);
      }
    }
    return toAdd;
  }
  private RobotComment createRobotCommentFromInput(
      ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
    RobotComment robotComment =
        commentsUtil.newRobotComment(
            ctx,
            path,
            psId,
            robotCommentInput.side(),
            robotCommentInput.message,
            robotCommentInput.robotId,
            robotCommentInput.robotRunId);
    robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
    robotComment.url = robotCommentInput.url;
    robotComment.properties = robotCommentInput.properties;
    robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
    robotComment.tag = in.tag;
    commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
    robotComment.fixSuggestions =
        CommentsUtil.createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
    return robotComment;
  }
  private Set readExistingComments(ChangeContext ctx) {
    return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
        .map(CommentSetEntry::create)
        .collect(toSet());
  }
  private Set readExistingRobotComments(ChangeContext ctx) {
    return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
        .map(CommentSetEntry::create)
        .collect(toSet());
  }
  private Map changeDrafts(ChangeContext ctx) {
    return draftCommentsReader
        .getDraftsByChangeAndDraftAuthor(ctx.getNotes(), user.getAccountId())
        .stream()
        .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
  }
  private Map patchSetDrafts(ChangeContext ctx) {
    return draftCommentsReader
        .getDraftsByPatchSetAndDraftAuthor(ctx.getNotes(), psId, user.getAccountId())
        .stream()
        .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
  }
  private Map approvalsByKey(Collection patchsetApprovals) {
    Map labels = new HashMap<>();
    for (PatchSetApproval psa : patchsetApprovals) {
      labels.put(psa.label(), psa.value());
    }
    return labels;
  }
  private Map getAllApprovals(
      LabelTypes labelTypes, Map current, Map input) {
    Map allApprovals = new HashMap<>();
    for (LabelType lt : labelTypes.getLabelTypes()) {
      allApprovals.put(lt.getName(), (short) 0);
    }
    // set approvals to existing votes
    if (current != null) {
      allApprovals.putAll(current);
    }
    // set approvals to new votes
    if (input != null) {
      allApprovals.putAll(input);
    }
    return allApprovals;
  }
  private Map getPreviousApprovals(
      Map allApprovals, Map current) {
    Map previous = new HashMap<>();
    for (Map.Entry approval : allApprovals.entrySet()) {
      // assume vote is 0 if there is no vote
      if (!current.containsKey(approval.getKey())) {
        previous.put(approval.getKey(), (short) 0);
      } else {
        previous.put(approval.getKey(), current.get(approval.getKey()));
      }
    }
    return previous;
  }
  private boolean isReviewer(ChangeContext ctx) {
    return approvalsUtil
        .getReviewers(ctx.getNotes())
        .byState(REVIEWER)
        .contains(ctx.getAccountId());
  }
  private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
      throws ResourceConflictException {
    Map inLabels = firstNonNull(in.labels, Collections.emptyMap());
    // If no labels were modified and change is closed, abort early.
    // This avoids trying to record a modified label caused by a user
    // losing access to a label after the change was submitted.
    if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
      return false;
    }
    List del = new ArrayList<>();
    List ups = new ArrayList<>();
    Map current = scanLabels(projectState, ctx, del);
    LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
    Map allApprovals =
        getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
    Map previous =
        getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
    ChangeUpdate update = ctx.getUpdate(psId);
    for (Map.Entry ent : allApprovals.entrySet()) {
      String name = ent.getKey();
      LabelType lt =
          labelTypes
              .byLabel(name)
              .orElseThrow(() -> new IllegalStateException("no label config for " + name));
      PatchSetApproval c = current.remove(lt.getName());
      String normName = lt.getName();
      approvals.put(normName, (short) 0);
      if (ent.getValue() == null || ent.getValue() == 0) {
        // User requested delete of this label.
        oldApprovals.put(normName, null);
        if (c != null) {
          if (c.value() != 0) {
            addLabelDelta(normName, (short) 0);
            oldApprovals.put(normName, previous.get(normName));
          }
          del.add(c);
          update.putApproval(normName, (short) 0);
        }
        // Only allow voting again if the values are different, if the real account differs or if
        // the vote is copied over from a past patch-set.
      } else if (c != null
          && (c.value() != ent.getValue()
              || !c.realAccountId().equals(reviewerId)
              || (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
        PatchSetApproval.Builder b =
            c.toBuilder()
                .value(ent.getValue())
                .granted(ctx.getWhen())
                .tag(Optional.ofNullable(in.tag));
        ctx.getUser().updateRealAccountId(b::realAccountId);
        c = b.build();
        ups.add(c);
        addLabelDelta(normName, c.value());
        oldApprovals.put(normName, previous.get(normName));
        approvals.put(normName, c.value());
        update.putApproval(normName, ent.getValue());
      } else if (c != null && c.value() == ent.getValue()) {
        current.put(normName, c);
        oldApprovals.put(normName, null);
        approvals.put(normName, c.value());
      } else if (c == null) {
        c =
            ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
                .tag(Optional.ofNullable(in.tag))
                .granted(ctx.getWhen())
                .build();
        ups.add(c);
        addLabelDelta(normName, c.value());
        oldApprovals.put(normName, previous.get(normName));
        approvals.put(normName, c.value());
        update.putApproval(normName, ent.getValue());
        // Votes may be applied on outdated patch sets, using a ChangeUpdate that was created for
        // the outdated patch set. Reviewers however cannot be added on outdated patch sets, but
        // only on the change. This means reviewers should always be added using a ChangeUpdate
        // that was created for the current patch set.
        // This is important so that updates on the current patch set that are done by other ops
        // within the same BatchUpdate after this PostReviewOp was executed can see the reviewer
        // updates. E.g. the AddToAttentionSetOp, that updates the attention set on the current
        // patch set, needs to see newly added reviewers, as otherwise attention set updates for
        // these reviewers are dropped (ChangeUpdate#updateAttentionSet drops attention set updates
        // for users that are not active on the change, i.e. for users that are neither change
        // owner, uploader nor reviewer).
        ctx.getUpdate(notes.getChange().currentPatchSetId())
            .putReviewer(user.getAccountId(), REVIEWER);
      }
    }
    validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
    // Return early if user is not a reviewer and not posting any labels.
    // This allows us to preserve their CC status.
    if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
      return false;
    }
    return !del.isEmpty() || !ups.isEmpty();
  }
  /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
  private boolean isApprovalCopiedOver(PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
    return !changeNotes
        .getApprovals()
        .onlyNonCopied()
        .get(changeNotes.getChange().currentPatchSetId())
        .stream()
        .anyMatch(p -> p.equals(patchSetApproval));
  }
  private void validatePostSubmitLabels(
      ChangeContext ctx,
      LabelTypes labelTypes,
      Map previous,
      List ups,
      List del)
      throws ResourceConflictException {
    if (ctx.getChange().isNew()) {
      return; // Not closed, nothing to validate.
    } else if (del.isEmpty() && ups.isEmpty()) {
      return; // No new votes.
    } else if (!ctx.getChange().isMerged()) {
      throw new ResourceConflictException("change is closed");
    }
    // Disallow reducing votes on any labels post-submit. This assumes the
    // high values were broadly necessary to submit, so reducing them would
    // make it possible to take a merged change and make it no longer
    // submittable.
    List reduced = new ArrayList<>(ups.size() + del.size());
    List disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
    for (PatchSetApproval psa : del) {
      LabelType lt =
          labelTypes
              .byLabel(psa.label())
              .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
      String normName = lt.getName();
      if (!lt.isAllowPostSubmit()) {
        disallowed.add(normName);
      }
      Short prev = previous.get(normName);
      if (prev != null && prev != 0) {
        reduced.add(psa);
      }
    }
    for (PatchSetApproval psa : ups) {
      LabelType lt =
          labelTypes
              .byLabel(psa.label())
              .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
      String normName = lt.getName();
      if (!lt.isAllowPostSubmit()) {
        disallowed.add(normName);
      }
      Short prev = previous.get(normName);
      if (prev == null) {
        continue;
      }
      if (prev > psa.value()) {
        reduced.add(psa);
      }
      // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
    }
    if (!disallowed.isEmpty()) {
      throw new ResourceConflictException(
          "Voting on labels disallowed after submit: "
              + disallowed.stream().distinct().sorted().collect(joining(", ")));
    }
    if (!reduced.isEmpty()) {
      throw new ResourceConflictException(
          "Cannot reduce vote on labels for closed change: "
              + reduced.stream()
                  .map(PatchSetApproval::label)
                  .distinct()
                  .sorted()
                  .collect(joining(", ")));
    }
  }
  private Map scanLabels(
      ProjectState projectState, ChangeContext ctx, List del) {
    LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
    Map current = new HashMap<>();
    for (PatchSetApproval a :
        approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
      if (a.isLegacySubmit()) {
        continue;
      }
      Optional lt = labelTypes.byLabel(a.labelId());
      if (lt.isPresent()) {
        current.put(lt.get().getName(), a);
      } else {
        del.add(a);
      }
    }
    return current;
  }
  /**
   * Copies approvals that have been newly applied on outdated patch sets to the follow-up patch
   * sets if they are copyable and no non-copied approvals prevent the copying.
   *
   * Must be invoked after the new approvals on outdated patch sets have been applied (e.g. after
   * {@link #updateLabels(ProjectState, ChangeContext)}.
   *
   * @param ctx the change context
   * @return {@code true} if an update was done, otherwise {@code false}
   */
  private boolean updateCopiedApprovalsOnFollowUpPatchSets(ChangeContext ctx) throws IOException {
    if (ctx.getNotes().getCurrentPatchSet().id().equals(psId)) {
      // the updated patch set is the current patch, there a no follow-up patch set to which new
      // approvals could be copied
      return false;
    }
    // compute follow-up patch sets (sorted by patch set ID)
    ImmutableList followUpPatchSets =
        ctx.getNotes().getPatchSets().keySet().stream()
            .filter(patchSetId -> patchSetId.get() > psId.get())
            .collect(toImmutableList());
    boolean dirty = false;
    ImmutableTable> newApprovals =
        ctx.getUpdate(psId).getApprovals();
    for (Cell> cell : newApprovals.cellSet()) {
      PatchSetApproval psaOrig = cell.getValue().get();
      if (isRemoval(cell)) {
        if (removeCopies(ctx, followUpPatchSets, psaOrig)) {
          dirty = true;
        }
        continue;
      }
      PatchSet patchSet = psUtil.get(ctx.getNotes(), psId);
      // Target patch sets to which the approval is copyable.
      ImmutableList targetPatchSets =
          approvalCopier.forApproval(
              ctx.getNotes(), patchSet, psaOrig.accountId(), psaOrig.label(), psaOrig.value());
      // Iterate over all follow-up patch sets, in patch set order.
      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
        if (hasOverrideOf(ctx, followUpPatchSetId, psaOrig.key())) {
          // a non-copied approval exists that overrides any copied approval
          // -> do not copy the approval to this patch set nor to any follow-up patch sets
          break;
        }
        if (targetPatchSets.contains(followUpPatchSetId)) {
          // The approval is copyable to the new patch set.
          if (hasCopyOfWithValue(ctx, followUpPatchSetId, psaOrig)) {
            // a copy approval with the exact value already exists
            continue;
          }
          // add/update the copied approval on the target patch set
          Optional copiedPsa = getCopyOf(ctx, followUpPatchSetId, psaOrig.key());
          PatchSetApproval copiedPatchSetApproval = psaOrig.copyWithPatchSet(followUpPatchSetId);
          ctx.getUpdate(followUpPatchSetId).putCopiedApproval(copiedPatchSetApproval);
          labelUpdatesOnFollowUpPatchSets.put(
              LabelVote.createFrom(psaOrig),
              copiedPsa.isPresent()
                  ? CopiedLabelUpdate.updated(
                      followUpPatchSetId, LabelVote.createFrom(copiedPsa.get()))
                  : CopiedLabelUpdate.added(followUpPatchSetId));
          dirty = true;
        } else {
          // The approval is not copyable to the new patch set.
          Optional copiedPsa = getCopyOf(ctx, followUpPatchSetId, psaOrig.key());
          if (copiedPsa.isPresent()) {
            // a copy approval exists and should be removed
            removeCopy(ctx, psaOrig, copiedPsa.get());
            dirty = true;
          }
        }
      }
    }
    return dirty;
  }
  /**
   * Whether the given cell entry from the approval table represents the removal of an approval.
   *
   * @param cell cell entry from the approval table
   * @return {@code true} if the approval is not set or the approval has {@code 0} as the value,
   *     otherwise {@code false}
   */
  private boolean isRemoval(Cell> cell) {
    return cell.getValue().isEmpty() || cell.getValue().get().value() == 0;
  }
  /**
   * Removes copies of the given approval from all follow-up patch sets.
   *
   * @param ctx the change context
   * @param followUpPatchSets the follow-up patch sets of the patch set on which the review is
   *     posted
   * @param psaOrig the original patch set approval for which copies should be removed from all
   *     follow-up patch sets
   * @return whether any copy approval has been removed
   */
  private boolean removeCopies(
      ChangeContext ctx, ImmutableList followUpPatchSets, PatchSetApproval psaOrig) {
    boolean dirty = false;
    for (PatchSet.Id followUpPatchSet : followUpPatchSets) {
      Optional copiedPsa = getCopyOf(ctx, followUpPatchSet, psaOrig.key());
      if (copiedPsa.isPresent()) {
        removeCopy(ctx, psaOrig, copiedPsa.get());
      } else {
        // Do not remove copy from this follow-up patch sets and also not from any further follow-up
        // patch sets (if the further follow-up patch sets have copies they are copies of a
        // non-copied approval on this follow-up patch set and hence those should not be removed).
        break;
      }
    }
    return dirty;
  }
  /**
   * Removes the copy approval with the given key from the given patch set.
   *
   * @param ctx the change context
   * @param psaOrig the original patch set approval for which copies should be removed from the
   *     given patch set
   * @param copiedPsa the copied patch set approval that should be removed
   */
  private void removeCopy(ChangeContext ctx, PatchSetApproval psaOrig, PatchSetApproval copiedPsa) {
    ctx.getUpdate(copiedPsa.patchSetId())
        .removeCopiedApprovalFor(
            ctx.getIdentifiedUser().getRealUser().isIdentifiedUser()
                ? ctx.getIdentifiedUser().getRealUser().getAccountId()
                : null,
            copiedPsa.accountId(),
            copiedPsa.labelId().get());
    labelUpdatesOnFollowUpPatchSets.put(
        LabelVote.createFrom(psaOrig),
        CopiedLabelUpdate.removed(copiedPsa.patchSetId(), LabelVote.createFrom(copiedPsa)));
  }
  /**
   * Retrieves the copy of the given approval from the given patch set if it exists.
   *
   * @param ctx the change context
   * @param patchSetId the ID of the patch from which it the copied approval should be returned
   * @param psaKey the key of the patch set approval for which the copied approval should be
   *     returned
   * @return the copy of the given approval from the given patch set if it exists
   */
  private Optional getCopyOf(
      ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
        .filter(psa -> areAccountAndLabelTheSame(psa.key(), psaKey))
        .findAny();
  }
  /**
   * Whether the given patch set has a copy approval with the given key and value.
   *
   * @param ctx the change context
   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
   *     approval with the given key and value
   * @param psaOrig the original patch set approval
   */
  private boolean hasCopyOfWithValue(
      ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval psaOrig) {
    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
        .anyMatch(
            psa ->
                areAccountAndLabelTheSame(psa.key(), psaOrig.key())
                    && psa.value() == psaOrig.value());
  }
  /**
   * Whether the given patch set has a normal approval with the given key that overrides copy
   * approvals with that key.
   *
   * @param ctx the change context
   * @param patchSetId the ID of the patch for which it should be checked whether it has a normal
   *     approval with the given key that overrides copy approvals with that key
   * @param psaKey the key of the patch set approval
   */
  private boolean hasOverrideOf(
      ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
    return ctx.getNotes().getApprovals().onlyNonCopied().get(patchSetId).stream()
        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
  }
  private boolean areAccountAndLabelTheSame(
      PatchSetApproval.Key psaKey1, PatchSetApproval.Key psaKey2) {
    return psaKey1.accountId().equals(psaKey2.accountId())
        && psaKey1.labelId().equals(psaKey2.labelId());
  }
  private boolean insertMessage(ChangeContext ctx) {
    String msg = Strings.nullToEmpty(in.message).trim();
    StringBuilder buf = new StringBuilder();
    for (String formattedLabelVote :
        labelDelta.stream().map(LabelVote::format).sorted().collect(toImmutableList())) {
      buf.append(" ").append(formattedLabelVote);
    }
    if (!labelUpdatesOnFollowUpPatchSets.isEmpty()) {
      buf.append("\n\nCopied votes on follow-up patch sets have been updated:");
      for (Map.Entry> e :
          labelUpdatesOnFollowUpPatchSets.asMap().entrySet().stream()
              .sorted(Map.Entry.comparingByKey(comparing(LabelVote::label)))
              .collect(toImmutableList())) {
        Optional copyCondition =
            projectState
                .getLabelTypes(ctx.getNotes())
                .byLabel(e.getKey().label())
                .map(LabelType::getCopyCondition)
                .map(Optional::get);
        buf.append(formatVotesCopiedToFollowUpPatchSets(e.getKey(), e.getValue(), copyCondition));
      }
    }
    if (comments.size() == 1) {
      buf.append("\n\n(1 comment)");
    } else if (comments.size() > 1) {
      buf.append(String.format("\n\n(%d comments)", comments.size()));
    }
    if (!msg.isEmpty()) {
      // Message was already validated when validating comments, since validators need to see
      // everything in a single call.
      buf.append("\n\n").append(msg);
    } else if (in.ready) {
      buf.append("\n\n" + START_REVIEW_MESSAGE);
    }
    List pluginMessages = new ArrayList<>();
    onPostReviews.runEach(
        onPostReview ->
            onPostReview
                .getChangeMessageAddOn(
                    ctx.getWhen(), user, ctx.getNotes(), ps, oldApprovals, approvals)
                .ifPresent(
                    pluginMessage ->
                        pluginMessages.add(
                            !pluginMessage.endsWith("\n") ? pluginMessage + "\n" : pluginMessage)));
    if (!pluginMessages.isEmpty()) {
      buf.append("\n\n");
      buf.append(Joiner.on("\n").join(pluginMessages));
    }
    if (buf.length() == 0) {
      return false;
    }
    mailMessage =
        cmUtil.setChangeMessage(ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
    return true;
  }
  /**
   * Given a label vote that has been applied on an outdated patch set, this method formats the
   * updates to the copied labels on the follow-up patch sets that have been performed for that
   * label vote.
   *
   * If label votes have been copied to follow-up patch sets the formatted message is
   * " has been copied to patch sets: 3, 4 (copy condition: "").".
   *
   * If existing copied votes on follow-up patch sets have been updated, the old copied votes are
   * included into the message: " has been copied to patch sets: 3 (was
   * ), 4 (was ) (copy condition: "").".
   *
   * If existing copied votes on follow-up patch sets have been removed (because the new vote is
   * not copyable) the message is: "Copied