LCOV - code coverage report
Current view: top level - server/restapi/change - PostReviewOp.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 359 366 98.1 %
Date: 2022-11-19 15:00:39 Functions: 36 40 90.0 %

          Line data    Source code
       1             : // Copyright (C) 2022 The Android Open Source Project
       2             : //
       3             : // Licensed under the Apache License, Version 2.0 (the "License");
       4             : // you may not use this file except in compliance with the License.
       5             : // You may obtain a copy of the License at
       6             : //
       7             : // http://www.apache.org/licenses/LICENSE-2.0
       8             : //
       9             : // Unless required by applicable law or agreed to in writing, software
      10             : // distributed under the License is distributed on an "AS IS" BASIS,
      11             : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12             : // See the License for the specific language governing permissions and
      13             : // limitations under the License.
      14             : 
      15             : package com.google.gerrit.server.restapi.change;
      16             : 
      17             : import static com.google.common.base.MoreObjects.firstNonNull;
      18             : import static com.google.common.collect.ImmutableList.toImmutableList;
      19             : import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
      20             : import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
      21             : import static java.util.stream.Collectors.joining;
      22             : import static java.util.stream.Collectors.toList;
      23             : import static java.util.stream.Collectors.toSet;
      24             : 
      25             : import com.google.common.annotations.VisibleForTesting;
      26             : import com.google.common.base.Joiner;
      27             : import com.google.common.base.Strings;
      28             : import com.google.common.collect.ImmutableList;
      29             : import com.google.common.collect.Streams;
      30             : import com.google.gerrit.entities.Comment;
      31             : import com.google.gerrit.entities.FixReplacement;
      32             : import com.google.gerrit.entities.FixSuggestion;
      33             : import com.google.gerrit.entities.HumanComment;
      34             : import com.google.gerrit.entities.LabelType;
      35             : import com.google.gerrit.entities.LabelTypes;
      36             : import com.google.gerrit.entities.PatchSet;
      37             : import com.google.gerrit.entities.PatchSetApproval;
      38             : import com.google.gerrit.entities.RobotComment;
      39             : import com.google.gerrit.extensions.api.changes.ReviewInput;
      40             : import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
      41             : import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
      42             : import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
      43             : import com.google.gerrit.extensions.common.FixReplacementInfo;
      44             : import com.google.gerrit.extensions.common.FixSuggestionInfo;
      45             : import com.google.gerrit.extensions.restapi.ResourceConflictException;
      46             : import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
      47             : import com.google.gerrit.extensions.restapi.Url;
      48             : import com.google.gerrit.extensions.validators.CommentForValidation;
      49             : import com.google.gerrit.extensions.validators.CommentValidationContext;
      50             : import com.google.gerrit.extensions.validators.CommentValidationFailure;
      51             : import com.google.gerrit.extensions.validators.CommentValidator;
      52             : import com.google.gerrit.server.ChangeMessagesUtil;
      53             : import com.google.gerrit.server.ChangeUtil;
      54             : import com.google.gerrit.server.CommentsUtil;
      55             : import com.google.gerrit.server.IdentifiedUser;
      56             : import com.google.gerrit.server.PatchSetUtil;
      57             : import com.google.gerrit.server.PublishCommentUtil;
      58             : import com.google.gerrit.server.approval.ApprovalsUtil;
      59             : import com.google.gerrit.server.change.EmailReviewComments;
      60             : import com.google.gerrit.server.change.NotifyResolver;
      61             : import com.google.gerrit.server.config.GerritServerConfig;
      62             : import com.google.gerrit.server.extensions.events.CommentAdded;
      63             : import com.google.gerrit.server.logging.Metadata;
      64             : import com.google.gerrit.server.logging.TraceContext;
      65             : import com.google.gerrit.server.notedb.ChangeNotes;
      66             : import com.google.gerrit.server.notedb.ChangeUpdate;
      67             : import com.google.gerrit.server.plugincontext.PluginSetContext;
      68             : import com.google.gerrit.server.project.ProjectState;
      69             : import com.google.gerrit.server.restapi.change.PostReview.CommentSetEntry;
      70             : import com.google.gerrit.server.update.BatchUpdateOp;
      71             : import com.google.gerrit.server.update.ChangeContext;
      72             : import com.google.gerrit.server.update.CommentsRejectedException;
      73             : import com.google.gerrit.server.update.PostUpdateContext;
      74             : import com.google.gerrit.server.util.LabelVote;
      75             : import com.google.inject.Inject;
      76             : import com.google.inject.assistedinject.Assisted;
      77             : import java.io.IOException;
      78             : import java.sql.Timestamp;
      79             : import java.util.ArrayList;
      80             : import java.util.Collection;
      81             : import java.util.Collections;
      82             : import java.util.HashMap;
      83             : import java.util.List;
      84             : import java.util.Map;
      85             : import java.util.Optional;
      86             : import java.util.Set;
      87             : import java.util.stream.Collectors;
      88             : import java.util.stream.Stream;
      89             : import org.eclipse.jgit.lib.Config;
      90             : 
      91             : public class PostReviewOp implements BatchUpdateOp {
      92             :   interface Factory {
      93             :     PostReviewOp create(ProjectState projectState, PatchSet.Id psId, ReviewInput in);
      94             :   }
      95             : 
      96             :   @VisibleForTesting
      97             :   public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
      98             : 
      99             :   private final ApprovalsUtil approvalsUtil;
     100             :   private final ChangeMessagesUtil cmUtil;
     101             :   private final CommentsUtil commentsUtil;
     102             :   private final PublishCommentUtil publishCommentUtil;
     103             :   private final PatchSetUtil psUtil;
     104             :   private final EmailReviewComments.Factory email;
     105             :   private final CommentAdded commentAdded;
     106             :   private final PluginSetContext<CommentValidator> commentValidators;
     107             :   private final PluginSetContext<OnPostReview> onPostReviews;
     108             : 
     109             :   private final ProjectState projectState;
     110             :   private final PatchSet.Id psId;
     111             :   private final ReviewInput in;
     112             :   private final boolean publishPatchSetLevelComment;
     113             : 
     114             :   private IdentifiedUser user;
     115             :   private ChangeNotes notes;
     116             :   private PatchSet ps;
     117             :   private String mailMessage;
     118          65 :   private List<Comment> comments = new ArrayList<>();
     119          65 :   private List<LabelVote> labelDelta = new ArrayList<>();
     120          65 :   private Map<String, Short> approvals = new HashMap<>();
     121          65 :   private Map<String, Short> oldApprovals = new HashMap<>();
     122             : 
     123             :   @Inject
     124             :   PostReviewOp(
     125             :       @GerritServerConfig Config gerritConfig,
     126             :       ApprovalsUtil approvalsUtil,
     127             :       ChangeMessagesUtil cmUtil,
     128             :       CommentsUtil commentsUtil,
     129             :       PublishCommentUtil publishCommentUtil,
     130             :       PatchSetUtil psUtil,
     131             :       EmailReviewComments.Factory email,
     132             :       CommentAdded commentAdded,
     133             :       PluginSetContext<CommentValidator> commentValidators,
     134             :       PluginSetContext<OnPostReview> onPostReviews,
     135             :       @Assisted ProjectState projectState,
     136             :       @Assisted PatchSet.Id psId,
     137          65 :       @Assisted ReviewInput in) {
     138          65 :     this.approvalsUtil = approvalsUtil;
     139          65 :     this.publishCommentUtil = publishCommentUtil;
     140          65 :     this.psUtil = psUtil;
     141          65 :     this.cmUtil = cmUtil;
     142          65 :     this.commentsUtil = commentsUtil;
     143          65 :     this.email = email;
     144          65 :     this.commentAdded = commentAdded;
     145          65 :     this.commentValidators = commentValidators;
     146          65 :     this.onPostReviews = onPostReviews;
     147          65 :     this.publishPatchSetLevelComment =
     148          65 :         gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
     149             : 
     150          65 :     this.projectState = projectState;
     151          65 :     this.psId = psId;
     152          65 :     this.in = in;
     153          65 :   }
     154             : 
     155             :   @Override
     156             :   public boolean updateChange(ChangeContext ctx)
     157             :       throws ResourceConflictException, UnprocessableEntityException, IOException,
     158             :           CommentsRejectedException {
     159          65 :     user = ctx.getIdentifiedUser();
     160          65 :     notes = ctx.getNotes();
     161          65 :     ps = psUtil.get(ctx.getNotes(), psId);
     162             :     List<RobotComment> newRobotComments =
     163          65 :         in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
     164          65 :     boolean dirty = false;
     165          65 :     try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
     166          65 :       dirty |= insertComments(ctx, newRobotComments);
     167             :     }
     168          65 :     try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
     169          65 :       dirty |= insertRobotComments(ctx, newRobotComments);
     170             :     }
     171          65 :     try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
     172          65 :       dirty |= updateLabels(projectState, ctx);
     173             :     }
     174          65 :     try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
     175          65 :       dirty |= insertMessage(ctx);
     176             :     }
     177          65 :     return dirty;
     178             :   }
     179             : 
     180             :   @Override
     181             :   public void postUpdate(PostUpdateContext ctx) {
     182          65 :     if (mailMessage == null) {
     183          24 :       return;
     184             :     }
     185          65 :     NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
     186          65 :     if (notify.shouldNotify()) {
     187          65 :       email
     188          65 :           .create(ctx, ps, notes.getMetaId(), mailMessage, comments, in.message, labelDelta)
     189          65 :           .sendAsync();
     190             :     }
     191          65 :     String comment = mailMessage;
     192          65 :     if (publishPatchSetLevelComment) {
     193             :       // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
     194             :       // added event. For backwards compatibility, patchset level comment has a higher priority
     195             :       // than change message and should be used as comment in comment added event.
     196          65 :       if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
     197           2 :         List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
     198           2 :         if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
     199           2 :           CommentInput firstComment = patchSetLevelComments.get(0);
     200           2 :           if (!Strings.isNullOrEmpty(firstComment.message)) {
     201           2 :             comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
     202             :           }
     203             :         }
     204             :       }
     205             :     }
     206          65 :     commentAdded.fire(
     207          65 :         ctx.getChangeData(notes),
     208             :         ps,
     209          65 :         user.state(),
     210             :         comment,
     211             :         approvals,
     212             :         oldApprovals,
     213          65 :         ctx.getWhen());
     214          65 :   }
     215             : 
     216             :   /**
     217             :    * Publishes draft and input comments. Input comments are those passed as input in the request
     218             :    * body.
     219             :    *
     220             :    * @param ctx context for performing the change update.
     221             :    * @param newRobotComments robot comments. Used only for validation in this method.
     222             :    * @return true if any input comments where published.
     223             :    */
     224             :   private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
     225             :       throws CommentsRejectedException {
     226          65 :     Map<String, List<CommentInput>> inputComments = in.comments;
     227          65 :     if (inputComments == null) {
     228          61 :       inputComments = Collections.emptyMap();
     229             :     }
     230             : 
     231             :     // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
     232          65 :     Map<String, HumanComment> drafts = new HashMap<>();
     233             : 
     234          65 :     if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
     235             :       drafts =
     236          20 :           in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
     237           2 :               ? changeDrafts(ctx)
     238          20 :               : patchSetDrafts(ctx);
     239             :     }
     240             : 
     241             :     // Existing published comments
     242             :     Set<CommentSetEntry> existingComments =
     243          65 :         in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
     244             : 
     245             :     // Input comments should be deduplicated from existing drafts
     246          65 :     List<HumanComment> inputCommentsToPublish =
     247          65 :         resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
     248             : 
     249          65 :     switch (in.drafts) {
     250             :       case PUBLISH:
     251             :       case PUBLISH_ALL_REVISIONS:
     252             :         Collection<HumanComment> filteredDrafts =
     253           9 :             in.draftIdsToPublish == null
     254           9 :                 ? drafts.values()
     255           1 :                 : drafts.values().stream()
     256           1 :                     .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
     257           9 :                     .collect(Collectors.toList());
     258             : 
     259           9 :         validateComments(
     260             :             ctx,
     261           9 :             Streams.concat(
     262           9 :                 drafts.values().stream(),
     263           9 :                 inputCommentsToPublish.stream(),
     264           9 :                 newRobotComments.stream()));
     265           9 :         publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
     266           9 :         comments.addAll(drafts.values());
     267           9 :         break;
     268             :       case KEEP:
     269          64 :         validateComments(
     270          64 :             ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
     271             :         break;
     272             :     }
     273          65 :     commentsUtil.putHumanComments(
     274          65 :         ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
     275          65 :     comments.addAll(inputCommentsToPublish);
     276          65 :     return !inputCommentsToPublish.isEmpty();
     277             :   }
     278             : 
     279             :   /**
     280             :    * Returns the subset of {@code inputComments} that do not have a matching comment (with same id)
     281             :    * neither in {@code existingComments} nor in {@code drafts}.
     282             :    *
     283             :    * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
     284             :    * removed.
     285             :    *
     286             :    * @param inputComments new comments provided as {@link CommentInput} entries in the API.
     287             :    * @param existingComments existing published comments in the database.
     288             :    * @param drafts existing draft comments in the database. This map can be modified.
     289             :    */
     290             :   private List<HumanComment> resolveInputCommentsAndDrafts(
     291             :       Map<String, List<CommentInput>> inputComments,
     292             :       Set<CommentSetEntry> existingComments,
     293             :       Map<String, HumanComment> drafts,
     294             :       ChangeContext ctx) {
     295          65 :     List<HumanComment> inputCommentsToPublish = new ArrayList<>();
     296          65 :     for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
     297          17 :       String path = entry.getKey();
     298          17 :       for (CommentInput inputComment : entry.getValue()) {
     299          17 :         HumanComment comment = drafts.remove(Url.decode(inputComment.id));
     300          17 :         if (comment == null) {
     301          17 :           String parent = Url.decode(inputComment.inReplyTo);
     302          17 :           comment =
     303          17 :               commentsUtil.newHumanComment(
     304          17 :                   ctx.getNotes(),
     305          17 :                   ctx.getUser(),
     306          17 :                   ctx.getWhen(),
     307             :                   path,
     308             :                   psId,
     309          17 :                   inputComment.side(),
     310             :                   inputComment.message,
     311             :                   inputComment.unresolved,
     312             :                   parent);
     313          17 :         } else {
     314             :           // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
     315           1 :           comment.writtenOn = Timestamp.from(ctx.getWhen());
     316           1 :           comment.side = inputComment.side();
     317           1 :           comment.message = inputComment.message;
     318             :         }
     319             : 
     320          17 :         commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
     321          17 :         comment.setLineNbrAndRange(inputComment.line, inputComment.range);
     322          17 :         comment.tag = in.tag;
     323             : 
     324          17 :         if (existingComments.contains(CommentSetEntry.create(comment))) {
     325           1 :           continue;
     326             :         }
     327          17 :         inputCommentsToPublish.add(comment);
     328          17 :       }
     329          17 :     }
     330          65 :     return inputCommentsToPublish;
     331             :   }
     332             : 
     333             :   /**
     334             :    * Validates all comments and the change message in a single call to fulfill the interface
     335             :    * contract of {@link CommentValidator#validateComments(CommentValidationContext, ImmutableList)}.
     336             :    */
     337             :   private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
     338             :       throws CommentsRejectedException {
     339          65 :     CommentValidationContext commentValidationCtx =
     340          65 :         CommentValidationContext.create(
     341          65 :             ctx.getChange().getChangeId(),
     342          65 :             ctx.getChange().getProject().get(),
     343          65 :             ctx.getChange().getDest().branch());
     344          65 :     String changeMessage = Strings.nullToEmpty(in.message).trim();
     345          65 :     ImmutableList<CommentForValidation> draftsForValidation =
     346          65 :         Stream.concat(
     347          65 :                 comments.map(
     348             :                     comment ->
     349          20 :                         CommentForValidation.create(
     350          20 :                             comment instanceof RobotComment
     351           7 :                                 ? CommentForValidation.CommentSource.ROBOT
     352          19 :                                 : CommentForValidation.CommentSource.HUMAN,
     353          20 :                             comment.lineNbr > 0
     354          17 :                                 ? CommentForValidation.CommentType.INLINE_COMMENT
     355          20 :                                 : CommentForValidation.CommentType.FILE_COMMENT,
     356             :                             comment.message,
     357          20 :                             comment.getApproximateSize())),
     358          65 :                 Stream.of(
     359          65 :                     CommentForValidation.create(
     360             :                         CommentForValidation.CommentSource.HUMAN,
     361             :                         CommentForValidation.CommentType.CHANGE_MESSAGE,
     362             :                         changeMessage,
     363          65 :                         changeMessage.length())))
     364          65 :             .collect(toImmutableList());
     365          65 :     ImmutableList<CommentValidationFailure> draftValidationFailures =
     366          65 :         PublishCommentUtil.findInvalidComments(
     367             :             commentValidationCtx, commentValidators, draftsForValidation);
     368          65 :     if (!draftValidationFailures.isEmpty()) {
     369           3 :       throw new CommentsRejectedException(draftValidationFailures);
     370             :     }
     371          65 :   }
     372             : 
     373             :   private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
     374          65 :     if (in.robotComments == null) {
     375          64 :       return false;
     376             :     }
     377           7 :     commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
     378           7 :     comments.addAll(newRobotComments);
     379           7 :     return !newRobotComments.isEmpty();
     380             :   }
     381             : 
     382             :   private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
     383           7 :     List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
     384             : 
     385             :     Set<CommentSetEntry> existingIds =
     386           7 :         in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
     387             : 
     388           7 :     for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
     389           7 :       String path = ent.getKey();
     390           7 :       for (RobotCommentInput c : ent.getValue()) {
     391           7 :         RobotComment e = createRobotCommentFromInput(ctx, path, c);
     392           7 :         if (existingIds.contains(CommentSetEntry.create(e))) {
     393           0 :           continue;
     394             :         }
     395           7 :         toAdd.add(e);
     396           7 :       }
     397           7 :     }
     398           7 :     return toAdd;
     399             :   }
     400             : 
     401             :   private RobotComment createRobotCommentFromInput(
     402             :       ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
     403           7 :     RobotComment robotComment =
     404           7 :         commentsUtil.newRobotComment(
     405             :             ctx,
     406             :             path,
     407             :             psId,
     408           7 :             robotCommentInput.side(),
     409             :             robotCommentInput.message,
     410             :             robotCommentInput.robotId,
     411             :             robotCommentInput.robotRunId);
     412           7 :     robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
     413           7 :     robotComment.url = robotCommentInput.url;
     414           7 :     robotComment.properties = robotCommentInput.properties;
     415           7 :     robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
     416           7 :     robotComment.tag = in.tag;
     417           7 :     commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
     418           7 :     robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
     419           7 :     return robotComment;
     420             :   }
     421             : 
     422             :   private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
     423             :       List<FixSuggestionInfo> fixSuggestionInfos) {
     424           7 :     if (fixSuggestionInfos == null) {
     425           6 :       return ImmutableList.of();
     426             :     }
     427             : 
     428           3 :     ImmutableList.Builder<FixSuggestion> fixSuggestions =
     429           3 :         ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
     430           3 :     for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
     431           2 :       fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
     432           2 :     }
     433           3 :     return fixSuggestions.build();
     434             :   }
     435             : 
     436             :   private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
     437           2 :     List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
     438           2 :     String fixId = ChangeUtil.messageUuid();
     439           2 :     return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
     440             :   }
     441             : 
     442             :   private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
     443           2 :     return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
     444             :   }
     445             : 
     446             :   private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
     447           2 :     Comment.Range range = new Comment.Range(fixReplacementInfo.range);
     448           2 :     return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
     449             :   }
     450             : 
     451             :   private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
     452           2 :     return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
     453           2 :         .map(CommentSetEntry::create)
     454           2 :         .collect(toSet());
     455             :   }
     456             : 
     457             :   private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
     458           0 :     return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
     459           0 :         .map(CommentSetEntry::create)
     460           0 :         .collect(toSet());
     461             :   }
     462             : 
     463             :   private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
     464           2 :     return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
     465           2 :         .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
     466             :   }
     467             : 
     468             :   private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
     469          20 :     return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
     470          20 :         .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
     471             :   }
     472             : 
     473             :   private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
     474          65 :     Map<String, Short> labels = new HashMap<>();
     475          65 :     for (PatchSetApproval psa : patchsetApprovals) {
     476          28 :       labels.put(psa.label(), psa.value());
     477          28 :     }
     478          65 :     return labels;
     479             :   }
     480             : 
     481             :   private Map<String, Short> getAllApprovals(
     482             :       LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
     483          65 :     Map<String, Short> allApprovals = new HashMap<>();
     484          65 :     for (LabelType lt : labelTypes.getLabelTypes()) {
     485          65 :       allApprovals.put(lt.getName(), (short) 0);
     486          65 :     }
     487             :     // set approvals to existing votes
     488          65 :     if (current != null) {
     489          65 :       allApprovals.putAll(current);
     490             :     }
     491             :     // set approvals to new votes
     492          65 :     if (input != null) {
     493          65 :       allApprovals.putAll(input);
     494             :     }
     495          65 :     return allApprovals;
     496             :   }
     497             : 
     498             :   private Map<String, Short> getPreviousApprovals(
     499             :       Map<String, Short> allApprovals, Map<String, Short> current) {
     500          65 :     Map<String, Short> previous = new HashMap<>();
     501          65 :     for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
     502             :       // assume vote is 0 if there is no vote
     503          65 :       if (!current.containsKey(approval.getKey())) {
     504          65 :         previous.put(approval.getKey(), (short) 0);
     505             :       } else {
     506          28 :         previous.put(approval.getKey(), current.get(approval.getKey()));
     507             :       }
     508          65 :     }
     509          65 :     return previous;
     510             :   }
     511             : 
     512             :   private boolean isReviewer(ChangeContext ctx) {
     513          23 :     return approvalsUtil
     514          23 :         .getReviewers(ctx.getNotes())
     515          23 :         .byState(REVIEWER)
     516          23 :         .contains(ctx.getAccountId());
     517             :   }
     518             : 
     519             :   private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
     520             :       throws ResourceConflictException {
     521          65 :     Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
     522             : 
     523             :     // If no labels were modified and change is closed, abort early.
     524             :     // This avoids trying to record a modified label caused by a user
     525             :     // losing access to a label after the change was submitted.
     526          65 :     if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
     527           2 :       return false;
     528             :     }
     529             : 
     530          65 :     List<PatchSetApproval> del = new ArrayList<>();
     531          65 :     List<PatchSetApproval> ups = new ArrayList<>();
     532          65 :     Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
     533          65 :     LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
     534          65 :     Map<String, Short> allApprovals =
     535          65 :         getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
     536          65 :     Map<String, Short> previous =
     537          65 :         getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
     538             : 
     539          65 :     ChangeUpdate update = ctx.getUpdate(psId);
     540          65 :     for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
     541          65 :       String name = ent.getKey();
     542          65 :       LabelType lt =
     543             :           labelTypes
     544          65 :               .byLabel(name)
     545          65 :               .orElseThrow(() -> new IllegalStateException("no label config for " + name));
     546             : 
     547          65 :       PatchSetApproval c = current.remove(lt.getName());
     548          65 :       String normName = lt.getName();
     549          65 :       approvals.put(normName, (short) 0);
     550          65 :       if (ent.getValue() == null || ent.getValue() == 0) {
     551             :         // User requested delete of this label.
     552          31 :         oldApprovals.put(normName, null);
     553          31 :         if (c != null) {
     554           9 :           if (c.value() != 0) {
     555           9 :             addLabelDelta(normName, (short) 0);
     556           9 :             oldApprovals.put(normName, previous.get(normName));
     557             :           }
     558           9 :           del.add(c);
     559           9 :           update.putApproval(normName, (short) 0);
     560             :         }
     561             :         // Only allow voting again if the vote is copied over from a past patch-set, or the
     562             :         // values are different.
     563          58 :       } else if (c != null
     564          28 :           && (c.value() != ent.getValue()
     565          26 :               || (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
     566          14 :         PatchSetApproval.Builder b =
     567          14 :             c.toBuilder()
     568          14 :                 .value(ent.getValue())
     569          14 :                 .granted(ctx.getWhen())
     570          14 :                 .tag(Optional.ofNullable(in.tag));
     571          14 :         ctx.getUser().updateRealAccountId(b::realAccountId);
     572          14 :         c = b.build();
     573          14 :         ups.add(c);
     574          14 :         addLabelDelta(normName, c.value());
     575          14 :         oldApprovals.put(normName, previous.get(normName));
     576          14 :         approvals.put(normName, c.value());
     577          14 :         update.putApproval(normName, ent.getValue());
     578          58 :       } else if (c != null && c.value() == ent.getValue()) {
     579          25 :         current.put(normName, c);
     580          25 :         oldApprovals.put(normName, null);
     581          25 :         approvals.put(normName, c.value());
     582          58 :       } else if (c == null) {
     583          58 :         c =
     584          58 :             ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
     585          58 :                 .tag(Optional.ofNullable(in.tag))
     586          58 :                 .granted(ctx.getWhen())
     587          58 :                 .build();
     588          58 :         ups.add(c);
     589          58 :         addLabelDelta(normName, c.value());
     590          58 :         oldApprovals.put(normName, previous.get(normName));
     591          58 :         approvals.put(normName, c.value());
     592          58 :         update.putReviewer(user.getAccountId(), REVIEWER);
     593          58 :         update.putApproval(normName, ent.getValue());
     594             :       }
     595          65 :     }
     596             : 
     597          65 :     validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
     598             : 
     599             :     // Return early if user is not a reviewer and not posting any labels.
     600             :     // This allows us to preserve their CC status.
     601          65 :     if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
     602          23 :       return false;
     603             :     }
     604             : 
     605          58 :     return !del.isEmpty() || !ups.isEmpty();
     606             :   }
     607             : 
     608             :   /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
     609             :   private boolean isApprovalCopiedOver(PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
     610          20 :     return !changeNotes.getApprovals().onlyNonCopied()
     611          20 :         .get(changeNotes.getChange().currentPatchSetId()).stream()
     612          20 :         .anyMatch(p -> p.equals(patchSetApproval));
     613             :   }
     614             : 
     615             :   private void validatePostSubmitLabels(
     616             :       ChangeContext ctx,
     617             :       LabelTypes labelTypes,
     618             :       Map<String, Short> previous,
     619             :       List<PatchSetApproval> ups,
     620             :       List<PatchSetApproval> del)
     621             :       throws ResourceConflictException {
     622          65 :     if (ctx.getChange().isNew()) {
     623          65 :       return; // Not closed, nothing to validate.
     624           7 :     } else if (del.isEmpty() && ups.isEmpty()) {
     625           5 :       return; // No new votes.
     626           3 :     } else if (!ctx.getChange().isMerged()) {
     627           1 :       throw new ResourceConflictException("change is closed");
     628             :     }
     629             : 
     630             :     // Disallow reducing votes on any labels post-submit. This assumes the
     631             :     // high values were broadly necessary to submit, so reducing them would
     632             :     // make it possible to take a merged change and make it no longer
     633             :     // submittable.
     634           3 :     List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
     635           3 :     List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
     636             : 
     637           3 :     for (PatchSetApproval psa : del) {
     638           1 :       LabelType lt =
     639             :           labelTypes
     640           1 :               .byLabel(psa.label())
     641           1 :               .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
     642           1 :       String normName = lt.getName();
     643           1 :       if (!lt.isAllowPostSubmit()) {
     644           0 :         disallowed.add(normName);
     645             :       }
     646           1 :       Short prev = previous.get(normName);
     647           1 :       if (prev != null && prev != 0) {
     648           1 :         reduced.add(psa);
     649             :       }
     650           1 :     }
     651             : 
     652           3 :     for (PatchSetApproval psa : ups) {
     653           3 :       LabelType lt =
     654             :           labelTypes
     655           3 :               .byLabel(psa.label())
     656           3 :               .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
     657           3 :       String normName = lt.getName();
     658           3 :       if (!lt.isAllowPostSubmit()) {
     659           1 :         disallowed.add(normName);
     660             :       }
     661           3 :       Short prev = previous.get(normName);
     662           3 :       if (prev == null) {
     663           0 :         continue;
     664             :       }
     665           3 :       if (prev > psa.value()) {
     666           1 :         reduced.add(psa);
     667             :       }
     668             :       // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
     669           3 :     }
     670             : 
     671           3 :     if (!disallowed.isEmpty()) {
     672           1 :       throw new ResourceConflictException(
     673             :           "Voting on labels disallowed after submit: "
     674           1 :               + disallowed.stream().distinct().sorted().collect(joining(", ")));
     675             :     }
     676           3 :     if (!reduced.isEmpty()) {
     677           1 :       throw new ResourceConflictException(
     678             :           "Cannot reduce vote on labels for closed change: "
     679           1 :               + reduced.stream()
     680           1 :                   .map(PatchSetApproval::label)
     681           1 :                   .distinct()
     682           1 :                   .sorted()
     683           1 :                   .collect(joining(", ")));
     684             :     }
     685           3 :   }
     686             : 
     687             :   private Map<String, PatchSetApproval> scanLabels(
     688             :       ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
     689          65 :     LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
     690          65 :     Map<String, PatchSetApproval> current = new HashMap<>();
     691             : 
     692             :     for (PatchSetApproval a :
     693          65 :         approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
     694          28 :       if (a.isLegacySubmit()) {
     695          13 :         continue;
     696             :       }
     697             : 
     698          28 :       Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
     699          28 :       if (lt.isPresent()) {
     700          28 :         current.put(lt.get().getName(), a);
     701             :       } else {
     702           0 :         del.add(a);
     703             :       }
     704          28 :     }
     705          65 :     return current;
     706             :   }
     707             : 
     708             :   private boolean insertMessage(ChangeContext ctx) {
     709          65 :     String msg = Strings.nullToEmpty(in.message).trim();
     710             : 
     711          65 :     StringBuilder buf = new StringBuilder();
     712          65 :     for (LabelVote d : labelDelta) {
     713          58 :       buf.append(" ").append(d.format());
     714          58 :     }
     715          65 :     if (comments.size() == 1) {
     716          18 :       buf.append("\n\n(1 comment)");
     717          62 :     } else if (comments.size() > 1) {
     718           5 :       buf.append(String.format("\n\n(%d comments)", comments.size()));
     719             :     }
     720          65 :     if (!msg.isEmpty()) {
     721             :       // Message was already validated when validating comments, since validators need to see
     722             :       // everything in a single call.
     723          22 :       buf.append("\n\n").append(msg);
     724          61 :     } else if (in.ready) {
     725           3 :       buf.append("\n\n" + START_REVIEW_MESSAGE);
     726             :     }
     727             : 
     728          65 :     List<String> pluginMessages = new ArrayList<>();
     729          65 :     onPostReviews.runEach(
     730             :         onPostReview ->
     731           1 :             onPostReview
     732           1 :                 .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
     733           1 :                 .ifPresent(
     734             :                     pluginMessage ->
     735           1 :                         pluginMessages.add(
     736           1 :                             !pluginMessage.endsWith("\n") ? pluginMessage + "\n" : pluginMessage)));
     737          65 :     if (!pluginMessages.isEmpty()) {
     738           1 :       buf.append("\n\n");
     739           1 :       buf.append(Joiner.on("\n").join(pluginMessages));
     740             :     }
     741             : 
     742          65 :     if (buf.length() == 0) {
     743          24 :       return false;
     744             :     }
     745             : 
     746          65 :     mailMessage =
     747          65 :         cmUtil.setChangeMessage(ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
     748          65 :     return true;
     749             :   }
     750             : 
     751             :   private void addLabelDelta(String name, short value) {
     752          58 :     labelDelta.add(LabelVote.create(name, value));
     753          58 :   }
     754             : 
     755             :   private TraceContext.TraceTimer newTimer(String method) {
     756          65 :     return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
     757             :   }
     758             : }

Generated by: LCOV version 1.16+git.20220603.dfeb750