Merge changes I1ec99d8b,I2109d0e3,I39e09c27

* changes:
  Clarify in docs that approvals >= the configured approvals also count
  Test that OO+2 counts as code owner override when OO+1 is required
  Support configuring multiple override approvals
diff --git a/.eslintrc.json b/.eslintrc.json
index b9a6517..2c0d24a 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -90,7 +90,8 @@
             2,
             {
                 "ignoreComments": true,
-                "ignorePattern": "^import .*;$"
+                "ignorePattern": "^import .*;$",
+                "ignoreTemplateLiterals": true
             }
         ],
         "new-cap": [
@@ -100,7 +101,8 @@
                     "Polymer",
                     "LegacyElementMixin",
                     "GestureEventListeners",
-                    "LegacyDataMixin"
+                    "LegacyDataMixin",
+                    "CodeOwnersModelMixin"
                 ]
             }
         ],
@@ -235,4 +237,4 @@
             }
         }
     ]
-}
\ No newline at end of file
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java b/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java
index 8562057..8caa0fe 100644
--- a/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java
+++ b/java/com/google/gerrit/plugins/codeowners/api/GeneralInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.plugins.codeowners.api;
 
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
+
 /**
  * Representation of the general code owners configuration in the REST API.
  *
@@ -43,4 +45,7 @@
    * code owner override.
    */
   public String overrideInfoUrl;
+
+  /** Policy that controls who should own paths that have no code owners defined. */
+  public FallbackCodeOwners fallbackCodeOwners;
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
index 8211b7c..26d67f0 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
@@ -48,6 +48,7 @@
 import java.nio.file.Path;
 import java.util.Collections;
 import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
@@ -199,9 +200,8 @@
       logger.atFine().log("global code owners = %s", globalCodeOwners);
 
       // If the branch doesn't contain any code owner config file yet, we apply special logic
-      // (project
-      // owners count as code owners) to allow bootstrapping the code owner configuration in the
-      // branch.
+      // (project owners count as code owners) to allow bootstrapping the code owner configuration
+      // in the branch.
       boolean isBootstrapping =
           !codeOwnerConfigScannerFactory.create().containsAnyCodeOwnerConfigFile(branch);
       logger.atFine().log("isBootstrapping = %s", isBootstrapping);
@@ -360,6 +360,20 @@
       codeOwnerStatus = CodeOwnerStatus.PENDING;
     }
 
+    // Since there are no code owner config files in bootstrapping mode, fallback code owners also
+    // apply if they are configured. We can skip checking them if we already found that the file was
+    // approved.
+    if (codeOwnerStatus != CodeOwnerStatus.APPROVED) {
+      codeOwnerStatus =
+          getCodeOwnerStatusForFallbackCodeOwners(
+              codeOwnerStatus,
+              branch.project(),
+              enableImplicitApprovalFromUploader,
+              reviewerAccountIds,
+              approverAccountIds,
+              absolutePath);
+    }
+
     PathCodeOwnerStatus pathCodeOwnerStatus =
         PathCodeOwnerStatus.create(absolutePath, codeOwnerStatus);
     logger.atFine().log("pathCodeOwnerStatus = %s", pathCodeOwnerStatus);
@@ -485,6 +499,8 @@
         codeOwnerStatus.set(CodeOwnerStatus.PENDING);
       }
 
+      AtomicBoolean hasRevelantCodeOwnerDefinitions = new AtomicBoolean(false);
+      AtomicBoolean parentCodeOwnersAreIgnored = new AtomicBoolean(false);
       codeOwnerConfigHierarchy.visit(
           branch,
           revision,
@@ -497,6 +513,10 @@
                 codeOwnerConfig.key().folderPath(),
                 codeOwnerConfig.key().fileName().orElse("<default>"));
 
+            if (codeOwners.hasRevelantCodeOwnerDefinitions()) {
+              hasRevelantCodeOwnerDefinitions.set(true);
+            }
+
             if (isApproved(
                 absolutePath,
                 codeOwners,
@@ -516,7 +536,29 @@
             // We need to continue to check if any of the higher-level code owners approved the
             // change or is a reviewer.
             return true;
+          },
+          codeOwnerConfigKey -> {
+            logger.atFine().log(
+                "code owner config %s ignores parent code owners for %s",
+                codeOwnerConfigKey, absolutePath);
+            parentCodeOwnersAreIgnored.set(true);
           });
+
+      // If no code owners have been defined for the file and if parent code owners are not ignored,
+      // the fallback code owners apply if they are configured. We can skip checking them if we
+      // already found that the file was approved.
+      if (codeOwnerStatus.get() != CodeOwnerStatus.APPROVED
+          && !hasRevelantCodeOwnerDefinitions.get()
+          && !parentCodeOwnersAreIgnored.get()) {
+        codeOwnerStatus.set(
+            getCodeOwnerStatusForFallbackCodeOwners(
+                codeOwnerStatus.get(),
+                branch.project(),
+                enableImplicitApprovalFromUploader,
+                reviewerAccountIds,
+                approverAccountIds,
+                absolutePath));
+      }
     }
 
     PathCodeOwnerStatus pathCodeOwnerStatus =
@@ -525,6 +567,66 @@
     return pathCodeOwnerStatus;
   }
 
+  /**
+   * Computes the code owner status for the given path based on the configured fallback code owners.
+   */
+  private CodeOwnerStatus getCodeOwnerStatusForFallbackCodeOwners(
+      CodeOwnerStatus codeOwnerStatus,
+      Project.NameKey project,
+      boolean enableImplicitApprovalFromUploader,
+      ImmutableSet<Account.Id> reviewerAccountIds,
+      ImmutableSet<Account.Id> approverAccountIds,
+      Path absolutePath) {
+    FallbackCodeOwners fallbackCodeOwners =
+        codeOwnersPluginConfiguration.getFallbackCodeOwners(project);
+    logger.atFine().log(
+        "getting code owner status for fallback code owners (fallback code owners = %s)",
+        fallbackCodeOwners);
+    switch (fallbackCodeOwners) {
+      case NONE:
+        logger.atFine().log("no fallback code owners");
+        return codeOwnerStatus;
+      case ALL_USERS:
+        return getCodeOwnerStatusIfAllUsersAreCodeOwners(
+            enableImplicitApprovalFromUploader,
+            reviewerAccountIds,
+            approverAccountIds,
+            absolutePath);
+    }
+
+    throw new StorageException(
+        String.format("unknown fallback code owners configured: %s", fallbackCodeOwners));
+  }
+
+  /** Computes the code owner status for the given path assuming that all users are code owners. */
+  private CodeOwnerStatus getCodeOwnerStatusIfAllUsersAreCodeOwners(
+      boolean enableImplicitApprovalFromUploader,
+      ImmutableSet<Account.Id> reviewerAccountIds,
+      ImmutableSet<Account.Id> approverAccountIds,
+      Path absolutePath) {
+    logger.atFine().log(
+        "getting code owner status for fallback code owners (all users are fallback code owners)");
+
+    if (enableImplicitApprovalFromUploader) {
+      logger.atFine().log(
+          "%s was implicitly approved by the patch set uploader since the uploader is a fallback"
+              + " code owner",
+          absolutePath);
+      return CodeOwnerStatus.APPROVED;
+    }
+
+    if (!approverAccountIds.isEmpty()) {
+      logger.atFine().log("%s was approved by a fallback code owner", absolutePath);
+      return CodeOwnerStatus.APPROVED;
+    } else if (!reviewerAccountIds.isEmpty()) {
+      logger.atFine().log("%s has a fallback code owner as reviewer", absolutePath);
+      return CodeOwnerStatus.PENDING;
+    }
+
+    logger.atFine().log("%s has no fallback code owner as a reviewer", absolutePath);
+    return CodeOwnerStatus.INSUFFICIENT_REVIEWERS;
+  }
+
   private boolean isApproved(
       Path absolutePath,
       CodeOwnerResolverResult codeOwners,
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchy.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchy.java
index e3bbb85..e9db8c8 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchy.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchy.java
@@ -28,6 +28,7 @@
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.Optional;
+import java.util.function.Consumer;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -74,10 +75,38 @@
       ObjectId revision,
       Path absolutePath,
       CodeOwnerConfigVisitor codeOwnerConfigVisitor) {
+    visit(
+        branchNameKey,
+        revision,
+        absolutePath,
+        codeOwnerConfigVisitor,
+        /* parentCodeOwnersIgnoredCallback= */ codeOwnerConfigKey -> {});
+  }
+
+  /**
+   * Visits the code owner configs in the given branch that apply for the given path by following
+   * the path hierarchy from the given path up to the root folder.
+   *
+   * @param branchNameKey project and branch from which the code owner configs should be visited
+   * @param revision the branch revision from which the code owner configs should be loaded
+   * @param absolutePath the path for which the code owner configs should be visited; the path must
+   *     be absolute; can be the path of a file or folder; the path may or may not exist
+   * @param codeOwnerConfigVisitor visitor that should be invoked for the applying code owner
+   *     configs
+   * @param parentCodeOwnersIgnoredCallback callback that is invoked for the first visited code
+   *     owner config that ignores parent code owners
+   */
+  public void visit(
+      BranchNameKey branchNameKey,
+      ObjectId revision,
+      Path absolutePath,
+      CodeOwnerConfigVisitor codeOwnerConfigVisitor,
+      Consumer<CodeOwnerConfig.Key> parentCodeOwnersIgnoredCallback) {
     requireNonNull(branchNameKey, "branch");
     requireNonNull(revision, "revision");
     requireNonNull(absolutePath, "absolutePath");
     requireNonNull(codeOwnerConfigVisitor, "codeOwnerConfigVisitor");
+    requireNonNull(parentCodeOwnersIgnoredCallback, "parentCodeOwnersIgnoredCallback");
     checkState(absolutePath.isAbsolute(), "path %s must be absolute", absolutePath);
 
     logger.atFine().log(
@@ -93,14 +122,19 @@
       // Read code owner config and invoke the codeOwnerConfigVisitor if the code owner config
       // exists.
       logger.atFine().log("inspecting code owner config for %s", ownerConfigFolder);
+      CodeOwnerConfig.Key codeOwnerConfigKey =
+          CodeOwnerConfig.Key.create(branchNameKey, ownerConfigFolder);
       Optional<PathCodeOwners> pathCodeOwners =
-          pathCodeOwnersFactory.create(
-              CodeOwnerConfig.Key.create(branchNameKey, ownerConfigFolder), revision, absolutePath);
+          pathCodeOwnersFactory.create(codeOwnerConfigKey, revision, absolutePath);
       if (pathCodeOwners.isPresent()) {
         logger.atFine().log("visit code owner config for %s", ownerConfigFolder);
         boolean visitFurtherCodeOwnerConfigs =
             codeOwnerConfigVisitor.visit(pathCodeOwners.get().getCodeOwnerConfig());
-        boolean ignoreParentCodeOwners = pathCodeOwners.get().ignoreParentCodeOwners();
+        boolean ignoreParentCodeOwners =
+            pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners();
+        if (ignoreParentCodeOwners) {
+          parentCodeOwnersIgnoredCallback.accept(codeOwnerConfigKey);
+        }
         logger.atFine().log(
             "visitFurtherCodeOwnerConfigs = %s, ignoreParentCodeOwners = %s",
             visitFurtherCodeOwnerConfigs, ignoreParentCodeOwners);
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
index 6d9d05e..74e2db6 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolver.java
@@ -45,7 +45,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.stream.Stream;
 
 /** Class to resolve {@link CodeOwnerReference}s to {@link CodeOwner}s. */
 public class CodeOwnerResolver {
@@ -154,7 +153,10 @@
                 .filePath(codeOwnerConfig.key().fileName().orElse("<default>"))
                 .build())) {
       logger.atFine().log("resolving path code owners for path %s", absolutePath);
-      return resolve(pathCodeOwnersFactory.create(codeOwnerConfig, absolutePath).get());
+      PathCodeOwnersResult pathCodeOwnersResult =
+          pathCodeOwnersFactory.create(codeOwnerConfig, absolutePath).resolveCodeOwnerConfig();
+      return resolve(
+          pathCodeOwnersResult.getPathCodeOwners(), pathCodeOwnersResult.hasUnresolvedImports());
     }
   }
 
@@ -176,8 +178,22 @@
    * @see #resolve(CodeOwnerReference)
    */
   public CodeOwnerResolverResult resolve(Set<CodeOwnerReference> codeOwnerReferences) {
+    return resolve(codeOwnerReferences, /* hasUnresolvedImports= */ false);
+  }
+
+  /**
+   * Resolves the given {@link CodeOwnerReference}s to {@link CodeOwner}s.
+   *
+   * @param codeOwnerReferences the code owner references that should be resolved
+   * @param hasUnresolvedImports whether there are unresolved imports
+   * @return the {@link CodeOwner} for the given code owner references
+   * @see #resolve(CodeOwnerReference)
+   */
+  private CodeOwnerResolverResult resolve(
+      Set<CodeOwnerReference> codeOwnerReferences, boolean hasUnresolvedImports) {
     requireNonNull(codeOwnerReferences, "codeOwnerReferences");
     AtomicBoolean ownedByAllUsers = new AtomicBoolean(false);
+    AtomicBoolean hasUnresolvedCodeOwners = new AtomicBoolean(false);
     ImmutableSet<CodeOwner> codeOwners =
         codeOwnerReferences.stream()
             .filter(
@@ -188,9 +204,19 @@
                   }
                   return true;
                 })
-            .flatMap(this::resolve)
+            .map(this::resolve)
+            .filter(
+                codeOwner -> {
+                  if (!codeOwner.isPresent()) {
+                    hasUnresolvedCodeOwners.set(true);
+                    return false;
+                  }
+                  return true;
+                })
+            .map(Optional::get)
             .collect(toImmutableSet());
-    return CodeOwnerResolverResult.create(codeOwners, ownedByAllUsers.get());
+    return CodeOwnerResolverResult.create(
+        codeOwners, ownedByAllUsers.get(), hasUnresolvedCodeOwners.get(), hasUnresolvedImports);
   }
 
   /**
@@ -233,35 +259,34 @@
    * @return the {@link CodeOwner} for the code owner reference if it was resolved, otherwise {@link
    *     Optional#empty()}
    */
-  @VisibleForTesting
-  public Stream<CodeOwner> resolve(CodeOwnerReference codeOwnerReference) {
+  public Optional<CodeOwner> resolve(CodeOwnerReference codeOwnerReference) {
     String email = requireNonNull(codeOwnerReference, "codeOwnerReference").email();
     logger.atFine().log("resolving code owner reference %s", codeOwnerReference);
 
     if (!isEmailDomainAllowed(email)) {
       logger.atFine().log("domain of email %s is not allowed", email);
-      return Stream.of();
+      return Optional.empty();
     }
 
     Optional<AccountState> accountState =
         lookupEmail(email).flatMap(accountId -> lookupAccount(accountId, email));
     if (!accountState.isPresent()) {
       logger.atFine().log("no account for email %s", email);
-      return Stream.of();
+      return Optional.empty();
     }
     if (!accountState.get().account().isActive()) {
       logger.atFine().log("account for email %s is inactive", email);
-      return Stream.of();
+      return Optional.empty();
     }
     if (enforceVisibility && !isVisible(accountState.get(), email)) {
       logger.atFine().log(
           "account %d or email %s not visible", accountState.get().account().id().get(), email);
-      return Stream.of();
+      return Optional.empty();
     }
 
     CodeOwner codeOwner = CodeOwner.create(accountState.get().account().id());
     logger.atFine().log("resolved to code owner %s", codeOwner);
-    return Stream.of(codeOwner);
+    return Optional.of(codeOwner);
   }
 
   /** Whether the given account can be seen. */
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
index 226d541..a03f0e1 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverResult.java
@@ -43,17 +43,40 @@
    */
   public abstract boolean ownedByAllUsers();
 
+  /** Whether there are code owner references which couldn't be resolved. */
+  public abstract boolean hasUnresolvedCodeOwners();
+
+  /** Whether there are imports which couldn't be resolved. */
+  public abstract boolean hasUnresolvedImports();
+
+  /**
+   * Whether there are any code owners defined for the path, regardless of whether they can be
+   * resolved or not.
+   */
+  public boolean hasRevelantCodeOwnerDefinitions() {
+    return !codeOwners().isEmpty()
+        || ownedByAllUsers()
+        || hasUnresolvedCodeOwners()
+        || hasUnresolvedImports();
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
         .add("codeOwners", codeOwners())
         .add("ownedByAllUsers", ownedByAllUsers())
+        .add("hasUnresolvedCodeOwners", hasUnresolvedCodeOwners())
+        .add("hasUnresolvedImports", hasUnresolvedImports())
         .toString();
   }
 
   /** Creates a {@link CodeOwnerResolverResult} instance. */
   public static CodeOwnerResolverResult create(
-      ImmutableSet<CodeOwner> codeOwners, boolean ownedByAllUsers) {
-    return new AutoValue_CodeOwnerResolverResult(codeOwners, ownedByAllUsers);
+      ImmutableSet<CodeOwner> codeOwners,
+      boolean ownedByAllUsers,
+      boolean hasUnresolvedCodeOwners,
+      boolean hasUnresolvedImports) {
+    return new AutoValue_CodeOwnerResolverResult(
+        codeOwners, ownedByAllUsers, hasUnresolvedCodeOwners, hasUnresolvedImports);
   }
 }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/FallbackCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/backend/FallbackCodeOwners.java
