| // 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.collect.ImmutableList.toImmutableList; |
| import static com.google.common.collect.ImmutableSet.toImmutableSet; |
| import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL; |
| import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER; |
| import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF; |
| import static com.google.gerrit.server.project.ProjectCache.illegalState; |
| import static java.nio.charset.StandardCharsets.UTF_8; |
| 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.Joiner; |
| 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.entities.Account; |
| import com.google.gerrit.entities.Address; |
| import com.google.gerrit.entities.Change; |
| 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.Patch; |
| import com.google.gerrit.entities.PatchSet; |
| import com.google.gerrit.entities.PatchSetApproval; |
| import com.google.gerrit.entities.RobotComment; |
| import com.google.gerrit.exceptions.StorageException; |
| 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.ReviewerInput; |
| import com.google.gerrit.extensions.api.changes.ReviewerResult; |
| 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.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.RestModifyView; |
| 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.metrics.Counter1; |
| import com.google.gerrit.metrics.Description; |
| import com.google.gerrit.metrics.Field; |
| import com.google.gerrit.metrics.MetricMaker; |
| 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.AccountCache; |
| import com.google.gerrit.server.account.AccountResolver; |
| import com.google.gerrit.server.account.AccountState; |
| import com.google.gerrit.server.approval.ApprovalsUtil; |
| import com.google.gerrit.server.change.ChangeResource; |
| import com.google.gerrit.server.change.EmailReviewComments; |
| import com.google.gerrit.server.change.ModifyReviewersEmail; |
| import com.google.gerrit.server.change.NotifyResolver; |
| import com.google.gerrit.server.change.ReviewerModifier; |
| import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification; |
| import com.google.gerrit.server.change.ReviewerOp.Result; |
| 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.extensions.events.ReviewerAdded; |
| 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.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.PostUpdateContext; |
| 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.inject.Inject; |
| import com.google.inject.Singleton; |
| 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.HashSet; |
| import java.util.Iterator; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.Optional; |
| 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 implements RestModifyView<RevisionResource, ReviewInput> { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| @Singleton |
| private static class Metrics { |
| final Counter1<String> draftHandling; |
| |
| @Inject |
| Metrics(MetricMaker metricMaker) { |
| draftHandling = |
| metricMaker.newCounter( |
| "change/post_review/draft_handling", |
| new Description( |
| "Total number of draft handling option " |
| + "(KEEP, PUBLISH, PUBLISH_ALL_REVISIONS) " |
| + "selected by users while posting a review.") |
| .setRate() |
| .setUnit("count"), |
| Field.ofString("type", Metadata.Builder::eventType) |
| .description( |
| "The type of the draft handling option" |
| + " (KEEP, PUBLISH, PUBLISH_ALL_REVISIONS).") |
| .build()); |
| } |
| } |
| |
| 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 final BatchUpdate.Factory updateFactory; |
| private final ChangeResource.Factory changeResourceFactory; |
| private final ChangeData.Factory changeDataFactory; |
| private final AccountCache accountCache; |
| 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 ReviewerModifier reviewerModifier; |
| private final Metrics metrics; |
| private final ModifyReviewersEmail modifyReviewersEmail; |
| private final NotifyResolver notifyResolver; |
| private final WorkInProgressOp.Factory workInProgressOpFactory; |
| private final ProjectCache projectCache; |
| private final PermissionBackend permissionBackend; |
| private final PluginSetContext<CommentValidator> commentValidators; |
| private final PluginSetContext<OnPostReview> onPostReviews; |
| private final ReplyAttentionSetUpdates replyAttentionSetUpdates; |
| private final ReviewerAdded reviewerAdded; |
| private final boolean strictLabels; |
| private final boolean publishPatchSetLevelComment; |
| |
| @Inject |
| PostReview( |
| BatchUpdate.Factory updateFactory, |
| ChangeResource.Factory changeResourceFactory, |
| ChangeData.Factory changeDataFactory, |
| AccountCache accountCache, |
| ApprovalsUtil approvalsUtil, |
| ChangeMessagesUtil cmUtil, |
| CommentsUtil commentsUtil, |
| PublishCommentUtil publishCommentUtil, |
| PatchSetUtil psUtil, |
| PatchListCache patchListCache, |
| AccountResolver accountResolver, |
| EmailReviewComments.Factory email, |
| CommentAdded commentAdded, |
| ReviewerModifier reviewerModifier, |
| Metrics metrics, |
| ModifyReviewersEmail modifyReviewersEmail, |
| NotifyResolver notifyResolver, |
| @GerritServerConfig Config gerritConfig, |
| WorkInProgressOp.Factory workInProgressOpFactory, |
| ProjectCache projectCache, |
| PermissionBackend permissionBackend, |
| PluginSetContext<CommentValidator> commentValidators, |
| PluginSetContext<OnPostReview> onPostReviews, |
| ReplyAttentionSetUpdates replyAttentionSetUpdates, |
| ReviewerAdded reviewerAdded) { |
| this.updateFactory = updateFactory; |
| this.changeResourceFactory = changeResourceFactory; |
| this.changeDataFactory = changeDataFactory; |
| this.accountCache = accountCache; |
| 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.reviewerModifier = reviewerModifier; |
| this.metrics = metrics; |
| this.modifyReviewersEmail = modifyReviewersEmail; |
| this.notifyResolver = notifyResolver; |
| this.workInProgressOpFactory = workInProgressOpFactory; |
| this.projectCache = projectCache; |
| this.permissionBackend = permissionBackend; |
| this.commentValidators = commentValidators; |
| this.onPostReviews = onPostReviews; |
| this.replyAttentionSetUpdates = replyAttentionSetUpdates; |
| this.reviewerAdded = reviewerAdded; |
| this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false); |
| this.publishPatchSetLevelComment = |
| gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true); |
| } |
| |
| @Override |
| public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input) |
| throws RestApiException, UpdateException, IOException, PermissionBackendException, |
| ConfigInvalidException, PatchListNotAvailableException { |
| return apply(revision, input, TimeUtil.nowTs()); |
| } |
| |
| public Response<ReviewResult> apply(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.get(revision.getProject()).orElseThrow(illegalState(revision.getProject())); |
| LabelTypes labelTypes = projectState.getLabelTypes(revision.getNotes()); |
| |
| logger.atFine().log("strict label checking is %s", (strictLabels ? "enabled" : "disabled")); |
| |
| metrics.draftHandling.increment(input.drafts == null ? "N/A" : input.drafts.name()); |
| 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.draftIdsToPublish != null) { |
| checkDraftIds(revision, input.draftIdsToPublish, input.drafts); |
| } |
| if (input.robotComments != null) { |
| input.robotComments = cleanUpComments(input.robotComments); |
| checkRobotComments(revision, input.robotComments); |
| } |
| |
| if (input.notify == null) { |
| input.notify = defaultNotify(revision.getChange(), input); |
| } |
| logger.atFine().log("notify handling = %s", input.notify); |
| |
| Map<String, ReviewerResult> reviewerJsonResults = null; |
| List<ReviewerModification> reviewerResults = Lists.newArrayList(); |
| boolean hasError = false; |
| boolean confirm = false; |
| if (input.reviewers != null) { |
| reviewerJsonResults = Maps.newHashMap(); |
| for (ReviewerInput reviewerInput : input.reviewers) { |
| ReviewerModification result = |
| reviewerModifier.prepare(revision.getNotes(), revision.getUser(), reviewerInput, true); |
| reviewerJsonResults.put(reviewerInput.reviewer, result.result); |
| if (result.result.error != null) { |
| logger.atFine().log( |
| "Adding %s as reviewer failed: %s", reviewerInput.reviewer, result.result.error); |
| hasError = true; |
| continue; |
| } |
| if (result.result.confirm != null) { |
| logger.atFine().log( |
| "Adding %s as reviewer requires confirmation", reviewerInput.reviewer); |
| confirm = true; |
| continue; |
| } |
| logger.atFine().log("Adding %s as reviewer was prepared", reviewerInput.reviewer); |
| 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 account = revision.getUser().asIdentifiedUser().getAccount(); |
| boolean ccOrReviewer = false; |
| if (input.labels != null && !input.labels.isEmpty()) { |
| ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0); |
| if (ccOrReviewer) { |
| logger.atFine().log("calling user is cc/reviewer on the change due to voting on a label"); |
| } |
| } |
| |
| 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(account.id()); |
| if (ccOrReviewer) { |
| logger.atFine().log("calling user is already cc/reviewer on the change"); |
| } |
| } |
| |
| // 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. |
| logger.atFine().log("adding reviewer additions"); |
| for (ReviewerModification reviewerResult : reviewerResults) { |
| reviewerResult.op.suppressEmail(); // Send a single batch email below. |
| reviewerResult.op.suppressEvent(); // Send events below, if possible as batch. |
| bu.addOp(revision.getChange().getId(), reviewerResult.op); |
| if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) { |
| logger.atFine().log("calling user is explicitly added as reviewer or CC"); |
| ccOrReviewer = true; |
| } |
| } |
| |
| 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. |
| logger.atFine().log("CCing calling user"); |
| ReviewerModification selfAddition = |
| reviewerModifier.ccCurrentUser(revision.getUser(), revision); |
| selfAddition.op.suppressEmail(); |
| selfAddition.op.suppressEvent(); |
| bu.addOp(revision.getChange().getId(), selfAddition.op); |
| } |
| |
| // Add WorkInProgressOp if requested. |
| if ((input.ready || input.workInProgress) |
| && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) { |
| 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; |
| } |
| |
| logger.atFine().log("setting work-in-progress to %s", input.workInProgress); |
| WorkInProgressOp wipOp = |
| workInProgressOpFactory.create(input.workInProgress, new WorkInProgressOp.Input()); |
| wipOp.suppressEmail(); |
| bu.addOp(revision.getChange().getId(), wipOp); |
| } |
| |
| // Add the review op. |
| logger.atFine().log("posting review"); |
| bu.addOp( |
| revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input)); |
| |
| // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs. |
| NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails); |
| bu.setNotify(notify); |
| |
| // Adjust the attention set based on the input |
| replyAttentionSetUpdates.updateAttentionSet( |
| bu, revision.getNotes(), input, revision.getUser()); |
| bu.execute(); |
| |
| // Re-read change to take into account results of the update. |
| ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId()); |
| for (ReviewerModification reviewerResult : reviewerResults) { |
| reviewerResult.gatherResults(cd); |
| } |
| |
| // Sending emails and events from ReviewersOps was suppressed so we can send a single batch |
| // email/event here. |
| batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify); |
| batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts); |
| } |
| |
| return Response.ok(output); |
| } |
| |
| private boolean didWorkInProgressChange(boolean currentWorkInProgress, ReviewInput input) { |
| return input.ready == currentWorkInProgress || input.workInProgress != currentWorkInProgress; |
| } |
| |
| 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<ReviewerModification> reviewerModifications, |
| NotifyResolver.Result notify) { |
| try (TraceContext.TraceTimer ignored = newTimer("batchEmailReviewers")) { |
| List<Account.Id> to = new ArrayList<>(); |
| List<Account.Id> cc = new ArrayList<>(); |
| List<Account.Id> removed = new ArrayList<>(); |
| List<Address> toByEmail = new ArrayList<>(); |
| List<Address> ccByEmail = new ArrayList<>(); |
| List<Address> removedByEmail = new ArrayList<>(); |
| for (ReviewerModification modification : reviewerModifications) { |
| Result reviewAdditionResult = modification.op.getResult(); |
| if (modification.state() == ReviewerState.REVIEWER |
| && (!reviewAdditionResult.addedReviewers().isEmpty() |
| || !reviewAdditionResult.addedReviewersByEmail().isEmpty())) { |
| to.addAll(modification.reviewers.stream().map(Account::id).collect(toImmutableSet())); |
| toByEmail.addAll(modification.reviewersByEmail); |
| } else if (modification.state() == ReviewerState.CC |
| && (!reviewAdditionResult.addedCCs().isEmpty() |
| || !reviewAdditionResult.addedCCsByEmail().isEmpty())) { |
| cc.addAll(modification.reviewers.stream().map(Account::id).collect(toImmutableSet())); |
| ccByEmail.addAll(modification.reviewersByEmail); |
| } else if (modification.state() == ReviewerState.REMOVED |
| && (reviewAdditionResult.deletedReviewer().isPresent() |
| || reviewAdditionResult.deletedReviewerByEmail().isPresent())) { |
| reviewAdditionResult.deletedReviewer().ifPresent(d -> removed.add(d)); |
| reviewAdditionResult.deletedReviewerByEmail().ifPresent(d -> removedByEmail.add(d)); |
| } |
| } |
| modifyReviewersEmail.emailReviewersAsync( |
| user.asIdentifiedUser(), |
| change, |
| to, |
| cc, |
| removed, |
| toByEmail, |
| ccByEmail, |
| removedByEmail, |
| notify); |
| } |
| } |
| |
| private void batchReviewerEvents( |
| CurrentUser user, |
| ChangeData cd, |
| PatchSet patchSet, |
| List<ReviewerModification> reviewerModifications, |
| Timestamp when) { |
| List<AccountState> newlyAddedReviewers = new ArrayList<>(); |
| |
| // There are no events for CCs and reviewers added/deleted by email. |
| for (ReviewerModification modification : reviewerModifications) { |
| Result reviewerAdditionResult = modification.op.getResult(); |
| if (modification.state() == ReviewerState.REVIEWER) { |
| newlyAddedReviewers.addAll( |
| reviewerAdditionResult.addedReviewers().stream() |
| .map(psa -> psa.accountId()) |
| .map(accountId -> accountCache.get(accountId)) |
| .flatMap(Streams::stream) |
| .collect(toList())); |
| } else if (modification.state() == ReviewerState.REMOVED) { |
| // There is no batch event for reviewer removals, hence fire the event for each |
| // modification that deleted a reviewer immediately. |
| modification.op.sendEvent(); |
| } |
| } |
| |
| // Fire a batch event for all newly added reviewers. |
| reviewerAdded.fire(cd, patchSet, newlyAddedReviewers, user.asIdentifiedUser().state(), when); |
| } |
| |
| private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in) |
| throws BadRequestException, AuthException, UnprocessableEntityException, |
| PermissionBackendException, IOException, ConfigInvalidException { |
| logger.atFine().log("request is executed on behalf of %s", in.onBehalfOf); |
| |
| 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"); |
| } |
| |
| logger.atFine().log("label input: %s", in.labels); |
| |
| 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(); |
| Optional<LabelType> type = labelTypes.byLabel(ent.getKey()); |
| if (!type.isPresent()) { |
| logger.atFine().log("label %s not found", ent.getKey()); |
| if (strictLabels) { |
| throw new BadRequestException( |
| String.format("label \"%s\" is not a configured label", ent.getKey())); |
| } |
| logger.atFine().log("ignoring input for unknown label %s", ent.getKey()); |
| itr.remove(); |
| continue; |
| } |
| |
| if (caller.isInternalUser()) { |
| logger.atFine().log( |
| "skipping on behalf of permission check for label %s" |
| + " because caller is an internal user", |
| type.get().getName()); |
| } else { |
| try { |
| perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type.get(), ent.getValue())); |
| } catch (AuthException e) { |
| throw new AuthException( |
| String.format( |
| "not permitted to modify label \"%s\" on behalf of \"%s\"", |
| type.get().getName(), in.onBehalfOf), |
| e); |
| } |
| } |
| } |
| if (in.labels.isEmpty()) { |
| logger.atFine().log("labels are empty after unknown labels have been removed"); |
| throw new AuthException( |
| String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf)); |
| } |
| |
| IdentifiedUser reviewer = accountResolver.resolve(in.onBehalfOf).asUniqueUserOnBehalfOf(caller); |
| logger.atFine().log("on behalf of user was resolved to %s", reviewer.getLoggableName()); |
| 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()), e); |
| } |
| |
| 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 { |
| logger.atFine().log("checking label input: %s", labels); |
| |
| PermissionBackend.ForChange perm = rsrc.permissions(); |
| Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator(); |
| while (itr.hasNext()) { |
| Map.Entry<String, Short> ent = itr.next(); |
| Optional<LabelType> lt = labelTypes.byLabel(ent.getKey()); |
| if (!lt.isPresent()) { |
| logger.atFine().log("label %s not found", ent.getKey()); |
| if (strictLabels) { |
| throw new BadRequestException( |
| String.format("label \"%s\" is not a configured label", ent.getKey())); |
| } |
| logger.atFine().log("ignoring input for unknown label %s", 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.get().getValue(ent.getValue()) == null) { |
| logger.atFine().log("label value %s not found", ent.getValue()); |
| if (strictLabels) { |
| throw new BadRequestException( |
| String.format("label \"%s\": %d is not a valid value", ent.getKey(), ent.getValue())); |
| } |
| logger.atFine().log( |
| "ignoring input for label %s because label value is unknown", ent.getKey()); |
| itr.remove(); |
| continue; |
| } |
| |
| short val = ent.getValue(); |
| try { |
| perm.check(new LabelPermission.WithValue(lt.get(), val)); |
| } catch (AuthException e) { |
| throw new AuthException( |
| String.format("Applying label \"%s\": %d is restricted", lt.get().getName(), val), e); |
| } |
| } |
| } |
| |
| private static <T extends com.google.gerrit.extensions.client.Comment> |
| 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 com.google.gerrit.extensions.client.Comment> List<T> cleanUpComments( |
| List<T> comments) { |
| return comments.stream() |
| .filter(Objects::nonNull) |
| .filter(comment -> !Strings.nullToEmpty(comment.message).trim().isEmpty()) |
| .collect(toList()); |
| } |
| |
| private TraceContext.TraceTimer newTimer(String method) { |
| return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty()); |
| } |
| |
| private <T extends com.google.gerrit.extensions.client.Comment> void checkComments( |
| RevisionResource revision, Map<String, List<T>> commentsPerPath) |
| throws BadRequestException, PatchListNotAvailableException { |
| logger.atFine().log("checking comments"); |
| 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); |
| ensureValidPatchsetLevelComment(path, comment); |
| ensureValidInReplyTo(revision.getNotes(), comment.inReplyTo); |
| } |
| } |
| } |
| |
| /** |
| * Asserts that the draft IDs to publish are valid, i.e. they exist and belong to the current |
| * user. If the {@code draftHandling} parameter is equal to {@link DraftHandling#PUBLISH}, then |
| * draft IDs should all correspond to the target revision, otherwise we throw a |
| * BadRequestException. |
| */ |
| private void checkDraftIds( |
| RevisionResource resource, List<String> draftIds, DraftHandling draftHandling) |
| throws BadRequestException { |
| Map<String, HumanComment> draftsByUuid = |
| commentsUtil.draftByChangeAuthor(resource.getNotes(), resource.getUser().getAccountId()) |
| .stream() |
| .collect(Collectors.toMap(c -> c.key.uuid, c -> c)); |
| List<String> nonExistingDraftIds = |
| draftIds.stream().filter(id -> !draftsByUuid.containsKey(id)).collect(toList()); |
| if (!nonExistingDraftIds.isEmpty()) { |
| throw new BadRequestException("Non-existing draft IDs: " + nonExistingDraftIds); |
| } |
| if (draftHandling == DraftHandling.PUBLISH_ALL_REVISIONS |
| || draftHandling == DraftHandling.KEEP) { |
| return; |
| } |
| List<String> draftsForOtherRevisions = |
| draftIds.stream() |
| .filter(id -> draftsByUuid.get(id).key.patchSetId != resource.getPatchSet().number()) |
| .collect(toList()); |
| if (!draftsForOtherRevisions.isEmpty()) { |
| throw new BadRequestException( |
| String.format( |
| "Draft comments for other revisions cannot be published when DraftHandling = PUBLISH." |
| + " (draft IDs: %s)", |
| draftsForOtherRevisions)); |
| } |
| } |
| |
| 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 com.google.gerrit.extensions.client.Comment> |
| 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 static <T extends com.google.gerrit.extensions.client.Comment> |
| void ensureValidPatchsetLevelComment(String path, T comment) throws BadRequestException { |
| if (path.equals(PATCHSET_LEVEL) |
| && (comment.side != null || comment.range != null || comment.line != null)) { |
| throw new BadRequestException("Patchset-level comments can't have side, range, or line"); |
| } |
| } |
| |
| private void ensureValidInReplyTo(ChangeNotes changeNotes, String inReplyTo) |
| throws BadRequestException { |
| if (inReplyTo != null |
| && !commentsUtil.getPublishedHumanComment(changeNotes, inReplyTo).isPresent() |
| && !commentsUtil.getRobotComment(changeNotes, inReplyTo).isPresent()) { |
| throw new BadRequestException( |
| String.format("Invalid inReplyTo, comment %s not found", inReplyTo)); |
| } |
| } |
| |
| private void checkRobotComments( |
| RevisionResource revision, Map<String, List<RobotCommentInput>> in) |
| throws BadRequestException, PatchListNotAvailableException { |
| logger.atFine().log("checking robot comments"); |
| for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) { |
| String commentPath = e.getKey(); |
| for (RobotCommentInput c : e.getValue()) { |
| ensureRobotIdIsSet(c.robotId, commentPath); |
| ensureRobotRunIdIsSet(c.robotRunId, commentPath); |
| ensureFixSuggestionsAreAddable(c.fixSuggestions, commentPath); |
| // Size is validated later, in CommentLimitsValidator. |
| } |
| } |
| checkComments(revision, in); |
| } |
| |
| 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) { |
| ensureReplacementPathIsSetAndNotPatchsetLevel(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 ensureReplacementPathIsSetAndNotPatchsetLevel( |
| 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)); |
| } |
| if (replacementPath.equals(PATCHSET_LEVEL)) { |
| throw new BadRequestException( |
| String.format( |
| "A file path must not be %s for the replacement of the robot comment on %s", |
| PATCHSET_LEVEL, 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 HumanComment}-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 String mailMessage; |
| 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, |
| 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("insertMessage")) { |
| dirty |= insertMessage(ctx); |
| } |
| return dirty; |
| } |
| |
| @Override |
| public void postUpdate(PostUpdateContext ctx) { |
| if (mailMessage == null) { |
| return; |
| } |
| NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId()); |
| if (notify.shouldNotify()) { |
| try { |
| email |
| .create( |
| notify, |
| notes, |
| ps, |
| user, |
| mailMessage, |
| ctx.getWhen(), |
| comments, |
| in.message, |
| labelDelta, |
| ctx.getRepoView()) |
| .sendAsync(); |
| } catch (IOException ex) { |
| throw new StorageException( |
| String.format("Repository %s not found", ctx.getProject().get()), ex); |
| } |
| } |
| 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. |
| if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) { |
| List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL); |
| if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) { |
| CommentInput firstComment = patchSetLevelComments.get(0); |
| if (!Strings.isNullOrEmpty(firstComment.message)) { |
| comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message); |
| } |
| } |
| } |
| } |
| 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#putComment() the draft with the same ID will be deleted. |
| comment.writtenOn = 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()); |
| 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 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.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 commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream() |
| .collect(Collectors.toMap(c -> c.key.uuid, c -> c)); |
| } |
| |
| private Map<String, HumanComment> 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) { |
| return approvalsUtil |
| .getReviewers(ctx.getNotes()) |
| .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 = |
| 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 vote is copied over from a past patch-set, or the |
| // values are different. |
| } else if (c != null |
| && (c.value() != ent.getValue() || 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().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) |
| 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; |
| } |
| |
| Optional<LabelType> lt = labelTypes.byLabel(a.labelId()); |
| if (lt.isPresent()) { |
| current.put(lt.get().getName(), a); |
| } else { |
| del.add(a); |
| } |
| } |
| return current; |
| } |
| |
| private boolean insertMessage(ChangeContext ctx) { |
| 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()) { |
| // 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(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; |
| } |
| |
| private void addLabelDelta(String name, short value) { |
| labelDelta.add(LabelVote.create(name, value)); |
| } |
| } |
| } |