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>());
+  }
+}