blob: cd2d79e615b089b2bcbc99fdb7e84b6f9ce8d7ee [file] [log] [blame]
// Copyright (C) 2014 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.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Table;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
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.Project;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.client.ChangeKind;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.change.ChangeKindCache;
import com.google.gerrit.server.change.LabelNormalizer;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.approval.ApprovalContext;
import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
import com.google.gerrit.server.util.ManualRequestContext;
import com.google.gerrit.server.util.OneOffRequestContext;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevWalk;
/**
* Computes copied approvals for a given patch set.
*
* <p>Approvals are copied if:
*
* <ul>
* <li>the approval on the previous patch set matches the copy condition of its label
* <li>the approval is not overridden by a current approval on the patch set
* </ul>
*
* <p>Callers should store the copied approvals in NoteDb when a new patch set is created.
*/
@Singleton
@VisibleForTesting
public class ApprovalCopier {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@AutoValue
public abstract static class Result {
/**
* Approvals that have been copied from the previous patch set.
*
* <p>An approval is copied if:
*
* <ul>
* <li>the approval on the previous patch set matches the copy condition of its label
* <li>the approval is not overridden by a current approval on the patch set
* </ul>
*/
public abstract ImmutableSet<PatchSetApprovalData> copiedApprovals();
/**
* Approvals on the previous patch set that have not been copied to the patch set.
*
* <p>These approvals didn't match the copy condition of their labels and hence haven't been
* copied.
*
* <p>Only returns non-copied approvals of the previous patch set. Approvals from earlier patch
* sets that were outdated before are not included.
*/
public abstract ImmutableSet<PatchSetApprovalData> outdatedApprovals();
static Result empty() {
return create(
/* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of());
}
@VisibleForTesting
public static Result create(
ImmutableSet<PatchSetApprovalData> copiedApprovals,
ImmutableSet<PatchSetApprovalData> outdatedApprovals) {
return new AutoValue_ApprovalCopier_Result(copiedApprovals, outdatedApprovals);
}
/**
* A {@link PatchSetApproval} with information about which atoms of the copy condition are
* passing/failing.
*/
@AutoValue
public abstract static class PatchSetApprovalData {
/** The approval. */
public abstract PatchSetApproval patchSetApproval();
/**
* Lists the leaf predicates of the copy condition that are fulfilled.
*
* <p>Example: The expression
*
* <pre>
* changekind:TRIVIAL_REBASE OR is:MIN
* </pre>
*
* has two leaf predicates:
*
* <ul>
* <li>changekind:TRIVIAL_REBASE
* <li>is:MIN
* </ul>
*
* This method will return the leaf predicates that are fulfilled, for example if only the
* first predicate is fulfilled, the returned list will be equal to
* ["changekind:TRIVIAL_REBASE"].
*
* <p>Empty if the label type is missing, if there is no copy condition or if the copy
* condition is not parseable.
*/
public abstract ImmutableSet<String> passingAtoms();
/**
* Lists the leaf predicates of the copy condition that are not fulfilled. See {@link
* #passingAtoms()} for more details.
*
* <p>Empty if the label type is missing, if there is no copy condition or if the copy
* condition is not parseable.
*/
public abstract ImmutableSet<String> failingAtoms();
@VisibleForTesting
public static PatchSetApprovalData create(
PatchSetApproval approval,
ImmutableSet<String> passingAtoms,
ImmutableSet<String> failingAtoms) {
return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(
approval, passingAtoms, failingAtoms);
}
private static PatchSetApprovalData createForMissingLabelType(PatchSetApproval approval) {
return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(
approval, ImmutableSet.of(), ImmutableSet.of());
}
}
}
private final GitRepositoryManager repoManager;
private final ProjectCache projectCache;
private final ChangeKindCache changeKindCache;
private final PatchSetUtil psUtil;
private final LabelNormalizer labelNormalizer;
private final ApprovalQueryBuilder approvalQueryBuilder;
private final OneOffRequestContext requestContext;
@Inject
ApprovalCopier(
GitRepositoryManager repoManager,
ProjectCache projectCache,
ChangeKindCache changeKindCache,
PatchSetUtil psUtil,
LabelNormalizer labelNormalizer,
ApprovalQueryBuilder approvalQueryBuilder,
OneOffRequestContext requestContext) {
this.repoManager = repoManager;
this.projectCache = projectCache;
this.changeKindCache = changeKindCache;
this.psUtil = psUtil;
this.labelNormalizer = labelNormalizer;
this.approvalQueryBuilder = approvalQueryBuilder;
this.requestContext = requestContext;
}
/**
* Returns all copied approvals that apply to the given patch set.
*
* <p>Approvals are copied if:
*
* <ul>
* <li>the approval on the previous patch set matches the copy condition of its label
* <li>the approval is not overridden by a current approval on the patch set
* </ul>
*/
@VisibleForTesting
public Result forPatchSet(ChangeNotes notes, PatchSet ps, RevWalk rw, Config repoConfig) {
ProjectState project;
try (TraceTimer traceTimer =
TraceContext.newTimer(
"Computing labels for patch set",
Metadata.builder()
.changeId(notes.load().getChangeId().get())
.patchSetId(ps.id().get())
.build())) {
project =
projectCache
.get(notes.getProjectName())
.orElseThrow(illegalState(notes.getProjectName()));
return computeForPatchSet(project.getLabelTypes(), notes, ps, rw, repoConfig);
}
}
/**
* Returns all follow-up patch sets of the given patch set to which the given approval is
* copyable.
*
* <p>An approval is considered as copyable to a follow-up patch set if it matches the copy rules
* of the label and it is copyable to all intermediate follow-up patch sets as well.
*
* <p>The returned follow-up patch sets are returned in the order of their patch set IDs.
*
* <p>Note: This method only checks the copy rules to detect if the approval is copyable. There
* are other factors, not checked here, that can prevent the copying of the approval to the
* returned follow-up patch sets (e.g. if they already have a matching non-copy approval that
* prevents the copying).
*
* @param changeNotes the change notes
* @param sourcePatchSet the patch set on which the approval was applied
* @param approverId the account ID of the user that applied the approval
* @param label the label of the approval that was applied
* @param approvalValue the value of the approval that was applied
* @return the follow-up patch sets to which the approval is copyable, ordered by patch set ID
*/
public ImmutableList<PatchSet.Id> forApproval(
ChangeNotes changeNotes,
PatchSet sourcePatchSet,
Account.Id approverId,
String label,
short approvalValue)
throws IOException {
ImmutableList.Builder<PatchSet.Id> targetPatchSetsBuilder = ImmutableList.builder();
Optional<LabelType> labelType =
projectCache
.get(changeNotes.getProjectName())
.orElseThrow(illegalState(changeNotes.getProjectName()))
.getLabelTypes()
.byLabel(label);
if (!labelType.isPresent()) {
// no label type exists for this label, hence this approval cannot be copied
return ImmutableList.of();
}
try (Repository repo = repoManager.openRepository(changeNotes.getProjectName());
RevWalk revWalk = new RevWalk(repo)) {
ImmutableList<PatchSet.Id> followUpPatchSets =
changeNotes.getPatchSets().keySet().stream()
.filter(psId -> psId.get() > sourcePatchSet.id().get())
.collect(toImmutableList());
PatchSet priorPatchSet = sourcePatchSet;
// Iterate over the follow-up patch sets in order to copy the approval from their prior patch
// set if possible (copy from PS N-1 to PS N).
for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
PatchSet followUpPatchSet = psUtil.get(changeNotes, followUpPatchSetId);
ChangeKind changeKind =
changeKindCache.getChangeKind(
changeNotes.getProjectName(),
revWalk,
repo.getConfig(),
priorPatchSet.commitId(),
followUpPatchSet.commitId());
boolean isMerge = isMerge(changeNotes.getProjectName(), revWalk, followUpPatchSet);
if (computeCopyResult(
changeNotes,
priorPatchSet.id(),
followUpPatchSet,
approverId,
labelType.get(),
approvalValue,
changeKind,
isMerge,
revWalk,
repo.getConfig())
.canCopy()) {
targetPatchSetsBuilder.add(followUpPatchSetId);
} else {
// The approval is not copyable to this follow-up patch set.
// This means it's also not copyable to any further follow-up patch set and we should stop
// the loop here.
break;
}
priorPatchSet = followUpPatchSet;
}
}
return targetPatchSetsBuilder.build();
}
/**
* Checks whether a given approval can be copied from the given source patch set to the given
* target patch set.
*
* <p>The returned result also informs about which atoms of the copy condition are
* passing/failing.
*/
private ApprovalCopyResult computeCopyResult(
ChangeNotes changeNotes,
PatchSet.Id sourcePatchSetId,
PatchSet targetPatchSet,
Account.Id approverId,
LabelType labelType,
short approvalValue,
ChangeKind changeKind,
boolean isMerge,
RevWalk revWalk,
Config repoConfig) {
if (!labelType.getCopyCondition().isPresent()) {
return ApprovalCopyResult.createForMissingCopyCondition();
}
ApprovalContext ctx =
ApprovalContext.create(
changeNotes,
sourcePatchSetId,
approverId,
labelType,
approvalValue,
targetPatchSet,
changeKind,
isMerge,
revWalk,
repoConfig);
try {
// 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()) {
Predicate<ApprovalContext> copyConditionPredicate =
approvalQueryBuilder.parse(labelType.getCopyCondition().get());
boolean canCopy = copyConditionPredicate.asMatchable().match(ctx);
ImmutableSet.Builder<String> passingAtoms = ImmutableSet.builder();
ImmutableSet.Builder<String> failingAtoms = ImmutableSet.builder();
evaluateAtoms(copyConditionPredicate, ctx, passingAtoms, failingAtoms);
return ApprovalCopyResult.create(canCopy, passingAtoms.build(), failingAtoms.build());
}
} catch (QueryParseException e) {
logger.atWarning().withCause(e).log(
"Unable to copy label because config is invalid. This should have been caught before.");
return ApprovalCopyResult.createForNonParseableCopyCondition();
}
}
private Result computeForPatchSet(
LabelTypes labelTypes,
ChangeNotes notes,
PatchSet targetPatchSet,
RevWalk rw,
Config repoConfig) {
Project.NameKey projectName = notes.getProjectName();
PatchSet.Id targetPsId = targetPatchSet.id();
// Bail out immediately if this is the first patch set. Return only approvals granted on the
// given patch set.
if (targetPsId.get() == 1) {
return Result.empty();
}
Map.Entry<PatchSet.Id, PatchSet> priorPatchSet =
notes.load().getPatchSets().lowerEntry(targetPsId);
if (priorPatchSet == null) {
return Result.empty();
}
Table<String, Account.Id, PatchSetApproval> currentApprovalsByUser = HashBasedTable.create();
ImmutableList<PatchSetApproval> nonCopiedApprovalsForGivenPatchSet =
notes.load().getApprovals().onlyNonCopied().get(targetPatchSet.id());
nonCopiedApprovalsForGivenPatchSet.forEach(
psa -> currentApprovalsByUser.put(psa.label(), psa.accountId(), psa));
Table<String, Account.Id, Result.PatchSetApprovalData> copiedApprovalsByUser =
HashBasedTable.create();
ImmutableSet.Builder<Result.PatchSetApprovalData> outdatedApprovalsBuilder =
ImmutableSet.builder();
ImmutableList<PatchSetApproval> priorApprovals =
notes.load().getApprovals().all().get(priorPatchSet.getKey());
// Add labels from the previous patch set to the result in case the label isn't already there
// and settings as well as change kind allow copying.
ChangeKind changeKind =
changeKindCache.getChangeKind(
projectName,
rw,
repoConfig,
priorPatchSet.getValue().commitId(),
targetPatchSet.commitId());
boolean isMerge = isMerge(projectName, rw, targetPatchSet);
logger.atFine().log(
"change kind for patch set %d of change %d against prior patch set %s is %s",
targetPatchSet.id().get(),
targetPatchSet.id().changeId().get(),
priorPatchSet.getValue().id().changeId(),
changeKind);
for (PatchSetApproval priorPsa : priorApprovals) {
if (priorPsa.value() == 0) {
// approvals with a zero vote record the deletion of a vote,
// they should neither be copied nor be reported as outdated, hence just skip them
continue;
}
Optional<LabelType> labelType = labelTypes.byLabel(priorPsa.labelId());
if (!labelType.isPresent()) {
logger.atFine().log(
"approval %d on label %s of patch set %d of change %d cannot be copied"
+ " to patch set %d because the label no longer exists on project %s",
priorPsa.value(),
priorPsa.label(),
priorPsa.key().patchSetId().get(),
priorPsa.key().patchSetId().changeId().get(),
targetPsId.get(),
projectName);
outdatedApprovalsBuilder.add(
Result.PatchSetApprovalData.createForMissingLabelType(priorPsa));
continue;
}
ApprovalCopyResult approvalCopyResult =
computeCopyResult(
notes,
priorPsa.patchSetId(),
targetPatchSet,
priorPsa.accountId(),
labelType.get(),
priorPsa.value(),
changeKind,
isMerge,
rw,
repoConfig);
if (approvalCopyResult.canCopy()) {
if (!currentApprovalsByUser.contains(priorPsa.label(), priorPsa.accountId())) {
PatchSetApproval copiedApproval = priorPsa.copyWithPatchSet(targetPatchSet.id());
// Normalize the copied approval.
Optional<PatchSetApproval> copiedApprovalNormalized =
labelNormalizer.normalize(notes, copiedApproval);
logger.atFine().log(
"Copied approval %s has been normalized to %s",
copiedApproval,
copiedApprovalNormalized.map(PatchSetApproval::toString).orElse("n/a"));
if (!copiedApprovalNormalized.isPresent()) {
continue;
}
copiedApprovalsByUser.put(
priorPsa.label(),
priorPsa.accountId(),
Result.PatchSetApprovalData.create(
copiedApprovalNormalized.get(),
approvalCopyResult.passingAtoms(),
approvalCopyResult.failingAtoms()));
}
} else {
outdatedApprovalsBuilder.add(
Result.PatchSetApprovalData.create(
priorPsa, approvalCopyResult.passingAtoms(), approvalCopyResult.failingAtoms()));
continue;
}
}
return Result.create(
ImmutableSet.copyOf(copiedApprovalsByUser.values()), outdatedApprovalsBuilder.build());
}
private boolean isMerge(Project.NameKey project, RevWalk rw, PatchSet patchSet) {
try {
return rw.parseCommit(patchSet.commitId()).getParentCount() > 1;
} catch (IOException e) {
throw new StorageException(
String.format(
"failed to check if patch set %d of change %s in project %s is a merge commit",
patchSet.id().get(), patchSet.id().changeId(), project),
e);
}
}
/**
* Evaluates a predicate of the copy condition and adds its passing and failing atoms to the given
* builders.
*
* @param predicate a predicate of the copy condition that should be evaluated
* @param approvalContext the approval context against which the predicate should be evaluated
* @param passingAtoms a builder to which passing atoms should be added
* @param failingAtoms a builder to which failing atoms should be added
*/
private static void evaluateAtoms(
Predicate<ApprovalContext> predicate,
ApprovalContext approvalContext,
ImmutableSet.Builder<String> passingAtoms,
ImmutableSet.Builder<String> failingAtoms) {
if (predicate.isLeaf()) {
boolean isPassing = predicate.asMatchable().match(approvalContext);
(isPassing ? passingAtoms : failingAtoms).add(predicate.getPredicateString());
return;
}
predicate
.getChildren()
.forEach(
childPredicate ->
evaluateAtoms(childPredicate, approvalContext, passingAtoms, failingAtoms));
}
/** Result for checking if an approval can be copied to the next patch set. */
@AutoValue
abstract static class ApprovalCopyResult {
/** Whether the approval can be copied to the next patch set. */
abstract boolean canCopy();
/**
* Lists the leaf predicates of the copy condition that are fulfilled. See {@link
* Result.PatchSetApprovalData#passingAtoms()} for more details.
*
* <p>Empty if there is no copy condition or if the copy condition is not parseable.
*/
abstract ImmutableSet<String> passingAtoms();
/**
* Lists the leaf predicates of the copy condition that are not fulfilled. See {@link
* Result.PatchSetApprovalData#passingAtoms()} for more details.
*
* <p>Empty if there is no copy condition or if the copy condition is not parseable.
*/
abstract ImmutableSet<String> failingAtoms();
private static ApprovalCopyResult create(
boolean canCopy, ImmutableSet<String> passingAtoms, ImmutableSet<String> failingAtoms) {
return new AutoValue_ApprovalCopier_ApprovalCopyResult(canCopy, passingAtoms, failingAtoms);
}
private static ApprovalCopyResult createForMissingCopyCondition() {
return new AutoValue_ApprovalCopier_ApprovalCopyResult(
/* canCopy= */ false,
/* passingAtoms= */ ImmutableSet.of(),
/* failingAtoms= */ ImmutableSet.of());
}
private static ApprovalCopyResult createForNonParseableCopyCondition() {
return new AutoValue_ApprovalCopier_ApprovalCopyResult(
/* canCopy= */ false,
/* passingAtoms= */ ImmutableSet.of(),
/* failingAtoms= */ ImmutableSet.of());
}
}
}