blob: 09820b13f922b2b527a1d5e895ea136e76c35c07 [file] [log] [blame]
// 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 Collection<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();
}
/**
* 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();
}
}