new file mode 100644
index 0000000..5ad4079
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/FallbackCodeOwners.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+/** Defines who owns paths for which no code owners are defined. */
+public enum FallbackCodeOwners {
+  /**
+   * Paths for which no code owners are defined are owned by no one. This means changes that touch
+   * these files can only be submitted with a code owner override.
+   */
+  NONE,
+
+  /**
+   * Paths for which no code owners are defined are owned by all users. This means changes to these
+   * paths can be approved by anyone. If implicit approvals are enabled, these files are always
+   * automatically approved.
+   *
+   * <p>The {@code ALL_USERS} option should only be used with care as it means that any path that is
+   * not covered by the code owner config files is automatically opened up to everyone and mistakes
+   * with configuring code owners can easily happen. This is why this option is intended to be only
+   * used if requiring code owner approvals should not be enforced.
+   */
+  ALL_USERS;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
index c8a9b82..078ba17 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwners.java
@@ -20,7 +20,6 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
@@ -123,7 +122,7 @@
   private final Path path;
   private final PathExpressionMatcher pathExpressionMatcher;
 
-  private CodeOwnerConfig resolvedCodeOwnerConfig;
+  private PathCodeOwnersResult pathCodeOwnersResult;
 
   private PathCodeOwners(
       ProjectCache projectCache,
@@ -146,32 +145,6 @@
   }
 
   /**
-   * Gets the code owners from the code owner config that apply to the path.
-   *
-   * <p>Code owners from inherited code owner configs are not considered.
-   *
-   * @return the code owners of the path
-   */
-  public ImmutableSet<CodeOwnerReference> get() {
-    logger.atFine().log("computing path code owners for %s from %s", path, codeOwnerConfig.key());
-    ImmutableSet<CodeOwnerReference> pathCodeOwners =
-        resolveCodeOwnerConfig().codeOwnerSets().stream()
-            .flatMap(codeOwnerSet -> codeOwnerSet.codeOwners().stream())
-            .collect(toImmutableSet());
-    logger.atFine().log("pathCodeOwners = %s", pathCodeOwners);
-    return pathCodeOwners;
-  }
-
-  /**
-   * Whether parent code owners should be ignored for the path.
-   *
-   * @return whether parent code owners should be ignored for the path
-   */
-  public boolean ignoreParentCodeOwners() {
-    return resolveCodeOwnerConfig().ignoreParentCodeOwners();
-  }
-
-  /**
    * Resolves the {@link #codeOwnerConfig}.
    *
    * <p>Resolving means that:
@@ -207,9 +180,9 @@
    *
    * @return the resolved code owner config
    */
-  private CodeOwnerConfig resolveCodeOwnerConfig() {
-    if (this.resolvedCodeOwnerConfig != null) {
-      return this.resolvedCodeOwnerConfig;
+  public PathCodeOwnersResult resolveCodeOwnerConfig() {
+    if (this.pathCodeOwnersResult != null) {
+      return this.pathCodeOwnersResult;
     }
 
     try (TraceTimer traceTimer =
@@ -233,7 +206,8 @@
       getMatchingPerFileCodeOwnerSets(codeOwnerConfig)
           .forEach(resolvedCodeOwnerConfigBuilder::addCodeOwnerSet);
 
-      resolveImports(codeOwnerConfig, resolvedCodeOwnerConfigBuilder);
+      boolean hasUnresolvedImports =
+          !resolveImports(codeOwnerConfig, resolvedCodeOwnerConfigBuilder);
 
       CodeOwnerConfig resolvedCodeOwnerConfig = resolvedCodeOwnerConfigBuilder.build();
 
@@ -255,15 +229,24 @@
                 .build();
       }
 
-      this.resolvedCodeOwnerConfig = resolvedCodeOwnerConfig;
-      logger.atFine().log("resolved code owner config = %s", resolvedCodeOwnerConfig);
-      return this.resolvedCodeOwnerConfig;
+      this.pathCodeOwnersResult =
+          PathCodeOwnersResult.create(path, resolvedCodeOwnerConfig, hasUnresolvedImports);
+      logger.atFine().log("path code owners result = %s", pathCodeOwnersResult);
+      return this.pathCodeOwnersResult;
     }
   }
 
-  private void resolveImports(
+  /**
+   * Resolve the imports of the given code owner config.
+   *
+   * @param importingCodeOwnerConfig the code owner config for which imports should be resolved
+   * @param resolvedCodeOwnerConfigBuilder the builder for the resolved code owner config
+   * @return whether all imports have been resolved successfully
+   */
+  private boolean resolveImports(
       CodeOwnerConfig importingCodeOwnerConfig,
       CodeOwnerConfig.Builder resolvedCodeOwnerConfigBuilder) {
+    boolean hasUnresolvedImports = false;
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
             "Resolve code owner config imports",
@@ -307,6 +290,7 @@
           Optional<ProjectState> projectState =
               projectCache.get(keyOfImportedCodeOwnerConfig.project());
           if (!projectState.isPresent()) {
+            hasUnresolvedImports = true;
             logger.atWarning().log(
                 "cannot resolve code owner config %s that is imported by code owner config %s:"
                     + " project %s not found",
@@ -316,6 +300,7 @@
             continue;
           }
           if (!projectState.get().statePermitsRead()) {
+            hasUnresolvedImports = true;
             logger.atWarning().log(
                 "cannot resolve code owner config %s that is imported by code owner config %s:"
                     + " state of project %s doesn't permit read",
@@ -337,6 +322,7 @@
                   : codeOwners.getFromCurrentRevision(keyOfImportedCodeOwnerConfig);
 
           if (!mayBeImportedCodeOwnerConfig.isPresent()) {
+            hasUnresolvedImports = true;
             logger.atWarning().log(
                 "cannot resolve code owner config %s that is imported by code owner config %s"
                     + " (revision = %s)",
@@ -404,6 +390,7 @@
         }
       }
     }
+    return !hasUnresolvedImports;
   }
 
   public static CodeOwnerConfig.Key createKeyForImportedCodeOwnerConfig(
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java
new file mode 100644
index 0000000..a463aef
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersResult.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import java.nio.file.Path;
+
+/** The result of resolving path code owners via {@link PathCodeOwners}. */
+@AutoValue
+public abstract class PathCodeOwnersResult {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /** Gets the path for which the code owner config was resolved. */
+  abstract Path path();
+
+  /** Gets the resolved code owner config. */
+  abstract CodeOwnerConfig codeOwnerConfig();
+
+  /** Whether there are unresolved imports. */
+  public abstract boolean hasUnresolvedImports();
+
+  /**
+   * Gets the code owners from the code owner config that apply to the path.
+   *
+   * <p>Code owners from inherited code owner configs are not considered.
+   *
+   * @return the code owners of the path
+   */
+  public ImmutableSet<CodeOwnerReference> getPathCodeOwners() {
+    logger.atFine().log(
+        "computing path code owners for %s from %s", path(), codeOwnerConfig().key());
+    ImmutableSet<CodeOwnerReference> pathCodeOwners =
+        codeOwnerConfig().codeOwnerSets().stream()
+            .flatMap(codeOwnerSet -> codeOwnerSet.codeOwners().stream())
+            .collect(toImmutableSet());
+    logger.atFine().log("pathCodeOwners = %s", pathCodeOwners);
+    return pathCodeOwners;
+  }
+
+  /**
+   * Whether parent code owners should be ignored for the path.
+   *
+   * @return whether parent code owners should be ignored for the path
+   */
+  public boolean ignoreParentCodeOwners() {
+    return codeOwnerConfig().ignoreParentCodeOwners();
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("path", path())
+        .add("codeOwnerConfig", codeOwnerConfig())
+        .add("hasUnresolvedImports", hasUnresolvedImports())
+        .toString();
+  }
+
+  /** Creates a {@link CodeOwnerResolverResult} instance. */
+  public static PathCodeOwnersResult create(
+      Path path, CodeOwnerConfig codeOwnerConfig, boolean hasUnresolvedImports) {
+    return new AutoValue_PathCodeOwnersResult(path, codeOwnerConfig, hasUnresolvedImports);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java b/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
index a66dc1b..57bc488 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
+++ b/java/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfiguration.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -139,6 +140,17 @@
   }
 
   /**
+   * Gets the fallback code owners for the given project.
+   *
+   * @param project the project for which the fallback code owners should be retrieved
+   * @return the fallback code owners for the given project
+   */
+  public FallbackCodeOwners getFallbackCodeOwners(Project.NameKey project) {
+    requireNonNull(project, "project");
+    return generalConfig.getFallbackCodeOwners(project, getPluginConfig(project));
+  }
+
+  /**
    * Checks whether an implicit code owner approval from the last uploader is assumed.
    *
    * @param project the project for it should be checked whether implict approvals are enabled
diff --git a/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java b/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java
index bd137ac..54ddf7c 100644
--- a/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/config/GeneralConfig.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigValidationPolicy;
 import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -59,6 +60,7 @@
   @VisibleForTesting public static final String KEY_ALLOWED_EMAIL_DOMAIN = "allowedEmailDomain";
   @VisibleForTesting public static final String KEY_FILE_EXTENSION = "fileExtension";
   @VisibleForTesting public static final String KEY_READ_ONLY = "readOnly";
+  @VisibleForTesting public static final String KEY_FALLBACK_CODE_OWNERS = "fallbackCodeOwners";
 
   @VisibleForTesting
   public static final String KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED =
@@ -118,6 +120,24 @@
               ValidationMessage.Type.ERROR));
     }
 
+    try {
+      projectLevelConfig
+          .getConfig()
+          .getEnum(SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS, FallbackCodeOwners.NONE);
+    } catch (IllegalArgumentException e) {
+      validationMessages.add(
+          new CommitValidationMessage(
+              String.format(
+                  "The value for fallback code owners '%s' that is configured in %s (parameter %s.%s) is invalid.",
+                  projectLevelConfig
+                      .getConfig()
+                      .getString(SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS),
+                  fileName,
+                  SECTION_CODE_OWNERS,
+                  KEY_FALLBACK_CODE_OWNERS),
+              ValidationMessage.Type.ERROR));
+    }
+
     return ImmutableList.copyOf(validationMessages);
   }
 
@@ -174,6 +194,46 @@
   }
 
   /**
+   * Gets the fallback code owners that own paths that have no defined code owners.
+   *
+   * @param project the project for which the fallback code owners should be read
+   * @param pluginConfig the plugin config from which the fallback code owners should be read
+   * @return the fallback code owners that own paths that have no defined code owners
+   */
+  FallbackCodeOwners getFallbackCodeOwners(Project.NameKey project, Config pluginConfig) {
+    requireNonNull(project, "project");
+    requireNonNull(pluginConfig, "pluginConfig");
+
+    String fallbackCodeOwnersString =
+        pluginConfig.getString(SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS);
+    if (fallbackCodeOwnersString != null) {
+      try {
+        return pluginConfig.getEnum(
+            SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS, FallbackCodeOwners.NONE);
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().log(
+            "Ignoring invalid value %s for fallback code owners in '%s.config' of project %s."
+                + " Falling back to global config.",
+            fallbackCodeOwnersString, pluginName, project.get());
+      }
+    }
+
+    try {
+      return pluginConfigFromGerritConfig.getEnum(
+          KEY_FALLBACK_CODE_OWNERS, FallbackCodeOwners.NONE);
+    } catch (IllegalArgumentException e) {
+      logger.atWarning().log(
+          "Ignoring invalid value %s for fallback code owners in gerrit.config (parameter"
+              + " plugin.%s.%s). Falling back to default value %s.",
+          pluginConfigFromGerritConfig.getString(KEY_FALLBACK_CODE_OWNERS),
+          pluginName,
+          KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED,
+          FallbackCodeOwners.NONE);
+      return FallbackCodeOwners.NONE;
+    }
+  }
+
+  /**
    * Gets the enable validation on commit received configuration from the given plugin config with
    * fallback to {@code gerrit.config} and default to {@code true}.
    *
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
index e7fcd72..6e53e33 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJson.java
@@ -116,6 +116,8 @@
         codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(projectName) ? true : null;
     generalInfo.overrideInfoUrl =
         codeOwnersPluginConfiguration.getOverrideInfoUrl(projectName).orElse(null);
+    generalInfo.fallbackCodeOwners =
+        codeOwnersPluginConfiguration.getFallbackCodeOwners(projectName);
     return generalInfo;
   }
 
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java b/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java
index 8df9a34..0ed505b 100644
--- a/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java
+++ b/java/com/google/gerrit/plugins/codeowners/restapi/RenameEmail.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.plugins.codeowners.restapi;
 
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -56,7 +53,7 @@
 @Singleton
 public class RenameEmail implements RestModifyView<BranchResource, RenameEmailInput> {
   @VisibleForTesting
-  public static String DEFAULT_COMMIT_MESSAGE = "Rename email in code owner config files";
+  public static final String DEFAULT_COMMIT_MESSAGE = "Rename email in code owner config files";
 
   private final Provider<CurrentUser> currentUser;
   private final PermissionBackend permissionBackend;
@@ -158,19 +155,14 @@
     }
   }
 
-  private Account.Id resolveEmail(String email)
-      throws ResourceConflictException, UnprocessableEntityException {
+  private Account.Id resolveEmail(String email) throws UnprocessableEntityException {
     requireNonNull(email, "email");
 
-    ImmutableSet<CodeOwner> codeOwners =
-        codeOwnerResolver.resolve(CodeOwnerReference.create(email)).collect(toImmutableSet());
-    if (codeOwners.isEmpty()) {
+    Optional<CodeOwner> codeOwner = codeOwnerResolver.resolve(CodeOwnerReference.create(email));
+    if (!codeOwner.isPresent()) {
       throw new UnprocessableEntityException(String.format("cannot resolve email %s", email));
     }
-    if (codeOwners.size() > 1) {
-      throw new ResourceConflictException(String.format("email %s is ambigious", email));
-    }
-    return Iterables.getOnlyElement(codeOwners).accountId();
+    return codeOwner.get().accountId();
   }
 
   /**
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
index 19f9a40..93504a7 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersPluginConfigValidatorIT.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT;
 import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
 import com.google.gerrit.plugins.codeowners.config.BackendConfig;
 import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
@@ -406,6 +407,45 @@
                 + " (parameter codeOwners.mergeCommitStrategy) is invalid.");
   }
 
+  @Test
+  public void configureFallbackCodeOwners() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setEnum(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        FallbackCodeOwners.ALL_USERS);
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus()).isEqualTo(Status.OK);
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
+  public void cannotSetInvalidFallbackCodeOwners() throws Exception {
+    fetchRefsMetaConfig();
+
+    Config cfg = new Config();
+    cfg.setString(
+        CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        "INVALID");
+    setCodeOwnersConfig(cfg);
+
+    PushResult r = pushRefsMetaConfig();
+    assertThat(r.getRemoteUpdate(RefNames.REFS_CONFIG).getStatus())
+        .isEqualTo(Status.REJECTED_OTHER_REASON);
+    assertThat(r.getMessages())
+        .contains(
+            "The value for fallback code owners 'INVALID' that is configured in code-owners.config"
+                + " (parameter codeOwners.fallbackCodeOwners) is invalid.");
+  }
+
   private void fetchRefsMetaConfig() throws Exception {
     fetch(testRepo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
     testRepo.reset(RefNames.REFS_CONFIG);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java
index 66edb89..9cc77ea 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerBranchConfigIT.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.plugins.codeowners.config.BackendConfig;
 import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.config.GeneralConfig;
@@ -89,6 +90,8 @@
     assertThat(codeOwnerBranchConfigInfo.general.fileExtension).isNull();
     assertThat(codeOwnerBranchConfigInfo.general.mergeCommitStrategy)
         .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+    assertThat(codeOwnerBranchConfigInfo.general.fallbackCodeOwners)
+        .isEqualTo(FallbackCodeOwners.NONE);
     assertThat(codeOwnerBranchConfigInfo.general.implicitApprovals).isNull();
     assertThat(codeOwnerBranchConfigInfo.general.overrideInfoUrl).isNull();
     assertThat(codeOwnerBranchConfigInfo.disabled).isNull();
@@ -129,6 +132,15 @@
   }
 
   @Test
+  public void getConfigWithConfiguredFallbackCodeOwners() throws Exception {
+    configureFallbackCodeOwners(project, FallbackCodeOwners.ALL_USERS);
+    CodeOwnerBranchConfigInfo codeOwnerBranchConfigInfo =
+        projectCodeOwnersApiFactory.project(project).branch("master").getConfig();
+    assertThat(codeOwnerBranchConfigInfo.general.fallbackCodeOwners)
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
   public void getConfigForBranchOfDisabledProject() throws Exception {
     disableCodeOwnersForProject(project);
     CodeOwnerBranchConfigInfo codeOwnerBranchConfigInfo =
@@ -264,6 +276,11 @@
     setConfig(project, null, GeneralConfig.KEY_MERGE_COMMIT_STRATEGY, mergeCommitStrategy.name());
   }
 
+  private void configureFallbackCodeOwners(
+      Project.NameKey project, FallbackCodeOwners fallbackCodeOwners) throws Exception {
+    setConfig(project, null, GeneralConfig.KEY_FALLBACK_CODE_OWNERS, fallbackCodeOwners.name());
+  }
+
   private void configureDisabledBranch(Project.NameKey project, String disabledBranch)
       throws Exception {
     setCodeOwnersConfig(project, null, StatusConfig.KEY_DISABLED_BRANCH, disabledBranch);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
index de208e9..a6de7df 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetCodeOwnerProjectConfigIT.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.plugins.codeowners.config.BackendConfig;
 import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
 import com.google.gerrit.plugins.codeowners.config.GeneralConfig;
@@ -88,6 +89,8 @@
     assertThat(codeOwnerProjectConfigInfo.general.fileExtension).isNull();
     assertThat(codeOwnerProjectConfigInfo.general.mergeCommitStrategy)
         .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
+    assertThat(codeOwnerProjectConfigInfo.general.fallbackCodeOwners)
+        .isEqualTo(FallbackCodeOwners.NONE);
     assertThat(codeOwnerProjectConfigInfo.general.implicitApprovals).isNull();
     assertThat(codeOwnerProjectConfigInfo.general.overrideInfoUrl).isNull();
     assertThat(codeOwnerProjectConfigInfo.status.disabled).isNull();
@@ -130,6 +133,15 @@
   }
 
   @Test
+  public void getConfigWithConfiguredFallbackCodeOwners() throws Exception {
+    configureFallbackCodeOwners(project, FallbackCodeOwners.ALL_USERS);
+    CodeOwnerProjectConfigInfo codeOwnerProjectConfigInfo =
+        projectCodeOwnersApiFactory.project(project).getConfig();
+    assertThat(codeOwnerProjectConfigInfo.general.fallbackCodeOwners)
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
   public void getConfigForDisabledProject() throws Exception {
     disableCodeOwnersForProject(project);
     CodeOwnerProjectConfigInfo codeOwnerProjectConfigInfo =
@@ -283,6 +295,11 @@
     setConfig(project, null, GeneralConfig.KEY_MERGE_COMMIT_STRATEGY, mergeCommitStrategy.name());
   }
 
+  private void configureFallbackCodeOwners(
+      Project.NameKey project, FallbackCodeOwners fallbackCodeOwners) throws Exception {
+    setConfig(project, null, GeneralConfig.KEY_FALLBACK_CODE_OWNERS, fallbackCodeOwners.name());
+  }
+
   private void configureDisabledBranch(Project.NameKey project, String disabledBranch)
       throws Exception {
     setCodeOwnersConfig(project, null, StatusConfig.KEY_DISABLED_BRANCH, disabledBranch);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java
index ec0a368..b9e7502 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/RenameEmailIT.java
@@ -22,6 +22,8 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.base.Splitter;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -527,14 +529,14 @@
           // insert comment line at the top of the file
           b.append("# top comment\n");
 
-          String[] lines = codeOwnerConfigFileContent.split("\\n");
-          b.append(lines[0] + "\n");
+          Iterable<String> lines = Splitter.on('\n').split(codeOwnerConfigFileContent);
+          b.append(Iterables.get(lines, /* position= */ 0) + "\n");
 
           // insert comment line in the middle of the file
           b.append("# middle comment\n");
 
-          for (int n = 1; n < lines.length; n++) {
-            b.append(lines[n] + "\n");
+          for (String line : Iterables.skip(lines, /* numberToSkip= */ 1)) {
+            b.append(line + "\n");
           }
 
           // insert comment line at the bottom of the file
@@ -560,10 +562,11 @@
     // verify that the comments are still present
     String codeOwnerConfigFileContent =
         codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getContent();
-    String[] lines = codeOwnerConfigFileContent.split("\\n");
-    assertThat(lines[0]).isEqualTo("# top comment");
-    assertThat(lines[2]).isEqualTo("# middle comment");
-    assertThat(lines[lines.length - 1]).isEqualTo("# bottom comment");
+    Iterable<String> lines = Splitter.on('\n').split(codeOwnerConfigFileContent);
+    assertThat(Iterables.get(lines, /* position= */ 0)).isEqualTo("# top comment");
+    assertThat(Iterables.get(lines, /* position= */ 2)).isEqualTo("# middle comment");
+    assertThat(Iterables.get(lines, /* position= */ Iterables.size(lines) - 2))
+        .isEqualTo("# bottom comment");
   }
 
   @Test
@@ -587,7 +590,7 @@
         "Insert comments",
         (codeOwnerConfigFilePath, codeOwnerConfigFileContent) -> {
           StringBuilder b = new StringBuilder();
-          for (String line : codeOwnerConfigFileContent.split("\\n")) {
+          for (String line : Splitter.on('\n').split(codeOwnerConfigFileContent)) {
             if (line.contains(user.email())) {
               b.append(line + "# some comment\n");
               continue;
@@ -619,7 +622,7 @@
     // verify that the inline comments are still present
     String codeOwnerConfigFileContent =
         codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getContent();
-    for (String line : codeOwnerConfigFileContent.split("\\n")) {
+    for (String line : Splitter.on('\n').split(codeOwnerConfigFileContent)) {
       if (line.contains(secondaryEmail)) {
         assertThat(line).endsWith("# some comment");
       } else if (line.contains(admin.email())) {
@@ -667,7 +670,8 @@
     // verify that the comments are still present
     String codeOwnerConfigFileContent =
         codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getContent();
-    assertThat(codeOwnerConfigFileContent.split("\\n")[0])
+    assertThat(
+            Iterables.get(Splitter.on('\n').split(codeOwnerConfigFileContent), /* position= */ 0))
         .endsWith("# foo " + secondaryEmail + " bar");
   }
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
index be4d6b0..6386a15 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
@@ -51,7 +51,10 @@
 import org.junit.Before;
 import org.junit.Test;
 
-/** Tests for {@link CodeOwnerApprovalCheck}. */
+/**
+ * Tests for {@link CodeOwnerApprovalCheck}. Further tests with fallback code owners are implemented
+ * in {@link CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest}}.
+ */
 public class CodeOwnerApprovalCheckTest extends AbstractCodeOwnersTest {
   @Inject private ChangeNotes.Factory changeNotesFactory;
   @Inject private RequestScopeOperations requestScopeOperations;
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java
new file mode 100644
index 0000000..e087873
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest.java
@@ -0,0 +1,668 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.codeowners.backend;
+
+import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatStream;
+
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.plugins.codeowners.JgitPath;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.config.GeneralConfig;
+import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnerApprovalCheck} with fallback code owners. */
+public class CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest
+    extends AbstractCodeOwnersTest {
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  private CodeOwnerApprovalCheck codeOwnerApprovalCheck;
+  private CodeOwnerConfigOperations codeOwnerConfigOperations;
+
+  /** Returns a {@code gerrit.config} that configures all users as fallback code owners. */
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setEnum(
+        "plugin",
+        "code-owners",
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        FallbackCodeOwners.ALL_USERS);
+    return cfg;
+  }
+
+  @Before
+  public void setUpCodeOwnersPlugin() throws Exception {
+    codeOwnerApprovalCheck = plugin.getSysInjector().getInstance(CodeOwnerApprovalCheck.class);
+    codeOwnerConfigOperations =
+        plugin.getSysInjector().getInstance(CodeOwnerConfigOperations.class);
+  }
+
+  @Test
+  public void notApprovedByFallbackCodeOwnerIfCodeOwnersDefined() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(codeOwner.email())
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void notImplicitlyApprovedByFallbackCodeOwnerIfCodeOwnersDefined() throws Exception {
+    TestAccount codeOwner =
+        accountCreator.create(
+            "codeOwner", "codeOwner@example.com", "CodeOwner", /* displayName= */ null);
+
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail(codeOwner.email())
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void notApprovedByFallbackCodeOwnerIfNonResolvableCodeOwnersDefined() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail("non-resolvable-code-owner@example.com")
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void notImplicitlyApprovedByFallbackCodeOwnerIfNonResolvableCodeOwnersDefined()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerEmail("non-resolvable-code-owner@example.com")
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void approvedByFallbackCodeOwner() throws Exception {
+    // create arbitrary code owner config to avoid entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+    createArbitraryCodeOwnerConfigFile();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet (the change owner is a code owner, but
+    // implicit approvals are disabled).
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a user a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Verify that the status is pending now .
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.PENDING);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    // Verify that the status is approved now
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void implicitlyApprovedByFallbackCodeOwner() throws Exception {
+    // create arbitrary code owner config to avoid entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+    createArbitraryCodeOwnerConfigFile();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is approved (the change owner is a code owner and implicit approvals are
+    // enabled).
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  public void approvedByFallbackCodeOwner_bootstrappingMode() throws Exception {
+    // since no code owner config exists we are entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+
+    TestAccount user2 = accountCreator.user2();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Verify that the file is not approved yet (the change owner is a code owner, but
+    // implicit approvals are disabled).
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a user a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user2.email());
+
+    // Verify that the status is pending now .
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.PENDING);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user2.id());
+    recommend(changeId);
+
+    // Verify that the status is approved now
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void implicitlyApprovedByFallbackCodeOwner_bootstrappingMode() throws Exception {
+    // since no code owner config exists we are entering the bootstrapping code path in
+    // CodeOwnerApprovalCheck
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange(user, "Change Adding A File", JgitPath.of(path).get(), "file content")
+            .getChangeId();
+
+    // Verify that the file is approved (the change owner is a code owner and implicit approvals are
+    // enabled).
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.APPROVED);
+  }
+
+  @Test
+  public void notApprovedByFallbackCodeOwnerIfParentCodeOwnersIgnored() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .ignoreParentCodeOwners()
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void notImplicitlyApprovedByFallbackCodeOwnerIfParentCodeOwnersIgnored() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .ignoreParentCodeOwners()
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void notApprovedByFallbackCodeOwnerIfImportCannotBeResolved() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addImport(
+            CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/non-existing/OWNERS"))
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void notImplicitlyApprovedByFallbackCodeOwnerIfImportCannotBeResolved() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addImport(
+            CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/non-existing/OWNERS"))
+        .create();
+
+    Path path = Paths.get("/foo/bar.baz");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  public void notApprovedByFallbackCodeOwnerIfPerFileImportCannotBeResolved() throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression("*.md")
+                .addImport(
+                    CodeOwnerConfigReference.create(
+                        CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+                .autoBuild())
+        .create();
+
+    Path path = Paths.get("/foo/bar.md");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a fallback code owner as reviewer.
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+
+    // Add a Code-Review+1 (= code owner approval) from a fallback code owner.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(changeId);
+
+    // Verify that the file is not approved (fallback code owner doesn't apply since a code owner is
+    // defined).
+    fileCodeOwnerStatuses = codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.enableImplicitApprovals", value = "true")
+  public void notImplicitlyApprovedByFallbackCodeOwnerIfPerFileImportCannotBeResolved()
+      throws Exception {
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/foo/")
+        .addCodeOwnerSet(
+            CodeOwnerSet.builder()
+                .addPathExpression("*.md")
+                .addImport(
+                    CodeOwnerConfigReference.create(
+                        CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY, "/bar/OWNERS"))
+                .autoBuild())
+        .create();
+
+    Path path = Paths.get("/foo/bar.md");
+    String changeId =
+        createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+    // Verify that the file is not approved yet.
+    Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+        codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+    FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+        assertThatStream(fileCodeOwnerStatuses).onlyElement();
+    fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+    fileCodeOwnerStatusSubject
+        .hasNewPathStatus()
+        .value()
+        .hasStatusThat()
+        .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+  }
+
+  private ChangeNotes getChangeNotes(String changeId) throws Exception {
+    return changeNotesFactory.create(project, Change.id(gApi.changes().id(changeId).get()._number));
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchyTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchyTest.java
index 2b30264..af08aa3 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchyTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigHierarchyTest.java
@@ -33,6 +33,7 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.nio.file.Paths;
+import java.util.function.Consumer;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -53,6 +54,7 @@
   @Rule public final MockitoRule mockito = MockitoJUnit.rule().strictness(Strictness.STRICT_STUBS);
 
   @Mock private CodeOwnerConfigVisitor visitor;
+  @Mock private Consumer<CodeOwnerConfig.Key> parentCodeOwnersIgnoredCallback;
 
   @Inject private ProjectOperations projectOperations;
 
@@ -145,6 +147,22 @@
   }
 
   @Test
+  public void cannotVisitCodeOwnerConfigsWithNullCallback() throws Exception {
+    BranchNameKey branchNameKey = BranchNameKey.create(project, "master");
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                codeOwnerConfigHierarchy.visit(
+                    branchNameKey,
+                    getCurrentRevision(branchNameKey),
+                    Paths.get("/foo/bar/baz.md"),
+                    visitor,
+                    /* parentCodeOwnersIgnoredCallback= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("parentCodeOwnersIgnoredCallback");
+  }
+
+  @Test
   public void visitorNotInvokedIfNoCodeOwnerConfigExists() throws Exception {
     visit("master", "/foo/bar/baz.md");
     verifyZeroInteractions(visitor);
@@ -414,6 +432,9 @@
         .verify(visitor)
         .visit(codeOwnerConfigOperations.codeOwnerConfig(fooBarCodeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
+
+    verify(parentCodeOwnersIgnoredCallback).accept(fooBarCodeOwnerConfigKey);
+    verifyNoMoreInteractions(parentCodeOwnersIgnoredCallback);
   }
 
   @Test
@@ -477,6 +498,9 @@
         .verify(visitor)
         .visit(codeOwnerConfigOperations.codeOwnerConfig(fooBarCodeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
+
+    verify(parentCodeOwnersIgnoredCallback).accept(fooBarCodeOwnerConfigKey);
+    verifyNoMoreInteractions(parentCodeOwnersIgnoredCallback);
   }
 
   @Test
@@ -533,6 +557,8 @@
         .verify(visitor)
         .visit(codeOwnerConfigOperations.codeOwnerConfig(rootCodeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
+
+    verifyZeroInteractions(parentCodeOwnersIgnoredCallback);
   }
 
   @Test
@@ -657,6 +683,9 @@
     visit("master", "/foo/bar/baz.md");
     verify(visitor).visit(codeOwnerConfigOperations.codeOwnerConfig(rootCodeOwnerConfigKey).get());
     verifyNoMoreInteractions(visitor);
+
+    verify(parentCodeOwnersIgnoredCallback).accept(rootCodeOwnerConfigKey);
+    verifyNoMoreInteractions(parentCodeOwnersIgnoredCallback);
   }
 
   @Test
@@ -711,7 +740,11 @@
       throws InvalidPluginConfigurationException, IOException {
     BranchNameKey branchNameKey = BranchNameKey.create(project, branchName);
     codeOwnerConfigHierarchy.visit(
-        branchNameKey, getCurrentRevision(branchNameKey), Paths.get(path), visitor);
+        branchNameKey,
+        getCurrentRevision(branchNameKey),
+        Paths.get(path),
+        visitor,
+        parentCodeOwnersIgnoredCallback);
   }
 
   private ObjectId getCurrentRevision(BranchNameKey branchNameKey) throws IOException {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
index 6e19e28..86652b2 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerResolverTest.java
@@ -34,8 +34,8 @@
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import java.nio.file.Paths;
+import java.util.Optional;
 import java.util.Set;
-import java.util.stream.Stream;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
@@ -89,14 +89,14 @@
 
   @Test
   public void resolveCodeOwnerReferenceForEmail() throws Exception {
-    Stream<CodeOwner> codeOwner =
+    Optional<CodeOwner> codeOwner =
         codeOwnerResolver.get().resolve(CodeOwnerReference.create(admin.email()));
-    assertThat(codeOwner).onlyElement().hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(admin.id());
   }
 
   @Test
   public void cannotResolveCodeOwnerReferenceForStarAsEmail() throws Exception {
-    Stream<CodeOwner> codeOwner =
+    Optional<CodeOwner> codeOwner =
         codeOwnerResolver
             .get()
             .resolve(CodeOwnerReference.create(CodeOwnerResolver.ALL_USERS_WILDCARD));
@@ -160,14 +160,14 @@
 
     // admin has the "Modify Account" global capability and hence can see the secondary email of the
     // user account.
-    Stream<CodeOwner> codeOwner =
+    Optional<CodeOwner> codeOwner =
         codeOwnerResolver.get().resolve(CodeOwnerReference.create(secondaryEmail));
-    assertThat(codeOwner).onlyElement().hasAccountIdThat().isEqualTo(user.id());
+    assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(user.id());
 
     // user can see its own secondary email.
     requestScopeOperations.setApiUser(user.id());
     codeOwner = codeOwnerResolver.get().resolve(CodeOwnerReference.create(secondaryEmail));
-    assertThat(codeOwner).onlyElement().hasAccountIdThat().isEqualTo(user.id());
+    assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(user.id());
   }
 
   @Test
@@ -192,6 +192,7 @@
         codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwners()).isEmpty();
     assertThat(result.ownedByAllUsers()).isFalse();
+    assertThat(result.hasUnresolvedCodeOwners()).isFalse();
   }
 
   @Test
@@ -205,6 +206,7 @@
         codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id(), user.id());
     assertThat(result.ownedByAllUsers()).isFalse();
+    assertThat(result.hasUnresolvedCodeOwners()).isFalse();
   }
 
   @Test
@@ -219,6 +221,7 @@
         codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).isEmpty();
     assertThat(result.ownedByAllUsers()).isTrue();
+    assertThat(result.hasUnresolvedCodeOwners()).isFalse();
   }
 
   @Test
@@ -233,6 +236,23 @@
         codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
     assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id());
     assertThat(result.ownedByAllUsers()).isFalse();
+    assertThat(result.hasUnresolvedCodeOwners()).isTrue();
+  }
+
+  @Test
+  public void resolvePathCodeOwnersNonResolvableCodeOwnersAreFilteredOutIfOwnedByAllUsers()
+      throws Exception {
+    CodeOwnerConfig codeOwnerConfig =
+        CodeOwnerConfig.builder(CodeOwnerConfig.Key.create(project, "master", "/"), TEST_REVISION)
+            .addCodeOwnerSet(
+                CodeOwnerSet.createWithoutPathExpressions(
+                    "*", admin.email(), "non-existing@example.com"))
+            .build();
+    CodeOwnerResolverResult result =
+        codeOwnerResolver.get().resolvePathCodeOwners(codeOwnerConfig, Paths.get("/README.md"));
+    assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id());
+    assertThat(result.ownedByAllUsers()).isTrue();
+    assertThat(result.hasUnresolvedCodeOwners()).isTrue();
   }
 
   @Test
@@ -297,9 +317,9 @@
     assertThat(codeOwnerResolver.get().resolve(adminCodeOwnerReference)).isEmpty();
 
     // if visibility is not enforced the code owner reference can be resolved regardless
-    Stream<CodeOwner> codeOwner =
+    Optional<CodeOwner> codeOwner =
         codeOwnerResolver.get().enforceVisibility(false).resolve(adminCodeOwnerReference);
-    assertThat(codeOwner).onlyElement().hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(codeOwner).value().hasAccountIdThat().isEqualTo(admin.id());
   }
 
   @Test
@@ -311,13 +331,13 @@
 
     // admin is the current user and can see the account
     assertThat(codeOwnerResolver.get().resolve(CodeOwnerReference.create(user.email())))
-        .isNotEmpty();
+        .isPresent();
     assertThat(
             codeOwnerResolver
                 .get()
                 .forUser(identifiedUserFactory.create(admin.id()))
                 .resolve(CodeOwnerReference.create(user.email())))
-        .isNotEmpty();
+        .isPresent();
 
     // user2 cannot see the account
     assertThat(
@@ -386,6 +406,21 @@
                     CodeOwnerReference.create(user.email())));
     assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id(), user.id());
     assertThat(result.ownedByAllUsers()).isFalse();
+    assertThat(result.hasUnresolvedCodeOwners()).isFalse();
+  }
+
+  @Test
+  public void resolveCodeOwnerReferencesNonResolveableCodeOwnersAreFilteredOut() throws Exception {
+    CodeOwnerResolverResult result =
+        codeOwnerResolver
+            .get()
+            .resolve(
+                ImmutableSet.of(
+                    CodeOwnerReference.create(admin.email()),
+                    CodeOwnerReference.create("non-existing@example.com")));
+    assertThat(result.codeOwnersAccountIds()).containsExactly(admin.id());
+    assertThat(result.ownedByAllUsers()).isFalse();
+    assertThat(result.hasUnresolvedCodeOwners()).isTrue();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersTest.java
index cb065a0..fafc620 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnersTest.java
@@ -64,7 +64,7 @@
             NullPointerException.class,
             () ->
                 codeOwners.get(
-                    CodeOwnerConfig.Key.create(project, "master", "/"), /* folderPath= */ null));
+                    CodeOwnerConfig.Key.create(project, "master", "/"), /* revision= */ null));
     assertThat(npe).hasMessageThat().isEqualTo("revision");
   }
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
index e5d06ac..0677ca4 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
@@ -205,7 +205,8 @@
     CodeOwnerConfig emptyCodeOwnerConfig = createCodeOwnerBuilder().build();
     PathCodeOwners pathCodeOwners =
         pathCodeOwnersFactory.create(emptyCodeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-    assertThat(pathCodeOwners.get()).isEmpty();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners()).isEmpty();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -216,7 +217,7 @@
             .build();
     PathCodeOwners pathCodeOwners =
         pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-    assertThat(pathCodeOwners.get())
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
   }
@@ -254,7 +255,7 @@
               .build();
       PathCodeOwners pathCodeOwners =
           pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.get())
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
           .comparingElementsUsing(hasEmail())
           .containsExactly(admin.email(), user.email());
     }
@@ -275,7 +276,7 @@
               .build();
       PathCodeOwners pathCodeOwners =
           pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.get()).isEmpty();
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners()).isEmpty();
     }
   }
 
@@ -307,7 +308,7 @@
               .build();
       PathCodeOwners pathCodeOwners =
           pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.get())
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
           .comparingElementsUsing(hasEmail())
           .containsExactly(admin.email());
     }
@@ -341,7 +342,7 @@
               .build();
       PathCodeOwners pathCodeOwners =
           pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.get())
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
           .comparingElementsUsing(hasEmail())
           .containsExactly(admin.email(), user.email());
     }
