blob: 49286fc9b2c3ee7553c94b848af1bdbc1cdb9ff5 [file] [log] [blame]
// Copyright (C) 2020 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server.restapi.change;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.common.collect.Sets.SetView;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.api.changes.AttentionSetInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.approval.ApprovalsUtil;
import com.google.gerrit.server.change.AddToAttentionSetOp;
import com.google.gerrit.server.change.AttentionSetUnchangedOp;
import com.google.gerrit.server.change.CommentThread;
import com.google.gerrit.server.change.CommentThreads;
import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.util.AttentionSetUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jgit.errors.ConfigInvalidException;
/**
* This class is used to update the attention set when performing a review or replying on a change.
*/
public class ReplyAttentionSetUpdates {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final PermissionBackend permissionBackend;
private final AddToAttentionSetOp.Factory addToAttentionSetOpFactory;
private final RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory;
private final ApprovalsUtil approvalsUtil;
private final AccountResolver accountResolver;
private final ServiceUserClassifier serviceUserClassifier;
private final CommentsUtil commentsUtil;
@Inject
ReplyAttentionSetUpdates(
PermissionBackend permissionBackend,
AddToAttentionSetOp.Factory addToAttentionSetOpFactory,
RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory,
ApprovalsUtil approvalsUtil,
AccountResolver accountResolver,
ServiceUserClassifier serviceUserClassifier,
CommentsUtil commentsUtil) {
this.permissionBackend = permissionBackend;
this.addToAttentionSetOpFactory = addToAttentionSetOpFactory;
this.removeFromAttentionSetOpFactory = removeFromAttentionSetOpFactory;
this.approvalsUtil = approvalsUtil;
this.accountResolver = accountResolver;
this.serviceUserClassifier = serviceUserClassifier;
this.commentsUtil = commentsUtil;
}
/** Adjusts the attention set but only based on the automatic rules. */
public void processAutomaticAttentionSetRulesOnReply(
BatchUpdate bu,
ChangeNotes changeNotes,
boolean readyForReview,
CurrentUser currentUser,
List<HumanComment> commentsToBePublished) {
if (serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
return;
}
processRules(
bu,
changeNotes,
readyForReview,
currentUser,
commentsToBePublished.stream().collect(toImmutableSet()));
}
/**
* Adjusts the attention set by adding and removing users. If the same user should be added and
* removed or added/removed twice, the user will only be added/removed once, based on first
* addition/removal.
*/
public void updateAttentionSet(
BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input, CurrentUser currentUser)
throws BadRequestException, IOException, PermissionBackendException,
UnprocessableEntityException, ConfigInvalidException {
processManualUpdates(bu, changeNotes, input);
if (input.ignoreAutomaticAttentionSetRules) {
// If we ignore automatic attention set rules it means we need to pass this information to
// ChangeUpdate. Also, we should stop all other attention set updates that are part of
// this method and happen in PostReview.
bu.addOp(changeNotes.getChangeId(), new AttentionSetUnchangedOp());
return;
}
boolean isReadyForReview = isReadyForReview(changeNotes, input);
if (isReadyForReview && serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
botsWithNegativeLabelsAddOwnerAndUploader(bu, changeNotes, input);
return;
}
processRules(
bu,
changeNotes,
isReadyForReview,
currentUser,
getAllNewComments(changeNotes, input, currentUser));
}
private ImmutableSet<HumanComment> getAllNewComments(
ChangeNotes changeNotes, ReviewInput input, CurrentUser currentUser) {
Set<HumanComment> newComments = new HashSet<>();
if (input.comments != null) {
for (ReviewInput.CommentInput commentInput :
input.comments.values().stream().flatMap(x -> x.stream()).collect(Collectors.toList())) {
newComments.add(
commentsUtil.newHumanComment(
changeNotes,
currentUser,
TimeUtil.now(),
commentInput.path,
commentInput.patchSet == null
? changeNotes.getChange().currentPatchSetId()
: PatchSet.id(changeNotes.getChange().getId(), commentInput.patchSet),
commentInput.side(),
commentInput.message,
commentInput.unresolved,
commentInput.inReplyTo));
}
}
List<HumanComment> drafts = new ArrayList<>();
if (input.drafts == ReviewInput.DraftHandling.PUBLISH) {
drafts =
commentsUtil.draftByPatchSetAuthor(
changeNotes.getChange().currentPatchSetId(), currentUser.getAccountId(), changeNotes);
}
if (input.drafts == ReviewInput.DraftHandling.PUBLISH_ALL_REVISIONS) {
drafts = commentsUtil.draftByChangeAuthor(changeNotes, currentUser.getAccountId());
}
return Stream.concat(newComments.stream(), drafts.stream()).collect(toImmutableSet());
}
/**
* Process the automatic rules of the attention set. All of the automatic rules except
* adding/removing reviewers and entering/exiting WIP state are done here, and the rest are done
* in {@link ChangeUpdate}
*/
private void processRules(
BatchUpdate bu,
ChangeNotes changeNotes,
boolean readyForReview,
CurrentUser currentUser,
ImmutableSet<HumanComment> allNewComments) {
// Replying removes the publishing user from the attention set.
removeFromAttentionSet(bu, changeNotes, currentUser.getAccountId(), "removed on reply", false);
Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
Account.Id owner = changeNotes.getChange().getOwner();
// The rest of the conditions only apply if the change is open.
if (changeNotes.getChange().getStatus().isClosed()) {
// We still add the owner if a new comment thread was created, on closed changes.
if (allNewComments.stream().anyMatch(c -> c.parentUuid == null)) {
addToAttentionSet(bu, changeNotes, owner, "A new comment thread was created", false);
}
return;
}
// The rest of the conditions only apply if the change is ready for review.
if (!readyForReview) {
return;
}
if (!currentUser.getAccountId().equals(owner)) {
addToAttentionSet(bu, changeNotes, owner, "Someone else replied on the change", false);
}
if (!owner.equals(uploader) && !currentUser.getAccountId().equals(uploader)) {
addToAttentionSet(bu, changeNotes, uploader, "Someone else replied on the change", false);
}
addAllAuthorsOfCommentThreads(bu, changeNotes, allNewComments);
}
/** Adds all authors of all comment threads that received a reply during this update */
private void addAllAuthorsOfCommentThreads(
BatchUpdate bu, ChangeNotes changeNotes, ImmutableSet<HumanComment> allNewComments) {
List<HumanComment> publishedComments = commentsUtil.publishedHumanCommentsByChange(changeNotes);
ImmutableSet<CommentThread<HumanComment>> repliedToCommentThreads =
CommentThreads.forComments(publishedComments).getThreadsForChildren(allNewComments);
ImmutableSet<Account.Id> repliedToUsers =
repliedToCommentThreads.stream()
.map(CommentThread::comments)
.flatMap(Collection::stream)
.map(comment -> comment.author.getId())
.collect(toImmutableSet());
ImmutableSet<Account.Id> possibleUsersToAdd = approvalsUtil.getReviewers(changeNotes).all();
SetView<Account.Id> usersToAdd = Sets.intersection(possibleUsersToAdd, repliedToUsers);
for (Account.Id user : usersToAdd) {
addToAttentionSet(
bu, changeNotes, user, "Someone else replied on a comment you posted", false);
}
}
/** Process the manual updates of the attention set. */
private void processManualUpdates(BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input)
throws BadRequestException, IOException, PermissionBackendException,
UnprocessableEntityException, ConfigInvalidException {
Set<Account.Id> accountsChangedInCommit = new HashSet<>();
// If we specify a user to remove, and the user is in the attention set, we remove it.
if (input.removeFromAttentionSet != null) {
for (AttentionSetInput remove : input.removeFromAttentionSet) {
removeFromAttentionSet(bu, changeNotes, remove, accountsChangedInCommit);
}
}
// If we don't specify a user to remove, but we specify addition for that user, the user will be
// added if they are not in the attention set yet.
if (input.addToAttentionSet != null) {
for (AttentionSetInput add : input.addToAttentionSet) {
addToAttentionSet(bu, changeNotes, add, accountsChangedInCommit);
}
}
}
/**
* Bots don't process automatic rules, the only attention set change they do is this rule: Add
* owner and uploader when a bot votes negatively.
*/
private void botsWithNegativeLabelsAddOwnerAndUploader(
BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input) {
if (input.labels != null && input.labels.values().stream().anyMatch(vote -> vote < 0)) {
Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
Account.Id owner = changeNotes.getChange().getOwner();
addToAttentionSet(bu, changeNotes, owner, "A robot voted negatively on a label", false);
if (!owner.equals(uploader)) {
addToAttentionSet(bu, changeNotes, uploader, "A robot voted negatively on a label", false);
}
}
}
/**
* Adds the user to the attention set
*
* @param bu BatchUpdate to perform the updates to the attention set
* @param changeNotes current change
* @param user user to add to the attention set
* @param reason reason for adding
* @param notify whether or not to notify about this addition
*/
private void addToAttentionSet(
BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
AddToAttentionSetOp addOwnerToAttentionSet =
addToAttentionSetOpFactory.create(user, reason, notify);
bu.addOp(changeNotes.getChangeId(), addOwnerToAttentionSet);
}
/**
* Removes the user from the attention set
*
* @param bu BatchUpdate to perform the updates to the attention set.
* @param changeNotes current change.
* @param user user to add remove from the attention set.
* @param reason reason for removing.
* @param notify whether or not to notify about this removal.
*/
private void removeFromAttentionSet(
BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
RemoveFromAttentionSetOp removeFromAttentionSetOp =
removeFromAttentionSetOpFactory.create(user, reason, notify);
bu.addOp(changeNotes.getChangeId(), removeFromAttentionSetOp);
}
private static boolean isReadyForReview(ChangeNotes changeNotes, ReviewInput input) {
return (!changeNotes.getChange().isWorkInProgress() && !input.workInProgress) || input.ready;
}
private void addToAttentionSet(
BatchUpdate bu,
ChangeNotes changeNotes,
AttentionSetInput add,
Set<Account.Id> accountsChangedInCommit)
throws BadRequestException, IOException, PermissionBackendException,
UnprocessableEntityException, ConfigInvalidException {
AttentionSetUtil.validateInput(add);
try {
Account.Id attentionUserId =
getAccountIdAndValidateUser(
changeNotes, add.user, accountsChangedInCommit, AttentionSetUpdate.Operation.ADD);
addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
} catch (AccountResolver.UnresolvableAccountException ex) {
// This happens only when the account doesn't exist. Silently ignore it. If we threw an error
// message here, then it would be possible to probe whether an account exists.
} catch (AuthException ex) {
// adding users without permission to the attention set should fail silently.
logger.atFine().log("%s", ex.getMessage());
}
}
private void removeFromAttentionSet(
BatchUpdate bu,
ChangeNotes changeNotes,
AttentionSetInput remove,
Set<Account.Id> accountsChangedInCommit)
throws BadRequestException, IOException, PermissionBackendException,
UnprocessableEntityException, ConfigInvalidException {
AttentionSetUtil.validateInput(remove);
try {
Account.Id attentionUserId =
getAccountIdAndValidateUser(
changeNotes,
remove.user,
accountsChangedInCommit,
AttentionSetUpdate.Operation.REMOVE);
removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
} catch (AccountResolver.UnresolvableAccountException ex) {
// This happens only when the account doesn't exist. Silently ignore it. If we threw an error
// message here, then it would be possible to probe whether an account exists.
} catch (AuthException ex) {
// this should never happen since removing users with permissions should work.
logger.atSevere().log("%s", ex.getMessage());
}
}
private Account.Id getAccountId(
ChangeNotes changeNotes, String user, AttentionSetUpdate.Operation operation)
throws ConfigInvalidException, IOException, UnprocessableEntityException,
PermissionBackendException, AuthException {
Account.Id attentionUserId = accountResolver.resolve(user).asUnique().account().id();
try {
permissionBackend
.absentUser(attentionUserId)
.change(changeNotes)
.check(ChangePermission.READ);
} catch (AuthException e) {
// If the change is private, it is okay to add the user to the attention set since that
// person will be granted visibility when a reviewer.
if (!changeNotes.getChange().isPrivate()) {
// Removing users without access is allowed, adding is not allowed
if (operation == AttentionSetUpdate.Operation.ADD) {
throw new AuthException(
"Can't modify attention set: Read not permitted for " + attentionUserId, e);
}
}
}
return attentionUserId;
}
private Account.Id getAccountIdAndValidateUser(
ChangeNotes changeNotes,
String user,
Set<Account.Id> accountsChangedInCommit,
AttentionSetUpdate.Operation operation)
throws ConfigInvalidException, IOException, PermissionBackendException,
UnprocessableEntityException, BadRequestException, AuthException {
try {
Account.Id attentionUserId = getAccountId(changeNotes, user, operation);
if (accountsChangedInCommit.contains(attentionUserId)) {
throw new BadRequestException(
String.format(
"%s can not be added/removed twice, and can not be added and "
+ "removed at the same time",
user));
}
accountsChangedInCommit.add(attentionUserId);
return attentionUserId;
} catch (AccountResolver.UnresolvableAccountException ex) {
// This can only happen if this user can't see the account or the account doesn't exist.
// Silently modify the account's attention set anyway, if the account exists.
return accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
}
}
}