| // Copyright (C) 2009 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package com.google.gerrit.server.approval; |
| |
| import static com.google.common.base.Preconditions.checkArgument; |
| import static com.google.common.base.Preconditions.checkState; |
| import static com.google.common.collect.ImmutableList.toImmutableList; |
| import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap; |
| import static com.google.common.collect.ImmutableSet.toImmutableSet; |
| import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC; |
| import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER; |
| import static com.google.gerrit.server.project.ProjectCache.illegalState; |
| import static java.util.Comparator.comparing; |
| import static java.util.Objects.requireNonNull; |
| import static java.util.stream.Collectors.joining; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Function; |
| import com.google.common.collect.ArrayListMultimap; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableListMultimap; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.common.collect.Iterables; |
| import com.google.common.collect.ListMultimap; |
| import com.google.common.collect.Lists; |
| import com.google.common.collect.Multimap; |
| import com.google.common.collect.Multimaps; |
| import com.google.common.collect.Sets; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.entities.Account; |
| import com.google.gerrit.entities.AttentionSetUpdate; |
| import com.google.gerrit.entities.Change; |
| import com.google.gerrit.entities.LabelId; |
| import com.google.gerrit.entities.LabelType; |
| import com.google.gerrit.entities.LabelTypes; |
| import com.google.gerrit.entities.PatchSet; |
| import com.google.gerrit.entities.PatchSetApproval; |
| import com.google.gerrit.entities.PatchSetInfo; |
| import com.google.gerrit.exceptions.StorageException; |
| import com.google.gerrit.extensions.restapi.AuthException; |
| import com.google.gerrit.extensions.restapi.BadRequestException; |
| import com.google.gerrit.extensions.restapi.RestApiException; |
| import com.google.gerrit.index.query.QueryParseException; |
| import com.google.gerrit.server.CurrentUser; |
| import com.google.gerrit.server.ReviewerSet; |
| import com.google.gerrit.server.ReviewerStatusUpdate; |
| import com.google.gerrit.server.account.AccountCache; |
| import com.google.gerrit.server.change.LabelNormalizer; |
| import com.google.gerrit.server.config.AnonymousCowardName; |
| import com.google.gerrit.server.notedb.ChangeNotes; |
| import com.google.gerrit.server.notedb.ChangeUpdate; |
| import com.google.gerrit.server.notedb.ReviewerStateInternal; |
| import com.google.gerrit.server.permissions.ChangePermission; |
| import com.google.gerrit.server.permissions.LabelPermission; |
| import com.google.gerrit.server.permissions.PermissionBackend; |
| import com.google.gerrit.server.permissions.PermissionBackendException; |
| import com.google.gerrit.server.project.ProjectCache; |
| import com.google.gerrit.server.query.approval.ApprovalQueryBuilder; |
| import com.google.gerrit.server.query.approval.UserInPredicate; |
| import com.google.gerrit.server.util.AccountTemplateUtil; |
| import com.google.gerrit.server.util.LabelVote; |
| import com.google.gerrit.server.util.ManualRequestContext; |
| import com.google.gerrit.server.util.OneOffRequestContext; |
| import com.google.inject.Inject; |
| import com.google.inject.Provider; |
| import com.google.inject.Singleton; |
| import java.time.Instant; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.HashSet; |
| import java.util.LinkedHashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.Optional; |
| import java.util.Set; |
| import java.util.StringTokenizer; |
| import org.eclipse.jgit.lib.Config; |
| import org.eclipse.jgit.revwalk.RevWalk; |
| |
| /** |
| * Utility functions to manipulate patchset approvals. |
| * |
| * <p>Approvals are overloaded, they represent both approvals and reviewers which should be CCed on |
| * a change. To ensure that reviewers are not lost there must always be an approval on each patchset |
| * for each reviewer, even if the reviewer hasn't actually given a score to the change. To mark the |
| * "no score" case, a dummy approval, which may live in any of the available categories, with a |
| * score of 0 is used. |
| */ |
| @Singleton |
| public class ApprovalsUtil { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| public static PatchSetApproval.Builder newApproval( |
| PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Instant when) { |
| PatchSetApproval.Builder b = |
| PatchSetApproval.builder() |
| .key(PatchSetApproval.key(psId, user.getAccountId(), labelId)) |
| .value(value) |
| .granted(when); |
| user.updateRealAccountId(b::realAccountId); |
| return b; |
| } |
| |
| private static Iterable<PatchSetApproval> filterApprovals( |
| Iterable<PatchSetApproval> psas, Account.Id accountId) { |
| return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId)); |
| } |
| |
| private final AccountCache accountCache; |
| private final String anonymousCowardName; |
| private final ApprovalCopier approvalCopier; |
| private final Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider; |
| private final PermissionBackend permissionBackend; |
| private final ProjectCache projectCache; |
| private final LabelNormalizer labelNormalizer; |
| private final OneOffRequestContext requestContext; |
| |
| @VisibleForTesting |
| @Inject |
| public ApprovalsUtil( |
| AccountCache accountCache, |
| @AnonymousCowardName String anonymousCowardName, |
| ApprovalCopier approvalCopier, |
| Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider, |
| PermissionBackend permissionBackend, |
| ProjectCache projectCache, |
| LabelNormalizer labelNormalizer, |
| OneOffRequestContext requestContext) { |
| this.accountCache = accountCache; |
| this.anonymousCowardName = anonymousCowardName; |
| this.approvalCopier = approvalCopier; |
| this.approvalQueryBuilderProvider = approvalQueryBuilderProvider; |
| this.permissionBackend = permissionBackend; |
| this.projectCache = projectCache; |
| this.labelNormalizer = labelNormalizer; |
| this.requestContext = requestContext; |
| } |
| |
| /** |
| * Get all reviewers for a change. |
| * |
| * @param notes change notes. |
| * @return reviewers for the change. |
| */ |
| public ReviewerSet getReviewers(ChangeNotes notes) { |
| return notes.load().getReviewers(); |
| } |
| |
| /** |
| * Get updates to reviewer set. |
| * |
| * @param notes change notes. |
| * @return reviewer updates for the change. |
| */ |
| public List<ReviewerStatusUpdate> getReviewerUpdates(ChangeNotes notes) { |
| return notes.load().getReviewerUpdates(); |
| } |
| |
| public List<PatchSetApproval> addReviewers( |
| ChangeUpdate update, |
| LabelTypes labelTypes, |
| Change change, |
| PatchSet ps, |
| PatchSetInfo info, |
| Iterable<Account.Id> wantReviewers, |
| Collection<Account.Id> existingReviewers) { |
| return addReviewers( |
| update, |
| labelTypes, |
| change, |
| ps.id(), |
| info.getAuthor().getAccount(), |
| info.getCommitter().getAccount(), |
| wantReviewers, |
| existingReviewers); |
| } |
| |
| public List<PatchSetApproval> addReviewers( |
| ChangeNotes notes, |
| ChangeUpdate update, |
| LabelTypes labelTypes, |
| Change change, |
| Iterable<Account.Id> wantReviewers) { |
| PatchSet.Id psId = change.currentPatchSetId(); |
| Collection<Account.Id> existingReviewers; |
| existingReviewers = notes.load().getReviewers().byState(REVIEWER); |
| // Existing reviewers should include pending additions in the REVIEWER |
| // state, taken from ChangeUpdate. |
| existingReviewers = Lists.newArrayList(existingReviewers); |
| for (Map.Entry<Account.Id, ReviewerStateInternal> entry : update.getReviewers().entrySet()) { |
| if (entry.getValue() == REVIEWER) { |
| existingReviewers.add(entry.getKey()); |
| } |
| } |
| return addReviewers( |
| update, labelTypes, change, psId, null, null, wantReviewers, existingReviewers); |
| } |
| |
| private List<PatchSetApproval> addReviewers( |
| ChangeUpdate update, |
| LabelTypes labelTypes, |
| Change change, |
| PatchSet.Id psId, |
| Account.Id authorId, |
| Account.Id committerId, |
| Iterable<Account.Id> wantReviewers, |
| Collection<Account.Id> existingReviewers) { |
| List<LabelType> allTypes = labelTypes.getLabelTypes(); |
| if (allTypes.isEmpty()) { |
| return ImmutableList.of(); |
| } |
| |
| Set<Account.Id> need = Sets.newLinkedHashSet(wantReviewers); |
| if (authorId != null && canSee(update.getNotes(), authorId)) { |
| need.add(authorId); |
| } |
| |
| if (committerId != null && canSee(update.getNotes(), committerId)) { |
| need.add(committerId); |
| } |
| need.remove(change.getOwner()); |
| need.removeAll(existingReviewers); |
| if (need.isEmpty()) { |
| return ImmutableList.of(); |
| } |
| |
| List<PatchSetApproval> cells = Lists.newArrayListWithCapacity(need.size()); |
| LabelId labelId = Iterables.getLast(allTypes).getLabelId(); |
| for (Account.Id account : need) { |
| cells.add( |
| PatchSetApproval.builder() |
| .key(PatchSetApproval.key(psId, account, labelId)) |
| .value(0) |
| .granted(update.getWhen()) |
| .build()); |
| update.putReviewer(account, REVIEWER); |
| } |
| return Collections.unmodifiableList(cells); |
| } |
| |
| private boolean canSee(ChangeNotes notes, Account.Id accountId) { |
| try { |
| if (!projectCache |
| .get(notes.getProjectName()) |
| .orElseThrow(illegalState(notes.getProjectName())) |
| .statePermitsRead()) { |
| return false; |
| } |
| return permissionBackend.absentUser(accountId).change(notes).test(ChangePermission.READ); |
| } catch (PermissionBackendException e) { |
| logger.atWarning().withCause(e).log( |
| "Failed to check if account %d can see change %d", |
| accountId.get(), notes.getChangeId().get()); |
| return false; |
| } |
| } |
| |
| /** |
| * Adds accounts to a change as reviewers in the CC state. |
| * |
| * @param notes change notes. |
| * @param update change update. |
| * @param wantCCs accounts to CC. |
| * @param keepExistingReviewers whether provided accounts that are already reviewer should be kept |
| * as reviewer or be downgraded to CC |
| * @return whether a change was made. |
| */ |
| public Collection<Account.Id> addCcs( |
| ChangeNotes notes, |
| ChangeUpdate update, |
| Collection<Account.Id> wantCCs, |
| boolean keepExistingReviewers) { |
| return addCcs(update, wantCCs, notes.load().getReviewers(), keepExistingReviewers); |
| } |
| |
| private Set<Account.Id> addCcs( |
| ChangeUpdate update, |
| Collection<Account.Id> wantCCs, |
| ReviewerSet existingReviewers, |
| boolean keepExistingReviewers) { |
| Set<Account.Id> need = new LinkedHashSet<>(wantCCs); |
| need.removeAll(existingReviewers.byState(CC)); |
| if (keepExistingReviewers) { |
| need.removeAll(existingReviewers.byState(REVIEWER)); |
| } |
| need.removeAll(update.getReviewers().keySet()); |
| for (Account.Id account : need) { |
| update.putReviewer(account, CC); |
| } |
| return need; |
| } |
| |
| /** |
| * Adds approvals to ChangeUpdate for a new patch set, and writes to NoteDb. |
| * |
| * @param update change update. |
| * @param labelTypes label types for the containing project. |
| * @param ps patch set being approved. |
| * @param user user adding approvals. |
| * @param approvals approvals to add. |
| */ |
| public Iterable<PatchSetApproval> addApprovalsForNewPatchSet( |
| ChangeUpdate update, |
| LabelTypes labelTypes, |
| PatchSet ps, |
| CurrentUser user, |
| Map<String, Short> approvals) |
| throws RestApiException, PermissionBackendException { |
| Account.Id accountId = user.getAccountId(); |
| checkArgument( |
| accountId.equals(ps.uploader()), |
| "expected user %s to match patch set uploader %s", |
| accountId, |
| ps.uploader()); |
| if (approvals.isEmpty()) { |
| return ImmutableList.of(); |
| } |
| checkApprovals(approvals, permissionBackend.user(user).change(update.getNotes())); |
| List<PatchSetApproval> cells = new ArrayList<>(approvals.size()); |
| Instant ts = update.getWhen(); |
| for (Map.Entry<String, Short> vote : approvals.entrySet()) { |
| Optional<LabelType> lt = labelTypes.byLabel(vote.getKey()); |
| if (!lt.isPresent()) { |
| throw new BadRequestException( |
| String.format("label \"%s\" is not a configured label", vote.getKey())); |
| } |
| cells.add(newApproval(ps.id(), user, lt.get().getLabelId(), vote.getValue(), ts).build()); |
| } |
| for (PatchSetApproval psa : cells) { |
| update.putApproval(psa.label(), psa.value()); |
| } |
| return cells; |
| } |
| |
| public static void checkLabel(LabelTypes labelTypes, String name, Short value) |
| throws BadRequestException { |
| Optional<LabelType> label = labelTypes.byLabel(name); |
| if (!label.isPresent()) { |
| throw new BadRequestException(String.format("label \"%s\" is not a configured label", name)); |
| } |
| if (label.get().getValue(value) == null) { |
| throw new BadRequestException( |
| String.format("label \"%s\": %d is not a valid value", name, value)); |
| } |
| } |
| |
| private static void checkApprovals( |
| Map<String, Short> approvals, PermissionBackend.ForChange forChange) |
| throws AuthException, PermissionBackendException { |
| for (Map.Entry<String, Short> vote : approvals.entrySet()) { |
| String name = vote.getKey(); |
| Short value = vote.getValue(); |
| if (!forChange.test(new LabelPermission.WithValue(name, value))) { |
| throw new AuthException( |
| String.format("applying label \"%s\": %d is restricted", name, value)); |
| } |
| } |
| } |
| |
| public ListMultimap<PatchSet.Id, PatchSetApproval> byChangeExcludingCopiedApprovals( |
| ChangeNotes notes) { |
| return notes.load().getApprovals().onlyNonCopied(); |
| } |
| |
| public ListMultimap<PatchSet.Id, PatchSetApproval> byChangeIncludingCopiedApprovals( |
| ChangeNotes notes) { |
| return notes.load().getApprovals().all(); |
| } |
| |
| /** |
| * Copies approvals to a new patch set. |
| * |
| * <p>Computes the approvals of the prior patch set that should be copied to the new patch set and |
| * stores them in NoteDb. |
| * |
| * <p>For outdated approvals (approvals on the prior patch set which are outdated by the new patch |
| * set and hence not copied) the approvers are added to the attention set since they need to |
| * re-review the change and renew their approvals. |
| * |
| * @param notes the change notes |
| * @param patchSet the newly created patch set |
| * @param revWalk {@link RevWalk} that can see the new patch set revision |
| * @param repoConfig the repo config |
| * @param changeUpdate changeUpdate that is used to persist the copied approvals and update the |
| * attention set |
| * @return the result of the approval copying |
| */ |
| public ApprovalCopier.Result copyApprovalsToNewPatchSet( |
| ChangeNotes notes, |
| PatchSet patchSet, |
| RevWalk revWalk, |
| Config repoConfig, |
| ChangeUpdate changeUpdate) { |
| ApprovalCopier.Result approvalCopierResult = |
| approvalCopier.forPatchSet(notes, patchSet, revWalk, repoConfig); |
| approvalCopierResult |
| .copiedApprovals() |
| .forEach(approvalData -> changeUpdate.putCopiedApproval(approvalData.patchSetApproval())); |
| |
| if (!notes.getChange().isWorkInProgress()) { |
| // The attention set should not be updated when the change is work-in-progress. |
| addAttentionSetUpdatesForOutdatedApprovals( |
| changeUpdate, |
| approvalCopierResult.outdatedApprovals().stream() |
| .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval) |
| .collect(toImmutableSet())); |
| } |
| |
| return approvalCopierResult; |
| } |
| |
| private void addAttentionSetUpdatesForOutdatedApprovals( |
| ChangeUpdate changeUpdate, ImmutableSet<PatchSetApproval> outdatedApprovals) { |
| Set<AttentionSetUpdate> updates = new HashSet<>(); |
| |
| Multimap<Account.Id, PatchSetApproval> outdatedApprovalsByUser = ArrayListMultimap.create(); |
| outdatedApprovals.forEach(psa -> outdatedApprovalsByUser.put(psa.accountId(), psa)); |
| for (Map.Entry<Account.Id, Collection<PatchSetApproval>> e : |
| outdatedApprovalsByUser.asMap().entrySet()) { |
| Account.Id approverId = e.getKey(); |
| Collection<PatchSetApproval> outdatedUserApprovals = e.getValue(); |
| |
| String message; |
| if (outdatedUserApprovals.size() == 1) { |
| PatchSetApproval outdatedUserApproval = Iterables.getOnlyElement(outdatedUserApprovals); |
| message = |
| String.format( |
| "Vote got outdated and was removed: %s", |
| LabelVote.create(outdatedUserApproval.label(), outdatedUserApproval.value()) |
| .format()); |
| } else { |
| message = |
| String.format( |
| "Votes got outdated and were removed: %s", |
| outdatedUserApprovals.stream() |
| .map( |
| outdatedUserApproval -> |
| LabelVote.create( |
| outdatedUserApproval.label(), outdatedUserApproval.value()) |
| .format()) |
| .sorted() |
| .collect(joining(", "))); |
| } |
| |
| updates.add( |
| AttentionSetUpdate.createForWrite(approverId, AttentionSetUpdate.Operation.ADD, message)); |
| } |
| changeUpdate.addToPlannedAttentionSetUpdates(updates); |
| } |
| |
| public Optional<String> formatApprovalCopierResult( |
| ApprovalCopier.Result approvalCopierResult, LabelTypes labelTypes) { |
| requireNonNull(approvalCopierResult, "approvalCopierResult"); |
| requireNonNull(labelTypes, "labelTypes"); |
| |
| if (approvalCopierResult.copiedApprovals().isEmpty() |
| && approvalCopierResult.outdatedApprovals().isEmpty()) { |
| return Optional.empty(); |
| } |
| |
| StringBuilder message = new StringBuilder(); |
| |
| if (!approvalCopierResult.copiedApprovals().isEmpty()) { |
| message.append("Copied Votes:\n"); |
| message.append( |
| formatApprovalListWithCopyCondition(approvalCopierResult.copiedApprovals(), labelTypes)); |
| } |
| if (!approvalCopierResult.outdatedApprovals().isEmpty()) { |
| if (!approvalCopierResult.copiedApprovals().isEmpty()) { |
| message.append("\n"); |
| } |
| message.append("Outdated Votes:\n"); |
| message.append( |
| formatApprovalListWithCopyCondition( |
| approvalCopierResult.outdatedApprovals(), labelTypes)); |
| } |
| |
| return Optional.of(message.toString()); |
| } |
| |
| /** |
| * Formats the given approvals as a bullet list, each approval with the corresponding copy |
| * condition if available. |
| * |
| * <p>E.g.: |
| * |
| * <pre> |
| * * Code-Review+1, Code-Review+2 (copy condition: "is:MIN") |
| * * Verified+1 (copy condition: "is:MIN") |
| * </pre> |
| * |
| * <p>Entries in the list can have the following formats: |
| * |
| * <ul> |
| * <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition: |
| * "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate |
| * is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")} |
| * <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition: |
| * "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is |
| * present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001> |
| * (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")} |
| * <li>{@code <comma-separated-list-of-approval-for-the-same-label>} (if no copy condition is |
| * present), e.g.: {@code Code-Review+1, Code-Review+2} |
| * <li>{@code <comma-separated-list-of-approval-for-the-same-label> (label type is missing)} (if |
| * the label type is missing), e.g.: {@code Code-Review+1, Code-Review+2 (label type is |
| * missing)} |
| * <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy |
| * condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is |
| * present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition: |
| * "is:FOO")} |
| * </ul> |
| * |
| * @param approvalDatas the approvals that should be formatted, with approval meta data |
| * @param labelTypes the label types |
| * @return bullet list with the formatted approvals |
| */ |
| private String formatApprovalListWithCopyCondition( |
| ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas, |
| LabelTypes labelTypes) { |
| StringBuilder message = new StringBuilder(); |
| |
| // sort approvals by label vote so that we list them in a deterministic order |
| ImmutableList<ApprovalCopier.Result.PatchSetApprovalData> approvalsSortedByLabelVote = |
| approvalDatas.stream() |
| .sorted( |
| comparing( |
| approvalData -> |
| LabelVote.create( |
| approvalData.patchSetApproval().label(), |
| approvalData.patchSetApproval().value()) |
| .format())) |
| .collect(toImmutableList()); |
| |
| ImmutableListMultimap<String, ApprovalCopier.Result.PatchSetApprovalData> approvalsByLabel = |
| Multimaps.index( |
| approvalsSortedByLabelVote, approvalData -> approvalData.patchSetApproval().label()); |
| |
| for (Map.Entry<String, Collection<ApprovalCopier.Result.PatchSetApprovalData>> |
| approvalsByLabelEntry : approvalsByLabel.asMap().entrySet()) { |
| String label = approvalsByLabelEntry.getKey(); |
| Collection<ApprovalCopier.Result.PatchSetApprovalData> approvalsForSameLabel = |
| approvalsByLabelEntry.getValue(); |
| |
| if (!labelTypes.byLabel(label).isPresent()) { |
| message |
| .append("* ") |
| .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)) |
| .append(" (label type is missing)\n"); |
| continue; |
| } |
| |
| LabelType labelType = labelTypes.byLabel(label).get(); |
| if (!labelType.getCopyCondition().isPresent()) { |
| message |
| .append("* ") |
| .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)) |
| .append("\n"); |
| continue; |
| } |
| |
| // Group the approvals that have the same label by the passing atoms. If approvals have the |
| // same label, but have different passing atoms, we need to list them in separate lines |
| // (because in each line we will highlight different passing atoms that matched). Approvals |
| // with the same label and the same passing atoms are formatted as a single line. |
| ImmutableListMultimap<ImmutableSet<String>, ApprovalCopier.Result.PatchSetApprovalData> |
| approvalsForSameLabelByPassingAndFailingAtoms = |
| Multimaps.index( |
| approvalsForSameLabel, ApprovalCopier.Result.PatchSetApprovalData::passingAtoms); |
| |
| // Approvals with the same label that have the same passing atoms should have the same failing |
| // atoms (since the label is the same they have the same copy condition). |
| approvalsForSameLabelByPassingAndFailingAtoms |
| .asMap() |
| .values() |
| .forEach( |
| approvalsForSameLabelAndSamePassingAtoms -> |
| checkThatPropertyIsTheSameForAllApprovals( |
| approvalsForSameLabelAndSamePassingAtoms, |
| "failing atoms", |
| approvalData -> approvalData.failingAtoms())); |
| |
| // The order in which we add lines for approvals with the same label but different passing |
| // atoms needs to be deterministic for tests. Just sort them by the string representation of |
| // the passing atoms. |
| for (Collection<ApprovalCopier.Result.PatchSetApprovalData> |
| approvalsForSameLabelWithSamePassingAndFailingAtoms : |
| approvalsForSameLabelByPassingAndFailingAtoms.asMap().entrySet().stream() |
| .sorted( |
| comparing( |
| (Map.Entry< |
| ImmutableSet<String>, |
| Collection<ApprovalCopier.Result.PatchSetApprovalData>> |
| e) -> e.getKey().toString())) |
| .map(Map.Entry::getValue) |
| .collect(toImmutableList())) { |
| message |
| .append("* ") |
| .append( |
| formatApprovalsWithCopyCondition( |
| approvalsForSameLabelWithSamePassingAndFailingAtoms, |
| labelType.getCopyCondition().get())) |
| .append("\n"); |
| } |
| } |
| |
| return message.toString(); |
| } |
| |
| /** |
| * Formats the given approvals with the given copy condition. |
| * |
| * <p>The given approvals must have the same label and the same passing and failing atoms. |
| * |
| * <p>E.g.: {Code-Review+1, Code-Review+2 (copy condition: "is:MIN")} |
| * |
| * <p>The following format may be returned: |
| * |
| * <ul> |
| * <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition: |
| * "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate |
| * is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")} |
| * <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition: |
| * "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is |
| * present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001> |
| * (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")} |
| * <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy |
| * condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is |
| * present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition: |
| * "is:FOO")} |
| * </ul> |
| * |
| * @param approvalsWithSameLabelAndSamePassingAndFailingAtoms the approvals that should be |
| * formatted, must be for the same label |
| * @param copyCondition the copy condition of the label |
| * @return the formatted approvals |
| */ |
| private String formatApprovalsWithCopyCondition( |
| Collection<ApprovalCopier.Result.PatchSetApprovalData> |
| approvalsWithSameLabelAndSamePassingAndFailingAtoms, |
| String copyCondition) { |
| // Check that all given approvals have the same label and the same passing and failing atoms. |
| checkThatPropertyIsTheSameForAllApprovals( |
| approvalsWithSameLabelAndSamePassingAndFailingAtoms, |
| "label", |
| approvalData -> approvalData.patchSetApproval().label()); |
| checkThatPropertyIsTheSameForAllApprovals( |
| approvalsWithSameLabelAndSamePassingAndFailingAtoms, |
| "passing atoms", |
| approvalData -> approvalData.passingAtoms()); |
| checkThatPropertyIsTheSameForAllApprovals( |
| approvalsWithSameLabelAndSamePassingAndFailingAtoms, |
| "failing atoms", |
| approvalData -> approvalData.failingAtoms()); |
| |
| StringBuilder message = new StringBuilder(); |
| |
| boolean containsUserInPredicate; |
| try { |
| containsUserInPredicate = containsUserInPredicate(copyCondition); |
| } catch (QueryParseException e) { |
| logger.atWarning().withCause(e).log("Non-parsable query condition"); |
| message.append( |
| formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms)); |
| message.append(String.format(" (non-parseable copy condition: \"%s\")", copyCondition)); |
| return message.toString(); |
| } |
| |
| if (containsUserInPredicate) { |
| // If a UserInPredicate is used (e.g. 'approverin:<group>' or 'uploaderin:<group>') we need to |
| // include the approvers into the change message since they are relevant for the matching. For |
| // example it can happen that the same approval of different users is copied for the one user |
| // but not for the other user (since the one user is a member of the approverin group and the |
| // other user isn't). |
| // |
| // Example: |
| // * label Foo has the copy condition 'is:ANY approverin:123' |
| // * group 123 contains UserA as member, but not UserB |
| // * a change has the following approvals: Foo+1 by UserA and Foo+1 by UserB |
| // |
| // In this case Foo+1 by UserA is copied because UserA is a member of group 123 and the copy |
| // condition matches, while Foo+1 by UserB is not copied because UserB is not a member of |
| // group 123 and the copy condition doesn't match. |
| // |
| // So it can happen that the same approval Foo+1, but by different users, is copied and |
| // outdated at the same time. To allow users to understand that the copying depends on who did |
| // the approval, the approvers must be included into the change message. |
| |
| // sort the approvals by their approvers name-email so that the approvers always appear in a |
| // deterministic order |
| ImmutableList<ApprovalCopier.Result.PatchSetApprovalData> |
| approvalsSortedByLabelVoteAndApprover = |
| approvalsWithSameLabelAndSamePassingAndFailingAtoms.stream() |
| .sorted( |
| comparing( |
| (ApprovalCopier.Result.PatchSetApprovalData approvalData) -> |
| LabelVote.create( |
| approvalData.patchSetApproval().label(), |
| approvalData.patchSetApproval().value()) |
| .format()) |
| .thenComparing( |
| approvalData -> |
| accountCache |
| .getEvenIfMissing(approvalData.patchSetApproval().accountId()) |
| .account() |
| .getNameEmail(anonymousCowardName))) |
| .collect(toImmutableList()); |
| |
| ImmutableListMultimap<LabelVote, Account.Id> approversByLabelVote = |
| Multimaps.index( |
| approvalsSortedByLabelVoteAndApprover, |
| approvalData -> |
| LabelVote.create( |
| approvalData.patchSetApproval().label(), |
| approvalData.patchSetApproval().value())) |
| .entries().stream() |
| .collect( |
| toImmutableListMultimap( |
| e -> e.getKey(), e -> e.getValue().patchSetApproval().accountId())); |
| message.append( |
| approversByLabelVote.asMap().entrySet().stream() |
| .map( |
| approversByLabelVoteEntry -> |
| formatLabelVoteWithApprovers( |
| approversByLabelVoteEntry.getKey(), approversByLabelVoteEntry.getValue())) |
| .collect(joining(", "))); |
| } else { |
| // copy condition doesn't contain a UserInPredicate |
| message.append( |
| formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms)); |
| } |
| ImmutableSet<String> passingAtoms = |
| !approvalsWithSameLabelAndSamePassingAndFailingAtoms.isEmpty() |
| ? approvalsWithSameLabelAndSamePassingAndFailingAtoms.iterator().next().passingAtoms() |
| : ImmutableSet.of(); |
| message.append( |
| String.format( |
| " (copy condition: \"%s\")", |
| formatCopyConditionAsMarkdown(copyCondition, passingAtoms))); |
| return message.toString(); |
| } |
| |
| /** Checks that all given approvals have the same value for a given property. */ |
| private void checkThatPropertyIsTheSameForAllApprovals( |
| Collection<ApprovalCopier.Result.PatchSetApprovalData> approvals, |
| String propertyName, |
| Function<ApprovalCopier.Result.PatchSetApprovalData, ?> propertyExtractor) { |
| if (approvals.isEmpty()) { |
| return; |
| } |
| |
| Object propertyOfFirstEntry = propertyExtractor.apply(approvals.iterator().next()); |
| approvals.forEach( |
| approvalData -> |
| checkState( |
| propertyExtractor.apply(approvalData).equals(propertyOfFirstEntry), |
| "property %s of approval %s does not match, expected value: %s", |
| propertyName, |
| approvalData, |
| propertyOfFirstEntry)); |
| } |
| |
| /** |
| * Formats the given copy condition as a Markdown string. |
| * |
| * <p>Passing atoms are formatted as bold. |
| * |
| * @param copyCondition the copy condition that should be formatted |
| * @param passingAtoms atoms of the copy conditions which are passing/matching |
| * @return the formatted copy condition as a Markdown string |
| */ |
| private String formatCopyConditionAsMarkdown( |
| String copyCondition, ImmutableSet<String> passingAtoms) { |
| StringBuilder formattedCopyCondition = new StringBuilder(); |
| StringTokenizer tokenizer = new StringTokenizer(copyCondition, " ()", /* returnDelims= */ true); |
| while (tokenizer.hasMoreTokens()) { |
| String token = tokenizer.nextToken(); |
| if (passingAtoms.contains(token)) { |
| formattedCopyCondition.append("**" + token.replace("*", "\\*") + "**"); |
| } else { |
| formattedCopyCondition.append(token); |
| } |
| } |
| return formattedCopyCondition.toString(); |
| } |
| |
| private boolean containsUserInPredicate(String copyCondition) throws QueryParseException { |
| // Use a request context to run checks as an internal user with expanded visibility. This is |
| // so that the output of the copy condition does not depend on who is running the current |
| // request (e.g. a group used in this query might not be visible to the person sending this |
| // request). |
| try (ManualRequestContext ignored = requestContext.open()) { |
| return approvalQueryBuilderProvider.get().parse(copyCondition).getFlattenedPredicateList() |
| .stream() |
| .anyMatch(UserInPredicate.class::isInstance); |
| } |
| } |
| |
| /** |
| * Formats the given approvals as a comma-separated list of label votes. |
| * |
| * <p>E.g.: {@code Code-Review+1, CodeReview+2} |
| * |
| * @param sortedApprovalsForSameLabel the approvals that should be formatted as a comma-separated |
| * list of label votes, must be sorted |
| * @return the given approvals as a comma-separated list of label votes |
| */ |
| private String formatApprovalsAsLabelVotesList( |
| Collection<ApprovalCopier.Result.PatchSetApprovalData> sortedApprovalsForSameLabel) { |
| return sortedApprovalsForSameLabel.stream() |
| .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval) |
| .map(psa -> LabelVote.create(psa.label(), psa.value())) |
| .distinct() |
| .map(LabelVote::format) |
| .collect(joining(", ")); |
| } |
| |
| /** |
| * Formats the given label vote with a comma-separated list of the given approvers. |
| * |
| * <p>E.g.: {@code Code-Review+1 by <user1-placeholder>, <user2-placeholder>} |
| * |
| * @param labelVote the label vote that should be formatted with a comma-separated list of the |
| * given approver |
| * @param sortedApprovers the approvers that should be formatted as a comma-separated list for the |
| * given label vote |
| * @return the given label vote with a comma-separated list of the given approvers |
| */ |
| private String formatLabelVoteWithApprovers( |
| LabelVote labelVote, Collection<Account.Id> sortedApprovers) { |
| return new StringBuilder() |
| .append(labelVote.format()) |
| .append(" by ") |
| .append( |
| sortedApprovers.stream() |
| .map(AccountTemplateUtil::getAccountTemplate) |
| .collect(joining(", "))) |
| .toString(); |
| } |
| |
| /** |
| * Gets {@link PatchSetApproval}s for a specified patch-set. The result includes copied votes but |
| * does not include deleted labels. |
| * |
| * @param notes changenotes of the change. |
| * @param psId patch-set id for the change and patch-set we want to get approvals. |
| * @return all approvals for the specified patch-set, including copied votes, not including |
| * deleted labels. |
| */ |
| public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) { |
| List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovals().all().get(psId); |
| return labelNormalizer.normalize(notes, approvalsNotNormalized).getNormalized(); |
| } |
| |
| public Iterable<PatchSetApproval> byPatchSetUser( |
| ChangeNotes notes, PatchSet.Id psId, Account.Id accountId) { |
| return filterApprovals(byPatchSet(notes, psId), accountId); |
| } |
| |
| @Nullable |
| public PatchSetApproval getSubmitter(ChangeNotes notes, PatchSet.Id c) { |
| if (c == null) { |
| return null; |
| } |
| try { |
| // Submit approval is never copied. |
| return getSubmitter(c, byChangeExcludingCopiedApprovals(notes).get(c)); |
| } catch (StorageException e) { |
| return null; |
| } |
| } |
| |
| @Nullable |
| public static PatchSetApproval getSubmitter(PatchSet.Id c, Iterable<PatchSetApproval> approvals) { |
| if (c == null) { |
| return null; |
| } |
| PatchSetApproval submitter = null; |
| for (PatchSetApproval a : approvals) { |
| if (a.patchSetId().equals(c) && a.value() > 0 && a.isLegacySubmit()) { |
| if (submitter == null || a.granted().compareTo(submitter.granted()) > 0) { |
| submitter = a; |
| } |
| } |
| } |
| return submitter; |
| } |
| |
| public static String renderMessageWithApprovals( |
| int patchSetId, Map<String, Short> n, Map<String, PatchSetApproval> c) { |
| StringBuilder msgs = new StringBuilder("Uploaded patch set " + patchSetId); |
| if (!n.isEmpty()) { |
| boolean first = true; |
| for (Map.Entry<String, Short> e : n.entrySet()) { |
| if (c.containsKey(e.getKey()) && c.get(e.getKey()).value() == e.getValue()) { |
| continue; |
| } |
| if (first) { |
| msgs.append(":"); |
| first = false; |
| } |
| msgs.append(" ").append(LabelVote.create(e.getKey(), e.getValue()).format()); |
| } |
| } |
| return msgs.toString(); |
| } |
| } |