@@ -376,7 +377,7 @@
               .build();
       PathCodeOwners pathCodeOwners =
           pathCodeOwnersFactory.create(codeOwnerConfig, Paths.get("/foo/bar/baz.md"));
-      assertThat(pathCodeOwners.get())
+      assertThat(pathCodeOwners.resolveCodeOwnerConfig().getPathCodeOwners())
           .comparingElementsUsing(hasEmail())
           .containsExactly(admin.email(), user.email());
     }
@@ -389,7 +390,7 @@
         pathCodeOwnersFactory.create(
             createCodeOwnerBuilder().setIgnoreParentCodeOwners().build(),
             Paths.get("/foo/bar/baz.md"));
-    assertThat(pathCodeOwners.ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().ignoreParentCodeOwners()).isTrue();
   }
 
   @Test
@@ -399,7 +400,7 @@
         pathCodeOwnersFactory.create(
             createCodeOwnerBuilder().setIgnoreParentCodeOwners(false).build(),
             Paths.get("/foo/bar/baz.md"));
-    assertThat(pathCodeOwners.ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
   }
 
   @Test
@@ -415,7 +416,7 @@
                         .build())
                 .build(),
             Paths.get("/foo.md"));
-    assertThat(pathCodeOwners.ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().ignoreParentCodeOwners()).isTrue();
   }
 
   @Test
