Line data Source code
1 : // Copyright (C) 2020 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.collect.ImmutableSet.toImmutableSet;
18 :
19 : import com.google.common.collect.ImmutableSet;
20 : import com.google.common.collect.Sets;
21 : import com.google.common.collect.Sets.SetView;
22 : import com.google.common.flogger.FluentLogger;
23 : import com.google.gerrit.entities.Account;
24 : import com.google.gerrit.entities.AttentionSetUpdate;
25 : import com.google.gerrit.entities.HumanComment;
26 : import com.google.gerrit.entities.PatchSet;
27 : import com.google.gerrit.extensions.api.changes.AttentionSetInput;
28 : import com.google.gerrit.extensions.api.changes.ReviewInput;
29 : import com.google.gerrit.extensions.restapi.AuthException;
30 : import com.google.gerrit.extensions.restapi.BadRequestException;
31 : import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
32 : import com.google.gerrit.server.CommentsUtil;
33 : import com.google.gerrit.server.CurrentUser;
34 : import com.google.gerrit.server.account.AccountResolver;
35 : import com.google.gerrit.server.account.ServiceUserClassifier;
36 : import com.google.gerrit.server.approval.ApprovalsUtil;
37 : import com.google.gerrit.server.change.AddToAttentionSetOp;
38 : import com.google.gerrit.server.change.AttentionSetUnchangedOp;
39 : import com.google.gerrit.server.change.CommentThread;
40 : import com.google.gerrit.server.change.CommentThreads;
41 : import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
42 : import com.google.gerrit.server.notedb.ChangeNotes;
43 : import com.google.gerrit.server.notedb.ChangeUpdate;
44 : import com.google.gerrit.server.permissions.ChangePermission;
45 : import com.google.gerrit.server.permissions.PermissionBackend;
46 : import com.google.gerrit.server.permissions.PermissionBackendException;
47 : import com.google.gerrit.server.update.BatchUpdate;
48 : import com.google.gerrit.server.util.AttentionSetUtil;
49 : import com.google.gerrit.server.util.time.TimeUtil;
50 : import com.google.inject.Inject;
51 : import java.io.IOException;
52 : import java.util.ArrayList;
53 : import java.util.Collection;
54 : import java.util.HashSet;
55 : import java.util.List;
56 : import java.util.Set;
57 : import java.util.stream.Collectors;
58 : import java.util.stream.Stream;
59 : import org.eclipse.jgit.errors.ConfigInvalidException;
60 :
61 : /**
62 : * This class is used to update the attention set when performing a review or replying on a change.
63 : */
64 : public class ReplyAttentionSetUpdates {
65 145 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
66 :
67 : private final PermissionBackend permissionBackend;
68 : private final AddToAttentionSetOp.Factory addToAttentionSetOpFactory;
69 : private final RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory;
70 : private final ApprovalsUtil approvalsUtil;
71 : private final AccountResolver accountResolver;
72 : private final ServiceUserClassifier serviceUserClassifier;
73 : private final CommentsUtil commentsUtil;
74 :
75 : @Inject
76 : ReplyAttentionSetUpdates(
77 : PermissionBackend permissionBackend,
78 : AddToAttentionSetOp.Factory addToAttentionSetOpFactory,
79 : RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory,
80 : ApprovalsUtil approvalsUtil,
81 : AccountResolver accountResolver,
82 : ServiceUserClassifier serviceUserClassifier,
83 145 : CommentsUtil commentsUtil) {
84 145 : this.permissionBackend = permissionBackend;
85 145 : this.addToAttentionSetOpFactory = addToAttentionSetOpFactory;
86 145 : this.removeFromAttentionSetOpFactory = removeFromAttentionSetOpFactory;
87 145 : this.approvalsUtil = approvalsUtil;
88 145 : this.accountResolver = accountResolver;
89 145 : this.serviceUserClassifier = serviceUserClassifier;
90 145 : this.commentsUtil = commentsUtil;
91 145 : }
92 :
93 : /** Adjusts the attention set but only based on the automatic rules. */
94 : public void processAutomaticAttentionSetRulesOnReply(
95 : BatchUpdate bu,
96 : ChangeNotes changeNotes,
97 : boolean readyForReview,
98 : CurrentUser currentUser,
99 : List<HumanComment> commentsToBePublished) {
100 4 : if (serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
101 0 : return;
102 : }
103 4 : processRules(
104 : bu,
105 : changeNotes,
106 : readyForReview,
107 : currentUser,
108 4 : commentsToBePublished.stream().collect(toImmutableSet()));
109 4 : }
110 :
111 : /**
112 : * Adjusts the attention set by adding and removing users. If the same user should be added and
113 : * removed or added/removed twice, the user will only be added/removed once, based on first
114 : * addition/removal.
115 : */
116 : public void updateAttentionSet(
117 : BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input, CurrentUser currentUser)
118 : throws BadRequestException, IOException, PermissionBackendException,
119 : UnprocessableEntityException, ConfigInvalidException {
120 65 : processManualUpdates(bu, changeNotes, input);
121 65 : if (input.ignoreAutomaticAttentionSetRules) {
122 :
123 : // If we ignore automatic attention set rules it means we need to pass this information to
124 : // ChangeUpdate. Also, we should stop all other attention set updates that are part of
125 : // this method and happen in PostReview.
126 2 : bu.addOp(changeNotes.getChangeId(), new AttentionSetUnchangedOp());
127 2 : return;
128 : }
129 65 : boolean isReadyForReview = isReadyForReview(changeNotes, input);
130 :
131 65 : if (isReadyForReview && serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
132 1 : botsWithNegativeLabelsAddOwnerAndUploader(bu, changeNotes, input);
133 1 : return;
134 : }
135 :
136 65 : processRules(
137 : bu,
138 : changeNotes,
139 : isReadyForReview,
140 : currentUser,
141 65 : getAllNewComments(changeNotes, input, currentUser));
142 65 : }
143 :
144 : private ImmutableSet<HumanComment> getAllNewComments(
145 : ChangeNotes changeNotes, ReviewInput input, CurrentUser currentUser) {
146 65 : Set<HumanComment> newComments = new HashSet<>();
147 65 : if (input.comments != null) {
148 : for (ReviewInput.CommentInput commentInput :
149 18 : input.comments.values().stream().flatMap(x -> x.stream()).collect(Collectors.toList())) {
150 17 : newComments.add(
151 17 : commentsUtil.newHumanComment(
152 : changeNotes,
153 : currentUser,
154 17 : TimeUtil.now(),
155 : commentInput.path,
156 17 : commentInput.patchSet == null
157 17 : ? changeNotes.getChange().currentPatchSetId()
158 17 : : PatchSet.id(changeNotes.getChange().getId(), commentInput.patchSet),
159 17 : commentInput.side(),
160 : commentInput.message,
161 : commentInput.unresolved,
162 : commentInput.inReplyTo));
163 17 : }
164 : }
165 65 : List<HumanComment> drafts = new ArrayList<>();
166 65 : if (input.drafts == ReviewInput.DraftHandling.PUBLISH) {
167 7 : drafts =
168 7 : commentsUtil.draftByPatchSetAuthor(
169 7 : changeNotes.getChange().currentPatchSetId(), currentUser.getAccountId(), changeNotes);
170 : }
171 65 : if (input.drafts == ReviewInput.DraftHandling.PUBLISH_ALL_REVISIONS) {
172 2 : drafts = commentsUtil.draftByChangeAuthor(changeNotes, currentUser.getAccountId());
173 : }
174 65 : return Stream.concat(newComments.stream(), drafts.stream()).collect(toImmutableSet());
175 : }
176 :
177 : /**
178 : * Process the automatic rules of the attention set. All of the automatic rules except
179 : * adding/removing reviewers and entering/exiting WIP state are done here, and the rest are done
180 : * in {@link ChangeUpdate}
181 : */
182 : private void processRules(
183 : BatchUpdate bu,
184 : ChangeNotes changeNotes,
185 : boolean readyForReview,
186 : CurrentUser currentUser,
187 : ImmutableSet<HumanComment> allNewComments) {
188 : // Replying removes the publishing user from the attention set.
189 65 : removeFromAttentionSet(bu, changeNotes, currentUser.getAccountId(), "removed on reply", false);
190 :
191 65 : Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
192 65 : Account.Id owner = changeNotes.getChange().getOwner();
193 :
194 : // The rest of the conditions only apply if the change is open.
195 65 : if (changeNotes.getChange().getStatus().isClosed()) {
196 : // We still add the owner if a new comment thread was created, on closed changes.
197 10 : if (allNewComments.stream().anyMatch(c -> c.parentUuid == null)) {
198 1 : addToAttentionSet(bu, changeNotes, owner, "A new comment thread was created", false);
199 : }
200 10 : return;
201 : }
202 : // The rest of the conditions only apply if the change is ready for review.
203 65 : if (!readyForReview) {
204 14 : return;
205 : }
206 :
207 65 : if (!currentUser.getAccountId().equals(owner)) {
208 29 : addToAttentionSet(bu, changeNotes, owner, "Someone else replied on the change", false);
209 : }
210 65 : if (!owner.equals(uploader) && !currentUser.getAccountId().equals(uploader)) {
211 1 : addToAttentionSet(bu, changeNotes, uploader, "Someone else replied on the change", false);
212 : }
213 :
214 65 : addAllAuthorsOfCommentThreads(bu, changeNotes, allNewComments);
215 65 : }
216 :
217 : /** Adds all authors of all comment threads that received a reply during this update */
218 : private void addAllAuthorsOfCommentThreads(
219 : BatchUpdate bu, ChangeNotes changeNotes, ImmutableSet<HumanComment> allNewComments) {
220 65 : List<HumanComment> publishedComments = commentsUtil.publishedHumanCommentsByChange(changeNotes);
221 65 : ImmutableSet<CommentThread<HumanComment>> repliedToCommentThreads =
222 65 : CommentThreads.forComments(publishedComments).getThreadsForChildren(allNewComments);
223 :
224 65 : ImmutableSet<Account.Id> repliedToUsers =
225 65 : repliedToCommentThreads.stream()
226 65 : .map(CommentThread::comments)
227 65 : .flatMap(Collection::stream)
228 65 : .map(comment -> comment.author.getId())
229 65 : .collect(toImmutableSet());
230 65 : ImmutableSet<Account.Id> possibleUsersToAdd = approvalsUtil.getReviewers(changeNotes).all();
231 65 : SetView<Account.Id> usersToAdd = Sets.intersection(possibleUsersToAdd, repliedToUsers);
232 :
233 65 : for (Account.Id user : usersToAdd) {
234 3 : addToAttentionSet(
235 : bu, changeNotes, user, "Someone else replied on a comment you posted", false);
236 3 : }
237 65 : }
238 :
239 : /** Process the manual updates of the attention set. */
240 : private void processManualUpdates(BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input)
241 : throws BadRequestException, IOException, PermissionBackendException,
242 : UnprocessableEntityException, ConfigInvalidException {
243 65 : Set<Account.Id> accountsChangedInCommit = new HashSet<>();
244 : // If we specify a user to remove, and the user is in the attention set, we remove it.
245 65 : if (input.removeFromAttentionSet != null) {
246 1 : for (AttentionSetInput remove : input.removeFromAttentionSet) {
247 1 : removeFromAttentionSet(bu, changeNotes, remove, accountsChangedInCommit);
248 1 : }
249 : }
250 :
251 : // If we don't specify a user to remove, but we specify addition for that user, the user will be
252 : // added if they are not in the attention set yet.
253 65 : if (input.addToAttentionSet != null) {
254 7 : for (AttentionSetInput add : input.addToAttentionSet) {
255 7 : addToAttentionSet(bu, changeNotes, add, accountsChangedInCommit);
256 7 : }
257 : }
258 65 : }
259 :
260 : /**
261 : * Bots don't process automatic rules, the only attention set change they do is this rule: Add
262 : * owner and uploader when a bot votes negatively.
263 : */
264 : private void botsWithNegativeLabelsAddOwnerAndUploader(
265 : BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input) {
266 1 : if (input.labels != null && input.labels.values().stream().anyMatch(vote -> vote < 0)) {
267 1 : Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
268 1 : Account.Id owner = changeNotes.getChange().getOwner();
269 1 : addToAttentionSet(bu, changeNotes, owner, "A robot voted negatively on a label", false);
270 1 : if (!owner.equals(uploader)) {
271 0 : addToAttentionSet(bu, changeNotes, uploader, "A robot voted negatively on a label", false);
272 : }
273 : }
274 1 : }
275 :
276 : /**
277 : * Adds the user to the attention set
278 : *
279 : * @param bu BatchUpdate to perform the updates to the attention set
280 : * @param changeNotes current change
281 : * @param user user to add to the attention set
282 : * @param reason reason for adding
283 : * @param notify whether or not to notify about this addition
284 : */
285 : private void addToAttentionSet(
286 : BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
287 30 : AddToAttentionSetOp addToAttentionSet = addToAttentionSetOpFactory.create(user, reason, notify);
288 30 : bu.addOp(changeNotes.getChangeId(), addToAttentionSet);
289 30 : }
290 :
291 : /**
292 : * Removes the user from the attention set
293 : *
294 : * @param bu BatchUpdate to perform the updates to the attention set.
295 : * @param changeNotes current change.
296 : * @param user user to add remove from the attention set.
297 : * @param reason reason for removing.
298 : * @param notify whether or not to notify about this removal.
299 : */
300 : private void removeFromAttentionSet(
301 : BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
302 65 : RemoveFromAttentionSetOp removeFromAttentionSetOp =
303 65 : removeFromAttentionSetOpFactory.create(user, reason, notify);
304 65 : bu.addOp(changeNotes.getChangeId(), removeFromAttentionSetOp);
305 65 : }
306 :
307 : private static boolean isReadyForReview(ChangeNotes changeNotes, ReviewInput input) {
308 65 : return (!changeNotes.getChange().isWorkInProgress() && !input.workInProgress) || input.ready;
309 : }
310 :
311 : private void addToAttentionSet(
312 : BatchUpdate bu,
313 : ChangeNotes changeNotes,
314 : AttentionSetInput add,
315 : Set<Account.Id> accountsChangedInCommit)
316 : throws BadRequestException, IOException, PermissionBackendException,
317 : UnprocessableEntityException, ConfigInvalidException {
318 7 : AttentionSetUtil.validateInput(add);
319 : try {
320 7 : Account.Id attentionUserId =
321 7 : getAccountIdAndValidateUser(
322 : changeNotes, add.user, accountsChangedInCommit, AttentionSetUpdate.Operation.ADD);
323 7 : addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
324 1 : } catch (AccountResolver.UnresolvableAccountException ex) {
325 : // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
326 : // message here, then it would be possible to probe whether an account exists.
327 1 : } catch (AuthException ex) {
328 : // adding users without permission to the attention set should fail silently.
329 1 : logger.atFine().log("%s", ex.getMessage());
330 7 : }
331 7 : }
332 :
333 : private void removeFromAttentionSet(
334 : BatchUpdate bu,
335 : ChangeNotes changeNotes,
336 : AttentionSetInput remove,
337 : Set<Account.Id> accountsChangedInCommit)
338 : throws BadRequestException, IOException, PermissionBackendException,
339 : UnprocessableEntityException, ConfigInvalidException {
340 1 : AttentionSetUtil.validateInput(remove);
341 : try {
342 1 : Account.Id attentionUserId =
343 1 : getAccountIdAndValidateUser(
344 : changeNotes,
345 : remove.user,
346 : accountsChangedInCommit,
347 : AttentionSetUpdate.Operation.REMOVE);
348 1 : removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
349 0 : } catch (AccountResolver.UnresolvableAccountException ex) {
350 : // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
351 : // message here, then it would be possible to probe whether an account exists.
352 0 : } catch (AuthException ex) {
353 : // this should never happen since removing users with permissions should work.
354 0 : logger.atSevere().log("%s", ex.getMessage());
355 1 : }
356 1 : }
357 :
358 : private Account.Id getAccountId(
359 : ChangeNotes changeNotes, String user, AttentionSetUpdate.Operation operation)
360 : throws ConfigInvalidException, IOException, UnprocessableEntityException,
361 : PermissionBackendException, AuthException {
362 7 : Account.Id attentionUserId = accountResolver.resolve(user).asUnique().account().id();
363 : try {
364 7 : permissionBackend
365 7 : .absentUser(attentionUserId)
366 7 : .change(changeNotes)
367 7 : .check(ChangePermission.READ);
368 1 : } catch (AuthException e) {
369 : // If the change is private, it is okay to add the user to the attention set since that
370 : // person will be granted visibility when a reviewer.
371 1 : if (!changeNotes.getChange().isPrivate()) {
372 :
373 : // Removing users without access is allowed, adding is not allowed
374 1 : if (operation == AttentionSetUpdate.Operation.ADD) {
375 1 : throw new AuthException(
376 : "Can't modify attention set: Read not permitted for " + attentionUserId, e);
377 : }
378 : }
379 7 : }
380 7 : return attentionUserId;
381 : }
382 :
383 : private Account.Id getAccountIdAndValidateUser(
384 : ChangeNotes changeNotes,
385 : String user,
386 : Set<Account.Id> accountsChangedInCommit,
387 : AttentionSetUpdate.Operation operation)
388 : throws ConfigInvalidException, IOException, PermissionBackendException,
389 : UnprocessableEntityException, BadRequestException, AuthException {
390 : try {
391 7 : Account.Id attentionUserId = getAccountId(changeNotes, user, operation);
392 7 : if (accountsChangedInCommit.contains(attentionUserId)) {
393 1 : throw new BadRequestException(
394 1 : String.format(
395 : "%s can not be added/removed twice, and can not be added and "
396 : + "removed at the same time",
397 : user));
398 : }
399 7 : accountsChangedInCommit.add(attentionUserId);
400 7 : return attentionUserId;
401 1 : } catch (AccountResolver.UnresolvableAccountException ex) {
402 : // This can only happen if this user can't see the account or the account doesn't exist.
403 : // Silently modify the account's attention set anyway, if the account exists.
404 0 : return accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
405 : }
406 : }
407 : }
|