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

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

There is a newer version: 3.10.0-rc4
Show 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.REVIEWER;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Ordering;
import com.google.common.collect.Streams;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.GroupDescription;
import com.google.gerrit.extensions.api.changes.AddReviewerInput;
import com.google.gerrit.extensions.api.changes.AddReviewerResult;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.RecipientType;
import com.google.gerrit.extensions.api.changes.ReviewerInfo;
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.mail.Address;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.AccountGroup;
import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
import com.google.gerrit.reviewdb.client.Branch;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.PatchSetApproval;
import com.google.gerrit.reviewdb.server.ReviewDb;
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.mail.send.OutgoingEmailValidator;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.NotesMigration;
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.Context;
import com.google.gwtorm.server.OrmException;
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 org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;

public class ReviewerAdder {
  public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
  public static final int DEFAULT_MAX_REVIEWERS = 20;

  public enum FailureBehavior {
    FAIL,
    IGNORE;
  }

  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 ReviewerAdder, rather than passing around the REST API type
  // internally.
  public static class InternalAddReviewerInput extends AddReviewerInput {
    /**
     * Behavior when identifying reviewers fails for any reason besides the input not
     * resolving to an account/group/email.
     */
    public FailureBehavior otherFailureBehavior = FailureBehavior.FAIL;
  }

  public static InternalAddReviewerInput newAddReviewerInput(
      Account.Id reviewer, ReviewerState state, NotifyHandling notify) {
    // AccountResolver always resolves by ID if the input string is numeric.
    return newAddReviewerInput(reviewer.toString(), state, notify);
  }

  public static InternalAddReviewerInput newAddReviewerInput(
      String reviewer, ReviewerState state, NotifyHandling notify) {
    InternalAddReviewerInput in = new InternalAddReviewerInput();
    in.reviewer = reviewer;
    in.state = state;
    in.notify = notify;
    return in;
  }

  public static Optional newAddReviewerInputFromCommitIdentity(
      Change change, @Nullable Account.Id accountId, NotifyHandling notify) {
    if (accountId == null || accountId.equals(change.getOwner())) {
      // If git ident couldn't be resolved to a user, or if it's not forged, do nothing.
      return Optional.empty();
    }

    InternalAddReviewerInput in = new InternalAddReviewerInput();
    in.reviewer = accountId.toString();
    in.state = REVIEWER;
    in.notify = notify;
    in.otherFailureBehavior = FailureBehavior.IGNORE;
    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 NotesMigration migration;
  private final NotifyUtil notifyUtil;
  private final ProjectCache projectCache;
  private final Provider anonymousProvider;
  private final AddReviewersOp.Factory addReviewersOpFactory;
  private final OutgoingEmailValidator validator;

  @Inject
  ReviewerAdder(
      AccountResolver accountResolver,
      PermissionBackend permissionBackend,
      GroupResolver groupResolver,
      GroupMembers groupMembers,
      AccountLoader.Factory accountLoaderFactory,
      @GerritServerConfig Config cfg,
      ReviewerJson json,
      NotesMigration migration,
      NotifyUtil notifyUtil,
      ProjectCache projectCache,
      Provider anonymousProvider,
      AddReviewersOp.Factory addReviewersOpFactory,
      OutgoingEmailValidator validator) {
    this.accountResolver = accountResolver;
    this.permissionBackend = permissionBackend;
    this.groupResolver = groupResolver;
    this.groupMembers = groupMembers;
    this.accountLoaderFactory = accountLoaderFactory;
    this.cfg = cfg;
    this.json = json;
    this.migration = migration;
    this.notifyUtil = notifyUtil;
    this.projectCache = projectCache;
    this.anonymousProvider = anonymousProvider;
    this.addReviewersOpFactory = addReviewersOpFactory;
    this.validator = validator;
  }

  /**
   * Prepare application of a single {@link AddReviewerInput}.
   *
   * @param db database.
   * @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
   * @throws OrmException
   * @throws IOException
   * @throws PermissionBackendException
   * @throws ConfigInvalidException
   */
  public ReviewerAddition prepare(
      ReviewDb db, ChangeNotes notes, CurrentUser user, AddReviewerInput input, boolean allowGroup)
      throws OrmException, IOException, PermissionBackendException, ConfigInvalidException {
    requireNonNull(input.reviewer);
    ListMultimap accountsToNotify;
    try {
      accountsToNotify = notifyUtil.resolveAccounts(input.notifyDetails);
    } catch (BadRequestException e) {
      return fail(input, FailureType.OTHER, e.getMessage());
    }
    boolean confirmed = input.confirmed();
    boolean allowByEmail =
        projectCache
            .checkedGet(notes.getProjectName())
            .is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL);

    ReviewerAddition byAccountId =
        addByAccountId(db, input, notes, user, accountsToNotify, allowGroup, allowByEmail);

    ReviewerAddition wholeGroup = null;
    if (byAccountId == null || !byAccountId.exactMatchFound) {
      wholeGroup =
          addWholeGroup(
              db, input, notes, user, accountsToNotify, confirmed, allowGroup, allowByEmail);
      if (wholeGroup != null && wholeGroup.exactMatchFound) {
        return wholeGroup;
      }
    }

    if (byAccountId != null) {
      return byAccountId;
    }
    if (wholeGroup != null) {
      return wholeGroup;
    }

    return addByEmail(db, input, notes, user, accountsToNotify);
  }

  public ReviewerAddition ccCurrentUser(CurrentUser user, RevisionResource revision) {
    return new ReviewerAddition(
        newAddReviewerInput(user.getUserName().orElse(null), CC, NotifyHandling.NONE),
        revision.getNotes(),
        revision.getUser(),
        ImmutableSet.of(user.getAccountId()),
        null,
        ImmutableListMultimap.of(),
        true);
  }

  @Nullable
  private ReviewerAddition addByAccountId(
      ReviewDb db,
      AddReviewerInput input,
      ChangeNotes notes,
      CurrentUser user,
      ListMultimap accountsToNotify,
      boolean allowGroup,
      boolean allowByEmail)
      throws OrmException, PermissionBackendException, IOException, ConfigInvalidException {
    IdentifiedUser reviewerUser;
    boolean exactMatchFound = false;
    try {
      reviewerUser = accountResolver.parse(input.reviewer);
      if (input.reviewer.equalsIgnoreCase(reviewerUser.getName())
          || input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
        exactMatchFound = true;
      }
    } catch (UnprocessableEntityException | AuthException e) {
      // AuthException won't occur since the user is authenticated at this point.
      if (!allowGroup && !allowByEmail) {
        // Only return failure if we aren't going to try other interpretations.
        return fail(
            input,
            FailureType.NOT_FOUND,
            MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, input.reviewer));
      }
      return null;
    }

    if (isValidReviewer(db, notes.getChange().getDest(), reviewerUser.getAccount())) {
      return new ReviewerAddition(
          input,
          notes,
          user,
          ImmutableSet.of(reviewerUser.getAccountId()),
          null,
          accountsToNotify,
          exactMatchFound);
    }
    if (!reviewerUser.getAccount().isActive()) {
      if (allowByEmail && input.state() == CC) {
        return null;
      }
      return fail(
          input,
          FailureType.OTHER,
          MessageFormat.format(ChangeMessages.get().reviewerInactive, input.reviewer));
    }
    return fail(
        input,
        FailureType.OTHER,
        MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
  }

  @Nullable
  private ReviewerAddition addWholeGroup(
      ReviewDb db,
      AddReviewerInput input,
      ChangeNotes notes,
      CurrentUser user,
      ListMultimap accountsToNotify,
      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()));
    }

    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) {
      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) {
      return fail(
          input,
          FailureType.OTHER,
          true,
          MessageFormat.format(
              ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
    }

    for (Account member : members) {
      if (isValidReviewer(db, notes.getChange().getDest(), member)) {
        reviewers.add(member.getId());
      }
    }

    return new ReviewerAddition(input, notes, user, reviewers, null, accountsToNotify, true);
  }

  @Nullable
  private ReviewerAddition addByEmail(
      ReviewDb db,
      AddReviewerInput input,
      ChangeNotes notes,
      CurrentUser user,
      ListMultimap accountsToNotify)
      throws PermissionBackendException {
    try {
      permissionBackend
          .user(anonymousProvider.get())
          .database(db)
          .change(notes)
          .check(ChangePermission.READ);
    } catch (AuthException e) {
      return fail(
          input,
          FailureType.OTHER,
          MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
    }

    if (!migration.readChanges()) {
      // addByEmail depends on NoteDb.
      return fail(
          input,
          FailureType.NOT_FOUND,
          MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, input.reviewer));
    }
    Address adr = Address.tryParse(input.reviewer);
    if (adr == null || !validator.isValid(adr.getEmail())) {
      return fail(
          input,
          FailureType.NOT_FOUND,
          MessageFormat.format(ChangeMessages.get().reviewerInvalid, input.reviewer));
    }
    return new ReviewerAddition(
        input, notes, user, null, ImmutableList.of(adr), accountsToNotify, true);
  }

  private boolean isValidReviewer(ReviewDb db, Branch.NameKey branch, Account member)
      throws PermissionBackendException {
    if (!member.isActive()) {
      return false;
    }

    try {
      // 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.
      permissionBackend
          .absentUser(member.getId())
          .database(db)
          .ref(branch)
          .check(RefPermission.READ);
      return true;
    } catch (AuthException e) {
      return false;
    }
  }

  private ReviewerAddition fail(AddReviewerInput input, FailureType failureType, String error) {
    return fail(input, failureType, false, error);
  }

  private ReviewerAddition fail(
      AddReviewerInput input, FailureType failureType, boolean confirm, String error) {
    ReviewerAddition addition = new ReviewerAddition(input, failureType);
    addition.result.confirm = confirm ? true : null;
    addition.result.error = error;
    return addition;
  }

  public class ReviewerAddition {
    public final AddReviewerResult result;
    @Nullable public final AddReviewersOp op;
    public final ImmutableSet reviewers;
    public final ImmutableSet
reviewersByEmail; @Nullable final IdentifiedUser caller; final boolean exactMatchFound; private final AddReviewerInput input; @Nullable private final FailureType failureType; private ReviewerAddition(AddReviewerInput input, FailureType failureType) { this.input = input; this.failureType = requireNonNull(failureType); result = new AddReviewerResult(input.reviewer); op = null; reviewers = ImmutableSet.of(); reviewersByEmail = ImmutableSet.of(); caller = null; exactMatchFound = false; } private ReviewerAddition( AddReviewerInput input, ChangeNotes notes, CurrentUser caller, @Nullable Iterable reviewers, @Nullable Iterable
reviewersByEmail, ListMultimap accountsToNotify, boolean exactMatchFound) { checkArgument( reviewers != null || reviewersByEmail != null, "must have either reviewers or reviewersByEmail"); this.input = input; this.failureType = null; result = new AddReviewerResult(input.reviewer); // 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 = omitOwner(notes, reviewers); this.reviewersByEmail = reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail); this.caller = caller.asIdentifiedUser(); op = addReviewersOpFactory.create( this.reviewers, this.reviewersByEmail, state(), input.notify, accountsToNotify); this.exactMatchFound = exactMatchFound; } private ImmutableSet omitOwner(ChangeNotes notes, Iterable reviewers) { return reviewers != null ? Streams.stream(reviewers) .filter(id -> !id.equals(notes.getChange().getOwner())) .collect(toImmutableSet()) : ImmutableSet.of(); } public void gatherResults(ChangeData cd) throws OrmException, 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. AddReviewersOp.Result opResult = op.getResult(); if (migration.readChanges() && state() == 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.getName(), a.getEmail())); } } else { 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.getAccountId().get()), psa.getAccountId(), cd, ImmutableList.of(psa))); } accountLoaderFactory.create(true).fill(result.reviewers); for (Address a : opResult.addedReviewersByEmail()) { result.reviewers.add(ReviewerInfo.byEmail(a.getName(), a.getEmail())); } } } public ReviewerState state() { return input.state(); } public boolean isFailure() { return failureType != null; } public boolean isIgnorableFailure() { checkState(failureType != null); FailureBehavior behavior = (input instanceof InternalAddReviewerInput) ? ((InternalAddReviewerInput) input).otherFailureBehavior : FailureBehavior.FAIL; return failureType == FailureType.OTHER && behavior == FailureBehavior.IGNORE; } } public static boolean isLegalReviewerGroup(AccountGroup.UUID groupUUID) { return !SystemGroupBackend.isSystemGroup(groupUUID); } public ReviewerAdditionList prepare( ReviewDb db, ChangeNotes notes, CurrentUser user, Iterable inputs, boolean allowGroup) throws OrmException, 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( i -> i.state(), Ordering.explicit(ReviewerState.CC, ReviewerState.REVIEWER))) .collect(toImmutableList()); List additions = new ArrayList<>(); for (AddReviewerInput input : sorted) { additions.add(prepare(db, notes, user, input, allowGroup)); } return new ReviewerAdditionList(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 AddReviewerInputs together. That would // probably also subsume the Addition class itself, which would be a good thing. public static class ReviewerAdditionList { private final ImmutableList additions; private ReviewerAdditionList(List additions) { this.additions = ImmutableList.copyOf(additions); } public ImmutableList getFailures() { return additions .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 OrmException, RestApiException, IOException { for (ReviewerAddition addition : additions()) { addition.op.setPatchSet(patchSet); addition.op.updateChange(ctx); } } public void postUpdate(Context ctx) throws Exception { for (ReviewerAddition addition : additions()) { if (addition.op != null) { addition.op.postUpdate(ctx); } } } public ImmutableSet flattenResults( Function> func) { additions() .forEach( a -> checkArgument( a.op != null && a.op.getResult() != null, "missing result on %s", a)); return additions() .stream() .map(a -> a.op.getResult()) .map(func) .flatMap(Collection::stream) .collect(toImmutableSet()); } private ImmutableList additions() { return additions .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