Merge "Allow making code owner approvals sticky per file"
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/BUILD b/java/com/google/gerrit/plugins/codeowners/backend/BUILD
index 83671a0..04aaf14 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/BUILD
+++ b/java/com/google/gerrit/plugins/codeowners/backend/BUILD
@@ -6,6 +6,7 @@
srcs = glob(["**/*.java"]),
visibility = ["//visibility:public"],
deps = PLUGIN_DEPS_NEVERLINK + [
+ "//lib/errorprone:annotations",
"//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/common",
"//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/metrics",
"//plugins/code-owners/java/com/google/gerrit/plugins/codeowners/util",
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
index 89f493f..cba9409 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
@@ -36,6 +36,7 @@
public class BackendModule extends FactoryModule {
@Override
protected void configure() {
+ factory(ChangedFilesByPatchSetCache.Factory.class);
factory(CodeOwnerApprovalCheckInput.Loader.Factory.class);
factory(CodeOwnersUpdate.Factory.class);
factory(CodeOwnerConfigScanner.Factory.class);
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/ChangedFilesByPatchSetCache.java b/java/com/google/gerrit/plugins/codeowners/backend/ChangedFilesByPatchSetCache.java
new file mode 100644
index 0000000..3d5fc9c
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/ChangedFilesByPatchSetCache.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2022 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 com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
+import com.google.gerrit.plugins.codeowners.common.ChangedFile;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Computes and caches the {@link ChangedFile}s for the patch sets of a change.
+ *
+ * <p>The changed files for a patch set are computed lazily. This way we do not compute changed
+ * files unnecessarily that are never requested.
+ *
+ * <p>This class is not thread-safe.
+ */
+public class ChangedFilesByPatchSetCache {
+ interface Factory {
+ ChangedFilesByPatchSetCache create(
+ CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig, ChangeNotes changeNotes);
+ }
+
+ private final ChangedFiles changedFiles;
+ private final CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig;
+ private final ChangeNotes changeNotes;
+
+ private Map<PatchSet.Id, ImmutableList<ChangedFile>> cache = new HashMap<>();
+
+ @Inject
+ public ChangedFilesByPatchSetCache(
+ ChangedFiles changedFiles,
+ @Assisted CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig,
+ @Assisted ChangeNotes changeNotes) {
+ this.changedFiles = changedFiles;
+ this.codeOwnersConfig = codeOwnersConfig;
+ this.changeNotes = changeNotes;
+ }
+
+ public ImmutableList<ChangedFile> get(PatchSet.Id patchSetId) {
+ return cache.computeIfAbsent(patchSetId, this::compute);
+ }
+
+ private ImmutableList<ChangedFile> compute(PatchSet.Id patchSetId) {
+ checkState(
+ patchSetId.changeId().equals(changeNotes.getChange().getId()),
+ "patch set %s belongs to other change than change %s",
+ patchSetId,
+ changeNotes.getChange().getId().get());
+ PatchSet patchSet = getPatchSet(patchSetId);
+ try {
+ return changedFiles.getFromDiffCache(
+ changeNotes.getProjectName(),
+ patchSet.commitId(),
+ codeOwnersConfig.getMergeCommitStrategy());
+ } catch (IOException | DiffNotAvailableException e) {
+ throw new StorageException(
+ String.format(
+ "failed to retrieve changed files for patch set %d of change %d"
+ + " in project %s (commit=%s)",
+ patchSetId.get(),
+ patchSetId.changeId().get(),
+ changeNotes.getProjectName(),
+ patchSet.commitId()),
+ e);
+ }
+ }
+
+ private PatchSet getPatchSet(PatchSet.Id patchSetId) {
+ PatchSet patchSet = changeNotes.getPatchSets().get(patchSetId);
+ if (patchSet == null) {
+ throw new StorageException(
+ String.format(
+ "patch set %s not found in change %d", patchSetId, changeNotes.getChangeId().get()));
+ }
+ return patchSet;
+ }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
index 792f641..71c0044 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
@@ -35,6 +35,7 @@
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;
@@ -89,6 +90,7 @@
private final Provider<CodeOwnerResolver> codeOwnerResolverProvider;
private final CodeOwnerApprovalCheckInput.Loader.Factory inputLoaderFactory;
private final CodeOwnerMetrics codeOwnerMetrics;
+ private final ChangedFilesByPatchSetCache.Factory changedFilesByPatchSetCacheFactory;
@Inject
CodeOwnerApprovalCheck(
@@ -99,7 +101,8 @@
Provider<CodeOwnerConfigHierarchy> codeOwnerConfigHierarchyProvider,
Provider<CodeOwnerResolver> codeOwnerResolverProvider,
CodeOwnerApprovalCheckInput.Loader.Factory codeOwnerApprovalCheckInputLoaderFactory,
- CodeOwnerMetrics codeOwnerMetrics) {
+ CodeOwnerMetrics codeOwnerMetrics,
+ ChangedFilesByPatchSetCache.Factory changedFilesByPatchSetCacheFactory) {
this.repoManager = repoManager;
this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
this.changedFiles = changedFiles;
@@ -108,6 +111,7 @@
this.codeOwnerResolverProvider = codeOwnerResolverProvider;
this.inputLoaderFactory = codeOwnerApprovalCheckInputLoaderFactory;
this.codeOwnerMetrics = codeOwnerMetrics;
+ this.changedFilesByPatchSetCacheFactory = changedFilesByPatchSetCacheFactory;
}
/**
@@ -212,9 +216,12 @@
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(codeOwnerConfigHierarchy, codeOwnerResolver, changeNotes)
+ !getFileStatuses(
+ codeOwnersConfig, codeOwnerConfigHierarchy, codeOwnerResolver, changeNotes)
.anyMatch(
fileStatus ->
(fileStatus.newPathStatus().isPresent()
@@ -247,17 +254,22 @@
*
* @param start number of file statuses to skip
* @param limit the max number of file statuses that should be returned (0 = unlimited)
- * @see #getFileStatuses(CodeOwnerConfigHierarchy, CodeOwnerResolver, ChangeNotes)
+ * @see #getFileStatuses(CodeOwnersPluginProjectConfigSnapshot, CodeOwnerConfigHierarchy,
+ * CodeOwnerResolver, ChangeNotes)
*/
public ImmutableSet<FileCodeOwnerStatus> getFileStatusesAsSet(
ChangeNotes changeNotes, int start, int limit) throws IOException, DiffNotAvailableException {
requireNonNull(changeNotes, "changeNotes");
- try (Timer0.Context ctx = codeOwnerMetrics.computeFileStatuses.start()) {
+ 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);
@@ -300,6 +312,7 @@
* returned
*/
private Stream<FileCodeOwnerStatus> getFileStatuses(
+ CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig,
CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
CodeOwnerResolver codeOwnerResolver,
ChangeNotes changeNotes)
@@ -310,9 +323,6 @@
"prepare stream to compute file statuses (project = %s, change = %d)",
changeNotes.getProjectName(), changeNotes.getChangeId().get());
- CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
- codeOwnersPluginConfiguration.getProjectConfig(changeNotes.getProjectName());
-
Account.Id patchSetUploader = changeNotes.getCurrentPatchSet().uploader();
ImmutableSet<Account.Id> exemptedAccounts = codeOwnersConfig.getExemptedAccounts();
logger.atFine().log("exemptedAccounts = %s", exemptedAccounts);
@@ -360,6 +370,8 @@
getFileStatus(
codeOwnerConfigHierarchy,
codeOwnerResolver,
+ codeOwnersConfig,
+ changedFilesByPatchSetCacheFactory.create(codeOwnersConfig, changeNotes),
branch,
revision.orElse(null),
changedFile,
@@ -430,6 +442,8 @@
getFileStatus(
codeOwnerConfigHierarchy,
codeOwnerResolver,
+ codeOwnersConfig,
+ changedFilesByPatchSetCacheFactory.create(codeOwnersConfig, changeNotes),
branch,
revision.orElse(null),
changedFile,
@@ -476,6 +490,8 @@
private FileCodeOwnerStatus getFileStatus(
CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
CodeOwnerResolver codeOwnerResolver,
+ CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig,
+ ChangedFilesByPatchSetCache changedFilesByPatchSetCache,
BranchNameKey branch,
@Nullable ObjectId revision,
ChangedFile changedFile,
@@ -492,6 +508,8 @@
getPathCodeOwnerStatus(
codeOwnerConfigHierarchy,
codeOwnerResolver,
+ codeOwnersConfig,
+ changedFilesByPatchSetCache,
branch,
revision,
newPath,
@@ -510,6 +528,8 @@
getPathCodeOwnerStatus(
codeOwnerConfigHierarchy,
codeOwnerResolver,
+ codeOwnersConfig,
+ changedFilesByPatchSetCache,
branch,
revision,
changedFile.oldPath().get(),
@@ -526,6 +546,8 @@
private PathCodeOwnerStatus getPathCodeOwnerStatus(
CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
CodeOwnerResolver codeOwnerResolver,
+ CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig,
+ ChangedFilesByPatchSetCache changedFilesByPatchSetCache,
BranchNameKey branch,
@Nullable ObjectId revision,
Path absolutePath,
@@ -554,10 +576,12 @@
boolean isGloballyApproved =
isApproved(
+ absolutePath,
input.globalCodeOwners(),
CodeOwnerKind.GLOBAL_CODE_OWNER,
- input.approvers(),
- input.implicitApprover().orElse(null),
+ codeOwnersConfig,
+ changedFilesByPatchSetCache,
+ input,
reason);
if (isGloballyApproved) {
@@ -626,10 +650,12 @@
}
if (isApproved(
+ absolutePath,
codeOwners,
codeOwnerKind,
- input.approvers(),
- input.implicitApprover().orElse(null),
+ codeOwnersConfig,
+ changedFilesByPatchSetCache,
+ input,
reason)) {
codeOwnerStatus.set(CodeOwnerStatus.APPROVED);
// No need to recurse if we are not checking all owners or all owners are
@@ -798,31 +824,36 @@
}
/**
- * Checks whether the given path was implicitly or explicitly approved.
+ * 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 approverAccountIds the IDs of the accounts that have approved the change
- * @param implicitApprover the ID of the account the could be an implicit approver (aka last patch
- * set uploader)
+ * @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,
- ImmutableSet<Account.Id> approverAccountIds,
- @Nullable Account.Id implicitApprover,
+ CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig,
+ ChangedFilesByPatchSetCache changedFilesByPatchSetCache,
+ CodeOwnerApprovalCheckInput input,
AtomicReference<String> reason) {
- if (implicitApprover != null) {
- if (codeOwners.codeOwnersAccountIds().contains(implicitApprover)
+ 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(implicitApprover),
+ AccountTemplateUtil.getAccountTemplate(input.implicitApprover().get()),
codeOwnerKind.getDisplayName(),
codeOwners.ownedByAllUsers()
? String.format(" (all users are %ss)", codeOwnerKind.getDisplayName())
@@ -831,13 +862,14 @@
}
}
- if (!Collections.disjoint(approverAccountIds, codeOwners.codeOwnersAccountIds())
- || (codeOwners.ownedByAllUsers() && !approverAccountIds.isEmpty())) {
+ 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()
- ? approverAccountIds.stream().findAny()
- : approverAccountIds.stream()
+ ? approvers.stream().findAny()
+ : approvers.stream()
.filter(accountId -> codeOwners.codeOwnersAccountIds().contains(accountId))
.findAny();
checkState(approver.isPresent(), "no approver found");
@@ -852,6 +884,58 @@
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;
}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckInput.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckInput.java
index cd2ef0e..9335e5b 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckInput.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckInput.java
@@ -14,14 +14,23 @@
package com.google.gerrit.plugins.codeowners.backend;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.ImmutableSetMultimap.toImmutableSetMultimap;
+import static java.util.Comparator.comparing;
import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
+import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
@@ -33,6 +42,8 @@
import com.google.gerrit.server.util.LabelVote;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
+import java.util.HashMap;
+import java.util.Map;
import java.util.Optional;
/**
@@ -63,6 +74,23 @@
public abstract ImmutableSet<Account.Id> approvers();
/**
+ * Gets a map of previous patch sets to the IDs of the accounts that have an approval on that
+ * patch set that is sticky and possibly counts as code owner approval (if they are code owners).
+ *
+ * <p>If self approvals are ignored the patch set uploader is filtered out for all patch sets
+ * since in this case the approval of the patch set uploader is ignored even if they are a code
+ * owner.
+ */
+ public abstract ImmutableMultimap<PatchSet.Id, Account.Id> approversFromPreviousPatchSets();
+
+ @Memoized
+ public ImmutableSortedSet<PatchSet.Id> previouslyApprovedPatchSetsInReverseOrder() {
+ return ImmutableSortedSet.orderedBy(comparing(PatchSet.Id::get).reversed())
+ .addAll(approversFromPreviousPatchSets().keySet())
+ .build();
+ }
+
+ /**
* Account from which an implicit code owner approval should be assumed.
*
* @see CodeOwnersPluginProjectConfigSnapshot#areImplicitApprovalsEnabled()
@@ -119,6 +147,7 @@
return create(
/* reviewers= */ ImmutableSet.of(),
/* approvers= */ accounts,
+ /* approversFromPreviousPatchSets= */ ImmutableMultimap.of(),
// Do not check for implicit approvals since implicit approvals of other users
// should be ignored. For the given account we do not need to check for
// implicit approvals since all owned files are already covered by the
@@ -152,6 +181,7 @@
private static CodeOwnerApprovalCheckInput create(
ImmutableSet<Account.Id> reviewers,
ImmutableSet<Account.Id> approvers,
+ ImmutableMultimap<PatchSet.Id, Account.Id> approversFromPreviousPatchSets,
Optional<Account.Id> implicitApprover,
ImmutableSet<PatchSetApproval> overrides,
CodeOwnerResolverResult globalCodeOwners,
@@ -160,6 +190,7 @@
return new AutoValue_CodeOwnerApprovalCheckInput(
reviewers,
approvers,
+ approversFromPreviousPatchSets,
implicitApprover,
overrides,
globalCodeOwners,
@@ -207,6 +238,7 @@
return CodeOwnerApprovalCheckInput.create(
getReviewers(),
getApprovers(),
+ getApproversFromPreviousPatchSets(),
getImplicitApprover(),
getOverrides(),
getGlobalCodeOwners(),
@@ -284,6 +316,108 @@
}
/**
+ * Gets a map of previous patch sets to the IDs of the accounts that have an approval on that
+ * patch set that is sticky and possibly counts as code owner approval (if they are code
+ * owners).
+ *
+ * <p>If self approvals are ignored the patch set uploader is filtered out for all patch sets
+ * since in this case the approval of the patch set uploader is ignored even if they are a code
+ * owner.
+ */
+ private ImmutableMultimap<PatchSet.Id, Account.Id> getApproversFromPreviousPatchSets() {
+ if (!codeOwnersConfig.areStickyApprovalsEnabled()) {
+ logger.atFine().log("sticky approvals are disabled");
+ return ImmutableMultimap.of();
+ }
+
+ // Filter out approvals on the current patch set, since here we are only interested in code
+ // owner approvals on previous patch sets that should be considered as sticky.
+ PatchSet.Id currentPatchSetId = changeNotes.getCurrentPatchSet().id();
+ ImmutableSetMultimap<PatchSet.Id, Account.Id> approversFromPreviousPatchSets =
+ getLastCodeOwnerApprovalsByAccount().values().stream()
+ .filter(psa -> psa.patchSetId().get() < currentPatchSetId.get())
+ .collect(
+ toImmutableSetMultimap(
+ PatchSetApproval::patchSetId, PatchSetApproval::accountId));
+ logger.atFine().log(
+ "sticky approvals are enabled, approversFromPreviousPatchSets=%s",
+ approversFromPreviousPatchSets);
+ return approversFromPreviousPatchSets;
+ }
+
+ /**
+ * Returns the last code owner approvals by account.
+ *
+ * <p>The returned map contains for each user their last approval on the change that counts as a
+ * code owner approval. Approvals that are invalidated by code owner votes on newer patch sets
+ * are filtered out.
+ */
+ private ImmutableMap<Account.Id, PatchSetApproval> getLastCodeOwnerApprovalsByAccount() {
+ RequiredApproval requiredApproval = codeOwnersConfig.getRequiredApproval();
+
+ Map<Account.Id, PatchSetApproval> lastCodeOwnerVotesByAccount = new HashMap<>();
+ ImmutableSetMultimap<PatchSet.Id, PatchSetApproval> allCodeOwnerApprovals =
+ changeNotes.getApprovals().all().entries().stream()
+ // Only look at approvals on the label that is configured for code owner approvals.
+ .filter(e -> e.getValue().label().equals(requiredApproval.labelType().getName()))
+ .collect(toImmutableSetMultimap(Map.Entry::getKey, Map.Entry::getValue));
+ logger.atFine().log("allCodeOwnerApprovals=%s", allCodeOwnerApprovals);
+ // Iterate over the patch sets in reverse order (latest patch set first).
+ for (PatchSet.Id patchSetId : getPatchSetIdsInReverseOrder()) {
+ // Only store the code owner approval if we didn't find a code owner approval for that
+ // account on a newer patch set yet.
+ // If a code owner approval on a newer patch set exist, it invalidated the code owner
+ // approval on the older patch set and we can ignore it.
+ allCodeOwnerApprovals
+ .get(patchSetId)
+ .forEach(psa -> lastCodeOwnerVotesByAccount.putIfAbsent(psa.accountId(), psa));
+ }
+
+ ImmutableMap<Account.Id, PatchSetApproval> lastCodeOwnerApprovalsByAccount =
+ lastCodeOwnerVotesByAccount.entrySet().stream()
+ // Remove all approvals which do not count as a code owner approval because the voting
+ // value is insufficient.
+ .filter(e -> requiredApproval.isApprovedBy(e.getValue()))
+ .filter(filterOutSelfApprovalsIfSelfApprovalsAreIgnored())
+ .collect(toImmutableMap(Map.Entry::getKey, Map.Entry::getValue));
+ logger.atFine().log(
+ "lastCodeOwnerApprovalsByAccount=%s, lastCodeOwnerVotesByAccount=%s",
+ lastCodeOwnerApprovalsByAccount, lastCodeOwnerVotesByAccount);
+ return lastCodeOwnerApprovalsByAccount;
+ }
+
+ /**
+ * Creates a filter that filters out self approvals by the patch set uploader if self approvals
+ * are ignored
+ */
+ private Predicate<Map.Entry<Account.Id, PatchSetApproval>>
+ filterOutSelfApprovalsIfSelfApprovalsAreIgnored() {
+ RequiredApproval requiredApproval = codeOwnersConfig.getRequiredApproval();
+ if (!requiredApproval.labelType().isIgnoreSelfApproval()) {
+ logger.atFine().log("s");
+ return e -> true;
+ }
+
+ Account.Id patchSetUploader = changeNotes.getCurrentPatchSet().uploader();
+ return e -> {
+ if (e.getKey().equals(patchSetUploader)) {
+ logger.atFine().log(
+ "Removing approvals of the patch set uploader %s since the label of the required"
+ + " approval (%s) is configured to ignore self approvals",
+ patchSetUploader, requiredApproval.labelType());
+ return false;
+ }
+ return true;
+ };
+ }
+
+ private ImmutableSortedSet<PatchSet.Id> getPatchSetIdsInReverseOrder() {
+ return ImmutableSortedSet.orderedBy(comparing(PatchSet.Id::get).reversed())
+ .addAll(changeNotes.getPatchSets().keySet())
+ .build();
+ }
+
+ /**
* Gets the approvals from the current patch set that count as code owner overrides.
*
* <p>If self approvals are ignored an override of the patch set uploader is filtered out since
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
index 89fc56e..f02e54d 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
@@ -95,6 +95,7 @@
private Map<String, Optional<PathExpressions>> pathExpressionsByBranch = new HashMap<>();
@Nullable private Optional<PathExpressions> pathExpressions;
@Nullable private Boolean implicitApprovalsEnabled;
+ @Nullable private Boolean stickyApprovalsEnabled;
@Nullable private RequiredApproval requiredApproval;
@Nullable private ImmutableSortedSet<RequiredApproval> overrideApprovals;
@@ -618,6 +619,27 @@
}
/**
+ * Checks whether sticky code owner approvals are enabled.
+ *
+ * <p>If enabled, a code owner approval on a previous patch set is sticky (if the approver didn't
+ * alter or remove it on a later patch set).
+ */
+ public boolean areStickyApprovalsEnabled() {
+ if (stickyApprovalsEnabled == null) {
+ stickyApprovalsEnabled = readStickyApprovalsEnabled();
+ }
+ return stickyApprovalsEnabled;
+ }
+
+ private boolean readStickyApprovalsEnabled() {
+ boolean enableStickyApprovals = generalConfig.enableStickyApprovals(projectName, pluginConfig);
+ logger.atFine().log(
+ "sticky approvals on project %s are %s",
+ projectName, enableStickyApprovals ? "enabled" : "disabled");
+ return enableStickyApprovals;
+ }
+
+ /**
* Returns the approval that is required from code owners to approve the files in a change.
*
* <p>Defines which approval counts as code owner approval.
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
index 00574bc..810486f 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
@@ -80,6 +80,7 @@
public static final String KEY_GLOBAL_CODE_OWNER = "globalCodeOwner";
public static final String KEY_EXEMPTED_USER = "exemptedUser";
public static final String KEY_ENABLE_IMPLICIT_APPROVALS = "enableImplicitApprovals";
+ public static final String KEY_ENABLE_STICKY_APPROVALS = "enableStickyApprovals";
public static final String KEY_OVERRIDE_INFO_URL = "overrideInfoUrl";
public static final String KEY_INVALID_CODE_OWNER_CONFIG_INFO_URL =
"invalidCodeOwnerConfigInfoUrl";
@@ -875,6 +876,22 @@
}
/**
+ * Gets whether sticky code owner approvals are enabled from the given plugin config with fallback
+ * to {@code gerrit.config}.
+ *
+ * <p>If enabled, a code owner approval on a previous patch set is sticky (if the approver didn't
+ * alter or remove it on a later patch set).
+ *
+ * @param project the name of the project for which the configuration should be read
+ * @param pluginConfig the plugin config from which the configuration should be read.
+ * @return whether a sticky code owner approvals are enabled
+ */
+ boolean enableStickyApprovals(Project.NameKey project, Config pluginConfig) {
+ return getBooleanConfig(
+ project, pluginConfig, KEY_ENABLE_STICKY_APPROVALS, /* defaultValue= */ false);
+ }
+
+ /**
* Gets the users which are configured as global code owners from the given plugin config with
* fallback to {@code gerrit.config}.
*
diff --git a/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java b/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
index d6ac638..fa90564 100644
--- a/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
+++ b/java/com/google/gerrit/plugins/codeowners/metrics/CodeOwnerMetrics.java
@@ -34,7 +34,7 @@
// latency metrics
public final Timer1<String> addChangeMessageOnAddReviewer;
public final Timer0 computeFileStatus;
- public final Timer0 computeFileStatuses;
+ public final Timer1<Boolean> computeFileStatuses;
public final Timer0 computeOwnedPaths;
public final Timer0 computePatchSetApprovals;
public final Timer0 extendChangeMessageOnPostReview;
@@ -90,7 +90,10 @@
this.computeFileStatuses =
createTimer(
"compute_file_statuses",
- "Latency for computing file statuses for all files in a change");
+ "Latency for computing file statuses for all files in a change",
+ Field.ofBoolean("sticky_approvals", (metadataBuilder, stickyApprovals) -> {})
+ .description("Whether sticky approvals on file level are enabled.")
+ .build());
this.computeOwnedPaths =
createTimer(
"compute_owned_paths",
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckInputTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckInputTest.java
index 3c41f14..0054f2e 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckInputTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckInputTest.java
@@ -17,13 +17,19 @@
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -34,6 +40,7 @@
import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
import org.junit.Before;
import org.junit.Test;
@@ -41,6 +48,7 @@
/** Tests for {@link CodeOwnerApprovalCheckInput}. */
public class CodeOwnerApprovalCheckInputTest extends AbstractCodeOwnersTest {
@Inject private ChangeNotes.Factory changeNotesFactory;
+ @Inject private ProjectOperations projectOperations;
@Inject private RequestScopeOperations requestScopeOperations;
private CodeOwnerApprovalCheckInput.Loader.Factory inputLoaderFactory;
@@ -48,6 +56,7 @@
private CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig;
private TestAccount user2;
private Change.Id changeId;
+ private Change.Key changeKey;
private Account.Id changeOwner;
@Before
@@ -63,7 +72,9 @@
@Before
public void setUp() throws Exception {
user2 = accountCreator.user2();
- changeId = createChange().getChange().getId();
+ ChangeData changeData = createChange().getChange();
+ changeId = changeData.getId();
+ changeKey = changeData.change().getKey();
changeOwner = admin.id();
}
@@ -143,6 +154,448 @@
assertThat(loadInput().approvers()).containsExactly(user.id(), user2.id());
}
+ /** Test that current approvals do not count for computing previous approvers. */
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void noPreviousApprovers() throws Exception {
+ // self approve current patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ recommend(changeId.toString());
+
+ // approve as user current patch set
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // approve as user2 current patch set
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId.toString());
+
+ assertThat(loadInput().approversFromPreviousPatchSets()).isEmpty();
+ }
+
+ /** Test that previous approvals on other labels do not count for computing previous approvers. */
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void noPreviousApproversIfApprovalIsOnUnrelatedLabel() throws Exception {
+ // Create Foo-Review label.
+ LabelDefinitionInput input = new LabelDefinitionInput();
+ input.values = ImmutableMap.of("+1", "Approved", " 0", "Not Approved");
+ gApi.projects().name(project.get()).label("Foo-Review").create(input).get();
+
+ // Allow to vote on the Foo-Review label.
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ TestProjectUpdate.allowLabel("Foo-Review")
+ .range(0, 1)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .build())
+ .update();
+
+ // approve on Foo-Review label
+ requestScopeOperations.setApiUser(user.id());
+ gApi.changes().id(changeId.get()).current().review(new ReviewInput().label("Foo-Review", 1));
+
+ // create a second patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ assertThat(loadInput().approversFromPreviousPatchSets()).isEmpty();
+ }
+
+ /**
+ * Test that previous votes with insufficient values do not count for computing previous
+ * approvers.
+ */
+ @Test
+ @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+2")
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void noPreviousApproversIfVoteIsNotAnApproval() throws Exception {
+ // vote with Code-Review+1, but only Code-Review+2 counts as a code owner approval
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // create a second patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ assertThat(loadInput().approversFromPreviousPatchSets()).isEmpty();
+ }
+
+ /** Test that previous approvals are ignored if sticky approvals are disabled. */
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "false")
+ public void noPreviousApproversIfEnableStickyApprovalsDisabled() throws Exception {
+ // self approve
+ requestScopeOperations.setApiUser(changeOwner);
+ recommend(changeId.toString());
+
+ // approve as user
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // approve as user2
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId.toString());
+
+ // create a second patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ assertThat(loadInput().approversFromPreviousPatchSets()).isEmpty();
+ }
+
+ /** Test that the approvals on the previous patch set count for computing previous approvers. */
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void withPreviousApprovers() throws Exception {
+ // self approve
+ requestScopeOperations.setApiUser(changeOwner);
+ recommend(changeId.toString());
+
+ // approve as user
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // approve as user2
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId.toString());
+
+ // create a second patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ ArrayListMultimap<PatchSet.Id, Account.Id> expectedPreviousApprovers =
+ ArrayListMultimap.create();
+ expectedPreviousApprovers.putAll(
+ PatchSet.id(changeId, 1), ImmutableSet.of(changeOwner, user.id(), user2.id()));
+ assertThat(loadInput().approversFromPreviousPatchSets())
+ .containsExactlyEntriesIn(expectedPreviousApprovers);
+ }
+
+ /**
+ * Test that a self-approval on the previous patch set is ignored for computing previous
+ * approvers.
+ */
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void withPreviousApprovers_selfApprovalsIgnored() throws Exception {
+ disableSelfCodeReviewApprovals();
+
+ // self approve
+ requestScopeOperations.setApiUser(changeOwner);
+ recommend(changeId.toString());
+
+ // approve as user
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // approve as user2
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId.toString());
+
+ // create a second patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ ArrayListMultimap<PatchSet.Id, Account.Id> expectedPreviousApprovers =
+ ArrayListMultimap.create();
+ expectedPreviousApprovers.putAll(
+ PatchSet.id(changeId, 1), ImmutableSet.of(user.id(), user2.id()));
+ assertThat(loadInput().approversFromPreviousPatchSets())
+ .containsExactlyEntriesIn(expectedPreviousApprovers);
+ }
+
+ /**
+ * Test that the approvals on different previous patch sets count for computing previous
+ * approvers.
+ */
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void withPreviousApproversOnDifferentPatchSets() throws Exception {
+ // self approve
+ requestScopeOperations.setApiUser(changeOwner);
+ recommend(changeId.toString());
+
+ // create a second patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ // approve as user
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // create a third patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ // approve as user2
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId.toString());
+
+ // create a 4th patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ ArrayListMultimap<PatchSet.Id, Account.Id> expectedPreviousApprovers =
+ ArrayListMultimap.create();
+ expectedPreviousApprovers.put(PatchSet.id(changeId, 1), changeOwner);
+ expectedPreviousApprovers.put(PatchSet.id(changeId, 2), user.id());
+ expectedPreviousApprovers.put(PatchSet.id(changeId, 3), user2.id());
+ assertThat(loadInput().approversFromPreviousPatchSets())
+ .containsExactlyEntriesIn(expectedPreviousApprovers);
+ }
+
+ /** Test that a self-approval on an old patch set is ignored for computing previous approvers. */
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void withPreviousApproversOnDifferentPatchSets_selfApprovalsIgnored() throws Exception {
+ disableSelfCodeReviewApprovals();
+
+ // self approve
+ requestScopeOperations.setApiUser(changeOwner);
+ recommend(changeId.toString());
+
+ // create a second patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ // approve as user
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // create a third patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ // approve as user2
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId.toString());
+
+ // create a 4th patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ ArrayListMultimap<PatchSet.Id, Account.Id> expectedPreviousApprovers =
+ ArrayListMultimap.create();
+ expectedPreviousApprovers.put(PatchSet.id(changeId, 2), user.id());
+ expectedPreviousApprovers.put(PatchSet.id(changeId, 3), user2.id());
+ assertThat(loadInput().approversFromPreviousPatchSets())
+ .containsExactlyEntriesIn(expectedPreviousApprovers);
+ }
+
+ /**
+ * Test that sticky approvals do not count for computing previous approvers (because if the
+ * approval is sticky it's a current approval).
+ */
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void noPreviousApproversIfApprovalIsCopied() throws Exception {
+ // Make Code-Review approvals sticky
+ LabelDefinitionInput input = new LabelDefinitionInput();
+ input.copyCondition = "is:ANY";
+ gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+
+ // approve as user
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // create a second patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ assertThat(loadInput().approversFromPreviousPatchSets()).isEmpty();
+ }
+
+ /**
+ * Test that a previous approval still counts for computing previous approvers if the approver
+ * comments on the current patch set without applying a vote.
+ */
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void previousApproversIsPreservedWhenThePreviousApproverCommentsOnTheChange()
+ throws Exception {
+ // approve as user
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // create a second patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ // check that the previous approver on patch set 1 is found
+ ArrayListMultimap<PatchSet.Id, Account.Id> expectedPreviousApprovers =
+ ArrayListMultimap.create();
+ expectedPreviousApprovers.put(PatchSet.id(changeId, 1), user.id());
+ assertThat(loadInput().approversFromPreviousPatchSets())
+ .containsExactlyEntriesIn(expectedPreviousApprovers);
+
+ // comment on the change
+ requestScopeOperations.setApiUser(user.id());
+ ReviewInput reviewInput = ReviewInput.noScore();
+ reviewInput.message = "a comment";
+ gApi.changes().id(changeId.get()).current().review(reviewInput);
+
+ assertThat(loadInput().approversFromPreviousPatchSets())
+ .containsExactlyEntriesIn(expectedPreviousApprovers);
+ }
+
+ /**
+ * Test that a previous approval doesn't count for computing previous approvers if the approver
+ * downgrades the vote.
+ */
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+2")
+ public void noPreviousApproversIfApprovalIsDowngraded() throws Exception {
+ // Allow all users to vote with Code-Review+2.
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ TestProjectUpdate.allowLabel("Code-Review")
+ .range(0, 2)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .build())
+ .update();
+
+ // approve as user
+ requestScopeOperations.setApiUser(user.id());
+ approve(changeId.toString());
+
+ // create a second patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ // check that the previous approver on patch set 1 is found
+ ArrayListMultimap<PatchSet.Id, Account.Id> expectedPreviousApprovers =
+ ArrayListMultimap.create();
+ expectedPreviousApprovers.put(PatchSet.id(changeId, 1), user.id());
+ assertThat(loadInput().approversFromPreviousPatchSets())
+ .containsExactlyEntriesIn(expectedPreviousApprovers);
+
+ // change vote from Code-Review+2 to Code-Review+1 as user
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // the Code-Review+1 vote on the current patch set overrode the previous approval
+ assertThat(loadInput().approversFromPreviousPatchSets()).isEmpty();
+
+ // create a third patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ // the Code-Review+1 vote on the previous patch set overrode the previous approval
+ assertThat(loadInput().approversFromPreviousPatchSets()).isEmpty();
+ }
+
+ /**
+ * Test that a previous approval doesn't count for computing previous approvers if the approver
+ * re-applies the approval (because now it's a current approval).
+ */
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void noPreviousApproversIfApprovalIsReapplied() throws Exception {
+ // approve as user
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // create a second patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ // check that the previous approver on patch set 1 is found
+ ArrayListMultimap<PatchSet.Id, Account.Id> expectedPreviousApprovers =
+ ArrayListMultimap.create();
+ expectedPreviousApprovers.put(PatchSet.id(changeId, 1), user.id());
+ assertThat(loadInput().approversFromPreviousPatchSets())
+ .containsExactlyEntriesIn(expectedPreviousApprovers);
+
+ // re-apply the approval
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ assertThat(loadInput().approversFromPreviousPatchSets()).isEmpty();
+ }
+
+ /**
+ * Test that only the last previous approval of a user counts for computing previous approvers.
+ */
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void onlyLastPreviousApprovalOfAUserIsConsideredForComputingPreviousApprovers()
+ throws Exception {
+ // approve as user
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // create a second patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ // re-approve as user
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // create a third patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ ArrayListMultimap<PatchSet.Id, Account.Id> expectedPreviousApprovers =
+ ArrayListMultimap.create();
+ expectedPreviousApprovers.put(PatchSet.id(changeId, 2), user.id());
+ assertThat(loadInput().approversFromPreviousPatchSets())
+ .containsExactlyEntriesIn(expectedPreviousApprovers);
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void noPreviouslyApprovedPatchSets() throws Exception {
+ // approve current patch set
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ assertThat(loadInput().previouslyApprovedPatchSetsInReverseOrder()).isEmpty();
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void previouslyApprovedPatchSetsAreReturnedInReverseOrder() throws Exception {
+ // create a second patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ // approve as user
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId.toString());
+
+ // create a third patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ // approve as user2, ignored since overridden on patch set 4
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId.toString());
+
+ // create a 4th patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ // re- approve as user2
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId.toString());
+
+ // create a 5th patch set
+ requestScopeOperations.setApiUser(changeOwner);
+ amendChange(changeKey.get()).assertOkStatus();
+
+ assertThat(loadInput().previouslyApprovedPatchSetsInReverseOrder())
+ .containsExactly(PatchSet.id(changeId, 4), PatchSet.id(changeId, 2));
+ }
+
@Test
public void noImplicitApprover() {
assertThat(loadInput().implicitApprover()).isEmpty();
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithStickyApprovalsTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithStickyApprovalsTest.java
new file mode 100644
index 0000000..fc73235
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithStickyApprovalsTest.java
@@ -0,0 +1,696 @@
+// Copyright (C) 2022 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.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatCollection;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
+import com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig;
+import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.util.JgitPath;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnerApprovalCheck} with sticky approvals enabled. */
+public class CodeOwnerApprovalCheckWithStickyApprovalsTest extends AbstractCodeOwnersTest {
+ @Inject private ChangeNotes.Factory changeNotesFactory;
+ @Inject private RequestScopeOperations requestScopeOperations;
+ @Inject private ProjectOperations projectOperations;
+
+ private CodeOwnerApprovalCheck codeOwnerApprovalCheck;
+ private CodeOwnerConfigOperations codeOwnerConfigOperations;
+
+ /** Returns a {@code gerrit.config} that configures all users as fallback code owners. */
+ @ConfigSuite.Default
+ public static Config defaultConfig() {
+ Config cfg = new Config();
+ cfg.setBoolean(
+ "plugin", "code-owners", GeneralConfig.KEY_ENABLE_STICKY_APPROVALS, /* value= */ true);
+ return cfg;
+ }
+
+ @Before
+ public void setUpCodeOwnersPlugin() throws Exception {
+ codeOwnerApprovalCheck = plugin.getSysInjector().getInstance(CodeOwnerApprovalCheck.class);
+ codeOwnerConfigOperations =
+ plugin.getSysInjector().getInstance(CodeOwnerConfigOperations.class);
+ }
+
+ @Test
+ public void notApproved_noStickyApproval() throws Exception {
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // Verify that the file is not approved.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+
+ // create a second patch set so that there is a previous patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "new file content")
+ .assertOkStatus();
+
+ // Verify that the file is not approved.
+ fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+ }
+
+ @Test
+ public void notApproved_byPreviousApprovalOfNonCodeOwner() throws Exception {
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // code owner approve as user who is not a code owner
+ recommend(changeId);
+
+ // create a second patch set so that the approval becomes an approval on a previous patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "new file content")
+ .assertOkStatus();
+
+ // Verify that the file is not approved.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(path, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+ }
+
+ @Test
+ public void approved_byStickyApprovalOnPreviousPatchSet() throws Exception {
+ TestAccount codeOwner =
+ accountCreator.create(
+ "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+ setAsRootCodeOwners(codeOwner);
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // code owner approve
+ requestScopeOperations.setApiUser(codeOwner.id());
+ recommend(changeId);
+
+ // create a second patch set so that the approval becomes an approval on a previous patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "new file content")
+ .assertOkStatus();
+
+ // Verify that the file is approved.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved on patch set 1 by %s who is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner.id()))));
+ }
+
+ @Test
+ public void approved_byStickyApprovalOnOldPatchSet() throws Exception {
+ TestAccount codeOwner =
+ accountCreator.create(
+ "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+ setAsRootCodeOwners(codeOwner);
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // code owner approve
+ requestScopeOperations.setApiUser(codeOwner.id());
+ recommend(changeId);
+
+ // create several new patch sets so that the approval becomes an approval on an old patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "new file content")
+ .assertOkStatus();
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "newer file content")
+ .assertOkStatus();
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "newest file content")
+ .assertOkStatus();
+
+ // Verify that the file is approved.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved on patch set 1 by %s who is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner.id()))));
+ }
+
+ @Test
+ public void approved_byStickyApprovalsOfDifferentUsersOnDifferentPreviousPatchSets()
+ throws Exception {
+ TestAccount codeOwner1 =
+ accountCreator.create(
+ "codeOwner1", "codeOwner1@example.com", "CodeOwner1", /* displayName= */ null);
+ setAsCodeOwners("/foo/", codeOwner1);
+ TestAccount codeOwner2 =
+ accountCreator.create(
+ "codeOwner2", "codeOwner2@example.com", "CodeOwner2", /* displayName= */ null);
+ setAsCodeOwners("/bar/", codeOwner2);
+
+ Path path1 = Paths.get("/foo/bar.baz");
+ Path path2 = Paths.get("/bar/foo.baz");
+ String changeId =
+ createChange(
+ "Change Adding A File",
+ ImmutableMap.of(
+ JgitPath.of(path1).get(),
+ "file content",
+ JgitPath.of(path2).get(),
+ "file content"))
+ .getChangeId();
+
+ // code owner approve first path
+ requestScopeOperations.setApiUser(codeOwner1.id());
+ recommend(changeId);
+
+ // create a second patch set so that the approval becomes an approval on a previous patch set
+ amendChange(
+ changeId,
+ "Change Adding A File",
+ ImmutableMap.of(
+ JgitPath.of(path1).get(),
+ "new file content",
+ JgitPath.of(path2).get(),
+ "new file content"))
+ .assertOkStatus();
+
+ // Verify that the path1 is approved, but path2 isn't.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path1,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved on patch set 1 by %s who is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner1.id()))),
+ FileCodeOwnerStatus.addition(path2, CodeOwnerStatus.INSUFFICIENT_REVIEWERS));
+
+ // code owner approve second path
+ requestScopeOperations.setApiUser(codeOwner2.id());
+ recommend(changeId);
+
+ // create another patch set so that the second approval becomes an approval on a previous patch
+ // set
+ amendChange(
+ changeId,
+ "Change Adding A File",
+ ImmutableMap.of(
+ JgitPath.of(path1).get(),
+ "newer file content",
+ JgitPath.of(path2).get(),
+ "newer file content"))
+ .assertOkStatus();
+
+ // Verify that both paths approved now.
+ fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path1,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved on patch set 1 by %s who is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner1.id()))),
+ FileCodeOwnerStatus.addition(
+ path2,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved on patch set 2 by %s who is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner2.id()))));
+ }
+
+ @Test
+ public void notApproved_byPreviousApprovalThatHasBeenDeleted() throws Exception {
+ TestAccount codeOwner =
+ accountCreator.create(
+ "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+ setAsRootCodeOwners(codeOwner);
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // code owner approve
+ requestScopeOperations.setApiUser(codeOwner.id());
+ recommend(changeId);
+
+ // delete the approval
+ adminRestSession
+ .delete(
+ "/changes/"
+ + changeId
+ + "/reviewers/"
+ + codeOwner.id().toString()
+ + "/votes/Code-Review")
+ .assertNoContent();
+
+ // create a second patch set so that the deleted approval becomes an approval on a previous
+ // patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "new file content")
+ .assertOkStatus();
+
+ // Verify that the file is not approved. The expected status is PENDING since the code owner is
+ // a reviewer now.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.PENDING,
+ String.format(
+ "reviewer %s is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner.id()))));
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.code-owners.requiredApproval", value = "Code-Review+2")
+ public void notApproved_byPreviousApprovalThatHasBeenDowngraded() throws Exception {
+ // Allow all users to vote with Code-Review+2.
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ TestProjectUpdate.allowLabel("Code-Review")
+ .range(0, 2)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .build())
+ .update();
+
+ TestAccount codeOwner =
+ accountCreator.create(
+ "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+ setAsRootCodeOwners(codeOwner);
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // code owner approve
+ requestScopeOperations.setApiUser(codeOwner.id());
+ approve(changeId);
+
+ // create a second patch set so that the deleted approval becomes an approval on a previous
+ // patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "new file content")
+ .assertOkStatus();
+
+ // downgrade approval
+ recommend(changeId);
+
+ // Verify that the file is not approved. The expected status is PENDING since the code owner is
+ // a reviewer now.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.PENDING,
+ String.format(
+ "reviewer %s is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner.id()))));
+
+ // create another patch set so that the downgraded approval becomes an approval on a previous
+ // patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "newer file content")
+ .assertOkStatus();
+
+ fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.PENDING,
+ String.format(
+ "reviewer %s is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner.id()))));
+ }
+
+ @Test
+ public void approved_reapprovalTrumpsPreviousApproval() throws Exception {
+ TestAccount codeOwner =
+ accountCreator.create(
+ "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+ setAsRootCodeOwners(codeOwner);
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // code owner approve
+ requestScopeOperations.setApiUser(codeOwner.id());
+ recommend(changeId);
+
+ // create a second patch set so that the deleted approval becomes an approval on a previous
+ // patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "new file content")
+ .assertOkStatus();
+
+ // re-approve
+ recommend(changeId);
+
+ // Verify that the file is approved by the current approval.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved by %s who is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner.id()))));
+
+ // create another patch set so that the re-approval becomes an approval on a previous patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "newer file content")
+ .assertOkStatus();
+
+ fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved on patch set 2 by %s who is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner.id()))));
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+ public void approved_implicitApprovalTrumpsPreviousApproval() throws Exception {
+ TestAccount implicitCodeOwner = admin; // the changes is created by the admit user
+ setAsRootCodeOwners(implicitCodeOwner);
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // code owner approve
+ requestScopeOperations.setApiUser(implicitCodeOwner.id());
+ recommend(changeId);
+
+ // create a second patch set so that the deleted approval becomes an approval on a previous
+ // patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "new file content")
+ .assertOkStatus();
+
+ // Verify that the file is approved by the current implicit approval.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "implicitly approved by the patch set uploader %s who is a code owner",
+ AccountTemplateUtil.getAccountTemplate(implicitCodeOwner.id()))));
+ }
+
+ @Test
+ public void notApproved_fileThatIsNotPresentInApprovedPatchSetIsNotCoveredByTheApproval()
+ throws Exception {
+ TestAccount codeOwner =
+ accountCreator.create(
+ "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+ setAsRootCodeOwners(codeOwner);
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // code owner approve
+ requestScopeOperations.setApiUser(codeOwner.id());
+ recommend(changeId);
+
+ // create a second patch set that adds a new file
+ Path path2 = Paths.get("/foo/abc.xyz");
+ amendChange(
+ changeId,
+ "Change Adding A File",
+ ImmutableMap.of(
+ JgitPath.of(path).get(),
+ "new file content",
+ JgitPath.of(path2).get(),
+ "file content"))
+ .assertOkStatus();
+
+ // Verify that the new file is not approved. The expected status is PENDING since the code owner
+ // is a reviewer.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved on patch set 1 by %s who is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner.id()))),
+ FileCodeOwnerStatus.addition(
+ path2,
+ CodeOwnerStatus.PENDING,
+ String.format(
+ "reviewer %s is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner.id()))));
+
+ // re-approve to cover all files
+ recommend(changeId);
+
+ fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved by %s who is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner.id()))),
+ FileCodeOwnerStatus.addition(
+ path2,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved by %s who is a code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner.id()))));
+ }
+
+ @Test
+ public void approved_byStickyApprovalOnPreviousPatchSet_everyoneIsCodeOwner() throws Exception {
+ // Create a code owner config file that makes everyone a code owner.
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/")
+ .addCodeOwnerEmail("*")
+ .create();
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // code owner approve
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId);
+
+ // create a second patch set so that the approval becomes an approval on a previous patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "new file content")
+ .assertOkStatus();
+
+ // Verify that the file is approved.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved on patch set 1 by %s who is a code owner (all users are code owners)",
+ AccountTemplateUtil.getAccountTemplate(user.id()))));
+ }
+
+ @Test
+ public void approved_byStickyApprovalOfDefaultCodeOnPreviousPatchSet() throws Exception {
+ TestAccount codeOwner =
+ accountCreator.create(
+ "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+ setAsDefaultCodeOwners(codeOwner);
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // code owner approve
+ requestScopeOperations.setApiUser(codeOwner.id());
+ recommend(changeId);
+
+ // create a second patch set so that the approval becomes an approval on a previous patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "new file content")
+ .assertOkStatus();
+
+ // Verify that the file is approved.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved on patch set 1 by %s who is a default code owner",
+ AccountTemplateUtil.getAccountTemplate(codeOwner.id()))));
+ }
+
+ @Test
+ public void approved_byStickyApprovalOfDefaultCodeOnPreviousPatchSet_everyoneIsDefaultCodeOwner()
+ throws Exception {
+ // Create a code owner config file that makes everyone a default code owner.
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch(RefNames.REFS_CONFIG)
+ .folderPath("/")
+ .addCodeOwnerEmail("*")
+ .create();
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // code owner approve
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId);
+
+ // create a second patch set so that the approval becomes an approval on a previous patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "new file content")
+ .assertOkStatus();
+
+ // Verify that the file is approved.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved on patch set 1 by %s who is a default code owner"
+ + " (all users are default code owners)",
+ AccountTemplateUtil.getAccountTemplate(user.id()))));
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "bot@example.com")
+ public void approved_byStickyApprovalOfGlobalCodeOnPreviousPatchSet() throws Exception {
+ // Create a bot user that is a global code owner.
+ TestAccount bot =
+ accountCreator.create("bot", "bot@example.com", "Bot", /* displayName= */ null);
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // code owner approve
+ requestScopeOperations.setApiUser(bot.id());
+ recommend(changeId);
+
+ // create a second patch set so that the approval becomes an approval on a previous patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "new file content")
+ .assertOkStatus();
+
+ // Verify that the file is approved.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved on patch set 1 by %s who is a global code owner",
+ AccountTemplateUtil.getAccountTemplate(bot.id()))));
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.code-owners.globalCodeOwner", value = "*")
+ public void approved_byStickyApprovalOfGlobalCodeOnPreviousPatchSet_everyoneIsGlobalCodeOwner()
+ throws Exception {
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // code owner approve
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId);
+
+ // create a second patch set so that the approval becomes an approval on a previous patch set
+ amendChange(changeId, "Change Adding A File", JgitPath.of(path).get(), "new file content")
+ .assertOkStatus();
+
+ // Verify that the file is approved.
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses = getFileCodeOwnerStatuses(changeId);
+ assertThatCollection(fileCodeOwnerStatuses)
+ .containsExactly(
+ FileCodeOwnerStatus.addition(
+ path,
+ CodeOwnerStatus.APPROVED,
+ String.format(
+ "approved on patch set 1 by %s who is a global code owner"
+ + " (all users are global code owners)",
+ AccountTemplateUtil.getAccountTemplate(user.id()))));
+ }
+
+ private ImmutableSet<FileCodeOwnerStatus> getFileCodeOwnerStatuses(String changeId)
+ throws Exception {
+ return codeOwnerApprovalCheck.getFileStatusesAsSet(
+ getChangeNotes(changeId), /* start= */ 0, /* limit= */ 0);
+ }
+
+ private ChangeNotes getChangeNotes(String changeId) throws Exception {
+ return changeNotesFactory.create(project, Change.id(gApi.changes().id(changeId).get()._number));
+ }
+
+ private PushOneCommit.Result amendChange(
+ String changeId, String subject, Map<String, String> files) throws Exception {
+ PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, subject, files, changeId);
+ return push.to("refs/for/master");
+ }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
index 86801cc..970aa74 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
@@ -20,6 +20,7 @@
import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_ASYNC_MESSAGE_ON_ADD_REVIEWER;
import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_CODE_OWNER_CONFIG_FILES_WITH_FILE_EXTENSIONS;
import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_STICKY_APPROVALS;
import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_BRANCH_CREATION;
import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED;
import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT;
@@ -1642,6 +1643,63 @@
}
@Test
+ public void cannotGetEnableStickyApprovalsForNullProject() throws Exception {
+ NullPointerException npe =
+ assertThrows(
+ NullPointerException.class,
+ () -> generalConfig.enableStickyApprovals(/* project= */ null, new Config()));
+ assertThat(npe).hasMessageThat().isEqualTo("project");
+ }
+
+ @Test
+ public void cannotGetEnableStickyApprovalsForNullPluginConfig() throws Exception {
+ NullPointerException npe =
+ assertThrows(
+ NullPointerException.class,
+ () -> generalConfig.enableStickyApprovals(project, /* pluginConfig= */ null));
+ assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+ }
+
+ @Test
+ public void noEnableStickyApprovals() throws Exception {
+ assertThat(generalConfig.enableStickyApprovals(project, new Config())).isFalse();
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void
+ enableStickyApprovalsConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+ throws Exception {
+ assertThat(generalConfig.enableStickyApprovals(project, new Config())).isTrue();
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void
+ enableStickyApprovalsConfigurationInPluginConfigOverridesEnableStickyApprovalsConfigurationInGerritConfig()
+ throws Exception {
+ Config cfg = new Config();
+ cfg.setString(
+ SECTION_CODE_OWNERS, /* subsection= */ null, KEY_ENABLE_STICKY_APPROVALS, "false");
+ assertThat(generalConfig.enableStickyApprovals(project, cfg)).isFalse();
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "true")
+ public void invalidEnableStickyApprovalsConfigurationInPluginConfigIsIgnored() throws Exception {
+ Config cfg = new Config();
+ cfg.setString(
+ SECTION_CODE_OWNERS, /* subsection= */ null, KEY_ENABLE_STICKY_APPROVALS, "INVALID");
+ assertThat(generalConfig.enableStickyApprovals(project, cfg)).isTrue();
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.code-owners.enableStickyApprovals", value = "INVALID")
+ public void invalidEnableStickyApprovalsConfigurationInGerritConfigIsIgnored() throws Exception {
+ assertThat(generalConfig.enableStickyApprovals(project, new Config())).isFalse();
+ }
+
+ @Test
public void cannotGetGlobalCodeOwnersForNullPluginConfig() throws Exception {
NullPointerException npe =
assertThrows(
diff --git a/resources/Documentation/config-guide.md b/resources/Documentation/config-guide.md
index 2a20478..3eaefca 100644
--- a/resources/Documentation/config-guide.md
+++ b/resources/Documentation/config-guide.md
@@ -30,6 +30,9 @@
approval](config.html#pluginCodeOwnersRequiredApproval) and [override
approval](config.html#pluginCodeOwnersOverrideApproval).
+Alternatively code owner approvals can be made sticky per file by [enabling
+sticky approvals in the plugin configuration](config.html#pluginCodeOwnersEnableStickyApprovals).
+
### <a id="implicitApprovals">Implicit code owner approvals
It's possible to [enable implicit approvals](config.html#pluginCodeOwnersEnableImplicitApprovals)
diff --git a/resources/Documentation/config.md b/resources/Documentation/config.md
index b4ea424..80c5b59 100644
--- a/resources/Documentation/config.md
+++ b/resources/Documentation/config.md
@@ -261,6 +261,66 @@
in `@PLUGIN@.config`.\
By default `FALSE`.
+<a id="pluginCodeOwnersEnableStickyApprovals">plugin.@PLUGIN@.enableStickyApprovals</a>
+: Whether code owner approvals should be sticky on the files they approve,
+ even if these files are changed in follow-up patch sets.\
+ \
+ With this setting previous code owner approvals on files only get
+ invalidated if the approval is revoked, changed (e.g. from
+ `Code-Review+1` to `Code-Review-1`) or re-applied, but not by the upload
+ of a new patch set regardless of whether the new patch sets changes
+ these files.\
+ \
+ Code owner approvals are sticky per file but not on change level. This
+ means they do not show up as (copied) approvals on the change screen
+ like regular sticky approvals, but only count for the computation of the
+ code owner file statuses. In contrast to this setting, making code owner
+ approvals sticky on change level (by setting a [copy
+ condition](../../../Documentation/config-labels.html#label_copyCondition)
+ on the label that is used for code owner approvals) would make the
+ sticky approvals count for all files in the current patch set that are
+ owned by the approvers, regardless of whether these files existed in the
+ patch sets that were originally approved.\
+ \
+ Since code owner approvals that are sticky on file level are not shown
+ in the UI, users need to inspect the [per-file code owner
+ statuses](how-to-use.html#perFileCodeOwnerStatuses) to know which files
+ are code owner approved.\
+ \
+ Example:\
+ If patch set 1 contains the files A, B and C and a user that owns the
+ files A and B approves the patch set by voting with `Code-Review+1`, A
+ and B are code owner approved for this patch set and all future patch
+ sets unless the approval is revoked, changed or re-applied. This means
+ if a second patch set is uploaded that touches files A and B, A and B
+ are still code owner approved. If a third patch set is uploaded that
+ adds file D that is also owned by the same code owner, file D is not
+ code owner approved since it is not present in patch set 1 on which the
+ user applied the code owner approval. If the user changes their vote on
+ patch set 3 from `Code-Review+1` to `Code-Review-1`, the new vote
+ invalidates the approval on patch set 1, so that in this example the
+ files A and B would no longer be code owner approved by this user. If
+ the user re-applies the `Code-Review+1` approval now all owned files
+ that are present in the current patch set, files A, B and D, are code
+ owner approved.\
+ \
+ Enabling sticky code owner approvals on file level can improve the
+ overall developer productivity significantly, since it makes
+ re-approvals on new patch sets unecessary in many cases. With sticky
+ code owner approvals on files new patch sets only need to be re-approved
+ if they touch additional files, and then only by the users that own
+ these files. In contrast to this, if code owner approvals are not
+ sticky (neither on file level via this setting, nor on change level via
+ a copy condition) a new patch set must always be re-approved by all code
+ owners that approved any of the contained files, no matter if these
+ files are touched in the new patch set.\
+ \
+ Can be overridden per project by setting
+ [codeOwners.enableStickyApprovals](#codeOwnersEnableStickyApprovals)
+ in `@PLUGIN@.config`.\
+ \
+ By default `false`.
+
<a id="pluginCodeOwnersGlobalCodeOwner">plugin.@PLUGIN@.globalCodeOwner</a>
: The email of a user that should be a code owner globally across all
branches.\
@@ -854,11 +914,24 @@
If implicit code owner approvals are disabled, code owners can still
self-approve their own changes by voting on the change.\
Overrides the global setting
- [plugin.@PLUGIN@.enableImplicitApprovals](#pluginCodeOwnersenableImplicitApprovals)
+ [plugin.@PLUGIN@.enableImplicitApprovals](#pluginCodeOwnersEnableImplicitApprovals)
in `gerrit.config` and the `codeOwners.enableImplicitApprovals` setting
from parent projects.\
If not set, the global setting
- [plugin.@PLUGIN@.enableImplicitApprovals](#pluginCodeOwnersenableImplicitApprovals)
+ [plugin.@PLUGIN@.enableImplicitApprovals](#pluginCodeOwnersEnableImplicitApprovals)
+ in `gerrit.config` is used.
+
+<a id="codeOwnersEnableStickyApprovals">codeOwners.enableStickyApprovals</a>
+: Whether code owner approvals should be sticky on the files they approve,
+ even if these files are changed in follow-up patch sets.\
+ For details see
+ [plugin.@PLUGIN@.enableStickyApprovals](#pluginCodeOwnersEnableStickyApprovals).\
+ Overrides the global setting
+ [plugin.@PLUGIN@.enableStickyApprovals](#pluginCodeOwnersEnableStickyApprovals)
+ in `gerrit.config` and the `codeOwners.enableStickyApprovals` setting
+ from parent projects.\
+ If not set, the global setting
+ [plugin.@PLUGIN@.enableStickyApprovals](#pluginCodeOwnersEnableStickyApprovals)
in `gerrit.config` is used.
<a id="codeOwnersGlobalCodeOwner">codeOwners.globalCodeOwner</a>
diff --git a/resources/Documentation/how-to-use.md b/resources/Documentation/how-to-use.md
index 552f675..1635034 100644
--- a/resources/Documentation/how-to-use.md
+++ b/resources/Documentation/how-to-use.md
@@ -191,7 +191,7 @@
\

-### <a id="perFilCodeOwnerStatuses">Per file code owner statuses
+### <a id="perFileCodeOwnerStatuses">Per file code owner statuses
The `@PLUGIN@` plugin also shows the code owner statuses per file in the file
list.
diff --git a/resources/Documentation/metrics.md b/resources/Documentation/metrics.md
index 7c15669..b5f7765 100644
--- a/resources/Documentation/metrics.md
+++ b/resources/Documentation/metrics.md
@@ -16,6 +16,8 @@
Latency for computing the file status for one file.
* `compute_file_statuses`:
Latency for computing file statuses for all files in a change.
+ * `sticky_approvals`:
+ Whether sticky approvals on file level are enabled.
* `compute_owned_paths`:
Latency for computing file statuses.
* `compute_owned_paths`: