blob: 71c00440deb6007510fdae0862056c338afa7690 [file] [log] [blame]
// Copyright (C) 2020 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.plugins.codeowners.backend;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException.newInternalServerError;
import static java.util.Objects.requireNonNull;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
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.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.metrics.Timer1;
import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval;
import com.google.gerrit.plugins.codeowners.common.ChangedFile;
import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.PureRevertCache;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.patch.DiffNotAvailableException;
import com.google.gerrit.server.util.AccountTemplateUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Collections;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevWalk;
/**
* Class to check code owner approvals on a change.
*
* <p>Every file, or rather touched path, in a change needs to be approved by a code owner:
*
* <ul>
* <li>New file: requires approval on the new path
* <li>Modified file: requires approval on the old/new path (old path == new path)
* <li>Deleted file: requires approval on the old path
* <li>Renamed file: requires approval on the old and new path (equivalent to delete old path +
* add new path)
* <li>Copied file: requires approval on the new path (an approval on the old path is not needed
* since the file at this path was not touched)
* </ul>
*/
@Singleton
public class CodeOwnerApprovalCheck {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final GitRepositoryManager repoManager;
private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
private final ChangedFiles changedFiles;
private final PureRevertCache pureRevertCache;
private final Provider<CodeOwnerConfigHierarchy> codeOwnerConfigHierarchyProvider;
private final Provider<CodeOwnerResolver> codeOwnerResolverProvider;
private final CodeOwnerApprovalCheckInput.Loader.Factory inputLoaderFactory;
private final CodeOwnerMetrics codeOwnerMetrics;
private final ChangedFilesByPatchSetCache.Factory changedFilesByPatchSetCacheFactory;
@Inject
CodeOwnerApprovalCheck(
GitRepositoryManager repoManager,
CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
ChangedFiles changedFiles,
PureRevertCache pureRevertCache,
Provider<CodeOwnerConfigHierarchy> codeOwnerConfigHierarchyProvider,
Provider<CodeOwnerResolver> codeOwnerResolverProvider,
CodeOwnerApprovalCheckInput.Loader.Factory codeOwnerApprovalCheckInputLoaderFactory,
CodeOwnerMetrics codeOwnerMetrics,
ChangedFilesByPatchSetCache.Factory changedFilesByPatchSetCacheFactory) {
this.repoManager = repoManager;
this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
this.changedFiles = changedFiles;
this.pureRevertCache = pureRevertCache;
this.codeOwnerConfigHierarchyProvider = codeOwnerConfigHierarchyProvider;
this.codeOwnerResolverProvider = codeOwnerResolverProvider;
this.inputLoaderFactory = codeOwnerApprovalCheckInputLoaderFactory;
this.codeOwnerMetrics = codeOwnerMetrics;
this.changedFilesByPatchSetCacheFactory = changedFilesByPatchSetCacheFactory;
}
/**
* Returns the paths of the files in the given patch set that are owned by the specified account.
*
* @param changeNotes the change notes for which the owned files should be returned
* @param patchSet the patch set for which the owned files should be returned
* @param accountId account ID of the code owner for which the owned files should be returned
* @param start number of owned paths to skip
* @param limit the max number of owned paths that should be returned (0 = unlimited)
* @param checkReviewers whether to check if the reviewers are in the owners.
* @return the paths of the files in the given patch set that are owned by the specified account
*/
public ImmutableList<OwnedChangedFile> getOwnedPaths(
ChangeNotes changeNotes,
PatchSet patchSet,
Account.Id accountId,
int start,
int limit,
boolean checkReviewers) {
try (Timer0.Context ctx = codeOwnerMetrics.computeOwnedPaths.start()) {
logger.atFine().log(
"compute owned paths for account %d (project = %s, change = %d, patch set = %d,"
+ " start = %d, limit = %d)",
accountId.get(),
changeNotes.getProjectName(),
changeNotes.getChangeId().get(),
patchSet.id().get(),
start,
limit);
ImmutableSet.Builder<Account.Id> checkOwnerIds = ImmutableSet.builder();
checkOwnerIds.add(accountId);
if (checkReviewers) {
checkOwnerIds.addAll(changeNotes.getReviewers().byState(ReviewerStateInternal.REVIEWER));
}
Stream<FileCodeOwnerStatus> fileStatuses =
getFileStatusesForAccounts(changeNotes, patchSet, checkOwnerIds.build())
.filter(
fileStatus ->
(fileStatus.newPathStatus().isPresent()
&& fileStatus.newPathStatus().get().owners().isPresent()
&& !fileStatus.newPathStatus().get().owners().get().isEmpty())
|| (fileStatus.oldPathStatus().isPresent()
&& fileStatus.oldPathStatus().get().owners().isPresent()
&& !fileStatus.oldPathStatus().get().owners().get().isEmpty()));
if (start > 0) {
fileStatuses = fileStatuses.skip(start);
}
if (limit > 0) {
fileStatuses = fileStatuses.limit(limit);
}
return fileStatuses
.map(
fileStatus ->
OwnedChangedFile.create(
fileStatus
.newPathStatus()
.map(
newPathStatus ->
OwnedPath.create(
newPathStatus.path(),
newPathStatus.owners().isPresent()
&& newPathStatus.owners().get().contains(accountId),
newPathStatus.owners().isPresent()
? newPathStatus.owners().get()
: ImmutableSet.of()))
.orElse(null),
fileStatus
.oldPathStatus()
.map(
oldPathStatus ->
OwnedPath.create(
oldPathStatus.path(),
oldPathStatus.owners().isPresent()
&& oldPathStatus.owners().get().contains(accountId),
oldPathStatus.owners().isPresent()
? oldPathStatus.owners().get()
: ImmutableSet.of()))
.orElse(null)))
.collect(toImmutableList());
} catch (IOException | DiffNotAvailableException e) {
throw newInternalServerError(
String.format(
"failed to compute owned paths of patch set %s for account %d",
patchSet.id(), accountId.get()),
e);
}
}
/**
* Whether the given change has sufficient code owner approvals to be submittable.
*
* @param changeNotes the change notes
* @return whether the given change has sufficient code owner approvals to be submittable
*/
public boolean isSubmittable(ChangeNotes changeNotes)
throws IOException, DiffNotAvailableException {
requireNonNull(changeNotes, "changeNotes");
logger.atFine().log(
"checking if change %d in project %s is submittable",
changeNotes.getChangeId().get(), changeNotes.getProjectName());
CodeOwnerConfigHierarchy codeOwnerConfigHierarchy = codeOwnerConfigHierarchyProvider.get();
CodeOwnerResolver codeOwnerResolver = codeOwnerResolverProvider.get().enforceVisibility(false);
CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
try {
boolean isSubmittable =
!getFileStatuses(
codeOwnersConfig, codeOwnerConfigHierarchy, codeOwnerResolver, changeNotes)
.anyMatch(
fileStatus ->
(fileStatus.newPathStatus().isPresent()
&& fileStatus.newPathStatus().get().status()
!= CodeOwnerStatus.APPROVED)
|| (fileStatus.oldPathStatus().isPresent()
&& fileStatus.oldPathStatus().get().status()
!= CodeOwnerStatus.APPROVED));
logger.atFine().log(
"change %d in project %s %s submittable",
changeNotes.getChangeId().get(),
changeNotes.getProjectName(),
isSubmittable ? "is" : "is not");
return isSubmittable;
} finally {
codeOwnerMetrics.codeOwnerConfigBackendReadsPerChange.record(
codeOwnerConfigHierarchy.getCodeOwnerConfigCounters().getBackendReadCount());
codeOwnerMetrics.codeOwnerConfigCacheReadsPerChange.record(
codeOwnerConfigHierarchy.getCodeOwnerConfigCounters().getCacheReadCount());
codeOwnerMetrics.codeOwnerResolutionsPerChange.record(
codeOwnerResolver.getCodeOwnerCounters().getResolutionCount());
codeOwnerMetrics.codeOwnerConfigCacheReadsPerChange.record(
codeOwnerResolver.getCodeOwnerCounters().getCacheReadCount());
}
}
/**
* Gets the code owner statuses for all files/paths that were changed in the current revision of
* the given change as a set.
*
* @param start number of file statuses to skip
* @param limit the max number of file statuses that should be returned (0 = unlimited)
* @see #getFileStatuses(CodeOwnersPluginProjectConfigSnapshot, CodeOwnerConfigHierarchy,
* CodeOwnerResolver, ChangeNotes)
*/
public ImmutableSet<FileCodeOwnerStatus> getFileStatusesAsSet(
ChangeNotes changeNotes, int start, int limit) throws IOException, DiffNotAvailableException {
requireNonNull(changeNotes, "changeNotes");
CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
try (Timer1.Context<Boolean> ctx =
codeOwnerMetrics.computeFileStatuses.start(codeOwnersConfig.areStickyApprovalsEnabled())) {
logger.atFine().log(
"compute file statuses (project = %s, change = %d, start = %d, limit = %d)",
changeNotes.getProjectName(), changeNotes.getChangeId().get(), start, limit);
Stream<FileCodeOwnerStatus> fileStatuses =
getFileStatuses(
codeOwnersConfig,
codeOwnerConfigHierarchyProvider.get(),
codeOwnerResolverProvider.get().enforceVisibility(false),
changeNotes);
if (start > 0) {
fileStatuses = fileStatuses.skip(start);
}
if (limit > 0) {
fileStatuses = fileStatuses.limit(limit);
}
return fileStatuses.collect(toImmutableSet());
}
}
/**
* Gets the code owner statuses for all files/paths that were changed in the current revision of
* the given change.
*
* <p>The code owner statuses tell the user which code owner approvals are still missing in order
* to make the change submittable.
*
* <p>Computing the code owner statuses for non-current revisions is not supported since the
* semantics are unclear, e.g.:
*
* <ul>
* <li>non-current revisions are never submittable, hence asking which code owner approvals are
* still missing in order to make the revision submittable makes no sense
* <li>the code owner status {@link CodeOwnerStatus#PENDING} doesn't make sense for an old
* revision, from the perspective of the change owner this status looks like the change is
* waiting for the code owner to approve, but since voting on old revisions is not possible
* the code-owner is not able to provide this approval
* <li>the code owner statuses are computed from the approvals that were given by code owners,
* the UI always shows the current approval even when looking at an old revision, showing
* code owner statuses that mismatch the shown approvals (because they are computed from
* approvals that were present on an old revision) would only confuse users
* </ul>
*
* @param codeOwnerConfigHierarchy {@link CodeOwnerConfigHierarchy} instance that should be used
* to iterate over code owner config hierarchies
* @param changeNotes the notes of the change for which the current code owner statuses should be
* returned
*/
private Stream<FileCodeOwnerStatus> getFileStatuses(
CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig,
CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
CodeOwnerResolver codeOwnerResolver,
ChangeNotes changeNotes)
throws IOException, DiffNotAvailableException {
requireNonNull(changeNotes, "changeNotes");
try (Timer0.Context ctx = codeOwnerMetrics.prepareFileStatusComputation.start()) {
logger.atFine().log(
"prepare stream to compute file statuses (project = %s, change = %d)",
changeNotes.getProjectName(), changeNotes.getChangeId().get());
Account.Id patchSetUploader = changeNotes.getCurrentPatchSet().uploader();
ImmutableSet<Account.Id> exemptedAccounts = codeOwnersConfig.getExemptedAccounts();
logger.atFine().log("exemptedAccounts = %s", exemptedAccounts);
if (exemptedAccounts.contains(patchSetUploader)) {
logger.atFine().log(
"patch set uploader %d is exempted from requiring code owner approvals",
patchSetUploader.get());
return getAllPathsAsApproved(
changeNotes,
changeNotes.getCurrentPatchSet(),
String.format(
"patch set uploader %s is exempted from requiring code owner approvals",
AccountTemplateUtil.getAccountTemplate(patchSetUploader)));
}
boolean arePureRevertsExempted = codeOwnersConfig.arePureRevertsExempted();
logger.atFine().log("arePureRevertsExempted = %s", arePureRevertsExempted);
if (arePureRevertsExempted && isPureRevert(changeNotes)) {
logger.atFine().log(
"change is a pure revert and is exempted from requiring code owner approvals");
return getAllPathsAsApproved(
changeNotes,
changeNotes.getCurrentPatchSet(),
"change is a pure revert and is exempted from requiring code owner approvals");
}
BranchNameKey branch = changeNotes.getChange().getDest();
Optional<ObjectId> revision = getDestBranchRevision(changeNotes.getChange());
if (revision.isPresent()) {
logger.atFine().log(
"dest branch %s has revision %s", branch.branch(), revision.get().name());
} else {
logger.atFine().log("dest branch %s does not exist", branch.branch());
}
CodeOwnerApprovalCheckInput input =
inputLoaderFactory.create(codeOwnersConfig, codeOwnerResolver, changeNotes).load();
return changedFiles
.getFromDiffCache(
changeNotes.getProjectName(), changeNotes.getCurrentPatchSet().commitId())
.stream()
.map(
changedFile ->
getFileStatus(
codeOwnerConfigHierarchy,
codeOwnerResolver,
codeOwnersConfig,
changedFilesByPatchSetCacheFactory.create(codeOwnersConfig, changeNotes),
branch,
revision.orElse(null),
changedFile,
input));
}
}
/**
* Gets the code owner status for all files/paths that were changed in the current revision of the
* given change assuming that there is only an approval from the given account.
*
* <p>This method doesn't take approvals from other users and global code owners into account.
*
* <p>The purpose of this method is to find the files/paths in a change that are owned by the
* given account.
*
* <p>As a side-effect, it also computes, for each file, who the approvers are if the file is not
* approved.
*
* @param changeNotes the notes of the change for which the code owner statuses should be returned
* @param patchSet the patch set for which the code owner statuses should be returned
* @param accountIds The accounts to check whether they have owners permission.
*/
@VisibleForTesting
public Stream<FileCodeOwnerStatus> getFileStatusesForAccounts(
ChangeNotes changeNotes, PatchSet patchSet, ImmutableSet<Account.Id> accountIds)
throws IOException, DiffNotAvailableException {
requireNonNull(changeNotes, "changeNotes");
requireNonNull(patchSet, "patchSet");
requireNonNull(accountIds, "accountIds");
try (Timer0.Context ctx = codeOwnerMetrics.prepareFileStatusComputationForAccount.start()) {
logger.atFine().log(
"prepare stream to compute file statuses for accounts %s (project = %s, change = %d,"
+ " patch set = %d)",
accountIds,
changeNotes.getProjectName(),
changeNotes.getChangeId().get(),
patchSet.id().get());
CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
RequiredApproval requiredApproval = codeOwnersConfig.getRequiredApproval();
logger.atFine().log("requiredApproval = %s", requiredApproval);
BranchNameKey branch = changeNotes.getChange().getDest();
Optional<ObjectId> revision = getDestBranchRevision(changeNotes.getChange());
if (revision.isPresent()) {
logger.atFine().log(
"dest branch %s has revision %s", branch.branch(), revision.get().name());
} else {
logger.atFine().log("dest branch %s does not exist", branch.branch());
}
FallbackCodeOwners fallbackCodeOwners = codeOwnersConfig.getFallbackCodeOwners();
logger.atFine().log("fallbackCodeOwner = %s", fallbackCodeOwners);
CodeOwnerConfigHierarchy codeOwnerConfigHierarchy = codeOwnerConfigHierarchyProvider.get();
CodeOwnerResolver codeOwnerResolver =
codeOwnerResolverProvider.get().enforceVisibility(false);
CodeOwnerApprovalCheckInput input =
CodeOwnerApprovalCheckInput.createForComputingOwnedPaths(
codeOwnersConfig, codeOwnerResolver, changeNotes, accountIds);
return changedFiles.getFromDiffCache(changeNotes.getProjectName(), patchSet.commitId())
.stream()
.map(
changedFile ->
getFileStatus(
codeOwnerConfigHierarchy,
codeOwnerResolver,
codeOwnersConfig,
changedFilesByPatchSetCacheFactory.create(codeOwnersConfig, changeNotes),
branch,
revision.orElse(null),
changedFile,
input));
}
}
private boolean isPureRevert(ChangeNotes changeNotes) throws IOException {
try {
return changeNotes.getChange().getRevertOf() != null
&& pureRevertCache.isPureRevert(changeNotes);
} catch (BadRequestException e) {
throw newInternalServerError(
String.format(
"failed to check if change %s in project %s is a pure revert",
changeNotes.getChangeId(), changeNotes.getProjectName()),
e);
}
}
private Stream<FileCodeOwnerStatus> getAllPathsAsApproved(
ChangeNotes changeNotes, PatchSet patchSet, String reason)
throws IOException, DiffNotAvailableException {
logger.atFine().log("all paths are approved (reason = %s)", reason);
return changedFiles.getFromDiffCache(changeNotes.getProjectName(), patchSet.commitId()).stream()
.map(
changedFile ->
FileCodeOwnerStatus.create(
changedFile,
changedFile
.newPath()
.map(
newPath ->
PathCodeOwnerStatus.create(
newPath, CodeOwnerStatus.APPROVED, reason)),
changedFile
.oldPath()
.map(
oldPath ->
PathCodeOwnerStatus.create(
oldPath, CodeOwnerStatus.APPROVED, reason))));
}
private FileCodeOwnerStatus getFileStatus(
CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
CodeOwnerResolver codeOwnerResolver,
CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig,
ChangedFilesByPatchSetCache changedFilesByPatchSetCache,
BranchNameKey branch,
@Nullable ObjectId revision,
ChangedFile changedFile,
CodeOwnerApprovalCheckInput input) {
try (Timer0.Context ctx = codeOwnerMetrics.computeFileStatus.start()) {
logger.atFine().log("computing file status for %s", changedFile);
// Compute the code owner status for the new path, if there is a new path.
Optional<PathCodeOwnerStatus> newPathStatus =
changedFile
.newPath()
.map(
newPath ->
getPathCodeOwnerStatus(
codeOwnerConfigHierarchy,
codeOwnerResolver,
codeOwnersConfig,
changedFilesByPatchSetCache,
branch,
revision,
newPath,
input));
// Compute the code owner status for the old path, if the file was deleted or renamed.
Optional<PathCodeOwnerStatus> oldPathStatus = Optional.empty();
if (changedFile.isDeletion() || changedFile.isRename()) {
checkState(
changedFile.oldPath().isPresent(), "old path must be present for deletion/rename");
logger.atFine().log(
"file was %s (old path = %s)",
changedFile.isDeletion() ? "deleted" : "renamed", changedFile.oldPath().get());
oldPathStatus =
Optional.of(
getPathCodeOwnerStatus(
codeOwnerConfigHierarchy,
codeOwnerResolver,
codeOwnersConfig,
changedFilesByPatchSetCache,
branch,
revision,
changedFile.oldPath().get(),
input));
}
FileCodeOwnerStatus fileCodeOwnerStatus =
FileCodeOwnerStatus.create(changedFile, newPathStatus, oldPathStatus);
logger.atFine().log("fileCodeOwnerStatus = %s", fileCodeOwnerStatus);
return fileCodeOwnerStatus;
}
}
private PathCodeOwnerStatus getPathCodeOwnerStatus(
CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
CodeOwnerResolver codeOwnerResolver,
CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig,
ChangedFilesByPatchSetCache changedFilesByPatchSetCache,
BranchNameKey branch,
@Nullable ObjectId revision,
Path absolutePath,
CodeOwnerApprovalCheckInput input) {
logger.atFine().log("computing path status for %s", absolutePath);
if (!input.overrides().isEmpty()) {
logger.atFine().log(
"the status for path %s is %s since an override is present (overrides = %s)",
absolutePath, CodeOwnerStatus.APPROVED.name(), input.overrides());
Optional<PatchSetApproval> override = input.overrides().stream().findAny();
checkState(override.isPresent(), "no override found");
return PathCodeOwnerStatus.create(
absolutePath,
CodeOwnerStatus.APPROVED,
String.format(
"override approval %s by %s is present",
override.get().label() + LabelValue.formatValue(override.get().value()),
AccountTemplateUtil.getAccountTemplate(override.get().accountId())));
}
AtomicReference<CodeOwnerStatus> codeOwnerStatus =
new AtomicReference<>(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
AtomicReference<String> reason = new AtomicReference<>(/* initialValue= */ null);
ImmutableSet.Builder<Account.Id> activeOwners = ImmutableSet.builder();
boolean isGloballyApproved =
isApproved(
absolutePath,
input.globalCodeOwners(),
CodeOwnerKind.GLOBAL_CODE_OWNER,
codeOwnersConfig,
changedFilesByPatchSetCache,
input,
reason);
if (isGloballyApproved) {
codeOwnerStatus.set(CodeOwnerStatus.APPROVED);
}
if (input.globalCodeOwners().ownedByAllUsers()) {
activeOwners.addAll(input.approvers());
activeOwners.addAll(input.reviewers());
} else {
activeOwners.addAll(
Sets.intersection(input.globalCodeOwners().codeOwnersAccountIds(), input.approvers()));
activeOwners.addAll(
Sets.intersection(input.globalCodeOwners().codeOwnersAccountIds(), input.reviewers()));
}
// Only check recursively for all OWNERs in two scenarios:
// 1. The path was not globally approved
// 2. The path was globally approved but is not owned by all users and we
// want to calculate all ownerIds.
if (!isGloballyApproved
|| (input.checkAllOwners() && !input.globalCodeOwners().ownedByAllUsers())) {
logger.atFine().log("%s was not approved by a global code owner", absolutePath);
if (isPending(
input.globalCodeOwners(), CodeOwnerKind.GLOBAL_CODE_OWNER, input.reviewers(), reason)) {
codeOwnerStatus.set(CodeOwnerStatus.PENDING);
}
AtomicBoolean hasRevelantCodeOwnerDefinitions = new AtomicBoolean(false);
AtomicBoolean parentCodeOwnersAreIgnored = new AtomicBoolean(false);
codeOwnerConfigHierarchy.visitForFile(
branch,
revision,
absolutePath,
(PathCodeOwnersVisitor)
pathCodeOwners -> {
CodeOwnerKind codeOwnerKind =
RefNames.REFS_CONFIG.equals(pathCodeOwners.getCodeOwnerConfig().key().ref())
? CodeOwnerKind.DEFAULT_CODE_OWNER
: CodeOwnerKind.REGULAR_CODE_OWNER;
CodeOwnerResolverResult codeOwners =
resolveCodeOwners(codeOwnerResolver, pathCodeOwners);
boolean ownedByAllUsers = codeOwners.ownedByAllUsers();
if (ownedByAllUsers) {
activeOwners.addAll(input.approvers());
activeOwners.addAll(input.reviewers());
} else {
activeOwners.addAll(
Sets.intersection(codeOwners.codeOwnersAccountIds(), input.approvers()));
activeOwners.addAll(
Sets.intersection(codeOwners.codeOwnersAccountIds(), input.reviewers()));
}
logger.atFine().log(
"code owners = %s (code owner kind = %s, code owner config folder path = %s,"
+ " file name = %s)",
codeOwners,
codeOwnerKind,
pathCodeOwners.getCodeOwnerConfig().key().folderPath(),
pathCodeOwners.getCodeOwnerConfig().key().fileName().orElse("<default>"));
if (codeOwners.hasRevelantCodeOwnerDefinitions()) {
hasRevelantCodeOwnerDefinitions.set(true);
}
if (isApproved(
absolutePath,
codeOwners,
codeOwnerKind,
codeOwnersConfig,
changedFilesByPatchSetCache,
input,
reason)) {
codeOwnerStatus.set(CodeOwnerStatus.APPROVED);
// No need to recurse if we are not checking all owners or all owners are
// are already added.
return input.checkAllOwners() && !ownedByAllUsers;
} else if (isPending(codeOwners, codeOwnerKind, input.reviewers(), reason)) {
codeOwnerStatus.set(CodeOwnerStatus.PENDING);
// We need to continue to check if any of the higher-level code owners approved
// the change.
return true;
}
// We need to continue to check if any of the higher-level code owners approved the
// change or is a reviewer.
return true;
},
codeOwnerConfigKey -> {
logger.atFine().log(
"code owner config %s ignores parent code owners for %s",
codeOwnerConfigKey, absolutePath);
parentCodeOwnersAreIgnored.set(true);
});
// If no code owners have been defined for the file and if parent code owners are not ignored,
// the fallback code owners apply if they are configured. We can skip checking them if we
// already found that the file was approved.
if (codeOwnerStatus.get() != CodeOwnerStatus.APPROVED
&& !hasRevelantCodeOwnerDefinitions.get()
&& !parentCodeOwnersAreIgnored.get()) {
CodeOwnerStatus codeOwnerStatusForFallbackCodeOwners =
getCodeOwnerStatusForFallbackCodeOwners(
codeOwnerStatus.get(),
input.implicitApprover().orElse(null),
input.reviewers(),
input.approvers(),
input.fallbackCodeOwners(),
absolutePath,
reason);
if (codeOwnerStatusForFallbackCodeOwners.equals(CodeOwnerStatus.APPROVED)) {
activeOwners.addAll(input.approvers());
} else if (codeOwnerStatusForFallbackCodeOwners.equals(CodeOwnerStatus.PENDING)) {
switch (input.fallbackCodeOwners()) {
case NONE:
// do nothing, if codeOwnerStatus is PENDING, the reviewers that are code owners have
// already been added to activeOwners
break;
case ALL_USERS:
// all users are code owners
activeOwners.addAll(input.approvers());
activeOwners.addAll(input.reviewers());
break;
}
}
// Merge codeOwnerStatusForFallbackCodeOwners into codeOwnerStatus:
// * codeOwnerStatus is the code owner status without taking fallback code owners into
// account
// * codeOwnerStatusForFallbackCodeOwners is the code owner status for fallback code owners
// only
// When merging both the "better" code owner status should take precedence (APPROVED is
// better than PENDING which is better than INSUFFICIENT_REVIEWERS):
// * if codeOwnerStatus == APPROVED we do not compute the code owner status for the fallback
// code owners and never reach this point. Hence we can ignore this case below.
// * if codeOwnerStatus == PENDING (e.g. because a global code owner is a reviewer) we must
// override it if codeOwnerStatusForFallbackCodeOwners is APPROVED
// * if codeOwnerStatus == INSUFFICIENT_REVIEWERS we must override it if
// codeOwnerStatusForFallbackCodeOwners is PENDING or APPROVED
// This means if codeOwnerStatusForFallbackCodeOwners is INSUFFICIENT_REVIEWERS it is never
// "better" than codeOwnerStatus, hence in this case we do not override codeOwnerStatus.
// On the other hand if codeOwnerStatusForFallbackCodeOwners is PENDING or APPROVED (aka not
// INSUFFICIENT_REVIEWERS) it is always as good or "better" than codeOwnerStatus (which can
// only be INSUFFICIENT_REVIEWERS or PENDING at this point), hence in this case we can/must
// override codeOwnerStatus.
if (!codeOwnerStatusForFallbackCodeOwners.equals(CodeOwnerStatus.INSUFFICIENT_REVIEWERS)) {
codeOwnerStatus.set(codeOwnerStatusForFallbackCodeOwners);
}
}
}
logger.atFine().log(
"%s has code owner status %s (reason = %s)",
absolutePath, codeOwnerStatus.get(), reason.get() != null ? reason.get() : "n/a");
PathCodeOwnerStatus pathCodeOwnerStatus =
PathCodeOwnerStatus.create(
absolutePath,
codeOwnerStatus.get(),
reason.get(),
input.checkAllOwners() ? Optional.of(activeOwners.build()) : Optional.empty());
logger.atFine().log("pathCodeOwnerStatus = %s", pathCodeOwnerStatus);
return pathCodeOwnerStatus;
}
/**
* Computes the code owner status for the given path based on the configured fallback code owners.
*/
private CodeOwnerStatus getCodeOwnerStatusForFallbackCodeOwners(
CodeOwnerStatus codeOwnerStatus,
@Nullable Account.Id implicitApprover,
ImmutableSet<Account.Id> reviewerAccountIds,
ImmutableSet<Account.Id> approverAccountIds,
FallbackCodeOwners fallbackCodeOwners,
Path absolutePath,
AtomicReference<String> reason) {
logger.atFine().log(
"getting code owner status for fallback code owners (fallback code owners = %s)",
fallbackCodeOwners);
switch (fallbackCodeOwners) {
case NONE:
logger.atFine().log("no fallback code owners");
return codeOwnerStatus;
case ALL_USERS:
return getCodeOwnerStatusIfAllUsersAreCodeOwners(
implicitApprover, reviewerAccountIds, approverAccountIds, absolutePath, reason);
}
throw newInternalServerError(
String.format("unknown fallback code owners configured: %s", fallbackCodeOwners));
}
/** Computes the code owner status for the given path assuming that all users are code owners. */
private CodeOwnerStatus getCodeOwnerStatusIfAllUsersAreCodeOwners(
@Nullable Account.Id implicitApprover,
ImmutableSet<Account.Id> reviewerAccountIds,
ImmutableSet<Account.Id> approverAccountIds,
Path absolutePath,
AtomicReference<String> reason) {
logger.atFine().log(
"getting code owner status for fallback code owners (all users are fallback code owners)");
if (implicitApprover != null) {
reason.set(
String.format(
"implicitly approved by the patch set uploader %s who is a %s"
+ " (all users are %ss)",
AccountTemplateUtil.getAccountTemplate(implicitApprover),
CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName(),
CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName()));
return CodeOwnerStatus.APPROVED;
}
if (!approverAccountIds.isEmpty()) {
Optional<Account.Id> approver = approverAccountIds.stream().findAny();
checkState(approver.isPresent(), "no approver found");
reason.set(
String.format(
"approved by %s who is a %s (all users are %ss)",
AccountTemplateUtil.getAccountTemplate(approver.get()),
CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName(),
CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName()));
return CodeOwnerStatus.APPROVED;
} else if (!reviewerAccountIds.isEmpty()) {
Optional<Account.Id> reviewer = reviewerAccountIds.stream().findAny();
checkState(reviewer.isPresent(), "no reviewer found");
reason.set(
String.format(
"reviewer %s is a %s (all users are %ss)",
AccountTemplateUtil.getAccountTemplate(reviewer.get()),
CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName(),
CodeOwnerKind.FALLBACK_CODE_OWNER.getDisplayName()));
return CodeOwnerStatus.PENDING;
}
logger.atFine().log("%s has no fallback code owner as a reviewer", absolutePath);
return CodeOwnerStatus.INSUFFICIENT_REVIEWERS;
}
/**
* Checks whether the given path was approved implicitly, explicitly or by sticky approvals.
*
* @param absolutePath the absolute path for which it should be checked whether it is code owner
* approved
* @param codeOwners users that own the path
* @param codeOwnerKind the kind of the given {@code codeOwners}
* @param codeOwnersConfig the code-owners plugin configuration that applies to the project that
* contains the change for which the code owner statuses are checked
* @param changedFilesByPatchSetCache cache that allows to lookup changed files by patch set
* @param input input data for checking if a path is code owner approved
* @param reason {@link AtomicReference} on which the reason is being set if the path is approved
* @return whether the path was approved
*/
private boolean isApproved(
Path absolutePath,
CodeOwnerResolverResult codeOwners,
CodeOwnerKind codeOwnerKind,
CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig,
ChangedFilesByPatchSetCache changedFilesByPatchSetCache,
CodeOwnerApprovalCheckInput input,
AtomicReference<String> reason) {
if (input.implicitApprover().isPresent()) {
if (codeOwners.codeOwnersAccountIds().contains(input.implicitApprover().get())
|| codeOwners.ownedByAllUsers()) {
// If the uploader of the patch set owns the path, there is an implicit code owner
// approval from the patch set uploader so that the path is automatically approved.
reason.set(
String.format(
"implicitly approved by the patch set uploader %s who is a %s%s",
AccountTemplateUtil.getAccountTemplate(input.implicitApprover().get()),
codeOwnerKind.getDisplayName(),
codeOwners.ownedByAllUsers()
? String.format(" (all users are %ss)", codeOwnerKind.getDisplayName())
: ""));
return true;
}
}
ImmutableSet<Account.Id> approvers = input.approvers();
if (!Collections.disjoint(approvers, codeOwners.codeOwnersAccountIds())
|| (codeOwners.ownedByAllUsers() && !approvers.isEmpty())) {
// At least one of the code owners approved the change.
Optional<Account.Id> approver =
codeOwners.ownedByAllUsers()
? approvers.stream().findAny()
: approvers.stream()
.filter(accountId -> codeOwners.codeOwnersAccountIds().contains(accountId))
.findAny();
checkState(approver.isPresent(), "no approver found");
reason.set(
String.format(
"approved by %s who is a %s%s",
AccountTemplateUtil.getAccountTemplate(approver.get()),
codeOwnerKind.getDisplayName(),
codeOwners.ownedByAllUsers()
? String.format(" (all users are %ss)", codeOwnerKind.getDisplayName())
: ""));
return true;
}
return codeOwnersConfig.areStickyApprovalsEnabled()
&& isApprovedByStickyApproval(
absolutePath, codeOwners, codeOwnerKind, changedFilesByPatchSetCache, input, reason);
}
/**
* Checks whether the given path is code owner approved by a sticky approval on a previous patch
* set.
*/
private boolean isApprovedByStickyApproval(
Path absolutePath,
CodeOwnerResolverResult codeOwners,
CodeOwnerKind codeOwnerKind,
ChangedFilesByPatchSetCache changedFilesByPatchSetCache,
CodeOwnerApprovalCheckInput input,
AtomicReference<String> reason) {
for (PatchSet.Id patchSetId : input.previouslyApprovedPatchSetsInReverseOrder()) {
if (changedFilesByPatchSetCache.get(patchSetId).stream()
.anyMatch(
changedFile ->
changedFile.hasNewPath(absolutePath) || changedFile.hasOldPath(absolutePath))) {
logger.atFine().log(
"previously approved patch set %d contains path %s", patchSetId.get(), absolutePath);
Optional<Account.Id> approver =
input.approversFromPreviousPatchSets().get(patchSetId).stream()
.filter(
accountId ->
codeOwners.codeOwnersAccountIds().contains(accountId)
|| codeOwners.ownedByAllUsers())
.findAny();
if (!approver.isPresent()) {
logger.atFine().log(
"none of the approvals on previous patch set %d is from a user that owns path %s"
+ " (approvers=%s)",
patchSetId.get(),
absolutePath,
input.approversFromPreviousPatchSets().get(patchSetId));
continue;
}
reason.set(
String.format(
"approved on patch set %d by %s who is a %s%s",
patchSetId.get(),
AccountTemplateUtil.getAccountTemplate(approver.get()),
codeOwnerKind.getDisplayName(),
codeOwners.ownedByAllUsers()
? String.format(" (all users are %ss)", codeOwnerKind.getDisplayName())
: ""));
return true;
}
}
return false;
}
/**
* Checks whether any of the reviewers is a code owner of the path.
*
* @param codeOwners users that own the path
* @param codeOwnerKind the kind of the given {@code codeOwners}
* @param reviewerAccountIds the IDs of the accounts that are reviewer of the change
* @param reason {@link AtomicReference} on which the reason is being set if the status for the
* path is {@code PENDING}
* @return whether the path was approved
*/
private boolean isPending(
CodeOwnerResolverResult codeOwners,
CodeOwnerKind codeOwnerKind,
ImmutableSet<Account.Id> reviewerAccountIds,
AtomicReference<String> reason) {
if (!Collections.disjoint(codeOwners.codeOwnersAccountIds(), reviewerAccountIds)
|| (codeOwners.ownedByAllUsers() && !reviewerAccountIds.isEmpty())) {
Optional<Account.Id> reviewer =
codeOwners.ownedByAllUsers()
? reviewerAccountIds.stream().findAny()
: reviewerAccountIds.stream()
.filter(accountId -> codeOwners.codeOwnersAccountIds().contains(accountId))
.findAny();
checkState(reviewer.isPresent(), "no reviewer found");
reason.set(
String.format(
"reviewer %s is a %s%s",
AccountTemplateUtil.getAccountTemplate(reviewer.get()),
codeOwnerKind.getDisplayName(),
codeOwners.ownedByAllUsers()
? String.format(" (all users are %ss)", codeOwnerKind.getDisplayName())
: ""));
return true;
}
return false;
}
/**
* Resolves the given path code owners.
*
* @param codeOwnerResolver the {@code CodeOwnerResolver} that should be used to resolve code
* owners
* @param pathCodeOwners the path code owners that should be resolved
*/
private CodeOwnerResolverResult resolveCodeOwners(
CodeOwnerResolver codeOwnerResolver, PathCodeOwners pathCodeOwners) {
return codeOwnerResolver.resolvePathCodeOwners(pathCodeOwners);
}
/**
* Gets the current revision of the destination branch of the given change.
*
* <p>This is the revision from which the code owner configs should be read when computing code
* owners for the files that are touched in the change.
*
* @return the current revision of the destination branch of the given change, {@link
* Optional#empty()} if the destination branch is not found (e.g. when the initial change is
* uploaded to an unborn branch or when the branch got deleted after the change was created)
*/
private Optional<ObjectId> getDestBranchRevision(Change change) throws IOException {
try (Repository repository = repoManager.openRepository(change.getProject());
RevWalk rw = new RevWalk(repository)) {
Ref ref = repository.exactRef(change.getDest().branch());
if (ref == null) {
return Optional.empty();
}
return Optional.of(rw.parseCommit(ref.getObjectId()));
}
}
}