Line data Source code
1 : // Copyright (C) 2012 The Android Open Source Project
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // http://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : package com.google.gerrit.server.restapi.change;
16 :
17 : import static com.google.common.base.MoreObjects.firstNonNull;
18 : import static com.google.common.collect.ImmutableSet.toImmutableSet;
19 : import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
20 : import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
21 : import static com.google.gerrit.server.project.ProjectCache.illegalState;
22 : import static java.nio.charset.StandardCharsets.UTF_8;
23 : import static java.util.stream.Collectors.groupingBy;
24 : import static java.util.stream.Collectors.toList;
25 : import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
26 :
27 : import com.google.auto.value.AutoValue;
28 : import com.google.common.base.Strings;
29 : import com.google.common.collect.Lists;
30 : import com.google.common.collect.Maps;
31 : import com.google.common.collect.Ordering;
32 : import com.google.common.collect.Streams;
33 : import com.google.common.flogger.FluentLogger;
34 : import com.google.common.hash.HashCode;
35 : import com.google.common.hash.Hashing;
36 : import com.google.gerrit.common.Nullable;
37 : import com.google.gerrit.entities.Account;
38 : import com.google.gerrit.entities.Address;
39 : import com.google.gerrit.entities.Change;
40 : import com.google.gerrit.entities.Comment;
41 : import com.google.gerrit.entities.HumanComment;
42 : import com.google.gerrit.entities.LabelType;
43 : import com.google.gerrit.entities.LabelTypes;
44 : import com.google.gerrit.entities.Patch;
45 : import com.google.gerrit.entities.PatchSet;
46 : import com.google.gerrit.extensions.api.changes.NotifyHandling;
47 : import com.google.gerrit.extensions.api.changes.ReviewInput;
48 : import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
49 : import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
50 : import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
51 : import com.google.gerrit.extensions.api.changes.ReviewResult;
52 : import com.google.gerrit.extensions.api.changes.ReviewerInput;
53 : import com.google.gerrit.extensions.api.changes.ReviewerResult;
54 : import com.google.gerrit.extensions.client.Comment.Range;
55 : import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
56 : import com.google.gerrit.extensions.client.ReviewerState;
57 : import com.google.gerrit.extensions.client.Side;
58 : import com.google.gerrit.extensions.common.FixReplacementInfo;
59 : import com.google.gerrit.extensions.common.FixSuggestionInfo;
60 : import com.google.gerrit.extensions.restapi.AuthException;
61 : import com.google.gerrit.extensions.restapi.BadRequestException;
62 : import com.google.gerrit.extensions.restapi.ResourceConflictException;
63 : import com.google.gerrit.extensions.restapi.Response;
64 : import com.google.gerrit.extensions.restapi.RestApiException;
65 : import com.google.gerrit.extensions.restapi.RestModifyView;
66 : import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
67 : import com.google.gerrit.metrics.Counter1;
68 : import com.google.gerrit.metrics.Description;
69 : import com.google.gerrit.metrics.Field;
70 : import com.google.gerrit.metrics.MetricMaker;
71 : import com.google.gerrit.server.ChangeMessagesUtil;
72 : import com.google.gerrit.server.CommentsUtil;
73 : import com.google.gerrit.server.CurrentUser;
74 : import com.google.gerrit.server.IdentifiedUser;
75 : import com.google.gerrit.server.ReviewerSet;
76 : import com.google.gerrit.server.account.AccountCache;
77 : import com.google.gerrit.server.account.AccountResolver;
78 : import com.google.gerrit.server.account.AccountState;
79 : import com.google.gerrit.server.approval.ApprovalsUtil;
80 : import com.google.gerrit.server.change.ChangeResource;
81 : import com.google.gerrit.server.change.ModifyReviewersEmail;
82 : import com.google.gerrit.server.change.NotifyResolver;
83 : import com.google.gerrit.server.change.ReviewerModifier;
84 : import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
85 : import com.google.gerrit.server.change.ReviewerOp.Result;
86 : import com.google.gerrit.server.change.RevisionResource;
87 : import com.google.gerrit.server.change.WorkInProgressOp;
88 : import com.google.gerrit.server.config.GerritServerConfig;
89 : import com.google.gerrit.server.extensions.events.ReviewerAdded;
90 : import com.google.gerrit.server.logging.Metadata;
91 : import com.google.gerrit.server.logging.TraceContext;
92 : import com.google.gerrit.server.notedb.ChangeNotes;
93 : import com.google.gerrit.server.patch.DiffSummary;
94 : import com.google.gerrit.server.patch.DiffSummaryKey;
95 : import com.google.gerrit.server.patch.PatchListCache;
96 : import com.google.gerrit.server.patch.PatchListKey;
97 : import com.google.gerrit.server.patch.PatchListNotAvailableException;
98 : import com.google.gerrit.server.permissions.ChangePermission;
99 : import com.google.gerrit.server.permissions.LabelPermission;
100 : import com.google.gerrit.server.permissions.PermissionBackend;
101 : import com.google.gerrit.server.permissions.PermissionBackendException;
102 : import com.google.gerrit.server.project.ProjectCache;
103 : import com.google.gerrit.server.project.ProjectState;
104 : import com.google.gerrit.server.query.change.ChangeData;
105 : import com.google.gerrit.server.update.BatchUpdate;
106 : import com.google.gerrit.server.update.UpdateException;
107 : import com.google.gerrit.server.util.time.TimeUtil;
108 : import com.google.inject.Inject;
109 : import com.google.inject.Singleton;
110 : import java.io.IOException;
111 : import java.time.Instant;
112 : import java.util.ArrayList;
113 : import java.util.HashMap;
114 : import java.util.HashSet;
115 : import java.util.Iterator;
116 : import java.util.List;
117 : import java.util.Map;
118 : import java.util.Objects;
119 : import java.util.Optional;
120 : import java.util.Set;
121 : import java.util.stream.Collectors;
122 : import org.eclipse.jgit.errors.ConfigInvalidException;
123 : import org.eclipse.jgit.lib.Config;
124 : import org.eclipse.jgit.lib.ObjectId;
125 :
126 : @Singleton
127 : public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
128 145 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
129 :
130 : @Singleton
131 : private static class Metrics {
132 : final Counter1<String> draftHandling;
133 :
134 : @Inject
135 145 : Metrics(MetricMaker metricMaker) {
136 145 : draftHandling =
137 145 : metricMaker.newCounter(
138 : "change/post_review/draft_handling",
139 : new Description(
140 : "Total number of draft handling option "
141 : + "(KEEP, PUBLISH, PUBLISH_ALL_REVISIONS) "
142 : + "selected by users while posting a review.")
143 145 : .setRate()
144 145 : .setUnit("count"),
145 145 : Field.ofString("type", Metadata.Builder::eventType)
146 145 : .description(
147 : "The type of the draft handling option"
148 : + " (KEEP, PUBLISH, PUBLISH_ALL_REVISIONS).")
149 145 : .build());
150 145 : }
151 : }
152 :
153 : private static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
154 : public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
155 : "work_in_progress and ready are mutually exclusive";
156 :
157 : private final BatchUpdate.Factory updateFactory;
158 : private final PostReviewOp.Factory postReviewOpFactory;
159 : private final PostReviewCopyApprovalsOpFactory postReviewCopyApprovalsOpFactory;
160 : private final ChangeResource.Factory changeResourceFactory;
161 : private final ChangeData.Factory changeDataFactory;
162 : private final AccountCache accountCache;
163 : private final ApprovalsUtil approvalsUtil;
164 : private final CommentsUtil commentsUtil;
165 : private final PatchListCache patchListCache;
166 : private final AccountResolver accountResolver;
167 : private final ReviewerModifier reviewerModifier;
168 : private final Metrics metrics;
169 : private final ModifyReviewersEmail modifyReviewersEmail;
170 : private final NotifyResolver notifyResolver;
171 : private final WorkInProgressOp.Factory workInProgressOpFactory;
172 : private final ProjectCache projectCache;
173 : private final PermissionBackend permissionBackend;
174 :
175 : private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
176 : private final ReviewerAdded reviewerAdded;
177 : private final boolean strictLabels;
178 :
179 : @Inject
180 : PostReview(
181 : BatchUpdate.Factory updateFactory,
182 : PostReviewOp.Factory postReviewOpFactory,
183 : PostReviewCopyApprovalsOpFactory postReviewCopyApprovalsOpFactory,
184 : ChangeResource.Factory changeResourceFactory,
185 : ChangeData.Factory changeDataFactory,
186 : AccountCache accountCache,
187 : ApprovalsUtil approvalsUtil,
188 : CommentsUtil commentsUtil,
189 : PatchListCache patchListCache,
190 : AccountResolver accountResolver,
191 : ReviewerModifier reviewerModifier,
192 : Metrics metrics,
193 : ModifyReviewersEmail modifyReviewersEmail,
194 : NotifyResolver notifyResolver,
195 : @GerritServerConfig Config gerritConfig,
196 : WorkInProgressOp.Factory workInProgressOpFactory,
197 : ProjectCache projectCache,
198 : PermissionBackend permissionBackend,
199 : ReplyAttentionSetUpdates replyAttentionSetUpdates,
200 145 : ReviewerAdded reviewerAdded) {
201 145 : this.updateFactory = updateFactory;
202 145 : this.postReviewOpFactory = postReviewOpFactory;
203 145 : this.postReviewCopyApprovalsOpFactory = postReviewCopyApprovalsOpFactory;
204 145 : this.changeResourceFactory = changeResourceFactory;
205 145 : this.changeDataFactory = changeDataFactory;
206 145 : this.accountCache = accountCache;
207 145 : this.commentsUtil = commentsUtil;
208 145 : this.patchListCache = patchListCache;
209 145 : this.approvalsUtil = approvalsUtil;
210 145 : this.accountResolver = accountResolver;
211 145 : this.reviewerModifier = reviewerModifier;
212 145 : this.metrics = metrics;
213 145 : this.modifyReviewersEmail = modifyReviewersEmail;
214 145 : this.notifyResolver = notifyResolver;
215 145 : this.workInProgressOpFactory = workInProgressOpFactory;
216 145 : this.projectCache = projectCache;
217 145 : this.permissionBackend = permissionBackend;
218 145 : this.replyAttentionSetUpdates = replyAttentionSetUpdates;
219 145 : this.reviewerAdded = reviewerAdded;
220 145 : this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
221 145 : }
222 :
223 : @Override
224 : public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input)
225 : throws RestApiException, UpdateException, IOException, PermissionBackendException,
226 : ConfigInvalidException, PatchListNotAvailableException {
227 65 : return apply(revision, input, TimeUtil.now());
228 : }
229 :
230 : public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input, Instant ts)
231 : throws RestApiException, UpdateException, IOException, PermissionBackendException,
232 : ConfigInvalidException, PatchListNotAvailableException {
233 : // Respect timestamp, but truncate at change created-on time.
234 65 : ts = Ordering.natural().max(ts, revision.getChange().getCreatedOn());
235 65 : if (revision.getEdit().isPresent()) {
236 0 : throw new ResourceConflictException("cannot post review on edit");
237 : }
238 65 : ProjectState projectState =
239 65 : projectCache.get(revision.getProject()).orElseThrow(illegalState(revision.getProject()));
240 65 : LabelTypes labelTypes = projectState.getLabelTypes(revision.getNotes());
241 :
242 65 : logger.atFine().log("strict label checking is %s", (strictLabels ? "enabled" : "disabled"));
243 :
244 65 : metrics.draftHandling.increment(input.drafts == null ? "N/A" : input.drafts.name());
245 65 : input.drafts = firstNonNull(input.drafts, DraftHandling.KEEP);
246 65 : logger.atFine().log("draft handling = %s", input.drafts);
247 :
248 65 : if (input.onBehalfOf != null) {
249 2 : revision = onBehalfOf(revision, labelTypes, input);
250 : }
251 65 : if (input.labels != null) {
252 58 : checkLabels(revision, labelTypes, input.labels);
253 : }
254 65 : if (input.comments != null) {
255 18 : input.comments = cleanUpComments(input.comments);
256 18 : checkComments(revision, input.comments);
257 : }
258 65 : if (input.draftIdsToPublish != null) {
259 1 : checkDraftIds(revision, input.draftIdsToPublish, input.drafts);
260 : }
261 65 : if (input.robotComments != null) {
262 7 : input.robotComments = cleanUpComments(input.robotComments);
263 7 : checkRobotComments(revision, input.robotComments);
264 : }
265 :
266 65 : if (input.notify == null) {
267 65 : input.notify = defaultNotify(revision.getChange(), input);
268 : }
269 65 : logger.atFine().log("notify handling = %s", input.notify);
270 :
271 65 : Map<String, ReviewerResult> reviewerJsonResults = null;
272 65 : List<ReviewerModification> reviewerResults = Lists.newArrayList();
273 65 : boolean hasError = false;
274 65 : boolean confirm = false;
275 65 : if (input.reviewers != null) {
276 11 : reviewerJsonResults = Maps.newHashMap();
277 11 : for (ReviewerInput reviewerInput : input.reviewers) {
278 11 : ReviewerModification result =
279 11 : reviewerModifier.prepare(revision.getNotes(), revision.getUser(), reviewerInput, true);
280 11 : reviewerJsonResults.put(reviewerInput.reviewer, result.result);
281 11 : if (result.result.error != null) {
282 2 : logger.atFine().log(
283 : "Adding %s as reviewer failed: %s", reviewerInput.reviewer, result.result.error);
284 2 : hasError = true;
285 2 : continue;
286 : }
287 11 : if (result.result.confirm != null) {
288 0 : logger.atFine().log(
289 : "Adding %s as reviewer requires confirmation", reviewerInput.reviewer);
290 0 : confirm = true;
291 0 : continue;
292 : }
293 11 : logger.atFine().log("Adding %s as reviewer was prepared", reviewerInput.reviewer);
294 11 : reviewerResults.add(result);
295 11 : }
296 : }
297 :
298 65 : ReviewResult output = new ReviewResult();
299 65 : output.reviewers = reviewerJsonResults;
300 65 : if (hasError || confirm) {
301 2 : output.error = ERROR_ADDING_REVIEWER;
302 2 : return Response.withStatusCode(SC_BAD_REQUEST, output);
303 : }
304 65 : output.labels = input.labels;
305 :
306 : // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
307 65 : NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
308 :
309 65 : try (BatchUpdate bu =
310 65 : updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
311 65 : bu.setNotify(notify);
312 :
313 65 : Account account = revision.getUser().asIdentifiedUser().getAccount();
314 65 : boolean ccOrReviewer = false;
315 65 : if (input.labels != null && !input.labels.isEmpty()) {
316 58 : ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
317 58 : if (ccOrReviewer) {
318 58 : logger.atFine().log("calling user is cc/reviewer on the change due to voting on a label");
319 : }
320 : }
321 :
322 65 : if (!ccOrReviewer) {
323 : // Check if user was already CCed or reviewing prior to this review.
324 27 : ReviewerSet currentReviewers =
325 27 : approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
326 27 : ccOrReviewer = currentReviewers.all().contains(account.id());
327 27 : if (ccOrReviewer) {
328 10 : logger.atFine().log("calling user is already cc/reviewer on the change");
329 : }
330 : }
331 :
332 : // Apply reviewer changes first. Revision emails should be sent to the
333 : // updated set of reviewers. Also keep track of whether the user added
334 : // themselves as a reviewer or to the CC list.
335 65 : logger.atFine().log("adding reviewer additions");
336 65 : for (ReviewerModification reviewerResult : reviewerResults) {
337 11 : reviewerResult.op.suppressEmail(); // Send a single batch email below.
338 11 : reviewerResult.op.suppressEvent(); // Send events below, if possible as batch.
339 11 : bu.addOp(revision.getChange().getId(), reviewerResult.op);
340 11 : if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
341 3 : logger.atFine().log("calling user is explicitly added as reviewer or CC");
342 3 : ccOrReviewer = true;
343 : }
344 11 : }
345 :
346 65 : if (!ccOrReviewer) {
347 : // User posting this review isn't currently in the reviewer or CC list,
348 : // isn't being explicitly added, and isn't voting on any label.
349 : // Automatically CC them on this change so they receive replies.
350 23 : logger.atFine().log("CCing calling user");
351 23 : ReviewerModification selfAddition =
352 23 : reviewerModifier.ccCurrentUser(revision.getUser(), revision);
353 23 : selfAddition.op.suppressEmail();
354 23 : selfAddition.op.suppressEvent();
355 23 : bu.addOp(revision.getChange().getId(), selfAddition.op);
356 : }
357 :
358 : // Add WorkInProgressOp if requested.
359 65 : if ((input.ready || input.workInProgress)
360 3 : && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
361 3 : if (input.ready && input.workInProgress) {
362 1 : output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
363 1 : return Response.withStatusCode(SC_BAD_REQUEST, output);
364 : }
365 :
366 3 : revision
367 3 : .getChangeResource()
368 3 : .permissions()
369 3 : .check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
370 :
371 3 : if (input.ready) {
372 3 : output.ready = true;
373 : }
374 :
375 3 : logger.atFine().log("setting work-in-progress to %s", input.workInProgress);
376 3 : WorkInProgressOp wipOp =
377 3 : workInProgressOpFactory.create(input.workInProgress, new WorkInProgressOp.Input());
378 3 : wipOp.suppressEmail();
379 3 : bu.addOp(revision.getChange().getId(), wipOp);
380 : }
381 :
382 : // Add the review ops.
383 65 : logger.atFine().log("posting review");
384 65 : PostReviewOp postReviewOp =
385 65 : postReviewOpFactory.create(projectState, revision.getPatchSet().id(), input);
386 65 : bu.addOp(revision.getChange().getId(), postReviewOp);
387 65 : bu.addOp(
388 65 : revision.getChange().getId(),
389 65 : postReviewCopyApprovalsOpFactory.create(revision.getPatchSet().id()));
390 :
391 : // Adjust the attention set based on the input
392 65 : replyAttentionSetUpdates.updateAttentionSet(
393 65 : bu, revision.getNotes(), input, revision.getUser());
394 65 : bu.execute();
395 1 : }
396 :
397 : // Re-read change to take into account results of the update.
398 65 : ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
399 65 : for (ReviewerModification reviewerResult : reviewerResults) {
400 11 : reviewerResult.gatherResults(cd);
401 11 : }
402 :
403 : // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
404 : // email/event here.
405 65 : batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
406 65 : batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
407 :
408 65 : return Response.ok(output);
409 : }
410 :
411 : private boolean didWorkInProgressChange(boolean currentWorkInProgress, ReviewInput input) {
412 3 : return input.ready == currentWorkInProgress || input.workInProgress != currentWorkInProgress;
413 : }
414 :
415 : private NotifyHandling defaultNotify(Change c, ReviewInput in) {
416 65 : boolean workInProgress = c.isWorkInProgress();
417 65 : if (in.workInProgress) {
418 3 : workInProgress = true;
419 : }
420 65 : if (in.ready) {
421 3 : workInProgress = false;
422 : }
423 :
424 65 : if (ChangeMessagesUtil.isAutogenerated(in.tag)) {
425 : // Autogenerated comments default to lower notify levels.
426 5 : return workInProgress ? NotifyHandling.OWNER : NotifyHandling.OWNER_REVIEWERS;
427 : }
428 :
429 64 : if (workInProgress && !c.hasReviewStarted()) {
430 : // If review hasn't started we want to eliminate notifications, no matter who the author is.
431 13 : return NotifyHandling.NONE;
432 : }
433 :
434 : // Otherwise, it's either a non-WIP change, or a WIP change where review has started. Notify
435 : // everyone.
436 64 : return NotifyHandling.ALL;
437 : }
438 :
439 : private void batchEmailReviewers(
440 : CurrentUser user,
441 : Change change,
442 : List<ReviewerModification> reviewerModifications,
443 : NotifyResolver.Result notify) {
444 65 : try (TraceContext.TraceTimer ignored =
445 65 : TraceContext.newTimer(
446 65 : getClass().getSimpleName() + "#batchEmailReviewers", Metadata.empty())) {
447 65 : List<Account.Id> to = new ArrayList<>();
448 65 : List<Account.Id> cc = new ArrayList<>();
449 65 : List<Account.Id> removed = new ArrayList<>();
450 65 : List<Address> toByEmail = new ArrayList<>();
451 65 : List<Address> ccByEmail = new ArrayList<>();
452 65 : List<Address> removedByEmail = new ArrayList<>();
453 65 : for (ReviewerModification modification : reviewerModifications) {
454 11 : Result reviewAdditionResult = modification.op.getResult();
455 11 : if (modification.state() == ReviewerState.REVIEWER
456 11 : && (!reviewAdditionResult.addedReviewers().isEmpty()
457 8 : || !reviewAdditionResult.addedReviewersByEmail().isEmpty())) {
458 11 : to.addAll(modification.reviewers.stream().map(Account::id).collect(toImmutableSet()));
459 11 : toByEmail.addAll(modification.reviewersByEmail);
460 11 : } else if (modification.state() == ReviewerState.CC
461 10 : && (!reviewAdditionResult.addedCCs().isEmpty()
462 7 : || !reviewAdditionResult.addedCCsByEmail().isEmpty())) {
463 10 : cc.addAll(modification.reviewers.stream().map(Account::id).collect(toImmutableSet()));
464 10 : ccByEmail.addAll(modification.reviewersByEmail);
465 4 : } else if (modification.state() == ReviewerState.REMOVED
466 3 : && (reviewAdditionResult.deletedReviewer().isPresent()
467 1 : || reviewAdditionResult.deletedReviewerByEmail().isPresent())) {
468 3 : reviewAdditionResult.deletedReviewer().ifPresent(d -> removed.add(d));
469 3 : reviewAdditionResult.deletedReviewerByEmail().ifPresent(d -> removedByEmail.add(d));
470 : }
471 11 : }
472 65 : modifyReviewersEmail.emailReviewersAsync(
473 65 : user.asIdentifiedUser(),
474 : change,
475 : to,
476 : cc,
477 : removed,
478 : toByEmail,
479 : ccByEmail,
480 : removedByEmail,
481 : notify);
482 : }
483 65 : }
484 :
485 : private void batchReviewerEvents(
486 : CurrentUser user,
487 : ChangeData cd,
488 : PatchSet patchSet,
489 : List<ReviewerModification> reviewerModifications,
490 : Instant when) {
491 65 : List<AccountState> newlyAddedReviewers = new ArrayList<>();
492 :
493 : // There are no events for CCs and reviewers added/deleted by email.
494 65 : for (ReviewerModification modification : reviewerModifications) {
495 11 : Result reviewerAdditionResult = modification.op.getResult();
496 11 : if (modification.state() == ReviewerState.REVIEWER) {
497 11 : newlyAddedReviewers.addAll(
498 11 : reviewerAdditionResult.addedReviewers().stream()
499 11 : .map(psa -> psa.accountId())
500 11 : .map(accountId -> accountCache.get(accountId))
501 11 : .flatMap(Streams::stream)
502 11 : .collect(toList()));
503 10 : } else if (modification.state() == ReviewerState.REMOVED) {
504 : // There is no batch event for reviewer removals, hence fire the event for each
505 : // modification that deleted a reviewer immediately.
506 3 : modification.op.sendEvent();
507 : }
508 11 : }
509 :
510 : // Fire a batch event for all newly added reviewers.
511 65 : reviewerAdded.fire(cd, patchSet, newlyAddedReviewers, user.asIdentifiedUser().state(), when);
512 65 : }
513 :
514 : private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
515 : throws BadRequestException, AuthException, UnprocessableEntityException,
516 : ResourceConflictException, PermissionBackendException, IOException,
517 : ConfigInvalidException {
518 2 : logger.atFine().log("request is executed on behalf of %s", in.onBehalfOf);
519 :
520 2 : if (in.labels == null || in.labels.isEmpty()) {
521 1 : throw new AuthException(
522 1 : String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
523 : }
524 2 : if (in.drafts != DraftHandling.KEEP) {
525 1 : throw new AuthException("not allowed to modify other user's drafts");
526 : }
527 :
528 2 : logger.atFine().log("label input: %s", in.labels);
529 :
530 2 : CurrentUser caller = rev.getUser();
531 2 : PermissionBackend.ForChange perm = rev.permissions();
532 2 : Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
533 2 : while (itr.hasNext()) {
534 2 : Map.Entry<String, Short> ent = itr.next();
535 2 : Optional<LabelType> type = labelTypes.byLabel(ent.getKey());
536 2 : if (!type.isPresent()) {
537 1 : logger.atFine().log("label %s not found", ent.getKey());
538 1 : if (strictLabels) {
539 1 : throw new BadRequestException(
540 1 : String.format("label \"%s\" is not a configured label", ent.getKey()));
541 : }
542 1 : logger.atFine().log("ignoring input for unknown label %s", ent.getKey());
543 1 : itr.remove();
544 1 : continue;
545 : }
546 :
547 2 : if (caller.isInternalUser()) {
548 0 : logger.atFine().log(
549 : "skipping on behalf of permission check for label %s"
550 : + " because caller is an internal user",
551 0 : type.get().getName());
552 : } else {
553 : try {
554 2 : perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type.get(), ent.getValue()));
555 1 : } catch (AuthException e) {
556 1 : throw new AuthException(
557 1 : String.format(
558 : "not permitted to modify label \"%s\" on behalf of \"%s\"",
559 1 : type.get().getName(), in.onBehalfOf),
560 : e);
561 2 : }
562 : }
563 2 : }
564 2 : if (in.labels.isEmpty()) {
565 0 : logger.atFine().log("labels are empty after unknown labels have been removed");
566 0 : throw new AuthException(
567 0 : String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
568 : }
569 :
570 2 : IdentifiedUser reviewer = accountResolver.resolve(in.onBehalfOf).asUniqueUserOnBehalfOf(caller);
571 2 : logger.atFine().log("on behalf of user was resolved to %s", reviewer.getLoggableName());
572 : try {
573 2 : permissionBackend.user(reviewer).change(rev.getNotes()).check(ChangePermission.READ);
574 1 : } catch (AuthException e) {
575 1 : throw new ResourceConflictException(
576 1 : String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()), e);
577 2 : }
578 :
579 2 : return new RevisionResource(
580 2 : changeResourceFactory.create(rev.getNotes(), reviewer), rev.getPatchSet());
581 : }
582 :
583 : private void checkLabels(RevisionResource rsrc, LabelTypes labelTypes, Map<String, Short> labels)
584 : throws BadRequestException, AuthException, PermissionBackendException {
585 58 : logger.atFine().log("checking label input: %s", labels);
586 :
587 58 : PermissionBackend.ForChange perm = rsrc.permissions();
588 58 : Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
589 58 : while (itr.hasNext()) {
590 58 : Map.Entry<String, Short> ent = itr.next();
591 58 : Optional<LabelType> lt = labelTypes.byLabel(ent.getKey());
592 58 : if (!lt.isPresent()) {
593 2 : logger.atFine().log("label %s not found", ent.getKey());
594 2 : if (strictLabels) {
595 1 : throw new BadRequestException(
596 1 : String.format("label \"%s\" is not a configured label", ent.getKey()));
597 : }
598 2 : logger.atFine().log("ignoring input for unknown label %s", ent.getKey());
599 2 : itr.remove();
600 2 : continue;
601 : }
602 :
603 58 : if (ent.getValue() == null || ent.getValue() == 0) {
604 : // Always permit 0, even if it is not within range.
605 : // Later null/0 will be deleted and revoke the label.
606 15 : continue;
607 : }
608 :
609 58 : if (lt.get().getValue(ent.getValue()) == null) {
610 1 : logger.atFine().log("label value %s not found", ent.getValue());
611 1 : if (strictLabels) {
612 1 : throw new BadRequestException(
613 1 : String.format("label \"%s\": %d is not a valid value", ent.getKey(), ent.getValue()));
614 : }
615 1 : logger.atFine().log(
616 1 : "ignoring input for label %s because label value is unknown", ent.getKey());
617 1 : itr.remove();
618 1 : continue;
619 : }
620 :
621 58 : short val = ent.getValue();
622 : try {
623 58 : perm.check(new LabelPermission.WithValue(lt.get(), val));
624 3 : } catch (AuthException e) {
625 3 : throw new AuthException(
626 3 : String.format("Applying label \"%s\": %d is restricted", lt.get().getName(), val), e);
627 58 : }
628 58 : }
629 58 : }
630 :
631 : private static <T extends com.google.gerrit.extensions.client.Comment>
632 : Map<String, List<T>> cleanUpComments(Map<String, List<T>> commentsPerPath) {
633 21 : Map<String, List<T>> cleanedUpCommentMap = new HashMap<>();
634 21 : for (Map.Entry<String, List<T>> e : commentsPerPath.entrySet()) {
635 21 : String path = e.getKey();
636 21 : List<T> comments = e.getValue();
637 :
638 21 : if (comments == null) {
639 0 : continue;
640 : }
641 :
642 21 : List<T> cleanedUpComments = cleanUpComments(comments);
643 21 : if (!cleanedUpComments.isEmpty()) {
644 20 : cleanedUpCommentMap.put(path, cleanedUpComments);
645 : }
646 21 : }
647 21 : return cleanedUpCommentMap;
648 : }
649 :
650 : private static <T extends com.google.gerrit.extensions.client.Comment> List<T> cleanUpComments(
651 : List<T> comments) {
652 21 : return comments.stream()
653 21 : .filter(Objects::nonNull)
654 21 : .filter(comment -> !Strings.nullToEmpty(comment.message).trim().isEmpty())
655 21 : .collect(toList());
656 : }
657 :
658 : private <T extends com.google.gerrit.extensions.client.Comment> void checkComments(
659 : RevisionResource revision, Map<String, List<T>> commentsPerPath)
660 : throws BadRequestException, PatchListNotAvailableException {
661 21 : logger.atFine().log("checking comments");
662 21 : Set<String> revisionFilePaths = getAffectedFilePaths(revision);
663 21 : for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
664 20 : String path = entry.getKey();
665 20 : PatchSet.Id patchSetId = revision.getPatchSet().id();
666 20 : ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths, patchSetId);
667 :
668 20 : List<T> comments = entry.getValue();
669 20 : for (T comment : comments) {
670 20 : ensureLineIsNonNegative(comment.line, path);
671 20 : ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
672 20 : ensureRangeIsValid(path, comment.range);
673 20 : ensureValidPatchsetLevelComment(path, comment);
674 20 : ensureValidInReplyTo(revision.getNotes(), comment.inReplyTo);
675 20 : }
676 20 : }
677 21 : }
678 :
679 : /**
680 : * Asserts that the draft IDs to publish are valid, i.e. they exist and belong to the current
681 : * user. If the {@code draftHandling} parameter is equal to {@link DraftHandling#PUBLISH}, then
682 : * draft IDs should all correspond to the target revision, otherwise we throw a
683 : * BadRequestException.
684 : */
685 : private void checkDraftIds(
686 : RevisionResource resource, List<String> draftIds, DraftHandling draftHandling)
687 : throws BadRequestException {
688 1 : Map<String, HumanComment> draftsByUuid =
689 1 : commentsUtil.draftByChangeAuthor(resource.getNotes(), resource.getUser().getAccountId())
690 1 : .stream()
691 1 : .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
692 1 : List<String> nonExistingDraftIds =
693 1 : draftIds.stream().filter(id -> !draftsByUuid.containsKey(id)).collect(toList());
694 1 : if (!nonExistingDraftIds.isEmpty()) {
695 1 : throw new BadRequestException("Non-existing draft IDs: " + nonExistingDraftIds);
696 : }
697 1 : if (draftHandling == DraftHandling.PUBLISH_ALL_REVISIONS
698 : || draftHandling == DraftHandling.KEEP) {
699 1 : return;
700 : }
701 1 : List<String> draftsForOtherRevisions =
702 1 : draftIds.stream()
703 1 : .filter(id -> draftsByUuid.get(id).key.patchSetId != resource.getPatchSet().number())
704 1 : .collect(toList());
705 1 : if (!draftsForOtherRevisions.isEmpty()) {
706 1 : throw new BadRequestException(
707 1 : String.format(
708 : "Draft comments for other revisions cannot be published when DraftHandling = PUBLISH."
709 : + " (draft IDs: %s)",
710 : draftsForOtherRevisions));
711 : }
712 0 : }
713 :
714 : private Set<String> getAffectedFilePaths(RevisionResource revision)
715 : throws PatchListNotAvailableException {
716 21 : ObjectId newId = revision.getPatchSet().commitId();
717 21 : DiffSummaryKey key =
718 21 : DiffSummaryKey.fromPatchListKey(
719 21 : PatchListKey.againstDefaultBase(newId, Whitespace.IGNORE_NONE));
720 21 : DiffSummary ds = patchListCache.getDiffSummary(key, revision.getProject());
721 21 : return new HashSet<>(ds.getPaths());
722 : }
723 :
724 : private static void ensurePathRefersToAvailableOrMagicFile(
725 : String path, Set<String> availableFilePaths, PatchSet.Id patchSetId)
726 : throws BadRequestException {
727 20 : if (!availableFilePaths.contains(path) && !Patch.isMagic(path)) {
728 1 : throw new BadRequestException(
729 1 : String.format("file %s not found in revision %s", path, patchSetId));
730 : }
731 20 : }
732 :
733 : private static void ensureLineIsNonNegative(Integer line, String path)
734 : throws BadRequestException {
735 20 : if (line != null && line < 0) {
736 0 : throw new BadRequestException(
737 0 : String.format("negative line number %d not allowed on %s", line, path));
738 : }
739 20 : }
740 :
741 : private static <T extends com.google.gerrit.extensions.client.Comment>
742 : void ensureCommentNotOnMagicFilesOfAutoMerge(String path, T comment)
743 : throws BadRequestException {
744 20 : if (Patch.isMagic(path) && comment.side == Side.PARENT && comment.parent == null) {
745 1 : throw new BadRequestException(String.format("cannot comment on %s on auto-merge", path));
746 : }
747 20 : }
748 :
749 : private static <T extends com.google.gerrit.extensions.client.Comment>
750 : void ensureValidPatchsetLevelComment(String path, T comment) throws BadRequestException {
751 20 : if (path.equals(PATCHSET_LEVEL)
752 : && (comment.side != null || comment.range != null || comment.line != null)) {
753 2 : throw new BadRequestException("Patchset-level comments can't have side, range, or line");
754 : }
755 20 : }
756 :
757 : private void ensureValidInReplyTo(ChangeNotes changeNotes, String inReplyTo)
758 : throws BadRequestException {
759 20 : if (inReplyTo != null
760 3 : && !commentsUtil.getPublishedHumanComment(changeNotes, inReplyTo).isPresent()
761 3 : && !commentsUtil.getRobotComment(changeNotes, inReplyTo).isPresent()) {
762 2 : throw new BadRequestException(
763 2 : String.format("Invalid inReplyTo, comment %s not found", inReplyTo));
764 : }
765 20 : }
766 :
767 : private void checkRobotComments(
768 : RevisionResource revision, Map<String, List<RobotCommentInput>> in)
769 : throws BadRequestException, PatchListNotAvailableException {
770 7 : logger.atFine().log("checking robot comments");
771 7 : for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
772 7 : String commentPath = e.getKey();
773 7 : for (RobotCommentInput c : e.getValue()) {
774 7 : ensureRobotIdIsSet(c.robotId, commentPath);
775 7 : ensureRobotRunIdIsSet(c.robotRunId, commentPath);
776 7 : ensureFixSuggestionsAreAddable(c.fixSuggestions, commentPath);
777 : // Size is validated later, in CommentLimitsValidator.
778 7 : }
779 7 : }
780 7 : checkComments(revision, in);
781 7 : }
782 :
783 : private static void ensureRobotIdIsSet(String robotId, String commentPath)
784 : throws BadRequestException {
785 7 : if (robotId == null) {
786 0 : throw new BadRequestException(
787 0 : String.format("robotId is missing for robot comment on %s", commentPath));
788 : }
789 7 : }
790 :
791 : private static void ensureRobotRunIdIsSet(String robotRunId, String commentPath)
792 : throws BadRequestException {
793 7 : if (robotRunId == null) {
794 0 : throw new BadRequestException(
795 0 : String.format("robotRunId is missing for robot comment on %s", commentPath));
796 : }
797 7 : }
798 :
799 : private static void ensureFixSuggestionsAreAddable(
800 : List<FixSuggestionInfo> fixSuggestionInfos, String commentPath) throws BadRequestException {
801 7 : if (fixSuggestionInfos == null) {
802 6 : return;
803 : }
804 :
805 3 : for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
806 2 : ensureDescriptionIsSet(commentPath, fixSuggestionInfo.description);
807 2 : ensureFixReplacementsAreAddable(commentPath, fixSuggestionInfo.replacements);
808 2 : }
809 3 : }
810 :
811 : private static void ensureDescriptionIsSet(String commentPath, String description)
812 : throws BadRequestException {
813 2 : if (description == null) {
814 1 : throw new BadRequestException(
815 1 : String.format(
816 : "A description is required for the suggested fix of the robot comment on %s",
817 : commentPath));
818 : }
819 2 : }
820 :
821 : private static void ensureFixReplacementsAreAddable(
822 : String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
823 2 : ensureReplacementsArePresent(commentPath, fixReplacementInfos);
824 :
825 2 : for (FixReplacementInfo fixReplacementInfo : fixReplacementInfos) {
826 2 : ensureReplacementPathIsSetAndNotPatchsetLevel(commentPath, fixReplacementInfo.path);
827 2 : ensureRangeIsSet(commentPath, fixReplacementInfo.range);
828 2 : ensureRangeIsValid(commentPath, fixReplacementInfo.range);
829 2 : ensureReplacementStringIsSet(commentPath, fixReplacementInfo.replacement);
830 2 : }
831 :
832 2 : Map<String, List<FixReplacementInfo>> replacementsPerFilePath =
833 2 : fixReplacementInfos.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
834 2 : for (List<FixReplacementInfo> sameFileReplacements : replacementsPerFilePath.values()) {
835 2 : ensureRangesDoNotOverlap(commentPath, sameFileReplacements);
836 2 : }
837 2 : }
838 :
839 : private static void ensureReplacementsArePresent(
840 : String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
841 2 : if (fixReplacementInfos == null || fixReplacementInfos.isEmpty()) {
842 1 : throw new BadRequestException(
843 1 : String.format(
844 : "At least one replacement is "
845 : + "required for the suggested fix of the robot comment on %s",
846 : commentPath));
847 : }
848 2 : }
849 :
850 : private static void ensureReplacementPathIsSetAndNotPatchsetLevel(
851 : String commentPath, String replacementPath) throws BadRequestException {
852 2 : if (replacementPath == null) {
853 1 : throw new BadRequestException(
854 1 : String.format(
855 : "A file path must be given for the replacement of the robot comment on %s",
856 : commentPath));
857 : }
858 2 : if (replacementPath.equals(PATCHSET_LEVEL)) {
859 1 : throw new BadRequestException(
860 1 : String.format(
861 : "A file path must not be %s for the replacement of the robot comment on %s",
862 : PATCHSET_LEVEL, commentPath));
863 : }
864 2 : }
865 :
866 : private static void ensureRangeIsSet(String commentPath, Range range) throws BadRequestException {
867 2 : if (range == null) {
868 1 : throw new BadRequestException(
869 1 : String.format(
870 : "A range must be given for the replacement of the robot comment on %s", commentPath));
871 : }
872 2 : }
873 :
874 : private static void ensureRangeIsValid(String commentPath, Range range)
875 : throws BadRequestException {
876 20 : if (range == null) {
877 20 : return;
878 : }
879 7 : if (!range.isValid()) {
880 2 : throw new BadRequestException(
881 2 : String.format(
882 : "Range (%s:%s - %s:%s) is not valid for the comment on %s",
883 2 : range.startLine,
884 2 : range.startCharacter,
885 2 : range.endLine,
886 2 : range.endCharacter,
887 : commentPath));
888 : }
889 6 : }
890 :
891 : private static void ensureReplacementStringIsSet(String commentPath, String replacement)
892 : throws BadRequestException {
893 2 : if (replacement == null) {
894 1 : throw new BadRequestException(
895 1 : String.format(
896 : "A content for replacement "
897 : + "must be indicated for the replacement of the robot comment on %s",
898 : commentPath));
899 : }
900 2 : }
901 :
902 : private static void ensureRangesDoNotOverlap(
903 : String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
904 2 : List<Range> sortedRanges =
905 2 : fixReplacementInfos.stream()
906 2 : .map(fixReplacementInfo -> fixReplacementInfo.range)
907 2 : .sorted()
908 2 : .collect(toList());
909 :
910 2 : int previousEndLine = 0;
911 2 : int previousOffset = -1;
912 2 : for (Range range : sortedRanges) {
913 2 : if (range.startLine < previousEndLine
914 : || (range.startLine == previousEndLine && range.startCharacter < previousOffset)) {
915 1 : throw new BadRequestException(
916 1 : String.format("Replacements overlap for the robot comment on %s", commentPath));
917 : }
918 2 : previousEndLine = range.endLine;
919 2 : previousOffset = range.endCharacter;
920 2 : }
921 2 : }
922 :
923 : /**
924 : * Used to compare existing {@link HumanComment}-s with {@link CommentInput} comments by copying
925 : * only the fields to compare.
926 : */
927 : @AutoValue
928 20 : abstract static class CommentSetEntry {
929 : private static CommentSetEntry create(
930 : String filename,
931 : int patchSetId,
932 : Integer line,
933 : Side side,
934 : HashCode message,
935 : Comment.Range range) {
936 20 : return new AutoValue_PostReview_CommentSetEntry(
937 : filename, patchSetId, line, side, message, range);
938 : }
939 :
940 : public static CommentSetEntry create(Comment comment) {
941 20 : return create(
942 : comment.key.filename,
943 : comment.key.patchSetId,
944 20 : comment.lineNbr,
945 20 : Side.fromShort(comment.side),
946 20 : Hashing.murmur3_128().hashString(comment.message, UTF_8),
947 : comment.range);
948 : }
949 :
950 : abstract String filename();
951 :
952 : abstract int patchSetId();
953 :
954 : @Nullable
955 : abstract Integer line();
956 :
957 : abstract Side side();
958 :
959 : abstract HashCode message();
960 :
961 : @Nullable
962 : abstract Comment.Range range();
963 : }
964 : }
|