@@ -432,7 +433,7 @@
                         .build())
                 .build(),
             Paths.get("/foo.md"));
-    assertThat(pathCodeOwners.ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
   }
 
   @Test
@@ -459,9 +460,10 @@
 
     // Expectation: we get the global code owner from the importing code owner config, the
     // non-resolveable import is silently ignored
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -504,9 +506,10 @@
 
     // Expectation: we get the global code owners from the importing and the imported code owner
     // config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -549,9 +552,10 @@
 
     // Expectation: we get the matching per-file code owners from the importing and the imported
     // code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -595,9 +599,10 @@
     // Expectation: we only get the matching per-file code owners from the importing code owner
     // config, the per-file code owners from the imported code owner config are not relevant since
     // they do not match
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -642,9 +647,10 @@
     // Expectation: we only get the matching per-file code owners from the importing code owner
     // config, the matching per-file code owners from the imported code owner config are not
     // relevant with import mode GLOBAL_CODE_OWNER_SETS_ONLY
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -690,10 +696,11 @@
     // the matching per-file code owner set in the imported code owner config has the
     // ignoreGlobalAndParentCodeOwners flag set to true which causes global code owners to be
     // ignored, in addition this flag causes parent code owners to be ignored
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(user.email());
-    assertThat(pathCodeOwners.get().ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -738,10 +745,11 @@
     // per-file code owners from the imported code owner config and its
     // ignoreGlobalAndParentCodeOwners flag are not relevant since the per-file code owner set does
     // not match
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -786,10 +794,11 @@
     // matching per-file code owners from the imported code owner config and its
     // ignoreGlobalAndParentCodeOwners flag are not relevant with import mode
     // GLOBAL_CODE_OWNER_SETS_ONLY
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
-    assertThat(pathCodeOwners.get().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -824,7 +833,7 @@
 
     // Expectation: ignoreParentCodeOwners is true because the ignoreParentCodeOwners flag in the
     // imported code owner config is set to true
-    assertThat(pathCodeOwners.get().ignoreParentCodeOwners()).isTrue();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isTrue();
   }
 
   @Test
@@ -861,7 +870,7 @@
 
     // Expectation: ignoreParentCodeOwners is false because the ignoreParentCodeOwners flag in the
     // imported code owner config is not relevant with import mode GLOBAL_CODE_OWNER_SETS_ONLY
-    assertThat(pathCodeOwners.get().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
   }
 
   @Test
@@ -919,9 +928,10 @@
 
     // Expectation: we get the global owners from the importing code owner config, the imported code
     // owner config and the code owner config that is imported by the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email(), user2.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -977,9 +987,10 @@
     // Expectation: we get the global owners from the importing code owner config and the imported
     // code owner config but not the per file code owner from the code owner config that is imported
     // by the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1027,7 +1038,7 @@
 
     // Expectation: we get the global code owners from the importing and the imported code owner
     // config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
   }
@@ -1064,7 +1075,7 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
   }
@@ -1111,7 +1122,7 @@
 
     // Expectation: we get the global owners from the importing and the imported code owner config
     // as they were defined at oldRevision
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
   }
@@ -1147,9 +1158,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1176,9 +1188,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -1220,9 +1233,10 @@
     // Expectation: we get the global owners from the importing code owner config, the global code
     // owners from the imported code owner config are ignored since the project that contains the
     // code owner config is hidden
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -1250,9 +1264,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -1290,9 +1305,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1337,9 +1353,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1380,9 +1397,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1425,9 +1443,10 @@
     assertThat(pathCodeOwners).isPresent();
 
     // Expectation: we get the global owners from the importing and the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1459,9 +1478,10 @@
 
     // Expectation: we get the per file code owner from the importing code owner config, the
     // non-resolveable per file import is silently ignored
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isTrue();
   }
 
   @Test
@@ -1511,12 +1531,13 @@
     // Expectation: we get the per file code owners from the importing and the global code owner
     // from the imported code owner config, but not the per file code owner from the imported code
     // owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
 
     // Expectation: the ignoreParentCodeOwners flag from the imported code owner config is ignored
-    assertThat(pathCodeOwners.get().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().ignoreParentCodeOwners()).isFalse();
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1570,9 +1591,10 @@
 
     // Expectation: we get the global owners from the importing code owner config, the imported code
     // owner config and the code owner config that is imported by the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email(), user2.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
@@ -1629,9 +1651,10 @@
     // Expectation: we get the global owners from the importing code owner config and the imported
     // code owner config, but not the per file code owner from the code owner config that is
     // imported by the imported code owner config
-    assertThat(pathCodeOwners.get().get())
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().getPathCodeOwners())
         .comparingElementsUsing(hasEmail())
         .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwners.get().resolveCodeOwnerConfig().hasUnresolvedImports()).isFalse();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
index 9fbc92c..f0ec10b 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/findowners/FindOwnersCodeOwnerConfigParserTest.java
@@ -614,7 +614,7 @@
             NullPointerException.class,
             () ->
                 FindOwnersCodeOwnerConfigParser.replaceEmail(
-                    /* content= */ null, admin.email(), user.email()));
+                    /* codeOwnerConfigFileContent= */ null, admin.email(), user.email()));
     assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfigFileContent");
   }
 
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java b/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
index 832e491..5b6e80b 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/config/CodeOwnersPluginConfigurationTest.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigUpdate;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.inject.Inject;
@@ -863,6 +864,52 @@
         .isEqualTo(MergeCommitStrategy.ALL_CHANGED_FILES);
   }
 
+  @Test
+  public void cannotGetFallbackCodeOwnersForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> codeOwnersPluginConfiguration.getFallbackCodeOwners(/* project= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void getFallbackCodeOwnersIfNoneIsConfigured() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void getFallbackCodeOwnersIfNoneIsConfiguredOnProjectLevel() throws Exception {
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void fallbackCodeOnwersOnProjectLevelOverridesGlobalFallbackCodeOwners() throws Exception {
+    configureFallbackCodeOwners(project, FallbackCodeOwners.NONE);
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void fallbackCodeOwnersIsInheritedFromParentProject() throws Exception {
+    configureFallbackCodeOwners(allProjects, FallbackCodeOwners.NONE);
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  public void inheritedFallbackCodeOwnersCanBeOverridden() throws Exception {
+    configureFallbackCodeOwners(allProjects, FallbackCodeOwners.ALL_USERS);
+    configureFallbackCodeOwners(project, FallbackCodeOwners.NONE);
+    assertThat(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
   private void configureDisabled(Project.NameKey project, String disabled) throws Exception {
     setCodeOwnersConfig(project, /* subsection= */ null, StatusConfig.KEY_DISABLED, disabled);
   }
@@ -919,6 +966,15 @@
         mergeCommitStrategy.name());
   }
 
+  private void configureFallbackCodeOwners(
+      Project.NameKey project, FallbackCodeOwners fallbackCodeOwners) throws Exception {
+    setCodeOwnersConfig(
+        project,
+        /* subsection= */ null,
+        GeneralConfig.KEY_FALLBACK_CODE_OWNERS,
+        fallbackCodeOwners.name());
+  }
+
   private AutoCloseable registerTestBackend() {
     RegistrationHandle registrationHandle =
         ((PrivateInternals_DynamicMapImpl<CodeOwnerBackend>) codeOwnerBackends)
diff --git a/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java
index 19e3300..2a92d16 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/config/GeneralConfigTest.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED;
+import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_FALLBACK_CODE_OWNERS;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_FILE_EXTENSION;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_GLOBAL_CODE_OWNER;
 import static com.google.gerrit.plugins.codeowners.config.GeneralConfig.KEY_MERGE_COMMIT_STRATEGY;
@@ -33,6 +34,7 @@
 import com.google.gerrit.plugins.codeowners.api.CodeOwnerConfigValidationPolicy;
 import com.google.gerrit.plugins.codeowners.api.MergeCommitStrategy;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.project.ProjectLevelConfig;
@@ -395,4 +397,62 @@
     cfg.setString(SECTION_CODE_OWNERS, null, KEY_OVERRIDE_INFO_URL, "http://bar.example.com");
     assertThat(generalConfig.getOverrideInfoUrl(cfg)).value().isEqualTo("http://bar.example.com");
   }
