Line data Source code
1 : // Copyright (C) 2022 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.ImmutableList.toImmutableList;
19 : import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
20 : import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
21 : import static java.util.stream.Collectors.joining;
22 : import static java.util.stream.Collectors.toList;
23 : import static java.util.stream.Collectors.toSet;
24 :
25 : import com.google.common.annotations.VisibleForTesting;
26 : import com.google.common.base.Joiner;
27 : import com.google.common.base.Strings;
28 : import com.google.common.collect.ImmutableList;
29 : import com.google.common.collect.Streams;
30 : import com.google.gerrit.entities.Comment;
31 : import com.google.gerrit.entities.FixReplacement;
32 : import com.google.gerrit.entities.FixSuggestion;
33 : import com.google.gerrit.entities.HumanComment;
34 : import com.google.gerrit.entities.LabelType;
35 : import com.google.gerrit.entities.LabelTypes;
36 : import com.google.gerrit.entities.PatchSet;
37 : import com.google.gerrit.entities.PatchSetApproval;
38 : import com.google.gerrit.entities.RobotComment;
39 : import com.google.gerrit.extensions.api.changes.ReviewInput;
40 : import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
41 : import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
42 : import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
43 : import com.google.gerrit.extensions.common.FixReplacementInfo;
44 : import com.google.gerrit.extensions.common.FixSuggestionInfo;
45 : import com.google.gerrit.extensions.restapi.ResourceConflictException;
46 : import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
47 : import com.google.gerrit.extensions.restapi.Url;
48 : import com.google.gerrit.extensions.validators.CommentForValidation;
49 : import com.google.gerrit.extensions.validators.CommentValidationContext;
50 : import com.google.gerrit.extensions.validators.CommentValidationFailure;
51 : import com.google.gerrit.extensions.validators.CommentValidator;
52 : import com.google.gerrit.server.ChangeMessagesUtil;
53 : import com.google.gerrit.server.ChangeUtil;
54 : import com.google.gerrit.server.CommentsUtil;
55 : import com.google.gerrit.server.IdentifiedUser;
56 : import com.google.gerrit.server.PatchSetUtil;
57 : import com.google.gerrit.server.PublishCommentUtil;
58 : import com.google.gerrit.server.approval.ApprovalsUtil;
59 : import com.google.gerrit.server.change.EmailReviewComments;
60 : import com.google.gerrit.server.change.NotifyResolver;
61 : import com.google.gerrit.server.config.GerritServerConfig;
62 : import com.google.gerrit.server.extensions.events.CommentAdded;
63 : import com.google.gerrit.server.logging.Metadata;
64 : import com.google.gerrit.server.logging.TraceContext;
65 : import com.google.gerrit.server.notedb.ChangeNotes;
66 : import com.google.gerrit.server.notedb.ChangeUpdate;
67 : import com.google.gerrit.server.plugincontext.PluginSetContext;
68 : import com.google.gerrit.server.project.ProjectState;
69 : import com.google.gerrit.server.restapi.change.PostReview.CommentSetEntry;
70 : import com.google.gerrit.server.update.BatchUpdateOp;
71 : import com.google.gerrit.server.update.ChangeContext;
72 : import com.google.gerrit.server.update.CommentsRejectedException;
73 : import com.google.gerrit.server.update.PostUpdateContext;
74 : import com.google.gerrit.server.util.LabelVote;
75 : import com.google.inject.Inject;
76 : import com.google.inject.assistedinject.Assisted;
77 : import java.io.IOException;
78 : import java.sql.Timestamp;
79 : import java.util.ArrayList;
80 : import java.util.Collection;
81 : import java.util.Collections;
82 : import java.util.HashMap;
83 : import java.util.List;
84 : import java.util.Map;
85 : import java.util.Optional;
86 : import java.util.Set;
87 : import java.util.stream.Collectors;
88 : import java.util.stream.Stream;
89 : import org.eclipse.jgit.lib.Config;
90 :
91 : public class PostReviewOp implements BatchUpdateOp {
92 : interface Factory {
93 : PostReviewOp create(ProjectState projectState, PatchSet.Id psId, ReviewInput in);
94 : }
95 :
96 : @VisibleForTesting
97 : public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
98 :
99 : private final ApprovalsUtil approvalsUtil;
100 : private final ChangeMessagesUtil cmUtil;
101 : private final CommentsUtil commentsUtil;
102 : private final PublishCommentUtil publishCommentUtil;
103 : private final PatchSetUtil psUtil;
104 : private final EmailReviewComments.Factory email;
105 : private final CommentAdded commentAdded;
106 : private final PluginSetContext<CommentValidator> commentValidators;
107 : private final PluginSetContext<OnPostReview> onPostReviews;
108 :
109 : private final ProjectState projectState;
110 : private final PatchSet.Id psId;
111 : private final ReviewInput in;
112 : private final boolean publishPatchSetLevelComment;
113 :
114 : private IdentifiedUser user;
115 : private ChangeNotes notes;
116 : private PatchSet ps;
117 : private String mailMessage;
118 65 : private List<Comment> comments = new ArrayList<>();
119 65 : private List<LabelVote> labelDelta = new ArrayList<>();
120 65 : private Map<String, Short> approvals = new HashMap<>();
121 65 : private Map<String, Short> oldApprovals = new HashMap<>();
122 :
123 : @Inject
124 : PostReviewOp(
125 : @GerritServerConfig Config gerritConfig,
126 : ApprovalsUtil approvalsUtil,
127 : ChangeMessagesUtil cmUtil,
128 : CommentsUtil commentsUtil,
129 : PublishCommentUtil publishCommentUtil,
130 : PatchSetUtil psUtil,
131 : EmailReviewComments.Factory email,
132 : CommentAdded commentAdded,
133 : PluginSetContext<CommentValidator> commentValidators,
134 : PluginSetContext<OnPostReview> onPostReviews,
135 : @Assisted ProjectState projectState,
136 : @Assisted PatchSet.Id psId,
137 65 : @Assisted ReviewInput in) {
138 65 : this.approvalsUtil = approvalsUtil;
139 65 : this.publishCommentUtil = publishCommentUtil;
140 65 : this.psUtil = psUtil;
141 65 : this.cmUtil = cmUtil;
142 65 : this.commentsUtil = commentsUtil;
143 65 : this.email = email;
144 65 : this.commentAdded = commentAdded;
145 65 : this.commentValidators = commentValidators;
146 65 : this.onPostReviews = onPostReviews;
147 65 : this.publishPatchSetLevelComment =
148 65 : gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
149 :
150 65 : this.projectState = projectState;
151 65 : this.psId = psId;
152 65 : this.in = in;
153 65 : }
154 :
155 : @Override
156 : public boolean updateChange(ChangeContext ctx)
157 : throws ResourceConflictException, UnprocessableEntityException, IOException,
158 : CommentsRejectedException {
159 65 : user = ctx.getIdentifiedUser();
160 65 : notes = ctx.getNotes();
161 65 : ps = psUtil.get(ctx.getNotes(), psId);
162 : List<RobotComment> newRobotComments =
163 65 : in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
164 65 : boolean dirty = false;
165 65 : try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
166 65 : dirty |= insertComments(ctx, newRobotComments);
167 : }
168 65 : try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
169 65 : dirty |= insertRobotComments(ctx, newRobotComments);
170 : }
171 65 : try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
172 65 : dirty |= updateLabels(projectState, ctx);
173 : }
174 65 : try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
175 65 : dirty |= insertMessage(ctx);
176 : }
177 65 : return dirty;
178 : }
179 :
180 : @Override
181 : public void postUpdate(PostUpdateContext ctx) {
182 65 : if (mailMessage == null) {
183 24 : return;
184 : }
185 65 : NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
186 65 : if (notify.shouldNotify()) {
187 65 : email
188 65 : .create(ctx, ps, notes.getMetaId(), mailMessage, comments, in.message, labelDelta)
189 65 : .sendAsync();
190 : }
191 65 : String comment = mailMessage;
192 65 : if (publishPatchSetLevelComment) {
193 : // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
194 : // added event. For backwards compatibility, patchset level comment has a higher priority
195 : // than change message and should be used as comment in comment added event.
196 65 : if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
197 2 : List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
198 2 : if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
199 2 : CommentInput firstComment = patchSetLevelComments.get(0);
200 2 : if (!Strings.isNullOrEmpty(firstComment.message)) {
201 2 : comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
202 : }
203 : }
204 : }
205 : }
206 65 : commentAdded.fire(
207 65 : ctx.getChangeData(notes),
208 : ps,
209 65 : user.state(),
210 : comment,
211 : approvals,
212 : oldApprovals,
213 65 : ctx.getWhen());
214 65 : }
215 :
216 : /**
217 : * Publishes draft and input comments. Input comments are those passed as input in the request
218 : * body.
219 : *
220 : * @param ctx context for performing the change update.
221 : * @param newRobotComments robot comments. Used only for validation in this method.
222 : * @return true if any input comments where published.
223 : */
224 : private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
225 : throws CommentsRejectedException {
226 65 : Map<String, List<CommentInput>> inputComments = in.comments;
227 65 : if (inputComments == null) {
228 61 : inputComments = Collections.emptyMap();
229 : }
230 :
231 : // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
232 65 : Map<String, HumanComment> drafts = new HashMap<>();
233 :
234 65 : if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
235 : drafts =
236 20 : in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
237 2 : ? changeDrafts(ctx)
238 20 : : patchSetDrafts(ctx);
239 : }
240 :
241 : // Existing published comments
242 : Set<CommentSetEntry> existingComments =
243 65 : in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
244 :
245 : // Input comments should be deduplicated from existing drafts
246 65 : List<HumanComment> inputCommentsToPublish =
247 65 : resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
248 :
249 65 : switch (in.drafts) {
250 : case PUBLISH:
251 : case PUBLISH_ALL_REVISIONS:
252 : Collection<HumanComment> filteredDrafts =
253 9 : in.draftIdsToPublish == null
254 9 : ? drafts.values()
255 1 : : drafts.values().stream()
256 1 : .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
257 9 : .collect(Collectors.toList());
258 :
259 9 : validateComments(
260 : ctx,
261 9 : Streams.concat(
262 9 : drafts.values().stream(),
263 9 : inputCommentsToPublish.stream(),
264 9 : newRobotComments.stream()));
265 9 : publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
266 9 : comments.addAll(drafts.values());
267 9 : break;
268 : case KEEP:
269 64 : validateComments(
270 64 : ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
271 : break;
272 : }
273 65 : commentsUtil.putHumanComments(
274 65 : ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
275 65 : comments.addAll(inputCommentsToPublish);
276 65 : return !inputCommentsToPublish.isEmpty();
277 : }
278 :
279 : /**
280 : * Returns the subset of {@code inputComments} that do not have a matching comment (with same id)
281 : * neither in {@code existingComments} nor in {@code drafts}.
282 : *
283 : * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
284 : * removed.
285 : *
286 : * @param inputComments new comments provided as {@link CommentInput} entries in the API.
287 : * @param existingComments existing published comments in the database.
288 : * @param drafts existing draft comments in the database. This map can be modified.
289 : */
290 : private List<HumanComment> resolveInputCommentsAndDrafts(
291 : Map<String, List<CommentInput>> inputComments,
292 : Set<CommentSetEntry> existingComments,
293 : Map<String, HumanComment> drafts,
294 : ChangeContext ctx) {
295 65 : List<HumanComment> inputCommentsToPublish = new ArrayList<>();
296 65 : for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
297 17 : String path = entry.getKey();
298 17 : for (CommentInput inputComment : entry.getValue()) {
299 17 : HumanComment comment = drafts.remove(Url.decode(inputComment.id));
300 17 : if (comment == null) {
301 17 : String parent = Url.decode(inputComment.inReplyTo);
302 17 : comment =
303 17 : commentsUtil.newHumanComment(
304 17 : ctx.getNotes(),
305 17 : ctx.getUser(),
306 17 : ctx.getWhen(),
307 : path,
308 : psId,
309 17 : inputComment.side(),
310 : inputComment.message,
311 : inputComment.unresolved,
312 : parent);
313 17 : } else {
314 : // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
315 1 : comment.writtenOn = Timestamp.from(ctx.getWhen());
316 1 : comment.side = inputComment.side();
317 1 : comment.message = inputComment.message;
318 : }
319 :
320 17 : commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
321 17 : comment.setLineNbrAndRange(inputComment.line, inputComment.range);
322 17 : comment.tag = in.tag;
323 :
324 17 : if (existingComments.contains(CommentSetEntry.create(comment))) {
325 1 : continue;
326 : }
327 17 : inputCommentsToPublish.add(comment);
328 17 : }
329 17 : }
330 65 : return inputCommentsToPublish;
331 : }
332 :
333 : /**
334 : * Validates all comments and the change message in a single call to fulfill the interface
335 : * contract of {@link CommentValidator#validateComments(CommentValidationContext, ImmutableList)}.
336 : */
337 : private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
338 : throws CommentsRejectedException {
339 65 : CommentValidationContext commentValidationCtx =
340 65 : CommentValidationContext.create(
341 65 : ctx.getChange().getChangeId(),
342 65 : ctx.getChange().getProject().get(),
343 65 : ctx.getChange().getDest().branch());
344 65 : String changeMessage = Strings.nullToEmpty(in.message).trim();
345 65 : ImmutableList<CommentForValidation> draftsForValidation =
346 65 : Stream.concat(
347 65 : comments.map(
348 : comment ->
349 20 : CommentForValidation.create(
350 20 : comment instanceof RobotComment
351 7 : ? CommentForValidation.CommentSource.ROBOT
352 19 : : CommentForValidation.CommentSource.HUMAN,
353 20 : comment.lineNbr > 0
354 17 : ? CommentForValidation.CommentType.INLINE_COMMENT
355 20 : : CommentForValidation.CommentType.FILE_COMMENT,
356 : comment.message,
357 20 : comment.getApproximateSize())),
358 65 : Stream.of(
359 65 : CommentForValidation.create(
360 : CommentForValidation.CommentSource.HUMAN,
361 : CommentForValidation.CommentType.CHANGE_MESSAGE,
362 : changeMessage,
363 65 : changeMessage.length())))
364 65 : .collect(toImmutableList());
365 65 : ImmutableList<CommentValidationFailure> draftValidationFailures =
366 65 : PublishCommentUtil.findInvalidComments(
367 : commentValidationCtx, commentValidators, draftsForValidation);
368 65 : if (!draftValidationFailures.isEmpty()) {
369 3 : throw new CommentsRejectedException(draftValidationFailures);
370 : }
371 65 : }
372 :
373 : private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
374 65 : if (in.robotComments == null) {
375 64 : return false;
376 : }
377 7 : commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
378 7 : comments.addAll(newRobotComments);
379 7 : return !newRobotComments.isEmpty();
380 : }
381 :
382 : private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
383 7 : List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
384 :
385 : Set<CommentSetEntry> existingIds =
386 7 : in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
387 :
388 7 : for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
389 7 : String path = ent.getKey();
390 7 : for (RobotCommentInput c : ent.getValue()) {
391 7 : RobotComment e = createRobotCommentFromInput(ctx, path, c);
392 7 : if (existingIds.contains(CommentSetEntry.create(e))) {
393 0 : continue;
394 : }
395 7 : toAdd.add(e);
396 7 : }
397 7 : }
398 7 : return toAdd;
399 : }
400 :
401 : private RobotComment createRobotCommentFromInput(
402 : ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
403 7 : RobotComment robotComment =
404 7 : commentsUtil.newRobotComment(
405 : ctx,
406 : path,
407 : psId,
408 7 : robotCommentInput.side(),
409 : robotCommentInput.message,
410 : robotCommentInput.robotId,
411 : robotCommentInput.robotRunId);
412 7 : robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
413 7 : robotComment.url = robotCommentInput.url;
414 7 : robotComment.properties = robotCommentInput.properties;
415 7 : robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
416 7 : robotComment.tag = in.tag;
417 7 : commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
418 7 : robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
419 7 : return robotComment;
420 : }
421 :
422 : private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
423 : List<FixSuggestionInfo> fixSuggestionInfos) {
424 7 : if (fixSuggestionInfos == null) {
425 6 : return ImmutableList.of();
426 : }
427 :
428 3 : ImmutableList.Builder<FixSuggestion> fixSuggestions =
429 3 : ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
430 3 : for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
431 2 : fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
432 2 : }
433 3 : return fixSuggestions.build();
434 : }
435 :
436 : private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
437 2 : List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
438 2 : String fixId = ChangeUtil.messageUuid();
439 2 : return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
440 : }
441 :
442 : private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
443 2 : return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
444 : }
445 :
446 : private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
447 2 : Comment.Range range = new Comment.Range(fixReplacementInfo.range);
448 2 : return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
449 : }
450 :
451 : private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
452 2 : return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
453 2 : .map(CommentSetEntry::create)
454 2 : .collect(toSet());
455 : }
456 :
457 : private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
458 0 : return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
459 0 : .map(CommentSetEntry::create)
460 0 : .collect(toSet());
461 : }
462 :
463 : private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
464 2 : return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
465 2 : .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
466 : }
467 :
468 : private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
469 20 : return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
470 20 : .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
471 : }
472 :
473 : private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
474 65 : Map<String, Short> labels = new HashMap<>();
475 65 : for (PatchSetApproval psa : patchsetApprovals) {
476 28 : labels.put(psa.label(), psa.value());
477 28 : }
478 65 : return labels;
479 : }
480 :
481 : private Map<String, Short> getAllApprovals(
482 : LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
483 65 : Map<String, Short> allApprovals = new HashMap<>();
484 65 : for (LabelType lt : labelTypes.getLabelTypes()) {
485 65 : allApprovals.put(lt.getName(), (short) 0);
486 65 : }
487 : // set approvals to existing votes
488 65 : if (current != null) {
489 65 : allApprovals.putAll(current);
490 : }
491 : // set approvals to new votes
492 65 : if (input != null) {
493 65 : allApprovals.putAll(input);
494 : }
495 65 : return allApprovals;
496 : }
497 :
498 : private Map<String, Short> getPreviousApprovals(
499 : Map<String, Short> allApprovals, Map<String, Short> current) {
500 65 : Map<String, Short> previous = new HashMap<>();
501 65 : for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
502 : // assume vote is 0 if there is no vote
503 65 : if (!current.containsKey(approval.getKey())) {
504 65 : previous.put(approval.getKey(), (short) 0);
505 : } else {
506 28 : previous.put(approval.getKey(), current.get(approval.getKey()));
507 : }
508 65 : }
509 65 : return previous;
510 : }
511 :
512 : private boolean isReviewer(ChangeContext ctx) {
513 23 : return approvalsUtil
514 23 : .getReviewers(ctx.getNotes())
515 23 : .byState(REVIEWER)
516 23 : .contains(ctx.getAccountId());
517 : }
518 :
519 : private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
520 : throws ResourceConflictException {
521 65 : Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
522 :
523 : // If no labels were modified and change is closed, abort early.
524 : // This avoids trying to record a modified label caused by a user
525 : // losing access to a label after the change was submitted.
526 65 : if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
527 2 : return false;
528 : }
529 :
530 65 : List<PatchSetApproval> del = new ArrayList<>();
531 65 : List<PatchSetApproval> ups = new ArrayList<>();
532 65 : Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
533 65 : LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
534 65 : Map<String, Short> allApprovals =
535 65 : getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
536 65 : Map<String, Short> previous =
537 65 : getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
538 :
539 65 : ChangeUpdate update = ctx.getUpdate(psId);
540 65 : for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
541 65 : String name = ent.getKey();
542 65 : LabelType lt =
543 : labelTypes
544 65 : .byLabel(name)
545 65 : .orElseThrow(() -> new IllegalStateException("no label config for " + name));
546 :
547 65 : PatchSetApproval c = current.remove(lt.getName());
548 65 : String normName = lt.getName();
549 65 : approvals.put(normName, (short) 0);
550 65 : if (ent.getValue() == null || ent.getValue() == 0) {
551 : // User requested delete of this label.
552 31 : oldApprovals.put(normName, null);
553 31 : if (c != null) {
554 9 : if (c.value() != 0) {
555 9 : addLabelDelta(normName, (short) 0);
556 9 : oldApprovals.put(normName, previous.get(normName));
557 : }
558 9 : del.add(c);
559 9 : update.putApproval(normName, (short) 0);
560 : }
561 : // Only allow voting again if the vote is copied over from a past patch-set, or the
562 : // values are different.
563 58 : } else if (c != null
564 28 : && (c.value() != ent.getValue()
565 26 : || (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
566 14 : PatchSetApproval.Builder b =
567 14 : c.toBuilder()
568 14 : .value(ent.getValue())
569 14 : .granted(ctx.getWhen())
570 14 : .tag(Optional.ofNullable(in.tag));
571 14 : ctx.getUser().updateRealAccountId(b::realAccountId);
572 14 : c = b.build();
573 14 : ups.add(c);
574 14 : addLabelDelta(normName, c.value());
575 14 : oldApprovals.put(normName, previous.get(normName));
576 14 : approvals.put(normName, c.value());
577 14 : update.putApproval(normName, ent.getValue());
578 58 : } else if (c != null && c.value() == ent.getValue()) {
579 25 : current.put(normName, c);
580 25 : oldApprovals.put(normName, null);
581 25 : approvals.put(normName, c.value());
582 58 : } else if (c == null) {
583 58 : c =
584 58 : ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
585 58 : .tag(Optional.ofNullable(in.tag))
586 58 : .granted(ctx.getWhen())
587 58 : .build();
588 58 : ups.add(c);
589 58 : addLabelDelta(normName, c.value());
590 58 : oldApprovals.put(normName, previous.get(normName));
591 58 : approvals.put(normName, c.value());
592 58 : update.putReviewer(user.getAccountId(), REVIEWER);
593 58 : update.putApproval(normName, ent.getValue());
594 : }
595 65 : }
596 :
597 65 : validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
598 :
599 : // Return early if user is not a reviewer and not posting any labels.
600 : // This allows us to preserve their CC status.
601 65 : if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
602 23 : return false;
603 : }
604 :
605 58 : return !del.isEmpty() || !ups.isEmpty();
606 : }
607 :
608 : /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
609 : private boolean isApprovalCopiedOver(PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
610 20 : return !changeNotes.getApprovals().onlyNonCopied()
611 20 : .get(changeNotes.getChange().currentPatchSetId()).stream()
612 20 : .anyMatch(p -> p.equals(patchSetApproval));
613 : }
614 :
615 : private void validatePostSubmitLabels(
616 : ChangeContext ctx,
617 : LabelTypes labelTypes,
618 : Map<String, Short> previous,
619 : List<PatchSetApproval> ups,
620 : List<PatchSetApproval> del)
621 : throws ResourceConflictException {
622 65 : if (ctx.getChange().isNew()) {
623 65 : return; // Not closed, nothing to validate.
624 7 : } else if (del.isEmpty() && ups.isEmpty()) {
625 5 : return; // No new votes.
626 3 : } else if (!ctx.getChange().isMerged()) {
627 1 : throw new ResourceConflictException("change is closed");
628 : }
629 :
630 : // Disallow reducing votes on any labels post-submit. This assumes the
631 : // high values were broadly necessary to submit, so reducing them would
632 : // make it possible to take a merged change and make it no longer
633 : // submittable.
634 3 : List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
635 3 : List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
636 :
637 3 : for (PatchSetApproval psa : del) {
638 1 : LabelType lt =
639 : labelTypes
640 1 : .byLabel(psa.label())
641 1 : .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
642 1 : String normName = lt.getName();
643 1 : if (!lt.isAllowPostSubmit()) {
644 0 : disallowed.add(normName);
645 : }
646 1 : Short prev = previous.get(normName);
647 1 : if (prev != null && prev != 0) {
648 1 : reduced.add(psa);
649 : }
650 1 : }
651 :
652 3 : for (PatchSetApproval psa : ups) {
653 3 : LabelType lt =
654 : labelTypes
655 3 : .byLabel(psa.label())
656 3 : .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
657 3 : String normName = lt.getName();
658 3 : if (!lt.isAllowPostSubmit()) {
659 1 : disallowed.add(normName);
660 : }
661 3 : Short prev = previous.get(normName);
662 3 : if (prev == null) {
663 0 : continue;
664 : }
665 3 : if (prev > psa.value()) {
666 1 : reduced.add(psa);
667 : }
668 : // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
669 3 : }
670 :
671 3 : if (!disallowed.isEmpty()) {
672 1 : throw new ResourceConflictException(
673 : "Voting on labels disallowed after submit: "
674 1 : + disallowed.stream().distinct().sorted().collect(joining(", ")));
675 : }
676 3 : if (!reduced.isEmpty()) {
677 1 : throw new ResourceConflictException(
678 : "Cannot reduce vote on labels for closed change: "
679 1 : + reduced.stream()
680 1 : .map(PatchSetApproval::label)
681 1 : .distinct()
682 1 : .sorted()
683 1 : .collect(joining(", ")));
684 : }
685 3 : }
686 :
687 : private Map<String, PatchSetApproval> scanLabels(
688 : ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
689 65 : LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
690 65 : Map<String, PatchSetApproval> current = new HashMap<>();
691 :
692 : for (PatchSetApproval a :
693 65 : approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
694 28 : if (a.isLegacySubmit()) {
695 13 : continue;
696 : }
697 :
698 28 : Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
699 28 : if (lt.isPresent()) {
700 28 : current.put(lt.get().getName(), a);
701 : } else {
702 0 : del.add(a);
703 : }
704 28 : }
705 65 : return current;
706 : }
707 :
708 : private boolean insertMessage(ChangeContext ctx) {
709 65 : String msg = Strings.nullToEmpty(in.message).trim();
710 :
711 65 : StringBuilder buf = new StringBuilder();
712 65 : for (LabelVote d : labelDelta) {
713 58 : buf.append(" ").append(d.format());
714 58 : }
715 65 : if (comments.size() == 1) {
716 18 : buf.append("\n\n(1 comment)");
717 62 : } else if (comments.size() > 1) {
718 5 : buf.append(String.format("\n\n(%d comments)", comments.size()));
719 : }
720 65 : if (!msg.isEmpty()) {
721 : // Message was already validated when validating comments, since validators need to see
722 : // everything in a single call.
723 22 : buf.append("\n\n").append(msg);
724 61 : } else if (in.ready) {
725 3 : buf.append("\n\n" + START_REVIEW_MESSAGE);
726 : }
727 :
728 65 : List<String> pluginMessages = new ArrayList<>();
729 65 : onPostReviews.runEach(
730 : onPostReview ->
731 1 : onPostReview
732 1 : .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
733 1 : .ifPresent(
734 : pluginMessage ->
735 1 : pluginMessages.add(
736 1 : !pluginMessage.endsWith("\n") ? pluginMessage + "\n" : pluginMessage)));
737 65 : if (!pluginMessages.isEmpty()) {
738 1 : buf.append("\n\n");
739 1 : buf.append(Joiner.on("\n").join(pluginMessages));
740 : }
741 :
742 65 : if (buf.length() == 0) {
743 24 : return false;
744 : }
745 :
746 65 : mailMessage =
747 65 : cmUtil.setChangeMessage(ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
748 65 : return true;
749 : }
750 :
751 : private void addLabelDelta(String name, short value) {
752 58 : labelDelta.add(LabelVote.create(name, value));
753 58 : }
754 :
755 : private TraceContext.TraceTimer newTimer(String method) {
756 65 : return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
757 : }
758 : }
|