LCOV - code coverage report
Current view: top level - server/approval - ApprovalsUtil.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 247 272 90.8 %
Date: 2022-11-19 15:00:39 Functions: 39 41 95.1 %

          Line data    Source code
       1             : // Copyright (C) 2009 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.approval;
      16             : 
      17             : import static com.google.common.base.Preconditions.checkArgument;
      18             : import static com.google.common.collect.ImmutableList.toImmutableList;
      19             : import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
      20             : import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
      21             : import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
      22             : import static com.google.gerrit.server.project.ProjectCache.illegalState;
      23             : import static java.util.Comparator.comparing;
      24             : import static java.util.Objects.requireNonNull;
      25             : import static java.util.stream.Collectors.joining;
      26             : 
      27             : import com.google.common.annotations.VisibleForTesting;
      28             : import com.google.common.collect.ArrayListMultimap;
      29             : import com.google.common.collect.ImmutableList;
      30             : import com.google.common.collect.ImmutableListMultimap;
      31             : import com.google.common.collect.ImmutableSet;
      32             : import com.google.common.collect.Iterables;
      33             : import com.google.common.collect.ListMultimap;
      34             : import com.google.common.collect.Lists;
      35             : import com.google.common.collect.Multimap;
      36             : import com.google.common.collect.Multimaps;
      37             : import com.google.common.collect.Sets;
      38             : import com.google.common.flogger.FluentLogger;
      39             : import com.google.gerrit.common.Nullable;
      40             : import com.google.gerrit.entities.Account;
      41             : import com.google.gerrit.entities.AttentionSetUpdate;
      42             : import com.google.gerrit.entities.Change;
      43             : import com.google.gerrit.entities.LabelId;
      44             : import com.google.gerrit.entities.LabelType;
      45             : import com.google.gerrit.entities.LabelTypes;
      46             : import com.google.gerrit.entities.PatchSet;
      47             : import com.google.gerrit.entities.PatchSetApproval;
      48             : import com.google.gerrit.entities.PatchSetInfo;
      49             : import com.google.gerrit.exceptions.StorageException;
      50             : import com.google.gerrit.extensions.restapi.AuthException;
      51             : import com.google.gerrit.extensions.restapi.BadRequestException;
      52             : import com.google.gerrit.extensions.restapi.RestApiException;
      53             : import com.google.gerrit.index.query.QueryParseException;
      54             : import com.google.gerrit.server.CurrentUser;
      55             : import com.google.gerrit.server.ReviewerSet;
      56             : import com.google.gerrit.server.ReviewerStatusUpdate;
      57             : import com.google.gerrit.server.account.AccountCache;
      58             : import com.google.gerrit.server.change.LabelNormalizer;
      59             : import com.google.gerrit.server.config.AnonymousCowardName;
      60             : import com.google.gerrit.server.notedb.ChangeNotes;
      61             : import com.google.gerrit.server.notedb.ChangeUpdate;
      62             : import com.google.gerrit.server.notedb.ReviewerStateInternal;
      63             : import com.google.gerrit.server.permissions.ChangePermission;
      64             : import com.google.gerrit.server.permissions.LabelPermission;
      65             : import com.google.gerrit.server.permissions.PermissionBackend;
      66             : import com.google.gerrit.server.permissions.PermissionBackendException;
      67             : import com.google.gerrit.server.project.ProjectCache;
      68             : import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
      69             : import com.google.gerrit.server.query.approval.UserInPredicate;
      70             : import com.google.gerrit.server.util.AccountTemplateUtil;
      71             : import com.google.gerrit.server.util.LabelVote;
      72             : import com.google.gerrit.server.util.ManualRequestContext;
      73             : import com.google.gerrit.server.util.OneOffRequestContext;
      74             : import com.google.inject.Inject;
      75             : import com.google.inject.Provider;
      76             : import com.google.inject.Singleton;
      77             : import java.time.Instant;
      78             : import java.util.ArrayList;
      79             : import java.util.Collection;
      80             : import java.util.Collections;
      81             : import java.util.HashSet;
      82             : import java.util.LinkedHashSet;
      83             : import java.util.List;
      84             : import java.util.Map;
      85             : import java.util.Objects;
      86             : import java.util.Optional;
      87             : import java.util.Set;
      88             : import org.eclipse.jgit.lib.Config;
      89             : import org.eclipse.jgit.revwalk.RevWalk;
      90             : 
      91             : /**
      92             :  * Utility functions to manipulate patchset approvals.
      93             :  *
      94             :  * <p>Approvals are overloaded, they represent both approvals and reviewers which should be CCed on
      95             :  * a change. To ensure that reviewers are not lost there must always be an approval on each patchset
      96             :  * for each reviewer, even if the reviewer hasn't actually given a score to the change. To mark the
      97             :  * "no score" case, a dummy approval, which may live in any of the available categories, with a
      98             :  * score of 0 is used.
      99             :  */
     100             : @Singleton
     101             : public class ApprovalsUtil {
     102         146 :   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
     103             : 
     104             :   public static PatchSetApproval.Builder newApproval(
     105             :       PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Instant when) {
     106             :     PatchSetApproval.Builder b =
     107          65 :         PatchSetApproval.builder()
     108          65 :             .key(PatchSetApproval.key(psId, user.getAccountId(), labelId))
     109          65 :             .value(value)
     110          65 :             .granted(when);
     111          65 :     user.updateRealAccountId(b::realAccountId);
     112          65 :     return b;
     113             :   }
     114             : 
     115             :   private static Iterable<PatchSetApproval> filterApprovals(
     116             :       Iterable<PatchSetApproval> psas, Account.Id accountId) {
     117          67 :     return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId));
     118             :   }
     119             : 
     120             :   private final AccountCache accountCache;
     121             :   private final String anonymousCowardName;
     122             :   private final ApprovalCopier approvalCopier;
     123             :   private final Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider;
     124             :   private final PermissionBackend permissionBackend;
     125             :   private final ProjectCache projectCache;
     126             :   private final LabelNormalizer labelNormalizer;
     127             :   private final OneOffRequestContext requestContext;
     128             : 
     129             :   @VisibleForTesting
     130             :   @Inject
     131             :   public ApprovalsUtil(
     132             :       AccountCache accountCache,
     133             :       @AnonymousCowardName String anonymousCowardName,
     134             :       ApprovalCopier approvalCopier,
     135             :       Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider,
     136             :       PermissionBackend permissionBackend,
     137             :       ProjectCache projectCache,
     138             :       LabelNormalizer labelNormalizer,
     139         146 :       OneOffRequestContext requestContext) {
     140         146 :     this.accountCache = accountCache;
     141         146 :     this.anonymousCowardName = anonymousCowardName;
     142         146 :     this.approvalCopier = approvalCopier;
     143         146 :     this.approvalQueryBuilderProvider = approvalQueryBuilderProvider;
     144         146 :     this.permissionBackend = permissionBackend;
     145         146 :     this.projectCache = projectCache;
     146         146 :     this.labelNormalizer = labelNormalizer;
     147         146 :     this.requestContext = requestContext;
     148         146 :   }
     149             : 
     150             :   /**
     151             :    * Get all reviewers for a change.
     152             :    *
     153             :    * @param notes change notes.
     154             :    * @return reviewers for the change.
     155             :    */
     156             :   public ReviewerSet getReviewers(ChangeNotes notes) {
     157         103 :     return notes.load().getReviewers();
     158             :   }
     159             : 
     160             :   /**
     161             :    * Get updates to reviewer set.
     162             :    *
     163             :    * @param notes change notes.
     164             :    * @return reviewer updates for the change.
     165             :    */
     166             :   public List<ReviewerStatusUpdate> getReviewerUpdates(ChangeNotes notes) {
     167         103 :     return notes.load().getReviewerUpdates();
     168             :   }
     169             : 
     170             :   public List<PatchSetApproval> addReviewers(
     171             :       ChangeUpdate update,
     172             :       LabelTypes labelTypes,
     173             :       Change change,
     174             :       PatchSet ps,
     175             :       PatchSetInfo info,
     176             :       Iterable<Account.Id> wantReviewers,
     177             :       Collection<Account.Id> existingReviewers) {
     178           0 :     return addReviewers(
     179             :         update,
     180             :         labelTypes,
     181             :         change,
     182           0 :         ps.id(),
     183           0 :         info.getAuthor().getAccount(),
     184           0 :         info.getCommitter().getAccount(),
     185             :         wantReviewers,
     186             :         existingReviewers);
     187             :   }
     188             : 
     189             :   public List<PatchSetApproval> addReviewers(
     190             :       ChangeNotes notes,
     191             :       ChangeUpdate update,
     192             :       LabelTypes labelTypes,
     193             :       Change change,
     194             :       Iterable<Account.Id> wantReviewers) {
     195          33 :     PatchSet.Id psId = change.currentPatchSetId();
     196             :     Collection<Account.Id> existingReviewers;
     197          33 :     existingReviewers = notes.load().getReviewers().byState(REVIEWER);
     198             :     // Existing reviewers should include pending additions in the REVIEWER
     199             :     // state, taken from ChangeUpdate.
     200          33 :     existingReviewers = Lists.newArrayList(existingReviewers);
     201          33 :     for (Map.Entry<Account.Id, ReviewerStateInternal> entry : update.getReviewers().entrySet()) {
     202          11 :       if (entry.getValue() == REVIEWER) {
     203           9 :         existingReviewers.add(entry.getKey());
     204             :       }
     205          11 :     }
     206          33 :     return addReviewers(
     207             :         update, labelTypes, change, psId, null, null, wantReviewers, existingReviewers);
     208             :   }
     209             : 
     210             :   private List<PatchSetApproval> addReviewers(
     211             :       ChangeUpdate update,
     212             :       LabelTypes labelTypes,
     213             :       Change change,
     214             :       PatchSet.Id psId,
     215             :       Account.Id authorId,
     216             :       Account.Id committerId,
     217             :       Iterable<Account.Id> wantReviewers,
     218             :       Collection<Account.Id> existingReviewers) {
     219          33 :     List<LabelType> allTypes = labelTypes.getLabelTypes();
     220          33 :     if (allTypes.isEmpty()) {
     221           0 :       return ImmutableList.of();
     222             :     }
     223             : 
     224          33 :     Set<Account.Id> need = Sets.newLinkedHashSet(wantReviewers);
     225          33 :     if (authorId != null && canSee(update.getNotes(), authorId)) {
     226           0 :       need.add(authorId);
     227             :     }
     228             : 
     229          33 :     if (committerId != null && canSee(update.getNotes(), committerId)) {
     230           0 :       need.add(committerId);
     231             :     }
     232          33 :     need.remove(change.getOwner());
     233          33 :     need.removeAll(existingReviewers);
     234          33 :     if (need.isEmpty()) {
     235           3 :       return ImmutableList.of();
     236             :     }
     237             : 
     238          33 :     List<PatchSetApproval> cells = Lists.newArrayListWithCapacity(need.size());
     239          33 :     LabelId labelId = Iterables.getLast(allTypes).getLabelId();
     240          33 :     for (Account.Id account : need) {
     241          33 :       cells.add(
     242          33 :           PatchSetApproval.builder()
     243          33 :               .key(PatchSetApproval.key(psId, account, labelId))
     244          33 :               .value(0)
     245          33 :               .granted(update.getWhen())
     246          33 :               .build());
     247          33 :       update.putReviewer(account, REVIEWER);
     248          33 :     }
     249          33 :     return Collections.unmodifiableList(cells);
     250             :   }
     251             : 
     252             :   private boolean canSee(ChangeNotes notes, Account.Id accountId) {
     253             :     try {
     254           0 :       if (!projectCache
     255           0 :           .get(notes.getProjectName())
     256           0 :           .orElseThrow(illegalState(notes.getProjectName()))
     257           0 :           .statePermitsRead()) {
     258           0 :         return false;
     259             :       }
     260           0 :       return permissionBackend.absentUser(accountId).change(notes).test(ChangePermission.READ);
     261           0 :     } catch (PermissionBackendException e) {
     262           0 :       logger.atWarning().withCause(e).log(
     263             :           "Failed to check if account %d can see change %d",
     264           0 :           accountId.get(), notes.getChangeId().get());
     265           0 :       return false;
     266             :     }
     267             :   }
     268             : 
     269             :   /**
     270             :    * Adds accounts to a change as reviewers in the CC state.
     271             :    *
     272             :    * @param notes change notes.
     273             :    * @param update change update.
     274             :    * @param wantCCs accounts to CC.
     275             :    * @param keepExistingReviewers whether provided accounts that are already reviewer should be kept
     276             :    *     as reviewer or be downgraded to CC
     277             :    * @return whether a change was made.
     278             :    */
     279             :   public Collection<Account.Id> addCcs(
     280             :       ChangeNotes notes,
     281             :       ChangeUpdate update,
     282             :       Collection<Account.Id> wantCCs,
     283             :       boolean keepExistingReviewers) {
     284          29 :     return addCcs(update, wantCCs, notes.load().getReviewers(), keepExistingReviewers);
     285             :   }
     286             : 
     287             :   private Collection<Account.Id> addCcs(
     288             :       ChangeUpdate update,
     289             :       Collection<Account.Id> wantCCs,
     290             :       ReviewerSet existingReviewers,
     291             :       boolean keepExistingReviewers) {
     292          29 :     Set<Account.Id> need = new LinkedHashSet<>(wantCCs);
     293          29 :     need.removeAll(existingReviewers.byState(CC));
     294          29 :     if (keepExistingReviewers) {
     295           4 :       need.removeAll(existingReviewers.byState(REVIEWER));
     296             :     }
     297          29 :     need.removeAll(update.getReviewers().keySet());
     298          29 :     for (Account.Id account : need) {
     299          29 :       update.putReviewer(account, CC);
     300          29 :     }
     301          29 :     return need;
     302             :   }
     303             : 
     304             :   /**
     305             :    * Adds approvals to ChangeUpdate for a new patch set, and writes to NoteDb.
     306             :    *
     307             :    * @param update change update.
     308             :    * @param labelTypes label types for the containing project.
     309             :    * @param ps patch set being approved.
     310             :    * @param user user adding approvals.
     311             :    * @param approvals approvals to add.
     312             :    */
     313             :   public Iterable<PatchSetApproval> addApprovalsForNewPatchSet(
     314             :       ChangeUpdate update,
     315             :       LabelTypes labelTypes,
     316             :       PatchSet ps,
     317             :       CurrentUser user,
     318             :       Map<String, Short> approvals)
     319             :       throws RestApiException, PermissionBackendException {
     320         103 :     Account.Id accountId = user.getAccountId();
     321         103 :     checkArgument(
     322         103 :         accountId.equals(ps.uploader()),
     323             :         "expected user %s to match patch set uploader %s",
     324             :         accountId,
     325         103 :         ps.uploader());
     326         103 :     if (approvals.isEmpty()) {
     327         103 :       return ImmutableList.of();
     328             :     }
     329           5 :     checkApprovals(approvals, permissionBackend.user(user).change(update.getNotes()));
     330           5 :     List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
     331           5 :     Instant ts = update.getWhen();
     332           5 :     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
     333           5 :       Optional<LabelType> lt = labelTypes.byLabel(vote.getKey());
     334           5 :       if (!lt.isPresent()) {
     335           0 :         throw new BadRequestException(
     336           0 :             String.format("label \"%s\" is not a configured label", vote.getKey()));
     337             :       }
     338           5 :       cells.add(newApproval(ps.id(), user, lt.get().getLabelId(), vote.getValue(), ts).build());
     339           5 :     }
     340           5 :     for (PatchSetApproval psa : cells) {
     341           5 :       update.putApproval(psa.label(), psa.value());
     342           5 :     }
     343           5 :     return cells;
     344             :   }
     345             : 
     346             :   public static void checkLabel(LabelTypes labelTypes, String name, Short value)
     347             :       throws BadRequestException {
     348           4 :     Optional<LabelType> label = labelTypes.byLabel(name);
     349           4 :     if (!label.isPresent()) {
     350           3 :       throw new BadRequestException(String.format("label \"%s\" is not a configured label", name));
     351             :     }
     352           4 :     if (label.get().getValue(value) == null) {
     353           3 :       throw new BadRequestException(
     354           3 :           String.format("label \"%s\": %d is not a valid value", name, value));
     355             :     }
     356           4 :   }
     357             : 
     358             :   private static void checkApprovals(
     359             :       Map<String, Short> approvals, PermissionBackend.ForChange forChange)
     360             :       throws AuthException, PermissionBackendException {
     361           5 :     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
     362           5 :       String name = vote.getKey();
     363           5 :       Short value = vote.getValue();
     364           5 :       if (!forChange.test(new LabelPermission.WithValue(name, value))) {
     365           0 :         throw new AuthException(
     366           0 :             String.format("applying label \"%s\": %d is restricted", name, value));
     367             :       }
     368           5 :     }
     369           5 :   }
     370             : 
     371             :   public ListMultimap<PatchSet.Id, PatchSetApproval> byChangeExcludingCopiedApprovals(
     372             :       ChangeNotes notes) {
     373         103 :     return notes.load().getApprovals().onlyNonCopied();
     374             :   }
     375             : 
     376             :   /**
     377             :    * Copies approvals to a new patch set.
     378             :    *
     379             :    * <p>Computes the approvals of the prior patch set that should be copied to the new patch set and
     380             :    * stores them in NoteDb.
     381             :    *
     382             :    * <p>For outdated approvals (approvals on the prior patch set which are outdated by the new patch
     383             :    * set and hence not copied) the approvers are added to the attention set since they need to
     384             :    * re-review the change and renew their approvals.
     385             :    *
     386             :    * @param notes the change notes
     387             :    * @param patchSet the newly created patch set
     388             :    * @param revWalk {@link RevWalk} that can see the new patch set revision
     389             :    * @param repoConfig the repo config
     390             :    * @param changeUpdate changeUpdate that is used to persist the copied approvals and update the
     391             :    *     attention set
     392             :    * @return the result of the approval copying
     393             :    */
     394             :   public ApprovalCopier.Result copyApprovalsToNewPatchSet(
     395             :       ChangeNotes notes,
     396             :       PatchSet patchSet,
     397             :       RevWalk revWalk,
     398             :       Config repoConfig,
     399             :       ChangeUpdate changeUpdate) {
     400          51 :     ApprovalCopier.Result approvalCopierResult =
     401          51 :         approvalCopier.forPatchSet(notes, patchSet, revWalk, repoConfig);
     402          51 :     approvalCopierResult.copiedApprovals().forEach(a -> changeUpdate.putCopiedApproval(a));
     403             : 
     404          51 :     if (!notes.getChange().isWorkInProgress()) {
     405             :       // The attention set should not be updated when the change is work-in-progress.
     406          51 :       addAttentionSetUpdatesForOutdatedApprovals(
     407          51 :           changeUpdate, approvalCopierResult.outdatedApprovals());
     408             :     }
     409             : 
     410          51 :     return approvalCopierResult;
     411             :   }
     412             : 
     413             :   private void addAttentionSetUpdatesForOutdatedApprovals(
     414             :       ChangeUpdate changeUpdate, ImmutableSet<PatchSetApproval> outdatedApprovals) {
     415          51 :     Set<AttentionSetUpdate> updates = new HashSet<>();
     416             : 
     417          51 :     Multimap<Account.Id, PatchSetApproval> outdatedApprovalsByUser = ArrayListMultimap.create();
     418          51 :     outdatedApprovals.forEach(psa -> outdatedApprovalsByUser.put(psa.accountId(), psa));
     419             :     for (Map.Entry<Account.Id, Collection<PatchSetApproval>> e :
     420          51 :         outdatedApprovalsByUser.asMap().entrySet()) {
     421          11 :       Account.Id approverId = e.getKey();
     422          11 :       Collection<PatchSetApproval> outdatedUserApprovals = e.getValue();
     423             : 
     424             :       String message;
     425          11 :       if (outdatedUserApprovals.size() == 1) {
     426          11 :         PatchSetApproval outdatedUserApproval = Iterables.getOnlyElement(outdatedUserApprovals);
     427          11 :         message =
     428          11 :             String.format(
     429             :                 "Vote got outdated and was removed: %s",
     430          11 :                 LabelVote.create(outdatedUserApproval.label(), outdatedUserApproval.value())
     431          11 :                     .format());
     432          11 :       } else {
     433           3 :         message =
     434           3 :             String.format(
     435             :                 "Votes got outdated and were removed: %s",
     436           3 :                 outdatedUserApprovals.stream()
     437           3 :                     .map(
     438             :                         outdatedUserApproval ->
     439           3 :                             LabelVote.create(
     440           3 :                                     outdatedUserApproval.label(), outdatedUserApproval.value())
     441           3 :                                 .format())
     442           3 :                     .sorted()
     443           3 :                     .collect(joining(", ")));
     444             :       }
     445             : 
     446          11 :       updates.add(
     447          11 :           AttentionSetUpdate.createForWrite(approverId, AttentionSetUpdate.Operation.ADD, message));
     448          11 :     }
     449          51 :     changeUpdate.addToPlannedAttentionSetUpdates(updates);
     450          51 :   }
     451             : 
     452             :   public Optional<String> formatApprovalCopierResult(
     453             :       ApprovalCopier.Result approvalCopierResult, LabelTypes labelTypes) {
     454          51 :     requireNonNull(approvalCopierResult, "approvalCopierResult");
     455          51 :     requireNonNull(labelTypes, "labelTypes");
     456             : 
     457          51 :     if (approvalCopierResult.copiedApprovals().isEmpty()
     458          51 :         && approvalCopierResult.outdatedApprovals().isEmpty()) {
     459          50 :       return Optional.empty();
     460             :     }
     461             : 
     462          15 :     StringBuilder message = new StringBuilder();
     463             : 
     464          15 :     if (!approvalCopierResult.copiedApprovals().isEmpty()) {
     465          12 :       message.append("Copied Votes:\n");
     466          12 :       message.append(
     467          12 :           formatApprovalListWithCopyCondition(approvalCopierResult.copiedApprovals(), labelTypes));
     468             :     }
     469          15 :     if (!approvalCopierResult.outdatedApprovals().isEmpty()) {
     470          11 :       if (!approvalCopierResult.copiedApprovals().isEmpty()) {
     471           4 :         message.append("\n");
     472             :       }
     473          11 :       message.append("Outdated Votes:\n");
     474          11 :       message.append(
     475          11 :           formatApprovalListWithCopyCondition(
     476          11 :               approvalCopierResult.outdatedApprovals(), labelTypes));
     477             :     }
     478             : 
     479          15 :     return Optional.of(message.toString());
     480             :   }
     481             : 
     482             :   /**
     483             :    * Formats the given approvals as a bullet list, each approval with the corresponding copy
     484             :    * condition if available.
     485             :    *
     486             :    * <p>E.g.:
     487             :    *
     488             :    * <pre>
     489             :    * * Code-Review+1, Code-Review+2 (copy condition: "is:MIN")
     490             :    * * Verified+1 (copy condition: "is:MIN")
     491             :    * </pre>
     492             :    *
     493             :    * <p>Entries in the list can have the following formats:
     494             :    *
     495             :    * <ul>
     496             :    *   <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition:
     497             :    *       "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate
     498             :    *       is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
     499             :    *   <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition:
     500             :    *       "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is
     501             :    *       present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001>
     502             :    *       (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")}
     503             :    *   <li>{@code <comma-separated-list-of-approval-for-the-same-label>} (if no copy condition is
     504             :    *       present), e.g.: {@code Code-Review+1, Code-Review+2}
     505             :    *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (label type is missing)} (if
     506             :    *       the label type is missing), e.g.: {@code Code-Review+1, Code-Review+2 (label type is
     507             :    *       missing)}
     508             :    *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy
     509             :    *       condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is
     510             :    *       present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition:
     511             :    *       "is:FOO")}
     512             :    * </ul>
     513             :    *
     514             :    * @param approvals the approvals that should be formatted
     515             :    * @param labelTypes the label types
     516             :    * @return bullet list with the formatted approvals
     517             :    */
     518             :   private String formatApprovalListWithCopyCondition(
     519             :       ImmutableSet<PatchSetApproval> approvals, LabelTypes labelTypes) {
     520          15 :     StringBuilder message = new StringBuilder();
     521             : 
     522             :     // sort approvals by label vote so that we list them in a deterministic order
     523          15 :     ImmutableList<PatchSetApproval> approvalsSortedByLabelVote =
     524          15 :         approvals.stream()
     525          15 :             .sorted(comparing(psa -> LabelVote.create(psa.label(), psa.value()).format()))
     526          15 :             .collect(toImmutableList());
     527             : 
     528          15 :     ImmutableListMultimap<String, PatchSetApproval> approvalsByLabel =
     529          15 :         Multimaps.index(approvalsSortedByLabelVote, PatchSetApproval::label);
     530             : 
     531             :     for (Map.Entry<String, Collection<PatchSetApproval>> approvalsByLabelEntry :
     532          15 :         approvalsByLabel.asMap().entrySet()) {
     533          15 :       String label = approvalsByLabelEntry.getKey();
     534          15 :       Collection<PatchSetApproval> approvalsForSameLabel = approvalsByLabelEntry.getValue();
     535             : 
     536          15 :       message.append("* ");
     537          15 :       if (!labelTypes.byLabel(label).isPresent()) {
     538           3 :         message
     539           3 :             .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
     540           3 :             .append(" (label type is missing)\n");
     541           3 :         continue;
     542             :       }
     543             : 
     544          14 :       LabelType labelType = labelTypes.byLabel(label).get();
     545          14 :       if (!labelType.getCopyCondition().isPresent()) {
     546           4 :         message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)).append("\n");
     547           4 :         continue;
     548             :       }
     549             : 
     550          14 :       message
     551          14 :           .append(
     552          14 :               formatApprovalsWithCopyCondition(
     553          14 :                   approvalsForSameLabel, labelType.getCopyCondition().get()))
     554          14 :           .append("\n");
     555          14 :     }
     556             : 
     557          15 :     return message.toString();
     558             :   }
     559             : 
     560             :   /**
     561             :    * Formats the given approvals of the same label with the given copy condition.
     562             :    *
     563             :    * <p>E.g.: {Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
     564             :    *
     565             :    * <p>The following format may be returned:
     566             :    *
     567             :    * <ul>
     568             :    *   <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition:
     569             :    *       "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate
     570             :    *       is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
     571             :    *   <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition:
     572             :    *       "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is
     573             :    *       present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001>
     574             :    *       (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")}
     575             :    *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy
     576             :    *       condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is
     577             :    *       present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition:
     578             :    *       "is:FOO")}
     579             :    * </ul>
     580             :    *
     581             :    * @param approvalsForSameLabel the approvals that should be formatted, must be for the same label
     582             :    * @param copyCondition the copy condition of the label
     583             :    * @return the formatted approvals
     584             :    */
     585             :   private String formatApprovalsWithCopyCondition(
     586             :       Collection<PatchSetApproval> approvalsForSameLabel, String copyCondition) {
     587          14 :     StringBuilder message = new StringBuilder();
     588             : 
     589             :     boolean containsUserInPredicate;
     590             :     try {
     591          14 :       containsUserInPredicate = containsUserInPredicate(copyCondition);
     592           1 :     } catch (QueryParseException e) {
     593           1 :       logger.atWarning().withCause(e).log("Non-parsable query condition");
     594           1 :       message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
     595           1 :       message.append(String.format(" (non-parseable copy condition: \"%s\")", copyCondition));
     596           1 :       return message.toString();
     597          14 :     }
     598             : 
     599          14 :     if (containsUserInPredicate) {
     600             :       // If a UserInPredicate is used (e.g. 'approverin:<group>' or 'uploaderin:<group>') we need to
     601             :       // include the approvers into the change message since they are relevant for the matching. For
     602             :       // example it can happen that the same approval of different users is copied for the one user
     603             :       // but not for the other user (since the one user is a member of the approverin group and the
     604             :       // other user isn't).
     605             :       //
     606             :       // Example:
     607             :       // * label Foo has the copy condition 'is:ANY approverin:123'
     608             :       // * group 123 contains UserA as member, but not UserB
     609             :       // * a change has the following approvals: Foo+1 by UserA and Foo+1 by UserB
     610             :       //
     611             :       // In this case Foo+1 by UserA is copied because UserA is a member of group 123 and the copy
     612             :       // condition matches, while Foo+1 by UserB is not copied because UserB is not a member of
     613             :       // group 123 and the copy condition doesn't match.
     614             :       //
     615             :       // So it can happen that the same approval Foo+1, but by different users, is copied and
     616             :       // outdated at the same time. To allow users to understand that the copying depends on who did
     617             :       // the approval, the approvers must be included into the change message.
     618             : 
     619             :       // sort the approvals by their approvers name-email so that the approvers always appear in a
     620             :       // deterministic order
     621           2 :       ImmutableList<PatchSetApproval> approvalsSortedByLabelVoteAndApprover =
     622           2 :           approvalsForSameLabel.stream()
     623           2 :               .sorted(
     624           2 :                   comparing(
     625             :                           (PatchSetApproval psa) ->
     626           2 :                               LabelVote.create(psa.label(), psa.value()).format())
     627           2 :                       .thenComparing(
     628             :                           psa ->
     629           1 :                               accountCache
     630           1 :                                   .getEvenIfMissing(psa.accountId())
     631           1 :                                   .account()
     632           1 :                                   .getNameEmail(anonymousCowardName)))
     633           2 :               .collect(toImmutableList());
     634             : 
     635           2 :       ImmutableListMultimap<LabelVote, Account.Id> approversByLabelVote =
     636           2 :           Multimaps.index(
     637             :                   approvalsSortedByLabelVoteAndApprover,
     638           2 :                   psa -> LabelVote.create(psa.label(), psa.value()))
     639           2 :               .entries().stream()
     640           2 :               .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue().accountId()));
     641           2 :       message.append(
     642           2 :           approversByLabelVote.asMap().entrySet().stream()
     643           2 :               .map(
     644             :                   approversByLabelVoteEntry ->
     645           2 :                       formatLabelVoteWithApprovers(
     646           2 :                           approversByLabelVoteEntry.getKey(), approversByLabelVoteEntry.getValue()))
     647           2 :               .collect(joining(", ")));
     648           2 :     } else {
     649             :       // copy condition doesn't contain a UserInPredicate
     650          14 :       message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
     651             :     }
     652          14 :     message.append(String.format(" (copy condition: \"%s\")", copyCondition));
     653          14 :     return message.toString();
     654             :   }
     655             : 
     656             :   private boolean containsUserInPredicate(String copyCondition) throws QueryParseException {
     657             :     // Use a request context to run checks as an internal user with expanded visibility. This is
     658             :     // so that the output of the copy condition does not depend on who is running the current
     659             :     // request (e.g. a group used in this query might not be visible to the person sending this
     660             :     // request).
     661          14 :     try (ManualRequestContext ignored = requestContext.open()) {
     662          14 :       return approvalQueryBuilderProvider.get().parse(copyCondition).getFlattenedPredicateList()
     663          14 :           .stream()
     664          14 :           .anyMatch(UserInPredicate.class::isInstance);
     665             :     }
     666             :   }
     667             : 
     668             :   /**
     669             :    * Formats the given approvals as a comma-separated list of label votes.
     670             :    *
     671             :    * <p>E.g.: {@code Code-Review+1, CodeReview+2}
     672             :    *
     673             :    * @param sortedApprovalsForSameLabel the approvals that should be formatted as a comma-separated
     674             :    *     list of label votes, must be sorted
     675             :    * @return the given approvals as a comma-separated list of label votes
     676             :    */
     677             :   private String formatApprovalsAsLabelVotesList(
     678             :       Collection<PatchSetApproval> sortedApprovalsForSameLabel) {
     679          15 :     return sortedApprovalsForSameLabel.stream()
     680          15 :         .map(psa -> LabelVote.create(psa.label(), psa.value()))
     681          15 :         .distinct()
     682          15 :         .map(LabelVote::format)
     683          15 :         .collect(joining(", "));
     684             :   }
     685             : 
     686             :   /**
     687             :    * Formats the given label vote with a comma-separated list of the given approvers.
     688             :    *
     689             :    * <p>E.g.: {@code Code-Review+1 by <user1-placeholder>, <user2-placeholder>}
     690             :    *
     691             :    * @param labelVote the label vote that should be formatted with a comma-separated list of the
     692             :    *     given approver
     693             :    * @param sortedApprovers the approvers that should be formatted as a comma-separated list for the
     694             :    *     given label vote
     695             :    * @return the given label vote with a comma-separated list of the given approvers
     696             :    */
     697             :   private String formatLabelVoteWithApprovers(
     698             :       LabelVote labelVote, Collection<Account.Id> sortedApprovers) {
     699           2 :     return new StringBuilder()
     700           2 :         .append(labelVote.format())
     701           2 :         .append(" by ")
     702           2 :         .append(
     703           2 :             sortedApprovers.stream()
     704           2 :                 .map(AccountTemplateUtil::getAccountTemplate)
     705           2 :                 .collect(joining(", ")))
     706           2 :         .toString();
     707             :   }
     708             : 
     709             :   /**
     710             :    * Gets {@link PatchSetApproval}s for a specified patch-set. The result includes copied votes but
     711             :    * does not include deleted labels.
     712             :    *
     713             :    * @param notes changenotes of the change.
     714             :    * @param psId patch-set id for the change and patch-set we want to get approvals.
     715             :    * @return all approvals for the specified patch-set, including copied votes, not including
     716             :    *     deleted labels.
     717             :    */
     718             :   public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
     719         103 :     List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovals().all().get(psId);
     720         103 :     return labelNormalizer.normalize(notes, approvalsNotNormalized).getNormalized();
     721             :   }
     722             : 
     723             :   public Iterable<PatchSetApproval> byPatchSetUser(
     724             :       ChangeNotes notes, PatchSet.Id psId, Account.Id accountId) {
     725          67 :     return filterApprovals(byPatchSet(notes, psId), accountId);
     726             :   }
     727             : 
     728             :   @Nullable
     729             :   public PatchSetApproval getSubmitter(ChangeNotes notes, PatchSet.Id c) {
     730           9 :     if (c == null) {
     731           0 :       return null;
     732             :     }
     733             :     try {
     734             :       // Submit approval is never copied.
     735           9 :       return getSubmitter(c, byChangeExcludingCopiedApprovals(notes).get(c));
     736           0 :     } catch (StorageException e) {
     737           0 :       return null;
     738             :     }
     739             :   }
     740             : 
     741             :   @Nullable
     742             :   public static PatchSetApproval getSubmitter(PatchSet.Id c, Iterable<PatchSetApproval> approvals) {
     743           9 :     if (c == null) {
     744           0 :       return null;
     745             :     }
     746           9 :     PatchSetApproval submitter = null;
     747           9 :     for (PatchSetApproval a : approvals) {
     748           9 :       if (a.patchSetId().equals(c) && a.value() > 0 && a.isLegacySubmit()) {
     749           9 :         if (submitter == null || a.granted().compareTo(submitter.granted()) > 0) {
     750           9 :           submitter = a;
     751             :         }
     752             :       }
     753           9 :     }
     754           9 :     return submitter;
     755             :   }
     756             : 
     757             :   public static String renderMessageWithApprovals(
     758             :       int patchSetId, Map<String, Short> n, Map<String, PatchSetApproval> c) {
     759          88 :     StringBuilder msgs = new StringBuilder("Uploaded patch set " + patchSetId);
     760          88 :     if (!n.isEmpty()) {
     761           4 :       boolean first = true;
     762           4 :       for (Map.Entry<String, Short> e : n.entrySet()) {
     763           4 :         if (c.containsKey(e.getKey()) && c.get(e.getKey()).value() == e.getValue()) {
     764           3 :           continue;
     765             :         }
     766           4 :         if (first) {
     767           4 :           msgs.append(":");
     768           4 :           first = false;
     769             :         }
     770           4 :         msgs.append(" ").append(LabelVote.create(e.getKey(), e.getValue()).format());
     771           4 :       }
     772             :     }
     773          88 :     return msgs.toString();
     774             :   }
     775             : }

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