+
+  @Test
+  public void cannotGetFallbackCodeOwnersForNullProject() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getFallbackCodeOwners(/* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetFallbackCodeOwnersForNullPluginConfig() throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () -> generalConfig.getFallbackCodeOwners(project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noFallbackCodeOwnersConfigured() throws Exception {
+    assertThat(generalConfig.getFallbackCodeOwners(project, new Config()))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void fallbackCodeOwnersIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+      throws Exception {
+    assertThat(generalConfig.getFallbackCodeOwners(project, new Config()))
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void fallbackCodeOwnersInPluginConfigOverridesFallbackCodeOwnersInGerritConfig()
+      throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS, "NONE");
+    assertThat(generalConfig.getFallbackCodeOwners(project, cfg))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "ALL_USERS")
+  public void globalFallbackOnwersUsedIfInvalidFallbackCodeOwnersConfigured() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS, "INVALID");
+    assertThat(generalConfig.getFallbackCodeOwners(project, cfg))
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fallbackCodeOwners", value = "INVALID")
+  public void defaultValueUsedIfInvalidGlobalFallbackCodeOwnersConfigured() throws Exception {
+    assertThat(generalConfig.getFallbackCodeOwners(project, new Config()))
+        .isEqualTo(FallbackCodeOwners.NONE);
+  }
 }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
index 45d957a..bd632c4 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/restapi/CodeOwnerProjectConfigJsonTest.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.plugins.codeowners.api.RequiredApprovalInfo;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackendId;
 import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigScanner;
+import com.google.gerrit.plugins.codeowners.backend.FallbackCodeOwners;
 import com.google.gerrit.plugins.codeowners.backend.findowners.FindOwnersBackend;
 import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend;
 import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
@@ -162,6 +163,8 @@
         .thenReturn(Optional.of("http://foo.example.com"));
     when(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
         .thenReturn(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+    when(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .thenReturn(FallbackCodeOwners.ALL_USERS);
     when(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).thenReturn(true);
     when(codeOwnersPluginConfiguration.getRequiredApproval(project))
         .thenReturn(RequiredApproval.create(getDefaultCodeReviewLabel(), (short) 2));
@@ -180,6 +183,8 @@
         .isEqualTo("http://foo.example.com");
     assertThat(codeOwnerProjectConfigInfo.general.mergeCommitStrategy)
         .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+    assertThat(codeOwnerProjectConfigInfo.general.fallbackCodeOwners)
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
     assertThat(codeOwnerProjectConfigInfo.general.implicitApprovals).isTrue();
     assertThat(codeOwnerProjectConfigInfo.backend.id)
         .isEqualTo(CodeOwnerBackendId.FIND_OWNERS.getBackendId());
@@ -269,6 +274,8 @@
         .thenReturn(Optional.of("http://foo.example.com"));
     when(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
         .thenReturn(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+    when(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .thenReturn(FallbackCodeOwners.ALL_USERS);
     when(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).thenReturn(true);
     when(codeOwnersPluginConfiguration.getRequiredApproval(project))
         .thenReturn(RequiredApproval.create(getDefaultCodeReviewLabel(), (short) 2));
@@ -286,6 +293,8 @@
         .isEqualTo("http://foo.example.com");
     assertThat(codeOwnerBranchConfigInfo.general.mergeCommitStrategy)
         .isEqualTo(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+    assertThat(codeOwnerBranchConfigInfo.general.fallbackCodeOwners)
+        .isEqualTo(FallbackCodeOwners.ALL_USERS);
     assertThat(codeOwnerBranchConfigInfo.general.implicitApprovals).isTrue();
     assertThat(codeOwnerBranchConfigInfo.backendId)
         .isEqualTo(CodeOwnerBackendId.FIND_OWNERS.getBackendId());
@@ -324,6 +333,8 @@
         .thenReturn(Optional.of("http://foo.example.com"));
     when(codeOwnersPluginConfiguration.getMergeCommitStrategy(project))
         .thenReturn(MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION);
+    when(codeOwnersPluginConfiguration.getFallbackCodeOwners(project))
+        .thenReturn(FallbackCodeOwners.ALL_USERS);
     when(codeOwnersPluginConfiguration.areImplicitApprovalsEnabled(project)).thenReturn(true);
     when(codeOwnersPluginConfiguration.getRequiredApproval(project))
         .thenReturn(RequiredApproval.create(getDefaultCodeReviewLabel(), (short) 2));
diff --git a/resources/Documentation/config.md b/resources/Documentation/config.md
index c9e7218..24ed4b9 100644
--- a/resources/Documentation/config.md
+++ b/resources/Documentation/config.md
@@ -220,6 +220,35 @@
         `@PLUGIN@.config`.\
         By default `ALL_CHANGED_FILES`.
 
+<a id="pluginCodeOwnersFallbackCodeOwners">plugin.@PLUGIN@.fallbackCodeOwners</a>
+:       Policy that controls who should own paths that have no code owners
+        defined. This policy only applies if the inheritance of parent code
+        owners hasn't been explicity disabled in a relevant code owner config
+        file and if there are no unresolved imports.\
+        \
+        Can be `NONE` or `ALL_USERS`.\
+        \
+        `NONE`:\
+        Paths for which no code owners are defined are owned by no one. This
+        means changes that touch these files can only be submitted with a code
+        owner override.\
+        \
+        `ALL_USERS`:\
+        Paths for which no code owners are defined are owned by all users. This
+        means changes to these paths can be approved by anyone. If [implicit
+        approvals](#pluginCodeOwnersEnableImplicitApprovals) are enabled, these
+        files are always automatically approved. The `ALL_USERS` option should
+        only be used with care as it means that any path that is not covered by
+        the code owner config files is automatically opened up to everyone and
+        mistakes with configuring code owners can easily happen. This is why
+        this option is intended to be only used if requiring code owner
+        approvals should not be enforced.\
+        \
+        Can be overridden per project by setting
+        [codeOwners.fallbackCodeOwners](#codeOwnersFallbackCodeOwners) in
+        `@PLUGIN@.config`.\
+        By default `NONE`.
+
 # <a id="projectConfiguration">Project configuration in @PLUGIN@.config</a>
 
 <a id="codeOwnersDisabled">codeOwners.disabled</a>
@@ -419,6 +448,21 @@
         [plugin.@PLUGIN@.mergeCommitStrategy](#pluginCodeOwnersMergeCommitStrategy)
         in `gerrit.config` is used.
 
+<a id="codeOwnersFallbackCodeOwners">codeOwners.fallbackCodeOwners</a>
+:       Policy that controls who should own paths that have no code owners
+        defined. This policy only applies if the inheritance of parent code
+        owners hasn't been explicity disabled in a relevant code owner config
+        file and if there are no unresolved imports.\
+        Can be `NONE` or `ALL_USERS` (see
+        [plugin.@PLUGIN@.fallbackCodeOwners](#pluginCodeOwnersFallbackCodeOwners)
+        for an explanation of these values).\
+        Overrides the global setting
+        [plugin.@PLUGIN@.fallbackCodeOwners](#pluginCodeOwnersFallbackCodeOwners)
+        in `gerrit.config`.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.fallbackCodeOwners](#pluginCodeOwnersFallbackCodeOwners)
+        in `gerrit.config` is used.
+
 ---
 
 Back to [@PLUGIN@ documentation index](index.html)
diff --git a/resources/Documentation/rest-api.md b/resources/Documentation/rest-api.md
index 6ec924e..64a077e 100644
--- a/resources/Documentation/rest-api.md
+++ b/resources/Documentation/rest-api.md
@@ -346,6 +346,7 @@
 * are referenced by an email with a disallowed domain (see
   [allowedEmailDomain configuration](config.html#pluginCodeOwnersAllowedEmailDomain))
 * do not have read access to the branch
+* [fallback code owners](config.html#pluginCodeOwnersFallbackCodeOwners)
 
 are omitted from the result.
 
@@ -689,6 +690,7 @@
 | `merge_commit_strategy` || Strategy that defines for merge commits which files require code owner approvals. Can be `ALL_CHANGED_FILES` or `FILES_WITH_CONFLICT_RESOLUTION` (see [mergeCommitStrategy](config.html#pluginCodeOwnersMergeCommitStrategy) for an explanation of these values).
 | `implicit_approvals` | optional |  Whether an implicit code owner approval from the last uploader is assumed (see [enableImplicitApprovals](config.html#pluginCodeOwnersEnableImplicitApprovals) for details). When unset, `false`.
 | `override_info_url` | optional | Optional URL for a page that provides project/host-specific information about how to request a code owner override.
+|`fallback_code_owners` || Policy that controls who should own paths that have no code owners defined. Possible values are: `NONE`: Paths for which no code owners are defined are owned by no one. `ALL_USER`: Paths for which no code owners are defined are owned by all users.
 
 ### <a id="path-code-owner-status-info"> PathCodeOwnerStatusInfo
 The `PathCodeOwnerStatusInfo` entity describes the code owner status for a path
diff --git a/resources/Documentation/user-guide.md b/resources/Documentation/user-guide.md
index 6e31243..1471348 100644
--- a/resources/Documentation/user-guide.md
+++ b/resources/Documentation/user-guide.md
@@ -146,8 +146,10 @@
 
 If the code owners functionality is enabled, all touched files require an
 approval from a code owner. If files are touched for which no code owners are
-defined, the change can only be submitted with a [code owner
-override](#codeOwnerOverride).
+defined, the change can only be submitted with an approval of a fallback code
+owner (if [configured](config.html#pluginCodeOwnersFallbackCodeOwners)) or with
+a [code owner override](#codeOwnerOverride). Please note that fallback code
+owners are not included in the [code owner suggestion](#codeOwnerSuggestion).
 
 If the destination branch doesn't contain any [code owner config
 file](#codeOwnerConfigFiles) at all yet and the project also doesn't have a
diff --git a/resources/Documentation/validation.md b/resources/Documentation/validation.md
index d06f5c8..d6160d6 100644
--- a/resources/Documentation/validation.md
+++ b/resources/Documentation/validation.md
@@ -145,6 +145,8 @@
   before)
 * the [codeOwners.mergeCommitStrategy](config.html#codeOwnersMergeCommitStrategy)
   configuration is valid
+* the [codeOwners.fallbackCodeOwners](config.html#codeOwnersFallbackCodeOwners)
+  configuration is valid
 
 ---
 
diff --git a/ui/code-owners-banner.js b/ui/code-owners-banner.js
new file mode 100644
index 0000000..a44e258
--- /dev/null
+++ b/ui/code-owners-banner.js
@@ -0,0 +1,176 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
+import {PluginState} from './code-owners-model.js';
+
+// There are 2 elements in this file:
+// CodeOwnersBanner - visual elements. This element is shown at the top
+// of the page when pluginStatus are changed to failed.
+// The CodeOwnersBanner is added to the 'banner' endpoint. The endpoint
+// is placed at the top level of the gerrit and doesn't provide a current
+// change, so it is impossible to get the pluginStatus directly in the
+// CodeOwnersBanner.
+// To solve this problem, this files provides non-visual
+// CodeOwnersPluginStatusNotifier element. This element is added to the
+// change-view-integration endpoint, so it can track the plugin state.
+// When a plugin state is changed, the CodeOwnersPluginStatusNotifier updates
+// the pluginStatus property of the banner.
+
+// CodeOwnersBanner and CodeOwnersPluginStatusNotifier can be attached and
+// detached in any order. The following piece of code ensures that these
+// banner and notifier are always connected correctly. The code assumes,
+// that max one banner and one notifier can be connected at any time.
+let activeBanner = undefined;
+let activeStatusNotifier = undefined;
+
+function setActiveBanner(banner) {
+  // banner is null when CodeOwnersBanner has been disconnected
+  activeBanner = banner;
+  if (activeStatusNotifier) {
+    activeStatusNotifier.banner = banner;
+  }
+}
+
+function setActiveStatusNotifier(notifier) {
+  // notifier is null when CodeOwnersBanner has been disconnected
+  if (activeStatusNotifier) {
+    if (activeStatusNotifier.banner) {
+      activeStatusNotifier.banner.pluginStatus = undefined;
+    }
+    activeStatusNotifier.banner = undefined;
+  }
+  activeStatusNotifier = notifier;
+  if (activeStatusNotifier) {
+    activeStatusNotifier.banner = activeBanner;
+  }
+}
+
+export class CodeOwnersBanner extends Polymer.Element {
+  static get is() { return 'gr-code-owners-banner'; }
+
+  static get template() {
+    return Polymer.html`
+      <style include="shared-styles">
+        :host {
+          display: block;
+          overflow: hidden;
+          background: red;
+        }
+        .text {
+          color: white;
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+          margin-left: var(--spacing-l);
+        }
+      </style>
+      <span class="text">Error: Code-owners plugin has failed</span>
+      <gr-button link on-click="_showFailDetails">
+        Details
+      </gr-button>
+    `;
+  }
+
+  static get properties() {
+    return {
+      // @internal attributes
+      hidden: {
+        type: Boolean,
+        value: true,
+        reflectToAttribute: true,
+        computed: '_computeHidden(pluginStatus)',
+      },
+      pluginStatus: {
+        type: Object,
+      },
+    };
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    setActiveBanner(this);
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    setActiveBanner(undefined);
+  }
+
+  _computeHidden(pluginStatus) {
+    return !pluginStatus || pluginStatus.state !== PluginState.Failed;
+  }
+
+  _showFailDetails() {
+    showPluginFailedMessage(this, this.pluginStatus);
+  }
+}
+
+customElements.define(CodeOwnersBanner.is, CodeOwnersBanner);
+
+export class CodeOwnersPluginStatusNotifier extends
+  CodeOwnersModelMixin(Polymer.Element) {
+  static get is() {
+    return 'owners-plugin-status-notifier';
+  }
+
+  static get properties() {
+    return {
+      banner: {
+        type: Object,
+      },
+    };
+  }
+
+  static get observers() {
+    return [
+      '_pluginStatusOrBannerChanged(model.pluginStatus, banner)',
+    ];
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    setActiveStatusNotifier(this);
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    setActiveStatusNotifier(undefined);
+  }
+
+  _pluginStatusOrBannerChanged(status, banner) {
+    if (!banner) return;
+    banner.pluginStatus = status;
+  }
+
+  _loadDataAfterStateChanged() {
+    this.modelLoader.loadPluginStatus();
+  }
+}
+
+customElements.define(CodeOwnersPluginStatusNotifier.is,
+    CodeOwnersPluginStatusNotifier);
+
+export function showPluginFailedMessage(sourceEl, pluginStatus) {
+  sourceEl.dispatchEvent(
+      new CustomEvent('show-error', {
+        detail: {message: pluginStatus.failedMessage},
+        composed: true,
+        bubbles: true,
+      })
+  );
+}
diff --git a/ui/code-owners-model-loader.js b/ui/code-owners-model-loader.js
new file mode 100644
index 0000000..2cd9095
--- /dev/null
+++ b/ui/code-owners-model-loader.js
@@ -0,0 +1,116 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {SuggestionsState} from './code-owners-model.js';
+
+/**
+ * ModelLoader provides a method for loading data into the model.
+ * It is a bridge between an ownersModel and an ownersService.
+ * When UI elements depends on a model property XXX, the element should
+ * observe (or bind to) the property and call modelLoader.loadXXX method
+ * to load the value.
+ * It is recommended to use CodeOwnersModelMixin and call all load... methods
+ * in the loadPropertiesAfterModelChanged method
+ */
+export class ModelLoader {
+  constructor(ownersService, ownersModel) {
+    this.ownersService = ownersService;
+    this.ownersModel = ownersModel;
+  }
+
+  async _loadProperty(propertyName, propertyLoader, propertySetter) {
+    // Load property only if it was not already loaded
+    if (this.ownersModel[propertyName] !== undefined) return;
+    let newValue;
+    try {
+      newValue = await propertyLoader();
+    } catch (e) {
+      this.ownersModel.setPluginFailed(e.message);
+      return;
+    }
+    // It is possible, that several requests is made in parallel.
+    // Store only the first result and discard all other results.
+    // (also, due to the CodeOwnersCacheApi all result must be identical)
+    if (this.ownersModel[propertyName] !== undefined) return;
+    propertySetter(newValue);
+  }
+
+  async loadBranchConfig() {
+    await this._loadProperty('branchConfig',
+        () => this.ownersService.getBranchConfig(),
+        value => this.ownersModel.setBranchConfig(value)
+    );
+  }
+
+  async loadStatus() {
+    await this._loadProperty('status',
+        () => this.ownersService.getStatus(),
+        value => this.ownersModel.setStatus(value)
+    );
+  }
+
+  async loadUserRole() {
+    await this._loadProperty('userRole',
+        () => this.ownersService.getLoggedInUserInitialRole(),
+        value => this.ownersModel.setUserRole(value)
+    );
+  }
+
+  async loadPluginStatus() {
+    await this._loadProperty('pluginStatus',
+        () => this.ownersService.isCodeOwnerEnabled(),
+        value => this.ownersModel.setPluginEnabled(value)
+    );
+  }
+
+  async loadAreAllFilesApproved() {
+    await this._loadProperty('areAllFilesApproved',
+        () => this.ownersService.areAllFilesApproved(),
+        value => this.ownersModel.setAreAllFilesApproved(value)
+    );
+  }
+
+  async loadSuggestions() {
+    // If a loading has been started already, do nothing
+    if (this.ownersModel.suggestionsState
+        !== SuggestionsState.NotLoaded) return;
+
+    this.ownersModel.setSuggestionsState(SuggestionsState.Loading);
+    let suggestedOwners;
+    try {
+      suggestedOwners = await this.ownersService.getSuggestedOwners();
+    } catch (e) {
+      this.ownersModel.setSuggestionsState(SuggestionsState.LoadFailed);
+      this.ownersModel.setPluginFailed(e.message);
+      return;
+    }
+    this.ownersModel.setSuggestions(suggestedOwners.suggestions);
+    this.ownersModel.setSuggestionsState(SuggestionsState.Loaded);
+  }
+
+  async updateLoadSuggestionsProgress() {
+    let suggestedOwners;
+    try {
+      suggestedOwners = await this.ownersService.getSuggestedOwnersProgress();
+    } catch {
+      // Ignore any error, keep progress unchanged.
+      return;
+    }
+    this.ownersModel.setSuggestionsLoadProgress(suggestedOwners.progress);
+    this.ownersModel.setSuggestions(suggestedOwners.suggestions);
+  }
+}
diff --git a/ui/code-owners-model-mixin.js b/ui/code-owners-model-mixin.js
new file mode 100644
index 0000000..722af6f
--- /dev/null
+++ b/ui/code-owners-model-mixin.js
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {CodeOwnerService} from './code-owners-service.js';
+import {ModelLoader} from './code-owners-model-loader.js';
+import {CodeOwnersModel} from './code-owners-model.js';
+
+/**
+ * The CodeOwnersMixin adds several properties to a class and translates
+ * 'model-property-changed' events from the model to the notifyPath calls.
+ * This allows to use properties of the model in observers, calculated
+ * properties and bindings.
+ * It is guaranteed, that model and modelLoader are set only if change,
+ * reporting and restApi properties are set.
+ */
+export const CodeOwnersModelMixin = Polymer.dedupingMixin(base => {
+  return class extends base {
+    constructor(...args) {
+      super(...args);
+      /**
+       * The modelLoader allows an element to request a property
+       * Typically should be used in loadPropertiesAfterModelChanged
+       * to ensure that all required model properties are loaded
+       */
+      this.modelLoader = undefined;
+    }
+    static get properties() {
+      return {
+        /* The following 3 properties (change, reporting, restApi) have to be
+         * set from the outside for the mixin to work.
+         */
+        change: Object,
+        reporting: Object,
+        restApi: Object,
+        model: {
+          type: Object,
+          observer: '_modelChanged',
+        },
+      };
+    }
+
+    static get observers() {
+      return ['onInputChanged(restApi, change, reporting)'];
+    }
+
+    onInputChanged(restApi, change, reporting) {
+      if ([restApi, change, reporting].includes(undefined)) {
+        this.model = undefined;
+        this.modelLoader = undefined;
+        return;
+      }
+      const ownerService = CodeOwnerService.getOwnerService(
+          this.restApi,
+          this.change
+      );
+      const model = CodeOwnersModel.getModel(change);
+      this.modelLoader = new ModelLoader(ownerService, model);
+      // Assign model after modelLoader, so modelLoader can be used in
+      // the _requirePropertiesAfterModelChanged method
+      this.model = model;
+    }
+
+    _modelChanged(newModel) {
+      if (this.modelPropertyChangedUnsubscriber) {
+        this.modelPropertyChangedUnsubscriber();
+        this.modelPropertyChangedUnsubscriber = undefined;
+      }
+      if (!newModel) return;
+      const propertyChangedListener = e => {
+        this.notifyPath(`model.${e.detail.propertyName}`);
+      };
+      newModel.addEventListener('model-property-changed',
+          propertyChangedListener);
+      this.modelPropertyChangedUnsubscriber = () => {
+        newModel.removeEventListener('model-property-changed',
+            propertyChangedListener);
+      };
+      this.loadPropertiesAfterModelChanged();
+    }
+
+    loadPropertiesAfterModelChanged() {
+      // The class should override this method and calls appropriate methods
+      // from this.modelLoader to ensure that all required properties are
+      // set in model.
+    }
+  };
+});
diff --git a/ui/code-owners-model.js b/ui/code-owners-model.js
new file mode 100644
index 0000000..cbd9bd2
--- /dev/null
+++ b/ui/code-owners-model.js
@@ -0,0 +1,157 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export const SuggestionsState = {
+  NotLoaded: 'NotLoaded',
+  Loaded: 'Loaded',
+  Loading: 'Loading',
+  LoadFailed: 'LoadFailed',
+};
+
+export const PluginState = {
+  Enabled: 'Enabled',
+  Disabled: 'Disabled',
+  Failed: 'Failed',
+};
+
+/**
+ * Maintain the state of code-owners.
+ * Raises 'model-property-changed' event when a property is changed.
+ * The plugin shares the same model between all UI elements (if it is not,
+ * the plugin can't maintain showSuggestions state across different UI elements).
+ * UI elements use values from this model to display information
+ * and listens for the model-property-changed event. To do so, UI elements
+ * add CodeOwnersModelMixin, which is doing the listening and the translation
+ * from model-property-changed event to Polymer property-changed-event. The
+ * translation allows to use model properties in observables, bindings,
+ * computed properties, etc...
+ * The CodeOwnersModelLoader updates the model.
+ *
+ * It would be good to use RxJs Observable for implementing model properties.
+ * However, RxJs library is imported by Gerrit and there is no
+ * good way to reuse the same library in the plugin.
+ */
+export class CodeOwnersModel extends EventTarget {
+  constructor(change) {
+    super();
+    this.change = change;
+    this.branchConfig = undefined;
+    this.status = undefined;
+    this.userRole = undefined;
+    this.isCodeOwnerEnabled = undefined;
+    this.areAllFilesApproved = undefined;
+    this.suggestions = undefined;
+    this.suggestionsState = SuggestionsState.NotLoaded;
+    this.suggestionsLoadProgress = undefined;
+    this.showSuggestions = false;
+    this.pluginStatus = undefined;
+  }
+
+  setBranchConfig(config) {
+    if (this.branchConfig === config) return;
+    this.branchConfig = config;
+    this._firePropertyChanged('branchConfig');
+  }
+
+  setStatus(status) {
+    if (this.status === status) return;
+    this.status = status;
+    this._firePropertyChanged('status');
+  }
+
+  setUserRole(userRole) {
+    if (this.userRole === userRole) return;
+    this.userRole = userRole;
+    this._firePropertyChanged('userRole');
+  }
+
+  setIsCodeOwnerEnabled(enabled) {
+    if (this.isCodeOwnerEnabled === enabled) return;
+    this.isCodeOwnerEnabled = enabled;
+    this._firePropertyChanged('isCodeOwnerEnabled');
+  }
+
+  setAreAllFilesApproved(approved) {
+    if (this.areAllFilesApproved === approved) return;
+    this.areAllFilesApproved = approved;
+    this._firePropertyChanged('areAllFilesApproved');
+  }
+
+  setSuggestions(suggestions) {
+    if (this.suggestions === suggestions) return;
+    this.suggestions = suggestions;
+    this._firePropertyChanged('suggestions');
+  }
+
+  setSuggestionsState(state) {
+    if (this.suggestionsState === state) return;
+    this.suggestionsState = state;
+    this._firePropertyChanged('suggestionsState');
+  }
+
+  setSuggestionsLoadProgress(progress) {
+    if (this.suggestionsLoadProgress === progress) return;
+    this.suggestionsLoadProgress = progress;
+    this._firePropertyChanged('suggestionsLoadProgress');
+  }
+
+  setShowSuggestions(show) {
+    if (this.showSuggestions === show) return;
+    this.showSuggestions = show;
+    this._firePropertyChanged('showSuggestions');
+  }
+
+  setPluginEnabled(enabled) {
+    this._setPluginStatus({state: enabled ?
+      PluginState.Enabled : PluginState.Disabled});
+  }
+
+  setPluginFailed(failedMessage) {
+    this._setPluginStatus({state: PluginState.Failed, failedMessage});
+  }
+
+  _setPluginStatus(status) {
+    if (this._arePluginStatusesEqual(this.pluginStatus, status)) return;
+    this.pluginStatus = status;
+    this._firePropertyChanged('pluginStatus');
+  }
+
+  _arePluginStatusesEqual(status1, status2) {
+    if (status1 === undefined || status2 === undefined) {
+      return status1 === status2;
+    }
+    if (status1.state !== status2.state) return false;
+    return status1.state === PluginState.Failed ?
+      status1.failedMessage === status2.failedMessage :
+      true;
+  }
+
+  _firePropertyChanged(propertyName) {
+    this.dispatchEvent(new CustomEvent('model-property-changed', {
+      detail: {
+        propertyName,
+      },
+    }));
+  }
+
+  static getModel(change) {
+    if (!this.model || this.model.change !== change) {
+      this.model = new CodeOwnersModel(change);
+    }
+    return this.model;
+  }
+}
diff --git a/ui/code-owners-service.js b/ui/code-owners-service.js
index 416b3ec..8bb14d1 100644
--- a/ui/code-owners-service.js
+++ b/ui/code-owners-service.js
@@ -111,30 +111,182 @@
   }
 
   /**
-   * Returns a promise fetching project_config for code owners.
-   *
-   * @doc https://gerrit.googlesource.com/plugins/code-owners/+/refs/heads/master/resources/Documentation/rest-api.md#get-code-owner-project-config
-   * @param {string} project
-   */
-  getProjectConfig(project) {
-    return this.restApi.get(
-        `/projects/${encodeURIComponent(project)}/code_owners.project_config`
-    );
-  }
-
-  /**
    * Returns a promise fetching the owners config for a given branch.
    *
    * @doc https://gerrit.googlesource.com/plugins/code-owners/+/refs/heads/master/resources/Documentation/rest-api.md#branch-endpoints
    * @param {string} project
    * @param {string} branch
    */
