All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.gerrit.server.change.ReviewerModifier Maven / Gradle / Ivy

The newest version!
// Copyright (C) 2018 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.change;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.extensions.client.ReviewerState.CC;
import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;

import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Ordering;
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.GroupDescription;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.ReviewerInfo;
import com.google.gerrit.extensions.api.changes.ReviewerInput;
import com.google.gerrit.extensions.api.changes.ReviewerResult;
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.AnonymousUser;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountLoader;
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.account.GroupMembers;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.group.GroupResolver;
import com.google.gerrit.server.group.SystemGroupBackend;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
import com.google.gerrit.server.notedb.ChangeNotes;
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.permissions.RefPermission;
import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.PostUpdateContext;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Stream;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;

public class ReviewerModifier {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();

  public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
  public static final int DEFAULT_MAX_REVIEWERS = 20;

  /**
   * Controls which failures should be ignored.
   *
   * 

If a failure is ignored the operation succeeds, but the reviewer is not added. If not * ignored a failure means that the operation fails. */ public enum FailureBehavior { // All failures cause the operation to fail. FAIL, // Only not found failures cause the operation to fail, all other failures are ignored. IGNORE_EXCEPT_NOT_FOUND, // All failures are ignored. IGNORE_ALL; } private enum FailureType { NOT_FOUND, OTHER; } // TODO(dborowitz): Subclassing is not the right way to do this. We should instead use an internal // type in the public interfaces of ReviewerModifier, rather than passing around the REST API type // internally. public static class InternalReviewerInput extends ReviewerInput { /** * Behavior when identifying reviewers fails for any reason besides the input not * resolving to an account/group/email. */ public FailureBehavior otherFailureBehavior = FailureBehavior.FAIL; /** Whether the visibility check for the reviewer account should be skipped. */ public boolean skipVisibilityCheck = false; } public static InternalReviewerInput newReviewerInput( String reviewer, ReviewerState state, NotifyHandling notify) { InternalReviewerInput in = new InternalReviewerInput(); in.reviewer = reviewer; in.state = state; in.notify = notify; return in; } public static Optional newReviewerInputFromCommitIdentity( Change change, ObjectId commitId, @Nullable Account.Id accountId, NotifyHandling notify, Account.Id mostRecentUploader) { if (accountId == null || accountId.equals(mostRecentUploader)) { // If git ident couldn't be resolved to a user, or if it's not forged, do nothing. return Optional.empty(); } logger.atFine().log( "Adding account %d from author/committer identity of commit %s as cc to change %d", accountId.get(), commitId.name(), change.getChangeId()); InternalReviewerInput in = new InternalReviewerInput(); in.reviewer = accountId.toString(); in.state = CC; in.notify = notify; in.otherFailureBehavior = FailureBehavior.IGNORE_ALL; return Optional.of(in); } private final AccountResolver accountResolver; private final PermissionBackend permissionBackend; private final GroupResolver groupResolver; private final GroupMembers groupMembers; private final AccountLoader.Factory accountLoaderFactory; private final Config cfg; private final ReviewerJson json; private final ProjectCache projectCache; private final Provider anonymousProvider; private final AddReviewersOp.Factory addReviewersOpFactory; private final OutgoingEmailValidator validator; private final DeleteReviewerOp.Factory deleteReviewerOpFactory; private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory; @Inject ReviewerModifier( AccountResolver accountResolver, PermissionBackend permissionBackend, GroupResolver groupResolver, GroupMembers groupMembers, AccountLoader.Factory accountLoaderFactory, @GerritServerConfig Config cfg, ReviewerJson json, ProjectCache projectCache, Provider anonymousProvider, AddReviewersOp.Factory addReviewersOpFactory, OutgoingEmailValidator validator, DeleteReviewerOp.Factory deleteReviewerOpFactory, DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) { this.accountResolver = accountResolver; this.permissionBackend = permissionBackend; this.groupResolver = groupResolver; this.groupMembers = groupMembers; this.accountLoaderFactory = accountLoaderFactory; this.cfg = cfg; this.json = json; this.projectCache = projectCache; this.anonymousProvider = anonymousProvider; this.addReviewersOpFactory = addReviewersOpFactory; this.validator = validator; this.deleteReviewerOpFactory = deleteReviewerOpFactory; this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory; } /** * Prepare application of a single {@link ReviewerInput}. * * @param notes change notes. * @param user user performing the reviewer addition. * @param input input describing user or group to add as a reviewer. * @param allowGroup whether to allow * @return handle describing the addition operation. If the {@code op} field is present, this * operation may be added to a {@code BatchUpdate}. Otherwise, the {@code error} field * contains information about an error that occurred */ public ReviewerModification prepare( ChangeNotes notes, CurrentUser user, ReviewerInput input, boolean allowGroup) throws IOException, PermissionBackendException, ConfigInvalidException { try (TraceContext.TraceTimer ignored = TraceContext.newTimer(getClass().getSimpleName() + "#prepare", Metadata.empty())) { if (Strings.nullToEmpty(input.reviewer).trim().isEmpty()) { return fail(input, FailureType.NOT_FOUND, "reviewer user identifier is required"); } boolean confirmed = input.confirmed(); boolean allowByEmail = projectCache .get(notes.getProjectName()) .orElseThrow(illegalState(notes.getProjectName())) .is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL); ReviewerModification byAccountId = byAccountId(input, notes, user); ReviewerModification wholeGroup = null; if (!byAccountId.exactMatchFound) { wholeGroup = addWholeGroup(input, notes, user, confirmed, allowGroup, allowByEmail); if (wholeGroup != null && wholeGroup.exactMatchFound) { return wholeGroup; } } if (wholeGroup != null && byAccountId.failureType == FailureType.NOT_FOUND && wholeGroup.failureType == FailureType.NOT_FOUND) { return fail( byAccountId.input, FailureType.NOT_FOUND, byAccountId.result.error + "\n" + wholeGroup.result.error); } if (byAccountId.failureType != FailureType.NOT_FOUND) { return byAccountId; } if (wholeGroup != null) { return wholeGroup; } return addByEmail(input, notes, user); } } public ReviewerModification ccCurrentUser(CurrentUser user, RevisionResource revision) { return new ReviewerModification( newReviewerInput(user.getUserName().orElse(null), CC, NotifyHandling.NONE), revision.getNotes(), revision.getUser(), ImmutableSet.of(user.asIdentifiedUser().getAccount()), null, true, false); } @Nullable private ReviewerModification byAccountId(ReviewerInput input, ChangeNotes notes, CurrentUser user) throws PermissionBackendException, IOException, ConfigInvalidException { IdentifiedUser reviewerUser; boolean exactMatchFound = false; try { if (ReviewerState.REMOVED.equals(input.state) || (input instanceof InternalReviewerInput && ((InternalReviewerInput) input).skipVisibilityCheck)) { reviewerUser = accountResolver.resolveIncludeInactiveIgnoreVisibility(input.reviewer).asUniqueUser(); } else { reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser(); } if (input.reviewer.equalsIgnoreCase(reviewerUser.getName()) || input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) { exactMatchFound = true; } } catch (UnprocessableEntityException e) { // Caller might choose to ignore this NOT_FOUND result if they find another result e.g. by // group, but if not, the error message will be useful. return fail(input, FailureType.NOT_FOUND, e.getMessage()); } // If the reviewer is removed, we do not have to perform the visibility check on the change if (ReviewerState.REMOVED.equals(input.state) || isValidReviewer(notes.getChange().getDest(), reviewerUser.getAccount())) { return new ReviewerModification( input, notes, user, ImmutableSet.of(reviewerUser.getAccount()), null, exactMatchFound, false); } return fail( input, FailureType.OTHER, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer)); } @Nullable private ReviewerModification addWholeGroup( ReviewerInput input, ChangeNotes notes, CurrentUser user, boolean confirmed, boolean allowGroup, boolean allowByEmail) throws IOException, PermissionBackendException { if (!allowGroup) { return null; } GroupDescription.Basic group; try { // TODO(dborowitz): This currently doesn't work in the push path because InternalGroupBackend // depends on the Provider which returns anonymous in that path. group = groupResolver.parseInternal(input.reviewer); } catch (UnprocessableEntityException e) { if (!allowByEmail) { return fail( input, FailureType.NOT_FOUND, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, input.reviewer)); } return null; } if (!isLegalReviewerGroup(group.getGroupUUID())) { return fail( input, FailureType.OTHER, MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName())); } if (input.state().equals(REMOVED)) { return fail( input, FailureType.OTHER, MessageFormat.format(ChangeMessages.get().groupRemovalIsNotAllowed, group.getName())); } Set reviewers = new HashSet<>(); Set members; try { members = groupMembers.listAccounts(group.getGroupUUID(), notes.getProjectName()); } catch (NoSuchProjectException e) { return fail(input, FailureType.OTHER, e.getMessage()); } // if maxAllowed is set to 0, it is allowed to add any number of // reviewers int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS); if (maxAllowed > 0 && members.size() > maxAllowed) { logger.atFine().log( "Adding %d group members is not allowed (maxAllowed = %d)", members.size(), maxAllowed); return fail( input, FailureType.OTHER, MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName())); } // if maxWithoutCheck is set to 0, we never ask for confirmation int maxWithoutConfirmation = cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK); if (!confirmed && maxWithoutConfirmation > 0 && members.size() > maxWithoutConfirmation) { logger.atFine().log( "Adding %d group members as reviewer requires confirmation (maxWithoutConfirmation = %d)", members.size(), maxWithoutConfirmation); return fail( input, FailureType.OTHER, true, MessageFormat.format( ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size())); } for (Account member : members) { if (isValidReviewer(notes.getChange().getDest(), member)) { reviewers.add(member); } } return new ReviewerModification(input, notes, user, reviewers, null, true, true); } @Nullable private ReviewerModification addByEmail(ReviewerInput input, ChangeNotes notes, CurrentUser user) throws PermissionBackendException { if (!permissionBackend .user(anonymousProvider.get()) .change(notes) .test(ChangePermission.READ)) { return fail( input, FailureType.OTHER, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer)); } Address adr = Address.tryParse(input.reviewer); if (adr == null || !validator.isValid(adr.email())) { return fail( input, FailureType.NOT_FOUND, MessageFormat.format(ChangeMessages.get().reviewerInvalid, input.reviewer)); } return new ReviewerModification(input, notes, user, null, ImmutableList.of(adr), true, false); } private boolean isValidReviewer(BranchNameKey branch, Account member) throws PermissionBackendException { // Check ref permission instead of change permission, since change permissions take into // account the private bit, whereas adding a user as a reviewer is explicitly allowing them to // see private changes. return permissionBackend.absentUser(member.id()).ref(branch).test(RefPermission.READ); } private ReviewerModification fail(ReviewerInput input, FailureType failureType, String error) { return fail(input, failureType, false, error); } private ReviewerModification fail( ReviewerInput input, FailureType failureType, boolean confirm, String error) { ReviewerModification addition = new ReviewerModification(input, failureType); addition.result.confirm = confirm ? true : null; addition.result.error = error; return addition; } public class ReviewerModification { public final ReviewerResult result; @Nullable public final ReviewerOp op; public final ImmutableSet reviewers; public final ImmutableSet

reviewersByEmail; @Nullable final IdentifiedUser caller; final boolean exactMatchFound; private final ReviewerInput input; @Nullable private final FailureType failureType; private ReviewerModification(ReviewerInput input, FailureType failureType) { this.input = input; this.failureType = requireNonNull(failureType); result = new ReviewerResult(input.reviewer); op = null; reviewers = ImmutableSet.of(); reviewersByEmail = ImmutableSet.of(); caller = null; exactMatchFound = false; } private ReviewerModification( ReviewerInput input, ChangeNotes notes, CurrentUser caller, @Nullable Iterable reviewers, @Nullable Iterable
reviewersByEmail, boolean exactMatchFound, boolean forGroup) { checkArgument( reviewers != null || reviewersByEmail != null, "must have either reviewers or reviewersByEmail"); this.input = input; this.failureType = null; result = new ReviewerResult(input.reviewer); if (!state().equals(REMOVED)) { // Always silently ignore adding the owner as any type of reviewer on their own change. They // may still be implicitly added as a reviewer if they vote, but not via the reviewer API. this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ true); } else { this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ false); } this.reviewersByEmail = reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail); this.caller = caller.asIdentifiedUser(); if (state().equals(REMOVED)) { // only one is set. checkState( (this.reviewers.size() == 1 && this.reviewersByEmail.isEmpty()) || (this.reviewers.isEmpty() && this.reviewersByEmail.size() == 1)); if (this.reviewers.size() >= 1) { checkState(this.reviewers.size() == 1); DeleteReviewerInput deleteReviewerInput = new DeleteReviewerInput(); deleteReviewerInput.notify = input.notify; deleteReviewerInput.notifyDetails = input.notifyDetails; op = deleteReviewerOpFactory.create( Iterables.getOnlyElement(this.reviewers.asList()), deleteReviewerInput); } else { checkState(this.reviewersByEmail.size() == 1); op = deleteReviewerByEmailOpFactory.create( Iterables.getOnlyElement(this.reviewersByEmail.asList())); } } else { op = addReviewersOpFactory.create( this.reviewers.stream().map(Account::id).collect(toImmutableSet()), this.reviewersByEmail, state(), forGroup); } this.exactMatchFound = exactMatchFound; } private ImmutableSet reviewersAsList( ChangeNotes notes, @Nullable Iterable reviewers, boolean omitChangeOwner) { if (reviewers == null) { return ImmutableSet.of(); } Stream reviewerStream = Streams.stream(reviewers); if (omitChangeOwner) { reviewerStream = reviewerStream.filter(account -> !account.id().equals(notes.getChange().getOwner())); } return reviewerStream.collect(toImmutableSet()); } public void gatherResults(ChangeData cd) throws PermissionBackendException { checkState(op != null, "addition did not result in an update op"); checkState(op.getResult() != null, "op did not return a result"); // Generate result details and fill AccountLoader. This occurs outside // the Op because the accounts are in a different table. ReviewerOp.Result opResult = op.getResult(); switch (state()) { case CC: result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size()); for (Account.Id accountId : opResult.addedCCs()) { result.ccs.add(json.format(new ReviewerInfo(accountId.get()), accountId, cd)); } accountLoaderFactory.create(true).fill(result.ccs); for (Address a : opResult.addedCCsByEmail()) { result.ccs.add(new AccountInfo(a.name(), a.email())); } break; case REVIEWER: result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size()); for (PatchSetApproval psa : opResult.addedReviewers()) { // New reviewers have value 0, don't bother normalizing. result.reviewers.add( json.format( new ReviewerInfo(psa.accountId().get()), psa.accountId(), cd, ImmutableList.of(psa))); } accountLoaderFactory.create(true).fill(result.reviewers); for (Address a : opResult.addedReviewersByEmail()) { result.reviewers.add(ReviewerInfo.byEmail(a.name(), a.email())); } break; case REMOVED: if (opResult.deletedReviewer().isPresent()) { result.removed = json.format( new ReviewerInfo(opResult.deletedReviewer().get().get()), opResult.deletedReviewer().get(), cd); accountLoaderFactory.create(true).fill(ImmutableList.of(result.removed)); } else if (opResult.deletedReviewerByEmail().isPresent()) { result.removed = new AccountInfo( opResult.deletedReviewerByEmail().get().name(), opResult.deletedReviewerByEmail().get().email()); } break; default: throw new IllegalStateException( String.format("Illegal ReviewerState argument is %s", state().name())); } } public ReviewerState state() { return input.state(); } public boolean isFailure() { return failureType != null; } public boolean isIgnorableFailure() { checkState(failureType != null); FailureBehavior behavior = (input instanceof InternalReviewerInput) ? ((InternalReviewerInput) input).otherFailureBehavior : FailureBehavior.FAIL; return behavior == FailureBehavior.IGNORE_ALL || (failureType == FailureType.OTHER && behavior == FailureBehavior.IGNORE_EXCEPT_NOT_FOUND); } } public static boolean isLegalReviewerGroup(AccountGroup.UUID groupUUID) { return !SystemGroupBackend.isSystemGroup(groupUUID); } public ReviewerModificationList prepare( ChangeNotes notes, CurrentUser user, Iterable inputs, boolean allowGroup) throws IOException, PermissionBackendException, ConfigInvalidException { // Process CC ops before reviewer ops, so a user that appears in both lists ends up as a // reviewer; the last call to ChangeUpdate#putReviewer wins. This can happen if the caller // specifies the same string twice, or less obviously if they specify multiple groups with // overlapping members. // TODO(dborowitz): Consider changing interface to allow excluding reviewers that were // previously processed, to proactively prevent overlap so we don't have to rely on this subtle // behavior. ImmutableList sorted = Streams.stream(inputs) .sorted( comparing( ReviewerInput::state, Ordering.explicit(ReviewerState.CC, ReviewerState.REVIEWER))) .collect(toImmutableList()); List additions = new ArrayList<>(); for (ReviewerInput input : sorted) { ReviewerModification addition = prepare(notes, user, input, allowGroup); if (addition.op != null) { // Assume any callers preparing a list of batch insertions are handling their own email. addition.op.suppressEmail(); } additions.add(addition); } return new ReviewerModificationList(additions); } // TODO(dborowitz): This class works, but ultimately feels wrong. It seems like an op but isn't // really an op, it's a collection of ops, and it's only called from the body of other ops. We // could make this class an op, but we would still have AddReviewersOp. Better would probably be // to design a single op that supports combining multiple ReviewerInputs together. That would // probably also subsume the Addition class itself, which would be a good thing. public static class ReviewerModificationList { private final ImmutableList modifications; private ReviewerModificationList(List modifications) { this.modifications = ImmutableList.copyOf(modifications); } public ImmutableList getFailures() { return modifications.stream() .filter(a -> a.isFailure() && !a.isIgnorableFailure()) .collect(toImmutableList()); } // We never call updateRepo on the addition ops, which is only ok because it's a no-op. public void updateChange(ChangeContext ctx, PatchSet patchSet) throws RestApiException, IOException, PermissionBackendException { for (ReviewerModification addition : modifications()) { addition.op.setPatchSet(patchSet); @SuppressWarnings("unused") var unused = addition.op.updateChange(ctx); } } public void postUpdate(PostUpdateContext ctx) throws Exception { for (ReviewerModification addition : modifications()) { if (addition.op != null) { addition.op.postUpdate(ctx); } } } public ImmutableSet flattenResults( Function> func) { modifications() .forEach( a -> checkArgument( a.op != null && a.op.getResult() != null, "missing result on %s", a)); return modifications().stream() .map(a -> a.op.getResult()) .map(func) .flatMap(Collection::stream) .collect(toImmutableSet()); } private ImmutableList modifications() { return modifications.stream() .filter( a -> { if (a.isFailure()) { if (a.isIgnorableFailure()) { return false; } // Shouldn't happen, caller should have checked that there were no errors. throw new IllegalStateException("error in addition: " + a.result.error); } return true; }) .collect(toImmutableList()); } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy