LCOV - code coverage report
Current view: top level - server/change - ReviewerModifier.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 276 285 96.8 %
Date: 2022-11-19 15:00:39 Functions: 35 35 100.0 %

          Line data    Source code
       1             : // Copyright (C) 2018 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.change;
      16             : 
      17             : import static com.google.common.base.Preconditions.checkArgument;
      18             : import static com.google.common.base.Preconditions.checkState;
      19             : import static com.google.common.collect.ImmutableList.toImmutableList;
      20             : import static com.google.common.collect.ImmutableSet.toImmutableSet;
      21             : import static com.google.gerrit.extensions.client.ReviewerState.CC;
      22             : import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
      23             : import static com.google.gerrit.server.project.ProjectCache.illegalState;
      24             : import static java.util.Comparator.comparing;
      25             : import static java.util.Objects.requireNonNull;
      26             : 
      27             : import com.google.common.collect.ImmutableList;
      28             : import com.google.common.collect.ImmutableSet;
      29             : import com.google.common.collect.Iterables;
      30             : import com.google.common.collect.Lists;
      31             : import com.google.common.collect.Ordering;
      32             : import com.google.common.collect.Streams;
      33             : import com.google.common.flogger.FluentLogger;
      34             : import com.google.gerrit.common.Nullable;
      35             : import com.google.gerrit.entities.Account;
      36             : import com.google.gerrit.entities.AccountGroup;
      37             : import com.google.gerrit.entities.Address;
      38             : import com.google.gerrit.entities.BooleanProjectConfig;
      39             : import com.google.gerrit.entities.BranchNameKey;
      40             : import com.google.gerrit.entities.Change;
      41             : import com.google.gerrit.entities.GroupDescription;
      42             : import com.google.gerrit.entities.PatchSet;
      43             : import com.google.gerrit.entities.PatchSetApproval;
      44             : import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
      45             : import com.google.gerrit.extensions.api.changes.NotifyHandling;
      46             : import com.google.gerrit.extensions.api.changes.ReviewerInfo;
      47             : import com.google.gerrit.extensions.api.changes.ReviewerInput;
      48             : import com.google.gerrit.extensions.api.changes.ReviewerResult;
      49             : import com.google.gerrit.extensions.client.ReviewerState;
      50             : import com.google.gerrit.extensions.common.AccountInfo;
      51             : import com.google.gerrit.extensions.restapi.RestApiException;
      52             : import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
      53             : import com.google.gerrit.server.AnonymousUser;
      54             : import com.google.gerrit.server.CurrentUser;
      55             : import com.google.gerrit.server.IdentifiedUser;
      56             : import com.google.gerrit.server.account.AccountLoader;
      57             : import com.google.gerrit.server.account.AccountResolver;
      58             : import com.google.gerrit.server.account.GroupMembers;
      59             : import com.google.gerrit.server.config.GerritServerConfig;
      60             : import com.google.gerrit.server.group.GroupResolver;
      61             : import com.google.gerrit.server.group.SystemGroupBackend;
      62             : import com.google.gerrit.server.logging.Metadata;
      63             : import com.google.gerrit.server.logging.TraceContext;
      64             : import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
      65             : import com.google.gerrit.server.notedb.ChangeNotes;
      66             : import com.google.gerrit.server.permissions.ChangePermission;
      67             : import com.google.gerrit.server.permissions.PermissionBackend;
      68             : import com.google.gerrit.server.permissions.PermissionBackendException;
      69             : import com.google.gerrit.server.permissions.RefPermission;
      70             : import com.google.gerrit.server.project.NoSuchProjectException;
      71             : import com.google.gerrit.server.project.ProjectCache;
      72             : import com.google.gerrit.server.query.change.ChangeData;
      73             : import com.google.gerrit.server.update.ChangeContext;
      74             : import com.google.gerrit.server.update.PostUpdateContext;
      75             : import com.google.inject.Inject;
      76             : import com.google.inject.Provider;
      77             : import java.io.IOException;
      78             : import java.text.MessageFormat;
      79             : import java.util.ArrayList;
      80             : import java.util.Collection;
      81             : import java.util.HashSet;
      82             : import java.util.List;
      83             : import java.util.Optional;
      84             : import java.util.Set;
      85             : import java.util.function.Function;
      86             : import java.util.stream.Stream;
      87             : import org.eclipse.jgit.errors.ConfigInvalidException;
      88             : import org.eclipse.jgit.lib.Config;
      89             : import org.eclipse.jgit.lib.ObjectId;
      90             : 
      91             : public class ReviewerModifier {
      92         146 :   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
      93             : 
      94             :   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
      95             :   public static final int DEFAULT_MAX_REVIEWERS = 20;
      96             : 
      97             :   /**
      98             :    * Controls which failures should be ignored.
      99             :    *
     100             :    * <p>If a failure is ignored the operation succeeds, but the reviewer is not added. If not
     101             :    * ignored a failure means that the operation fails.
     102             :    */
     103          37 :   public enum FailureBehavior {
     104             :     // All failures cause the operation to fail.
     105          37 :     FAIL,
     106             : 
     107             :     // Only not found failures cause the operation to fail, all other failures are ignored.
     108          37 :     IGNORE_EXCEPT_NOT_FOUND,
     109             : 
     110             :     // All failures are ignored.
     111          37 :     IGNORE_ALL;
     112             :   }
     113             : 
     114          39 :   private enum FailureType {
     115          39 :     NOT_FOUND,
     116          39 :     OTHER;
     117             :   }
     118             : 
     119             :   // TODO(dborowitz): Subclassing is not the right way to do this. We should instead use an internal
     120             :   // type in the public interfaces of ReviewerModifier, rather than passing around the REST API type
     121             :   // internally.
     122          37 :   public static class InternalReviewerInput extends ReviewerInput {
     123             :     /**
     124             :      * Behavior when identifying reviewers fails for any reason <em>besides</em> the input not
     125             :      * resolving to an account/group/email.
     126             :      */
     127          37 :     public FailureBehavior otherFailureBehavior = FailureBehavior.FAIL;
     128             : 
     129             :     /** Whether the visibility check for the reviewer account should be skipped. */
     130          37 :     public boolean skipVisibilityCheck = false;
     131             :   }
     132             : 
     133             :   public static InternalReviewerInput newReviewerInput(
     134             :       String reviewer, ReviewerState state, NotifyHandling notify) {
     135          29 :     InternalReviewerInput in = new InternalReviewerInput();
     136          29 :     in.reviewer = reviewer;
     137          29 :     in.state = state;
     138          29 :     in.notify = notify;
     139          29 :     return in;
     140             :   }
     141             : 
     142             :   public static Optional<InternalReviewerInput> newReviewerInputFromCommitIdentity(
     143             :       Change change,
     144             :       ObjectId commitId,
     145             :       @Nullable Account.Id accountId,
     146             :       NotifyHandling notify,
     147             :       Account.Id mostRecentUploader) {
     148         103 :     if (accountId == null || accountId.equals(mostRecentUploader)) {
     149             :       // If git ident couldn't be resolved to a user, or if it's not forged, do nothing.
     150         103 :       return Optional.empty();
     151             :     }
     152             : 
     153          16 :     logger.atFine().log(
     154             :         "Adding account %d from author/committer identity of commit %s as cc to change %d",
     155          16 :         accountId.get(), commitId.name(), change.getChangeId());
     156             : 
     157          16 :     InternalReviewerInput in = new InternalReviewerInput();
     158          16 :     in.reviewer = accountId.toString();
     159          16 :     in.state = CC;
     160          16 :     in.notify = notify;
     161          16 :     in.otherFailureBehavior = FailureBehavior.IGNORE_ALL;
     162          16 :     return Optional.of(in);
     163             :   }
     164             : 
     165             :   private final AccountResolver accountResolver;
     166             :   private final PermissionBackend permissionBackend;
     167             :   private final GroupResolver groupResolver;
     168             :   private final GroupMembers groupMembers;
     169             :   private final AccountLoader.Factory accountLoaderFactory;
     170             :   private final Config cfg;
     171             :   private final ReviewerJson json;
     172             :   private final ProjectCache projectCache;
     173             :   private final Provider<AnonymousUser> anonymousProvider;
     174             :   private final AddReviewersOp.Factory addReviewersOpFactory;
     175             :   private final OutgoingEmailValidator validator;
     176             :   private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
     177             :   private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
     178             : 
     179             :   @Inject
     180             :   ReviewerModifier(
     181             :       AccountResolver accountResolver,
     182             :       PermissionBackend permissionBackend,
     183             :       GroupResolver groupResolver,
     184             :       GroupMembers groupMembers,
     185             :       AccountLoader.Factory accountLoaderFactory,
     186             :       @GerritServerConfig Config cfg,
     187             :       ReviewerJson json,
     188             :       ProjectCache projectCache,
     189             :       Provider<AnonymousUser> anonymousProvider,
     190             :       AddReviewersOp.Factory addReviewersOpFactory,
     191             :       OutgoingEmailValidator validator,
     192             :       DeleteReviewerOp.Factory deleteReviewerOpFactory,
     193         146 :       DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) {
     194         146 :     this.accountResolver = accountResolver;
     195         146 :     this.permissionBackend = permissionBackend;
     196         146 :     this.groupResolver = groupResolver;
     197         146 :     this.groupMembers = groupMembers;
     198         146 :     this.accountLoaderFactory = accountLoaderFactory;
     199         146 :     this.cfg = cfg;
     200         146 :     this.json = json;
     201         146 :     this.projectCache = projectCache;
     202         146 :     this.anonymousProvider = anonymousProvider;
     203         146 :     this.addReviewersOpFactory = addReviewersOpFactory;
     204         146 :     this.validator = validator;
     205         146 :     this.deleteReviewerOpFactory = deleteReviewerOpFactory;
     206         146 :     this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
     207         146 :   }
     208             : 
     209             :   /**
     210             :    * Prepare application of a single {@link ReviewerInput}.
     211             :    *
     212             :    * @param notes change notes.
     213             :    * @param user user performing the reviewer addition.
     214             :    * @param input input describing user or group to add as a reviewer.
     215             :    * @param allowGroup whether to allow
     216             :    * @return handle describing the addition operation. If the {@code op} field is present, this
     217             :    *     operation may be added to a {@code BatchUpdate}. Otherwise, the {@code error} field
     218             :    *     contains information about an error that occurred
     219             :    */
     220             :   public ReviewerModification prepare(
     221             :       ChangeNotes notes, CurrentUser user, ReviewerInput input, boolean allowGroup)
     222             :       throws IOException, PermissionBackendException, ConfigInvalidException {
     223          39 :     try (TraceContext.TraceTimer ignored =
     224          39 :         TraceContext.newTimer(getClass().getSimpleName() + "#prepare", Metadata.empty())) {
     225          39 :       requireNonNull(input.reviewer);
     226          39 :       boolean confirmed = input.confirmed();
     227          39 :       boolean allowByEmail =
     228             :           projectCache
     229          39 :               .get(notes.getProjectName())
     230          39 :               .orElseThrow(illegalState(notes.getProjectName()))
     231          39 :               .is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL);
     232             : 
     233          39 :       ReviewerModification byAccountId = byAccountId(input, notes, user);
     234             : 
     235          39 :       ReviewerModification wholeGroup = null;
     236          39 :       if (!byAccountId.exactMatchFound) {
     237          27 :         wholeGroup = addWholeGroup(input, notes, user, confirmed, allowGroup, allowByEmail);
     238          27 :         if (wholeGroup != null && wholeGroup.exactMatchFound) {
     239           5 :           return wholeGroup;
     240             :         }
     241             :       }
     242             : 
     243          39 :       if (wholeGroup != null
     244             :           && byAccountId.failureType == FailureType.NOT_FOUND
     245             :           && wholeGroup.failureType == FailureType.NOT_FOUND) {
     246           5 :         return fail(
     247             :             byAccountId.input,
     248             :             FailureType.NOT_FOUND,
     249             :             byAccountId.result.error + "\n" + wholeGroup.result.error);
     250             :       }
     251             : 
     252          39 :       if (byAccountId.failureType != FailureType.NOT_FOUND) {
     253          39 :         return byAccountId;
     254             :       }
     255          10 :       if (wholeGroup != null) {
     256           2 :         return wholeGroup;
     257             :       }
     258             : 
     259          10 :       return addByEmail(input, notes, user);
     260          39 :     }
     261             :   }
     262             : 
     263             :   public ReviewerModification ccCurrentUser(CurrentUser user, RevisionResource revision) {
     264          23 :     return new ReviewerModification(
     265          23 :         newReviewerInput(user.getUserName().orElse(null), CC, NotifyHandling.NONE),
     266          23 :         revision.getNotes(),
     267          23 :         revision.getUser(),
     268          23 :         ImmutableSet.of(user.asIdentifiedUser().getAccount()),
     269             :         null,
     270             :         true,
     271             :         false);
     272             :   }
     273             : 
     274             :   @Nullable
     275             :   private ReviewerModification byAccountId(ReviewerInput input, ChangeNotes notes, CurrentUser user)
     276             :       throws PermissionBackendException, IOException, ConfigInvalidException {
     277             :     IdentifiedUser reviewerUser;
     278          39 :     boolean exactMatchFound = false;
     279             :     try {
     280          39 :       if (input instanceof InternalReviewerInput
     281             :           && ((InternalReviewerInput) input).skipVisibilityCheck) {
     282           4 :         reviewerUser =
     283           4 :             accountResolver.resolveIncludeInactiveIgnoreVisibility(input.reviewer).asUniqueUser();
     284             :       } else {
     285          39 :         reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
     286             :       }
     287          39 :       if (input.reviewer.equalsIgnoreCase(reviewerUser.getName())
     288          37 :           || input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
     289          26 :         exactMatchFound = true;
     290             :       }
     291          11 :     } catch (UnprocessableEntityException e) {
     292             :       // Caller might choose to ignore this NOT_FOUND result if they find another result e.g. by
     293             :       // group, but if not, the error message will be useful.
     294          11 :       return fail(input, FailureType.NOT_FOUND, e.getMessage());
     295          39 :     }
     296             : 
     297          39 :     if (isValidReviewer(notes.getChange().getDest(), reviewerUser.getAccount())) {
     298          39 :       return new ReviewerModification(
     299             :           input,
     300             :           notes,
     301             :           user,
     302          39 :           ImmutableSet.of(reviewerUser.getAccount()),
     303             :           null,
     304             :           exactMatchFound,
     305             :           false);
     306             :     }
     307           1 :     return fail(
     308             :         input,
     309             :         FailureType.OTHER,
     310           1 :         MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
     311             :   }
     312             : 
     313             :   @Nullable
     314             :   private ReviewerModification addWholeGroup(
     315             :       ReviewerInput input,
     316             :       ChangeNotes notes,
     317             :       CurrentUser user,
     318             :       boolean confirmed,
     319             :       boolean allowGroup,
     320             :       boolean allowByEmail)
     321             :       throws IOException, PermissionBackendException {
     322          27 :     if (!allowGroup) {
     323           6 :       return null;
     324             :     }
     325             : 
     326             :     GroupDescription.Basic group;
     327             :     try {
     328             :       // TODO(dborowitz): This currently doesn't work in the push path because InternalGroupBackend
     329             :       // depends on the Provider<CurrentUser> which returns anonymous in that path.
     330           5 :       group = groupResolver.parseInternal(input.reviewer);
     331          27 :     } catch (UnprocessableEntityException e) {
     332          27 :       if (!allowByEmail) {
     333          22 :         return fail(
     334             :             input,
     335             :             FailureType.NOT_FOUND,
     336          22 :             MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, input.reviewer));
     337             :       }
     338          10 :       return null;
     339           5 :     }
     340             : 
     341           5 :     if (!isLegalReviewerGroup(group.getGroupUUID())) {
     342           0 :       return fail(
     343             :           input,
     344             :           FailureType.OTHER,
     345           0 :           MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
     346             :     }
     347             : 
     348           5 :     if (input.state().equals(REMOVED)) {
     349           1 :       return fail(
     350             :           input,
     351             :           FailureType.OTHER,
     352           1 :           MessageFormat.format(ChangeMessages.get().groupRemovalIsNotAllowed, group.getName()));
     353             :     }
     354             : 
     355           5 :     Set<Account> reviewers = new HashSet<>();
     356             :     Set<Account> members;
     357             :     try {
     358           5 :       members = groupMembers.listAccounts(group.getGroupUUID(), notes.getProjectName());
     359           0 :     } catch (NoSuchProjectException e) {
     360           0 :       return fail(input, FailureType.OTHER, e.getMessage());
     361           5 :     }
     362             : 
     363             :     // if maxAllowed is set to 0, it is allowed to add any number of
     364             :     // reviewers
     365           5 :     int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
     366           5 :     if (maxAllowed > 0 && members.size() > maxAllowed) {
     367           1 :       logger.atFine().log(
     368           1 :           "Adding %d group members is not allowed (maxAllowed = %d)", members.size(), maxAllowed);
     369           1 :       return fail(
     370             :           input,
     371             :           FailureType.OTHER,
     372           1 :           MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
     373             :     }
     374             : 
     375             :     // if maxWithoutCheck is set to 0, we never ask for confirmation
     376           5 :     int maxWithoutConfirmation =
     377           5 :         cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
     378           5 :     if (!confirmed && maxWithoutConfirmation > 0 && members.size() > maxWithoutConfirmation) {
     379           1 :       logger.atFine().log(
     380             :           "Adding %d group members as reviewer requires confirmation (maxWithoutConfirmation = %d)",
     381           1 :           members.size(), maxWithoutConfirmation);
     382           1 :       return fail(
     383             :           input,
     384             :           FailureType.OTHER,
     385             :           true,
     386           1 :           MessageFormat.format(
     387           1 :               ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
     388             :     }
     389             : 
     390           5 :     for (Account member : members) {
     391           5 :       if (isValidReviewer(notes.getChange().getDest(), member)) {
     392           5 :         reviewers.add(member);
     393             :       }
     394           5 :     }
     395             : 
     396           5 :     return new ReviewerModification(input, notes, user, reviewers, null, true, true);
     397             :   }
     398             : 
     399             :   @Nullable
     400             :   private ReviewerModification addByEmail(ReviewerInput input, ChangeNotes notes, CurrentUser user)
     401             :       throws PermissionBackendException {
     402          10 :     if (!permissionBackend
     403          10 :         .user(anonymousProvider.get())
     404          10 :         .change(notes)
     405          10 :         .test(ChangePermission.READ)) {
     406           0 :       return fail(
     407             :           input,
     408             :           FailureType.OTHER,
     409           0 :           MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
     410             :     }
     411             : 
     412          10 :     Address adr = Address.tryParse(input.reviewer);
     413          10 :     if (adr == null || !validator.isValid(adr.email())) {
     414           1 :       return fail(
     415             :           input,
     416             :           FailureType.NOT_FOUND,
     417           1 :           MessageFormat.format(ChangeMessages.get().reviewerInvalid, input.reviewer));
     418             :     }
     419          10 :     return new ReviewerModification(input, notes, user, null, ImmutableList.of(adr), true, false);
     420             :   }
     421             : 
     422             :   private boolean isValidReviewer(BranchNameKey branch, Account member)
     423             :       throws PermissionBackendException {
     424             :     // Check ref permission instead of change permission, since change permissions take into
     425             :     // account the private bit, whereas adding a user as a reviewer is explicitly allowing them to
     426             :     // see private changes.
     427          39 :     return permissionBackend.absentUser(member.id()).ref(branch).test(RefPermission.READ);
     428             :   }
     429             : 
     430             :   private ReviewerModification fail(ReviewerInput input, FailureType failureType, String error) {
     431          27 :     return fail(input, failureType, false, error);
     432             :   }
     433             : 
     434             :   private ReviewerModification fail(
     435             :       ReviewerInput input, FailureType failureType, boolean confirm, String error) {
     436          27 :     ReviewerModification addition = new ReviewerModification(input, failureType);
     437          27 :     addition.result.confirm = confirm ? true : null;
     438          27 :     addition.result.error = error;
     439          27 :     return addition;
     440             :   }
     441             : 
     442             :   public class ReviewerModification {
     443             :     public final ReviewerResult result;
     444             :     @Nullable public final ReviewerOp op;
     445             :     public final ImmutableSet<Account> reviewers;
     446             :     public final ImmutableSet<Address> reviewersByEmail;
     447             :     @Nullable final IdentifiedUser caller;
     448             :     final boolean exactMatchFound;
     449             :     private final ReviewerInput input;
     450             :     @Nullable private final FailureType failureType;
     451             : 
     452          27 :     private ReviewerModification(ReviewerInput input, FailureType failureType) {
     453          27 :       this.input = input;
     454          27 :       this.failureType = requireNonNull(failureType);
     455          27 :       result = new ReviewerResult(input.reviewer);
     456          27 :       op = null;
     457          27 :       reviewers = ImmutableSet.of();
     458          27 :       reviewersByEmail = ImmutableSet.of();
     459          27 :       caller = null;
     460          27 :       exactMatchFound = false;
     461          27 :     }
     462             : 
     463             :     private ReviewerModification(
     464             :         ReviewerInput input,
     465             :         ChangeNotes notes,
     466             :         CurrentUser caller,
     467             :         @Nullable Iterable<Account> reviewers,
     468             :         @Nullable Iterable<Address> reviewersByEmail,
     469             :         boolean exactMatchFound,
     470          46 :         boolean forGroup) {
     471          46 :       checkArgument(
     472             :           reviewers != null || reviewersByEmail != null,
     473             :           "must have either reviewers or reviewersByEmail");
     474             : 
     475          46 :       this.input = input;
     476          46 :       this.failureType = null;
     477          46 :       result = new ReviewerResult(input.reviewer);
     478          46 :       if (!state().equals(REMOVED)) {
     479             :         // Always silently ignore adding the owner as any type of reviewer on their own change. They
     480             :         // may still be implicitly added as a reviewer if they vote, but not via the reviewer API.
     481          46 :         this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ true);
     482             :       } else {
     483           3 :         this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ false);
     484             :       }
     485          46 :       this.reviewersByEmail =
     486          46 :           reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail);
     487          46 :       this.caller = caller.asIdentifiedUser();
     488          46 :       if (state().equals(REMOVED)) {
     489             :         // only one is set.
     490           3 :         checkState(
     491           3 :             (this.reviewers.size() == 1 && this.reviewersByEmail.isEmpty())
     492           3 :                 || (this.reviewers.isEmpty() && this.reviewersByEmail.size() == 1));
     493           3 :         if (this.reviewers.size() >= 1) {
     494           3 :           checkState(this.reviewers.size() == 1);
     495           3 :           DeleteReviewerInput deleteReviewerInput = new DeleteReviewerInput();
     496           3 :           deleteReviewerInput.notify = input.notify;
     497           3 :           deleteReviewerInput.notifyDetails = input.notifyDetails;
     498           3 :           op =
     499           3 :               deleteReviewerOpFactory.create(
     500           3 :                   Iterables.getOnlyElement(this.reviewers.asList()), deleteReviewerInput);
     501           3 :         } else {
     502           1 :           checkState(this.reviewersByEmail.size() == 1);
     503           1 :           op =
     504           1 :               deleteReviewerByEmailOpFactory.create(
     505           1 :                   Iterables.getOnlyElement(this.reviewersByEmail.asList()));
     506             :         }
     507             :       } else {
     508          46 :         op =
     509          46 :             addReviewersOpFactory.create(
     510          46 :                 this.reviewers.stream().map(Account::id).collect(toImmutableSet()),
     511             :                 this.reviewersByEmail,
     512          46 :                 state(),
     513             :                 forGroup);
     514             :       }
     515          46 :       this.exactMatchFound = exactMatchFound;
     516          46 :     }
     517             : 
     518             :     private ImmutableSet<Account> reviewersAsList(
     519             :         ChangeNotes notes, @Nullable Iterable<Account> reviewers, boolean omitChangeOwner) {
     520          46 :       if (reviewers == null) {
     521          10 :         return ImmutableSet.of();
     522             :       }
     523             : 
     524          46 :       Stream<Account> reviewerStream = Streams.stream(reviewers);
     525          46 :       if (omitChangeOwner) {
     526          46 :         reviewerStream =
     527          46 :             reviewerStream.filter(account -> !account.id().equals(notes.getChange().getOwner()));
     528             :       }
     529          46 :       return reviewerStream.collect(toImmutableSet());
     530             :     }
     531             : 
     532             :     public void gatherResults(ChangeData cd) throws PermissionBackendException {
     533          30 :       checkState(op != null, "addition did not result in an update op");
     534          30 :       checkState(op.getResult() != null, "op did not return a result");
     535             : 
     536             :       // Generate result details and fill AccountLoader. This occurs outside
     537             :       // the Op because the accounts are in a different table.
     538          30 :       ReviewerOp.Result opResult = op.getResult();
     539          30 :       switch (state()) {
     540             :         case CC:
     541          12 :           result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
     542          12 :           for (Account.Id accountId : opResult.addedCCs()) {
     543          12 :             result.ccs.add(json.format(new ReviewerInfo(accountId.get()), accountId, cd));
     544          12 :           }
     545          12 :           accountLoaderFactory.create(true).fill(result.ccs);
     546          12 :           for (Address a : opResult.addedCCsByEmail()) {
     547           7 :             result.ccs.add(new AccountInfo(a.name(), a.email()));
     548           7 :           }
     549          12 :           break;
     550             :         case REVIEWER:
     551          30 :           result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
     552          30 :           for (PatchSetApproval psa : opResult.addedReviewers()) {
     553             :             // New reviewers have value 0, don't bother normalizing.
     554          30 :             result.reviewers.add(
     555          30 :                 json.format(
     556          30 :                     new ReviewerInfo(psa.accountId().get()),
     557          30 :                     psa.accountId(),
     558             :                     cd,
     559          30 :                     ImmutableList.of(psa)));
     560          30 :           }
     561          30 :           accountLoaderFactory.create(true).fill(result.reviewers);
     562          30 :           for (Address a : opResult.addedReviewersByEmail()) {
     563           7 :             result.reviewers.add(ReviewerInfo.byEmail(a.name(), a.email()));
     564           7 :           }
     565          30 :           break;
     566             :         case REMOVED:
     567           3 :           if (opResult.deletedReviewer().isPresent()) {
     568           3 :             result.removed =
     569           3 :                 json.format(
     570           3 :                     new ReviewerInfo(opResult.deletedReviewer().get().get()),
     571           3 :                     opResult.deletedReviewer().get(),
     572             :                     cd);
     573           3 :             accountLoaderFactory.create(true).fill(ImmutableList.of(result.removed));
     574           1 :           } else if (opResult.deletedReviewerByEmail().isPresent()) {
     575           1 :             result.removed =
     576             :                 new AccountInfo(
     577           1 :                     opResult.deletedReviewerByEmail().get().name(),
     578           1 :                     opResult.deletedReviewerByEmail().get().email());
     579             :           }
     580             :           break;
     581             :         default:
     582           0 :           throw new IllegalStateException(
     583           0 :               String.format("Illegal ReviewerState argument is %s", state().name()));
     584             :       }
     585          30 :     }
     586             : 
     587             :     public ReviewerState state() {
     588          46 :       return input.state();
     589             :     }
     590             : 
     591             :     public boolean isFailure() {
     592          19 :       return failureType != null;
     593             :     }
     594             : 
     595             :     public boolean isIgnorableFailure() {
     596           5 :       checkState(failureType != null);
     597             :       FailureBehavior behavior =
     598           5 :           (input instanceof InternalReviewerInput)
     599           5 :               ? ((InternalReviewerInput) input).otherFailureBehavior
     600           5 :               : FailureBehavior.FAIL;
     601           5 :       return behavior == FailureBehavior.IGNORE_ALL
     602             :           || (failureType == FailureType.OTHER
     603             :               && behavior == FailureBehavior.IGNORE_EXCEPT_NOT_FOUND);
     604             :     }
     605             :   }
     606             : 
     607             :   public static boolean isLegalReviewerGroup(AccountGroup.UUID groupUUID) {
     608           5 :     return !SystemGroupBackend.isSystemGroup(groupUUID);
     609             :   }
     610             : 
     611             :   public ReviewerModificationList prepare(
     612             :       ChangeNotes notes,
     613             :       CurrentUser user,
     614             :       Iterable<? extends ReviewerInput> inputs,
     615             :       boolean allowGroup)
     616             :       throws IOException, PermissionBackendException, ConfigInvalidException {
     617             :     // Process CC ops before reviewer ops, so a user that appears in both lists ends up as a
     618             :     // reviewer; the last call to ChangeUpdate#putReviewer wins. This can happen if the caller
     619             :     // specifies the same string twice, or less obviously if they specify multiple groups with
     620             :     // overlapping members.
     621             :     // TODO(dborowitz): Consider changing interface to allow excluding reviewers that were
     622             :     // previously processed, to proactively prevent overlap so we don't have to rely on this subtle
     623             :     // behavior.
     624         103 :     ImmutableList<ReviewerInput> sorted =
     625         103 :         Streams.stream(inputs)
     626         103 :             .sorted(
     627         103 :                 comparing(
     628             :                     ReviewerInput::state,
     629         103 :                     Ordering.explicit(ReviewerState.CC, ReviewerState.REVIEWER)))
     630         103 :             .collect(toImmutableList());
     631         103 :     List<ReviewerModification> additions = new ArrayList<>();
     632         103 :     for (ReviewerInput input : sorted) {
     633          19 :       ReviewerModification addition = prepare(notes, user, input, allowGroup);
     634          19 :       if (addition.op != null) {
     635             :         // Assume any callers preparing a list of batch insertions are handling their own email.
     636          19 :         addition.op.suppressEmail();
     637             :       }
     638          19 :       additions.add(addition);
     639          19 :     }
     640         103 :     return new ReviewerModificationList(additions);
     641             :   }
     642             : 
     643             :   // TODO(dborowitz): This class works, but ultimately feels wrong. It seems like an op but isn't
     644             :   // really an op, it's a collection of ops, and it's only called from the body of other ops. We
     645             :   // could make this class an op, but we would still have AddReviewersOp. Better would probably be
     646             :   // to design a single op that supports combining multiple ReviewerInputs together. That would
     647             :   // probably also subsume the Addition class itself, which would be a good thing.
     648             :   public static class ReviewerModificationList {
     649             :     private final ImmutableList<ReviewerModification> modifications;
     650             : 
     651         103 :     private ReviewerModificationList(List<ReviewerModification> modifications) {
     652         103 :       this.modifications = ImmutableList.copyOf(modifications);
     653         103 :     }
     654             : 
     655             :     public ImmutableList<ReviewerModification> getFailures() {
     656         103 :       return modifications.stream()
     657         103 :           .filter(a -> a.isFailure() && !a.isIgnorableFailure())
     658         103 :           .collect(toImmutableList());
     659             :     }
     660             : 
     661             :     // We never call updateRepo on the addition ops, which is only ok because it's a no-op.
     662             : 
     663             :     public void updateChange(ChangeContext ctx, PatchSet patchSet)
     664             :         throws RestApiException, IOException, PermissionBackendException {
     665         103 :       for (ReviewerModification addition : modifications()) {
     666          19 :         addition.op.setPatchSet(patchSet);
     667          19 :         addition.op.updateChange(ctx);
     668          19 :       }
     669         103 :     }
     670             : 
     671             :     public void postUpdate(PostUpdateContext ctx) throws Exception {
     672         103 :       for (ReviewerModification addition : modifications()) {
     673          19 :         if (addition.op != null) {
     674          19 :           addition.op.postUpdate(ctx);
     675             :         }
     676          19 :       }
     677         103 :     }
     678             : 
     679             :     public <T> ImmutableSet<T> flattenResults(
     680             :         Function<ReviewerOp.Result, ? extends Collection<T>> func) {
     681         103 :       modifications()
     682         103 :           .forEach(
     683             :               a ->
     684          19 :                   checkArgument(
     685          19 :                       a.op != null && a.op.getResult() != null, "missing result on %s", a));
     686         103 :       return modifications().stream()
     687         103 :           .map(a -> a.op.getResult())
     688         103 :           .map(func)
     689         103 :           .flatMap(Collection::stream)
     690         103 :           .collect(toImmutableSet());
     691             :     }
     692             : 
     693             :     private ImmutableList<ReviewerModification> modifications() {
     694         103 :       return modifications.stream()
     695         103 :           .filter(
     696             :               a -> {
     697          19 :                 if (a.isFailure()) {
     698           5 :                   if (a.isIgnorableFailure()) {
     699           5 :                     return false;
     700             :                   }
     701             :                   // Shouldn't happen, caller should have checked that there were no errors.
     702           0 :                   throw new IllegalStateException("error in addition: " + a.result.error);
     703             :                 }
     704          19 :                 return true;
     705             :               })
     706         103 :           .collect(toImmutableList());
     707             :     }
     708             :   }
     709             : }

Generated by: LCOV version 1.16+git.20220603.dfeb750