-  getBranchConfig(project, branch) {
-    return this.restApi.get(
+  async getBranchConfig(project, branch) {
+    const config = await this.restApi.get(
         `/projects/${encodeURIComponent(project)}/` +
         `branches/${encodeURIComponent(branch)}/` +
         `code_owners.branch_config`
     );
+    if (config.override_approval && !(config.override_approval instanceof Array)) {
+      // In the upcoming backend changes, the override_approval will be changed
+      // to array with (possible) multiple items.
+      // While this transition is in progress, the frontend supports both API -
+      // the old one and the new one.
+      return {...config, override_approval: [config.override_approval]};
+    }
+    return config;
+  }
+}
+
+/**
+ * Wrapper around codeOwnerApi, sends each requests only once and then cache
+ * the response. A new CodeOwnersCacheApi instance is created every time when a
+ * new change object is assigned.
+ * Gerrit never updates existing change object, but instead always assigns a new
+ * change object. Particularly, a new change object is assigned when a change
+ * is updated and user clicks reload toasts to see the updated change.
+ * As a result, the lifetime of a cache is the same as a lifetime of an assigned
+ * change object.
+ * Periodical cache invalidation can lead to inconsistency in UI, i.e.
+ * user can see the old reviewers list (reflects a state when a change was
+ * loaded) and code-owners status for the current reviewer list. To avoid
+ * this inconsistency, the cache doesn't invalidate.
+ */
+export class CodeOwnersCacheApi {
+  constructor(codeOwnerApi, change) {
+    this.codeOwnerApi = codeOwnerApi;
+    this.change = change;
+    this.promises = {};
+  }
+
+  _fetchOnce(cacheKey, asyncFn) {
+    if (!this.promises[cacheKey]) {
+      this.promises[cacheKey] = asyncFn();
+    }
+    return this.promises[cacheKey];
+  }
+
+  getAccount() {
+    return this._fetchOnce('getAccount', () => this._getAccount());
+  }
+
+  async _getAccount() {
+    const loggedIn = await this.codeOwnerApi.restApi.getLoggedIn();
+    if (!loggedIn) return undefined;
+    return await this.codeOwnerApi.restApi.getAccount();
+  }
+
+  listOwnerStatus() {
+    return this._fetchOnce('listOwnerStatus',
+        () => this.codeOwnerApi.listOwnerStatus(this.change._number));
+  }
+
+  getBranchConfig() {
+    return this._fetchOnce('getBranchConfig',
+        () => this.codeOwnerApi.getBranchConfig(this.change.project,
+            this.change.branch));
+  }
+
+  listOwnersForPath(path) {
+    return this._fetchOnce(`listOwnersForPath:${path}`,
+        () => this.codeOwnerApi.listOwnersForPath(this.change.id, path));
+  }
+}
+
+export class OwnersFetcher {
+  constructor(restApi, change, options) {
+    // fetched files and fetching status
+    this._fetchedOwners = new Map();
+    this._fetchStatus = FetchStatus.NOT_STARTED;
+    this._totalFetchCount = 0;
+    this.change = change;
+    this.options = options;
+    this.codeOwnerApi = new CodeOwnerApi(restApi);
+  }
+
+  getStatus() {
+    return this._fetchStatus;
+  }
+
+  getProgressString() {
+    return this._totalFetchCount === 0 ?
+      `Loading suggested owners ...` :
+      `${this._fetchedOwners.size} out of ${this._totalFetchCount} files have returned suggested owners.`;
+  }
+
+  getFiles() {
+    const result = [];
+    for (const [path, info] of this._fetchedOwners.entries()) {
+      result.push({path, info});
+    }
+    return result;
+  }
+
+  async fetchSuggestedOwners(codeOwnerStatusMap) {
+    // reset existing temporary storage
+    this._fetchedOwners = new Map();
+    this._fetchStatus = FetchStatus.FETCHING;
+    this._totalFetchCount = 0;
+
+    // only fetch those not approved yet
+    const filesGroupByStatus = [...codeOwnerStatusMap.keys()].reduce(
+        (list, file) => {
+          const status = codeOwnerStatusMap
+              .get(file).status;
+          if (status === OwnerStatus.INSUFFICIENT_REVIEWERS) {
+            list.missing.push(file);
+          } else if (status === OwnerStatus.PENDING) {
+            list.pending.push(file);
+          }
+          return list;
+        }
+        , {pending: [], missing: []});
+    // always fetch INSUFFICIENT_REVIEWERS first and then pending
+    const filesToFetch = filesGroupByStatus.missing
+        .concat(filesGroupByStatus.pending);
+    this._totalFetchCount = filesToFetch.length;
+    await this._batchFetchCodeOwners(filesToFetch);
+    this._fetchStatus = FetchStatus.FINISHED;
+  }
+
+  /**
+   * Recursively fetches code owners for all files until finished.
+   *
+   * @param {!Array<string>} files
+   */
+  async _batchFetchCodeOwners(files) {
+    if (this._fetchStatus === FetchStatus.ABORT) {
+      return this._fetchedOwners;
+    }
+
+    const batchRequests = [];
+    const maxConcurrentRequests = this.options.maxConcurrentRequests;
+    for (let i = 0; i < maxConcurrentRequests; i++) {
+      const filePath = files[i];
+      if (filePath) {
+        this._fetchedOwners.set(filePath, {});
+        batchRequests.push(this._fetchOwnersForPath(this.change.id, filePath));
+      }
+    }
+    const resPromise = Promise.all(batchRequests);
+    await resPromise;
+    if (files.length > maxConcurrentRequests) {
+      return await this._batchFetchCodeOwners(
+          files.slice(maxConcurrentRequests));
+    }
+    return this._fetchedOwners;
+  }
+
+  async _fetchOwnersForPath(changeId, filePath) {
+    try {
+      const owners = await this.codeOwnerApi.listOwnersForPath(changeId,
+          filePath);
+      this._fetchedOwners.get(filePath).owners = new Set(owners);
+    } catch (e) {
+      this._fetchedOwners.get(filePath).error = e;
+    }
+  }
+
+  abort() {
+    this._fetchStatus = FetchStatus.ABORT;
+    this._fetchedOwners = new Map();
+    this._totalFetchCount = 0;
   }
 }
 
@@ -145,50 +297,29 @@
   constructor(restApi, change, options = {}) {
     this.restApi = restApi;
     this.change = change;
-    this.options = {maxConcurrentRequests: 10, ...options};
-    this.codeOwnerApi = new CodeOwnerApi(restApi);
+    const codeOwnerApi = new CodeOwnerApi(restApi);
+    this.codeOwnerCacheApi = new CodeOwnersCacheApi(codeOwnerApi, change);
 
-    // fetched files and fetching status
-    this._fetchedOwners = new Map();
-    this._fetchStatus = FetchStatus.NOT_STARTED;
-    this._totalFetchCount = 0;
-
-    this.init();
+    const fetcherOptions = {
+      maxConcurrentRequests: options.maxConcurrentRequests || 10,
+    };
+    this.ownersFetcher = new OwnersFetcher(restApi, change, fetcherOptions);
   }
 
   /**
-   * Initial fetches.
+   * Prefetch data
    */
-  init() {
-    this.accountPromise = this.restApi.getLoggedIn().then(loggedIn => {
-      if (!loggedIn) {
-        return undefined;
-      }
-      return this.restApi.getAccount();
-    });
-
-    this.statusPromise = this.isCodeOwnerEnabled().then(enabled => {
-      if (!enabled) {
-        return {
-          patchsetNumber: 0,
-          enabled: false,
-          codeOwnerStatusMap: new Map(),
-          rawStatuses: [],
-        };
-      }
-      return this.codeOwnerApi
-          .listOwnerStatus(this.change._number)
-          .then(res => {
-            return {
-              enabled: true,
-              patchsetNumber: res.patch_set_number,
-              codeOwnerStatusMap: this._formatStatuses(
-                  res.file_code_owner_statuses
-              ),
-              rawStatuses: res.file_code_owner_statuses,
-            };
-          });
-    });
+  async prefetch() {
+    try {
+      await Promise.all([
+        this.codeOwnerCacheApi.getAccount(),
+        this.getStatus(),
+      ]);
+    } catch {
+      // Ignore any errors during prefetch.
+      // The same call from a different place throws the same exception
+      // again. The CodeOwnerService is not responsible for error processing.
+    }
   }
 
   /**
@@ -197,71 +328,92 @@
    * For example, if a user removes themselves as a reviewer, the returned
    * role 'REVIEWER' remains unchanged until the change view is reloaded.
    */
-  getLoggedInUserInitialRole() {
-    return this.accountPromise.then(account => {
-      if (!account) {
-        return UserRole.ANONYMOUS;
-      }
-      const change = this.change;
+  async getLoggedInUserInitialRole() {
+    const account = await this.codeOwnerCacheApi.getAccount();
+    if (!account) {
+      return UserRole.ANONYMOUS;
+    }
+    const change = this.change;
+    if (
+      change.revisions &&
+      change.current_revision &&
+      change.revisions[change.current_revision]
+    ) {
+      const commit = change.revisions[change.current_revision].commit;
       if (
-        change.revisions &&
-        change.current_revision &&
-        change.revisions[change.current_revision]
+        commit &&
+        commit.author &&
+        account.email &&
+        commit.author.email === account.email
       ) {
-        const commit = change.revisions[change.current_revision].commit;
-        if (
-          commit &&
-          commit.author &&
-          account.email &&
-          commit.author.email === account.email
-        ) {
-          return UserRole.AUTHOR;
-        }
+        return UserRole.AUTHOR;
       }
-      if (change.owner._account_id === account._account_id) {
-        return UserRole.CHANGE_OWNER;
+    }
+    if (change.owner._account_id === account._account_id) {
+      return UserRole.CHANGE_OWNER;
+    }
+    if (change.reviewers) {
+      if (this._accountInReviewers(change.reviewers.REVIEWER, account)) {
+        return UserRole.REVIEWER;
+      } else if (this._accountInReviewers(change.reviewers.CC, account)) {
+        return UserRole.CC;
+      } else if (this._accountInReviewers(change.reviewers.REMOVED, account)) {
+        return UserRole.REMOVED_REVIEWER;
       }
-      if (change.reviewers) {
-        if (this._accountInReviewers(change.reviewers.REVIEWER, account)) {
-          return UserRole.REVIEWER;
-        } else if (this._accountInReviewers(change.reviewers.CC, account)) {
-          return UserRole.CC;
-        } else if (this._accountInReviewers(change.reviewers.REMOVED, account)) {
-          return UserRole.REMOVED_REVIEWER;
-        }
-      }
-      return UserRole.OTHER;
-    })
+    }
+    return UserRole.OTHER;
   }
 
   _accountInReviewers(reviewers, account) {
     if (!reviewers) {
       return false;
     }
-    return reviewers.some(reviewer => reviewer._account_id === account._account_id);
+    return reviewers.some(reviewer =>
+      reviewer._account_id === account._account_id);
   }
 
-  getStatus() {
-    return this.statusPromise.then(res => {
-      if (res.enabled && !this.isOnLatestPatchset(res.patchsetNumber)) {
-        // status is outdated, abort and re-init
-        this.abort();
-        this.init();
-        return this.statusPromise;
-      }
-      return res;
-    });
+  async getStatus() {
+    const status = await this._getStatus();
+    if (status.enabled && !this.isOnLatestPatchset(status.patchsetNumber)) {
+      // status is outdated, abort and re-init
+      this.abort();
+      this.prefetch();
+      return await this.codeOwnerCacheApi.getStatus();
+    }
+    return status;
   }
 
-  areAllFilesApproved() {
-    return this.getStatus().then(({rawStatuses}) => {
-      return !rawStatuses.some(status => {
-        const oldPathStatus = status.old_path_status;
-        const newPathStatus = status.new_path_status;
-        // For deleted files, no new_path_status exists
-        return (newPathStatus && newPathStatus.status !== OwnerStatus.APPROVED)
-          || (oldPathStatus && oldPathStatus.status !== OwnerStatus.APPROVED);
-      });
+  async _getStatus() {
+    const enabled = await this.isCodeOwnerEnabled();
+    if (!enabled) {
+      return {
+        patchsetNumber: 0,
+        enabled: false,
+        codeOwnerStatusMap: new Map(),
+        rawStatuses: [],
+      };
+    }
+
+    const onwerStatus = await this.codeOwnerCacheApi.listOwnerStatus();
+
+    return {
+      enabled: true,
+      patchsetNumber: onwerStatus.patch_set_number,
+      codeOwnerStatusMap: this._formatStatuses(
+          onwerStatus.file_code_owner_statuses
+      ),
+      rawStatuses: onwerStatus.file_code_owner_statuses,
+    };
+  }
+
+  async areAllFilesApproved() {
+    const {rawStatuses} = await this.getStatus();
+    return !rawStatuses.some(status => {
+      const oldPathStatus = status.old_path_status;
+      const newPathStatus = status.new_path_status;
+      // For deleted files, no new_path_status exists
+      return (newPathStatus && newPathStatus.status !== OwnerStatus.APPROVED)
+        || (oldPathStatus && oldPathStatus.status !== OwnerStatus.APPROVED);
     });
   }
 
@@ -282,57 +434,37 @@
    *  }>
    * }}
    */
-  getSuggestedOwners() {
+  async getSuggestedOwners() {
+    const {codeOwnerStatusMap} = await this.getStatus();
+
     // In case its aborted due to outdated patches
     // should kick start the fetching again
     // Note: we currently are not reusing the instance when switching changes,
     // so if its `abort` due to different changes, the whole instance will be
     // outdated and not used.
-    if (this._fetchStatus === FetchStatus.NOT_STARTED
-      || this._fetchStatus === FetchStatus.ABORT) {
-      this._fetchSuggestedOwners().then(() => {
-        this._fetchStatus = FetchStatus.FINISHED;
-      });
+    if (this.ownersFetcher.getStatus() === FetchStatus.NOT_STARTED
+      || this.ownersFetcher.getStatus() === FetchStatus.ABORT) {
+      await this.ownersFetcher.fetchSuggestedOwners(codeOwnerStatusMap);
     }
 
-    return this.getStatus().then(({codeOwnerStatusMap}) => {
-      return {
-        finished: this._fetchStatus === FetchStatus.FINISHED,
-        status: this._fetchStatus,
-        progress: this._totalFetchCount === 0 ?
-          `Loading suggested owners ...` :
-          `${this._fetchedOwners.size} out of ${this._totalFetchCount} files have returned suggested owners.`,
-        suggestions: this._groupFilesByOwners(codeOwnerStatusMap),
-      };
-    });
+    return {
+      finished: this.ownersFetcher.getStatus() === FetchStatus.FINISHED,
+      status: this.ownersFetcher.getStatus(),
+      progress: this.ownersFetcher.getProgressString(),
+      suggestions: this._groupFilesByOwners(codeOwnerStatusMap,
+          this.ownersFetcher.getFiles()),
+    };
   }
 
-  _fetchSuggestedOwners() {
-    // reset existing temporary storage
-    this._fetchedOwners = new Map();
-    this._fetchStatus = FetchStatus.FETCHING;
-    this._totalFetchCount = 0;
-
-    return this.getStatus()
-        .then(({codeOwnerStatusMap}) => {
-          // only fetch those not approved yet
-          const filesGroupByStatus = [...codeOwnerStatusMap.keys()].reduce(
-              (list, file) => {
-                const status = codeOwnerStatusMap
-                    .get(file).status;
-                if (status === OwnerStatus.INSUFFICIENT_REVIEWERS) {
-                  list.missing.push(file);
-                } else if (status === OwnerStatus.PENDING) {
-                  list.pending.push(file);
-                }
-                return list;
-              }
-              , {pending: [], missing: []});
-          // always fetch INSUFFICIENT_REVIEWERS first and then pending
-          const filesToFetch = filesGroupByStatus.missing.concat(filesGroupByStatus.pending);
-          this._totalFetchCount = filesToFetch.length;
-          return this._batchFetchCodeOwners(filesToFetch);
-        });
+  async getSuggestedOwnersProgress() {
+    const {codeOwnerStatusMap} = await this.getStatus();
+    return {
+      finished: this.ownersFetcher.getStatus() === FetchStatus.FINISHED,
+      status: this.ownersFetcher.getStatus(),
+      progress: this.ownersFetcher.getProgressString(),
+      suggestions: this._groupFilesByOwners(codeOwnerStatusMap,
+          this.ownersFetcher.getFiles()),
+    };
   }
 
   _formatStatuses(statuses) {
@@ -368,28 +500,27 @@
     return;
   }
 
-  _groupFilesByOwners(codeOwnerStatusMap) {
+  _groupFilesByOwners(codeOwnerStatusMap, files) {
     // Note: for renamed or moved files, they will have two entries in the map
     // we will treat them as two entries when group as well
-    const allFiles = [...this._fetchedOwners.keys()];
     const ownersFilesMap = new Map();
     const failedToFetchFiles = new Set();
-    for (let i = 0; i < allFiles.length; i++) {
+    for (const file of files) {
       const fileInfo = {
-        path: allFiles[i],
-        status: this._computeFileStatus(codeOwnerStatusMap, allFiles[i]),
+        path: file.path,
+        status: this._computeFileStatus(codeOwnerStatusMap, file.path),
       };
       // for files failed to fetch, add them to the special group
-      if (this._fetchedOwners.get(fileInfo.path).error) {
+      if (file.info.error) {
         failedToFetchFiles.add(fileInfo);
         continue;
       }
 
       // do not include files still in fetching
-      if (!this._fetchedOwners.get(fileInfo.path).owners) {
+      if (!file.info.owners) {
         continue;
       }
-      const owners = [...this._fetchedOwners.get(fileInfo.path).owners];
+      const owners = [...file.info.owners];
       const ownersKey = owners
           .map(owner => owner.account._account_id)
           .sort()
@@ -415,7 +546,8 @@
       groupedItems.push({
         groupName: this.getGroupName(failedFiles),
         files: failedFiles,
-        error: new Error('Failed to fetch code owner info. Try to refresh the page.'),
+        error: new Error(
+            'Failed to fetch code owner info. Try to refresh the page.'),
       });
     }
 
@@ -435,78 +567,23 @@
     return `${latestRevision._number}` === `${patchsetId}`;
   }
 
-  /**
-   * Recursively fetches code owners for all files until finished.
-   *
-   * @param {!Array<string>} files
-   */
-  _batchFetchCodeOwners(files) {
-    if (this._fetchStatus === FetchStatus.ABORT) {
-      return Promise.resolve(this._fetchedOwners);
-    }
-
-    const batchRequests = [];
-    const maxConcurrentRequests = this.options.maxConcurrentRequests;
-    for (let i = 0; i < maxConcurrentRequests; i++) {
-      const filePath = files[i];
-      if (filePath) {
-        this._fetchedOwners.set(filePath, {});
-        batchRequests.push(
-            this.codeOwnerApi
-                .listOwnersForPath(
-                    this.change.id,
-                    filePath
-                )
-                .then(owners => {
-                  // use Set to de-dup
-                  this._fetchedOwners.get(filePath).owners = new Set(owners);
-                })
-                .catch(e => {
-                  this._fetchedOwners.get(filePath).error = e;
-                })
-        );
-      }
-    }
-    const resPromise = Promise.all(batchRequests);
-    if (files.length > maxConcurrentRequests) {
-      return resPromise.then(() => {
-        return this._batchFetchCodeOwners(files.slice(maxConcurrentRequests));
-      });
-    }
-    return resPromise.then(() => this._fetchedOwners);
-  }
-
   abort() {
-    this._fetchStatus = FetchStatus.ABORT;
-    this._fetchedOwners = new Map();
-    this._totalFetchCount = 0;
+    this.ownersFetcher.abort();
+    const codeOwnerApi = new CodeOwnerApi(this.restApi);
+    this.codeOwnerCacheApi = new CodeOwnersCacheApi(codeOwnerApi, change);
   }
 
-  getProjectConfig() {
-    if (!this.getProjectConfigPromise) {
-      this.getProjectConfigPromise =
-          this.codeOwnerApi.getProjectConfig(this.change.project);
-    }
-    return this.getProjectConfigPromise;
+  async getBranchConfig() {
+    return this.codeOwnerCacheApi.getBranchConfig();
   }
 
-  getBranchConfig() {
-    if (!this.getBranchConfigPromise) {
-      this.getBranchConfigPromise =
-          this.codeOwnerApi.getBranchConfig(this.change.project,
-              this.change.branch);
-    }
-    return this.getBranchConfigPromise;
-  }
-
-  isCodeOwnerEnabled() {
+  async isCodeOwnerEnabled() {
     if (this.change.status === ChangeStatus.ABANDONED ||
         this.change.status === ChangeStatus.MERGED) {
-      return Promise.resolve(false);
+      return false;
     }
-    return this.getBranchConfig().then(config => {
-      return !(config.status && config.status.disabled);
-    });
+    const config = await this.codeOwnerCacheApi.getBranchConfig();
+    return config && !config.disabled;
   }
 
   static getOwnerService(restApi, change) {
@@ -515,7 +592,9 @@
         // Chrome has a limit of 6 connections per host name, and a max of 10 connections.
         maxConcurrentRequests: 6,
       });
+      this.ownerService.prefetch();
     }
     return this.ownerService;
   }
 }
+
diff --git a/ui/code-owners-service_test.html b/ui/code-owners-service_test.html
index 6986ea9..884b97f 100644
--- a/ui/code-owners-service_test.html
+++ b/ui/code-owners-service_test.html
@@ -98,12 +98,14 @@
 
     suite('basic api request tests', () => {
       setup(done => {
-        getApiStub.withArgs(`/changes/${fakeChange._number}/code_owners.status`).returns(Promise.resolve(fakeStatus));
-        codeOwnersService = CodeOwnerService.getOwnerService(fakeRestApi, {...fakeChange});
+        getApiStub.withArgs(`/changes/${fakeChange._number}/code_owners.status`)
+            .returns(Promise.resolve(fakeStatus));
+        codeOwnersService = CodeOwnerService.getOwnerService(fakeRestApi,
+            {...fakeChange});
         flush(done);
       });
 
-      test('same change should return the same instance through getOwnerService', () => {
+      test('getOwnerService - same change returns the same instance', () => {
         assert.equal(
             CodeOwnerService.getOwnerService(fakeRestApi, fakeChange),
             CodeOwnerService.getOwnerService(fakeRestApi, fakeChange)
@@ -119,7 +121,8 @@
 
       test('should fetch status after init', () => {
         assert.isTrue(getApiStub.calledOnce);
-        assert.equal(getApiStub.lastCall.args[0], `/changes/${fakeChange._number}/code_owners.status`);
+        assert.equal(getApiStub.lastCall.args[0],
+            `/changes/${fakeChange._number}/code_owners.status`);
       });
 
       test('getSuggestion should kickoff the fetch', done => {
@@ -144,33 +147,35 @@
 
     suite('all approved case', () => {
       setup(done => {
-        getApiStub.withArgs(`/changes/${fakeChange._number}/code_owners.status`).returns(Promise.resolve({
-          // fake data with fake files
-          patch_set_number: 1,
-          file_code_owner_statuses: [
-            {
-              new_path_status: {
-                path: 'a.js',
-                status: 'APPROVED',
-              },
-            },
-            {
-              new_path_status: {
-                path: 'b.js',
-                status: 'APPROVED',
-              },
-            },
-            {
-              old_path_status: {
-                path: 'd.js',
-                status: 'APPROVED',
-              },
-              change_type: 'DELETED',
-            },
-          ],
-        }));
+        getApiStub.withArgs(`/changes/${fakeChange._number}/code_owners.status`)
+            .returns(Promise.resolve({
+              // fake data with fake files
+              patch_set_number: 1,
+              file_code_owner_statuses: [
+                {
+                  new_path_status: {
+                    path: 'a.js',
+                    status: 'APPROVED',
+                  },
+                },
+                {
+                  new_path_status: {
+                    path: 'b.js',
+                    status: 'APPROVED',
+                  },
+                },
+                {
+                  old_path_status: {
+                    path: 'd.js',
+                    status: 'APPROVED',
+                  },
+                  change_type: 'DELETED',
+                },
+              ],
+            }));
 
-        codeOwnersService = CodeOwnerService.getOwnerService(fakeRestApi, {...fakeChange});
+        codeOwnersService = CodeOwnerService.getOwnerService(fakeRestApi,
+            {...fakeChange});
         flush(done);
       });
 
@@ -196,11 +201,12 @@
     });
 
     suite('abort', () => {
-      let resolver;
       setup(done => {
-        getApiStub.withArgs(`/changes/${fakeChange._number}/code_owners.status`).returns(Promise.resolve(fakeStatus));
+        getApiStub.withArgs(`/changes/${fakeChange._number}/code_owners.status`)
+            .returns(Promise.resolve(fakeStatus));
 
-        codeOwnersService = CodeOwnerService.getOwnerService(fakeRestApi, {...fakeChange});
+        codeOwnersService = CodeOwnerService.getOwnerService(fakeRestApi,
+            {...fakeChange});
         flush(done);
       });
 
diff --git a/ui/owner-requirement.js b/ui/owner-requirement.js
index 7bd74ee..1ad5b4b 100644
--- a/ui/owner-requirement.js
+++ b/ui/owner-requirement.js
@@ -15,8 +15,10 @@
  * limitations under the License.
  */
 
-import {CodeOwnerService, OwnerStatus} from './code-owners-service.js';
-import {ownerState} from './owner-ui-state.js';
+import {OwnerStatus} from './code-owners-service.js';
+import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
+import {showPluginFailedMessage} from './code-owners-banner.js';
+import {PluginState} from './code-owners-model.js';
 
 /**
  * Owner requirement control for `submit-requirement-item-code-owners` endpoint.
@@ -24,7 +26,8 @@
  * This will show the status and suggest owners button next to
  * the code-owners submit requirement.
  */
-export class OwnerRequirementValue extends Polymer.Element {
+export class OwnerRequirementValue extends
+  CodeOwnersModelMixin(Polymer.Element) {
   static get is() {
     return 'owner-requirement-value';
   }
@@ -61,16 +64,35 @@
           Loading status ...
         </p>
         <template is="dom-if" if="[[!_isLoading]]">
-          <span>[[_computeStatusText(_statusCount, _isOverriden)]]</span>
-          <template is="dom-if" if="[[_overrideInfoUrl]]">
-            <a on-click="_reportDocClick" href="[[_overrideInfoUrl]]" target="_blank">
-              <iron-icon icon="gr-icons:help-outline" title="Documentation for overriding code owners"></iron-icon>
-            </a>
+          <template is="dom-if" if="[[!_pluginFailed(model.pluginStatus)]]">
+            <template is="dom-if" if="[[!model.branchConfig.no_code_owners_defined]]">              
+              <span>[[_computeStatusText(_statusCount, _isOverriden)]]</span>
+              <template is="dom-if" if="[[_overrideInfoUrl]]">
+                <a on-click="_reportDocClick" href="[[_overrideInfoUrl]]"
+                  target="_blank">
+                  <iron-icon icon="gr-icons:help-outline"
+                    title="Documentation for overriding code owners"></iron-icon>
+                </a>
+              </template>
+              <template is="dom-if" if="[[!_allApproved]]">
+                <gr-button link on-click="_openReplyDialog">
+                Suggest owners
+              </gr-button>
+              </template>
+            </template>
+            <template is="dom-if" if="[[model.branchConfig.no_code_owners_defined]]">                
+              <span>No code-owners file</span>
+              <a href="https://gerrit.googlesource.com/plugins/code-owners/+/master/resources/Documentation/user-guide.md#how-to-submit-changes-with-files-that-have-no-code-owners" target="_blank">
+                <iron-icon icon="gr-icons:help-outline"
+                  title="Documentation about submitting changes with files that have no code owners?"></iron-icon>
+              </a>
+            </template>
           </template>
-          <template is="dom-if" if="[[!_allApproved]]">
-            <gr-button link on-click="_openReplyDialog">
-            Suggest owners
-          </gr-button>
+          <template is="dom-if" if="[[_pluginFailed(model.pluginStatus)]]">
+            <span>Code-owners plugin has failed</span>
+            <gr-button link on-click="_showFailDetails">
+              Details
+            </gr-button>
           </template>
         </template>
       `;
@@ -78,87 +100,97 @@
 
   static get properties() {
     return {
-      change: Object,
-      reporting: Object,
-      restApi: Object,
-
-      ownerService: Object,
-
       _statusCount: Object,
       _isLoading: {
         type: Boolean,
-        value: true,
+        computed: '_computeIsLoading(model.branchConfig, model.status, '
+            + 'model.userRole, model.pluginStatus)',
       },
       _allApproved: {
         type: Boolean,
         computed: '_computeAllApproved(_statusCount)',
       },
-      _isOverriden: Boolean,
-      _overrideInfoUrl: String,
+      _isOverriden: {
+        type: Boolean,
+        computed: '_computeIsOverriden(change, model.branchConfig)',
+      },
+      _overrideInfoUrl: {
+        type: String,
+        computed: '_computeOverrideInfoUrl(model.branchConfig)',
+      },
     };
   }
 
   static get observers() {
     return [
-      'onInputChanged(restApi, change, reporting)',
+      '_onStatusChanged(model.status, model.userRole)',
     ];
   }
 
-  _checkIfOverriden() {
-    this.ownerService.getBranchConfig().then(res => {
-      if (!res['override_approval']) {
-        // no override label configured
-        this._isOverriden = false;
-        return;
-      }
+  loadPropertiesAfterModelChanged() {
+    super.loadPropertiesAfterModelChanged();
+    this.reporting.reportLifeCycle('owners-submit-requirement-summary-start');
+    this.modelLoader.loadBranchConfig();
+    this.modelLoader.loadStatus();
+    this.modelLoader.loadUserRole();
+  }
 
-      const overridenLabel = res['override_approval'].label;
-      const overridenValue = res['override_approval'].value;
+  _computeIsLoading(branchConfig, status, userRole, pluginStatus) {
+    if (this._pluginFailed(pluginStatus)) {
+      return false;
+    }
+    return !branchConfig || !status || !userRole;
+  }
+
+  _pluginFailed(pluginStatus) {
+    return pluginStatus && pluginStatus.state === PluginState.Failed;
+  }
+
+  _onStatusChanged(status, userRole) {
+    if (!status || !userRole) {
+      this._statusCount = undefined;
+      return;
+    }
+    const rawStatuses = status.rawStatuses;
+    this._statusCount = this._getStatusCount(rawStatuses);
+    this.reporting.reportLifeCycle('owners-submit-requirement-summary-shown',
+        {...this._statusCount, user_role: userRole});
+  }
+
+  _computeOverrideInfoUrl(branchConfig) {
+    if (!branchConfig) {
+      return '';
+    }
+    return branchConfig.general && branchConfig.general.override_info_url
+      ? branchConfig.general.override_info_url : '';
+  }
+
+  _computeIsOverriden(change, branchConfig) {
+    if (!change || !branchConfig || !branchConfig['override_approval']) {
+      // no override labels configured
+      return false;
+    }
+
+
+    for(const requiredApprovalInfo of branchConfig['override_approval']) {
+      const overridenLabel = requiredApprovalInfo.label;
+      const overridenValue = Number(requiredApprovalInfo.value);
+      if (isNaN(overridenValue)) continue;
 
       if (this.change.labels[overridenLabel]) {
-        const votes = this.change.labels[overridenLabel].all || [];
-        if (votes.find(v => `${v.value}` === `${overridenValue}`)) {
-          this._isOverriden = true;
-          return;
+        const votes = change.labels[overridenLabel].all || [];
+        if (votes.find(v => Number(v.value) >= overridenValue)) {
+          return true;
         }
       }
+    }
 
-      // otherwise always reset it to false
-      this._isOverriden = false;
-    });
-  }
-
-  _updateStatus() {
-    this._isLoading = true;
-    this.reporting.reportLifeCycle('owners-submit-requirement-summary-start');
-
-    return this.ownerService.getStatus()
-        .then(({rawStatuses}) => {
-          this._statusCount = this._getStatusCount(rawStatuses);
-          this.ownerService.getLoggedInUserInitialRole().then(role => {
-            // Send a metric with overall summary when code owners submit
-            // requirement shown and finished fetching status
-            this.reporting.reportLifeCycle(
-                'owners-submit-requirement-summary-shown',
-                {...this._statusCount, user_role: role}
-            );
-          });
-        })
-        .finally(() => {
-          this._isLoading = false;
-        });
-  }
-
-  _updateOverrideInfoUrl() {
-    this.ownerService.getBranchConfig().then(config => {
-      this._overrideInfoUrl = config.general && config.general.override_info_url
-        ?
-        config.general.override_info_url : '';
-    });
+    // otherwise always reset it to false
+    return false;
   }
 
   _computeAllApproved(statusCount) {
-    return statusCount.missing === 0
+    return statusCount && statusCount.missing === 0
             && statusCount.pending === 0;
   }
 
@@ -186,6 +218,7 @@
   }
 
   _computeStatusText(statusCount, isOverriden) {
+    if (statusCount === undefined || isOverriden === undefined) return '';
     const statusText = [];
     if (statusCount.missing) {
       statusText.push(`${statusCount.missing} missing`);
@@ -210,15 +243,8 @@
     return status === OwnerStatus.PENDING;
   }
 
-  onInputChanged(restApi, change, reporting) {
-    if ([restApi, change, reporting].includes(undefined)) return;
-    this.ownerService = CodeOwnerService.getOwnerService(this.restApi, change);
-    this._updateStatus();
-    this._checkIfOverriden();
-    this._updateOverrideInfoUrl();
-  }
-
   _openReplyDialog() {
+    this.model.setShowSuggestions(true);
     this.dispatchEvent(
         new CustomEvent('open-reply-dialog', {
           detail: {},
@@ -226,11 +252,12 @@
           bubbles: true,
         })
     );
-    ownerState.expandSuggestion = true;
-    this.ownerService.getLoggedInUserInitialRole().then(role => {
-      this.reporting.reportInteraction(
-          'suggest-owners-from-submit-requirement', {user_role: role});
-    });
+    this.reporting.reportInteraction('suggest-owners-from-submit-requirement',
+        {user_role: this.model.userRole});
+  }
+
+  _showFailDetails() {
+    showPluginFailedMessage(this, this.model.pluginStatus);
   }
 }
 
diff --git a/ui/owner-status-column.js b/ui/owner-status-column.js
index 3fdcd50..31ad654 100644
--- a/ui/owner-status-column.js
+++ b/ui/owner-status-column.js
@@ -15,7 +15,8 @@
  * limitations under the License.
  */
 
-import {CodeOwnerService, OwnerStatus} from './code-owners-service.js';
+import {OwnerStatus} from './code-owners-service.js';
+import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
 
 const MAGIC_FILES = ['/COMMIT_MSG', '/MERGE_LIST', '/PATCHSET_LEVEL'];
 const STATUS_CODE = {
@@ -38,13 +39,15 @@
 const STATUS_TOOLTIP = {
   [STATUS_CODE.PENDING]: 'Pending code owner approval',
   [STATUS_CODE.MISSING]: 'Missing code owner approval',
-  [STATUS_CODE.PENDING_OLD_PATH]: 'Pending code owner approval on pre-renamed file',
-  [STATUS_CODE.MISSING_OLD_PATH]: 'Missing code owner approval on pre-renamed file',
+  [STATUS_CODE.PENDING_OLD_PATH]:
+      'Pending code owner approval on pre-renamed file',
+  [STATUS_CODE.MISSING_OLD_PATH]:
+      'Missing code owner approval on pre-renamed file',
   [STATUS_CODE.APPROVED]: 'Approved by code owner',
   [STATUS_CODE.ERROR]: 'Failed to fetch code owner status',
 };
 
-class BaseEl extends Polymer.Element {
+class BaseEl extends CodeOwnersModelMixin(Polymer.Element) {
   computeHidden(change, patchRange) {
     if ([change, patchRange].includes(undefined)) return true;
     // if code-owners is not a submit requirement, don't show status column
@@ -64,11 +67,6 @@
     if (`${patchRange.patchNum}` !== `${latestPatchset._number}`) return true;
     return false;
   }
-
-  onInputChanged(restApi, change) {
-    if ([restApi, change].includes(undefined)) return;
-    this.ownerService = CodeOwnerService.getOwnerService(this.restApi, change);
-  }
 }
 
 /**
@@ -94,26 +92,15 @@
 
   static get properties() {
     return {
-      change: Object,
-      reporting: Object,
       patchRange: Object,
-      restApi: Object,
 
       hidden: {
         type: Boolean,
         reflectToAttribute: true,
         computed: 'computeHidden(change, patchRange)',
       },
-      ownerService: Object,
     };
   }
-
-  static get observers() {
-    return [
-      'onInputChanged(restApi, change)',
-      'onOwnerServiceChanged(ownerServive)',
-    ];
-  }
 }
 
 customElements.define(OwnerStatusColumnHeader.is, OwnerStatusColumnHeader);
@@ -128,10 +115,6 @@
 
   static get properties() {
     return {
-      change: Object,
-      reporting: Object,
-      restApi: Object,
-
       path: String,
       patchRange: Object,
       hidden: {
@@ -186,47 +169,45 @@
 
   static get observers() {
     return [
-      'onInputChanged(restApi, change)',
-      'computeStatusIcon(ownerService, path)',
+      'computeStatusIcon(model.status, path)',
     ];
   }
 
-  computeStatusIcon(ownerService, path) {
-    if ([ownerService, path].includes(undefined)) return;
+  loadPropertiesAfterModelChanged() {
+    super.loadPropertiesAfterModelChanged();
+    this.modelLoader.loadStatus();
+  }
+
+  computeStatusIcon(modelStatus, path) {
+    if ([modelStatus, path].includes(undefined)) return;
     if (MAGIC_FILES.includes(path)) return;
 
-    ownerService.getStatus()
-        .then(({codeOwnerStatusMap}) => {
-          const statusItem = codeOwnerStatusMap.get(path);
-          if (!statusItem) {
-            this.status = STATUS_CODE.ERROR;
-            return;
-          }
+    const codeOwnerStatusMap = modelStatus.codeOwnerStatusMap;
+    const statusItem = codeOwnerStatusMap.get(path);
+    if (!statusItem) {
+      this.status = STATUS_CODE.ERROR;
+      return;
+    }
 
-          const status = statusItem.status;
-          let oldPathStatus = null;
-          if (statusItem.oldPath) {
-            const oldStatusItem = codeOwnerStatusMap.get(statusItem.oldPath);
-            if (!oldStatusItem) {
-              // should not happen
-            } else {
-              oldPathStatus = oldStatusItem.status;
-            }
-          }
+    const status = statusItem.status;
+    let oldPathStatus = null;
+    if (statusItem.oldPath) {
+      const oldStatusItem = codeOwnerStatusMap.get(statusItem.oldPath);
+      if (!oldStatusItem) {
+        // should not happen
+      } else {
+        oldPathStatus = oldStatusItem.status;
+      }
+    }
 
-          const newPathStatus = this._computeStatus(status);
-          if (!oldPathStatus) {
-            this.status = newPathStatus;
-          } else {
-            this.status = newPathStatus === STATUS_CODE.APPROVED
-              ? this._computeStatus(oldPathStatus, /* oldPath= */ true)
-              : newPathStatus;
-          }
-        })
-        .catch(e => {
-          this.status = STATUS_CODE.ERROR;
-          throw e;
-        });
+    const newPathStatus = this._computeStatus(status);
+    if (!oldPathStatus) {
+      this.status = newPathStatus;
+    } else {
+      this.status = newPathStatus === STATUS_CODE.APPROVED
+        ? this._computeStatus(oldPathStatus, /* oldPath= */ true)
+        : newPathStatus;
+    }
   }
 
   _computeIcon(status) {
@@ -248,4 +229,4 @@
   }
 }
 
-customElements.define(OwnerStatusColumnContent.is, OwnerStatusColumnContent);
\ No newline at end of file
+customElements.define(OwnerStatusColumnContent.is, OwnerStatusColumnContent);
diff --git a/ui/owner-ui-state.js b/ui/owner-ui-state.js
deleted file mode 100644
index 3d47ef9..0000000
--- a/ui/owner-ui-state.js
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * @enum
- */
-const OwnerUIStateEventType = {
-  EXPAND_SUGGESTION: 'expandSuggestion',
-};
-
-/**
- * For states used in code owners plugin across multiple components.
- */
-class OwnerUIState {
-  constructor() {
-    this._expandSuggestion = false;
-    this._listeners = new Map();
-    this._listeners.set(OwnerUIStateEventType.EXPAND_SUGGESTION, []);
-  }
-
-  get expandSuggestion() {
-    return this._expandSuggestion;
-  }
-
-  set expandSuggestion(value) {
-    this._expandSuggestion = value;
-    this._listeners.get(OwnerUIStateEventType.EXPAND_SUGGESTION).forEach(cb => {
-      try {
-        cb(value);
-      } catch (e) {
-        console.warn(e);
-      }
-    });
-  }
-
-  _subscribeEvent(eventType, cb) {
-    this._listeners.get(eventType).push(cb);
-    return () => {
-      this._unsubscribeEvent(eventType, cb);
-    };
-  }
-
-  _unsubscribeEvent(eventType, cb) {
-    this._listeners.set(
-        eventType,
-        this._listeners.get(eventType).filter(handler => handler !== cb)
-    );
-  }
-
-  onExpandSuggestionChange(cb) {
-    return this._subscribeEvent(OwnerUIStateEventType.EXPAND_SUGGESTION, cb);
-  }
-}
-
-export const ownerState = new OwnerUIState();
diff --git a/ui/plugin.js b/ui/plugin.js
index 1a278dd..40bc4e5 100644
--- a/ui/plugin.js
+++ b/ui/plugin.js
@@ -19,11 +19,21 @@
 import {OwnerStatusColumnContent, OwnerStatusColumnHeader} from './owner-status-column.js';
 import {OwnerRequirementValue} from './owner-requirement.js';
 import {SuggestOwnersTrigger} from './suggest-owners-trigger.js';
+import {CodeOwnersBanner, CodeOwnersPluginStatusNotifier} from './code-owners-banner.js';
 
 Gerrit.install(plugin => {
   const restApi = plugin.restApi();
   const reporting = plugin.reporting();
 
+  plugin.registerCustomComponent('banner', CodeOwnersBanner.is);
+
+  plugin.registerCustomComponent(
+      'change-view-integration', CodeOwnersPluginStatusNotifier.is)
+      .onAttached(view => {
+        view.restApi = restApi;
+        view.reporting = reporting;
+      });
+
   // owner status column / rows for file list
   plugin.registerDynamicCustomComponent(
       'change-view-file-list-header-prepend', OwnerStatusColumnHeader.is)
@@ -61,4 +71,4 @@
         view.restApi = restApi;
         view.reporting = reporting;
       });
-});
\ No newline at end of file
+});
diff --git a/ui/suggest-owners-trigger.js b/ui/suggest-owners-trigger.js
index 2c0ccc0..97f616d 100644
--- a/ui/suggest-owners-trigger.js
+++ b/ui/suggest-owners-trigger.js
@@ -14,41 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {CodeOwnerService} from './code-owners-service.js';
-import {ownerState} from './owner-ui-state.js';
+import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
+import {PluginState} from './code-owners-model.js';
 
-export class SuggestOwnersTrigger extends Polymer.Element {
+export class SuggestOwnersTrigger extends
+  CodeOwnersModelMixin(Polymer.Element) {
   static get is() {
     return 'suggest-owners-trigger';
   }
 
-  constructor(props) {
-    super(props);
-    this.expandSuggestionStateUnsubscriber = undefined;
-  }
-
   static get properties() {
     return {
-      change: Object,
-      reporting: Object,
-      restApi: Object,
-
-      expanded: {
-        type: Boolean,
-        value: false,
-      },
       hidden: {
         type: Boolean,
-        value: true,
+        computed: '_computeHidden(model.pluginStatus,' +
+            'model.areAllFilesApproved, model.userRole, model.branchConfig)',
         reflectToAttribute: true,
       },
     };
   }
 
-  static get observers() {
-    return ['onInputChanged(restApi, change)'];
-  }
-
   static get template() {
     return Polymer.html`
         <style include="shared-styles">
@@ -68,7 +53,7 @@
           has-tooltip
           title="Suggest owners for your change"
         >
-          [[computeButtonText(expanded)]]
+          [[computeButtonText(model.showSuggestions)]]
         </gr-button>
         <span>
           <a on-click="_reportBugClick" href="https://bugs.chromium.org/p/gerrit/issues/entry?template=code-owners-plugin" target="_blank">
@@ -81,50 +66,35 @@
       `;
   }
 
-  connectedCallback() {
-    super.connectedCallback();
-    this.expandSuggestionStateUnsubscriber = ownerState
-        .onExpandSuggestionChange(expanded => {
-          this.expanded = expanded;
-        });
+  loadPropertiesAfterModelChanged() {
+    super.loadPropertiesAfterModelChanged();
+    this.modelLoader.loadUserRole();
+    this.modelLoader.loadPluginStatus();
+    this.modelLoader.loadAreAllFilesApproved();
+    this.modelLoader.loadBranchConfig();
   }
 
-  disconnnectedCallback() {
-    super.disconnectedCallback();
-    if (this.expandSuggestionStateUnsubscriber) {
-      this.expandSuggestionStateUnsubscriber();
-      this.expandSuggestionStateUnsubscriber = undefined;
+  _computeHidden(pluginStatus, allFilesApproved, userRole, branchConfig) {
+    if (pluginStatus === undefined ||
+        allFilesApproved === undefined ||
+        userRole === undefined ||
+        branchConfig === undefined) {
+      return true;
+    }
+    if (branchConfig.no_code_owners_defined) return true;
+    if (pluginStatus.state === PluginState.Enabled) {
+      return allFilesApproved;
+    } else {
+      return true;
     }
   }
 
-  onInputChanged(restApi, change) {
-    if ([restApi, change].includes(undefined)) return;
-    this.ownerService = CodeOwnerService.getOwnerService(
-        this.restApi,
-        this.change
-    );
-
-    Promise.all([
-      this.ownerService.isCodeOwnerEnabled(),
-      this.ownerService.areAllFilesApproved(),
-      this.ownerService.getLoggedInUserInitialRole()
-    ])
-        .then(([enabled, approved, userRole]) => {
-          if (enabled) {
-            this.hidden = approved;
-          } else {
-            this.hidden = true;
-          }
-          this._userRole = userRole;
-        });
-  }
-
   toggleControlContent() {
-    this.expanded = !this.expanded;
-    ownerState.expandSuggestion = this.expanded;
+    this.model.setShowSuggestions(!this.model.showSuggestions);
     this.reporting.reportInteraction('toggle-suggest-owners', {
       expanded: this.expanded,
-      user_role: this._userRole ? this._userRole : 'UNKNOWN',
+      user_role: this.model.userRole ?
+        this.model.userRole : 'UNKNOWN',
     });
   }
 
diff --git a/ui/suggest-owners.js b/ui/suggest-owners.js
index 2e1a487..e0bf8a8 100644
--- a/ui/suggest-owners.js
+++ b/ui/suggest-owners.js
@@ -14,8 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {CodeOwnerService} from './code-owners-service.js';
-import {ownerState} from './owner-ui-state.js';
+import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
+import {SuggestionsState} from './code-owners-model.js';
 
 const SUGGESTION_POLLING_INTERVAL = 1000;
 
@@ -24,11 +24,6 @@
     return 'owner-group-file-list';
   }
 
-  constructor() {
-    super();
-    this.expandSuggestionStateUnsubscriber = undefined;
-  }
-
   static get properties() {
     return {
       files: Array,
@@ -89,7 +84,7 @@
 
 customElements.define(OwnerGroupFileList.is, OwnerGroupFileList);
 
-export class SuggestOwners extends Polymer.Element {
+export class SuggestOwners extends CodeOwnersModelMixin(Polymer.Element) {
   static get is() {
     return 'suggest-owners';
   }
@@ -262,6 +257,7 @@
             <template is="dom-if" if="[[suggestion.error]]">
               <div class="fetch-error-content">
                 [[suggestion.error]]
+                <a on-click="_showErrorDetails"
               </div>
             </template>
             <template is="dom-if" if="[[!suggestion.error]]">
@@ -299,18 +295,14 @@
 
   static get properties() {
     return {
-      // @input
-      change: Object,
-      restApi: Object,
-      reporting: Object,
-
       // @internal attributes
       hidden: {
         type: Boolean,
         value: true,
         reflectToAttribute: true,
+        computed: '_isHidden(model.areAllFilesApproved, ' +
+            'model.showSuggestions)',
       },
-      ownerService: Object,
       suggestedOwners: Array,
       isLoading: {
         type: Boolean,
@@ -325,93 +317,87 @@
 
   static get observers() {
     return [
-      'onInputChanged(restApi, change)',
-      'onReviewerChange(reviewers)',
+      '_onReviewerChanged(reviewers)',
+      '_onShowSuggestionsChanged(model.showSuggestions)',
+      '_onSuggestionsStateChanged(model.suggestionsState)',
+      '_onSuggestionsChanged(model.suggestions, model.suggestionsState)',
+      '_onSuggestionsLoadProgressChanged(model.suggestionsLoadProgress)',
     ];
   }
 
-  connectedCallback() {
-    super.connectedCallback();
-    this.expandSuggestionStateUnsubscriber = ownerState
-        .onExpandSuggestionChange(expanded => {
-          this.hidden = !expanded;
-          if (expanded) {
-            // this is more of a hack to let reivew input lose focus
-            // to avoid suggestion dropdown
-            // gr-autocomplete has a internal state for tracking focus
-            // that will be canceled if any click happens outside of
-            // it's target
-            // Can not use `this.async` as it's only available in
-            // legacy element mixin which not used in this plugin.
-            Polymer.Async.timeOut.run(() => this.click(), 100);
+  _onShowSuggestionsChanged(showSuggestions) {
+    if (!showSuggestions ||
+        this.model.suggestionsLoadProgress === SuggestionsState.NotLoaded) {
+      return;
+    }
+    // this is more of a hack to let review input lose focus
+    // to avoid suggestion dropdown
+    // gr-autocomplete has a internal state for tracking focus
+    // that will be canceled if any click happens outside of
+    // it's target
+    // Can not use `this.async` as it's only available in
+    // legacy element mixin which not used in this plugin.
+    Polymer.Async.timeOut.run(() => this.click(), 100);
 
-            // start fetching suggestions
-            if (!this._suggestionsTimer) {
-              this._suggestionsTimer = setInterval(() => {
-                this._pollingSuggestions();
-              }, SUGGESTION_POLLING_INTERVAL);
-
-              // poll immediately to kick start the fetching
-              this.reporting
-                  .reportLifeCycle('owners-suggestions-fetching-start');
-              this._pollingSuggestions();
-            }
-          }
-        });
+    this.modelLoader.loadSuggestions();
+    this.reporting.reportLifeCycle('owners-suggestions-fetching-start');
   }
 
   disconnectedCallback() {
     super.disconnectedCallback();
-    if (this.expandSuggestionStateUnsubscriber) {
-      this.expandSuggestionStateUnsubscriber();
-      this.expandSuggestionStateUnsubscriber = undefined;
-    }
+    this._stopUpdateProgressTimer();
   }
 
-  onInputChanged(restApi, change) {
-    ownerState.expandSuggestion = false;
-    if ([restApi, change].includes(undefined)) return;
-    this.isLoading = true;
-    const ownerService = CodeOwnerService.getOwnerService(this.restApi, change);
-    if (this.ownerService && this.ownerService !== ownerService) {
-      // abort all pending requests
-      this.ownerService.abort();
-      clearInterval(this._suggestionsTimer);
-      this._suggestionsTimer = null;
-    }
-    this.ownerService = ownerService;
+  _startUpdateProgressTimer() {
+    if (this._progressUpdateTimer) return;
+    this._progressUpdateTimer = setInterval(() => {
+      this.modelLoader.updateLoadSuggestionsProgress();
+    }, SUGGESTION_POLLING_INTERVAL);
+  }
 
+  _stopUpdateProgressTimer() {
+    if (!this._progressUpdateTimer) return;
+    clearInterval(this._progressUpdateTimer);
+    this._progressUpdateTimer = undefined;
+  }
+
+  _onSuggestionsStateChanged(state) {
+    this._stopUpdateProgressTimer();
+    if (state === SuggestionsState.Loading) {
+      this._startUpdateProgressTimer();
+    }
+    this.isLoading = state === SuggestionsState.Loading;
+  }
+
+  _isHidden(allFilesApproved, showSuggestions) {
+    if (!showSuggestions) return true;
     // if all approved, no need to show the container
-    this.ownerService.areAllFilesApproved().then(approved => {
-      if (approved) {
-        this.hidden = approved;
-      }
-    });
+    return allFilesApproved === undefined || !!allFilesApproved;
   }
 
-  _pollingSuggestions() {
-    this.ownerService
-        .getSuggestedOwners()
-        .then(res => {
-          if (res.finished) {
-            clearInterval(this._suggestionsTimer);
-            this._suggestionsTimer = null;
-            const reportDetails = res.suggestions.reduce((details, cur) => {
-              details.totalGroups++;
-              details.stats.push([cur.files.length, cur.owners ? cur.owners.length : 0]);
-              return details;
-            }, {totalGroups: 0, stats: []});
-            this.reporting.reportLifeCycle('owners-suggestions-fetching-finished', reportDetails);
-          }
-          this.progressText = res.progress;
-          this.isLoading = !res.finished;
+  loadPropertiesAfterModelChanged() {
+    super.loadPropertiesAfterModelChanged();
+    this._stopUpdateProgressTimer();
+    this.modelLoader.loadAreAllFilesApproved();
+  }
 
-          this._updateSuggestions(res.suggestions);
+  _onSuggestionsChanged(suggestions, suggestionsState) {
+    // The updateLoadSuggestionsProgress method also updates suggestions
+    this._updateSuggestions(suggestions || []);
+    this._updateAllChips(this._currentReviewers);
+    if (!suggestions || suggestionsState !== SuggestionsState.Loaded) return;
+    const reportDetails = suggestions.reduce((details, cur) => {
+      details.totalGroups++;
+      details.stats.push([cur.files.length,
+        cur.owners ? cur.owners.length : 0]);
+      return details;
+    }, {totalGroups: 0, stats: []});
+    this.reporting.reportLifeCycle(
+        'owners-suggestions-fetching-finished', reportDetails);
+  }
 
-          // in case `_updateAllChips` called before suggestedOwners ready
-          // from onReviewerChange
-          this._updateAllChips(this._currentReviewers);
-        });
+  _onSuggestionsLoadProgressChanged(progress) {
+    this.progressText = progress;
   }
 
   _updateSuggestions(suggestions) {
@@ -421,7 +407,7 @@
     });
   }
 
-  onReviewerChange(reviewers) {
+  _onReviewerChanged(reviewers) {
     this._currentReviewers = reviewers;
     this._updateAllChips(reviewers);
   }
@@ -517,7 +503,8 @@
   }
 
   _reportDocClick() {
-    this.reporting.reportInteraction('code-owners-doc-click', {section: 'no owners found'});
+    this.reporting.reportInteraction('code-owners-doc-click',
+        {section: 'no owners found'});
   }
 }