| // 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.ImmutableSet.toImmutableSet; | |
| import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL; | |
| import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF; | |
| import static com.google.gerrit.server.project.ProjectCache.illegalState; | |
| import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION; | |
| import static java.nio.charset.StandardCharsets.UTF_8; | |
| import static java.util.stream.Collectors.groupingBy; | |
| import static java.util.stream.Collectors.toList; | |
| 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.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.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.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.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.CommentsUtil; | |
| import com.google.gerrit.server.CurrentUser; | |
| import com.google.gerrit.server.DraftCommentsReader; | |
| import com.google.gerrit.server.IdentifiedUser; | |
| 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.ChangeJson; | |
| import com.google.gerrit.server.change.ChangeResource; | |
| 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.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.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.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.UpdateException; | |
| import com.google.gerrit.server.update.context.RefUpdateContext; | |
| import com.google.gerrit.server.util.time.TimeUtil; | |
| import com.google.inject.Inject; | |
| import com.google.inject.Singleton; | |
| import java.io.IOException; | |
| import java.time.Instant; | |
| import java.util.ArrayList; | |
| 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 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"; | |
| private final BatchUpdate.Factory updateFactory; | |
| private final PostReviewOp.Factory postReviewOpFactory; | |
| private final ChangeResource.Factory changeResourceFactory; | |
| private final ChangeData.Factory changeDataFactory; | |
| private final AccountCache accountCache; | |
| private final ApprovalsUtil approvalsUtil; | |
| private final CommentsUtil commentsUtil; | |
| private final DraftCommentsReader draftCommentsReader; | |
| private final PatchListCache patchListCache; | |
| private final AccountResolver accountResolver; | |
| 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 ReplyAttentionSetUpdates replyAttentionSetUpdates; | |
| private final ReviewerAdded reviewerAdded; | |
| private final boolean strictLabels; | |
| private final ChangeJson.Factory changeJsonFactory; | |
| @Inject | |
| PostReview( | |
| BatchUpdate.Factory updateFactory, | |
| PostReviewOp.Factory postReviewOpFactory, | |
| ChangeResource.Factory changeResourceFactory, | |
| ChangeData.Factory changeDataFactory, | |
| AccountCache accountCache, | |
| ApprovalsUtil approvalsUtil, | |
| CommentsUtil commentsUtil, | |
| DraftCommentsReader draftCommentsReader, | |
| PatchListCache patchListCache, | |
| AccountResolver accountResolver, | |
| ReviewerModifier reviewerModifier, | |
| Metrics metrics, | |
| ModifyReviewersEmail modifyReviewersEmail, | |
| NotifyResolver notifyResolver, | |
| @GerritServerConfig Config gerritConfig, | |
| WorkInProgressOp.Factory workInProgressOpFactory, | |
| ProjectCache projectCache, | |
| PermissionBackend permissionBackend, | |
| ReplyAttentionSetUpdates replyAttentionSetUpdates, | |
| ReviewerAdded reviewerAdded, | |
| ChangeJson.Factory changeJsonFactory) { | |
| this.updateFactory = updateFactory; | |
| this.postReviewOpFactory = postReviewOpFactory; | |
| this.changeResourceFactory = changeResourceFactory; | |
| this.changeDataFactory = changeDataFactory; | |
| this.accountCache = accountCache; | |
| this.commentsUtil = commentsUtil; | |
| this.draftCommentsReader = draftCommentsReader; | |
| this.patchListCache = patchListCache; | |
| this.approvalsUtil = approvalsUtil; | |
| this.accountResolver = accountResolver; | |
| this.reviewerModifier = reviewerModifier; | |
| this.metrics = metrics; | |
| this.modifyReviewersEmail = modifyReviewersEmail; | |
| this.notifyResolver = notifyResolver; | |
| this.workInProgressOpFactory = workInProgressOpFactory; | |
| this.projectCache = projectCache; | |
| this.permissionBackend = permissionBackend; | |
| this.replyAttentionSetUpdates = replyAttentionSetUpdates; | |
| this.reviewerAdded = reviewerAdded; | |
| this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false); | |
| this.changeJsonFactory = changeJsonFactory; | |
| } | |
| @Override | |
| public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input) | |
| throws RestApiException, UpdateException, IOException, PermissionBackendException, | |
| ConfigInvalidException, PatchListNotAvailableException { | |
| return apply(revision, input, TimeUtil.now()); | |
| } | |
| public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input, Instant 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; | |
| // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs. | |
| NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails); | |
| try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) { | |
| try (BatchUpdate bu = | |
| updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) { | |
| bu.setNotify(notify); | |
| 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 ops. | |
| logger.atFine().log("posting review"); | |
| PostReviewOp postReviewOp = | |
| postReviewOpFactory.create( | |
| projectState, revision.getPatchSet().id(), input, revision.getAccountId()); | |
| bu.addOp(revision.getChange().getId(), postReviewOp); | |
| // 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); | |
| if (input.responseFormatOptions != null) { | |
| output.changeInfo = changeJsonFactory.create(input.responseFormatOptions).format(cd); | |
| } | |
| 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 = | |
| TraceContext.newTimer( | |
| getClass().getSimpleName() + "#batchEmailReviewers", Metadata.empty())) { | |
| 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, | |
| Instant 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, | |
| ResourceConflictException, 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 ResourceConflictException( | |
| 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 <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 = | |
| draftCommentsReader | |
| .getDraftsByChangeAndDraftAuthor(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(); | |
| } | |
| } |