All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.gerrit.server.restapi.change.PostReviewOp Maven / Gradle / Ivy

There is a newer version: 3.11.0-rc3
Show newest version
// 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 do not have a matching comment (with same id) * neither in {@code existingComments} nor in {@code drafts}. * *

Entries in {@code drafts} that have a matching entry in {@code inputComments} will be * removed. * * @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; } 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 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

If copied votes have been both added/updated and removed, 2 messages are returned. * *

Each returned message is formatted as a list item (prefixed with '* '). * *

Passing atoms in copy conditions are not highlighted. This is because the passing atoms can * be different for different follow-up patch sets (e.g. 'changekind:TRIVIAL_REBASE OR * changekind:NO_CODE_CHANGE' can have 'changekind:TRIVIAL_REBASE' passing for one follow-up patch * set and 'changekind:NO_CODE_CHANGE' passing for another follow-up patch set). Including the * copy condition once per follow-up patch set with differently highlighted passing atoms would * make the message unreadable. Hence we don't highlight passing atoms here. * * @param labelVote the label vote that has been applied on an outdated patch set * @param followUpPatchSetUpdates updates to copied votes on follow-up patch sets that have been * done by copying the label vote on the outdated patch set to follow-up patch sets * @param copyCondition the copy condition of the label for which a vote was applied on an * outdated patch set * @return formatted string to be included into a change message */ private String formatVotesCopiedToFollowUpPatchSets( LabelVote labelVote, Collection followUpPatchSetUpdates, Optional copyCondition) { StringBuilder b = new StringBuilder(); // Add line for added/updated copied approvals. ImmutableList additionsAndUpdates = followUpPatchSetUpdates.stream() .filter( copiedLabelUpdate -> copiedLabelUpdate.type() == CopiedLabelUpdate.Type.ADDED || copiedLabelUpdate.type() == CopiedLabelUpdate.Type.UPDATED) .collect(toImmutableList()); if (!additionsAndUpdates.isEmpty()) { b.append("\n* "); b.append(labelVote.format()); b.append(" has been copied to patch set "); b.append( additionsAndUpdates.stream() .map(CopiedLabelUpdate::formatPatchSetWithOldLabelVote) .collect(joining(", "))); copyCondition.ifPresent(cc -> b.append(" (copy condition: \"" + cc + "\")")); b.append("."); } // Add line for removed copied approvals. ImmutableList removals = followUpPatchSetUpdates.stream() .filter(copiedLabelUpdate -> copiedLabelUpdate.type() == CopiedLabelUpdate.Type.REMOVED) .collect(toImmutableList()); if (!removals.isEmpty()) { b.append("\n* Copied "); b.append(labelVote.label()); b.append(" vote has been removed from patch set "); b.append( removals.stream() .map(CopiedLabelUpdate::formatPatchSetWithOldLabelVote) .collect(joining(", "))); b.append(" since the new "); b.append(labelVote.value() != 0 ? labelVote.format() : labelVote.formatWithEquals()); b.append(" vote is not copyable"); copyCondition.ifPresent(cc -> b.append(" (copy condition: \"" + cc + "\")")); b.append("."); } return b.toString(); } private void addLabelDelta(String name, short value) { labelDelta.add(LabelVote.create(name, value)); } /** * Gets the result of running this {@code PostReviewOp}. * *

Must only be invoked after this {@code PostReviewOp} has been executed with {@link * com.google.gerrit.server.update.BatchUpdate}. * * @throws IllegalStateException thrown if invoked before this {@code PostReviewOp} has been * executed */ public Result getResult() { checkState(result != null, "cannot retrieve result, change update has not been executed yet"); return result; } /** * Whether this {@code PostReviewOp} updated any vote on the current patch set. * *

Must only be invoked after this {@code PostReviewOp} has been executed with {@link * com.google.gerrit.server.update.BatchUpdate}. * * @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. */ private boolean updatedAnyVoteOnCurrentPatchSet() { return in.labels != null && !in.labels.isEmpty() && (notes.getCurrentPatchSet().id().equals(psId) || labelUpdatesOnFollowUpPatchSets.values().stream() .anyMatch( copiedLabelUpdate -> copiedLabelUpdate.patchSetId().equals(notes.getCurrentPatchSet().id()))); } /** * Whether this {@code PostReviewOp} applied any negative vote on the current patch set. * *

Must only be invoked after this {@code PostReviewOp} has been executed with {@link * com.google.gerrit.server.update.BatchUpdate}. * * @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. */ private boolean updatedAnyNegativeVoteOnCurrentPatchSet() { return in.labels != null && in.labels.values().stream().anyMatch(vote -> vote < 0) && (notes.getCurrentPatchSet().id().equals(psId) || labelUpdatesOnFollowUpPatchSets.entries().stream() .filter(e -> e.getKey().value() < 0) .anyMatch(e -> e.getValue().patchSetId().equals(notes.getCurrentPatchSet().id()))); } /** * Whether this {@code PostReviewOp} applied votes on an outdated patch set that were not copied * to the current patch set. * *

Must only be invoked after this {@code PostReviewOp} has been executed with {@link * com.google.gerrit.server.update.BatchUpdate}. * * @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 */ private boolean appliedVotesOnOutdatedPatchSetThatWereNotCopiedToCurrentPatchSet() { if (in.labels == null || notes.getCurrentPatchSet().id().equals(psId)) { return false; } for (Map.Entry labelEntry : in.labels.entrySet()) { if (labelUpdatesOnFollowUpPatchSets .get(LabelVote.create(labelEntry.getKey(), labelEntry.getValue())).stream() .anyMatch( copiedLabelUpdate -> copiedLabelUpdate.patchSetId().equals(notes.getCurrentPatchSet().id()))) { continue; } // vote was not copied to current patch set return true; } return false; } /** * Whether this {@code PostReviewOp} posted a change message. * * @return returns {@code true} if ReviewInput contained a message. */ private boolean postedChangeMessage() { return !Strings.isNullOrEmpty(in.message); } private TraceContext.TraceTimer newTimer(String method) { return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty()); } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy