|
// 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.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.toList; |
|
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.FixReplacement; |
|
import com.google.gerrit.entities.FixSuggestion; |
|
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.common.FixReplacementInfo; |
|
import com.google.gerrit.extensions.common.FixSuggestionInfo; |
|
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.ChangeUtil; |
|
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. |
|
* |
|
* <p>Whether the copied vote has been added |
|
* |
|
* <ul> |
|
* <li>added to |
|
* <li>updated on |
|
* <li>removed from |
|
* </ul> |
|
* |
|
* 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. |
|
* |
|
* <p>Not set if {@link #type()} is {@link Type#ADDED}. |
|
*/ |
|
abstract Optional<LabelVote> 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<LabelVote> oldLabelVote, Type type) { |
|
return new AutoValue_PostReviewOp_CopiedLabelUpdate(patchSetId, oldLabelVote, type); |
|
} |
|
} |
|
|
|
@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<CommentValidator> commentValidators; |
|
private final PluginSetContext<OnPostReview> 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<Comment> comments = new ArrayList<>(); |
|
private List<LabelVote> labelDelta = new ArrayList<>(); |
|
private SortedSetMultimap<LabelVote, CopiedLabelUpdate> labelUpdatesOnFollowUpPatchSets = |
|
MultimapBuilder.hashKeys().treeSetValues(comparing(CopiedLabelUpdate::patchSetId)).build(); |
|
private Map<String, Short> approvals = new HashMap<>(); |
|
private Map<String, Short> oldApprovals = new HashMap<>(); |
|
|
|
@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<CommentValidator> commentValidators, |
|
PluginSetContext<OnPostReview> 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<RobotComment> 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); |
|
} |
|
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<RobotComment> newRobotComments) |
|
throws CommentsRejectedException { |
|
Map<String, List<CommentInput>> inputComments = in.comments; |
|
if (inputComments == null) { |
|
inputComments = Collections.emptyMap(); |
|
} |
|
|
|
// Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts(). |
|
Map<String, HumanComment> 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<CommentSetEntry> existingComments = |
|
in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet(); |
|
|
|
// Input comments should be deduplicated from existing drafts |
|
List<HumanComment> inputCommentsToPublish = |
|
resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx); |
|
|
|
switch (in.drafts) { |
|
case PUBLISH: |
|
case PUBLISH_ALL_REVISIONS: |
|
Collection<HumanComment> 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}. |
|
* |
|
* <p>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<HumanComment> resolveInputCommentsAndDrafts( |
|
Map<String, List<CommentInput>> inputComments, |
|
Set<CommentSetEntry> existingComments, |
|
Map<String, HumanComment> drafts, |
|
ChangeContext ctx) { |
|
List<HumanComment> inputCommentsToPublish = new ArrayList<>(); |
|
for (Map.Entry<String, List<CommentInput>> 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); |
|
} 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<? extends Comment> comments) |
|
throws CommentsRejectedException { |
|
CommentValidationContext commentValidationCtx = |
|
CommentValidationContext.create( |
|
ctx.getChange().getChangeId(), |
|
ctx.getChange().getProject().get(), |
|
ctx.getChange().getDest().branch()); |
|
String changeMessage = Strings.nullToEmpty(in.message).trim(); |
|
ImmutableList<CommentForValidation> 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<CommentValidationFailure> draftValidationFailures = |
|
PublishCommentUtil.findInvalidComments( |
|
commentValidationCtx, commentValidators, draftsForValidation); |
|
if (!draftValidationFailures.isEmpty()) { |
|
throw new CommentsRejectedException(draftValidationFailures); |
|
} |
|
} |
|
|
|
private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) { |
|
if (in.robotComments == null) { |
|
return false; |
|
} |
|
commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments); |
|
comments.addAll(newRobotComments); |
|
return !newRobotComments.isEmpty(); |
|
} |
|
|
|
private List<RobotComment> getNewRobotComments(ChangeContext ctx) { |
|
List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size()); |
|
|
|
Set<CommentSetEntry> existingIds = |
|
in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet(); |
|
|
|
for (Map.Entry<String, List<RobotCommentInput>> 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 = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions); |
|
return robotComment; |
|
} |
|
|
|
private ImmutableList<FixSuggestion> createFixSuggestionsFromInput( |
|
List<FixSuggestionInfo> fixSuggestionInfos) { |
|
if (fixSuggestionInfos == null) { |
|
return ImmutableList.of(); |
|
} |
|
|
|
ImmutableList.Builder<FixSuggestion> fixSuggestions = |
|
ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size()); |
|
for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) { |
|
fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo)); |
|
} |
|
return fixSuggestions.build(); |
|
} |
|
|
|
private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) { |
|
List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements); |
|
String fixId = ChangeUtil.messageUuid(); |
|
return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements); |
|
} |
|
|
|
private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) { |
|
return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList()); |
|
} |
|
|
|
private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) { |
|
Comment.Range range = new Comment.Range(fixReplacementInfo.range); |
|
return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement); |
|
} |
|
|
|
private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) { |
|
return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream() |
|
.map(CommentSetEntry::create) |
|
.collect(toSet()); |
|
} |
|
|
|
private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) { |
|
return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream() |
|
.map(CommentSetEntry::create) |
|
.collect(toSet()); |
|
} |
|
|
|
private Map<String, HumanComment> changeDrafts(ChangeContext ctx) { |
|
return draftCommentsReader.getDraftsByChangeAndDraftAuthor(ctx.getNotes(), user.getAccountId()) |
|
.stream() |
|
.collect(Collectors.toMap(c -> c.key.uuid, c -> c)); |
|
} |
|
|
|
private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) { |
|
return draftCommentsReader |
|
.getDraftsByPatchSetAndDraftAuthor(ctx.getNotes(), psId, user.getAccountId()).stream() |
|
.collect(Collectors.toMap(c -> c.key.uuid, c -> c)); |
|
} |
|
|
|
private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) { |
|
Map<String, Short> labels = new HashMap<>(); |
|
for (PatchSetApproval psa : patchsetApprovals) { |
|
labels.put(psa.label(), psa.value()); |
|
} |
|
return labels; |
|
} |
|
|
|
private Map<String, Short> getAllApprovals( |
|
LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) { |
|
Map<String, Short> 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<String, Short> getPreviousApprovals( |
|
Map<String, Short> allApprovals, Map<String, Short> current) { |
|
Map<String, Short> previous = new HashMap<>(); |
|
for (Map.Entry<String, Short> 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<String, Short> 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<PatchSetApproval> del = new ArrayList<>(); |
|
List<PatchSetApproval> ups = new ArrayList<>(); |
|
Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del); |
|
LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes()); |
|
Map<String, Short> allApprovals = |
|
getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels); |
|
Map<String, Short> previous = |
|
getPreviousApprovals(allApprovals, approvalsByKey(current.values())); |
|
|
|
ChangeUpdate update = ctx.getUpdate(psId); |
|
for (Map.Entry<String, Short> 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.putReviewer(user.getAccountId(), REVIEWER); |
|
update.putApproval(normName, ent.getValue()); |
|
} |
|
} |
|
|
|
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<String, Short> previous, |
|
List<PatchSetApproval> ups, |
|
List<PatchSetApproval> 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<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size()); |
|
List<String> 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<String, PatchSetApproval> scanLabels( |
|
ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) { |
|
LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes()); |
|
Map<String, PatchSetApproval> current = new HashMap<>(); |
|
|
|
for (PatchSetApproval a : |
|
approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) { |
|
if (a.isLegacySubmit()) { |
|
continue; |
|
} |
|
|
|
Optional<LabelType> 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. |
|
* |
|
* <p>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<PatchSet.Id> followUpPatchSets = |
|
ctx.getNotes().getPatchSets().keySet().stream() |
|
.filter(patchSetId -> patchSetId.get() > psId.get()) |
|
.collect(toImmutableList()); |
|
|
|
boolean dirty = false; |
|
ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> newApprovals = |
|
ctx.getUpdate(psId).getApprovals(); |
|
for (Cell<String, Account.Id, Optional<PatchSetApproval>> 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<PatchSet.Id> 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<PatchSetApproval> 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<PatchSetApproval> 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<String, Account.Id, Optional<PatchSetApproval>> 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<PatchSet.Id> followUpPatchSets, PatchSetApproval psaOrig) { |
|
boolean dirty = false; |
|
for (PatchSet.Id followUpPatchSet : followUpPatchSets) { |
|
Optional<PatchSetApproval> 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<PatchSetApproval> 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<LabelVote, Collection<CopiedLabelUpdate>> e : |
|
labelUpdatesOnFollowUpPatchSets.asMap().entrySet().stream() |
|
.sorted(Map.Entry.comparingByKey(comparing(LabelVote::label))) |
|
.collect(toImmutableList())) { |
|
Optional<String> 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<String> 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. |
|
* |
|
* <p>If label votes have been copied to follow-up patch sets the formatted message is |
|
* "<label-vote> has been copied to patch sets: 3, 4 (copy condition: "<copy-condition>").". |
|
* |
|
* <p>If existing copied votes on follow-up patch sets have been updated, the old copied votes are |
|
* included into the message: "<label-vote> has been copied to patch sets: 3 (was |
|
* <old-label-vote>), 4 (was <old-label-vote>) (copy condition: "<copy-condition>").". |
|
* |
|
* <p>If existing copied votes on follow-up patch sets have been removed (because the new vote is |
|
* not copyable) the message is: "Copied <label> vote has been removed from patch set 3 (was |
|
* <old-label-vote>), 4 (was <old-label-vote>) (copy condition: "<copy-condition>").". |
|
* |
|
* <p>If copied votes have been both added/updated and removed, 2 messages are returned. |
|
* |
|
* <p>Each returned message is formatted as a list item (prefixed with '* '). |
|
* |
|
* <p>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<CopiedLabelUpdate> followUpPatchSetUpdates, |
|
Optional<String> copyCondition) { |
|
StringBuilder b = new StringBuilder(); |
|
|
|
// Add line for added/updated copied approvals. |
|
ImmutableList<CopiedLabelUpdate> 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<CopiedLabelUpdate> 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)); |
|
} |
|
|
|
private TraceContext.TraceTimer newTimer(String method) { |
|
return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty()); |
|
} |
|
} |