com.google.gerrit.server.approval.ApprovalCopier Maven / Gradle / Ivy
// 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.base.Strings;
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.common.Nullable;
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.config.GerritServerConfig;
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.query.change.ChangeData;
import com.google.gerrit.server.update.RepoView;
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.Singleton;
import java.io.IOException;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.eclipse.jgit.attributes.AttributesNodeProvider;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevWalk;
/**
 * Computes copied approvals for a given patch set.
 *
 * Approvals are copied if:
 *
 * 
 *   - the approval on the previous patch set matches the copy condition of its label
 *   
 - the approval is not overridden by a current approval on the patch set
 * 
 
 *
 * 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.
     *
     * 
An approval is copied if:
     *
     * 
     *   - the approval on the previous patch set matches the copy condition of its label
     *   
 - the approval is not overridden by a current approval on the patch set
     * 
 
     */
    public abstract ImmutableSet copiedApprovals();
    /**
     * Approvals on the previous patch set that have not been copied to the patch set.
     *
     * These approvals didn't match the copy condition of their labels and hence haven't been
     * copied.
     *
     * 
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 outdatedApprovals();
    static Result empty() {
      return create(
          /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of());
    }
    @VisibleForTesting
    public static Result create(
        ImmutableSet copiedApprovals,
        ImmutableSet 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();
      /** Details about the evaluation of approval copy condition. */
      public abstract ApprovalCopyResult approvalCopyResult();
      @VisibleForTesting
      public static PatchSetApprovalData create(
          PatchSetApproval approval, ApprovalCopyResult copyResult) {
        return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(approval, copyResult);
      }
      private static PatchSetApprovalData createForMissingLabelType(PatchSetApproval approval) {
        return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(
            approval, ApprovalCopyResult.createEvaluationSkipped());
      }
    }
  }
  /** Result for checking if an approval can be copied to the next patch set. */
  @AutoValue
  public abstract static class ApprovalCopyResult {
    /** Whether the approval can be copied to the next patch set. */
    public abstract boolean canCopy();
    /** Label's copyCondition */
    public abstract @Nullable String labelCopyCondition();
    /** Whether the approval can be copied to the next patch set based on label's copyCondition. */
    public abstract boolean labelCopy();
    /** Condition that forces copy based on server configuration */
    public abstract @Nullable String copyEnforcement();
    /**
     * Whether the approval must be copied to the next patch set based on servers copyEnforcement.
     */
    public abstract boolean forcedCopy();
    /** Condition that forces copy not to be made based on server configuration */
    public abstract @Nullable String copyRestriction();
    /**
     * Whether the approval must be not be copied to the next patch set based on servers
     * copyRestriction.
     */
    public abstract boolean forcedNonCopy();
    /**
     * Lists the leaf predicates of the copy condition that are fulfilled.
     *
     * Example: The expression
     *
     * 
     * changekind:TRIVIAL_REBASE OR is:MIN
     * 
     *
     * has two leaf predicates:
     *
     * 
     *   - changekind:TRIVIAL_REBASE
     *   
 - is:MIN
     * 
 
     *
     * 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"].
     *
     * Empty if the label type is missing, if there is no copy condition or if the copy condition
     * is not parseable.
     */
    public abstract ImmutableSet passingAtoms();
    /**
     * Lists the leaf predicates of the copy condition that are not fulfilled. See {@link
     * #passingAtoms()} for more details.
     *
     * Empty if the label type is missing, if there is no copy condition or if the copy condition
     * is not parseable.
     */
    public abstract ImmutableSet failingAtoms();
    public static ApprovalCopyResult create(
        @Nullable String labelCopyCondition,
        boolean labelCopy,
        @Nullable String copyEnforcement,
        boolean forcedCopy,
        @Nullable String copyRestriction,
        boolean forcedNonCopy,
        Set passingAtoms,
        Set failingAtoms) {
      return new AutoValue_ApprovalCopier_ApprovalCopyResult(
          forcedCopy || (labelCopy && !forcedNonCopy),
          labelCopyCondition,
          labelCopy,
          copyEnforcement,
          forcedCopy,
          copyRestriction,
          forcedNonCopy,
          ImmutableSet.copyOf(passingAtoms),
          ImmutableSet.copyOf(failingAtoms));
    }
    public static ApprovalCopyResult createEvaluationSkipped() {
      return new AutoValue_ApprovalCopier_ApprovalCopyResult(
          false, null, false, null, false, null, false, 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;
  private final ChangeData.Factory changeDataFactory;
  private final Config cfg;
  @Inject
  ApprovalCopier(
      GitRepositoryManager repoManager,
      ProjectCache projectCache,
      ChangeKindCache changeKindCache,
      PatchSetUtil psUtil,
      LabelNormalizer labelNormalizer,
      ApprovalQueryBuilder approvalQueryBuilder,
      OneOffRequestContext requestContext,
      ChangeData.Factory changeDataFactory,
      @GerritServerConfig Config cfg) {
    this.repoManager = repoManager;
    this.projectCache = projectCache;
    this.changeKindCache = changeKindCache;
    this.psUtil = psUtil;
    this.labelNormalizer = labelNormalizer;
    this.approvalQueryBuilder = approvalQueryBuilder;
    this.requestContext = requestContext;
    this.changeDataFactory = changeDataFactory;
    this.cfg = cfg;
  }
  /**
   * Returns all copied approvals that apply to the given patch set.
   *
   * Approvals are copied if:
   *
   * 
   *   - the approval on the previous patch set matches the copy condition of its label
   *   
 - the approval is not overridden by a current approval on the patch set
   * 
 
   */
  @VisibleForTesting
  public Result forPatchSet(ChangeNotes notes, PatchSet ps, RepoView repoView) {
    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, repoView);
    }
  }
  /**
   * Returns all follow-up patch sets of the given patch set to which the given approval is
   * copyable.
   *
   * 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.
   *
   * 
The returned follow-up patch sets are returned in the order of their patch set IDs.
   *
   * 
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 forApproval(
      ChangeNotes changeNotes,
      PatchSet sourcePatchSet,
      Account.Id approverId,
      String label,
      short approvalValue)
      throws IOException {
    ImmutableList.Builder targetPatchSetsBuilder = ImmutableList.builder();
    Optional 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());
        ObjectInserter ins = repo.newObjectInserter();
        ObjectReader reader = ins.newReader();
        RevWalk revWalk = new RevWalk(reader);
        RepoView repoView = new RepoView(repo, revWalk, ins)) {
      ImmutableList 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).
      AttributesNodeProvider attributesNodeProvider = repo.createAttributesNodeProvider();
      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
        PatchSet followUpPatchSet = psUtil.get(changeNotes, followUpPatchSetId);
        ChangeKind changeKind =
            changeKindCache.getChangeKind(
                changeNotes.getProjectName(),
                revWalk,
                repo.getConfig(),
                attributesNodeProvider,
                priorPatchSet.commitId(),
                followUpPatchSet.commitId());
        boolean isMerge = isMerge(changeNotes.getProjectName(), revWalk, followUpPatchSet);
        if (computeCopyResult(
                changeNotes,
                priorPatchSet.id(),
                followUpPatchSet,
                approverId,
                labelType.get(),
                approvalValue,
                changeKind,
                isMerge,
                repoView)
            .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.
   *
   * 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,
      RepoView repoView) {
    String forcedCopyCondition =
        cfg.getString("label", labelType.getName(), "labelCopyEnforcement");
    String forcedNonCopyCondition =
        cfg.getString("label", labelType.getName(), "labelCopyRestriction");
    String labelCopyCondition = labelType.getCopyCondition().orElse(null);
    if (Strings.isNullOrEmpty(forcedCopyCondition)
        && Strings.isNullOrEmpty(forcedNonCopyCondition)
        && Strings.isNullOrEmpty(labelCopyCondition)) {
      return ApprovalCopyResult.createEvaluationSkipped();
    }
    ApprovalContext ctx =
        ApprovalContext.create(
            changeDataFactory.create(changeNotes),
            sourcePatchSetId,
            approverId,
            labelType,
            approvalValue,
            targetPatchSet,
            changeKind,
            isMerge,
            repoView);
    // 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()) {
      LinkedHashSet passingAtoms = new LinkedHashSet<>();
      LinkedHashSet failingAtoms = new LinkedHashSet<>();
      boolean labelCopy = evaluateCondition(labelCopyCondition, ctx, passingAtoms, failingAtoms);
      boolean forcedCopy = evaluateCondition(forcedCopyCondition, ctx, passingAtoms, failingAtoms);
      boolean forcedNonCopy =
          evaluateCondition(forcedNonCopyCondition, ctx, passingAtoms, failingAtoms);
      ApprovalCopyResult result =
          ApprovalCopyResult.create(
              labelCopyCondition,
              labelCopy,
              forcedCopyCondition,
              forcedCopy,
              forcedNonCopyCondition,
              forcedNonCopy,
              passingAtoms,
              failingAtoms);
      logger.atFine().log(
          "%s copy %s of account %d on change %d from patch set %d to patch set %d"
              + " (%s%s%spassingAtoms = %s, failingAtoms = %s, changeKind = %s)",
          result.canCopy() ? "Can" : "Cannot",
          LabelVote.create(labelType.getName(), approvalValue).format(),
          approverId.get(),
          changeNotes.getChangeId().get(),
          sourcePatchSetId.get(),
          targetPatchSet.id().get(),
          !Strings.isNullOrEmpty(labelCopyCondition)
              ? String.format("copyCondition = %s, ", labelCopyCondition)
              : "",
          !Strings.isNullOrEmpty(forcedCopyCondition)
              ? String.format("copyEnforcement = %s, ", forcedCopyCondition)
              : "",
          !Strings.isNullOrEmpty(forcedNonCopyCondition)
              ? String.format("copyRestriction = %s, ", forcedNonCopyCondition)
              : "",
          passingAtoms,
          failingAtoms,
          changeKind.name());
      return result;
    }
  }
  private boolean evaluateCondition(
      @Nullable String copyCondition,
      ApprovalContext ctx,
      LinkedHashSet passingAtoms,
      LinkedHashSet failingAtoms) {
    if (Strings.isNullOrEmpty(copyCondition)) {
      return false;
    }
    try {
      Predicate copyConditionPredicate = approvalQueryBuilder.parse(copyCondition);
      boolean result = copyConditionPredicate.asMatchable().match(ctx);
      evaluateAtoms(copyConditionPredicate, ctx, passingAtoms, failingAtoms);
      return result;
    } catch (QueryParseException e) {
      logger.atWarning().withCause(e).log(
          "Unable to copy label because config is invalid. This should have been caught before.");
      return false;
    }
  }
  private Result computeForPatchSet(
      LabelTypes labelTypes, ChangeNotes notes, PatchSet targetPatchSet, RepoView repoView) {
    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 priorPatchSet =
        notes.load().getPatchSets().lowerEntry(targetPsId);
    if (priorPatchSet == null) {
      return Result.empty();
    }
    Table currentApprovalsByUser = HashBasedTable.create();
    ImmutableList nonCopiedApprovalsForGivenPatchSet =
        notes.load().getApprovals().onlyNonCopied().get(targetPatchSet.id());
    nonCopiedApprovalsForGivenPatchSet.forEach(
        psa -> currentApprovalsByUser.put(psa.label(), psa.accountId(), psa));
    Table copiedApprovalsByUser =
        HashBasedTable.create();
    ImmutableSet.Builder outdatedApprovalsBuilder =
        ImmutableSet.builder();
    ImmutableList 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,
            repoView.getRevWalk(),
            repoView.getConfig(),
            repoView.getAttributesNodeProvider(),
            priorPatchSet.getValue().commitId(),
            targetPatchSet.commitId());
    boolean isMerge = isMerge(projectName, repoView.getRevWalk(), 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 = 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,
              repoView);
      if (approvalCopyResult.canCopy()) {
        if (!currentApprovalsByUser.contains(priorPsa.label(), priorPsa.accountId())) {
          PatchSetApproval copiedApproval = priorPsa.copyWithPatchSet(targetPatchSet.id());
          // Normalize the copied approval.
          Optional 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));
        }
      } else {
        outdatedApprovalsBuilder.add(
            Result.PatchSetApprovalData.create(priorPsa, approvalCopyResult));
        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 predicate,
      ApprovalContext approvalContext,
      LinkedHashSet passingAtoms,
      LinkedHashSet failingAtoms) {
    if (predicate.isLeaf()) {
      String predicateString = predicate.getPredicateString();
      if (passingAtoms.contains(predicateString) || failingAtoms.contains(predicateString)) {
        return;
      }
      boolean isPassing = predicate.asMatchable().match(approvalContext);
      (isPassing ? passingAtoms : failingAtoms).add(predicateString);
      return;
    }
    predicate
        .getChildren()
        .forEach(
            childPredicate ->
                evaluateAtoms(childPredicate, approvalContext, passingAtoms, failingAtoms));
  }
}