Initial commit Change-Id: Ic7ece4f9935492cd1ef4b1c75b08ab4363b6c27d
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1293c82 --- /dev/null +++ b/.gitignore
@@ -0,0 +1,9 @@ +*.iml +*.ipr +.idea +/.classpath +/.project +/.settings/ +/bazel-* +/eclipse-out/ +.DS_Store
diff --git a/BUILD b/BUILD new file mode 100644 index 0000000..97a3ee9 --- /dev/null +++ b/BUILD
@@ -0,0 +1,47 @@ +load("//tools/bzl:junit.bzl", "junit_tests") +load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS", "PLUGIN_DEPS_NEVERLINK", "PLUGIN_TEST_DEPS", "gerrit_plugin") + +gerrit_plugin( + name = "owners-common-api", + srcs = glob([ + "src/main/java/**/*.java", + ]), + manifest_entries = [ + "Implementation-Title: Gerrit OWNERS api plugin", + "Implementation-URL: https://gerrit.googlesource.com/plugins/owners", + "Gerrit-PluginName: owners-api", + "Gerrit-Module: com.googlesource.gerrit.owners.api.OwnersApiModule", + ], + resources = glob(["src/main/**/*"]), + deps = [ + "@jackson-annotations//jar", + "@jackson-core//jar", + "@jackson-databind//jar", + "@jackson-dataformat-yaml//jar", + "@snakeyaml//jar", + ], +) + +junit_tests( + name = "owners-common-api_tests", + srcs = glob([ + "src/test/java/**/*.java", + ]), + jvm_flags = [ + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "--add-opens=java.base/java.util.stream=ALL-UNNAMED", + ], + visibility = ["//visibility:public"], + deps = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [ + ":owners-common-api__plugin", + "@easymock//jar", + "@javassist//jar", + "@powermock-api-easymock//jar", + "@powermock-api-support//jar", + "@powermock-core//jar", + "@powermock-module-junit4-common//jar", + "@powermock-module-junit4//jar", + "@powermock-reflect//jar", + ], +)
diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..11069ed --- /dev/null +++ b/LICENSE
@@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +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.
diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ab69f0 --- /dev/null +++ b/README.md
@@ -0,0 +1,52 @@ +# Gerrit Owners-Common-API Plugin + +This repository contains the basic infrastructure and API for building and running +two separate plugins, `owners` and `owners-autoassign`. + +It provides the ability to parse the OWNERS file format and it's parsing across +branches and repositories. + +## Building the plugins + +This plugin is built with Bazel in Gerrit tree + +Create symbolic links of the owners-common-api folderd and of the +external_plugin_deps.bzl file to the Gerrit source code /plugins directory. + +Then build the owners-common-api plugin with the usual Gerrit +plugin compile command. + +Example: + +``` + git clone https://gerrit.googlesource.com/plugins/owners-common-api + git clone --recurse-submodules https://gerrit.googlesource.com/gerrit + cd gerrit/plugins + ln -s ../../owners/owners-common-api . + ln -sf owners-common-api/external_plugin_deps.bzl . + cd .. + bazel build plugins/owners-common-api +``` + +The output is created in + +``` + bazel-bin/plugins/owners-common-api/owners-common-api.jar +``` + +To execute the tests run: + +``` + bazel test plugins/owners-common-api/... +``` + +This project can be imported into the Eclipse IDE: + +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: + +``` + ./tools/eclipse/project.py +``` +
diff --git a/external_plugin_deps.bzl b/external_plugin_deps.bzl new file mode 100644 index 0000000..5ad1930 --- /dev/null +++ b/external_plugin_deps.bzl
@@ -0,0 +1,99 @@ +load("//tools/bzl:maven_jar.bzl", "maven_jar") + +JACKSON_VER = "2.9.7" + +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", + artifact = "com.fasterxml.jackson.core:jackson-databind:" + JACKSON_VER, + sha1 = "e6faad47abd3179666e89068485a1b88a195ceb7", + ) + + maven_jar( + name = "jackson-annotations", + artifact = "com.fasterxml.jackson.core:jackson-annotations:" + JACKSON_VER, + sha1 = "4b838e5c4fc17ac02f3293e9a558bb781a51c46d", + ) + + maven_jar( + name = "jackson-dataformat-yaml", + artifact = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:" + JACKSON_VER, + sha1 = "a428edc4bb34a2da98a50eb759c26941d4e85960", + ) + + maven_jar( + name = "snakeyaml", + artifact = "org.yaml:snakeyaml:1.23", + sha1 = "ec62d74fe50689c28c0ff5b35d3aebcaa8b5be68", + ) + + maven_jar( + name = "easymock", + artifact = "org.easymock:easymock:3.1", + sha1 = "3e127311a86fc2e8f550ef8ee4abe094bbcf7e7e", + deps = [ + "@cglib//jar", + "@objenesis//jar", + ], + ) + + maven_jar( + name = "cglib", + artifact = "cglib:cglib-nodep:3.2.6", + sha1 = "92bf48723d277d6efd1150b2f7e9e1e92cb56caf", + ) + + maven_jar( + name = "objenesis", + artifact = "org.objenesis:objenesis:2.6", + sha1 = "639033469776fd37c08358c6b92a4761feb2af4b", + ) + + POWERM_VERS = "1.6.1" + maven_jar( + name = "powermock-module-junit4", + artifact = "org.powermock:powermock-module-junit4:" + POWERM_VERS, + sha1 = "ea8530b2848542624f110a393513af397b37b9cf", + ) + + maven_jar( + name = "powermock-module-junit4-common", + artifact = "org.powermock:powermock-module-junit4-common:" + POWERM_VERS, + sha1 = "7222ced54dabc310895d02e45c5428ca05193cda", + ) + + maven_jar( + name = "powermock-reflect", + artifact = "org.powermock:powermock-reflect:" + POWERM_VERS, + sha1 = "97d25eda8275c11161bcddda6ef8beabd534c878", + ) + + maven_jar( + name = "powermock-api-easymock", + artifact = "org.powermock:powermock-api-easymock:" + POWERM_VERS, + sha1 = "aa740ecf89a2f64d410b3d93ef8cd6833009ef00", + ) + + maven_jar( + name = "powermock-api-support", + artifact = "org.powermock:powermock-api-support:" + POWERM_VERS, + sha1 = "592ee6d929c324109d3469501222e0c76ccf0869", + ) + + maven_jar( + name = "powermock-core", + artifact = "org.powermock:powermock-core:" + POWERM_VERS, + sha1 = "5afc1efce8d44ed76b30af939657bd598e45d962", + ) + + maven_jar( + name = "javassist", + artifact = "org.javassist:javassist:3.22.0-GA", + sha1 = "3e83394258ae2089be7219b971ec21a8288528ad", + )
diff --git a/src/main/java/com/googlesource/gerrit/owners/api/OwnersApiModule.java b/src/main/java/com/googlesource/gerrit/owners/api/OwnersApiModule.java new file mode 100644 index 0000000..54ee51d --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/api/OwnersApiModule.java
@@ -0,0 +1,27 @@ +// 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.api; + +import com.google.gerrit.extensions.registration.DynamicItem; +import com.google.inject.AbstractModule; + +public class OwnersApiModule extends AbstractModule { + + @Override + protected void configure() { + DynamicItem.itemOf(binder(), OwnersAttentionSet.class); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/api/OwnersAttentionSet.java b/src/main/java/com/googlesource/gerrit/owners/api/OwnersAttentionSet.java new file mode 100644 index 0000000..31cd2a4 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/api/OwnersAttentionSet.java
@@ -0,0 +1,33 @@ +// 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.api; + +import com.google.gerrit.entities.Account; +import com.google.gerrit.extensions.common.ChangeInfo; +import java.util.Collection; + +/** API to expose a mechanism to selectively add owners to the attention-set. */ +public interface OwnersAttentionSet { + + /** + * Select the owners that should be added to the attention-set. + * + * @param changeInfo change under review + * @param owners set of owners associated with a change. + * @return subset of owners that need to be added to the attention-set. + */ + Collection<Account.Id> addToAttentionSet(ChangeInfo changeInfo, Collection<Account.Id> owners); +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/Accounts.java b/src/main/java/com/googlesource/gerrit/owners/common/Accounts.java new file mode 100644 index 0000000..9a27d36 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/Accounts.java
@@ -0,0 +1,26 @@ +// Copyright (C) 2017 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; +import com.google.inject.ImplementedBy; +import java.util.Set; + +@ImplementedBy(AccountsImpl.class) +public interface Accounts { + public String GROUP_PREFIX = "group/"; + + Set<Account.Id> find(String nameOrEmail); +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/AccountsImpl.java b/src/main/java/com/googlesource/gerrit/owners/common/AccountsImpl.java new file mode 100644 index 0000000..07c3dad --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/AccountsImpl.java
@@ -0,0 +1,186 @@ +// Copyright (C) 2017 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.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT; +import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO; +import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME; + +import com.google.gerrit.entities.Account; +import com.google.gerrit.entities.Account.Id; +import com.google.gerrit.entities.AccountGroup; +import com.google.gerrit.entities.InternalGroup; +import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.account.AccountCache; +import com.google.gerrit.server.account.AccountResolver; +import com.google.gerrit.server.account.AccountState; +import com.google.gerrit.server.account.GroupCache; +import com.google.gerrit.server.account.GroupMembers; +import com.google.gerrit.server.account.externalids.ExternalId; +import com.google.gerrit.server.project.NoSuchProjectException; +import com.google.gerrit.server.util.ManualRequestContext; +import com.google.gerrit.server.util.OneOffRequestContext; +import com.google.inject.Inject; +import java.io.IOException; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AccountsImpl implements Accounts { + private static final Logger log = LoggerFactory.getLogger(AccountsImpl.class); + + private final AccountResolver resolver; + private final AccountCache byId; + private final GroupCache groupCache; + private final GroupMembers groupMembers; + private final IdentifiedUser adminUser; + private final OneOffRequestContext oneOffRequestContext; + + @Inject + public AccountsImpl( + AccountResolver resolver, + AccountCache byId, + GroupCache groupCache, + GroupMembers groupMembers, + OneOffRequestContext oneOffRequestContext, + IdentifiedUser.GenericFactory userFactory) { + this.resolver = resolver; + this.byId = byId; + this.groupCache = groupCache; + this.groupMembers = groupMembers; + this.adminUser = userFactory.create(Account.id(1000000)); + this.oneOffRequestContext = oneOffRequestContext; + } + + @Override + public Set<Account.Id> find(String nameOrEmailOrGroup) { + if (nameOrEmailOrGroup.startsWith(GROUP_PREFIX)) { + return findAccountsInGroup(nameOrEmailOrGroup.substring(GROUP_PREFIX.length())); + } + return findUserOrEmail(nameOrEmailOrGroup); + } + + private Set<Id> findAccountsInGroup(String groupNameOrUUID) { + Optional<InternalGroup> group = + groupCache + .get(AccountGroup.nameKey(groupNameOrUUID)) + .map(Optional::of) + .orElse(groupCache.get(AccountGroup.uuid(groupNameOrUUID))); + + if (!group.isPresent()) { + log.warn("Group {} was not found", groupNameOrUUID); + return Collections.emptySet(); + } + + try (ManualRequestContext ctx = oneOffRequestContext.openAs(adminUser.getAccountId())) { + + return groupMembers.listAccounts(group.get().getGroupUUID(), null).stream() + .map(Account::id) + .collect(Collectors.toSet()); + } catch (NoSuchProjectException | IOException e) { + log.error("Unable to list accounts in group " + group, e); + return Collections.emptySet(); + } + } + + private Set<Account.Id> findUserOrEmail(String nameOrEmail) { + try (ManualRequestContext ctx = oneOffRequestContext.open()) { + Set<Id> accountIds = resolver.resolve(nameOrEmail).asIdSet(); + if (accountIds.isEmpty()) { + log.warn("User '{}' does not resolve to any account.", nameOrEmail); + return accountIds; + } + + Set<Id> activeAccountIds = + accountIds.stream().filter(this::isActive).collect(Collectors.toSet()); + if (activeAccountIds.isEmpty()) { + log.warn( + "User '{}' resolves to {} accounts {}, but none of them are active", + nameOrEmail, + accountIds.size(), + accountIds); + return activeAccountIds; + } + + Set<Id> fulllyMatchedAccountIds = + activeAccountIds.stream() + .filter(id -> isFullMatch(id, nameOrEmail)) + .collect(Collectors.toSet()); + if (fulllyMatchedAccountIds.isEmpty()) { + log.warn( + "User '{}' resolves to {} accounts {}, but does not correspond to any them", + nameOrEmail, + accountIds.size(), + accountIds); + return fulllyMatchedAccountIds; + } + + return accountIds; + } catch (IOException | ConfigInvalidException e) { + log.error("Error trying to resolve user " + nameOrEmail, e); + return Collections.emptySet(); + } + } + + private boolean isFullMatch(Account.Id id, String nameOrEmail) { + Optional<AccountState> accountState = byId.get(id); + if (!accountState.isPresent()) { + return false; + } + + Account account = accountState.get().account(); + return isFullNameMatch(account, nameOrEmail) + || nameOrEmail.equalsIgnoreCase(account.preferredEmail()) + || accountState.get().externalIds().stream() + .anyMatch(eid -> isEMailMatch(eid, nameOrEmail) || isUsernameMatch(eid, nameOrEmail)); + } + + private boolean isFullNameMatch(Account account, String fullName) { + return Optional.ofNullable(account.fullName()) + .filter(n -> n.trim().equalsIgnoreCase(fullName)) + .isPresent(); + } + + private boolean isUsernameMatch(ExternalId externalId, String username) { + return OptionalUtils.combine( + keySchemeRest(SCHEME_GERRIT, externalId.key()), + keySchemeRest(SCHEME_USERNAME, externalId.key())) + .filter(name -> name.equals(username)) + .isPresent(); + } + + private boolean isEMailMatch(ExternalId externalId, String email) { + ExternalId.Key externalKey = externalId.key(); + return OptionalUtils.combine( + Optional.ofNullable(externalId.email()).filter(mail -> mail.equalsIgnoreCase(email)), + keySchemeRest(SCHEME_MAILTO, externalKey).filter(mail -> mail.equalsIgnoreCase(email))) + .isPresent(); + } + + private boolean isActive(Account.Id accountId) { + return byId.get(accountId).map(AccountState::account).map(Account::isActive).orElse(false); + } + + private Optional<String> keySchemeRest(String scheme, ExternalId.Key key) { + if (scheme != null && key.isScheme(scheme)) { + return Optional.of(key.get().substring(scheme.length() + 1)); + } + return Optional.empty(); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/ConfigurationParser.java b/src/main/java/com/googlesource/gerrit/owners/common/ConfigurationParser.java new file mode 100644 index 0000000..921d967 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/ConfigurationParser.java
@@ -0,0 +1,170 @@ +// Copyright (C) 2017 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.googlesource.gerrit.owners.common.StreamUtils.iteratorStream; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.google.gerrit.entities.Account.Id; +import com.google.gerrit.extensions.client.InheritableBoolean; +import java.io.IOException; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ConfigurationParser { + private static final Logger log = LoggerFactory.getLogger(OwnersConfig.class); + private Accounts accounts; + + public ConfigurationParser(Accounts accounts) { + this.accounts = accounts; + } + + public OwnersConfig getOwnersConfig(byte[] yamlBytes) throws IOException { + final OwnersConfig ret = new OwnersConfig(); + JsonNode jsonNode = new ObjectMapper(new YAMLFactory()).readValue(yamlBytes, JsonNode.class); + 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)); + Optional<InheritableBoolean> autoOwnersApproved = parseAutoOwnersApproved(jsonNode); + + autoOwnersApproved.ifPresent(ret::setAutoOwnersApproved); + + addClassicMatcher(jsonNode, ret); + addMatchers(jsonNode, ret); + return ret; + } + + private Optional<InheritableBoolean> parseAutoOwnersApproved(JsonNode jsonNode) { + return Optional.ofNullable(jsonNode.get("auto-owners-approved")) + .map(JsonNode::asText) + .map(String::toUpperCase) + .map(InheritableBoolean::valueOf); + } + + private void addClassicMatcher(JsonNode jsonNode, OwnersConfig ret) { + ret.setOwners(toClassicOwnersList(jsonNode, "owners").collect(Collectors.toSet())); + ret.setReviewers(toClassicOwnersList(jsonNode, "reviewers").collect(Collectors.toSet())); + } + + private void addMatchers(JsonNode jsonNode, OwnersConfig ret) { + getNode(jsonNode, "matchers") + .map(m -> getMatchers(m)) + .ifPresent(m -> m.forEach(ret::addMatcher)); + } + + private Stream<Matcher> getMatchers(JsonNode node) { + return iteratorStream(node.iterator()) + .map(this::toMatcher) + .filter(Optional::isPresent) + .map(m -> m.get()); + } + + private static Stream<String> extractAsText(JsonNode node) { + if (node.isTextual()) { + return Stream.of(node.asText()); + } + return iteratorStream(node.iterator()).map(JsonNode::asText); + } + + private static <T> Stream<T> flatten(Optional<Stream<T>> optionalStream) { + return optionalStream.orElse(Stream.empty()); + } + + private Stream<String> toClassicOwnersList(JsonNode jsonNode, String sectionName) { + Stream<String> ownersStream = + Optional.ofNullable(jsonNode.get(sectionName)) + .map(ConfigurationParser::extractAsText) + .orElse(Stream.empty()); + return ownersStream; + } + + private Optional<Matcher> toMatcher(JsonNode node) { + Set<Id> owners = + getNode(node, "owners") + .map(ConfigurationParser::extractAsText) + .orElse(Stream.empty()) + .flatMap(o -> accounts.find(o).stream()) + .collect(Collectors.toSet()); + Set<String> groupOwners = + flatten( + getNode(node, "owners") + .map(ConfigurationParser::extractAsText) + .map(owns -> owns.map(PathOwnersEntry::stripOwnerDomain))) + .collect(Collectors.toSet()); + Set<Id> reviewers = + getNode(node, "reviewers") + .map(ConfigurationParser::extractAsText) + .orElse(Stream.empty()) + .flatMap(o -> accounts.find(o).stream()) + .collect(Collectors.toSet()); + + InheritableBoolean autoOwnersApproved = + parseAutoOwnersApproved(node).orElse(InheritableBoolean.INHERIT); + + Optional<Matcher> suffixMatcher = + getText(node, "suffix") + .map(el -> new SuffixMatcher(el, owners, reviewers, groupOwners, autoOwnersApproved)); + Optional<Matcher> regexMatcher = + getText(node, "regex") + .map(el -> new RegExMatcher(el, owners, reviewers, groupOwners, autoOwnersApproved)); + Optional<Matcher> partialRegexMatcher = + getText(node, "partial_regex") + .map( + el -> + new PartialRegExMatcher( + el, owners, reviewers, groupOwners, autoOwnersApproved)); + Optional<Matcher> exactMatcher = + getText(node, "exact") + .map(el -> new ExactMatcher(el, owners, reviewers, groupOwners, autoOwnersApproved)); + Optional<Matcher> genericMatcher = + getText(node, "generic") + .map(el -> new GenericMatcher(el, owners, reviewers, groupOwners, autoOwnersApproved)); + + return Optional.ofNullable( + suffixMatcher.orElseGet( + () -> + regexMatcher.orElseGet( + () -> + partialRegexMatcher.orElseGet( + () -> + exactMatcher.orElseGet( + () -> + genericMatcher.orElseGet( + () -> { + log.warn( + "Ignoring invalid element " + node.toString()); + return null; + })))))); + } + + private static Optional<String> getText(JsonNode node, String field) { + return Optional.ofNullable(node.get(field)).map(JsonNode::asText); + } + + private static Optional<JsonNode> getNode(JsonNode node, String field) { + return Optional.ofNullable(node.get(field)); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/ExactMatcher.java b/src/main/java/com/googlesource/gerrit/owners/common/ExactMatcher.java new file mode 100644 index 0000000..02b9ab5 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/ExactMatcher.java
@@ -0,0 +1,46 @@ +// Copyright (C) 2017 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; +import com.google.gerrit.entities.Account.Id; +import com.google.gerrit.extensions.client.InheritableBoolean; +import java.util.Set; + +public class ExactMatcher extends Matcher { + public ExactMatcher( + String path, + Set<Account.Id> owners, + Set<Account.Id> reviewers, + Set<String> groupOwners, + InheritableBoolean autoOwnersApproved) { + super(path, owners, reviewers, groupOwners, autoOwnersApproved); + } + + @Override + public boolean matches(String pathToMatch) { + return pathToMatch.equals(path); + } + + @Override + protected Matcher clone( + Set<Id> owners, + Set<Id> reviewers, + Set<String> groupOwners, + InheritableBoolean autoOwnersApproved) { + return new ExactMatcher(path, owners, reviewers, groupOwners, autoOwnersApproved); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/GenericMatcher.java b/src/main/java/com/googlesource/gerrit/owners/common/GenericMatcher.java new file mode 100644 index 0000000..f46ee7f --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/GenericMatcher.java
@@ -0,0 +1,32 @@ +// 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.common; + +import com.google.gerrit.entities.Account; +import com.google.gerrit.extensions.client.InheritableBoolean; +import java.util.Set; + +public class GenericMatcher extends RegExMatcher { + + public GenericMatcher( + String path, + Set<Account.Id> owners, + Set<Account.Id> reviewers, + Set<String> groupOwners, + InheritableBoolean autoOwnersApproved) { + super(path, owners, reviewers, groupOwners, autoOwnersApproved); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/InvalidOwnersFileException.java b/src/main/java/com/googlesource/gerrit/owners/common/InvalidOwnersFileException.java new file mode 100644 index 0000000..1914625 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/InvalidOwnersFileException.java
@@ -0,0 +1,30 @@ +// Copyright (C) 2024 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; + +public class InvalidOwnersFileException extends Exception { + private static final long serialVersionUID = 1L; + + public InvalidOwnersFileException( + String project, String ownersPath, String branch, Throwable reason) { + super(exceptionMessage(project, ownersPath, branch), reason); + } + + private static String exceptionMessage(String project, String ownersPath, String branch) { + return String.format( + "Invalid owners file: %s, in project: %s, on branch %s", ownersPath, project, branch); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/JgitWrapper.java b/src/main/java/com/googlesource/gerrit/owners/common/JgitWrapper.java new file mode 100644 index 0000000..17b8af9 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/JgitWrapper.java
@@ -0,0 +1,71 @@ +// Copyright (C) 2017 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 org.eclipse.jgit.lib.Constants.OBJ_BLOB; +import static org.eclipse.jgit.lib.FileMode.TYPE_FILE; +import static org.eclipse.jgit.lib.FileMode.TYPE_MASK; + +import com.google.gerrit.entities.RefNames; +import java.io.IOException; +import java.util.Optional; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.TreeWalk; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class JgitWrapper { + private static final Logger log = LoggerFactory.getLogger(JgitWrapper.class); + + public static Optional<byte[]> getBlobAsBytes(Repository repository, String revision, String path) + throws IOException { + Ref ref = repository.getRefDatabase().exactRef(RefNames.fullName(revision)); + if (ref == null) { + return Optional.empty(); + } + + try (final TreeWalk w = + TreeWalk.forPath( + repository, path, parseCommit(repository, ref.getLeaf().getObjectId()).getTree())) { + + return Optional.ofNullable(w) + .filter(walk -> (walk.getRawMode(0) & TYPE_MASK) == TYPE_FILE) + .map(walk -> walk.getObjectId(0)) + .flatMap(id -> readBlob(repository, id)); + } + } + + private static RevCommit parseCommit(Repository repository, ObjectId commit) throws IOException { + try (final RevWalk walk = new RevWalk(repository)) { + walk.setRetainBody(true); + return walk.parseCommit(commit); + } + } + + private static Optional<byte[]> readBlob(Repository repository, ObjectId id) { + try { + return Optional.of(repository.open(id, OBJ_BLOB).getCachedBytes(Integer.MAX_VALUE)); + } catch (Exception e) { + // TODO: are we sure we want to swallow this exception? + log.error("Unexpected error while reading Git Object " + id, e); + return Optional.empty(); + } + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/LabelDefinition.java b/src/main/java/com/googlesource/gerrit/owners/common/LabelDefinition.java new file mode 100644 index 0000000..08664fe --- /dev/null +++ b/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/src/main/java/com/googlesource/gerrit/owners/common/Matcher.java b/src/main/java/com/googlesource/gerrit/owners/common/Matcher.java new file mode 100644 index 0000000..278e75d --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/Matcher.java
@@ -0,0 +1,116 @@ +// Copyright (C) 2017 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.collect.ImmutableSet; +import com.google.gerrit.entities.Account; +import com.google.gerrit.entities.Account.Id; +import com.google.gerrit.extensions.client.InheritableBoolean; +import java.util.Set; + +public abstract class Matcher { + private Set<Account.Id> owners; + private Set<Account.Id> reviewers; + private Set<String> groupOwners; + protected String path; + private InheritableBoolean autoOwnersApproved; + + public Matcher( + String key, + Set<Account.Id> owners, + Set<Account.Id> reviewers, + Set<String> groupOwners, + InheritableBoolean autoOwnersApproved) { + this.path = key; + this.owners = owners; + this.reviewers = reviewers; + this.groupOwners = groupOwners; + this.autoOwnersApproved = autoOwnersApproved; + } + + @Override + public String toString() { + return "Matcher [path=" + + path + + ", owners=" + + owners + + ", groupOwners=" + + groupOwners + + ", reviewers=" + + reviewers + + ", autoOwnersApproved=" + + autoOwnersApproved + + "]"; + } + + public Set<Account.Id> getOwners() { + return owners; + } + + public Set<String> getGroupOwners() { + 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 void setPath(String path) { + this.path = path; + } + + public String getPath() { + return path; + } + + public InheritableBoolean getAutoOwnersApproved() { + return autoOwnersApproved; + } + + public abstract boolean matches(String pathToMatch); + + public Matcher merge(Matcher other) { + if (other == null) { + return this; + } + + return clone( + mergeSet(owners, other.owners), + mergeSet(reviewers, other.reviewers), + mergeSet(groupOwners, other.groupOwners), + other.autoOwnersApproved == InheritableBoolean.INHERIT + ? autoOwnersApproved + : other.getAutoOwnersApproved()); + } + + protected abstract Matcher clone( + Set<Id> owners, + Set<Id> reviewers, + Set<String> groupOwners, + InheritableBoolean autoOwnersApproved); + + private <T> Set<T> mergeSet(Set<T> set1, Set<T> set2) { + ImmutableSet.Builder<T> setBuilder = ImmutableSet.builder(); + return setBuilder.addAll(set1).addAll(set2).build(); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/OptionalUtils.java b/src/main/java/com/googlesource/gerrit/owners/common/OptionalUtils.java new file mode 100644 index 0000000..f35941d --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/OptionalUtils.java
@@ -0,0 +1,24 @@ +// Copyright (C) 2017 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 java.util.Optional; + +public class OptionalUtils { + + public static <T> Optional<T> combine(Optional<T> firstChoice, Optional<T> secondChoice) { + return Optional.ofNullable(firstChoice.orElse(secondChoice.orElse(null))); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/OwnersConfig.java b/src/main/java/com/googlesource/gerrit/owners/common/OwnersConfig.java new file mode 100644 index 0000000..1fce900 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/OwnersConfig.java
@@ -0,0 +1,111 @@ +// Copyright (c) 2013 VMware, Inc. All Rights Reserved. +// Copyright (C) 2017 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.gerrit.extensions.client.InheritableBoolean.INHERIT; + +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.google.gerrit.extensions.client.InheritableBoolean; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * OWNERS file model. + * + * <p>Used for de-serializing the OWNERS files. + */ +public class OwnersConfig { + /** Flag for marking that this OWNERS file inherits from the parent OWNERS. */ + private boolean inherited = true; + + private Set<String> owners = Sets.newHashSet(); + private Set<String> reviewers = Sets.newHashSet(); + + /** 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(); + + /** Ability to enable or disable the owners auto approval, when configured */ + private InheritableBoolean autoOwnersApproved = INHERIT; + + @Override + public String toString() { + return "OwnersConfig [inherited=" + + inherited + + ", owners=" + + owners + + ", matchers=" + + matchers + + ", label=" + + label + + ", autoOwnersApproved=" + + autoOwnersApproved + + "]"; + } + + public boolean isInherited() { + return inherited; + } + + public void setInherited(boolean inherited) { + this.inherited = inherited; + } + + public Set<String> getOwners() { + return owners; + } + + public void setOwners(Set<String> owners) { + this.owners = owners; + } + + public Set<String> getReviewers() { + return reviewers; + } + + public void setReviewers(Set<String> reviewers) { + this.reviewers = reviewers; + } + + public Map<String, Matcher> getMatchers() { + return matchers; + } + + 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; + } + + public InheritableBoolean getAutoOwnersApproved() { + return autoOwnersApproved; + } + + public void setAutoOwnersApproved(InheritableBoolean autoOwnersApproved) { + this.autoOwnersApproved = autoOwnersApproved; + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java b/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java new file mode 100644 index 0000000..d6dbc13 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/OwnersMap.java
@@ -0,0 +1,145 @@ +// Copyright (C) 2017 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.collect.HashMultimap; +import com.google.common.collect.Maps; +import com.google.common.collect.SetMultimap; +import com.google.common.collect.Sets; +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 { + private SetMultimap<String, Account.Id> pathOwners = HashMultimap.create(); + private SetMultimap<String, Account.Id> pathReviewers = HashMultimap.create(); + private Map<String, Matcher> matchers = Maps.newHashMap(); + 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 Set<String> fileOwnersAllowedAutoApproval = Sets.newHashSet(); + private Optional<LabelDefinition> label = Optional.empty(); + + @Override + public String toString() { + return "OwnersMap [pathOwners=" + pathOwners + ", matchers=" + matchers + "]"; + } + + public void setMatchers(Map<String, Matcher> matchers) { + this.matchers = matchers; + } + + public SetMultimap<String, Account.Id> getPathOwners() { + return pathOwners; + } + + public void setPathOwners(SetMultimap<String, Account.Id> pathOwners) { + this.pathOwners = pathOwners; + } + + public SetMultimap<String, Account.Id> getPathReviewers() { + return pathReviewers; + } + + public void setPathReviewers(SetMultimap<String, Account.Id> pathReviewers) { + this.pathReviewers = pathReviewers; + } + + public Map<String, Matcher> getMatchers() { + return matchers; + } + + public void addMatchers(Map<String, Matcher> matchers) { + this.matchers.putAll(matchers); + } + + public void addPathOwners(String ownersPath, Set<Id> owners) { + pathOwners.putAll(ownersPath, owners); + } + + public void addPathReviewers(String ownersPath, Set<Id> reviewers) { + pathOwners.putAll(ownersPath, reviewers); + } + + public Map<String, Set<Id>> getFileOwners() { + return fileOwners; + } + + public Map<String, Set<Id>> getFileReviewers() { + return fileReviewers; + } + + public Map<String, Set<String>> getFileGroupOwners() { + return fileGroupOwners; + } + + public Set<String> getFileOwnersAllowedAutoApproval() { + return fileOwnersAllowedAutoApproval; + } + + public void addFileOwners(String file, Set<Id> owners) { + if (owners.isEmpty()) { + return; + } + + Set<Id> set = fileOwners.get(file); + if (set != null) { + // add new owners removing duplicates + set.addAll(owners); + } else { + fileOwners.put(file, Sets.newHashSet(owners)); + } + } + + public void addFileReviewers(String file, Set<Id> reviewers) { + if (reviewers.isEmpty()) { + return; + } + + Set<Id> set = fileReviewers.get(file); + if (set != null) { + // add new owners removing duplicates + set.addAll(reviewers); + } else { + fileReviewers.put(file, Sets.newHashSet(reviewers)); + } + } + + public void addFileGroupOwners(String file, Set<String> groupOwners) { + if (groupOwners.isEmpty()) { + return; + } + + fileGroupOwners.computeIfAbsent(file, (f) -> Sets.newHashSet()).addAll(groupOwners); + } + + public void addAllowedFileForOwnersAutoApproval(String file) { + fileOwnersAllowedAutoApproval.add(file); + } + + public void removeAllowedFileForOwnersAutoApproval(String file) { + fileOwnersAllowedAutoApproval.remove(file); + } + + public Optional<LabelDefinition> getLabel() { + return label; + } + + public void setLabel(Optional<LabelDefinition> label) { + this.label = label; + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/PartialRegExMatcher.java b/src/main/java/com/googlesource/gerrit/owners/common/PartialRegExMatcher.java new file mode 100644 index 0000000..8fe7812 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/PartialRegExMatcher.java
@@ -0,0 +1,51 @@ +// Copyright (C) 2017 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; +import com.google.gerrit.entities.Account.Id; +import com.google.gerrit.extensions.client.InheritableBoolean; +import java.util.Set; +import java.util.regex.Pattern; + +// Javascript like regular expression matching substring +public class PartialRegExMatcher extends Matcher { + Pattern pattern; + + public PartialRegExMatcher( + String path, + Set<Account.Id> owners, + Set<Account.Id> reviewers, + Set<String> groupOwners, + InheritableBoolean autoOwnersApproved) { + super(path, owners, reviewers, groupOwners, autoOwnersApproved); + pattern = Pattern.compile(".*" + path + ".*"); + } + + @Override + public boolean matches(String pathToMatch) { + return pattern.matcher(pathToMatch).matches(); + } + + @Override + protected Matcher clone( + Set<Id> owners, + Set<Id> reviewers, + Set<String> groupOwners, + InheritableBoolean autoOwnersApproved) { + return new PartialRegExMatcher(path, owners, reviewers, groupOwners, autoOwnersApproved); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java b/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java new file mode 100644 index 0000000..71ce117 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/PathOwners.java
@@ -0,0 +1,577 @@ +// Copyright (c) 2013 VMware, Inc. All Rights Reserved. +// Copyright (C) 2017 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.gerrit.entities.Patch.COMMIT_MSG; +import static com.google.gerrit.entities.Patch.MERGE_LIST; +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; +import com.google.common.collect.Sets; +import com.google.gerrit.common.Nullable; +import com.google.gerrit.entities.Account; +import com.google.gerrit.entities.Account.Id; +import com.google.gerrit.entities.Patch; +import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.client.InheritableBoolean; +import com.google.gerrit.server.git.GitRepositoryManager; +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; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; +import org.eclipse.jgit.lib.Repository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Calculates the owners of a patch list. */ +// TODO(vspivak): provide assisted factory +public class PathOwners { + + private static final Logger log = LoggerFactory.getLogger(PathOwners.class); + + private enum MatcherLevel { + Regular, + Fallback, + CatchAll; + + static MatcherLevel forMatcher(Matcher matcher) { + return matcher instanceof GenericMatcher + ? (matcher.path.equals(".*") ? CatchAll : Fallback) + : Regular; + } + } + + private final SetMultimap<String, Account.Id> owners; + + private final SetMultimap<String, Account.Id> reviewers; + + private final Repository repository; + + private final List<Project.NameKey> parentProjectsNames; + + private final ConfigurationParser parser; + + private final Set<String> modifiedPaths; + + private final Accounts accounts; + + private final GitRepositoryManager repositoryManager; + + private final Map<String, Matcher> matchers; + + private final Map<String, Set<Id>> fileOwners; + + private final Map<String, Set<String>> fileGroupOwners; + + private final Set<String> fileOwnersAllowedAutoApproval; + + private final boolean expandGroups; + + private final Optional<LabelDefinition> label; + + public PathOwners( + Accounts accounts, + GitRepositoryManager repositoryManager, + Repository repository, + List<Project.NameKey> parentProjectsNames, + Optional<String> branchWhenEnabled, + Map<String, FileDiffOutput> fileDiffMap, + boolean expandGroups, + String project, + PathOwnersEntriesCache cache, + Optional<LabelDefinition> globalLabel) + throws InvalidOwnersFileException { + this( + accounts, + repositoryManager, + repository, + parentProjectsNames, + branchWhenEnabled, + getModifiedPaths(fileDiffMap), + expandGroups, + project, + cache, + globalLabel); + } + + public PathOwners( + Accounts accounts, + GitRepositoryManager repositoryManager, + Repository repository, + List<Project.NameKey> parentProjectsNames, + Optional<String> branchWhenEnabled, + DiffSummary diffSummary, + boolean expandGroups, + String project, + PathOwnersEntriesCache cache, + Optional<LabelDefinition> globalLabel) + throws InvalidOwnersFileException { + this( + accounts, + repositoryManager, + repository, + parentProjectsNames, + branchWhenEnabled, + ImmutableSet.copyOf(diffSummary.getPaths()), + expandGroups, + project, + cache, + globalLabel); + } + + public PathOwners( + Accounts accounts, + GitRepositoryManager repositoryManager, + Repository repository, + List<Project.NameKey> parentProjectsNames, + Optional<String> branchWhenEnabled, + Set<String> modifiedPaths, + boolean expandGroups, + String project, + PathOwnersEntriesCache cache, + Optional<LabelDefinition> globalLabel) + throws InvalidOwnersFileException { + this.repositoryManager = repositoryManager; + this.repository = repository; + this.parentProjectsNames = parentProjectsNames; + this.modifiedPaths = modifiedPaths; + this.parser = new ConfigurationParser(accounts); + this.accounts = accounts; + this.expandGroups = expandGroups; + + OwnersMap map; + if (branchWhenEnabled.isPresent()) { + map = fetchOwners(project, branchWhenEnabled.get(), cache); + } else { + map = new OwnersMap(); + } + owners = Multimaps.unmodifiableSetMultimap(map.getPathOwners()); + reviewers = Multimaps.unmodifiableSetMultimap(map.getPathReviewers()); + matchers = map.getMatchers(); + fileOwners = map.getFileOwners(); + fileGroupOwners = map.getFileGroupOwners(); + fileOwnersAllowedAutoApproval = map.getFileOwnersAllowedAutoApproval(); + label = globalLabel.or(map::getLabel); + } + + /** + * Returns a read only view of the paths to owners mapping. + * + * @return multimap of paths to owners + */ + public SetMultimap<String, Account.Id> get() { + return owners; + } + + /** + * Returns a read only view of the paths to reviewers mapping. + * + * @return multimap of paths to reviewers + */ + public SetMultimap<String, Account.Id> getReviewers() { + return reviewers; + } + + public Map<String, Matcher> getMatchers() { + return matchers; + } + + public Map<String, Set<Account.Id>> getFileOwners() { + return fileOwners; + } + + public Map<String, Set<String>> getFileGroupOwners() { + return fileGroupOwners; + } + + public Set<String> getFileOwnersAllowedAutoApproval() { + return fileOwnersAllowedAutoApproval; + } + + public boolean expandGroups() { + 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. + * + * @return A structure containing matchers paths to owners + * @throws InvalidOwnersFileException when reading/parsing OWNERS file fails + */ + private OwnersMap fetchOwners(String project, String branch, PathOwnersEntriesCache cache) + throws InvalidOwnersFileException { + OwnersMap ownersMap = new OwnersMap(); + try { + // Using a `map` would have needed a try/catch inside the lamba, resulting in more code + List<ReadOnlyPathOwnersEntry> parentsPathOwnersEntries = + getPathOwnersEntries(parentProjectsNames, RefNames.REFS_CONFIG, cache); + Optional<ReadOnlyPathOwnersEntry> projectEntry = + getPathOwnersEntryOrEmpty(project, repository, RefNames.REFS_CONFIG, cache); + PathOwnersEntry rootEntry = getPathOwnersEntryOrNew(project, repository, branch, cache); + + Map<String, PathOwnersEntry> entries = new HashMap<>(); + PathOwnersEntry currentEntry = null; + for (String path : modifiedPaths) { + currentEntry = + resolvePathEntry( + project, + path, + branch, + projectEntry, + parentsPathOwnersEntries, + rootEntry, + entries, + cache); + + // add owners and reviewers to file for matcher predicates + ownersMap.addFileOwners(path, currentEntry.getOwners()); + ownersMap.addFileReviewers(path, currentEntry.getReviewers()); + ownersMap.addFileGroupOwners(path, currentEntry.getGroupOwners()); + if (currentEntry.isAutoOwnersApproved()) { + ownersMap.addAllowedFileForOwnersAutoApproval(path); + } + // Only add the path to the OWNERS file to reduce the number of + // entries in the result + if (currentEntry.getOwnersPath() != null) { + ownersMap.addPathOwners(currentEntry.getOwnersPath(), currentEntry.getOwners()); + ownersMap.addPathReviewers(currentEntry.getOwnersPath(), currentEntry.getReviewers()); + } + ownersMap.addMatchers(currentEntry.getMatchers()); + } + + // We need to only keep matchers that match files in the patchset + Map<String, Matcher> matchers = ownersMap.getMatchers(); + if (matchers.size() > 0) { + HashMap<String, Matcher> newMatchers = Maps.newHashMap(); + // extra loop + for (String path : modifiedPaths) { + processMatcherPerPath(matchers, newMatchers, path, ownersMap); + } + if (matchers.size() != newMatchers.size()) { + ownersMap.setMatchers(newMatchers); + } + } + ownersMap.setLabel(Optional.ofNullable(currentEntry).flatMap(PathOwnersEntry::getLabel)); + } catch (IOException e) { + log.warn("Opening repository {} failed", project, e); + } catch (ExecutionException e) { + log.warn("Reading OWNERS file for {} project, from cache failed", project, e); + } + return ownersMap; + } + + private List<ReadOnlyPathOwnersEntry> getPathOwnersEntries( + List<Project.NameKey> projectNames, String branch, PathOwnersEntriesCache cache) + throws IOException, InvalidOwnersFileException, ExecutionException { + ImmutableList.Builder<ReadOnlyPathOwnersEntry> pathOwnersEntries = ImmutableList.builder(); + for (Project.NameKey projectName : projectNames) { + try (Repository repo = repositoryManager.openRepository(projectName)) { + Optional<ReadOnlyPathOwnersEntry> pathOwnersEntry = + getPathOwnersEntryOrEmpty(projectName.get(), repo, branch, cache); + if (pathOwnersEntry.isPresent()) { + pathOwnersEntries = pathOwnersEntries.add(pathOwnersEntry.get()); + } + } + } + return pathOwnersEntries.build(); + } + + private Optional<ReadOnlyPathOwnersEntry> getPathOwnersEntryOrEmpty( + String project, Repository repo, String branch, PathOwnersEntriesCache cache) + throws InvalidOwnersFileException, ExecutionException { + return getPathOwnersEntry(project, repo, branch, cache).map(v -> (ReadOnlyPathOwnersEntry) v); + } + + private PathOwnersEntry getPathOwnersEntryOrNew( + String project, Repository repo, String branch, PathOwnersEntriesCache cache) + throws InvalidOwnersFileException, ExecutionException { + return getPathOwnersEntry(project, repo, branch, cache).orElseGet(PathOwnersEntry::new); + } + + private Optional<PathOwnersEntry> getPathOwnersEntry( + String project, Repository repo, String branch, PathOwnersEntriesCache cache) + throws InvalidOwnersFileException, ExecutionException { + String rootPath = "OWNERS"; + return unwrapInvalidOwnersFileException( + () -> + cache + .get( + project, + branch, + rootPath, + () -> getOwnersConfig(project, repo, rootPath, branch)) + .map( + conf -> + new PathOwnersEntry( + rootPath, + conf, + accounts, + Optional.empty(), + Collections.emptySet(), + Collections.emptySet(), + Optional.empty(), + Collections.emptySet(), + Collections.emptySet()))); + } + + private void processMatcherPerPath( + Map<String, Matcher> fullMatchers, + HashMap<String, Matcher> newMatchers, + String path, + OwnersMap ownersMap) { + + Map<MatcherLevel, List<Matcher>> matchersByLevel = + fullMatchers.values().stream().collect(Collectors.groupingBy(MatcherLevel::forMatcher)); + if (findAndAddMatchers( + newMatchers, path, ownersMap, matchersByLevel.get(MatcherLevel.Regular))) { + return; + } + + if (findAndAddMatchers( + newMatchers, path, ownersMap, matchersByLevel.get(MatcherLevel.Fallback))) { + return; + } + + findAndAddMatchers(newMatchers, path, ownersMap, matchersByLevel.get(MatcherLevel.CatchAll)); + } + + private boolean findAndAddMatchers( + HashMap<String, Matcher> newMatchers, + String path, + OwnersMap ownersMap, + @Nullable List<Matcher> matchers) { + if (matchers == null) { + return false; + } + + boolean matchingFound = false; + + for (Matcher matcher : matchers) { + if (matcher.matches(path)) { + newMatchers.put(matcher.getPath(), matcher); + ownersMap.addFileOwners(path, matcher.getOwners()); + ownersMap.addFileGroupOwners(path, matcher.getGroupOwners()); + ownersMap.addFileReviewers(path, matcher.getReviewers()); + switch (matcher.getAutoOwnersApproved()) { + // We have an explicit allowance for this matcher. + case InheritableBoolean.TRUE: + ownersMap.addAllowedFileForOwnersAutoApproval(path); + break; + // We have an explicit disable for this matcher. + case InheritableBoolean.FALSE: + ownersMap.removeAllowedFileForOwnersAutoApproval(path); + break; + + // There is no matcher-level specification of auto-owner-approved, + // therefore the OWNERS-level resolution still applies. + default: + break; + } + matchingFound = true; + } + } + return matchingFound; + } + + private PathOwnersEntry resolvePathEntry( + String project, + String path, + String branch, + Optional<ReadOnlyPathOwnersEntry> projectEntry, + List<ReadOnlyPathOwnersEntry> parentsPathOwnersEntries, + PathOwnersEntry rootEntry, + Map<String, PathOwnersEntry> entries, + PathOwnersEntriesCache cache) + throws InvalidOwnersFileException, ExecutionException { + String[] parts = path.split("/"); + StringBuilder builder = new StringBuilder(); + + // Inherit from Project if OWNER in root enables inheritance + if (rootEntry.isInherited()) { + projectEntry.ifPresent(pe -> calculateCurrentEntry(pe, rootEntry)); + } + + // Inherit from Parent Project if OWNER in Project enables inheritance + for (ReadOnlyPathOwnersEntry parentPathOwnersEntry : parentsPathOwnersEntries) { + if (projectEntry.isEmpty() || projectEntry.get().isInherited()) { + calculateCurrentEntry(parentPathOwnersEntry, rootEntry); + } + } + + // Iterate through the parent paths, not including the file name + // itself + PathOwnersEntry currentEntry = rootEntry; + for (int i = 0; i < parts.length - 1; i++) { + String part = parts[i]; + builder.append(part).append("/"); + String partial = builder.toString(); + + // Skip if we already parsed this path + if (entries.containsKey(partial)) { + currentEntry = entries.get(partial); + } else { + String ownersPath = partial + "OWNERS"; + PathOwnersEntry pathFallbackEntry = currentEntry; + currentEntry = + unwrapInvalidOwnersFileException( + () -> + cache + .get( + project, + branch, + ownersPath, + () -> getOwnersConfig(project, 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, + Optional.of(pathFallbackEntry.isAutoOwnersApproved()), + inheritedMatchers, + groupOwners); + }) + .orElse(pathFallbackEntry)); + entries.put(partial, currentEntry); + } + } + return currentEntry; + } + + private void calculateCurrentEntry( + ReadOnlyPathOwnersEntry projectEntry, PathOwnersEntry currentEntry) { + for (Matcher matcher : projectEntry.getMatchers().values()) { + if (!currentEntry.hasMatcher(matcher.getPath())) { + currentEntry.addMatcher(matcher); + } + } + if (currentEntry.getOwners().isEmpty()) { + currentEntry.setOwners(projectEntry.getOwners()); + } + if (currentEntry.getOwnersPath() == null) { + currentEntry.setOwnersPath(projectEntry.getOwnersPath()); + } + if (currentEntry.getLabel().isEmpty()) { + currentEntry.setLabel(projectEntry.getLabel()); + } + if (!currentEntry.hasExplicitAutoOwnersApproved()) { + currentEntry.setAutoOwnersApproved(projectEntry.isAutoOwnersApproved()); + } + } + + /** + * Parses the diff list for any paths that were modified. + * + * @return set of modified paths. + */ + private static Set<String> getModifiedPaths(Map<String, FileDiffOutput> patchList) { + Set<String> paths = Sets.newHashSet(); + for (Map.Entry<String, FileDiffOutput> patch : patchList.entrySet()) { + // Ignore commit message and Merge List + 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.getValue().changeType() == Patch.ChangeType.RENAMED) { + paths.add(patch.getValue().oldPath().get()); + } + } + } + return paths; + } + + /** + * Returns the parsed FileOwnersConfig file for the given path if it exists. + * + * @return Optional(config) or Optional.empty if it doesn't exist + * @throws InvalidOwnersFileException when reading/parsing of the OWNERS file fails + */ + private Optional<OwnersConfig> getOwnersConfig( + String project, Repository repo, String ownersPath, String branch) + throws InvalidOwnersFileException { + try { + Optional<byte[]> configBytes = getBlobAsBytes(repo, branch, ownersPath); + if (configBytes.isEmpty()) { + return Optional.empty(); + } + return Optional.of(parser.getOwnersConfig(configBytes.get())); + } catch (IOException e) { + throw new InvalidOwnersFileException(project, ownersPath, branch, e); + } + } + + @FunctionalInterface + private interface Executable<T> { + T call() throws ExecutionException; + } + + /** + * Unwraps the InvalidOwnersFileException from the ExecutionException so that callers can focus on + * handling InvalidOwnersFileException if/when needed. Note that ExecutionException is thrown only + * when loading values to PathOwnersEntriesCache fails. + * + * @throws InvalidOwnersFileException when call throws ExecutionException that is matchable with + * InvalidOwnersFileException + * @throws ExecutionException in all other cases + */ + private <T> T unwrapInvalidOwnersFileException(Executable<T> action) + throws InvalidOwnersFileException, ExecutionException { + try { + return action.call(); + } catch (ExecutionException e) { + if (e.getCause() instanceof InvalidOwnersFileException) { + throw (InvalidOwnersFileException) e.getCause(); + } + throw e; + } + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCache.java b/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCache.java new file mode 100644 index 0000000..aea33ff --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCache.java
@@ -0,0 +1,160 @@ +// 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.cache.RemovalNotification; +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.annotations.PluginName; +import com.google.gerrit.extensions.events.GitBatchRefUpdateListener; +import com.google.gerrit.extensions.registration.DynamicSet; +import com.google.gerrit.server.cache.CacheModule; +import com.google.gerrit.server.cache.CacheRemovalListener; +import com.google.gerrit.server.config.AllUsersName; +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; + +public interface PathOwnersEntriesCache { + String CACHE_NAME = "path_owners_entries"; + + static Module module() { + return new CacheModule() { + @Override + protected void configure() { + cache(CACHE_NAME, Key.class, new TypeLiteral<Optional<OwnersConfig>>() {}) + .maximumWeight(Long.MAX_VALUE) + .expireAfterWrite(Duration.ofSeconds(60)); + bind(PathOwnersEntriesCache.class).to(PathOwnersEntriesCacheImpl.class); + DynamicSet.bind(binder(), GitBatchRefUpdateListener.class) + .to(OwnersRefUpdateListener.class); + DynamicSet.bind(binder(), CacheRemovalListener.class).to(OwnersCacheRemovalListener.class); + } + }; + } + + Optional<OwnersConfig> get( + String project, String branch, String path, Callable<Optional<OwnersConfig>> loader) + throws ExecutionException; + + void invalidate(String project, String branch); + + void invalidateIndexKey(Key key); + + class Key { + final String project; + final String branch; + final String path; + + Key(String project, String branch, String path) { + this.project = project; + this.branch = branch; + this.path = path; + } + + @Override + public int hashCode() { + return Objects.hash(branch, path, project); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj == null) { + return false; + } + + if (getClass() != obj.getClass()) { + return false; + } + + Key other = (Key) obj; + return Objects.equals(branch, other.branch) + && Objects.equals(path, other.path) + && Objects.equals(project, other.project); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("Key [project="); + builder.append(project); + builder.append(", branch="); + builder.append(branch); + builder.append(", path="); + builder.append(path); + builder.append("]"); + return builder.toString(); + } + } + + @Singleton + class OwnersRefUpdateListener implements GitBatchRefUpdateListener { + private final PathOwnersEntriesCache cache; + private final String allUsersName; + + @Inject + OwnersRefUpdateListener(PathOwnersEntriesCache cache, AllUsersName allUsersName) { + this.cache = cache; + this.allUsersName = allUsersName.get(); + } + + @Override + public void onGitBatchRefUpdate(GitBatchRefUpdateListener.Event event) { + String projectName = event.getProjectName(); + if (!allUsersName.equals(projectName)) { + event.getUpdatedRefs().stream() + .filter(refUpdate -> supportedEvent(refUpdate.getRefName())) + .forEach(refUpdate -> cache.invalidate(projectName, refUpdate.getRefName())); + } + } + + private boolean supportedEvent(String refName) { + return (refName.equals(RefNames.REFS_CONFIG) || !RefNames.isGerritRef(refName)); + } + } + + @Singleton + class OwnersCacheRemovalListener implements CacheRemovalListener<Key, PathOwnersEntry> { + private final PathOwnersEntriesCache cache; + private final String cacheName; + + @Inject + OwnersCacheRemovalListener(@PluginName String pluginName, PathOwnersEntriesCache cache) { + this.cache = cache; + this.cacheName = String.format("%s.%s", pluginName, CACHE_NAME); + } + + @Override + public void onRemoval( + String pluginName, + String cacheName, + RemovalNotification<Key, PathOwnersEntry> notification) { + if (!this.cacheName.equals(cacheName)) { + return; + } + + cache.invalidateIndexKey(notification.getKey()); + } + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheImpl.java b/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheImpl.java new file mode 100644 index 0000000..c882414 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheImpl.java
@@ -0,0 +1,94 @@ +// 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.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +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, Optional<OwnersConfig>> cache; + private final Multimap<String, Key> keysIndex; + private final LoadingCache<String, Object> keyLocks; + + @Inject + PathOwnersEntriesCacheImpl(@Named(CACHE_NAME) Cache<Key, Optional<OwnersConfig>> cache) { + this.cache = cache; + this.keysIndex = HashMultimap.create(); + this.keyLocks = + CacheBuilder.newBuilder() + .expireAfterAccess(Duration.ofMinutes(10L)) + .build(CacheLoader.from(Object::new)); + } + + @Override + 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, + () -> { + Optional<OwnersConfig> entry = loader.call(); + String indexKey = indexKey(project, branch); + synchronized (keyLocks.getUnchecked(indexKey)) { + keysIndex.put(indexKey, key); + } + return entry; + }); + } + + @Override + public void invalidate(String project, String branch) { + String indexKey = indexKey(project, branch); + Collection<Key> keysToInvalidate; + + synchronized (keyLocks.getUnchecked(indexKey)) { + keysToInvalidate = keysIndex.removeAll(indexKey); + } + + keysToInvalidate.forEach(cache::invalidate); + } + + @Override + public void invalidateIndexKey(Key key) { + String indexKey = indexKey(key.project, key.branch); + + synchronized (keyLocks.getUnchecked(indexKey)) { + Collection<Key> values = keysIndex.asMap().get(indexKey); + if (values != null) { + values.remove(key); + } + } + } + + private String indexKey(String project, String branch) { + return new StringBuilder(project).append('@').append(RefNames.fullName(branch)).toString(); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java b/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java new file mode 100644 index 0000000..8402642 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/PathOwnersEntry.java
@@ -0,0 +1,205 @@ +// Copyright (c) 2013 VMware, Inc. All Rights Reserved. +// Copyright (C) 2017 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.base.Splitter; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.google.gerrit.entities.Account; +import com.google.gerrit.extensions.client.InheritableBoolean; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Path Owners Entry. + * + * <p>Used internally by PathOwners to represent and compute the owners at a specific path. + */ +class PathOwnersEntry extends ReadOnlyPathOwnersEntry { + public PathOwnersEntry() { + super(true); + } + + public PathOwnersEntry( + String path, + OwnersConfig config, + Accounts accounts, + Optional<LabelDefinition> inheritedLabel, + Set<Account.Id> inheritedOwners, + Set<Account.Id> inheritedReviewers, + Optional<Boolean> autoOwnersApproved, + Collection<Matcher> inheritedMatchers, + Set<String> inheritedGroupOwners) { + super(config.isInherited()); + this.explicitAutoOwnersApproved = config.getAutoOwnersApproved() != InheritableBoolean.INHERIT; + this.ownersPath = path; + this.owners = + config.getOwners().stream() + .flatMap(o -> accounts.find(o).stream()) + .collect(Collectors.toSet()); + this.reviewers = + config.getReviewers().stream() + .flatMap(o -> accounts.find(o).stream()) + .collect(Collectors.toSet()); + this.groupOwners = + config.getOwners().stream() + .map(PathOwnersEntry::stripOwnerDomain) + .collect(Collectors.toSet()); + this.matchers = config.getMatchers(); + + if (config.isInherited()) { + this.owners.addAll(inheritedOwners); + this.groupOwners.addAll(inheritedGroupOwners); + this.reviewers.addAll(inheritedReviewers); + if (config.getAutoOwnersApproved() == InheritableBoolean.INHERIT) { + autoOwnersApproved.ifPresent(this::setAutoOwnersApproved); + } else { + setAutoOwnersApproved(config.getAutoOwnersApproved() == InheritableBoolean.TRUE); + } + for (Matcher matcher : inheritedMatchers) { + addMatcher(matcher); + } + this.label = config.getLabel().or(() -> inheritedLabel); + } else { + this.label = config.getLabel(); + // Default to false unless the OWNERS file explicitly sets it to true. + this.setAutoOwnersApproved(config.getAutoOwnersApproved() == InheritableBoolean.TRUE); + } + } + + public void addMatcher(Matcher matcher) { + Matcher currMatchers = this.matchers.get(matcher.getPath()); + 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 boolean autoOwnersApproved; + protected boolean explicitAutoOwnersApproved; + + protected ReadOnlyPathOwnersEntry(boolean inherited) { + this.inherited = inherited; + label = Optional.empty(); + } + + public Map<String, Matcher> getMatchers() { + return matchers; + } + + public Set<Account.Id> getOwners() { + return owners; + } + + public Set<String> getGroupOwners() { + return groupOwners; + } + + public Set<Account.Id> getReviewers() { + return reviewers; + } + + public String getOwnersPath() { + return ownersPath; + } + + public boolean isInherited() { + return inherited; + } + + public Optional<LabelDefinition> getLabel() { + return label; + } + + public boolean isAutoOwnersApproved() { + return autoOwnersApproved; + } + + public boolean hasExplicitAutoOwnersApproved() { + return explicitAutoOwnersApproved; + } + + public void setAutoOwnersApproved(boolean autoOwnersApproved) { + this.autoOwnersApproved = autoOwnersApproved; + } + + public boolean hasMatcher(String path) { + return this.matchers.containsKey(path); + } + + 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/src/main/java/com/googlesource/gerrit/owners/common/PluginSettings.java b/src/main/java/com/googlesource/gerrit/owners/common/PluginSettings.java new file mode 100644 index 0000000..951b133 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/PluginSettings.java
@@ -0,0 +1,135 @@ +// 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.common; + +import com.google.common.base.Suppliers; +import com.google.common.collect.ImmutableSet; +import com.google.gerrit.entities.Project; +import com.google.gerrit.extensions.annotations.PluginName; +import com.google.gerrit.server.config.PluginConfig; +import com.google.gerrit.server.config.PluginConfigFactory; +import com.google.gerrit.server.project.NoSuchProjectException; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.util.Optional; +import java.util.function.Supplier; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.lib.Constants; + +/** Global owners plugin's settings defined globally or on a per-project basis. */ +@Singleton +public class PluginSettings { + private final ImmutableSet<String> disabledBranchesPatterns; + private final PluginConfigFactory configFactory; + private final String ownersPluginName; + private final Config globalPluginConfig; + private final boolean expandGroups; + private final boolean enableSubmitRequirement; + + private final Supplier<Optional<LabelDefinition>> globalLabel; + + @Inject + public PluginSettings(PluginConfigFactory configFactory, @PluginName String ownersPluginName) { + this.configFactory = configFactory; + this.ownersPluginName = ownersPluginName; + + this.globalPluginConfig = configFactory.getGlobalPluginConfig(ownersPluginName); + disabledBranchesPatterns = + ImmutableSet.copyOf(globalPluginConfig.getStringList("owners", "disable", "branch")); + + this.expandGroups = globalPluginConfig.getBoolean("owners", "expandGroups", true); + this.enableSubmitRequirement = + globalPluginConfig.getBoolean("owners", "enableSubmitRequirement", false); + + this.globalLabel = + Suppliers.memoize( + () -> LabelDefinition.parse(globalPluginConfig.getString("owners", null, "label"))); + } + + /** + * Branches that should be ignored for the OWNERS processing. + * + * @return set of branches regex + */ + public ImmutableSet<String> disabledBranchPatterns() { + return disabledBranchesPatterns; + } + + /** + * Check if the branch or ref is enabled for processing. + * + * <p>NOTE: If the branch does not start with 'refs/heads' it will then normalized into a + * ref-name. + * + * @param branch or ref name + * @return true if the branch or ref is disabled for processing. + */ + public boolean isBranchDisabled(String branch) { + String normalizedRef = normalizeRef(branch); + return disabledBranchesPatterns.stream().anyMatch(normalizedRef::matches); + } + + /** Returns true if the groups in the OWNERS file should be expanded in a list of account ids. */ + public boolean expandGroups() { + 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. + * + * @param projectKey project name + * @return project-specific plugin config + * @throws NoSuchProjectException if the project cannot be found + */ + public PluginConfig projectSpecificConfig(Project.NameKey projectKey) + throws NoSuchProjectException { + return configFactory.getFromProjectConfigWithInheritance(projectKey, ownersPluginName); + } + + /** + * Global definition of the review label to use for the owners' plugin. + * + * <p>When the global label is set in the plugin.owners.label settings the value overrides any + * label name defined in the OWNERS files of any repository. + * + * @return the global label definition or {@link Optional#empty()} if there isn't any global label + * definition. + */ + public Optional<LabelDefinition> globalLabel() { + return globalLabel.get(); + } + + // Logic copied from JGit's TestRepository + private static String normalizeRef(String ref) { + if (Constants.HEAD.equals(ref)) { + // nothing + } else if ("FETCH_HEAD".equals(ref)) { + // nothing + } else if ("MERGE_HEAD".equals(ref)) { + // nothing + } else if (ref.startsWith(Constants.R_REFS)) { + // nothing + } else ref = Constants.R_HEADS + ref; + return ref; + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/RegExMatcher.java b/src/main/java/com/googlesource/gerrit/owners/common/RegExMatcher.java new file mode 100644 index 0000000..d7920a8 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/RegExMatcher.java
@@ -0,0 +1,50 @@ +// Copyright (C) 2017 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; +import com.google.gerrit.entities.Account.Id; +import com.google.gerrit.extensions.client.InheritableBoolean; +import java.util.Set; +import java.util.regex.Pattern; + +public class RegExMatcher extends Matcher { + Pattern pattern; + + public RegExMatcher( + String path, + Set<Account.Id> owners, + Set<Account.Id> reviewers, + Set<String> groupOwners, + InheritableBoolean autoOwnersApproved) { + super(path, owners, reviewers, groupOwners, autoOwnersApproved); + pattern = Pattern.compile(path); + } + + @Override + public boolean matches(String pathToMatch) { + return pattern.matcher(pathToMatch).matches(); + } + + @Override + protected Matcher clone( + Set<Id> owners, + Set<Id> reviewers, + Set<String> groupOwners, + InheritableBoolean autoOwnersApproved) { + return new RegExMatcher(path, owners, reviewers, groupOwners, autoOwnersApproved); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/StreamUtils.java b/src/main/java/com/googlesource/gerrit/owners/common/StreamUtils.java new file mode 100644 index 0000000..0f8bac3 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/StreamUtils.java
@@ -0,0 +1,27 @@ +// Copyright (C) 2017 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 java.util.Iterator; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +public class StreamUtils { + + public static <T> Stream<T> iteratorStream(Iterator<T> sourceIterator) { + Iterable<T> iterable = () -> sourceIterator; + return StreamSupport.stream(iterable.spliterator(), false); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/owners/common/SuffixMatcher.java b/src/main/java/com/googlesource/gerrit/owners/common/SuffixMatcher.java new file mode 100644 index 0000000..b9a2fdb --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/owners/common/SuffixMatcher.java
@@ -0,0 +1,46 @@ +// Copyright (C) 2017 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; +import com.google.gerrit.entities.Account.Id; +import com.google.gerrit.extensions.client.InheritableBoolean; +import java.util.Set; + +public class SuffixMatcher extends Matcher { + public SuffixMatcher( + String path, + Set<Account.Id> owners, + Set<Account.Id> reviewers, + Set<String> groupOwners, + InheritableBoolean autoOwnersApproved) { + super(path, owners, reviewers, groupOwners, autoOwnersApproved); + } + + @Override + public boolean matches(String pathToMatch) { + return pathToMatch.endsWith(path); + } + + @Override + protected Matcher clone( + Set<Id> owners, + Set<Id> reviewers, + Set<String> groupOwners, + InheritableBoolean autoOwnersApproved) { + return new SuffixMatcher(path, owners, reviewers, groupOwners, autoOwnersApproved); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/owners/api/OwnersAttentionSetIT.java b/src/test/java/com/googlesource/gerrit/owners/api/OwnersAttentionSetIT.java new file mode 100644 index 0000000..8a3ec18 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/owners/api/OwnersAttentionSetIT.java
@@ -0,0 +1,67 @@ +// 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.api; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gerrit.acceptance.LightweightPluginDaemonTest; +import com.google.gerrit.acceptance.TestPlugin; +import com.google.gerrit.entities.Account.Id; +import com.google.gerrit.extensions.common.ChangeInfo; +import com.google.gerrit.extensions.registration.DynamicItem; +import com.google.inject.AbstractModule; +import com.google.inject.Inject; +import com.google.inject.Module; +import com.google.inject.Scopes; +import java.util.Collection; +import org.junit.Test; + +@TestPlugin( + name = "owners-api", + sysModule = "com.googlesource.gerrit.owners.api.OwnersAttentionSetIT$TestModule") +public class OwnersAttentionSetIT extends LightweightPluginDaemonTest { + + @Inject private DynamicItem<OwnersAttentionSet> ownerAttentionSetItem; + + @Override + public Module createModule() { + return new OwnersApiModule(); + } + + public static class TestModule extends AbstractModule { + @Override + protected void configure() { + DynamicItem.bind(binder(), OwnersAttentionSet.class) + .to(SelectFirstOwnerForAttentionSet.class) + .in(Scopes.SINGLETON); + } + } + + public static class SelectFirstOwnerForAttentionSet implements OwnersAttentionSet { + @Override + public Collection<Id> addToAttentionSet(ChangeInfo changeInfo, Collection<Id> owners) { + return null; + } + } + + @Test + public void shouldAllowOwnersAttentionSetOverride() { + OwnersAttentionSet attentionSetSelector = ownerAttentionSetItem.get(); + + assertThat(attentionSetSelector).isNotNull(); + assertThat(attentionSetSelector.getClass()).isEqualTo(SelectFirstOwnerForAttentionSet.class); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/owners/common/ClassicConfig.java b/src/test/java/com/googlesource/gerrit/owners/common/ClassicConfig.java new file mode 100644 index 0000000..a9e78b1 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/owners/common/ClassicConfig.java
@@ -0,0 +1,41 @@ +// Copyright (C) 2017 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; +import org.junit.Ignore; + +@Ignore +public class ClassicConfig extends Config { + public static final String USER_A = "user-a"; + public static final String USER_B = "user-b"; + public static final String USER_C = "user-c"; + public static final String EMAIL_DOMAIN = "@email.com"; + public static final String USER_A_EMAIL_COM = USER_A + EMAIL_DOMAIN; + public static final String USER_B_EMAIL_COM = USER_B + EMAIL_DOMAIN; + public static final String USER_C_EMAIL_COM = USER_C + EMAIL_DOMAIN; + public static final Account.Id USER_A_ID = Account.id(1); + public static final Account.Id USER_B_ID = Account.id(2); + public static final Account.Id USER_C_ID = Account.id(3); + + @Override + public void setup() throws Exception { + accounts.put(USER_A_EMAIL_COM, USER_A_ID); + accounts.put(USER_B_EMAIL_COM, USER_B_ID); + accounts.put(USER_C_EMAIL_COM, USER_C_ID); + + super.setup(); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/owners/common/Config.java b/src/test/java/com/googlesource/gerrit/owners/common/Config.java new file mode 100644 index 0000000..e531a3f --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/owners/common/Config.java
@@ -0,0 +1,119 @@ +// Copyright (C) 2017 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 org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; + +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.PatchListEntry; +import java.io.IOException; +import java.util.Optional; +import org.eclipse.jgit.lib.Repository; +import org.junit.Ignore; +import org.powermock.api.easymock.PowerMock; + +@Ignore +public abstract class Config { + protected GitRepositoryManager repositoryManager; + protected Repository repository; + protected Repository parentRepository1; + protected Repository parentRepository2; + protected ConfigurationParser parser; + protected TestAccounts accounts = new TestAccounts(); + protected Optional<String> branch = Optional.of("master"); + + public void setup() throws Exception { + PowerMock.mockStatic(JgitWrapper.class); + + repositoryManager = PowerMock.createMock(GitRepositoryManager.class); + repository = PowerMock.createMock(Repository.class); + parentRepository1 = PowerMock.createMock(Repository.class); + parentRepository2 = PowerMock.createMock(Repository.class); + parser = new ConfigurationParser(accounts); + } + + void expectConfig(String path, String config) throws IOException { + expect( + JgitWrapper.getBlobAsBytes( + anyObject(Repository.class), anyObject(String.class), eq(path))) + .andReturn(Optional.of(config.getBytes())) + .anyTimes(); + } + + void expectConfig(String path, String branch, String config) throws IOException { + expect(JgitWrapper.getBlobAsBytes(anyObject(Repository.class), eq(branch), eq(path))) + .andReturn(Optional.of(config.getBytes())) + .anyTimes(); + } + + void expectConfig(String path, String branch, Repository repo, String config) throws IOException { + expect(JgitWrapper.getBlobAsBytes(eq(repo), eq(branch), eq(path))) + .andReturn(Optional.of(config.getBytes())) + .anyTimes(); + } + + void expectNoConfig(String path) throws IOException { + expect( + JgitWrapper.getBlobAsBytes( + anyObject(Repository.class), anyObject(String.class), eq(path))) + .andReturn(Optional.empty()) + .anyTimes(); + } + + PatchListEntry expectEntry(String name) { + PatchListEntry entry = PowerMock.createMock(PatchListEntry.class); + expect(entry.getNewName()).andReturn(name).anyTimes(); + expect(entry.getChangeType()).andReturn(Patch.ChangeType.MODIFIED).anyTimes(); + expect(entry.getDeletions()).andReturn(1); + expect(entry.getInsertions()).andReturn(1); + return entry; + } + + OwnersConfig getOwnersConfig(String string) throws IOException { + return parser.getOwnersConfig(string.getBytes(Charsets.UTF_8)); + } + + 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) { + sb.append("- " + owner + "\n"); + } + } + if (matchers.length > 0) { + sb.append("matchers: \n"); + for (MatcherConfig matcher : matchers) { + sb.append(matcher.toYaml()); + } + } + return sb.toString(); + } + + public String[] owners(String... owners) { + return owners; + } +}
diff --git a/src/test/java/com/googlesource/gerrit/owners/common/LabelDefinitionTest.java b/src/test/java/com/googlesource/gerrit/owners/common/LabelDefinitionTest.java new file mode 100644 index 0000000..126e239 --- /dev/null +++ b/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/src/test/java/com/googlesource/gerrit/owners/common/MatcherConfig.java b/src/test/java/com/googlesource/gerrit/owners/common/MatcherConfig.java new file mode 100644 index 0000000..51e0b74 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/owners/common/MatcherConfig.java
@@ -0,0 +1,67 @@ +// Copyright (C) 2017 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 org.junit.Ignore; + +@Ignore +public class MatcherConfig { + public static final String MATCH_EXACT = "exact"; + public static final String MATCH_REGEX = "regex"; + public static final String MATCH_SUFFIX = "suffix"; + public static final String MATCH_PARTIAL_REGEX = "partial_regex"; + public static final String MATCH_GENERIC = "generic"; + + private final String matchType; + private final String matchExpr; + private final String[] owners; + + public static MatcherConfig exactMatcher(String expr, String... owners) { + return new MatcherConfig(MATCH_EXACT, expr, owners); + } + + public static MatcherConfig regexMatcher(String expr, String... owners) { + return new MatcherConfig(MATCH_REGEX, expr, owners); + } + + public static MatcherConfig suffixMatcher(String expr, String... owners) { + return new MatcherConfig(MATCH_SUFFIX, expr, owners); + } + + public static MatcherConfig partialRegexMatcher(String expr, String... owners) { + return new MatcherConfig(MATCH_PARTIAL_REGEX, expr, owners); + } + + public static MatcherConfig genericMatcher(String expr, String... owners) { + return new MatcherConfig(MATCH_GENERIC, expr, owners); + } + + public MatcherConfig(String matchType, String matchExpr, String[] owners) { + super(); + this.matchType = matchType; + this.matchExpr = matchExpr; + this.owners = owners; + } + + public String toYaml() { + StringBuilder sb = new StringBuilder(); + sb.append("- " + matchType + ": " + matchExpr + "\n"); + sb.append(" owners: \n"); + for (String owner : owners) { + sb.append(" - " + owner + "\n"); + } + return sb.toString(); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/owners/common/MatcherMergeTest.java b/src/test/java/com/googlesource/gerrit/owners/common/MatcherMergeTest.java new file mode 100644 index 0000000..a36383a --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/owners/common/MatcherMergeTest.java
@@ -0,0 +1,83 @@ +// Copyright (C) 2026 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.Truth.assertThat; + +import com.google.gerrit.entities.Account; +import com.google.gerrit.extensions.client.InheritableBoolean; +import java.util.Set; +import org.junit.Test; + +public class MatcherMergeTest { + @Test + public void mergeAutoOwnersApprovedKeepsCurrentWhenOtherUnset() { + assertThatMatchersMergeAs( + InheritableBoolean.TRUE, InheritableBoolean.INHERIT, InheritableBoolean.TRUE); + } + + @Test + public void mergeAutoOwnersApprovedUsesOtherWhenOtherIsFalse() { + assertThatMatchersMergeAs( + InheritableBoolean.INHERIT, InheritableBoolean.FALSE, InheritableBoolean.FALSE); + } + + @Test + public void mergeAutoOwnersApprovedUsesOtherWhenOtherIsTrue() { + assertThatMatchersMergeAs( + InheritableBoolean.FALSE, InheritableBoolean.TRUE, InheritableBoolean.TRUE); + } + + @Test + public void mergeUnsetAutoOwnersApprovedIsUnset() { + assertThatMatchersMergeAs( + InheritableBoolean.INHERIT, InheritableBoolean.INHERIT, InheritableBoolean.INHERIT); + } + + private void assertThatMatchersMergeAs( + InheritableBoolean baseMatcherBool, + InheritableBoolean otherMatcherBool, + InheritableBoolean mergedMatcherBool) { + Matcher baseTrue = new TestMatcher(baseMatcherBool); + Matcher otherUnset = new TestMatcher(otherMatcherBool); + Matcher mergedUnsetOther = baseTrue.merge(otherUnset); + assertThat(mergedUnsetOther.getAutoOwnersApproved()).isEqualTo(mergedMatcherBool); + } + + private static class TestMatcher extends Matcher { + private TestMatcher(InheritableBoolean autoOwnersApproved) { + super( + "test", + java.util.Set.of(Account.id(1)), + java.util.Set.of(Account.id(2)), + java.util.Set.of("grp"), + autoOwnersApproved); + } + + @Override + public boolean matches(String pathToMatch) { + return true; + } + + @Override + protected Matcher clone( + Set<Account.Id> owners, + Set<Account.Id> reviewers, + Set<String> groupOwners, + InheritableBoolean autoOwnersApproval) { + return new TestMatcher(autoOwnersApproval); + } + } +}
diff --git a/src/test/java/com/googlesource/gerrit/owners/common/OwnersBatchRefUpdateListenerTest.java b/src/test/java/com/googlesource/gerrit/owners/common/OwnersBatchRefUpdateListenerTest.java new file mode 100644 index 0000000..fe99ad3 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/owners/common/OwnersBatchRefUpdateListenerTest.java
@@ -0,0 +1,99 @@ +// 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 org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.events.GitBatchRefUpdateListener; +import com.google.gerrit.server.config.AllProjectsNameProvider; +import com.google.gerrit.server.config.AllUsersName; +import com.google.gerrit.server.config.AllUsersNameProvider; +import com.googlesource.gerrit.owners.common.PathOwnersEntriesCache.OwnersRefUpdateListener; +import java.util.Arrays; +import java.util.Collection; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +@RunWith(Parameterized.class) +public class OwnersBatchRefUpdateListenerTest { + @Parameterized.Parameters + public static Collection<Object[]> events() { + return Arrays.asList( + new Object[][] { + {mockEvent(ALL_USERS_NAME.get(), RefNames.REFS_CONFIG), 0}, + {mockEvent(AllProjectsNameProvider.DEFAULT, RefNames.REFS_CHANGES), 0}, + {mockEvent(AllProjectsNameProvider.DEFAULT, RefNames.REFS_SEQUENCES), 0}, + {mockEvent(AllProjectsNameProvider.DEFAULT, RefNames.REFS_CONFIG), 1}, + { + mockEvent( + AllProjectsNameProvider.DEFAULT, RefNames.REFS_CONFIG, RefNames.REFS_SEQUENCES), + 1 + }, + {mockEvent("foo", RefNames.fullName("bar")), 1}, + {mockEvent("foo", RefNames.REFS_CONFIG), 1}, + {mockEvent("foo", RefNames.REFS_CONFIG, RefNames.fullName("bar")), 2} + }); + } + + private static AllUsersName ALL_USERS_NAME = new AllUsersName(AllUsersNameProvider.DEFAULT); + + private final GitBatchRefUpdateListener.Event input; + private final int expectedTimes; + + public OwnersBatchRefUpdateListenerTest( + GitBatchRefUpdateListener.Event input, int expectedTimes) { + this.input = input; + this.expectedTimes = expectedTimes; + } + + @Test + public void shouldTriggerCacheInvalidationAccordingly() { + // given + PathOwnersEntriesCache cachMock = mock(PathOwnersEntriesCache.class); + OwnersRefUpdateListener listener = new OwnersRefUpdateListener(cachMock, ALL_USERS_NAME); + + // when + listener.onGitBatchRefUpdate(input); + + // then + verify(cachMock, times(expectedTimes)).invalidate(anyString(), anyString()); + } + + private static GitBatchRefUpdateListener.Event mockEvent(String project, String... refs) { + GitBatchRefUpdateListener.Event eventMock = mock(GitBatchRefUpdateListener.Event.class); + when(eventMock.getProjectName()).thenReturn(project); + Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs = + Stream.of(refs) + .map( + ref -> { + GitBatchRefUpdateListener.UpdatedRef updatedRef = + mock(GitBatchRefUpdateListener.UpdatedRef.class); + when(updatedRef.getRefName()).thenReturn(ref); + return updatedRef; + }) + .collect(Collectors.toSet()); + when(eventMock.getUpdatedRefs()).thenReturn(updatedRefs); + return eventMock; + } +}
diff --git a/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheMock.java b/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheMock.java new file mode 100644 index 0000000..752bf7f --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersEntriesCacheMock.java
@@ -0,0 +1,44 @@ +// 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 java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import org.junit.Ignore; + +/** This is a test implementation that doesn't cache anything but calls loader instead. */ +@Ignore +public class PathOwnersEntriesCacheMock implements PathOwnersEntriesCache { + int hit = 0; + + @Override + public void invalidate(String project, String branch) {} + + @Override + public void invalidateIndexKey(Key key) {} + + @Override + public Optional<OwnersConfig> get( + String project, String branch, String path, Callable<Optional<OwnersConfig>> loader) + throws ExecutionException { + try { + hit++; + return loader.call(); + } catch (Exception e) { + throw new ExecutionException(e); + } + } +}
diff --git a/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java b/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java new file mode 100644 index 0000000..134eeda --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/owners/common/PathOwnersTest.java
@@ -0,0 +1,634 @@ +// Copyright (C) 2017 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.Truth.assertThat; +import static com.googlesource.gerrit.owners.common.MatcherConfig.suffixMatcher; +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.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.powermock.api.easymock.PowerMock.replayAll; + +import com.google.common.truth.Truth8; +import com.google.gerrit.entities.Account; +import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.RefNames; +import java.io.IOException; +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.eclipse.jgit.lib.Repository; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +@RunWith(PowerMockRunner.class) +@PowerMockIgnore("jdk.internal.reflect.*") +@PrepareForTest(JgitWrapper.class) +public class PathOwnersTest extends ClassicConfig { + + 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 Short EXPECTED_LABEL_SCORE = 1; + private static final LabelDefinition EXPECTED_LABEL_DEFINITION = + new LabelDefinition(EXPECTED_LABEL, EXPECTED_LABEL_SCORE); + private static final String A_LABEL = "a-label"; + private static final String B_LABEL = "b-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"); + public static final Project.NameKey parentRepository2NameKey = + Project.NameKey.parse("parentRepository2"); + + @Override + @Before + public void setup() throws Exception { + super.setup(); + } + + @Test + public void testClassic() throws Exception { + mockOwners(USER_A_EMAIL_COM, USER_B_EMAIL_COM); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + Set.of(CLASSIC_FILE_TXT), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.empty()); + Set<Account.Id> ownersSet = owners.get().get(CLASSIC_OWNERS); + assertEquals(2, ownersSet.size()); + assertTrue(ownersSet.contains(USER_A_ID)); + assertTrue(ownersSet.contains(USER_B_ID)); + assertTrue(owners.expandGroups()); + Truth8.assertThat(owners.getLabel()).isEmpty(); + } + + @Test + public void testGlobalLabel() throws Exception { + mockOwners(USER_A_EMAIL_COM, USER_B_EMAIL_COM); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + Set.of(CLASSIC_FILE_TXT), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.of(EXPECTED_LABEL_DEFINITION)); + Truth8.assertThat(owners.getLabel()).hasValue(EXPECTED_LABEL_DEFINITION); + } + + @Test + public void testFileBasedOwnersUnexpanded() throws Exception { + mockOwners(USER_A_EMAIL_COM, USER_B_EMAIL_COM); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + Set.of(CLASSIC_FILE_TXT), + DO_NOT_EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.empty()); + Set<String> ownersSet = owners.getFileGroupOwners().get(CLASSIC_FILE_TXT); + assertEquals(2, ownersSet.size()); + assertTrue(ownersSet.contains(USER_A)); + assertTrue(ownersSet.contains(USER_B)); + assertFalse(owners.expandGroups()); + } + + @Test + public void testDisabledBranch() throws Exception { + mockOwners(USER_A_EMAIL_COM); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + Optional.empty(), + Set.of(CLASSIC_FILE_TXT), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.empty()); + Set<Account.Id> ownersSet = owners.get().get(CLASSIC_OWNERS); + assertEquals(0, ownersSet.size()); + } + + @Test + public void testClassicWithInheritance() throws Exception { + 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))); + + replayAll(); + + PathOwners owners2 = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + Set.of("classic/file.txt"), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.empty()); + Set<Account.Id> ownersSet2 = owners2.get().get(CLASSIC_OWNERS); + + // in this case we are inheriting the acct3 from /OWNERS + assertEquals(3, ownersSet2.size()); + 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 + Truth8.assertThat(owners2.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL); + } + + @Test + public void testClassicWithInheritanceAndGlobalLabel() throws Exception { + expectConfig("OWNERS", createConfig(true, Optional.of(A_LABEL), owners())); + expectConfig(CLASSIC_OWNERS, createConfig(true, Optional.of(B_LABEL), owners())); + + replayAll(); + + PathOwners owners2 = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + Set.of("classic/file.txt"), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.of(EXPECTED_LABEL_DEFINITION)); + Truth8.assertThat(owners2.getLabel()).hasValue(EXPECTED_LABEL_DEFINITION); + } + + @Test + public void testClassicWithoutInheritanceAndGlobalLabel() throws Exception { + expectConfig("OWNERS", createConfig(false, Optional.of(A_LABEL), owners())); + expectConfig(CLASSIC_OWNERS, createConfig(false, Optional.of(B_LABEL), owners())); + + replayAll(); + + PathOwners owners2 = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + Set.of("classic/file.txt"), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.of(EXPECTED_LABEL_DEFINITION)); + Truth8.assertThat(owners2.getLabel()).hasValue(EXPECTED_LABEL_DEFINITION); + } + + @Test + public void testRootInheritFromProject() throws Exception { + expectConfig("OWNERS", "master", createConfig(true, owners())); + expectConfig( + "OWNERS", + RefNames.REFS_CONFIG, + createConfig( + true, + Optional.of(EXPECTED_LABEL), + owners(), + suffixMatcher(".sql", USER_A_EMAIL_COM, USER_B_EMAIL_COM))); + + String fileName = "file.sql"; + replayAll(); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + Set.of(fileName), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.empty()); + + Map<String, Set<Account.Id>> fileOwners = owners.getFileOwners(); + assertEquals(1, fileOwners.size()); + + Set<Account.Id> ownersSet = fileOwners.get(fileName); + assertEquals(2, ownersSet.size()); + assertTrue(ownersSet.contains(USER_A_ID)); + assertTrue(ownersSet.contains(USER_B_ID)); + Truth8.assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL); + } + + @Test + public void testProjectInheritFromParentProject() 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( + "OWNERS", + RefNames.REFS_CONFIG, + parentRepository1, + createConfig( + true, + Optional.of(A_LABEL), + owners(), + suffixMatcher(".sql", USER_A_EMAIL_COM, USER_B_EMAIL_COM))); + + String fileName = "file.sql"; + + mockParentRepository(parentRepository1NameKey, parentRepository1); + replayAll(); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + Arrays.asList(parentRepository1NameKey), + branch, + Set.of(fileName), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.empty()); + + Map<String, Set<Account.Id>> fileOwners = owners.getFileOwners(); + assertEquals(fileOwners.size(), 1); + + Set<Account.Id> ownersSet = fileOwners.get(fileName); + 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 + Truth8.assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL); + } + + @Test + public void testProjectInheritFromMultipleParentProjects() throws Exception { + expectConfig("OWNERS", "master", createConfig(true, owners())); + expectConfig("OWNERS", RefNames.REFS_CONFIG, repository, createConfig(true, owners())); + expectConfig( + "OWNERS", + RefNames.REFS_CONFIG, + parentRepository1, + createConfig( + true, Optional.of(EXPECTED_LABEL), owners(), suffixMatcher(".sql", USER_A_EMAIL_COM))); + expectConfig( + "OWNERS", + RefNames.REFS_CONFIG, + parentRepository2, + createConfig( + true, Optional.of(A_LABEL), owners(), suffixMatcher(".java", USER_B_EMAIL_COM))); + + String sqlFileName = "file.sql"; + String javaFileName = "file.java"; + + mockParentRepository(parentRepository1NameKey, parentRepository1); + mockParentRepository(parentRepository2NameKey, parentRepository2); + replayAll(); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + Arrays.asList(parentRepository1NameKey, parentRepository2NameKey), + branch, + Set.of(sqlFileName, javaFileName), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.empty()); + + Map<String, Set<Account.Id>> fileOwners = owners.getFileOwners(); + assertEquals(fileOwners.size(), 2); + + Set<Account.Id> ownersSet1 = fileOwners.get(sqlFileName); + assertEquals(1, ownersSet1.size()); + assertTrue(ownersSet1.contains(USER_A_ID)); + + 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 + Truth8.assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL); + } + + private void mockParentRepository(Project.NameKey repositoryName, Repository repository) + throws IOException { + expect(repositoryManager.openRepository(eq(repositoryName))).andReturn(repository).anyTimes(); + repository.close(); + expectLastCall(); + } + + @Test + public void testClassicWithInheritanceAndDeepNesting() throws Exception { + expectConfig("OWNERS", createConfig(true, owners(USER_C_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))); + + replayAll(); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + Set.of("dir/subdir/file.txt"), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.empty()); + Set<Account.Id> ownersSet = owners.get().get("dir/subdir/OWNERS"); + + assertEquals(3, ownersSet.size()); + 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 + Truth8.assertThat(owners.getLabel().map(LabelDefinition::getName)).hasValue(EXPECTED_LABEL); + } + + @Test + public void testParsingYamlWithLabelWithScore() throws IOException { + String yamlString = + "inherited: true\nlabel: " + EXPECTED_LABEL + ",1\nowners:\n- " + USER_C_EMAIL_COM; + OwnersConfig ownersConfig = getOwnersConfig(yamlString); + + assertTrue(ownersConfig.isInherited()); + Truth8.assertThat(ownersConfig.getLabel()).isPresent(); + + LabelDefinition label = ownersConfig.getLabel().get(); + assertThat(label.getName()).isEqualTo(EXPECTED_LABEL); + Truth8.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() throws IOException { + String yamlString = + "inherited: true\nlabel: " + EXPECTED_LABEL + "\nowners:\n- " + USER_C_EMAIL_COM; + OwnersConfig ownersConfig = getOwnersConfig(yamlString); + + assertTrue(ownersConfig.isInherited()); + Truth8.assertThat(ownersConfig.getLabel()).isPresent(); + + LabelDefinition label = ownersConfig.getLabel().get(); + assertThat(label.getName()).isEqualTo(EXPECTED_LABEL); + Truth8.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, + Optional.empty()); + + 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); + } + + @Test + public void testAutoOwnersMisconfigured() throws Exception { + expectConfig("OWNERS", "inherited: true\nauto-owners-approved: \"some wrong value\""); + + replayAll(); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + Set.of("file.txt"), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.empty()); + + assertThat(owners.getFileOwnersAllowedAutoApproval()).isEmpty(); + assertThat(owners.getFileOwners()).isEmpty(); + } + + @Test + public void testAutoOwnersApprovedInheritedFromRoot() throws Exception { + expectConfig( + "OWNERS", + "inherited: true\nauto-owners-approved: true\nowners:\n- " + USER_A_EMAIL_COM + "\n"); + expectConfig("dir/OWNERS", "inherited: true\nowners:\n- " + USER_B_EMAIL_COM + "\n"); + + replayAll(); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + Set.of("dir/file.txt"), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.empty()); + + assertThat(owners.getFileOwnersAllowedAutoApproval()).contains("dir/file.txt"); + } + + @Test + public void testAutoOwnersApprovedDefaultsWhenInheritanceStopped() throws Exception { + expectConfig( + "OWNERS", + "inherited: true\nauto-owners-approved: true\nowners:\n- " + USER_A_EMAIL_COM + "\n"); + expectConfig("dir/OWNERS", "inherited: false\nowners:\n- " + USER_B_EMAIL_COM + "\n"); + + replayAll(); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + Set.of("dir/file.txt"), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.empty()); + + assertThat(owners.getFileOwnersAllowedAutoApproval()).isEmpty(); + } + + @Test + public void testAutoOwnersApprovedInheritedFromParentProjectOwners() throws Exception { + expectConfig("OWNERS", "master", createConfig(true, owners())); + expectConfig("OWNERS", RefNames.REFS_CONFIG, repository, createConfig(true, owners())); + expectConfig( + "OWNERS", + RefNames.REFS_CONFIG, + parentRepository1, + "inherited: true\nauto-owners-approved: true\nowners:\n- " + USER_A_EMAIL_COM + "\n"); + + mockParentRepository(parentRepository1NameKey, parentRepository1); + replayAll(); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + Arrays.asList(parentRepository1NameKey), + branch, + Set.of("file.txt"), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.empty()); + + assertThat(owners.getFileOwnersAllowedAutoApproval()).contains("file.txt"); + } + + @Test + public void testExplicitAutoOwnersApprovedInRootOverridesProjectOwners() throws Exception { + expectConfig( + "OWNERS", + "master", + "inherited: true\nauto-owners-approved: false\nowners:\n- " + USER_A_EMAIL_COM + "\n"); + expectConfig( + "OWNERS", + RefNames.REFS_CONFIG, + repository, + "inherited: true\nauto-owners-approved: true\nowners:\n- " + USER_B_EMAIL_COM + "\n"); + + replayAll(); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + Set.of("file.txt"), + EXPAND_GROUPS, + "foo", + CACHE_MOCK, + Optional.empty()); + + assertThat(owners.getFileOwnersAllowedAutoApproval()).isEmpty(); + } + + private void mockOwners(String... owners) throws IOException { + expectNoConfig("OWNERS"); + expectConfig(CLASSIC_OWNERS, createConfig(false, owners(owners))); + + replayAll(); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/owners/common/PluginSettingsTest.java b/src/test/java/com/googlesource/gerrit/owners/common/PluginSettingsTest.java new file mode 100644 index 0000000..2aecd88 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/owners/common/PluginSettingsTest.java
@@ -0,0 +1,102 @@ +// 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.common; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.when; + +import com.google.gerrit.server.config.PluginConfigFactory; +import java.util.Optional; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.lib.Constants; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class PluginSettingsTest { + private static final String PLUGIN_NAME = "plugin-name"; + @Mock PluginConfigFactory mockPluginConfigFactory; + + private PluginSettings pluginSettings; + + public void setupMocks(Config pluginConfig) { + when(mockPluginConfigFactory.getGlobalPluginConfig(PLUGIN_NAME)).thenReturn(pluginConfig); + pluginSettings = new PluginSettings(mockPluginConfigFactory, PLUGIN_NAME); + } + + @Test + public void allBranchesAreEnabledByDefault() { + setupMocks(new Config()); + + assertThat(pluginSettings.disabledBranchPatterns()).isEmpty(); + assertThat(pluginSettings.isBranchDisabled("some-branch")).isFalse(); + } + + @Test + public void branchRefShouldBeDisabled() { + String branchName = "refs/heads/some-branch"; + Config pluginConfig = new Config(); + pluginConfig.setString("owners", "disable", "branch", branchName); + setupMocks(pluginConfig); + + assertThat(pluginSettings.disabledBranchPatterns()).contains(branchName); + assertThat(pluginSettings.isBranchDisabled(branchName)).isTrue(); + } + + @Test + public void branchNameShouldBeDisabled() { + String branchName = "some-branch"; + String branchRefName = Constants.R_HEADS + branchName; + Config pluginConfig = new Config(); + pluginConfig.setString("owners", "disable", "branch", branchRefName); + setupMocks(pluginConfig); + + assertThat(pluginSettings.disabledBranchPatterns()).contains(branchRefName); + assertThat(pluginSettings.isBranchDisabled(branchName)).isTrue(); + } + + @Test + public void branchNameShouldBeDisabledByRegex() { + String branchName1 = "some-branch-1"; + String branchName2 = "some-branch-2"; + String branchRefRegex = Constants.R_HEADS + "some-branch-\\d"; + Config pluginConfig = new Config(); + pluginConfig.setString("owners", "disable", "branch", branchRefRegex); + setupMocks(pluginConfig); + + assertThat(pluginSettings.disabledBranchPatterns()).contains(branchRefRegex); + assertThat(pluginSettings.isBranchDisabled(branchName1)).isTrue(); + assertThat(pluginSettings.isBranchDisabled(branchName2)).isTrue(); + } + + @Test + public void globalLabelIsEmptyByDefault() { + setupMocks(new Config()); + + assertThat(pluginSettings.globalLabel()).isEqualTo(Optional.empty()); + } + + @Test + public void globalLabelSetByConfig() { + LabelDefinition globalLabelName = new LabelDefinition("Custom-Label", (short) 2); + Config pluginConfig = new Config(); + pluginConfig.setString("owners", null, "label", "Custom-Label,2"); + setupMocks(pluginConfig); + + assertThat(pluginSettings.globalLabel()).isEqualTo(Optional.of(globalLabelName)); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/owners/common/RegexMatcherTest.java b/src/test/java/com/googlesource/gerrit/owners/common/RegexMatcherTest.java new file mode 100644 index 0000000..d157ebb --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/owners/common/RegexMatcherTest.java
@@ -0,0 +1,42 @@ +// Copyright (C) 2017 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 org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class RegexMatcherTest { + @Test + public void testRegex() { + RegExMatcher matcher = new RegExMatcher(".*/a.*", null, null, null, null); + assertTrue(matcher.matches("xxxxxx/axxxx")); + assertFalse(matcher.matches("axxxx")); + assertFalse(matcher.matches("xxxxx/bxxxx")); + + RegExMatcher matcher2 = new RegExMatcher("a.*.sql", null, null, null, null); + assertFalse(matcher2.matches("xxxxxx/alfa.sql")); + } + + @Test + public void testFloatingRegex() { + PartialRegExMatcher matcher = new PartialRegExMatcher("a.*.sql", null, null, null, null); + assertTrue(matcher.matches("xxxxxxx/alfa.sql")); + assertTrue(matcher.matches("alfa.sqlxxxxx")); + assertFalse(matcher.matches("alfa.bar")); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/owners/common/RegexTest.java b/src/test/java/com/googlesource/gerrit/owners/common/RegexTest.java new file mode 100644 index 0000000..a6e10a6 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/owners/common/RegexTest.java
@@ -0,0 +1,306 @@ +// Copyright (C) 2017 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.Truth.assertThat; +import static com.googlesource.gerrit.owners.common.MatcherConfig.exactMatcher; +import static com.googlesource.gerrit.owners.common.MatcherConfig.genericMatcher; +import static com.googlesource.gerrit.owners.common.MatcherConfig.partialRegexMatcher; +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.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 com.google.gerrit.extensions.client.InheritableBoolean; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +@RunWith(PowerMockRunner.class) +@PowerMockIgnore("jdk.internal.reflect.*") +@PrepareForTest(JgitWrapper.class) +public class RegexTest extends Config { + + private static final String ACCOUNT_A = "a"; + private static final String ACCOUNT_B = "b"; + private static final String ACCOUNT_C = "c"; + private static final String ACCOUNT_D = "d"; + private static final String ACCOUNT_E = "e"; + private static final String ACCOUNT_F = "f"; + private static final Account.Id ACCOUNT_A_ID = Account.id(1); + private static final Account.Id ACCOUNT_B_ID = Account.id(2); + private static final Account.Id ACCOUNT_C_ID = Account.id(3); + private static final Account.Id ACCOUNT_D_ID = Account.id(4); + private static final Account.Id ACCOUNT_E_ID = Account.id(5); + private static final Account.Id ACCOUNT_F_ID = Account.id(6); + private static final boolean EXPAND_GROUPS = true; + + @Override + @Before + public void setup() throws Exception { + accounts.put(ACCOUNT_A, ACCOUNT_A_ID); + accounts.put(ACCOUNT_B, ACCOUNT_B_ID); + accounts.put(ACCOUNT_C, ACCOUNT_C_ID); + accounts.put(ACCOUNT_D, ACCOUNT_D_ID); + accounts.put(ACCOUNT_E, ACCOUNT_E_ID); + accounts.put(ACCOUNT_F, ACCOUNT_F_ID); + + super.setup(); + } + + @Test + public void testNewParsingYaml() throws Exception { + replayAll(); + + String fullConfig = + createConfig( + true, + owners(ACCOUNT_A), + suffixMatcher(".sql", ACCOUNT_B, ACCOUNT_C), + regexMatcher(".*/a.*", ACCOUNT_D), + partialRegexMatcher("Product.sql", ACCOUNT_A)); + // the function to test + OwnersConfig config = getOwnersConfig(fullConfig); + // check classical configuration + assertTrue(config.isInherited()); + + Set<String> owners = config.getOwners(); + assertEquals(1, owners.size()); + assertTrue(owners.contains(ACCOUNT_A)); + // check matchers + Map<String, Matcher> matchers = config.getMatchers(); + assertEquals(3, matchers.size()); + assertTrue(matchers.containsKey(".sql")); + assertTrue(matchers.containsKey(".*/a.*")); + assertTrue(matchers.containsKey("Product.sql")); + + // suffix .sql matcher + Matcher advMatcher = matchers.get(".sql"); + assertEquals(2, advMatcher.getOwners().size()); + Set<Account.Id> advOwners = advMatcher.getOwners(); + assertTrue(advOwners.contains(ACCOUNT_B_ID)); + assertTrue(advOwners.contains(ACCOUNT_C_ID)); + + // regex matcher + Matcher dbMatcher = matchers.get(".*/a.*"); + assertEquals(1, dbMatcher.getOwners().size()); + Set<Account.Id> dbOwners = dbMatcher.getOwners(); + assertTrue(dbOwners.contains(ACCOUNT_D_ID)); + + // partial_regex matcher + Matcher partial = matchers.get("Product.sql"); + assertEquals(1, partial.getOwners().size()); + Set<Account.Id> partialOwners = partial.getOwners(); + assertTrue(partialOwners.contains(ACCOUNT_A_ID)); + } + + @Test + public void checkMatchers() throws Exception { + String parentConfig = + createConfig( + true, + owners(ACCOUNT_A), + suffixMatcher(".sql", ACCOUNT_B, ACCOUNT_C), + regexMatcher(".*/a.*", ACCOUNT_D)); + String childConfig = + createConfig( + true, + owners(ACCOUNT_F), + exactMatcher("project/file.txt", ACCOUNT_D, ACCOUNT_E), + partialRegexMatcher("alfa", ACCOUNT_A)); + + expectConfig("OWNERS", parentConfig); + expectConfig("project/OWNERS", childConfig); + + replayAll(); + + // function under test + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + 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(), + Optional.empty()); + + // assertions on classic owners + Set<Account.Id> ownersSet = owners.get().get("project/OWNERS"); + assertEquals(2, ownersSet.size()); + + // get matchers + Map<String, Matcher> matchers = owners.getMatchers(); + assertEquals(4, matchers.size()); + + // asserts we have 1 exact matcher + List<Entry<String, Matcher>> onlyExacts = + iteratorStream(matchers.entrySet().iterator()) + .filter(entry -> entry.getValue() instanceof ExactMatcher) + .collect(Collectors.toList()); + assertEquals(1, onlyExacts.size()); + assertEquals("project/file.txt", onlyExacts.get(0).getKey()); + + // ... 1 regex matcher + List<Entry<String, Matcher>> regexList = + StreamUtils.iteratorStream(matchers.entrySet().iterator()) + .filter(entry -> entry.getValue() instanceof RegExMatcher) + .collect(Collectors.toList()); + assertEquals(1, regexList.size()); + assertEquals(".*/a.*", regexList.get(0).getKey()); + + // ... 1 partial regex matcher + List<Entry<String, Matcher>> partialRegexList = + iteratorStream(matchers.entrySet().iterator()) + .filter(entry -> entry.getValue() instanceof PartialRegExMatcher) + .collect(Collectors.toList()); + assertEquals(1, partialRegexList.size()); + assertEquals("alfa", partialRegexList.get(0).getKey()); + + // .... 1 suffix matcher + List<Entry<String, Matcher>> suffixList = + iteratorStream(matchers.entrySet().iterator()) + .filter(entry -> entry.getValue() instanceof SuffixMatcher) + .collect(Collectors.toList()); + assertEquals(1, suffixList.size()); + assertEquals(".sql", suffixList.get(0).getKey()); + + // now checks file owners as well + Map<String, Set<Account.Id>> fileOwners = owners.getFileOwners(); + assertEquals(6, fileOwners.size()); + + Set<Account.Id> set1 = fileOwners.get("project/file.txt"); + assertEquals(4, set1.size()); // includes classic owners a and f + assertTrue(set1.contains(ACCOUNT_A_ID)); + assertTrue(set1.contains(ACCOUNT_D_ID)); + assertTrue(set1.contains(ACCOUNT_E_ID)); + assertTrue(set1.contains(ACCOUNT_F_ID)); + + Set<Account.Id> set2 = fileOwners.get("project/afile2.sql"); + assertEquals(5, set2.size()); + assertTrue(set2.contains(ACCOUNT_A_ID)); + assertTrue(set2.contains(ACCOUNT_B_ID)); + assertTrue(set2.contains(ACCOUNT_C_ID)); + assertTrue(set2.contains(ACCOUNT_D_ID)); + assertTrue(set2.contains(ACCOUNT_F_ID)); + + Set<Account.Id> set3 = fileOwners.get("project/file.sql"); + assertEquals(4, set3.size()); + assertTrue(set3.contains(ACCOUNT_A_ID)); + assertTrue(set3.contains(ACCOUNT_B_ID)); + assertTrue(set3.contains(ACCOUNT_C_ID)); + assertTrue(set3.contains(ACCOUNT_F_ID)); + + Set<Account.Id> set4 = fileOwners.get("projectalfa"); + assertEquals(1, set4.size()); // only 1 because a is class and alfa owner + assertTrue(set4.contains(ACCOUNT_A_ID)); + } + + @Test + public void testMatchersOnlyConfig() throws Exception { + replayAll(); + + OwnersConfig ownersConfig = + getOwnersConfig( + createConfig( + false, + new String[0], + suffixMatcher(".txt", ACCOUNT_B), + genericMatcher(".*", ACCOUNT_A))); + + assertThat(ownersConfig.getOwners()).isEmpty(); + assertThat(ownersConfig.getMatchers()).isNotEmpty(); + } + + @Test + public void testkRegexShouldMatchOnlyOnSuffix() throws Exception { + String configString = createConfig(false, new String[0], suffixMatcher(".sql", ACCOUNT_B)); + + expectConfig("OWNERS", configString); + expectNoConfig("project/OWNERS"); + replayAll(); + + PathOwners owners = + new PathOwners( + accounts, + repositoryManager, + repository, + emptyList(), + branch, + Set.of("project/file.sql", "another.txt"), + EXPAND_GROUPS, + "foo", + new PathOwnersEntriesCacheMock(), + Optional.empty()); + + Set<String> ownedFiles = owners.getFileOwners().keySet(); + assertThat(ownedFiles).containsExactly("project/file.sql"); + } + + @Test + public void testParsingAutoOwnersApprovedFlags() throws Exception { + replayAll(); + + String configString = + "inherited: true\n" + + "auto-owners-approved: false\n" + + "owners:\n" + + "- a\n" + + "matchers:\n" + + "- suffix: .sql\n" + + " auto-owners-approved: true\n" + + " owners:\n" + + " - b\n" + + "- exact: project/a.txt\n" + + " auto-owners-approved: false\n" + + " owners:\n" + + " - c\n" + + "- regex: .*\\.md\n" + + " owners:\n" + + " - d\n"; + + OwnersConfig config = getOwnersConfig(configString); + + assertThat(config.getMatchers().get(".sql").getAutoOwnersApproved()) + .isEqualTo(InheritableBoolean.TRUE); + assertThat(config.getMatchers().get("project/a.txt").getAutoOwnersApproved()) + .isEqualTo(InheritableBoolean.FALSE); + assertThat(config.getMatchers().get(".*\\.md").getAutoOwnersApproved()) + .isEqualTo(InheritableBoolean.INHERIT); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/owners/common/TestAccounts.java b/src/test/java/com/googlesource/gerrit/owners/common/TestAccounts.java new file mode 100644 index 0000000..c450f01 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/owners/common/TestAccounts.java
@@ -0,0 +1,35 @@ +// Copyright (C) 2017 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; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import org.junit.Ignore; + +@Ignore +public class TestAccounts extends HashMap<String, Account.Id> implements Accounts { + private static final long serialVersionUID = 1L; + + @Override + public Set<Account.Id> find(String nameOrEmail) { + return Optional.ofNullable(get(nameOrEmail)) + .map(id -> new HashSet<>(Arrays.asList(id))) + .orElse(new HashSet<Account.Id>()); + } +}