Merge branch 'stable-3.4' into stable-3.5 * stable-3.4: Expire the owners cache after 1 minute and make it unlimited Optimize the 'path_owners_entries' cache eviction Introduce PathOwners cache from Ic7d61de07 Change-Id: I038f21524b7df93422b0ccd0f95afc5b9819828a
diff --git a/README.md b/README.md index 4893600..9c1decb 100644 --- a/README.md +++ b/README.md
@@ -118,7 +118,8 @@ This project can be imported into the Eclipse IDE: -Add the plugin name to the `CUSTOM_PLUGINS` in Gerrit core in +Add the plugin name to the `CUSTOM_PLUGINS` (and in case when you want to run +tests from the IDE to `CUSTOM_PLUGINS_TEST_DEPS`) in Gerrit core in `tools/bzl/plugins.bzl` file and run: ```
diff --git a/external_plugin_deps.bzl b/external_plugin_deps.bzl index bca48e4..5ad1930 100644 --- a/external_plugin_deps.bzl +++ b/external_plugin_deps.bzl
@@ -2,13 +2,12 @@ JACKSON_VER = "2.9.7" -def external_plugin_deps(omit_jackson_core = True): - if not omit_jackson_core: - maven_jar( - name = "jackson-core", - artifact = "com.fasterxml.jackson.core:jackson-core:" + JACKSON_VER, - sha1 = "4b7f0e0dc527fab032e9800ed231080fdc3ac015", - ) +def external_plugin_deps(): + maven_jar( + name = "jackson-core", + artifact = "com.fasterxml.jackson.core:jackson-core:" + JACKSON_VER, + sha1 = "4b7f0e0dc527fab032e9800ed231080fdc3ac015", + ) maven_jar( name = "jackson-databind",
diff --git a/owners-autoassign/BUILD b/owners-autoassign/BUILD index 024be42..4aa4cec 100644 --- a/owners-autoassign/BUILD +++ b/owners-autoassign/BUILD
@@ -15,8 +15,8 @@ ], resources = glob(["src/main/**/*"]), deps = [ + ":owners-api-neverlink", "//owners-common", - "//plugins/owners-api", ], ) @@ -28,10 +28,16 @@ visibility = ["//visibility:public"], deps = PLUGIN_DEPS_NEVERLINK + [ "//owners-common", - "//plugins/owners-api", + ":owners-api-neverlink", ], ) +java_library( + name = "owners-api-neverlink", + neverlink = 1, + exports = ["//plugins/owners-api"], +) + junit_tests( name = "owners_autoassign_tests", testonly = 1,
diff --git a/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/AutoassignModule.java b/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/AutoassignModule.java index 89e5e16..5e6ad99 100644 --- a/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/AutoassignModule.java +++ b/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/AutoassignModule.java
@@ -18,21 +18,33 @@ import com.google.common.annotations.VisibleForTesting; import com.google.gerrit.extensions.events.GitReferenceUpdatedListener; +import com.google.gerrit.extensions.registration.DynamicItem; import com.google.gerrit.extensions.registration.DynamicSet; import com.google.inject.AbstractModule; import com.google.inject.Inject; +import com.google.inject.Scopes; +import com.googlesource.gerrit.owners.api.OwnersAttentionSet; public class AutoassignModule extends AbstractModule { - private final AutoAssignConfig config; - @Inject - AutoassignModule(AutoAssignConfig config) { - this.config = config; - } + private final Class<? extends OwnersAttentionSet> ownersAttentionSetImpl; + private final AutoAssignConfig config; @VisibleForTesting AutoassignModule() { - this.config = new AutoAssignConfig(); + this(DefaultAddAllOwnersToAttentionSet.class, new AutoAssignConfig()); + } + + @Inject + AutoassignModule(AutoAssignConfig config) { + this(DefaultAddAllOwnersToAttentionSet.class, config); + } + + @VisibleForTesting + public AutoassignModule( + Class<? extends OwnersAttentionSet> ownersAttentionSetImpl, AutoAssignConfig config) { + this.ownersAttentionSetImpl = ownersAttentionSetImpl; + this.config = config; } @Override @@ -41,6 +53,9 @@ bind(ReviewerManager.class) .to(config.isAsyncReviewers() ? AsyncReviewerManager.class : SyncReviewerManager.class); DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(GitRefListener.class); + DynamicItem.bind(binder(), OwnersAttentionSet.class) + .to(ownersAttentionSetImpl) + .in(Scopes.SINGLETON); install(new AutoassignConfigModule()); } }
diff --git a/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/DefaultAddAllOwnersToAttentionSet.java b/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/DefaultAddAllOwnersToAttentionSet.java new file mode 100644 index 0000000..d53a239 --- /dev/null +++ b/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/DefaultAddAllOwnersToAttentionSet.java
@@ -0,0 +1,29 @@ +// Copyright (C) 2021 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.googlesource.gerrit.owners.common; + +import com.google.gerrit.entities.Account.Id; +import com.google.gerrit.extensions.common.ChangeInfo; +import com.googlesource.gerrit.owners.api.OwnersAttentionSet; +import java.util.Collection; + +class DefaultAddAllOwnersToAttentionSet implements OwnersAttentionSet { + + @Override + public Collection<Id> addToAttentionSet(ChangeInfo changeInfo, Collection<Id> owners) { + return owners; + } +}
diff --git a/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/GitRefListener.java b/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/GitRefListener.java index d4f002d..d6fd574 100644 --- a/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/GitRefListener.java +++ b/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/GitRefListener.java
@@ -40,7 +40,8 @@ import com.google.gerrit.server.notedb.ChangeNotes; import com.google.gerrit.server.notedb.ChangeNotesCommit; import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk; -import com.google.gerrit.server.patch.PatchList; +import com.google.gerrit.server.patch.DiffSummary; +import com.google.gerrit.server.patch.DiffSummaryKey; import com.google.gerrit.server.patch.PatchListCache; import com.google.gerrit.server.patch.PatchListKey; import com.google.gerrit.server.patch.PatchListNotAvailableException; @@ -228,7 +229,7 @@ .collect(Collectors.toList())) .orElse(Collections.emptyList()); - PatchList patchList = getPatchList(repository, event, change); + DiffSummary patchList = getDiffSummary(repository, event, change); if (patchList != null) { PathOwners owners = new PathOwners( @@ -258,7 +259,7 @@ } } - private PatchList getPatchList(Repository repository, Event event, ChangeInfo change) { + private DiffSummary getDiffSummary(Repository repository, Event event, ChangeInfo change) { ObjectId newId = null; PatchListKey plKey; try { @@ -272,7 +273,8 @@ } plKey = PatchListKey.againstCommit(null, newId, IGNORE_NONE); } - return patchListCache.get(plKey, Project.nameKey(change.project)); + return patchListCache.getDiffSummary( + DiffSummaryKey.fromPatchListKey(plKey), Project.nameKey(change.project)); } catch (PatchListNotAvailableException | IOException e) { logger.warn("Could not load patch list for change {}", change.id, e); }
diff --git a/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/ReviewerManager.java b/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/ReviewerManager.java index 64ba615..bf56e26 100644 --- a/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/ReviewerManager.java +++ b/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/ReviewerManager.java
@@ -19,12 +19,11 @@ import com.google.gerrit.entities.Project.NameKey; import com.google.gerrit.extensions.api.changes.ChangeApi; import com.google.gerrit.server.project.NoSuchProjectException; - import java.util.Collection; public interface ReviewerManager { - public void addReviewers( - NameKey projectNameKey, ChangeApi cApi, Collection<Account.Id> accountsIds) - throws ReviewerManagerException, NoSuchProjectException; + public void addReviewers( + NameKey projectNameKey, ChangeApi cApi, Collection<Account.Id> accountsIds) + throws ReviewerManagerException, NoSuchProjectException; }
diff --git a/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/SyncReviewerManager.java b/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/SyncReviewerManager.java index e8bd2eb..bdfa7f6 100644 --- a/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/SyncReviewerManager.java +++ b/owners-autoassign/src/main/java/com/googlesource/gerrit/owners/common/SyncReviewerManager.java
@@ -17,15 +17,14 @@ package com.googlesource.gerrit.owners.common; import com.google.gerrit.entities.Account; -import com.google.gerrit.entities.Account.Id; import com.google.gerrit.entities.Change; import com.google.gerrit.entities.Project; import com.google.gerrit.entities.Project.NameKey; import com.google.gerrit.extensions.api.GerritApi; -import com.google.gerrit.extensions.api.changes.AddReviewerInput; import com.google.gerrit.extensions.api.changes.AttentionSetInput; import com.google.gerrit.extensions.api.changes.ChangeApi; import com.google.gerrit.extensions.api.changes.ReviewInput; +import com.google.gerrit.extensions.api.changes.ReviewerInput; import com.google.gerrit.extensions.client.ReviewerState; import com.google.gerrit.extensions.common.ChangeInfo; import com.google.gerrit.extensions.registration.DynamicItem; @@ -88,7 +87,6 @@ this.cfg = cfg; } - @Override public void addReviewers( NameKey projectNameKey, ChangeApi cApi, Collection<Account.Id> accountsIds) throws ReviewerManagerException, NoSuchProjectException { @@ -112,7 +110,7 @@ continue; } if (isVisibleTo(changeInfo, account)) { - AddReviewerInput addReviewerInput = new AddReviewerInput(); + ReviewerInput addReviewerInput = new ReviewerInput(); addReviewerInput.reviewer = account.toString(); addReviewerInput.state = reviewerState; in.reviewers.add(addReviewerInput); @@ -122,7 +120,8 @@ } } else { log.warn( - "Not adding account {} as reviewer to change {} because the associated ref is not visible", + "Not adding account {} as reviewer to change {} because the associated ref is not" + + " visible", account, changeInfo._number); } @@ -142,7 +141,7 @@ in.ignoreAutomaticAttentionSetRules = true; in.addToAttentionSet = - reviewersAccounts.stream() + ownersForAttentionSet.get().addToAttentionSet(changeInfo, reviewersAccounts).stream() .map( (reviewer) -> new AttentionSetInput( @@ -157,7 +156,7 @@ } } - private boolean isVisibleTo(ChangeInfo changeInfo, Id account) { + private boolean isVisibleTo(ChangeInfo changeInfo, Account.Id account) { ChangeData changeData = changeDataFactory.create( Project.nameKey(changeInfo.project), Change.id(changeInfo._number));
diff --git a/owners-autoassign/src/main/resources/Documentation/attention-set.md b/owners-autoassign/src/main/resources/Documentation/attention-set.md index 7ad9a55..0ae7fa5 100644 --- a/owners-autoassign/src/main/resources/Documentation/attention-set.md +++ b/owners-autoassign/src/main/resources/Documentation/attention-set.md
@@ -11,7 +11,7 @@ a generic interface that can be used to customize Gerrit's default attention-set behaviour. -## owner-api setup +## owners-api setup Copy the `owners-api.jar` libModule into the $GERRIT_SITE/lib directory and add the following entry to `gerrit.config`:
diff --git a/owners-autoassign/src/main/resources/Documentation/config.md b/owners-autoassign/src/main/resources/Documentation/config.md index 7de8eec..a99e3b1 100644 --- a/owners-autoassign/src/main/resources/Documentation/config.md +++ b/owners-autoassign/src/main/resources/Documentation/config.md
@@ -1,3 +1,13 @@ +## Setup + +The owners-autoassign plugin depends on the shared library `owners-api.jar` +which needs to be installed into the `$GERRIT_SITE/lib` and requires a +restart of the Gerrit service. + +Once the `owners-api.jar` is loaded at Gerrit startup, the `owners-autoassign.jar` +file can be installed like a regular Gerrit plugin, by being dropped to the +`GRRIT_SITE/plugins` directory or installed through the plugin manager. + ## Global configuration The global plugin configuration is read from the `$GERRIT_SITE/etc/owners-autoassign.config`
diff --git a/owners-autoassign/src/test/java/com/googlesource/gerrit/owners/common/AbstractAutoassignIT.java b/owners-autoassign/src/test/java/com/googlesource/gerrit/owners/common/AbstractAutoassignIT.java index a38b7a3..52fc441 100644 --- a/owners-autoassign/src/test/java/com/googlesource/gerrit/owners/common/AbstractAutoassignIT.java +++ b/owners-autoassign/src/test/java/com/googlesource/gerrit/owners/common/AbstractAutoassignIT.java
@@ -24,9 +24,9 @@ import com.google.gerrit.acceptance.UseLocalDisk; import com.google.gerrit.acceptance.config.GlobalPluginConfig; import com.google.gerrit.common.RawInputUtil; -import com.google.gerrit.extensions.api.changes.AddReviewerInput; import com.google.gerrit.extensions.api.changes.ChangeApi; import com.google.gerrit.extensions.api.changes.ChangeEditApi; +import com.google.gerrit.extensions.api.changes.ReviewerInput; import com.google.gerrit.extensions.client.ReviewerState; import com.google.gerrit.extensions.common.AccountInfo; import com.google.gerrit.extensions.common.ChangeInfo; @@ -35,6 +35,8 @@ import com.google.gerrit.server.project.ProjectConfig; import com.google.inject.AbstractModule; import com.google.inject.Inject; +import com.google.inject.Module; +import com.googlesource.gerrit.owners.api.OwnersApiModule; import java.util.Collection; import java.util.HashMap; import java.util.List; @@ -73,6 +75,11 @@ } @Override + public Module createModule() { + return new OwnersApiModule(); + } + + @Override public void setUpTestPlugin() throws Exception { super.setUpTestPlugin(); @@ -145,7 +152,7 @@ assertThat(reviewers).hasSize(1); // Switch user from CC to Reviewer or the other way around - AddReviewerInput switchReviewerInput = new AddReviewerInput(); + ReviewerInput switchReviewerInput = new ReviewerInput(); switchReviewerInput.reviewer = ownerEmail; switchReviewerInput.state = assignedUserState == ReviewerState.REVIEWER ? ReviewerState.CC : ReviewerState.REVIEWER;
diff --git a/owners-autoassign/src/test/java/com/googlesource/gerrit/owners/common/GitRefListenerIT.java b/owners-autoassign/src/test/java/com/googlesource/gerrit/owners/common/GitRefListenerIT.java index d6428db..cb34173 100644 --- a/owners-autoassign/src/test/java/com/googlesource/gerrit/owners/common/GitRefListenerIT.java +++ b/owners-autoassign/src/test/java/com/googlesource/gerrit/owners/common/GitRefListenerIT.java
@@ -35,6 +35,8 @@ import com.google.gerrit.server.util.ThreadLocalRequestContext; import com.google.inject.AbstractModule; import com.google.inject.Inject; +import com.google.inject.Module; +import com.googlesource.gerrit.owners.api.OwnersApiModule; import java.util.HashMap; import java.util.Map; import java.util.stream.StreamSupport; @@ -53,6 +55,11 @@ String anOldObjectId = "anOldRef"; String aNewObjectId = "aNewRef"; + @Override + public Module createModule() { + return new OwnersApiModule(); + } + public static class TestModule extends AbstractModule { @Override protected void configure() {
diff --git a/owners-autoassign/src/test/java/com/googlesource/gerrit/owners/common/OwnersAutoassignWithAttentionSetIT.java b/owners-autoassign/src/test/java/com/googlesource/gerrit/owners/common/OwnersAutoassignWithAttentionSetIT.java index 36795a6..1cfcf04 100644 --- a/owners-autoassign/src/test/java/com/googlesource/gerrit/owners/common/OwnersAutoassignWithAttentionSetIT.java +++ b/owners-autoassign/src/test/java/com/googlesource/gerrit/owners/common/OwnersAutoassignWithAttentionSetIT.java
@@ -30,12 +30,10 @@ import com.google.gerrit.extensions.api.groups.GroupInput; import com.google.gerrit.extensions.client.ReviewerState; import com.google.gerrit.extensions.common.ChangeInfo; -import com.google.gerrit.extensions.registration.DynamicItem; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.inject.AbstractModule; import com.google.inject.Inject; import com.google.inject.Module; -import com.google.inject.Scopes; import com.googlesource.gerrit.owners.api.OwnersApiModule; import com.googlesource.gerrit.owners.api.OwnersAttentionSet; import java.util.Collection; @@ -58,11 +56,7 @@ public static class TestModule extends AbstractModule { @Override protected void configure() { - install(new AutoassignModule()); - - DynamicItem.bind(binder(), OwnersAttentionSet.class) - .to(SelectFirstOwnerForAttentionSet.class) - .in(Scopes.SINGLETON); + install(new AutoassignModule(SelectFirstOwnerForAttentionSet.class, new AutoAssignConfig())); } } @@ -88,7 +82,7 @@ @Test public void shouldAddToAttentionSetOneUserIfAnotherUserHasNoPermission() throws Exception { - TestAccount userWithAccessToProject = accountCreator.user(); + TestAccount userWithAccessToProject = accountCreator.user1(); TestAccount userWithNoAccessToProject = accountCreator.user2(); AccountGroup.UUID groupWithNoAccessToProject =
diff --git a/owners-common/common.bzl b/owners-common/common.bzl index c571145..d2ce9f5 100644 --- a/owners-common/common.bzl +++ b/owners-common/common.bzl
@@ -1,5 +1,5 @@ EXTERNAL_DEPS = [ - "@jackson-core//jar:neverlink", + "@jackson-core//jar", "@jackson-databind//jar", "@jackson-annotations//jar", "@jackson-dataformat-yaml//jar",
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/ConfigurationParser.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/ConfigurationParser.java index 84d8f6c..2c8c619 100644 --- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/ConfigurationParser.java +++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/ConfigurationParser.java
@@ -44,6 +44,10 @@ Boolean inherited = Optional.ofNullable(jsonNode.get("inherited")).map(JsonNode::asBoolean).orElse(true); ret.setInherited(inherited); + ret.setLabel( + Optional.ofNullable(jsonNode.get("label")) + .map(JsonNode::asText) + .flatMap(LabelDefinition::parse)); addClassicMatcher(jsonNode, ret); addMatchers(jsonNode, ret); return Optional.of(ret);
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/LabelDefinition.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/LabelDefinition.java new file mode 100644 index 0000000..08664fe --- /dev/null +++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/LabelDefinition.java
@@ -0,0 +1,97 @@ +// Copyright (C) 2023 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.googlesource.gerrit.owners.common; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.LabelId; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Describes the label together with score (the latter is optional) that is configured in the OWNERS + * file. File owners have to give the score for change to be submittable. + */ +public class LabelDefinition { + public static final LabelDefinition CODE_REVIEW = new LabelDefinition(LabelId.CODE_REVIEW, null); + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private static final Pattern LABEL_PATTERN = + Pattern.compile("^([a-zA-Z0-9-]+)(?:(?:\\s*,\\s*)(\\d))?$"); + + private final String name; + private final Optional<Short> score; + + LabelDefinition(String name, Short score) { + this.name = name; + this.score = Optional.ofNullable(score); + } + + public String getName() { + return name; + } + + public Optional<Short> getScore() { + return score; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("LabelDefinition [name="); + builder.append(name); + builder.append(", score="); + builder.append(score); + builder.append("]"); + return builder.toString(); + } + + @Override + public int hashCode() { + return Objects.hash(name, score); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if ((obj == null) || getClass() != obj.getClass()) { + return false; + } + + LabelDefinition other = (LabelDefinition) obj; + return Objects.equals(name, other.name) && Objects.equals(score, other.score); + } + + public static Optional<LabelDefinition> parse(String definition) { + return Optional.ofNullable(definition) + .filter(def -> !def.isBlank()) + .map( + def -> { + Matcher labelDef = LABEL_PATTERN.matcher(def.trim()); + if (!labelDef.matches()) { + logger.atSevere().log("Parsing label definition [%s] has failed.", def); + return null; + } + + return new LabelDefinition( + labelDef.group(1), + Optional.ofNullable(labelDef.group(2)).map(Short::valueOf).orElse(null)); + }); + } +}
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersConfig.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersConfig.java index d6e40eb..7e7e705 100644 --- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersConfig.java +++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersConfig.java
@@ -19,6 +19,7 @@ import com.google.common.collect.Maps; import com.google.common.collect.Sets; import java.util.Map; +import java.util.Optional; import java.util.Set; /** @@ -36,6 +37,9 @@ /** Map name of matcher and Matcher (value + Set Owners) */ private Map<String, Matcher> matchers = Maps.newHashMap(); + /** Label that is required for submit. CodeReview if nothing is specified. */ + private Optional<LabelDefinition> label = Optional.empty(); + @Override public String toString() { return "OwnersConfig [inherited=" @@ -44,6 +48,8 @@ + owners + ", matchers=" + matchers + + ", label=" + + label + "]"; } @@ -78,4 +84,12 @@ public Matcher addMatcher(Matcher matcher) { return this.matchers.put(matcher.path, matcher); } + + public void setLabel(Optional<LabelDefinition> label) { + this.label = label; + } + + public Optional<LabelDefinition> getLabel() { + return label; + } }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java index c4897bb..760b8a6 100644 --- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java +++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java
@@ -21,6 +21,7 @@ import com.google.gerrit.entities.Account; import com.google.gerrit.entities.Account.Id; import java.util.Map; +import java.util.Optional; import java.util.Set; public class OwnersMap { @@ -30,6 +31,7 @@ private Map<String, Set<Account.Id>> fileOwners = Maps.newHashMap(); private Map<String, Set<Account.Id>> fileReviewers = Maps.newHashMap(); private Map<String, Set<String>> fileGroupOwners = Maps.newHashMap(); + private Optional<LabelDefinition> label = Optional.empty(); @Override public String toString() { @@ -119,4 +121,12 @@ fileGroupOwners.computeIfAbsent(file, (f) -> Sets.newHashSet()).addAll(groupOwners); } + + public Optional<LabelDefinition> getLabel() { + return label; + } + + public void setLabel(Optional<LabelDefinition> label) { + this.label = label; + } }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java index 6a35797..b904715 100644 --- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java +++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java
@@ -21,6 +21,7 @@ import static com.googlesource.gerrit.owners.common.JgitWrapper.getBlobAsBytes; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Maps; import com.google.common.collect.Multimaps; import com.google.common.collect.SetMultimap; @@ -32,8 +33,9 @@ import com.google.gerrit.entities.Project; import com.google.gerrit.entities.RefNames; import com.google.gerrit.server.git.GitRepositoryManager; -import com.google.gerrit.server.patch.PatchList; -import com.google.gerrit.server.patch.PatchListEntry; +import com.google.gerrit.server.patch.DiffSummary; +import com.google.gerrit.server.patch.filediff.FileDiffOutput; +import com.google.gerrit.server.project.ProjectState; import java.io.IOException; import java.util.Collection; import java.util.Collections; @@ -74,10 +76,10 @@ private final List<Project.NameKey> parentProjectsNames; - private final PatchList patchList; - private final ConfigurationParser parser; + private final Set<String> modifiedPaths; + private final Accounts accounts; private final GitRepositoryManager repositoryManager; @@ -90,20 +92,66 @@ private final boolean expandGroups; + private final Optional<LabelDefinition> label; + public PathOwners( Accounts accounts, GitRepositoryManager repositoryManager, Repository repository, List<Project.NameKey> parentProjectsNames, Optional<String> branchWhenEnabled, - PatchList patchList, + Map<String, FileDiffOutput> fileDiffMap, + boolean expandGroups, + String project, + PathOwnersEntriesCache cache) { + this( + accounts, + repositoryManager, + repository, + parentProjectsNames, + branchWhenEnabled, + getModifiedPaths(fileDiffMap), + expandGroups, + project, + cache); + } + + public PathOwners( + Accounts accounts, + GitRepositoryManager repositoryManager, + Repository repository, + List<Project.NameKey> parentProjectsNames, + Optional<String> branchWhenEnabled, + DiffSummary diffSummary, + boolean expandGroups, + String project, + PathOwnersEntriesCache cache) { + this( + accounts, + repositoryManager, + repository, + parentProjectsNames, + branchWhenEnabled, + ImmutableSet.copyOf(diffSummary.getPaths()), + expandGroups, + project, + cache); + } + + public PathOwners( + Accounts accounts, + GitRepositoryManager repositoryManager, + Repository repository, + List<Project.NameKey> parentProjectsNames, + Optional<String> branchWhenEnabled, + Set<String> modifiedPaths, boolean expandGroups, String project, PathOwnersEntriesCache cache) { this.repositoryManager = repositoryManager; this.repository = repository; this.parentProjectsNames = parentProjectsNames; - this.patchList = patchList; + this.modifiedPaths = modifiedPaths; this.parser = new ConfigurationParser(accounts); this.accounts = accounts; this.expandGroups = expandGroups; @@ -117,8 +165,8 @@ matchers = map.getMatchers(); fileOwners = map.getFileOwners(); fileGroupOwners = map.getFileGroupOwners(); + label = map.getLabel(); } - /** * Returns a read only view of the paths to owners mapping. * @@ -153,6 +201,16 @@ return expandGroups; } + public Optional<LabelDefinition> getLabel() { + return label; + } + + public static List<Project.NameKey> getParents(ProjectState projectState) { + return projectState.parents().stream() + .map(ProjectState::getNameKey) + .collect(Collectors.toList()); + } + /** * Fetched the owners for the associated patch list. * @@ -162,13 +220,12 @@ OwnersMap ownersMap = new OwnersMap(); try { // Using a `map` would have needed a try/catch inside the lamba, resulting in more code - List<PathOwnersEntry> parentsPathOwnersEntries = + List<ReadOnlyPathOwnersEntry> parentsPathOwnersEntries = getPathOwnersEntries(parentProjectsNames, RefNames.REFS_CONFIG, cache); - PathOwnersEntry projectEntry = - getPathOwnersEntry(project, repository, RefNames.REFS_CONFIG, cache); - PathOwnersEntry rootEntry = getPathOwnersEntry(project, repository, branch, cache); + ReadOnlyPathOwnersEntry projectEntry = + getPathOwnersEntryOrEmpty(project, repository, RefNames.REFS_CONFIG, cache); + PathOwnersEntry rootEntry = getPathOwnersEntryOrNew(project, repository, branch, cache); - Set<String> modifiedPaths = getModifiedPaths(); Map<String, PathOwnersEntry> entries = new HashMap<>(); PathOwnersEntry currentEntry = null; for (String path : modifiedPaths) { @@ -209,6 +266,7 @@ ownersMap.setMatchers(newMatchers); } } + ownersMap.setLabel(Optional.ofNullable(currentEntry).flatMap(PathOwnersEntry::getLabel)); return ownersMap; } catch (IOException | ExecutionException e) { log.warn("Invalid OWNERS file", e); @@ -216,40 +274,51 @@ } } - private List<PathOwnersEntry> getPathOwnersEntries( + private List<ReadOnlyPathOwnersEntry> getPathOwnersEntries( List<Project.NameKey> projectNames, String branch, PathOwnersEntriesCache cache) throws IOException, ExecutionException { - ImmutableList.Builder<PathOwnersEntry> pathOwnersEntries = ImmutableList.builder(); + ImmutableList.Builder<ReadOnlyPathOwnersEntry> pathOwnersEntries = ImmutableList.builder(); for (Project.NameKey projectName : projectNames) { try (Repository repo = repositoryManager.openRepository(projectName)) { pathOwnersEntries = - pathOwnersEntries.add(getPathOwnersEntry(projectName.get(), repo, branch, cache)); + pathOwnersEntries.add( + getPathOwnersEntryOrEmpty(projectName.get(), repo, branch, cache)); } } return pathOwnersEntries.build(); } - private PathOwnersEntry getPathOwnersEntry( + private ReadOnlyPathOwnersEntry getPathOwnersEntryOrEmpty( String project, Repository repo, String branch, PathOwnersEntriesCache cache) - throws IOException, ExecutionException { + throws ExecutionException { + return getPathOwnersEntry(project, repo, branch, cache) + .map(v -> (ReadOnlyPathOwnersEntry) v) + .orElse(PathOwnersEntry.EMPTY); + } + + private PathOwnersEntry getPathOwnersEntryOrNew( + String project, Repository repo, String branch, PathOwnersEntriesCache cache) + throws ExecutionException { + return getPathOwnersEntry(project, repo, branch, cache).orElseGet(PathOwnersEntry::new); + } + + private Optional<PathOwnersEntry> getPathOwnersEntry( + String project, Repository repo, String branch, PathOwnersEntriesCache cache) + throws ExecutionException { String rootPath = "OWNERS"; - return cache.get( - project, - branch, - rootPath, - () -> - getOwnersConfig(repo, rootPath, branch) - .map( - conf -> - new PathOwnersEntry( - rootPath, - conf, - accounts, - Collections.emptySet(), - Collections.emptySet(), - Collections.emptySet(), - Collections.emptySet())) - .orElse(new PathOwnersEntry())); + return cache + .get(project, branch, rootPath, () -> getOwnersConfig(repo, rootPath, branch)) + .map( + conf -> + new PathOwnersEntry( + rootPath, + conf, + accounts, + Optional.empty(), + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet())); } private void processMatcherPerPath( @@ -300,12 +369,12 @@ String project, String path, String branch, - PathOwnersEntry projectEntry, - List<PathOwnersEntry> parentsPathOwnersEntries, + ReadOnlyPathOwnersEntry projectEntry, + List<ReadOnlyPathOwnersEntry> parentsPathOwnersEntries, PathOwnersEntry rootEntry, Map<String, PathOwnersEntry> entries, PathOwnersEntriesCache cache) - throws IOException, ExecutionException { + throws ExecutionException { String[] parts = path.split("/"); PathOwnersEntry currentEntry = rootEntry; StringBuilder builder = new StringBuilder(); @@ -314,7 +383,7 @@ calculateCurrentEntry(rootEntry, projectEntry, currentEntry); // Inherit from Parent Project if OWNER in Project enables inheritance - for (PathOwnersEntry parentPathOwnersEntry : parentsPathOwnersEntries) { + for (ReadOnlyPathOwnersEntry parentPathOwnersEntry : parentsPathOwnersEntries) { calculateCurrentEntry(projectEntry, parentPathOwnersEntry, currentEntry); } @@ -331,31 +400,32 @@ } else { String ownersPath = partial + "OWNERS"; PathOwnersEntry pathFallbackEntry = currentEntry; - currentEntry = - cache.get( - project, - branch, - ownersPath, - () -> - getOwnersConfig(repository, ownersPath, branch) - .map( - c -> { - final Set<Id> owners = pathFallbackEntry.getOwners(); - final Set<Id> reviewers = pathFallbackEntry.getReviewers(); - Collection<Matcher> inheritedMatchers = - pathFallbackEntry.getMatchers().values(); - Set<String> groupOwners = pathFallbackEntry.getGroupOwners(); - return new PathOwnersEntry( - ownersPath, - c, - accounts, - owners, - reviewers, - inheritedMatchers, - groupOwners); - }) - .orElse(pathFallbackEntry)); + cache + .get( + project, + branch, + ownersPath, + () -> getOwnersConfig(repository, ownersPath, branch)) + .map( + c -> { + Optional<LabelDefinition> label = pathFallbackEntry.getLabel(); + final Set<Id> owners = pathFallbackEntry.getOwners(); + final Set<Id> reviewers = pathFallbackEntry.getReviewers(); + Collection<Matcher> inheritedMatchers = + pathFallbackEntry.getMatchers().values(); + Set<String> groupOwners = pathFallbackEntry.getGroupOwners(); + return new PathOwnersEntry( + ownersPath, + c, + accounts, + label, + owners, + reviewers, + inheritedMatchers, + groupOwners); + }) + .orElse(pathFallbackEntry); entries.put(partial, currentEntry); } } @@ -363,7 +433,9 @@ } private void calculateCurrentEntry( - PathOwnersEntry rootEntry, PathOwnersEntry projectEntry, PathOwnersEntry currentEntry) { + ReadOnlyPathOwnersEntry rootEntry, + ReadOnlyPathOwnersEntry projectEntry, + PathOwnersEntry currentEntry) { if (rootEntry.isInherited()) { for (Matcher matcher : projectEntry.getMatchers().values()) { if (!currentEntry.hasMatcher(matcher.getPath())) { @@ -376,26 +448,29 @@ if (currentEntry.getOwnersPath() == null) { currentEntry.setOwnersPath(projectEntry.getOwnersPath()); } + if (currentEntry.getLabel().isEmpty()) { + currentEntry.setLabel(projectEntry.getLabel()); + } } } /** - * Parses the patch list for any paths that were modified. + * Parses the diff list for any paths that were modified. * * @return set of modified paths. */ - private Set<String> getModifiedPaths() { + private static Set<String> getModifiedPaths(Map<String, FileDiffOutput> patchList) { Set<String> paths = Sets.newHashSet(); - for (PatchListEntry patch : patchList.getPatches()) { + for (Map.Entry<String, FileDiffOutput> patch : patchList.entrySet()) { // Ignore commit message and Merge List - String newName = patch.getNewName(); + String newName = patch.getKey(); if (!COMMIT_MSG.equals(newName) && !MERGE_LIST.equals(newName)) { paths.add(newName); // If a file was moved then we need approvals for old and new // path - if (patch.getChangeType() == Patch.ChangeType.RENAMED) { - paths.add(patch.getOldName()); + if (patch.getValue().changeType() == Patch.ChangeType.RENAMED) { + paths.add(patch.getValue().oldPath().get()); } } }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCache.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCache.java index 295c670..cc482fe 100644 --- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCache.java +++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCache.java
@@ -25,8 +25,10 @@ import com.google.inject.Inject; import com.google.inject.Module; import com.google.inject.Singleton; +import com.google.inject.TypeLiteral; import java.time.Duration; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; @@ -37,7 +39,7 @@ return new CacheModule() { @Override protected void configure() { - cache(CACHE_NAME, Key.class, PathOwnersEntry.class) + cache(CACHE_NAME, Key.class, new TypeLiteral<Optional<OwnersConfig>>() {}) .maximumWeight(Long.MAX_VALUE) .expireAfterWrite(Duration.ofSeconds(60)); bind(PathOwnersEntriesCache.class).to(PathOwnersEntriesCacheImpl.class); @@ -48,7 +50,8 @@ }; } - PathOwnersEntry get(String project, String branch, String path, Callable<PathOwnersEntry> loader) + Optional<OwnersConfig> get( + String project, String branch, String path, Callable<Optional<OwnersConfig>> loader) throws ExecutionException; void invalidate(String project, String branch);
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheImpl.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheImpl.java index f819c46..3af7a58 100644 --- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheImpl.java +++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheImpl.java
@@ -20,24 +20,24 @@ import com.google.common.cache.LoadingCache; import com.google.common.collect.HashMultimap; import com.google.common.collect.Multimap; -import com.google.gerrit.entities.RefNames; import com.google.inject.Inject; import com.google.inject.Singleton; import com.google.inject.name.Named; import java.time.Duration; import java.util.Collection; +import java.util.Optional; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; @Singleton class PathOwnersEntriesCacheImpl implements PathOwnersEntriesCache { - private final Cache<Key, PathOwnersEntry> cache; + private final Cache<Key, Optional<OwnersConfig>> cache; private final Multimap<String, Key> keysIndex; private final LoadingCache<String, Object> keyLocks; @Inject - PathOwnersEntriesCacheImpl(@Named(CACHE_NAME) Cache<Key, PathOwnersEntry> cache) { + PathOwnersEntriesCacheImpl(@Named(CACHE_NAME) Cache<Key, Optional<OwnersConfig>> cache) { this.cache = cache; this.keysIndex = HashMultimap.create(); this.keyLocks = @@ -47,14 +47,14 @@ } @Override - public PathOwnersEntry get( - String project, String branch, String path, Callable<PathOwnersEntry> loader) + public Optional<OwnersConfig> get( + String project, String branch, String path, Callable<Optional<OwnersConfig>> loader) throws ExecutionException { Key key = new Key(project, branch, path); return cache.get( key, () -> { - PathOwnersEntry entry = loader.call(); + Optional<OwnersConfig> entry = loader.call(); String indexKey = indexKey(project, branch); synchronized (keyLocks.getUnchecked(indexKey)) { keysIndex.put(indexKey, key); @@ -88,6 +88,6 @@ } private String indexKey(String project, String branch) { - return new StringBuilder(project).append('@').append(RefNames.fullName(branch)).toString(); + return new StringBuilder(project).append('@').append(branch).toString(); } }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java index 168d8bd..8e4c53c 100644 --- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java +++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java
@@ -22,6 +22,7 @@ import com.google.gerrit.entities.Account; import java.util.Collection; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -30,26 +31,23 @@ * * <p>Used internally by PathOwners to represent and compute the owners at a specific path. */ -class PathOwnersEntry { - private final boolean inherited; - private Set<Account.Id> owners = Sets.newHashSet(); - private Set<Account.Id> reviewers = Sets.newHashSet(); - private String ownersPath; - private Map<String, Matcher> matchers = Maps.newHashMap(); - private Set<String> groupOwners = Sets.newHashSet(); +class PathOwnersEntry extends ReadOnlyPathOwnersEntry { + static final ReadOnlyPathOwnersEntry EMPTY = new ReadOnlyPathOwnersEntry(true) {}; public PathOwnersEntry() { - inherited = true; + super(true); } public PathOwnersEntry( String path, OwnersConfig config, Accounts accounts, + Optional<LabelDefinition> inheritedLabel, Set<Account.Id> inheritedOwners, Set<Account.Id> inheritedReviewers, Collection<Matcher> inheritedMatchers, Set<String> inheritedGroupOwners) { + super(config.isInherited()); this.ownersPath = path; this.owners = config.getOwners().stream() @@ -72,19 +70,10 @@ for (Matcher matcher : inheritedMatchers) { addMatcher(matcher); } + this.label = config.getLabel().or(() -> inheritedLabel); + } else { + this.label = config.getLabel(); } - this.inherited = config.isInherited(); - } - - @Override - public String toString() { - return "PathOwnersEntry [ownersPath=" - + ownersPath - + ", owners=" - + owners - + ", matchers=" - + matchers - + "]"; } public void addMatcher(Matcher matcher) { @@ -92,6 +81,52 @@ this.matchers.put(matcher.getPath(), matcher.merge(currMatchers)); } + public void setOwners(Set<Account.Id> owners) { + this.owners = owners; + } + + public void setReviewers(Set<Account.Id> reviewers) { + this.reviewers = reviewers; + } + + public void setOwnersPath(String ownersPath) { + this.ownersPath = ownersPath; + } + + public void setMatchers(Map<String, Matcher> matchers) { + this.matchers = matchers; + } + + public void setLabel(Optional<LabelDefinition> label) { + this.label = label; + } + + public void addMatchers(Collection<Matcher> values) { + for (Matcher matcher : values) { + addMatcher(matcher); + } + } + + @Override + protected String className() { + return getClass().getSimpleName(); + } +} + +abstract class ReadOnlyPathOwnersEntry { + protected final boolean inherited; + protected Optional<LabelDefinition> label; + protected Set<Account.Id> owners = Sets.newHashSet(); + protected Set<Account.Id> reviewers = Sets.newHashSet(); + protected String ownersPath; + protected Map<String, Matcher> matchers = Maps.newHashMap(); + protected Set<String> groupOwners = Sets.newHashSet(); + + protected ReadOnlyPathOwnersEntry(boolean inherited) { + this.inherited = inherited; + label = Optional.empty(); + } + public Map<String, Matcher> getMatchers() { return matchers; } @@ -104,38 +139,20 @@ return groupOwners; } - public void setOwners(Set<Account.Id> owners) { - this.owners = owners; - } - public Set<Account.Id> getReviewers() { return reviewers; } - public void setReviewers(Set<Account.Id> reviewers) { - this.reviewers = reviewers; - } - public String getOwnersPath() { return ownersPath; } - public void setOwnersPath(String ownersPath) { - this.ownersPath = ownersPath; - } - - public void setMatchers(Map<String, Matcher> matchers) { - this.matchers = matchers; - } - public boolean isInherited() { return inherited; } - public void addMatchers(Collection<Matcher> values) { - for (Matcher matcher : values) { - addMatcher(matcher); - } + public Optional<LabelDefinition> getLabel() { + return label; } public boolean hasMatcher(String path) { @@ -145,4 +162,22 @@ public static String stripOwnerDomain(String owner) { return Splitter.on('@').split(owner).iterator().next(); } + + @Override + public String toString() { + return className() + + " [ownersPath=" + + ownersPath + + ", owners=" + + owners + + ", matchers=" + + matchers + + ", label=" + + label + + "]"; + } + + protected String className() { + return "ReadOnlyPathOwnersEntry"; + } }
diff --git a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PluginSettings.java b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PluginSettings.java index ef60d58..26b0f62 100644 --- a/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PluginSettings.java +++ b/owners-common/src/main/java/com/googlesource/gerrit/owners/common/PluginSettings.java
@@ -34,6 +34,7 @@ private final String ownersPluginName; private final Config globalPluginConfig; private final boolean expandGroups; + private final boolean enableSubmitRequirement; @Inject public PluginSettings(PluginConfigFactory configFactory, @PluginName String ownersPluginName) { @@ -45,6 +46,8 @@ ImmutableSet.copyOf(globalPluginConfig.getStringList("owners", "disable", "branch")); this.expandGroups = globalPluginConfig.getBoolean("owners", "expandGroups", true); + this.enableSubmitRequirement = + globalPluginConfig.getBoolean("owners", "enableSubmitRequirement", false); } /** @@ -75,6 +78,11 @@ return expandGroups; } + /** @return <code>true</code> when OWNERS file should be evaluated through the submit rule */ + public boolean enableSubmitRequirement() { + return enableSubmitRequirement; + } + /** * Project-specific config of the owners plugin. *
diff --git a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/Config.java b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/Config.java index f298164..ae3fad2 100644 --- a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/Config.java +++ b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/Config.java
@@ -21,13 +21,9 @@ import com.google.common.base.Charsets; import com.google.gerrit.entities.Patch; import com.google.gerrit.server.git.GitRepositoryManager; -import com.google.gerrit.server.patch.PatchList; import com.google.gerrit.server.patch.PatchListEntry; import java.io.IOException; -import java.util.Arrays; -import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; import org.eclipse.jgit.lib.Repository; import org.junit.Ignore; import org.powermock.api.easymock.PowerMock; @@ -38,7 +34,6 @@ protected Repository repository; protected Repository parentRepository1; protected Repository parentRepository2; - protected PatchList patchList; protected ConfigurationParser parser; protected TestAccounts accounts = new TestAccounts(); protected Optional<String> branch = Optional.of("master"); @@ -81,17 +76,6 @@ .anyTimes(); } - void creatingPatch(String... fileNames) { - creatingPatchList(Arrays.asList(fileNames)); - } - - void creatingPatchList(List<String> names) { - patchList = PowerMock.createMock(PatchList.class); - List<PatchListEntry> entries = - names.stream().map(name -> expectEntry(name)).collect(Collectors.toList()); - expect(patchList.getPatches()).andReturn(entries); - } - PatchListEntry expectEntry(String name) { PatchListEntry entry = PowerMock.createMock(PatchListEntry.class); expect(entry.getNewName()).andReturn(name).anyTimes(); @@ -105,9 +89,15 @@ return parser.getOwnersConfig(string.getBytes(Charsets.UTF_8)); } - public String createConfig(boolean inherited, String[] owners, MatcherConfig... matchers) { + String createConfig(boolean inherited, String[] owners, MatcherConfig... matchers) { + return createConfig(inherited, Optional.empty(), owners, matchers); + } + + String createConfig( + boolean inherited, Optional<String> label, String[] owners, MatcherConfig... matchers) { StringBuilder sb = new StringBuilder(); sb.append("inherited: " + inherited + "\n"); + label.ifPresent(l -> sb.append("label: " + l + "\n")); if (owners.length > 0) { sb.append("owners: \n"); for (String owner : owners) {
diff --git a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/LabelDefinitionTest.java b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/LabelDefinitionTest.java new file mode 100644 index 0000000..126e239 --- /dev/null +++ b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/LabelDefinitionTest.java
@@ -0,0 +1,59 @@ +// Copyright (C) 2023 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.googlesource.gerrit.owners.common; + +import static com.google.common.truth.Truth8.assertThat; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +@RunWith(Parameterized.class) +public class LabelDefinitionTest { + @Parameterized.Parameters + public static Collection<Object[]> labels() { + return Arrays.asList( + new Object[][] { + {null, Optional.empty()}, + {"", Optional.empty()}, + {"foo,", Optional.empty()}, + {"foo", Optional.of(new LabelDefinition("foo", null))}, + {"foo,1", Optional.of(new LabelDefinition("foo", (short) 1))}, + {"foo, 1", Optional.of(new LabelDefinition("foo", (short) 1))}, + {"foo , 1", Optional.of(new LabelDefinition("foo", (short) 1))}, + {"foo ,1 ", Optional.of(new LabelDefinition("foo", (short) 1))} + }); + } + + private final String input; + private final Optional<LabelDefinition> expected; + + public LabelDefinitionTest(String input, Optional<LabelDefinition> expected) { + this.input = input; + this.expected = expected; + } + + @Test + public void shouldParseLabelDefinition() { + // when + Optional<LabelDefinition> result = LabelDefinition.parse(input); + + // then + assertThat(result).isEqualTo(expected); + } +}
diff --git a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheMock.java b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheMock.java index 1eac62c..752bf7f 100644 --- a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheMock.java +++ b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheMock.java
@@ -14,6 +14,7 @@ package com.googlesource.gerrit.owners.common; +import java.util.Optional; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import org.junit.Ignore; @@ -30,8 +31,8 @@ public void invalidateIndexKey(Key key) {} @Override - public PathOwnersEntry get( - String project, String branch, String path, Callable<PathOwnersEntry> loader) + public Optional<OwnersConfig> get( + String project, String branch, String path, Callable<Optional<OwnersConfig>> loader) throws ExecutionException { try { hit++;
diff --git a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java index 3130dad..2e23f33 100644 --- a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java +++ b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java
@@ -14,12 +14,16 @@ package com.googlesource.gerrit.owners.common; +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth8.assertThat; import static com.googlesource.gerrit.owners.common.MatcherConfig.suffixMatcher; -import static java.util.Collections.EMPTY_LIST; +import static java.util.Collections.emptyList; import static org.easymock.EasyMock.eq; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.powermock.api.easymock.PowerMock.replayAll; import com.google.gerrit.entities.Account; @@ -27,7 +31,6 @@ import com.google.gerrit.entities.RefNames; import java.io.IOException; import java.util.Arrays; -import java.util.Collections; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -47,7 +50,10 @@ private static final String CLASSIC_OWNERS = "classic/OWNERS"; private static final boolean EXPAND_GROUPS = true; private static final boolean DO_NOT_EXPAND_GROUPS = false; + private static final String EXPECTED_LABEL = "expected-label"; + private static final String A_LABEL = "a-label"; private static PathOwnersEntriesCache CACHE_MOCK = new PathOwnersEntriesCacheMock(); + public static final String CLASSIC_FILE_TXT = "classic/file.txt"; public static final Project.NameKey parentRepository1NameKey = Project.NameKey.parse("parentRepository1"); @@ -69,9 +75,9 @@ accounts, repositoryManager, repository, - Collections.EMPTY_LIST, + emptyList(), branch, - patchList, + Set.of(CLASSIC_FILE_TXT), EXPAND_GROUPS, "foo", CACHE_MOCK); @@ -91,9 +97,9 @@ accounts, repositoryManager, repository, - EMPTY_LIST, + emptyList(), branch, - patchList, + Set.of(CLASSIC_FILE_TXT), DO_NOT_EXPAND_GROUPS, "foo", CACHE_MOCK); @@ -113,9 +119,9 @@ accounts, repositoryManager, repository, - EMPTY_LIST, + emptyList(), Optional.empty(), - patchList, + Set.of(CLASSIC_FILE_TXT), EXPAND_GROUPS, "foo", CACHE_MOCK); @@ -125,10 +131,12 @@ @Test public void testClassicWithInheritance() throws Exception { - expectConfig("OWNERS", createConfig(true, owners(USER_C_EMAIL_COM))); - expectConfig(CLASSIC_OWNERS, createConfig(true, owners(USER_A_EMAIL_COM, USER_B_EMAIL_COM))); + expectConfig("OWNERS", createConfig(true, Optional.of(A_LABEL), owners(USER_C_EMAIL_COM))); + expectConfig( + CLASSIC_OWNERS, + createConfig( + true, Optional.of(EXPECTED_LABEL), owners(USER_A_EMAIL_COM, USER_B_EMAIL_COM))); - creatingPatchList(Arrays.asList("classic/file.txt")); replayAll(); PathOwners owners2 = @@ -136,9 +144,9 @@ accounts, repositoryManager, repository, - EMPTY_LIST, + emptyList(), branch, - patchList, + Set.of("classic/file.txt"), EXPAND_GROUPS, "foo", CACHE_MOCK); @@ -149,6 +157,10 @@ assertTrue(ownersSet2.contains(USER_A_ID)); assertTrue(ownersSet2.contains(USER_B_ID)); assertTrue(ownersSet2.contains(USER_C_ID)); + + // expect that classic configuration takes precedence over `OWNERS` file for the label + // definition + assertThat(owners2.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL); } @Test @@ -157,10 +169,13 @@ expectConfig( "OWNERS", RefNames.REFS_CONFIG, - createConfig(true, owners(), suffixMatcher(".sql", USER_A_EMAIL_COM, USER_B_EMAIL_COM))); + createConfig( + true, + Optional.of(EXPECTED_LABEL), + owners(), + suffixMatcher(".sql", USER_A_EMAIL_COM, USER_B_EMAIL_COM))); String fileName = "file.sql"; - creatingPatchList(Collections.singletonList(fileName)); replayAll(); PathOwners owners = @@ -168,9 +183,9 @@ accounts, repositoryManager, repository, - EMPTY_LIST, + emptyList(), branch, - patchList, + Set.of(fileName), EXPAND_GROUPS, "foo", CACHE_MOCK); @@ -182,20 +197,28 @@ assertEquals(2, ownersSet.size()); assertTrue(ownersSet.contains(USER_A_ID)); assertTrue(ownersSet.contains(USER_B_ID)); + assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL); } @Test public void testProjectInheritFromParentProject() throws Exception { - expectConfig("OWNERS", "master", createConfig(true, owners())); - expectConfig("OWNERS", RefNames.REFS_CONFIG, repository, createConfig(true, owners())); + expectConfig("OWNERS", "master", createConfig(true, Optional.of(EXPECTED_LABEL), owners())); + expectConfig( + "OWNERS", + RefNames.REFS_CONFIG, + repository, + createConfig(true, Optional.of("foo"), owners())); expectConfig( "OWNERS", RefNames.REFS_CONFIG, parentRepository1, - createConfig(true, owners(), suffixMatcher(".sql", USER_A_EMAIL_COM, USER_B_EMAIL_COM))); + createConfig( + true, + Optional.of(A_LABEL), + owners(), + suffixMatcher(".sql", USER_A_EMAIL_COM, USER_B_EMAIL_COM))); String fileName = "file.sql"; - creatingPatchList(Collections.singletonList(fileName)); mockParentRepository(parentRepository1NameKey, parentRepository1); replayAll(); @@ -207,7 +230,7 @@ repository, Arrays.asList(parentRepository1NameKey), branch, - patchList, + Set.of(fileName), EXPAND_GROUPS, "foo", CACHE_MOCK); @@ -219,6 +242,10 @@ assertEquals(2, ownersSet.size()); assertTrue(ownersSet.contains(USER_A_ID)); assertTrue(ownersSet.contains(USER_B_ID)); + + // expect that `master` configuration overwrites the label definition of both `refs/meta/config` + // and parent repo + assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL); } @Test @@ -229,16 +256,17 @@ "OWNERS", RefNames.REFS_CONFIG, parentRepository1, - createConfig(true, owners(), suffixMatcher(".sql", USER_A_EMAIL_COM))); + createConfig( + true, Optional.of(EXPECTED_LABEL), owners(), suffixMatcher(".sql", USER_A_EMAIL_COM))); expectConfig( "OWNERS", RefNames.REFS_CONFIG, parentRepository2, - createConfig(true, owners(), suffixMatcher(".java", USER_B_EMAIL_COM))); + createConfig( + true, Optional.of(A_LABEL), owners(), suffixMatcher(".java", USER_B_EMAIL_COM))); String sqlFileName = "file.sql"; String javaFileName = "file.java"; - creatingPatchList(Arrays.asList(sqlFileName, javaFileName)); mockParentRepository(parentRepository1NameKey, parentRepository1); mockParentRepository(parentRepository2NameKey, parentRepository2); @@ -251,7 +279,7 @@ repository, Arrays.asList(parentRepository1NameKey, parentRepository2NameKey), branch, - patchList, + Set.of(sqlFileName, javaFileName), EXPAND_GROUPS, "foo", CACHE_MOCK); @@ -266,6 +294,9 @@ Set<Account.Id> ownersSet2 = fileOwners.get(javaFileName); assertEquals(1, ownersSet2.size()); assertTrue(ownersSet2.contains(USER_B_ID)); + + // expect that closer parent (parentRepository1) overwrites the label definition + assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL); } private void mockParentRepository(Project.NameKey repositoryName, Repository repository) @@ -278,10 +309,11 @@ @Test public void testClassicWithInheritanceAndDeepNesting() throws Exception { expectConfig("OWNERS", createConfig(true, owners(USER_C_EMAIL_COM))); - expectConfig("dir/OWNERS", createConfig(true, owners(USER_B_EMAIL_COM))); - expectConfig("dir/subdir/OWNERS", createConfig(true, owners(USER_A_EMAIL_COM))); + expectConfig("dir/OWNERS", createConfig(true, Optional.of(A_LABEL), owners(USER_B_EMAIL_COM))); + expectConfig( + "dir/subdir/OWNERS", + createConfig(true, Optional.of(EXPECTED_LABEL), owners(USER_A_EMAIL_COM))); - creatingPatchList(Arrays.asList("dir/subdir/file.txt")); replayAll(); PathOwners owners = @@ -289,9 +321,9 @@ accounts, repositoryManager, repository, - EMPTY_LIST, + emptyList(), branch, - patchList, + Set.of("dir/subdir/file.txt"), EXPAND_GROUPS, "foo", CACHE_MOCK); @@ -301,23 +333,99 @@ assertTrue(ownersSet.contains(USER_A_ID)); assertTrue(ownersSet.contains(USER_B_ID)); assertTrue(ownersSet.contains(USER_C_ID)); + + // expect that more specific configuration overwrites the label definition + assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL); } @Test - public void testParsingYaml() { - String yamlString = ("inherited: true\nowners:\n- " + USER_C_EMAIL_COM); + public void testParsingYamlWithLabelWithScore() { + String yamlString = + "inherited: true\nlabel: " + EXPECTED_LABEL + ",1\nowners:\n- " + USER_C_EMAIL_COM; Optional<OwnersConfig> config = getOwnersConfig(yamlString); + assertTrue(config.isPresent()); - assertTrue(config.get().isInherited()); - assertEquals(1, config.get().getOwners().size()); - assertTrue(config.get().getOwners().contains(USER_C_EMAIL_COM)); + + OwnersConfig ownersConfig = config.get(); + assertTrue(ownersConfig.isInherited()); + assertThat(ownersConfig.getLabel()).isPresent(); + + LabelDefinition label = ownersConfig.getLabel().get(); + assertThat(label.getName()).isEqualTo(EXPECTED_LABEL); + assertThat(label.getScore()).hasValue(1); + + Set<String> owners = ownersConfig.getOwners(); + assertEquals(1, owners.size()); + assertTrue(owners.contains(USER_C_EMAIL_COM)); + } + + @Test + public void testParsingYamlWithLabelWithoutScore() { + String yamlString = + "inherited: true\nlabel: " + EXPECTED_LABEL + "\nowners:\n- " + USER_C_EMAIL_COM; + Optional<OwnersConfig> config = getOwnersConfig(yamlString); + + assertTrue(config.isPresent()); + + OwnersConfig ownersConfig = config.get(); + assertTrue(ownersConfig.isInherited()); + assertThat(ownersConfig.getLabel()).isPresent(); + + LabelDefinition label = ownersConfig.getLabel().get(); + assertThat(label.getName()).isEqualTo(EXPECTED_LABEL); + assertThat(label.getScore()).isEmpty(); + + Set<String> owners = ownersConfig.getOwners(); + assertEquals(1, owners.size()); + assertTrue(owners.contains(USER_C_EMAIL_COM)); + } + + @Test + public void testPathOwnersEntriesCacheIsCalled() throws Exception { + expectConfig("OWNERS", "master", createConfig(true, Optional.of(EXPECTED_LABEL), owners())); + expectConfig( + "OWNERS", + RefNames.REFS_CONFIG, + repository, + createConfig(true, Optional.of("foo"), owners())); + expectConfig("dir/OWNERS", createConfig(true, Optional.of(A_LABEL), owners(USER_B_EMAIL_COM))); + expectConfig( + "dir/subdir/OWNERS", + createConfig(true, Optional.of(EXPECTED_LABEL), owners(USER_A_EMAIL_COM))); + expectConfig( + "OWNERS", + RefNames.REFS_CONFIG, + parentRepository1, + createConfig(true, Optional.of("bar"), owners())); + + mockParentRepository(parentRepository1NameKey, parentRepository1); + replayAll(); + + PathOwnersEntriesCacheMock cacheMock = new PathOwnersEntriesCacheMock(); + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + Arrays.asList(parentRepository1NameKey), + branch, + Set.of("dir/subdir/file.txt"), + EXPAND_GROUPS, + "foo", + cacheMock); + + assertThat(owners.getFileOwners()).isNotEmpty(); + int expectedCacheCalls = + 1 /* for refs/meta/config/OWNERS */ + + 3 /* for each parent directory of 'file.txt' */ + + 1 /* for parent's refs/meta/config/OWNERS */; + assertThat(cacheMock.hit).isEqualTo(expectedCacheCalls); } private void mockOwners(String... owners) throws IOException { expectNoConfig("OWNERS"); expectConfig(CLASSIC_OWNERS, createConfig(false, owners(owners))); - creatingPatchList(Arrays.asList(CLASSIC_FILE_TXT)); replayAll(); } }
diff --git a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/RegexTest.java b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/RegexTest.java index 15e7957..ac011b0 100644 --- a/owners-common/src/test/java/com/googlesource/gerrit/owners/common/RegexTest.java +++ b/owners-common/src/test/java/com/googlesource/gerrit/owners/common/RegexTest.java
@@ -22,13 +22,12 @@ import static com.googlesource.gerrit.owners.common.MatcherConfig.regexMatcher; import static com.googlesource.gerrit.owners.common.MatcherConfig.suffixMatcher; import static com.googlesource.gerrit.owners.common.StreamUtils.iteratorStream; -import static java.util.Collections.EMPTY_LIST; +import static java.util.Collections.emptyList; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.powermock.api.easymock.PowerMock.replayAll; import com.google.gerrit.entities.Account; -import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -141,15 +140,6 @@ expectConfig("OWNERS", parentConfig); expectConfig("project/OWNERS", childConfig); - creatingPatchList( - Arrays.asList( - "project/file.txt", // matches exact in - // project owners d,e - "file1.txt", // no matches so nothing for this - "project/afile2.sql", // matches two matchers so we have b,c,d - "project/bfile.txt", // no matching - "projectalfa", // matches PartialRegex - "project/file.sql")); // only .sql matching b,c replayAll(); // function under test @@ -158,9 +148,16 @@ accounts, repositoryManager, repository, - EMPTY_LIST, + emptyList(), branch, - patchList, + Set.of( + "project/file.txt", // matches exact in + // project owners d,e + "file1.txt", // no matches so nothing for this + "project/afile2.sql", // matches two matchers so we have b,c,d + "project/bfile.txt", // no matching + "projectalfa", // matches PartialRegex + "project/file.sql"), // only .sql matching b,c EXPAND_GROUPS, "foo", new PathOwnersEntriesCacheMock()); @@ -261,7 +258,6 @@ expectConfig("OWNERS", configString); expectNoConfig("project/OWNERS"); - creatingPatch("project/file.sql", "another.txt"); replayAll(); PathOwners owners = @@ -269,9 +265,9 @@ accounts, repositoryManager, repository, - EMPTY_LIST, + emptyList(), branch, - patchList, + Set.of("project/file.sql", "another.txt"), EXPAND_GROUPS, "foo", new PathOwnersEntriesCacheMock());
diff --git a/owners/BUILD b/owners/BUILD index d04d83d..f08ec84 100644 --- a/owners/BUILD +++ b/owners/BUILD
@@ -4,6 +4,7 @@ load("//tools/bzl:junit.bzl", "junit_tests") PROLOG_PREDICATES = glob(["src/main/java/gerrit_owners/**/*.java"]) + [ + "src/main/java/com/googlesource/gerrit/owners/OwnersMetrics.java", "src/main/java/com/googlesource/gerrit/owners/OwnersStoredValues.java", ] @@ -11,8 +12,8 @@ name = "gerrit-owners-predicates", srcs = PROLOG_PREDICATES, deps = [ + "//owners-common", "@prolog-runtime//jar:neverlink", - "//owners-common:owners-common", ] + PLUGIN_DEPS_NEVERLINK, ) @@ -49,15 +50,37 @@ java_library( name = "owners__plugin_test_deps", testonly = 1, + srcs = glob( + ["src/test/java/**/*.java"], + exclude = [ + "src/test/java/**/*Test.java", + "src/test/java/**/*IT.java", + ], + ), visibility = ["//visibility:public"], exports = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [ ":owners", ], + deps = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [ + ":owners", + ], ) junit_tests( name = "owners_tests", - srcs = glob(["src/test/java/**/*.java"]), + srcs = glob(["src/test/java/**/*Test.java"]), tags = ["owners"], - deps = ["owners__plugin_test_deps"], + deps = [ + ":owners__plugin_test_deps", + ], ) + +[junit_tests( + name = f[:f.index(".")].replace("/", "_"), + srcs = [f], + tags = ["owners"], + visibility = ["//visibility:public"], + deps = [ + ":owners__plugin_test_deps", + ], +) for f in glob(["src/test/java/**/*IT.java"])]
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/OwnerPredicateProvider.java b/owners/src/main/java/com/googlesource/gerrit/owners/OwnerPredicateProvider.java index 400d8fb..d15da8e 100644 --- a/owners/src/main/java/com/googlesource/gerrit/owners/OwnerPredicateProvider.java +++ b/owners/src/main/java/com/googlesource/gerrit/owners/OwnerPredicateProvider.java
@@ -29,8 +29,11 @@ public class OwnerPredicateProvider implements PredicateProvider { @Inject public OwnerPredicateProvider( - Accounts accounts, PluginSettings config, PathOwnersEntriesCache cache) { - OwnersStoredValues.initialize(accounts, config, cache); + Accounts accounts, + PluginSettings config, + PathOwnersEntriesCache cache, + OwnersMetrics metrics) { + OwnersStoredValues.initialize(accounts, config, cache, metrics); } @Override
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/OwnersApprovalHasOperand.java b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersApprovalHasOperand.java new file mode 100644 index 0000000..ff00cfd --- /dev/null +++ b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersApprovalHasOperand.java
@@ -0,0 +1,52 @@ +// Copyright (C) 2023 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.googlesource.gerrit.owners; + +import com.google.gerrit.extensions.annotations.Exports; +import com.google.gerrit.index.query.Predicate; +import com.google.gerrit.index.query.QueryParseException; +import com.google.gerrit.server.query.change.ChangeData; +import com.google.gerrit.server.query.change.ChangeQueryBuilder; +import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeHasOperandFactory; +import com.google.inject.AbstractModule; +import com.google.inject.Inject; +import com.google.inject.Singleton; + +/** Class contributing an "approval_owners" operand to the "has" predicate. */ +@Singleton +class OwnersApprovalHasOperand implements ChangeHasOperandFactory { + static final String OPERAND = "approval"; + + static class OwnerApprovalHasOperandModule extends AbstractModule { + @Override + protected void configure() { + bind(ChangeHasOperandFactory.class) + .annotatedWith(Exports.named(OPERAND)) + .to(OwnersApprovalHasOperand.class); + } + } + + private final OwnersApprovalHasPredicate ownersApprovalHasPredicate; + + @Inject + OwnersApprovalHasOperand(OwnersApprovalHasPredicate ownersApprovalHasPredicate) { + this.ownersApprovalHasPredicate = ownersApprovalHasPredicate; + } + + @Override + public Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException { + return ownersApprovalHasPredicate; + } +}
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/OwnersApprovalHasPredicate.java b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersApprovalHasPredicate.java new file mode 100644 index 0000000..bb2d474 --- /dev/null +++ b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersApprovalHasPredicate.java
@@ -0,0 +1,57 @@ +// Copyright (C) 2023 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.googlesource.gerrit.owners; + +import com.google.gerrit.entities.SubmitRecord; +import com.google.gerrit.extensions.annotations.PluginName; +import com.google.gerrit.server.query.change.ChangeData; +import com.google.gerrit.server.query.change.SubmitRequirementPredicate; +import com.google.gerrit.server.rules.SubmitRule; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.util.Optional; + +/** + * A predicate that checks if a given change has all necessary owner approvals. Matches with changes + * that have an owner approval. This predicate wraps the existing {@link OwnersSubmitRequirement} + * (that implements the {@link SubmitRule}) to perform the logic. + */ +@Singleton +class OwnersApprovalHasPredicate extends SubmitRequirementPredicate { + + private final OwnersSubmitRequirement ownersSubmitRequirement; + + @Inject + OwnersApprovalHasPredicate( + @PluginName String pluginName, OwnersSubmitRequirement ownersSubmitRequirement) { + super("has", OwnersApprovalHasOperand.OPERAND + "_" + pluginName); + this.ownersSubmitRequirement = ownersSubmitRequirement; + } + + @Override + public boolean match(ChangeData cd) { + Optional<SubmitRecord> submitRecord = ownersSubmitRequirement.evaluate(cd); + return submitRecord.map(sr -> sr.status == SubmitRecord.Status.OK).orElse(true); + } + + /** + * Assuming that it is similarly expensive to calculate this as the 'code-owners' plugin hence + * giving the same value. + */ + @Override + public int getCost() { + return 10; + } +}
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/OwnersMetrics.java b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersMetrics.java new file mode 100644 index 0000000..5cc8e19 --- /dev/null +++ b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersMetrics.java
@@ -0,0 +1,60 @@ +// Copyright (C) 2023 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.googlesource.gerrit.owners; + +import com.google.gerrit.metrics.Counter0; +import com.google.gerrit.metrics.Description; +import com.google.gerrit.metrics.Description.Units; +import com.google.gerrit.metrics.MetricMaker; +import com.google.gerrit.metrics.Timer0; +import com.google.inject.Inject; +import com.google.inject.Singleton; + +@Singleton +class OwnersMetrics { + final Counter0 countConfigLoads; + final Timer0 loadConfig; + + final Counter0 countSubmitRuleRuns; + final Timer0 runSubmitRule; + + @Inject + OwnersMetrics(MetricMaker metricMaker) { + this.countConfigLoads = + createCounter( + metricMaker, "count_configuration_loads", "Total number of owners configuration loads"); + this.loadConfig = + createTimer( + metricMaker, + "load_configuration_latency", + "Latency for loading owners configuration for a change"); + + this.countSubmitRuleRuns = + createCounter( + metricMaker, "count_submit_rule_runs", "Total number of owners submit rule runs"); + this.runSubmitRule = + createTimer( + metricMaker, "run_submit_rule_latency", "Latency for running the owners submit rule"); + } + + private static Counter0 createCounter(MetricMaker metricMaker, String name, String description) { + return metricMaker.newCounter(name, new Description(description).setRate()); + } + + private static Timer0 createTimer(MetricMaker metricMaker, String name, String description) { + return metricMaker.newTimer( + name, new Description(description).setCumulative().setUnit(Units.MILLISECONDS)); + } +}
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/OwnersModule.java b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersModule.java index c743bef..7d2c4d5 100644 --- a/owners/src/main/java/com/googlesource/gerrit/owners/OwnersModule.java +++ b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersModule.java
@@ -15,12 +15,24 @@ package com.googlesource.gerrit.owners; +import com.google.common.flogger.FluentLogger; import com.google.gerrit.extensions.registration.DynamicSet; import com.google.gerrit.server.rules.PredicateProvider; import com.google.inject.AbstractModule; +import com.google.inject.Inject; import com.googlesource.gerrit.owners.common.PathOwnersEntriesCache; +import com.googlesource.gerrit.owners.common.PluginSettings; public class OwnersModule extends AbstractModule { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final PluginSettings pluginSettings; + + @Inject + OwnersModule(PluginSettings pluginSettings) { + this.pluginSettings = pluginSettings; + } + @Override protected void configure() { install(PathOwnersEntriesCache.module()); @@ -28,5 +40,13 @@ .to(OwnerPredicateProvider.class) .asEagerSingleton(); install(new OwnersRestApiModule()); + + if (pluginSettings.enableSubmitRequirement()) { + install(new OwnersSubmitRequirement.OwnersSubmitRequirementModule()); + install(new OwnersApprovalHasOperand.OwnerApprovalHasOperandModule()); + } else { + logger.atInfo().log( + "OwnersSubmitRequirement is disabled therefore it will not be evaluated."); + } } }
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/OwnersStoredValues.java b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersStoredValues.java index 17f818d..c4d27e4 100644 --- a/owners/src/main/java/com/googlesource/gerrit/owners/OwnersStoredValues.java +++ b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersStoredValues.java
@@ -17,8 +17,9 @@ package com.googlesource.gerrit.owners; import com.google.gerrit.entities.Project; +import com.google.gerrit.metrics.Timer0; import com.google.gerrit.server.git.GitRepositoryManager; -import com.google.gerrit.server.patch.PatchList; +import com.google.gerrit.server.patch.filediff.FileDiffOutput; import com.google.gerrit.server.project.ProjectState; import com.google.gerrit.server.rules.StoredValue; import com.google.gerrit.server.rules.StoredValues; @@ -28,8 +29,8 @@ import com.googlesource.gerrit.owners.common.PathOwnersEntriesCache; import com.googlesource.gerrit.owners.common.PluginSettings; import java.util.List; +import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; import org.eclipse.jgit.lib.Repository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,36 +42,38 @@ public static StoredValue<PathOwners> PATH_OWNERS; public static synchronized void initialize( - Accounts accounts, PluginSettings settings, PathOwnersEntriesCache cache) { + Accounts accounts, + PluginSettings settings, + PathOwnersEntriesCache cache, + OwnersMetrics metrics) { if (PATH_OWNERS != null) { return; } log.info("Initializing OwnerStoredValues"); PATH_OWNERS = - new StoredValue<PathOwners>() { + new StoredValue<>() { @Override protected PathOwners createValue(Prolog engine) { - PatchList patchList = StoredValues.PATCH_LIST.get(engine); + Map<String, FileDiffOutput> patchList = StoredValues.DIFF_LIST.get(engine); Repository repository = StoredValues.REPOSITORY.get(engine); ProjectState projectState = StoredValues.PROJECT_STATE.get(engine); GitRepositoryManager gitRepositoryManager = StoredValues.REPO_MANAGER.get(engine); - List<Project.NameKey> parentProjectsNameKeys = - projectState.parents().stream() - .map(ProjectState::getNameKey) - .collect(Collectors.toList()); - - String branch = StoredValues.getChange(engine).getDest().branch(); - return new PathOwners( - accounts, - gitRepositoryManager, - repository, - parentProjectsNameKeys, - settings.isBranchDisabled(branch) ? Optional.empty() : Optional.of(branch), - patchList, - settings.expandGroups(), - projectState.getName(), - cache); + metrics.countConfigLoads.increment(); + try (Timer0.Context ctx = metrics.loadConfig.start()) { + List<Project.NameKey> parentProjectsNameKeys = PathOwners.getParents(projectState); + String branch = StoredValues.getChange(engine).getDest().branch(); + return new PathOwners( + accounts, + gitRepositoryManager, + repository, + parentProjectsNameKeys, + settings.isBranchDisabled(branch) ? Optional.empty() : Optional.of(branch), + patchList, + settings.expandGroups(), + projectState.getName(), + cache); + } } }; }
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/OwnersSubmitRequirement.java b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersSubmitRequirement.java new file mode 100644 index 0000000..0a2c5dd --- /dev/null +++ b/owners/src/main/java/com/googlesource/gerrit/owners/OwnersSubmitRequirement.java
@@ -0,0 +1,366 @@ +// 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.googlesource.gerrit.owners; + +import static com.google.gerrit.server.project.ProjectCache.illegalState; +import static java.util.Objects.requireNonNull; +import static java.util.stream.Collectors.toSet; + +import com.google.common.base.Joiner; +import com.google.common.collect.Streams; +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.Account; +import com.google.gerrit.entities.Change; +import com.google.gerrit.entities.LabelFunction; +import com.google.gerrit.entities.LabelType; +import com.google.gerrit.entities.LabelTypes; +import com.google.gerrit.entities.LegacySubmitRequirement; +import com.google.gerrit.entities.PatchSetApproval; +import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.SubmitRecord; +import com.google.gerrit.extensions.annotations.Exports; +import com.google.gerrit.metrics.Timer0; +import com.google.gerrit.server.approval.ApprovalsUtil; +import com.google.gerrit.server.git.GitRepositoryManager; +import com.google.gerrit.server.notedb.ChangeNotes; +import com.google.gerrit.server.patch.DiffNotAvailableException; +import com.google.gerrit.server.patch.DiffOperations; +import com.google.gerrit.server.patch.filediff.FileDiffOutput; +import com.google.gerrit.server.project.ProjectCache; +import com.google.gerrit.server.project.ProjectState; +import com.google.gerrit.server.query.change.ChangeData; +import com.google.gerrit.server.rules.SubmitRule; +import com.google.inject.AbstractModule; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.googlesource.gerrit.owners.common.Accounts; +import com.googlesource.gerrit.owners.common.LabelDefinition; +import com.googlesource.gerrit.owners.common.PathOwners; +import com.googlesource.gerrit.owners.common.PathOwnersEntriesCache; +import com.googlesource.gerrit.owners.common.PluginSettings; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; + +@Singleton +public class OwnersSubmitRequirement implements SubmitRule { + public static class OwnersSubmitRequirementModule extends AbstractModule { + @Override + public void configure() { + bind(SubmitRule.class) + .annotatedWith(Exports.named("OwnersSubmitRequirement")) + .to(OwnersSubmitRequirement.class); + } + } + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private static final LegacySubmitRequirement SUBMIT_REQUIREMENT = + LegacySubmitRequirement.builder().setFallbackText("Owners").setType("owners").build(); + + private final OwnersMetrics metrics; + private final PluginSettings pluginSettings; + private final ProjectCache projectCache; + private final Accounts accounts; + private final GitRepositoryManager repoManager; + private final DiffOperations diffOperations; + private final ApprovalsUtil approvalsUtil; + private final PathOwnersEntriesCache cache; + + @Inject + OwnersSubmitRequirement( + OwnersMetrics metrics, + PluginSettings pluginSettings, + ProjectCache projectCache, + Accounts accounts, + GitRepositoryManager repoManager, + DiffOperations diffOperations, + ApprovalsUtil approvalsUtil, + PathOwnersEntriesCache cache) { + this.metrics = metrics; + this.pluginSettings = pluginSettings; + this.projectCache = projectCache; + this.accounts = accounts; + this.repoManager = repoManager; + this.diffOperations = diffOperations; + this.approvalsUtil = approvalsUtil; + this.cache = cache; + } + + @Override + public Optional<SubmitRecord> evaluate(ChangeData cd) { + requireNonNull(cd, "changeData"); + + Change change = cd.change(); + Project.NameKey project = cd.project(); + int changeId = cd.getId().get(); + if (change.isClosed()) { + logger.atFine().log( + "Project '%s': change #%d is closed therefore OWNERS submit requirements are skipped.", + project, changeId); + return Optional.empty(); + } + + metrics.countSubmitRuleRuns.increment(); + try (Timer0.Context ctx = metrics.runSubmitRule.start()) { + ProjectState projectState = getProjectState(project); + PathOwners pathOwners = getPathOwners(cd, projectState); + Map<String, Set<Account.Id>> fileOwners = pathOwners.getFileOwners(); + if (fileOwners.isEmpty()) { + logger.atFinest().log( + "Project '%s': change #%d has no OWNERS submit requirements defined. " + + "Skipping submit requirements.", + project, changeId); + return Optional.empty(); + } + + ChangeNotes notes = cd.notes(); + requireNonNull(notes, "notes"); + LabelTypes labelTypes = projectState.getLabelTypes(notes); + LabelDefinition label = resolveLabel(labelTypes, pathOwners.getLabel()); + Optional<LabelAndScore> ownersLabel = ownersLabel(labelTypes, label, project); + + Map<Account.Id, List<PatchSetApproval>> approvalsByAccount = + Streams.stream(approvalsUtil.byPatchSet(notes, cd.currentPatchSet().id())) + .collect(Collectors.groupingBy(PatchSetApproval::accountId)); + + Account.Id uploader = notes.getCurrentPatchSet().uploader(); + + Set<String> missingApprovals = + fileOwners.entrySet().stream() + .filter( + requiredApproval -> + ownersLabel + .map( + ol -> + isApprovalMissing( + requiredApproval, uploader, approvalsByAccount, ol)) + .orElse(true)) + .map(Map.Entry::getKey) + .collect(toSet()); + + return Optional.of( + missingApprovals.isEmpty() + ? ok() + : notReady( + label.getName(), + String.format( + "Missing approvals for path(s): [%s]", + Joiner.on(", ").join(missingApprovals)))); + } catch (IOException e) { + String msg = + String.format( + "Project '%s': repository cannot be opened to evaluate OWNERS submit requirements.", + project); + logger.atSevere().withCause(e).log("%s", msg); + throw new IllegalStateException(msg, e); + } catch (DiffNotAvailableException e) { + String msg = + String.format( + "Project '%s' change #%d: unable to get diff to evaluate OWNERS submit requirements.", + project, changeId); + logger.atSevere().withCause(e).log("%s", msg); + throw new IllegalStateException(msg, e); + } + } + + private ProjectState getProjectState(Project.NameKey project) { + ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project)); + if (projectState.hasPrologRules()) { + logger.atInfo().atMostEvery(1, TimeUnit.DAYS).log( + "Project '%s' has prolog rules enabled. " + + "It may interfere with the OWNERS submit requirements evaluation.", + project); + } + return projectState; + } + + private PathOwners getPathOwners(ChangeData cd, ProjectState projectState) + throws IOException, DiffNotAvailableException { + metrics.countConfigLoads.increment(); + try (Timer0.Context ctx = metrics.loadConfig.start()) { + String branch = cd.change().getDest().branch(); + + List<Project.NameKey> parents = PathOwners.getParents(projectState); + Project.NameKey nameKey = projectState.getNameKey(); + try (Repository repo = repoManager.openRepository(nameKey)) { + PathOwners pathOwners = + new PathOwners( + accounts, + repoManager, + repo, + parents, + pluginSettings.isBranchDisabled(branch) ? Optional.empty() : Optional.of(branch), + getDiff(nameKey, cd.currentPatchSet().commitId()), + pluginSettings.expandGroups(), + nameKey.get(), + cache); + + return pathOwners; + } + } + } + + /** + * The idea is to select the label type that is configured for owner to cast the vote. If nothing + * is configured in the OWNERS file then `Code-Review` will be selected. + * + * @param labelTypes labels configured for project + * @param label and score definition that is configured in the OWNERS file + */ + static LabelDefinition resolveLabel(LabelTypes labelTypes, Optional<LabelDefinition> label) { + return label.orElse(LabelDefinition.CODE_REVIEW); + } + + /** + * Create {@link LabelAndScore} definition with a label LabelType if label can be found or empty + * otherwise. Note that score definition is copied from the OWNERS. + * + * @param labelTypes labels configured for project + * @param label and score definition (optional) that is resolved from the OWNERS file + * @param project that change is evaluated for + */ + static Optional<LabelAndScore> ownersLabel( + LabelTypes labelTypes, LabelDefinition label, Project.NameKey project) { + return labelTypes + .byLabel(label.getName()) + .map(type -> new LabelAndScore(type, label.getScore())) + .or( + () -> { + logger.atSevere().log( + "OWNERS label '%s' is not configured for '%s' project. Change is not submittable.", + label, project); + return Optional.empty(); + }); + } + + static boolean isApprovalMissing( + Map.Entry<String, Set<Account.Id>> requiredApproval, + Account.Id uploader, + Map<Account.Id, List<PatchSetApproval>> approvalsByAccount, + LabelAndScore ownersLabel) { + return requiredApproval.getValue().stream() + .noneMatch( + fileOwner -> isApprovedByOwner(fileOwner, uploader, approvalsByAccount, ownersLabel)); + } + + static boolean isApprovedByOwner( + Account.Id fileOwner, + Account.Id uploader, + Map<Account.Id, List<PatchSetApproval>> approvalsByAccount, + LabelAndScore ownersLabel) { + return Optional.ofNullable(approvalsByAccount.get(fileOwner)) + .map( + approvals -> + approvals.stream() + .anyMatch( + approval -> + hasSufficientApproval(approval, ownersLabel, fileOwner, uploader))) + .orElse(false); + } + + static boolean hasSufficientApproval( + PatchSetApproval approval, + LabelAndScore ownersLabel, + Account.Id fileOwner, + Account.Id uploader) { + return ownersLabel.getLabelType().getLabelId().equals(approval.labelId()) + && isLabelApproved( + ownersLabel.getLabelType(), ownersLabel.getScore(), fileOwner, uploader, approval); + } + + static boolean isLabelApproved( + LabelType label, + Optional<Short> score, + Account.Id fileOwner, + Account.Id uploader, + PatchSetApproval approval) { + if (label.isIgnoreSelfApproval() && fileOwner.equals(uploader)) { + return false; + } + + return score + .map(value -> approval.value() >= value) + .orElseGet( + () -> { + LabelFunction function = label.getFunction(); + if (function.isMaxValueRequired()) { + return label.isMaxPositive(approval); + } + + if (function.isBlock() && label.isMaxNegative(approval)) { + return false; + } + + return approval.value() > label.getDefaultValue(); + }); + } + + static class LabelAndScore { + private final LabelType labelType; + private final Optional<Short> score; + + LabelAndScore(LabelType labelType, Optional<Short> score) { + this.labelType = labelType; + this.score = score; + } + + LabelType getLabelType() { + return labelType; + } + + Optional<Short> getScore() { + return score; + } + } + + private Map<String, FileDiffOutput> getDiff(Project.NameKey project, ObjectId revision) + throws DiffNotAvailableException { + requireNonNull(project, "project"); + requireNonNull(revision, "revision"); + + // Use parentNum=0 to do the comparison against the default base. + // For non-merge commits the default base is the only parent (aka parent 1, initial commits + // are not supported). + // For merge commits the default base is the auto-merge commit which should be used as base IOW + // only the changes from it should be reviewed as changes against the parent 1 were already + // reviewed + return diffOperations.listModifiedFilesAgainstParent(project, revision, 0); + } + + private static SubmitRecord notReady(String ownersLabel, String missingApprovals) { + SubmitRecord submitRecord = new SubmitRecord(); + submitRecord.status = SubmitRecord.Status.NOT_READY; + submitRecord.errorMessage = missingApprovals; + submitRecord.requirements = List.of(SUBMIT_REQUIREMENT); + SubmitRecord.Label label = new SubmitRecord.Label(); + label.label = String.format("%s from owners", ownersLabel); + label.status = SubmitRecord.Label.Status.NEED; + submitRecord.labels = List.of(label); + return submitRecord; + } + + private static SubmitRecord ok() { + SubmitRecord submitRecord = new SubmitRecord(); + submitRecord.status = SubmitRecord.Status.OK; + submitRecord.requirements = List.of(SUBMIT_REQUIREMENT); + return submitRecord; + } +}
diff --git a/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java b/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java index afae467..5f5b1fb 100644 --- a/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java +++ b/owners/src/main/java/com/googlesource/gerrit/owners/restapi/GetFilesOwners.java
@@ -16,10 +16,10 @@ package com.googlesource.gerrit.owners.restapi; import com.google.common.collect.Maps; +import com.google.common.flogger.FluentLogger; import com.google.gerrit.entities.Account; import com.google.gerrit.entities.Change; import com.google.gerrit.entities.LabelId; -import com.google.gerrit.entities.PatchSet; import com.google.gerrit.entities.Project; import com.google.gerrit.extensions.api.GerritApi; import com.google.gerrit.extensions.client.ListChangesOption; @@ -27,16 +27,15 @@ import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.extensions.restapi.BadRequestException; import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.ResourceNotFoundException; import com.google.gerrit.extensions.restapi.Response; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.extensions.restapi.RestReadView; import com.google.gerrit.server.account.AccountCache; import com.google.gerrit.server.change.RevisionResource; import com.google.gerrit.server.git.GitRepositoryManager; -import com.google.gerrit.server.patch.PatchList; -import com.google.gerrit.server.patch.PatchListCache; import com.google.gerrit.server.project.ProjectCache; -import com.google.gerrit.server.project.ProjectState; +import com.google.gerrit.server.query.change.ChangeData; import com.google.inject.Inject; import com.google.inject.Singleton; import com.googlesource.gerrit.owners.common.Accounts; @@ -46,8 +45,10 @@ import com.googlesource.gerrit.owners.entities.FilesOwnersResponse; import com.googlesource.gerrit.owners.entities.GroupOwner; import com.googlesource.gerrit.owners.entities.Owner; +import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -55,14 +56,9 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import org.eclipse.jgit.lib.Repository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; @Singleton public class GetFilesOwners implements RestReadView<RevisionResource> { - private static final Logger log = LoggerFactory.getLogger(GetFilesOwners.class); - - private final PatchListCache patchListCache; private final Accounts accounts; private final AccountCache accountCache; private final ProjectCache projectCache; @@ -71,9 +67,12 @@ private final GerritApi gerritApi; private final PathOwnersEntriesCache cache; + static final String MISSING_CODE_REVIEW_LABEL = + "Cannot calculate file owners state when review label is not configured"; + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + @Inject GetFilesOwners( - PatchListCache patchListCache, Accounts accounts, AccountCache accountCache, ProjectCache projectCache, @@ -81,7 +80,6 @@ PluginSettings pluginSettings, GerritApi gerritApi, PathOwnersEntriesCache cache) { - this.patchListCache = patchListCache; this.accounts = accounts; this.accountCache = accountCache; this.projectCache = projectCache; @@ -94,29 +92,15 @@ @Override public Response<FilesOwnersResponse> apply(RevisionResource revision) throws AuthException, BadRequestException, ResourceConflictException, Exception { - PatchSet ps = revision.getPatchSet(); Change change = revision.getChange(); - short codeReviewMaxValue = - revision - .getChangeResource() - .getChangeData() - .getLabelTypes() - .byLabel(LabelId.CODE_REVIEW) - .getMaxPositive(); - int id = revision.getChangeResource().getChange().getChangeId(); + ChangeData changeData = revision.getChangeResource().getChangeData(); Project.NameKey project = change.getProject(); List<Project.NameKey> projectParents = - projectCache - .get(project) - .map(Stream::of) - .orElse(Stream.empty()) - .flatMap(s -> s.parents().stream()) - .map(ProjectState::getNameKey) - .collect(Collectors.toList()); + projectCache.get(project).map(PathOwners::getParents).orElse(Collections.emptyList()); try (Repository repository = repositoryManager.openRepository(project)) { - PatchList patchList = patchListCache.get(change, ps); + Set<String> changePaths = new HashSet<>(changeData.currentFilePaths()); String branch = change.getDest().branch(); PathOwners owners = @@ -126,7 +110,7 @@ repository, projectParents, pluginSettings.isBranchDisabled(branch) ? Optional.empty() : Optional.of(branch), - patchList, + changePaths, pluginSettings.expandGroups(), project.get(), cache); @@ -137,7 +121,7 @@ ids -> ids.stream() .map(this::getOwnerFromAccountId) - .flatMap(owner -> owner.map(Stream::of).orElse(Stream.empty())) + .flatMap(Optional::stream) .collect(Collectors.toSet())); Map<String, Set<GroupOwner>> fileToOwners = @@ -148,36 +132,75 @@ groupNames -> groupNames.stream().map(GroupOwner::new).collect(Collectors.toSet())); - Map<Integer, Map<String, Integer>> ownersLabels = getLabels(id); + Map<Integer, Map<String, Integer>> ownersLabels = getLabels(change.getChangeId()); + + LabelAndScore label = getLabelDefinition(owners, changeData); Map<String, Set<GroupOwner>> filesWithPendingOwners = Maps.filterEntries( fileToOwners, (fileOwnerEntry) -> !isApprovedByOwner( - fileExpandedOwners.get(fileOwnerEntry.getKey()), - ownersLabels, - codeReviewMaxValue)); + fileExpandedOwners.get(fileOwnerEntry.getKey()), ownersLabels, label)); return Response.ok(new FilesOwnersResponse(ownersLabels, filesWithPendingOwners)); } } + private LabelAndScore getLabelDefinition(PathOwners owners, ChangeData changeData) + throws ResourceNotFoundException { + + try { + return Optional.of(pluginSettings.enableSubmitRequirement()) + .filter(Boolean::booleanValue) + .flatMap(enabled -> getLabelFromOwners(owners, changeData)) + .orElseGet( + () -> + new LabelAndScore( + LabelId.CODE_REVIEW, getMaxScoreForLabel(changeData, LabelId.CODE_REVIEW))); + } catch (LabelNotFoundException e) { + logger.atInfo().withCause(e).log("Invalid configuration"); + throw new ResourceNotFoundException(MISSING_CODE_REVIEW_LABEL, e); + } + } + + private Optional<LabelAndScore> getLabelFromOwners(PathOwners owners, ChangeData changeData) + throws LabelNotFoundException { + return owners + .getLabel() + .map( + label -> + new LabelAndScore( + label.getName(), + label + .getScore() + .orElseGet(() -> getMaxScoreForLabel(changeData, label.getName())))); + } + + private short getMaxScoreForLabel(ChangeData changeData, String labelId) + throws LabelNotFoundException { + return changeData + .getLabelTypes() + .byLabel(labelId) + .map(label -> label.getMaxPositive()) + .orElseThrow(() -> new LabelNotFoundException(changeData.change().getProject(), labelId)); + } + private boolean isApprovedByOwner( Set<GroupOwner> fileOwners, Map<Integer, Map<String, Integer>> ownersLabels, - short codeReviewMaxValue) { + LabelAndScore label) { return fileOwners.stream() .filter(owner -> owner instanceof Owner) .map(owner -> ((Owner) owner).getId()) - .map(ownerId -> codeReviewLabelValue(ownersLabels, ownerId)) - .anyMatch(value -> value.filter(v -> v == codeReviewMaxValue).isPresent()); + .flatMap(ownerId -> codeReviewLabelValue(ownersLabels, ownerId, label.getLabelId())) + .anyMatch(value -> value >= label.getScore()); } - private Optional<Integer> codeReviewLabelValue( - Map<Integer, Map<String, Integer>> ownersLabels, int ownerId) { - return Optional.ofNullable(ownersLabels.get(ownerId)) - .flatMap(m -> Optional.ofNullable(m.get(LabelId.CODE_REVIEW))); + private Stream<Integer> codeReviewLabelValue( + Map<Integer, Map<String, Integer>> ownersLabels, int ownerId, String labelId) { + return Stream.ofNullable(ownersLabels.get(ownerId)) + .flatMap(m -> Stream.ofNullable(m.get(labelId))); } /** @@ -207,7 +230,7 @@ changeInfo.labels.forEach( (label, labelInfo) -> { Optional.ofNullable(labelInfo.all) - .map( + .ifPresent( approvalInfos -> { approvalInfos.forEach( approvalInfo -> { @@ -217,7 +240,6 @@ currentOwnerLabels.put(label, approvalInfo.value); ownerToLabels.put(currentOwnerId, currentOwnerLabels); }); - return ownerToLabels; }); }); @@ -229,4 +251,30 @@ .get(accountId) .map(as -> new Owner(as.account().fullName(), as.account().id().get())); } + + static class LabelNotFoundException extends RuntimeException { + private static final long serialVersionUID = 1L; + + LabelNotFoundException(Project.NameKey project, String labelId) { + super(String.format("Project %s has no %s label defined", project, labelId)); + } + } + + private static class LabelAndScore { + private final String labelId; + private final short score; + + private LabelAndScore(String labelId, short score) { + this.labelId = labelId; + this.score = score; + } + + private String getLabelId() { + return labelId; + } + + private short getScore() { + return score; + } + } }
diff --git a/owners/src/main/java/gerrit_owners/PRED_code_review_user_1.java b/owners/src/main/java/gerrit_owners/PRED_code_review_user_1.java index 1ac2da8..a2dd06b 100644 --- a/owners/src/main/java/gerrit_owners/PRED_code_review_user_1.java +++ b/owners/src/main/java/gerrit_owners/PRED_code_review_user_1.java
@@ -54,7 +54,7 @@ ChangeData cd = StoredValues.CHANGE_DATA.get(engine); Optional<LabelValue> codeReviewMaxValue = - Optional.ofNullable(cd.getLabelTypes().byLabel(LabelId.CODE_REVIEW)).map(LabelType::getMax); + cd.getLabelTypes().byLabel(LabelId.CODE_REVIEW).map(LabelType::getMax); Iterator<Account.Id> approvalsAccounts = cd.currentApprovals().stream()
diff --git a/owners/src/main/resources/Documentation/config.md b/owners/src/main/resources/Documentation/config.md index 92b7d5e..5aaaeab 100644 --- a/owners/src/main/resources/Documentation/config.md +++ b/owners/src/main/resources/Documentation/config.md
@@ -25,6 +25,37 @@ expandGroups = false ``` +owners.enableSubmitRequirement +: If set to `true` the approvals are evaluated through the owners submit rule without a need of + prolog predicate being added to a project. Defaults to `false`. + +Example: + +``` +[owners] + enableSubmitRequirement = true +``` + +cache."owners.path_owners_entries".memoryLimit +: The cache is used to hold the parsed version of `OWNERS` files in the + repository so that when submit rules are calculated (either through prolog + or through submit requirements) it is not read over and over again. The + cache entry gets invalidated when `OWNERS` file branch is updated. + By default it follows default Gerrit's cache memory limit but it makes + sense to adjust it as a function of number of project that use the `owners` + plugin multiplied by average number of active branches (plus 1 for the + refs/meta/config) and average number of directories (as directory hierarchy + back to root is checked for the `OWNERS` file existence). + _Note that in opposite to the previous settings the modification needs to be + performed in the `$GERRIT_SITE/etc/gerrit.config` file._ + +Example + +``` +[cache "owners.path_owners_entries"] + memoryLimit = 2048 +``` + ## Configuration Owner approval is determined based on OWNERS files located in the same @@ -34,6 +65,7 @@ ```yaml inherited: true +label: Code-Review, 1 owners: - some.email@example.com - User Name @@ -78,6 +110,14 @@ building an OWNERS hierarchy. It stops once it finds an OWNERS file that has “inherited” set to false (by default it’s true.) +> **NOTE:** The `label` value (default is `Code-Review`) is taken into +> consideration only when `owners.enableSubmitRequirement = true`. +> Owners scores are matched against the label specified in the property in +> question. +> The required label's score can be provided (by default label's scores +> configuration is used) so that owners don't have to be granted with the +> maximum label's score. Note that only single digit (0..9) is allowed. + For example, imagine the following tree: ``` @@ -130,6 +170,20 @@ - Doug Smith ``` +### When `owners.enableSubmitRequirement = true` + +Then Gerrit would: + +Evaluate default submit requirement which gives `OK` if no `Code-Review -2` is +given and at least one `Code-Review +2` is being provided. + +Evaluate owners submit requirement to check if `Code-Review +2` is given by +either 'John Doe' or 'Doug Smith'. If none of them has approved then +`Code-Review from owners` requirement is added making the change not +submittable. + +### When `owners.enableSubmitRequirement = false` (default) + And sample rules.pl that uses this predicate to enable the submit rule if one of the owners has given a Code Review +2 @@ -164,6 +218,45 @@ - Doug Smith ``` +### When `owners.enableSubmitRequirement = true` + +This case is supported with the `Code-Review` label and `OWNERS` file +modifications. + +The `OWNERS` file requires the label configuration to be added (here is the +updated version): + +```yaml +inherited: true +label: Code-Review, 1 +owners: +- John Doe +- Doug Smith +``` + +But additionally one needs to modify the label on the particular project level +to the following version: + +``` +[label "Code-Review"] + function = NoOp + defaultValue = 0 + copyMinScore = true + copyAllScoresOnTrivialRebase = true + value = -2 This shall not be merged + value = -1 I would prefer this is not merged as is + value = 0 No score + value = +1 Looks good to me, but someone else must approve + value = +2 Looks good to me, approved +``` + +Note that `function` is set to `NoOp`. + +As a result Gerrit would make the change Submittable only if 'John Doe' or +'Doug Smith' have provided at least a `Code-Review +1`. + +### When `owners.enableSubmitRequirement = false` (default) + And a rule which makes submittable a change if at least one of the owners has given a +1 without taking into consideration any other label: @@ -188,11 +281,10 @@ In this case, we need to grant specific people with the _Owner-Approved_ label without necessarily having to give Code-Review +2 rights on the entire project. -Amend the project.config as shown in (1) and add a new label; then give -permissions to any registered user. Finally, define a small variant of the -Prolog rules as shown in (2). +Amend the project.config as shown in below and add a new label; then give +permissions to any registered user. -(1) Example fo the project config changes with the new label with values +Example fo the project config changes with the new label with values (label name and values are arbitrary) ``` @@ -208,7 +300,25 @@ label-Owner-Approved = -1..+1 group Registered Users ``` -(2) Define the project's rules.pl with an amended version of Example 1: +### When `owners.enableSubmitRequirement = true` + +Given now an OWNERS configuration of: + +```yaml +inherited: true +label: Owner-Approved +owners: +- John Doe +- Doug Smith +``` + +A change cannot be submitted until 'John Doe' or 'Doug Smith' add a label +`Owner-Approved`, independently from being able to provide any Code-Review. + +### When `owners.enableSubmitRequirement = false` (default) + +Finally, define prolog rules as shown in below (an amended version of +Example 1): ```prolog submit_rule(S) :- @@ -231,7 +341,30 @@ A change cannot be submitted until John Doe or Doug Smith add a label "Owner-Approved", independently from being able to provide any Code-Review. -## Example 4 - Owners based on matchers +## Example 4 - OWNERS file without matchers, default Gerrit submit rules and owners Code-Review +1 required + +This is a variant of `example 3` when no additional label is created but owners +shouldn't be granted with `Code-Review +2` for all project files as it might be +outside of their comptenence/comfort zone. + +### When `owners.enableSubmitRequirement = true` + +Given an OWNERS configuration of: + +```yaml +inherited: true +label: Code-Review, 1 +owners: +- John Doe +- Doug Smith +``` + +A change cannot be submitted until 'John Doe' or 'Doug Smith' add +`Code-Review+1` score. Note that regular developers still need to cast the +`Code-Review+2` for a change to be submittable as default submit rule is +still evaluated. + +## Example 5 - Owners based on matchers Often the ownership comes from the developer's skills and competencies and cannot be purely defined by the project's directory structure. @@ -252,6 +385,18 @@ - Matt Designer ``` +### When `owners.enableSubmitRequirement = true` + +Then for any change that contains files with .sql or .css extensions, besides +to the default Gerrit submit rules, the extra constraints on the additional +owners of the modified files will be added. The final submit is enabled if both +Gerrit default rules are satisfied and all the owners of the .sql files +('Mister Dba') and the .css files (either 'John Creative' or 'Matt Designer') +have provided their `Code-Review +2` feedback (as `Code-Review` is default +label for owners submit requirement). + +### When `owners.enableSubmitRequirement = false` (default) + And a rules.pl of: ```prolog @@ -269,7 +414,14 @@ (Mister Dba) and the .css files (either John Creative or Matt Designer) have provided their Code-Review +2 feedback. -## Example 5 - Owners details on a per-file basis +## Example 6 - Owners details on a per-file basis + +### When `owners.enableSubmitRequirement = true` + +This case is obsolete and _only_ prolog specific. The list of which file is +owned by whom is available through the [REST API](rest-api.md). + +### When `owners.enableSubmitRequirement = false` (default) When using the owners with a series of matchers associated to different set of owners, it may not be trivial to understand exactly *why* change is not approved
diff --git a/owners/src/main/resources/Documentation/metrcis.md b/owners/src/main/resources/Documentation/metrcis.md new file mode 100644 index 0000000..8f3c9f6 --- /dev/null +++ b/owners/src/main/resources/Documentation/metrcis.md
@@ -0,0 +1,19 @@ +Metrics +============= + +The following metrics are always emitted: + +* plugins/owners/count_configuration_loads + : the total number of owners configuration loads. + +* plugins/owners/load_configuration_latency + : the latency for loading owners configuration for a change. + +When submit requirements are enabled (`owners.enableSubmitRequirement = true`) +these are additionally emitted: + +* plugins/owners/count_submit_rule_runs + : the total number of owners submit rule runs. + +* plugins/owners/run_submit_rule_latency + : the latency for running the owners submit rule.
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/OwnersApprovalHasOperandIT.java b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersApprovalHasOperandIT.java new file mode 100644 index 0000000..9fdb4b9 --- /dev/null +++ b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersApprovalHasOperandIT.java
@@ -0,0 +1,109 @@ +// Copyright (C) 2023 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.googlesource.gerrit.owners; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status.SATISFIED; +import static com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status.UNSATISFIED; + +import com.google.gerrit.acceptance.PushOneCommit; +import com.google.gerrit.acceptance.TestAccount; +import com.google.gerrit.acceptance.TestPlugin; +import com.google.gerrit.acceptance.UseLocalDisk; +import com.google.gerrit.acceptance.config.GlobalPluginConfig; +import com.google.gerrit.entities.SubmitRequirement; +import com.google.gerrit.entities.SubmitRequirementExpression; +import com.google.gerrit.extensions.api.changes.ChangeApi; +import com.google.gerrit.extensions.api.changes.ReviewInput; +import com.google.gerrit.extensions.client.ListChangesOption; +import com.google.gerrit.extensions.common.ChangeInfo; +import com.google.gerrit.extensions.common.SubmitRequirementResultInfo; +import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status; +import com.google.gerrit.server.experiments.ExperimentFeaturesConstants; +import com.google.gerrit.testing.ConfigSuite; +import com.googlesource.gerrit.owners.common.LabelDefinition; +import java.util.Collection; +import org.eclipse.jgit.lib.Config; +import org.junit.Before; +import org.junit.Test; + +@TestPlugin(name = "owners", sysModule = "com.googlesource.gerrit.owners.OwnersModule") +@UseLocalDisk +public class OwnersApprovalHasOperandIT extends OwnersSubmitRequirementITAbstract { + private static final String REQUIREMENT_NAME = "Owner-Approval"; + + // This configuration is needed on 3.5 only and should be removed during/after the merge to + // stable-3.6 as it is enabled there by default. + @ConfigSuite.Default + public static Config defaultConfig() { + Config cfg = new Config(); + cfg.setString( + "experiments", + null, + "enabled", + ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS); + return cfg; + } + + @Before + public void setup() throws Exception { + configSubmitRequirement( + project, + SubmitRequirement.builder() + .setName(REQUIREMENT_NAME) + .setSubmittabilityExpression(SubmitRequirementExpression.create("has:approval_owners")) + .setAllowOverrideInChildProjects(false) + .build()); + } + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldOwnersRequirementBeSatisfied() throws Exception { + TestAccount admin2 = accountCreator.admin2(); + addOwnerFileToRoot(true, LabelDefinition.parse("Code-Review,1").get(), admin2); + + PushOneCommit.Result r = createChange("Add a file", "foo", "bar"); + ChangeApi changeApi = forChange(r); + ChangeInfo changeNotReady = changeApi.get(ListChangesOption.SUBMIT_REQUIREMENTS); + verifySubmitRequirements(changeNotReady.submitRequirements, REQUIREMENT_NAME, UNSATISFIED); + + requestScopeOperations.setApiUser(admin2.id()); + forChange(r).current().review(ReviewInput.recommend()); + ChangeInfo ownersVoteSufficient = forChange(r).get(ListChangesOption.SUBMIT_REQUIREMENTS); + verifySubmitRequirements(ownersVoteSufficient.submitRequirements, REQUIREMENT_NAME, SATISFIED); + } + + private void verifySubmitRequirements( + Collection<SubmitRequirementResultInfo> requirements, String name, Status status) { + for (SubmitRequirementResultInfo requirement : requirements) { + if (requirement.name.equals(name) && requirement.status == status) { + return; + } + } + + throw new AssertionError( + String.format( + "Could not find submit requirement %s with status %s (results = %s)", + name, + status, + requirements.stream() + .map(r -> String.format("%s=%s", r.name, r.status)) + .collect(toImmutableList()))); + } +}
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/OwnersMetricsIT.java b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersMetricsIT.java new file mode 100644 index 0000000..47e2510 --- /dev/null +++ b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersMetricsIT.java
@@ -0,0 +1,70 @@ +// Copyright (C) 2023 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.googlesource.gerrit.owners; + +import static com.google.common.truth.Truth.assertWithMessage; + +import com.codahale.metrics.MetricRegistry; +import com.google.gerrit.acceptance.LightweightPluginDaemonTest; +import com.google.gerrit.acceptance.TestAccount; +import com.google.gerrit.acceptance.TestPlugin; +import com.google.gerrit.acceptance.UseLocalDisk; +import com.google.gerrit.acceptance.config.GlobalPluginConfig; +import com.google.gerrit.entities.RefNames; +import com.google.inject.Inject; +import org.junit.Test; + +@TestPlugin(name = "owners", sysModule = "com.googlesource.gerrit.owners.OwnersModule") +@UseLocalDisk +public class OwnersMetricsIT extends LightweightPluginDaemonTest { + @Inject MetricRegistry metricRegistry; + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldOwnersMetricsBeAvailable() throws Exception { + // one needs to at least create the OWNERS file to have metrics emitted + TestAccount admin2 = accountCreator.admin2(); + addOwnerFileToRoot(true, admin2); + + assertMetricExists("plugins/owners/count_configuration_loads"); + assertMetricExists("plugins/owners/load_configuration_latency"); + assertMetricExists("plugins/owners/count_submit_rule_runs"); + assertMetricExists("plugins/owners/run_submit_rule_latency"); + } + + private void assertMetricExists(String name) { + assertWithMessage(name).that(metricRegistry.getMetrics().get(name)).isNotNull(); + } + + private void addOwnerFileToRoot(boolean inherit, TestAccount u) throws Exception { + // Add OWNERS file to root: + // + // inherited: true + // owners: + // - u.email() + pushFactory + .create( + admin.newIdent(), + testRepo, + "Add OWNER file", + "OWNERS", + String.format("inherited: %s\nowners:\n- %s\n", inherit, u.email())) + .to(RefNames.fullName("master")) + .assertOkStatus(); + } +}
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementIT.java b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementIT.java new file mode 100644 index 0000000..0d5c2c5 --- /dev/null +++ b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementIT.java
@@ -0,0 +1,23 @@ +// Copyright (C) 2023 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.googlesource.gerrit.owners; + +import com.google.gerrit.acceptance.TestPlugin; +import com.google.gerrit.acceptance.UseLocalDisk; + +@TestPlugin(name = "owners", sysModule = "com.googlesource.gerrit.owners.OwnersModule") +@UseLocalDisk +public class OwnersSubmitRequirementIT extends OwnersSubmitRequirementITAbstract {}
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementITAbstract.java b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementITAbstract.java new file mode 100644 index 0000000..d7cea60 --- /dev/null +++ b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementITAbstract.java
@@ -0,0 +1,515 @@ +// Copyright (C) 2023 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.googlesource.gerrit.owners; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth8.assertThat; +import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel; +import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; +import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder; +import static com.google.gerrit.server.project.testing.TestLabels.value; +import static java.util.stream.Collectors.joining; + +import com.google.gerrit.acceptance.GitUtil; +import com.google.gerrit.acceptance.LightweightPluginDaemonTest; +import com.google.gerrit.acceptance.PushOneCommit; +import com.google.gerrit.acceptance.TestAccount; +import com.google.gerrit.acceptance.config.GlobalPluginConfig; +import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations; +import com.google.gerrit.entities.LabelFunction; +import com.google.gerrit.entities.LabelId; +import com.google.gerrit.entities.LabelType; +import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.api.changes.ChangeApi; +import com.google.gerrit.extensions.api.changes.ReviewInput; +import com.google.gerrit.extensions.client.SubmitType; +import com.google.gerrit.extensions.common.ChangeInfo; +import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo; +import com.google.gerrit.extensions.common.SubmitRecordInfo; +import com.google.gerrit.extensions.restapi.RestApiException; +import com.google.gerrit.server.project.testing.TestLabels; +import com.google.inject.Inject; +import com.googlesource.gerrit.owners.common.LabelDefinition; +import java.util.Collection; +import java.util.stream.Stream; +import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository; +import org.eclipse.jgit.junit.TestRepository; +import org.junit.Test; + +abstract class OwnersSubmitRequirementITAbstract extends LightweightPluginDaemonTest { + private static final LegacySubmitRequirementInfo NOT_READY = + new LegacySubmitRequirementInfo("NOT_READY", "Owners", "owners"); + private static final LegacySubmitRequirementInfo READY = + new LegacySubmitRequirementInfo("OK", "Owners", "owners"); + + @Inject protected RequestScopeOperations requestScopeOperations; + @Inject private ProjectOperations projectOperations; + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldRequireAtLeastOneApprovalForMatchingPathFromOwner() throws Exception { + TestAccount admin2 = accountCreator.admin2(); + TestAccount user1 = accountCreator.user1(); + addOwnerFileWithMatchersToRoot(true, ".md", admin2, user1); + + PushOneCommit.Result r = createChange("Add a file", "README.md", "foo"); + ChangeApi changeApi = forChange(r); + ChangeInfo changeNotReady = changeApi.get(); + assertThat(changeNotReady.submittable).isFalse(); + assertThat(changeNotReady.requirements).containsExactly(NOT_READY); + + changeApi.current().review(ReviewInput.approve()); + ChangeInfo changeNotReadyAfterSelfApproval = changeApi.get(); + assertThat(changeNotReadyAfterSelfApproval.submittable).isFalse(); + assertThat(changeNotReadyAfterSelfApproval.requirements).containsExactly(NOT_READY); + + requestScopeOperations.setApiUser(admin2.id()); + forChange(r).current().review(ReviewInput.approve()); + ChangeInfo changeReady = forChange(r).get(); + assertThat(changeReady.submittable).isTrue(); + assertThat(changeReady.requirements).containsExactly(READY); + } + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldNotRequireApprovalForNotMatchingPath() throws Exception { + TestAccount admin2 = accountCreator.admin2(); + addOwnerFileWithMatchersToRoot(true, ".md", admin2); + + PushOneCommit.Result r = createChange("Add a file", "README.txt", "foo"); + ChangeApi changeApi = forChange(r); + ChangeInfo changeNotReady = changeApi.get(); + assertThat(changeNotReady.submittable).isFalse(); + assertThat(changeNotReady.requirements).isEmpty(); + + changeApi.current().review(ReviewInput.approve()); + ChangeInfo changeReady = changeApi.get(); + assertThat(changeReady.submittable).isTrue(); + assertThat(changeReady.requirements).isEmpty(); + } + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldRequireApprovalFromRootOwner() throws Exception { + TestAccount admin2 = accountCreator.admin2(); + addOwnerFileToRoot(true, admin2); + + PushOneCommit.Result r = createChange("Add a file", "foo", "bar"); + ChangeApi changeApi = forChange(r); + ChangeInfo changeNotReady = changeApi.get(); + assertThat(changeNotReady.submittable).isFalse(); + assertThat(changeNotReady.requirements).containsExactly(NOT_READY); + + changeApi.current().review(ReviewInput.approve()); + ChangeInfo changeNotReadyAfterSelfApproval = changeApi.get(); + assertThat(changeNotReadyAfterSelfApproval.submittable).isFalse(); + assertThat(changeNotReadyAfterSelfApproval.requirements).containsExactly(NOT_READY); + + requestScopeOperations.setApiUser(admin2.id()); + forChange(r).current().review(ReviewInput.approve()); + ChangeInfo changeReady = forChange(r).get(); + assertThat(changeReady.submittable).isTrue(); + assertThat(changeReady.requirements).containsExactly(READY); + } + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldBlockOwnersApprovalForMaxNegativeVote() throws Exception { + TestAccount admin2 = accountCreator.admin2(); + addOwnerFileToRoot(true, admin2); + + PushOneCommit.Result r = createChange("Add a file", "foo", "bar"); + ChangeApi changeApi = forChange(r); + ChangeInfo changeNotReady = changeApi.get(); + assertThat(changeNotReady.submittable).isFalse(); + assertThat(changeNotReady.requirements).containsExactly(NOT_READY); + + requestScopeOperations.setApiUser(admin2.id()); + forChange(r).current().review(ReviewInput.approve()); + ChangeInfo changeReady = forChange(r).get(); + assertThat(changeReady.submittable).isTrue(); + assertThat(changeReady.requirements).containsExactly(READY); + + changeApi.current().review(ReviewInput.reject()); + assertThat(forChange(r).get().submittable).isFalse(); + } + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldRequireVerifiedApprovalEvenIfCodeOwnerApproved() throws Exception { + TestAccount admin2 = accountCreator.admin2(); + addOwnerFileToRoot(true, admin2); + + installVerifiedLabel(); + + PushOneCommit.Result r = createChange("Add a file", "foo", "bar"); + ChangeApi changeApi = forChange(r); + assertThat(changeApi.get().submittable).isFalse(); + assertThat(changeApi.get().requirements).containsExactly(NOT_READY); + + requestScopeOperations.setApiUser(admin2.id()); + forChange(r).current().review(ReviewInput.approve()); + assertThat(forChange(r).get().submittable).isFalse(); + assertThat(forChange(r).get().requirements).containsExactly(READY); + verifyHasSubmitRecord( + forChange(r).get().submitRecords, LabelId.VERIFIED, SubmitRecordInfo.Label.Status.NEED); + + changeApi.current().review(new ReviewInput().label(LabelId.VERIFIED, 1)); + assertThat(changeApi.get().submittable).isTrue(); + verifyHasSubmitRecord( + changeApi.get().submitRecords, LabelId.VERIFIED, SubmitRecordInfo.Label.Status.OK); + } + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldRequireCodeOwnerApprovalEvenIfVerifiedWasApproved() throws Exception { + TestAccount admin2 = accountCreator.admin2(); + addOwnerFileToRoot(true, admin2); + + installVerifiedLabel(); + + PushOneCommit.Result r = createChange("Add a file", "foo", "bar"); + ChangeApi changeApi = forChange(r); + assertThat(changeApi.get().submittable).isFalse(); + assertThat(changeApi.get().requirements).containsExactly(NOT_READY); + + requestScopeOperations.setApiUser(admin2.id()); + forChange(r).current().review(new ReviewInput().label(LabelId.VERIFIED, 1)); + ChangeInfo changeNotReady = forChange(r).get(); + assertThat(changeNotReady.submittable).isFalse(); + assertThat(changeNotReady.requirements).containsExactly(NOT_READY); + verifyHasSubmitRecord( + changeNotReady.submitRecords, LabelId.VERIFIED, SubmitRecordInfo.Label.Status.OK); + + forChange(r).current().review(ReviewInput.approve()); + ChangeInfo changeReady = forChange(r).get(); + assertThat(changeReady.submittable).isTrue(); + assertThat(changeReady.requirements).containsExactly(READY); + } + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldRequireConfiguredLabelByCodeOwner() throws Exception { + TestAccount admin2 = accountCreator.admin2(); + String labelId = "Foo"; + addOwnerFileToRoot(true, LabelDefinition.parse(labelId).get(), admin2); + + installLabel(labelId); + + PushOneCommit.Result r = createChange("Add a file", "foo", "bar"); + ChangeApi changeApi = forChange(r); + assertThat(changeApi.get().submittable).isFalse(); + assertThat(changeApi.get().requirements).containsExactly(NOT_READY); + + changeApi.current().review(ReviewInput.approve()); + ChangeInfo changeStillNotReady = changeApi.get(); + assertThat(changeStillNotReady.submittable).isFalse(); + assertThat(changeStillNotReady.requirements).containsExactly(NOT_READY); + + requestScopeOperations.setApiUser(admin2.id()); + forChange(r).current().review(new ReviewInput().label(labelId, 1)); + ChangeInfo changeReady = forChange(r).get(); + assertThat(changeReady.submittable).isTrue(); + assertThat(changeReady.requirements).containsExactly(READY); + verifyHasSubmitRecord(changeReady.submitRecords, labelId, SubmitRecordInfo.Label.Status.OK); + } + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldRequireConfiguredLabelByCodeOwnerEvenIfItIsNotConfiguredForProject() + throws Exception { + TestAccount admin2 = accountCreator.admin2(); + String notExistinglabelId = "Foo"; + addOwnerFileToRoot(true, LabelDefinition.parse(notExistinglabelId).get(), admin2); + + PushOneCommit.Result r = createChange("Add a file", "foo", "bar"); + ChangeApi changeApi = forChange(r); + assertThat(changeApi.get().submittable).isFalse(); + assertThat(changeApi.get().requirements).containsExactly(NOT_READY); + + changeApi.current().review(ReviewInput.approve()); + ChangeInfo changeStillNotReady = changeApi.get(); + assertThat(changeStillNotReady.submittable).isFalse(); + assertThat(changeStillNotReady.requirements).containsExactly(NOT_READY); + } + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldRequireConfiguredLabelScoreByCodeOwner() throws Exception { + TestAccount admin2 = accountCreator.admin2(); + addOwnerFileToRoot(true, LabelDefinition.parse("Code-Review,1").get(), admin2); + + PushOneCommit.Result r = createChange("Add a file", "foo", "bar"); + ChangeApi changeApi = forChange(r); + ChangeInfo changeNotReady = changeApi.get(); + assertThat(changeNotReady.submittable).isFalse(); + assertThat(changeNotReady.requirements).containsExactly(NOT_READY); + + changeApi.current().review(ReviewInput.approve()); + ChangeInfo changeNotReadyAfterSelfApproval = changeApi.get(); + assertThat(changeNotReadyAfterSelfApproval.submittable).isFalse(); + assertThat(changeNotReadyAfterSelfApproval.requirements).containsExactly(NOT_READY); + + requestScopeOperations.setApiUser(admin2.id()); + forChange(r).current().review(ReviewInput.recommend()); + ChangeInfo changeReadyWithOwnerScore = forChange(r).get(); + assertThat(changeReadyWithOwnerScore.submittable).isTrue(); + assertThat(changeReadyWithOwnerScore.requirements).containsExactly(READY); + } + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldConfiguredLabelScoreByCodeOwnerBeNotSufficientIfLabelRequiresMaxValue() + throws Exception { + TestAccount admin2 = accountCreator.admin2(); + addOwnerFileToRoot(true, LabelDefinition.parse("Code-Review,1").get(), admin2); + + PushOneCommit.Result r = createChange("Add a file", "foo", "bar"); + ChangeApi changeApi = forChange(r); + ChangeInfo changeNotReady = changeApi.get(); + assertThat(changeNotReady.submittable).isFalse(); + assertThat(changeNotReady.requirements).containsExactly(NOT_READY); + + requestScopeOperations.setApiUser(admin2.id()); + forChange(r).current().review(ReviewInput.recommend()); + ChangeInfo ownersVoteNotSufficient = changeApi.get(); + assertThat(ownersVoteNotSufficient.submittable).isFalse(); + assertThat(ownersVoteNotSufficient.requirements).containsExactly(READY); + verifyHasSubmitRecord( + ownersVoteNotSufficient.submitRecords, + LabelId.CODE_REVIEW, + SubmitRecordInfo.Label.Status.NEED); + + requestScopeOperations.setApiUser(admin.id()); + forChange(r).current().review(ReviewInput.approve()); + ChangeInfo changeReadyWithMaxScore = forChange(r).get(); + assertThat(changeReadyWithMaxScore.submittable).isTrue(); + assertThat(changeReadyWithMaxScore.requirements).containsExactly(READY); + verifyHasSubmitRecord( + changeReadyWithMaxScore.submitRecords, + LabelId.CODE_REVIEW, + SubmitRecordInfo.Label.Status.OK); + } + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldConfiguredLabelScoreByCodeOwnersOverwriteSubmitRequirement() throws Exception { + installLabel(TestLabels.codeReview().toBuilder().setFunction(LabelFunction.NO_OP).build()); + + TestAccount admin2 = accountCreator.admin2(); + addOwnerFileToRoot(true, LabelDefinition.parse("Code-Review,1").get(), admin2); + + PushOneCommit.Result r = createChange("Add a file", "foo", "bar"); + ChangeApi changeApi = forChange(r); + ChangeInfo changeNotReady = changeApi.get(); + assertThat(changeNotReady.submittable).isFalse(); + assertThat(changeNotReady.requirements).containsExactly(NOT_READY); + + requestScopeOperations.setApiUser(admin2.id()); + forChange(r).current().review(ReviewInput.recommend()); + ChangeInfo ownersVoteSufficient = forChange(r).get(); + assertThat(ownersVoteSufficient.submittable).isTrue(); + assertThat(ownersVoteSufficient.requirements).containsExactly(READY); + } + + @Test + @GlobalPluginConfig( + pluginName = "owners", + name = "owners.enableSubmitRequirement", + value = "true") + public void shouldRequireApprovalFromGrandParentProjectOwner() throws Exception { + Project.NameKey parentProjectName = + createProjectOverAPI("parent", allProjects, true, SubmitType.FAST_FORWARD_ONLY); + Project.NameKey childProjectName = + createProjectOverAPI("child", parentProjectName, true, SubmitType.FAST_FORWARD_ONLY); + TestRepository<InMemoryRepository> childRepo = cloneProject(childProjectName); + + TestAccount admin2 = accountCreator.admin2(); + addOwnerFileToRefsMetaConfig(true, admin2, allProjects); + + PushOneCommit.Result r = + createCommitAndPush(childRepo, "refs/for/master", "Add a file", "foo", "bar"); + ChangeApi changeApi = forChange(r); + ChangeInfo changeNotReady = changeApi.get(); + assertThat(changeNotReady.submittable).isFalse(); + assertThat(changeNotReady.requirements).containsExactly(NOT_READY); + + changeApi.current().review(ReviewInput.approve()); + ChangeInfo changeNotReadyAfterSelfApproval = changeApi.get(); + assertThat(changeNotReadyAfterSelfApproval.submittable).isFalse(); + assertThat(changeNotReadyAfterSelfApproval.requirements).containsExactly(NOT_READY); + + requestScopeOperations.setApiUser(admin2.id()); + forChange(r).current().review(ReviewInput.approve()); + ChangeInfo changeReady = forChange(r).get(); + assertThat(changeReady.submittable).isTrue(); + assertThat(changeReady.requirements).containsExactly(READY); + } + + private void verifyHasSubmitRecord( + Collection<SubmitRecordInfo> records, String label, SubmitRecordInfo.Label.Status status) { + assertThat( + records.stream() + .flatMap(record -> record.labels.stream()) + .filter(l -> l.label.equals(label) && l.status == status) + .findAny()) + .isPresent(); + } + + private void installVerifiedLabel() throws Exception { + installLabel(LabelId.VERIFIED); + } + + private void installLabel(String labelId) throws Exception { + LabelType verified = + labelBuilder(labelId, value(1, "Verified"), value(0, "No score"), value(-1, "Fails")) + .setFunction(LabelFunction.MAX_WITH_BLOCK) + .build(); + + installLabel(verified); + + String heads = RefNames.REFS_HEADS + "*"; + projectOperations + .project(project) + .forUpdate() + .add(allowLabel(verified.getName()).ref(heads).group(REGISTERED_USERS).range(-1, 1)) + .update(); + } + + private void installLabel(LabelType label) throws Exception { + try (ProjectConfigUpdate u = updateProject(project)) { + u.getConfig().upsertLabelType(label); + u.save(); + } + } + + protected ChangeApi forChange(PushOneCommit.Result r) throws RestApiException { + return gApi.changes().id(r.getChangeId()); + } + + private void addOwnerFileWithMatchersToRoot( + boolean inherit, String extension, TestAccount... users) throws Exception { + // Add OWNERS file to root: + // + // inherited: true + // matchers: + // - suffix: extension + // owners: + // - u1.email() + // - ... + // - uN.email() + pushOwnersToMaster( + String.format( + "inherited: %s\nmatchers:\n" + "- suffix: %s\n owners:\n%s", + inherit, + extension, + Stream.of(users) + .map(user -> String.format(" - %s\n", user.email())) + .collect(joining()))); + } + + private void addOwnerFileToRoot(boolean inherit, TestAccount u) throws Exception { + // Add OWNERS file to root: + // + // inherited: true + // owners: + // - u.email() + pushOwnersToMaster(String.format("inherited: %s\nowners:\n- %s\n", inherit, u.email())); + } + + private void addOwnerFileToRefsMetaConfig( + boolean inherit, TestAccount u, Project.NameKey projectName) throws Exception { + // Add OWNERS file to root: + // + // inherited: true + // owners: + // - u.email() + pushOwnersToRefsMetaConfig( + String.format("inherited: %s\nowners:\n- %s\n", inherit, u.email()), projectName); + } + + protected void addOwnerFileToRoot(boolean inherit, LabelDefinition label, TestAccount u) + throws Exception { + // Add OWNERS file to root: + // + // inherited: true + // label: label,score # score is optional + // owners: + // - u.email() + pushOwnersToMaster( + String.format( + "inherited: %s\nlabel: %s\nowners:\n- %s\n", + inherit, + String.format( + "%s%s", + label.getName(), + label.getScore().map(value -> String.format(",%d", value)).orElse("")), + u.email())); + } + + private void pushOwnersToMaster(String owners) throws Exception { + pushFactory + .create(admin.newIdent(), testRepo, "Add OWNER file", "OWNERS", owners) + .to(RefNames.fullName("master")) + .assertOkStatus(); + } + + private void pushOwnersToRefsMetaConfig(String owners, Project.NameKey projectName) + throws Exception { + TestRepository<InMemoryRepository> project = cloneProject(projectName); + GitUtil.fetch(project, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG); + project.reset(RefNames.REFS_CONFIG); + pushFactory + .create(admin.newIdent(), project, "Add OWNER file", "OWNERS", owners) + .to(RefNames.REFS_CONFIG) + .assertOkStatus(); + } +}
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementTest.java b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementTest.java new file mode 100644 index 0000000..97113c0 --- /dev/null +++ b/owners/src/test/java/com/googlesource/gerrit/owners/OwnersSubmitRequirementTest.java
@@ -0,0 +1,464 @@ +// Copyright (C) 2023 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.googlesource.gerrit.owners; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth8.assertThat; +import static com.google.gerrit.server.project.testing.TestLabels.codeReview; +import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder; +import static com.google.gerrit.server.project.testing.TestLabels.value; +import static com.google.gerrit.server.project.testing.TestLabels.verified; +import static com.googlesource.gerrit.owners.OwnersSubmitRequirement.hasSufficientApproval; +import static com.googlesource.gerrit.owners.OwnersSubmitRequirement.isApprovalMissing; +import static com.googlesource.gerrit.owners.OwnersSubmitRequirement.isApprovedByOwner; +import static com.googlesource.gerrit.owners.OwnersSubmitRequirement.isLabelApproved; +import static com.googlesource.gerrit.owners.OwnersSubmitRequirement.ownersLabel; +import static com.googlesource.gerrit.owners.OwnersSubmitRequirement.resolveLabel; +import static org.mockito.Mockito.mock; + +import com.google.gerrit.entities.Account; +import com.google.gerrit.entities.LabelFunction; +import com.google.gerrit.entities.LabelId; +import com.google.gerrit.entities.LabelType; +import com.google.gerrit.entities.LabelTypes; +import com.google.gerrit.entities.PatchSet; +import com.google.gerrit.entities.PatchSetApproval; +import com.google.gerrit.entities.Project; +import com.googlesource.gerrit.owners.OwnersSubmitRequirement.LabelAndScore; +import com.googlesource.gerrit.owners.common.LabelDefinition; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.Test; + +public class OwnersSubmitRequirementTest { + private static String LABEL_ID = "foo"; + private static LabelDefinition OWNERS_LABEL = LabelDefinition.parse(LABEL_ID).get(); + private static LabelDefinition OWNERS_LABEL_WITH_SCORE = + LabelDefinition.parse(String.format("%s,1", LABEL_ID)).get(); + private static int MAX_LABEL_VALUE = 1; + private static Project.NameKey PROJECT = Project.nameKey("project"); + + @Test + public void shouldResolveLabelToConfiguredOne() { + // when + LabelDefinition label = resolveLabel(null, Optional.of(OWNERS_LABEL)); + + // then + assertThat(label).isEqualTo(OWNERS_LABEL); + } + + @Test + public void shouldResolveLabelToCodeReview() { + // given + LabelTypes types = + new LabelTypes(List.of(verified(), label().setFunction(LabelFunction.NO_BLOCK).build())); + + // when + LabelDefinition label = resolveLabel(types, Optional.empty()); + + // then + assertThat(label).isEqualTo(LabelDefinition.CODE_REVIEW); + } + + @Test + public void shouldOwnersLabelContainOnlyConfiguredLabelAndItsScore() { + // when + Optional<LabelAndScore> result = + ownersLabel(new LabelTypes(List.of(label().build())), OWNERS_LABEL_WITH_SCORE, PROJECT); + + // then + assertThat(result.map(label -> label.getLabelType().getName())).hasValue(LABEL_ID); + assertThat(result.flatMap(LabelAndScore::getScore)) + .isEqualTo(OWNERS_LABEL_WITH_SCORE.getScore()); + } + + @Test + public void shouldOwnersLabelBeEmptyIfNonExistingLabelIsConfigured() { + // when + Optional<LabelAndScore> result = + ownersLabel(new LabelTypes(List.of(codeReview())), OWNERS_LABEL, PROJECT); + + // then + assertThat(result).isEmpty(); + } + + @Test + public void shouldApprovalBeMissingWhenSomeoneElseApproved() { + // given + Account.Id fileOwner = mock(Account.Id.class); + Account.Id uploader = mock(Account.Id.class); + LabelAndScore ownersLabel = maxNoBlockLabelFooOwnersLabel(); + Map<Account.Id, List<PatchSetApproval>> uploaderApproval = + Map.of(uploader, List.of(approvedBy(uploader, LABEL_ID, MAX_LABEL_VALUE))); + + // when + boolean isApprovalMissing = + isApprovalMissing( + Map.entry("path", Set.of(fileOwner)), uploader, uploaderApproval, ownersLabel); + + // then + assertThat(isApprovalMissing).isTrue(); + } + + @Test + public void shouldApprovalBeNotMissingWhenApprovedByFileOwner() { + // given + Account.Id fileOwner = mock(Account.Id.class); + Account.Id uploader = mock(Account.Id.class); + LabelAndScore ownersLabel = maxNoBlockLabelFooOwnersLabel(); + Map<Account.Id, List<PatchSetApproval>> fileOwnerApproval = + Map.of(fileOwner, List.of(approvedBy(fileOwner, LABEL_ID, MAX_LABEL_VALUE))); + + // when + boolean isApprovalMissing = + isApprovalMissing( + Map.entry("path", Set.of(fileOwner)), uploader, fileOwnerApproval, ownersLabel); + + // then + assertThat(isApprovalMissing).isFalse(); + } + + @Test + public void shouldApprovalBeNotMissingWhenApprovedByAtLeastOneOwner() { + // given + Account.Id fileOwnerA = mock(Account.Id.class); + Account.Id fileOwnerB = mock(Account.Id.class); + Account.Id uploader = mock(Account.Id.class); + LabelAndScore ownersLabel = maxNoBlockLabelFooOwnersLabel(); + Map<Account.Id, List<PatchSetApproval>> fileOwnerApproval = + Map.of(fileOwnerA, List.of(approvedBy(fileOwnerA, LABEL_ID, MAX_LABEL_VALUE))); + + // when + boolean isApprovalMissing = + isApprovalMissing( + Map.entry("path", Set.of(fileOwnerA, fileOwnerB)), + uploader, + fileOwnerApproval, + ownersLabel); + + // then + assertThat(isApprovalMissing).isFalse(); + } + + @Test + public void shouldNotBeApprovedByOwnerWhenSomeoneElseApproved() { + // given + Account.Id fileOwner = mock(Account.Id.class); + Account.Id uploader = mock(Account.Id.class); + LabelAndScore ownersLabel = maxNoBlockLabelFooOwnersLabel(); + Map<Account.Id, List<PatchSetApproval>> uploaderApproval = + Map.of(uploader, List.of(approvedBy(uploader, LABEL_ID, MAX_LABEL_VALUE))); + + // when + boolean approvedByOwner = + isApprovedByOwner(fileOwner, fileOwner, uploaderApproval, ownersLabel); + + // then + assertThat(approvedByOwner).isFalse(); + } + + @Test + public void shouldNotBeApprovedWhenApprovalGivenForDifferentLabel() { + // given + Account.Id fileOwner = mock(Account.Id.class); + LabelAndScore ownersLabel = + new LabelAndScore( + label().setName("bar").setFunction(LabelFunction.MAX_NO_BLOCK).build(), + Optional.empty()); + Map<Account.Id, List<PatchSetApproval>> fileOwnerForDifferentLabelApproval = + Map.of(fileOwner, List.of(approvedBy(fileOwner, LABEL_ID, MAX_LABEL_VALUE))); + + // when + boolean approvedByOwner = + isApprovedByOwner(fileOwner, fileOwner, fileOwnerForDifferentLabelApproval, ownersLabel); + + // then + assertThat(approvedByOwner).isFalse(); + } + + @Test + public void shouldBeApprovedByOwner() { + // given + Account.Id fileOwner = mock(Account.Id.class); + LabelAndScore ownersLabel = maxNoBlockLabelFooOwnersLabel(); + Map<Account.Id, List<PatchSetApproval>> fileOwnerApproval = + Map.of(fileOwner, List.of(approvedBy(fileOwner, LABEL_ID, MAX_LABEL_VALUE))); + + // when + boolean approvedByOwner = + isApprovedByOwner(fileOwner, fileOwner, fileOwnerApproval, ownersLabel); + + // then + assertThat(approvedByOwner).isTrue(); + } + + @Test + public void shouldHaveNotSufficientApprovalWhenLabelIsNotApproved() { + // given + Account.Id fileOwner = mock(Account.Id.class); + LabelAndScore ownersLabel = maxNoBlockLabelFooOwnersLabel(); + + // when + boolean hasSufficientApproval = + hasSufficientApproval( + approvedBy(fileOwner, LABEL_ID, 0), ownersLabel, fileOwner, fileOwner); + + // then + assertThat(hasSufficientApproval).isFalse(); + } + + @Test + public void shouldHaveNotSufficientApprovalWhenLabelDoesntMatch() { + // given + Account.Id fileOwner = mock(Account.Id.class); + + // when + boolean hasSufficientApproval = + hasSufficientApproval( + approvedBy(fileOwner, LABEL_ID, 0), + new LabelAndScore( + LabelType.builder("foo", Collections.emptyList()).build(), Optional.empty()), + fileOwner, + fileOwner); + + // then + assertThat(hasSufficientApproval).isFalse(); + } + + @Test + public void shouldHaveSufficientApprovalWhenLabelIsApproved() { + // given + Account.Id fileOwner = mock(Account.Id.class); + LabelAndScore ownersLabel = maxNoBlockLabelFooOwnersLabel(); + + // when + boolean hasSufficientApproval = + hasSufficientApproval( + approvedBy(fileOwner, LABEL_ID, MAX_LABEL_VALUE), ownersLabel, fileOwner, fileOwner); + + // then + assertThat(hasSufficientApproval).isTrue(); + } + + @Test + public void labelShouldNotBeApprovedWhenSelfApprovalIsDisabledAndOwnerApproved() { + // given + LabelType ignoreSelfApproval = label().setIgnoreSelfApproval(true).build(); + Account.Id fileOwner = mock(Account.Id.class); + + // when + boolean approved = + isLabelApproved( + ignoreSelfApproval, + Optional.empty(), + fileOwner, + fileOwner, + approvedBy(fileOwner, LABEL_ID, MAX_LABEL_VALUE)); + + // then + assertThat(approved).isFalse(); + } + + @Test + public void labelShouldNotBeApprovedWhenMaxValueIsRequiredButNotProvided() { + // given + LabelType maxValueRequired = label().setFunction(LabelFunction.MAX_NO_BLOCK).build(); + Account.Id fileOwner = mock(Account.Id.class); + + // when + boolean approved = + isLabelApproved( + maxValueRequired, + Optional.empty(), + fileOwner, + fileOwner, + approvedBy(fileOwner, LABEL_ID, 0)); + + // then + assertThat(approved).isFalse(); + } + + @Test + public void labelShouldBeApprovedWhenMaxValueIsRequiredAndProvided() { + // given + LabelType maxValueRequired = label().setFunction(LabelFunction.MAX_NO_BLOCK).build(); + Account.Id fileOwner = mock(Account.Id.class); + + // when + boolean approved = + isLabelApproved( + maxValueRequired, + Optional.empty(), + fileOwner, + fileOwner, + approvedBy(fileOwner, LABEL_ID, MAX_LABEL_VALUE)); + + // then + assertThat(approved).isTrue(); + } + + @Test + public void labelShouldBeApprovedWhenMaxValueIsRequiredButLowerScoreIsConfiguredForOwner() { + // given + LabelType maxValueRequired = codeReview(); + Short ownersScore = 1; + Optional<Short> requiredOwnerScore = Optional.of(ownersScore); + Account.Id fileOwner = mock(Account.Id.class); + + // when + boolean approved = + isLabelApproved( + maxValueRequired, + requiredOwnerScore, + fileOwner, + fileOwner, + approvedBy(fileOwner, LabelId.CODE_REVIEW, ownersScore)); + + // then + assertThat(approved).isTrue(); + } + + @Test + public void + labelShouldNotBeApprovedWhenMaxValueIsRequiredLowerScoreIsConfiguredForOwnerAndTooLowScoreIsCast() { + // given + LabelType anyWithBlock = + label() + .setValues( + Arrays.asList( + value(3, "Great"), + value(2, "Approved"), + value(1, "Maybe"), + value(0, "No score"), + value(-1, "Blocked"))) + .setFunction(LabelFunction.ANY_WITH_BLOCK) + .build(); + Optional<Short> requiredOwnerScore = Optional.of((short) 2); + Short ownersScore = 1; + Account.Id fileOwner = mock(Account.Id.class); + + // when + boolean approved = + isLabelApproved( + anyWithBlock, + requiredOwnerScore, + fileOwner, + fileOwner, + approvedBy(fileOwner, LabelId.CODE_REVIEW, ownersScore)); + + // then + assertThat(approved).isFalse(); + } + + @Test + public void labelShouldNotBeApprovedWhenAnyValueWithBlockIsConfiguredAndMaxNegativeIsProvided() { + // given + LabelType anyWithBlock = label().setFunction(LabelFunction.ANY_WITH_BLOCK).build(); + Account.Id fileOwner = mock(Account.Id.class); + + // when + boolean approved = + isLabelApproved( + anyWithBlock, + Optional.empty(), + fileOwner, + fileOwner, + approvedBy(fileOwner, LABEL_ID, -1)); + + // then + assertThat(approved).isFalse(); + } + + @Test + public void labelShouldBeApprovedWhenAnyValueWithBlockIsConfiguredAndPositiveValueIsProvided() { + // given + LabelType anyWithBlock = + label() + .setValues( + Arrays.asList( + value(2, "Approved"), + value(1, "OK"), + value(0, "No score"), + value(-1, "Blocked"))) + .setFunction(LabelFunction.ANY_WITH_BLOCK) + .build(); + Account.Id fileOwner = mock(Account.Id.class); + + // when + boolean approved = + isLabelApproved( + anyWithBlock, + Optional.empty(), + fileOwner, + fileOwner, + approvedBy(fileOwner, LABEL_ID, 1)); + + // then + assertThat(approved).isTrue(); + } + + @Test + public void labelShouldNotBeApprovedWhenAnyValueWithBlockIsConfiguredAndDefaultValueIsProvided() { + // given + LabelType anyWithBlock = + label() + .setValues( + Arrays.asList( + value(2, "Approved"), + value(1, "OK"), + value(0, "No score"), + value(-1, "Blocked"))) + .setFunction(LabelFunction.ANY_WITH_BLOCK) + .build(); + Account.Id fileOwner = mock(Account.Id.class); + + // when + boolean approved = + isLabelApproved( + anyWithBlock, + Optional.empty(), + fileOwner, + fileOwner, + approvedBy(fileOwner, LABEL_ID, 0)); + + // then + assertThat(approved).isFalse(); + } + + private static final LabelAndScore maxNoBlockLabelFooOwnersLabel() { + LabelType maxValueRequired = label().setFunction(LabelFunction.MAX_NO_BLOCK).build(); + return new LabelAndScore(maxValueRequired, Optional.empty()); + } + + private static final LabelType.Builder label() { + return labelBuilder( + LABEL_ID, value(MAX_LABEL_VALUE, "Approved"), value(0, "No score"), value(-1, "Blocked")); + } + + private static final PatchSetApproval approvedBy(Account.Id approving, String label, int value) { + return PatchSetApproval.builder() + .key(PatchSetApproval.key(mock(PatchSet.Id.class), approving, LabelId.create(label))) + .granted(Timestamp.from(Instant.now())) + .realAccountId(approving) + .value(value) + .build(); + } +}
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersIT.java b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersIT.java index 703c1f1..76c4962 100644 --- a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersIT.java +++ b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersIT.java
@@ -15,298 +15,9 @@ package com.googlesource.gerrit.owners.restapi; -import static com.google.common.truth.Truth.assertThat; - -import com.google.gerrit.acceptance.GitUtil; -import com.google.gerrit.acceptance.LightweightPluginDaemonTest; -import com.google.gerrit.acceptance.PushOneCommit.Result; import com.google.gerrit.acceptance.TestPlugin; import com.google.gerrit.acceptance.UseLocalDisk; -import com.google.gerrit.acceptance.config.GlobalPluginConfig; -import com.google.gerrit.entities.Project; -import com.google.gerrit.entities.Project.NameKey; -import com.google.gerrit.entities.RefNames; -import com.google.gerrit.extensions.client.SubmitType; -import com.google.gerrit.extensions.restapi.AuthException; -import com.google.gerrit.extensions.restapi.BadRequestException; -import com.google.gerrit.extensions.restapi.ResourceConflictException; -import com.google.gerrit.extensions.restapi.Response; -import com.googlesource.gerrit.owners.entities.FilesOwnersResponse; -import com.googlesource.gerrit.owners.entities.GroupOwner; -import com.googlesource.gerrit.owners.entities.Owner; -import java.util.Arrays; -import javax.servlet.http.HttpServletResponse; -import org.apache.commons.compress.utils.Sets; -import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription; -import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository; -import org.eclipse.jgit.junit.TestRepository; -import org.eclipse.jgit.lib.Config; -import org.eclipse.jgit.transport.FetchResult; -import org.eclipse.jgit.util.FS; -import org.junit.Test; @TestPlugin(name = "owners", sysModule = "com.googlesource.gerrit.owners.OwnersModule") @UseLocalDisk -public class GetFilesOwnersIT extends LightweightPluginDaemonTest { - - private static final String REFS_META_CONFIG = RefNames.REFS_META + "config"; - private GetFilesOwners ownersApi; - private Owner rootOwner; - private Owner projectOwner; - private NameKey parentProjectName; - private NameKey childProjectName; - private TestRepository<InMemoryRepository> childRepo; - private TestRepository<InMemoryRepository> parentRepo; - private TestRepository<InMemoryRepository> allProjectsRepo; - - @Override - public void setUpTestPlugin() throws Exception { - super.setUpTestPlugin(); - - rootOwner = new Owner(admin.fullName(), admin.id().get()); - projectOwner = new Owner(user.fullName(), user.id().get()); - ownersApi = plugin.getSysInjector().getInstance(GetFilesOwners.class); - - parentProjectName = - createProjectOverAPI("parent", allProjects, true, SubmitType.FAST_FORWARD_ONLY); - parentRepo = cloneProjectWithMetaRefs(parentProjectName); - - childProjectName = - createProjectOverAPI("child", parentProjectName, true, SubmitType.FAST_FORWARD_ONLY); - childRepo = cloneProject(childProjectName); - - allProjectsRepo = cloneProjectWithMetaRefs(allProjects); - } - - @Test - public void shouldReturnExactFileOwners() throws Exception { - addOwnerFileToRoot(true); - assertChangeHasOwners(createChange().getChangeId()); - } - - @Test - public void shouldReturnExactFileOwnersWhenOwnersIsSetToAllProjects() throws Exception { - addOwnerFileWithMatchers(allProjectsRepo, REFS_META_CONFIG, true); - assertChangeHasOwners(createChange(childRepo).getChangeId()); - } - - @Test - public void shouldReturnExactFileOwnersWhenOwnersIsSetToParentProject() throws Exception { - addOwnerFileWithMatchers(parentRepo, REFS_META_CONFIG, true); - assertChangeHasOwners(createChange(childRepo).getChangeId()); - } - - @Test - public void shouldReturnOwnersLabelsWhenNotApprovedByOwners() throws Exception { - addOwnerFileToRoot(true); - String changeId = createChange().getChangeId(); - - Response<FilesOwnersResponse> resp = - assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); - - assertThat(resp.value().files) - .containsExactly("a.txt", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get()))); - - assertThat(resp.value().ownersLabels).isEmpty(); - } - - @Test - public void shouldReturnEmptyResponseWhenApprovedByOwners() throws Exception { - addOwnerFileToRoot(true); - String changeId = createChange().getChangeId(); - approve(changeId); - - Response<FilesOwnersResponse> resp = - assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); - - assertThat(resp.value().files).isEmpty(); - } - - @Test - @GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false") - public void shouldReturnResponseWithUnexpandedFileOwners() throws Exception { - addOwnerFileToRoot(true); - String changeId = createChange().getChangeId(); - - Response<FilesOwnersResponse> resp = - assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); - - assertThat(resp.value().files) - .containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username()))); - } - - @Test - @GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false") - public void shouldReturnEmptyResponseWhenApprovedByOwnersWithUnexpandedFileOwners() - throws Exception { - addOwnerFileToRoot(true); - String changeId = createChange().getChangeId(); - approve(changeId); - - Response<FilesOwnersResponse> resp = - assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); - - assertThat(resp.value().files).isEmpty(); - } - - @Test - @GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false") - public void shouldReturnResponseWithUnexpandedFileMatchersOwners() throws Exception { - addOwnerFileWithMatchersToRoot(true); - String changeId = createChange().getChangeId(); - - Response<FilesOwnersResponse> resp = - assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); - - assertThat(resp.value().files) - .containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username()))); - } - - @Test - @UseLocalDisk - public void shouldReturnInheritedOwnersFromProjectsOwners() throws Exception { - assertInheritFromProject(project); - } - - @Test - @UseLocalDisk - public void shouldReturnInheritedOwnersFromParentProjectsOwners() throws Exception { - assertInheritFromProject(allProjects); - } - - @Test - @UseLocalDisk - public void shouldNotReturnInheritedOwnersFromProjectsOwners() throws Exception { - assertNotInheritFromProject(project); - } - - @Test - @UseLocalDisk - public void shouldNotReturnInheritedOwnersFromParentProjectsOwners() throws Exception { - addOwnerFileToProjectConfig(project, false); - assertNotInheritFromProject(allProjects); - } - - private static <T> Response<T> assertResponseOk(Response<T> response) { - assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_OK); - return response; - } - - private void assertNotInheritFromProject(Project.NameKey projectNameKey) throws Exception { - addOwnerFileToRoot(false); - addOwnerFileToProjectConfig(projectNameKey, true); - - String changeId = createChange().getChangeId(); - assertChangeHasOwners(changeId); - } - - private void assertChangeHasOwners(String changeId) - throws AuthException, BadRequestException, ResourceConflictException, Exception { - Response<FilesOwnersResponse> resp = - assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); - - assertThat(resp.value().files).containsExactly("a.txt", Sets.newHashSet(rootOwner)); - } - - private void assertInheritFromProject(Project.NameKey projectNameKey) throws Exception { - addOwnerFileToRoot(true); - addOwnerFileToProjectConfig(projectNameKey, true); - - String changeId = createChange().getChangeId(); - Response<FilesOwnersResponse> resp = - assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); - - assertThat(resp.value().files) - .containsExactly("a.txt", Sets.newHashSet(rootOwner, projectOwner)); - } - - private void addOwnerFileToProjectConfig(Project.NameKey projectNameKey, boolean inherit) - throws Exception { - TestRepository<InMemoryRepository> project = cloneProject(projectNameKey); - GitUtil.fetch(project, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG); - project.reset(RefNames.REFS_CONFIG); - pushFactory - .create( - admin.newIdent(), - project, - "Add OWNER file", - "OWNERS", - String.format( - "inherited: %s\nmatchers:\n" + "- suffix: .txt\n owners:\n - %s\n", - inherit, user.email())) - .to(RefNames.REFS_CONFIG); - } - - private void addOwnerFileToRoot(boolean inherit) throws Exception { - // Add OWNERS file to root: - // - // inherited: true - // owners: - // - admin - merge( - createChange( - testRepo, - "master", - "Add OWNER file", - "OWNERS", - String.format("inherited: %s\nowners:\n- %s\n", inherit, admin.email()), - "")); - } - - private void addOwnerFileWithMatchersToRoot(boolean inherit) throws Exception { - addOwnerFileWithMatchers(testRepo, "master", inherit); - } - - private void addOwnerFileWithMatchers(TestRepository<?> repo, String targetRef, boolean inherit) - throws Exception { - // Add OWNERS file to root: - // - // inherited: true - // matchers: - // - suffix: .txt - // owners: - // - admin@mail.com - Result changeCreated = - createChange( - repo, - targetRef, - "Add OWNER file", - "OWNERS", - String.format( - "inherited: %s\nmatchers:\n" + "- suffix: .txt\n owners:\n - %s\n", - inherit, admin.email()), - ""); - changeCreated.assertOkStatus(); - merge(changeCreated); - } - - public TestRepository<InMemoryRepository> cloneProjectWithMetaRefs(Project.NameKey project) - throws Exception { - String uri = registerRepoConnection(project, admin); - String initialRef = "refs/remotes/origin/config"; - DfsRepositoryDescription desc = new DfsRepositoryDescription("clone of " + project.get()); - - InMemoryRepository.Builder b = new InMemoryRepository.Builder().setRepositoryDescription(desc); - if (uri.startsWith("ssh://")) { - // SshTransport depends on a real FS to read ~/.ssh/config, but InMemoryRepository by default - // uses a null FS. - // Avoid leaking user state into our tests. - b.setFS(FS.detect().setUserHome(null)); - } - InMemoryRepository dest = b.build(); - Config cfg = dest.getConfig(); - cfg.setString("remote", "origin", "url", uri); - cfg.setStringList( - "remote", - "origin", - "fetch", - Arrays.asList( - "+refs/heads/*:refs/remotes/origin/*", "+refs/meta/config:refs/remotes/origin/config")); - TestRepository<InMemoryRepository> testRepo = GitUtil.newTestRepository(dest); - FetchResult result = testRepo.git().fetch().setRemote("origin").call(); - if (result.getTrackingRefUpdate(initialRef) != null) { - testRepo.reset(initialRef); - } - return testRepo; - } -} +public class GetFilesOwnersIT extends GetFilesOwnersITAbstract {}
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java new file mode 100644 index 0000000..936f3d3 --- /dev/null +++ b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersITAbstract.java
@@ -0,0 +1,361 @@ +// Copyright (C) 2023 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.googlesource.gerrit.owners.restapi; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; + +import com.google.gerrit.acceptance.GitUtil; +import com.google.gerrit.acceptance.LightweightPluginDaemonTest; +import com.google.gerrit.acceptance.PushOneCommit.Result; +import com.google.gerrit.acceptance.TestAccount; +import com.google.gerrit.acceptance.UseLocalDisk; +import com.google.gerrit.acceptance.config.GlobalPluginConfig; +import com.google.gerrit.entities.LabelId; +import com.google.gerrit.entities.LabelType; +import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.Project.NameKey; +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.client.SubmitType; +import com.google.gerrit.extensions.restapi.AuthException; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.ResourceNotFoundException; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.server.project.testing.TestLabels; +import com.googlesource.gerrit.owners.entities.FilesOwnersResponse; +import com.googlesource.gerrit.owners.entities.GroupOwner; +import com.googlesource.gerrit.owners.entities.Owner; +import com.googlesource.gerrit.owners.restapi.GetFilesOwners.LabelNotFoundException; +import java.util.Arrays; +import javax.servlet.http.HttpServletResponse; +import org.apache.commons.compress.utils.Sets; +import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription; +import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository; +import org.eclipse.jgit.junit.TestRepository; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.transport.FetchResult; +import org.eclipse.jgit.util.FS; +import org.junit.Test; + +public abstract class GetFilesOwnersITAbstract extends LightweightPluginDaemonTest { + + private static final String REFS_META_CONFIG = RefNames.REFS_META + "config"; + protected GetFilesOwners ownersApi; + private Owner rootOwner; + private Owner projectOwner; + private NameKey parentProjectName; + private NameKey childProjectName; + private TestRepository<InMemoryRepository> childRepo; + private TestRepository<InMemoryRepository> parentRepo; + private TestRepository<InMemoryRepository> allProjectsRepo; + + @Override + public void setUpTestPlugin() throws Exception { + super.setUpTestPlugin(); + + rootOwner = new Owner(admin.fullName(), admin.id().get()); + projectOwner = new Owner(user.fullName(), user.id().get()); + ownersApi = plugin.getSysInjector().getInstance(GetFilesOwners.class); + + parentProjectName = + createProjectOverAPI("parent", allProjects, true, SubmitType.FAST_FORWARD_ONLY); + parentRepo = cloneProjectWithMetaRefs(parentProjectName); + + childProjectName = + createProjectOverAPI("child", parentProjectName, true, SubmitType.FAST_FORWARD_ONLY); + childRepo = cloneProject(childProjectName); + + allProjectsRepo = cloneProjectWithMetaRefs(allProjects); + } + + @Test + public void shouldReturnExactFileOwners() throws Exception { + addOwnerFileToRoot(true); + assertChangeHasOwners(createChange().getChangeId()); + } + + @Test + public void shouldReturnExactFileOwnersWhenOwnersIsSetToAllProjects() throws Exception { + addOwnerFileWithMatchers(allProjectsRepo, REFS_META_CONFIG, true); + assertChangeHasOwners(createChange(childRepo).getChangeId()); + } + + @Test + public void shouldReturnExactFileOwnersWhenOwnersIsSetToParentProject() throws Exception { + addOwnerFileWithMatchers(parentRepo, REFS_META_CONFIG, true); + assertChangeHasOwners(createChange(childRepo).getChangeId()); + } + + @Test + public void shouldReturnOwnersLabelsWhenNotApprovedByOwners() throws Exception { + addOwnerFileToRoot(true); + String changeId = createChange().getChangeId(); + + Response<FilesOwnersResponse> resp = + assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); + + assertThat(resp.value().files) + .containsExactly("a.txt", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get()))); + + assertThat(resp.value().ownersLabels).isEmpty(); + } + + @Test + public void shouldReturnEmptyResponseWhenApprovedByOwners() throws Exception { + addOwnerFileToRoot(true); + String changeId = createChange().getChangeId(); + approve(changeId); + + Response<FilesOwnersResponse> resp = + assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); + + assertThat(resp.value().files).isEmpty(); + } + + @Test + @GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false") + public void shouldReturnResponseWithUnexpandedFileOwners() throws Exception { + addOwnerFileToRoot(true); + String changeId = createChange().getChangeId(); + + Response<FilesOwnersResponse> resp = + assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); + + assertThat(resp.value().files) + .containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username()))); + } + + @Test + @GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false") + public void shouldReturnEmptyResponseWhenApprovedByOwnersWithUnexpandedFileOwners() + throws Exception { + addOwnerFileToRoot(true); + String changeId = createChange().getChangeId(); + approve(changeId); + + Response<FilesOwnersResponse> resp = + assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); + + assertThat(resp.value().files).isEmpty(); + } + + @Test + @GlobalPluginConfig(pluginName = "owners", name = "owners.expandGroups", value = "false") + public void shouldReturnResponseWithUnexpandedFileMatchersOwners() throws Exception { + addOwnerFileWithMatchersToRoot(true); + String changeId = createChange().getChangeId(); + + Response<FilesOwnersResponse> resp = + assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); + + assertThat(resp.value().files) + .containsExactly("a.txt", Sets.newHashSet(new GroupOwner(admin.username()))); + } + + @Test + @UseLocalDisk + public void shouldReturnInheritedOwnersFromProjectsOwners() throws Exception { + assertInheritFromProject(project); + } + + @Test + @UseLocalDisk + public void shouldReturnInheritedOwnersFromParentProjectsOwners() throws Exception { + assertInheritFromProject(allProjects); + } + + @Test + @UseLocalDisk + public void shouldReflectChangesInParentProject() throws Exception { + addOwnerFileToProjectConfig(allProjects, true, admin); + + String changeId = createChange().getChangeId(); + Response<FilesOwnersResponse> resp = + assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); + assertThat(resp.value().files).containsExactly("a.txt", Sets.newHashSet(rootOwner)); + + addOwnerFileToProjectConfig(allProjects, true, user); + resp = assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); + assertThat(resp.value().files).containsExactly("a.txt", Sets.newHashSet(projectOwner)); + } + + @Test + @UseLocalDisk + public void shouldNotReturnInheritedOwnersFromProjectsOwners() throws Exception { + assertNotInheritFromProject(project); + } + + @Test + @UseLocalDisk + public void shouldNotReturnInheritedOwnersFromParentProjectsOwners() throws Exception { + addOwnerFileToProjectConfig(project, false); + assertNotInheritFromProject(allProjects); + } + + @Test + @UseLocalDisk + public void shouldThrowExceptionWhenCodeReviewLabelIsNotConfigured() throws Exception { + addOwnerFileToProjectConfig(project, false); + replaceCodeReviewWithLabel( + TestLabels.label( + "Foo", TestLabels.value(1, "Foo is fine"), TestLabels.value(-1, "Foo is not fine"))); + String changeId = createChange().getChangeId(); + + ResourceNotFoundException thrown = + assertThrows( + ResourceNotFoundException.class, + () -> ownersApi.apply(parseCurrentRevisionResource(changeId))); + assertThat(thrown).hasMessageThat().isEqualTo(GetFilesOwners.MISSING_CODE_REVIEW_LABEL); + assertThat(thrown).hasCauseThat().isInstanceOf(LabelNotFoundException.class); + } + + protected void replaceCodeReviewWithLabel(LabelType label) throws Exception { + try (ProjectConfigUpdate u = updateProject(allProjects)) { + u.getConfig().getLabelSections().remove(LabelId.CODE_REVIEW); + u.getConfig().upsertLabelType(label); + u.save(); + } + } + + protected static <T> Response<T> assertResponseOk(Response<T> response) { + assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_OK); + return response; + } + + private void assertNotInheritFromProject(Project.NameKey projectNameKey) throws Exception { + addOwnerFileToRoot(false); + addOwnerFileToProjectConfig(projectNameKey, true); + + String changeId = createChange().getChangeId(); + assertChangeHasOwners(changeId); + } + + private void assertChangeHasOwners(String changeId) + throws AuthException, BadRequestException, ResourceConflictException, Exception { + Response<FilesOwnersResponse> resp = + assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); + + assertThat(resp.value().files).containsExactly("a.txt", Sets.newHashSet(rootOwner)); + } + + private void assertInheritFromProject(Project.NameKey projectNameKey) throws Exception { + addOwnerFileToRoot(true); + addOwnerFileToProjectConfig(projectNameKey, true); + + String changeId = createChange().getChangeId(); + Response<FilesOwnersResponse> resp = + assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); + + assertThat(resp.value().files) + .containsExactly("a.txt", Sets.newHashSet(rootOwner, projectOwner)); + } + + private void addOwnerFileToProjectConfig(Project.NameKey projectNameKey, boolean inherit) + throws Exception { + addOwnerFileToProjectConfig(projectNameKey, inherit, user); + } + + private void addOwnerFileToProjectConfig( + Project.NameKey projectNameKey, boolean inherit, TestAccount account) throws Exception { + TestRepository<InMemoryRepository> project = cloneProject(projectNameKey); + GitUtil.fetch(project, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG); + project.reset(RefNames.REFS_CONFIG); + pushFactory + .create( + admin.newIdent(), + project, + "Add OWNER file", + "OWNERS", + String.format( + "inherited: %s\nmatchers:\n" + "- suffix: .txt\n owners:\n - %s\n", + inherit, account.email())) + .to(RefNames.REFS_CONFIG); + } + + private void addOwnerFileToRoot(boolean inherit) throws Exception { + // Add OWNERS file to root: + // + // inherited: true + // owners: + // - admin + merge( + createChange( + testRepo, + "master", + "Add OWNER file", + "OWNERS", + String.format("inherited: %s\nowners:\n- %s\n", inherit, admin.email()), + "")); + } + + private void addOwnerFileWithMatchersToRoot(boolean inherit) throws Exception { + addOwnerFileWithMatchers(testRepo, "master", inherit); + } + + private void addOwnerFileWithMatchers(TestRepository<?> repo, String targetRef, boolean inherit) + throws Exception { + // Add OWNERS file to root: + // + // inherited: true + // matchers: + // - suffix: .txt + // owners: + // - admin@mail.com + Result changeCreated = + createChange( + repo, + targetRef, + "Add OWNER file", + "OWNERS", + String.format( + "inherited: %s\nmatchers:\n" + "- suffix: .txt\n owners:\n - %s\n", + inherit, admin.email()), + ""); + changeCreated.assertOkStatus(); + merge(changeCreated); + } + + public TestRepository<InMemoryRepository> cloneProjectWithMetaRefs(Project.NameKey project) + throws Exception { + String uri = registerRepoConnection(project, admin); + String initialRef = "refs/remotes/origin/config"; + DfsRepositoryDescription desc = new DfsRepositoryDescription("clone of " + project.get()); + + InMemoryRepository.Builder b = new InMemoryRepository.Builder().setRepositoryDescription(desc); + if (uri.startsWith("ssh://")) { + // SshTransport depends on a real FS to read ~/.ssh/config, but InMemoryRepository by default + // uses a null FS. + // Avoid leaking user state into our tests. + b.setFS(FS.detect().setUserHome(null)); + } + InMemoryRepository dest = b.build(); + Config cfg = dest.getConfig(); + cfg.setString("remote", "origin", "url", uri); + cfg.setStringList( + "remote", + "origin", + "fetch", + Arrays.asList( + "+refs/heads/*:refs/remotes/origin/*", "+refs/meta/config:refs/remotes/origin/config")); + TestRepository<InMemoryRepository> testRepo = GitUtil.newTestRepository(dest); + FetchResult result = testRepo.git().fetch().setRemote("origin").call(); + if (result.getTrackingRefUpdate(initialRef) != null) { + testRepo.reset(initialRef); + } + return testRepo; + } +}
diff --git a/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java new file mode 100644 index 0000000..8172611 --- /dev/null +++ b/owners/src/test/java/com/googlesource/gerrit/owners/restapi/GetFilesOwnersSubmitRequirementsIT.java
@@ -0,0 +1,143 @@ +// Copyright (C) 2023 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.googlesource.gerrit.owners.restapi; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel; +import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; + +import com.google.gerrit.acceptance.TestAccount; +import com.google.gerrit.acceptance.TestPlugin; +import com.google.gerrit.acceptance.UseLocalDisk; +import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.entities.LabelId; +import com.google.gerrit.entities.LabelType; +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.api.changes.ReviewInput; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.server.project.testing.TestLabels; +import com.google.inject.Inject; +import com.googlesource.gerrit.owners.common.LabelDefinition; +import com.googlesource.gerrit.owners.entities.FilesOwnersResponse; +import com.googlesource.gerrit.owners.entities.Owner; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.util.Map; +import org.apache.commons.compress.utils.Sets; +import org.eclipse.jgit.lib.Config; +import org.junit.Test; + +@TestPlugin(name = "owners", sysModule = "com.googlesource.gerrit.owners.OwnersModule") +@UseLocalDisk +public class GetFilesOwnersSubmitRequirementsIT extends GetFilesOwnersITAbstract { + @Inject private ProjectOperations projectOperations; + + @Override + public void setUpTestPlugin() throws Exception { + Config pluginCfg = pluginConfig.getGlobalPluginConfig("owners"); + // enable submit requirements and store them to the file as there is no `ConfigSuite` mechanism + // for plugin config and there is no other way (but adding it to each test case) to enable it + // globally + pluginCfg.setBoolean("owners", null, "enableSubmitRequirement", true); + Files.writeString( + server.getSitePath().resolve("etc").resolve("owners.config"), + pluginCfg.toText(), + StandardOpenOption.CREATE, + StandardOpenOption.APPEND); + super.setUpTestPlugin(); + } + + @Test + public void shouldRequireConfiguredCodeReviewScore() throws Exception { + // configure submit requirement to require CR+1 only + addOwnerFileToRoot(LabelDefinition.parse("Code-Review,1").get(), admin); + + String changeId = createChange("Add a file", "foo", "bar").getChangeId(); + + Response<FilesOwnersResponse> resp = + assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); + assertThat(resp.value().files) + .containsExactly("foo", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get()))); + assertThat(resp.value().ownersLabels).isEmpty(); + + // give CR+1 as requested + recommend(changeId); + + resp = assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); + assertThat(resp.value().files).isEmpty(); + assertThat(resp.value().ownersLabels) + .containsExactly(admin.id().get(), Map.of(LabelId.CODE_REVIEW, 1)); + } + + @Test + public void shouldRequireConfiguredLabelAndScore() throws Exception { + // configure submit requirement to require LabelFoo+1 + String label = "LabelFoo"; + addOwnerFileToRoot(LabelDefinition.parse(String.format("%s,1", label)).get(), admin); + replaceCodeReviewWithLabel(label); + + String changeId = createChange("Add a file", "foo", "bar").getChangeId(); + + Response<FilesOwnersResponse> resp = + assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); + assertThat(resp.value().files) + .containsExactly("foo", Sets.newHashSet(new Owner(admin.fullName(), admin.id().get()))); + assertThat(resp.value().ownersLabels).isEmpty(); + + // give LabelFoo+1 as requested + gApi.changes().id(changeId).current().review(new ReviewInput().label(label, 1)); + + resp = assertResponseOk(ownersApi.apply(parseCurrentRevisionResource(changeId))); + assertThat(resp.value().files).isEmpty(); + assertThat(resp.value().ownersLabels).containsEntry(admin.id().get(), Map.of(label, 1)); + } + + private void addOwnerFileToRoot(LabelDefinition label, TestAccount u) throws Exception { + // Add OWNERS file to root: + // + // inherited: true + // label: label,score # score is optional + // owners: + // - u.email() + String owners = + String.format( + "inherited: true\nlabel: %s\nowners:\n- %s\n", + String.format( + "%s%s", + label.getName(), + label.getScore().map(value -> String.format(",%d", value)).orElse("")), + u.email()); + pushFactory + .create(admin.newIdent(), testRepo, "Add OWNER file", "OWNERS", owners) + .to(RefNames.fullName("master")) + .assertOkStatus(); + } + + private void replaceCodeReviewWithLabel(String labelId) throws Exception { + LabelType label = + TestLabels.label(labelId, TestLabels.value(1, "OK"), TestLabels.value(-1, "Not OK")); + + replaceCodeReviewWithLabel(label); + + // grant label to RegisteredUsers so that it is vote-able + String heads = RefNames.REFS_HEADS + "*"; + projectOperations + .project(project) + .forUpdate() + .add(allowLabel(label.getName()).ref(heads).group(REGISTERED_USERS).range(-1, 1)) + .update(); + } +}