blob: 974a72c16cd902ce04f89c4ecd7f256200a2ec25 [file] [log] [blame]
// Copyright (C) 2012 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.server.CommentsUtil.setCommentCommitId;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import com.google.auto.value.AutoValue;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Ordering;
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
import com.google.common.hash.HashCode;
import com.google.common.hash.Hashing;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.common.data.LabelTypes;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.FixReplacement;
import com.google.gerrit.entities.FixSuggestion;
import com.google.gerrit.entities.LabelId;
import com.google.gerrit.entities.Patch;
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.AddReviewerInput;
import com.google.gerrit.extensions.api.changes.AddReviewerResult;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
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.api.changes.ReviewResult;
import com.google.gerrit.extensions.api.changes.ReviewerInfo;
import com.google.gerrit.extensions.client.Comment.Range;
import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.FixReplacementInfo;
import com.google.gerrit.extensions.common.FixSuggestionInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
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.CommentValidationFailure;
import com.google.gerrit.extensions.validators.CommentValidator;
import com.google.gerrit.json.OutputFormat;
import com.google.gerrit.mail.Address;
import com.google.gerrit.server.ApprovalsUtil;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.PublishCommentUtil;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.change.AddReviewersEmail;
import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.change.EmailReviewComments;
import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.ReviewerAdder;
import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
import com.google.gerrit.server.change.RevisionResource;
import com.google.gerrit.server.change.WorkInProgressOp;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.extensions.events.CommentAdded;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.patch.DiffSummary;
import com.google.gerrit.server.patch.DiffSummaryKey;
import com.google.gerrit.server.patch.PatchListCache;
import com.google.gerrit.server.patch.PatchListKey;
import com.google.gerrit.server.patch.PatchListNotAvailableException;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.LabelPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.plugincontext.PluginSetContext;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.update.BatchUpdate;
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.Context;
import com.google.gerrit.server.update.RetryHelper;
import com.google.gerrit.server.update.RetryingRestModifyView;
import com.google.gerrit.server.update.UpdateException;
import com.google.gerrit.server.util.LabelVote;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gson.Gson;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
@Singleton
public class PostReview
extends RetryingRestModifyView<RevisionResource, ReviewInput, ReviewResult> {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
"work_in_progress and ready are mutually exclusive";
public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
private static final int DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES = 1024 * 1024;
private final ChangeResource.Factory changeResourceFactory;
private final ChangeData.Factory changeDataFactory;
private final ApprovalsUtil approvalsUtil;
private final ChangeMessagesUtil cmUtil;
private final CommentsUtil commentsUtil;
private final PublishCommentUtil publishCommentUtil;
private final PatchSetUtil psUtil;
private final PatchListCache patchListCache;
private final AccountResolver accountResolver;
private final EmailReviewComments.Factory email;
private final CommentAdded commentAdded;
private final ReviewerAdder reviewerAdder;
private final AddReviewersEmail addReviewersEmail;
private final NotifyResolver notifyResolver;
private final Config gerritConfig;
private final WorkInProgressOp.Factory workInProgressOpFactory;
private final ProjectCache projectCache;
private final PermissionBackend permissionBackend;
private final PluginSetContext<CommentValidator> commentValidators;
private final boolean strictLabels;
@Inject
PostReview(
RetryHelper retryHelper,
ChangeResource.Factory changeResourceFactory,
ChangeData.Factory changeDataFactory,
ApprovalsUtil approvalsUtil,
ChangeMessagesUtil cmUtil,
CommentsUtil commentsUtil,
PublishCommentUtil publishCommentUtil,
PatchSetUtil psUtil,
PatchListCache patchListCache,
AccountResolver accountResolver,
EmailReviewComments.Factory email,
CommentAdded commentAdded,
ReviewerAdder reviewerAdder,
AddReviewersEmail addReviewersEmail,
NotifyResolver notifyResolver,
@GerritServerConfig Config gerritConfig,
WorkInProgressOp.Factory workInProgressOpFactory,
ProjectCache projectCache,
PermissionBackend permissionBackend,
PluginSetContext<CommentValidator> commentValidators) {
super(retryHelper);
this.changeResourceFactory = changeResourceFactory;
this.changeDataFactory = changeDataFactory;
this.commentsUtil = commentsUtil;
this.publishCommentUtil = publishCommentUtil;
this.psUtil = psUtil;
this.patchListCache = patchListCache;
this.approvalsUtil = approvalsUtil;
this.cmUtil = cmUtil;
this.accountResolver = accountResolver;
this.email = email;
this.commentAdded = commentAdded;
this.reviewerAdder = reviewerAdder;
this.addReviewersEmail = addReviewersEmail;
this.notifyResolver = notifyResolver;
this.gerritConfig = gerritConfig;
this.workInProgressOpFactory = workInProgressOpFactory;
this.projectCache = projectCache;
this.permissionBackend = permissionBackend;
this.commentValidators = commentValidators;
this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
}
@Override
protected Response<ReviewResult> applyImpl(
BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input)
throws RestApiException, UpdateException, IOException, PermissionBackendException,
ConfigInvalidException, PatchListNotAvailableException {
return apply(updateFactory, revision, input, TimeUtil.nowTs());
}
public Response<ReviewResult> apply(
BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input, Timestamp ts)
throws RestApiException, UpdateException, IOException, PermissionBackendException,
ConfigInvalidException, PatchListNotAvailableException {
// Respect timestamp, but truncate at change created-on time.
ts = Ordering.natural().max(ts, revision.getChange().getCreatedOn());
if (revision.getEdit().isPresent()) {
throw new ResourceConflictException("cannot post review on edit");
}
ProjectState projectState = projectCache.checkedGet(revision.getProject());
LabelTypes labelTypes = projectState.getLabelTypes(revision.getNotes());
input.drafts = firstNonNull(input.drafts, DraftHandling.KEEP);
logger.atFine().log("draft handling = %s", input.drafts);
if (input.onBehalfOf != null) {
revision = onBehalfOf(revision, labelTypes, input);
}
if (input.labels != null) {
checkLabels(revision, labelTypes, input.labels);
}
if (input.comments != null) {
input.comments = cleanUpComments(input.comments);
checkComments(revision, input.comments);
}
if (input.robotComments != null) {
input.robotComments = cleanUpComments(input.robotComments);
checkRobotComments(revision, input.robotComments);
}
if (input.notify == null) {
input.notify = defaultNotify(revision.getChange(), input);
}
Map<String, AddReviewerResult> reviewerJsonResults = null;
List<ReviewerAddition> reviewerResults = Lists.newArrayList();
boolean hasError = false;
boolean confirm = false;
if (input.reviewers != null) {
reviewerJsonResults = Maps.newHashMap();
for (AddReviewerInput reviewerInput : input.reviewers) {
ReviewerAddition result =
reviewerAdder.prepare(revision.getNotes(), revision.getUser(), reviewerInput, true);
reviewerJsonResults.put(reviewerInput.reviewer, result.result);
if (result.result.error != null) {
hasError = true;
continue;
}
if (result.result.confirm != null) {
confirm = true;
continue;
}
reviewerResults.add(result);
}
}
ReviewResult output = new ReviewResult();
output.reviewers = reviewerJsonResults;
if (hasError || confirm) {
output.error = ERROR_ADDING_REVIEWER;
return Response.withStatusCode(SC_BAD_REQUEST, output);
}
output.labels = input.labels;
try (BatchUpdate bu =
updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
Account.Id id = revision.getUser().getAccountId();
boolean ccOrReviewer = false;
if (input.labels != null && !input.labels.isEmpty()) {
ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
}
if (!ccOrReviewer) {
// Check if user was already CCed or reviewing prior to this review.
ReviewerSet currentReviewers =
approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
ccOrReviewer = currentReviewers.all().contains(id);
}
// Apply reviewer changes first. Revision emails should be sent to the
// updated set of reviewers. Also keep track of whether the user added
// themselves as a reviewer or to the CC list.
for (ReviewerAddition reviewerResult : reviewerResults) {
reviewerResult.op.suppressEmail(); // Send a single batch email below.
bu.addOp(revision.getChange().getId(), reviewerResult.op);
if (!ccOrReviewer && reviewerResult.result.reviewers != null) {
for (ReviewerInfo reviewerInfo : reviewerResult.result.reviewers) {
if (Objects.equals(id.get(), reviewerInfo._accountId)) {
ccOrReviewer = true;
break;
}
}
}
if (!ccOrReviewer && reviewerResult.result.ccs != null) {
for (AccountInfo accountInfo : reviewerResult.result.ccs) {
if (Objects.equals(id.get(), accountInfo._accountId)) {
ccOrReviewer = true;
break;
}
}
}
}
if (!ccOrReviewer) {
// User posting this review isn't currently in the reviewer or CC list,
// isn't being explicitly added, and isn't voting on any label.
// Automatically CC them on this change so they receive replies.
ReviewerAddition selfAddition = reviewerAdder.ccCurrentUser(revision.getUser(), revision);
selfAddition.op.suppressEmail();
bu.addOp(revision.getChange().getId(), selfAddition.op);
}
// Add WorkInProgressOp if requested.
if (input.ready || input.workInProgress) {
if (input.ready && input.workInProgress) {
output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
return Response.withStatusCode(SC_BAD_REQUEST, output);
}
revision
.getChangeResource()
.permissions()
.check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
if (input.ready) {
output.ready = true;
}
WorkInProgressOp wipOp =
workInProgressOpFactory.create(input.workInProgress, new WorkInProgressOp.Input());
wipOp.suppressEmail();
bu.addOp(revision.getChange().getId(), wipOp);
}
// Add the review op.
bu.addOp(
revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
// Notify based on ReviewInput, ignoring the notify settings from any AddReviewerInputs.
NotifyResolver.Result notify =
notifyResolver.resolve(getNotifyHandling(input, output, revision), input.notifyDetails);
bu.setNotify(notify);
bu.execute();
// Re-read change to take into account results of the update.
ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
for (ReviewerAddition reviewerResult : reviewerResults) {
reviewerResult.gatherResults(cd);
}
// Sending from AddReviewersOp was suppressed so we can send a single batch email here.
batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
}
return Response.ok(output);
}
private NotifyHandling getNotifyHandling(
ReviewInput input, ReviewResult output, RevisionResource revision) {
if (input.notify != null) {
return input.notify;
}
if ((output.ready != null && output.ready) || !revision.getChange().isWorkInProgress()) {
return NotifyHandling.ALL;
}
return NotifyHandling.NONE;
}
private NotifyHandling defaultNotify(Change c, ReviewInput in) {
boolean workInProgress = c.isWorkInProgress();
if (in.workInProgress) {
workInProgress = true;
}
if (in.ready) {
workInProgress = false;
}
if (ChangeMessagesUtil.isAutogenerated(in.tag)) {
// Autogenerated comments default to lower notify levels.
return workInProgress ? NotifyHandling.OWNER : NotifyHandling.OWNER_REVIEWERS;
}
if (workInProgress && !c.hasReviewStarted()) {
// If review hasn't started we want to eliminate notifications, no matter who the author is.
return NotifyHandling.NONE;
}
// Otherwise, it's either a non-WIP change, or a WIP change where review has started. Notify
// everyone.
return NotifyHandling.ALL;
}
private void batchEmailReviewers(
CurrentUser user,
Change change,
List<ReviewerAddition> reviewerAdditions,
NotifyResolver.Result notify) {
List<Account.Id> to = new ArrayList<>();
List<Account.Id> cc = new ArrayList<>();
List<Address> toByEmail = new ArrayList<>();
List<Address> ccByEmail = new ArrayList<>();
for (ReviewerAddition addition : reviewerAdditions) {
if (addition.state() == ReviewerState.REVIEWER) {
to.addAll(addition.reviewers);
toByEmail.addAll(addition.reviewersByEmail);
} else if (addition.state() == ReviewerState.CC) {
cc.addAll(addition.reviewers);
ccByEmail.addAll(addition.reviewersByEmail);
}
}
addReviewersEmail.emailReviewers(
user.asIdentifiedUser(), change, to, cc, toByEmail, ccByEmail, notify);
}
private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
throws BadRequestException, AuthException, UnprocessableEntityException,
PermissionBackendException, IOException, ConfigInvalidException {
if (in.labels == null || in.labels.isEmpty()) {
throw new AuthException(
String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
}
if (in.drafts != DraftHandling.KEEP) {
throw new AuthException("not allowed to modify other user's drafts");
}
CurrentUser caller = rev.getUser();
PermissionBackend.ForChange perm = rev.permissions();
Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
while (itr.hasNext()) {
Map.Entry<String, Short> ent = itr.next();
LabelType type = labelTypes.byLabel(ent.getKey());
if (type == null) {
if (strictLabels) {
throw new BadRequestException(
String.format("label \"%s\" is not a configured label", ent.getKey()));
}
itr.remove();
continue;
}
if (!caller.isInternalUser()) {
try {
perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type, ent.getValue()));
} catch (AuthException e) {
throw new AuthException(
String.format(
"not permitted to modify label \"%s\" on behalf of \"%s\"",
type.getName(), in.onBehalfOf));
}
}
}
if (in.labels.isEmpty()) {
throw new AuthException(
String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
}
IdentifiedUser reviewer = accountResolver.resolve(in.onBehalfOf).asUniqueUserOnBehalfOf(caller);
try {
permissionBackend.user(reviewer).change(rev.getNotes()).check(ChangePermission.READ);
} catch (AuthException e) {
throw new UnprocessableEntityException(
String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()));
}
return new RevisionResource(
changeResourceFactory.create(rev.getNotes(), reviewer), rev.getPatchSet());
}
private void checkLabels(RevisionResource rsrc, LabelTypes labelTypes, Map<String, Short> labels)
throws BadRequestException, AuthException, PermissionBackendException {
PermissionBackend.ForChange perm = rsrc.permissions();
Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
while (itr.hasNext()) {
Map.Entry<String, Short> ent = itr.next();
LabelType lt = labelTypes.byLabel(ent.getKey());
if (lt == null) {
if (strictLabels) {
throw new BadRequestException(
String.format("label \"%s\" is not a configured label", ent.getKey()));
}
itr.remove();
continue;
}
if (ent.getValue() == null || ent.getValue() == 0) {
// Always permit 0, even if it is not within range.
// Later null/0 will be deleted and revoke the label.
continue;
}
if (lt.getValue(ent.getValue()) == null) {
if (strictLabels) {
throw new BadRequestException(
String.format("label \"%s\": %d is not a valid value", ent.getKey(), ent.getValue()));
}
itr.remove();
continue;
}
short val = ent.getValue();
try {
perm.check(new LabelPermission.WithValue(lt, val));
} catch (AuthException e) {
throw new AuthException(
String.format("Applying label \"%s\": %d is restricted", lt.getName(), val));
}
}
}
private static <T extends CommentInput> Map<String, List<T>> cleanUpComments(
Map<String, List<T>> commentsPerPath) {
Map<String, List<T>> cleanedUpCommentMap = new HashMap<>();
for (Map.Entry<String, List<T>> e : commentsPerPath.entrySet()) {
String path = e.getKey();
List<T> comments = e.getValue();
if (comments == null) {
continue;
}
List<T> cleanedUpComments = cleanUpComments(comments);
if (!cleanedUpComments.isEmpty()) {
cleanedUpCommentMap.put(path, cleanedUpComments);
}
}
return cleanedUpCommentMap;
}
private static <T extends CommentInput> List<T> cleanUpComments(List<T> comments) {
return comments.stream()
.filter(Objects::nonNull)
.filter(comment -> !Strings.nullToEmpty(comment.message).trim().isEmpty())
.collect(toList());
}
private <T extends CommentInput> void checkComments(
RevisionResource revision, Map<String, List<T>> commentsPerPath)
throws BadRequestException, PatchListNotAvailableException {
Set<String> revisionFilePaths = getAffectedFilePaths(revision);
for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
String path = entry.getKey();
PatchSet.Id patchSetId = revision.getPatchSet().id();
ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths, patchSetId);
List<T> comments = entry.getValue();
for (T comment : comments) {
ensureLineIsNonNegative(comment.line, path);
ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
ensureRangeIsValid(path, comment.range);
}
}
}
private Set<String> getAffectedFilePaths(RevisionResource revision)
throws PatchListNotAvailableException {
ObjectId newId = revision.getPatchSet().commitId();
DiffSummaryKey key =
DiffSummaryKey.fromPatchListKey(
PatchListKey.againstDefaultBase(newId, Whitespace.IGNORE_NONE));
DiffSummary ds = patchListCache.getDiffSummary(key, revision.getProject());
return new HashSet<>(ds.getPaths());
}
private static void ensurePathRefersToAvailableOrMagicFile(
String path, Set<String> availableFilePaths, PatchSet.Id patchSetId)
throws BadRequestException {
if (!availableFilePaths.contains(path) && !Patch.isMagic(path)) {
throw new BadRequestException(
String.format("file %s not found in revision %s", path, patchSetId));
}
}
private static void ensureLineIsNonNegative(Integer line, String path)
throws BadRequestException {
if (line != null && line < 0) {
throw new BadRequestException(
String.format("negative line number %d not allowed on %s", line, path));
}
}
private static <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
String path, T comment) throws BadRequestException {
if (Patch.isMagic(path) && comment.side == Side.PARENT && comment.parent == null) {
throw new BadRequestException(String.format("cannot comment on %s on auto-merge", path));
}
}
private void checkRobotComments(
RevisionResource revision, Map<String, List<RobotCommentInput>> in)
throws BadRequestException, PatchListNotAvailableException {
for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
String commentPath = e.getKey();
for (RobotCommentInput c : e.getValue()) {
ensureSizeOfJsonInputIsWithinBounds(c);
ensureRobotIdIsSet(c.robotId, commentPath);
ensureRobotRunIdIsSet(c.robotRunId, commentPath);
ensureFixSuggestionsAreAddable(c.fixSuggestions, commentPath);
}
}
checkComments(revision, in);
}
private void ensureSizeOfJsonInputIsWithinBounds(RobotCommentInput robotCommentInput)
throws BadRequestException {
OptionalInt robotCommentSizeLimit = getRobotCommentSizeLimit();
if (robotCommentSizeLimit.isPresent()) {
int sizeLimit = robotCommentSizeLimit.getAsInt();
byte[] robotCommentBytes = GSON.toJson(robotCommentInput).getBytes(StandardCharsets.UTF_8);
int robotCommentSize = robotCommentBytes.length;
if (robotCommentSize > sizeLimit) {
throw new BadRequestException(
String.format(
"Size %d (bytes) of robot comment is greater than limit %d (bytes)",
robotCommentSize, sizeLimit));
}
}
}
private OptionalInt getRobotCommentSizeLimit() {
int robotCommentSizeLimit =
gerritConfig.getInt(
"change", "robotCommentSizeLimit", DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES);
if (robotCommentSizeLimit <= 0) {
return OptionalInt.empty();
}
return OptionalInt.of(robotCommentSizeLimit);
}
private static void ensureRobotIdIsSet(String robotId, String commentPath)
throws BadRequestException {
if (robotId == null) {
throw new BadRequestException(
String.format("robotId is missing for robot comment on %s", commentPath));
}
}
private static void ensureRobotRunIdIsSet(String robotRunId, String commentPath)
throws BadRequestException {
if (robotRunId == null) {
throw new BadRequestException(
String.format("robotRunId is missing for robot comment on %s", commentPath));
}
}
private static void ensureFixSuggestionsAreAddable(
List<FixSuggestionInfo> fixSuggestionInfos, String commentPath) throws BadRequestException {
if (fixSuggestionInfos == null) {
return;
}
for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
ensureDescriptionIsSet(commentPath, fixSuggestionInfo.description);
ensureFixReplacementsAreAddable(commentPath, fixSuggestionInfo.replacements);
}
}
private static void ensureDescriptionIsSet(String commentPath, String description)
throws BadRequestException {
if (description == null) {
throw new BadRequestException(
String.format(
"A description is required for the suggested fix of the robot comment on %s",
commentPath));
}
}
private static void ensureFixReplacementsAreAddable(
String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
ensureReplacementsArePresent(commentPath, fixReplacementInfos);
for (FixReplacementInfo fixReplacementInfo : fixReplacementInfos) {
ensureReplacementPathIsSet(commentPath, fixReplacementInfo.path);
ensureRangeIsSet(commentPath, fixReplacementInfo.range);
ensureRangeIsValid(commentPath, fixReplacementInfo.range);
ensureReplacementStringIsSet(commentPath, fixReplacementInfo.replacement);
}
Map<String, List<FixReplacementInfo>> replacementsPerFilePath =
fixReplacementInfos.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
for (List<FixReplacementInfo> sameFileReplacements : replacementsPerFilePath.values()) {
ensureRangesDoNotOverlap(commentPath, sameFileReplacements);
}
}
private static void ensureReplacementsArePresent(
String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
if (fixReplacementInfos == null || fixReplacementInfos.isEmpty()) {
throw new BadRequestException(
String.format(
"At least one replacement is "
+ "required for the suggested fix of the robot comment on %s",
commentPath));
}
}
private static void ensureReplacementPathIsSet(String commentPath, String replacementPath)
throws BadRequestException {
if (replacementPath == null) {
throw new BadRequestException(
String.format(
"A file path must be given for the replacement of the robot comment on %s",
commentPath));
}
}
private static void ensureRangeIsSet(String commentPath, Range range) throws BadRequestException {
if (range == null) {
throw new BadRequestException(
String.format(
"A range must be given for the replacement of the robot comment on %s", commentPath));
}
}
private static void ensureRangeIsValid(String commentPath, Range range)
throws BadRequestException {
if (range == null) {
return;
}
if (!range.isValid()) {
throw new BadRequestException(
String.format(
"Range (%s:%s - %s:%s) is not valid for the comment on %s",
range.startLine,
range.startCharacter,
range.endLine,
range.endCharacter,
commentPath));
}
}
private static void ensureReplacementStringIsSet(String commentPath, String replacement)
throws BadRequestException {
if (replacement == null) {
throw new BadRequestException(
String.format(
"A content for replacement "
+ "must be indicated for the replacement of the robot comment on %s",
commentPath));
}
}
private static void ensureRangesDoNotOverlap(
String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
List<Range> sortedRanges =
fixReplacementInfos.stream()
.map(fixReplacementInfo -> fixReplacementInfo.range)
.sorted()
.collect(toList());
int previousEndLine = 0;
int previousOffset = -1;
for (Range range : sortedRanges) {
if (range.startLine < previousEndLine
|| (range.startLine == previousEndLine && range.startCharacter < previousOffset)) {
throw new BadRequestException(
String.format("Replacements overlap for the robot comment on %s", commentPath));
}
previousEndLine = range.endLine;
previousOffset = range.endCharacter;
}
}
/**
* Used to compare existing {@link Comment}-s with {@link CommentInput} comments by copying only
* the fields to compare.
*/
@AutoValue
abstract static class CommentSetEntry {
private static CommentSetEntry create(
String filename,
int patchSetId,
Integer line,
Side side,
HashCode message,
Comment.Range range) {
return new AutoValue_PostReview_CommentSetEntry(
filename, patchSetId, line, side, message, range);
}
public static CommentSetEntry create(Comment comment) {
return create(
comment.key.filename,
comment.key.patchSetId,
comment.lineNbr,
Side.fromShort(comment.side),
Hashing.murmur3_128().hashString(comment.message, UTF_8),
comment.range);
}
abstract String filename();
abstract int patchSetId();
@Nullable
abstract Integer line();
abstract Side side();
abstract HashCode message();
@Nullable
abstract Comment.Range range();
}
private class Op implements BatchUpdateOp {
private final ProjectState projectState;
private final PatchSet.Id psId;
private final ReviewInput in;
private IdentifiedUser user;
private ChangeNotes notes;
private PatchSet ps;
private ChangeMessage message;
private List<Comment> comments = new ArrayList<>();
private List<LabelVote> labelDelta = new ArrayList<>();
private Map<String, Short> approvals = new HashMap<>();
private Map<String, Short> oldApprovals = new HashMap<>();
private Op(ProjectState projectState, PatchSet.Id psId, ReviewInput in) {
this.projectState = projectState;
this.psId = psId;
this.in = in;
}
@Override
public boolean updateChange(ChangeContext ctx)
throws ResourceConflictException, UnprocessableEntityException, IOException,
PatchListNotAvailableException, CommentsRejectedException {
user = ctx.getIdentifiedUser();
notes = ctx.getNotes();
ps = psUtil.get(ctx.getNotes(), psId);
boolean dirty = insertComments(ctx);
dirty |= insertRobotComments(ctx);
dirty |= updateLabels(projectState, ctx);
dirty |= insertMessage(ctx);
return dirty;
}
@Override
public void postUpdate(Context ctx) {
if (message == null) {
return;
}
NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
if (notify.shouldNotify()) {
email
.create(notify, notes, ps, user, message, comments, in.message, labelDelta)
.sendAsync();
}
commentAdded.fire(
notes.getChange(),
ps,
user.state(),
message.getMessage(),
approvals,
oldApprovals,
ctx.getWhen());
}
private boolean insertComments(ChangeContext ctx)
throws UnprocessableEntityException, PatchListNotAvailableException,
CommentsRejectedException {
Map<String, List<CommentInput>> inputComments = in.comments;
if (inputComments == null) {
inputComments = Collections.emptyMap();
}
// HashMap instead of Collections.emptyMap() avoids warning about remove() on immutable
// object.
Map<String, Comment> drafts = new HashMap<>();
// If there are inputComments we need the deduplication loop below, so we have to read (and
// publish) drafts here.
if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
if (in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS) {
drafts = changeDrafts(ctx);
} else {
drafts = patchSetDrafts(ctx);
}
}
// This will be populated with Comment-s created from inputComments.
List<Comment> toPublish = new ArrayList<>();
Set<CommentSetEntry> existingComments =
in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
// Deduplication:
// - Ignore drafts with the same ID as an inputComment here. These are deleted later.
// - Swallow comments that already exist.
for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
String path = entry.getKey();
for (CommentInput inputComment : entry.getValue()) {
Comment comment = drafts.remove(Url.decode(inputComment.id));
if (comment == null) {
String parent = Url.decode(inputComment.inReplyTo);
comment =
commentsUtil.newComment(
ctx,
path,
psId,
inputComment.side(),
inputComment.message,
inputComment.unresolved,
parent);
} else {
// In ChangeUpdate#putComment() the draft with the same ID will be deleted.
comment.writtenOn = ctx.getWhen();
comment.side = inputComment.side();
comment.message = inputComment.message;
}
setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
comment.setLineNbrAndRange(inputComment.line, inputComment.range);
comment.tag = in.tag;
if (existingComments.contains(CommentSetEntry.create(comment))) {
continue;
}
toPublish.add(comment);
}
}
switch (in.drafts) {
case PUBLISH:
case PUBLISH_ALL_REVISIONS:
validateComments(Streams.concat(drafts.values().stream(), toPublish.stream()));
publishCommentUtil.publish(ctx, psId, drafts.values(), in.tag);
comments.addAll(drafts.values());
break;
case KEEP:
default:
validateComments(toPublish.stream());
break;
}
ChangeUpdate changeUpdate = ctx.getUpdate(psId);
commentsUtil.putComments(changeUpdate, Comment.Status.PUBLISHED, toPublish);
comments.addAll(toPublish);
return !toPublish.isEmpty();
}
private void validateComments(Stream<Comment> comments) throws CommentsRejectedException {
ImmutableList<CommentForValidation> draftsForValidation =
comments
.map(
comment ->
CommentForValidation.create(
comment.lineNbr > 0
? CommentForValidation.CommentType.INLINE_COMMENT
: CommentForValidation.CommentType.FILE_COMMENT,
comment.message))
.collect(toImmutableList());
ImmutableList<CommentValidationFailure> draftValidationFailures =
PublishCommentUtil.findInvalidComments(commentValidators, draftsForValidation);
if (!draftValidationFailures.isEmpty()) {
throw new CommentsRejectedException(draftValidationFailures);
}
}
private boolean insertRobotComments(ChangeContext ctx) throws PatchListNotAvailableException {
if (in.robotComments == null) {
return false;
}
List<RobotComment> newRobotComments = getNewRobotComments(ctx);
commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
comments.addAll(newRobotComments);
return !newRobotComments.isEmpty();
}
private List<RobotComment> getNewRobotComments(ChangeContext ctx)
throws PatchListNotAvailableException {
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)
throws PatchListNotAvailableException {
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;
setCommentCommitId(robotComment, patchListCache, ctx.getChange(), ps);
robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
return robotComment;
}
private List<FixSuggestion> createFixSuggestionsFromInput(
List<FixSuggestionInfo> fixSuggestionInfos) {
if (fixSuggestionInfos == null) {
return Collections.emptyList();
}
List<FixSuggestion> fixSuggestions = new ArrayList<>(fixSuggestionInfos.size());
for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
}
return fixSuggestions;
}
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.publishedByChange(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, Comment> changeDrafts(ChangeContext ctx) {
return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
.collect(
Collectors.toMap(
c -> c.key.uuid,
c -> {
c.tag = in.tag;
return c;
}));
}
private Map<String, Comment> patchSetDrafts(ChangeContext ctx) {
return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).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) {
ChangeData cd = changeDataFactory.create(ctx.getNotes());
ReviewerSet reviewers = cd.reviewers();
return reviewers.byState(REVIEWER).contains(ctx.getAccountId());
}
private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
throws ResourceConflictException, IOException {
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 = requireNonNull(labelTypes.byLabel(name), 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);
}
} else if (c != null && c.value() != ent.getValue()) {
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;
}
forceCallerAsReviewer(projectState, ctx, current, ups, del);
return !del.isEmpty() || !ups.isEmpty();
}
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 = requireNonNull(labelTypes.byLabel(psa.label()));
String normName = lt.getName();
if (!lt.allowPostSubmit()) {
disallowed.add(normName);
}
Short prev = previous.get(normName);
if (prev != null && prev != 0) {
reduced.add(psa);
}
}
for (PatchSetApproval psa : ups) {
LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
String normName = lt.getName();
if (!lt.allowPostSubmit()) {
disallowed.add(normName);
}
Short prev = previous.get(normName);
if (prev == null) {
continue;
}
checkState(prev != psa.value()); // Should be filtered out above.
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 void forceCallerAsReviewer(
ProjectState projectState,
ChangeContext ctx,
Map<String, PatchSetApproval> current,
List<PatchSetApproval> ups,
List<PatchSetApproval> del) {
if (current.isEmpty() && ups.isEmpty()) {
// TODO Find another way to link reviewers to changes.
if (del.isEmpty()) {
// If no existing label is being set to 0, hack in the caller
// as a reviewer by picking the first server-wide LabelType.
List<LabelType> labelTypes = projectState.getLabelTypes(ctx.getNotes()).getLabelTypes();
if (labelTypes.isEmpty()) {
logger.atWarning().log(
"no label type found for project %s, change %s",
projectState.getName(), ctx.getChange().getChangeId());
return;
}
LabelId labelId = labelTypes.get(0).getLabelId();
ups.add(
ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen())
.tag(Optional.ofNullable(in.tag))
.granted(ctx.getWhen())
.build());
} else {
// Pick a random label that is about to be deleted and keep it.
Iterator<PatchSetApproval> i = del.iterator();
ups.add(i.next().toBuilder().value(0).granted(ctx.getWhen()).build());
i.remove();
}
}
ctx.getUpdate(ctx.getChange().currentPatchSetId()).putReviewer(user.getAccountId(), REVIEWER);
}
private Map<String, PatchSetApproval> scanLabels(
ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del)
throws IOException {
LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
Map<String, PatchSetApproval> current = new HashMap<>();
for (PatchSetApproval a :
approvalsUtil.byPatchSetUser(
ctx.getNotes(),
psId,
user.getAccountId(),
ctx.getRevWalk(),
ctx.getRepoView().getConfig())) {
if (a.isLegacySubmit()) {
continue;
}
LabelType lt = labelTypes.byLabel(a.labelId());
if (lt != null) {
current.put(lt.getName(), a);
} else {
del.add(a);
}
}
return current;
}
private boolean insertMessage(ChangeContext ctx) throws CommentsRejectedException {
String msg = Strings.nullToEmpty(in.message).trim();
StringBuilder buf = new StringBuilder();
for (LabelVote d : labelDelta) {
buf.append(" ").append(d.format());
}
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()) {
ImmutableList<CommentValidationFailure> messageValidationFailure =
PublishCommentUtil.findInvalidComments(
commentValidators,
ImmutableList.of(
CommentForValidation.create(
CommentForValidation.CommentType.CHANGE_MESSAGE, msg)));
if (!messageValidationFailure.isEmpty()) {
throw new CommentsRejectedException(messageValidationFailure);
}
buf.append("\n\n").append(msg);
} else if (in.ready) {
buf.append("\n\n" + START_REVIEW_MESSAGE);
}
if (buf.length() == 0) {
return false;
}
message =
ChangeMessagesUtil.newMessage(
psId, user, ctx.getWhen(), "Patch Set " + psId.get() + ":" + buf, in.tag);
cmUtil.addChangeMessage(ctx.getUpdate(psId), message);
return true;
}
private void addLabelDelta(String name, short value) {
labelDelta.add(LabelVote.create(name, value));
}
}
}